聊天历史不是越多越好:用 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 区分 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. 最小可迁移结论

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

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

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

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

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

相关推荐
for_ever_love__1 小时前
LangChain 1.0 迁移指南:哪些 API 变了,怎么改
chatgpt·langchain·升级·新版本·迁移·变化·1.0
for_ever_love__2 小时前
LangChain 结构化输出:让模型稳定吐出符合 schema 的 JSON
langchain·json
flash俊杰20 小时前
给 7B 小模型当 Agent 框架:真机三轮排障,十条失败链逐个补
langchain
打工仔折腾 AI1 天前
LangChain 工具调用实战:从 Tool 定义到 Agent 执行链路拆解
java·前端·人工智能·后端·python·langchain·ai agent 实战
for_ever_love__1 天前
LangChain 上线部署:把链和 Agent 变成能对外服务的 API
langchain·部署·fastapi·rest api·服务·上线·langserve
染指11101 天前
145.Agent-LangGraph基础概念和安装和简单代码示例
java·服务器·数据库·langchain·agents
一木 之林1 天前
LangChain 第6章 Agent 智能体核心:create_agent 统一入口与 ReAct 循环
前端·react.js·langchain
小王师傅661 天前
LangChain 系统化学习入门
学习·langchain
for_ever_love__1 天前
LangChain 多工具协作:一个 Agent 不够用时怎么办
人工智能·langchain·langgraph·协作·多agent·多工具·工具编排
for_ever_love__1 天前
LangChain 效果评测:没有评测,优化就是在瞎调
人工智能·学习·langchain·rag·评估·langsmith·评测指标