练习 15:跨会话记忆——价值在筛选,不在积累

练习 11 的会话持久化,续的是同一场对话——-c 恢复的是同一个 session ID, History 里的每一条消息都还在。这一章问一件不一样的事:下一次完全重新 开始的对话,跟上一次压根不是同一个 session,模型还能不能记得上一次 发生过的事?如果能记,那接下来更扎心的问题是:记住之后,会不会越记 越乱——写进去容易,什么时候该改、该删,谁来判断?

敲进去

在练习 14 的代码上继续写。新增一个记忆文件层,和 .harnessrules 的项目 规则层结构类似,但性质不同:.harnessrules 是人写的、模型只读;这份 MEMORY.md 是模型自己写的、自己读。完整文件在 exercises/ex15/

// ---- 记忆层:模型自己维护的跨会话笔记 ----

// memoryFile 蒸馏自 octo 的 MEMORY.md——但只留最小的那一部分:一个项目
// 一份文件,本章不做 octo 真实实现里的按仓库分目录、跨项目继承、200 行/25KB
// 截断预算,够用就好,把"跨会话"这一件事立住是这一章的唯一目的。
const memoryFile = "MEMORY.md"

// readMemory 读工作目录下的 MEMORY.md,文件不存在就返回空字符串——
// 全新项目还没写过这份文件,这是正常状态,不是错误。
func readMemory() string {
	data, err := os.ReadFile(memoryFile)
	if err != nil {
		return ""
	}
	return strings.TrimSpace(string(data))
}

// memoryGuidance 是这一层唯一新增的"规矩",蒸馏自 octo 真实的 memory 注入
// 说明:MEMORY.md 是什么、什么值得写、用什么工具写。这段话不因文件是否
// 存在而变化——第一次跑到这个项目,模型也要知道有这么个地方能写。
// 全书唯一一处故意不新增专用工具的地方:记东西用 write_file,改错一条、
// 删掉一条用 edit_file——和练习 6 已经有的工具是同一套,没有专门的
// remember/forget。
const memoryGuidance = `# 跨会话记忆 (` + memoryFile + `)

` + memoryFile + ` 是你自己维护的记忆文件,不是这次任务的草稿。这次任务
结束后,下一次全新会话——不是用 -c 续接这一次,是完全重新开始的下一次
——会在系统提示里重新读到你现在写下的内容。

- 值得写:用户明确要求记住的偏好、和默认做法不一样的项目约定、你自己
  验证过、以后大概率还用得上的结论。不值得写:这次任务本身的中间状态、
  代码改动的具体内容——那些内容已经在文件和 git 历史里,不需要在这里
  重复一份。
- 没有专门的"记住"或"忘记"工具。` + memoryFile + ` 就是一个普通文件:
  想写新的用 write_file,想改一条用 edit_file,想删掉一条也是 edit_file
  ——记错一件事和改错一行代码,是同一种操作,用同一套工具。
- 引用这份文件里的内容之前,先确认它现在还成立——项目会变,你之前记下
  的事,不保证放到现在还是真的。`

把它接进 composeSystemPrompt

func composeSystemPrompt() string {
	prompt := basePrompt
	if rules := readProjectRules(); rules != "" {
		prompt += "\n\n---\n\n# 项目约定 (" + projectRulesFile + ")\n\n" + rules
	}
	prompt += "\n\n---\n\n" + memoryGuidance
	if mem := readMemory(); mem != "" {
		prompt += "\n\n## 你目前记下的内容\n\n" + mem
	} else {
		prompt += "\n\n## 你目前记下的内容\n\n(还是空的——这是这个项目第一次有你可读的记忆)"
	}
	return prompt
}

main() 一行都不用改。composeSystemPrompt 从练习 14 起就是每次开新 会话(没带 -c)都会被调一次的地方——线练习 14 已经接好了,这一章只是 往这根线上多挂一层。

跑起来

go build -o ex15 .

在一个干净的空目录里做三步实验,中间不加 -c——每一步都是一次全新的 ./ex15,模拟的是"完全不同的一次对话",不是"继续上一次"。

第一步:让模型记一件事。

./ex15 "我们仓库的默认分支叫 trunk,不叫 main。以后我让你开 PR,目标分支默认写 trunk。把这条记到你的记忆文件里。"

