给 AI 增加记忆:历史截断、对话总结与检索该如何选择

作者:袖梨 2026-09-19

让聊天应用记住前文,最直接的做法是把历史消息重新放进每次请求,但对话一长,上下文窗口和 token 成本就会迅速成为限制。此时需要决定哪些内容保留、哪些内容压缩,以及怎样找回更早的关键信息。下面从截断、总结和向量检索三种策略入手,比较它们的实现方式与成本边界。

给 AI 补记性,别只会把历史一股脑塞 prompt:截断、总结、检索三条路怎么选

多轮对话做到第二轮就会发现:模型根本不记得上一句。解法也很直白——把历史消息数组每次都塞回上下文。但第三轮、第十轮之后,历史越攒越多,上下文窗口有限、token 有成本,"无脑全量塞"这条路走不远。真正影响体验的是超限后的处理策略:截断、总结、检索三条路各有一套成本模型。本文基于一份 LangChain Memory 学习项目(9 个源码文件),把三条路各自的最小实现拆给你看,最后一并给出 Milvus 检索式记忆的完整读写闭环。代码静态整理、运行未验证,Milvus 需本地 localhost:19530,模型依赖 .env 的 API_KEY。

先看清前提:记忆 = 每次重发历史

笔记里一句话戳破本质(readme.md L8):"大模型是无状态的,基于上次的问答继续问,回答。" 所以"记忆"的实现就是消息数组的维护循环(src/history.mjs):

const history = new InMemoryChatMessageHistory()
await history.addMessage(new HumanMessage("你今天吃什么?"))
const messages = [systemMessage, ...(await history.getMessages())]
const response = await model.invoke(messages)
await history.addMessage(response)   // AI 回复也回存

这是临时记忆:进程内存,重启即丢。换成文件就是长期记忆(src/history2.mjs):

import { FileSystemChatMessageHistory } from '@langchain/community/stores/message/file_system'
const history = new FileSystemChatMessageHistory({
  filePath: path.join(process.cwd(), 'ch@t_history.json'),
  sessionId: "user_session_001",   // 区分多用户
})

注意导入路径是 @langchain/community 而非 core——材料作者踩过这个坑(从 core 导入报 not exported)。history3.mjs 验证了持久化:同参数新建实例,getMessages() 恢复历史,第三轮对话无缝接上。

路线一:截断——极低成本,但会腰斩对话

两档粒度(src/memory/truncation-memory.mjs):

// 按条数:一行 slice
const trimmedMessages = allMessages.slice(-4)

// 按 token:langchain 的 trimMessages
const enc = getEncoding('cl100k_base')
const trimmedMessages = await trimMessages(allMessages, {
  maxTokens: 100,
  tokenCounter: async (messages) => countTokens(messages, enc),
  strategy: 'last',   // 留最近的
})

源码注释点出关键:"不同模型的 token 计算方式不一样",所以 tokenCounter 要按模型定制(这里用 js-tiktoken 的 cl100k_base 逐条 encode 累加)。截断的问题也直观:slice 可能把"我叫李四"和"你好李四"这对问答拆开,上下文被腰斩。

路线二:总结——多花一次调用,换信息保留

被截掉的老消息不该直接扔,可以让模型先消化一遍(src/memory/summarization-memory.mjs,超 6 条触发、留最近 2 条):

// ① 老消息数组 → 对话字符串
const conversationText = getBufferString(messagesToSummarize, '用户', '助手')
// ② 让模型总结
const summary = `请总结以下对话的核心内容,保留重要消息:${conversationText}n总结:`
const summaryResponse = await model.invoke([new SystemMessage(summary)])
// ③ 清空 → ④ 回存最近消息 → ⑤ 回存摘要
await history.clear()
for (const message of recentMessages) await history.addMessage(message)
await history.addMessage(new AIMessage(summaryResponse.content))  // 摘要作为 AIMessage

源码注释里两个值得背的细节:

  • model.invoke() 不收裸字符串——只收消息对象数组,每条必须标明"谁说的"。
  • token 版(summarization-memory2.mjs)用 maxTokens=200 触发,逆序累积凑满 keepRecentTokens=80 的最近消息;注释把 clear / 回存摘要对应到 /clear/compact

三条路的成本模型:

策略触发成本信息保留实现
截断条数/token 超限极低丢弃旧消息slice / trimMessages
总结条数(6)/token(200) 超限多一次模型调用摘要保留要点getBufferString→invoke→clear
检索每轮问答embedding + 向量检索按相关度取回Milvus search→注入 prompt

路线三:检索——跨会话按需取回,Milvus 闭环

截断和总结都困在"最近若干条"里;很久之前聊过的"我的职业是软件工程师",十轮后再问"我的职业是什么"就取不回了。检索式记忆的解法:把历史存进向量库,回答前按相关度检索注入

建库(src/memory/insert-conversation.mjs):集合 conversation 五个字段——id(VarChar 主键)、vector(FloatVector,dim=1024)、content(VarChar 5000)、round(Int64)、timestamp(VarChar 100,Milvus 没有 datetime 类型,源码注释特意标了这点);向量字段建 IVF_FLAT + COSINE 索引。插入前每条对话文本先过 embedQuery 变 1024 维向量。

闭环(src/memory/retrieval-memory.mjs 每轮循环):

// 读:检索注入
const queryVector = await getEmbedding(input)
const searchResult = await client.search({
  collection_name: 'conversation',
  vector: queryVector, metric_type: MetricType.COSINE, limit: 2,
  output_fields: ['id','content','round','timestamp'],
})
// 拼成「相关历史会话 + 用户问题」
const contextMessages = relevantHistory
  ? [new HumanMessage(`相关历史会话:n ${relevantHistory} nn 用户问题:${input}`)]
  : [userMessage]
const response = await model.invoke(contextMessages)

// 写:会话回写
const conversationText = `用户:${input}n 助手:${response.content}`
const convId = `conv_${Date.now()}_${i+1}`
await client.insert({
  collection_name: 'conversation',
  data: [{ id: convId, content: conversationText, round: i+1,
           timestamp: new Date().toISOString(), vector: await getEmbedding(conversationText) }],
})

读和写各一步:回答前 query 向量化、COSINE top-2、拼 prompt;回答后本轮对话文本向量化、以 conv_时间戳_轮次 为 id 写回。这正是笔记里规划的方向(readme.md L50-51:"每聊 20 条触发一次总结,生成摘要,存入 milvus 向量数据库")——目前实现是每轮就写,总结触发条件还没加。

收藏:Milvus 闭环自检清单

  • Milvus 本地 19530 已启动(运行未验证)
  • 集合五字段齐全,vector 维度与 embedding 输出一致(1024)
  • IVF_FLAT + COSINE 索引已建且 loadCollection
  • search 的 output_fields 含 content/round/timestamp
  • 回写 id 唯一(时间戳+轮次)
  • 检索为空时 fallback 只发原问题

收尾:一个可立即执行的检查

可迁移的判断:三条路不是互斥选项,而是分层组合——内存管本轮、总结管压缩、向量库管跨会话;真正决定体验的是触发条件与成本(截断几乎免费、总结多一次调用、检索多一次 embedding)。现在可以立刻做一件事:打开你的聊天应用统计一次真实对话的 token 总量,看它离模型上限有多远——这个数字决定你该先上截断还是直接上总结+检索。本文代码为静态整理、运行未验证。

标签:LangChain, Memory, Milvus, 向量数据库

相关文章

精彩推荐