DeerFlow 自动对话标题生成机制:TitleMiddleware 触发条件、双路径策略与持久化实现
DeerFlow(deer-flow)的长时任务对话线程在首次交互后需要自动生成一个可读的会话标题,用于前端对话列表展示与线程检索。本文围绕 backend/docs/AUTO_TITLE_GENERATION.md 的完整设计展开:标题在 after_model 钩子中如何判定"首轮对话"、本地 fallback 与 LLM 生成两条路径如何取舍、ThreadState.title 经由 checkpointer 的持久化机制,以及 DeerFlow 运行层针对中断 run 的补偿写入逻辑。读完后你将掌握该功能的配置方式、源码级实现细节、客户端读取方法,以及标题缺失/丢失场景的排查手段。
功能定位与触发时机
自动 Thread Title 生成功能在用户首次提问并收到回复后自动触发,由 TitleMiddleware 在 LangChain Agent 中间件链的 after_model / aafter_model 钩子中完成。它不是独立服务,而是 DeerFlow 主 Agent(lead agent)中间件链的一环:从 lead_agent 组装代码 可以看到,TitleMiddleware 在 TokenUsageMiddleware 之后、MemoryMiddleware 之前被追加到链上(源码注释明确 "TitleMiddleware generates title after first exchange, MemoryMiddleware queues conversation for memory update (after TitleMiddleware)")。在自定义 Agent 工厂 factory.py 中,它对应第 8 号位("8. TitleMiddleware (auto_title feature)"),支持按 auto_title 特性开关启用或替换为自定义中间件实例。
核心判定逻辑(见 title_middleware.py 的 _should_generate_title 方法):
- 配置必须启用(
config.enabled为真),否则直接返回None; state中已存在title时不重复生成(幂等);- 必须是首轮对话:恰好 1 条用户消息,且至少 1 条助手回复。源码对
messages通道做了防御性处理——读取半初始化 checkpoint 时messages可能为None,会归一为空列表以避免len()报错; - 用户消息的识别排除了"动态上下文提醒"类消息(
is_dynamic_context_reminder),即只有真正的用户输入计入首轮判定。
中断 run 的补偿路径
文档描述的常规路径之外,DeerFlow 还处理了一个边界场景:首轮 run 在助手消息写入 checkpoint 之前被取消。此时 after_model 永远不会执行,标题就会缺失。run worker 中的 _ensure_interrupted_title 函数在 run 结束阶段补偿:它读取线程 checkpoint 的 channel_values,若已有 title 则短路返回(幂等),否则调用 middleware._generate_title_result(..., allow_partial_exchange=True)。allow_partial_exchange=True 放宽了消息数门槛(min_messages 从 2 降为 1,允许"仅有 1 条用户消息"的部分对话状态),从而把本地 fallback 标题持久化进 checkpoint。该写入还带有 stale-snapshot 防护:写入前会比较 checkpoint 身份标识,若期间有其他写者更新了快照则重试(最多 3 次),避免覆盖并发写入。
双路径标题生成:本地 fallback 优先,LLM 可选
TitleMiddleware 的策略是"默认快、显式才慢":
- 默认路径(未配置
title.model_name):不发起任何 LLM 调用,直接从首条用户消息生成 fallback 标题。这避免了在流式回复结束前额外等待一次模型往返; - 显式路径(配置了
title.model_name):构造 title prompt 并异步调用配置的标题模型,失败时(模型不可用、超时、返回空等任意异常)回退到本地策略,日志仅记录 debug 级别。
从源码实现看(title_middleware.py 的 _agenerate_title_result),LLM 路径还有几个值得注意的细节:
- 附件-only 首轮保护:若首轮用户消息没有任何文本(仅附件),代码直接走 fallback,不讓标题模型基于助手回复"脑补"标题;
- prompt 构造(
_build_title_prompt):取首条用户消息与首条助手消息各截断到前 500 字符,填入prompt_template;助手消息会先经_strip_think_tags去除推理模型的<think>...</think>块(针对 minimax、DeepSeek-R1 等推理型模型); - 输出归一化(
_parse_title):模型输出先做结构化内容归一化、剥离 think 标签、去除首尾引号,最后按max_chars截断; - 调用可观测性:LLM 调用通过
observe_system_model_call包裹并标记为SystemOperationKind.TITLE,且_get_runnable_config会继承父 RunnableConfig 并追加run_name=title_agent与middleware:title、TAG_NOSTREAM标签,使 RunJournal 能将其识别为中间件调用而非 lead_agent 调用,同时TAG_NOSTREAM保证这次调用不打扰前端流式输出。
内容归一化与 fallback 截断
文档强调"先把 LangChain message content 里的结构化 block/list 内容归一化为纯文本,再拼到 title prompt 里,避免把 Python/JSON 的原始 repr 泄漏到标题生成模型"。对应实现是 _normalize_content:字符串原样返回;列表递归归一后用换行拼接;字典优先取 text 字段,其次递归 content 字段;无法解析则返回空串。
_fallback_title 的截断规则:上限取 min(max_chars, 50);超长时预留 3 个字符的省略号位(body = min(fallback_chars, max_chars - len("..."))),保证与模型路径的 max_chars 约束完全一致;用户消息为空时返回 "New Conversation"。另外,若用户消息的 additional_kwargs 中带有 ORIGINAL_USER_CONTENT_KEY(原始用户内容标记),会优先用 get_original_user_content_text 还原用户原始文本,保证标题反映的是用户真正输入而非经过改写的消息。
存储机制:为什么是 ThreadState 而非 metadata
Title 存储在 ThreadState.title 中,而非 thread metadata。ThreadState 定义 中该字段声明为:
class ThreadState(AgentState):
sandbox: SandboxStateField
thread_data: NotRequired[ThreadDataState | None]
title: NotRequired[str | None] # ✅ Title stored here
artifacts: Annotated[list[str], merge_artifacts]
# ... 其余字段省略
TitleMiddleware 自身只声明了一个最小兼容 schema TitleMiddlewareState(AgentState),其中 title: NotRequired[str | None],与 ThreadState 的 title 通道对齐,从而让中间件的返回值 {"title": "..."} 直接落入线程状态通道。
选择 State 而非 Metadata 的对比(原文档表格):
| 特性 | State | Metadata |
|---|---|---|
| 持久化 | ✅ 自动(通过 checkpointer) | ⚠️ 取决于实现 |
| 版本控制 | ✅ 支持时间旅行 | ❌ 不支持 |
| 类型安全 | ✅ TypedDict 定义 | ❌ 任意字典 |
| 可追溯 | ✅ 每次更新都记录 | ⚠️ 只有最新值 |
| 标准化 | ✅ LangGraph 核心机制 | ⚠️ 扩展功能 |
持久化行为与部署方式
| 部署方式 | 持久化 | 说明 |
|---|---|---|
| LangGraph Studio (本地) | ❌ 否 | 仅内存存储,重启后丢失 |
| LangGraph Platform | ✅ 是 | 自动持久化到数据库 |
| 自定义 + Checkpointer | ✅ 是 | 需配置 PostgreSQL/SQLite checkpointer |
如果需要在本地开发时也持久化 title,配置 checkpointer:
# 在 langgraph.json 同级目录创建 checkpointer.py
from langgraph.checkpoint.postgres import PostgresSaver
checkpointer = PostgresSaver.from_conn_string(
"postgresql://user:pass@localhost/dbname"
)
然后在 langgraph.json 中引用:
{
"graphs": {
"lead_agent": "deerflow.agents:lead_agent"
},
"checkpointer": "checkpointer:checkpointer"
}
需要说明的前提:DeerFlow 实际部署(gateway / Docker Compose)走的是内置持久化栈,ThreadState 中的 messages 通道可配置为 delta 快照模式(见 thread_state.py 的 get_thread_state_schema / adapt_state_schema_for_mode),title 作为普通状态字段随 checkpoint 一并落库,因此生产形态下标题天然持久化;上表的"本地 Studio 丢失"主要针对纯 LangGraph Studio 内存场景。
配置项详解
config.yaml 配置
仓库根目录 config.example.yaml 中的实际配置块(第 1734 行附近):
# ============================================================================
# Title Generation Configuration
# ============================================================================
# Automatic conversation title generation settings
title:
enabled: true
max_words: 6
max_chars: 60
model_name: null # null = fast local fallback; set a model name to use LLM title generation
代码级配置
配置由 title_config.py 管理,TitleConfig 是一个 Pydantic 模型,各字段的取值范围有硬约束(比文档的示例更完整):
| 字段 | 类型 | 默认值 | 约束 | 说明 |
|---|---|---|---|---|
enabled |
bool |
True |
— | 是否启用自动标题生成 |
max_words |
int |
6 |
1 <= x <= 20 |
生成的标题最大词数(仅约束 LLM prompt) |
max_chars |
int |
60 |
10 <= x <= 200 |
标题最大字符数(LLM 路径与 fallback 路径统一执行) |
model_name |
str | None |
None |
— | None 表示走本地 fallback;填模型名才启用 LLM 标题生成 |
prompt_template |
str |
见下 | — | LLM 标题生成的 prompt 模板,含 {max_words}、{user_msg}、{assistant_msg} 占位符 |
默认 prompt 模板为:
Generate a concise title (max {max_words} words) for this conversation.
User: {user_msg}
Assistant: {assistant_msg}
Return ONLY the title, no quotes, no explanation.
全局单例通过 get_title_config() / set_title_config() 读写,load_title_config_from_dict() 在 AppConfig.from_file() 时由配置字典加载,reset_title_config() 供测试还原默认值。代码中覆盖示例:
from deerflow.config.title_config import TitleConfig, set_title_config
set_title_config(TitleConfig(
enabled=True,
max_words=8,
max_chars=80,
))
注意 TitleMiddleware 的配置解析优先级(_get_title_config):构造时显式传入的 title_config 参数 > 构造时传入的 app_config.title > 全局 get_title_config() 单例。lead agent 组装时始终注入 app_config,因此文件配置路径在 DeerFlow 运行时是主路径。
端到端工作流程
用户首条消息 → 首轮完整回复 → after_model 判定 → 双路径生成 → state 写入 → checkpointer 持久化 → 客户端读取:
sequenceDiagram
participant User
participant Client
participant LangGraph
participant TitleMiddleware
participant TitleModel as Title model (optional)
participant Checkpointer
User->>Client: 发送首条消息
Client->>LangGraph: POST /threads/{id}/runs
LangGraph->>Agent: 处理消息
Agent-->>LangGraph: 返回回复
LangGraph->>TitleMiddleware: after_model()/aafter_model()
TitleMiddleware->>TitleMiddleware: 检查是否需要生成 title
alt title.model_name 为空(默认)
TitleMiddleware->>TitleMiddleware: 从首条用户消息生成本地 fallback title
else 显式配置 title.model_name
TitleMiddleware->>TitleModel: 生成 LLM title
TitleModel-->>TitleMiddleware: 返回 title
end
TitleMiddleware->>LangGraph: return {"title": "..."}
LangGraph->>Checkpointer: 保存 state (含 title)
LangGraph-->>Client: 返回响应
Client->>Client: 从 state.values.title 读取
实现层面同步与异步钩子的分工(title_middleware.py):
@override
def after_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
# 同步钩子只做本地 fallback,绝不在同步上下文发起 LLM 调用
return self._generate_title_result(state)
@override
async def aafter_model(self, state: TitleMiddlewareState, runtime: Runtime) -> dict | None:
# 异步钩子才走"LLM 生成 + 失败回退"完整路径
from deerflow_extension_api import task_store_from_runtime
return await self._agenerate_title_result(
state,
task_store=task_store_from_runtime(runtime),
)
客户端使用
获取 Thread Title
// 方式1: 从 thread state 获取
const state = await client.threads.getState(threadId);
const title = state.values.title || "New Conversation";
// 方式2: 监听 stream 事件
for await (const chunk of client.runs.stream(threadId, assistantId, {
input: { messages: [{ role: "user", content: "Hello" }] }
})) {
if (chunk.event === "values" && chunk.data.title) {
console.log("Title:", chunk.data.title);
}
}
在对话列表中显示 Title
// 在对话列表中显示
function ConversationList() {
const [threads, setThreads] = useState([]);
useEffect(() => {
async function loadThreads() {
const allThreads = await client.threads.list();
// 获取每个 thread 的 state 来读取 title
const threadsWithTitles = await Promise.all(
allThreads.map(async (t) => {
const state = await client.threads.getState(t.thread_id);
return {
id: t.thread_id,
title: state.values.title || "New Conversation",
updatedAt: t.updated_at,
};
})
);
setThreads(threadsWithTitles);
}
loadThreads();
}, []);
return (
<ul>
{threads.map(thread => (
<li key={thread.id}>
<a href={`/chat/${thread.id}`}>{thread.title}</a>
</li>
))}
</ul>
);
}
优势与注意事项
设计优势(原文档总结):
- 可靠持久化 — 使用 LangGraph 的 state 机制,自动随 checkpointer 持久化;
- 完全后端处理 — 客户端无需额外逻辑;
- 自动触发 — 首次对话后自动生成,且首轮 run 被中断时由 run worker 补偿写入本地 fallback 标题;
- 可配置 — 支持自定义长度、prompt 模板、模型;
- 容错性强 — LLM 路径任何异常都回退到本地策略,标题生成永不阻塞或阻断主流程;
- 架构一致 — 与现有 SandboxMiddleware 等中间件采用相同的 state 更新模式。
使用时的注意事项:
- 读取方式:Title 在
state.values.title,而非thread.metadata.title; - 性能:默认配置(
model_name: null)零额外 LLM 开销;只有显式配置title.model_name时,首轮回复后才会额外等待一次 LLM title 生成(该调用带TAG_NOSTREAM,不会混入前端事件流); - 并发安全:middleware 在 agent 首次完整回复后更新 state,不需要客户端额外请求;幂等判定(state 已有 title 即返回
None)与 run worker 的 stale-snapshot 重试共同保证并发安全; - Fallback 策略:默认使用用户消息前若干字符(上限
min(max_chars, 50)+ 省略号)作为 title;LLM 调用失败时同样回退该策略;用户消息为空时固定为"New Conversation"。
测试与验证
在 backend/tests 目录下,两个测试文件分别覆盖核心逻辑与端到端生成(原文档给出的验证命令):
cd backend
uv run pytest tests/test_title_middleware_core_logic.py tests/test_title_generation.py
- test_title_middleware_core_logic.py 覆盖
_should_generate_title的首轮判定、allow_partial_exchange的中断路径(含中文消息 "请帮我写测试" 的 fallback 断言)等核心分支; - test_title_generation.py 验证标题生成的完整流程。
此外,test_run_worker_rollback.py 中有大量针对中断 run 补偿标题路径的桩测试(mock _generate_title_result(state, allow_partial_exchange=True)),可用于理解 run 回滚场景下标题持久化的各种边界。
故障排查
Title 没有生成
- 检查配置是否启用:
get_title_config().enabled == True(注意 middleware 若注入了app_config,实际读的是app_config.title); - 确认是首轮对话:只有恰好 1 个用户消息(排除动态上下文提醒消息)且至少 1 个助手回复时才会触发;
- 若显式配置了
title.model_name,检查标题模型是否可用;未配置时走本地 fallback(该路径不依赖任何外部模型,只要用户消息有文本就必然生成); - 若 run 在中途被取消,确认 run worker 的中断补偿(
_ensure_interrupted_title)已执行——它在无 checkpoint 或无法从消息派生文本时返回None,不会写入空标题。
Title 生成但客户端看不到
- 确认读取位置:应该从
state.values.title读取,而非thread.metadata.title; - 检查 API 响应:确认 state 中包含 title 字段;
- 尝试重新获取 state:
client.threads.getState(threadId)。
Title 重启后丢失
- 检查是否配置了 checkpointer(本地开发需要);
- 确认部署方式:LangGraph Platform 会自动持久化;
- 查看数据库:确认 checkpointer 正常工作(
channel_values.title应存在)。
相关源码文件索引
| 文件 | 作用 |
|---|---|
| thread_state.py | ThreadState 定义,title 字段所在 |
| title_middleware.py | TitleMiddleware 完整实现(判定、归一化、双路径生成、钩子) |
| title_config.py | TitleConfig 模型与全局配置管理 |
| config.example.yaml | title 配置块的参考示例(第 1734 行附近) |
| lead_agent/agent.py | lead agent 中间件链中 TitleMiddleware 的注册位置 |
| factory.py | 自定义 Agent 工厂中的 auto_title 特性开关 |
| worker.py | _ensure_interrupted_title:中断 run 的 fallback 标题补偿写入 |
| AUTO_TITLE_GENERATION.md | 本主题的设计文档 |
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00