智能体记忆系统:为你的 AI 提供持久化上下文
每个 AI 智能体都存在记忆问题。每次调用模型都是无状态的。模型不记得上一次的请求,不记得用户的名字、偏好或之前的决策。没有外部记忆,你的智能体在每次对话中都从零开始。
本指南涵盖四种记忆策略——从最简单到最强大——以及何时使用每种策略。所有示例均使用 TypeScript 和 Anthropic SDK。
为什么智能体需要记忆
考虑一个代码审查智能体。在第一次运行时,用户解释了团队的编码规范:不使用 any 类型,始终显式处理错误,优先使用函数式风格。智能体给出了一次很好的审查。
在第二次运行时,用户提交了另一个文件。智能体已经忘记了一切。它遗漏了昨天标记过的相同规范违规。
这是核心问题:智能体是无状态的,但有用的智能体需要连续性。
记忆为智能体提供了三种能力:
- 回忆 — 从对话早期或之前的会话中检索事实
- 个性化 — 随着时间的推移适应用户的偏好
- 协调 — 在多智能体系统中,在智能体之间共享状态
四种记忆类型
在编写代码之前,清晰地命名各种策略很有帮助。智能体记忆分为四种模式:
| 类型 | 存储位置 | 作用范围 | 最适用于 |
|---|---|---|---|
| 缓冲区 | 上下文中(消息数组) | 当前会话 | 短对话 |
| 摘要 | 上下文中(压缩) | 当前会话 | 长对话 |
| 语义记忆 | 外部(向量数据库) | 跨会话 | 知识检索 |
| 情景记忆 | 外部(键值存储) | 跨会话 | 用户事实、偏好 |
每种策略在简单性、成本和能力之间都有不同的权衡。选择能解决你问题的最简方案。
策略一:对话缓冲区
最简单的策略是将完整的对话历史传递给每次模型调用。模型看到所有之前的消息,并在完整上下文中进行响应。
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
interface Message { role: "user" | "assistant"; content: string;}
// 缓冲区保存完整的对话历史const buffer: Message[] = [];
async function chat(userMessage: string): Promise<string> { // 将新的用户消息追加到缓冲区 buffer.push({ role: "user", content: userMessage });
const response = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 1024, // 每次调用时传递完整的缓冲区 messages: buffer, });
const assistantMessage = response.content[0].type === "text" ? response.content[0].text : "";
// 将助手回复追加到缓冲区 buffer.push({ role: "assistant", content: assistantMessage });
return assistantMessage;}
// 使用示例await chat("我叫 Alex,我更喜欢 Python 而不是 TypeScript。");await chat("我下一个项目应该使用什么语言?");// 模型记住了第一条消息中声明的偏好何时使用: 少于 20 轮的对话。简单的聊天机器人。原型。
局限性: 上下文窗口是有限的。长会话最终会超过 token 限制,最旧的消息必须被丢弃。模型悄悄地丢失早期上下文,导致行为不一致。
策略二:滚动摘要
当对话变长时,对旧轮次进行摘要而不是丢弃。维护一个压缩摘要,记录最近消息滑动窗口之前发生的事情。
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
interface ConversationState { summary: string; // 旧轮次的压缩历史 recentMessages: Array<{ role: "user" | "assistant"; content: string }>; maxRecentTurns: number; // 保留最近 N 轮的原文}
const state: ConversationState = { summary: "", recentMessages: [], maxRecentTurns: 6, // 3 轮用户 + 3 轮助手};
// 将旧轮次压缩到滚动摘要中async function compressSummary( currentSummary: string, messagesToCompress: Array<{ role: string; content: string }>): Promise<string> { const formatted = messagesToCompress .map((m) => `${m.role.toUpperCase()}: ${m.content}`) .join("\n");
const response = await client.messages.create({ model: "claude-haiku-4-5-20251001", max_tokens: 512, messages: [ { role: "user", content: `通过添加以下新的对话内容来更新对话摘要。只返回更新后的摘要。保持在 200 字以内。
当前摘要:${currentSummary || "(无)"}
新的对话内容:${formatted}`, }, ], });
return response.content[0].type === "text" ? response.content[0].text : currentSummary;}
async function chat(userMessage: string): Promise<string> { // 当缓冲区超过限制时,压缩最旧的一半 if (state.recentMessages.length >= state.maxRecentTurns * 2) { const toCompress = state.recentMessages.splice(0, state.maxRecentTurns); state.summary = await compressSummary(state.summary, toCompress); }
state.recentMessages.push({ role: "user", content: userMessage });
// 构建消息数组:系统摘要 + 最近的原文轮次 const messages = [ ...(state.summary ? [ { role: "user" as const, content: `本次对话早期的上下文:\n${state.summary}`, }, { role: "assistant" as const, content: "明白。我会使用那些上下文。", }, ] : []), ...state.recentMessages, ];
const response = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 1024, messages, });
const assistantMessage = response.content[0].type === "text" ? response.content[0].text : "";
state.recentMessages.push({ role: "assistant", content: assistantMessage }); return assistantMessage;}摘要调用使用快速、低成本的模型(Haiku)。主对话使用更强大的模型(Sonnet)。这种分工在保持质量的同时降低了成本。
何时使用: 超过 20 轮的会话。客服智能体。长时间运行的任务智能体。
局限性: 摘要会丢失细节。对话早期声明的具体事实可能被压缩成模糊的陈述。对于精确召回特定事实,请使用情景记忆。
策略三:情景记忆
情景记忆将关于用户或会话的特定事实存储在键值存储中。智能体在对话期间提取事实,并在未来的会话中检索它们。
import Anthropic from "@anthropic-ai/sdk";import fs from "fs/promises";
const client = new Anthropic();
// 在生产环境中,用 Redis 或数据库替换此文件存储const MEMORY_PATH = "./memory.json";
interface EpisodicStore { [userId: string]: Record<string, string>;}
async function loadMemory(): Promise<EpisodicStore> { try { const data = await fs.readFile(MEMORY_PATH, "utf-8"); return JSON.parse(data); } catch { return {}; }}
async function saveMemory(store: EpisodicStore): Promise<void> { await fs.writeFile(MEMORY_PATH, JSON.stringify(store, null, 2));}
// 从最新的对话中提取结构化事实async function extractFacts( userMessage: string, assistantReply: string): Promise<Record<string, string>> { const response = await client.messages.create({ model: "claude-haiku-4-5-20251001", max_tokens: 256, messages: [ { role: "user", content: `从这次对话中提取任何个人事实、偏好或重要决定。返回一个键值对的 JSON 对象。如果没有值得记住的内容,返回 {}。
用户:${userMessage}助手:${assistantReply}`, }, ], });
try { const text = response.content[0].type === "text" ? response.content[0].text : "{}"; const match = text.match(/\{[\s\S]*\}/); return match ? JSON.parse(match[0]) : {}; } catch { return {}; }}
// 将存储的事实格式化为系统提示注入function formatMemory(facts: Record<string, string>): string { const entries = Object.entries(facts); if (entries.length === 0) return ""; return ( "你对该用户了解的内容:\n" + entries.map(([k, v]) => `- ${k}:${v}`).join("\n") );}
async function chat(userId: string, userMessage: string): Promise<string> { const store = await loadMemory(); const userFacts = store[userId] ?? {};
const systemPrompt = formatMemory(userFacts);
const response = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 1024, system: systemPrompt || undefined, messages: [{ role: "user", content: userMessage }], });
const assistantMessage = response.content[0].type === "text" ? response.content[0].text : "";
// 提取事实并将其持久化以供未来会话使用 const newFacts = await extractFacts(userMessage, assistantMessage); if (Object.keys(newFacts).length > 0) { store[userId] = { ...userFacts, ...newFacts }; await saveMemory(store); }
return assistantMessage;}
// 会话一await chat("user-123", "我喜欢简洁的回答。除非必要,否则不要用项目符号。");// 会话二(几天后的不同进程)await chat("user-123", "解释 TCP 握手是如何工作的。");// 智能体记住了会话一中的格式偏好何时使用: 跨会话运行的面向用户的智能体。个性化。偏好追踪。
局限性: 事实随时间积累。过时的事实可能导致错误的行为(用户更改了首选语言;旧偏好仍在存储中)。添加更新或过期事实的机制。
策略四:语义记忆(向量搜索)
情景记忆存储离散事实。语义记忆存储按含义索引的文档、代码或对话片段。当智能体需要信息时,它使用查询搜索索引。
这是检索增强生成(RAG)的基础。
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();
interface Document { id: string; content: string; embedding?: number[];}
// 计算两个向量之间的余弦相似度function cosineSimilarity(a: number[], b: number[]): number { const dot = a.reduce((sum, val, i) => sum + val * b[i], 0); const magA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0)); const magB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0)); return dot / (magA * magB);}
class SemanticMemory { private documents: Document[] = [];
// 索引文档以便后续检索 async add(id: string, content: string): Promise<void> { const embedding = await embed(content); // 使用专用的嵌入模型 this.documents.push({ id, content, embedding }); }
// 检索与查询最相关的 k 个文档 async search(query: string, topK = 3): Promise<Document[]> { const queryEmbedding = await embed(query); return this.documents .filter((doc) => doc.embedding) .map((doc) => ({ doc, score: cosineSimilarity(queryEmbedding, doc.embedding!), })) .sort((a, b) => b.score - a.score) .slice(0, topK) .map(({ doc }) => doc); }}
const memory = new SemanticMemory();
async function answerWithContext(question: string): Promise<string> { // 检索相关文档 const relevant = await memory.search(question); const context = relevant.map((d) => d.content).join("\n\n---\n\n");
const response = await client.messages.create({ model: "claude-sonnet-4-6", max_tokens: 1024, system: context ? `使用以下上下文回答问题:\n\n${context}` : undefined, messages: [{ role: "user", content: question }], });
return response.content[0].type === "text" ? response.content[0].text : "";}
// 示例:索引知识库并查询await memory.add("doc-1", "团队使用 React 18 和 app router 模式。");await memory.add("doc-2", "所有 API 路由必须使用 Zod 模式验证输入。");await memory.add("doc-3", "数据库查询使用 Drizzle ORM。避免原始 SQL。");
const answer = await answerWithContext("我应该如何编写一个新的 API 端点?");// 智能体检索 doc-2 和 doc-3,并生成有依据的回答对于生产使用,用专用向量数据库替换内存存储。如果你已经运行 PostgreSQL,Pgvector 是最简单的选择。Chroma 和 Qdrant 是不错的独立选项。
何时使用: 文档助手。代码搜索智能体。知识库问答。
将记忆连接到 MCP
如果你遵循了 MCP 服务器指南,可以将记忆作为 MCP 资源暴露。这使你的记忆层可供任何兼容 MCP 的客户端访问,包括 Claude Code。
import { Server } from "@modelcontextprotocol/sdk/server/index.js";import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
const server = new Server( { name: "memory-server", version: "1.0.0" }, { capabilities: { tools: {}, resources: {} } });
// 工具:存储一个事实server.setRequestHandler("tools/call", async (request) => { if (request.params.name === "remember") { const { key, value, userId } = request.params.arguments as { key: string; value: string; userId: string; }; await storeEpisodicFact(userId, key, value); return { content: [{ type: "text", text: `已存储:${key} = ${value}` }] }; }
if (request.params.name === "recall") { const { userId } = request.params.arguments as { userId: string }; const facts = await loadEpisodicFacts(userId); return { content: [{ type: "text", text: JSON.stringify(facts, null, 2) }], }; }});
const transport = new StdioServerTransport();await server.connect(transport);任何使用 MCP 的智能体现在都可以将 remember 和 recall 作为标准工具调用。记忆成为共享服务,而不是每个智能体各自实现。
选择正确的策略
以此决策表为起点:
| 情况 | 策略 |
|---|---|
| 单次会话,少于 20 轮 | 缓冲区 |
| 单次会话,长时间运行 | 摘要 |
| 多会话,用户偏好 | 情景记忆 |
| 大型知识库查询 | 语义记忆 |
| 以上所有情况 | 组合:会话内摘要 + 跨会话情景/语义记忆 |
从缓冲区开始。当对话变长时添加摘要层。当用户跨会话返回时添加情景记忆。当你有文档语料库需要检索时添加语义记忆。
不要在需要之前添加复杂性。对话缓冲区是大多数原型的正确答案。
结论
记忆不是单一的东西——它是一组在不同范围内解决不同问题的策略。缓冲区处理当前会话。摘要延伸该会话。情景记忆连接各个会话。语义记忆将回答建立在知识库的基础上。
选择能解决你今天问题的层次。将其封装在干净的接口后面,这样以后可以切换实现。如果你的智能体通过 MCP 通信,将记忆作为共享工具暴露——这使每个智能体保持简单,同时给整个系统一个持久的大脑。