LangChain :把历史记录和链式调用写明白

很多 LangChain 示例看起来都很简单:

ts 复制代码
const result = await model.invoke("帮我总结这段文本");

这当然能跑。

但一旦你开始做真实应用,很快会遇到两个问题:

  1. 用户第二轮追问时,模型不知道"刚才"是什么。
  2. 业务流程不止一步,需要先改写问题、再查资料、再组织答案。

这两个问题,对应 LangChain 里最常用的两个进阶能力:

  • 历史记录:让模型记住同一个会话里的上下文。
  • 链式调用:把 prompt、model、parser、retriever 等步骤串起来。

这篇不讲大而全的 Agent,先把这两个基础能力讲透。

1. 为什么不能只调用一次模型

先看一个普通调用:

ts 复制代码
const answer1 = await model.invoke("我叫小王,喜欢 TypeScript");
const answer2 = await model.invoke("我喜欢什么语言?");

第二次调用时,模型并不知道第一次说了什么。

因为对大模型来说,每一次 invoke 默认都是一次新的请求。它不会自动记住你上一次传了什么,除非你把历史消息也传进去。

所以多轮对话的本质不是"模型有记忆",而是应用帮它管理历史记录:

text 复制代码
第 1 轮 Human: 我叫小王,喜欢 TypeScript
第 1 轮 AI: 记住了
第 2 轮 Human: 我喜欢什么语言?

然后把这组消息一起交给模型。

2. 最朴素的历史记录:手动维护 messages

最简单的做法是自己维护一个数组。

ts 复制代码
import { HumanMessage, AIMessage } from "@langchain/core/messages";

const history = [];

const first = await model.invoke([
  ...history,
  new HumanMessage("我叫小王,喜欢 TypeScript"),
]);

history.push(new HumanMessage("我叫小王,喜欢 TypeScript"));
history.push(new AIMessage(first.content));

const second = await model.invoke([
  ...history,
  new HumanMessage("我喜欢什么语言?"),
]);

console.log(second.content);

这个版本很好理解,但很快会变乱:

  • 每次都要手动 push 用户消息和 AI 消息
  • 多个用户同时使用时,要按 session 隔离
  • 历史记录越来越长,需要裁剪
  • 生产环境还要存 Redis、数据库或 KV

所以手动维护适合学习原理,不适合作为长期方案。

3. 用 Prompt 明确插入历史记录

LangChain 里更常见的做法,是在 Prompt 里留一个历史记录占位符。

ts 复制代码
import {
  ChatPromptTemplate,
  MessagesPlaceholder,
} from "@langchain/core/prompts";

const prompt = ChatPromptTemplate.fromMessages([
  ["system", "你是一个简洁的中文技术助手。"],
  new MessagesPlaceholder("history"),
  ["human", "{question}"],
]);

这里的 history 就是历史消息的位置。

当你传入:

ts 复制代码
await prompt.invoke({
  history: [
    new HumanMessage("我叫小王,喜欢 TypeScript"),
    new AIMessage("好的,我记住了。"),
  ],
  question: "我喜欢什么语言?",
});

最终模型看到的是:

text 复制代码
System: 你是一个简洁的中文技术助手。
Human: 我叫小王,喜欢 TypeScript
AI: 好的,我记住了。
Human: 我喜欢什么语言?

这一步很关键。

历史记录不是随便拼到字符串里,而是作为消息列表进入模型。这样模型能区分 system、human、ai,而不是把所有内容当成一坨文本。

4. 链式调用的核心:pipe

LangChain 的链式调用可以理解成 Unix 管道:

text 复制代码
上一步输出 -> 下一步输入

一个最小链路通常是:

text 复制代码
Prompt -> Model -> OutputParser

代码是这样:

ts 复制代码
import { ChatPromptTemplate } from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";

const prompt = ChatPromptTemplate.fromMessages([
  ["system", "你是一个中文技术编辑。"],
  ["human", "把下面这句话改写得更适合发技术博客:{text}"],
]);

const chain = prompt.pipe(model).pipe(new StringOutputParser());

const result = await chain.invoke({
  text: "LangChain 可以把多个步骤串起来用。",
});

console.log(result);

pipe 做的事情很直接:

  • prompt 把变量变成消息
  • model 把消息变成模型回复
  • StringOutputParser 把模型消息转成字符串

这就是 LCEL,也就是 LangChain Expression Language。

你可以先把它理解成 LangChain 的"组合语法"。不要一上来就想着 Agent,大部分确定流程用 chain 就够了。

5. 把历史记录接到链上

现在问题来了:如果 chain 里需要历史记录,应该怎么写?

先定义一个带 history 的 prompt:

ts 复制代码
import {
  ChatPromptTemplate,
  MessagesPlaceholder,
} from "@langchain/core/prompts";
import { StringOutputParser } from "@langchain/core/output_parsers";

const prompt = ChatPromptTemplate.fromMessages([
  ["system", "你是一个中文技术助手,回答要短。"],
  new MessagesPlaceholder("history"),
  ["human", "{question}"],
]);

