Agent记忆系统

要点

  • Agent 每轮调用 LLM 时,模型本身不记得上一轮说了什么——所有「记忆」都要由外部代码提供
  • 短期记忆是当前对话的消息数组,让 Agent 在一次任务执行过程中保持上下文连贯
  • 工作记忆是对上下文窗口的管理——token 有限,消息超出窗口时需要压缩或截断
  • 长期记忆存储跨会话的信息(用户偏好、历史事实),需要持久化到 KV 或向量数据库
  • 记忆的检索不能靠全量塞入,对话历史越长越需要按相关性筛选后再拼接进 prompt
  • 记忆压缩有三种常见策略:滑动窗口截断、摘要替换、重要性打分
  • 把短期记忆、工作记忆、长期记忆拆成独立模块,后续替换存储后端时不用改 Agent 逻辑

1. 从一个问题开始

回到前面 Agent 代码里的 messages 数组。每一轮循环都把它整个传给 LLM:

// src/agent/loop.ts
const messages: Message[] = [
  { role: 'system', content: systemPrompt },
  { role: 'user', content: userInput },
]
 
for (let i = 0; i < MAX_ITERATIONS; i++) {
  const response = await callLLM(messages)
  // ...
}

这个数组就是 Agent 的全部记忆。LLM 每次被调用时,它看到的「世界」就是数组里的内容——数组里有什么它就记得什么,没有的就完全不知道。

这带来两个问题:

  1. 对话太长:如果聊了 50 轮,messages 会增长到几十万 token,超过模型的上下文窗口限制
  2. 跨会话遗忘:用户上周说「我对花生过敏」,这周再聊时 Agent 完全不记得——因为上一次对话结束后 messages 就被丢弃了

第一个问题是工作记忆要解决的,第二个是长期记忆要解决的。

2. 三种记忆类型

短期记忆(Short-term Memory)

当前任务执行期间的对话历史和工具调用结果,就是 runAgent 里的 messages 数组。

  • 生命周期:从任务开始到任务结束
  • 存储位置:内存
  • 作用:让 Agent 在多轮循环中知道之前做了什么、结果是什么

工作记忆(Working Memory)

当前送入 LLM 的上下文窗口。短期记忆记录了所有历史,工作记忆是经过筛选和压缩后实际送入模型的那部分。

  • 生命周期:每次 LLM 调用
  • 存储位置:内存
  • 内容:system prompt + 筛选后的历史消息 + 当前输入
  • 作用:在有限的 token 窗口内,放入对当前决策最有用的信息

长期记忆(Long-term Memory)

跨会话持久保存的信息——用户偏好、历史事实、过去的任务结论。

  • 生命周期:永久(直到被主动删除或过期)
  • 存储位置:KV、数据库、向量数据库
  • 作用:让 Agent 在不同会话之间保持对用户的了解

三者的关系:

长期记忆(KV / 向量数据库)
    ↓ 按需检索
工作记忆(上下文窗口)← 短期记忆(完整对话历史)
    ↓ 送入 LLM
Agent 推理与行动

3. 短期记忆:对话历史管理

用一个类封装短期记忆的读写,加上 token 估算和截断策略。

// src/memory/short-term.ts
import type { Message } from '../agent/types'
 
// 粗略估算:中文 1 字 ≈ 1.5 token,英文 1 token ≈ 4 字符
function estimateTokens(text: string): number {
  let tokens = 0
  for (const char of text) {
    tokens += /[一-鿿]/.test(char) ? 1.5 : 0.25
  }
  return Math.ceil(tokens)
}
 
export class ShortTermMemory {
  private messages: Message[] = []
  private maxTokens: number
 
  constructor(systemPrompt: string, maxTokens = 4000) {
    this.maxTokens = maxTokens
    this.messages = [{ role: 'system', content: systemPrompt }]
  }
 
  addMessage(message: Message) {
    this.messages.push(message)
    this.trimIfNeeded()
  }
 
  getMessages(): Message[] {
    return [...this.messages]
  }
 
