给 AI 补记性,别只会把历史一股脑塞 prompt:截断、总结、检索三条路怎么选
多轮对话做到第二轮就会发现:模型根本不记得上一句。解法也很直白------把历史消息数组每次都塞回上下文。但第三轮、第十轮之后,历史越攒越多,上下文窗口有限、token 有成本,"无脑全量塞"这条路走不远。真正影响体验的是超限后的处理策略 :截断、总结、检索三条路各有一套成本模型。本文基于一份 LangChain Memory 学习项目(9 个源码文件),把三条路各自的最小实现拆给你看,最后一并给出 Milvus 检索式记忆的完整读写闭环。代码静态整理、运行未验证 ,Milvus 需本地 localhost:19530,模型依赖 .env 的 API_KEY。
先看清前提:记忆 = 每次重发历史
笔记里一句话戳破本质(readme.md L8):"大模型是无状态的,基于上次的问答继续问,回答。" 所以"记忆"的实现就是消息数组的维护循环(src/history.mjs):
js
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):
js
import { FileSystemChatMessageHistory } from '@langchain/community/stores/message/file_system'
const history = new FileSystemChatMessageHistory({
filePath: path.join(process.cwd(), 'chat_history.json'),
sessionId: "user_session_001", // 区分多用户
})
注意导入路径是 @langchain/community 而非 core------材料作者踩过这个坑(从 core 导入报 not exported)。history3.mjs 验证了持久化:同参数新建实例,getMessages() 恢复历史,第三轮对话无缝接上。
路线一:截断------极低成本,但会腰斩对话
两档粒度(src/memory/truncation-memory.mjs):
js
// 按条数:一行 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 条):
js
// ① 老消息数组 → 对话字符串
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 每轮循环):
js
// 读:检索注入
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} \n\n 用户问题:${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, 向量数据库