☰
AI编码代理失忆自救:用init.d与外部化记忆对抗上下文压缩
2026/10/15 6:21:15 网站建设 项目流程

我见过一个编码代理最惨烈的死法——不是报错,不是死循环,而是它坐在那里,眼神清澈,记忆归零。上一秒它还在帮我重构十万行代码里的模块依赖,下一秒一次上下文压缩触发,它像刚醒来的失忆患者,礼貌地问:“你想让我做什么?”

做 AI 编码代理最怕的不是任务太难,而是这个“失忆”瞬间。我把上下文压缩称为代理的“脑叶切除术”。听上去夸张,但它真的会把代理当前工作记忆里最关键的部分——刚刚得出的结论、约好的命名规则、踩过的测试坑、还没提交的改动意图——全部切掉。尤其当会话已经打满 20 万 token,再叠加一次截断,那效果不亚于彻底失忆。

这篇文章想分享的是我如何从两个非常古老的机制里找到解药:Unix 的 init.d 启动体系,和电影《记忆碎片》。前者给我一整套“开机恢复状态”的流程框架,后者给了我“不信任记忆、把关键信息刻在外部”的设计哲学。两者拼在一起,我的 AI 编码代理对上下文压缩这件事,变得几乎免疫。

1. 事故现场:20 万 token 的窗口是怎么变成“脑叶切除术”的

1.1 为什么代理会突然失忆:上下文窗口不是仓库,是弹簧

先说底层机制。大模型的上下文窗口是一段连续且有限的空间,模型只能基于窗口里当下的 token 进行推理,并没有一个“指针”去翻阅窗口之外的内容。对编码代理来说,窗口里装的是这些:系统提示、工具定义、对话历史、工具执行后回传的 stdout,以及模型自己生成的中间推理。

表面上看,20 万 token 是一个很大的数字。很多模型的中文处理能力也相当可观,一个汉字大约占 1 到 2 个 token,数学上你可以塞进几十万汉字。但在编码代理的真实运行里,窗口消耗快得惊人:一次全仓代码扫描、两轮测试输出、几个大文件的读取,就能轻松吃掉五六万 token;如果再叠加代理反复的推理、修改、编译、读报错,窗口余量会像倒计时一样迅速见底。

一旦超出上限,主流编码代理产品通常会默默做三件事中的一件:

  • 滚动窗口裁剪:把中间历史一刀切掉,只保留最早的系统提示和最近的若干条消息;
  • 递归摘要:用一次额外调用把现有对话总结成一段摘要,再放回上下文;
  • 硬截断:直接丢弃超额部分,连提示都不给你。

这三招的共同点是有损。裁剪会丢失推理链,摘要会丢失细节,硬截断最干脆——把代理变成一张白纸。压缩发生时,代理正握着最后一句话的“尾巴”,等压缩完成,它手里只剩空气。

我用“脑叶切除术”来形容这个过程,不是因为它像医学手术,而是因为它很像:为了保住生命体征(让程序不崩溃),切割掉了负责“执行人格”的脑区(工作记忆)。之后,代理确实还在运行,但那个知道自己下一步要干什么的自我,已经没了。

1.2 内置压缩为什么救不回来

你可能想,既然有压缩机制,那应该还好吧?在普通聊天场景里,压缩也许够用;但在编码代理高密度、强因果的任务里,内置压缩带来的伤害经常大于帮助。

第一,压缩摘要会丢掉“为什么”。编码任务中最值钱的信息不是“改了哪个文件”,而是“为什么要这么改”。比如我们因为某个隐藏依赖决定不升级某库,或者因为某个集成测试太脆弱而放弃在构造函数里注入配置。这些“为什么”通常埋在多轮讨论、一连串报错和试探性修改里,很难被总结模型浓缩成一句准确的话。压缩一次,决策依据就断一截。

第二,摘要生成过程可能引入幻觉。把几小时的复杂推理压成两句话时,模型会倾向于补全一个“看起来合理”的解释。这个补全会污染后续所有决策,让代理在错误前提上继续工作。它可能不是故意的,只是摘要模型不知道某个细节时,会下意识地帮你“填坑”。