  private trimIfNeeded() {
    while (this.getTotalTokens() > this.maxTokens && this.messages.length > 1) {
      // system prompt 始终保留,移除最早的非系统消息
      this.messages.splice(1, 1)
    }
  }
 
  private getTotalTokens(): number {
    return this.messages.reduce((sum, msg) => sum + estimateTokens(msg.content) + 4, 0)
  }
}

这就是滑动窗口策略:token 超限时丢掉最早的消息。简单直接,但被丢弃的消息里如果有重要信息(比如用户最初说的过敏情况),后续就会丢失上下文。更稳妥的做法是在截断前把旧消息压缩成摘要,下面第 6 节会讲。

4. 工作记忆:管理上下文窗口

工作记忆要解决一个问题:在有限的 token 窗口里放什么。模型的上下文窗口需要预留给 system prompt、工具 schema 定义和模型输出,剩下的才是历史消息的空间。

// src/memory/working.ts
import type { Message } from '../agent/types'
 
type WorkingMemoryConfig = {
  maxTokens: number          // 上下文窗口总预算
  systemPromptTokens: number
  toolSchemaTokens: number
  reserveForOutput: number   // 预留给模型输出
}
 
export function buildWorkingMemory(
  config: WorkingMemoryConfig,
  systemPrompt: string,
  history: Message[],
  currentInput: string,
): Message[] {
  const { maxTokens, systemPromptTokens, toolSchemaTokens, reserveForOutput } = config
  const budgetForHistory = maxTokens - systemPromptTokens - toolSchemaTokens - reserveForOutput
 
  const systemMsg: Message = { role: 'system', content: systemPrompt }
  const userMsg: Message = { role: 'user', content: currentInput }
  let usedTokens = estimateTokens(systemMsg.content) + estimateTokens(userMsg.content) + 8
 
  // 从最新消息往前取,尽量多放近期对话
  const selected: Message[] = []
  for (let i = history.length - 1; i >= 0; i--) {
    const msg = history[i]
    const msgTokens = estimateTokens(msg.content) + 4
    if (usedTokens + msgTokens > budgetForHistory) break
    selected.unshift(msg)
    usedTokens += msgTokens
  }
 
  return [systemMsg, ...selected, userMsg]
}

逻辑是从最新消息开始往前取,直到 token 预算用完。最近的对话一定被包含,较早的按剩余空间决定放多少。这比简单的全局截断好一些——它保证了每次 LLM 调用都在预算范围内。

但这种方式仍然是按时间顺序选择。如果早期的某条消息比近期消息更重要,它就会被忽略。要解决这个问题,需要引入语义检索——这就是长期记忆的工作方式。

5. 长期记忆:跨会话的信息保持

用 KV 存储结构化事实

用户偏好、个人属性这类有限且结构明确的信息,直接用 Cloudflare KV 存键值对:

// src/memory/long-term-kv.ts
type UserFact = { key: string; value: string; updatedAt: string }
 
export async function saveUserFact(kv: KVNamespace, userId: string, fact: UserFact) {
  const facts = await getUserFacts(kv, userId)
  const idx = facts.findIndex((f) => f.key === fact.key)
  if (idx >= 0) {
    facts[idx] = { ...fact, updatedAt: new Date().toISOString() }
  } else {
    facts.push({ ...fact, updatedAt: new Date().toISOString() })
  }
  await kv.put(`user:${userId}:facts`, JSON.stringify(facts))
}
 
export async function getUserFacts(kv: KVNamespace, userId: string): Promise<UserFact[]> {
  const data = await kv.get(`user:${userId}:facts`)
  return data ? JSON.parse(data) : []
}

KV 适合数量少、结构明确的事实。但如果对话里有大量散落的信息需要按语义查找,就需要向量数据库。

用向量数据库做语义检索

用户问「我上次说的那个餐厅叫什么」,Agent 需要语义上相关的记忆,而不是精确匹配某个 key。把历史对话转成向量存入向量数据库,检索时按余弦相似度找出最相关的几条。

// src/memory/long-term-vectorize.ts
import type { AppEnv } from '../types'
 
