1. 当上下文被压缩,代理为什么会“失忆”
做过 AI 编码代理的人都遇到过一个很具体的场景:你花了两小时把项目背景、目录结构、编码规范、历史踩坑记录一点点喂给代理,它终于能稳定地帮你改代码了。然后对话继续推进,token 数一路涨到十几万,系统触发上下文压缩,把前面的内容摘要成一小段。下一秒,代理开始胡言乱语——它忘了项目用的是哪个测试框架,忘了某个模块不能动,甚至把之前明确否定的方案又提了一遍。
这个现象我把它叫做“脑叶切除术”。上下文压缩本意是好的,它要在有限的窗口里塞进尽可能多的信息,但代价是把那些看起来“冗余”、实际上承载着关键约束的细节给切掉了。代理的短期记忆被重置,而它自己并不知道自己被重置了。
标题里提到的两个东西,一个是 Unix 的 init.d,一个是电影《记忆碎片》。这两个看似毫不相干的概念,恰好对应了解决这个问题的两条思路:init.d 代表“进程启动时自动加载的持久化配置”,《记忆碎片》代表“主角靠纹身和纸条对抗失忆”。把这两者结合起来,就能让 AI 编码代理在上下文被反复压缩的情况下,依然保持对核心约束的记忆。
这篇文章适合两类人看:一类是正在用 AI 代理做实际项目、被上下文丢失折磨过的开发者;另一类是想理解“代理记忆管理”这个问题的技术爱好者。我会把整套方案的思路、实现细节、踩过的坑都摊开讲,代码和配置都能直接抄。
2. 问题拆解:上下文压缩到底切掉了什么
2.1 压缩不是删除,是“有损摘要”
很多人以为上下文压缩就是把旧消息删掉,其实不是。主流实现通常是让模型对早期对话做一次摘要,用几百 token 概括几千 token 的内容。问题就出在这个“概括”上。
摘要模型判断什么是重点,依据的是“这段内容在语义上是否重要”,而不是“这段内容对当前任务是否关键”。举个例子,你在对话早期说过一句“这个项目的数据库迁移脚本必须手动执行,不能放进 CI”,这句话在语义上平平无奇,摘要时很容易被合并成“讨论了数据库相关事项”。但对代理来说,这条约束一旦丢失,它下次就可能自动帮你跑迁移,直接把生产库搞乱。
我实测过几次,压缩后丢失的信息大致集中在三类:否定性约束(“不要做 X”)、环境特定的配置(路径、端口、密钥位置)、以及历史决策的原因(“当初为什么选 A 不选 B”)。这三类恰好都是代理执行任务时最需要的。
2.2 代理的“失忆”和人的失忆不一样
人的失忆是记忆没了,但知道自己忘了。代理的问题是,它不知道自己忘了。压缩之后,代理拿到的是一份“看起来完整”的上下文,它会理所当然地认为自己掌握了全部信息,然后基于残缺的信息自信地做决策。
这就像《记忆碎片》里的主角,他每隔一段时间记忆就重置,但他学会了用拍立得照片和纹身把关键信息固化下来。每次“醒来”,他先看纹身,再看照片,就能恢复行动能力。我们要给代理做的,就是同一件事:把关键约束从易失的对话上下文里,搬到不易失的外部存储里,并且在每次任务开始前强制加载。
2.3 为什么不能简单地把窗口开大
有人会说,那就用更大窗口的模型不就行了。这个思路有两个问题。第一,窗口再大也有上限,20 万 token 听起来很多,但一个中等规模项目的完整上下文加上多轮对话,很容易就顶到天花板。第二,就算窗口够大,长上下文本身也会带来注意力稀释的问题,模型对中间部分的关注度会下降,这在业界已经有不少讨论。
所以正确的方向不是“避免压缩”,而是“让压缩变得无害”。核心思路是:把代理真正需要长期记住的东西,从对话流里剥离出来,放到一个压缩碰不到的地方。
3. 核心思路:用 init.d 的思路给代理做“纹身”
3.1 init.d 给我们的启发
Unix 系统启动时,init 进程会按顺序执行 /etc/init.d/ 目录下的脚本,把各种服务拉起来。这些脚本的特点是:它们是文件,持久存在于磁盘上,每次启动都会被读取执行,不依赖任何“上一次运行时的内存状态”。
把这个模式搬到 AI 代理上,就是:不要让代理依赖对话历史来记住约束,而是把约束写成文件,在每次任务开始时读取。对话可以被压缩、被清空、甚至换一个全新的会话,只要这些文件还在,代理就能恢复到“知道所有关键约束”的状态。
3.2 《记忆碎片》给我们的启发
电影里主角的纹身有几个特点:极简、只记最关键的、按重要性排列。他不会把每天吃了什么纹在身上,只纹“不要相信某某”这种生死攸关的信息。
对应到代理的持久化记忆,就是:不要试图把所有对话都存下来,那样等于没存。要提炼出真正关键的、跨会话不变的约束,用最紧凑的形式写下来。一份好的“代理纹身”应该控制在几百 token 以内,这样每次加载的成本极低,而且不会挤占正常任务的上下文空间。
3.3 整体架构
我把这套方案分成三层:
| 层级 | 作用 | 存储位置 | 生命周期 |
|---|---|---|---|
| 纹身层 | 跨会话不变的硬约束 | 项目根目录的固定文件 | 永久 |
| 照片层 | 当前任务的进度和决策 | 任务专属的进度文件 | 单任务 |
| 对话层 | 正常的交互上下文 | 模型上下文窗口 | 易失 |
纹身层是核心,它解决“代理忘了项目基本规则”的问题。照片层解决“代理忘了这个任务做到哪了”的问题。对话层就是正常干活的地方,被压缩了也无所谓,因为关键信息都在前两层。
4. 实操:把约束写成代理能读懂的“纹身”
4.1 纹身文件该放哪、叫什么
我习惯在项目根目录建一个.agent/目录,里面放几个固定文件。为什么不直接放根目录?因为根目录容易被各种工具扫描,放子目录更干净,也方便加进版本控制。
.agent/ constraints.md # 硬约束,代理绝对不能违反的 context.md # 项目背景,代理需要知道的基本信息 decisions.md # 历史决策记录,为什么这么做 progress/ # 每个任务的进度文件 task-001.mdconstraints.md是最重要的,它对应“纹身”。context.md和decisions.md对应“照片”,信息量大一些但也是持久化的。progress/目录下的文件是任务级的,做完一个任务可以归档。
4.2 constraints.md 怎么写才有效
这份文件的目标是让代理在 30 秒内读完并记住所有红线。写法上有几个要点。
第一,用祈使句,不要用描述句。写“禁止修改config/prod.yaml”,不要写“config/prod.yaml是一个生产环境配置文件”。前者是命令,后者是信息,代理对命令的执行率明显更高。
第二,每条约束单独一行,前面加编号。编号让代理在引用时能精确指代,比如“根据约束 3,我不能这么做”。
第三,把最危险的约束放最前面。代理读文件也是从头读的,虽然理论上都会读,但前面的内容权重更高。
一个实际的例子:
# 硬约束 1. 禁止修改 config/prod.yaml 和 config/staging.yaml 2. 禁止在测试中使用真实数据库,必须用 test/fixtures 下的 mock 3. 所有新增 API 必须带 rate limit 装饰器 4. 禁止删除 migrations/ 下的任何文件,只能新增 5. 提交前必须跑 make lint,lint 不过不许提交 6. 涉及金额计算的代码必须用 decimal,禁止用 float这六条加起来不到 200 token,但每一条都是踩过坑才写上去的。第 6 条就是有一次代理用 float 算金额,测试数据碰巧没暴露精度问题,上线后对账差了三分钱,查了一下午。
4.3 怎么让代理每次都读这些文件
光有文件不够,得让代理在每次任务开始时主动加载。有两种做法。
一种是在系统提示词里写死:“每次开始新任务前,先读取.agent/constraints.md和.agent/context.md”。这种做法简单,但依赖代理听话。
更可靠的做法是做一个包装脚本,代理的所有操作都通过这个脚本走,脚本在调用模型之前自动把纹身文件的内容拼进提示词。这样即使代理“想偷懒”,也绕不过去。
import pathlib def build_system_prompt(project_root: str) -> str: agent_dir = pathlib.Path(project_root) / ".agent" parts = [] for name in ["constraints.md", "context.md", "decisions.md"]: f = agent_dir / name if f.exists(): parts.append(f"## {name}\n\n{f.read_text(encoding='utf-8')}") return "\n\n".join(parts)这个函数每次构建提示词时都重新读文件,所以你在文件里改了约束,下一个任务立刻生效,不需要重启任何东西。这就是 init.d 的精髓:配置在磁盘上,启动时读取,改了下次启动就生效。
4.4 照片层:任务进度文件怎么维护
纹身层解决的是“跨会话不变的约束”,但一个具体任务做到一半被压缩了,代理还需要知道“这个任务做到哪了”。这就是进度文件的作用。
我让代理在开始一个任务时,先在.agent/progress/下建一个文件,格式固定:
# 任务:给用户模块加导出功能 ## 目标 支持把用户列表导出为 CSV 和 Excel ## 已完成 - [x] 设计导出接口签名 - [x] 实现 CSV 导出 ## 进行中 - [ ] 实现 Excel 导出(用了 openpyxl,卡在样式设置) ## 待办 - [ ] 写测试 - [ ] 更新 API 文档 ## 关键决策 - 选择 openpyxl 而不是 xlsxwriter,因为需要读取现有模板每次代理完成一个子步骤,就更新这个文件。上下文被压缩后,代理只要重新读这个文件,就能立刻恢复状态。这个文件本身也是持久的,不随对话消失。
5. 让压缩“无害”的关键细节
5.1 压缩触发时主动做一次“记忆固化”
被动等待代理去读文件,不如在压缩发生前主动固化一次。大多数代理框架都提供了压缩事件的钩子,可以在压缩前插入一段逻辑:把当前对话里的关键信息提炼出来,追加到进度文件或决策文件里。
具体做法是,在压缩钩子里调用一次模型,提示词大概是:“以下是即将被压缩的对话,请提取其中所有否定性约束、环境配置、以及做出的决策,用简洁的列表输出。”然后把输出追加到.agent/decisions.md。
这一步的成本很低,一次调用几百 token,但能极大降低信息丢失的概率。我实测下来,加了这一步之后,压缩导致的“代理犯傻”次数下降了大概七成。
5.2 纹身文件要定期“瘦身”
纹身文件不是越大越好。如果 constraints.md 涨到几千 token,每次加载都会挤占正常任务的上下文,而且代理对长列表的遵守率会下降。
我的做法是每个月过一遍 constraints.md,把已经通过其他机制保证的约束删掉。比如某条约束“禁止用 float 算金额”,如果项目里已经加了 lint 规则自动检查,那这条就可以从纹身文件里移除,因为 lint 会兜底。
这就像《记忆碎片》里主角不会把所有事都纹上,只纹最关键的。纹身太多,反而看不清。
5.3 用“引用”代替“复制”
有时候约束很长,比如一段完整的 API 规范。这时候不要在纹身文件里复制全文,而是写一个引用:“API 规范见 docs/api-spec.md,所有新增接口必须符合该规范。”
代理看到引用,会在需要时去读那个文件。这样纹身文件保持精简,详细信息放在该放的地方。这个思路和 init.d 里脚本引用外部配置文件是一样的。
6. 常见问题与排查技巧
6.1 代理读了纹身文件但还是违反约束
先检查文件格式。如果 constraints.md 里混了大量描述性文字,代理可能会把约束当成“背景信息”而不是“命令”。解决办法是把约束和背景分开,constraints.md 里只放祈使句。
再检查加载时机。如果纹身文件是在对话中途才加载的,代理可能已经基于旧上下文做了决策。确保它在每次任务开始时加载,而不是中途补。
6.2 进度文件更新不及时
这是最常见的问题。代理干着干着就忘了更新进度文件,等压缩发生时才想起来,但信息已经丢了。
我的解决办法是在包装脚本里加一个检查:每次代理调用工具(比如写文件、跑命令)之后,检查进度文件的修改时间,如果超过一定轮次没更新,就在下一次提示词里加一句提醒。这个机制有点像定时提醒,但触发条件是“操作次数”而不是“时间”。
6.3 多个任务并行时进度文件冲突
如果一个代理同时处理多个任务,进度文件要按任务分开,不能共用一个。我在.agent/progress/下用任务 ID 命名文件,代理在开始任务时生成一个 ID,后续所有进度都写到对应文件。
如果多个代理实例并行,还要考虑文件锁的问题。简单做法是每个实例用独立的进度目录,最后再合并。复杂做法是加文件锁,但一般项目用不上。
6.4 压缩后代理“假装”记得
有时候代理会表现得很自信,说“根据之前的讨论,我们应该……”,但实际上它已经忘了。这种“假装记得”比直接说“我忘了”更危险。
识别方法是让它复述关键约束。在任务开始前,让它先输出一遍 constraints.md 的内容摘要。如果摘要和文件对不上,说明它没真正加载。这个检查可以做成自动的,摘要不匹配就重新加载。
6.5 速查表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 代理违反硬约束 | 纹身文件格式不对或未加载 | 检查文件是否为祈使句、加载时机是否正确 |
| 压缩后状态丢失 | 进度文件未及时更新 | 检查更新触发机制、增加操作后提醒 |
| 代理假装记得 | 上下文残留导致误判 | 要求复述关键约束、做一致性校验 |
| 纹身文件失效 | 文件过大导致遵守率下降 | 定期瘦身、用引用代替复制 |
| 多任务混乱 | 进度文件未隔离 | 按任务 ID 分文件、并行时用独立目录 |
7. 我在实际项目里踩过的几个坑
第一个坑是纹身文件写得太“温柔”。早期我写的是“建议不要修改生产配置”,结果代理该改还是改。改成“禁止修改”之后,遵守率立刻上来了。措辞的强硬程度直接影响执行率,这点和跟人沟通是一样的。
第二个坑是进度文件写得太细。一开始我让代理把每一步操作都记下来,结果文件迅速膨胀到几千行,代理读它比读对话还费劲。后来改成只记“目标、已完成、进行中、待办、关键决策”五个部分,每个部分控制在几行,效果好很多。
第三个坑是忘了给纹身文件加版本控制。有一次我改了 constraints.md,但没提交,换了个环境跑代理,它读的是旧版本,行为不一致,排查了半天才发现是文件没同步。现在.agent/目录是强制纳入版本控制的。
第四个坑是过度依赖自动固化。压缩钩子里的提炼调用偶尔会漏掉关键信息,尤其是那些表述比较隐晦的约束。所以我现在是自动固化加人工抽查,每周过一遍 decisions.md,看有没有漏掉的。
这套方案跑了大半年,代理在长任务里的稳定性明显提升。最直观的感受是,以前一个任务做到后半段经常要重新解释背景,现在基本不需要了。上下文压缩依然会发生,但它不再是一次“脑叶切除术”,而更像是一次普通的换页——代理看一眼纹身,继续干活。