告别 Agent 失忆:用截断、总结与 Milvus 构建生产级 Memory 模块

作者:袖梨 2026-09-14

随着对话轮次持续增加,Agent 的消息历史迟早会超过模型上下文窗口。简单删除旧消息虽然能控制 Token 消耗,却可能连同用户身份和关键偏好一起丢失。要让智能体保持稳定记忆,需要把截断、摘要压缩与按需检索组合起来,并处理消息角色、Token 预算和长期存储等工程细节。

做聊天应用最怕什么?

用户跟 AI 聊了半小时,说了自己叫李四、是设计师、喜欢 UI/UX,结果下一句问"我适合学什么?"——AI 回你一句:"请问您是谁?"

没有 Memory 的 Agent,就像一个每次见面都要重新自我介绍的社恐。

今天这篇文章,我们就以开发 codex 这个聊天应用为背景,把 Agent Memory 的三大核心手段——截断(Truncation)、总结(Summarization)、检索(Retrieval) ——从思路到代码,一次性讲透。最后你会发现,Harness 的 Memory 模块本质上就是这三件事的组合拳。


一、Memory 到底在解决什么问题?

LLM 的上下文窗口是有限的(哪怕是 128K 也不是无限的),而一次会话的消息会越来越多。摆在面前的只有三条路:

  1. 截断:扔旧的,留新的 —— 简单粗暴,但会丢信息
  2. 总结:把旧的压成摘要 —— 有损压缩,但保留主线
  3. 检索:把全量历史存起来,需要时再捞 —— 无损,但要额外基础设施

一句话总结:截断是"断舍离",总结是"做笔记",检索是"翻档案"。

生产环境的 Memory 模块,一定是三者的组合。下面逐个拆。


二、截断:最简单也最容易翻车的方案

2.1 按消息条数截断(入门版)

最朴素的思路:保留最近 N 条消息。

import { InMemoryChatMessageHistory } from '@langchain/core/ch@t_history';
import { HumanMessage, AIMessage } from '@langchain/core/messages';

async function messageCountTruncation() {
  const history = new InMemoryChatMessageHistory();
  const maxMessages = 4;
  const messages = [
    { type: 'human', content: '我叫李四' },
    { type: 'ai', content: '你好李四,很高兴认识你!' },
    { type: 'human', content: '我是一名设计师' },
    { type: 'ai', content: '设计师是个很有创造力的职业!' },
    { type: 'human', content: '我喜欢艺术和音乐' },
    { type: 'ai', content: '艺术和音乐都是很好的爱好。' },
    { type: 'human', content: '我擅长 UI/UX 设计' },
    { type: 'ai', content: 'UI/UX 设计非常重要!' },
  ];

  for (const msg of messages) {
    if (msg.type === 'human') {
      await history.addMessage(new HumanMessage(msg.content));
    } else {
      await history.addMessage(new AIMessage(msg.content));
    }
  }

  // invoke 之前截断
  const allMessages = await history.getMessages();
  const trimmedMessages = allMessages.slice(-maxMessages);
  console.log(`保留消息数量:${trimmedMessages.length}`);
}

踩坑点:如果你 slice(-4),很可能把"用户说'我叫李四'"这条扔掉了,但留下了"AI 说'你好李四'"——上下文就串了。所以按条数截断时,一定要按"轮次"(human + ai 一对)截断,而不是单条。

顺带说一句:为什么每次 addMessage 都要 new 一个 Message?

刚接触 LangChain 的人经常会疑惑:{ type: 'human', content: '我叫李四' } 明明是个普通对象,为什么不能直接塞给 history,非要 new HumanMessage(...)

因为 HumanMessage / AIMessage / SystemMessage,不是普通对象。它们内部携带了 role_getType()getType()_combineTokens() 等方法,模型调用链和 history 都靠这些方法识别消息角色。

  • 必须 new 的场景:从外部数据(JSON、DB 字段、用户输入)构造消息时,一定要包一层类,否则 LLM 拿到的消息角色是"未定义"。
  • 不用 new 的场景:从 history 里 getMessages() 拿出来、或者 slice() 截出来的消息,本身就是对应类的实例,可以直接 addMessage

