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