跳到主要内容

记忆子系统:短期缓冲、持久化、工作记忆与语义检索

30 秒导读: 大模型每次调用都是「失忆」的——你不把上文喂进去,它就什么都不记得。 VoltAgent 的记忆子系统就是那台「喂料机」:把这一轮产生的消息先攒在内存缓冲里, 生成结束后异步刷进数据库(LibSQL / Postgres / Supabase 任选),下一轮再从库里 捞出来拼进 prompt。在这条主干上,它再挂三样「记得更准 / 更省」的能力:语义检索 (按意思召回老消息)、工作记忆(把要点写进一块结构化便签)、摘要压缩 (历史太长就先总结再喂)。

本章只讲「记忆」这一层。agent 主循环怎么把这些记忆拼进一次生成,见 01-agent-runtime.md;工具系统见 02-tools-and-mcp.md


1. 这是什么(零基础也能懂)

一句话定义: 记忆子系统 = agent 的「上下文持久层」,负责把对话消息存下来、取回来, 并在取回时做得更聪明(只取相关的、只取要点、太长就压缩)。

为什么需要它? 大模型本身无状态。你问「我叫什么名字」,它只能看这一次请求里带了什么。 要让 agent 跨越很多轮、甚至跨越很多天还记得你,就必须有人在每轮之间把消息落盘、 在每轮开头把消息读回。这个「有人」就是记忆子系统。

它把「记忆」分成两个层次,正好对应人脑的两种记忆:

层次对应人脑在 VoltAgent 里是什么存在哪
短期工作台上的便签ConversationBuffer(本轮消息缓冲)进程内存,单次生成期间
长期笔记本 / 档案柜Memory + 存储适配器(全部历史)LibSQL / Postgres / Supabase…

用起来什么样: 配一个 Memory,把它交给 agent,记忆就自动转起来了——你只要在每次调用 时带上 userIdconversationId,框架就知道「这是谁、哪段对话」,自动读旧的、存新的。

// 示意,非源码:最小配置——一句话开启「能跨轮记忆」的 agent
const memory = new Memory({
storage: new LibSQLMemoryAdapter({ url: "file:./voltagent.db" }), // 存哪里
});

const agent = new Agent({ name: "assistant", model, memory });

// 同一个 conversationId,第二轮就能记得第一轮说过的话
await agent.generateText("我叫小明", { userId: "u1", conversationId: "c1" });
await agent.generateText("我叫什么?", { userId: "u1", conversationId: "c1" }); // → 小明

一句话直觉:ConversationBuffer内存(RAM)、把存储适配器当磁盘, 把 MemoryPersistQueue 当那个「攒够了 / 该收尾了就把 RAM 写回磁盘」的刷盘线程—— 整套记忆就是一个为对话定制的「读缓存 + 写回」体系。


2. 顶层全景(它大概怎么转)

先看一次生成里,记忆是怎么在两端各出场一次的。

怎么读这张图: 从左到右是一次 generateText 的时间线;记忆在开头读结尾写,中间的生成过程只跟内存里的 ConversationBuffer 打交道。

┌────────────────────────── 一次 agent 生成 ──────────────────────────┐
│ │
│ ① 读历史 ② 本轮攒消息 ③ 异步落库 │
│ │
│ 存储适配器 ──读──► ConversationBuffer ──drain──► MemoryPersist │
│ (DB / 库) (内存·短期缓冲) Queue │
│ ▲ │ ▲ │ │
│ │ │ │ 每步 addModelMessages │ debounce │
│ │ 模型生成/工具调用 │ 200ms │
│ │ ▼ │
│ └──────────────── 写 ◄─────────── saveMessage ── 存储适配器 │
│ │
└──────────────────────────────────────────────────────────────────┘
叠加能力(读的时候可选):
· 语义检索 —— 用 embedding+vector 按「意思」召回老消息
· 工作记忆 —— 读/写一块结构化便签,注入 system 指令
· 摘要压缩 —— 历史超阈值就先总结,再喂给模型

部件一句话职责:

