AI编码代理失忆难题:用init.d与《记忆碎片》架构实现上下文压缩免疫
1. 从一次上下文崩溃说起为什么长会话代理总会“失忆”做过 AI 编码代理的人都遇到过这个场景你花了两个小时跟代理一起把一个模块从零搭起来中间讨论了接口设计、踩了三个依赖冲突的坑、定下了命名规范结果对话一长代理突然开始胡言乱语——它把你三小时前明确否掉的方案又提了一遍把已经改好的函数名写回旧版本甚至开始编造根本不存在的文件路径。你往上翻记录发现它并不是“忘了”而是上下文被压缩之后那些关键决策被摘要成了几句不痛不痒的废话。这个现象我管它叫“脑叶切除术”。上下文窗口就像一个容量固定的工作台当 token 数量逼近上限系统必须做压缩要么截断最早的对话要么用一个小模型把历史总结成摘要。无论哪种方式被切掉的部分往往是“为什么这么做”的推理链留下的只是“做了什么”的结论。而编码这件事恰恰极度依赖推理链——一个变量为什么叫这个名字、一个边界条件为什么这么处理这些信息一旦丢失代理就会退化成只会补全代码的自动机。标题里提到的“20 万 token”不是夸张。一个中等复杂度的重构任务加上反复的报错日志、文件内容、diff 输出轻松就能堆到十几万 token。我实测过一个跨模块的接口迁移任务光是来回粘贴的编译错误就有四万多 token。在这种量级下任何依赖“把全部历史塞进上下文”的方案都是脆弱的。那怎么办标题给了两个看似不相关的线索Unix init.d和《记忆碎片》。前者是操作系统里那套“开机自动拉起服务、崩溃自动重启”的机制后者是那部倒叙顺叙双线交织的电影。把这两个东西揉进 AI 代理的设计里就能得到一套对上下文压缩免疫的架构。下面我把这套思路完整拆开讲。2. 核心思路拆解把代理当成一个需要“开机自启”的系统服务2.1 为什么是 init.d 而不是“更聪明的记忆”大多数人遇到代理失忆第一反应是“换个更大的上下文窗口”或者“换个更强的摘要模型”。这两个方向我都试过结论是治标不治本。窗口再大也有上限摘要模型再强也会丢信息因为你无法保证它每次都把“关键决策”判定为关键。init.d 的思路完全不同。它不关心“记忆”本身它关心的是状态的可重建性。一个 Unix 服务重启之后它不需要记得自己上次运行到哪它只需要读取配置文件、检查运行时状态、然后继续工作。换句话说持久化的不是对话历史而是对话产生的副作用。映射到 AI 编码代理上代理不应该依赖“我记得我们讨论过用 snake_case”而应该依赖“项目里有一个.editorconfig文件写着naming snake_case”。代理不需要记得“上次编译报错是因为缺了某个依赖”而应该依赖“项目里有一个deps.lock文件记录了完整依赖树”。对话历史可以丢但这些落盘的状态不能丢。这就是 init.d 哲学的第一层把易失的上下文转化为持久的、可被重新读取的配置和状态文件。2.2 《记忆碎片》结构倒叙与顺叙的双轨记忆《记忆碎片》的主角有短期记忆障碍他的应对方式是把关键信息纹在身上、拍成照片、写成纸条。电影的结构也是双线——一条线倒叙一条线顺叙两条线在中间交汇。这套结构用在代理上非常巧妙。传统的对话历史是纯线性的从第一条消息到最后一条。压缩的时候系统倾向于保留最近的、丢掉最早的。但编码任务的关键决策往往发生在早期——架构选型、接口定义、命名规范这些都在开头几轮就定下了。线性压缩恰好把最重要的部分丢掉了。双轨结构是这样设计的顺叙轨正常的对话流记录“我们正在做什么”。这条轨可以随意压缩、截断因为它只承载过程。倒叙轨一个反向索引的“决策日志”记录“我们为什么这么做”。每做一次关键决策就往这个日志里追加一条带时间戳和理由的记录。这条轨永不压缩因为它本身就是高度浓缩的。当上下文被压缩后代理重新加载时先读倒叙轨决策日志再读顺叙轨最近几轮对话就能快速重建出“我是谁、我在做什么、我为什么这么做”的完整认知。这就像主角每次醒来先看自己身上的纹身。2.3 两套机制如何咬合单有 init.d 的持久化还不够因为状态文件会越来越多代理重新加载时怎么知道该读哪些单有《记忆碎片》的决策日志也不够因为日志只记录决策不记录决策产生的实际文件变更。两者咬合的方式是决策日志负责“指路”状态文件负责“落地”。决策日志里的一条记录长这样[2024-XX-XX 14:32] 决策接口层采用适配器模式 理由上游有三个数据源格式不一致直接耦合会导致每次新增源都要改核心逻辑 影响文件src/adapters/*.ts, src/core/pipeline.ts 状态快照snapshot-014代理重启后读到最后一条决策就知道要去src/adapters/目录看当前有哪些适配器去src/core/pipeline.ts看核心逻辑怎么调用的。它不需要记得讨论过程只需要读文件就能重建认知。这就是“对上下文压缩免疫”的本质——压缩掉的是对话不是认知。3. 核心细节解析决策日志与状态快照的具体设计3.1 决策日志的字段设计与写入时机决策日志不是流水账不能每轮对话都写。写得太密它自己就变成了需要压缩的长文本写得太疏关键信息又会丢。我的经验是只在“不可逆决策”发生时写入。什么叫不可逆决策满足以下任一条件即可选定了某个方案且该方案会排除其他方案比如选了 REST 就不太可能同时用 GraphQL定义了会被多处引用的命名、接口、数据结构确认了某个约束条件比如“这个模块不能引入新的第三方依赖”踩了一个坑并确定了规避方式比如“这个库的某个版本有 bug锁定到上一个版本”每条记录的字段我固定为六个字段说明是否必填时间戳精确到分钟用于排序是决策内容一句话说清选了什么是理由为什么这么选这是最关键的是影响文件涉及哪些文件路径是状态快照对应的快照编号是替代方案考虑过但放弃的方案及原因否“理由”字段是灵魂。我见过太多代理在压缩后把决策内容记住了但把理由忘了结果遇到一个边界情况就不知道该怎么处理因为它不知道当初为什么这么设计。理由写清楚了代理就能自己推导出边界情况该怎么处理。写入时机上我建议在每轮对话结束时由代理自己判断是否触发写入而不是每轮都写。判断逻辑可以很简单如果这轮对话产生了文件变更且变更涉及接口或命名就写一条。这个判断本身可以用一个轻量规则实现不需要再调一次大模型。3.2 状态快照的粒度与存储策略状态快照不是 git commit。git commit 记录的是代码的完整状态而状态快照记录的是代理的认知状态。两者互补但不能互相替代。快照的粒度我试过三种每轮快照太密存储爆炸而且大部分快照没有区别每小时快照太疏中间的关键变更可能丢失决策触发快照每写一条决策日志就拍一个快照这个粒度最合适快照的内容包括当前工作目录的文件树、关键文件的哈希值、当前打开的“任务上下文”比如正在改哪个模块、以及最近 N 轮对话的摘要。注意快照里不存完整对话历史只存摘要。完整历史交给顺叙轨去管快照只负责“重建认知所需的最小信息集”。存储策略上我用的是“快照链 定期归档”。最近 20 个快照保留完整内容更早的快照只保留决策日志和文件哈希具体文件内容从 git 历史里按哈希找回。这样既控制了存储又保证了可追溯性。3.3 重新加载时的“唤醒流程”这是整套机制最关键的环节。代理被压缩后重新加载不能直接把所有决策日志和快照都读一遍——那又变成大上下文了。唤醒流程必须是有序的、增量的。我的唤醒流程分四步读最后一条决策日志知道当前处于哪个决策节点读对应的状态快照知道当前文件树和任务上下文读最近 3-5 轮顺叙对话知道刚才在做什么具体操作按需回溯如果当前任务涉及某个早期决策再定向读取那条决策日志这四步下来通常只需要 2000-4000 token 就能重建完整认知相比原来十几万 token 的上下文压缩比超过 95%。而且这个重建过程是确定性的——同样的决策日志和快照每次重建出的认知都一样不会像摘要那样每次都有细微差异。注意唤醒流程里第 4 步“按需回溯”需要代理有能力判断“当前任务涉及哪个早期决策”。这个判断我建议用一个简单的关键词匹配 文件路径匹配来实现不要用大模型判断否则又引入不确定性。4. 实操过程从零搭一套免疫压缩的代理工作流4.1 目录结构约定先定目录结构这是整套机制的地基。我在项目根目录下建一个.agent/目录里面放所有代理相关的状态文件.agent/ decisions.log # 决策日志纯文本一行一条 snapshots/ # 状态快照目录 snapshot-001.json snapshot-002.json ... current-task.md # 当前任务描述代理每次唤醒先读这个 constraints.md # 硬约束清单比如“不许引入新依赖”decisions.log用纯文本而不是 JSON是为了让代理能快速 grep。JSON 虽然结构化好但读取时需要解析而且容易因为格式问题导致整个文件不可读。纯文本一行一条即使某行格式坏了其他行还能用。current-task.md是代理的“当前指令”。每次唤醒时代理先读这个文件知道“我现在要干什么”。这个文件由人维护或者在任务切换时由代理自己更新。它不需要长三五句话即可。constraints.md是硬约束比如“这个项目用 pnpm 不用 npm”“所有异步操作必须用 async/await 不用回调”。这些约束不常变但一旦违反就会产生大量返工所以单独拎出来每次唤醒必读。4.2 决策日志的写入脚本代理自己写日志容易格式不统一我写了一个简单的 shell 函数来规范化写入# 用法log_decision 决策内容 理由 影响文件 替代方案 log_decision() { local ts$(date %Y-%m-%d %H:%M) local snapshot_idsnapshot-$(printf %03d $(ls .agent/snapshots/ | wc -l | tr -d )) echo [$ts] 决策$1 | 理由$2 | 影响文件$3 | 快照$snapshot_id | 替代方案$4 .agent/decisions.log # 拍快照 mkdir -p .agent/snapshots { echo { echo \timestamp\: \$ts\, echo \decision\: \$1\, echo \files\: \$(find src -type f -newer .agent/snapshots/last_marker 2/dev/null | tr \n ,)\, echo \task\: \$(cat .agent/current-task.md | head -5 | tr \n )\ echo } } .agent/snapshots/$snapshot_id.json touch .agent/snapshots/last_marker }这个脚本做了三件事写日志、拍快照、更新标记文件。标记文件用于下次拍快照时判断哪些文件是新改的。实际使用时代理在每轮对话结束时调用这个函数参数由代理自己填。实操心得理由字段一定要让代理用“因为……所以……”的句式写不要只写“因为……”。我试过只写原因不写结论结果代理唤醒后看到原因自己推导出了另一个结论跟当初的决策不一致。加上“所以……”能强制它把结论也写清楚。4.3 唤醒流程的实现唤醒流程我封装成一个脚本代理启动时先跑这个脚本把输出作为第一条系统消息注入#!/bin/bash # wakeup.sh - 代理唤醒脚本 echo 当前任务 cat .agent/current-task.md echo echo 硬约束 cat .agent/constraints.md echo echo 最近决策 tail -5 .agent/decisions.log echo echo 当前文件树 find src -type f -name *.ts -o -name *.js -o -name *.py | head -30 echo echo 最近对话摘要 # 这里从顺叙轨读取最近几轮摘要具体实现依赖你的对话存储方式 cat .agent/recent-summary.md 2/dev/null || echo (无摘要)这个脚本的输出通常只有 1500-2500 token但包含了重建认知所需的全部信息。代理读完这个输出就知道自己是谁、在哪、要干什么、有什么约束、最近做了什么决策。4.4 与现有工具链的集成这套机制不需要替换你现有的代理框架它是在框架之上加一层“状态管理层”。我用的是这样的集成方式对话存储仍然用框架自带的对话历史但设置一个较短的保留窗口比如最近 20 轮更早的自动丢弃决策日志由代理在每轮结束时调用log_decision写入唤醒注入在每次新会话开始时先跑wakeup.sh把输出作为 system message 注入任务切换当任务切换时更新current-task.md并写一条“任务切换”决策日志这样即使框架把对话历史压缩得面目全非代理也能通过唤醒流程重建认知。我实测过一个 18 万 token 的重构任务中间经历了 7 次上下文压缩代理没有出现一次“失忆”导致的返工。5. 常见问题与排查技巧实录5.1 决策日志膨胀了怎么办这是最常见的问题。项目跑久了decisions.log可能积累几百条记录唤醒时tail -5只看最近 5 条但代理有时需要回溯更早的决策一 grep 就拉出一大堆。我的解法是分层归档。每 50 条决策为一个“纪元”归档到.agent/decisions-epoch-N.log主日志只保留当前纪元的记录。唤醒时只读主日志需要回溯时再按纪元号去读归档文件。纪元号可以从决策日志的时间戳推算或者直接在current-task.md里标注“当前纪元3”。另一个技巧是给决策打标签。在决策内容前加一个方括号标签比如[架构]、[命名]、[依赖]、[坑]。回溯时按标签 grep比全文搜索快得多也准得多。5.2 快照和 git 冲突了怎么办有人会问既然有 git为什么还要快照两者不冲突但职责不同。git 记录的是代码状态快照记录的是认知状态。git 里看不到“我们为什么选了这个方案”快照里能看到。但确实会有冗余。我的做法是快照里不存文件内容只存文件路径列表和哈希值。需要文件内容时用哈希值去 git 历史里找。这样快照文件很小通常只有几 KB不会和 git 抢存储。注意快照里的哈希值要用 git 的 blob 哈希不要用 md5 或 sha256。git blob 哈希可以直接用git cat-file找回内容其他哈希还得自己维护映射表。5.3 代理不按格式写决策日志怎么办这是提示词工程问题。我的经验是不要指望代理自觉。在 system prompt 里明确写“每轮结束必须调用 log_decision参数格式如下”并给一个完整示例。如果代理还是写错就在唤醒脚本里加一个校验读最近一条决策日志如果格式不对就在唤醒输出里加一句“上一条决策日志格式异常请修正”。更狠的办法是用工具调用强制格式。如果你的代理框架支持 function calling把log_decision定义成一个工具参数用 JSON Schema 约束。这样代理不按格式写就调不通自然会修正。5.4 唤醒后代理仍然“跑偏”怎么排查唤醒后跑偏通常是三个原因之一现象可能原因排查方法代理重复已否决的方案决策日志里“替代方案”字段没写清楚grep 决策日志看被否决方案是否记录在案代理用了错误的命名constraints.md里没写命名规范检查 constraints 是否覆盖了命名约定代理不知道当前在改哪个文件current-task.md太模糊把任务描述细化到文件级别我踩过最坑的一次是current-task.md里写“优化数据处理流程”代理唤醒后把整个数据层重写了一遍因为它不知道“优化”具体指哪个环节。后来我把任务描述改成“优化src/core/pipeline.ts里transformBatch函数的批处理逻辑目标是把单批耗时从 200ms 降到 100ms 以内”代理就精准多了。5.5 多代理协作时怎么共享状态如果多个代理同时在一个项目上工作决策日志会互相覆盖。我的解法是按代理分文件.agent/decisions-agentA.log、.agent/decisions-agentB.log唤醒时读所有代理的日志按时间戳合并排序。但更根本的解法是按模块分文件.agent/decisions-frontend.log、.agent/decisions-backend.log。每个代理只负责一个模块只写自己模块的日志。跨模块的决策单独放.agent/decisions-global.log由人工维护或由主代理写入。这样既避免了冲突又让每个代理的唤醒上下文更聚焦。6. 一些踩坑之后的个人体会这套机制我用了大半年最大的体会是不要试图让代理“记住”要让它“能查”。记忆是不可靠的尤其是经过压缩的记忆。但查询是可靠的只要状态文件写得好代理每次都能查到准确的信息。另一个体会是决策日志的质量比数量重要得多。我一开始每轮都写结果日志里全是“修改了某文件某行”这种废话真正关键的架构决策反而被淹没了。后来改成只在不可逆决策时写日志短了但每条都有分量唤醒效果反而更好。最后分享一个小技巧在current-task.md里加一行“上次中断时的状态”比如“正在等编译结果”或“刚改完接口还没跑测试”。代理唤醒后看到这行能更快接上中断前的节奏不用从头推理“我上次干到哪了”。这行由代理在每轮结束时自己更新成本很低但效果立竿见影。