多轮对话纠错

一、问题现象

多轮对话场景:

用户:带孩子去西安玩3天
系统:好的,西安3天行程,正在安排...
       [输出西安行程卡片]

用户:算了,我还是想去北京
系统:好的,西安3天行程,正在安排...   ← 仍然是西安!
       [卡死,无后续输出]

两个 bug 叠加:

  1. 纠错识别失败:用户明确说"算了去北京",supervisor 输出的 PLAN 目的地仍是"西安"
  2. 卡死无反馈:子 Agent 搜索/LLM 调用阻塞,SSE 流中断

二、初始方案及其问题

最初的反应是往 supervisor prompt 里加规则:

=== 纠错识别规则(必须遵守) ===
如果用户最新消息中包含纠正/反悔/改变主意的关键词...

这是治标不治本。任何一个合格的 LLM 看到"算了,我还是想去北京"都应该能理解用户改变了主意。问题不在 prompt 写得不够细,而在于 LLM 收到的上下文本身就是残缺的。

三、根因分析

3.1 消息存储的不对称

跟踪两次请求的对话消息流:

Turn 1graph.invoke 调用 supervisor_node):

# supervisor_node 收到的 state["messages"] = [](首次对话)
# LLM 调用:
resp = invoke_with_retry(supervisor_agent,
    [SystemMessage(content=prompt)] + processed_messages + [HumanMessage(content=state["user_input"])]
)

# LLM 输出 PLAN|西安|3天|...

# supervisor_node 返回给 checkpoint 的内容:
return {
    "messages": [AIMessage(content="好的,西安 3天行程...")],  # ← 只有 AIMessage!
    "destination": "西安",
    ...
}

关键问题:HumanMessage(content=state["user_input"])内联传入 LLM 调用,但没有写回 state["messages"]。只有 AIMessage 通过 add_messages 追加到了 checkpoint。

Turn 2(checkpoint 恢复后):

checkpoint 恢复的 state["messages"] = [
    AIMessage("好的,西安 3天行程。正在安排4位助手...")    ← 只有系统的回复
]

LLM 实际收到的消息序列:

SystemMessage:  "你是TravelAgent-5...根据用户消息输出一行指令"
AIMessage:      "好的,西安 3天行程..."    ← 系统上一轮的回复
HumanMessage:   "算了,我还是想去北京"      ← 用户当前的输入(由调用方注入)

LLM 看不到用户 Turn 1 说了什么("带孩子去西安玩3天")。它只看到自己说过"西安",然后用户说"算了去北京"。在这种不对称的对话历史中,LLM 更倾向于维护自己上一轮的输出——因为"西安"是它自己提出的。

3.2 为什么这不是 LLM 能力问题

如果 LLM 能看到完整的对话历史:

HumanMessage:  "带孩子去西安玩3天"       ← 缺失!
AIMessage:     "好的,西安 3天行程..."
HumanMessage:  "算了,我还是想去北京"

任何一个合格的 LLM 都能理解:用户先提了西安,然后改成了北京。这是人类对话中最基本的"自我纠正"模式。问题不在 LLM 的推理能力,而在我们喂给它的上下文是残缺的。

3.3 次要问题:max_tokens_before_summary 配置

# 修改前
summary_result = summarize_messages(
    messages=messages,
    max_tokens=4000,
    max_tokens_before_summary=4000,  # ← 与 max_tokens 相同
    max_summary_tokens=256,
)

max_tokens_before_summary 是触发摘要的阈值,max_tokens 是输出的 token 上限。两者设为相同值意味着摘要触发和输出上限同时到达——没有余量。应该让摘要提前触发(如 3000 tokens),给摘要本身和最近的消息留出空间。

3.4 次要问题:重试超时过短

MAX_RETRIES = 3
RETRY_BASE_DELAY = 1  # 指数退避:1s → 2s → 4s = 总计 7s 等待

很多 LLM 单次推理就超过 7 秒。总计 7 秒的退避时间不足以覆盖网络波动和 API 限流。

四、修复方案

4.1 核心修复:HumanMessage 写入 checkpoint

supervisor_node 的所有四个返回路径中,将 HumanMessageAIMessage 成对存入 messages

# CHAT 路径(修改前)
"messages": [AIMessage(content=reply)],

