如果你让 AI 编码代理在长会话里跑过真实项目,大概率会遇到这一幕:上下文窗口明明还有富余,但你追问某个函数的具体实现时,代理却开始用模糊的表述糊弄你——“这个函数之前定义过,具体叫什么不重要,逻辑是查得到的”。你回翻日志,发现某一步系统提示上下文已压缩。你失去的不只是几轮聊天记录,而是整个项目在代理脑海里的精确记忆。这个现象,我叫它“脑叶切除术”。20 万 token 的窗口,说切就切,切完代理对项目一脸茫然,跟刚开机时没什么两样。
这个坑我踩了不止一次。后来我从两个完全不相干的东西里找到了灵感:一是 Unix 的 init.d 启动体系,二是电影《记忆碎片》里那个记不住新事的男主角。前者教会我“状态应该放在文件系统里而不是进程里”,后者教会我“记忆可以外包给环境”。把这两套思路合到一起,我搭了一套让编码代理对上下文压缩近乎免疫的工作流,实测了几个月,效果远超预期。
这篇文章把这套方案从问题根源、设计思路、具体实现到踩坑记录完整讲一遍。适合所有被长上下文困扰的开发者,也适合刚接触 AI 编码代理、想从一开始就避开这个坑的新手。不扯玄学,全部是我真实跑过的方案。
1. 先搞懂:上下文压缩为什么是编码代理的“脑叶切除术”
1.1 压缩的真相:不是摘要,是断片
很多人对上下文压缩有个误解,以为它是“智能摘要”——把老对话浓缩一下,核心信息都还在。实际根本不是这么回事。典型的上下文管理策略是粗暴的:超出窗口部分要么直接丢弃,要么用一小段概括性文本替换掉原始内容。而概括只有一个目标,就是“剧情梗概级别”地保留对话主旨,它不会替你保留代码细节。
举个我真实遇到的例子。我让代理重构数据层的某个查询接口,前后聊了大概一百多轮,期间确认了表结构、索引策略、缓存键的命名规则、异常处理方式。然后上下文被压缩了。我再问代理“缓存键的命名规则我们刚才不是定了吗,按这个规则实现一下”,它回了一句:“我们之前确实讨论过缓存键的规则,具体规则在之前的对话里,我建议你重新发一下。”那一刻我意识到,压缩后的代理根本不是在“回忆”,它是在“意识到自己失忆了”。
对自然语言聊天来说,梗概尚可接受。但代码是精确文本,一个函数名、一个参数顺序、一个魔法数字丢了,整个实现就废了。压缩对编码代理来说根本不是降清晰度,是直接断电。
1.2 编码代理对“失忆”格外敏感的三层原因
第一层是精确性依赖。代码不像聊天,代码里的每个标识符都是确切的。代理依赖上下文里的精确符号来写代码、改代码。一旦压缩把符号替换成“某个函数”,它就只能靠猜——猜出来的东西轻则风格不一致,重则引用不存在的接口,编译都过不了。
第二层是跨文件索引。现代项目的代码修改几乎都是跨文件的。代理需要同时记住 A 模块的接口、B 模块的调用方式、C 模块的配置项。这些信息可能散落在几十轮对话里。压缩把这些相关性打散之后,代理就失去了“全局索引”,只能盯着当前打开的文件瞎转。
第三层是调试链路的连续性。调 bug 的本质是在追踪一条因果链:“报错→查日志→定位可疑函数→验证假设→找到根因”。这条链在上下文里是一步步建立的。压缩之后,代理只记得“有个 bug 在处理”,但忘了已经排查过哪些方向、排除过哪些假设,于是它会把之前否定的方案再翻出来炒冷饭,浪费大量 token。
2. 第一味解药:init.d 的重启哲学,把状态写进文件系统
2.1 为什么 Unix 服务可以随便崩溃,还一脸淡定
想明白这件事,得先看 Unix 是怎么处理进程生命周期的。系统里的各种服务随时可能崩溃,但运维根本不慌,因为服务本身并不持有“不可再生”的状态。配置在 /etc 下,数据在 /var/lib 下,日志在 /var/log 下,服务进程自己的内存里只有可随时重建的缓存。崩溃之后,init.d 里的启动脚本把它拉起来,它读配置、加载数据、回到可用状态,整个流程行云流水。
这个设计里有个关键原则:进程内存是易失的,文件系统是持久的。聪明的服务都默认自己随时会死,所以频繁地把状态刷到磁盘里。这才有了幂等启动、按序启动、可重启这些特性。你见过哪个服务在崩溃前还要靠进程内存里的“聊天记录”来恢复吗?没有,因为那是不及格的设计。
反观我们大多数人用编码代理的方式,简直是把所有记忆都放在“进程内存”里。上下文窗口就是代理的 RAM,贪便宜把什么都往里面塞,从来没想过这个 RAM 会被系统强制清空。等到被压缩的那一刻,自然就是一场灾难。
2.2 给代理套一个“开机自检”协议
init.d 的思路给了我最直接的启发:如果代理的上下文注定会被压缩,那我就在文件系统里为它建一套“引导档案”,让它每次失忆之后都能像 Unix 服务一样,按固定脚本恢复状态。
这套档案的核心是一份 PROTOCOL.md,相当于它的 /etc/init.d 启动脚本。里面写清楚重启后第一件事做什么、第二件事做什么。我在实际项目里的 PROTOCOL.md 长这样:
# Agent Boot Protocol 如果你发现自己的上下文不完整、对项目的记忆模糊,或被告知上下文已压缩, 请立即按以下步骤执行: 1. 读取 .agent/STATE.yaml —— 这是你的当前状态。严格按其中的 current_task 继续。 2. 读取 .agent/DECISIONS.md 的最后 20 条 —— 这是你最近做过的重要决策与理由,避免推翻已定结论。 3. 读取 .agent/TODO.md 的前 10 条 —— 这是待办清单,找出当前最优先的 next_action。 4. 按 STATE.yaml 中的 files_to_load 列表,逐个读取相关源文件。 5. 如果以上步骤执行完仍不确定下一步,向用户询问,不要猜测。这个协议最关键的地方在于第 5 条——不许猜。编码代理失忆后最大的风险不是不干活,而是自信地干错活。有了明确指令让它先加载状态文件、再定向读源码,就能把失忆后的不确定性压到最低。
实操心得:PROTOCOL.md 一旦写好就别频繁改。它相当于代理的“出厂引导”,你自己每次都要保持最新,但也不要让它长得超过一屏。引导步骤超过 5 条,代理就会在执行时偷懒跳步。
3. 第二味解药:像《记忆碎片》一样把记忆外包给环境
3.1 电影里的生存系统:便条、纹身和仪式
《记忆碎片》的男主角 Leonard 患的是顺行性遗忘症,新发生的事情只能记住几分钟。正常人靠大脑记忆,他靠外部环境记忆。他的系统里有三样东西:纹身,记录永久不变的核心事实——比如“他杀了你老婆”“别接电话”;拍立得照片,记录人物和地点的客观信息,旁边写着备注;还有一套固定的仪式和操作流程,让他每次“醒来”时都能快速定位自己身处何阶段、下一步该干嘛。
这个系统最妙的地方在于,它完全不依赖大脑能记住多少,而是环境替他记住。你看他活得虽然艰难,但确实能够持续推进调查——因为信息不随记忆消失,只随环境变化而丢失。只要他的笔记还在、纹身还在、流程还在,他就永远可以重启调查状态。
这不就是我们想要代理拥有的能力吗?代理的“顺行性遗忘”就是上下文压缩。它同样不可能靠“更努力地记忆”来抵抗压缩,唯一可靠的方案就是在项目环境里建立体外记忆。
3.2 把电影系统翻译成代码库的“体外记忆”
我把 Leonard 的三件套翻译成了编码代理的三类文件:
纹身对应那些绝对不许变、每次都要重新确认的项目事实。比如系统架构约束、模块边界、接口契约、命名规范。这些信息放进 .agent/FACTS.md,它是最高优先级的事实源,代理每次重启都要重读,且不得违背。
拍立得照片对应决策日志,即 DECISIONS.md。每条记录包含时间、决策内容、理由、备选方案为什么被否决。照片会褪色、会被乱写,但 Leonard 依然依赖它们;同理,决策日志也可能写得不够好,但只要有记录,代理在重启后至少知道自己曾经选过哪条路,不会盲目推翻自己。
仪式对应检查点动作。Leonard 每次做关键动作前都会拍照、写备注、确认纹身;代理每次进入高风险操作前也应该强制执行“状态写入”。我把这个写进了系统提示词:改接口签名前、大规模重构前、排查出根因后、切换任务文件时,这四个时刻必须更新 STATE.yaml 和 DECISIONS.md。
实操心得:很多人只记“做了什么”,不记“为什么这么做”。DECISIONS.md 里一定要写理由。理由写清楚了,代理重启后才能区分“这个决策是硬约束还是临时选择”。光记结论不记理由,等于拍立得上只画了个黑点,谁都看不懂。
4. 完整实现:一套对压缩免疫的代理工作区
4.1 目录结构总览
我不想把方案搞得太重,核心就是项目根目录下建一个.agent目录,放几个纯文本文件。不需要数据库,不需要特殊工具,任何编码代理都能直接读写。我用的结构如下:
project/ .agent/ PROTOCOL.md # 开机引导协议,失忆后最先读的文件 FACTS.md # 永久事实:架构约束、接口契约、命名规范 STATE.yaml # 当前状态:进行到哪、下一步做啥、刚碰过哪些文件 DECISIONS.md # 追加式决策日志:时间 + 决策 + 理由 TODO.md # 待办清单:按优先级排,标注阻塞项 checkpoints/ # 阶段检查点:关键节点的时间点快照不用把所有逻辑放在一个文件里,职责拆开反而更容易让代理按需加载。FACTS.md 是重读次数最少的,STATE.yaml 是每次重启必读的,DECISIONS.md 是只读尾部的。
4.2 三个核心文件的实操模板
先说 STATE.yaml,它必须是机器可解析的格式,我选了 YAML。内容不需要多,够代理定位自己就行:
project: user-profile-service current_stage: implement-cache-layer current_task: 完成 Redis 缓存装饰器的编写 last_file_edited: services/profile_cache.py next_action: 为装饰器补充缓存穿透保护 blocked: false files_to_load: - services/profile_cache.py - repositories/user_repo.py - config/cache_config.py active_branches: - feature/cache-layer recent_findings: 已排除数据库连接池问题,延迟集中在重复查询FACTS.md 的条目最好每条一两行,像是给代理的“永久纹身”:
## 架构约束 - 所有数据访问必须走 repository 层,禁止在 service 层直查数据库 - 缓存键格式统一为 user:{id}:profile,禁止自定义变体 - 对外接口的返回结构统一为 { code, data, message }DECISIONS.md 是纯追加式,每条记录用一个固定模板:
## [2025-06-12] 缓存策略选择 Redis + 本地过期标记 - 决策:缓存统一走 Redis,key 由 profile_cache.py 统一管理 - 理由:多实例部署下本地缓存会不一致,统一走 Redis 保证一致性 - 否决方案:进程内 Caffeine 缓存(因多实例不一致)、DB 旁路缓存(因实现复杂)4.3 压缩发生后,代理的“冷启动”流程
假设代理正在改代码,系统突然提示上下文已压缩,代理对当前任务只剩模糊印象。我把 PROTOCOL.md 里的冷启动流程归纳成下面这个操作序列:
第一步,读 PROTOCOL.md 本身——这个文件短,读完代理就知道自己该按流程走。第二步,读 STATE.yaml——它看到 current_task 是“完成缓存装饰器”,next_action 是“补充缓存穿透保护”,立刻知道刚才干到哪了。第三步,读 DECISIONS.md 尾部——看到缓存策略已经定了,就不会再纠结“要不要换一种缓存方案”。第四步,按 files_to_load 列表打开那三个源文件,重新加载代码上下文。第五步,回到 TODO.md,认领最优先的待办项。
整个过程花不了多少 token,但代理从“失忆懵圈”变成“恢复工作状态”只需要一两次交互。我实测下来,这个冷启动流程通常只需要一轮对话就能把代理拉回正轨,而以前等代理自己摸索回状态,往往要浪费几万 token。
4.4 写检查点的时机:宁可多写,不可不写
状态文件不是写一次就完了,关键是要在正确的时间点刷新。我这里有一个强制规则,四个时刻必须更新状态:
开始一项大改动前,先把当前状态固化一下,这样折腾坏了还能回退。排查出根因的那一刻立刻记录 findings,因为根因是排查链的终点,也是最容易被压缩掉的成果。切换文件或切换任务前,把上一个任务的输出和结论写进 DECISIONS.md,防止回头继续时断片。每完成一个可验证的小任务,更新 STATE.yaml 的 next_action,让代理永远知道下一步方向。
注意:不要让代理把状态文件当成“写日记”。每条记录必须紧凑、结构一致、有明确的行动指向。过度运维状态文件会挤占真实开发的 token 预算,得不偿失。
5. 实测记录与失败模式
5.1 一个 20 万 token 长会话的完整观察
这套方案真正让我信服,是在一个真实项目的长会话里。项目是一个带用户服务、订单模块、管理后台的中型系统,代码规模不大但跨文件依赖很多。会话积累到 20 万 token 时,我故意让代理继续实现一个新功能,果然到某个节点上下文被压缩了。
压缩前代理的工作状态很清醒,知道用户服务用 repository 模式、缓存键的规范、订单模块的接口签名。压缩后它第一反应果然开始犯迷糊,甚至问我“订单模块的订单号生成策略你之前在哪儿提过”。我直接丢给它一句话:“按 PROTOCOL.md 执行冷启动。”下一轮开始,代理读完了状态文件和决策日志,回头看它的回答,已经能准确说出当前正在改哪个文件、下一步要做什么、为什么不能直接用缓存键的变体。整个过程只用了一轮交互的引导成本。
对比没有这套方案之前,同样的情况我得把之前定过的东西重新叙述一遍,有时候代理还会坚持它的“幻觉”——声称某个接口之前定义过,但实际上根本没定义。方案落地后,“接口不存在”这类幻觉大幅减少,因为代理知道先加载文件再回答问题。
5.2 四类典型失败模式与排查方法
用了一段时间,也总结出几个常见的翻车情况,整理成一张表,遇到问题可以对着查:
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| 失忆后代理完全不理协议,直接开始猜测 | PROTOCOL.md 的入口没有被写进系统提示词,代理不知道有这文件 | 在系统提示词里固定追加:“开始前先查 .agent/PROTOCOL.md,若上下文不完整必须按协议执行” |
| 代理读了状态文件但依然跑偏 | STATE.yaml 更新滞后,内容与真实代码状态不一致 | 强化写状态时机的强制规则,特别是“切换文件前必须更新” |
| 决策日志越长越没人读 | DECISIONS.md 几百条之后,代理读尾部也找不到关键决策 | 定期把重要且仍生效的决策上浮到 FACTS.md,DECISIONS.md 只留最近 30 条以内可读 |
| 代理花太多 token 维护状态文件 | 状态文件模板太复杂,代理被迫反复改写大文件 | 简化模板,STATE.yaml 保持一次到两次交互就能写完的量级 |
踩坑提醒:这套方案不是银弹。如果代理的提示词太短、系统提示里根本没有 PROTOCOL.md 的入口,代理即使看到 .agent 目录也不会主动初始化。第一件事永远是把它写进系统提示词的最前面,当作开机引导的第一行。
6. 重要提醒与后续扩展
6.1 别把“免疫”变成新的“累赘”
方案核心是“少而精”,不是“大而全”。我见过有人把这类状态文件做成了一整套带数据库同步的复杂系统,结果代理每轮都要花掉几千 token 去读写和同步状态,留给写代码的空间反而缩水了。这套架构最适合的形态始终是几个纯文本文件,总量控制在几 KB 级别,让代理用至少一次交互就能完全载入。
另一个容易踩的坑是“层层套娃”。比如 FACTS.md 里写“详见 DEPENDENCIES.md”,DECISIONS.md 里又引用一大堆外部文件。一旦代理需要为了找一条信息连读五六个文件,冷启动的 token 成本就上来了,协议本身的优势就没了。保持扁平,让代理打开文件的第一屏就能拿到最关键的内容。
还有一点,状态文件要跟代码库一起提交到版本控制里。我习惯把 .agent 目录纳入 git 管理,每次检查点写入就伴随一次 commit。这样万一代理产生了错误的状态记录,还能通过历史版本找回上一个正确状态。这相当于给“体外记忆”加了一层备份,成本极低,收益很大。
6.2 这套思路还能延伸到哪些场景
init.d 加《记忆碎片》这套组合的适用范围其实比编码代理宽得多。只要是需要“长时运行且随时可能断片”的系统,都可以套用同样思路:外部状态加引导协议加频繁检查点。
一个是多代理协作。多个编码代理任务不同、会话独立,完全可以共享同一个 .agent 目录,通过 FACTS.md 和 DECISIONS.md 同步事实与决策,避免各说各话。
一个是离线批处理任务。比如让代理跑一个持续数小时的批量重构,期间如果上下文耗尽或进程中断,重启后凭状态文件就能接着上次的进度继续,而不是从头再来。
还有一个是知识沉淀。我发现这套文件化记忆的习惯,本质上只是在逼迫代理“把思考显式化”。把决策理由写下来,不仅是为了飞单后恢复状态,更是为了让未来的自己(和未来的其他人)能够看懂当初为什么这么设计。这已经超出了对抗上下文压缩的范畴,变成了项目长期可维护性的基础功夫。
我个人在实际操作中的体会是,代码代理有没有“文件系统记忆”,差别不是体验上的差异,而是能不能用于严肃生产工作的根本差异。会话上下文只是缓存,缓存一定失效;项目目录才是持久层,可靠系统永远靠持久层说话。你最好从新项目的第一天就把 .agent 目录建好,写下第一版 PROTOCOL.md 和空白的 STATE.yaml。这个动作一分钟就够,但它决定了你的代理是“断电即失忆的玩具”,还是“重启即恢复的工程工具”。
最后再分享一个小技巧:我每次让代理开始一项新任务前,都会在下达指令时附一句“开工前先把 STATE.yaml 里 current_task 同步成这个新任务”。这个方法比任何复杂配置都管用,因为你永远无法保证代理会在你不提醒的情况下主动维护好状态,但一句简单的指令就能保证状态永远是最新的。记住,你越是在状态文件上多花一点点时间,代理在漫长会话里就越不可能出岔子。