所以你会看到总结代码里这么写:

const recentMessages = allMessages.slice(-keepRecent);
// 下面这行不用 new,因为它已经是 message 实例了
for (const msg of recentMessages) await history.addMessage(msg);

如果你手贱写 history.addMessage(new HumanMessage(msg.content)),反而会把 AI 消息的 role 冲掉变成 human——这类 bug 排查起来最烦,因为它不报错,只是回答越来越离谱

2.2 按 Token 截断(生产版)

真实场景下,消息长短差别巨大。一条 2000 字的 prompt 和一条 "嗯",token 开销天差地别。按条数截断根本不靠谱。

LangChain 提供了 trimMessages,配合 js-tiktoken 做精确 token 计算:

import { trimMessages } from '@langchain/core/messages';
import { getEncoding } from 'js-tiktoken';

function countTokens(messages, encoding) {
  let total = 0;
  for (const msg of messages) {
    const content = typeof msg.content === 'string' 
      ? msg.content 
      : JSON.stringify(msg.content);
    total += encoding.encode(content).length;
  }
  return total;
}

async function tokenCountTruncation() {
  const history = new InMemoryChatMessageHistory();
  // ... 填充消息同上 ...
  const allMessages = await history.getMessages();
  const enc = getEncoding('cl100k_base'); // 编码

  console.log(`所有消息的总token数:${countTokens(allMessages, enc)}`);

  const trimmedMessages = await trimMessages(allMessages, {
    maxTokens: 150,
    // 不同 LLM token 计算方式不一样,交给你自己决定
    tokenCounter: async (msgs) => countTokens(msgs, enc),
    strategy: 'last', // 从最后一条开始,往前留
  });

  console.log(`截断后 token:${countTokens(trimmedMessages, enc)}`);
}

那个 typeof msg.content === 'string' 三元是干嘛的?

msg.content 在 LangChain 里的类型是 string | MessageContentComplex[]

  • 纯文本消息content 就是字符串。
  • 多模态消息content 是数组,形如 [{ type: 'text', text: '...' }, { type: 'image_url', image_url: {...} }]

js-tiktokenencoding.encode() 只接受字符串,你直接扔数组进去它会抛类型错误。所以这里做个判断:

  • 是字符串 → 直接用,最准。
  • 是数组 → JSON.stringify 拍成字符串,算个近似值

严格来说,多模态消息里图片的 token 跟文本根本不是一个算法(图有自己一套 patch 计算方式),但作为上下文预算的估算,JSON.stringify 已经够用了。记住一句话:token 计算不是会计对账,而是做预算,精度到 5% 就够了。

trimMessages 内部到底怎么截的?

trimMessages 的核心不是"从头或从尾简单切",而是围绕"保留哪些消息"做二分查找

为什么能二分?因为后 k 条消息的 token 总量,关于 k 是单调递增的——k 越大,累积 token 越多,不存在"多加一条反而变少"的情况。单调 → 可以二分。

举个例子。假设从旧到新有 5 条消息,每条 token 如下:

A(30)  B(50)  C(40)  D(60)  E(20)
maxTokens = 100,strategy = 'last'

我们要找的是最大的 k,使得"最后 k 条"的总 token ≤ 100。

  • k = 2 → 后 2 条 = D + E = 80 ≤ 100 ✓(还能更大,往右搜)
  • k = 3 → 后 3 条 = C + D + E = 120 > 100 ✗(超了,往左搜)
  • k = 2 → 确定

最终保留 [D, E] ,共 80 tokens。

整个过程只需要 log₂(5) ≈ 3 次 token 计算,而不是"一条一条往前加,直到爆掉"的 O(n) 次。当你 history 有几百条消息、且每条 token 计算都要跑 tiktoken 时,这个差距就是几十毫秒 vs 几百毫秒

