
折腾 AI Agent 有一段时间了,调 system prompt、试 chain-of-thought、跑 ReAct、把 GPT-4o 和 Claude 3.5 Sonnet 比了个遍,结果呢?Agent 还是在第三轮对话就失忆、还是前后矛盾、用户第二天回来感觉像在跟一个新人聊天。
说句可能被喷的实话:你的 Agent 根本不是推理有问题,是记忆有问题。
现在的大模型在给定的上下文窗口内推理能力已经相当强了。一个"聪明的 Agent"和"聊三句就崩"的 Agent 之间的差距,几乎从来不是模型逻辑能力的问题,而是 Agent 记住了什么、以什么方式记忆、以及这些记忆能不能从 Demo 活到生产环境。
这篇文章深入讲讲生产级 Agent 状态的架构、模式和权衡,目标是:从原型到能接真实用户的系统。
在解决记忆问题之前,先把这个推理叙事彻底破掉。
我们评估 LLM 推理能力时,测的东西其实很窄:给一个 prompt 和有限上下文,模型解决一个定义好的问题的能力。Chain-of-thought 论文、MATH 基准、GPQA、LiveCodeBench——这些都是无状态评估。模型看到问题、推理、给出答案。没有持续、没有积累。
真实的 Agent 工作完全不同。Agent 跨多轮对话、多工具、以及增量到达的信息分布工作。推理的挑战不是"模型会不会思考"——而是"模型能不能在已经知道的东西上思考,同时还要决定下一步做什么"。
看这个对话:
用户(第1轮): "我要订下周二从北京到上海的机票,预算400以内。"
Agent: 调用搜索工具 → 找到航班 → 返回结果
用户(第2轮): "哪个航班经停时间最短?"
Agent: 调用另一个工具或基于之前结果推理
用户(第3轮): "算了,改成去杭州吧。"
Agent: ...它还记得原始需求是什么吗?
在第3轮,Agent 需要协调一个修改后的目标与之前的工具结果、中间结论、跨轮表达的用户偏好。这不是推理缺陷,是状态管理缺陷。再多 prompt 工程也修不了这个——信息压根就没在那里。
你可以把每轮对话都塞进上下文窗口,期待模型自己记住。这在生产环境里会因三个原因挂掉:
上下文窗口很贵。 每个 token 都要花钱。一段10轮对话加上工具返回结果,每轮调用轻松吃掉 15,000–50,000 tokens。上了量这就是无底洞。
上下文窗口很吵。 取出 40K tokens 的对话历史不代表模型关注的是对的 40K tokens。长上下文中注意力会稀释。第1轮的关键细节早就被埋了。
上下文窗口跨会话不持久。 用户明天回来,上周对话已经没了,除非你主动把它存到某处再取出来。
解决方案不是更大的窗口,是更好的记忆架构。
Agent 状态不是单一东西。它是几个不同但相互作用层的复合体。混淆这些层是大多数生产事故的根源。
驱动当前交互的短生命周期、会话绑定的状态。包括:
这些通常驻留在上下文窗口中,每轮刷新。它快、灵活、但生命周期短暂。
Agent 需要反复引用的世界知识:
这些存在于上下文窗口之外,通常在数据库或向量存储中,按需检索。
Agent 的动作及其结果清单:
这些通常编码在函数/工具定义中,但也应包含随时间改进的学习启发式规则。
每条信息从哪里来的——对信任和调试至关重要:
没有来源记忆,Agent 会自信满满地复述那些它"记得"但无法验证的细节。区别在于 Agent 说"根据您的偏好..."和说"您3月3日告诉我您更喜欢..."。
生产级 Agent 记忆系统有四层,每层服务不同的延迟和持久化需求。
注入每个 LLM 调用的活跃工作记忆。这不是原始对话历史——是 Agent 维护和更新的精心的缓冲区。
type ContextBuffer = { // 当前会话状态 sessionId: string; turnCount: number; // 活跃目标栈 currentGoal: GoalState; subgoals: Subgoal[]; completedSubgoals: Subgoal[]; // 工具调用账本(精简版,不是原始日志) toolLedger: ToolEvent[]; // 本轮提取的事实 extractedFacts: Fact[]; // 对话摘要(滚动,用于裁剪时) rollingSummary: string;
};
关键洞察:Agent 写入这个缓冲区来工作,而不只是接收。每次工具调用后,Agent 应该用结构化结果更新缓冲区,而不是把原始输出一股脑丢进上下文。
语义记忆和程序记忆在这里。当 Agent 需要召回超出当前上下文的东西时,它查询这层。
interface MemoryRetriever { // 语义召回 — 找相关事实 recallSemantics(query: string, userId: string): Promise<Fact[]>; // 情景召回 — 找相似的历史交互 recallEpisodes(similarTo: string, limit: number): Promise<Episode[]>; // 程序召回 — 找相关工具/模式 recallProcedures(taskType: string): Promise<ToolDefinition[]>;
}
实现选项:
带生命周期管理的长期存储。这是记忆生存、衰老、最终被修剪的地方。
interface MemoryStore { // 带 TTL 和优先级写入 write(entry: MemoryEntry): Promise<void>; // 压缩 — 合并冗余记忆 compact(userId: string): Promise<number>; // 过期 — 移除陈旧记忆 expire(maxAgeDays: number): Promise<number>; // 溯源审计 getProvenance(memoryId: string): Promise<ProvenanceRecord>;
} interface MemoryEntry { id: string; type: 'fact' | 'preference' | 'event' | 'procedure'; content: string; metadata: Record<string, unknown>; source: SourceReference; createdAt: Date; expiresAt?: Date; confidence: number; // 0–1,用于不确定的记忆 accessCount: number; // 用于基于访问频率的淘汰
}
这是所有人都跳过的层。没有遗忘的记忆是负担。 陈旧偏好、过时目标、冗余事实会把有效信息淹没。生产系统需要明确的遗忘策略:
这一切在实践中怎么运作?以下是每轮 Agent 运行的生产级记忆循环:
┌─────────────────────────────────────────────────┐
│ TURN START │
│ │
│ 1. 用户输入到达 │
│ ↓ │
│ 2. 查询检索 │
│ ┌─→ 语义召回(事实、偏好) │
│ ├─→ 情景召回(相似的历史轮次) │
│ └─→ 程序召回(相关工具) │
│ ↓ │
│ 3. 上下文组合 │
│ ┌─→ 当前工作记忆(缓冲区) │
│ ├─→ 检索到的记忆 │
│ ├─→ 滚动摘要(压缩后的历史) │
│ └─→ 系统提示词 + 工具定义 │
│ ↓ │
│ 4. LLM 调用 │
│ (Agent 在组合后的上下文中推理) │
│ ↓ │
│ 5. 动作执行 │
│ 工具调用 → 结果 → 验证 │
│ ↓ │
│ 6. 记忆更新 │
│ ┌─→ 将新事实写入持久化层 │
│ ├─→ 更新工作缓冲区 │
│ ├─→ 压缩/过期陈旧记忆 │
│ └─→ 更新来源溯源 │
│ ↓ │
│ 7. 响应 │
│ 格式化并返回给用户 │
└─────────────────────────────────────────────────┘
第2步 — 查询检索: 在 LLM 看到任何内容之前,系统先获取相关记忆。检索查询不是用户的原始输入——是从用户输入加当前目标生成的元查询。这个两步流程(生成查询 → 检索记忆)防止无关记忆污染检索结果。
async function buildRetrievalQuery( userInput: string, workingMemory: ContextBuffer
): Promise<string> { // 使用轻量级模型生成聚焦的检索查询 const queryGen = await llm.call({ model: 'fast-model', // 更便宜、更快的模型用于查询生成 messages: [ { role: 'system', content: RETRIEVAL_QUERY_SYSTEM_PROMPT }, { role: 'user', content: `目标: ${workingMemory.currentGoal.description}\n输入: ${userInput}` } ] }); return queryGen.content;
}
第3步 — 上下文组合: 这是大多数 Agent 失败的地方。上下文不是"把所有东西拼接起来",而是带优先级的结构化组装:
滚动摘要是关键。不是保留原始对话历史,而是在每 5–10 轮时用 LLM 本身将历史压缩成简洁摘要:
async function compressHistory( rawHistory: Message[], workingMemory: ContextBuffer
): Promise<string> { const summary = await llm.call({ model: 'summary-model', messages: [ { role: 'system', content: COMPRESS_SYSTEM_PROMPT }, { role: 'user', content: formatMessages(rawHistory) } ], maxTokens: 500 }); return summary.content;
}
第6步 — 记忆更新: Agent 动作之后,系统提取并持久化新知识:
async function updateMemories( turnResult: AgentTurnResult, workingMemory: ContextBuffer, memoryStore: MemoryStore
): Promise<void> { // 从本轮提取新事实 const newFacts = await extractFacts(turnResult); for (const fact of newFacts) { await memoryStore.write({ id: crypto.randomUUID(), type: classifyFact(fact), content: fact.text, metadata: fact.metadata, source: { type: 'extraction', confidence: fact.confidence }, createdAt: new Date(), confidence: fact.confidence }); } // 更新工作缓冲区 workingMemory.extractedFacts.push(...newFacts); workingMemory.turnCount++; // 定期压缩 if (workingMemory.turnCount % 10 === 0) { await memoryStore.compact(workingMemory.sessionId); }
}
Agent 记忆最难的部分不是存储——是知道存储什么。原始对话是噪音。你不能什么都持久化。你需要从噪音中提取信号。
| 信号类型 | 示例 | 优先级 |
|---|---|---|
| 用户偏好 | "我喜欢早班机" | 高 |
| 明确事实 | "我的订单号是 ORD-12345" | 高 |
| 目标状态 | "我想要退款,不要换货" | 中 |
| 工具结果 | "机票搜索返回了3个400以下的结果" | 中 |
| 上下文线索 | 对话语气、紧急信号 | 低 |
| 闲聊 | "谢谢!" "知道了" "等等" | 无 |
原始轮次输出 ↓
┌──────────────┐
│ 分类器 │ 这个值得记住吗?
└──────┬───────┘ ↓ 是
┌──────────────┐
│ 提取器 │ 提取结构化事实
└──────┬───────┘ ↓
┌──────────────┐
│ 验证器 │ 与现有记忆核对
│ (去重) │ 避免存储"北京到上海"当
└──────┬───────┘ "北京到杭州"已存在时 ↓
┌──────────────┐
│ 优先级器 │ 分配置信度、TTL、类型
└──────┬───────┘ ↓
┌──────────────┐
│ 持久化器 │ 写入记忆存储
└──────────────┘
const EXTRACTION_PROMPT = `
你正在从用户和 AI Agent 之间的对话中提取事实。 只提取以下类型的信息:
1. 用户偏好(出行、饮食、日程等)
2. 明确陈述的事实(订单号、日期、姓名)
3. 活跃目标及其状态
4. 限制未来决策的工具结果 不要提取:
- 随意的对话填充词
- Agent 本轮已完成的请求
- 已存储在现有记忆中的信息(你会收到这些) 该用户的现有记忆:
{{existingMemories}} 对话:
{{conversation}} 返回一个 JSON 数组的事实。每条事实:{type, content, confidence (0-1), expiresAt (永久则为 null)}。
`;
一个无法引用来源的 Agent 就是一个会自信幻觉的 Agent。生产系统需要溯源追踪——每条记忆必须可追溯到其来源。
自我修正:当 Agent 说"根据您之前的请求..."时,它应该能指出是哪条之前的请求。如果用户纠正了,系统知道该更新什么。
调试:当 Agent 做出错误决策时,你需要知道是推理出了问题还是记忆出了问题。这是不同类型的 bug,需要不同的修复方式。
用户信任:用户能察觉 Agent 什么时候在编造。说"您3月3日提到..."和说"我觉得您可能说过..."给用户的信任感完全不同。
interface ProvenanceRecord { memoryId: string; sourceType: 'user-stated' | 'tool-result' | 'inferred' | 'system-injected'; sourceId: string; // 引用原始消息、工具调用或系统事件 timestamp: Date; confidence: number; overwrittenBy?: string; // 如果该记忆被取代了
}
当 Agent 检索记忆时,溯源记录应该是检索上下文的一部分,而非隐藏的元数据。Agent 需要知道它如何知道某事,以便表达适当的确定性。
即使有好的提取和检索,你也会碰到上下文限制。以下是生产系统如何处理。
不是在开头截断对话历史(丢失最老的、可能是关键性的上下文),而是使用摘要锚点:
[第1–12轮摘要:"用户订了北京→上海的机票,订单号 ORD-999。偏好靠窗座位。"]
[第13–24轮摘要:"用户要求改到杭州。已发起退款。"]
[第25轮:...]
[第26轮:...]
[第27轮:...]
摘要保留了早期轮次的意义,而没有token 成本。你可以按轮次计数或上下文预算触发定期摘要。
interface ContextBudget { totalTokens: number; // 例如 GPT-4o 的 128_000 reservedForSystem: number; // ~2,000 — 提示词、工具定义 reservedForTools: number; // ~8,000 — 相关工具结果 reservedForSummary: number; // ~1,000 — 滚动摘要 budgetForRetrieval: number; // 剩余 — 基于检索得分动态分配
}
每轮,上下文组合器检查预算并决定:能包含多少检索到的记忆?如果预算紧张,只包含相关度阈值以上的记忆。
最难的情况:用户几天后回来。上次的工作记忆已经没了。Agent 如何重建联系?
interface SessionLink { currentSessionId: string; previousSessionId: string; linkReason: 'same_user' | 'similar_goal' | 'referenced_context'; bridgeSummary: string; // "上次我们正在订去上海的机票..." linkedAt: Date;
}
新会话开始时,系统检查可能的链接:
桥接摘要是跨会话连续性最重要的单一产物。它是上一会话发生的事情的 2–3 句话压缩,在会话关闭时生成:
"上一会话:您正在订下周二北京到上海的机票。我们找到了三个400以下的选项。您当时在美联航红眼航班和国航早班机之间犹豫。对话结束前未做出选择。"
这个桥接被注入下一会话的第一轮,让 Agent 立即获得连续性,而无需重建整个对话。
当 Agent 自己的输出成为其记忆的一部分时,会产生反馈循环。它"记住"的是自己生成的内容,而非用户陈述的内容。缓解:来源标记。每条记忆必须携带 sourceType——Agent 自己的推理输出未经用户确认不应写入长期记忆。
记住一切的 Agent 反而记不住有用的。没有压缩和遗忘,检索索引变得嘈杂,相关记忆被淹没。缓解:基于 TTL 的驱逐和基于置信度的修剪。
当 Agent 假设存在连续性但实际没有——混淆对话、将会话归属错误。缓解:默认情况下话会话作用域记忆,只在验证后才进行明确的跨会话链接。
当提取器错过重要信息,因为它们嵌入在间接陈述中时。"我不太想坐东航"是偏好,但简单的提取器可能会在对话噪音中遗漏。缓解:多轮提取——第一轮提取明确事实,第二轮提取隐含偏好,每轮用不同的提示词。
用记忆中间件层包装你的 Agent 框架,拦截每轮:
class AgentWithMemory { constructor( private agent: BaseAgent, private memoryStore: MemoryStore, private retriever: MemoryRetriever, private contextBuffer: ContextBuffer ) {} async execute(userInput: string, sessionId: string): Promise<AgentResponse> { // 1. 检索相关记忆 const retrievalQuery = await this.buildRetrievalQuery(userInput); const memories = await this.retriever.recall(retrievalQuery, sessionId); // 2. 组合丰富上下文 const enrichedContext = this.composeContext( this.contextBuffer, memories, userInput ); // 3. 执行 Agent const response = await this.agent.execute(enrichedContext); // 4. 更新记忆 await this.updateMemories(response, sessionId); return response; }
}
| 框架 | 记忆方案 | 说明 |
|---|---|---|
| LangGraph | 带持久化的 StateGraph | 最适合复杂多步 Agent;用 Checkpointers 做持久化 |
| LangChain | ConversationBufferMemory、VectorStoreRetrieverMemory | 好用的原语,但需要手动组合 |
| LlamaIndex | QueryEngine + 带记忆的 ChatEngine | 检索集成强,状态管理弱 |
| CrewAI | 通过共享上下文的 Agent 记忆 | 更简单,但对记忆生命周期的控制更少 |
| 自研(生产推荐) | 手撸中间件 | 对上述每一层都有完全控制 |
对于生产系统,趋势是走向自研中间件而非开箱即用的记忆方案。框架提供构建块,但本文描述的架构——明确的分层、溯源、压缩、跨会话链接——需要目前没有框架能提供的编排能力。
无法衡量就无法改进。追踪这些指标:
Q: 我直接用向量数据库当记忆行不行?
不行。向量数据库给你的是检索,不是记忆。记忆需要提取、溯源追踪、压缩、TTL 管理、跨会话链接——向量存储默认都不提供。向量数据库是记忆系统的一个组件,不是记忆系统本身。
Q: 记忆应该预取还是按需取?
语义记忆(用户偏好、领域事实)预取——延迟可接受且相关度高,每轮都取。情景记忆(历史对话)按检索查询按需获取。程序记忆(工具定义)不要预取——只加载与当前目标相关的工具以节省上下文 tokens。
Q: 生产 Agent 的最小可行记忆系统是什么?
三件事:(1) 结构化上下文缓冲区,Agent 每轮写入,(2) 检索层用于语义记忆(用户偏好 + 领域事实),(3) 滚动摘要机制处理上下文窗口限制。先把这三件事做对,再考虑跨会话链接、溯源追踪或压缩。那些是优化,不是基础。
模型的推理能力已经在线。缺的永远是好用的记忆系统。把记忆系统搭好,推理问题大部分会自己消失。