聊天历史不是越多越好:用 LangChain 把“记住”和“带上”分开

0 阅读4分钟

聊天历史不是越多越好:用 LangChain 把“记住”和“带上”分开

聊天机器人常见的误解是:只要把历史写进文件,模型就拥有了长期记忆。指定项目展示了更准确的关系:FileSystemChatMessageHistory 负责跨运行恢复,getMessages() 决定读出什么,而 invoke() 之前的截断决定本轮真正带给模型什么。本文用一条调用链拆开这三个动作。代码示例基于材料整理,运行未验证。

先建立一个心智模型

写入文件 ≠ 一定会自动进入模型上下文

文件历史 --getMessages--> 历史消息
当前问题 --addMessage----> 历史消息
系统指令 ------------------> 请求数组
请求数组 --invoke---------> AIMessage
AIMessage --addMessage----> 文件历史

这个模型也解释了为什么同一个会话重复运行脚本后,输出会出现多组问答:每次运行都在同一个 sessionId 下继续追加。

1. history3.mjs 做的其实是“恢复—追加—请求—保存”

文件历史对象的初始化:

const filePath = path.join(process.cwd(), 'chat_history.json')
const sessionId = 'user_session_001'

const restoredHistory = new FileSystemChatMessageHistory({
  filePath,
  sessionId,
})

接下来是第三轮消息:

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 区分 humanai,AI 记录还可能带有 Token、模型名称和结束原因等元数据。因此打印完整对象适合调试,展示给用户通常只取 content

2. 为什么恢复历史后还要裁剪

历史文件会越积越多,但每一轮请求不一定需要完整上下文。把全部消息原样传给模型会让输入变长,也可能突破上下文限制。更重要的是,截断应该发生在 invoke() 之前:

恢复历史 → 选择上下文 → 调用模型

而不是:

调用失败 → 再想办法删除历史

3. slice(-N) 是一个很好的入门方案

内存截断示例使用:

const trimmedMessages = allMessages.slice(-maxMessages)

如果 maxMessages = 4,结果就是最近四条消息。它的优点是非常直接:不改原数组,负数从末尾计数,易读且低成本。

但它回答的是“保留几条”,不是“保留多少 Token”。一条很长的 AI 回复可能比多条短消息占用更多上下文,所以条数截断更像一个粗粒度保险丝。

4. Token 截断把预算变成显式参数

示例使用 js-tiktoken

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

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,只输出 typecontent
  • Token 截断没有效果:确认 trimMessages 返回值被打印,或替换了真正传给模型的消息数组。

7. 最小可迁移结论

把聊天记忆拆成两层最容易理解:

  1. 存储层:文件、内存或数据库,负责保存和恢复。
  2. 上下文层:本轮请求实际发送的消息,负责控制模型看到的内容。

history3.mjs 证明了存储层的读写链路;truncation-memory.mjs 证明了上下文层可以按条数或 Token 裁剪。两者组合起来,才是可控的对话记忆方案。

当前材料没有覆盖摘要记忆、向量检索、并发写入和生产环境清理策略。下一步可以把 trimMessages 的结果接入实际 model.invoke(),再分别验证“文件恢复成功”和“上下文确实被裁剪”。

标签:LangChain、对话记忆、上下文管理、Token、JavaScript