AI Agent 长跑必踩的坑:上下文压缩导致信息流失的根因分析与解法

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 不能注入,但能在压缩前确定性地执行脚本,这正好落实"压缩前落盘",且不依赖模型自觉。

采集内容按"断线后能不能接回去"设计:

  1. hook 传入的 trigger(auto/manual)、session_id、turn_id、model;
  2. progress.md 的运行字段白名单(当前状态/阶段/心跳/下一检查点),不整文件复制;
  3. git status --porcelain 摘要(非 git 仓库则标注);
  4. 时间预算内扫描到的"新鲜改动文件"。
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,compacted 14:06:29 ------ 快照早 71 秒;模型走分支 B,输出中断说明,27 秒后结束回合;
  • 第 2 次:快照 14:35:29,compacted 14: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


相关推荐
qinglongcn5 小时前
Agent 可靠性的数学——为什么编程是第一个跑通的赛道
agent·ai编程
用户3126874877205 小时前
Gemini 4 Argon 技术拆解:100 万输出 token 的"长活"模型,谷歌押注的三个战场
agent
.Bryce.5 小时前
在智能体中使用A2A工具:让大模型自己决定“该找谁帮忙“
android·java·服务器·redis·后端·agent·a2a
崎岖Qiu5 小时前
MCP 的架构、协议分层与通信过程
后端·ai·架构·agent·mcp
Percy_kk5 小时前
AI Agent 开发实践:从手写 ReAct 到框架选型
agent·设计
方方洛6 小时前
ai-agent教程-07-从零实现一个工程化AI-Agent
人工智能·llm·agent
方方洛6 小时前
ai-agent教程-08-多智能体协作
人工智能·llm·agent
simon_1236 小时前
它验证过才删的:agent 的 dry-run 是干净的,线上文件还是没了
agent
BreezeJiang7 小时前
RAG 答不准,先别急着换模型:给检索管道加上路由、拆解和循环检索
agent