上下文完整性
一、问题
1.1 修复前的消息流
Turn 1: 用户"去成都3天"
→ supervisor 调用 LLM → 输出 PLAN|成都|3天|...
→ 4 个子 Agent 并行执行:
route: 搜索景点 → LLM规划路线 → 存到 route_result(不可见!)
hotel: 搜索酒店 → LLM对比分析 → 存到 hotel_result(不可见!)
food: LLM推荐餐厅 → 存到 food_result(不可见!)
info: LLM介绍文化 → 存到 info_result(不可见!)
→ aggregator 汇总 → 存 AIMessage("为用户规划了成都 3天行程")
checkpoint.messages = [
HumanMessage("去成都3天"), ← supervisor 写入
AIMessage("好的,成都 3天行程..."), ← supervisor 写入
AIMessage("为用户规划了成都 3天行程") ← aggregator 写入
]
问题:子 Agent 的具体产出——推荐了什么酒店、规划了什么路线、推荐了什么餐厅——全部封在 route_result、hotel_result、food_result、info_result 这些状态字段中。这些字段不在 checkpoint 的 messages 列表里,LLM 在后续对话中看不到它们。
1.2 导致的体验断裂
用户:去成都3天
系统:好的,[输出成都3天行程 + 酒店 + 餐厅]
用户:把第二天的酒店换成便宜点的
系统:请问您之前看的是哪家酒店? ← LLM 根本不知道推荐了什么!
LLM 的回复暴露了真相:它只知道"为用户规划了成都 3 天行程"这个笼统摘要,完全不知道具体推荐了哪几家酒店、价格如何。
这是一个上下文不对称问题:用户看到了完整结果(前端渲染了完整的行程卡片),但 LLM 看不到自己团队的工作成果。
二、根因分析
2.1 状态字段 ≠ 对话消息
LangGraph 的 checkpoint 同时保存两类数据:
| 类型 | 存储方式 | LLM 能否看到 |
|---|---|---|
messages |
add_messages reducer → 追加到 checkpoint |
summarize_messages 处理后传给 LLM |
route_result 等 |
普通字段,只被下一次 invoke_input 覆盖 |
看不到 |
summarize_messages 只处理 messages 列表,不关心 route_result 这种业务字段。子 Agent 把产出写到 result 字段,等于把信息锁进了 LLM 无法触及的抽屉里。
2.2 为什么一开始这么设计
最初的设计意图是"让 supervisor 统筹全局,子 Agent 只干活不发言"。这是一种人类管理直觉的映射:老板分配任务,员工交报告,老板汇总后向客户汇报。
但 LLM 不是人类经理——它不会主动翻阅"员工报告"。它只看自己对话窗口里的消息。不给它看报告,它就真不知道。
三、修复方案
3.1 每个子 Agent 写入摘要 AIMessage
修复后,每个子 Agent 在返回 result 的同时,也向 messages 写入一条精简的摘要:
# route_agent_node —— 路线摘要
day_summaries = ";".join(
f"第{d['day']}天({' → '.join(a['name'] for a in d.get('activities',[]))})"
for d in days_data
)
route_msg = f"已规划{dest} {days}天行程。{day_summaries}"
return {
"route_result": json.dumps(result, ensure_ascii=False),
"messages": [AIMessage(content=route_msg)],
}
# hotel_agent_node —— 酒店摘要
hotel_names = "、".join(h["name"] + f"({h['price']})" for h in hotels_json[:5])
hotel_msg = f"已筛选{dest}酒店:{hotel_names}。" + comparison
return {
"hotel_result": ...,
"messages": [AIMessage(content=hotel_msg[:300])],
}
# food_agent_node —— 美食摘要
food_lines = []
for fd in food_plan.get("days", []):
lunch_name = (fd.get("lunch") or {}).get("name", "")
dinner_name = (fd.get("dinner") or {}).get("name", "")
food_lines.append(f"第{d}天 午餐:{lunch_name} 晚餐:{dinner_name}")
return {
"food_result": ...,
"messages": [AIMessage(content=food_msg[:300])],
}
# info_agent_node —— 文化摘要
info_msg = f"已整理{dest}旅行文化指南(含文化特色、冷知识、礼仪提醒等)"
return {
"info_result": info_text,
"messages": [AIMessage(content=info_msg)],
}
3.2 修复后的消息流
Turn 1: 用户"去成都3天"
checkpoint.messages = [
HumanMessage("去成都3天"), ← supervisor
AIMessage("好的,成都 3天行程..."), ← supervisor
AIMessage("已规划成都3天行程。第1天(宽窄巷子 → 太古里 → 锦里)..."), ← route
AIMessage("已筛选成都酒店:锦江宾馆(¥500)、如家(¥200)..."), ← hotel
AIMessage("已推荐成都3天美食。第1天 午餐:龙抄手 晚餐:蜀九香..."), ← food
AIMessage("已整理成都旅行文化指南..."), ← info
AIMessage("为用户规划了成都 3天行程"), ← aggregator
]
LLM 现在能"看到"每个子 Agent 的具体产出。
3.3 关键设计决策:存摘要而非全量数据
为什么不直接把 route_result 的完整 JSON 写入 messages?
- Token 消耗:完整 JSON 可能 2000+ tokens,5 轮对话后 checkpoint 严重膨胀
- LLM 处理效率:LLM 不需要看到 JSON 结构——它只需要知道"推荐了哪些酒店、价格如何"
- 摘要足够解决问题:用户说"换便宜的"时,LLM 看到"锦江宾馆(¥500)、如家(¥200)"就能判断哪个更便宜
摘要原则:存到 messages 里的信息要足够让 LLM 回答针对性的追问,但不要冗余到浪费 token。
四、体验改善
4.1 修复前后对比
修复前:
用户:去成都3天
系统:[完整行程卡片]
用户:把第二天的酒店换成便宜点的
系统:请问您之前看的是哪家? ← LLM 不知道推荐了什么
修复后:
用户:去成都3天
系统:[完整行程卡片]
用户:把第二天的酒店换成便宜点的
系统:您之前看的三家是锦江宾馆(¥500)、汉庭(¥300)、如家(¥200)。
如家最便宜,但评分较低。建议换成汉庭,性价比最高。 ← LLM 能看到酒店信息
4.2 支持的追问类型
修复后,LLM 可以处理这些针对性的追问:
| 追问 | LLM 需要的上下文 | 所在摘要 |
|---|---|---|
| "换便宜的酒店" | 酒店名称和价格 | hotel 摘要 |
| "去掉第三天的某个景点" | 每天景点名称 | route 摘要 |
| "我不吃辣,换餐厅" | 每天餐厅名称 | food 摘要 |
| "介绍下第二天那个景点" | 景点名称 | route 摘要 |
五、经验总结
-
对话记忆不只是"说了什么",还包括"产出了什么"。当一个 multi-agent 系统生成了结构化结果,把结果摘要写入对话记忆是让后续对话可理解的前提。
-
状态字段 ≠ LLM 可读。LangGraph 的 checkpoint 保存了完整的图状态,但
summarize_messages只处理messages列表。子 Agent 的结果要进入 LLM 上下文,必须通过messages管道。 -
摘要优于全量。在对话记忆里存摘要而非原始 JSON,在 token 效率和可用性之间取得平衡。LLM 不需要看到
{ "days": [{ "day": 1, "activities": [{...}] }] }这种嵌套结构——它只需要知道"第一天去了宽窄巷子、太古里"。 -
与上一篇的关联:上一篇(多轮对话纠错)修复了 HumanMessage 缺失导致的"用户说了什么"丢失。本篇修复了子 Agent AIMessage 缺失导致的"系统产出了什么"丢失。两者共同构成了完整的对话上下文。
导航
← 上一篇:用户隔离设计