从便利贴到可回滚记忆:nanobot 记忆模块五个月演进
如果你做过 LLM Agent,大概都踩过「记忆」这个坑:会话一截断,刚才说的过敏、偏好、决策就没了;好不容易写进文件,模型下一次又漏写一行,等于从磁盘上删掉。
本文顺着 nanobot 的 git 历史,把记忆模块从 2026-02-01 的第一份代码讲到 2026-07 的审计改造。目标不是复述源码,而是搞清:LLM 应用里的记忆,在生产里会摔哪些跟头。
事实均来自 commit;运行现场里的具体字符串标了「还原示例」;推断会单独标明。
先记住一个用户
2026-04-02 19:00,用户对助手说:
我对花生过敏,推荐食物时绝对不要含花生。
三天后他说「推荐个晚餐」。合格的记忆系统要给出不含花生的菜单,依据来自长期记忆,而不是碰巧还留在当前会话窗口里。
下面每个版本都用这一句话当探针:改完之后,磁盘上到底多了什么、少了什么、指针停在哪。
一张图看完全程
graph LR
A["2月 日记本<br/>模型自己 write_file"] --> B["2月 两层 + JSON"]
B --> C["2月 tool call"]
C --> D["3月 按 token 切"]
D --> E["3月 空 payload 降级"]
E --> F["3月底 Dream + jsonl"]
F --> G["4月 GitStore"]
G --> H["5月 cursor 抢跑修复"]
H --> I["6月 推倒两阶段 Dream"]
I --> J["7月 审计信 git diff"]
memory.py 从 110 行涨到七百多行,中间一度瘦到 30 行——不是功能变少了,是巩固逻辑被塞进了 loop.py,后来又抽回来。
1. 便利贴时代:记得住全靠模型自觉
commit d4cc48af(2026-02-01)
初代记忆只有两件事:
- 长期事实写
memory/MEMORY.md - 每天一篇
memory/YYYY-MM-DD.md
system prompt 会把 MEMORY.md 加上最近 7 天日记全文一起塞进去。没有自动归档。过敏这句话能不能活过三天,完全取决于模型记不记得调用 write_file。
这很像把过敏写在便利贴上、塞进口袋:口袋空的时候好用;口袋满了(会话超 50 条被 get_history 截断,或用户打了 /new),便利贴就掉了。
类比失效的边界:真便利贴不会每次对话都被「近 7 天日记」整本朗读一遍。初代会——日记一长,token 线性涨。
落盘(还原示例)
| 文件 | 模型听话 | 模型没写(更常见) |
|---|---|---|
MEMORY.md |
可能有「花生过敏」 | 仍 empty |
2026-04-02.md |
可能有日记 | 不存在 |
| session | 有原始句 | 有,但 3 天后不一定还在 prompt |
工程上还没有「巩固」这个环节。
2. 两层记忆:常驻事实 vs 可检索流水
commit 94c21fc2(2026-02-12)· PR #565
团队很快意识到:不能把所有过去都塞进每次 prompt。于是拆成两层:
MEMORY.md:长期事实,每次进 contextHISTORY.md:append-only 流水,不进 context,用 grep 搜
会话超过默认 50 条,就触发巩固:把除最近 10 条以外的对话送给 LLM,请它吐一段 JSON:
1 | { |
成功后 session.messages = messages[-10:],过敏那句从会话里物理删掉。
graph TD
U[同一句过敏] --> L[AgentLoop]
L -->|"len > 50"| Cons[巩固 LLM 吐 JSON]
Cons -->|history_entry| H[HISTORY.md 不进 prompt]
Cons -->|"memory_update 整文件覆盖"| M[MEMORY.md 进 prompt]
L -->|grep| H
P[问题发生点: json.loads] -.-> Cons
这步消除了「只靠模型想起 write_file」。但引入了两个新坑:
- 自己解析 LLM 的 JSON。 模型爱包 ```json 围栏,尾逗号、夹句「Sure, here is the JSON」都会让
json.loads炸掉。炸了就不写 HISTORY、不写 MEMORY。 memory_update是整文件覆盖。 漏写一行等于从磁盘删一行。PostgreSQL 决策和花生过敏不能同归于尽。
次日 740294fd 不敢再物理删 session 消息了——会打穿 KV cache。于是引入 last_consolidated 偏移:消息还在数组里,prompt 只取偏移之后的部分。记住这个指针,后面会反复出现。
四天后有人加了 json_repair(49fec368)。它能修围栏,修不了模型把字段写成数组。这个补丁只活了 4 天。
3. 别解析作文,让提供商给你结构化字段
commit afca0278(2026-02-19)· PR #866
改法很干脆:不要「Respond only with valid JSON」,改成强制调用 save_memory 工具,两个字段写在 JSON Schema 的 required 里。取值从 response.content 换成 response.tool_calls[0].arguments,json_repair 从 loop 里删掉。
没调工具就 skip,不写盘、不 trim。过敏句还留在未巩固区,下一轮可以再试。
这是整段历史里第一条可迁移原则:
结构化落盘走提供商的 tool schema,不要事后正则解析模型作文。
后来审计日志还会把同一原则再用一次。
代价:不支持 tool call 的提供商会巩固失败(随后为 DashScope 做了 tool_choice 回退)。Schema 仍允许空字符串交差——这把我们带到下一章。
4. 别用「条数」度量会爆的东西
commit 62ccda43(2026-03-10)· PR #1704
默认 memory_window = 50。一条带 16K 工具结果的消息大约 8000 tokens。20 条就约 16 万 tokens,远超当时默认窗口 65536;但 len(messages) = 20 < 50,旧逻辑一次巩固都不跑,请求直接撞墙。
反过来,51 条短问候会被切掉 41 条,归档过度。
新逻辑用真实资源做驱逐:
1 | target = context_window_tokens // 2 # 32768 |
最多 5 轮,直到估算值掉到窗口一半。source 日志会写明这次数字是 tokenizer 估的还是提供商回报的——方便核对「信谁的话」。
指针在干什么(还原示例)
1 | 巩固前 estimated=72000 last_consolidated=0 |
原则:
驱逐条件要量你会真正爆掉的那个数字,不要用量起来方便的代理指标。
5. 「调用了工具」≠「写下了有效内容」
这里有两种失败,窗口方向相反。混在一起会越想越拧。
先记住:get_history() 只喂 messages[last_consolidated:]。
- 偏移前进 → 旧消息离开 prompt → 窗口缩小
- 偏移不动 → 旧消息一直在 → 窗口继续涨,涨到 API 413
失败 A:交差成功(窗口缩小,记忆丢了)
62ccda43 时代的代码:
1 | if entry := args.get("history_entry"): # null / "" 都是假值,跳过写入 |
模型交来 {"history_entry": null, "memory_update": ""}:
- HISTORY / MEMORY 不写
- 返回
True→last_consolidated前进 - 过敏句从会话里「归档掉了」,文件里却没有
- 三天后问晚餐:两头都空 → 静默丢失
窗口没涨。涨的是数据空洞。
失败 B:巩固失败(窗口继续涨)
没调工具、或稍后 b24d6ffc 把空/null 改成显式 return False:偏移不前进,同一坨消息下一轮还在 prompt 里,直到 413。
commit 6d3a0ab6(2026-03-13)· PR #1810 修的是 B:连续 3 次 False 之后把原文打进 HISTORY([RAW]),再返回 True,让窗口能降下来,磁盘上至少留证据。
几天后 /new 也改成「先清空会话,后台重试直到 raw-dump;关机前 gather 待归档任务」(b29275a1)。用户不等巩固结束就能开新会话,进程退出也不该把过敏句带走。
降级也有账单。main 上的 2848f698 写得很具体:raw-archive 曾把约 1MB 原文打进 jsonl,后续请求撑破 200K 窗口(错误 1261)。后来单条封顶 16000 字符。
原则:
热路径失败时保原始证据;降级也要封顶。
6. Dream:热路径只归档,冷路径再解释
commit b9616674(2026-03-31)· PR #2717 · 随 v0.1.5 发布
单阶段巩固有一个结构性错误:每次窗口一满,就让模型重写整份 MEMORY.md。本轮 chunk 只有过敏一句,模型却可能漏掉已经记了 80 行的 PostgreSQL 决策——漏写等于删除。
拆成两段:
| 角色 | 何时跑 | 做什么 | 不做什么 |
|---|---|---|---|
| Consolidator | 用户还在等、token 超了 | 摘要追加到 history.jsonl |
不碰 MEMORY / USER / SOUL |
| Dream | cron 每 2 小时,或 /dream |
读未处理 jsonl,用 edit_file 外科手术改长期文件 |
不在用户关键路径上 |
历史格式从散文 HISTORY.md 换成 JSONL,一行一个对象:
1 | {"cursor": 29, "timestamp": "2026-04-02 19:05", "content": "- User allergic to peanuts"} |
两个文件当水位:
.cursor:下一笔 写 jsonl 用几号.dream_cursor:Dream 已经 读到 几号
graph TD
S[session 超 token] --> C[Consolidator.archive]
C -->|append-only| J[history.jsonl]
C --> CF[.cursor 写指针]
CR[cron 每 2h] --> D[Dream]
J --> D
DF[.dream_cursor 读指针] --> D
D -->|Phase2 edit_file| U[USER.md]
D --> M[MEMORY.md]
D --> SO[SOUL.md]
过敏先作为 jsonl 证据存在;最多 2 小时后 Dream 才决定写进 USER.md。三天后问晚餐,通常已经做过梦。
升级时会把旧 HISTORY.md 迁到 jsonl,并且两个 cursor 都设成最后一条——否则第一次启动会把用户全部历史重放进 Dream。
这一步消除了「窗口一满就重写 MEMORY.md」。但 Dream 的读指针提交点选错了,坑要到第 8 节才爆。
7. 给副作用加 git:能回滚,不等于审计诚实
commit f824a629(2026-04-02)
Dream Phase 2 直接 edit_file 改 USER.md。模型完全可能把「花生过敏」改成「花生偏好」。磁盘只有最新错误版本。
于是 workspace 里为 SOUL.md / USER.md / memory/MEMORY.md 建了一个小 git 库(dulwich),Dream 改完 auto_commit。用户可以用 /dream-log 看、/dream-restore <sha> 回到改前。
Git HEAD 成了第三支位置指针:写 jsonl、读 jsonl、长期文件版本。restore 只动那三个 md,不动 jsonl。
当时 changelog 仍抄 tool_events[].detail——也就是模型自己说「我加了 peanut allergy」。7 月的 PR #4673(f0c989ba)才改成 summarize_working_tree():审计记录信工作区相对 HEAD 的真实 diff,空 diff 不推进 cursor、不把自述写进 log。
原则又出现了一次:不要信模型的自我汇报,校验客观状态。
8. 失败方案往往比成功方案更值钱
8.1 水位线表示「已成功」,不是「已尝试」
Dream 引入时注释写得很坦率:
Advance cursor — always, to avoid re-processing Phase 1
Phase 1 已经花掉一次 LLM,作者怕失败重跑浪费钱。于是 Phase 2 抛错或 stop_reason != completed 时,仍然:
1 | new_cursor = batch[-1]["cursor"] |
还原示例
1 | 开始前 .dream_cursor = 28 |
jsonl 里 grep 找得到过敏;三天后 system prompt 里的 USER.md 没有它。默认 cron 每 2 小时跑一次,这个 bug 在代码里活了 35 天(3-31 到 5-05)。
sequenceDiagram
participant Cron
participant Dream
participant JSONL as history.jsonl
participant Cur as .dream_cursor
participant USER as USER.md
Cron->>Dream: run()
Dream->>Cur: read → 28
Dream->>JSONL: cursor>28 → 29,30
Dream->>Dream: Phase1 ok, Phase2 fail
Note over Dream,Cur: 问题发生点: 无条件 set(30)
Dream->>Cur: 旧 30 / 新 不写
Dream->>USER: 未写入过敏
原则:
任何 queue / offset / cursor,都只在副作用提交成功之后再 +1。
用「避免重复 Phase 1 成本」当前进条件,等于用钱换正确性——一次失败的过敏约束会丢一辈子。
修复代价:同一 batch 可能每 2 小时重跑 Phase 1(默认最多 20 条)。换来的是 USER.md 与 jsonl 最终一致。
8.2 第二套运行时会污染自己的输入
两阶段 Dream 类养了一套私有 AgentRunner + 两份 prompt(分析 / 编辑),和主 agent 的工具、技能、会话模型分叉。更糟的是:Dream 会话若走普通巩固,会把自己的输出再写进 history.jsonl,下一轮 Dream 再读——正反馈。
PR #3990(d1a94dae,2026-06-02)把这个类删了。cron 改为 process_direct(..., ephemeral=True):复用主循环,但跳过 raw_archive / maybe_consolidate,session key 形如 dream:YYYYMMDD-HHMMSS。整理记忆的过程,不应再变成需要被记忆的内容。
配置里的 modelOverride / maxIterations 留下来做兼容,实际忽略。Dream 质量绑在主模型上——这是刻意的取舍。
同类并发问题:两个写入同时 append_history 会抢到同一个 cursor(#4081 加锁)。
8.3 上线两天就被 revert 的摘要
4 月的 AutoCompact(PR #2982):空闲超过 TTL 就把会话 clear(),摘要塞进 metadata._last_summary。prepare_session 注入后 pop 掉它。若 pop 之后、用户下一句之前进程重启:会话空、metadata 也空,只剩 jsonl,主对话不会自动 grep。
5 月 7 日有人改成 sentinel:摘要留着,另用 _last_summary_used 防同一轮重复注入。5 月 9 日整段 revert,commit body 没写原因。同日另一提交把 archived summary 挪进 system prompt。推断是「一次性注入票据」和「跨重启记忆」抢同一个字段,语义打结。
两天实验说明:摘要的所有权必须只有一个角色。
可带走的五条
结构化落盘走 tool schema / git diff,不要解析模型作文。 JSON 围栏修了 4 天就被 tool call 替代;审计后来也不再信
resp.content。度量你会爆的那个资源。
len(messages) > 50漏掉 20 条 × 8k token 的工具历史。水位线只表示成功。
.dream_cursor在 Phase 2 失败时前进,jsonl 里的过敏变成永久不可达。写端.cursor和读端.dream_cursor拆开是对的,读端的提交点选错了。热路径归档、冷路径解释;失败保证据,降级要封顶。 无上限 raw-archive 能把 jsonl 撑到 1MB、窗口报 1261。
能复用主循环就不要养第二套 runtime。 Dream 整理过程写回 jsonl,就是在喂自己。
对照代码时可以看哪里
实现集中在 nanobot/agent/memory.py(MemoryStore / Consolidator / Dream),配套 nanobot/utils/gitstore.py 和 nanobot/skills/memory/SKILL.md。
若你检出的是较早的学习分支(例如停在 AutoCompact 的 fb6dd111),阶段 1–7 和「cursor 总是前进」都在树上;9fa90b10 之后的修复在 main。要复现「三天后推荐含花生晚餐」:让 Dream Phase 2 失败一次即可——jsonl 有过敏,USER.md 没有,.dream_cursor 已经跨过去。
记忆不该是一堆笔记。它该是一套安静的注意力:认出什么值得留下,丢掉不必占据窗口的东西,再把活过的经验变成可审计、可回滚、且不依赖模型自觉的事实。nanobot 用五个月、一次 revert 和一次推倒重来,把这句话写成了代码。