1. 当上下文被压缩,代理为什么会“失忆”
做过 AI 编码代理的人大概都经历过这种崩溃:你花了两小时跟代理把需求聊清楚,它理解了项目结构、记住了命名约定、甚至记住了你讨厌它用某个库。然后对话一长,上下文窗口顶到上限,系统自动做了一次压缩。压缩完之后,代理像被做了脑叶切除术一样,突然开始用你三令五申禁止的写法,把之前定好的接口签名改得面目全非,甚至忘了自己刚才在改哪个文件。
这不是模型变笨了,是上下文压缩把它的“工作记忆”洗掉了。压缩算法通常保留最近几轮对话和系统提示,中间那些“我们约定过”的内容被摘要成一句话,细节全丢。对于聊天场景这没问题,但对于一个要连续改十几个文件的编码代理,这等于把施工图纸烧了只留一句“盖个房子”。
我后来用一个很土的办法解决了这个问题:把 Unix 的 init.d 启动脚本思路,和电影《记忆碎片》里主角靠纹身和纸条维持记忆的机制,移植到代理的记忆管理上。核心就一句话——别指望上下文窗口记住任何东西,把所有关键状态落到磁盘上,每次开工前重新加载。这套方案让我的代理在 20 万 token 的压缩风暴里活了下来,压缩前后行为完全一致。
这篇文章我会把这套机制的设计思路、目录结构、脚本实现、踩坑记录全部摊开讲。适合正在做 AI 编码代理、或者被长对话失忆问题折磨的开发者。不需要你懂内核开发,但需要你愿意接受一个反直觉的前提:代理的记忆不该存在对话里。
2. 核心设计思路:把记忆从上下文里搬出来
2.1 为什么“加大窗口”是条死路
很多人第一反应是换更大的上下文窗口。我试过,从 8K 换到 32K 再换到 128K,问题只是被推迟了,没有消失。原因有三层。
第一层是成本。窗口越大,每轮推理的 token 消耗越高,一个改十几次文件的编码任务,账单能翻好几倍。第二层是注意力稀释。窗口里塞的东西越多,模型对中间内容的注意力越弱,这就是常说的“lost in the middle”,你精心写的约定放在中段,模型根本看不见。第三层最致命——压缩依然会发生。哪怕窗口有 20 万 token,长任务照样能顶满,系统照样会触发摘要压缩,你的记忆照样被洗。
所以真正的解法不是“记住更多”,而是“不依赖记住”。这就像 Unix 系统重启:进程内存全丢,但系统照样能恢复到可用状态,因为关键配置写在/etc里,启动脚本按顺序读一遍就重建了运行环境。代理也应该这样。
2.2 init.d 给我们的三个启发
Unix 的 init.d 机制看起来古老,但它解决的是一个和代理记忆一模一样的问题:如何在无状态的启动过程中,可靠地重建一个有状态的服务。它有三个设计值得抄。
第一,约定优于配置的目录结构。/etc/init.d/下每个脚本就是一个服务,脚本名就是服务名,不需要额外的注册表。代理的记忆也应该这样,一个文件对应一类记忆,文件名自解释。
第二,幂等的启动语义。init 脚本可以反复执行,start已经启动的服务不会重复启动。代理加载记忆也必须是幂等的,加载十次和加载一次结果一样,否则重试就会污染状态。
第三,显式的依赖顺序。init 脚本有运行级别和依赖声明,保证 A 在 B 之前启动。代理的记忆加载也有顺序:先加载项目全局约定,再加载当前任务状态,最后加载临时草稿,顺序错了就会互相覆盖。
2.3 《记忆碎片》教我们的“外部化记忆”
电影里主角有顺行性遗忘,每隔几分钟就忘光,但他靠三样东西维持任务连续性:纹身(永久事实)、宝丽来照片(带注释的快照)、纸条(临时待办)。这三样对应到代理记忆系统里,就是三个层级的持久化文件。
- 纹身层:项目级的硬约束,比如“这个仓库用 tabs 不用 spaces”“所有 API 返回统一包装成
{code, data, msg}”。这些几乎不变,写在memory/tattoo.md。 - 照片层:任务级的快照,比如“当前正在重构
user_service,已完成 3/7 个函数,接口签名已定稿为 XXX”。写在memory/snapshot.md,每个任务阶段更新一次。 - 纸条层:临时待办和草稿,比如“下一步要跑测试”“这个 bug 怀疑在缓存层”。写在
memory/notes.md,随时可丢。
关键在于,这三层文件在每次代理开工前被强制读取,压缩发生多少次都不影响。上下文压缩洗掉的是对话历史,洗不掉磁盘文件。代理每次“醒来”,第一件事就是读这三个文件,把自己重新拼装成压缩前的样子。
提示:这套机制的本质是把易失的上下文记忆,转换成非易失的磁盘状态。压缩只影响前者,后者永远安全。
3. 目录结构与文件规范:让记忆可读可写可审计
3.1 整体目录布局
我在项目根目录下建了一个.agent/隐藏目录,所有记忆文件都放里面。为什么不放系统临时目录?因为记忆必须跟着项目走,换台机器、换个代理实例,只要目录在,记忆就在。结构如下:
.agent/ ├── memory/ │ ├── tattoo.md # 永久约束,几乎不改 │ ├── snapshot.md # 任务快照,阶段更新 │ └── notes.md # 临时草稿,随时可丢 ├── hooks/ │ ├── pre_task.sh # 任务开始前加载记忆 │ ├── post_edit.sh # 每次编辑后更新快照 │ └── on_compact.sh # 检测到压缩后强制重载 ├── log/ │ └── memory.log # 记忆读写审计日志 └── config.yaml # 记忆系统配置这个布局的灵感直接来自 init.d:memory/相当于/etc/init.d/,hooks/相当于运行级别脚本,log/相当于/var/log/。每个目录职责单一,不混用。
3.2 三个记忆文件该写什么
tattoo.md 是宪法,不是日记。它只放那些“代理如果违反就必须回滚”的硬约束。我实测下来,这个文件超过 200 行就开始失效,因为模型对超长约束的遵守率会下降。所以写法要极度精炼,用祈使句,一条一行。
# 项目硬约束 - 缩进用 4 空格,禁止 tab - 所有对外接口返回 {code, data, msg} - 禁止引入新的第三方依赖,除非 tattoo 里显式批准 - 数据库查询必须走 repository 层,禁止在 service 里裸写 SQL - 提交信息格式:type(scope): subjectsnapshot.md 是施工进度板。它记录“当前任务进行到哪了”,必须包含:任务目标、已完成项、进行中项、待办项、关键决策。这个文件是压缩后恢复的核心,因为压缩最容易丢的就是“我们刚才在干嘛”。
# 当前任务快照 ## 目标 重构 user_service,把同步调用改成异步 ## 已完成 - [x] 接口签名定稿:async def get_user(uid: int) -> User - [x] repository 层已改完 3 个方法 ## 进行中 - [ ] service 层 get_user 改造(当前卡在异常处理) ## 关键决策 - 异常统一抛 UserNotFound,不返回 None - 缓存层暂不动,下一阶段处理notes.md 是草稿纸。它允许混乱,允许过期,代理可以随手写“怀疑 bug 在 cache key 拼接”“下次试试用 dataclass”。这个文件的价值在于降低代理写记忆的心理门槛,不用每次都想“这条够不够格进 snapshot”。
3.3 文件更新的触发时机
记忆文件不能只在任务结束时写,那样压缩发生在中途就白搭。我的触发策略是三个时机:
- 任务开始前:
pre_task.sh读取三个文件,注入到系统提示最前面。 - 每次文件编辑后:
post_edit.sh检查 snapshot 是否需要更新,如果本次编辑改变了任务进度,就追加一行。 - 检测到压缩后:
on_compact.sh是兜底,一旦发现上下文长度骤降或摘要标记出现,立即重新读取三个文件并注入。
这里有个细节:注入位置比注入内容更重要。我试过把记忆放在对话中段,压缩后模型基本无视;放在系统提示最开头,遵守率明显提升。原因是模型对开头和结尾的注意力最强,记忆必须抢开头。
注意:不要把所有记忆都塞进系统提示,那样每轮都消耗大量 token。正确做法是只注入 tattoo 和 snapshot 的摘要版,完整版放在磁盘,代理需要时自己读。
4. 核心脚本实现:从加载到审计的完整链路
4.1 pre_task.sh:开工前的记忆重建
这个脚本是整套机制的心脏。它做四件事:检查记忆文件是否存在、读取内容、生成注入块、写审计日志。我用 bash 写,因为不依赖任何运行时,任何环境都能跑。
#!/usr/bin/env bash set -euo pipefail AGENT_DIR=".agent" MEM_DIR="$AGENT_DIR/memory" LOG_FILE="$AGENT_DIR/log/memory.log" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE" } # 确保目录存在,幂等 mkdir -p "$MEM_DIR" "$AGENT_DIR/log" # 三个记忆文件,缺失则创建空文件 for f in tattoo snapshot notes; do touch "$MEM_DIR/$f.md" done # 生成注入块,带明确分隔符 { echo "===== AGENT MEMORY BEGIN =====" echo "## 硬约束(必须遵守)" cat "$MEM_DIR/tattoo.md" echo "" echo "## 当前任务快照" cat "$MEM_DIR/snapshot.md" echo "" echo "## 临时笔记(可能过期,仅供参考)" cat "$MEM_DIR/notes.md" echo "===== AGENT MEMORY END =====" } > "$AGENT_DIR/injected_context.txt" log "pre_task: 记忆已加载,tattoo=$(wc -l < "$MEM_DIR/tattoo.md")行 snapshot=$(wc -l < "$MEM_DIR/snapshot.md")行" cat "$AGENT_DIR/injected_context.txt"这个脚本的关键设计是幂等。反复执行不会重复追加内容,因为每次都是覆盖写injected_context.txt。这直接抄了 init 脚本的语义:start一个已启动的服务是安全的。
4.2 post_edit.sh:编辑后的快照更新
每次代理改完文件,这个脚本被调用。它不自动改 snapshot,而是提示代理自己判断是否需要更新。为什么不让脚本自动写?因为脚本不知道这次编辑是否改变了任务进度,只有代理知道。脚本的职责是提供模板和检查。
#!/usr/bin/env bash set -euo pipefail AGENT_DIR=".agent" SNAPSHOT="$AGENT_DIR/memory/snapshot.md" LOG_FILE="$AGENT_DIR/log/memory.log" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE" } # 检查 snapshot 最后修改时间,超过 10 分钟提醒更新 if [ -f "$SNAPSHOT" ]; then last_mod=$(stat -c %Y "$SNAPSHOT" 2>/dev/null || stat -f %m "$SNAPSHOT") now=$(date +%s) diff=$(( (now - last_mod) / 60 )) if [ "$diff" -gt 10 ]; then log "post_edit: snapshot 已 $diff 分钟未更新,建议刷新" echo "提醒:snapshot 已 $diff 分钟未更新,如果任务进度有变化,请更新 .agent/memory/snapshot.md" fi fi这个“超时提醒”机制是我踩坑后加的。早期版本没有它,代理连续改十几个文件都不更新快照,结果压缩一来,进度全丢。加了提醒后,代理会主动在关键节点刷新 snapshot。
4.3 on_compact.sh:压缩后的强制重载
这是兜底脚本,也是最难写对的一个。难点在于如何检测压缩发生了。不同平台的压缩信号不一样,有的给摘要标记,有的直接截断历史。我的策略是双保险:检测 token 数骤降 + 检测摘要关键词。
#!/usr/bin/env bash set -euo pipefail AGENT_DIR=".agent" LOG_FILE="$AGENT_DIR/log/memory.log" LAST_LEN_FILE="$AGENT_DIR/log/last_context_len" log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$LOG_FILE" } # 从环境变量读取当前上下文长度(由代理框架注入) CURRENT_LEN="${AGENT_CONTEXT_LEN:-0}" if [ -f "$LAST_LEN_FILE" ]; then LAST_LEN=$(cat "$LAST_LEN_FILE") # 长度骤降超过 40% 视为压缩 if [ "$LAST_LEN" -gt 0 ] && [ "$CURRENT_LEN" -lt $(( LAST_LEN * 60 / 100 )) ]; then log "on_compact: 检测到上下文骤降 $LAST_LEN -> $CURRENT_LEN,触发重载" bash "$AGENT_DIR/hooks/pre_task.sh" > /dev/null echo "检测到上下文压缩,已重新加载记忆。请以 .agent/memory/ 下的内容为准。" fi fi echo "$CURRENT_LEN" > "$LAST_LEN_FILE"这个脚本的 40% 阈值是实测调出来的。设太低会误报,设太高会漏报。40% 能覆盖大多数压缩场景,又不会因为正常对话波动而误触发。
4.4 审计日志:让记忆可追溯
memory.log记录每次记忆读写,格式统一。它的价值在排查问题时体现:当代理行为异常,你可以翻日志看它最后一次加载记忆是什么时候、加载了什么版本。
[2025-01-15 10:23:01] pre_task: 记忆已加载,tattoo=12行 snapshot=18行 [2025-01-15 10:35:44] post_edit: snapshot 已 12 分钟未更新,建议刷新 [2025-01-15 10:41:02] on_compact: 检测到上下文骤降 185000 -> 42000,触发重载 [2025-01-15 10:41:03] pre_task: 记忆已加载,tattoo=12行 snapshot=21行看到没,压缩发生在 10:41:02,重载在 10:41:03,中间只隔一秒。代理几乎无感地恢复了记忆。
5. 实操过程:从零搭起一套抗压缩记忆系统
5.1 第一步:初始化目录和文件
在项目根目录执行,一条命令建好骨架:
mkdir -p .agent/{memory,hooks,log} touch .agent/memory/{tattoo,snapshot,notes}.md touch .agent/log/memory.log然后把前面三个脚本分别写入hooks/下,加执行权限:
chmod +x .agent/hooks/*.sh这一步没什么技术含量,但目录名和文件名必须固定,因为脚本里是硬编码路径。我试过做成可配置的,结果配置本身也需要记忆,绕回去了。约定优于配置在这里是真理。
5.2 第二步:填充 tattoo.md
打开.agent/memory/tattoo.md,把项目的硬约束一条条写进去。判断标准很简单:如果代理违反这条,你会不会想回滚?会,就写进去;不会,就别写,免得稀释注意力。
我一般从这几个维度收集:代码风格(缩进、命名)、架构约束(分层、依赖方向)、接口约定(返回格式、错误码)、流程规范(提交信息、分支命名)。每个维度控制在 3-5 条,总共不超过 20 条。
5.3 第三步:接入代理框架的钩子
不同代理框架的钩子机制不一样,但思路一致:在任务开始、文件编辑、上下文变化三个时机调用对应脚本。以常见的几种框架为例:
| 框架类型 | 任务开始钩子 | 编辑后钩子 | 压缩检测 |
|---|---|---|---|
| 命令行代理 | 启动参数 | 文件监听 | 轮询上下文长度 |
| IDE 插件 | onSessionStart | onFileSave | onContextChange |
| 自研框架 | 手动调用 | 手动调用 | 手动调用 |
如果是自研框架,最省事的做法是在系统提示拼装函数里,第一行就调用pre_task.sh并把输出拼进去。这样每次拼提示都自动加载记忆,不需要额外的钩子管理。
5.4 第四步:验证抗压缩效果
搭好之后必须验证。我的验证方法是人为制造压缩:让代理做一个长任务,中途手动清空对话历史(模拟压缩),看它能否继续。具体步骤:
- 给代理一个多步骤任务,比如“重构三个模块的日志调用”。
- 做到一半,手动清空对话,只保留系统提示。
- 观察代理下一步动作是否符合 snapshot 里的进度。
如果代理能准确说出“我正在改第二个模块,第一个已完成”,说明记忆系统生效。如果它一脸茫然,检查injected_context.txt是否被正确注入。
提示:验证时一定要用真实的长任务,短任务触发不了压缩,测不出问题。我一般用“重构 5 个以上文件”的任务来测。
5.5 第五步:调优注入内容
跑通之后开始调优。核心指标是注入 token 数和遵守率的平衡。注入太少,代理记不住;注入太多,每轮成本高还稀释注意力。
我的调优经验是:tattoo 全量注入(它短且关键),snapshot 只注入“目标 + 进行中 + 关键决策”三段(已完成和待办可以省略),notes 只注入最近 5 条。这样注入量能控制在 500 token 以内,遵守率还很高。
6. 常见问题与排查技巧实录
6.1 代理无视记忆,照旧犯错
这是最常见的问题。排查顺序:先看注入位置,再看注入内容,最后看模型本身。
注入位置:记忆必须在系统提示的最开头,不能放在中段或结尾。我试过放在结尾,遵守率直接掉一半。
注入内容:检查是否有矛盾。比如 tattoo 说“禁止用 tab”,snapshot 里却写“缩进用 tab”,代理会困惑。记忆文件之间必须一致。
模型本身:有些模型对系统提示的遵守率天生就低,这时候只能靠更频繁的提醒,或者在每次用户消息前再注入一次摘要。
6.2 压缩检测漏报
on_compact.sh的阈值检测不是万能的。有些平台压缩时不改变上下文长度,只是替换内容。这种情况我的兜底方案是定时重载:不管有没有检测到压缩,每 20 轮对话强制重载一次记忆。代价是多消耗一点 token,但换来的是稳定。
6.3 snapshot 更新太频繁导致噪音
早期我让代理每次编辑都更新 snapshot,结果文件里全是“改了第 3 行”“删了个空行”这种噪音。后来改成只在任务阶段变化时更新,判断标准是“这次编辑是否改变了任务的整体进度”。改一个函数内部实现不算,改完一个函数算。
6.4 记忆文件冲突
多个代理实例同时操作同一个项目时,记忆文件会冲突。我的解法是加文件锁,在pre_task.sh和post_edit.sh里用flock包一层。虽然简单,但能避免大部分并发写坏文件的情况。
exec 200>"$AGENT_DIR/log/.lock" flock -n 200 || { echo "另一个代理正在操作,稍后重试"; exit 1; }6.5 常见问题速查表
| 现象 | 可能原因 | 排查动作 |
|---|---|---|
| 代理完全无视记忆 | 注入位置错误 | 检查是否在系统提示开头 |
| 压缩后行为突变 | 压缩检测未触发 | 查看 memory.log 有无重载记录 |
| 记忆内容自相矛盾 | 文件间不一致 | 对比 tattoo 和 snapshot |
| snapshot 全是噪音 | 更新过于频繁 | 改为阶段更新 |
| 多实例写坏文件 | 无并发控制 | 加 flock 文件锁 |
| 注入 token 过高 | 全量注入 | 改为摘要注入 |
6.6 几个我踩过的坑
坑一:把记忆写在对话里。最早我让代理“记住这个约定”,结果压缩一来全没了。记忆必须落盘,这是铁律。
坑二:snapshot 写得太细。我一度把每个函数的改动都写进 snapshot,结果文件几百行,代理读都读不完。snapshot 是进度板,不是 diff 记录。
坑三:忘了幂等。早期pre_task.sh用追加写,重试几次后注入块重复了十几遍,token 爆炸。改成覆盖写就好了。
坑四:检测阈值拍脑袋。40% 这个数字是试了七八次才定下来的,一开始设 20% 误报不断,设 60% 又漏报。阈值必须实测。
7. 进阶玩法:让记忆系统自我进化
7.1 记忆的自动归档
跑久了之后,snapshot 会积累大量已完成任务。我的做法是任务完成后归档:把 snapshot 内容移到memory/archive/下,按日期命名,snapshot 清空准备下一个任务。归档文件不注入,只在需要追溯时手动查。
archive_snapshot() { local date_str=$(date '+%Y%m%d_%H%M%S') mv .agent/memory/snapshot.md ".agent/memory/archive/snapshot_$date_str.md" touch .agent/memory/snapshot.md }7.2 从 notes 里提炼 tattoo
notes 里经常出现重复的临时约定,比如代理反复写“记得用 async”。当某条笔记出现超过 3 次,就该考虑把它提升为 tattoo。我写了个简单的统计脚本:
grep -o '记得[^。]*' .agent/memory/notes.md | sort | uniq -c | sort -rn | head -5出现频率高的,手动确认后写进 tattoo。这样记忆系统会随着项目推进越来越懂你的偏好。
7.3 跨项目的记忆共享
有些约束是跨项目的,比如“我讨厌用某个库”。这种可以放在用户级目录~/.agent/memory/tattoo.md,项目级 tattoo 加载时先读用户级再读项目级,后者覆盖前者。这样换项目不用重写通用约束。
7.4 记忆版本化
把.agent/memory/纳入 git 管理,每次记忆变更都有 commit 记录。好处是能回滚——如果某次 snapshot 更新写错了导致代理跑偏,直接git checkout回上一个版本。我实测下来,这个习惯救过我好几次。
8. 一些个人体会
这套机制我用了大半年,最大的感受是:AI 代理的可靠性不取决于模型多强,而取决于你给它搭的外部脚手架多稳。模型会忘、会漂、会被压缩洗脑,但磁盘文件不会。把记忆外部化,等于给代理装了一个不会失忆的大脑外挂。
另一个体会是简单胜过聪明。我一开始想搞复杂的向量检索、语义压缩,后来发现最土的文件读写反而最可靠。init.d 那套东西几十年没变,不是因为它先进,是因为它把“可靠重建状态”这件事做到了极致。代理记忆管理也该这样,别追求花哨,追求每次都能正确恢复。
最后分享一个小技巧:定期手动检查injected_context.txt。这个文件是代理实际看到的记忆,不是你以为它看到的。我踩过好几次坑,都是因为注入内容和预期不符。养成每周看一眼的习惯,能提前发现很多问题。
这套方案后续还能扩展,比如把记忆文件做成结构化 JSON 方便程序处理,或者接入外部数据库做多机同步。但核心思路不变:记忆在磁盘,不在上下文;加载靠脚本,不靠模型自觉。把这两条守住,20 万 token 的压缩风暴也就是一阵风而已。