const chain = prompt.pipe(model).pipe(new StringOutputParser());

这个 chain 需要两个输入:

ts 复制代码
await chain.invoke({
  history: [],
  question: "我叫小王,喜欢 TypeScript",
});

但它还不会自动保存历史。

要自动管理历史,可以用 RunnableWithMessageHistory 包一层。

ts 复制代码
import { RunnableWithMessageHistory } from "@langchain/core/runnables";
import { ChatMessageHistory } from "@langchain/classic/stores/message/in_memory";

const store = new Map<string, ChatMessageHistory>();

function getMessageHistory(sessionId: string) {
  if (!store.has(sessionId)) {
    store.set(sessionId, new ChatMessageHistory());
  }

  return store.get(sessionId)!;
}

const chainWithHistory = new RunnableWithMessageHistory({
  runnable: chain,
  getMessageHistory,
  inputMessagesKey: "question",
  historyMessagesKey: "history",
});

调用时传 sessionId

ts 复制代码
await chainWithHistory.invoke(
  {
    question: "我叫小王,喜欢 TypeScript",
  },
  {
    configurable: {
      sessionId: "user-001",
    },
  },
);

const answer = await chainWithHistory.invoke(
  {
    question: "我喜欢什么语言?",
  },
  {
    configurable: {
      sessionId: "user-001",
    },
  },
);

console.log(answer);

重点是这几个字段:

  • runnable:被包装的链
  • getMessageHistory:根据 sessionId 找到历史记录
  • inputMessagesKey:当前用户输入字段
  • historyMessagesKey:Prompt 里的历史占位符字段

这就是"会话记忆"的基本形态。

6. sessionId 是历史记录的隔离边界

历史记录最容易踩的坑,是忘记隔离用户。

错误做法:

ts 复制代码
const history = new ChatMessageHistory();

所有人共用一份历史,A 用户说了名字,B 用户可能问出来。

正确做法是按 sessionId 拆开:

text 复制代码
user-001 -> history A
user-002 -> history B
user-003 -> history C

在 Web 应用里,sessionId 可以来自:

  • 登录用户 ID
  • 会话 ID
  • 浏览器生成的临时 conversationId
  • 数据库里的 threadId

不要直接用 IP。

IP 不稳定,也不代表一个用户。

7. 历史记录不能无限增长

历史记录不是越多越好。

长对话会带来几个问题:

  • token 成本变高
  • 响应速度变慢
  • 上下文噪音变多
  • 旧信息干扰当前问题

LangChain 官方文档也把短期记忆定义为 thread 范围内的消息历史,并建议在长对话里做裁剪、删除、摘要或自定义策略。

最简单的策略是只保留最近 N 轮:

ts 复制代码
async function trimHistory(sessionId: string, maxMessages = 12) {
  const history = getMessageHistory(sessionId);
  const messages = await history.getMessages();

  if (messages.length <= maxMessages) {
    return;
  }

  const recentMessages = messages.slice(-maxMessages);
  await history.clear();
  await history.addMessages(recentMessages);
}

调用模型前先裁剪:

ts 复制代码
const sessionId = "user-001";

await trimHistory(sessionId);

const answer = await chainWithHistory.invoke(
  { question: "继续解释上一段代码" },
  { configurable: { sessionId } },
);

如果业务更复杂,可以做摘要:

text 复制代码
前 20 轮消息 -> 总结成一段 conversation summary
最近 6 轮消息 -> 原样保留
summary + recent messages -> 传给模型

不要把所有历史都塞进去。能裁就裁,能总结就总结。

8. 链式调用不只是 prompt 接 model

链式调用真正好用的地方,是可以把确定步骤拆开。

比如一个客服问答流程:

text 复制代码
用户问题
-> 改写成独立问题
-> 检索知识库
-> 拼接上下文
-> 调模型回答
-> 输出字符串

每一步都可以是一个 Runnable。

示意代码:

ts 复制代码
import { RunnableSequence } from "@langchain/core/runnables";
import { StringOutputParser } from "@langchain/core/output_parsers";

const rewritePrompt = ChatPromptTemplate.fromMessages([
  ["system", "把用户问题改写成不依赖上下文的独立问题。"],
  new MessagesPlaceholder("history"),
  ["human", "{question}"],
]);

const answerPrompt = ChatPromptTemplate.fromMessages([
  ["system", "你只能根据提供的资料回答。资料:\n{context}"],
  ["human", "{question}"],
]);

const rewriteChain = rewritePrompt.pipe(model).pipe(new StringOutputParser());

const qaChain = RunnableSequence.from([
  async (input) => {
    const standaloneQuestion = await rewriteChain.invoke(input);
    return {
      ...input,
      standaloneQuestion,
    };
  },
  async (input) => {
    const docs = await retriever.invoke(input.standaloneQuestion);
    return {
      question: input.question,
      context: docs.map((doc) => doc.pageContent).join("\n\n"),
    };
  },
  answerPrompt,
  model,
  new StringOutputParser(),
]);