部件干什么在哪个文件
Memory记忆总门面,统管消息 / 会话 / 向量 / 工作记忆packages/core/src/memory/index.ts:76
StorageAdapter可插拔「磁盘」:落库消息、会话、工作记忆、工作流状态packages/core/src/memory/types.ts:397
VectorAdapter可插拔「向量库」:存向量、按余弦相似度检索packages/core/src/memory/adapters/vector/types.ts:4
EmbeddingAdapter可插拔「向量化器」:文本 → 向量packages/core/src/memory/adapters/embedding/types.ts:11
MemoryManageragent 侧包装:接 OpenTelemetry、后台队列、生成标题packages/core/src/memory/manager/memory-manager.ts:28
ConversationBuffer本轮短期缓冲:合并 tool 调用/结果、标记待落库packages/core/src/agent/conversation-buffer.ts:62
MemoryPersistQueue异步刷盘:防抖 + 串行,把缓冲写进存储packages/core/src/agent/memory-persist-queue.ts:31
applySummarization历史超阈值时生成/注入摘要packages/core/src/agent/apply-summarization.ts:42

主线走一遍(高层):

  1. 生成开始,MemoryManager.prepareConversationContext 从存储读回最近若干条消息 (memory-manager.ts:478,默认 contextLimit=10)。
  2. 生成过程中,模型每吐一段 / 每次工具调用,都 ConversationBuffer.addModelMessages 进缓冲,同时标记成「待落库」。
  3. 流式期间边生成边 scheduleSave(防抖攒批),生成收尾 flush(强制刷盘), MemoryPersistQueue 把缓冲里「待落库」的消息交给 MemoryManager.saveMessage 写库。

3. 三类可插拔适配器:记忆的「三块插槽」

这节讲什么: VoltAgent 把「记忆」拆成三个正交的接口,像三个插槽——你插什么进去, 就得到什么能力。这是整个子系统最重要的设计。

3.1 三块插槽各管什么

Memory 构造时只认三个可选件(memory/types.ts:257 MemoryConfig):

插槽接口负责不配会怎样
storage(必填)StorageAdapter消息、会话、工作记忆、工作流状态的落库无法构造
embedding(可选)EmbeddingAdapter把文本变成向量语义检索 / RAG 关闭
vector(可选)VectorAdapter存向量 + 相似度检索语义检索 / RAG 关闭

关键规则:语义能力要 embedding 和 vector 两个都配齐才开。代码里到处是这个 「与」判断,例如写入时是否顺带向量化(memory/index.ts:143 addMessage):

// packages/core/src/memory/index.ts:143 —— 两个都在,才自动 embed 并存向量
if (this.embedding && this.vector) {
await this.embedAndStoreMessage(message, userId, conversationId);
}
await this.storage.addMessage(message, userId, conversationId, context);

hasVectorSupport() 就是这条判断的公开版(memory/index.ts:480),供上层决定走不走 语义检索。

3.2 每个接口长什么样

三个接口都刻意做得很窄,方便第三方实现。以向量接口为例 (memory/adapters/vector/types.ts:4):store / storeBatch / search / delete / deleteBatch / clear / count / get 八个方法,search 返回带 score 的结果。

embedding 接口更小(memory/adapters/embedding/types.ts:11):就 embed / embedBatch / getDimensions / getModelName 四个。

storage 接口最大(memory/types.ts:397),因为它要兼管四类数据:消息、会话、 工作记忆(getWorkingMemory/setWorkingMemory/deleteWorkingMemory)、 工作流状态(getWorkflowState 等,给 05-workflow-engine.md 的暂停/恢复用)。

3.3 内置实现与真实适配器

框架自带两个纯内存实现,零依赖、开箱即用,专供开发和测试:

  • InMemoryStorageAdapter(memory/adapters/storage/in-memory.ts)——MemoryManager 在你没配存储时的默认兜底(memory-manager.ts:88)。
  • InMemoryVectorAdapter(memory/adapters/vector/in-memory.ts:8)——用 Map 存向量, 遍历算余弦相似度,适合 <10k 向量。

生产用的是独立包里的真实适配器:

npm 包存储适配器向量适配器
@voltagent/libsqlLibSQLMemoryAdapterLibSQLVectorAdapter
@voltagent/postgresPostgreSQLMemoryAdapterPostgreSQLVectorAdapter
@voltagent/supabaseSupabaseMemoryAdapter—(复用 Postgres 向量)

