如果你做过 LLM Agent,大概都踩过「记忆」这个坑:会话一截断,刚才说的过敏、偏好、决策就没了;好不容易写进文件,模型下一次又漏写一行,等于从磁盘上删掉。

本文顺着 nanobot 的 git 历史,把记忆模块从 2026-02-01 的第一份代码讲到 2026-07 的审计改造。目标不是复述源码,而是搞清:LLM 应用里的记忆,在生产里会摔哪些跟头。

事实均来自 commit;运行现场里的具体字符串标了「还原示例」;推断会单独标明。


先记住一个用户

2026-04-02 19:00,用户对助手说:

我对花生过敏,推荐食物时绝对不要含花生。

三天后他说「推荐个晚餐」。合格的记忆系统要给出不含花生的菜单,依据来自长期记忆,而不是碰巧还留在当前会话窗口里。

下面每个版本都用这一句话当探针:改完之后,磁盘上到底多了什么、少了什么、指针停在哪。


一张图看完全程

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:长期事实,每次进 context
  • HISTORY.md:append-only 流水,不进 context,用 grep 搜

会话超过默认 50 条,就触发巩固:把除最近 10 条以外的对话送给 LLM,请它吐一段 JSON:

1
2
3
4
{
"history_entry": "[2026-04-02 19:00] User allergic to peanuts.",
"memory_update": "# User\n- Allergic to peanuts\n"
}

成功后 session.messages = messages[-10:],过敏那句从会话里物理删掉。

这步消除了「只靠模型想起 write_file」。但引入了两个新坑:

  1. 自己解析 LLM 的 JSON。 模型爱包 ```json 围栏,尾逗号、夹句「Sure, here is the JSON」都会让 json.loads 炸掉。炸了就不写 HISTORY、不写 MEMORY。
  2. memory_update 是整文件覆盖。 漏写一行等于从磁盘删一行。PostgreSQL 决策和花生过敏不能同归于尽。

次日 740294fd 不敢再物理删 session 消息了——会打穿 KV cache。于是引入 last_consolidated 偏移:消息还在数组里,prompt 只取偏移之后的部分。记住这个指针,后面会反复出现。

四天后有人加了 json_repair49fec368)。它能修围栏,修不了模型把字段写成数组。这个补丁只活了 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].argumentsjson_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
2
3
4
target = context_window_tokens // 2          # 32768
estimated, source = estimate_session_prompt_tokens(session)
# 只在 role == "user" 的边界切开,避免孤儿 tool_result
session.last_consolidated = end_idx

最多 5 轮,直到估算值掉到窗口一半。source 日志会写明这次数字是 tokenizer 估的还是提供商回报的——方便核对「信谁的话」。

指针在干什么(还原示例)

1
2
3
4
5
6
7
8
9
10
巩固前  estimated=72000  last_consolidated=0
► [0] USER 我对花生过敏…
[1] ASST 记下了
[2..19] 超长 tool 结果

旧版:20 < 50,指针不动,下次仍带 72000 tokens

新版:last_consolidated=18
[0..17] 已进 HISTORY.md
► [18] USER 那晚饭吃什么 ← 停在下一轮 user 边界

原则:

驱逐条件要量你会真正爆掉的那个数字,不要用量起来方便的代理指标。


5. 「调用了工具」≠「写下了有效内容」

这里有两种失败,窗口方向相反。混在一起会越想越拧。

先记住:get_history() 只喂 messages[last_consolidated:]

  • 偏移前进 → 旧消息离开 prompt → 窗口缩小
  • 偏移不动 → 旧消息一直在 → 窗口继续涨,涨到 API 413

失败 A:交差成功(窗口缩小,记忆丢了)

62ccda43 时代的代码:

1
2
3
if entry := args.get("history_entry"):   # null / "" 都是假值,跳过写入
self.append_history(...)
return True # 只要调了 save_memory,就算成功

模型交来 {"history_entry": null, "memory_update": ""}

  • HISTORY / MEMORY 不写
  • 返回 Truelast_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 已经 读到 几号

过敏先作为 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_fileUSER.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 #4673f0c989ba)才改成 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
2
new_cursor = batch[-1]["cursor"]
self.store.set_last_dream_cursor(new_cursor) # 总是前进

还原示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
开始前 .dream_cursor = 28
[28] Prefers dark mode ✓ 已处理
► [29] User allergic to peanuts ← 本批
[30] Discussed Friday dinner

Phase 1 成功,Phase 2 失败

旧版 .dream_cursor = 30
[29] ✗ 被跳过,USER.md 永不更新
[30] ✗ 被跳过
下次 cron:read_unprocessed(since=30) → 空

新版 9fa90b10(2026-05-05)
.dream_cursor 仍 = 28
下次 cron 重试 29、30

jsonl 里 grep 找得到过敏;三天后 system prompt 里的 USER.md 没有它。默认 cron 每 2 小时跑一次,这个 bug 在代码里活了 35 天(3-31 到 5-05)。

原则:

任何 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 #3990d1a94dae,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_summaryprepare_session 注入后 pop 掉它。若 pop 之后、用户下一句之前进程重启:会话空、metadata 也空,只剩 jsonl,主对话不会自动 grep。

5 月 7 日有人改成 sentinel:摘要留着,另用 _last_summary_used 防同一轮重复注入。5 月 9 日整段 revert,commit body 没写原因。同日另一提交把 archived summary 挪进 system prompt。推断是「一次性注入票据」和「跨重启记忆」抢同一个字段,语义打结。

两天实验说明:摘要的所有权必须只有一个角色。


可带走的五条

  1. 结构化落盘走 tool schema / git diff,不要解析模型作文。 JSON 围栏修了 4 天就被 tool call 替代;审计后来也不再信 resp.content

  2. 度量你会爆的那个资源。 len(messages) > 50 漏掉 20 条 × 8k token 的工具历史。

  3. 水位线只表示成功。 .dream_cursor 在 Phase 2 失败时前进,jsonl 里的过敏变成永久不可达。写端 .cursor 和读端 .dream_cursor 拆开是对的,读端的提交点选错了。

  4. 热路径归档、冷路径解释;失败保证据,降级要封顶。 无上限 raw-archive 能把 jsonl 撑到 1MB、窗口报 1261。

  5. 能复用主循环就不要养第二套 runtime。 Dream 整理过程写回 jsonl,就是在喂自己。


对照代码时可以看哪里

实现集中在 nanobot/agent/memory.pyMemoryStore / Consolidator / Dream),配套 nanobot/utils/gitstore.pynanobot/skills/memory/SKILL.md

若你检出的是较早的学习分支(例如停在 AutoCompact 的 fb6dd111),阶段 1–7 和「cursor 总是前进」都在树上;9fa90b10 之后的修复在 main。要复现「三天后推荐含花生晚餐」:让 Dream Phase 2 失败一次即可——jsonl 有过敏,USER.md 没有,.dream_cursor 已经跨过去。

记忆不该是一堆笔记。它该是一套安静的注意力:认出什么值得留下,丢掉不必占据窗口的东西,再把活过的经验变成可审计、可回滚、且不依赖模型自觉的事实。nanobot 用五个月、一次 revert 和一次推倒重来,把这句话写成了代码。