1. 当上下文被压缩到只剩骨架,代理为什么还会“失忆”
做过长会话 AI 编码代理的人,大概都遇到过这种诡异场景:明明前面几十轮对话里已经把项目结构、命名约定、依赖版本、甚至某个函数的边界条件都讲清楚了,结果聊到某个临界点之后,代理突然开始“胡言乱语”——它把之前确认过的接口签名改掉,把已经排除的方案又提一遍,甚至信誓旦旦地说某个文件不存在。你回头翻记录,发现那些信息确实还在上下文里,只是被压缩成了一段摘要,而摘要里恰好丢掉了它此刻最需要的那几个字。
这个现象我把它叫做“脑叶切除术式失忆”。上下文窗口就像一个容量有限的记忆体,当 token 逼近上限,系统必须做压缩:把冗长的历史折叠成更短的表示。折叠本身没错,错的是折叠的粒度和选择——它往往保留了“用户说了什么”的语义梗概,却丢掉了“系统当前处于什么状态”的结构化事实。对一个人来说,这就像你记得“昨天讨论过登录流程”,却忘了“登录接口的 token 字段叫 access_token 而不是 token”。前者是叙事记忆,后者是程序性记忆,而编码代理真正依赖的是后者。
这篇内容想聊的,是我怎么用一套很“土”的思路,让一个长会话编码代理在 20 万 token 级别的压缩反复冲刷下依然保持行为一致。核心借用了两个隐喻:Unix 的 init.d 启动脚本体系,以及电影《记忆碎片》里那种“靠外部纸条和纹身维持自我连续性”的做法。关键词会围绕上下文压缩、AI 编码代理、init.d、状态外置、记忆锚点、会话连续性展开。适合正在做长会话代理、RAG 编排、或者被“聊着聊着就崩”折磨过的开发者参考,小白也能看懂思路,老手可以直接抄结构。
先说结论,免得你看到一半觉得我在绕:不要把关键状态放在会被压缩的对话历史里,而要把它外置成代理每次都能重新读取的“启动脚本”和“记忆锚点”。对话历史可以烂、可以被摘要、可以丢,但只要代理每一轮都能重新加载那份外置状态,它的行为就不会漂移。这就是 init.d 的思路——系统启动时不依赖“上一次运行残留的内存”,而是按固定顺序执行一组幂等的初始化脚本。也是《记忆碎片》的思路——主角不信任自己的短期记忆,所以把事实纹在身上、写在拍立得上,每次行动前先读一遍。
下面我会拆开讲:压缩到底砍掉了什么、init.d 式状态外置怎么设计、记忆锚点怎么写才不会被摘要吞掉、以及我在实测中踩过的几个坑。全程不涉及任何敏感工具,纯讲工程结构。
2. 上下文压缩真正砍掉的是“程序性记忆”,而不是“叙事记忆”
2.1 一次典型的压缩事故复盘
先还原一个我实际遇到过的场景,方便你对照自己的项目。假设代理在做一个多模块的重构任务,前 40 轮里我们确认了这些事实:
- 项目使用某测试框架,配置文件在根目录,测试命令是固定的一条;
- 某个核心模块对外暴露的函数签名有三个参数,第三个是可选的回调;
- 数据库字段命名统一用下划线风格,不用驼峰;
- 已经明确排除了“引入新的状态管理库”这个方案。
聊到第 60 轮左右,上下文触发压缩。压缩后的摘要大概是:“用户与助手讨论了项目重构,涉及测试配置、函数签名、数据库命名规范,并排除了引入新状态管理库的方案。”看起来挺全,对吧?但代理接下来做的事是:把函数签名改成了两个参数,把数据库字段写成了驼峰,还重新提议引入状态管理库。
问题出在哪?摘要保留了“讨论过什么”的叙事,却丢掉了“结论是什么”的程序性事实。函数签名具体几个参数、字段用什么风格、哪个方案被排除——这些是可执行的状态,不是可叙述的事件。压缩算法天然倾向于保留事件脉络,因为那读起来更连贯;而可执行状态是离散的、无叙事的,最容易被当成冗余砍掉。
2.2 叙事记忆与程序性记忆的分野
我用一个更直白的类比。你让一个朋友帮你搬家,你跟他讲了一遍“哪些箱子放客厅、哪些放卧室、哪个箱子易碎”。如果他记性好,全程不用再问。但如果他中途接了个电话,回来只记得“帮你搬家、有些箱子要分类”,那他就得重新问。这里的“分类规则”就是程序性记忆,“搬家这件事”就是叙事记忆。
AI 编码代理的上下文压缩,几乎总是优先保住叙事记忆,牺牲程序性记忆。原因不复杂:叙事记忆是连续的文本流,压缩后仍然通顺;程序性记忆是零散的约束条件,压缩时很难用一句话概括而不丢信息。所以你会看到代理“记得我们在做重构”,但“忘了重构的约束”。
理解了这一点,解决方案就清晰了:既然压缩一定会牺牲程序性记忆,那就别让它待在会被压缩的地方。把它挪到一个压缩碰不到的位置,让代理每一轮都重新读。
2.3 为什么“让摘要写详细点”是治标不治本
有人会说,那我让压缩摘要写详细点不就行了?我试过,效果有限,原因有三。
第一,摘要的详细程度和 token 预算直接冲突。你要求摘要保留所有约束,摘要就会变长,压缩的收益就没了,系统迟早还得再压一次,而第二次压缩会把第一次的细节再砍一轮。这是个递减的过程,越压越糊。
第二,摘要的生成是概率性的。同样的历史,两次压缩可能保留不同的细节。你没法保证“函数签名”这次被保住、下次还被保住。对编码任务来说,行为不一致比信息缺失更致命。
第三,也是最关键的:摘要终究是“二手信息”。代理读到的是“摘要说函数有三个参数”,而不是“函数定义本身”。一旦摘要和真实代码有出入,代理会基于错误前提继续推理,错误会累积。而外置状态可以是“一手信息”——直接读文件、直接读配置,没有中间商。
所以我的选择是:不跟压缩对抗,而是绕过它。压缩爱怎么压怎么压,我只保证代理每轮都能重新加载那份外置的、一手的状态。
3. 把代理的“人格”写进 init.d:状态外置的目录结构设计
3.1 init.d 的核心哲学:幂等、有序、可重入
Unix 的 init.d 体系有个很朴素的设计哲学:系统启动时,不假设任何服务已经就绪,而是按固定顺序执行一组脚本,每个脚本负责把系统带到某个确定状态。脚本是幂等的——跑一次和跑十次结果一样;是有序的——网络要先于依赖网络的服务启动;是可重入的——中途失败可以重跑。
我把这套哲学搬到代理身上。代理每一轮开始前,不假设自己“记得”任何东西,而是按固定顺序加载一组“状态脚本”,把自己带到确定的工作状态。这些脚本不放在对话历史里,而是放在代理可以主动读取的外部位置——可以是项目里的一个约定目录,可以是代理工具链里的一个固定文件,形式不重要,重要的是它不在压缩范围内。
具体来说,我在项目根目录建了一个隐藏目录,里面放几份职责单一的文件。代理的每一轮系统提示里,只写一句话:“开始任何操作前,先读取该目录下的全部文件。”就这一句,剩下的交给文件本身。
3.2 目录里到底放什么:四类状态文件
我把它分成四类,每类一个文件,职责不重叠。这样做的原因是:单一职责的文件在更新时不容易互相污染,代理读的时候也容易定位。
| 文件类别 | 作用 | 更新频率 | 是否可被压缩影响 |
|---|---|---|---|
| 项目宪法 | 命名规范、目录约定、技术栈版本 | 极低 | 否,外置 |
| 当前任务卡 | 本轮要做什么、验收标准 | 每轮 | 否,外置 |
| 决策日志 | 已确认的结论、已排除的方案 | 中 | 否,外置 |
| 接口契约 | 关键函数签名、数据结构定义 | 中 | 否,外置 |
“项目宪法”是最稳定的,基本一个项目周期内不变。它回答的是“这个项目长什么样”:字段用下划线还是驼峰、测试命令是什么、依赖锁在哪个文件。“当前任务卡”变化最频繁,每轮开始前更新,回答“这一轮的目标是什么”。“决策日志”记录那些“讨论过并拍板”的事,尤其是被排除的方案——这能防止代理反复提议已经被否掉的东西。“接口契约”直接放关键签名,代理要用的时候读原文,不靠记忆。
这四类文件加起来可能就几百行,但它们承载的正是压缩最容易砍掉的程序性记忆。代理每轮读一遍,成本可控,收益是行为不再漂移。
3.3 为什么是“读取”而不是“注入”
这里有个关键选择:这些状态是让代理主动读取,还是直接拼进系统提示?我选前者,理由是主动读取这个动作本身,就是一次记忆刷新。
如果你把状态直接拼进系统提示,代理是被动接收的,它可能“扫一眼就过”,尤其在长上下文里,系统提示的内容容易被淹没。而让它主动执行“读取文件”这个动作,相当于强迫它在每一轮开始时做一次显式的状态加载。这个动作在行为上更接近 init.d 的“执行脚本”,而不是“把脚本内容贴在墙上”。
实测下来,主动读取的代理在长会话里的行为一致性明显更好。我猜测原因是:读取动作产生了一条新的、靠近当前轮次的上下文记录,这条记录里包含了状态内容,而它离当前推理位置很近,不容易被远处的压缩波及。换句话说,你把状态“搬运”到了压缩的射程之外,又“搬运”回了当前轮次的有效范围。
3.4 一个最小可用的目录示例
不涉及任何具体工具,只讲结构。假设项目根目录下有个约定目录,里面是这样:
.agent-state/ constitution.md # 项目宪法 task-card.md # 当前任务卡 decisions.md # 决策日志 contracts.md # 接口契约constitution.md里写死那些不该变的东西,比如“所有数据库字段使用下划线命名”“测试命令为固定的一条”“禁止引入新的状态管理库”。task-card.md每轮重写,只写本轮目标。decisions.md追加式记录,每条一行,格式统一为“结论 + 日期 + 原因”。contracts.md放关键签名,用代码块原样贴。
代理的系统提示里只有一句加载指令。剩下的,全靠它自己去读。这套结构跑起来之后,我最大的感受是:代理终于有了“自我连续性”,它不再依赖那条随时会被截断的对话流来维持身份。
4. 记忆锚点:让关键事实像纹身一样刻在压缩砍不到的地方
4.1 《记忆碎片》给工程带来的启发
电影里的主角有个设定:他无法形成新的长期记忆,所以他把重要事实纹在身上,把线索写在拍立得照片背面,每次行动前先读一遍。这套机制的精髓不在于“记录”,而在于记录的位置选择——他不在脑子里记,因为脑子不可靠;他记在身体和随身物品上,因为这些载体不会被“遗忘”这个机制波及。
映射到代理身上:对话历史就是那个不可靠的“脑子”,而外置文件就是“身体和随身物品”。但光有外置文件还不够,因为代理可能“忘了去读”。所以还需要第二层机制:记忆锚点——在对话流的关键位置埋下指向外置状态的短标记,让代理在压缩后依然能顺着标记找回状态。
4.2 锚点的写法:短、可识别、指向明确
锚点不能长,长了会被压缩当成冗余砍掉;也不能太隐晦,隐晦了代理认不出来。我的经验是,锚点应该是一个固定的、格式统一的短句,比如“状态见约定目录”或者“约束以契约文件为准”。每次代理做出关键决策、或者用户确认某个结论时,就在对话里插入这样一句。
为什么这样有效?因为压缩算法对“重复出现的固定短语”往往有较高的保留倾向——它看起来像是一个结构性标记,而不是普通叙述。即使某次压缩把锚点也砍了,只要还有一两个残留,代理就能顺着它去读外置文件。这就像主角身上不止一处纹身,丢了一处还有别的。
我实测过一个极端情况:把对话压到只剩原长度的很小一部分,锚点短语依然有较大概率被保留,因为它短、重复、且不携带叙事信息,压缩时反而“不值得砍”。这是个反直觉的发现——越是没有叙事价值的东西,越容易在压缩中幸存,因为它不参与“故事线”的构建,压缩算法对它不敏感。
4.3 锚点与任务卡的联动
锚点单独存在意义不大,它必须和外置状态联动。我的做法是:锚点短语固定指向“当前任务卡”,而任务卡里再指向“决策日志”和“接口契约”。这样形成一条链:对话里的锚点 → 任务卡 → 决策日志/接口契约。压缩可能砍掉链的中间环节,但只要锚点还在,代理就能重新走一遍这条链。
这条链的设计要点是每一环都要能独立定位。任务卡里不能只写“见决策日志”,而要写清楚“决策日志在约定目录下,文件名固定”。这样即使代理对目录结构一时模糊,也能靠固定文件名找回来。冗余在这里是好事,因为冗余意味着抗压缩。
4.4 一个容易忽略的细节:锚点的时效性
锚点不能一直用同一个。如果整个会话里锚点短语完全不变,代理可能会把它当成背景噪音忽略掉。我的做法是让锚点短语保持格式一致但内容微调,比如“状态见约定目录(第 N 轮更新)”。这个 N 让锚点有了时效性,代理读到时会意识到“这是较新的状态”,而不是一个陈旧的、可能过期的标记。
这个细节看起来小,但实测中它明显提升了代理对锚点的响应率。原因可能是:带时效的锚点在语义上更“像一条有效指令”,而不像一段静态文本。压缩算法对“看起来像指令”的内容,保留倾向也更高。
5. 实测中的三次翻车:压缩、锚点与状态文件的边界
5.1 第一次翻车:状态文件被代理自己改坏了
外置状态最大的风险,是代理在操作过程中把状态文件本身改坏。我遇到过代理在“整理项目”时,顺手把决策日志里的几条结论“优化”了一下措辞,结果把“排除方案 A”改成了“考虑方案 A”,行为立刻漂移。
修复方案是给状态文件加一层保护:在项目宪法里明确写“约定目录下的文件只读,任何修改必须经用户确认”。同时在代理的工具权限上,把该目录设为只读(如果你的代理框架支持的话)。如果框架不支持只读,那就在系统提示里用强指令约束,并且在每轮加载后做一次校验——比如用文件哈希对比,发现被改就报警。
这个坑的教训是:外置状态的可信度,取决于它的不可篡改性。一旦代理能随意改状态,外置就失去了意义,因为它会把状态改成“自己以为的样子”,而不是“事实的样子”。
5.2 第二次翻车:锚点被当成普通文本忽略
有一段时间我发现代理对锚点视而不见,明明对话里插了“状态见约定目录”,它还是凭记忆瞎猜。排查后发现,锚点被埋在了大段叙述中间,格式上和周围文本没有区分度,代理把它当成了普通句子。
修复方案是给锚点加格式标记,比如用固定的引用块或者固定的前缀符号,让它在视觉和结构上都和周围文本区分开。格式一致性很重要——每次都用同一种标记,代理才会把它识别为“结构性元素”而非“叙述内容”。这跟 init.d 脚本要有固定 shebang 是一个道理,格式本身就是一种信号。
5.3 第三次翻车:压缩把“任务卡”的更新历史也压没了
任务卡是每轮更新的,但对话里关于“任务卡更新了什么”的叙述会被压缩。有一次压缩后,代理读到的是旧版任务卡(因为它没重新读文件,而是凭对话记忆),结果做了一轮已经完成的任务。
修复方案是双保险:一是任务卡文件名里带轮次或时间戳,代理每次读最新的;二是在锚点里明确写“以文件内容为准,不以对话记忆为准”。这句话很关键,它直接告诉代理“你的记忆不可信,去读文件”。实测下来,这句话能显著降低代理凭记忆行事的概率。
5.4 三次翻车后的稳定结构
经过这三次,我最终稳定下来的结构是这样的:
- 约定目录只读,代理只能读不能写;
- 锚点用固定格式,带轮次标记,明确指向文件;
- 任务卡文件名带轮次,代理每次读最新;
- 系统提示里有一句“以文件内容为准,不以对话记忆为准”;
- 每轮开始强制读取,读取动作本身作为状态刷新。
这套结构跑长会话,代理的行为一致性有了质的提升。它不再依赖那条随时会被压缩的对话流,而是依赖一套它每轮都会重新加载的外部状态。压缩可以继续砍对话,但砍不到状态文件,也砍不断锚点指向的链。
6. 把“抗压缩”做成习惯:日常维护与扩展思路
6.1 状态文件的维护节奏
外置状态不是建好就完事,它需要维护。我的节奏是:项目宪法基本不动,除非技术栈变更;任务卡每轮更新,写完就丢旧的;决策日志追加式增长,定期归档;接口契约随代码变更同步。维护成本其实不高,因为每份文件职责单一,更新时不用动别的。
关键是更新要及时。如果代码改了但契约没改,代理读到的是过期契约,行为照样漂移。我的做法是把“更新契约”作为代码变更流程的一部分,改完代码顺手改契约,形成肌肉记忆。
6.2 扩展到多代理协作
这套思路可以扩展到多代理场景。多个代理共享同一份约定目录,每个代理有自己的任务卡,但共享项目宪法和接口契约。这样即使代理之间不直接通信,也能通过共享状态保持一致。锚点机制在这里尤其有用——每个代理在自己的对话里埋锚点,指向共享状态,压缩各自对话时不会影响共享状态。
我试过两个代理协作做重构,一个改接口一个改调用方。共享契约文件让它们对签名的理解始终一致,即使各自的对话都被压缩过好几轮。这个场景下,外置状态的价值被放大了——它成了代理之间的“共同记忆”。
6.3 什么情况下这套方法不划算
不是所有场景都值得上这套结构。如果任务很短,几轮就结束,那对话历史本身够用,外置状态是过度设计。如果任务对一致性要求不高,比如纯创意生成,那压缩导致的漂移可能无所谓。这套方法的价值在长会话 + 高一致性要求的场景,比如多轮重构、长周期调试、跨模块协作。
判断标准很简单:如果你发现代理“聊着聊着就变了”,而且这种变化让你不得不反复重申同样的约束,那就值得上这套结构。反之,如果代理一直很稳,那说明你的任务还没触到压缩的边界,暂时不用折腾。
6.4 一个可以立刻试的最小实验
如果你想验证这套思路,不用一上来就搞四类文件。先做一件事:在项目里建一个固定文件,写三条你最常重申的约束,然后在系统提示里加一句“每轮开始先读这个文件”。跑一个长会话,观察代理是否还会违反这三条约束。
我当初就是这么起步的。三条约束、一个文件、一句加载指令,成本极低,但效果立竿见影。等你确认有效,再逐步扩展成完整的约定目录。这套东西的魅力就在于它不依赖任何特定工具,纯粹是结构设计,换任何代理框架都能用。
最后分享一个我自己的体会:对抗上下文压缩,本质上不是技术问题,而是记忆架构问题。你没法让压缩不砍东西,但你可以决定什么东西不该被砍,以及怎么让它砍不到。init.d 和《记忆碎片》给的启发,说到底就一句话——别把重要的东西放在会丢的地方。把它纹在身上,每次行动前读一遍。