几个关键点:

  • cl100k_base 是 OpenAI GPT-3.5/4 系列用的编码。用国产模型(Qwen、GLM)时,编码器要换成对应的(或者用 tiktoken 的近似版)。
  • strategy: 'last' 表示从最新往旧数,凑够 maxTokens 就停。也有 'first'
  • 一定要在 invoke 之前截断,不能等模型报 context length 错误才处理。

截断只能救急,不能救命。真正丢失的"我是李四"这类关键信息,是截断永远补不回来的。


三、总结:让 AI 自己做笔记

截断的痛点很明确:丢信息。那能不能让 AI 把旧消息"压一压"再留着?

能。这就是 Summarization Memory。

3.1 基础版:超过 N 条就总结

思路很直接:

  1. 检查消息条数
  2. 超过阈值 → 把老消息交给 LLM 总结成一段话
  3. clear history,塞回"最近消息 + 摘要"
import { ChatOpenAI } from '@langchain/openai';
import { getBufferString, SystemMessage, AIMessage } from '@langchain/core/messages';

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  temperature: 0,
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
});

async function summarizeHistory(messages) {
  if (messages.length === 0) return '';
  // 消息数组 -> 带角色前缀的字符串
  const conversationText = getBufferString(messages, '用户', '助手');
  
  const summaryPrompt = `请总结以下对话的核心内容,保留重要信息:
${conversationText}
总结:`;

  const summaryResponse = await model.invoke([new SystemMessage(summaryPrompt)]);
  return summaryResponse.content;
}

async function summarizationMemoryDemo() {
  const history = new InMemoryChatMessageHistory();
  const maxMessages = 6;
  const keepRecent = 2;
  // ... 填充消息 ...

  const allMessages = await history.getMessages();

  if (allMessages.length > maxMessages) {
    const recentMessages = allMessages.slice(-keepRecent);
    const messagesToSummarize = allMessages.slice(0, -keepRecent);

    const summary = await summarizeHistory(messagesToSummarize);

    await history.clear();
    for (const msg of recentMessages) await history.addMessage(msg);
    await history.addMessage(new AIMessage(summary)); // 把摘要塞回去
  }
}

注意 getBufferString(messages, '用户', '助手') 这个工具——它把消息数组拼成带角色标签的字符串,省得你手动 map + join

这里 history.addMessage(msg)new,因为 recentMessages 已经是消息实例;而 new AIMessage(summary) 必须 new,因为 summarymodel.invoke 返回的纯字符串,得手动包成 AI 消息塞回 history。

判断标准就一条:你手上拿到的是"字符串 / 普通对象"还是"消息实例"。前者必须 new,后者别多此一举。

3.2 进阶版:按 Token 触发总结

按条数触发的问题是:可能这 6 条都很短,没必要总结;也可能 3 条就把 4K 窗口挤爆了。

改成按 token 触发:

const encoder = getEncoding('cl100k_base');
const maxTokens = 200;    // 超过就触发总结
const keepTokens = 80;    // 最近消息至少保留多少 token

const totalTokens = countTokens(allMessages, encoder);
if (totalTokens >= maxTokens) {
  const recentMessages = [];
  let recentTokens = 0;

  // 从最新一条往前倒推,凑够 keepTokens
  for (let i = allMessages.length - 1; i >= 0; i--) {
    const msg = allMessages[i];
    const msgTokens = countTokens([msg], encoder);
    if (recentTokens + msgTokens <= keepTokens) {
      recentMessages.unshift(msg);
      recentTokens += msgTokens;
    } else {
      break;
    }
  }

  const messagesToSummarize = allMessages.slice(
    0, allMessages.length - recentMessages.length
  );
  const summary = await summarizeHistory(messagesToSummarize);

  await history.clear();
  for (const msg of recentMessages) await history.addMessage(msg);
  await history.addMessage(new AIMessage(summary));
}

