1. 从一次上下文崩溃说起:为什么长会话代理总会“失忆”
做过 AI 编码代理的人大概都经历过这种崩溃瞬间:你花了两个小时跟代理把一套构建脚本调通,中间它读了十几个文件、跑了几轮测试、修了三个边界条件,结果上下文一压缩,它突然开始重新问你“这个项目用什么语言写的”。更离谱的是,它会把之前已经确认过的接口签名改回去,把刚删掉的死代码又加回来,仿佛前面那两小时是一场集体幻觉。
这个项目标题里提到的“20 万 token 的脑叶切除术”,说的就是这件事。当会话上下文逼近模型窗口上限时,系统会触发压缩机制,把早期对话摘要化甚至直接丢弃。对聊天机器人来说这没什么,但对一个正在维护代码状态的编码代理来说,这等于把它的工作记忆切掉一大块。标题里用《记忆碎片》来类比非常准确——那部电影的主角只能靠纹身、纸条和宝丽来照片维持连续性,而我们要做的,就是给代理准备一套属于它自己的“纹身和纸条”。
我这次折腾的核心目标很明确:让一个长时间运行的编码代理,在经历多轮上下文压缩之后,依然能准确知道“我是谁、我在哪个项目、我改过什么、下一步该干什么”。整套方案借用了 Unixinit.d的思路——用一组幂等的、可重复执行的启动脚本,在每次会话恢复时把关键状态重新注入。下面我把这套东西从设计到落地完整拆一遍,包括我踩过的坑和最后稳定下来的配置。
2. 整体设计思路:把代理的“人格”和“记忆”拆开管理
2.1 为什么不能只靠系统提示词硬扛
最直觉的做法是把所有关键信息塞进系统提示词,指望它永远不被压缩掉。我一开始也是这么干的,结果发现两个问题。第一,系统提示词本身也会占用窗口,你塞得越多,留给实际工作的空间越小,压缩触发得越早。第二,很多代理框架在压缩时对系统提示词和对话历史是一视同仁的,它并不会因为“这段很重要”就特殊对待。
所以正确的思路不是“防止压缩”,而是“压缩之后能快速恢复”。这就像服务器重启,你不可能阻止它重启,但你可以保证它重启后能自动拉起所有服务。init.d的价值就在这里:它定义了一套标准的、幂等的启动流程,不管机器重启多少次,跑一遍脚本状态就回来了。
2.2 三层记忆结构的设计
我把代理需要维持的状态拆成三层,分别用不同机制承载。
第一层是不变的身份层,包括项目根路径、技术栈、代码规范、禁止事项。这部分内容极少变动,我把它写成一个独立的identity.md,每次会话启动时通过脚本读取并注入。它的特点是短、稳定、必须每次都在。
第二层是可变的进度层,记录当前任务、已完成步骤、待办事项、已知阻塞。这部分我用一个结构化的progress.json来存,代理每完成一个关键节点就更新它。压缩发生后,脚本会把这个文件重新读进来,转成一段紧凑的摘要注入。
第三层是原始证据层,也就是完整的操作日志、文件变更记录、测试输出。这部分体量最大,不可能全量注入,我的做法是只保留最近 N 条,更早的用脚本生成索引,代理需要时再按需读取。
提示:三层结构的核心原则是“注入成本从低到高、恢复优先级从高到低”。身份层永远注入,进度层每次恢复都注入,证据层按需拉取。
2.3 为什么选择脚本注入而不是框架内置机制
现在不少代理框架都提供了某种“记忆”功能,但我实测下来更倾向于自己用脚本控制。原因有三个。一是可控性,我知道每一轮到底注入了什么、占了多少 token。二是可移植性,脚本方案不绑定特定框架,换一个代理实现照样能用。三是可调试性,出问题的时候我可以直接看脚本输出,而不是去猜框架内部做了什么。
这套思路本质上就是把代理当成一个需要被“引导启动”的服务,而不是一个需要被“喂养上下文”的聊天对象。视角一换,很多设计就顺了。
3. 核心细节拆解:init.d 式状态恢复的四个关键环节
3.1 身份锚点:让代理每次醒来都知道自己是谁
身份锚点是我踩坑最多的地方。最初我写了一大段项目介绍,结果发现代理经常忽略其中的关键约束。后来我改成极简的键值对格式,效果立刻好转。原因很简单:结构化、短、无歧义的内容更容易被模型稳定遵循。
我的identity.md大概长这样:
project_root: /workspace/myapp language: python 3.11 framework: fastapi test_command: pytest -x lint_command: ruff check . forbidden: 不要修改 migrations 目录下的历史文件 convention: 所有新接口必须有类型注解注意这里的forbidden和convention是自然语言,但非常短。我试过写成长段落,代理的遵循率明显下降。短句、命令式、一条一个约束,这是我从多次失败里总结出来的经验。
注入时机也很关键。我是在每次会话恢复的第一个动作就注入,而不是等代理开始工作后再补。因为压缩往往发生在长会话的中段,如果恢复后代理已经基于残缺记忆开始行动,再注入就晚了。
3.2 进度快照:用结构化数据替代自然语言摘要
进度层我坚持用 JSON 而不是自然语言。自然语言摘要看起来友好,但有两个致命问题:一是每次压缩后重新生成的摘要会有信息漂移,二是代理很难精确判断“这一步到底做没做”。
我的progress.json结构如下:
{ "current_task": "为订单模块添加幂等重试", "completed": [ "定位到 order_service.py 的 create_order 方法", "确认现有重试逻辑在 network 异常下会重复下单" ], "in_progress": "编写幂等键生成逻辑", "blocked": [], "next_steps": [ "在 create_order 入口加幂等键校验", "补充并发场景的单元测试" ], "last_updated": "session-42" }这个文件由代理自己在关键节点更新,我给它定的规则是:每完成一个completed项、每改变一次in_progress、每发现一个blocked,都必须立刻写回。写回动作本身很便宜,但它是整个恢复机制的基石。
注意:
last_updated字段我用来做版本校验。如果恢复时发现这个字段比预期旧,说明代理可能漏写了,脚本会提示我人工介入。
3.3 证据索引:让代理按需“回忆”而不是全量背诵
证据层是最容易失控的部分。我一开始把完整日志全存下来,结果恢复时一注入就爆窗口。后来改成索引加按需读取。
具体做法是:每次代理完成一个较大的操作单元,脚本自动生成一条索引记录,包含时间、操作类型、涉及文件、一句话结果。完整日志存在磁盘上,索引里只放路径。代理需要细节时,通过一个recall工具按索引路径读取。
索引记录大概长这样:
{ "id": "op-017", "type": "file_edit", "target": "order_service.py", "summary": "在 create_order 中插入幂等键校验分支", "log_path": "/logs/op-017.json", "timestamp": "session-42-step-8" }这样恢复时只需要注入最近 10 到 20 条索引,token 占用极小,但代理能清楚知道“我最近干了什么、细节去哪找”。
3.4 恢复脚本:幂等是唯一的要求
把上面三层串起来的就是恢复脚本。它的逻辑非常简单:读身份、读进度、读最近索引、拼成一段注入文本、交给代理。关键在于它必须幂等——跑一次和跑十次结果一样,不会重复追加、不会污染状态。
我用一个简单的 shell 脚本实现,核心逻辑如下:
#!/bin/bash # restore_context.sh IDENTITY=$(cat /workspace/state/identity.md) PROGRESS=$(cat /workspace/state/progress.json) RECENT=$(tail -n 20 /workspace/state/index.jsonl) cat <<EOF [SESSION RESTORE] $IDENTITY [CURRENT PROGRESS] $PROGRESS [RECENT OPERATIONS] $RECENT EOF这个脚本的输出会被代理框架作为恢复消息注入。我特意用了明确的段落标记,方便代理区分不同层的信息。
提示:脚本里不要做任何有副作用的操作,比如修改文件、调用外部接口。恢复脚本只读不写,这是保证幂等的前提。
4. 实操落地:从零搭一套抗压缩的代理工作流
4.1 目录结构与环境准备
先说目录结构,这套东西不复杂,但位置要固定,否则脚本找不到。
/workspace/ state/ identity.md progress.json index.jsonl logs/ op-001.json op-002.json ... scripts/ restore_context.sh append_index.sh update_progress.shstate放三层记忆,logs放完整证据,scripts放工具脚本。我建议把整个state和logs目录纳入版本控制,这样即使环境重建,记忆也不会丢。
环境准备上没什么特殊依赖,一个 POSIX shell 加jq就够了。jq用来在脚本里安全地读写 JSON,比手写字符串拼接可靠得多。
4.2 身份文件的编写要点
身份文件我建议控制在 200 token 以内。超过这个量,压缩后重新注入的收益就开始下降。写法上遵循三个原则:用键值对、用命令式短句、一条约束一行。
我见过有人把整个 README 塞进身份文件,结果代理反而抓不住重点。身份文件不是文档,是约束清单。它回答的是“我是谁、我不能做什么”,而不是“这个项目有多牛”。
4.3 进度更新的触发时机
进度更新最容易出问题的地方是“什么时候写”。写太频繁会打断代理工作流,写太少又会在压缩时丢状态。我最后定下来的触发点是三个:完成一个明确的子任务、切换当前任务、遇到阻塞。
具体实现上,我给代理定义了一个update_progress工具,它接受completed、in_progress、blocked、next_steps四个字段,内部用jq合并到progress.json。代理只需要在关键节点调用一次,不需要自己拼 JSON。
#!/bin/bash # update_progress.sh jq --arg task "$1" --arg done "$2" \ '.current_task = $task | .completed += [$done]' \ /workspace/state/progress.json > /tmp/progress.json \ && mv /tmp/progress.json /workspace/state/progress.json这个脚本用临时文件加mv的方式写入,避免写入过程中被读取导致文件损坏。这是个小细节,但在并发场景下很重要。
4.4 索引追加与日志落盘
索引追加我用一个类似的脚本,每次代理完成一个操作单元就调用一次。日志落盘则是代理框架自己的职责,我只需要保证日志路径和索引里的log_path一致。
这里有个经验:索引的summary字段一定要由代理自己写,不要用脚本自动生成。因为代理知道这次操作的真实意图,而脚本只能看到文件变更。我试过自动生成摘要,结果恢复时代理经常误解自己之前干了什么。
4.5 恢复流程的完整串联
把所有环节串起来,一次完整的恢复流程是这样的:
- 会话启动或压缩触发后,框架调用
restore_context.sh。 - 脚本读取身份、进度、最近索引,拼成恢复消息。
- 恢复消息作为第一条消息注入代理。
- 代理基于恢复消息确认当前状态,继续工作。
- 工作过程中,代理在关键节点调用
update_progress.sh和append_index.sh。
整个流程没有任何魔法,全是普通脚本和文件。但正是这种朴素,让它在各种框架下都能跑起来。
5. 常见问题与排查技巧实录
5.1 恢复后代理仍然“失忆”怎么办
这是最常见的问题。排查顺序我建议从下往上:先看恢复脚本输出是否完整,再看注入消息是否真的到达代理,最后看代理是否遵循了注入内容。
我遇到过一次,脚本输出正常,但代理完全无视。后来发现是注入消息被放在了对话历史的最前面,而压缩机制把它当成旧消息又压了一遍。解决办法是把恢复消息标记为高优先级,或者在框架层面配置为不参与压缩。不同框架配置方式不同,但思路是一样的:恢复消息必须享受特殊待遇。
5.2 进度文件被写坏或字段缺失
JSON 写坏通常是因为并发写入或者写入中断。我的应对是两点:一是所有写入都用临时文件加mv,二是恢复脚本里加一层校验,字段缺失时用默认值兜底。
jq '.completed // [] | .in_progress // "unknown"' \ /workspace/state/progress.json这行jq保证即使字段缺失,脚本也不会崩,而是给出一个安全的默认值。代理看到unknown会主动询问,而不是基于错误状态继续。
5.3 索引膨胀导致恢复变慢
索引文件是追加写的,时间长了会很大。我的做法是定期归档:保留最近 100 条在主索引,更早的移到index.archive.jsonl。恢复脚本只读主索引,需要历史时再按需查归档。
归档脚本很简单,用head和tail切分即可。关键是归档动作要幂等,重复跑不会丢数据。
5.4 代理不主动更新进度
有些代理比较“懒”,你不催它就不写。我的解决办法是在身份文件里明确写一条约束:“每完成一个子任务必须调用 update_progress”。同时在恢复消息里加一句提醒。实测下来,明确约束加恢复提醒,遵循率能到九成以上。
如果还是不行,可以在框架层面做强制:代理每执行 N 次工具调用,框架自动触发一次进度快照。这是兜底方案,不优雅但有效。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查动作 | 解决方式 |
|---|---|---|---|
| 恢复后代理重复已完成工作 | 进度文件未更新 | 检查 progress.json 的 completed 字段 | 补写进度,加强更新约束 |
| 恢复消息被压缩 | 注入位置或优先级不对 | 查看框架压缩日志 | 配置恢复消息不参与压缩 |
| 进度文件损坏 | 并发写入或中断 | 检查文件是否为合法 JSON | 改用临时文件加 mv 写入 |
| 索引过大恢复慢 | 未归档历史索引 | 查看 index.jsonl 行数 | 定期归档,只保留最近 100 条 |
| 代理忽略身份约束 | 身份文件过长或格式松散 | 检查 identity.md 长度 | 精简为键值对短句 |
提示:这张表我建议贴在项目 README 里,出问题时按行排查,比临时想快得多。
6. 几个让我少走弯路的实操心得
第一,恢复机制要尽早搭,不要等出问题再补。我一开始觉得“代理挺聪明的,应该不会忘”,结果第一次长会话就翻车。后来把恢复脚本前置到项目初始化阶段,后面再没出过大问题。
第二,注入内容宁短勿长。我试过把完整进度历史全注入,结果代理反而抓不住重点。现在只注入当前任务和最近几步,效果稳定得多。信息密度比信息总量重要。
第三,所有状态文件都要能人工阅读和编辑。我用 JSON 和 Markdown 而不是二进制格式,就是为了出问题时能直接打开看、直接改。代理的记忆应该是透明的,不是黑盒。
第四,恢复脚本要能独立运行。我把它设计成不依赖代理框架、不依赖网络、不依赖任何外部服务。这样即使框架升级、环境迁移,恢复逻辑照样能用。这种解耦在长期维护里价值极大。
这套东西我用了几个月,中间经历过好几次 20 万 token 级别的压缩,代理的状态连续性基本没再出过问题。它不复杂,但每一处设计都是从实际翻车里抠出来的。如果你也在做长会话代理,建议从身份锚点和进度快照这两个最小可用部分开始,跑通了再往上加索引和归档。先让它不失忆,再让它记得好。