export async function storeMemory(env: AppEnv['Bindings'], userId: string, content: string) {
  const embedding = await env.AI.run('@cf/baai/bge-base-en-v1.5', { text: content })
  await env.VECTORIZE.upsert([{
    id: crypto.randomUUID(),
    values: embedding.data[0],
    metadata: { userId, content, createdAt: new Date().toISOString() },
  }])
}
 
export async function retrieveMemories(
  env: AppEnv['Bindings'],
  userId: string,
  query: string,
  topK = 5,
) {
  const queryEmbedding = await env.AI.run('@cf/baai/bge-base-en-v1.5', { text: query })
  const results = await env.VECTORIZE.query(queryEmbedding, {
    topK,
    filter: { userId: { $eq: userId } },
    returnMetadata: 'all',
  })
  return results.matches.map((m) => ({
    content: m.metadata?.content as string,
    createdAt: m.metadata?.createdAt as string,
  }))
}

语义检索的好处是:用户说「我不吃花生」,之后问「有什么坚果要注意的」,Agent 能通过语义相似度找到之前的记录。

何时提取记忆

把什么内容存入长期记忆,可以让 LLM 自己判断。在工具列表里提供一个 save_to_memory 工具,LLM 在推理过程中主动调用:

// src/memory/memory-tool.ts
export const saveMemoryTool = {
  name: 'save_to_memory',
  description: '当用户提到重要的个人信息、偏好或事实时调用。如过敏信息、饮食偏好、家庭成员。不要保存临时性任务信息。',
  inputSchema: {
    type: 'object',
    properties: {
      content: { type: 'string', description: '用简洁的陈述句描述要保存的事实' },
      category: { type: 'string', enum: ['preference', 'personal_info', 'fact', 'rule'] },
    },
    required: ['content', 'category'],
  },
}

LLM 自己判断什么值得记,比「每轮都存」更可控,也避免了存储噪音。

6. 记忆压缩策略

滑动窗口截断最简单,但会丢失重要信息。另外两种策略可以补上这个缺口。

摘要替换

当对话历史超过阈值时,把最早的一段消息压缩成一条摘要。摘要保留关键信息,占用的 token 远少于原始对话:

// src/memory/compressor.ts
export async function compressWithSummary(
  messages: Message[],
  maxTokens: number,
  recentCount: number,
): Promise<Message[]> {
  const totalTokens = messages.reduce((sum, msg) => sum + estimateTokens(msg.content) + 4, 0)
  if (totalTokens <= maxTokens) return messages
 
  const systemMsg = messages[0]
  const rest = messages.slice(1)
  const toCompress = rest.slice(0, rest.length - recentCount)
  const toKeep = rest.slice(rest.length - recentCount)
 
  const textToSummarize = toCompress.map((m) => `[${m.role}]: ${m.content}`).join('\n')
  const summary = await callLLMForSummary(
    `用 3-5 句话总结以下对话的关键信息,包括用户偏好、已确认的事实和重要结果:\n\n${textToSummarize}`,
  )
 
  return [systemMsg, { role: 'system', content: `之前对话的摘要:${summary}` }, ...toKeep]
}

代价是多一次 LLM 调用。可以用小模型(如 Haiku)做摘要,成本很低。

重要性打分

给每条消息打一个重要性分数,压缩时优先丢弃分数低的消息。分数来自几个维度:

  • 时间衰减:越近的消息分数越高(权重 0.4)
  • 角色权重:用户消息和工具结果通常比 Agent 的过渡回复更重要(+0.3)
  • 内容特征:包含具体数字、关键词(「偏好」「必须」「过敏」)的消息更重要(+0.2)
// src/memory/importance.ts
export function scoreImportance(messages: Message[]) {
  const total = messages.length
  return messages.map((msg, index) => {
    let score = (index / total) * 0.4 // 时间衰减
    if (msg.role === 'user') score += 0.3
    if (msg.role === 'tool') score += 0.25
    if (/\d+/.test(msg.content)) score += 0.1
    if (/(偏好|记住|不要|必须|过敏)/.test(msg.content)) score += 0.2
    if (/^(好的|收到|明白了|谢谢)/.test(msg.content)) score -= 0.1
    return { ...msg, importance: Math.max(0, Math.min(1, score)) }
  })
}