这套逻辑就是 Claude Code 里 /compact 命令的雏形——把老的上下文压缩成摘要,留最近几轮保证连贯性

总结的本质是"有损压缩":你丢掉了细节,但保住了主线。李四还是李四,只是"喜欢艺术和音乐"这种边角料可能被压掉。


四、检索:Milvus 让 Memory 永远"在线"

总结也有极限。如果用户跟 AI 聊了 500 轮,摘要越滚越大,最后摘要本身也塞不进 context。

终极方案:向量数据库。

把所有对话历史 embedding 后存进 Milvus,需要时再按语义检索。这样:

  • 上下文窗口只放"当前问题 + 检索到的相关历史"
  • 历史永不丢失,可以检索过去任何一轮
  • 跨会话也能用(比如用户下次开新会话,还能找到上次提到的项目)

4.1 建集合 & 插入对话

import { MilvusClient, DataType, MetricType, IndexType } from '@zilliz/milvus2-sdk-node';
import { OpenAIEmbeddings } from '@langchain/openai';

const COLLECTION_NAME = 'conversations';
const VECTOR_DIM = 1024;

const embeddings = new OpenAIEmbeddings({
  apiKey: process.env.OPENAI_API_KEY,
  model: 'text-embedding-v3',
  configuration: { baseURL: process.env.OPENAI_BASE_URL },
  dimension: VECTOR_DIM,
});

const client = new MilvusClient({ address: 'localhost:19530' });

async function main() {
  await client.connectPromise;

  // 1. 建集合
  await client.createCollection({
    collection_name: COLLECTION_NAME,
    fields: [
      { name: 'id', data_type: DataType.VarChar, max_length: 50, is_primary_key: true },
      { name: 'vector', data_type: DataType.FloatVector, dim: VECTOR_DIM },
      { name: 'content', data_type: DataType.VarChar, max_length: 5000 },
      { name: 'round', data_type: DataType.Int64 },
      { name: 'timestamp', data_type: DataType.VarChar, max_length: 100 },
    ],
  });

  // 2. 建索引(向量字段一定要建)
  await client.createIndex({
    collection_name: COLLECTION_NAME,
    field_name: 'vector',
    index_type: IndexType.IVF_FLAT,
    metric_type: MetricType.COSINE,
  });

  // 3. 加载到内存才能查
  await client.loadCollection({ collection_name: COLLECTION_NAME });

  // 4. 插入数据(先 embedding 再 insert)
  const conversations = [
    { id: 'conv_001', content: '用户:我叫赵六,是一名数据科学家n助手:很高兴认识你!', round: 1, timestamp: new Date().toISOString() },
    { id: 'conv_002', content: '用户:我最近在研究机器学习算法n助手:机器学习确实很有意思。', round: 2, timestamp: new Date().toISOString() },
    // ...
  ];

  const data = await Promise.all(
    conversations.map(async (conv) => ({
      ...conv,
      vector: await embeddings.embedQuery(conv.content),
    }))
  );

  await client.insert({ collection_name: COLLECTION_NAME, data });
}

几个实践提醒:

  • Milvus 没有 DateTime 类型,时间戳用 VarChar 存 ISO 字符串,省事。
  • 主键用 UUID / 时间戳_序号,别用自增 int(并发场景容易撞)。
  • contentmax_length 要留足,一次对话两三轮拼接很容易破千。
  • 索引类型:小数据量用 IVF_FLAT 够;数据量上百万后再考虑 HNSW

new Date().toISOString() 到底返回什么?

一行代码,但值得展开讲两句。

toISOString() 返回的是 ISO 8601 标准格式,形如:

2025-01-15T08:30:00.000Z

拆开看:

  • 2025-01-15 → 日期
  • T → 日期和时间之间的分隔符
  • 08:30:00.000 → 时间(精确到毫秒)
  • ZZulu time,代表 UTC 时区。也就是说不管你在北京、纽约还是伦敦,这行代码跑出来都是同一个 UTC 时间点。