因为接口窄,换库就是换一行 storage: new XxxMemoryAdapter(...),Memory 上层逻辑一字不改。

3.4 embedding 参数的多形态解析

embedding 这个插槽还有个贴心处理:它接受四种写法,统一由 resolveEmbeddingAdapter 归一(memory/index.ts:49):

你传的处理
一个 EmbeddingAdapter 实例直接用
一个字符串(模型名,如 "openai/text-embedding-3-small")包成 AiSdkEmbeddingAdapter
{ model, ...options } 配置对象拆出 model 再包
一个 AI SDK 的 EmbeddingModel 对象包成 AiSdkEmbeddingAdapter

AiSdkEmbeddingAdapter(memory/adapters/embedding/ai-sdk.ts:14)是对 Vercel AI SDK embed / embedMany 的封装:embedBatch 会按 maxBatchSize(默认 100)切批 避免触发限流(ai-sdk.ts:106),维度在第一次成功 embed 后才回填(ai-sdk.ts:89)。


4. 短期缓冲与异步落库:生成期怎么「攒」和「刷」

这节讲什么: 一次生成会吐出一大堆碎片——文本增量、工具调用、工具结果、推理块。 把每个碎片都立刻写库既慢又乱。VoltAgent 的解法是:先在内存里攒成干净的消息, 再异步、防抖、串行地刷进库。这活儿由 ConversationBufferMemoryPersistQueue 分工。

4.1 ConversationBuffer:把碎片攒成干净消息

ConversationBuffer(agent/conversation-buffer.ts:62)是本轮的内存工作台。它的价值不在 「存」,而在合并:模型是分步、流式产出的,同一条 assistant 消息可能被喂进来很多次, 工具调用(tool-call)和工具结果(tool-result)还得配对拼到一起。

它维护三样东西:

  • messages —— 当前这轮攒下的所有 UIMessage
  • pendingMessageIds —— 哪些消息还没落库(一个 Set)。
  • toolPartIndex —— toolCallId → 位置 的索引,让工具结果能 O(1) 找到对应的工具调用。

核心入口是 addModelMessages(conversation-buffer.ts:79),它按角色分流: assistant 消息尝试并入上一条 assistant(handleAssistantMessage),tool 消息则 按 toolCallId 回填到已有的工具调用块上(tryMergeToolPart,conversation-buffer.ts:464)。

// packages/core/src/agent/conversation-buffer.ts:464 —— 工具结果按 toolCallId 就地更新
const existing = this.toolPartIndex.get(toolCallId);
if (existing && this.messages[existing.messageIndex]) {
const existingPart = this.messages[existing.messageIndex].parts[existing.partIndex];
existingPart.state = part.state ?? existingPart.state; // pending → 有结果
if ("output" in part) existingPart.output = part.output; // 把结果填回调用块
return "updated";
}

只要有改动,消息 id 就被加进 pendingMessageIds(conversation-buffer.ts:317), 等着被刷盘。刷盘时调 drainPendingMessages(conversation-buffer.ts:139): 它取出全部待落库消息并清空标记——注意消息本身留在缓冲里(后续还要拼 prompt), 只是「待落库」这个标记被清掉,保证同一条不会被写两次。

ConversationBuffer 还支持 createCheckpoint / restoreCheckpoint (conversation-buffer.ts:120),给工作流暂停/恢复做快照。

4.2 MemoryPersistQueue:防抖 + 串行的刷盘器

MemoryPersistQueue(agent/memory-persist-queue.ts:31)负责真正把缓冲写进库, 它解决两个问题:别写太频繁(防抖)和别并发打架(串行)。

防抖: scheduleSave(memory-persist-queue.ts:44)不立刻写,而是设一个 setTimeout,默认 debounceMs = 200(memory-persist-queue.ts:40)。流式生成里每来一批 增量就 scheduleSave 一次,新的会 clearTimeout 掉旧的——于是密集的 token 流被合并成 「静默 200ms 后写一次」。