# CHAT 路径(修改后)
"messages": [HumanMessage(content=state["user_input"]), AIMessage(content=reply)],

同样修改 ASK、PLAN 和兜底路径。

修复后,Turn 2 的 LLM 看到的完整上下文:

HumanMessage:  "带孩子去西安玩3天"
AIMessage:     "好的,西安 3天行程..."
HumanMessage:  "算了,我还是想去北京"

LLM 能清晰看到:用户先说要西安 → 系统确认 → 用户改说北京。这是自然的人类纠错模式。

4.2 max_tokens_before_summary 分离

SUMMARY_MAX_TOKENS = 4000          # 输出 token 上限
SUMMARY_MAX_TOKENS_BEFORE = 3000   # 摘要触发阈值(低于上限)
SUMMARY_MAX_SUMMARY_TOKENS = 256   # 摘要大小上限

摘要提前触发,给 [摘要 + 最近N条消息] 留出从 3000 到 4000 的 1000 token 余量。

4.3 重试时间延长

MAX_RETRIES = 3
RETRY_BASE_DELAY = 4  # 指数退避:4s → 8s → 16s = 总计 28s 等待

总计 28 秒的退避时间,覆盖绝大多数 LLM 推理延迟。

4.4 超时保护(保留)

with ThreadPoolExecutor(max_workers=1) as executor:
    future = executor.submit(graph.invoke, invoke_input, config)
    final = future.result(timeout=120)

graph.invoke() 的 120s 超时保留,防止子 Agent 搜索卡死导致请求永久挂起。

五、修复前后对比

5.1 消息完整性

修复前(Turn 2 的 LLM 视角):
┌──────────────────────────────────────────────┐
│ AIMessage:  "好的,西安3天行程..."            │  ← 系统回复
│ HumanMessage: "算了,我还是想去北京"           │  ← 当前输入
│                                              │
│ LLM 推理:用户对"我提出的西安"不满,想改北京? │
│ 但"西安"是我自己提出的,用户没有说要西安...     │
│ → 混乱,倾向于维持原方案                       │
└──────────────────────────────────────────────┘

修复后(Turn 2 的 LLM 视角):
┌──────────────────────────────────────────────┐
│ HumanMessage: "带孩子去西安玩3天"              │  ← 用户原话
│ AIMessage:    "好的,西安3天行程..."           │  ← 系统回复
│ HumanMessage: "算了,我还是想去北京"           │  ← 用户纠正
│                                              │
│ LLM 推理:用户先说要西安 → 我确认了            │
│ → 用户说"算了"改去北京 → 目的地=北京           │
│ → 清晰,正确                                  │
└──────────────────────────────────────────────┘

5.2 纠错场景

修复前:
用户:带孩子去西安玩3天
系统:西安3天行程 ✓
用户:算了,我还是想去北京
系统:西安3天行程 ✗  ← 上下文断裂导致
      [卡死]

修复后:
用户:带孩子去西安玩3天
系统:西安3天行程 ✓
用户:算了,我还是想去北京
系统:好的,北京3天行程...  ← 完整上下文下的正确推理
      北京3天行程卡片

六、经验总结

  1. Checkpoint 存储的是"对话",不是"系统日志"。只存 AIMessage 不存 HumanMessage,等于只记住自己说过的话而忘了用户说过什么。LLM 在后续轮次中拿着残缺的对话历史做推理,自然会出错。

  2. Prompt 规则补丁是最后手段,不是首选。当 LLM 无法完成一个人类认为"显而易见"的任务时,先检查它收到的输入是否完整、正确。大多数"LLM 理解力不足"的问题,实际上是输入质量的问题。

  3. max_tokens_before_summary 应该显著低于 max_tokens。两者相等意味着摘要触发和输出上限同时到达,没有给摘要内容留余量。

  4. 重试退避时间要覆盖 LLM 推理延迟。很多模型单次推理需要 10-20 秒,base_delay=1s 的指数退避完全不够。

  5. graph.invoke() 的最外层超时是低成本安全网。不需要侵入每个节点的逻辑,但能防止请求永久挂起。用户宁愿看到"处理超时,请重试"也不愿盯着空白屏幕。

导航

← 上一篇:Agent Prompt 工程设计 | → 下一篇:用户隔离设计