DeerFlow 自动会话标题生成:从 TitleConfig 配置到 TitleMiddleware 中间件的完整实现解析
本篇围绕 DeerFlow 的自动会话标题(Thread Title)生成功能展开:它如何让 Agent 在首轮对话结束后自动为会话写入一个 title,默认以零 LLM 开销的本地 fallback 策略生成标题,同时支持显式配置标题模型走 LLM 精炼。读完你可以掌握该功能的完整配置项(含取值范围与默认值)、触发条件与源码级实现原理、断线/中断边界场景下的持久化兜底,以及客户端从 state.values.title 读取标题的标准姿势。
功能全貌:涉及哪些核心文件
自动标题功能横跨「状态 → 配置 → 中间件 → 入口注册 → 运行器兜底」五个层面,各文件职责如下:
| 文件 | 职责 |
|---|---|
| thread_state.py | ThreadState 新增 title 字段,作为标题的持久化载体 |
| title_config.py | 新建 TitleConfig 配置类,含全局单例的 get/set/load/reset API |
| title_middleware.py | 新建 TitleMiddleware,在首轮对话后触发标题生成 |
| app_config.py | 在 from_file() 中把 config.yaml 的 title 段加载进全局配置 |
| agent.py | 将 TitleMiddleware 注册进 lead agent 的中间件列表 |
| worker.py | 首轮 run 被中断时补写 fallback 标题,并把标题同步到线程元数据 |
设计上的一个关键决策是:标题存在 Graph 的 State 里,而不是 thread.metadata 里。两者的对比如下:
| 方面 | State(采用) | Metadata(未采用) |
|---|---|---|
| 持久化 | 自动(通过 checkpointer) | 取决于实现,不可靠 |
| 版本控制 | 支持时间旅行 | 不支持 |
| 类型安全 | TypedDict 定义 | 任意字典 |
| 标准化 | LangGraph 核心机制 | 扩展功能 |
数据层:ThreadState 增加 title 字段
在 ThreadState 中,title 被声明为可选字段:
class ThreadState(AgentState):
sandbox: SandboxStateField
thread_data: NotRequired[ThreadDataState | None]
title: NotRequired[str | None] # 自动生成的会话标题
artifacts: Annotated[list[str], merge_artifacts]
# ... 其余字段
使用 NotRequired[str | None] 意味着:旧 checkpoint 中不存在该字段时反序列化不会报错,新字段对历史会话完全向后兼容;而一旦写入,它就和 messages、artifacts 等通道一样被 checkpointer 自动持久化,并支持时间旅行回溯。
配置层:TitleConfig 的五个参数
TitleConfig 是一个 Pydantic 模型,字段与约束如下(约束值直接来自源码的 Field 参数):
| 参数 | 类型 | 默认值 | 约束 | 说明 |
|---|---|---|---|---|
enabled |
bool |
True |
— | 是否启用自动标题生成 |
max_words |
int |
6 |
1 ≤ x ≤ 20 |
标题最大词数 |
max_chars |
int |
60 |
10 ≤ x ≤ 200 |
标题最大字符数 |
model_name |
str | None |
None |
— | 为 None 时走本地 fallback;填模型名才启用 LLM 标题 |
prompt_template |
str |
内置模板 | — | LLM 路径使用的提示词模板,支持 {max_words}、{user_msg}、{assistant_msg} 占位符 |
默认的 prompt_template 为:
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(config) # 整体替换
load_title_config_from_dict(dict) # 从 config.yaml 解析出的字典加载
reset_title_config() # 恢复 TitleConfig() 纯净默认值(供测试使用)
加载链路在 AppConfig.from_file() 中完成:title 段先解析为 AppConfig.title 字段(L237),随后调用 load_title_config_from_dict(config.title.model_dump()) 同步进全局单例,使中间件即使不持有 AppConfig 实例也能读到配置。
仓库根目录的 config.example.yaml 给出了标准配置段:
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
核心机制:TitleMiddleware 触发时机与双策略生成
注册与钩子
lead agent 的构建流程 中,TitleMiddleware 被实例化并追加到中间件列表,紧随其后注册 MemoryMiddleware:
# Add TitleMiddleware
middlewares.append(
TitleMiddleware(
app_config=resolved_app_config,
extensions=resolved_extensions,
)
)
中间件覆写了 after_model() 与 aafter_model() 两个钩子(L278-L288),在每次模型响应后触发。同步钩子只生成本地 fallback 标题;异步钩子则额外承担 LLM 标题路径。整体工作流程为:
用户发送首条消息
↓
Agent 处理并返回回复
↓
TitleMiddleware.after_model() / aafter_model() 触发
↓
检查:是否首次对话?是否已有 title?
↓
默认从首条用户消息生成本地 fallback title
↓
如果显式配置 title.model_name,才调用 LLM 生成更精炼的 title
↓
返回 {"title": "..."} 更新 state
↓
Checkpointer 自动持久化(如果配置了)
↓
客户端从 state.values.title 读取
触发条件 _should_generate_title
_should_generate_title 定义了「是否该生成标题」的全部判据:
config.enabled为False→ 不生成;state中已有title→ 不生成(每个 thread 只生成一次);- 消息数检查:正常路径要求至少 1 条用户消息 + 1 条助手回复(
len(messages) >= 2); - 精确计数:用户消息(排除动态上下文提醒消息)恰好为 1 条,且助手消息至少 1 条。
其中第 3 点有一个细节:allow_partial_exchange=True 时把门槛降为「至少 1 条用户消息」,专门服务于首轮 run 被取消、AI 回复尚未落 checkpoint 的中断场景——此时允许仅凭首条用户消息生成 fallback 标题。
本地 fallback 策略(默认路径)
当 model_name 为 None(默认配置)时,_generate_title_result 直接调用 _fallback_title,其规则是:
- 用户消息为空 → 返回
"New Conversation"; - 用户消息长度超过
min(max_chars, 50)→ 截断并追加省略号...(预留省略号空间,保证结果恰好满足max_chars上限); - 否则原样返回用户消息作为标题。
这条路径不发起任何 LLM 调用,因此默认配置下标题生成对流式回复收尾的延迟贡献几乎为零。
LLM 标题路径(显式配置 model_name 时)
异步钩子走 _agenerate_title_result,关键实现点:
- 无用户文本时短路:仅含附件的首轮没有用户自撰文本,直接返回 fallback,避免让标题模型从助手回复中「脑补」标题;
- Prompt 构建:_build_title_prompt 取首条用户消息与首条 AI 回复,各截断到 500 字符后填入
prompt_template;AI 回复会先经 _strip_think_tags 用正则<think>...</think>剥除推理模型的思维块(兼容 minimax、DeepSeek-R1 等); - 模型调用:通过
create_chat_model(name=config.model_name, thinking_enabled=False, ...)创建标题模型,并经observe_system_model_call(..., SystemOperationKind.TITLE, ...)包装,使扩展系统能观测到这是一次系统级模型调用; - 输出清洗:_parse_title 归一化内容类型、再剥 think 标签、strip 引号,最后按
max_chars截断; - 异常兜底:模型创建或调用抛任何异常时仅记录 debug 日志,回落到本地 fallback 标题——LLM 路径的失败永远不会阻断主流程。
另外,_get_runnable_config 继承父级 RunnableConfig 并追加 middleware:title 标签与 TAG_NOSTREAM:前者让 RunJournal 把这次 LLM 调用识别为 middleware:title 而非 lead_agent,后者确保标题生成产生的 token 流不会混入主对话的流式输出。
配置与客户端使用
后端配置
启用/禁用:
# config.yaml
title:
enabled: true # 设为 false 禁用
自定义参数:
title:
enabled: true
max_words: 8 # 标题最多 8 个词(取值范围 1-20)
max_chars: 80 # 标题最多 80 个字符(取值范围 10-200)
model_name: null # null = 快速本地 fallback;填模型名才启用 LLM 标题
配置持久化(可选)
本地开发时如需持久化 title,可配置 checkpointer:
# checkpointer.py
from langgraph.checkpoint.sqlite import SqliteSaver
checkpointer = SqliteSaver.from_conn_string("deerflow.db")
// langgraph.json
{
"graphs": {
"lead_agent": "deerflow.agents:lead_agent"
},
"checkpointer": "checkpointer:checkpointer"
}
客户端读取
// 获取 thread title
const state = await client.threads.getState(threadId);
const title = state.values.title || "New Conversation";
// 显示在对话列表
<li>{title}</li>
注意:Title 位于 state.values.title,而非 thread.metadata.title——这是使用 State 方案带来的直接约定。
边界场景:首轮 run 中断后的标题兜底
这是该功能中最精细的一段实现。当首轮 run 在 checkpointer 写入可用 checkpoint 前被取消时,worker.py 会在 interrupted-run cleanup 中调用 _ensure_interrupted_title,其设计要点:
- 保持 finalizing 状态:run 在 cleanup 期间保持 finalizing,避免同线程的新 run 在 fallback title 写入期间覆盖 checkpoint;
- 幂等:写入前先读 latest checkpoint 的
channel_values.title,已有标题则直接返回,重复调用不会重写; - title-only 更新:若同线程状态已经前进(后续消息已写入),只对最新 snapshot 做 title-only 更新,避免把旧消息重新变成 latest;
- 版本推进:写入时同步 bump
channel_versions["title"]并在metadata.writes中声明runtime_interrupt_title,保证 checkpointer 的通道版本一致性。
标题落 checkpoint 后,worker 还会把它同步到线程元数据的展示名(worker.py L1524-L1535):
# Sync title from checkpoint to threads_meta.display_name
title = ckpt.get("channel_values", {}).get("title")
if title:
await thread_store.update_display_name(thread_id, title)
从源码结构看,这形成了一个「State 为权威来源、metadata 为展示副本」的双写结构:State 里的 title 随 checkpoint 持久化且可时间旅行,display_name 则供会话列表等 UI 快速读取。
测试与验证
仓库提供了 tests/test_title_generation.py,覆盖配置类与中间件初始化:
# 运行标题生成测试
pytest tests/test_title_generation.py -v
# 运行所有测试
pytest
测试内容包含:默认配置值断言(enabled=True、max_words=6、max_chars=60、model_name=None)、自定义配置、越界校验(max_words 传 0 或 21、max_chars 传 5 或 201 均抛 ValueError)、全局 get/set、中间件初始化与 state_schema 校验。文件末尾留有集成测试 TODO(mock Runtime、checkpointer 持久化、并发标题生成等)。
故障排查
Title 没有生成?
- 检查配置:
title.enabled: true; - 确认是首次对话(1 个用户消息 + 1 个助手回复),且 state 中尚无 title;
- 如果显式配置了
title.model_name,检查标题模型是否可用;未配置时会走本地 fallback,不会静默失败。
Title 生成但看不到?
- 确认读取位置:
state.values.title(不是thread.metadata.title); - 检查 API 响应是否包含 title;
- 重新获取 state(标题在模型响应后的钩子中写入,过早读取可能拿不到)。
Title 重启后丢失?
- 本地开发需要配置 checkpointer(见上文 SqliteSaver 示例);
- 托管部署环境下持久化由平台自动完成;
- 检查数据库确认 checkpointer 工作正常。
中断首轮后仍显示默认标题?
- worker 的 interrupted-run cleanup 会补写 fallback 标题(见「边界场景」一节),确认 run 的取消路径是否进入了该分支;
- 确认取消发生在 checkpoint 写入前——若已有可用 checkpoint 且其中含 title,兜底逻辑幂等返回,不会覆盖。
性能影响与优化建议
- 默认延迟:默认
title.model_name: null不发起额外 LLM 调用,仅从首条用户消息生成本地 fallback 标题; - 显式 LLM 标题延迟:只有配置
title.model_name时,首轮回复后才会等待一次标题模型调用; - 并发安全:在
after_model()/aafter_model()中更新 state,不需要客户端额外请求; - 资源消耗:每个 thread 只生成一次。
优化建议:
- 默认保持
model_name: null,避免流式回复结束前的额外 LLM 等待; - 如需更精炼标题,再显式配置较快的标题模型;
- 适当调小
max_words和max_chars,并保持 prompt 简洁(用户/助手消息各截断 500 字符,prompt 体积有天然上限)。
延伸阅读
- 完整功能说明文档(含 Mermaid 流程图与架构设计):AUTO_TITLE_GENERATION.md
- 功能完成记录:TODO.md
- 核心实现:title_middleware.py、title_config.py
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