从0打造编码Agent 阅读 + 实验 ≈ 35 分钟 页内实验 ×2
A6 记忆与会话:明天它还记得吗? 第 2 课

agent 自己学到的东西,什么时候写进 prompt?

记忆什么时候写进 prompt
第一天,模型用 remember 记下一条它学到的东西,立刻落盘。刷新组在下一轮就把它塞进 system prompt:那一轮 2,300 token 只命中缓存 294——前缀在记忆块处断开,排在后面的工具定义与全部历史都要重算;同一轮不刷新,按同样的规则应命中 2,219。冻结组等到下一个会话:会话内每一轮都命中「上一轮长度减一」,第二天的第一个请求就带着那条记忆。这就是 Hermes 的冻结快照。
❓

本课一问:干活的过程中,agent 自己学到了一件以后用得上的事(比如这个仓库怎么跑测试)。它记下来之后,什么时候写进 prompt——马上,还是等一等?

这一课先不碰:几十份技能说明怎么放(第 3 课);会话本身怎么存(第 4 课);一整次任务的完整账单(A8)。这里的「代价」只看前缀缓存命中多少。

从已有出发

从已有出发 · 累计数据流 点已学环节看它回答什么
每一轮循环都经过

上一课的 system prompt 是三块:基础 prompt、AGENTS.md、# Memory from previous sessions。前两块是人写的;第三块是 agent 自己写的。miniagent 给模型一个 remember(note) 工具:把一句话追加到仓库之外的 MEMORY.md,上限 2,200 字符(照 Hermes 的默认值)。T10 第一天的任务要求「学到后面的会话用得上的东西,就用 remember 记下来」,两组各记了一条(模型原话):

两组同一份代码、同一个 seed,前 4 轮的请求逐 token 相同;第 5 轮起分岔,所以记下的内容不一样。本课只比一件事:记下之后,system prompt 什么时候变。

先自己设计:写进去,马上刷新

最直接的想法:记忆一写进磁盘,下一轮就重建 system prompt,本会话马上用得上。miniagent 的 refresh 模式就是这么做的:

# miniagent/memory.py(本课项目)
def system_prompt(self):
    memory = self.snapshot if self.mode == "frozen" else self.store.read()
    return build_system_prompt(self.base, self.agents_md, memory)
# miniagent/loop.py:每轮调模型前调用一次 system_prompt_provider;
# 返回值与当前 system 不同就替换,并在会话树里记一条 system_refresh
  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案写入后下一轮就刷新 system prompt

刷新组逐轮的服务端 usage(实测):

轮prompt命中缓存这一轮之前
11,152293会话开始
21,1991,151
31,6421,198
41,8061,641
52,0731,805
62,2202,072模型调用 remember,写入落盘
72,300294重建 system(带上刚写的记忆)

拆开看:前缀断在哪、为什么后面全部重算

前缀缓存:每一轮有多少 token 能从缓存里读 命中缓存写入缓存按原价 / 本机重算前缀断开处
构造输入 · 真算法假设第 轮调用 remember 后立即刷新 system

口径:本课项目 miniagent 用 Qwen/Qwen3-8B-MLX-4bit 在本机录制的 T10(2026-09-26)。每轮请求用录制模型的 tokenizer 与 chat template 重算成 token 序列,长度与服务端 usage 逐轮一致;「实测」是服务端返回的 cached_tokens(本机服务只报告命中数,不报告写入量)。Anthropic 规则与价目表依据官方文档实现(prompt-caching 与 pricing 页,2026-09-26 抓取);把本地模型的 token 数按云端价目表计费只是换算,真实的 Claude 请求 token 数会不同。耗时不作比较。

🎯

试三件事:① 默认选中的是刷新组第 7 轮:读下方详情,前缀断在哪一块?断点前后的原文是什么?绿色(推算)和蓝色虚线(实测)对得上吗?② 切到「反事实」,把 k 从 1 拖到 8:每次刷新多重算多少 token?为什么越晚刷新,多重算的越多?③ 把缓存规则换成 Anthropic、模型选 Claude Opus 5.5,刷新那一轮读到了多少?勾上「显式断点」再看;最后把模型换成 Claude Haiku 4.5——为什么一个 token 都没缓存?