为什么大家喜欢用它存时间?

  1. 字符串字典序 = 时间顺序'2025-01-15T08:30:00.000Z' < '2025-01-15T09:00:00.000Z' 直接成立,排序、范围查询都能当字符串比,Milvus 里用 VarChar 存起来毫无压力。
  2. 跨时区无歧义。前端展示时自己 new Date(str).toLocaleString() 转本地时间即可,不会像 '2025-01-15 08:30:00' 这种本地时间字符串一样出现"到底是哪个时区"的扯皮。
  3. 前后端通吃。JS、Python、Go 都能原生解析 ISO 8601,序列化传输零成本。

反例:别用 new Date().toString() 存 Milvus,它返回的是 Wed Jan 15 2025 08:30:00 GMT+0800 (中国标准时间) 这种人类可读但机器难处理的格式,排序会失效,且带时区后缀解析麻烦。

4.2 检索 + 拼上下文

每次用户提问时,先用问题去 Milvus 里捞 top-k 相关历史:

async function retrieveRelevantConversations(query, k = 2) {
  const queryVector = await embeddings.embedQuery(query);
  const searchResult = await client.search({
    collection_name: COLLECTION_NAME,
    vector: queryVector,
    limit: k,
    metric_type: MetricType.COSINE,
    output_fields: ['id', 'content', 'round', 'timestamp'],
  });
  return searchResult.results;
}

async function retrievalMemoryDemo() {
  await client.connectPromise;

  const history = new InMemoryChatMessageHistory();
  const conversations = [
    { input: '我之前提到的机器学习项目进展如何?' },
    { input: '我周末经常做什么?' },
    { input: '我的职业是什么?' },
  ];

  for (let i = 0; i < conversations.length; i++) {
    const { input } = conversations[i];
    const userMessage = new HumanMessage(input);

    // 1. 检索相关历史
    const retrieved = await retrieveRelevantConversations(input, 2);
    const relevantHistory = retrieved.length
      ? retrieved.map((conv, idx) => 
          `[历史对话 ${idx + 1}]n轮次:${conv.round}n${conv.content}`
        ).join('nn------nn')
      : '';

    // 2. 拼上下文
    const contextMessages = relevantHistory
      ? [new HumanMessage(`相关历史对话:n${relevantHistory}nn用户问题: ${input}`)]
      : [userMessage];
    contextMessages.push(userMessage);

    // 3. 调用 LLM
    const response = await model.invoke(contextMessages);

    // 4. 新对话也写回 Milvus,形成闭环
    const conversationText = `用户: ${input}n助手: ${response.content}`;
    const convId = `conv_${Date.now()}_${i + 1}`;
    const vector = await embeddings.embedQuery(conversationText);

    await client.insert({
      collection_name: COLLECTION_NAME,
      data: [{
        id: convId,
        content: conversationText,
        vector,
        round: i + 1,
        timestamp: new Date().toISOString(),
      }],
    });

    await history.addMessage(response);
  }
}

这套流程就是 RAG 的经典范式:检索 → 拼上下文 → 生成 → 落库。区别只在于检索源是"对话历史"而不是"知识文档"。

检索 Memory 的核心价值:让 AI 有能力"想起"具体某一轮对话,而不是只记得一段模糊的摘要。


五、把三招合起来:codex 的 Memory 模块设计

回到文章开头那个"聊了半小时就失忆"的场景。真实的 codex 会这么做:

┌─────────────────────────────────────────────┐
│  用户新消息进来                              │
└──────────────┬──────────────────────────────┘
               ▼
┌─────────────────────────────────────────────┐
│  1. 计算当前 history 的 token 总量           │
└──────────────┬──────────────────────────────┘
               ▼
     ┌─────────┴─────────┐
     │ 超阈值?           │
     ▼                   ▼
   是                   否
     │                   │
     ▼                   ▼