串行: 每个 userId:conversationId 维护一条 pendingPromise 承诺链 (enqueuePersist,memory-persist-queue.ts:137)。新任务 .then 挂在旧任务之后, 保证同一段对话的写入永远顺序执行,不会两次刷盘交错、写乱消息顺序。

scheduleSave ──► [清掉旧 timer] ──► setTimeout(200ms) ──► enqueuePersist

多次 scheduleSave 只保留最后一个 timer ▼
pendingPromise 承诺链(串行)

flush() ──► [立刻清 timer] ──► enqueuePersist ─────────────┘

buffer.drainPendingMessages()
→ 逐条 memoryManager.saveMessage

两个触发时机(在 agent.ts 里,memory-persist-queue.ts 提供能力):

  • 流式生成中:scheduleSave(防抖攒批,agent.ts:7434)。
  • 生成收尾 / 出错时:flush(立刻清 timer 并刷完,agent.ts:7432)——保证不丢尾巴。

真正落库前,persist 还会给消息补上子代理归属元数据(applySubAgentMetadata, memory-persist-queue.ts:172):谁(哪个 subagent)产生的这条消息,写进 metadata.subAgentId, 供 UI 还原多 agent 协作的时间线(见 04-subagents-supervisor.md)。

4.3 MemoryManager:agent 与 Memory 之间的适配层

MemoryManager(memory/manager/memory-manager.ts:28)是 agent 侧对 Memory 的包装, 它加了三样 Memory 本身不管的事:

  • 可观测性: 每次读写都开一个 OpenTelemetry span(saveMessage 里的 memory.write, memory-manager.ts:123),接进 VoltOps(见 06-observability-guardrails-voltops.md)。
  • 后台队列: 保存用户输入走 BackgroundQueue(memory-manager.ts:94,并发 10、重试 5), 不阻塞生成主线。
  • 会话保障 + 标题生成: 写消息前先确保 conversation 存在(ensureConversationExists), 幂等处理「已存在」的竞态(isConversationAlreadyExistsError,memory-manager.ts:627, 认得 Postgres 23505、SQLite 约束等),并可选调 LLM 给新会话起标题。

5. 语义检索:按「意思」召回老消息

它要解决的小问题: 对话上百轮后,只取「最近 10 条」会漏掉早前的关键信息。 用户现在问的问题,答案可能埋在 50 轮前——按时间取不到,得按意思取。

思路: 把每条消息 embed 成向量存进向量库;检索时把「当前问题」也 embed, 在向量库里找余弦相似度最高的几条老消息,再和「最近 N 条」合并喂给模型。

入口: getMessagesWithSemanticSearch(memory/index.ts:321)。它做三步:

  1. 先照常取最近消息;若没传 query 或没配 embedding+vector,直接返回最近消息(降级)。
  2. 把 query embed 成向量,this.vector.searchuserId + conversationId 过滤检索 (memory/index.ts:347),默认 semanticLimit=5
  3. 用检索到的 messageId 捞回真实消息,按策略与最近消息合并去重(mergeMessages)。

三种合并策略(mergeMessages,memory/index.ts:439):

策略效果
prepend语义消息放最近消息之前
append(默认)语义消息放最近消息之后
interleave两组交错排列

合并时用最近消息的 id 集合去重,避免同一条既在「最近」又在「语义」里重复出现 (memory/index.ts:445)。

容错优先: 整个语义检索包在 try/catch 里,任何一步失败都退回只返回最近消息 (memory/index.ts:371)——记忆宁可「少而对」,不因向量库抽风而整轮失败。

向量 id 的编码约定: 每条消息的向量 id 固定为 msg_${conversationId}_${message.id} (memory/index.ts:600)。这个稳定命名让「删会话 / 删消息时顺带删向量」不必查表, 直接拼 id 就能 deleteBatch(memory/index.ts:204:299)。

相似度怎么算(以内存实现为例): InMemoryVectorAdapter.search (memory/adapters/vector/in-memory.ts:39)遍历所有向量算 cosineSimilarity,再把 余弦值(-11)映射到 01 的分数:score = (similarity + 1) / 2 (in-memory.ts:72),按分数降序取前 limit

