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

明天开新会话,它怎么知道这个项目的规矩?

项目的规矩放在哪
把「不许改测试文件」写在第一条消息里:A5 的真实运行写了 17 份摘要,只有 2 份还带着它;第二天开新会话,第一个请求里来自前一天对话的消息是 0 条。现代做法是指令文件:会话开始从磁盘读一次,拼进每一个请求,压缩后再从磁盘注入。T10 的第二天,任务里没提日期格式,模型在读任何文件之前就写出了 DD/MM/YYYY,思考里复述的正是 AGENTS.md 的原词。三家把指令文件做成了三种样子。
❓

本课一问:今天在这个仓库里干完活,明天开一个新会话,它怎么知道这个项目的规矩——日期写成什么格式、测试文件能不能改?

这一课先不碰:agent 自己在干活时学到的东西怎么记(第 2 课);几十份技能说明怎么放(第 3 课);会话本身怎么存(第 4 课)。这里只讲人写好的规矩。

从已有出发

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

A5 结束在两件事上:压缩是给接手者写交接文档;完整历史留在磁盘上,只是不在窗口里。可那都发生在一个会话之内。明天开新会话,messages 从空列表开始,昨天窗口里的东西一条都不在。

这一章用项目线专门录的 T10:两天会话。任务仓 tasks/sessions 就是贯穿任务 parse_date 的那份代码,多了一个仓库根的 AGENTS.md:

# AGENTS.md - This project writes and displays dates day-first: `DD/MM/YYYY` with zero padding (5 March 2024 is `05/03/2024`). Never use month-first dates in code, tests or output. - Keep functions small and add a unit test next to every new function in `test_dateparse.py`.

为什么这一章换了第二天的任务:贯穿任务 parse_date 的答案不依赖任何没写进任务的规矩,测不出「它记不记得」;第二天的任务专门设计成「只有读到项目规矩才能做对」,验收脚本检查 format_date(2024-03-05) 是否输出 05/03/2024。

先自己设计:把规矩写进第一条消息

最省事的做法:任务说明里顺手写上规矩。第一天的任务就是这么写的,原文:

The test `test_parse_date` in test_dateparse.py is failing. Find and fix the bug(s) in the source code so that the whole test suite passes. Do not modify the test file. As soon as you learn something about this repository that a future session would need (for example the exact command that runs the tests), save it with the remember tool, then continue with the task.
  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案把规矩写进第一条消息(任务说明)

第一条消息在会话里的命运,A5 已经见过:本课项目的截断特意保住了 system 与任务说明,但交给摘要模型的旧轮次里,规矩只在任务说明里出现过一次,写进摘要的就所剩无几。下面回放那次运行,展开任意一份摘要全文,找找「test file」:

A5 的真实运行 pd-v2-layered-s1:每轮之前的压缩与摘要全文 请求模型回复工具调用工具结果harness 注入

数据:本课项目 miniagent 的真实运行(src/data/project/agent-traces.json,2026-09-26 导出;模型 Qwen/Qwen3-8B-MLX-4bit,本机 mlx_lm.server)。token 数取自服务端 usage;思考截取约 600 字、正文与工具输出约 900 字(保留头尾)。耗时受本机同时运行的其他任务影响,只能粗看;token 与轮数才可比。

Claude Code 的官方文档对同一件事的说法是:压缩用摘要替换旧消息,所以「specific instructions from early in the conversation may not be preserved」(Agent SDK《How the agent loop works》,2026-09-26 读取)。

改进:指令文件,每个请求都重新注入

本课项目 miniagent 的 M6 实现只有几行:会话开始时读仓库根的 AGENTS.md,把 system prompt 拼成固定的三块——基础 prompt、# Project instructions (AGENTS.md)、# Memory from previous sessions(第 2 课)。稳定的放前面,为的是前缀缓存(第 2 课展开)。

第二天两组会话的第一轮,模型都没有读任何文件,就已经知道约定:

对照组(T10b):同一任务、同一 seed、同一起点,只把 AGENTS.md 与记忆从 prompt 里拿掉(AGENTS.md 文件仍在仓库里),重复 3 次。结果:按 AGENTS.md 的格式输出 0 / 3,验收通过 0 / 3;3 次里没有一次主动用工具去读 AGENTS.md(读到的次数 0),写出的格式都是 ISO 的 %Y-%m-%d——唯一能被验收脚本调用到的那一次,输出是 2024-03-05,而 AGENTS.md 要的是 05/03/2024。所以现在可以说:在这个任务上,规矩没进 prompt,模型就不会自己去找,也猜不到。样本只有 3 次、同一个 8B 本地模型,只当例子读,不当比率读 。

AGENTS.md 与 MEMORY.md 在两天里的注入:磁盘上的版本 vs 这一轮上下文里的版本 空有一条记忆磁盘 ≠ 上下文注入(会话开始 / system 刷新)

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