第三,新会话没有冷启动能力。有的压缩发生在同一会话内部,有的发生在代理重启之后。重启后,代理的上下文是全新的,框架不会主动回忆“上一个会话在干嘛”。于是我经常面对这种对话:它礼貌地重复最初的问题,礼貌地重读已经读过的文件,甚至理直气壮地把已经被否决的方案再提一遍。它不是不配合,而是真的不记得。

1.3 我观察到的三种“术后后遗症”

在密集使用编码代理几周后,我记录了压缩后代理反复出现的三种典型症状。

症状一:病灶复读。代理会重新生成一个已经被讨论并放弃的方案,或者在一个早已完成的阶段任务上再花一轮时间去“确认”。有次它甚至在三个新会话里分别提出了同一个我们已明确否决的技术选型,每次给出的理由都是“从架构角度看更合理”。它不知道我们已经做过这个判断了。

症状二:决策漂移。上一轮明确说好订单金额统一用整数分存储,不碰浮点数;下一轮代理却自作主张在接口层返回一个小数,还为此写了一大段解释。不是它变笨了,而是决策只存在于那段被切除的上下文里。它完全是以一个新成员的身份加入项目,自然不理解那些既有约定。

症状三:反复踩坑。代理第一次遇到某个构建报错,自己查了半小时终于找到原因;压缩之后,它把这件事忘得干干净净,又重新花半小时从头查起。人的时间会因此翻倍,耐心也会被消耗殆尽。

这些症状逼我得出一个结论:重要信息绝不能只放在上下文窗口里。窗口应该是一个临时工作台,而不是档案馆。这个判断,成了后面整套方案的起点。

2. 答案藏在《记忆碎片》里:外部化记忆的设计思路

2.1 Leonard Shelby 的教条:不信任你的记忆

电影《记忆碎片》里,男主角 Leonard Shelby 因头部创伤患了顺行性失忆,记不住新发生的事情。他给自己设计了一套极其偏执的外置记忆系统:重要的线索写在拍立得照片背面,临时目标写在便条上,最核心的信条干脆用纹身刻在身体上。每次醒来,他依靠这些东西重新拼出“我是谁、我要做什么、那件事做到哪一步了”。

这个设定简直是编码代理的完美写照。代理的模型权重里装着海量世界知识,但对“当前这个项目里发生了什么”一无所知。它的每一次新会话都像 Leonard 的每一次醒来。如果我们不把关键信息外置,代理就只能靠上下文窗口里残存的一点点内容艰难猜谜。

从电影里,我抄到三条核心教条:

  • 永远不要相信临时的“心智状态”,它随时会被清零;
  • 所有关键结论必须落在可以被外部读取的地方;
  • 读取的过程必须简单、固定、可重复,不能依赖某个好运气出现的瞬间。

把这三条翻译成工程语言,就是九个字:外部化、自描述、幂等加载。

2.2 三个设计原则:外部化、自描述、幂等

外部化最好理解:重要事实不放在对话里,而是写进文件系统,用 git 管理。这样压缩来了、进程崩了,都带不走这些文件。你只需要保证一件事:下一次启动时,它们能被找到。

自描述要稍微多想一层。同样是记录一条失败,两种写法差别巨大:

  • 写法一:“payment 模块测试挂了。”
  • 写法二:“payment 模块的 refund_test.go 在 2025-06-14 14:00 失败,因为 mock 没有更新 signer;失败前我们刚改了 contract_type 字段,下一步需要同步更新 mock。”

第一条对未来的代理几乎无用,第二条才是它能真正往下走的线索。自描述要求每条记忆包含必要上下文:时间、位置、原因、下一步。这就像 Leonard 在拍立得照片背后写字,他不只写“这是旅馆”,还会写“这人是骗子,别信他”。

幂等性则是为了防止二次污染。启动加载器每次读取同一份记忆时,应该得到同样的语义,不能让代理“读着读着就把记忆改了”。这也是我把记忆文件当作受保护资产、而不是普通工作文件的原因。幂等加载保证“恢复过程”本身不会成为新的故障源。

2.3 三层记忆体系:启动记忆、工作日志、长期知识