embedding 缓存(省钱省延迟):enableCache 后,文本→向量结果进 BatchEmbeddingCache(memory/utils/cache.ts:147)。批量 embed 时先 splitByCached 把已缓存的挑出来,只对未命中的调 API(memory/index.ts:646), 再按原顺序拼回。底层是带 TTL(默认 1 小时)的 LRU(cache.ts:9)。


6. 工作记忆:一块给 agent 自己维护的结构化便签

它要解决的小问题: 消息历史会被截断、会被摘要压缩,一些「必须一直记得」的要点 (用户叫什么、偏好、当前任务目标)不该随历史一起丢。

思路: 单独开一块便签,让 agent 通过工具主动往里写要点;这块便签每轮都 完整注入 system 指令,不受历史截断影响。这就是 working memory。

两个作用域(WorkingMemoryConfig,memory/types.ts:241):

scope便签跟着谁走用途
conversation(默认)单段会话记住本次任务的进展
user该用户的所有会话跨会话记住用户画像

两种格式,自动判定(getWorkingMemoryFormat,memory/index.ts:887):配了 Zod schemaJSON(带校验);只给 templateMarkdown;都不给 → 自由文本。

两种写入模式(updateWorkingMemory,memory/index.ts:717):

  • append(推荐默认):与现有内容合并。JSON 走 simpleDeepMerge (memory/index.ts:807)——嵌套对象递归合并、数组去重合并;Markdown 则拼接。
  • replace(危险):整块覆盖,没带上的字段会丢。

怎么让模型用它? agent 在系统提示里注入一段专门的操作指南 (getWorkingMemoryInstructions,memory/index.ts:909;由 agent.ts:5396 调用), 并挂一个 update_working_memory 工具(agent.ts:8821)。这段指南很讲究,专门反复强调 「优先用 append、别用 replace 免得丢数据」,还把当前便签内容一起塞进去:

CONVERSATION CONTEXT MANAGEMENT:
...
CRITICAL UPDATE RULES:
• Append mode (DEFAULT): Safely adds new information without losing existing data
• Replace mode (DANGEROUS): Only use when you want to DELETE everything ...

<current_context>
(这里塞入便签的当前内容,没有则提示 "begin capturing ... immediately")
</current_context>

于是模型在对话中「学到值得记的东西」就顺手调工具写便签,下一轮这块便签又被完整读回。


7. 超长历史摘要压缩:太长就先总结再喂

它要解决的小问题: 对话累积到十几万 token,直接全塞进模型既超窗口又烧钱。得在喂给 模型之前把旧历史压成一段摘要。

思路: 估算历史 token 数,超过阈值就调一次 LLM 把「较早那段」总结成要点,保留最近 几条原文,用「摘要 + 尾部原文」替换掉「一长串完整历史」。

入口: applySummarization(agent/apply-summarization.ts:42),在 agent 拼完消息、 真正调模型前执行(agent.ts:5231)。默认参数:

参数默认值含义
triggerTokens170_000估算 token 超过它才触发(apply-summarization.ts:20)
keepMessages6末尾保留几条原文(apply-summarization.ts:21)
token 估算字符数 / 4粗估(apply-summarization.ts:23:331)

增量摘要(省钱的关键): 它不是每次都从头总结。状态里记着「上次摘要覆盖到第几条」 (summaryMessageCount),这次只把新增的那几条接在旧摘要后面再总结 (buildSummaryInput,apply-summarization.ts:411),避免重复消耗老消息。

摘要存哪? 存进 conversation 的 metadata.agent(updateAgentSummaryState, apply-summarization.ts:270),并有一份进程内 fallback。这样跨轮、跨进程都能复用。

最终喂给模型的形态(apply-summarization.ts:191): [系统消息] + [一条 <agent_summary> 摘要消息] + [最近 keepMessages 条原文]。摘要被包在 <agent_summary>…</agent_summary> 标记里(buildSummarySystemMessage,apply-summarization.ts:434), 下一轮进来时先按这个标记把旧摘要消息剔掉(removeSystemMessagesWithMarker,apply-summarization.ts:322), 避免摘要层层套娃。