🎯

试三件事:① 看最上面一条泳道 AGENTS.md:紫色竖线出现在哪几列?(每个会话开始各一次,fork 也算)② 点 day2 的第 1 列,读下方「上下文里」那一行:这一轮请求里 AGENTS.md 块占多少 token?③ 再看最下面的「缓存命中」:day2 每一轮都重发 AGENTS.md,它有没有让命中率变低?

三家怎么做:同一个想法,三种样子

指令文件几乎所有编码 agent 都有,分歧在细节:什么时候读、放在请求的哪个位置、会话中途文件变了怎么办。

什么时候读放在请求的哪里多个文件中途改了文件大小
Claude Code(官方文档)会话开始读一次;压缩后从磁盘重新注入system 之后的「项目上下文」层从文件系统根到工作目录逐级拼接;子目录的在读到那里的文件时才加载;有 CLAUDE.md 时默认不读 AGENTS.md不生效,到 /clear、/compact 或重启才加载新内容建议每个文件 200 行以内;单个文件最多 4 MiB
Codex(pin 8cabf5a)会话初始化时加载一次,按环境缓存user 角色的一条消息项目根到 cwd 逐级拼接模型看到的版本与加载到的不同时,在末尾追加一条「以下取代之前全部」,不改前面的历史32 KiB 共享预算,超出硬截断
OpenCode(pin v1.18.32)每一步都从磁盘重新读system同名 first-match:找到 AGENTS.md 就不再找 CLAUDE.md;读子目录文件时懒加载沿途的 AGENTS.md下一步就生效(system 也就变了)—
Hermes(pin 56dc01d9)会话内缓存 system,只在压缩或恢复时重建三段式 system 的 context 段—到下一次重建—
本课项目 miniagent会话开始读一次system 的第二块只读仓库根不生效(下一个会话)—

表里 pin 源码那几行,逐行可以点开看。Codex 把「文件变了」做成了追加,而不是改写:

agents_md.rs codex@8cabf5a:codex-rs/core/src/context/world_state/agents_md.rs:52-79需联网 Apache-2.0
52 fn render_diff(
53 &self,
54 previous: PreviousSectionState<'_, Self::Snapshot>,
55 ) -> Option<Box<dyn ContextualUserFragment>> {
56 let current = self.snapshot();
57 if matches!(previous, PreviousSectionState::Known(previous) if previous == &current) {
58 return None;
59 }
60
61 let previous_may_contain_instructions = match previous {
62 PreviousSectionState::Known(previous) => previous.text.is_some(),
63 PreviousSectionState::Unknown => true,
64 PreviousSectionState::Absent => false,
65 };
66 let instructions = match (&self.instructions, previous_may_contain_instructions) {
67 (Some(instructions), true) => UserInstructions {
68 directory: instructions.directory.clone(),
69 text: format!("{REPLACEMENT_NOTICE}\n\n{}", instructions.text),
70 },
71 (Some(instructions), false) => instructions.clone(),
72 (None, true) => UserInstructions {
73 directory: None,
74 text: REMOVAL_NOTICE.to_string(),
75 },
76 (None, false) => return None,
77 };
78 Some(Box::new(instructions))
79 }
👆 带下划线的行可以点:点哪行,讲哪行。

加载本身在会话初始化时做一次、按环境缓存(agents_md_manager.rs:31-44);预算与截断:

