1. 这个实验到底在折腾什么
先把这个标题拆开看。上下文压缩,指的是 AI 编码代理在长会话里,当对话历史、代码文件、工具调用结果累积到超出模型上下文窗口时,系统会自动把旧内容做摘要、截断或丢弃,腾出空间给新内容。AI 编码代理,就是像 Codex 这类能自己读文件、改代码、跑命令、看报错的自动化编程助手。问题就出在这两者的交界处:压缩一发生,代理就"失忆"了,之前读过的文件路径、改过的函数名、跑通的命令,全没了,于是它开始重复劳动、改错文件、甚至把已经修好的 bug 又改回去。
这个实验用 10 天时间、430 条公开记录,专门盯着"压缩之后代理怎么接得上"这件事。所谓"接得上",就是压缩发生后,代理还能不能延续之前的任务状态,而不是从零开始重新摸索。适合谁看?如果你在用 Codex 这类 CLI 编码代理跑长任务,或者你在自己搭 agent 框架、被上下文窗口卡过脖子,这篇就是给你写的。我踩过的坑基本都在下面了。
先说结论方向:单纯靠"压缩得更好"解决不了问题,真正管用的是在压缩之外维护一份独立于对话历史的长期记忆,让代理每次都能从这份记忆里把关键状态捞回来。这个思路在社区里常被叫做 long_mem 或者 engram 一类的记忆层设计。下面我把整个实验的设计、实现、踩坑和排查过程完整拆一遍。
2. 为什么压缩之后代理会"断片"
2.1 上下文窗口的硬约束和压缩的必然性
任何模型的上下文窗口都是有限的。你让代理去重构一个中型项目,它要读的文件、要看的报错、要跑的测试输出,加起来轻松超过窗口上限。这时候系统只有几条路:要么截断最早的对话,要么把中间一段做摘要,要么按重要性丢弃。不管哪条路,本质都是信息有损。
我实测下来,最坑的不是"丢了多少 token",而是丢的东西恰好是任务状态。比如代理在第 3 轮读到了src/utils/date.ts里有个formatDate函数,第 40 轮它要改这个函数,但中间压缩把第 3 轮的内容摘要成了一句"读取了若干工具文件"。摘要里没有函数名、没有路径,代理就只能重新去搜,搜的时候又可能搜到同名的另一个文件,直接改错地方。
2.2 代理"断片"的三种典型表现
我把 430 条记录里代理出问题的模式归了三类,这个分类后面排查问题很好用:
| 表现类型 | 具体症状 | 根因 |
|---|---|---|
| 重复劳动 | 反复读同一个文件、反复跑同一条命令 | 压缩丢了"已做过"的记录 |
| 状态错乱 | 改错文件、用错变量名、回退已修复的 bug | 压缩丢了"当前进度"和"关键标识" |
| 目标漂移 | 忘了原始需求,开始做无关的事 | 压缩丢了"任务目标"和"约束条件" |
这三类里,状态错乱最危险,因为它会直接破坏代码。重复劳动只是浪费 token,目标漂移你还能靠人工拉回来,但状态错乱可能让你花半小时 review 才发现代理把对的改成了错的。
2.3 为什么"压缩得更聪明"是条死路
一开始我也想过,能不能让压缩算法更聪明,只保留重要内容。试了两天就放弃了。原因是:你无法在压缩的那一刻预知未来哪条信息会被用到。第 3 轮读到的那个函数名,在第 40 轮才用得上,压缩发生在第 20 轮,它怎么知道要留?
这就像你整理书桌,把"暂时用不到"的东西收进箱子,结果三天后要找的东西正好在箱子里。压缩算法再聪明,也是在信息不完整的情况下做决策。所以正确的方向不是"压缩得更好",而是把关键状态从对话历史里剥离出来,单独存一份,压缩对话历史时不动它。这就是 long_mem / engram 这类记忆层的核心思想。
3. 记忆层怎么设计才接得上
3.1 记忆层要存什么:三类关键状态
不是所有东西都值得进记忆层,存太多等于没存。我最后收敛到三类:
- 任务锚点:原始需求、验收标准、硬约束(比如"不许改数据库 schema")。这类东西一旦确定就不该变,压缩多少次都得留着。
- 实体索引:代理操作过的文件路径、函数名、类名、关键变量。这是防止"改错文件"的核心。
- 进度快照:当前做到哪一步、哪些已验证通过、哪些还待办。
这三类里,实体索引是实验里收益最大的。因为代理 80% 的"断片"事故都跟"找不到之前那个东西"有关。把实体索引单独存一份,代理每次压缩后先读这份索引,基本就能接上。
3.2 记忆层的存储结构设计
我用的是最简单的键值 + 结构化条目混合方案,不搞复杂向量库,因为实测下来向量检索在"精确找回某个函数名"这种场景上不如直接结构化查询稳。结构大概长这样:
{ "task_anchor": { "goal": "给用户模块加软删除", "constraints": ["不改 schema", "兼容旧数据"], "acceptance": "旧数据查询不受影响" }, "entities": [ {"type": "file", "path": "src/user/service.ts", "note": "核心逻辑在这"}, {"type": "function", "name": "softDeleteUser", "file": "src/user/service.ts"}, {"type": "table", "name": "users", "note": "加了 deleted_at 字段"} ], "progress": { "done": ["schema 迁移", "service 层实现"], "verified": ["单元测试通过"], "todo": ["controller 层接入", "补集成测试"] } }这个结构的好处是代理可以精确查询,比如"我之前改过哪个文件",直接读 entities 里 type=file 的条目,不用做语义检索。语义检索留给"我大概想找跟日期相关的逻辑"这种模糊需求。
3.3 记忆的写入时机:别等压缩才写
这是实验里最关键的一个设计决策。我一开始是"压缩发生时,顺便把要保留的写进记忆层"。结果发现压缩那一刻代理已经在丢信息了,写进去的东西本身就不全。
后来改成边做边写:代理每完成一个原子操作(读了一个文件、改了一个函数、跑通一条命令),就立刻往记忆层追加一条。这样压缩发生时,记忆层早就存好了,压缩爱怎么压怎么压,不影响记忆层。这个改动让"接得上"的成功率从大概六成提到了九成以上。
提示:写入要幂等。同一个文件被读三次,记忆层里应该还是一条(或更新 note),不是三条。否则记忆层自己会膨胀成新的"上下文窗口"。
4. 实操:把记忆层接进编码代理
4.1 整体流程和关键节点
整个链路是这样的:代理执行操作 → 操作结果写入记忆层 → 上下文接近上限 → 触发压缩 → 压缩后代理先读记忆层 → 继续任务。关键节点有两个:写入点和恢复点。
写入点我放在工具调用的返回处理里。代理每次调用"读文件""改文件""跑命令"这类工具,返回结果先过一遍记忆层写入逻辑,再进对话历史。恢复点放在压缩之后的第一条系统消息里,强制代理先读记忆层再干活。
4.2 写入逻辑的实现要点
写入逻辑的核心是从工具调用结果里抽取实体。读文件就抽路径,改文件就抽路径 + 改动的函数/类名,跑命令就抽命令本身和结果状态。抽取不用太复杂,正则 + 简单 AST 就够,别上大模型,太慢。
def extract_entities(tool_name, args, result): entities = [] if tool_name == "read_file": entities.append({"type": "file", "path": args["path"]}) elif tool_name == "edit_file": entities.append({"type": "file", "path": args["path"]}) # 从 diff 里抽函数名 for fn in parse_changed_functions(result["diff"]): entities.append({"type": "function", "name": fn, "file": args["path"]}) elif tool_name == "run_command": entities.append({"type": "command", "cmd": args["cmd"], "status": "ok" if result["exit"] == 0 else "fail"}) return entities这里有个坑:edit_file 的 diff 解析别写太复杂。我一开始想精确解析出改了哪几行、哪个 AST 节点,结果各种边界情况处理到崩溃。后来退一步,只抽"这个文件被改过"和"diff 里出现的函数名",够用了。记忆层是辅助,不是精确账本。
4.3 恢复逻辑:压缩后怎么让代理"想起来"
恢复逻辑比写入更讲究。压缩后不能简单把整个记忆层塞回上下文,那样等于没压缩。我的做法是按当前任务阶段做筛选:
- 任务锚点永远全量注入,这部分很小,几十个 token。
- 实体索引按"最近操作过"和"与当前 todo 相关"两个维度筛,只注入 top N。
- 进度快照只注入 done 和 todo,verified 的细节可以省。
def build_recovery_context(memory, current_todo, top_n=15): ctx = {"task_anchor": memory["task_anchor"]} # 实体按最近使用 + 相关性排序 entities = rank_entities(memory["entities"], current_todo) ctx["entities"] = entities[:top_n] ctx["progress"] = { "done": memory["progress"]["done"], "todo": memory["progress"]["todo"] } return ctx实测下来 top_n 取 15 左右比较平衡,太少接不上,太多又挤占上下文。这个值跟你的模型窗口大小有关,窗口大可以放宽到 25。
4.4 一个完整的恢复消息长什么样
压缩后注入给代理的系统消息大概是这样,我直接贴一段脱敏后的真实记录:
[记忆恢复] 你正在执行的任务:给用户模块加软删除。 硬约束:不改 schema,兼容旧数据。 你已操作过的关键文件: - src/user/service.ts(核心逻辑,已改) - src/user/controller.ts(待接入) 已完成的步骤:schema 迁移、service 层实现、单元测试通过。 待办:controller 层接入、补集成测试。 请基于以上状态继续,不要重复已完成的工作。这条消息大概 200 token,但能让代理瞬间回到状态。对比没有这条消息的情况,代理平均要多花 3 到 5 轮才能重新摸清状况,而且经常摸错。
5. 430 条记录里踩出来的坑
5.1 记忆层自己膨胀的问题
前面提过写入要幂等,但实际做的时候还是踩了坑。代理有时候会用不同的路径写法指向同一个文件,比如./src/user/service.ts和src/user/service.ts,甚至绝对路径。如果不做归一化,记忆层里同一个文件会有好几条,恢复时注入一堆重复内容。
解决办法是写入前做路径归一化:统一转成相对于项目根目录的路径,去掉./,解析软链接。这个处理放在写入逻辑最前面,一行代码的事,但不做就是灾难。
5.2 代理"不信任"记忆层
这个坑很隐蔽。有几次代理明明收到了记忆恢复消息,但它还是重新去读了一遍文件。原因是它在对话历史里找不到"我读过这个文件"的证据,就本能地重新确认。这不算错,但浪费 token。
我的处理是在恢复消息里加一句明确的指令:"以上状态已由系统确认,无需重新验证。"加了这句之后,重复读取明显减少。代理对系统消息的信任度高于对自身记忆的信任度,这个特性要利用起来。
5.3 压缩触发时机和记忆写入的竞态
有几次出现"记忆层里没有某条记录,但代理明明做过这个操作"。排查发现是压缩和写入的竞态:代理刚发起一个操作,压缩恰好在这时触发,操作结果还没写进记忆层就被压缩截断了。
解决办法是把写入做成同步阻塞:工具调用返回后,先同步写完记忆层,再允许压缩检查。这会稍微增加一点延迟,但换来的是记忆的完整性。异步写入看着优雅,实际会丢数据。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决 |
|---|---|---|---|
| 压缩后代理重复读文件 | 恢复消息没注入实体索引 | 检查 build_recovery_context 输出 | 确认 entities 非空且 top_n 够大 |
| 代理改错文件 | 实体索引里路径不唯一 | 查记忆层是否有重复路径 | 加路径归一化 |
| 记忆层越来越大 | 写入不幂等 | 统计 entities 条目数增长 | 写入前按 key 去重 |
| 恢复后代理仍迷茫 | 任务锚点没注入 | 检查 task_anchor 字段 | 锚点必须全量注入 |
| 记忆层缺记录 | 写入与压缩竞态 | 看日志时间戳 | 写入改同步阻塞 |
5.5 几条不那么显然的经验
第一,记忆层不要存代码内容本身。存路径和函数名就够了,代码让代理自己去读。存代码内容会让记忆层迅速膨胀,而且代码一改就过期,反而误导代理。
第二,恢复消息要放在对话历史的最前面,不是最后面。放最后面代理容易忽略,放最前面它每次生成都会看到。这个细节我调了两天才发现。
第三,给记忆层加个"最后更新时间"。代理有时候会基于过期状态干活,比如记忆层说"controller 待接入",但其实上一轮已经接了。加时间戳后,代理会优先信任更新的记录。
6. 这套方案还能怎么扩展
实验做完,我觉得这套记忆层思路不止能用在编码代理上。任何长任务型的 agent,只要涉及上下文压缩,都能套这个模式:把任务锚点、实体索引、进度快照从对话历史里剥离出来,独立维护,压缩后按需恢复。
具体到编码场景,还能往下做的有几个方向。一是跨会话记忆,现在记忆层是单次会话内的,如果代理重启,记忆就没了。把记忆层持久化到磁盘,下次会话能接着用,这对长期项目很有价值。二是记忆的自动过期,有些实体操作完就再也不用了,一直留着占地方,可以按"多久没被引用"做淘汰。三是多代理共享记忆,如果同时跑几个代理分工干活,共享一份记忆层能避免它们互相踩脚。
不过这几点我还没在 430 条记录里充分验证,属于下一步想试的方向。就目前这套单会话方案,10 天跑下来,代理在长任务里的"断片"率从最初的接近四成降到了不到一成,剩下的主要是模型自身能力边界导致的问题,跟记忆层无关了。
最后分享一个我调参时的小技巧:恢复消息的 top_n 不要拍脑袋定,用记录反推。把 430 条记录里"恢复后仍需重新读取"的案例挑出来,看当时注入了多少实体、缺了哪类,就能算出你这个项目场景下的合理值。我这边算下来是 15,但你的项目文件多、依赖复杂的话,可能要 20 到 25。这个值跟项目规模强相关,没有通用答案。