聊天历史不是越多越好:用 LangChain 把"记住"和"带上"分开
聊天机器人常见的误解是:只要把历史写进文件,模型就拥有了长期记忆。指定项目展示了更准确的关系:FileSystemChatMessageHistory 负责跨运行恢复,getMessages() 决定读出什么,而 invoke() 之前的截断决定本轮真正带给模型什么。本文用一条调用链拆开这三个动作。代码示例基于材料整理,运行未验证。
先建立一个心智模型
text
写入文件 ≠ 一定会自动进入模型上下文
文件历史 --getMessages--> 历史消息
当前问题 --addMessage----> 历史消息
系统指令 ------------------> 请求数组
请求数组 --invoke---------> AIMessage
AIMessage --addMessage----> 文件历史
这个模型也解释了为什么同一个会话重复运行脚本后,输出会出现多组问答:每次运行都在同一个 sessionId 下继续追加。
1. history3.mjs 做的其实是"恢复---追加---请求---保存"
文件历史对象的初始化:
js
const filePath = path.join(process.cwd(), 'chat_history.json')
const sessionId = 'user_session_001'
const restoredHistory = new FileSystemChatMessageHistory({
filePath,
sessionId,
})
接下来是第三轮消息:
js
const userMessage3 = new HumanMessage('需要哪些食材')
await restoredHistory.addMessage(userMessage3)
const message3 = [systemMessage, ...(await restoredHistory.getMessages())]
const response3 = await model.invoke(message3)
await restoredHistory.addMessage(response3)
这里有两个关键动作:
getMessages()是读取,不是调用模型。addMessage()不只是在内存数组里追加,还承担了历史持久化。
chat_history.json 里的消息按 type 区分 human 和 ai,AI 记录还可能带有 Token、模型名称和结束原因等元数据。因此打印完整对象适合调试,展示给用户通常只取 content。
2. 为什么恢复历史后还要裁剪
历史文件会越积越多,但每一轮请求不一定需要完整上下文。把全部消息原样传给模型会让输入变长,也可能突破上下文限制。更重要的是,截断应该发生在 invoke() 之前:
text
恢复历史 → 选择上下文 → 调用模型
而不是:
text
调用失败 → 再想办法删除历史
3. slice(-N) 是一个很好的入门方案
内存截断示例使用:
js
const trimmedMessages = allMessages.slice(-maxMessages)
如果 maxMessages = 4,结果就是最近四条消息。它的优点是非常直接:不改原数组,负数从末尾计数,易读且低成本。
但它回答的是"保留几条",不是"保留多少 Token"。一条很长的 AI 回复可能比多条短消息占用更多上下文,所以条数截断更像一个粗粒度保险丝。
4. Token 截断把预算变成显式参数
示例使用 js-tiktoken:
js
const enc = getEncoding('cl100k_base')
function countTokens(messages, enc) {
let total = 0
for (const msg of messages) {
const content = typeof msg.content === 'string'
? msg.content
: JSON.stringify(msg.content)
total += enc.encode(content).length
}
return total
}
再把计数器交给 trimMessages:
js
const trimmedMessages = trimMessages(allMessages, {
maxTokens: 100,
tokenCounter: async (messages) => countTokens(messages, enc),
strategy: 'latest',
})
这段配置表达了一个清晰的策略:在 100 的示例预算内,优先保留最新消息。相比固定条数,它更适合消息长度差异明显的场景。
但不要把这个计数结果直接当成最终账单。材料里的函数只统计 content,而当前编码器也未被材料证明与目标模型的完整分词规则完全一致。工程上应把它视为裁剪估算,并预留余量。
5. 两种策略怎么选
| 你的问题 | 优先方案 | 原因 |
|---|---|---|
| 只是想保留最近几轮 | slice(-N) |
代码少,行为清楚 |
| 回复长度差异很大 | Token 截断 | 条数不能代表上下文大小 |
| 需要严格控制请求预算 | Token 截断 + 安全余量 | 参数直接对应预算 |
| 正在验证记忆链路 | 先文件恢复,再简单截断 | 便于定位问题 |
实际项目也可以先做 Token 截断,再用日志记录保留了多少条消息;但不要误以为 slice() 会改变 history 本身,它只返回一个新数组。
6. 一份排错清单
- 文件找不到 :打印
filePath,确认运行工作目录是否改变。 - 读不到历史 :确认恢复时使用的
sessionId与写入时相同。 - 模型看不到历史 :确认
getMessages()的结果确实被展开到invoke()参数中。 - 重复问答不断增加:检查是否每次都复用同一个会话 ID。
- 控制台信息过长 :不要直接打印完整
AIMessage,只输出type与content。 - Token 截断没有效果 :确认
trimMessages返回值被打印,或替换了真正传给模型的消息数组。
7. 最小可迁移结论
把聊天记忆拆成两层最容易理解:
- 存储层:文件、内存或数据库,负责保存和恢复。
- 上下文层:本轮请求实际发送的消息,负责控制模型看到的内容。
history3.mjs 证明了存储层的读写链路;truncation-memory.mjs 证明了上下文层可以按条数或 Token 裁剪。两者组合起来,才是可控的对话记忆方案。
当前材料没有覆盖摘要记忆、向量检索、并发写入和生产环境清理策略。下一步可以把 trimMessages 的结果接入实际 model.invoke(),再分别验证"文件恢复成功"和"上下文确实被裁剪"。
标签:LangChain、对话记忆、上下文管理、Token、JavaScript