AI编码代理上下文压缩后为何断片?430条记录实测long_mem与engram
1. 为什么"压缩完就断片"是编码代理绕不开的坎
做过 AI 编码代理的人,大概率都经历过这个场景:一个任务跑到一半,上下文窗口快满了,系统触发压缩,把前面几十轮对话揉成一段摘要,然后继续往下跑。结果代理突然像换了个人——之前定好的变量命名规则忘了,刚确认过的接口签名记错了,甚至连"我们正在改哪个文件"都搞混了。这不是模型变笨了,而是压缩过程丢掉了对当前任务真正关键的那部分状态。
我这次做的事情,就是拿一个真实的编码代理,在 10 天里跑了 430 条公开可查的记录,专门盯着"上下文压缩之后,代理还能不能接得上"这个问题。所谓"接得上",我给它下了个可操作的定义:压缩发生后,代理在接下来 3 轮内,能否在不重新询问用户的前提下,继续沿用压缩前已经确立的任务约束、文件路径、命名约定和未完成的子目标。这个定义很土,但它可测。
先把几个核心概念摆清楚,不然后面没法聊。上下文压缩(context compression)指的是当对话历史逼近模型上下文上限时,用某种策略把旧内容替换成更短的表示,腾出空间给新内容。AI 编码代理(coding agent)是一类能读写文件、执行命令、多轮自主推进任务的程序,它和普通聊天机器人的最大区别是:它有"工作现场",而工作现场的状态必须跨轮次保持。long_mem和engram是我在实验里对比的两种记忆保持思路——前者偏向把关键信息外置成可检索的长期记忆,后者偏向在压缩时保留一份"记忆痕迹"式的结构化摘要。至于Codex,它是这类代理里被讨论最多的一个代表,很多人的第一课就是拿它跑通一个多文件重构任务。
为什么这个问题值得单独拿出来做实验?因为绝大多数关于编码代理的评测,都是在"上下文充裕"的理想条件下跑的。真实使用中,长任务必然触发压缩,而压缩恰恰是最容易出问题的环节。你去看那些社区里高频出现的报错和困惑——代理重复问同一个问题、改错文件、把已经删掉的函数又加回来——相当一部分根因都能追到压缩后的状态丢失上。所以这不是一个边角问题,而是决定代理能不能真正用于长任务的核心问题。
这篇内容适合三类人看:正在自己搭编码代理的开发者、重度使用编码代理做重构和迁移的工程师、以及想理解"记忆"这件事在代理系统里到底怎么落地的人。我会把实验设计、两种记忆思路的对比、430 条记录里暴露出的典型失败模式,以及我自己踩过的坑,全部摊开讲。不吹不黑,数据说话。
2. 实验怎么设计的:430 条记录不是随便凑的数
2.1 任务集的挑选逻辑
我没有随便找 430 个任务来凑数,而是按"压缩必然发生"这个前提来筛。具体标准有三条:第一,任务需要至少 15 轮工具调用才能完成,保证上下文会涨到触发压缩;第二,任务涉及至少 3 个文件的协同修改,这样状态丢失的后果才明显;第三,任务有客观的完成判据,比如测试通过、编译成功、diff 符合预期,避免"感觉做完了"这种主观判断。
最终任务集覆盖了四类典型场景:跨文件重命名(把某个模块的命名风格统一)、接口迁移(把旧 API 调用替换成新 API)、bug 修复(需要先定位再改)、以及小规模功能新增。这四类的压缩压力依次递增,因为功能新增往往需要保留最多的设计决策上下文。
2.2 压缩策略的对照组设置
实验的核心是对比不同"压缩后如何接续"的策略。我设了三组:
- 裸压缩组:直接用通用的摘要压缩,把旧对话揉成一段自然语言摘要,不做任何额外处理。这是最接近默认行为的一组。
- long_mem 组:在压缩触发时,把任务约束、文件清单、命名约定、未完成子目标这几类信息抽取出来,写进一个外置的记忆存储,后续每轮按需检索注入。
- engram 组:不额外存储,而是在压缩摘要里强制保留一份结构化的"记忆痕迹",包括关键实体、已确认决策、当前进度指针,格式固定,不随摘要风格漂移。
三组跑同样的任务集,每组每个任务重复跑,最终累计 430 条有效记录。之所以强调"有效",是因为有些记录因为环境问题(依赖装不上、网络超时)被剔除了,这部分我单独做了标记,不混进结论里。
2.3 怎么判定"接得上"
判定标准我前面提过,这里细化一下。压缩发生后,我记录接下来 3 轮里代理的行为,按下面这个表打分:
| 判定项 | 通过条件 | 权重 |
|---|---|---|
| 任务约束保持 | 未违反压缩前确立的约束(如命名风格、禁用某 API) | 高 |
| 文件路径正确 | 操作的文件与压缩前的工作现场一致 | 高 |
| 命名约定一致 | 新增代码沿用既有命名,不引入冲突 | 中 |
| 子目标连续 | 继续推进未完成的子目标,不重复已完成步骤 | 高 |
| 无重复询问 | 不重新询问压缩前已确认的信息 | 中 |
五项里高权重项任意一项失败,这条记录就判为"接不上"。这个判定偏严格,但我认为对编码任务来说合理——改错文件这种事,一次就够让人崩溃了。
提示:判定标准一定要在实验开始前定死,不能边跑边调。我第一版实验就是因为中途放宽了"子目标连续"的判定,导致数据没法横向对比,白白浪费了两天。
3. 裸压缩为什么会断片:三个反复出现的失败模式
3.1 摘要的"平均化"倾向吃掉了关键细节
通用摘要模型有个天然倾向:它追求的是"读起来通顺、覆盖主要信息",而不是"保留对下一步操作最关键的信息"。这两者经常冲突。比如前面 20 轮里,用户随口说了一句"这个项目用 snake_case,别用 camelCase",摘要模型很可能把它当成次要信息压缩掉,因为它只出现了一次、篇幅又短。但对编码代理来说,这条约束是硬性的,违反了就是错。
我在记录里统计过,裸压缩组里因为"约束丢失"导致的失败占了将近四成。更麻烦的是,这类失败往往不是立刻暴露,而是代理默默用错风格改了五六个文件之后,你 review 的时候才发现。这种延迟暴露让排查成本陡增。
3.2 工作现场指针丢失导致"改错地方"
编码代理的工作现场包括:当前在哪个文件、光标附近是什么代码、上一步执行了什么命令、命令输出是什么。压缩时,这些"指针"信息最容易被当成冗余丢掉,因为它们看起来只是过程性内容。但恰恰是这些指针,决定了代理下一步该动哪里。
我遇到过最典型的一次:代理在压缩前已经确认要修改src/parser/tokenizer.py,压缩后它把改动打到了src/parser/lexer.py上——两个文件名字相近,功能相关,摘要里只留了"修改 parser 模块"这种模糊表述,代理就自己"合理推测"了一个文件。这种错误在裸压缩组里出现了十几次。
3.3 已完成步骤被重复执行
这个失败模式最隐蔽,因为它不一定报错。代理压缩后忘了某个子步骤已经做完,又做了一遍。轻则浪费时间,重则产生副作用——比如重复插入一条数据库迁移、重复添加一个已经存在的依赖。我在记录里看到过一次代理把同一个函数重命名了两遍,第二遍因为找不到原名而报错,然后它开始"修复"这个自己制造的错误,越修越乱。
这三个模式的共同根因是:通用摘要优化的是"人类可读性",而编码代理需要的是"状态可续性"。这两者的目标函数根本不一样。你让一个为前者设计的压缩器去服务后者,断片是必然的。
4. long_mem 与 engram:两种"接得上"的思路实测
4.1 long_mem 的外置记忆怎么落地
long_mem 的核心思想是:别指望压缩摘要能保住所有关键状态,干脆把状态抽出来单独存。具体做法是在压缩触发的那一刻,用一个抽取步骤把四类信息结构化:
- 任务约束:从历史里识别出的硬性规则,比如命名风格、禁用项、必须通过的测试。
- 文件清单:当前任务涉及的所有文件路径,以及每个文件的角色(待改/已改/只读)。
- 命名约定:已确立的标识符命名,避免新增代码冲突。
- 未完成子目标:还没做完的步骤,按优先级排序。
这些信息写进一个键值存储,后续每轮在构造 prompt 时按相关性检索注入。实测下来,long_mem 组在"任务约束保持"和"文件路径正确"这两项上的通过率明显高于裸压缩组。原因很直接:约束和路径不再依赖摘要模型的"心情",而是被显式保存了。
但 long_mem 不是没有代价。它引入了一个额外的检索环节,每轮都要判断"这次该注入哪些记忆"。检索不准的时候,要么注入了无关信息浪费上下文,要么漏掉了关键信息。我在记录里看到过检索把三天前另一个任务的约束也捞出来的情况,导致代理行为混乱。所以 long_mem 的成败,很大程度取决于检索的相关性判断。
4.2 engram 的结构化痕迹思路
engram 走的是另一条路:不搞外置存储,而是在压缩摘要里强制保留一份固定格式的"痕迹"。这份痕迹不追求自然语言通顺,而是像一张状态卡片:
[任务约束] snake_case; 禁用 requests 库 [文件清单] tokenizer.py(待改) lexer.py(只读) test_parser.py(待改) [命名约定] parse_token, TokenStream [进度] 已完成 tokenizer 重命名; 待完成 lexer 适配这份卡片被硬编码进摘要的开头,不参与摘要模型的自由发挥。好处是稳定——不管摘要模型怎么变,这张卡片的内容是确定的。坏处是它占用了固定的上下文预算,而且卡片本身也需要维护,如果抽取环节出错,错误会被固化下来。
实测中,engram 组在"子目标连续"这一项上表现最好,因为进度指针被显式保留了。但在任务特别复杂、约束特别多的时候,卡片会变得很长,挤压了正常对话的空间。我记录里最长的一张卡片占了将近 800 token,这在上下文紧张时是笔不小的开销。
4.3 两组数据的横向对比
把三组的关键指标拉平了看:
| 指标 | 裸压缩组 | long_mem 组 | engram 组 |
|---|---|---|---|
| 任务约束保持通过率 | 约 61% | 约 88% | 约 84% |
| 文件路径正确率 | 约 67% | 约 91% | 约 86% |
| 子目标连续通过率 | 约 58% | 约 82% | 约 90% |
| 无重复询问通过率 | 约 70% | 约 85% | 约 83% |
| 平均额外 token 开销 | 0 | 中 | 中高 |
这组数字不是要证明哪个方案"最好",而是说明一个事实:只要你在压缩时对状态做任何显式的保留,接续能力就会有肉眼可见的提升。裸压缩组和另外两组的差距,不是几个百分点,而是二十几个百分点,这个量级足以决定一个长任务能不能跑完。
long_mem 和 engram 各有侧重:前者强在约束和路径的保持,后者强在进度的连续。实际用的时候,我倾向于把两者结合——用 long_mem 存约束和文件清单,用 engram 的卡片保进度指针。这个组合我在后半程实验里试过,接续失败率进一步下降,但复杂度也上去了,需要权衡。
5. 430 条记录里最值得说的几个真实案例
5.1 一次跨文件重命名的完整接续过程
有个任务是统一一个模块的命名风格,涉及 7 个文件。任务跑到第 18 轮触发压缩,此时已完成 3 个文件的重命名。裸压缩组在这里翻车了:压缩后代理从第 4 个文件开始,但用的还是旧的命名风格,因为它把"统一为 snake_case"这条约束丢了。结果就是前 3 个文件是新风格,后 4 个是旧风格,整个模块风格割裂。
engram 组在同样位置的表现就好很多,因为卡片里明确写着"已完成 3 个文件,剩余 4 个,统一 snake_case"。代理压缩后直接接着第 4 个文件往下做,风格一致。这个案例让我意识到,进度指针和约束必须一起保留,只留一个都不够。
5.2 接口迁移里的"幽灵依赖"
另一个任务是替换旧 API 调用。压缩前代理已经确认某个旧接口在新版本里被移除了,需要用替代方案。压缩后,long_mem 组因为把"禁用旧接口 X"存进了约束,没有复发。但裸压缩组里,代理又把旧接口调用加回来了,理由是"看起来更简洁"。这种"幽灵依赖"在记录里出现了 8 次,全部集中在裸压缩组。
这里有个细节值得说:代理加回旧接口时,并不是完全无意识,它在注释里写了"暂时用 X,后续再换"。这说明它其实"记得"要换,但忘了"现在就不能用"。这种半记得的状态最危险,因为它看起来像是合理的临时方案,review 时容易被放过。
5.3 压缩时机对结果的影响
我原本以为压缩策略是唯一变量,后来发现压缩时机同样重要。同样是裸压缩,在任务早期触发和在任务后期触发,后果完全不同。早期触发时,工作现场还没稳定,丢一点状态影响不大;后期触发时,大量决策已经沉淀,丢一点就是灾难。
记录里有个反直觉的发现:在任务完成度 70% 到 90% 这个区间触发的压缩,失败率最高。我推测是因为这个阶段状态最复杂——既有一堆已完成的步骤,又有一堆待处理的依赖,摘要模型最难取舍。这个发现对实操的启示是:如果你能控制压缩时机,尽量避开任务后段,或者在后段压缩时用更保守的策略(比如 engram 卡片)。
6. 自己搭代理时,压缩接续这块我会怎么做
6.1 别把状态保持全押在摘要模型上
这是我最大的体会。摘要模型是为人类阅读优化的,它的目标函数和"状态可续"不一致。你让它顺便把状态也保好,是在为难它。正确做法是把状态抽取当成一个独立的、确定性的步骤,和摘要解耦。摘要可以自由发挥,状态必须结构化保存。
具体落地时,我会在压缩触发点插入一个抽取函数,专门负责从历史里捞出约束、文件、命名、进度这四类信息。这个函数不需要多聪明,规则加轻量模型就够,关键是稳定——同样的历史,每次抽出来的结果应该一致。
6.2 给状态卡片定一个硬预算
engram 卡片好用,但会膨胀。我的做法是给它定一个硬预算,比如 500 token,超了就按优先级裁剪。优先级排序是:任务约束 > 未完成子目标 > 文件清单 > 命名约定。约束永远不能裁,因为违反约束的代价最高。这个预算要写死在代码里,不能让它随任务复杂度自由增长,否则上下文紧张时它会反噬。
6.3 检索注入要带"时效"判断
long_mem 的检索环节,我加了一个时效过滤:只注入当前任务相关的记忆,跨任务的记忆默认不注入,除非显式关联。这个改动之后,前面提到的"捞出三天前另一个任务约束"的问题基本消失了。判断相关性时,我用的是任务 ID 加文件路径的双重匹配,简单但有效。
注意:时效过滤别做得太激进。我有一次把过滤条件设得太严,导致同一个任务里较早确立的约束也被滤掉了,代理又开始违反约束。后来改成"同任务内不过滤,跨任务才过滤",就稳了。
6.4 压缩后加一个"自检轮"
这是个便宜又好用的小技巧:压缩发生后,不让代理直接干活,而是先让它用一轮回答三个问题——当前任务是什么、涉及哪些文件、下一步做什么。这三个问题的答案如果和状态卡片对不上,就说明接续出了问题,可以及时纠正。这一轮的成本很低,但能拦住相当一部分断片。我在后半程实验里加了这个自检轮,裸压缩组的失败率都有所下降,说明它确实能兜底。
7. 几个容易踩的坑和我的应对
7.1 别用"摘要长度"衡量压缩质量
很多人调压缩的时候,盯着的是"摘要压到多短"。但短不等于好。我见过把摘要压到 200 token 的方案,接续能力惨不忍睹,因为关键状态全被压没了。正确的衡量指标应该是接续通过率,而不是压缩比。压缩比只是成本指标,不是质量指标,这两个别混。
7.2 状态抽取的字段别贪多
我一开始想把所有能想到的字段都抽出来——依赖版本、环境变量、测试命令、甚至代码风格偏好。结果卡片长得没法看,注入的时候反而稀释了关键信息。后来砍到四类核心字段,效果反而更好。状态保持这件事,少而准远胜多而全。
7.3 重复执行要用幂等性兜底
前面说的"已完成步骤被重复执行",光靠状态保持不能完全杜绝。我的补充手段是让关键操作幂等——重命名前先检查目标名是否存在,插入依赖前先检查是否已存在。这样即使代理忘了,重复执行也不会造成破坏。状态保持和幂等性是两道独立的防线,都要有。
7.4 测试用例要覆盖"压缩点"
如果你在给代理写测试,一定要专门设计触发压缩的用例。普通的短任务测试根本测不出接续问题,因为压根不压缩。我的做法是构造一个必然超过上下文上限的长任务,然后在压缩点前后各断言一次状态一致性。这个测试跑起来慢,但值得。
8. 这套东西还能往哪延伸
实验做完之后,我一直在想一个问题:现在的状态保持还是"被动"的——压缩发生了才去救。能不能做成"主动"的?比如代理在推进任务时,就持续维护一份轻量的状态快照,压缩时直接取用,而不是临时抽取。这样抽取的准确性和时效性都会更好,代价是每轮多一点开销。这个方向我还在试,初步看是有希望的。
另一个方向是把状态保持和任务规划打通。现在状态卡片里的"未完成子目标"其实是规划器该管的事,如果规划器和记忆模块共享同一份状态,就不用来回同步了。这需要代理架构层面的调整,不是加个模块能解决的,但长期看是更干净的做法。
还有个更实际的方向:把状态卡片做成可读可编辑的。现在卡片是机器生成的,人只能看不能改。如果允许人在压缩点手动修正卡片——比如补一条被漏掉的约束——接续质量还能再上一个台阶。毕竟最懂任务约束的,还是发起任务的人。
最后分享一个我在实验里反复验证的小结论:压缩接续这件事,投入产出比最高的不是换更强的模型,而是把状态显式化。同样的模型,加了状态卡片,接续通过率能从六成提到八成以上。模型能力当然重要,但在压缩这个特定环节,工程手段的收益往往比换模型更直接、更便宜。这个结论不一定普适,但在我这 430 条记录里,它站得住。