很多 LangChain 示例看起来都很简单:
ts
const result = await model.invoke("帮我总结这段文本");
这当然能跑。
但一旦你开始做真实应用,很快会遇到两个问题:
- 用户第二轮追问时,模型不知道"刚才"是什么。
- 业务流程不止一步,需要先改写问题、再查资料、再组织答案。
这两个问题,对应 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、工具调用和人工审核,会顺很多。
参考资料
- LangChain JS 安装文档:docs.langchain.com/oss/javascr...
- LangChain JS Overview:docs.langchain.com/oss/javascr...
- LangChain JS Short-term memory:docs.langchain.com/oss/javascr...
- LangChain Memory 概念说明:docs.langchain.com/oss/javascr...
RunnableWithMessageHistoryJS Reference:reference.langchain.com/javascript/...