实际项目中,滑动窗口 + 摘要替换的组合已经能覆盖大部分场景。重要性打分适合对话特别长、信息密度不均匀的情况。

7. 整合:在 Agent 循环中使用记忆

把三层记忆串起来,看完整的 Agent 循环如何与记忆系统配合:

// src/agent/loop-with-memory.ts
import type { AppEnv } from '../types'
import { ShortTermMemory } from '../memory/short-term'
import { buildWorkingMemory } from '../memory/working'
import { storeMemory, retrieveMemories } from '../memory/long-term-vectorize'
import { compressWithSummary } from '../memory/compressor'
 
export async function runAgentWithMemory(
  userInput: string,
  userId: string,
  env: AppEnv['Bindings'],
) {
  // 1. 从长期记忆检索与当前输入相关的历史
  const relevantMemories = await retrieveMemories(env, userId, userInput, 5)
  const memoryContext = relevantMemories.length > 0
    ? `\n用户历史信息:\n${relevantMemories.map((m) => `- ${m.content}`).join('\n')}`
    : ''
  const systemPrompt = `你是一个智能助手。${memoryContext}`
 
  // 2. 初始化短期记忆
  const shortTerm = new ShortTermMemory(systemPrompt, 6000)
  shortTerm.addMessage({ role: 'user', content: userInput })
 
  // 3. Agent 执行循环
  for (let i = 0; i < 10; i++) {
    const workingMemory = buildWorkingMemory(
      { maxTokens: 8000, systemPromptTokens: 500, toolSchemaTokens: 800, reserveForOutput: 2000 },
      systemPrompt,
      shortTerm.getMessages().slice(1),
      '',
    )
 
    const response = await callLLM(workingMemory)
 
    if (response.toolCalls.length === 0) {
      // 任务完成,把关键信息存入长期记忆
      await storeMemory(env, userId, `请求:${userInput}\n结果:${response.text}`)
      return response.text
    }
 
    shortTerm.addMessage({ role: 'assistant', content: response.text, toolCalls: response.toolCalls })
    for (const toolCall of response.toolCalls) {
      const result = await executeTool(toolCall)
      shortTerm.addMessage({ role: 'tool', toolCallId: toolCall.id, content: JSON.stringify(result) })
    }
 
    // 每 5 轮压缩一次短期记忆
    if (i > 0 && i % 5 === 0) {
      const compressed = await compressWithSummary(shortTerm.getMessages(), 6000, 6)
      shortTerm.clear()
      compressed.forEach((msg) => shortTerm.addMessage(msg))
    }
  }
 
  return 'Agent 已达到最大执行轮次。'
}

三层记忆的配合方式:

  1. 长期记忆在循环开始前检索,把相关历史信息注入 system prompt
  2. 工作记忆在每轮循环中组装,控制送入 LLM 的上下文大小
  3. 短期记忆在整个循环中持续积累,定期压缩防止超限

任务结束后关键信息写入长期记忆,下一轮对话就能用到。

总结

回顾一下这篇的要点:

  • Agent 的记忆完全依赖外部代码管理,LLM 本身在调用之间没有记忆
  • 短期记忆是当前任务的对话历史,加上 token 计数和截断策略后防止上下文溢出
  • 工作记忆从短期记忆中挑选和组织后送入 LLM,需要为 system prompt、工具定义和模型输出预留空间
  • 长期记忆分两种存储:KV 存结构化事实,向量数据库按语义检索非结构化历史
  • 记忆压缩三种策略:滑动窗口(简单直接)、摘要替换(保留关键信息)、重要性打分(精细但复杂)
  • 三层记忆各有分工:短期积累原始素材,工作记忆控制每次送入模型的内容,长期记忆跨会话保持知识

下一篇来看 Agent 的错误处理与恢复机制——工具调用失败、LLM 返回异常结果时,怎样让 Agent 有策略地重试和降级。