首页
/ DeerFlow 自动对话标题生成机制:TitleMiddleware 触发条件、双路径策略与持久化实现

DeerFlow 自动对话标题生成机制:TitleMiddleware 触发条件、双路径策略与持久化实现

2026-09-05 12:37:31作者:咎竹峻Karen

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 组装代码 可以看到,TitleMiddlewareTokenUsageMiddleware 之后、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 方法):

  1. 配置必须启用(config.enabled 为真),否则直接返回 None
  2. state 中已存在 title 时不重复生成(幂等);
  3. 必须是首轮对话:恰好 1 条用户消息,且至少 1 条助手回复。源码对 messages 通道做了防御性处理——读取半初始化 checkpoint 时 messages 可能为 None,会归一为空列表以避免 len() 报错;
  4. 用户消息的识别排除了"动态上下文提醒"类消息(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_agentmiddleware:titleTAG_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 metadataThreadState 定义 中该字段声明为:

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],与 ThreadStatetitle 通道对齐,从而让中间件的返回值 {"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.pyget_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 更新模式。

使用时的注意事项:

  1. 读取方式:Title 在 state.values.title,而非 thread.metadata.title
  2. 性能:默认配置(model_name: null)零额外 LLM 开销;只有显式配置 title.model_name 时,首轮回复后才会额外等待一次 LLM title 生成(该调用带 TAG_NOSTREAM,不会混入前端事件流);
  3. 并发安全:middleware 在 agent 首次完整回复后更新 state,不需要客户端额外请求;幂等判定(state 已有 title 即返回 None)与 run worker 的 stale-snapshot 重试共同保证并发安全;
  4. 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_run_worker_rollback.py 中有大量针对中断 run 补偿标题路径的桩测试(mock _generate_title_result(state, allow_partial_exchange=True)),可用于理解 run 回滚场景下标题持久化的各种边界。

故障排查

Title 没有生成

  1. 检查配置是否启用:get_title_config().enabled == True(注意 middleware 若注入了 app_config,实际读的是 app_config.title);
  2. 确认是首轮对话:只有恰好 1 个用户消息(排除动态上下文提醒消息)且至少 1 个助手回复时才会触发;
  3. 若显式配置了 title.model_name,检查标题模型是否可用;未配置时走本地 fallback(该路径不依赖任何外部模型,只要用户消息有文本就必然生成);
  4. 若 run 在中途被取消,确认 run worker 的中断补偿(_ensure_interrupted_title)已执行——它在无 checkpoint 或无法从消息派生文本时返回 None,不会写入空标题。

Title 生成但客户端看不到

  1. 确认读取位置:应该从 state.values.title 读取,而非 thread.metadata.title
  2. 检查 API 响应:确认 state 中包含 title 字段;
  3. 尝试重新获取 state:client.threads.getState(threadId)

Title 重启后丢失

  1. 检查是否配置了 checkpointer(本地开发需要);
  2. 确认部署方式:LangGraph Platform 会自动持久化;
  3. 查看数据库:确认 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 本主题的设计文档
登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384