光说外部化还不够,记忆也需要分层。我把代理的记忆体系拆成三层:

  • 启动记忆,也叫 L0:每次代理启动时注入的一段精简上下文,由 agent_boot.d 的钩子脚本动态生成;
  • 工作日志,也叫 L1:代理运行中产生的逐日记录、TODO、决策调整,按日期追加;
  • 长期知识,也叫 L2:项目架构地图、接口约束、历史决策(ADR)、环境约定。这部分不常变化,是比较稳定的基座。

启动时,加载器从 L1 和 L2 里挑出当前最重要的小部分,拼成 L0 注入上下文。三层分别对应电影里 Leonard 的三种载体:便条是临时的,拍立得是阶段性的,纹身是拿不掉的。真正管住全局的,永远是刻得最深的那部分。

3. 向 Unix init.d 借一套“开机自启”的记忆流程

3.1 为什么是 init.d,而不是 crontab 或 shell profile

理解了三层记忆体系,下一个问题是:怎么让代理每次启动时,都“自动”恢复记忆?答案藏在 Unix 的老传统里。

init.d 是 Unix 系统的服务启动脚本目录。系统开机会按编号依次执行里面的脚本,每个脚本响应 start、stop、restart 等操作。它的价值不在于“能执行命令”,而在于把“开机后的状态恢复”变成一套确定性流程:脚本顺序固定、依赖关系明确、输出可以被日志化。

这种语义恰恰是编码代理缺的。代理每次重启,面临的其实是一个微型“系统启动”:它需要先知道自己在哪里、有什么约束、当前任务是什么,然后才能开始干活。

为什么不用 crontab?因为它擅长定时任务,不关心进程启动时的初始状态。代理需要的是“启动那一刻就加载一次”,不是“每五分钟执行一次”。

为什么不用 shell profile?那只能设置环境变量和别名,没有“按优先级注入上下文、生成启动报告”的机制。init.d 给了我一个更合适的模板:给记忆恢复步骤排好顺序,让它们依次执行,最后合并成一个“启动状态包”。

3.2 agent_boot.d 目录:我的扩展版 init.d

我建了一个 agent_boot.d 目录,放在项目根目录的记忆文件旁边。目录命名和编号方式完全模仿 init.d:

agent_boot.d/ ├── 00_memento_loader.sh # 读取 MEMORY.md 与当日 journal,生成“目标与决策简报” ├── 10_project_map.sh # 扫描目录结构 + 最近 git log,生成项目地图 ├── 20_todo_sync.sh # 从工作日志 .todo 文件里解析未完成任务 ├── 30_contract_check.sh # 检查接口/环境约束,如 .env.example 是否存在 ├── 40_git_state.sh # 输出当前分支、未提交改动、最近提交 hash └── 99_context_report.sh # 汇总所有钩子输出,形成最终启动上下文报告

编号的意义是确定顺序。00 必须最先执行,因为它决定代理的“自我认知”;10 到 40 是状态收集;80 以后放收尾类动作。如果你有某些脚本,不想每次启动都跑,把它们移出目录即可,加载器会自动跳过。整套流程和 init.d 很像:你可以通过目录中的存在性来控制某个服务是否启动。

3.3 钩子脚本不是摆设:它们真的在拼上下文

每个钩子脚本的职责是输出一段 Markdown 文本。比如 20_todo_sync.sh 可以写成这样:

#!/usr/bin/env bash set -euo pipefail TODO_FILE="${AGENT_HOME:-.}/journal/$(date +%Y-%m-%d).todo" if [[ -f "$TODO_FILE" ]]; then echo "### TODO 同步" grep -E '^[-*] \[[ x]\]' "$TODO_FILE" || true fi

脚本本身很简单,但有几个细节很重要。第一,它的输出带二级标题,方便加载器按语义合并;第二,用|| true保证脚本失败不会阻塞整个启动流程;第三,只在文件存在时才输出,避免空转垃圾。这就是 init.d 脚本里常见的容错哲学:一个脚本启动失败,不应该让整台机器卡住。

每个钩子脚本输出后,加载器按文件名顺序收集、拼接,就得到一份启动上下文报告。这份报告会被作为新会话的第一条消息喂给代理。换句话说,代理每次醒来,第一眼看到的不是我模糊的需求重述,而是“你上次干到哪了、定过什么规则、现在卡在哪个函数”。

