ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI编码代理失忆难题:用init.d与《记忆碎片》架构实现上下文压缩免疫

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 重新加载时的“唤醒流程”

这是整套机制最关键的环节。代理被压缩后重新加载,不能直接把所有决策日志和快照都读一遍——那又变成大上下文了。唤醒流程必须是有序的、增量的。

我的唤醒流程分四步:

  1. 读最后一条决策日志:知道当前处于哪个决策节点
  2. 读对应的状态快照:知道当前文件树和任务上下文
  3. 读最近 3-5 轮顺叙对话:知道刚才在做什么具体操作
  4. 按需回溯:如果当前任务涉及某个早期决策,再定向读取那条决策日志

这四步下来,通常只需要 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_id="snapshot-$(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里加一行“上次中断时的状态”,比如“正在等编译结果”或“刚改完接口还没跑测试”。代理唤醒后看到这行,能更快接上中断前的节奏,不用从头推理“我上次干到哪了”。这行由代理在每轮结束时自己更新,成本很低,但效果立竿见影。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进