┌──────────────────┐  ┌──────────────────┐
│ 触发 Summary     │  │ 直接拼上下文     │
│ (老消息→摘要)    │  │                  │
└────────┬─────────┘  └────────┬─────────┘
         │                     │
         ▼                     ▼
┌─────────────────────────────────────────────┐
│  2. 用当前问题检索 Milvus(top-k 相关历史)  │
└──────────────┬──────────────────────────────┘
               ▼
┌─────────────────────────────────────────────┐
│  3. 拼装最终 context:                       │
│     SystemPrompt                             │
│     + 历史摘要                               │
│     + Milvus 检索到的相关历史                │
│     + 最近 k 轮原文                          │
│     + 当前用户消息                           │
└──────────────┬──────────────────────────────┘
               ▼
         调用 LLM 生成回答
               │
               ▼
┌─────────────────────────────────────────────┐
│  4. 新对话写回 Milvus,形成闭环              │
└─────────────────────────────────────────────┘

每 20 条触发一次总结就是你笔记里的策略——这很合理:

  • 太频繁(比如每 5 条)→ summary 调用成本高,且每次压缩都损失一点信息
  • 太稀疏(比如每 100 条)→ context 早就爆了
  • 20 条大约是 10 轮对话,差不多对应"话题要切换"的粒度

六、几个血泪踩坑点

最后,把笔记里那些容易翻车的点集中讲一下:

1. slice(-N) 按条数截断,永远不靠谱。
长短消息混在一起,token 分布完全非线性。生产一定用 trimMessages + js-tiktoken

2. getBufferString 的角色标签要显式传。
默认是 "Human" / "Assistant",中文场景传 ('用户', '助手') 更自然,LLM 理解也更稳。

3. 别乱 new message。
从 history 里拿出来的已经是实例,直接 addMessage 就好;只有从字符串/JSON/DB 字段构造时才需要 new。乱 new 会把角色搞乱,且不报错,非常隐蔽。

4. msg.content 未必是字符串。
多模态场景下它是数组,tiktoken.encode 会直接抛错,所以要用 typeof ... === 'string' ? ... : JSON.stringify(...) 兜一下。

5. Milvus 的 loadCollection 千万别漏。
建了集合、建了索引、插了数据,但没 loadCollectionsearch 会告诉你"collection not loaded"。这个坑我踩过两次。

6. embedding 的 dimension 必须和集合的 dim 一致。
text-embedding-v3 支持多种维度,OpenAIEmbeddings 初始化时的 dimensioncreateCollection 里的 dim 不一致,插数据会直接报错。

7. 写回 Milvus 的时候,一定要写"用户 + 助手"的完整对话。
只写用户问题,后面检索语义会偏——因为用户的短问题通常缺少"上一轮 AI 回复"里的关键实体信息。

8. 摘要不是越详细越好。
temperature: 0 + "保留重要信息"这种模糊 prompt,会让 LLM 倾向于复述。更稳的模板是给几条硬规则,比如:"保留姓名、职业、偏好、未完成的任务,其余压缩"。

9. 时间戳用 new Date().toISOString(),别用 toString()
ISO 8601 带 Z 后缀,跨时区无歧义,字典序即时间序;toString() 的本地化格式在 Milvus VarChar 里排序会乱套。


七、总结

一句话回顾三种 Memory 手段:

手段本质优势代价
Truncation丢旧留新零成本、零延迟丢信息
Summarization有损压缩保留主线、成本可控丢细节、需 LLM 调用
Retrieval按需召回无损、可跨会话需向量库、检索质量依赖 embedding

Agent 不是靠"记住所有"变强的,而是靠"在对的时候想起对的事"变强的。

Codex、Claude Code、Cursor 这些工具的 Memory 模块,说穿了就是这三招 + 一次精心的上下文编排。你不用一次全上,可以从"按 token 截断 + 定时总结"起步,等量级上来了再补 Milvus。

下一次你的 Agent 答非所问的时候,先别骂模型——大概率是它真的忘了。

相关文章

精彩推荐