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、工具调用和人工审核,会顺很多。

参考资料

相关推荐
幸福巡礼2 小时前
【LangChain 1.2 实战(八)】Agent Middleware 实战 —— 动态路由、监控、安全与容错
java·安全·langchain
晚风吹长发6 小时前
LangChain快速入门
langchain
Irissgwe6 小时前
LangChain之核心组件(文档加载器Document loaders)
人工智能·ai·langchain·llm·rag·langgraph·文档加载器
lbb 小魔仙7 小时前
LangChain + RAG 知识库系统搭建指南:从零构建企业级文档问答系统
python·langchain
dinl_vin8 小时前
LangChain 系列·(六):RAG 评估——你怎么知道它够好?
人工智能·langchain
深海鱼在掘金9 小时前
深入浅出 LangChain —— 第十二章:实战二 - 代码助手 Agent
人工智能·langchain·agent
深海鱼在掘金9 小时前
深入浅出 LangChain —— 第十三章:实战三 - 企业知识库问答
人工智能·langchain·agent
Byron__10 小时前
AI学习_05_LangChain使用
学习·langchain