1. 从一次上下文截断事故说起
1.1 问题现场:当代理突然“失忆”
我第一次遇到这个问题时,正在用一个 AI 编码代理重构一个中型项目的模块依赖。前四十分钟一切正常,它能记住我们讨论过的接口约定、命名规范、甚至我随口提过一句“这个函数暂时不要动”。然后,上下文窗口撞到了上限,压缩机制启动,一切归零。
它开始重新问我:“你希望这个模块用什么命名风格?”而这个问题我们在半小时前已经确认过三遍。更糟的是,它开始“自信地”修改那些我明确说过不要碰的文件,因为它已经不记得那条约束了。
这不是模型能力问题,这是上下文管理问题。任何长时间运行的 AI 编码代理,只要上下文窗口有限,就一定会遇到压缩或截断。压缩本身不可怕,可怕的是压缩之后代理丢失了“我是谁、我在干什么、哪些事不能干”这些元信息。
我把这个现象叫做“脑叶切除术”——上下文被切掉一块之后,代理还是那个代理,语言能力还在,但人格和记忆的连续性断了。
1.2 为什么常规的“摘要压缩”救不了你
大多数框架处理上下文溢出的方式是:把旧消息总结成一段摘要,然后丢掉原始消息。这个思路在聊天场景下勉强能用,在编码代理场景下是灾难。
原因有三点。第一,摘要是有损的,而且损失的是约束类信息——那些“不要动 X”“必须用 Y 格式”的规则,在摘要里最容易被当成“细节”丢掉。第二,摘要是一次性的,压缩之后如果又溢出,你只能对摘要再摘要,信息衰减是指数级的。第三,摘要没有结构,代理无法判断哪条信息是当前的、哪条是过期的。
我试过调大摘要的保留比例、试过让模型自己决定保留什么,效果都不稳定。真正让我找到方向的,是一个看起来完全不相关的类比:Unix 的 init.d 和电影《记忆碎片》。
1.3 两个类比:init.d 的启动脚本与《记忆碎片》的纹身
Unix 系统的 init.d 机制有个特点:系统启动时,它不依赖“记忆”,而是按固定顺序读取一组脚本文件,每个脚本声明自己负责什么、依赖谁、在什么运行级别执行。系统重启一百次,行为完全一致,因为状态不在内存里,在文件系统里。
《记忆碎片》的主角有顺行性遗忘症,记不住新发生的事情。他的应对方式是:把关键事实纹在身上,把待办事项写在拍立得照片背面,把结论写在纸条上随身携带。他不靠记忆活着,他靠外部化的、结构化的笔记活着。
这两个类比的共同点是:把连续性从易失的上下文里,搬到持久化的、有结构的外部存储里。代理可以失忆,但只要它能重新读取这些外部状态,它就能恢复“我是谁、我在干什么”。
这就是我这套方案的核心思路。下面我把整套东西拆开讲。
2. 整体设计:把代理的“人格”和“任务状态”外置
2.1 核心思路:上下文只做工作台,不做仓库
我的设计原则一句话概括:上下文窗口是工作台,不是仓库。仓库在磁盘上,工作台上只放当前这一步要用的东西。
具体来说,我把代理需要持久记住的信息分成三类,分别外置:
- 身份与规则层:代理的角色定义、行为约束、禁止事项、输出规范。这一层几乎不变,对应 init.d 里的“系统级配置”。
- 任务状态层:当前在做什么任务、进行到哪一步、下一步是什么、有哪些未决问题。这一层频繁变化,对应《记忆碎片》里的拍立得照片。
- 事实与决策层:已经确认的技术决策、接口约定、命名规范、踩过的坑。这一层只增不改,对应主角身上的纹身。
这三层分别落到三个文件里,代理在每次“醒来”(新一轮推理)时按固定顺序读取。上下文压缩可以清空工作台,但清不掉磁盘。
2.2 为什么是文件而不是向量库
有人会问:为什么不把这些状态存进向量数据库,用检索的方式召回?
我实测过这条路,结论是:检索适合“找相关信息”,不适合“恢复状态”。状态恢复需要的是确定性——每次醒来必须读到完全相同的那几条核心信息,不能这次召回了约束 A、下次漏掉了约束 B。向量检索是概率性的,用它做状态恢复,等于把代理的“人格连续性”交给相似度分数,太不稳。
文件读取是确定性的。路径固定、顺序固定、内容固定,代理每次读到的“自我”完全一致。这一点上,init.d 的思路完胜。
2.3 目录结构长什么样
我用的结构大概是这样,你可以直接抄:
.agent/ identity.md # 身份、角色、全局规则 constraints.md # 硬约束、禁止事项 task_state.md # 当前任务、进度、下一步 decisions.md # 已确认的决策与约定(只增不改) scratchpad.md # 临时草稿,可被压缩清理前四个文件是“纹身”,最后一个scratchpad.md是“工作台”。压缩机制只允许清理scratchpad.md和上下文里的对话历史,前四个文件永远不动。
这个划分的关键在于:把“可丢弃”和“不可丢弃”在物理上分开。很多框架的失败就在于把所有信息混在一个上下文里,压缩时无法区分轻重。
3. 核心细节:每个文件写什么、怎么读、怎么更新
3.1 identity.md:代理的“我是谁”
这个文件回答三个问题:代理扮演什么角色、遵循什么工作方式、输出遵循什么格式。
我写的时候有个心得:不要写抽象的人格描述,要写可执行的行为规则。比如不要写“你是一个严谨的工程师”,而要写“修改任何文件前,先在 scratchpad.md 里写出修改计划和影响范围,等我确认后再动手”。
抽象描述在压缩后容易被模型自由发挥,可执行规则不会。下面是我实际用的一个片段:
# 角色 你是一个编码代理,负责在既有代码库上做增量修改。 # 工作方式 1. 每轮开始先读 task_state.md,确认当前步骤 2. 修改前先在 scratchpad.md 写计划,包含:改哪些文件、为什么、影响范围 3. 不确定的事情必须问,不要猜 4. 每完成一步,更新 task_state.md 的进度 # 输出格式 - 代码修改用 diff 格式 - 解释不超过三句话这个文件我基本不动,除非发现代理反复犯某类错误,才回来加一条规则。
3.2 constraints.md:那些“绝对不能碰”的红线
这是最容易被压缩机制误伤的部分,所以单独成文件。里面放的是硬约束:不能改的文件、不能用的依赖、必须保持的接口签名、性能红线等。
我踩过的一个坑是:约束写得太笼统,比如“不要破坏现有测试”。代理压缩后会把这条理解成“尽量别破坏”,然后理直气壮地改了一个测试的断言。后来我改成具体清单:
# 硬约束 - 禁止修改 tests/legacy/ 下任何文件 - 禁止修改 public API 的函数签名(见 decisions.md 第 3 条) - 新增依赖前必须问我 - 单次修改不超过 5 个文件具体到文件路径和数量,代理就没有发挥空间了。这个文件也是只增不改,废弃的约束标记为[已废弃]而不是删除,保留历史。
3.3 task_state.md:代理的“拍立得照片”
这个文件是变化最频繁的,也是恢复任务连续性的关键。它必须回答:当前任务是什么、进行到哪一步、下一步做什么、有哪些卡住的问题。
我的写法是固定模板,代理每轮更新:
# 当前任务 重构模块 A 的依赖注入方式 # 进度 - [x] 梳理现有依赖关系 - [x] 确认新方案(见 decisions.md 第 5 条) - [ ] 修改 module_a/core.py - [ ] 更新对应测试 - [ ] 跑全量测试 # 下一步 修改 module_a/core.py 的构造函数,注入 Config 对象 # 卡住的问题 无关键是那个“下一步”。压缩之后代理醒来,读完这个文件就知道该干什么,不需要重新推理。这就像《记忆碎片》主角照片背面写的“去找 Natalie”,简单直接。
3.4 decisions.md:只增不改的“纹身”
这个文件记录所有已确认的技术决策,格式是编号 + 决策 + 理由 + 日期。它只增不改,因为决策的历史本身就是重要信息——代理需要知道“为什么当初这么定”,才能在遇到冲突时正确取舍。
# 决策记录 ## 3. public API 签名冻结 日期:第 1 天 决策:public 下所有函数签名冻结,内部重构不得改变签名 理由:有外部调用方依赖,改动成本高 状态:生效中 ## 5. 依赖注入方案 日期:第 2 天 决策:采用构造函数注入,不用全局单例 理由:便于测试,避免隐式依赖 状态:生效中这个文件是代理“人格连续性”的核心。它可能忘了昨天讨论的细节,但只要读到“第 5 条:用构造函数注入”,它就不会跑偏。
3.5 读取顺序为什么重要
这四个文件的读取顺序不能乱:先 identity,再 constraints,再 task_state,最后 decisions。
原因是优先级递减。身份决定行为方式,约束划定边界,任务状态给出当前目标,决策记录提供背景。如果顺序反了,代理可能先看到任务细节,再看到约束,容易在推理时把约束当成“建议”而不是“红线”。
这个顺序对应 init.d 的启动顺序:先加载系统级配置,再加载服务级配置,最后启动具体服务。顺序本身就是一种优先级声明。
4. 实操过程:从零搭一套抗压缩的代理
4.1 第一步:初始化目录和模板
先建目录,把四个“纹身”文件的模板写进去。模板不用复杂,但要包含固定的标题结构,方便代理解析。
mkdir -p .agent touch .agent/identity.md .agent/constraints.md .agent/task_state.md .agent/decisions.md .agent/scratchpad.md然后往每个文件里填初始内容。identity.md 填角色和工作方式,constraints.md 先填已知红线,task_state.md 填当前任务,decisions.md 可以先空着。
这一步的注意点:模板里的标题层级要固定。代理靠标题定位内容,如果这次是# 当前任务、下次变成## 任务,解析就会出错。我一般用二级标题做区块,三级标题做条目。
4.2 第二步:改造代理的每轮启动逻辑
代理每轮“醒来”时,第一件事不是看用户消息,而是按顺序读这四个文件。伪代码大概是这样:
def wake_up(): identity = read(".agent/identity.md") constraints = read(".agent/constraints.md") task_state = read(".agent/task_state.md") decisions = read(".agent/decisions.md") system_prompt = f""" {identity} {constraints} {task_state} {decisions} """ return system_prompt这段拼出来的内容作为系统提示注入,优先级高于对话历史。这样即使对话历史被压缩得只剩一句话,代理的“自我”依然是完整的。
我实测下来,这个改造是整个方案里收益最大的一步。加之前,代理压缩后平均要花 3 到 5 轮才能重新进入状态;加之后,基本一轮就恢复。
4.3 第三步:定义压缩时的保护规则
压缩机制要改造成“只清理工作台,不动仓库”。具体规则:
- 对话历史可以压缩,压缩后只保留最近 N 轮和一段摘要
scratchpad.md可以清空或压缩- 前四个文件永远不进入压缩范围,它们通过文件读取注入,不占对话历史
这里有个实现细节:如果你的框架是把所有东西塞进一个 messages 数组再压缩,那前四个文件的内容也会被当成消息被压缩掉。正确做法是让它们走系统提示注入通道,而不是消息通道。系统提示通常不参与压缩。
注意:不同框架对系统提示的处理不一样,有的框架会把系统提示也算进 token 预算。如果你的框架是这样,那就把四个文件的内容做成“每轮重新注入”的形式,而不是“一次性注入后留在历史里”。
4.4 第四步:让代理自己维护状态文件
光有文件不够,代理得会更新它们。我在 identity.md 里加了一条规则:每完成一个步骤,必须更新 task_state.md;每确认一个决策,必须追加到 decisions.md。
更新用工具调用完成,比如:
def update_task_state(progress, next_step, blockers): content = f""" # 当前任务 {current_task} # 进度 {progress} # 下一步 {next_step} # 卡住的问题 {blockers} """ write(".agent/task_state.md", content)这里的关键是让更新成为强制流程,而不是可选项。我一开始没强制,结果代理干着干着就忘了更新,压缩后状态文件还是旧的,等于白搭。后来在 identity.md 里写成硬规则,并且每轮结束前检查一次,才稳定下来。
4.5 第五步:验证抗压缩效果
验证方法很直接:手动触发一次压缩,看代理能不能恢复。
我的测试方式是:让代理做一个多步骤任务,进行到一半时,手动把对话历史清空(模拟极端压缩),只保留系统提示注入的四个文件。然后发一条消息:“继续”。
如果代理能准确说出当前进度、下一步动作,并且不违反约束,说明方案生效。我第一次测的时候,代理确实说出了正确的下一步,但漏了一条约束——原因是 constraints.md 里那条约束写得太靠后,被它忽略了。后来我把约束按重要性排序,最重要的放最前面,问题解决。
这个测试建议做三遍以上,因为压缩后的行为有一定随机性,单次通过不代表稳定。
5. 常见问题与排查技巧实录
5.1 代理压缩后“人格漂移”怎么办
症状:压缩后代理的语气、工作方式变了,比如原来会先写计划再动手,现在直接改代码。
排查顺序:先看 identity.md 是否被正确注入,再看注入的内容是否完整。我遇到过一次是注入时把 identity.md 和 constraints.md 拼反了,导致约束被当成身份描述,代理行为完全跑偏。
如果注入没问题,那就是 identity.md 本身写得太抽象。解决办法是把行为规则写得更具体,最好带例子。比如“修改前先写计划”不如“修改前先在 scratchpad.md 写一段以‘计划:’开头的文字,包含改哪些文件和为什么”。
5.2 状态文件越来越大怎么办
task_state.md 如果只增不减,几天下来会变得很长,注入时又占 token。
我的处理方式是归档:已完成的任务移到task_state_archive.md,当前文件只保留进行中的任务。归档文件不注入,需要时再手动查。
decisions.md 我一般不归档,因为它只增不改,而且每条都很短。但如果超过 50 条,我会按主题拆成多个文件,注入时只注入当前任务相关的那个。
5.3 代理不遵守约束的三种典型情况
第一种:约束写得太笼统。前面说过,改成具体清单。
第二种:约束和任务冲突时,代理选择任务。比如约束说“不改测试”,任务说“让测试通过”,代理可能改测试。解决办法是在 constraints.md 里明确优先级:“约束优先级高于任务目标,冲突时必须停下来问我”。
第三种:约束太多,代理记不住。这时候要精简,只保留真正重要的。我一般控制在 10 条以内,超过就说明有些约束其实可以合并或降级为建议。
5.4 常见问题速查表
| 症状 | 可能原因 | 排查动作 | 解决方式 |
|---|---|---|---|
| 压缩后忘记任务 | task_state.md 未更新 | 检查文件修改时间 | 强制每步更新 |
| 压缩后违反约束 | 约束未注入或太笼统 | 检查注入内容 | 具体化约束并前置 |
| 代理行为漂移 | identity.md 太抽象 | 检查身份文件 | 改为可执行规则 |
| 状态文件过大 | 未归档 | 查看文件行数 | 归档已完成任务 |
| 决策被遗忘 | decisions.md 未追加 | 检查决策记录 | 强制确认后追加 |
| 注入内容被压缩 | 走了消息通道 | 检查注入方式 | 改用系统提示通道 |
5.5 几个我踩过的坑
第一个坑:把状态文件放在项目根目录,结果被代理自己“重构”时误删了。后来移到.agent/隐藏目录,并在 constraints.md 里加了一条“禁止修改 .agent/ 下任何文件”。
第二个坑:decisions.md 里写了决策但没写理由,代理后来遇到冲突时无法判断该不该推翻。加上理由之后,它能自己推理出“这个决策的前提还在,不能改”。
第三个坑:以为系统提示一定不占 token。实测发现有些框架会把系统提示也算进预算,导致注入内容被截断。解决办法是控制四个文件的总长度,我一般压在 2000 token 以内。
6. 一些延伸想法和实际体会
这套方案跑了一段时间之后,我发现它的价值不只是“抗压缩”。它实际上把代理的工作方式从“依赖记忆”变成了“依赖流程”。代理不再需要记住所有东西,它只需要知道去哪里读、读完怎么做。这反而让它更稳定,因为流程是确定的,记忆是易失的。
我后来把这个思路延伸到了多代理协作场景:多个代理共享同一套.agent/文件,每个代理有自己的 identity,但共享 constraints 和 decisions。这样它们之间的约定不会因为某个代理的上下文压缩而丢失。实测下来,协作时的“互相误解”明显减少。
还有一个意外收获:这套文件本身成了很好的项目文档。任务结束后,decisions.md 就是一份决策日志,task_state.md 就是一份工作记录。有时候我自己回头查“当初为什么这么设计”,直接翻 decisions.md 比翻聊天记录快得多。
如果你也在用长时间运行的编码代理,我建议先从 identity.md 和 constraints.md 这两个文件开始,哪怕不做完整的四文件方案,先把“身份”和“红线”外置,收益就已经很明显了。剩下的两个文件可以等遇到具体问题时再补。