agents_md.rs codex@8cabf5a:codex-rs/core/src/agents_md.rs:115-132需联网 Apache-2.0
115 let mut remaining: u64 = max_total as u64;
116 let mut loaded = LoadedAgentsMd::default();
117
118 for p in paths {
119 if remaining == 0 {
120 break;
121 }
122
123 let mut data = match fs.read_file(&p, /*sandbox*/ None).await {
124 Ok(data) => data,
125 Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
126 Err(err) => return Err(err),
127 };
128 let size = data.len() as u64;
129 if size > remaining {
130 data.truncate(remaining as usize);
131 }
132
👆 带下划线的行可以点:点哪行,讲哪行。

OpenCode 的选择相反:每一步都重读(prompt.ts:1257-1268 在循环里调用 instruction.system(),instruction.ts:90-92 的 read 没有缓存),改了马上生效;同名文件只取第一个:

instruction.ts opencode@v1.18.32:packages/opencode/src/session/instruction.ts:110-132需联网 MIT
110 const systemPaths = Effect.fn("Instruction.systemPaths")(function* () {
111 const config = yield* cfg.get()
112 const ctx = yield* InstanceState.context
113 const paths = new Set<string>()
114
115 for (const file of globalFiles) {
116 if (yield* fs.existsSafe(file)) {
117 paths.add(path.resolve(file))
118 break
119 }
120 }
121
122 // The first project-level match wins so we don't stack AGENTS.md/CLAUDE.md from every ancestor.
123 if (!Flag.OPENCODE_DISABLE_PROJECT_CONFIG) {
124 for (const file of instructionFiles) {
125 const matches = yield* fs
126 .findUp(file, ctx.directory, ctx.worktree)
127 .pipe(Effect.catch(() => Effect.succeed([])))
128 if (matches.length > 0) {
129 matches.forEach((item) => paths.add(path.resolve(item)))
130 break
131 }
132 }
👆 带下划线的行可以点:点哪行,讲哪行。
⚠
常见误解一:「会话中途改了 CLAUDE.md,马上就生效。」

Claude Code 官方文档(《How Claude Code uses prompt caching》,2026-09-26 读取)写明:项目根与用户级的 CLAUDE.md 在会话开始时读一次、留在内存里;中途编辑不会让缓存失效,但也不会生效,要到下一次 /clear、/compact 或重启。这不是所有 harness 的共同行为:OpenCode 每一步都重读,改了下一步就生效,代价是 system 变了、前缀缓存从那里断开。先问清楚你用的 harness 是哪一种。

常见误解二:「写进 CLAUDE.md 的规矩就是强制执行的。」

官方 memory 文档原话是它们被当作上下文,不是强制配置(treats them as context, not enforced configuration);要无论模型怎么想都拦住某个动作,用 PreToolUse hook——那是 A3 的 hook 与权限层的事。第二天的任务允许改测试文件,所以本章的录制没有测「规矩被违反」这一面。

极小真数字:第二天第一个请求由什么组成

冻结组第二天第 1 轮,服务端报告 prompt 1,138 token。用录制模型的 tokenizer 与 chat template 把同一个请求重算一遍,按块拆开,加起来正好是这个数:

T10 冻结组 day2 第 1 轮:服务端 usage.prompt_tokens = 1138
= + + + + +
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
这个会话一共 15 轮,每一轮都重发这 87 token 的 AGENTS.md 块,合计 1,305 token。但它在请求的最前面:15 轮里命中缓存最少的一轮也有 293 token,都盖过了 AGENTS.md 块的结尾(第 287 个 token)。

官方文档对同一件事的表述是:CLAUDE.md 在会话开始加载,每个请求都带着全文,但会被前缀缓存,所以只有第一个请求付全价。上面最后一行就是这句话在本机上的样子——本机服务的缓存跨会话共享,连第二天的第一个请求也命中了;云端缓存有 5 分钟到 1 小时的存活时间,隔夜的第一个请求要重新写入。

🔍 AGENTS.md 和 CLAUDE.md 同时存在,听谁的?

Claude Code 默认只读 CLAUDE.md:工作目录或上层目录里有任何一个 CLAUDE.md / CLAUDE.local.md,就不读 AGENTS.md;没有时才读 AGENTS.md(要 v2.1.277 以上)。想两份都读,把设置 Project instructions 改成 claude-md-and-agents-md,或在 CLAUDE.md 里写一行 @AGENTS.md 导入(官方 memory 文档)。OpenCode 按 AGENTS.md → CLAUDE.md 的顺序找,第一个命中的胜出。Codex 只认 AGENTS.md(可以配置备用文件名)。同一个仓库给多家 agent 用,最稳的是只维护一份 AGENTS.md,再让 CLAUDE.md 导入它。

证据三问

问本课的回答
① 它解决什么问题?规矩不随对话被压缩、不随会话结束而消失
② 为什么一定有效?指令文件不在被压缩的历史里,每个请求都由 harness 从磁盘拼进去;新会话的第一个请求就带着它
③ 实验上呢?「CLAUDE.md 每个请求都重新注入并被缓存」满票核实 ;T10 第二天在读任何文件之前写出了 AGENTS.md 规定的格式

演进地图:你在这里

记忆与会话的演进:每一代都被上一代的某个真数字逼出来
  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)。

一句话

📌
规矩放进每轮重注入的指令文件,不放在会被压缩掉的第一条消息。
🚦

先停在这里:记住三件事——指令文件跟着项目走,不跟着对话走;每个请求都带着它,但放在前缀里,只有第一次付全价;各家在「中途改了怎么办」上做法不同。
再深一层:去实验课把两组、两天的每一轮都点开看。

下一个缺口

agent 自己学到的东西,什么时候写进 prompt? → 第 2 课:什么时候把记忆写进 prompt

指令文件装的是人写好的规矩。第一天修 bug 的过程中,模型自己也学到了东西(比如这个仓库怎么跑测试)。它记下来之后,是马上塞进 system prompt,还是等一等?第 2 课会看到「马上」在真实的缓存数字上付出的代价。

你可能会问