三件事,数字都来自上面的真实请求:

  1. 断点在记忆块。刷新前后两个请求的前 287 个 token 完全一样(基础 prompt 加 AGENTS.md),接着是记忆块的标题,从记忆的内容开始不同:与上一轮的公共前缀是 293 个 token。实测命中 294,多出的 1 个来自本机缓存里更早的一个请求——另一组第二天的请求,它的记忆恰好也以 - 开头。本机缓存跨会话共享,组件按「最近 10 个请求里的最长前缀」推算,算出的正是 294。
  2. 后面的全部重算,连工具定义也算。录制模型的 chat template 把 769 token 的工具定义写在 system 文本之后,记忆块一变,工具定义也落在断点后面。Anthropic 的顺序是 tools → system → messages,工具定义排在最前面;但它只在断点处写缓存条目,自动缓存的断点在最后一块,所以刷新那一轮照样一个也读不到——除非在稳定段末尾再放一个显式断点(上面的勾选框),才能读回工具定义、基础 prompt 与 AGENTS.md。
  3. 越晚刷新越贵。断点之后的历史越长,重算的越多;而会话越往后,缓存本来越值钱。
一轮请求的输入费用(依据 Anthropic 官方 prompt-caching 与 pricing 文档,2026-09-26)
= + +
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
同一段真实历史(冻结组 day1)的第 6 轮:不刷新时读 2,071、写 44,输入费用 $0.000634;假设第 5 轮写入后刷新,这一轮读 0、写 2,136,$0.010680,是前者的 16.8 倍。token 数是录制模型 Qwen3-8B 的计数,按 Claude 的价目表换算,只比较相对大小。
⚠
常见误解一:「冻结快照 = 记忆写不进去。」

写入是立刻的。冻结组第 8 轮 remember 的工具结果原文:Saved to memory (104/2200 chars). It will be available in future sessions.——磁盘上已经有了,只是本会话的 system 不改。下面的泳道里,冻结组从第 9 列开始「磁盘上」就是新内容,「上下文里」要到 day2 才追上。

常见误解二:「Hermes 的记忆会自动检索相关事实。」

不会。它的内置记忆是两个带字符上限的 Markdown 文件,在会话开始时整份注入一次,没有 embedding、没有检索——这一条在调研的对抗验证里以 0 票支持被否决,是本课程的事实红线。Hermes 另外留了外部记忆 provider 的插件接口(默认关闭),那是另一回事,见岔路。

两组对照:磁盘上的记忆什么时候变、上下文里的什么时候跟上 空有一条记忆磁盘 ≠ 上下文注入(会话开始 / system 刷新)

数据:本课项目 miniagent 的 T10 录制(Qwen3-8B-MLX-4bit,本机 mlx_lm.server,2026-09-26)。「上下文里」取自每一轮真实请求的 system 段;「磁盘上」由录制里 remember 工具写入 MEMORY.md 的时刻推出(写入在那一轮执行工具时落盘)。token 数用录制模型的 tokenizer 重算;缓存命中是服务端报告的 cached_tokens。

改进:冻结快照

Hermes:写进磁盘立刻,写进 prompt 等下一个会话

