1. 从“脑叶切除术”说起:长上下文代理为什么越用越傻
做过AI编码代理的人大概都有过这种体验:一开始它思路清晰,能记住你半小时前说过的架构约定,能准确引用三天前那次重构里改过的函数签名。但聊到某个临界点之后,它突然就像被做了一场“脑叶切除术”——你明明在同一个会话里,它却开始重复问你已经回答过的问题,忘记了你定下的命名规范,甚至把自己刚写过的代码又重写一遍,还改出了新bug。
这个临界点,通常就是上下文窗口被压缩的那一刻。20万token听起来很大,但一个真实的编码会话里,工具调用返回、文件内容、报错日志、历史对话全都在往里塞,撑满只是时间问题。一旦触发压缩,代理的“人格连续性”就断了。
我最近在折腾一个模拟项目X的编码代理时,就反复撞上这堵墙。后来我用了两个看起来完全不搭边的东西来解决它:一个是Unix时代的老古董init.d,另一个是诺兰的电影《记忆碎片》。这篇文章就把这套思路完整拆开讲,包括它为什么有效、具体怎么落地、以及我在实操中踩过的坑。
先给结论:核心思路是把代理的“记忆”从易失的上下文窗口里搬出来,变成文件系统上一份可持久、可检索、可重建的状态。上下文压缩可以清空对话,但清不掉磁盘上的文件。代理每次“醒来”,靠的不是回忆,而是像《记忆碎片》主角那样,读自己留下的笔记重新拼出“我是谁、我在干什么”。
这套东西适合谁?适合所有在做长会话AI代理、被上下文压缩折磨过的开发者。不需要你有多深的AI背景,但需要你对进程管理、文件系统这些基础概念不陌生。下面我按设计思路、核心机制、实操落地、问题排查四个部分展开,尽量把每个“为什么”都讲透。
2. 整体设计思路:把记忆从上下文搬到磁盘
2.1 上下文压缩到底杀死了什么
要理解这套方案,得先搞清楚上下文压缩的破坏性到底在哪。很多人以为压缩只是“丢了一些旧消息”,其实远不止。代理的连续性依赖三类信息:事实性记忆(项目结构、命名约定、已完成的改动)、程序性记忆(当前任务处于哪一步、下一步该做什么)、情境性记忆(为什么当初做了某个决策、否决了哪些方案)。
压缩算法通常按“重要性”或“时间近远”来裁剪,但问题是它无法区分“这条消息是闲聊”还是“这条消息定义了整个项目的错误处理规范”。一旦后者被裁掉,代理后续所有代码都会偏离规范,而且它自己毫无察觉。这就像《记忆碎片》里的主角,如果纹身被擦掉一块,他接下来的行动就会基于错误的前提。
所以我的设计目标很明确:让代理的关键状态不依赖上下文窗口的存活。上下文可以随便压缩,压缩完代理还能从外部把状态读回来。
2.2 为什么选init.d而不是别的
你可能会问,持久化状态为什么不用数据库、不用向量存储、不用现成的记忆框架?我试过一些,最后回到init.d,原因有三个。
第一,启动语义天然匹配。init.d的本质是“系统启动时按顺序执行一组脚本”,这跟代理“每次会话开始时重建状态”的需求几乎一模一样。代理每次被唤醒,就像系统重启,跑一遍初始化脚本,把该加载的加载进来。
第二,零依赖、可审计。一个init.d脚本就是普通shell脚本,放在文件系统上,你能直接cat出来看它干了什么。相比之下,某些记忆框架把状态藏在二进制索引里,出问题时你根本不知道它记住了什么、忘了什么。调试代理最怕黑盒。
第三,顺序即优先级。init.d的脚本按编号顺序执行(比如S10、S20),这个顺序本身就是一种优先级声明。我可以把“加载项目规范”放在S10,“恢复任务进度”放在S20,“校验状态一致性”放在S30,逻辑一目了然。
注意:这里说的
init.d是借用它的设计模式,不是要你真的去改操作系统的启动脚本。我是在代理的工作目录下建了一个agent.init.d/目录,用同样的编号约定来组织初始化脚本。
2.3 《记忆碎片》给的核心启发
电影里主角的处境和长会话代理惊人地相似:短期记忆不断丢失,只能靠外部记录维持连续性。他做了三件事,我几乎原样搬到了代理设计里。
第一,即时记录。想到什么立刻纹在身上或拍成照片,绝不依赖脑子记。对应到代理,就是任何关键决策、任何状态变更,立刻写进文件,而不是指望它留在上下文里。
第二,分级标注。“事实”用纹身(永久),“待办”用纸条(临时),“人物”用宝丽来照片(带注释)。对应到代理,就是区分哪些状态是永久的(项目规范)、哪些是会话级的(当前任务)、哪些是易变的(临时变量)。
第三,启动时重建。主角每次醒来第一件事是环顾四周、读身上的纹身、看手里的照片,重新建立对处境的认知。对应到代理,就是每次会话开始跑一遍初始化脚本,把状态读回来。
这三条构成了整套方案的骨架。下面进入具体机制。
3. 核心机制拆解:状态文件、初始化脚本与重建协议
3.1 状态文件的分层设计
我把代理的状态分成三层,分别对应不同的持久化策略和生命周期。
第一层是项目级状态,生命周期最长,跨会话甚至跨天存在。内容包括项目结构说明、命名规范、架构决策记录、已知的坑。这些放在agent.state/project/目录下,用Markdown文件存储,因为Markdown对人可读、对模型也友好。文件名带语义,比如naming-conventions.md、architecture-decisions.md。
第二层是任务级状态,生命周期是当前任务从开始到完成。内容包括任务目标、当前进度、已完成的子步骤、下一步计划、遇到的阻塞。放在agent.state/task/下,用一个current-task.md文件维护,每次状态变更就更新它。这个文件是代理“醒来”后最先读的东西。
第三层是会话级状态,生命周期就是当前这一次会话。内容包括本次会话的临时上下文、刚读过的文件摘要、待验证的假设。放在agent.state/session/下,会话结束可以清理,也可以保留供下次参考。
分层的意义在于:压缩上下文时,第一层和第二层完全不受影响,因为它们本来就不在上下文里;第三层即使丢了,重建成本也低。这样代理的“核心人格”就锚定在了磁盘上。
3.2 init.d脚本的组织与执行顺序
在代理工作目录下建agent.init.d/,里面放编号脚本。我的实际编号方案是这样的:
S00-mount-state.sh:检查状态目录是否存在,不存在就创建骨架。S10-load-project.sh:读取项目级状态,输出到标准输出,供代理加载。S20-load-task.sh:读取任务级状态,同样输出。S30-validate.sh:校验状态一致性,比如任务文件里引用的文件是否还存在。S40-emit-context.sh:把前面加载的内容拼成一段“启动上下文”,注入到代理的初始提示里。
每个脚本都是幂等的,跑多少次结果都一样。这点很重要,因为代理可能在一个会话里被多次“唤醒”(比如压缩后重新注入),初始化脚本必须能安全重跑。
脚本用bash写,输出用简单的分隔符标记段落,方便代理解析。比如S10的输出长这样:
echo "=== PROJECT STATE BEGIN ===" cat agent.state/project/naming-conventions.md echo "=== PROJECT STATE END ==="代理的启动提示里会有一段指令,告诉它“以下是你的持久状态,请以此为准,不要依赖对话历史”。这样即使上下文被压缩得只剩最后几条消息,代理也能从这段注入内容里恢复认知。
3.3 重建协议:代理醒来后的第一件事
光有脚本还不够,代理得知道“醒来后该干什么”。我在系统提示里写了一段固定的重建协议,大意是:
- 先读
agent.state/task/current-task.md,确认当前任务是什么、进行到哪。 - 再读项目级状态里的规范文件,确认约束条件。
- 对比两者,如果发现任务状态引用了已不存在的文件或已变更的规范,标记冲突并暂停,先解决冲突再继续。
- 把重建结果用一句话总结出来,写在会话开头,作为后续行动的锚点。
这个协议的关键是第3步的冲突检测。我踩过的坑是:任务文件里写着“下一步修改utils/parser.js”,但那个文件在上次会话里已经被重命名了。代理如果不检查,就会去改一个不存在的文件,然后报错,然后困惑。加上冲突检测后,它会先报告“任务引用的文件不存在,可能已重命名,请确认”,这就把问题暴露在了正确的层面。
实操心得:重建协议不要写得太长,超过五步代理就容易漏。我试过写十步的版本,结果代理经常只执行前三四步就开始干活了。精简到四步以内,执行率明显提高。
4. 实操落地:从零搭一套抗压缩的代理状态系统
4.1 目录结构与初始化
先建目录骨架。我在项目根目录下执行:
mkdir -p agent.state/project agent.state/task agent.state/session agent.init.d touch agent.state/task/current-task.md然后往current-task.md里写初始内容,至少包含任务目标、当前进度、下一步。格式不用太讲究,但建议用固定的小标题,方便脚本解析:
## 任务目标 实现模拟项目X的配置加载模块 ## 当前进度 - 已完成配置schema定义 - 已完成默认值合并逻辑 ## 下一步 - 实现环境变量覆盖 - 写单元测试 ## 阻塞 无接着写agent.init.d/S00-mount-state.sh,确保目录存在。这个脚本简单但必要,因为代理可能在一个干净环境里启动,状态目录不一定在。
4.2 编写加载脚本与注入逻辑
S10-load-project.sh的内容:
#!/bin/bash STATE_DIR="agent.state/project" if [ -d "$STATE_DIR" ]; then echo "=== PROJECT STATE BEGIN ===" for f in "$STATE_DIR"/*.md; do [ -e "$f" ] || continue echo "--- $(basename "$f") ---" cat "$f" echo done echo "=== PROJECT STATE END ===" fiS20-load-task.sh类似,只是目录换成agent.state/task,且只读current-task.md。
S30-validate.sh做一致性检查。我的版本会提取任务文件里出现的文件路径,逐个检查是否存在:
#!/bin/bash TASK_FILE="agent.state/task/current-task.md" grep -oE '[a-zA-Z0-9_/.-]+\.(js|ts|py|md)' "$TASK_FILE" | sort -u | while read -r f; do if [ ! -e "$f" ]; then echo "WARN: referenced file missing: $f" fi doneS40-emit-context.sh把前面所有输出拼起来,加上一段固定前缀:
#!/bin/bash echo "以下是你(代理)的持久状态。请以此为准,不要依赖对话历史。" bash agent.init.d/S10-load-project.sh bash agent.init.d/S20-load-task.sh echo "=== VALIDATION ===" bash agent.init.d/S30-validate.sh代理启动时,先跑S40,把输出注入到初始提示里。这一步可以用一个包装脚本自动完成,也可以在代理框架的钩子里做。
4.3 状态更新:什么时候写、写什么
状态文件不是写完就不管了,代理在运行过程中要持续更新它。我的规则是:任何“如果丢了会导致后续行动错误”的信息,立刻写进状态文件。
具体触发点包括:完成一个子任务、做出一个架构决策、发现一个约束条件、遇到一个阻塞、改变任务目标。每次更新只改current-task.md里对应的段落,不要重写整个文件,避免引入格式错误。
写的时候有个技巧:用“事实+理由”的格式。比如不要只写“使用camelCase命名”,而要写“使用camelCase命名(理由:与现有代码库保持一致,避免混用)”。理由部分在重建时特别有用,因为代理能理解为什么,从而在边缘情况下做出正确判断。
注意:状态文件不要写得太细。我一开始把每个函数签名都记进去,结果文件膨胀到几千行,加载慢,代理也抓不住重点。后来改成只记“决策”和“约束”,细节让代理自己去读代码。状态文件是索引,不是副本。
4.4 与代理框架的对接
不同的代理框架对接方式不同,但核心都是“在会话开始时注入状态,在关键节点触发状态更新”。我用的是一个支持钩子的框架,在on_session_start钩子里跑S40,把输出塞进系统提示;在on_tool_result钩子里检查是否触发了状态更新条件。
如果你的框架没有钩子,退而求其次的办法是:在系统提示里写死一段指令,让代理每次开始工作前先自己跑一遍初始化脚本(通过执行shell工具),并把结果读进来。这个办法不如钩子可靠,但能用。
对接时有个坑:注入的状态不能太长。我试过把整个项目状态全注入,结果占了上下文一大块,反而加速了压缩。后来改成只注入任务级状态和项目级状态的摘要,完整内容让代理按需读取。摘要用脚本自动生成,比如只取每个文件的前几行。
5. 常见问题与排查技巧实录
5.1 代理不读状态文件怎么办
这是最常见的问题。代理启动后,明明注入了状态,它还是按自己的“记忆”干活。原因通常是注入内容被淹没在了系统提示的其他部分里,或者代理的指令遵循能力不够强。
我的解决办法有两个。一是把状态注入放在系统提示的最前面,并且用醒目的分隔符包起来,比如=== CRITICAL STATE ===。二是加一句强指令:“在开始任何操作前,先用一句话复述你的当前任务和目标。”这句复述强制代理处理状态内容,而不是忽略它。
实测下来,加了复述指令后,代理读取状态的比例从大概一半提升到了九成以上。代价是每次会话开头多花几十个token,完全值得。
5.2 状态文件与实际情况不一致
代理更新状态有延迟,或者更新时写错了,就会导致状态文件与代码实际情况不符。比如状态里写着“已完成登录模块”,但代码里其实只写了一半。
排查办法是定期跑一致性校验。我除了S30的静态检查,还会在任务关键节点让代理做一次“状态审计”:读状态文件,对照代码,报告差异。差异分两类处理:状态落后于代码,就更新状态;代码落后于状态,就补代码。
实操心得:状态审计不要频繁做,每个大阶段做一次就行。做太勤会打断代理的工作流,而且代理自己审计自己容易“自圆其说”,发现不了问题。我一般是在任务切换或长时间会话后做一次。
5.3 初始化脚本执行失败
脚本失败的原因五花八门:路径不对、权限不够、依赖的命令不存在。因为脚本是在代理环境里跑的,环境可能和你的开发机不一样。
我的做法是让脚本“失败也要输出”。每个脚本开头加set +e,关键步骤用|| echo "WARN: ..."兜底,确保即使出错也能输出一段可读的信息,而不是静默失败。代理看到WARN会知道状态可能不完整,从而更谨慎。
另外,脚本里不要用太花哨的命令。我一开始用了jq解析JSON状态,结果代理环境里没装jq,整个初始化就挂了。后来改成纯bash和基础文本工具,兼容性好很多。
5.4 压缩后代理“人格分裂”
有时候压缩发生在会话中途,代理前半段按状态文件干活,后半段突然开始按压缩后的残缺记忆干活,行为不一致。
这个问题比较棘手,因为压缩是框架层的行为,你控制不了。我的缓解办法是在系统提示里反复强调“状态文件是唯一真相来源”,并且在代理每次调用工具前,用钩子检查它最近有没有读过状态文件,如果超过一定轮次没读,就提醒它重读。
这个提醒机制有点像《记忆碎片》里主角定时看纹身的习惯。频率不用太高,我设的是每十轮工具调用提醒一次,效果不错。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| 代理忽略状态文件 | 注入位置靠后或指令不强 | 检查系统提示结构 | 前置注入+复述指令 |
| 状态与代码不符 | 更新延迟或写错 | 跑状态审计 | 按差异方向修正 |
| 脚本执行失败 | 环境差异或依赖缺失 | 看脚本输出 | 用基础命令+失败兜底 |
| 压缩后行为不一致 | 框架压缩不可控 | 观察压缩触发点 | 定期提醒重读状态 |
| 状态文件膨胀 | 记录过细 | 看文件行数 | 只记决策和约束 |
6. 一些延伸想法和实际体会
这套方案跑了一段时间,最大的感受是:代理的可靠性不取决于模型多强,而取决于它的状态管理多稳。同一个模型,加上状态持久化之后,长会话里的表现提升非常明显,尤其是跨天任务,基本不会出现“忘了昨天干了啥”的情况。
延伸出去,这套思路还能用在几个地方。一是多代理协作,每个代理有自己的状态目录,通过共享的项目级状态协调,避免互相覆盖。二是任务交接,一个代理干到一半,把状态文件交给另一个代理,后者跑一遍初始化就能接手。三是审计和回放,状态文件本身就是一份操作日志,出问题可以回溯。
当然,这套东西不是银弹。它解决的是“记忆连续性”问题,不解决“推理能力”问题。代理该算错的还是会算错,只是算错之后你能从状态文件里看出它是在哪个前提上算错的,排查起来快很多。
最后分享一个小技巧:状态文件的文件名和目录名尽量用英文,内容可以用中文。因为脚本处理文件名时英文更稳,而内容用中文对模型的理解更自然。这个混搭是我试了几种组合后觉得最顺手的。
如果你也在被上下文压缩折磨,建议先从任务级状态文件开始,就一个current-task.md,配合一个加载脚本,成本很低,但效果立竿见影。跑顺了再往上加项目级状态和校验逻辑。别一上来就搭全套,容易在细节里迷路。