第二步:全新会话,只字不提上一步的事,看它记不记得。

./ex15 "帮我写一条 gh pr create 命令,从当前分支创建 PR 到默认分支,只要把命令打印出来,不用执行,也别问我默认分支叫什么。"

第三步:现实变了,让它去更正——不是删掉重写,是改一条。

./ex15 "仓库的默认分支后来又从 trunk 改回 main 了。MEMORY.md 里记的还是旧的,把那条改成现在对的。"

你应该看到什么

DeepSeek,第一步:

[round 1] write_file({"path": "MEMORY.md", "content": "# 项目约定 / 用户偏好\n\n- 本仓库默认分支叫 `trunk`,不叫 `main`。以后开 PR 时,目标分支默认写 `trunk`。\n"})
记好了。已写入 MEMORY.md:
> 本仓库默认分支叫 `trunk`,不叫 `main`。以后开 PR 时,目标分支默认写 `trunk`。

第二步,全新会话,system prompt 里没有任何人再提"trunk"两个字:

[round 1] bash({"command": "git branch --show-current && git symbolic-ref refs/remotes/origin/HEAD 2>/dev/null || echo \"no origin HEAD set\""})

⚠️  模型想执行: git branch --show-current && ...
允许吗?(y/N)
[错误: 权限拒绝——用户没有批准这条命令。]

明白,不执行,只打印。根据记忆,本仓库默认分支是 `trunk`:

    gh pr create --base trunk

第三步,分支改回 main 之后:

[round 1] read_file({"path": "MEMORY.md"})
[round 2] edit_file({"path": "MEMORY.md", "old_string": "- 本仓库默认分支叫 `trunk`,不叫 `main`。以后开 PR 时,目标分支默认写 `trunk`。", "new_string": "- 本仓库默认分支叫 `main`。以后开 PR 时,目标分支默认写 `main`。"})
已更新 MEMORY.md:把默认分支从 `trunk` 改回 `main`,并同步修正了开 PR 时的目标分支约定。

三步全对:写进去了,跨会话记住了,改错了也能改回来——而且改的方式, 从头到尾都是 read_file + edit_file,没有出现任何"记忆专用"的动作。

发生了什么

"跨会话"不需要新机制,只需要读同一份磁盘状态。 composeSystemPrompt 从练习 14 起就在每次开新会话时被调用一次;这一章只是让它多读一个文件。 上一次会话写下的字,这一次会话开始的那一刻就已经在系统提示里——不是 因为有什么专门的"记忆同步"逻辑,是因为文件系统本来就是跨进程共享的。

第二步比看上去更说明问题。 我在任务里明确写了"不用问我默认分支叫 什么",指的是不要停下来问用户;DeepSeek 没有盲信 MEMORY.md 里的旧记录, 而是先尝试跑一条 git branch/git symbolic-ref 去验证——这正是 memoryGuidance 里那句"引用记忆里的内容之前,先确认它现在还成立"字面 发生了的样子。这条命令撞上了权限层(含 && 的命令不落在任何 allow 规则里,默认判 ask,脚本化调用读不到终端输入,自动按拒绝处理),模型 才退回去用记忆里的 trunk——这次"验证被拒绝,退回记忆"的顺序,比它 一上来就无条件信任 MEMORY.md 更让人放心。

第三步证明的是设计上的一句话,不是比喻。 octo 真实的 dev-docs/memory-design.md 里记着这套设计的来历:更早的版本是一个带 类型的记忆库,模型通过一个专门的 remember 工具写入,再由后台子任务 定期"整理"成一段总结注入进去——一条记错的事实一旦被并进那段总结, 就没有一个能单独指向它的把手,只能带着错误内容跟着总结一起,一直被 重新注入下去。换成纯文件之后,这个问题不是被"解决"了,是从设计上就 不存在:文件是可以被单独定位、单独编辑的东西,模型改错一条记忆,跟 edit_file 改错一行代码,走的是完全一样的路径。DeepSeek 那次"改回 main"用的正是这条路径——没有新工具,没有新流程,read_file 读一遍, edit_file 换一句话。

但"改得动"不等于"会改对"。 本机 Ollama 跑第一步的同一个任务时, write_file 的调用参数里除了两条该记的内容,还带着一整段它自己的工具 声明 JSON:

write_file({"path": "MEMORY.md", "content": "# Tools\n\n...\n<tools>\n{...read_file 的完整声明...}\n{...write_file 的完整声明...}\n{...edit_file 的完整声明...}\n{...bash 的完整声明...}\n</tools>\n\n# Memory\n\n- 仓库默认分支是 trunk,不是 main。\n- 以后开 PR 时,目标分支默认写为 trunk。"})

打开 MEMORY.md,四个工具的完整 JSON 声明原样躺在文件开头,真正该记的 两条内容反而被挤到最后。没有人让它记工具声明——这是模型自己往"该保存 的内容"里塞进了不该在那儿的东西。这就是标题那句话在真实机器上长出来 的样子:知识的价值在筛选,不在积累;一个不加区分地把眼前一切都往 记忆里搬的模型,记忆文件会比不记还乱。

再让本机模型自己清理这份混进垃圾的 MEMORY.md,暴露了第二层问题:它 先 read_file,然后 edit_file 了两次,最后报告"已完成,仅保留真正 该记的两条"——但再打开文件看,工具声明的 JSON 大部分还在,只是开头 那句引导语被换成了两条记忆,原来结尾那两条记忆也还留着,变成了重复。 模型清理失败了,但它并不知道自己失败了——它没有在收尾前再读一遍 文件确认结果,就直接宣布任务完成。回收路径确实存在(edit_file 就在 那儿,随时可以调),但"路径存在"和"模型这次真的走对了这条路"是两件 事,后者需要模型自己验证,这一章的代码不替它做这件事。

常见问题

  • 创建 MEMORY.md 时为什么不用先 read_file:练习 6 的 read-before-write 检查只拦"文件已存在但没读过"这一种情况。第一次写 记忆文件时它还不存在,write_file 直接创建,检查不适用。
  • MEMORY.md 的内容已经在这次会话的系统提示里出现过了,为什么改它 还要先 read_file 一次:read-before-write 检查看的是"这个会话里 有没有调用过 read_file 这个工具",跟"内容有没有在上下文别处出现过" 是两件事——系统提示里的文字不算数。这条规矩对所有文件一视同仁,没有 为记忆文件开后门,DeepSeek 第三步那次也老老实实先读了一遍。
  • 本机模型那次清理失败还谎报成功,是不是说明这套设计有漏洞:不是 设计漏洞,是能力边界——edit_file 提供的是"改一条记忆和改一行代码 一样容易"这件事本身,不保证模型每次都精确执行、也不保证模型会主动 验证结果。这和练习 14 的教训是同一类事:工具或规则给的是可能性, 模型会不会真的用对,是另一个需要单独验证的问题。
  • 为什么不干脆限制 MEMORY.md 的长度或格式,从代码层面挡住乱写: 够用就好——这一章要证明的是"文件模型天然带着可编辑、可删除的回收 路径",不是把记忆做成一个强校验的存储。加防护是有价值的下一步, 留作加分练习。

加分练习

  1. 在干净目录里把本机小模型那次"把工具声明写进 MEMORY.md"的实验重复 几次,看它是不是每次都这样——如果稳定复现,说明模型对"写文件"这个 动作本身理解有偏差,会把当前上下文里能看到的东西都当成"该保存的 内容";如果只是偶发,说明这次撞见的是采样噪声。
  2. memoryGuidance 里加一句"改完记忆文件后,用 read_file 读一遍 确认改对了",看这条规矩能不能把本机小模型那次谎报成功的问题堵上—— 如果能,说明本章的失败不是能力不够,是没被要求验证;如果堵不住, 说明问题比"少一句提示"更深。
  3. 连续跑 8-10 个互不相关的小任务,每次都顺手让模型往 MEMORY.md 里 记一笔,全程不做任何人工清理——最后打开文件看看,是不是已经有几条 过时、甚至互相矛盾的记录了。这是"只生成不回收"在你自己机器上长出 来的样子,比读这句话本身更有说服力。
  4. 参照 octo 真实设计里的分层做法,实现"必须遵守"和"触发提醒"两层: 把 MEMORY.md 拆成"每轮都要提醒模型"和"命中关键词才提醒"两部分。 现在这一章的版本不管内容多少都整段搬进系统提示,条目一多,这条 区分就会开始有用。