1. AI Agent 长跑必踩的坑:上下文压缩导致信息流失的根因分析与解法
2. 【摘要】
一次 64 分钟的 Codex 会话中,自动压缩在回合中途触发,吞掉了本该交付的结果,模型还把恢复当成新一轮继续干活。我从源码定位机制,用 SessionStart / PreCompact 两道 Hook 加规则改写做了三层防护,附可照抄的实现。
3. 【正文】
Codex 上下文压缩吞掉我一整轮结果后,我用 3 层防护解决了它
引言:我等了 64 分钟,却什么都没拿到
先讲那次事故。
我用 Codex 推进一个 POC 项目的规则迭代,指令很简单:「根据会话 ID 获取最新上下文,继续推进」。然后我等了 64 分钟。
中途它一直在给阶段说明,最后一条是:
文档、进度和 changelog 已落盘。现在做最后一次可复核性检查......完成后我会直接给出结果、阻塞和产物路径,不再启动新的本地任务。
看到「不再启动新的本地任务」,我准备收结果了。结果它又调了一次工具。紧接着,会话触发自动压缩。压缩之后它第一句话是:
我会先按项目约定恢复现场:读取根目录 AGENTS.md、progress.md、今天的变更日志......
然后它按项目规则里的《每轮开始时的上下文恢复顺序》,把这次压缩恢复当成了新一轮,又干了 40 分钟。我真正拿到最终答复,是在会话结束前 1 秒。
复盘数据:单个 turn 持续 64 分钟,385 次工具调用,2 次自动压缩,3 条用户消息,最终答复只有 1 次。
这不是模型"忘了",而是机制使然。
一、压缩为什么必然丢信息
1.1 压缩发生在回合中途,且压缩后强制续跑
我在 Codex 的开源实现里找到了触发点(codex-rs/core/src/session/turn.rs,约 467 行):
rust
let total_usage_tokens = sess.get_total_token_usage().await;
let token_limit_reached = total_usage_tokens >= auto_compact_limit;
// 注释原文:只要压缩能把 token 降下来,就不用担心中间无限循环
if token_limit_reached && needs_follow_up {
run_auto_compact(/* ... */ CompactionPhase::MidTurn /* ... */).await?;
continue; // ← 不结束回合,继续同一 turn
}
三点结论:
- 触发条件是
token 超阈值 && needs_follow_up------只要模型还想继续(还有工具要调),就会压缩; - 压缩后
continue,回合不结束,用户拿不到交付; - 源码注释直言「不必担心中间无限循环」------「一轮跑很久」是设计预期,不是 bug。
所以「压缩前把结果说给用户」无法靠模型自觉保证:压缩时点在采样边界,模型没有"最后说一句"的机会窗。这也解释了为什么它说了「不再启动新的本地任务」,却还是调了工具------那次调用让 needs_follow_up 变成 true。
1.2 压缩后会完整重放 AGENTS.md
我统计会话记录(rollout JSONL)里的 world_state 事件,发现它只出现 3 次 :会话开始、第 1 次压缩后、第 2 次压缩后。每次都是 "full": true,内含 AGENTS.md 全文。
这才是问题的放大器:上下文被压成摘要后,AGENTS.md 又被完整灌一遍。模型看到的是「一份全新注入的 AGENTS.md + 一段摘要」,最自然的解读就是「新一轮开始了」,于是执行《每轮开始时的上下文恢复顺序》,读状态文件,继续开工。
我更新的 progress.md、changelog 在这一刻,从"已交付的证据"变成了"继续干活的燃料"。
1.3 "察觉阈值"这类规则物理上不可执行
我最初的对策是在 AGENTS.md 里写:
只要察觉上下文占用接近上限(即将触发压缩)时,先收尾当前步骤并把任务状态写入文件。
这条规则从未生效,因为它要求模型"察觉"一个它根本看不到的指标。整场会话里,没有任何工具向模型暴露过 token 用量。软约束 + 不可观测指标 = 必然失效。
1.4 隐藏因素:判定量不等于你以为的那个数
按公式,默认阈值 = 上下文窗口 × 90%(codex-rs/protocol/src/openai_models.rs)。我的窗口 258,400,名义阈值约 232,560,实际却在 244,844 触发;另一个会话里名义阈值 244,800,实际 193,446 就触发了。
原因是判定量的构成(codex-rs/core/src/context_manager/history.rs):
rust
total = last_token_usage.total_tokens
+ get_non_last_reasoning_items_tokens() // 客户端自行估算
+ items_after_last_model_generated_item() 估算
我用的 provider 的 server_reasoning_included = false,reasoning 需客户端估算;而我开着 reasoning_summary = detailed + reasoning_effort = high,压缩前 reasoning 负载粗估约 9 万 token。名义 last 没到阈值,判定值已经越线。
(个人观察:不同 provider / 模型表现可能不同,建议用自己 rollout 里的 token_count 事件核对。)
二、方案全景:三层防护,各防一类流失
思路很直接:不要再依赖模型"自觉",把每一层交给确定性机制。
| 层 | 组件 | 时机 | 防哪一类流失 |
|---|---|---|---|
| 认知层 | AGENTS.md 规则重写 |
常驻 | 防"把压缩恢复当新一轮"的语义误判 |
| 行为层 | SessionStart Hook(source == "compact") |
压缩后 | 防"上一轮没交付就开新工" |
| 证据层 | PreCompact Hook |
压缩前 | 防"现场状态只存在于上下文里" |
三层互补而非替代:认知层管判断 ,行为层管动作 ,证据层管记忆。
关键的能力差异,是我读了 Codex 二进制里内嵌的 JSON Schema 才确认的:
| Hook 事件 | 能否向模型注入上下文 |
|---|---|
SessionStart |
✅ 支持 hookSpecificOutput.additionalContext |
PreToolUse / PostToolUse |
✅ 同上 |
PreCompact / PostCompact |
❌ 输出仅 continue/stopReason/suppressOutput/systemMessage |
想让压缩后的模型"看到"强制协议,唯一正确的抓手是 SessionStart ------它的 source 枚举里包含 "compact":
json
"source": { "enum": ["startup", "resume", "clear", "compact"], "type": "string" }
而 PreCompact 只能落盘。我一开始以为两者都能注入,这是第一个认知修正。
三、关键实现
3.1 行为层:压缩恢复守卫
matcher 匹配的是 source 字段(session_start.rs 里传入 Some(request.source)),所以直接写 "compact":
json
{
"hooks": {
"SessionStart": [{
"matcher": "compact",
"hooks": [{
"type": "command",
"command": "python3 -X utf8 .codex/hooks/compact-recovery-guard.py",
"timeout": 10
}]
}]
}
}
脚本只做一件事:读 stdin,source == "compact" 就吐注入文本,否则静默退出。核心要求是任何情况下都不阻塞会话 ,所以所有异常都吞掉并 exit 0。
注入文本(实际使用的原文):
text
[压缩恢复强制协议 · 由 SessionStart(source=compact) hook 注入]
刚刚发生了上下文自动压缩,且压缩发生在上一轮的【回合中途】。请按以下事实行动:
1) 上一轮的用户请求很可能【尚未交付最终答复】就被压缩打断了。
2) 本次恢复【不是新一轮的开始】。禁止把这次恢复当作"每轮开始",
禁止据此执行《每轮开始时的上下文恢复顺序》后自动推进新工作。
3) 压缩摘要里的"状态已落盘/待完成项"是【上一轮的收尾证据】,
不是本次恢复的开工指令。
恢复后的第一动作,按顺序二选一:
A. 摘要或状态文件显示上一轮已具备可交付结论(验证已跑完、产物已生成):
→ 立即输出上一轮的最终答复:已完成事项、仍在运行事项、当前阻塞、
预计剩余时间、可复核产物的绝对路径;然后【结束本轮】。
→ 在补完该答复之前,除"读取状态文件"这类只读操作外,
【禁止发起任何工具调用】。
B. 上一轮确实尚未形成结论:
→ 先输出一段"中断说明":压缩发生在哪一步、已完成什么、下一步本要做什么;
→ 然后【等待用户确认】,不要自动续跑。
硬性禁止:
- 禁止在补出上一轮答复之前开始新一轮工作;
- 禁止把 progress.md 的"下一步"当作本次恢复自动开工的理由;
- 禁止在单个用户回合内连续推进多个阶段:每完成一个可验证阶段就输出结果并停止,
把"是否继续"交还给用户。
为什么这样写有效:它把"模糊判断"变成"二选一的确定性分支"。模型不用自己想"现在该干什么",只需判断上一轮有没有结论,然后选 A 或 B。
另一个要点:这段注入以 developer 角色进入上下文,而不是普通 user 消息------在模型看来属于系统级指令,权重更高。
3.2 证据层:压缩前落盘快照
PreCompact 不能注入,但能在压缩前确定性地执行脚本,这正好落实"压缩前落盘",且不依赖模型自觉。
采集内容按"断线后能不能接回去"设计:
- hook 传入的
trigger(auto/manual)、session_id、turn_id、model; progress.md的运行字段白名单(当前状态/阶段/心跳/下一检查点),不整文件复制;git status --porcelain摘要(非 git 仓库则标注);- 时间预算内扫描到的"新鲜改动文件"。
json
"PreCompact": [{
"matcher": "",
"hooks": [{
"type": "command",
"command": "python3 -X utf8 .codex/hooks/pre-compact-snapshot.py",
"timeout": 15
}]
}]
matcher 留空表示 auto 与 manual 都触发。脚本整体套了 4 秒时间预算,超预算就用已有结果落盘------压缩是关键路径,hook 绝不能拖慢它。
3.3 认知层:把 AGENTS.md 里做不到的规则删掉
这层最容易被忽略,但它是前两层能生效的前提。我改了三处:
① 明确"事实前提",删掉不可执行条款
markdown
## 事实前提(不要与之对抗)
- 压缩由 Codex 客户端在回合中途自动触发,你无法预知、无法拒绝,
也看不到 token 计数。「察觉上下文接近上限再收尾」这类要求
对你不可执行,不要假装执行它。
- 压缩后 Codex 会完整重放本文件,但这不代表新一轮开始。
② 新增"交付优先"
markdown
1. 每完成一个可验证阶段,立即输出该阶段结果,然后停止本轮,
把「是否继续」交还给用户。禁止在单个用户回合内连续推进多个阶段。
2. 一旦你输出「最后检查 / 收尾 / 完成后给出结果」这类话,
就不得再发起新的工具调用。
第 2 条直接对应那次事故。
③ 把监控维度从"耗时"改成"体积"
原来的长任务规则盯的是命令耗时,但我实测这场会话 332 次命令的墙钟总计只有 14.8 秒,最大 2.0 秒 ------那套规则全程空转。真正撑爆上下文的是调用次数 × 单条输出体积 (135 万字符)。所以我补了一条模型能自己数清的指标:
markdown
- 若本轮工具调用次数已明显偏多(例如超过 60 次),或多次出现超长输出,
应主动收尾当前阶段并交付,不要在未交付的情况下继续堆叠调用。
四、效果验证
第一次验证 (hook 上线后的新会话):压缩后模型收到 632 字节的 developer 注入,输出是------
发生了上下文自动压缩,上一轮在「准备基于当前平台状态定向复跑某个样本」这一步被中断......仍在运行的事项:无......当前仅因上下文压缩保护规则暂停,等待你确认继续。
结尾:「请回复「继续」,我将只执行该阶段的复跑与对比,完成该阶段后立即交付并停止。」注入后 18 秒回合正常结束,用户 1 分 24 秒后回「继续」,它才继续。
对比修复前:同样是压缩后,模型说的是「我会先按项目约定恢复现场......」,然后自动续跑 40 分钟。
第二次验证(含 PreCompact 的完整链路):一个会话里发生 2 次压缩。
- 第 1 次:快照 14:05:18,
compacted14:06:29 ------ 快照早 71 秒;模型走分支 B,输出中断说明,27 秒后结束回合; - 第 2 次:快照 14:35:29,
compacted14:36:09 ------ 早 40 秒;模型走分支 A,输出该轮结论,30 秒后结束回合。
那 40~70 秒的时间差正是 Codex 生成压缩摘要的耗时,说明 PreCompact 确实在压缩流程真正开始时 触发。累积到验证时,快照目录已有 8 份,覆盖 3 个会话 8 次压缩。
五、踩坑复盘
坑 1:以为 AGENTS.md 写了就会生效 现象:写了"察觉阈值先落盘",从未生效。原因:模型看不到 token 计数,"每轮"在压缩恢复时语义歧义。解决:删掉不可执行条款,交给 Hook 做机器保证。
坑 2:以为 PreCompact 也能注入上下文 现象:想让 PreCompact 直接塞"先交付"的指令。原因:读 JSON Schema 才发现它的输出没有 additionalContext 。解决:注入交给 SessionStart(source=compact),PreCompact 只落盘,两者分工。
坑 3:hook 配了但根本没跑 现象:hooks.json 写好了,会话里毫无效果。原因:Codex 对每个 hook 有一次性信任机制 ,批准结果记在 ~/.codex/config.toml 的 [hooks.state] 里(key 形如 路径:事件:组索引:hook索引 + trusted_hash),没走审批的 hook 处于"已配置但零执行"。解决:在 TUI 里执行 /hooks 逐条信任。判断是否生效,直接看 [hooks.state] 有没有你的条目,比翻日志快。
坑 4:落盘脚本自己泄露了业务信息 现象:首版把"最近 30 分钟改动的文件"逐条列全路径,真实项目里试跑后发现快照含 6 处业务标识(业务数据文件名里带客户名和订单号)。原因:只考虑"信息有没有用",没考虑"落盘安不安全"。解决:改两级策略------业务数据目录只记聚合计数(浅层目录 + 文件数 + 最近时间,不列文件名),只有工程与状态文件才记完整路径。改完全目录复查,敏感命中数为 0。
坑 5:以为工具输出越大越完整越好 现象:140 条工具输出产生 86.8 万字符,其中 45 条超过 8000 字符的输出占了 84% 的字符量。原因:少数超大输出主导膨胀,而非调用次数(中位数只有 1,865 字符)。解决:默认单条预算是 10000 token,收紧到 6000,只影响超长输出。
六、一个我没做的优化,和一个你会踩的坑
没做的优化 :模型的 max_context_window(872,000)远大于实际使用的 context_window(272,000),而压缩阈值按后者计算。理论上调大 context_window 就能大幅降低压缩频率。但我没动------如果 provider 的实际限制没那么大,配错会导致请求直接被拒。收益大风险也大,需先确认 provider 的真实限制。
你会踩的坑 :model_auto_compact_token_limit 只能调低,不能调高:
rust
min(config_limit, 上下文窗口 × 9 / 10)
别指望用它降低压缩频率。
另外提醒 :Codex 有个 token_budget 配置(reminder_threshold_tokens 等)看名字很像"压缩提醒",但它依赖 notes 工具与 functions.new_context,属于另一套"存笔记 + 开新窗口"的机制。我核对过自己的会话,这两个工具的调用数都是 0------在第三方 provider 场景下不生效,配了是无效项。
七、总结
这次实践最大的收获不是某个配置,而是一个判断标准:
凡是依赖模型"察觉"某个它看不到的指标(token 数、剩余上下文、运行时长)的规则,都不可靠。 要么把它变成模型能自己观测的量(如"调用次数超过 60 次"),要么交给外部机制做确定性保证。
三层防护的分工:
- 认知层(AGENTS.md):告诉模型压缩恢复不等于新一轮;
- 行为层(SessionStart Hook):强制先交付、再问是否继续;
- 证据层(PreCompact Hook):压缩前把现场落到磁盘,不依赖记忆。
每层代码量都不大,但组合起来能把"被压缩吞掉一整轮"这类事故的概率大幅压低。
如果你的 Agent 也经常长跑、频繁压缩,建议按这个顺序落地:先做行为层 (收益最直接),再做证据层 (兜底),最后回头清理规则层里那些做不到的条款------这一步最容易被跳过,但它决定了前两层能不能被模型正确理解。
4. 【标签】
Codex、AI Agent、上下文压缩、提示词工程、Hooks、LLM、工程实践、Claude Code