记忆与检索
记忆与检索
前 10 章我们把 Agent 的「躯干」搭起来了:HTTP 裸调(ch02)、流式输出(ch03)、Provider 抽象(ch04)、结构化输出(ch05)、工具调用(ch06)、Agent Loop(ch07)、工具系统(ch08)、上下文工程(ch09)、持久层(ch10)。但有一个问题一直悬着:这个 Agent 每一轮对话结束就「失忆」了。这一章给它装上记忆,先分清两种记忆,再手写一套零依赖的向量检索把它们接起来。
本章目标
读完本章并做完配套练习后,你应该能够:
- 说清「工作记忆」与「长期记忆」的分工,以及没有长期记忆的 Agent 为什么不可用;
- 解释为什么长期记忆需要向量检索而不是关键词匹配(同义不同词的问题);
- 理解 embedding 是什么、为什么它可以被注入式替换(假 embedding 与真实 embedding API 同接口);
- 手写余弦相似度与暴力检索(不装向量库):
embed 查询 → 逐条比对 → 阈值过滤 → top-k 排序; - 把「记忆写入时机」抽象成纯函数(信号关键词 / 会话长度阈值),决策可测试;
- 说清长期记忆与第 10 章持久层的关系(向量是索引,正文必须落盘)。
开场反例:没有记忆的 Agent
周一,你给新部署的「会议助手」交代了一句话:
你:我的项目用 Docker 部署,回复请尽量简洁,用中文。
助手:好的,已了解你的要求。
周二,你新开了一个会话,问:
你:我们项目的部署环境是什么?
助手:(一脸茫然)我不清楚,请告诉我或等我查一下。
它不是忘了——它从来没有记住过。模型是「无状态」的:每次请求拿到的是你这次塞进 messages 数组的内容,上次会话的内容在会话结束时就已经随着上下文窗口消失了。于是用户被迫反复重复自己说过的话:每次新会话都要重新交代部署环境、重新声明偏好、重新解释项目背景。这就是「没有长期记忆」的 Agent 的真实用户体验——demo 永远惊艳,落地永远失忆。
要让 Agent 真正可用,必须解决一个人类根本不会意识到的问题:会话之间怎么「记得」。答案是记忆系统,它由两部分组成:工作记忆与长期记忆。
工作记忆 vs 长期记忆
工作记忆(working memory):就是我们在 ch02 之后一直在用的 messages 数组(当前会话的上下文窗口)。它有两个硬约束:会话内有效(关闭即消失)、有容量上限(token 预算,ch09 的上下文工程就是跟它搏斗)。它解决的问题是「这一轮对话里保持连贯」,代价是「不跨会话」。
长期记忆(long-term memory):跨会话存在的事实/经验库。它不可能是「把每轮对话原样塞进去」,那会让检索变成大海捞针,还会把过期信息当成现状。它必须有选择地写入、按需地召回:
flowchart TB
subgraph Working["工作记忆(会话内 · 易失)"]
W1["上下文窗口 messages[]"]
W2["token 预算有限"]
end
subgraph LongTerm["长期记忆(跨会话 · 有选择沉淀)"]
L1["文本片段(模型真正要看的)"]
L2["向量(检索用的索引)"]
L3["元数据(来源/时间/会话)"]
end
User["用户 / 工具"] -->|"消息进入当前会话"| W1
W1 -->|"会话结束 · 写入时机判定"| L1
L1 -. "成对存储" .- L2
L1 -. "成对存储" .- L3
L1 -->|"新会话 · 检索命中回填"| W1
两个方向的动作缺一不可:
- 写入:会话结束后(或触发时机到来时),把值得记住的内容提取出来、
embed成向量、连同原文一起落库,这是「记住」; - 召回:新会话里用户提问时,把查询
embed成向量,在库里找最相似的若干条,注入当前上下文,这是「想起」。
业界成熟的 Agent 记忆系统都长这个形状。Claude Code 的记忆模型把「工作记忆 = 上下文窗口 + 无长期记忆」视为基线,长期记忆(MEMORY.md 等)由 harness 显式读写(The Four Types of Memory for AI Agents (and How Claude Code Implements Each));learn-claude-code 的记忆章把记忆系统拆成 selection(选什么值得记)→ extraction(怎么提炼)→ consolidation(怎么合并沉淀) 三个子系统(learn-claude-code s09 Memory);Hermes Agent 则把「短期记忆」做成会话内环形缓冲、把「长期记忆」做成 embedding + 向量库的技能库,任务完成后把执行方法序列化入库、下次遇到相似任务再检索回来(Hermes AI Agent: Architecture, Self-Learning Loop)。本章用最朴素的手段把这条链亲手打通:没有记忆系统,先看它为什么要用向量。
为什么是向量检索:关键词匹配的局限
长期记忆存的是「文本」,召回时我们想找「语义相关的文本」。最容易想到的方案是关键词匹配(grep 风格):查询里出现哪个词,就把含这个词的记忆捞出来。它的局限很致命:同义不同词。
库里存的记忆:用户不喜欢咖啡,偏好茶饮。
用户的新查询:他平时喝什么?对饮品有什么偏好?
关键词「咖啡」根本没出现在查询里,grep 命中不了;但人一眼就看出这条记忆和这个问题高度相关。真实对话里这种措辞错位是常态(「部署」vs「环境」、 「退钱」vs「refund」、缩写与全称),关键词匹配在长期记忆场景里几乎不可用。
embedding 就是为这个问题生的:一个把「文本 → 向量」的函数,它的核心性质是语义相近的文本,映射后的向量也相近:「用户不喜欢咖啡」和「用户对饮品有偏好」即便没有一个字相同,它们的向量夹角也小。于是召回从「字面匹配」升级成「语义近似」:把查询也转成向量,在库里找夹角最小(余弦相似度最高)的向量对应的文本。
embedding 的语义质量来自模型(真实系统里调 embedding API,如 OpenAI 的 text-embedding-3-small,见 OpenAI Embeddings 官方文档),但它对外只有一个接口:embed(text) -> number[]。这给了我们一个关键的自由度,本章把 embedding 设计成注入式:谁调用记忆库,谁负责提供 embedding 函数。测试与判题注入一个确定性假 embedding(离线、可复现、无需 API key),真实产品注入 embedding API 的封装。机制(写 → 嵌入 → 比对 → 取回)与 embedding 质量解耦,两件事可以分开学。
手写实现:假 embedding
先看注入式的假 embedding 长什么样。它不需要任何语义智能,只需要满足两条确定性性质:相同文本 → 完全相同向量;共享字符片段越多的文本 → 向量越相似(这样「部署」相关的记忆互相接近)。一个朴素的实现是「字符 unigram + bigram 计数,哈希到固定维度,最后归一化成单位向量」:
// 确定性假 embedding:文本 -> 96 维单位向量(字符计数 + 哈希寻址)。
// 相同文本 -> 相同向量;共享字符子串(如"部署""主题")越多 -> 相似度越高。
// 仅供教学与判题:语义质量由真实 embedding 模型提供,机制本身与质量无关。
function fakeEmbed(text: string, dims = 96): number[] {
const vec = new Array(dims).fill(0);
const chars = Array.from(text); // 按码点迭代,中文安全
for (let i = 0; i < chars.length; i++) {
const code = chars[i].codePointAt(0)!;
vec[code % dims] += 1; // unigram:单字计数
if (i + 1 < chars.length) {
const bg = (code * 31 + chars[i + 1].codePointAt(0)!) % dims; // bigram:字符对
vec[bg] += 8; // bigram 权重大,共享子串是强信号
}
}
return normalize(vec);
}
normalize 把向量长度缩成 1(单位向量),这样「相似度」只取决于方向,这正是余弦相似度的语义。注意这里我们没有装任何向量库、没有调任何网络:一个普通函数而已。真实系统里把 fakeEmbed 换成一个调用 embedding API 的封装,接口不变(我们会在本章演示代码的 mock server 里看到真实 API 的请求/响应形状)。
手写实现:余弦相似度
embedding 把一切变成了向量,剩下的是数学。衡量两个向量「方向有多接近」的标准指标是余弦相似度:
cos(θ) = (a · b) / (|a| × |b|) —— 点积 ÷ 两个长度的乘积
它有三个我们想要的数学性质:缩放不变(向量翻倍方向不变,相似度仍是 1);取值在 [-1, 1](同向 1、正交 0、反向 -1);只比方向不比长度(文本长短不碍事)。手写只需要三个小函数:
// 点积:逐元素相乘求和
function dotProduct(a: number[], b: number[]): number {
let sum = 0;
for (let i = 0; i < a.length; i++) sum += a[i] * b[i];
return sum;
}
// L2 范数:向量长度
function magnitude(v: number[]): number {
return Math.sqrt(dotProduct(v, v));
}
// 余弦相似度:a·b / (|a|·|b|),取值 [-1, 1]
function cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) {
// embedding 维度一致性:不一致必须主动抛错,而不是算出错误分数
throw new Error(`dimension mismatch: ${a.length} vs ${b.length}`);
}
const magA = magnitude(a);
const magB = magnitude(b);
if (magA === 0 || magB === 0) return 0; // 零向量没有方向,返回 0 而不是 NaN
return dotProduct(a, b) / (magA * magB);
}
两个细节是本章的「坑位」,写代码时就要钉死:维度不一致直接抛错(库里的向量是 96 维、查询却是 128 维时,点积会算出离谱甚至 NaN 的结果,错得静悄悄,Debug 到怀疑人生);零向量返回 0(全零向量没有方向,0/0 是 NaN,必须显式拦掉)。
手写实现:记忆库(写入 + 暴力检索)
有了向量,就能写记忆库了。它是本教程一贯的风格:不装向量数据库。记忆库规模只有几百上千条时,检索就是「逐条算一遍余弦」的 O(n) 扫描,几毫秒完事。向量库(以及它的 ANN 近似最近邻索引)是在记忆膨胀到几千上万条、延迟成为瓶颈时才值得引入的优化;先手写暴力检索,是为了让你看到机制的全貌,将来读得懂向量库在快什么。这是 build-your-own-agent 记忆/RAG 组件给出的同样取舍。
interface MemoryEntry {
id: string;
text: string; // 原文——模型真正要看的
vector: number[]; // 索引——检索真正比对的
createdAt: number;
source?: string; // 来源会话等元数据
}
function createMemoryStore(embed: (text: string) => number[]) {
// embed 由调用方注入:测试给 fakeEmbed,生产给 embedding API 封装
const entries: MemoryEntry[] = [];
let nextId = 1;
function add(text: string, source?: string): MemoryEntry | null {
if (typeof text !== 'string' || text.trim() === '') return null; // 空文本拒绝
const existing = entries.find((e) => e.text === text);
if (existing) return existing; // 相同文本去重——记忆库膨胀要防在写入时
const entry = { id: `mem-${nextId++}`, text, vector: embed(text), createdAt: Date.now(), ...(source ? { source } : {}) };
entries.push(entry);
return entry;
}
function retrieve(query: string, options: { topK?: number; minScore?: number } = {}): { entry: MemoryEntry; score: number }[] {
const { topK = 3, minScore = 0 } = options;
if (entries.length === 0) return [];
const qv = embed(query);
return entries
.map((entry) => ({ entry, score: cosineSimilarity(qv, entry.vector) }))
.filter((r) => r.score >= minScore) // ① 阈值过滤
.sort((a, b) => b.score - a.score) // ② 降序
.slice(0, topK) // ③ top-k 截断
.map(({ entry, score }) => ({ entry, score }));
}
return { add, retrieve, list: () => entries.slice(), get size() { return entries.length; } };
}
retrieve 的三步是检索的全部:过滤 → 排序 → 截断。返回的每一项带着 score,调用方据此决定「哪些记忆值得注入、以什么顺序注入」。写入侧也藏了两个工程决定:空文本拒绝(不是所有「提取」都成功)与相同文本去重(同一条事实被两个会话各写一遍,库里就有两份噪声)。
跑一个本章演示代码里的例子(会议助手,三条长期记忆),查询「我们项目的部署环境是什么?」,检索结果长这样:最相关的 Docker 记忆排第一,SQLite 与主题偏好因共享「项目/存储」等字符次之:
retrieve('我们项目的部署环境是什么?', { topK: 3, minScore: 0.12 });
// -> [
// { entry: mem-1 "我的项目用 Docker 部署,回复请尽量简洁,用中文。" score: 0.291 },
// { entry: mem-2 "项目的数据存储用 SQLite,备份每天凌晨两点执行。" score: 0.177 },
// { entry: mem-3 "我偏好浅色主题,聊天窗口用深色文字。" score: 0.134 },
// ]
把这些命中拼进 system prompt,模型就能「想起」周一的事了。完整的写入/检索时序如下:
sequenceDiagram
participant U as 用户
participant A as Agent 循环
participant E as embedding(注入)
participant M as 长期记忆库
participant LLM as LLM
Note over U,LLM: 会话 1:交代偏好
U->>A: 我的项目用 Docker 部署,回复请简洁
A->>LLM: messages(工作记忆)
LLM-->>A: 好的,已了解你的要求
Note over A,M: 会话结束:写入时机判定
A->>A: shouldWriteMemory -> 命中信号关键词
A->>A: 提取事实片段
A->>E: embed(事实片段)
E-->>A: 96 维向量
A->>M: add(text + vector)
Note over U,LLM: 会话 2:全新会话,只靠长期记忆
U->>A: 我们项目的部署环境是什么?
A->>E: embed(查询)
E-->>A: 96 维向量
A->>M: 暴力比对全部向量
M-->>A: top 命中(score 0.291)
A->>LLM: 注入记忆后的 messages
LLM-->>A: 据记忆:项目用 Docker 部署
A-->>U: 回答
手写实现:记忆写入时机
检索做得再好,也救不了「什么都往库里写」的记忆库。写入时机是记忆系统里最容易被低估的一环:不写什么,和写什么同等重要。全量写入的下场是:库里塞满「今天天气不错」这类会话垃圾,检索时噪声全被注入上下文,模型被过期/无关记忆带偏(错塞一条过期记忆比少塞一条更糟)。
learn-claude-code 的记忆章把这一点总结为记忆系统的第一子系统 selection(选什么值得记),其次才是 extraction(怎么提炼)与 consolidation(怎么合并)(learn-claude-code s09 Memory)。Hermes 的做法是「任务完成后把执行方法序列化入库」,写入发生在明确的事件点,而不是每个 token 都写(Hermes 自我进化回路)。
我们把它抽象成一个纯函数,同样的输入永远得到同样的决策,从而可测试、可复现:
// 写入时机策略:这段对话该不该沉淀进长期记忆?
// 1. 任一条消息命中信号关键词(记住/偏好/我的项目/以后)→ 写(用户在陈述值得保留的事实)
// 2. 没有信号词,但会话足够长(≥ minMessages)→ 写(批量沉淀值得做)
// 3. 否则不写(什么都写会污染记忆库)
function shouldWriteMemory(
conversation: string[],
options: { signalKeywords?: string[]; minMessages?: number } = {},
): { write: boolean; reason?: string } {
const signalKeywords = options.signalKeywords ?? ['记住', '偏好', '我的项目', '以后'];
const minMessages = options.minMessages ?? 6;
for (const msg of conversation) {
for (const kw of signalKeywords) {
if (msg.includes(kw)) return { write: true, reason: `signal keyword "${kw}"` };
}
}
if (conversation.length >= minMessages) {
return { write: true, reason: `session length ${conversation.length} >= ${minMessages}` };
}
return { write: false };
}
两条规则覆盖两种典型场景:显式信号(用户主动说「记住/偏好/以后」,这是最高置信度的可记内容)与会话长度(聊了十几轮,即使没人喊「记住」,也有值得沉淀的经验,比如「先跑测试再改代码」这类工作习惯)。关键词表和长度阈值都可注入,方便按产品调。真实系统的写入时机比这更丰富:对话结束异步写、compaction 触发前写(把上下文快塞满时抢救性沉淀)、周期性批量蒸馏,但核心决策结构不变:先在纯函数里定「写不写」,再谈「写什么、怎么写」。
与持久层(第 10 章)的关系
有个容易混淆的点必须说清:向量不是记忆,向量是记忆的索引。检索返回的是 score,但注入进上下文、模型真正读到的是 text 原文,原文必须持久化。所以长期记忆 = 检索层(本章)+ 持久层(第 10 章):
- 第 10 章解决了「对话可回放」:session/message/event 落盘,崩溃后能重放会话;
- 本章解决了「跨会话可召回」:事实片段 + 向量 + 元数据落盘,新会话按语义检索回填。
两者共享同一套持久化基础设施,只是表/集合不同、用途不同。add 的时候把条目写进存储(记忆库就是一张「记忆表」,向量存成 blob),retrieve 的时候先读全表再比对(暴力检索的前提就是数据可整体取出)。本章的演示代码用内存数组当库,是为了把检索逻辑独立出来讲;接上第 10 章的存储层,记忆库就真正落地了。
常见坑
坑一:相似度阈值误判。 minScore 设太高,相关记忆全被过滤,Agent 变成「失忆患者」;设太低(或干脆不设),无关记忆灌进上下文,模型被噪声带偏。阈值不是拍脑袋定的,要对着真实记忆库调:先看检索结果的分数分布,再选「相关性明显高一截」的档位。原则:宁可少注入,不可错注入,注入一条错记忆的成本远高于少注入一条。
坑二:embedding 维度一致性。 写入用 96 维、查询用 128 维,点积直接越界或算出 NaN,相似度变成一坨废数字。必须在 cosineSimilarity 里显式校验长度并抛错,把故障暴露在最早的位置,这也是本章练习 stage 1 的必考项。
坑三:记忆库膨胀与过期记忆。 写入时机比检索更关键:全量写入 → 检索噪声化。另外「用户改主意了」怎么办?周一「用 Docker」,周三「改用 K8s」,旧记忆还在库里,周四检索出两条矛盾记忆。需要更新(相同来源的旧条目作废)、去重(本章 add 已做)和元数据(来源/时间,检索时可加权或过滤)。Claude Code 的实践里「索引短一点、正文拆出去、同一轮已经注入过的记忆不要重复塞」是直接可抄的防膨胀纪律(Claude Code 记忆系统详解)。
坑四:假 embedding 与真实 embedding 的落差。 字符计数假 embedding 只能保证「相同文本 → 相同向量」,不保证「同义词 → 相近向量」:它是用来把机制练会的,不是用来当语义引擎的。生产环境必须换成真实 embedding 模型,且换的时候接口不变(注入式设计的红利);换完记得重新调阈值:不同模型的分数分布完全不同。
坑五:把「记忆」塞进 system prompt 就不管了。 注入不是越多越好。检索到的 top-3 每条都值得注入吗?同一轮已经注入过的记忆要不要再去重?注入顺序(最相关的放最前面)影响模型注意力。记忆注入是上下文工程(ch09)在记忆场景的具体化:控制注入量与顺序,和写记忆本身同等重要。
小结
- 没有长期记忆的 Agent 每轮会话都失忆:用户反复重复自己的话,产品永远停在「演示」档;
- 工作记忆 = 上下文窗口(会话内、有 token 上限);长期记忆 = 跨会话事实库(有选择写入、按需召回);两者用「写入 → 检索 → 回填」闭环连接;
- 长期记忆要用向量检索而非关键词匹配:同义不同词是常态,embedding 让语义相近的文本向量也相近;
- embedding 注入式:记忆库只认
embed(text) -> number[]接口,测试/判题注入确定性假 embedding(离线可复现),生产注入 embedding API; - 手写余弦相似度 + 暴力检索(无向量库):
过滤 → 排序 → 截断三步,记忆库规模小时 O(n) 扫描绰绰有余; - 写入时机是纯函数:信号关键词命中 / 会话长度阈值决定「写不写」,决策可测试;什么都写 = 检索噪声化;
- 长期记忆 = 检索层(本章)+ 持久层(第 10 章):向量是索引,原文必须落盘。
现在去浏览器里完成本章练习,把 cosineSimilarity、createMemoryStore、shouldWriteMemory 亲手补全。下一章(ch12)我们把这些零件装进三种经典范式(ReAct、Plan-and-Execute、Reflection),你会发现记忆系统在范式里的作用正是「循环外有记忆,循环内看上下文」。
延伸阅读
- learn-claude-code — s09 Memory System:记忆系统三子系统:selection(选什么值得记)/ extraction(怎么提炼)/ consolidation(怎么合并),本章「写入时机」的直接思想来源。
- The Four Types of Memory for AI Agents (and How Claude Code Implements Each):工作/会话/长期/范围四类记忆的划分,Claude Code 的「工作记忆 = 上下文窗口」基线。
- Hermes AI Agent: Architecture, Self-Learning Loop:短期记忆(会话内缓冲)+ 长期记忆(embedding 技能库)的落地架构,「任务完成后序列化入库」的写入时机参考。
- build-your-own-agent:从零构建 Agent 的 10 组件资源索引,记忆/RAG 组件的 embedding + 检索取舍参照。
- OpenAI Embeddings 官方文档:embedding API 的请求/响应形状与
text-embedding-3-small等模型说明。 - Claude Code 记忆系统详解:索引短一点、正文拆出去、同轮已注入的记忆不重复塞等防记忆膨胀的具体纪律。