给 Agent 加记忆:截断、总结与向量检索三种方案怎么取舍

我做多轮对话时最早的"记忆"实现,就是把所有消息放进数组,每次请求都完整传给模型。对话少时没有问题;轮次一多,token 开销持续上涨,旧内容开始挤占上下文窗口,真正相关的信息反而被淹没。

大模型本身是无状态的。所谓 Memory(记忆),本质上是应用替模型完成两件事:保存历史,以及决定本轮把哪些历史重新放进上下文。

我依次实现了三种策略:

  1. 截断:只保留最近消息;
  2. 总结:把较老对话压缩成摘要;
  3. 检索:把历史写入向量数据库,需要时按语义找回。

这三种方式不是互相替代,而是成本与能力逐级增加。

1. 消息条数截断:最便宜,也最粗糙

最直接的做法是保留最近 N 条消息:

js 复制代码
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
import { HumanMessage, AIMessage } from "@langchain/core/messages";

const history = new InMemoryChatMessageHistory();

await history.addMessage(new HumanMessage("我叫李四"));
await history.addMessage(new AIMessage("你好李四"));
// ...继续加入对话

const allMessages = await history.getMessages();
const recentMessages = allMessages.slice(-4);

实际运行时,8 条消息最后保留了 4 条。

它的优点很明显:不调用额外模型、不需要数据库、行为容易预测。问题也同样明显:消息长度差异很大。"保留 4 条"可能只有几十个 token,也可能包含一段很长的工具输出,仍然把窗口撑满。

因此,消息条数适合原型或消息长度比较稳定的场景,不适合作为严格的上下文预算。

2. 按 token 截断:从条数升级为预算

模型限制和计费都围绕 token,而不是消息条数。这里使用 js-tiktoken 计算内容长度,再交给 LangChain 的 trimMessages:

js 复制代码
import { trimMessages } from "@langchain/core/messages";
import { getEncoding } from "js-tiktoken";

const encoder = getEncoding("cl100k_base");

function countTokens(messages) {
  return messages.reduce((total, message) => {
    const content =
      typeof message.content === "string"
        ? message.content
        : JSON.stringify(message.content);
    return total + encoder.encode(content).length;
  }, 0);
}

const trimmed = await trimMessages(allMessages, {
  maxTokens: 100,
  tokenCounter: async (messages) => countTokens(messages),
  strategy: "last",
});

这个示例本地保留了 4 条消息,共计算出 78 token。数字只代表这组固定示例和所选编码器,我不会把它外推成任何性能结论。

按 token 截断比按条数准确,但仍有两个坑:

  • 编码器必须与实际模型尽量匹配,否则预算只是近似;
  • 直接从中间切断,可能丢掉 system message、tool call 与 tool result 的配对关系。

生产实现需要明确哪些消息必须保留,并保证工具消息结构合法。

3. 总结式记忆:让旧消息留下"压缩版本"

只截断会永久丢失旧信息。总结式记忆(Summarization Memory)会保留最近对话,把更老的部分交给模型压缩成摘要:

js 复制代码
import {
  HumanMessage,
  SystemMessage,
  getBufferString,
} from "@langchain/core/messages";

async function summarizeHistory(messages, model) {
  if (messages.length === 0) return "";

  const conversationText = getBufferString(messages, "用户", "助手");
  const response = await model.invoke([
    new HumanMessage(`
请总结以下对话,保留用户身份、偏好、承诺和未完成事项:

${conversationText}
`),
  ]);

  return response.content;
}

const keepRecent = 2;
const recent = allMessages.slice(-keepRecent);
const oldMessages = allMessages.slice(0, -keepRecent);
const summary = await summarizeHistory(oldMessages, model);

await history.clear();
await history.addMessage(new SystemMessage(`历史摘要:${summary}`));
for (const message of recent) {
  await history.addMessage(message);
}

方案 A 是简单截断,成本为零但信息直接消失;方案 B 是总结,增加一次模型调用,换来更长时间跨度的连续性。

总结也不是无损压缩。模型可能遗漏数字、姓名或约束,多次"摘要的摘要"还会不断累积偏差。因此重要业务事实不应该只存在自然语言摘要里,应该提取为明确字段,例如:

js 复制代码
{
  userProfile: {
    name: "李四",
    occupation: "设计师",
    interests: ["艺术", "音乐"]
  },
  pendingTasks: ["补充作品集链接"]
}

我还尝试过按 token 进一步划分"保留区"和"总结区",但初版出现了未定义变量和作用域问题。这里采用更完整、边界更清楚的第一版总结流程。

4. 检索式记忆:不是每轮都带上全部历史

长时记忆的另一个思路,是把每轮对话向量化后存入向量数据库。新问题到来时,只检索语义相关的几轮,而不是把所有历史都发给模型。

检索式记忆使用 Milvus,集合同时保存向量和对话元数据:

js 复制代码
await client.createCollection({
  collection_name: "conversations",
  fields: [
    {
      name: "id",
      data_type: DataType.VarChar,
      max_length: 50,
      is_primary_key: true,
    },
    {
      name: "vector",
      data_type: DataType.FloatVector,
      dim: 1024,
    },
    {
      name: "content",
      data_type: DataType.VarChar,
      max_length: 5000,
    },
    { name: "round", data_type: DataType.Int64 },
    {
      name: "timestamp",
      data_type: DataType.VarChar,
      max_length: 100,
    },
  ],
});

await client.createIndex({
  collection_name: "conversations",
  field_name: "vector",
  index_type: IndexType.IVF_FLAT,
  metric_type: MetricType.COSINE,
});

插入时,先把一轮问答拼成文本,再生成 Embedding(嵌入向量):

js 复制代码
const conversationText =
  `用户:${input}\n助手:${response.content}`;

const vector = await embeddings.embedQuery(conversationText);

await client.insert({
  collection_name: "conversations",
  data: [{
    id: crypto.randomUUID(),
    content: conversationText,
    vector,
    round,
    timestamp: new Date().toISOString(),
  }],
});

下一次提问时,用同一个 Embedding 模型生成查询向量:

js 复制代码
async function retrieveRelevantConversations(query, k = 2) {
  const queryVector = await embeddings.embedQuery(query);

  const result = await client.search({
    collection_name: "conversations",
    vector: queryVector,
    limit: k,
    metric_type: MetricType.COSINE,
    output_fields: ["id", "content", "round", "timestamp"],
  });

  return result.results;
}

这里有一个必须补上的业务边界:原始集合没有 user_id 或租户字段。真实系统必须先按用户、会话或组织过滤,再做向量检索,否则不同用户的记忆可能互相泄漏。

5. 三种方案怎么组合

三种记忆策略解决的时间尺度不同:

策略 适合保存 主要成本 主要风险
最近消息截断 当前几轮的精确上下文 token 老信息被直接丢弃
历史总结 中期对话脉络 额外模型调用 摘要遗漏、误差累积
向量检索 跨会话长期记忆 Embedding、数据库、检索 召回错误、权限隔离

我更愿意把它们组合成三层:

  1. 最近消息原样保留;
  2. 超出窗口的旧消息形成滚动摘要;
  3. 值得长期保存的事实和对话写入数据库,按需检索。

这比"所有历史都向量化"更稳。最近两轮对话如果还要先检索才能找到,既增加延迟,也可能因为相似度不高而丢失上下文。

6. Docker Compose 只是基础设施,不是记忆逻辑

本地环境通过 Compose 启动 Milvus、etcd 和 MinIO,凭据全部改为环境变量:

yaml 复制代码
services:
  minio:
    image: minio/minio:<VERSION>
    environment:
      MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-<REDACTED>}
      MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-<REDACTED>}

  standalone:
    image: milvusdb/milvus:<VERSION>
    environment:
      ETCD_ENDPOINTS: etcd:2379
      MINIO_ADDRESS: minio:9000
    ports:
      - "19530:19530"

这部分依赖 Docker 和外部 Embedding 服务,运行前还需要根据自己的环境补齐配置。示例中的敏感值均已脱敏。

结尾

Agent Memory 不是"把聊天记录存下来"这么简单,关键在于每轮如何选择上下文:

  • 先用 token 预算控制最近消息;
  • 用摘要延长对话连续性,但不要把关键事实只交给自然语言摘要;
  • 用向量检索找回跨会话信息,并把用户隔离放在相似度之前;
  • 最近上下文、摘要和长期记忆分层处理,不要只押一种方案;
  • 对摘要与检索都保留失败出口,信息不足时让模型明确说不知道。

下一步可以把长期记忆从独立 Milvus 迁到 PostgreSQL + pgvector,让用户、会话、消息和向量处在同一个事务与权限模型里。第七篇会继续这个方向。

相关推荐
AI袋鼠帝1 小时前
WorkBuddy悄悄干了件大事,下一代Office真来了!
人工智能·agent
浮链序1 小时前
用 Claude Haiku 5.5 做子智能体路由,把 Agent 成本砍掉六成
人工智能·python·llm
Soofjan1 小时前
Agent基础(3):提示工程、采样参数与 Token
llm·agent
Soofjan2 小时前
Agent基础(1):组成、运行机制与幻觉处理
llm·agent
hpoenixf2 小时前
工具查到的数据,为什么不一定要给大模型看?
agent
吴佳浩2 小时前
终章实战|300 行纯 Python 代码,手把手带你手搓一个生产级 Coding Agent
人工智能·agent
阿里云云原生2 小时前
阿里云 AgentBridge:为生产级 Agent 构建多源实时上下文
agent
万联WANFLOW2 小时前
从Agent到Agentic Collaboration:企业AI工作流背后的技术架构
llm·agent·mcp
孟健2 小时前
200 美元订阅实测:从 Agent 吞吐与缓存机制,算清 Claude 与 OpenAI 的算力账
人工智能·llm·ai编程