4. 记忆账本:数据结构与写入协议

4.1 MEMORY.md:一份给失忆者看的“刺青”

记忆系统的核心文件是 MEMORY.md。它用 Markdown 加 YAML front-matter 写成,控制在一个比较小的行数范围内。下面是我在某个订单重构项目里用过的示例:

--- session_id: 20250614-1430 updated_at: 2025-06-14T22:10:00+08:00 priority: P0 status: active tags: [feature, order-refund] --- ## 当前主线目标 - 完成订单退款流程中的部分退款支持,接口路径:POST /api/orders/:id/refund ## 已定决策 - 金额一律使用整数分存储,禁止浮点入参。 - 幂等键用 refund_batch_id,而不是用户 ID。 - 不要修改 payment_gateway.go 里的 secret 读取逻辑,测试环境没有密钥文件。 ## 进行中任务 - [x] 新增 RefundRequest DTO - [ ] 重构 RefundService 的幂等校验 - [ ] 为部分退款写补偿用例 ## 最近完成 - 支付回调 schema 校验已拆到独立中间件 ## 阻塞与风险 - 网关沙箱账号的退款结果有 5 分钟延迟,用例需要轮询。

这个文件不是项目说明书,而是“当前最重要的事实”。它只记录那些如果代理忘了、就会做出错误决定的条目。任何一条超过三行的内容都不应该出现在这里,而应该拆到 docs/memory/ 下独立成文,MEMORY.md 里只留链接。

4.2 写入协议:谁写、什么时候写、怎么写

文件结构定好了,还得解决“谁来写”的问题。我的方案是一套明确的记忆纪律:

  1. 每次代理完成一个关键动作,要求它往journal/$(date).md追加一段三段式记录:“做了什么 / 为什么 / 下一步动作”。
  2. 每天结束时,我会从 journal 里把标记为 [DECISION] 的条目提升到 MEMORY.md 的对应分区,并撤下已经失效的旧条目。
  3. 所有记忆文件的修改都要通过 git 追踪。代理想改 MEMORY.md?可以,但必须显式提出,由人审阅后合入,而不是在正常编码过程中顺手改掉。
  4. 写条目的长度要受控:每个子项不超过三行;超过三行的内容一律单独成文档,防止 MEMORY.md 变成“叙事文学”。

这套纪律最大的作用是防止记忆系统腐化。技术方案可以复杂,但维护动作必须简单,否则坚持不下去。

4.3 防止记忆文件膨胀:TTL、归档与可信度

再好的系统,如果不做清理,早晚会变成垃圾堆。我加了三个防腐机制。

第一个是 TTL。每个条目带 updated_at 字段,加载器默认只注入 24 小时内更新过的有效条目,除非优先级是 P0。

第二个是归档。每周归档一次,把旧任务、旧决策搬到archive/2025-W24.md这类文件里,MEMORY.md 保持精简。归档不是删除,因为代理需要时可以按需去查,只是不默认加载。

第三个是可信度分级。手动写入的条目可信度最高,钩子自动追加的内容标为 tentative,只有当人类确认后才升为 accepted。这个机制避免代理因为自己生成的某句“总结”而走上错误方向。记忆应该被信任,但前提是它经过校验。

5. 启动加载器:把记忆拼回上下文的最后一公里

5.1 加载器的核心职责

有了钩子脚本,有了记忆账本,最后一步是把它们拼进新会话。启动加载器就是这个过程的入口,相当于 init.d 里的service start。它做的事很纯粹:

  1. 遍历 agent_boot.d 目录,按文件名排序;
  2. 依次执行每个可执行脚本;
  3. 把脚本产生的 stdout 收集起来;
  4. 按 token 预算修剪掉太长、太旧、太低优先级的片段;
  5. 把最终结果加上明确指令,作为代理新会话的第一条消息。

这个加载器可以是一个 Python 脚本,也可以是一个 shell 脚本。我建议用 Python 写,因为后面一旦接入过滤、排序、向量检索,Python 更顺手。

5.2 一个最小可用的 Python 加载器骨架

下面是一个可以直接跑起来的最小实现,核心是运行钩子、拼接内容、做预算裁剪:

#!/usr/bin/env python3 import pathlib import subprocess BOOT_DIR = pathlib.Path("agent_boot.d") BUDGET = 4000 # 估算 token 预算,启动包不超过 4000 token def run_hooks(): chunks = [] for script in sorted(BOOT_DIR.glob("*.sh")): cp = subprocess.run(["bash", str(script)], capture_output=True, text=True) out = cp.stdout.strip() if out: chunks.append(f"### 钩子: {script.name}\n{out}") return "\n\n".join(chunks) def trim(content: str, budget: int) -> str: # 简化估算:1 token 约等于 4 个英文字符 max_chars = budget * 4 if len(content) <= max_chars: return content return content[:max_chars].rsplit("\n", 1)[0] def main(): report = run_hooks() trimmed = trim(report, BUDGET) prompt = ( "以下是你的记忆恢复包,全部来自项目外部文件,而非历史上下文。\n" "请先仔细阅读,然后用三句话复述当前任务与最关键的约束。\n\n" + trimmed ) print(prompt) if __name__ == "__main__": main()

这段代码有几个关键点。

第一,glob("*.sh")是按文件名排序的,天然遵从 init.d 的编号约定。第二,capture_output捕获钩子的输出,而钩子内部的|| true容错保证了某个脚本崩溃不会终止整个加载流程。第三,裁剪逻辑虽然简单,但它的存在很重要:启动包不能无限大。第四,提示词里要求代理先复述当前任务,这个设计来自电影里的“自证”:Leonard 每次醒来都要确认自己看到了纸条上的字,而不是以为自己记住了。代理只有正确复述“我是谁、我要干什么、我卡在哪”,才算真正接上记忆。

5.3 控制启动包的大小与优先级

token 预算为什么如此重要?因为启动包如果塞得太多,会把代理的新工作窗口再次占满,导致它还没开始干活就又处于爆仓边缘。我给启动包设 4000 token 的预算,大约对应 16000 个英文字符。超出时,加载器按优先级 P0 > P1 > P2 分层裁剪:P0 的当前目标和已定决策必须保留,P1 的进行中任务按需保留,P2 的长期知识降级成“按需读取提示”。

关键思路是:记忆恢复包只给代理“锚点”,不给它“完整历史”。完整历史永远有损、永远过长,而锚点只需要足够让它知道自己是谁。

6. 实测:人为制造一次 20 万 token 的“脑叶切除”

6.1 压测步骤

理论说了一堆,最终还得用实验说话。我特意设计了一次模拟灾难,看这套系统在压力下到底能不能扛住。

步骤是这样的:

  1. 启动代理,加载启动包,开始一个真实任务:给订单导出功能增加按天分片。
  2. 让代理正常写代码,同时故意运行一个大输出命令,比如 dump 全量数据库 schema、打印依赖树,把上下文推到接近 20 万 token。
  3. 触发压缩。可以调用代理框架自带的 compact 功能,也可以直接杀掉进程重启。两种方式我都试过。
  4. 重启后,不做任何提示,直接看代理的第一条回复。

过程里要注意:一定要把“压缩触发前”的状态记录下来。我会先让代理在 journal 里写清楚当前目标,再触发压缩,否则无法对比。

6.2 恢复结果对比

下面这张表是我在同样任务、同样压测方式下,分别跑“无记忆系统”和“有记忆系统”后得到的对比结果:

维度没有记忆系统的代理有记忆系统的代理
能否说出当前任务不能,直接反问用户能,复述任务与关键文件名
是否重提已否决方案是,频繁否,沿既有决策推进
恢复后第一次有效动作重新读文件、重新分析直接定位待办任务
无效 token 消耗高,多次重复读取显著降低
对既有约束的遵守差,容易漂移稳,P0 决策不丢失

印象最深的是一次恢复过程:重启后,代理的第一句话是“我读到当前主线目标是……目前卡在 RefundService 幂等校验,待办任务是……”。那一刻我确实有点感动,因为窗口压缩还在发生,但代理已经不再是从零开始了。它像 Leonard 醒来后,摸到口袋里那张写着“我在追查谁杀了我妻子”的纸条,马上就进入了状态。

6.3 这套方案的临界点:当记忆包本身也超预算

