一个多轮会话进行到第七轮,用户问"把字幕去掉",Agent 却开始兴致勃勃地回答第一轮的问题"帮我做一个产品介绍视频"。没有报错,没有异常日志,模型对自己答非所问这件事毫无察觉。这不是模型能力问题,是一个上下文注入 bug——2026 年 8 月 16 日合并进 bytedance/deer-flow 主干的 PR #4667,把这个坑钉进了契约。
这篇文�章做三件事:完整解剖这个 bug 的四环链条(它比"扫错了方向"深刻得多);用一手源码横评六个主流 Agent 系统在注入与压缩上的防御工事;最后提炼出六条可以直接搬进你代码评审清单的不变量。结论先放在这:动态上下文挂到历史里哪条消息上,决定了模型认为"现在"是什么——而绝大多数注入机制写出来的第一版,都会挂错。
一、bug 的完整解剖:四环链条缺一不可
deer-flow 的 DynamicContextMiddleware 负责在每轮对话前注入动态上下文(当前日期提醒 + 记忆块)。它的注入手法是 ID-swap:构造一条 reminder 消息夺走目标 user message 的 ID,原内容以派生 ID {id}__user 重新发出。LangGraph 的 add_messages reducer 按 ID 去重合并——同 ID 原位替换,新 ID 追加到列表尾部。
单看每一步都没问题,灾难藏在组合里(deer-flow 2.x 的整体架构我此前在另一篇拆解里写过,本文只聚焦这条注入链):
- 某轮注入被跳过。 异步
abefore_agent降级路径在 tiktoken 冷下载超时时放弃注入(这是更早的 #3402 加的保护 guard)。 - 下一轮走进"首注入"分支。 中间件检测不到任何历史 reminder(
last_date is None),认为这是会话第一轮——但历史里已经躺着六个 turn。 - 首注入分支从头扫。
next(i for i, m in enumerate(messages) if _is_user_injection_target(m))——第一直觉的写法,attach 到第一条 user message。 - ID-swap 触发隐式重排。 第一条 user message 的 ID 被 reminder 占用,原内容以
msg-1__user的新 ID 被add_messages追加到队尾——恰好落在当前问题的后面。
折叠后的消息列表长这样(buggy 路径):
[reminder(id=msg-1)] [a1] [u2] [a2] ... [u7 "把字幕去掉"] [msg-1__user "帮我做一个产品介绍视频"]
↑ 当前问题 ↑ 旧 prompt 顶到了队尾
模型看到的最后一条 user 内容是六轮之前的第一条 prompt。它答非所问,是因为在它的视角里,那就是本轮的问题。
修复只有一个词的差别——reversed(range(len(messages))),从尾部扫,attach 到最新 user message。真·首轮只有一条消息(first 即 last),行为不变;fallback 路径下,msg-7__user 的追加位置和当前问题重合,顺序天然保真。
这个 PR 被打上 risk:high 和 needs-validation 标签,归入 2.1.0 里程碑,改动本身只有二十来行。但它暴露的语义陷阱值得每个用 LangGraph(或任何"按 ID 合并消息列表"运行时)的人背下来:
# LangGraph add_messages 的契约(源码 docstring,2026-08 主干):
# msgs1 = [HumanMessage(content="Hello", id="1")]
# msgs2 = [HumanMessage(content="Hello again", id="1")]
# → [HumanMessage(content='Hello again', id='1')] # 同 ID 原位替换
# 新 ID 一律追加到尾部
改历史消息的 ID + reducer 的追加语义 = 隐式历史重排。 你没有写任何"move"代码,但消息物理上被传送了。
测试教训:旧测试把 bug 编进了契约
#4667 之前的测试叫 test_injects_only_into_first_human_message_not_later_ones——它忠实地断言了错误行为:"reminder 应该挂第一条"。测试绿着,bug 活着。新测试改名 test_first_turn_fallback_targets_the_latest_user_message,并且做了一件旧测试没做的事:把 middleware 的返回值折叠进真实的 add_messages reducer,再断言当前问题仍然是最后一条 user message。
这是本次修复里最值得抄的部分。只断言中间产物形状("返回了两条消息,ID 分别是 X 和 Y")的测试,验证不了注入在完整管线里的净效果——而重排恰恰发生在 reducer 里,不在 middleware 里。
二、为什么这不是 deer-flow 独有
三个原因让这个 bug 类具有普遍性:
- "找第一条 user message" 是注入代码的第一直觉。 "系统提醒应该出现在对话开头"的心理模型,让从头扫成了默认写法。
- ID-swap 是精巧但危险的注入手法。 它的好处是注入产物能复用 prompt cache 前缀(deer-flow 注释里明说了这个动机:reminder 内容不变,后续每轮都能命中缓存)。代价是任何"attach 到旧消息"的错误都会被 reducer 放大成历史重排。
- 降级路径制造"不可能状态"。 正常流程下"多轮历史 + 从未注入过"这个状态不存在,首注入分支才敢假设"历史里只有一条消息"。一旦某个 timeout/降级 guard 跳过一次注入,不可能状态变成现实,假设崩塌。防御性代码(#3402 的 guard)自己制造了新 bug 的触发条件——这在 Agent 系统里是 recurring theme。
三、六大系统横评:注入与压缩的防御工事
我拉了五个其他系统的源码或官方文档,看它们把"动态上下文放哪、压缩时从哪切"这两个问题解成什么样。截至 2026 年 8 月 17 日的主干。
Codex(OpenAI,Rust):把注入位置编码成类型
Codex 的 codex-rs/core/src/compact.rs 里有一个教科书级的类型定义:
pub(crate) enum InitialContextInjection {
BeforeLastUserMessage { world_state: Arc<WorldState>, step_context: Arc<StepContext> },
DoNotInject,
}
注释写明规则:轮前/手动压缩用 DoNotInject——历史被摘要整体替换后,下一个常规 turn 自然重注入初始上下文;轮中压缩必须用 BeforeLastUserMessage,因为"模型被训练成在轮中压缩后把摘要视为历史最后一项",所以初始上下文要插在最后一条真实 user message 之前。
插入点选择函数 insert_initial_context_before_last_real_user_or_summary 用 .rev() 反向遍历,优先级链:最后一条真实 user message → 最后一条摘要类消息(保证摘要仍在末尾)→ 最后一个压缩条目 → 否则追加。跟 #4667 的修复思路完全一致——从尾扫,锚定最新——但 Codex 把它做成了编译期可检查的类型和带注释的不变式,而不是散在循环里的一个 reversed。
压缩产物本身也工程化得很重:每段压缩历史带 window_number 和 AutoCompactWindowIds(窗口编号单调递增,可追溯第几次压缩);摘要 prompt 是四要点的"交接班"格式(当前进展与关键决策 / 约束与偏好 / 剩余步骤 / 继续所需的关键数据),prefix 固定为 "Another language model started to solve this problem..."——把"接手者视角"写死在提示词里。单条压缩输入上限 20,000 tokens。
Gemini CLI(Google):两代压缩的进化史
Gemini CLI 的仓库里同时躺着两代实现,像一层地质剖面。
2025 年版(chatCompressionService.ts):阈值 0.5 触发,保留最新 30% 历史。切点函数 findCompressSplitPoint 只在真实 user message 边界落刀(role === 'user' 且不含 functionResponse),永不切半轮——和 #4667 修复后首分支共享同一条哲学:边界感知。更精彩的是 Reverse Token Budget:从最新往最旧扫,最近轮次的工具输出全量保留,累计超过 50,000 token 预算后,更老的大输出截断为最后 30 行、全文存临时文件换一个引用。新鲜度分级——离当前 turn 越近的信息保真度越高。
2026 年版(context/processors/rollingSummaryProcessor.ts):上下文变成图结构后,压缩改为滚动摘要节点。三种策略(incremental / freeNTokens / max)决定每轮折叠多少,摘要节点的 ID 由被消费节点 ID 派生(deriveStableId(consumedIds))——确定性派生,同一输入同一 ID,重放安全。
Claude Code(Anthropic):文档确认的最小面
官方成本文档(2026-08 抓取)确认了 auto-compact window 的存在:"the threshold where Claude Code summarizes older history to free space",配套建议是"不相关任务之间清理会话"。细节未开源,但方向与业界一致:阈值触发 + 摘要替换旧历史 + prompt caching 省成本。值得注意的是文档把"长会话不清理"列为高花费的头号原因——压缩是兜底,不是免费午餐。
Hermes Agent(Nous Research):双层压缩 + 机械锚
Hermes 的 context_compressor.py(本地 checkout v0.20.0)是"确定性优先"路线的极端样本(它的跨会话记忆召回机制我在早前的源码走读里拆过,本文聚焦压缩层):
- 微压缩与批量压缩分层,用不同 metadata key 标记(
MICRO_COMPACT_MARKER_KEY),注释里写明为什么必须区分:批量标记的内容不包含在滚动摘要里,误删即毁历史。 - 图像锚点:锚定最后一条带图 user message,更早消息里的图像替换为占位符——"anchor 是最后一条"again,和 #4667 修复、Codex 插入点用的是同一个方向性判断。
- 工具输出先剪枝再进 LLM 摘要(cheap pre-pass),摘要用辅助小模型。
- 最说明态度的是仓库 AGENTS.md 里的原则:"Per-conversation prompt caching is sacred……唯一的例外是上下文压缩。" 压缩被当作受控的、谨慎的例外,而不是随手可用的工具。配套测试覆盖 busy-retry(不可摘要的交换不空转)、locks、trajectory 压缩。
Letta:根本不压缩对话
Letta(MemGPT 血统)走了另一条路:把记忆外置出上下文窗口。它的 Context Constitution(github.com/letta-ai/context-constitution)定义"什么信息、什么顺序、什么粒度、留存多久"进入上下文;对话之外的 MemFS 是 git 版本化的记忆文件系统,sleep-time 子代理在后台整理记忆。压缩问题被转化为检索问题——代价是召回链路变长,收益是从根本上不碰历史重排这类坑。
| 系统 | 动态上下文注入位置 | 压缩切点 | 摘要产物 | 重排风险防御 |
|---|---|---|---|---|
| deer-flow | ID-swap 挂 user message | —(middleware 不压缩) | — | 修复后从尾扫;测试穿过真实 reducer |
| Codex | 类型化枚举:插入最后真实 user message 前 / 不注入 | 窗口边界 | 交接班摘要 + 窗口编号 | 编译期类型 + 反向扫描优先级链 |
| Gemini CLI | 压缩时保留最新 30% | 只在 user turn 边界 | LLM 摘要(两代:阈值/滚动) | 边界感知切点 + 新鲜度分级预算 |
| Claude Code | 未公开 | auto-compact 阈值 | 摘要替换旧历史 | prompt caching + 会话卫生建议 |
| Hermes | 图像锚点 = 最后带图消息 | 微压缩(工具输出)+ 批量(中段轮次) | 辅助模型摘要,产物带持久化标记 | 双层标记防误删;byte 稳定性优先 |
| Letta | 不注入历史,记忆外置 | 不压缩对话 | MemFS + sleep-time 整理 | 问题转化为检索,绕开重排 |
我的判断:这六家在"注入位置"上收敛到了同一条规则——锚定最新的 user message 或干脆不碰历史——差别只在用什么强度表达它:deer-flow 用一行 reversed,Codex 用类型系统,Gemini 用边界感知切点,Hermes 用锚点+分层标记,Letta 用架构性规避。表达强度越高,bug 越难复发。
四、生产系统实战:三种注入形态与一次"去压缩"重构
横评之外,补一个生产视角。我们团队维护一个节点画布式工作流 Copilot(视频创作方向,LangGraph 风格的多轮 agent),近期刚审查过全部上下文注入路径——#4667 合并当天我逐条核对了我们的代码,结论是这一类 bug 在我们的架构里结构性不存在,原因值得展开:
形态一:状态型上下文每轮从数据库重建。 画布的节点/边索引(类似文件树、日期之于通用 agent)不落对话历史,而是每轮在系统提示里从 DB 确认的最新快照重建。不存在"扫历史找挂载点"这个动作——动态状态根本不进历史,重排无从谈起。这比"从尾扫 attach 到最新"更彻底:连 attach 点这个概念都消掉了。快照读失败时还有降级头声明"以下可能是过时状态,禁止据此判断节点缺失"——防的是另一个方向的坑(模型把陈旧空画布当重建许可)。
形态二:事件型上下文创建时挂载。 用户消息的附件、@提及等上下文,在消息写入时就序列化进该消息的信封(envelope)。挂载点天然是最新条,因为消息本身就是新的。回放(replay)时信封被剥掉,只回正文——历史消息永远不被二次加工。
形态三:修正型上下文 in-place 更新。 唯一需要回写历史的地方(附件元数据的迟到补全),走数据库行的原位更新——同 ID 同位置改内容,不做任何消息 ID 的改写或追加。这是"改历史"的安全形态:内容变,结构不变。
压缩侧我们做过一次反向的决策:删掉每轮 LLM 生成的四段式 turn summary。它的问题跟 #4667 同源——两份记录(用户看到的回复 vs 下一轮读到的摘要)可以在无人察觉的情况下漂移,而且永远只有单向损耗。重构后"reply 即记录":模型每轮的回复既是用户读到的内容、也是后续轮次回放的全部依据,一份字符串不可能和自己漂移。留下的唯一压缩器是阈值触发的折叠(fold):切点从目标位置向后走到最近一轮的边界(不切半轮),折叠产物是确定性的逐轮拼接(用户消息截 300 字符、执行记录截 600 字符)——刻意不调 LLM,因为确定性拼接让压缩产物 byte 稳定,prompt cache 压缩后立即恢复命中。仓库注释里写着这条决策的分界线:"真需要更短的那天,才是 LLM 调用挣得席位的那天。"
五、六条不变量
把以上全部收编成可迁移的规则,按防御强度排序:
- 动态状态不落历史。 画布状态、文件树、日期这类"当前状态"信息,每轮从 source of truth 重建(系统提示级),而不是注入到某条历史消息里。挂载点问题被架构性消灭。
- 必须改历史时,in-place,永不改 ID。 同 ID 原位替换或数据库行更新是安全的;任何"旧消息换新 ID 重发"(ID-swap)都在借 reducer 之手做隐式重排。要用 ID-swap(为了 cache 前缀),就必须接受"挂载点只能是最新的 user message"这个约束。
- 扫描一律从尾开始。 需要在历史里找挂载点/锚点/插入点时,反向遍历锚定最新——deer-flow 修复、Codex 插入函数、Hermes 图像锚点、我们的折叠切点,全部同向。从头扫唯一的合法场景是"确认历史里没有任何 X"。
- 压缩切点必须落在轮次边界。 永远不切半轮:Gemini 的 user-boundary 切点、Codex 的窗口、我们向后回退到 turn 边界,同一纪律。
- 压缩产物持久化一次,回放复用存量字节。 LLM 摘要如果在每次重载时重新生成,等于每个进程重排一次前缀,cache 全毁。摘要落库,replay 读存量。确定性拼接比 LLM 摘要多一层 byte 稳定性。
- 测试必须穿过真实 reducer。 断言注入/压缩在完整管线折叠后的净效果(消息顺序、当前问题仍在末尾、事实召回),而不是中间产物的形状。旧 deer-flow 测试绿了三年,测的只是形状。
六、决策矩阵:什么时候用什么
- 注入"状态"(画布、文件系统、日期、权限)→ 每轮从 DB 重建,进系统提示。别碰历史。
- 注入"事件"(附件、提及、一次性补充)→ 创建时挂载到正在写入的消息。天然最新。
- 修正历史数据(元数据迟到、字段补全)→ in-place 行更新。内容变,结构不变。
- 历史超限,会话内仍有连续性需求 → 阈值触发的边界折叠,确定性拼接优先;召回率量化证明不够短之后,才升级到 LLM 摘要(Codex 的交接班四要点是现成的提示词模板),产物持久化。
- 轮中超限(工具循环把窗口打满)→ Codex 式 BeforeLastUserMessage 注入,或 Gemini 式新鲜度分级预算(近端全保、远端截断+外存引用)。
- 跨会话连续性 → 记忆外置(Letta 式 MemFS / 检索层),把压缩问题转化为检索问题。
结论
#4667 的直接教训是"fallback 注入要从尾扫",但它真正揭示的是三件事:改历史消息的 ID 等于隐式重排,这是所有按 ID 合并消息的运行时(LangGraph 只是其一)共享的语义地雷;降级路径会制造"不可能状态",让正常流程下安全的假设("首注入=首对话")崩塌;只测中间产物形状的测试会替 bug 站岗。六大系统的横评显示业界已在注入位置上收敛(锚定最新或不碰历史),分歧在表达强度——一行 reversed、一个 enum、一层切点函数、一套分层标记,或者干脆换架构。如果你的 agent 项目里有一段"找第一条 user message 然后挂点什么"的代码,现在是去 grep 它的时候了。
参考资料
- Baldwinzc — fix(middleware): target the latest user message on first-turn fallback injection, deer-flow PR #4667(2026-08-16 合并,milestone 2.1.0)
- LangChain — langgraph/graph/message.py add_messages reducer(2026-08 主干)
- OpenAI — codex-rs/core/src/compact.rs 及 prompts/templates/compact/prompt.md(2026-08 主干)
- Google — gemini-cli chatCompressionService.ts(© 2025)与 rollingSummaryProcessor.ts(© 2026)
- Anthropic — Claude Code 成本文档(auto-compact window)(2026-08-17 抓取)
- Nous Research — hermes-agent agent/context_compressor.py(本地 checkout v0.20.0,2026-08-17)
- Letta — Context Constitution 与 Letta 文档(2026-08-17 抓取)