这个链路的好处是边界清楚:

  • 改写问题只负责改写
  • retriever 只负责查资料
  • answer prompt 只负责组织答案
  • parser 只负责输出格式

出问题时也好排查。

如果答案不对,你可以看是问题改写错了、检索结果错了,还是最终 prompt 没约束住。

9. 历史记录和链式调用结合:真正的多轮 RAG

普通 RAG 经常只处理当前问题:

text 复制代码
用户:它支持流式输出吗?

这里的"它"是谁?

如果没有历史记录,retriever 很可能查错。

所以多轮 RAG 通常要先做一次问题改写:

text 复制代码
历史:
用户:LangChain 的 Runnable 是什么?
AI:Runnable 是 LangChain 中统一的可执行抽象。

当前问题:
它支持流式输出吗?

改写后:
LangChain 的 Runnable 支持流式输出吗?

然后再用改写后的问题去检索。

这就是历史记录和链式调用结合的价值:

  • history 帮你理解上下文
  • chain 帮你拆分步骤
  • retriever 帮你找外部资料
  • model 负责最终表达

10. 生产里怎么存历史记录

上面的示例用的是内存:

ts 复制代码
const store = new Map<string, ChatMessageHistory>();

这只适合本地测试。

生产环境至少要考虑:

  • 服务重启后历史不能丢
  • 多实例部署时历史要共享
  • 用户可以清空会话
  • 历史记录要有过期时间
  • 敏感信息要能删除

更常见的方案是:

text 复制代码
sessionId -> Redis
sessionId -> Postgres
sessionId -> MongoDB
sessionId -> 云 KV

如果你只是做客服、知识库问答、学习助手,Redis 通常够用。

如果你需要审计、回放、分析用户行为,数据库更合适。

11. 我会怎么组织代码

不要把所有东西写到一个 index.ts

一个可维护的结构可以这样:

text 复制代码
src/
  chains/
    chat-chain.ts
    rewrite-chain.ts
    qa-chain.ts
  memory/
    message-history.ts
    trim-history.ts
  prompts/
    chat.prompt.ts
    rewrite.prompt.ts
    qa.prompt.ts
  retrievers/
    docs-retriever.ts
  app.ts

每个文件只做一件事:

  • chat-chain.ts:普通多轮聊天
  • rewrite-chain.ts:根据历史改写问题
  • qa-chain.ts:检索增强问答
  • message-history.ts:按 sessionId 读写历史
  • trim-history.ts:控制历史长度

这样后面加功能不会乱。

12. 最容易踩的坑

第一,忘记传 sessionId

ts 复制代码
await chainWithHistory.invoke({ question: "继续" });

没有 sessionId,历史记录就不知道该挂到哪个会话。

第二,Prompt 里没有 MessagesPlaceholder

你包了 RunnableWithMessageHistory,但 prompt 不接收 history,历史自然进不去。

第三,把历史记录拼成字符串。

可以临时用,但不建议长期这么做。聊天模型更适合吃 message list。

第四,不裁剪历史。

一开始没问题,用户聊几十轮之后,成本和质量都会变差。

第五,把历史记录当长期记忆。

"当前会话里提过的内容"和"用户长期偏好"不是一回事。前者是 short-term memory,后者应该单独存。

13. 进阶的重点不是写更复杂,而是拆得更清楚

LangChain 进阶,不是把代码写得更玄。

真正有用的是这几个判断:

  • 当前输入是否需要历史?
  • 历史应该按什么 session 隔离?
  • 哪些历史应该保留,哪些应该裁剪?
  • 这个流程能不能拆成多个明确步骤?
  • 每一步的输入输出是什么?

如果你能把这几个问题回答清楚,LangChain 就不再是"套 prompt 的库",而是一个可以组织 LLM 应用流程的工程工具。

链式调用解决流程问题。

历史记录解决上下文问题。

这两个能力用扎实,再去看 Agent、LangGraph、工具调用和人工审核,会顺很多。

参考资料

相关推荐
swipe1 小时前
混合检索 RAG 的工程化实践:不是多查几路,而是把召回、重排和上下文预算管好
后端·langchain·llm
啊哈哈哈哈哈啊哈哈3 小时前
LangChain 与 LlamaIndex 实现 RAG:代码知识点总结
langchain
lhxcc_fly4 小时前
2.LangChain--聊天模型之流式传输
ai·langchain·llm·流式传输
lhxcc_fly7 小时前
3.LangChain组件--消息
langchain·llm·messages
我材不敲代码8 小时前
Llamafactory的使用
langchain
喵叔哟8 小时前
Day 4:提示工程与输出解析
langchain
索西引擎8 小时前
【langchain 1.0】ChromaDB 原生 API 实战:为 LangChain 向量库打造管理工具集
python·ai·langchain
是一个Bug8 小时前
LangChain 入门完全指南:核心概念、学习路线与实战 Demo
学习·langchain
牧子川9 小时前
018-tool-decorator-basics
langchain·tools
tang&9 小时前
【LangGraph】LangGraph 协调者-工作者模式完全解析:从零构建一个智能报告生成系统
langchain