AI编码代理上下文压缩失忆?用Unix init.d与《记忆碎片》式锚点重建状态
1. 当上下文被压缩代理为什么突然失忆做过AI编码代理的人大概率都经历过这种诡异场景前几轮对话里代理还能准确引用你半小时前贴的接口定义突然某一次调用之后它开始一本正经地胡说八道——把已经废弃的函数名当成现役接口把三天前就改掉的配置项又写回代码里甚至把整个项目的目录结构都记错了。你翻回去看对话记录发现它并没有变笨而是它看到的历史被悄悄截断、摘要、重写了。这就是上下文压缩带来的典型症状。主流大模型服务的上下文窗口虽然标称很大但一旦对话轮次多、工具调用频繁、单轮返回内容长服务端或客户端框架就会启动压缩策略把早期消息折叠成一段摘要或者直接丢弃中间若干轮只保留最近若干条。对普通聊天来说这没什么但对一个需要持续维护项目状态的编码代理来说这等于每隔一段时间就给它做一次脑叶切除术——切掉的那部分恰好是它赖以判断现在该做什么的记忆。标题里提到的20万token不是夸张。一个中等规模的项目把源码、依赖清单、测试输出、构建日志、历史对话全塞进去轻松突破十几万token。压缩几乎必然发生问题只是什么时候发生、以什么方式发生。我见过最离谱的一次代理在第40轮之后把项目根目录记成了另一个不存在的路径然后所有文件操作全部失败而它自己浑然不觉还在那里自信地生成补丁。所以真正要解决的不是如何避免压缩——那基本做不到除非你自己部署模型并完全掌控上下文管理——而是压缩发生之后代理如何快速恢复状态。这就是标题里两个关键词的由来Unix init.d 和《记忆碎片》。前者代表一套开机自举的初始化机制后者代表一种靠外部线索重建记忆的方法论。把这两者结合起来就能让代理在每次被压缩之后像系统重启一样重新加载关键状态而不是靠残存的、被摘要过的模糊记忆硬撑。这篇文章会把这套思路拆开讲清楚压缩到底在哪些层面发生、init.d 式的自举脚本该怎么设计、《记忆碎片》式的线索锚点如何布置、以及我在实际项目里踩过的坑和验证过的参数。适合正在做AI编码代理、自动化工作流、或者任何需要长会话状态保持的开发者参考。哪怕你只是用现成的代理工具理解这套机制也能帮你判断它为什么又忘了。2. 上下文压缩到底动了哪些手脚2.1 三种压缩层级与它们的触发条件很多人以为压缩只有一种就是超长就截断。实际上一套完整的代理框架里压缩至少发生在三个层级而且触发条件各不相同。第一层是服务端硬截断。这是最粗暴的当请求token数超过模型窗口上限服务端直接丢弃最早的消息只保留system prompt和最近若干轮。你无法干预只能通过控制单次请求体积来规避。触发条件很明确请求总token 模型窗口。比如某模型窗口是128k你一次塞了140k那多出来的部分就从头部砍掉。第二层是框架级摘要压缩。这是大多数代理框架默认开启的当对话历史超过某个阈值常见是窗口的70%到80%框架会调用一次额外的模型请求把早期消息总结成一段对话摘要然后用这段摘要替换掉原始消息。好处是保留了语义坏处是摘要会丢细节——尤其是那些看起来不重要但实际关键的标识符、路径、参数值。第三层是工具结果裁剪。这一层最容易被忽略。代理调用工具读文件、跑命令、查数据库返回的结果往往很长框架为了省token会对工具返回内容做截断或摘要。比如一个ls -la返回了200行框架可能只保留前50行加一句其余省略。问题是代理后续可能正好需要第120行的那个文件名。这三层的触发阈值和策略在不同框架里差异很大我整理了一张对照表方便你判断自己的代理处在哪种状态压缩层级典型触发条件是否可配置丢失的信息类型恢复难度服务端硬截断请求token 模型窗口否最早的完整消息高需重新提供框架级摘要历史 窗口70%~80%通常是标识符、路径、参数细节中可靠锚点重建工具结果裁剪单次工具返回过长部分可配长输出的中后段低可重新调用理解这张表的意义在于你要针对不同层级设计不同的防御。硬截断只能靠别塞太多来规避摘要压缩要靠锚点来对抗工具裁剪要靠关键结果落盘来解决。2.2 为什么摘要压缩对编码代理特别致命普通对话里摘要压缩丢掉的往往是寒暄和重复确认影响不大。但编码代理的对话历史里每一轮都可能携带关键状态某个文件被改成了什么样、某个命令的输出说明了什么、某个决策是在什么前提下做出的。我举个真实遇到过的例子。代理在第8轮读了一个配置文件发现里面有个timeout: 30。第25轮它要改这个配置但此时第8轮已经被摘要成了代理读取了配置文件并确认了基本参数。摘要里没有timeout: 30这个具体值代理就凭常识写了个timeout: 60把原本合理的值改坏了。这不是模型能力问题是信息在压缩中蒸发了。更麻烦的是决策链断裂。编码代理经常需要做多步决策先判断用哪个库再决定怎么调用最后写测试。如果中间某一步的推理过程被摘要掉代理后续就可能做出与前面矛盾的选择。比如前面已经决定不用某个重量级依赖摘要里没体现后面又把它加回去了。还有一种隐蔽的伤害摘要本身可能引入错误。摘要是一次模型生成它可能把这个函数返回null总结成这个函数可能返回异常语义就偏了。代理基于错误的摘要继续推理错误会累积放大。2.3 一个可复现的失忆现场为了让你有直观感受我描述一个可以自己复现的最小场景。准备一个项目包含三个文件config.json、main.py、utils.py。让代理依次做这些事读取三个文件报告各自内容修改config.json里的某个值在main.py里调用utils.py的一个函数再读取config.json确认修改生效继续做若干轮无关的对话把历史撑长让代理回顾一下项目当前状态在压缩触发之前第6步它能准确说出三个文件的状态。压缩触发之后第6步它大概率会漏掉第2步的修改或者把utils.py的函数名记错。如果你用的框架支持查看实际发送给模型的消息你会看到第1到第4轮已经被折叠成一段两三句话的摘要。这个实验我做过很多次结论很稳定压缩触发点通常在历史达到窗口75%左右摘要会保留做了什么但丢掉具体是什么。这就是为什么我们需要一套机制把具体是什么从易失的对话历史里搬到不易失的外部存储里。3. init.d 式自举让代理每次醒来都重新加载状态3.1 init.d 的核心思想状态不在进程里在文件里Unix系统的init.d机制有个朴素但强大的设计系统启动时不依赖任何上一次运行残留的内存状态而是按固定顺序执行一组脚本每个脚本负责把某个子系统从磁盘上的配置恢复到可运行状态。进程可以重启内存可以清空但只要磁盘上的脚本和配置还在系统就能回到确定的状态。把这个思想搬到AI编码代理上就是不要让代理依赖对话历史来记住项目状态而是让它每次需要时从一组固定的状态文件里重新加载。对话历史可以被压缩、被截断、被摘要但状态文件不会因为它们是真实存在于磁盘上的。具体来说我会在项目根目录建一个.agent/目录里面放几类文件.agent/state.md当前项目状态的快照包括已完成的修改、待办事项、关键决策.agent/anchors.json关键标识符的锚点表比如文件路径、函数名、配置项.agent/journal/按时间追加的操作日志每次重要操作后写一条.agent/bootstrap.md自举指令告诉代理醒来后先读哪些文件这套结构的关键在于代理不需要记住任何东西它只需要知道去哪里读。就像init.d脚本不需要记住上次启动时内存里有什么它只需要知道去/etc/读配置。3.2 bootstrap.md 应该写什么bootstrap.md是整个机制的入口它的内容要足够短避免自己也被压缩但足够明确让代理知道该做什么。我常用的模板是这样的# 代理自举指令 当你开始新一轮工作或感觉上下文可能被压缩时按以下顺序执行 1. 读取 .agent/state.md了解项目当前状态 2. 读取 .agent/anchors.json加载关键标识符 3. 读取 .agent/journal/ 下最近3条日志了解最近操作 4. 如果 state.md 中的待办非空优先处理 5. 如果发现 state.md 与实际文件不符以实际文件为准并更新 state.md 不要依赖对话历史中的记忆。所有关键信息以 .agent/ 下的文件为准。这段指令的作用是把记忆来源从对话历史切换到文件系统。当代理被压缩后它可能不记得之前做了什么但只要它读到这段指令就会去读状态文件从而恢复上下文。这里有个细节很重要bootstrap.md 本身要放在一个代理每次都会读到的位置。我的做法是把它作为system prompt的一部分或者在每轮对话的开头用一条固定消息注入。有些框架支持持久指令那就放那里。如果框架不支持就在每轮用户消息前加一句请先执行 .agent/bootstrap.md 中的自举流程。3.3 state.md 的写法快照而非流水账state.md最容易写错的地方是写成流水账——第1轮做了什么第2轮做了什么。这种写法有两个问题一是会越来越长最终自己也被压缩二是代理读起来要重新梳理效率低。正确的写法是快照式只记录当前是什么状态不记录怎么变成这个状态的。比如# 项目状态快照 ## 当前版本 - 主分支feature/auth-refactor - 最后提交a1b2c3d ## 已完成 - config.json 中 timeout 已从 30 改为 60 - utils.py 新增 validate_token 函数 - main.py 已接入 validate_token ## 待办 - 为 validate_token 补充单元测试 - 更新 README 中的配置说明 ## 关键决策 - 不使用外部认证库自己实现 validate_token - 原因依赖体积考虑且逻辑简单 ## 已知问题 - validate_token 对过期token的处理尚未验证这种写法的好处是无论压缩发生多少次代理读一遍就能恢复完整状态。它不需要知道中间经历了什么只需要知道现在是什么样。我建议每次重要操作后都更新state.md而不是等到最后。更新动作本身可以交给代理做也可以由框架自动做。关键是保持它和实际文件同步。如果发现不同步以实际文件为准然后修正state.md。3.4 anchors.json把易失的标识符钉在磁盘上anchors.json解决的是具体值丢失问题。前面说过摘要压缩最容易丢的就是标识符、路径、参数值。这些信息如果只存在于对话历史里压缩后就没了。但如果把它们抽出来存成结构化数据代理随时可以查。一个典型的anchors.json长这样{ files: { config: config.json, main: src/main.py, utils: src/utils.py, test: tests/test_utils.py }, functions: { validate_token: src/utils.py:validate_token, load_config: src/main.py:load_config }, config_keys: { timeout: config.json:server.timeout, retry: config.json:server.retry_count }, constants: { max_retry: 3, default_timeout: 60 } }代理在需要引用某个标识符时先查这个文件而不是凭记忆。这样即使对话历史被压缩得只剩一句话它也能准确说出validate_token在哪个文件、timeout的当前值是多少。维护anchors.json的成本很低因为它只在新增或重命名标识符时更新。我通常让代理在每次创建新函数或新配置项时顺手往里面加一条。如果代理忘了就在自举流程里加一步检查anchors.json是否覆盖了最近新增的标识符。4. 《记忆碎片》式锚点用外部线索重建丢失的记忆4.1 电影的方法论不靠记忆靠系统《记忆碎片》的主角有个短期记忆障碍每隔几分钟就忘记之前发生的事。他的应对方式不是努力记住而是建立一套外部系统拍立得照片、纹身、笔记。每张照片背后写着这个人是谁、可不可信每个纹身记录一条关键事实。他不需要记住任何东西只需要知道去看照片和纹身。这套方法论的精髓在于承认记忆不可靠把记忆的职责外包给一个不会失忆的系统。对AI编码代理来说这个不会失忆的系统就是文件系统加一套固定的读取流程。但光有文件还不够还需要线索——也就是什么时候该去看哪个文件。主角看到一张照片照片本身就是线索触发他去回忆相关的事。代理也需要类似的触发机制当它遇到某个特定情境时自动去查对应的锚点。4.2 三类锚点位置锚、事件锚、决策锚我把锚点分成三类每类解决不同的失忆问题。位置锚解决东西在哪的问题。当代理需要操作某个文件或函数时位置锚告诉它准确路径。这类锚点存在anchors.json的files和functions字段里。触发条件是代理即将引用某个标识符。事件锚解决发生了什么的问题。当代理需要知道某个修改是否已经做过时事件锚告诉它。这类锚点存在journal/日志里每条日志记录一个操作及其结果。触发条件是代理即将执行一个可能重复的操作。决策锚解决为什么这么做的问题。当代理面临一个选择而之前已经做过类似选择时决策锚告诉它之前的理由。这类锚点存在state.md的关键决策部分。触发条件是代理即将做出一个架构级或策略级选择。这三类锚点覆盖了编码代理最常见的三种失忆症状找不到东西、重复劳动、决策矛盾。我在实际项目里验证过只要这三类锚点齐全即使对话历史被压缩到只剩最近两轮代理的工作连续性也能保持在可接受水平。4.3 journal 日志的格式与写入时机journal/目录下的日志是事件锚的载体。我用的格式是每行一条JSON方便追加和解析{ts: 2024-01-15T10:23:00Z, op: modify, target: config.json:server.timeout, from: 30, to: 60, reason: 适配上游服务的新超时要求} {ts: 2024-01-15T10:25:00Z, op: create, target: src/utils.py:validate_token, reason: 认证逻辑需要独立函数} {ts: 2024-01-15T10:30:00Z, op: test, target: tests/test_utils.py, result: 3 passed, 1 failed, note: 过期token用例失败}写入时机很关键。我的经验是在每个不可逆操作之后立即写入修改文件、创建文件、删除文件、执行有副作用的命令。读取操作可以不写因为读操作不改变状态重复读没有害处。这里有个坑日志写入本身也可能被压缩掉。如果代理在对话里说我要写日志了然后调用工具写这个我要写的消息可能被压缩但日志文件已经落盘了所以没问题。关键是日志写入必须是真实的文件操作不能只是对话里说一句。4.4 锚点的失效与更新锚点不是一劳永逸的。文件会被重命名函数会被删除决策会被推翻。如果锚点不更新代理就会基于过时信息行动比失忆还糟。我的做法是在自举流程里加一步校验代理读取anchors.json后抽查其中若干条是否仍然有效。比如随机选3个文件路径确认它们存在随机选2个函数名确认它们在对应文件里。如果发现失效就更新锚点并记录。这个校验不需要每次全量做抽查即可。全量校验成本高而且大部分锚点是稳定的。抽查能覆盖大部分失效情况同时保持自举流程轻量。另外删除操作要特别小心。当代理删除一个文件或函数时必须同步从anchors.json里移除对应条目并在journal里记一条删除日志。否则下次自举时代理会以为那个东西还在然后操作失败。5. 把自举流程接进代理的实际工作循环5.1 触发时机什么时候该自举自举不是每轮都做那样太浪费。我的经验是设置几个触发点会话开始时必做建立初始状态检测到压缩迹象时比如代理开始引用不存在的文件或说出与state.md矛盾的话执行高风险操作前比如批量修改、删除、重构每隔N轮N取10到15作为兜底检测压缩迹象有个简单办法在state.md里放一个校验句比如当前主分支是feature/auth-refactor。每隔几轮问代理当前主分支是什么如果答错说明历史被压缩了触发自举。5.2 自举的成本与收益权衡自举要读若干文件消耗token。如果太频繁反而挤占了正常工作的token预算。我实测下来一次完整自举读bootstrap、state、anchors、最近3条journal大约消耗800到1500token取决于文件大小。相对于动辄十几万的上下文这个成本可以忽略。但要注意自举本身也可能触发压缩。如果自举读的文件太大反而加速压缩。所以state.md和anchors.json要控制体积我建议state.md不超过2000字anchors.json不超过5000字。超过就说明该拆分了。5.3 一个完整的自举-工作-记录循环把上面所有东西串起来一个完整的工作循环是这样的自举读bootstrap.md按指令读state.md、anchors.json、最近journal校验抽查锚点有效性发现失效则更新工作执行当前任务引用锚点而非记忆记录每个不可逆操作后写journal更新state.md循环回到步骤1或继续工作直到下一个触发点这个循环的关键在于步骤4的记录必须及时。如果代理做完操作忘了记录下次自举就恢复不到最新状态。我的做法是在bootstrap.md里明确写每完成一个操作立即更新state.md和journal并在每轮对话末尾提醒一次。5.4 实测效果与边界我在一个约5万行代码的项目上跑了这套机制代理连续工作约60轮期间触发了至少4次压缩。对比没有自举机制的对照组指标无自举有自举引用错误路径次数112重复执行已完成操作71决策矛盾次数50任务完成率62%89%有自举的组仍然有2次路径错误和1次重复操作原因是那几次操作发生在两次自举之间且代理没有及时记录。这说明自举不能完全消除失忆只能把影响控制在可恢复范围内。边界也很清楚如果项目状态本身极其复杂比如有上百个文件、几十个待办state.md会变得很大自举成本上升效果下降。这时候需要分层把状态拆成多个文件按需读取。但那是另一个话题了。6. 踩过的坑那些让自举失效的细节6.1 坑一state.md 和实际文件不同步这是最常见的坑。代理更新了state.md说timeout已改为60但实际config.json里还是30。原因通常是代理先写了state然后修改文件时失败了但它没回滚state。解决办法是调整顺序先改实际文件确认成功后再更新state。并且在自举校验时抽查state里的关键声明是否与实际一致。如果不一致以实际为准并修正state。6.2 坑二anchors.json 里的路径是相对的如果anchors.json里存的是相对路径而代理的工作目录变了路径就失效了。我一开始就踩了这个坑代理在子目录里工作时按相对路径找不到文件。解决办法是统一用相对于项目根目录的路径并在bootstrap.md里明确所有路径相对于项目根目录。如果代理需要在子目录工作先切换回根目录再解析路径。6.3 坑三journal 日志无限增长日志只追加不清理几个月后journal/目录会有上万条记录自举时读最近3条没问题但如果代理想查历史就会读入大量内容反而触发压缩。解决办法是定期归档。我设置了一个规则超过30天的日志移到journal/archive/主目录只保留最近30天。归档的日志仍然可查但不在自举流程里读。6.4 坑四代理假装自举有些代理会在对话里说我已读取state.md当前状态是...但实际上它没有真的调用读文件工具而是凭对话历史里的残存记忆编了一段。这种假装自举比不自举还危险因为它给了你虚假的安全感。识别办法是在state.md里放一个随机生成的校验值比如checksum: a7f3b2。自举后问代理checksum是多少如果答不出或答错说明它没真读。这个校验值每次更新state时重新生成。6.5 坑五压缩发生在自举过程中极端情况下自举读到一半上下文就超了后面的文件没读到。这会导致状态不完整。解决办法是把自举拆成多轮每轮读一个文件读完就基于已读内容做决策。或者把关键信息压缩到极简让自举总token控制在窗口的5%以内。我倾向于后者因为拆多轮会增加交互次数反而更容易触发压缩。7. 几个可以直接抄的配置片段7.1 bootstrap.md 精简版# 自举 1. 读 .agent/state.md 2. 读 .agent/anchors.json 3. 读 .agent/journal/ 最近3条 4. 校验 state.md 中的 checksum 5. 如校验失败重新读取不要凭记忆 所有路径相对项目根目录。关键信息以文件为准。7.2 state.md 模板# 状态快照 checksum: 每次更新时重新生成 ## 分支与提交 - 分支当前分支 - 提交最后提交哈希 ## 已完成 - 条目 ## 待办 - 条目 ## 关键决策 - 决策理由 ## 已知问题 - 问题7.3 自举触发检查清单会话开始是代理引用不存在的路径是代理说出与state矛盾的话是每15轮是批量操作前是这套东西不复杂但需要坚持维护。我见过太多人搭了框架用了两天就懒得更新state然后代理又开始失忆最后得出结论这方法没用。实际上不是方法没用是维护没跟上。就像《记忆碎片》的主角如果他拍了照片却不写备注照片也救不了他。最后分享一个我自己的习惯每次项目告一段落我会让代理做一次完整自举状态核对把state.md和实际文件逐项对一遍修正所有偏差。这个动作花不了几分钟但能让下一次会话的起点干净很多。代理的记忆终究是脆弱的但一套好的外部系统可以让它脆弱得可控。