按我的实测经验,这套方案在中小型项目里非常稳。项目一大,问题就来了:MEMORY.md 加 journal 加 project map,整体可能超过 4000 token 的预算。到这时候,单纯靠“全量注入”已经不行了。

应对思路是把 L0 改成“检索式注入”:启动包只保留最高优先级的少量锚点,同时告诉代理“项目地图在 docs/project-map.md,需要时请先读取”;或者给代理提供一个本地模糊检索入口,让它用关键词从记忆文件里拉取相关片段。这样 L0 只是引子,L1/L2 变成按需读取层,避免二次占满上下文。换句话说,你越接近上下文的极限,越需要学会“精挑细选”而不是“全部搬进去”。

7. 踩坑、边界与后续玩法

7.1 我在实操中踩过的坑

这套系统不是我等一天就能稳定运行的,中间踩了不少坑,挑几个典型的说说。

坑一:代理顺手改写记忆文件。有次代理把 MEMORY.md 当成普通代码“顺手重构”了,把段落顺序打乱,还自创了几个新分区。解决办法:记忆目录对外只读,钩子脚本执行前用chmod 0444锁住文件;同时对代理工作区与记忆区做权限隔离,只有人类能直接改动 MEMORY.md。

坑二:多会话并发互相覆盖。我同时开多个代理会话跑不同子任务时,两个会话同时追加 journal,导致记录互相覆盖。解决办法:每个会话使用独立 session_id,写入journal/<session_id>.md,每天结束再由人合并;或者写入时加锁,保证同一时刻只有一个住写者。

坑三:启动包太长,压碎了任务空间。早期的加载器没有预算裁剪,启动包动辄上万 token。代理每次醒来都要花大量篇幅处理“记忆内容”,反而没空间干活。解决办法就是前面说的 BUDGET 常量。

坑四:代理读完记忆包后“假装记得”。有些模型会在收到一大段记忆后直接说“好的,我明白了”,但实际上并没有真正理解。解决办法是强制要求它复述当前目标和三个关键约束,复述不准确就重新加载。这一步能有效过滤“虚假记忆”。

坑五:时间一长,记忆文件开始变成“叙事文学”。某天我打开 MEMORY.md,发现里面写着大段大段的背景故事,把“做了什么”和“为什么做”混在一起。解决办法就是严格的三行限制和不定期归档。

7.2 记忆系统不是一次搭建,而是持续维护

很多朋友看到这套方案后,第一反应是“这得花多少时间维护”。我的回答是:初期搭建大概要半天,持续维护每天至少需要十分钟。

维护节奏大概是这样的:每天结束时,打开 journal 快速扫一遍,把关键决策提升进 MEMORY.md;每周做一次归档,把过期的任务和已经稳定的决策挪到 archive;每一个里程碑节点,重新审视优先级,删掉不再需要的旧条目。

一旦你停止了维护节奏,记忆系统会在一周内腐化。这有点像健身:练出腹肌可能需要几个月,但停下来几周,一切都回到原点。所以,如果你不打算持续维护,那这整套方案可能不划算。但如果你和我一样长期依赖编码代理干活,这笔投入会回报得很明显——每次压缩后少浪费的时间,就值回票价了。

7.3 一点真实的体感:它没有让代理变聪明,但它让代理“知道自己是谁”

最后说点体感层面的东西。给代理搭好这套“启动即恢复”机制之后,我最强烈的感受并不是代理变聪明了,而是它变稳了。它不再因为一次压缩就陷入“我是谁、我在哪”的迷茫,也不再反复提出已经否决的方案。它在压缩后、重启后、多会话切换时,都能很快回到正轨,就像口袋里永远装着一张写满关键信息的纸条。

这套方案既不是一个新框架,也不是什么花哨的模型技巧,它只是把工程世界里最古老的哲学——初始化时恢复状态、不信任临时记忆、把关键信息持久化——搬到 AI 代理身上。编码代理的工作方式越接近长周期任务,这个“记忆 + 启动钩子”的组合就越值得抄一份。

如果你也遇到过那种让你血压升高的失忆瞬间,我建议你今晚就建一个agent_boot.d目录,把最重要的三条决定写进 MEMORY.md,然后假装什么都没发生,等下次上下文压缩时,你会感谢这十几分钟的投入。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询