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

聊天历史不是越多越好:用 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 区分 humanai,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,只输出 typecontent
  • Token 截断没有效果 :确认 trimMessages 返回值被打印,或替换了真正传给模型的消息数组。

7. 最小可迁移结论

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

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

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

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

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

相关推荐
花间相见9 小时前
【LangChain中间件01】—— LangChain 中间件入门:六个钩子让 Agent 可插拔
中间件·langchain
Boop_wu12 小时前
[LangGraph] 案例 2 : 支持搜索的智能代理系统
服务器·windows·python·langchain
测试开发Kevin13 小时前
DeepEval + Eval‑Harness 完整讲解(结合 Playwright UI 自动化例子)
人工智能·ai·langchain
码农小麦1 天前
LangChain 1.3.18 + DeepSeek-v4-flash 工具调用踩坑实录
网络·数据库·langchain
10年前端老司机2 天前
别卷CRUD了!前端用Next.js+LangChain.js,低成本冲进AI高薪赛道
前端·langchain·next.js
cui_ruicheng2 天前
LangChain 应用开发(十四):Agent 上下文与记忆机制
服务器·人工智能·python·langchain
meilindehuzi_a2 天前
LangChain.js 对话 Memory 实战:History 持久化、截断与摘要压缩
java·javascript·langchain
梦在远山后3 天前
Python 中两种 Queue 的区别
python·langchain
花间相见3 天前
【LangChain组件03】—— LangChain Agents 执行与状态:工作流程与状态管理实战
java·前端·langchain