memory_tool.py hermes-agent@56dc01d9:tools/memory_tool.py:1-17需联网 MIT 最新版已变化 · 勘误表 H5
1#!/usr/bin/env python3
2"""
3Memory Tool Module - Persistent Curated Memory
4
5Provides bounded, file-backed memory that persists across sessions. Two stores:
6 - MEMORY.md: agent's personal notes and observations (environment facts, project
7 conventions, tool quirks, things learned)
8 - USER.md: what the agent knows about the user (preferences, communication style,
9 expectations, workflow habits)
10
11Both are injected into the system prompt as a frozen snapshot at session start.
12Mid-session writes update files on disk immediately (durable) but do NOT change
13the system prompt -- this preserves the prefix cache for the entire session.
14The snapshot refreshes on the next session start.
15
16Entry delimiter: § (section sign). Entries can be multiline.
17Character limits (not tokens) because char counts are model-independent.
👆 带下划线的行可以点:点哪行,讲哪行。

上限本身在配置默认值里:

config_defaults.py hermes-agent@56dc01d9:hermes_cli/config_defaults.py:1709-1716需联网 MIT 最新版已变化 · 勘误表 H5
1709 "memory_char_limit": 2200, # ~800 tokens at 2.75 chars/token
1710 "user_char_limit": 1375, # ~500 tokens at 2.75 chars/token
1711 # External memory provider plugin (empty = built-in only).
1712 # Set to a provider name to activate: "openviking", "mem0",
1713 # "hindsight", "holographic", "retaindb", "byterover".
1714 # Only ONE external provider is allowed at a time.
1715 "provider": "",
1716 },
👆 带下划线的行可以点:点哪行,讲哪行。

仓库的贡献准则把这件事写成了第一原则,并点名唯一的例外:

AGENTS.md hermes-agent@56dc01d9:AGENTS.md:16-26需联网 MIT
16Two properties shape almost every design decision and are the lens for
17reviewing any change:
18
19- **Per-conversation prompt caching is sacred.** A long-lived conversation
20 reuses a cached prefix every turn. Anything that mutates past context,
21 swaps toolsets, or rebuilds the system prompt mid-conversation invalidates
22 that cache and multiplies the user's cost. We do not do it (the one
23 exception is context compression).
24- **The core is a narrow waist; capability lives at the edges.** Every model
25 tool we add is sent on every API call, so the bar for a new *core* tool is
26 high. Most new capability should arrive as a CLI command + skill, a
👆 带下划线的行可以点:点哪行,讲哪行。

压缩时发生什么?调研档案把它列为待核实(U19),本章在 pin 源码里核到了:压缩之后使 system 失效,并从磁盘重新加载记忆;如果重新加载的记忆与缓存里那份 prompt 已经一模一样(会话中没写过),就原样保留缓存的 prompt,本地后端的 KV 前缀不丢:

conversation_compression.py hermes-agent@56dc01d9:agent/conversation_compression.py:3209-3226需联网 MIT
3209 cached_system_prompt = agent._cached_system_prompt
3210 agent._invalidate_system_prompt()
3211
3212 # Built-in memory is the only system-prompt input that a normal
3213 # compaction reloads. When the cached prompt already embeds the
3214 # freshly-reloaded memory blocks verbatim, keep the exact cached
3215 # prompt so local backends retain their KV-cache prefix. Containment
3216 # (not before/after snapshot equality) is required: fresh-agent
3217 # surfaces restore the cached prompt from the session DB, where it
3218 # can predate mid-session memory writes the in-memory snapshot has
3219 # already absorbed. External providers can change their own prompt
3220 # block during on_pre_compress(), so they retain the rebuild path.
3221 if (
3222 cached_system_prompt is not None
3223 and getattr(agent, "_memory_manager", None) is None
3224 and _cached_prompt_reflects_builtin_memory(agent, cached_system_prompt)
3225 ):
3226 new_system_prompt = cached_system_prompt
👆 带下划线的行可以点:点哪行,讲哪行。

Claude Code 的 auto memory 在官方文档里也是同一个形状:MEMORY.md 的前 200 行或 25KB 在每个会话开始时加载;压缩之后「Re-injected from disk」(《Explore the context window》的存活表,2026-09-26 读取)。

放在哪:会变的放最后

冻结解决「什么时候变」,布局解决「变了损失多少」。Hermes 把 system prompt 切成三段,最可能变的放最后:

system_prompt.py hermes-agent@56dc01d9:agent/system_prompt.py:506-556需联网 MIT
506 # ── Volatile tier (most likely to differ on a rebuild; kept last so the stable prefix stays reusable) ──
507 volatile_parts: List[str] = []
508 # Skills are runtime-mutable: the agent adds and patches them across a
509 # session (SKILLS_GUIDANCE tells it to patch a skill the moment it goes
510 # stale). The built prompt is cached per session and only rebuilt on
511 # compaction/restore (see build_system_prompt), so a skill change is not
512 # byte-stable across rebuilds. With the index in the stable band, a rebuild
513 # that picked up a skill change would bust the cached prefix from the index
514 # down, taking the whole scaffold with it. Render it at the FRONT of the
515 # volatile band instead, ahead of the turn-varying memory/timestamp tail:
516 # on an implicit longest-prefix backend an unchanged index still falls
517 # inside the reused prefix, and a changed one only re-prefills from here on.
518 # (No effect for single-block cache_control backends, where the whole
519 # system message is one cache unit regardless of internal order.)
520 if skills_prompt:
521 volatile_parts.append(skills_prompt)
522
523 if agent._memory_store:
524 if agent._memory_enabled:
525 mem_block = agent._memory_store.format_for_system_prompt("memory")
526 if mem_block:
527 volatile_parts.append(mem_block)
528 # USER.md is always included when enabled.
529 if agent._user_profile_enabled:
530 user_block = agent._memory_store.format_for_system_prompt("user")
531 if user_block:
532 volatile_parts.append(user_block)
533
534 # External memory provider system prompt block (additive to built-in)
535 if agent._memory_manager:
536 try:
537 _ext_mem_block = agent._memory_manager.build_system_prompt()
538 if _ext_mem_block:
539 volatile_parts.append(_ext_mem_block)
540 except Exception:
541 pass
542
543 from hermes_time import now as _hermes_now
544 now = _hermes_now()
545 # Date-only (not minute-precision) so the system prompt is byte-stable
546 # for the full day. Minute-precision changes invalidate prefix-cache KV
547 # on every rebuild path (compression boundary, fresh-agent gateway turns,
548 # session resume without a stored prompt). The model can still query the
549 # exact wall-clock time via tools when it actually needs it.
550 # Credit: @iamfoz (PR #20451).
551 timestamp_line = f"Conversation started: {now.strftime('%A, %B %d, %Y')}"
552 if agent.pass_session_id and agent.session_id:
553 timestamp_line += f"\nSession ID: {agent.session_id}"
554 if agent.model:
555 timestamp_line += f"\nModel: {agent.model}"
556 if agent.provider:
👆 带下划线的行可以点:点哪行,讲哪行。

miniagent 的布局「基础 → AGENTS.md → 记忆」同样把会变的放在后面;但录制模型的 chat template 又把工具定义接在 system 文本之后,于是记忆一变,工具定义也跟着重算——上面第 2 条。布局要看到最终渲染出来的 token 序列,不能只看自己拼的字符串。

冻结组的真实结果

对照:按相关性挑选,而不是整份注入

learn-claude-code 的 s09 走另一条路:每次用户发话,先让模型从记忆目录里挑出相关的几条(最多 5 条、合计 20,000 字符)再拼进 system。它仍然不是向量检索:

code.py learn-claude-code@0dcafa2:s09_memory/code.py:280-330需联网 MIT
280def select_relevant_memories(messages: list, max_items: int = 5) -> list[str]:
281 records = list_memory_files()
282 query = recent_user_text(messages)
283 if not records or not query:
284 return []
285
286 catalog = "\n".join(
287 f"{index}: {' '.join(record['name'].split())} - "
288 f"{' '.join(record['description'].split())}"
289 for index, record in enumerate(records)
290 )
291 prompt = (
292 "Select memory records that are relevant to the current user request. "
293 "Return only a JSON array of catalog indices, such as [0, 2]. "
294 "Return [] when none are relevant.\n\n"
295 f"Current request:\n{query}\n\nMemory catalog:\n{catalog[:12000]}"
296 )
297
298 try:
299 response = client.messages.create(
300 model=MODEL,
301 messages=[{"role": "user", "content": prompt}],
302 max_tokens=200,
303 )
304 indices = extract_json_array(
305 message_text({"content": response.content})
306 )
307 selected = []
308 for index in indices:
309 if isinstance(index, int) and 0 <= index < len(records):
310 filename = records[index]["filename"]
311 if filename not in selected:
312 selected.append(filename)
313 if len(selected) == max_items:
314 break
315 return selected
316 except Exception:
317 return keyword_memory_selection(records, query, max_items)
318
319def load_memories(messages: list) -> str:
320 loaded = []
321 remaining = RECALL_CHAR_LIMIT
322 for filename in select_relevant_memories(messages):
323 content = read_memory_file(filename)
324 if not content or remaining <= 0:
325 continue
326 recalled = content[:remaining]
327 loaded.append({"source": filename, "content": recalled})
328 remaining -= len(recalled)
329 return json.dumps(loaded, ensure_ascii=False, indent=2) if loaded else ""
330
👆 带下划线的行可以点:点哪行,讲哪行。

挑选发生在 agent_loop 一开始(s09_memory/code.py:720-722):一次用户发话之内 system 不变,换一次用户发话就可能变。它和冻结快照的差别在「谁决定加载哪些」,这个问题留给岔路课。

证据三问

问本课的回答
① 它解决什么问题?让 agent 学到的东西跨会话留下来,又不在会话中途打穿前缀缓存
② 为什么冻结更省?前缀缓存要求逐 token 相同;system 在最前面,会话中途改它,后面全部失效
③ 实验上呢?Hermes 冻结快照、「不是检索系统」都是满票结论并有源码直证 ;T10 两组的缓存数字是单次运行实测

演进地图:你在这里

记忆与会话的演进:每一代都被上一代的某个真数字逼出来
  1. 最省事的做法:任务说明里顺手写上「不许改测试文件」「日期用 DD/MM/YYYY」
    解决了不用改 harness,当场就生效。
    卡在A5 的分层压缩运行里,规矩写在任务说明中,17 份真实摘要只有 2 份还带着「不许改测试文件」;换一个新会话更彻底:T10 day2 的第一个请求只有 2 条消息、1,138 token,来自前一天对话的是 0 条。
  2. Claude Code 的 CLAUDE.md、Codex / OpenCode 的 AGENTS.md(Codex 从项目根到 cwd 逐级叠加、32 KiB 上限;OpenCode 同名文件 first-match)、Hermes 三段 prompt 的 context 段
    解决了会话开始从磁盘读一次,拼进每个请求;压缩之后从磁盘重新注入。T10 day2 的每一轮都带着 AGENTS.md 块(87 token),验收「按 AGENTS.md 的格式输出」:通过。
    卡在它只装人写好的规矩。agent 自己在干活时学到的东西(比如这个仓库怎么跑测试)写到哪、什么时候生效?
  3. 本课项目 miniagent 的 refresh 模式(T10 刷新组)
    解决了本会话马上就能用上刚学到的东西。
    卡在T10 刷新组 day1 第 7 轮:上一轮命中 2,072 token,刷新后这一轮 2,300 token 只命中 294——前缀在记忆块处断开,后面的工具定义与全部历史都要重算。
  4. Hermes MEMORY.md(2,200 字符)/ USER.md(1,375 字符);miniagent frozen 模式;仓库准则「Per-conversation prompt caching is sacred」
    解决了会话内 system 字节不变:T10 冻结组 day1 的 8 个后续请求里,8 个把上一轮的请求整个读回了缓存(最多差 1 个模板 token);day2 开新会话,第一个请求就带着前一天写的记忆。
    卡在流程性知识越攒越多:Hermes 随包的 77 个 SKILL.md 全文合计 215,494 token,是录制模型窗口(40,960)的 5.3 倍,全塞进快照放不下。
  5. Pi(name + description + location)、Hermes(描述截到 60 字符)、Claude Code(单条 1,536 字符上限)、agentskills.io 规范;分发:MCP Skills Extension(SEP-2640,2026-09-13 Final)
    解决了同样 77 个 skill,Hermes 实际注入的索引 2,209 token;模型觉得相关时再读正文。
    卡在知识的问题解决了,会话本身怎么存?想回到三轮前换条路:T10 从 e0005 分叉,线性列表只能删掉它后面的 15 条(原来那条成功的路径)。
  6. Pi(id / parentId,一个 JSONL,/tree /fork /clone)、Claude Code(/branch、--fork-session)、Codex rollout(append-only,压缩写成 CompactedItem)、miniagent session.py
    解决了两条分支并存,压缩有损、历史无损;分叉的第一个请求与原分支共享前缀:T10 fork 第 1 轮 1,236 token,实测命中 1,197。
    卡在一个会话、一个窗口的边界还在:一件事大到一个 agent 的上下文装不下、或需要别人把关时 → 下一章 A7 子 agent。
旁支 按相关性挑选记忆注入,或检索(RAG) 没有比较证据
learn-claude-code s09(每次用户发话先让模型从目录里挑相关记忆,最多 20,000 字符);Hermes 的外部记忆 provider 插件(默认关闭,每轮预取、只拼进当轮 user 消息)
和文件式冻结快照谁更好?档案里没有任何比较性证据——能讲清的只有设计维度:谁决定加载、什么时候注入、会不会打穿前缀缓存。

数字口径:A5 的摘要来自 pd-v2-layered-s1 的真实运行(a05-context.json);其余来自本课项目 miniagent 的 T10 两天会话(Qwen3-8B-MLX-4bit,本机 mlx_lm.server,2026-09-26),缓存命中是服务端报告的 cached_tokens;skill 的 token 数由录制模型的 tokenizer 在 Hermes pin 的 77 个 SKILL.md 上现算(a06-memory.json,2026-09-26)。

一句话

📌
记忆写进磁盘可以立刻,写进 prompt 要等下一个会话——一切为前缀缓存让路。
🚦

先停在这里:记住「写」与「生效」是两件事;前缀缓存在第一个不同的 token 处断开;会变的内容放在最终 token 序列的最后。
再深一层:走岔路:文件记忆和 RAG 谁更好?——一个还没有证据的问题。

下一个缺口

流程性知识(skills)也这样全量注入吗? → 第 3 课:几十个技能说明,全塞进 prompt 吗?

记忆是几条短句,整份注入没问题(Hermes 的上限才 2,200 字符)。可「怎么做代码评审」「怎么调试」这类流程性知识一份就是几千 token,几十份放在一起呢?

你可能会问