第3章:简单记忆的实现
从零搭建一个能写入、检索和回忆的最小记忆系统。
从零构建理解Agent-03-实现一个记忆模块
在第0章谈到Agent和大模型的区别时,记忆模块被一笔带过了。原因是记忆涉及一些稍微高级的计算机知识。现在,经过前两章的铺垫,是时候直面这个问题了。
但这一次,我们换个方式——不直接告诉你答案,而是通过一系列问题引导你自己推导出设计。每个问题的答案会引出下一个问题,最终你会看到一个完整记忆模块的成型过程。
问题0:Agent为什么需要记忆?
这个问题看似简单,但答案决定了整个设计的方向。
思考一下:
- 如果你和Agent每次对话都要重新介绍自己,你会疯掉吗?
- 如果Agent不能记住你三天前提到的“下周三有面试”,它还算智能吗?
- 电影《初恋50次》的男主每天都要让女主重新爱上他——这是浪漫,但在工程上是灾难
答案: Agent需要记忆,因为状态是智能的基础。没有状态,Agent只是一个无状态的函数调用。
但这引出了下一个问题:记忆应该如何存储?
问题1:一条记忆应该包含什么信息?
假设你要设计一个数据结构来存储“用户说过的话”,最少需要哪些字段?
选项A: 只需要内容
interface MemoryItem { content: string;}选项B: 内容 + 时间
interface MemoryItem { content: string; ts: Date;}选项C: 内容 + 时间 + 谁说的
type Role = "user" | "assistant" | "system";
interface MemoryItem { role: Role; content: string; ts: Date;}选项D: 再加一个唯一ID
interface MemoryItem { id: string; role: Role; content: string; ts: Date;}思考一下:
- 没有时间戳,你怎么知道哪条记忆更新?
- 没有role,你怎么区分“用户问的问题”和“助手的回答”?
- 没有ID,如果两条内容完全一样的记忆,你怎么区分它们?
答案:选D。 一条完整的记忆至少需要这四个字段。这在src/types.ts中有明确定义:
export type Role = "user" | "assistant" | "system";export type MemoryTier = "short" | "long" | "summary";
export interface MemoryItem { id: string; role: Role; content: string; ts: string; importance: number; tier: MemoryTier;}注意到我们还加了importance和tier——这两个字段会在后面解释为什么要加。
问题2:记忆无限增长怎么办?
假设用户和Agent聊了1000轮,如果你把所有记忆都加载进内存,会发生什么?
思考一下:
- 内存会被耗尽吗?
- 把这些记忆都塞进LLM的prompt,成本会有多高?
- 早期的记忆真的还有价值吗?
答案: 必须对记忆数量做限制。工程上最简单的策略是先进先出(FIFO)——超过上限时,删除最早的记忆。
FIFO也很符合我们的直觉。 我们很容易忘记前几天吃了什么饭。
class SimpleMemory { private items: MemoryItem[] = []; constructor(private maxItems: number = 100) {}
add(role: Role, content: string): void { const item: MemoryItem = { /* ... */ }; this.items.push(item); // 控制容量:超过上限就删除最早的一条 if (this.items.length > this.maxItems) { this.items.shift(); } }}但这引出了一个新问题:如果第1条记忆是“我叫张三”,第100条是“帮我订机票”,当第101条进来时,“我叫张三”被删掉了。下次用户问“我叫什么”,Agent回答不上来——这合理吗?
问题3:有些记忆比其他记忆更重要,怎么办?
假设有两条记忆:
- “我叫张三”(用户身份,永远重要)
- “今天天气不错”(闲聊,一周后无价值)
用FIFO删除最早的记忆,会把两条一视同仁地处理。这显然不合理。
思考一下:
- 你能想到什么策略来区分记忆的重要性吗?
- 谁来决定一条记忆的importance分数?
- 如果所有记忆都是5分,这个机制还有意义吗?
答案:给每条记忆打重要性分数(1-5分)。 高重要性的记忆进入长期保存队列,低重要性的记忆可以被优先淘汰。
add(role: Role, content: string, importance: number = 3): MemoryItem { const item: MemoryItem = { // ... importance: clampImportance(importance), }; // 所有记录先进入短期记忆 this.shortTerm.push(item);
// 高重要性或system消息,同步写入长期记忆 if (item.importance >= 4 || role === "system") { this.addLongTerm({ ...item, tier: "long" }); }}现在出现了两个新概念:短期记忆和长期记忆。为什么会需要分层?
问题4:为什么需要分层记忆(短期vs长期)?
假设你只用一个数组存所有记忆,高重要性的不删除,低重要性的删除。这会有什么问题?
场景分析:
- 用户和Agent聊了50轮技术面试准备
- 其中第3轮用户说“我叫李四”(重要性5,长期保存)
- 其余45轮都是技术讨论(重要性3,短期即可)
如果只有一个数组:
- 要么45轮技术讨论占满空间,导致“我叫李四”被淘汰
- 要么为了保留“我叫李四”,必须扩大容量,导致噪声增多
思考一下: 人的记忆是如何工作的?你会把昨晚吃了什么和银行卡密码存在同一个“存储层”吗?
答案:分层存储。
| 层级 | 特点 | 用途 |
|---|---|---|
| 短期记忆 | 内存中、容量小、重启丢失 | 当前对话上下文 |
| 长期记忆 | 持久化、容量大、跨会话保留 | 重要事实、用户偏好 |
| 摘要记忆 | 由短期压缩生成、高重要性 | 对话主题总结 |
class LayeredMemoryManager { private shortTerm: MemoryItem[] = []; private longTerm: MemoryItem[] = [];
async add(role: Role, content: string, importance = 3): Promise<MemoryItem> { // 所有记录先进入短期记忆 this.shortTerm.push(item); if (this.shortTerm.length > this.shortMaxItems) this.shortTerm.shift();
// 高重要性或system消息,同步写入长期记忆 if (item.importance >= 4 || role === "system") { this.addLongTerm({ ...item, tier: "long" }); } }}现在短期记忆有容量限制,但如果用户连续聊了100轮,前80轮难道就完全没有价值了吗?
问题5:短期记忆太多,如何压缩?
假设shortMaxItems = 20,但用户已经聊了100轮。第1-80轮的记忆已经被挤出短期记忆了,它们真的应该被完全丢弃吗?
思考一下:
- 这100轮可能围绕同一个主题(比如“面试准备”)
- 完全丢弃会丢失上下文连续性
- 但保留全部100轮又太冗余
答案:自动摘要。 每积累N条短期记录,自动压缩成一条摘要,存入长期记忆。
if (this.shortTerm.length > 0 && this.shortTerm.length % this.summaryEvery === 0) { await this.makeSummaryFromRecent();}
private async makeSummaryFromRecent(): Promise<void> { const chunk = this.shortTerm.slice(-this.summaryEvery); const content = await buildSummary(chunk); const summaryItem: MemoryItem = { role: "system", content: `[summary] ${content}`, importance: 5, tier: "summary", }; this.addLongTerm(summaryItem);}摘要可以用规则实现(如提取最近3条用户消息+2条助手消息),也可以调用LLM生成更智能的摘要。
现在系统可以:
- 写入记忆(区分重要性)
- 分层存储(短期/长期/摘要)
- 自动压缩(定期摘要)
但怎么读取记忆呢?
问题6:用户问“面试准备得怎么样了”,系统该返回哪些记忆?
这是一个召回问题。假设记忆库中有:
- “我叫张三”(高重要性)
- “明天有技术面试”(高重要性)
- “我准备了系统设计”(中等重要性)
- “今天吃了面条”(低重要性)
- 最近的10轮闲聊
思考一下:
- 只返回包含“面试”关键词的记忆?
- 只返回最近5条?
- 只返回高重要性的?
- 还是某种组合?
答案:多路召回 + 融合。
retrieveContext(keyword: string, recentN = 6, importantN = 4): MemoryItem[] { const byKeyword = this.search(keyword); // 关键词命中 const byRecent = this.recentShort(recentN); // 最近短期上下文 const byImportant = this.topImportant(importantN); // 高重要度记录
// 去重:同一条记忆只保留一份 const map = new Map<string, MemoryItem>(); [...byKeyword, ...byRecent, ...byImportant].forEach((it) => map.set(it.id, it));
// 最终按时间排序,便于喂给LLM return [...map.values()].sort((a, b) => a.ts.localeCompare(b.ts));}这样设计的好处是:
- 关键词确保字面匹配
- 最近记录保证上下文连贯
- 高重要性记录确保关键事实不丢失
但这个方案有个缺陷:如果记忆内容是“我下周三有技术面试”,用户问“面试准备得怎么样了”,关键词搜索能匹配到吗?
问题7:关键词搜不到,但语义相关的记忆,如何召回?
场景:
- 记忆:
我下周三有技术面试 - 查询:
面试准备得怎么样了
关键词搜索要求“面试”这个词同时出现在记忆和查询中,这没问题。但如果:
- 记忆:
我在准备系统设计的面试 - 查询:
技术面试
“技术”和“系统设计”字面不匹配,但语义相关。
思考一下:
- 如何让系统理解“技术面试”和“系统设计面试”是相关的?
- 需要引入什么技术?
答案:语义检索(向量相似度)。
核心思想:
- 把文本转换成向量(Embedding)
- 计算查询向量与记忆向量的相似度
- 返回最相似的topK条
文字向量化是个我觉得特别有意思的事情。感兴趣的同学可以看这个视频 https://www.bilibili.com/video/BV1Dz9WBHEgs/?spm_id_from=333.337.search-card.all.click&vd_source=842b1225f5d953c8df3fd60918cd05ff
async retrieveSemanticContext(query: string, topK = 5): Promise<MemoryItem[]> { const all = [...this.longTerm, ...this.shortTerm]; return (await rankBySemantic(query, all, topK)).map((x) => x.item);}
// 在semantic_retriever.ts中export async function rankBySemantic( query: string, items: MemoryItem[], topK = 5): Promise<Array<{ item: MemoryItem; score: number }>> { const queryEmbedding = await embedTexts([query]); const queryVec = queryEmbedding.vectors[0];
const scored = items .map((item) => ({ item, score: cosineSimilarity(queryVec, item.vector), })) .filter((x) => x.score > 0) .sort((a, b) => b.score - a.score);
return scored.slice(0, topK);}Embedding可以通过调用外部API(如智谱、OpenAI)获取,也可以用本地哈希向量做fallback。
现在系统有两种召回方式:
/ctx:关键词 + 最近 + 高重要性/sem:语义相似度
但有个工程问题:如果记忆系统上线后召回效果不好,你怎么知道?怎么优化?
问题8:如何判断记忆系统的好坏?
假设你上线了记忆系统,用户反馈“Agent记不住我说的话”。你如何定位问题?
可能的原因:
- 记忆根本没写进去?
- 写进去了但检索没召回?
- 召回了但LLM没用?
- 摘要质量太差导致信息丢失?
思考一下: 没有观测和评测,优化就是盲人摸象。
答案:建立可观测、可评测、可治理的体系。
8.1 可观测:事件日志
export function logEvent(event: string, payload: Record<string, unknown>): void { const line = JSON.stringify({ ts: new Date().toISOString(), event, payload, }); fs.appendFileSync("logs/memory_events.jsonl", `${line}\n`, "utf-8");}
// 使用logEvent("memory_add", { id: item.id, role, importance });logEvent("retrieve_ctx", { keyword, resultCount: results.length });8.2 可评测:定义指标
// Recall@K: 正确记忆是否出现在前K条// MRR: 正确记忆出现位置越靠前越好// Summary Coverage: 摘要覆盖关键信息比例8.3 可治理:记忆生命周期
// 时间衰减:超过N天自动降权export function decayImportance(item: MemoryItem, now = Date.now()): number { const ageDays = (now - Date.parse(item.ts)) / (1000 * 60 * 60 * 24); const decayed = item.importance - Math.floor(ageDays / 14); return Math.max(1, Math.min(5, decayed));}现在记忆系统可以自我诊断和优化了。但还有一个架构问题:如何把这个memory模块集成到Agent中?
问题9:Memory如何与ReAct Agent集成?
回顾第1章的ReAct Agent,它的核心循环是:
- 模型输出 Thought + Action
- 执行 Action 得到 Observation
- 重复直到 Finish
思考一下:
- Memory应该作为工具让Agent主动调用?
- 还是每次自动注入到prompt中?
- 谁来决定哪些记忆需要被召回?
答案:两种方式结合。
- 自动注入:每次调用模型前,自动把
recentShort塞进prompt,保证基础上下文 - 工具触发:提供记忆工具,让Agent主动决定何时深入检索
export const MemoryTools = { MemoryRecall: async (input: string) => { return memoryManager.retrieveContext(input); }, MemorySemantic: async (input: string) => { return memoryManager.retrieveSemanticContext(input); }, SaveMemory: async (role: string, importance: number, content: string) => { return memoryManager.add(role as Role, content, importance); },};
// 在ReAct循环中const tools = { ...MemoryTools, // ...其他工具};这样Agent既拥有基础上下文(自动注入),又能在需要时主动“回忆”(工具触发)。
问题10:如何让记忆系统可复用、可测试?
现在的代码都写在main.ts里,如果另一个项目想用你的memory模块,怎么办?
思考一下:
- 如何设计模块边界?
- 如何确保改动不会破坏现有功能?
- 如何让别人容易集成?
答案:模块化 + 测试。
// memory/module.ts - 统一接口export const MemoryModule = { init(options?: MemoryOptions) { /* ... */ }, record(role: Role, content: string, importance?: number) { /* ... */ }, retrieveContext(keyword: string, recentN?: number, importantN?: number) { /* ... */ }, retrieveSemanticContext(query: string, topK?: number) { /* ... */ }, flush() { /* ... */ },};
// test/memory_module.test.tsimport { describe, it } from "node:test";import assert from "node:assert";
describe("MemoryModule", () => { it("should record and retrieve memory", async () => { MemoryModule.init({ shortMaxItems: 10 }); await MemoryModule.record("user", "我喜欢咖啡", 5); const results = MemoryModule.retrieveContext("咖啡"); assert.equal(results.length, 1); });});最终架构回顾
经过这10个问题的推导,Memory模块的完整架构如下:

