给 Agent 装一个会学习的记忆

这篇番外讲一个具体的系统:给 coding agent 装一个跨会话的长期记忆。
写它的原因和上一篇番外不同。那篇是一次理论探索的摊开——"我借信息论的视角重新理解 agent 工程,但不知道对不对"。这一篇是一次工程落地的复盘——"我把一个记忆系统从想法做到了能跑,过程中设计被真实数据推翻了好几次,最后留下的这套工程设计长这样"。
所以这篇的主体是工程设计,不是故事。但工程设计的每一个决策背后都有来由,我会把"一开始怎么想的、跑真实数据发现了什么、最后改成什么"穿插在对应环节里。
引言 · 一个未被开采的矿
用过一段时间 coding agent 的人,大概都有过一种隐隐的浪费感。
你和 agent 的每一次 session 里,都沉淀着东西:你纠正过它的错误、你否决过它的方案、你让它重做的理由、你最终满意的那个版本。这些东西在当下是有价值的——它们让这一次的产出变好了。但 session 一结束,它们就蒸发了。下一次开一个新会话,agent 又是一张白纸,你会把同样的纠正再讲一遍。
我和 agent 的对话记录,是一个未被开采的经验矿。
这件事让人想做点什么,但第一反应往往是错的。大多数人的第一反应是"做个记忆库"——把对话存下来,建个索引,下次检索回来。这是数据库思维。
我做完之后发现,这套东西最容易掉进去的坑,恰恰就是做成数据库——存得很漂亮,检索很快,结构很完整,但 agent 的行为没有任何改变。经验躺在库里睡大觉,下一次该犯的错一个没少。
所以这篇番外的核心判断,也是整套设计的第一性原理,先说清楚:
这不是数据库问题,是自改进问题。 一条记忆如果从不改变任何一次后续行为,就是死数据,存得再精巧也没用。唯一价值是让 agent 下一次表现得不一样。
这句话决定了后面所有的工程设计——从"记什么"到"怎么存"到"怎么取",每一个环节都要被"它能不能改变下一次行为"这把尺子量一遍。
第一节 · 先看全景:从注入端倒推
在展开每一层之前,先看这张全景图,知道整套东西最终拼成一个什么。
这张图里藏着整套设计最反常识的一个决策——设计顺序是倒着来的。
大多数人设计记忆系统,正着设计:先想记什么,再想怎么存,最后想怎么取。这个顺序很自然,但它容易导向一个结果——你在"记什么"和"怎么存"上花了很多功夫,造出一个存得很漂亮的库,最后到"怎么取"的时候才发现,取出来的东西 agent 根本用不上。
我的做法是反过来,从末端倒推:
注入(agent 下一次需要什么、能消化什么)
← 检索(那得检索出什么形态的东西)
← 蒸馏(那蒸馏时要产出什么)
← 记什么(那原始对话里什么值得进入蒸馏)先想清楚末端——agent 在 UserPromptSubmit 那一刻能吃下什么。是几条简洁的行动建议,还是一大段背景?能消化多少条?什么语气?想清楚这些,才知道检索要返回什么;知道检索要返回什么,才知道蒸馏要产出什么;知道蒸馏要产出什么,才知道对话里什么东西值得进入蒸馏。
倒推设计逼着你先回答"它怎么用",再回答"它存什么"。 这是对抗"数据库思维"的第一个工程手段。
四步闭环的每一步,我用四个动词来概括:
- 淘金——从对话汪洋里识别有信号的段落(谁在纠正、谁在否决、谁在表达偏好)
- 炼金——把信号蒸馏成可复用的经验规则("如果 X 情境,则 Y 行为")
- 用金——在对的时刻把对的记忆注入给 agent(情境命中时)
- 会学习——根据 agent 之后的反馈更新置信度,对的越来越强,错的被淘汰
前三步,任何记忆系统都该有。第四步是关键——它让这套东西从"堆积经验"变成"学习经验"。 一条经验被注入后,用户下一次的反应反过来更新它。符合的越来越强,违反的逐渐淘汰。库是活的,不是只进不出的仓库。
接下来的几节,按倒推设计的链条展开。先讲最核心也最难的蒸馏,再讲检索,再讲注入,最后讲让它会学习的反馈闭环,和怎么把这一切工程化落地。
第二节 · 蒸馏:规则管确定性,LLM 管模糊性
蒸馏要回答的问题是:这段对话里,有没有值得记的东西?
难点在于,原始对话是噪声的海洋,经验是稀疏的信号。一百条消息里可能只有三四条有蒸馏价值。你怎么把这几条捞出来?
借鉴 AlphaGeometry 的结构
我借鉴了 AlphaGeometry 的结构思想:规则引擎在原空间做确定性筛选,LLM 在筛选后的片段上做软理解和合成。 规则管确定性,LLM 管模糊性。
翻译到记忆系统里,蒸馏拆成两步:
原始对话流(一个话题片段)
↓
[规则粗筛] 用固定词表匹配"有信号的对话段"
↓ 确定性、便宜、无幻觉——只决定"值不值得进 LLM"
[LLM 精炼] 理解语境,提取"如果X情境则Y行为"的经验
↓ 智能,处理微妙——产出结构化经验
[四判据校验] 可复现? 有信号? 可行动? 用户特有?
↓ 全满足才存,否则 skip
经验记忆库规则只做一件事:决定"这段对话值不值得进入 LLM 精炼"。 它不做任何理解,只做匹配。这是防幻觉的第一道关——规则不会有幻觉,它只是个模式匹配器。
规则粗筛:三级信号词表
哪些是对话里"有信号"的段落?我按信号强度分了三级:
| 级别 | 匹配模式 | 判断 |
|---|---|---|
| 强信号 | 明确纠正("不对/错了/应该是")、否决重做("重写/算了/换方法")、偏好声明("我偏好/记住/以后都") | 几乎一定值得蒸馏 |
| 中信号 | 追问不理解("没明白/为什么")、修改意图("想法变了") | 可能值得,进 LLM 判断 |
| 弱信号 | 正向反馈("对了/很好/就是这样") | 不单独蒸馏,只作置信度信号 |
匹配有优先级——命中强信号就不看低级别。这套词表是从真实对话里一条条攒出来的,不是拍脑袋编的。
LLM 精炼与四判据
规则筛出的候选段,交给 LLM 去理解、提取规则、标注触发情境。但 LLM 不是自由发挥的——判据锁死在 system prompt 里,不允许模型自己决定"什么值得记":
- 可复现:描述的是一个会再次发生的情境,不是一次性事件
- 有信号:依据是用户真实的反馈动作,不是 agent 的自我猜测
- 可行动:能翻译成 agent 下次能执行的具体行为
- 用户特有:这是这个特定用户的偏好,不是任何开发者都会有的通用常识
全满足才提取,否则输出 skip。宁缺毋滥。
穿插 · 四判据不是一开始就设计对的
这里要诚实交代一个过程:判据不是一开始就长这样的。
最初只有三条(可复现 / 有信号 / 可行动)。跑了一批真实 session 之后,发现经验库被"通用常识"污染了——比如蒸馏出"用户不理解时用类比解释"这种,任何开发者都该这么做的常识,也进了库。这种东西存进来,除了占地方,还会在检索时挤掉真正有用的个人经验。
加了第四条"用户特有"——换一个用户也成立的,就不是个人经验,跳过。判据从三条收紧到四条之后,拿 5 个 session 实测:从 27 条筛到 17 条,被筛掉的都是通用常识,留下的全是"换个人就不成立"的硬核偏好。
判据不是设计出来的,是跑数据后收紧出来的。 这个过程本身也说明:防污染的防线,往往要等你真的看到了污染长什么样,才知道该在哪里加挡板。
穿插 · session ≠ 蒸馏单位
另一个被真实数据推翻的预设,是蒸馏的触发单位。
设计的时候,很自然地假设"session 结束 = 蒸馏时机"。一个会话结束了,把里面的东西捞一遍。听起来天经地义。
跑了真实数据才发现完全不是这样。我有一个 session 跨了 3 天、5 个完全不同的话题——从一篇视觉模型的论文,聊到跨域知识转移,又跳到蜂群算法,再聊到一个电子宠物的想法,最后才聊到记忆系统本身。182 条消息,前半段和后半段毫无关系。session 和任务之间根本没有对齐关系——一个 session 可能永不"结束",只是被你遗忘。
于是修正:蒸馏的触发单位不是 session,是 topic segment(话题片段)。 话题切换被检测到时,对刚结束的那个片段触发蒸馏。一个 session = N 个话题片段,每个片段独立蒸馏。
这个修正带来的连锁后果后面会讲到——它直接影响了 hook 的触发逻辑。
第三节 · 检索:三层结构,情境优先
注入依赖检索。检索的核心问题是:当 agent 遇到一个新情境,怎么把过去相关的经验找出来?
最直觉的答案是语义检索——拿当前 prompt 做 embedding,找库里语义相近的经验。但我很快发现它不够用。
一个反例:经验是"编辑 package.json 前先备份,因为 agent 可能打乱依赖顺序"。下次在另一个项目里编辑 package.json,语境和"上次出错的项目"差异很大,纯语义检索可能命中率很低。但如果有一条"情境"信息——"当前正在编辑 package.json"——就能稳稳命中。
所以检索不能只靠语义相似度,要引入情境触发条件。我做了一个三层结构:
| 层 | 检索依据 | 作用 |
|---|---|---|
| 情境层 | 当前任务类型、宿主 agent 类型 | 优先级最高,确定性命中 |
| 语义层 | 当前 prompt 的语义(embedding) | 情境命中不足时的主力 |
| 时效层 | 衰减、最近性、置信度 | 过滤——太老又没被复用的降权 |
情境层优先级最高,因为它最确定。语义层兜底,处理情境覆盖不到的情况。时效层做过滤,保证注入的不是过时的经验。
穿插 · 情境维度从四个收窄到两个
设计的时候,我给情境层设想了四个维度:宿主类型、任务类型、当前工具、当前文件。听起来很合理——知道 agent 正在用什么工具、改什么文件,检索当然更精准。
跑了真实 session 数据,发现一个根本性的时序矛盾:检索发生在 UserPromptSubmit——用户刚提交 prompt,agent 还没开始用工具。 这个时刻没有"当前工具"——上一轮的工具调用已经结束了,下一轮的还没开始。能拿到工具信息的是 PreToolUse hook,但那个时点不做检索,也没法把结果注入给已经提交的 prompt。
这不是"最后一公里没走完",是这条路本身不通。
于是修正:情境层真正能用的维度从四个收窄到两个(宿主类型 + 任务类型)。工具和文件这两个维度没有扔掉,降级为经验的"元信息"——它们标注"这条经验是在什么情境下产生的",帮助理解适用范围,但不参与检索匹配。
这个修正又一次印证了一个规律:设计阶段再合理的设想,也要用真实运行去验。 四个维度的设想在纸面上毫无破绽,是真实数据告诉你哪条路不通。
第四节 · 注入:三种模式叠加
检索到的经验,怎么送到 agent 面前?注入是记忆和 agent 行为之间的唯一接口。我做了三种模式叠加:
- 模式 A(启动时静态注入):SessionStart 时,注入少量高置信度、跨项目通用的经验(比如用户偏好)。量要小,因为 context 有上限。
- 模式 B(按 prompt 语义检索注入):UserPromptSubmit 时,检索和当前 prompt 相关的几条经验注入。这是主力通道,大多数经验通过这条路径抵达 agent。
- 模式 C(agent 主动调用):agent 遇到不确定的事,主动调用一个 MCP 工具去查记忆。这条路径的优势是——agent 自己最清楚什么时候需要查记忆。
三种模式不是互斥的,是叠加的。高置信度的普适经验走 A,情境相关的走 B,需要深度的走 C。
注入内容的真实形态
这里有一个工程上很关键的细节:这三种模式最终都走同一个字段——additionalContext。理解它在 agent 内部的真实形态,是设计"注入什么内容"的前提。
它不是拼接进 user prompt,也不是 system prompt 的一部分。它被包装成一条 meta 消息,包在 <system-reminder> 标签里,和真正的用户消息并列但独立。模型把它当作"系统提醒",不是用户的真实输入,默认注意力权重低于真实 prompt。
这决定了注入内容的设计:不能太弱(会被当噪声忽略),也不能太强(会喧宾夺主,干扰 agent 对真实任务的理解)。
我当前的选择是保持克制——平铺的"请参考"格式,每条经验包含 scope 标签、类型、规则、行动建议。为什么不做得更花哨(比如用全大写标签强调)?因为现在没有反馈数据告诉我哪种格式更有效。 在没有数据的时候调格式,是拍脑袋。正确的做法是先让反馈闭环跑起来,等能区分"经验没被注入"和"经验注入了但被忽略"的时候,再做格式实验。
这条经验我在第 11 章也撞见过——鲁棒性的"默认配置"分两层:能强制的用机制锁死,只能约定的先靠纪律等数据。注入格式属于后者。
第五节 · 会学习:反馈闭环
前面四步——淘金、炼金、用金——是任何记忆系统都该有的。这一步,是让这套东西和"数据库"产生本质区别的地方:让经验会学习,而不是只堆积。
闭环的逻辑是这样的:经验被注入给 agent 之后,用户的下一次反应,反过来更新这条经验。
经验被注入 → agent 行为(可能符合经验、可能偏离)
↓
检测用户下一轮的反应
├── 用户行为符合注入的经验(正向)→ confidence +0.1,reinforce
├── 用户行为违反注入的经验(纠正)→ confidence -0.2,violate
└── 长期未被触发 → decay,权重衰减
↓
confidence < 0.2 或 violate_count > 3 → prune(淘汰)对的,经验越来越强,在检索时排序更靠前;错的,逐渐被淘汰,不再注入。库是活的,会自我净化。
归因怎么做
这里有个绕不开的工程问题:怎么判断用户"符合了"还是"违反了"一条经验?
我的做法是:注入经验之后,拿用户下一轮的 prompt 做 embedding,和"被注入经验的 action"算语义相似度。相似度高,说明用户的行为和经验一致,是 reinforce;如果检测到纠正信号词("不对/错了/重做"),说明用户违反了经验的指引,是 violate。
这个归因不完美——它有噪声,会误判。但它有一个关键优势:信号是硬的。 它依据的是用户真实的、可观测的行为(下一轮说了什么),不是 LLM 的自我评分。
这也是我说它"是最该先成熟的闭环"的原因。上一篇番外讲过一个困境——agent 的"有效性"很难衡量,因为反事实不存在(你观测不到"没有这个建议会怎样"),信号被噪声淹没。但记忆系统的反馈闭环不一样:它的闭环很短(下一个 session 就能验证),信号很硬(用户行为是可观测的事实)。所以,如果要找一个让 agent "自改进"的切入点,这里的闭环信号是最实在的。
第六节 · 工程化落地:从 hook 到常驻 daemon
前面五节讲的是引擎设计——蒸馏怎么设计、检索怎么设计、注入怎么设计。设计想清楚了,怎么落地是另一套工程。这一节讲落地。
跨宿主架构
第一个工程决策:记忆引擎不能绑死在某个 agent 宿主上。
┌─────────────────┐
│ 核心记忆引擎 │ ← 全部核心价值
│ (宿主无关) │
│ · 蒸馏 pipeline │
│ · 检索引擎 │
│ · 置信度更新 │
│ · 经验库 │
└────────┬────────┘
│
┌──────────────┼──────────────┐
↓ ↓ ↓
CC 适配层 ZCode 适配层 未来宿主
· 读 jsonl · 读 sqlite · ...
· CC hooks · ZC hooks
· CC MCP · ZC MCP核心引擎的输入是标准化的对话段,输出是标准化的经验记忆。它不依赖任何宿主。适配层只做三件事:从宿主读 session 数据、注册宿主的 hooks、注册宿主的 MCP server。换一个宿主,只加一层薄适配,引擎不动。
daemon 化的转向
第二个工程决策,是被实测逼出来的:业务逻辑放 hook 里,还是放常驻进程里?
最初设想是放 hook 里——每次 hook 触发,现场跑蒸馏、检索、注入。听起来很直接。实测发现三个问题撞在一起:
- hook 有 15 秒超时限制
- tsx 冷启动慢
- 反馈归因要调 embedding,是个网络往返
三个加起来,在 hook 里跑业务逻辑会超时。
于是转向常驻 daemon。架构变成:
hook(极薄客户端,~70 行) daemon(常驻进程,持有全部业务逻辑)
读 stdin · 蒸馏 pipeline
→ POST daemon · 检索引擎
→ 写 stdout · 置信度更新
· 定时维护(衰减 + 淘汰)
· 持有 DB / embedding / LLM 连接hook 变成了极薄的客户端——读 stdin、POST 给 daemon、写 stdout,三步。所有业务逻辑进 daemon。daemon 持有数据库和外部连接的复用,还跑定时维护任务。
穿插 · Stop hook 的语义陷阱
daemon 化的转向里,还牵出一个被 hook 语义坑到的故事。
设计时假设 Stop hook = session 结束,可以拿来做兜底蒸馏——session 结束了,把没处理完的片段捞一遍。这在 Claude Code 里可能是对的。但 ZCode 的 Stop hook 不是这个语义——它是"每次 agent 回复结束",一个 session 有几十轮对话,Stop 会触发几十次。
如果按原始设计,每次 Stop 都全量兜底蒸馏,等于一个 session 里重跑几十遍。纯粹是浪费。
修正:Stop hook 改做水位线增量蒸馏——只处理上次水位线(上次处理到的最后一条消息时间戳)之后新闭合的片段。没有新闭合的片段就跳过,只更新水位线。全量兜底交给定期批量任务。
原始的"Stop hook 全量兜底蒸馏"方案废弃。这个废弃的过程也挺典型——同一种机制(Stop hook),在不同宿主里语义不同。设计阶段假设的语义,要拿到目标宿主上实测才知道对不对。
穿插 · 一个诚实的回退
daemon 化前后还做过一次更大的回退。
有一版我做了"双通道注入"——除了 UserPromptSubmit 的主注入通道,还想在 PreToolUse 时做一次情境注入(当时以为"当前工具"维度能用)。做了一天,第二天复盘发现:情境层命中率低的根因不在注入端,在蒸馏端——是因为蒸馏时没有把情境维度提准,不是注入时没传进去。
根因判断错了,方向就错了。果断 revert,把双通道注入整个回退。
方向错了就回退,不硬撑。 这件事和第 3 章讲的"可回滚才有真稳定"是一回事——这里回滚的不是部署版本,是设计方向。能回退的前提是:你承认上一版可能错了,愿意拿真实数据去验。
第七节 · 元架构:规则核心 + 泛化边缘
讲到这儿,你可能发现一个贯穿全系统的设计原则一直在浮现——哪些地方用规则锁死,哪些地方交给泛化。
这个原则的判定尺度很简单:一个判断出错了,代价高不高。
代价高的(漏掉真经验、污染经验库、错误注入),用规则锁死;代价低的(话题边界略偏一点、经验措辞不完美),交给泛化。
| 系统层 | 规则核心(锁死) | 泛化边缘(可进化) |
|---|---|---|
| 话题切分 | 系统事件 + 固定切分词表 | 语义突变 + 词表扩充 |
| 蒸馏粗筛 | 强信号的固定匹配模式 | 弱信号的模糊匹配 |
| LLM 精炼 | 四判据是硬约束 | 具体提取方式交给 LLM |
| 检索 | 情境层用确定性维度 | 语义层用 embedding |
| 置信度 | 更新规则固定(+0.1/-0.2/淘汰线) | 阈值可随使用调优 |
这个结构的好处是:行为可预测(规则锁住骨架),同时处理长尾(泛化兜底)。 更重要的是,泛化的"进化"本身是被规则约束的——词表可以扩充,但扩充方式是结构化的;LLM 可以提取经验,但必须过四判据。
这就是"泛化进化,但不失控"。
细心的读者会发现,这和第 11 章鲁棒性撞见的"强制默认 vs 约定默认"是同一个母题——代价高的判断用机制锁死,代价低的靠纪律。 鲁棒性如此,记忆系统也如此。好的工程架构,母题是相通的。
第八节 · 诚实的边界
沿用上一篇番外的姿态,我得把这套东西的边界交代清楚。
经验库还小。 现在只有四十多条经验。置信度更新的统计有效性——哪些经验该被淘汰、阈值设多少合适——还需要更长时间的积累才能判断。当前的 +0.1/-0.2/淘汰线 0.2 这些值,都是"v0 占位",是跑起来之后才能调的。
注入格式、检索阈值、衰减参数,都是待验证的。 我在这篇文章里讲的每一个具体数值,都不是经过严格验证的最优解,而是"先用默认值跑起来、靠反馈数据驱动迭代"的起点。它们可能对,可能不对,需要时间检验。
定位还在 L2 到 L3 之间。 如果按"自改进系统的成熟度"来分——L1 是会记录,L2 是会积累经验,L3 是会根据置信度学习。这套系统目前在 L2 到 L3。再往上,L4 是"meta-memory"——系统反思自己的记忆策略本身对不对(比如"我总是蒸馏这类经验,但它其实没用")。这一层我还没碰。
这套方法能不能推广,我不知道。 它在我自己的使用习惯下跑通了,但换一个使用模式完全不同的人——比如一个几乎不怎么纠正 agent、只让它做执行的用户——这套蒸馏流水线可能根本采不到足够信号。它的适用边界在哪,还需要更多人、更多场景去检验。
最后说一句这两篇番外的关系。
上一篇,我用信息论的视角去重新理解 agent 工程——哪些工程会被模型升级吃掉、agent 的有效性为什么难衡量。它是一次理论探索的摊开,对不对我不敢打包票。
这一篇,我落地了一个具体的系统——给 agent 装一个会学习的记忆。它是一次工程落地的复盘,设计被真实数据推翻了好几次,留下的这套工程设计是验过的,但参数还要继续调。
一篇偏理论探索,一篇偏工程落地。两篇番外,从两个方向逼近同一个问题——怎么让 agent 变得更好用,以及怎么知道它真的变好了。 前者给方向感,后者给一个可以摸到的实例。
两篇都还在路上。
回到正文
结语 —— 13 章正文讲"怎么不翻车",两篇番外一篇讲"怎么重新理解翻车",一篇讲"怎么让 agent 自己少翻车"。