1. 当上下文被压缩,代理为什么会“失忆”
做过 AI 编码代理的人都遇到过一个很具体的崩溃场景:你花了半小时跟代理把需求聊清楚,它终于开始动手改代码,改到一半上下文窗口满了,系统触发压缩,把前面几十轮对话揉成一段摘要。压缩完之后代理继续干活,但它已经忘了你之前强调过的“不要动那个配置文件”“数据库迁移脚本必须手写”“测试用例命名要跟现有风格一致”。它开始自由发挥,改出一堆你根本不想要的东西。
这个现象我管它叫“脑叶切除术”——上下文压缩把代理的长期记忆切掉了,只留下一个模糊的自我认知,它还记得自己是个编码代理,但记不得自己正在干什么、为什么这么干。标题里提到的《记忆碎片》就是这个意思:主角每隔几分钟就失忆一次,只能靠纹身和纸条维持连续性。我们要做的,就是给 AI 代理纹上那些“纸条”。
这篇文章拆解的是一个具体方案:用 Unix 传统的 init.d 机制,配合一套类似《记忆碎片》的持久化记忆结构,让编码代理在经历任意次上下文压缩之后,依然能恢复到正确的工作状态。核心思路不复杂,但落地细节很多,我会把设计考量、实现步骤、踩过的坑都摊开讲。
适合谁看:正在做 AI 编码代理、Agent 工作流、或者任何需要长会话连续性的开发者。如果你只是偶尔用用聊天式编程,这篇可能偏重;但如果你在搭一套能跑几十轮甚至上百轮任务的代理系统,这里的东西能直接抄。
先说清楚一个前提:上下文压缩本身不是 bug,它是必须的。20 万 token 的窗口听起来很大,但一个真实的编码任务,读几个大文件、跑几轮测试输出、加上工具调用的返回,很容易就吃掉大半。压缩是省钱的必要手段,问题不在于压缩,而在于压缩之后代理怎么“想起来”。
2. 整体设计思路:为什么是 init.d 加记忆碎片
2.1 核心问题拆解:压缩到底丢了什么
要解决问题,先得搞清楚压缩丢的到底是什么。我观察下来,上下文压缩丢失的信息分三类。
第一类是任务状态:当前在做什么任务、做到哪一步、下一步该干什么。这类信息如果丢了,代理会重新开始或者卡住。
第二类是约束条件:用户明确说过的禁忌、必须遵守的规范、项目特有的约定。这类信息丢了最危险,因为代理会“自信地做错事”。
第三类是决策历史:之前为什么选了方案 A 而不是方案 B,踩过哪些坑。这类信息丢了会导致代理重复犯错,或者推翻已经验证过的决策。
普通的摘要式压缩对第一类信息保留得还行,对第二类和第三类几乎是无差别丢弃。因为摘要模型不知道哪些约束是关键的,它只会按“信息密度”来压缩,而一条“不要动 X 文件”的约束在摘要里往往被当成冗余删掉。
2.2 方案选型:为什么不直接用向量数据库
有人会问,为什么不搞个向量数据库做长期记忆检索?我试过,问题在于检索的时机和可靠性。向量检索是“按需拉取”,但代理在压缩后往往不知道自己“需要”什么,它连自己忘了什么都意识不到。等它意识到的时候,往往已经改错文件了。
而且向量检索有召回率问题,关键约束可能因为表述差异检索不出来。对于“绝对不能做某事”这种硬约束,靠概率召回是不可接受的。
所以我选了一条更笨但更可靠的路:把关键状态写成文件,用 init.d 风格的脚本在每次会话恢复时强制加载。不依赖检索,不依赖模型判断,就是硬编码的“开机自启”。
2.3 init.d 的隐喻为什么贴切
Unix 的 init.d 是系统启动时按顺序执行的一堆脚本,每个脚本负责把某个服务拉起来。它的特点是:确定性、有序、幂等。不管系统重启多少次,跑完 init.d 之后系统状态都是一致的。
AI 代理的会话恢复需要完全一样的性质。每次上下文被压缩或者新会话开始,都应该跑一遍“恢复脚本”,把代理的工作状态、约束条件、决策历史重新加载进来。这个过程必须是确定性的,不能依赖模型“想起来”。
《记忆碎片》的隐喻则对应记忆的组织方式:主角不靠大脑记忆,靠的是拍立得照片、纹身、笔记。我们的代理也一样,不靠上下文里的对话历史,靠的是一组结构化的持久化文件。照片对应状态快照,纹身对应硬约束,笔记对应决策日志。
2.4 整体架构长什么样
整个方案分四层。
最底层是持久化存储,就是项目目录下的一组文件,包括状态文件、约束文件、决策日志。这些文件是代理的“外部大脑”。
第二层是恢复脚本,模仿 init.d 的写法,按顺序读取这些文件,拼装成一段“恢复上下文”,在每次会话开始时注入。
第三层是写入钩子,代理在干活过程中,遇到关键节点(完成任务、做出决策、收到新约束)时,主动往持久化文件里写。
第四层是压缩感知,代理需要能检测到“我刚刚被压缩了”,从而触发恢复流程。这一层是最容易被忽略的,但也是最关键的。
下面几节我会逐层拆开讲。
3. 核心细节解析:记忆碎片怎么设计和写入
3.1 状态文件:代理的“拍立得照片”
状态文件记录的是“当前在哪”。我用的格式很简单,就是一个 Markdown 文件,但字段是固定的,方便脚本解析。
# AGENT_STATE ## current_task 实现用户登录接口的密码重置功能 ## progress - [x] 阅读现有 auth 模块代码 - [x] 确定使用邮件验证码方案 - [ ] 实现验证码生成逻辑 - [ ] 接入邮件发送服务 - [ ] 写测试 ## next_action 实现验证码生成逻辑,文件位置 src/auth/verification.py ## blockers 邮件服务配置还没拿到,先用 mock这个文件的关键在于next_action 字段必须具体到文件和函数。我踩过的坑是:如果只写“继续实现验证码”,代理恢复后会重新读一遍代码,浪费大量 token,而且可能选错文件。写到文件级别,代理可以直接上手。
progress 用 checkbox 格式是有意的,因为大多数模型对 checkbox 的理解很稳定,能准确判断哪些完成了。而且这个格式人也能读,调试的时候很方便。
3.2 约束文件:代理的“纹身”
约束文件记录的是“绝对不能做什么”和“必须怎么做”。这是最重要的一层,因为约束丢失的代价最高。
# AGENT_CONSTRAINTS ## hard_rules - 禁止修改 config/production.yaml - 禁止直接操作数据库,所有变更走 migration 脚本 - 禁止删除任何现有测试用例 - 所有新代码必须通过 mypy 类型检查 ## style_rules - 测试用例命名遵循 test_<module>_<behavior> 格式 - 日志使用项目统一的 logger,不用 print - 异常处理必须记录上下文信息 ## context_rules - 这个项目是 Python 3.11,不要用 3.12 的新语法 - 依赖管理用 poetry,不要用 pip 直接装hard_rules 是硬约束,恢复时必须完整注入。style_rules 是风格约束,可以压缩但最好保留。context_rules 是环境约束,帮助代理避免用错工具。
我特意把约束分成三类,是因为在 token 紧张的时候可以分级注入。hard_rules 永远全量注入,style_rules 可以只注入前几条,context_rules 可以按需。这个分级策略后面会细讲。
3.3 决策日志:代理的“笔记”
决策日志记录的是“为什么这么做”。这个文件是追加写的,不覆盖。
# DECISION_LOG ## 2024-01-15 14:30 决策:密码重置用邮件验证码,不用短信 原因:项目没有短信服务商账号,邮件服务已有现成集成 备选方案:短信验证码(被否,成本高) ## 2024-01-15 15:10 决策:验证码有效期设为 10 分钟 原因:跟现有 session 过期时间保持一致,避免用户困惑 备选方案:5 分钟(被否,用户体验差)、30 分钟(被否,安全性低)决策日志的价值在于防止代理推翻已验证的决策。我遇到过代理恢复后重新讨论“要不要用短信”,浪费了一整轮对话。有了决策日志,恢复上下文里直接写“以下决策已确定,不要重新讨论”,代理就不会跑偏。
3.4 写入时机:什么时候该记
写入时机比写入内容更容易出错。写太频繁会拖慢代理,写太少会丢关键信息。我的经验是三个时机必须写。
第一,任务状态变化时。完成一个子任务、开始一个新子任务,都要更新状态文件。这个可以做成工具调用,让代理自己触发。
第二,做出非平凡决策时。什么叫非平凡?就是“如果忘了会重新纠结”的决策。比如选型、参数设定、方案取舍。这个需要代理有意识地去记,可以在系统提示里明确要求。
第三,收到新约束时。用户说了“不要动 X”,立刻写进约束文件。这个最好做成自动的,从用户消息里提取约束关键词,自动追加。
注意:写入操作本身要幂等。我踩过的坑是代理在压缩恢复后重复写入同一条决策,导致日志里出现多条重复记录。解决办法是写入前先检查是否已存在相同内容。
3.5 压缩感知:怎么知道“我被切了”
这是整个方案里最容易被忽略的一环。代理需要能检测到自己被压缩了,否则它不会主动去读恢复文件。
检测方法有几种。最简单的是在系统提示里埋一个哨兵:告诉代理“如果你发现对话历史不完整,或者记不清之前的事,就去读 AGENT_STATE.md”。但这个方法依赖模型自觉,不太可靠。
更可靠的是在上下文里放一个计数器。每次工具调用返回时带上一个递增的序号,代理如果发现序号跳变,就知道中间有压缩。这个方法需要工具层配合。
我用的是混合方案:系统提示里明确要求代理在每轮开始时检查状态文件的时间戳,如果时间戳比它记忆中的新,说明有外部更新,需要重新加载。同时工具层在检测到上下文长度突降时,主动在下一轮注入一个提示“检测到上下文压缩,请重新加载状态”。
4. 实操过程:从零搭一套恢复机制
4.1 目录结构设计
先定目录结构。我习惯在项目根目录下建一个.agent/目录,所有持久化文件放里面。
project/ ├── .agent/ │ ├── state.md │ ├── constraints.md │ ├── decisions.md │ ├── snapshots/ │ │ └── 2024-01-15-1430.md │ └── scripts/ │ ├── 00-load-state.sh │ ├── 10-load-constraints.sh │ └── 20-load-decisions.sh ├── src/ └── tests/snapshots 目录存历史状态快照,用于回溯。scripts 目录放恢复脚本,编号模仿 init.d 的命名习惯,数字小的先执行。
4.2 恢复脚本的写法
恢复脚本的核心任务是把持久化文件拼装成一段可注入的文本。我用 bash 写,因为简单可靠,不依赖额外运行时。
#!/bin/bash # 00-load-state.sh # 加载代理状态,输出到 stdout STATE_FILE=".agent/state.md" if [ ! -f "$STATE_FILE" ]; then echo "[STATE] 无状态文件,可能是新任务" exit 0 fi echo "=== 代理状态恢复 ===" echo "" cat "$STATE_FILE" echo "" echo "=== 状态恢复结束 ==="约束脚本类似,但要做分级处理:
#!/bin/bash # 10-load-constraints.sh # 加载约束,支持分级 CONSTRAINTS_FILE=".agent/constraints.md" LEVEL="${1:-full}" # full 或 lite if [ ! -f "$CONSTRAINTS_FILE" ]; then exit 0 fi echo "=== 约束条件 ===" echo "" # hard_rules 永远全量加载 sed -n '/## hard_rules/,/## style_rules/p' "$CONSTRAINTS_FILE" | head -n -1 if [ "$LEVEL" = "full" ]; then sed -n '/## style_rules/,/## context_rules/p' "$CONSTRAINTS_FILE" | head -n -1 sed -n '/## context_rules/,$p' "$CONSTRAINTS_FILE" fi echo "" echo "=== 约束结束 ==="决策日志脚本只加载最近 N 条,避免 token 爆炸:
#!/bin/bash # 20-load-decisions.sh # 加载最近 10 条决策 DECISIONS_FILE=".agent/decisions.md" LIMIT="${1:-10}" if [ ! -f "$DECISIONS_FILE" ]; then exit 0 fi echo "=== 近期决策(已确定,不要重新讨论)===" echo "" # 按 ## 分割,取最后 LIMIT 个 awk -v limit="$LIMIT" ' /^## / { count++ } { lines[count] = lines[count] $0 "\n" } END { start = count - limit + 1 if (start < 1) start = 1 for (i = start; i <= count; i++) { printf "%s", lines[i] } } ' "$DECISIONS_FILE" echo "" echo "=== 决策结束 ==="4.3 注入时机与方式
恢复脚本写好了,什么时候跑?我的做法是在每次会话开始时跑一遍,把输出拼进系统提示。
具体实现取决于你用的框架。如果是自己写的代理循环,就在构造 messages 数组之前跑脚本,把输出作为 system message 的一部分。如果用现成的框架,一般都有“会话初始化钩子”,挂上去就行。
注入的文本长这样:
你是一个编码代理。以下是你的工作状态和约束,请在开始任何操作前先阅读。 [00-load-state.sh 的输出] [10-load-constraints.sh 的输出] [20-load-decisions.sh 的输出] 重要:以上状态和约束是权威的。如果你发现自己的记忆与以上内容冲突,以以上内容为准。最后那句“以以上内容为准”很关键。我踩过的坑是:代理恢复后,上下文里可能还残留一些压缩后的模糊记忆,跟恢复文件冲突。如果不明确优先级,代理会纠结甚至选错。
4.4 写入钩子的实现
写入钩子分两种:代理主动写和系统自动写。
代理主动写,是通过工具调用实现的。给代理一个update_state工具,参数是新的状态内容,工具负责写入文件。
def update_state(task: str, progress: list, next_action: str, blockers: str = ""): """更新代理状态文件""" content = f"""# AGENT_STATE ## current_task {task} ## progress {chr(10).join(f"- [{'x' if p['done'] else ' '}] {p['text']}" for p in progress)} ## next_action {next_action} ## blockers {blockers} """ with open(".agent/state.md", "w") as f: f.write(content) return "状态已更新"系统自动写,是在检测到用户消息里有约束关键词时触发。比如用户说“不要动 X”,系统自动追加到 constraints.md。
CONSTRAINT_PATTERNS = [ r"不要(动|改|删|碰)(.+)", r"禁止(.+)", r"必须(.+)", r"记得(.+)", ] def extract_constraints(user_message: str): """从用户消息里提取约束""" constraints = [] for pattern in CONSTRAINT_PATTERNS: matches = re.findall(pattern, user_message) for m in matches: constraints.append("".join(m) if isinstance(m, tuple) else m) return constraints这个自动提取不用太精确,宁可多提取也不能漏。多提取的约束最多浪费点 token,漏掉的约束可能导致代理做错事。
4.5 压缩检测的实现
压缩检测我用了两个信号。
第一个信号是消息数量突降。在代理循环里记录上一轮的消息数量,如果这一轮突然少了很多,说明发生了压缩。
class CompressionDetector: def __init__(self): self.last_count = 0 def check(self, messages): current = len(messages) compressed = current < self.last_count * 0.7 # 减少超过 30% 视为压缩 self.last_count = current return compressed第二个信号是哨兵 token 丢失。在上下文里埋一个特殊的标记,压缩后如果标记不见了,说明被切了。
SENTINEL = "[[AGENT_MEMORY_SENTINEL]]" def inject_sentinel(messages): """在消息里埋哨兵""" if not any(SENTINEL in str(m) for m in messages): messages.insert(1, {"role": "system", "content": SENTINEL}) return messages def check_sentinel(messages): """检查哨兵是否还在""" return any(SENTINEL in str(m) for m in messages)两个信号任一触发,就在下一轮注入恢复提示:
[系统提示] 检测到上下文压缩。请立即重新阅读你的状态文件和约束文件,确认当前任务和约束后再继续操作。4.6 完整流程串起来
把上面这些串起来,一次完整的会话是这样的:
- 会话开始,跑恢复脚本,把状态、约束、决策注入系统提示。
- 代理开始干活,每完成一个子任务,调用
update_state更新状态。 - 做出决策时,调用
log_decision追加决策日志。 - 用户发新消息,系统自动提取约束,追加到约束文件。
- 代理循环每轮检查压缩信号,如果检测到压缩,注入恢复提示。
- 代理收到恢复提示,重新读状态文件,继续干活。
这个流程跑下来,代理在经历任意次压缩后都能恢复到正确状态。我实测过一个跑了 80 多轮的任务,中间触发了 5 次压缩,代理没有一次跑偏。
5. 常见问题与排查技巧实录
5.1 恢复后代理不读状态文件怎么办
这是最常见的问题。代理收到恢复提示后,嘴上说“好的我读一下”,然后直接开始干活,根本没读文件。
原因是恢复提示不够强制。我的解决办法是把恢复提示写成工具调用指令,而不是自然语言请求。具体做法是在系统提示里定义:收到压缩提示后,必须先调用read_state工具,工具返回状态内容,代理才能继续。
def read_state(): """读取代理状态,必须在压缩恢复后调用""" with open(".agent/state.md") as f: return f.read()然后在系统提示里写死:“如果你检测到上下文压缩,你的第一个动作必须是调用 read_state,在收到返回之前不要做任何其他操作。”
这个“第一个动作必须是”的措辞比“请阅读”有效得多。模型对“必须”和“第一个动作”这类指令的遵循度明显更高。
5.2 约束文件越来越长怎么办
跑久了约束文件会膨胀,每次注入都吃掉大量 token。我的处理策略是分级加归档。
分级前面讲过,hard_rules 全量注入,其他按需。归档是把已经不适用的约束移到 archived 区,注入时跳过。
# AGENT_CONSTRAINTS ## hard_rules (当前有效) ## style_rules (当前有效) ## archived - [2024-01-10] 禁止使用 X 库(已迁移到 Y 库,此约束失效)归档的触发可以手动,也可以定期。我习惯每周清理一次,把明显过期的约束移走。
5.3 决策日志重复写入
前面提过,压缩恢复后代理可能重复写同一条决策。解决办法是在写入前做去重检查。
def log_decision(decision: str, reason: str, alternatives: str = ""): """追加决策日志,自动去重""" with open(".agent/decisions.md") as f: existing = f.read() # 简单去重:检查决策内容是否已存在 if decision in existing: return "决策已存在,跳过" entry = f""" ## {datetime.now().strftime('%Y-%m-%d %H:%M')} 决策:{decision} 原因:{reason} 备选方案:{alternatives} """ with open(".agent/decisions.md", "a") as f: f.write(entry) return "决策已记录"去重用简单的字符串包含判断就够了,不用上语义相似度。因为重复写入的决策通常措辞完全一样,字符串匹配能覆盖 95% 的情况。
5.4 状态文件与实际代码不同步
有时候代理更新了状态文件,但实际代码没改,或者反过来。这会导致恢复后代理以为某步完成了,实际没完成。
解决办法是状态更新必须跟代码操作绑定。具体做法是把update_state和文件操作放在同一个工具调用里,要么都成功,要么都失败。
def complete_subtask(subtask_index: int, files_changed: list): """完成子任务,同时更新状态和验证文件变更""" # 先验证文件确实变了 for f in files_changed: if not os.path.exists(f): return f"错误:文件 {f} 不存在,状态未更新" # 再更新状态 state = load_state() state['progress'][subtask_index]['done'] = True save_state(state) return "子任务完成,状态已更新"这个绑定机制能有效防止状态和实际不一致。
5.5 常见问题速查表
| 问题 | 现象 | 排查方向 | 解决办法 |
|---|---|---|---|
| 恢复后不读状态 | 代理直接干活 | 恢复提示是否强制 | 改成工具调用指令 |
| 约束膨胀 | token 消耗大 | 约束文件大小 | 分级注入加归档 |
| 决策重复 | 日志有多条相同 | 写入去重逻辑 | 加字符串去重 |
| 状态不同步 | 代理以为完成了 | 状态与代码绑定 | 合并工具调用 |
| 压缩检测失效 | 代理不知道被压缩 | 哨兵是否被切 | 双信号检测 |
| 恢复后跑偏 | 代理做错事 | 约束优先级 | 明确“以文件为准” |
5.6 几个我踩过的坑
第一个坑是恢复脚本执行太慢。一开始我用 Python 写恢复脚本,每次会话启动要跑 2 秒多。后来改成 bash,降到 200 毫秒以内。恢复脚本要快,因为它卡在会话启动的关键路径上。
第二个坑是状态文件格式太自由。一开始我用自然语言写状态,结果脚本解析经常出错。后来改成固定字段的 Markdown,解析稳定多了。格式自由是给人类看的,给机器读的东西必须结构化。
第三个坑是忘了处理空状态。新任务开始时没有状态文件,恢复脚本报错,整个会话起不来。后来加了文件存在性检查,没有就输出“新任务”,不报错。
第四个坑是约束提取太激进。用户说“这个功能不要做得太复杂”,被提取成“不要做这个功能”,完全理解反了。后来我加了否定词白名单,只提取明确的禁止性表述,模糊的表述不自动提取,留给代理判断。
6. 进阶玩法:让记忆碎片自己生长
6.1 自动快照与回溯
状态文件只保留当前状态,但有时候需要回溯到之前的状态。我在恢复脚本里加了一个快照机制:每次状态更新前,先把旧状态存到 snapshots 目录。
#!/bin/bash # 状态更新前先快照 snapshot_state() { if [ -f ".agent/state.md" ]; then timestamp=$(date +%Y-%m-%d-%H%M) cp ".agent/state.md" ".agent/snapshots/${timestamp}.md" fi }快照的价值在于调试。代理跑偏的时候,可以对比快照看是哪一步开始错的。
6.2 决策日志的自动摘要
决策日志跑久了会很长,每次注入最近 10 条可能不够,但全量注入又太多。我的做法是定期对旧决策做摘要,把摘要放在日志开头。
# DECISION_LOG ## 摘要(2024-01-01 至 2024-01-14) - 认证方案确定为邮件验证码 - 数据库迁移统一用 alembic - 测试覆盖率要求 80% 以上 ## 2024-01-15 14:30 (详细决策)摘要可以手动写,也可以让代理定期生成。我习惯每周让代理生成一次,顺便检查有没有过期的决策。
6.3 跨会话的记忆继承
如果同一个项目要开多个会话,记忆碎片可以继承。做法是把.agent/目录提交到版本控制,新会话直接读旧会话的状态。
但要注意状态冲突。如果两个会话同时改状态文件,会互相覆盖。我的做法是加一个锁文件,写入前先检查锁。
acquire_lock() { if [ -f ".agent/.lock" ]; then echo "状态被锁定,请稍后重试" exit 1 fi touch ".agent/.lock" } release_lock() { rm -f ".agent/.lock" }这个锁机制在单机单会话场景下用不上,但如果是团队协作或者多代理并行,就很有必要。
6.4 记忆碎片的可视化
最后分享一个小工具:把状态文件渲染成一张图,方便人快速了解代理在干什么。
def render_state_dashboard(): """把状态渲染成简单的文本仪表盘""" state = load_state() total = len(state['progress']) done = sum(1 for p in state['progress'] if p['done']) bar = "█" * done + "░" * (total - done) print(f"任务:{state['current_task']}") print(f"进度:[{bar}] {done}/{total}") print(f"下一步:{state['next_action']}") if state['blockers']: print(f"阻塞:{state['blockers']}")这个仪表盘我放在终端的一个固定 pane 里,随时能看到代理的状态。调试的时候特别有用,一眼就能看出代理是不是卡住了或者跑偏了。
整套方案跑下来,最大的体会是:AI 代理的连续性不靠模型记忆,靠工程机制。模型会忘,但文件不会。把关键信息从上下文里搬到文件里,用确定性的脚本在每次恢复时重新加载,代理就能在任意次压缩后幸存下来。这个思路不限于编码代理,任何需要长会话连续性的 Agent 系统都能用。