多轮对话纠错
一、问题现象
多轮对话场景:
用户:带孩子去西安玩3天
系统:好的,西安3天行程,正在安排...
[输出西安行程卡片]
用户:算了,我还是想去北京
系统:好的,西安3天行程,正在安排... ← 仍然是西安!
[卡死,无后续输出]
两个 bug 叠加:
- 纠错识别失败:用户明确说"算了去北京",supervisor 输出的 PLAN 目的地仍是"西安"
- 卡死无反馈:子 Agent 搜索/LLM 调用阻塞,SSE 流中断
二、初始方案及其问题
最初的反应是往 supervisor prompt 里加规则:
=== 纠错识别规则(必须遵守) ===
如果用户最新消息中包含纠正/反悔/改变主意的关键词...
这是治标不治本。任何一个合格的 LLM 看到"算了,我还是想去北京"都应该能理解用户改变了主意。问题不在 prompt 写得不够细,而在于 LLM 收到的上下文本身就是残缺的。
三、根因分析
3.1 消息存储的不对称
跟踪两次请求的对话消息流:
Turn 1(graph.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 的所有四个返回路径中,将 HumanMessage 与 AIMessage 成对存入 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天行程卡片
六、经验总结
-
Checkpoint 存储的是"对话",不是"系统日志"。只存 AIMessage 不存 HumanMessage,等于只记住自己说过的话而忘了用户说过什么。LLM 在后续轮次中拿着残缺的对话历史做推理,自然会出错。
-
Prompt 规则补丁是最后手段,不是首选。当 LLM 无法完成一个人类认为"显而易见"的任务时,先检查它收到的输入是否完整、正确。大多数"LLM 理解力不足"的问题,实际上是输入质量的问题。
-
max_tokens_before_summary应该显著低于max_tokens。两者相等意味着摘要触发和输出上限同时到达,没有给摘要内容留余量。 -
重试退避时间要覆盖 LLM 推理延迟。很多模型单次推理需要 10-20 秒,base_delay=1s 的指数退避完全不够。
-
graph.invoke()的最外层超时是低成本安全网。不需要侵入每个节点的逻辑,但能防止请求永久挂起。用户宁愿看到"处理超时,请重试"也不愿盯着空白屏幕。
导航
← 上一篇:Agent Prompt 工程设计 | → 下一篇:用户隔离设计