8. 巧妙之处(可借鉴的技术)

  • 三窄接口 + 「与」门控。 storage/vector/embedding 三个正交窄接口,换库零成本; 语义能力用 this.embedding && this.vector 统一门控(memory/index.ts:143), 没配齐就静默降级到纯时序记忆,不报错。

  • 缓冲/刷盘分离。 ConversationBuffer 只管「攒干净」、MemoryPersistQueue 只管 「防抖串行地写」。drainPendingMessages(conversation-buffer.ts:139)清标记不清消息, 一举兼顾「不重复落库」和「消息还能继续拼 prompt」。

  • 工具调用/结果按 toolCallId 就地回填。 toolPartIndex 让流式乱序到达的 tool-result 能 O(1) 找到 tool-call 并合并(conversation-buffer.ts:464), 落库的是配好对的干净消息,而非一堆碎片。

  • 向量 id 可推导。 msg_${conversationId}_${message.id}(memory/index.ts:600) 让删会话/删消息时不查表即可反算出要删的向量,存储与向量库天然对齐。

  • 语义检索容错优先。 任何环节失败都退回最近消息(memory/index.ts:371), 记忆的「可用」优先于「更聪明」。

  • 增量摘要 + 标记防套娃。 只总结新增消息、旧摘要用 <agent_summary> 标记先剔除 (apply-summarization.ts:322),既省 token 又避免摘要嵌套膨胀。


9. 边界与局限(诚实)

  • 语义能力全有或全无。 只配 embedding 或只配 vector 都不开语义检索,必须成对 (memory/index.ts:143:480)。

  • 内存适配器不适合生产。 InMemoryVectorAdapter 每次 search 全表遍历算相似度 (in-memory.ts:39),注释明说适用 <10k 向量;进程重启即丢。生产要换真实适配器。

  • token 估算很粗。 摘要触发用「字符数 / 4」估 token(apply-summarization.ts:331), 非真实分词,对 CJK 等非英文文本可能偏差较大。

  • 清空整个用户记忆是 O(会话数 × 消息数)。 删向量前要分页遍历该用户所有会话的 所有消息来收集向量 id(getMessageVectorIdsForClear,memory/index.ts:395), 会话极多时较重。

  • 摘要状态耦合 conversation.metadata。 摘要存在会话元数据的 agent 字段 (apply-summarization.ts:270);若存储适配器丢失 metadata,则退回进程内 fallback, 跨进程可能失效。


10. 代码地图(导航索引)

主题文件路径关键符号
记忆总门面packages/core/src/memory/index.tsMemorygetMessagesaddMessagegetMessagesWithSemanticSearch
embedding 参数归一packages/core/src/memory/index.tsresolveEmbeddingAdapter
语义合并策略packages/core/src/memory/index.tsmergeMessages
写入即向量化packages/core/src/memory/index.tsembedAndStoreMessageembedAndStoreMessages
工作记忆读写packages/core/src/memory/index.tsupdateWorkingMemorysimpleDeepMergegetWorkingMemoryInstructions
存储接口packages/core/src/memory/types.tsStorageAdapterMemoryConfigWorkingMemoryConfig
向量接口 / 内存实现packages/core/src/memory/adapters/vector/VectorAdapterInMemoryVectorAdapter
向量化接口 / AI SDK 实现packages/core/src/memory/adapters/embedding/EmbeddingAdapterAiSdkEmbeddingAdapter
embedding 缓存packages/core/src/memory/utils/cache.tsBatchEmbeddingCachesplitByCached
agent 侧记忆管理packages/core/src/memory/manager/memory-manager.tsMemoryManagersaveMessageprepareConversationContextperformSemanticSearch
短期缓冲packages/core/src/agent/conversation-buffer.tsConversationBufferaddModelMessagesdrainPendingMessagestryMergeToolPart
异步刷盘packages/core/src/agent/memory-persist-queue.tsMemoryPersistQueuescheduleSaveflushenqueuePersist
摘要压缩packages/core/src/agent/apply-summarization.tsapplySummarizationbuildSummaryInputupdateAgentSummaryState
真实适配器packages/libsqlpackages/postgrespackages/supabaseLibSQLMemoryAdapterPostgreSQLVectorAdapterSupabaseMemoryAdapter