Skip to content
LangChain.js 关键组件:记忆与回调
多轮对话里,模型本身是无状态的。每次请求都是独立的,它不会记住上一轮说过什么。要让对话连贯,就得在每次调用时把历史消息一起传进去。
LangChain 把这拆成了两层:底层是存消息列表的 ChatMessageHistory,上层是控制加载、保存时机的 Memory 机制。另外,回调系统提供了一套事件钩子,能在链执行的各个节点插入自定义逻辑,用来日志、监控都行。
三个东西的关系
- ChatMessageHistory:最薄的抽象,只管往消息列表里追加,以及把消息读出来。不关心持久化,也不管对话是不是同一段 session。
- Memory:在 ChatMessageHistory 之上加了加载/保存逻辑。链执行前从 history 取出消息填进 prompt,链结束后把新产生的 human、ai 消息写回去。不同的 Memory 策略区别在于怎么裁剪历史、保留多少。
- CallbackHandler:纯观察者。不修改链的行为,只在链执行的不同阶段回调你指定的方法。多个 handler 可以同时工作。
运行时调用关系大致是:
- 请求进来,Memory 从 ChatMessageHistory 加载历史消息。
- 链执行,依次触发回调:
handleChainStart→handleLLMStart→handleLLMEnd→handleChainEnd。 - Memory 把本轮新消息保存回 ChatMessageHistory。
ChatMessageHistory:低层接口
它就是个消息列表容器,提供 addMessage、addUserMessage、addAIMessage 这些方法。数据只在内存里,没有持久化能力。
typescript
import {
ChatMessageHistory,
HumanMessage,
AIMessage,
} from "langchain/schema";
const history = new ChatMessageHistory();
await history.addUserMessage("我叫小明");
await history.addAIMessage("记住了,你好小明");
const messages = await history.getMessages();
console.log(messages.map((m) => m.content));
// [ '我叫小明', '记住了,你好小明' ]实际场景中几乎不会单独用它。有用的是把它塞给 Memory,让 Memory 去管加载和保存的时机。
Memory:自动加载与保存
Memory 包在 ChatMessageHistory 外面,负责两件事:链开始前读取历史消息,链结束后写回新消息。
LangChain.js 早期版本有 BufferMemory,现在已经标记为 deprecated,但它的设计思路还是值得了解,很多遗留代码里还会碰到。
typescript
// 旧版 BufferMemory(已弃用)
import { BufferMemory } from "langchain/memory";
import { HumanMessage, AIMessage } from "langchain/schema";
const memory = new BufferMemory({
returnMessages: true,
chatHistory: new ChatMessageHistory([
new HumanMessage("你好"),
new AIMessage("你好,有什么可以帮你?"),
]),
});
// 链执行时,memory.chatHistory.getMessages() 自动调用
// 链结束后,memory.chatHistory.addUserMessage() / addAIMessage() 写入新消息BufferMemory 的问题在于它在链外部独立运行,和 LCEL 的 Runnable 组合不够自然。当前版本推荐直接把 ChatMessageHistory 和 RunnableWithMessageHistory 绑在一起用。
ConversationBufferMemory、ConversationSummaryMemory 这些变体的原理类似,区别在于对历史消息做裁剪或摘要,而不是永远保留全量。
RunnableWithMessageHistory:把记忆绑进链
RunnableWithMessageHistory 接收一个 Runnable 链和一个 getMessageHistory 工厂函数,每次调用时按以下步骤走:
- 通过
options.sessionId拿到对应的 history。 - 把历史消息填充到 prompt 里
chat_history变量的位置。 - 执行链。
- 把新的 human、ai 消息追加回 history。
这样记忆就完全嵌入链的执行流,不需要在外面手动操作。
typescript
import {
ChatPromptTemplate,
MessagesPlaceholder,
} from "@langchain/core/prompts";
import { ChatOpenAI } from "@langchain/openai";
import { RunnableWithMessageHistory } from "@langchain/core/runnables";
import { ChatMessageHistory } from "langchain/schema";
const prompt = ChatPromptTemplate.fromMessages([
["system", "你是一个友好的助手。"],
new MessagesPlaceholder("chat_history"),
["human", "{input}"],
]);
const model = new ChatOpenAI({ modelName: "gpt-3.5-turbo" });
const chain = prompt.pipe(model);
// 存放不同 session 对应的 history
const historyStore = new Map<string, ChatMessageHistory>();
const withHistory = new RunnableWithMessageHistory({
runnable: chain,
getMessageHistory: async (sessionId) => {
if (!historyStore.has(sessionId)) {
historyStore.set(sessionId, new ChatMessageHistory());
}
return historyStore.get(sessionId)!;
},
inputMessagesKey: "input",
historyMessagesKey: "chat_history",
});
// 第一轮
const res1 = await withHistory.invoke(
{ input: "我叫小明" },
{ configurable: { sessionId: "session-a" } }
);
console.log(res1.content);
// 第二轮,历史自动带入
const res2 = await withHistory.invoke(
{ input: "我叫什么名字?" },
{ configurable: { sessionId: "session-a" } }
);
console.log(res2.content); // 能答出"小明"inputMessagesKey 和 historyMessagesKey 必须和 prompt 里的占位符名字对上。历史消息会被展开成 HumanMessage、AIMessage 列表填进 chat_history 位置。如果对不上,模型看不到任何历史,但不会报错——这是一个静默的问题。
CallbackHandler:事件模型
回调系统是纯观察者模式。你实现 CallbackHandler 接口,通过 callbacks 配置传给链。链在执行过程中,在特定时间点调用你提供的方法。
关键事件方法:
| 方法 | 触发时机 |
|---|---|
handleLLMStart | LLM 调用开始,携带 prompts |
handleLLMEnd | LLM 调用结束,携带 token 用量 |
handleChainStart | 链开始执行 |
handleChainEnd | 链执行结束 |
handleToolStart / handleToolEnd | 工具调用开始/结束 |
handleLLMNewToken | 流式输出时逐 token 触发 |
每个方法有标准签名,比如 handleLLMStart(llm, prompts, runId, parentRunId?, extraParams?)。返回值可以忽略。观察逻辑里抛异常会中断链的执行,所以回调内部要自己捕获错误。
使用方式:
typescript
const chain = prompt.pipe(model);
await chain.invoke(
{ input: "你好" },
{
callbacks: [
{
handleLLMStart: (llm, prompts) => {
console.log("LLM 开始调用,prompts:", prompts);
},
handleLLMEnd: (output) => {
console.log(
"LLM 调用结束,token 用量:",
output.llmOutput?.tokenUsage
);
},
handleChainStart: (chain) => {
console.log("链开始执行");
},
handleChainEnd: (outputs) => {
console.log("链执行结束");
},
},
],
}
);可以传单个回调对象,也可以传数组。多个回调按数组顺序依次调用。注意这些方法是串行执行的,如果其中一个很耗时,会拖慢整条链——别在回调里做复杂计算或外部网络请求。
自定义回调处理器
把回调逻辑抽成类,方便复用:
typescript
import { BaseCallbackHandler } from "@langchain/core/callbacks/base";
import type { Serialized } from "@langchain/core/callbacks/base";
import type { LLMResult } from "@langchain/core/outputs";
class LoggingCallbackHandler extends BaseCallbackHandler {
name = "LoggingCallbackHandler";
async handleLLMStart(
llm: Serialized,
prompts: string[],
runId: string,
parentRunId?: string
) {
console.log(`[LLM start] runId: ${runId}, prompts count: ${prompts.length}`);
}
async handleLLMEnd(output: LLMResult, runId: string) {
const tokenUsage = output.llmOutput?.tokenUsage;
console.log(`[LLM end] runId: ${runId}, tokens:`, tokenUsage);
}
async handleChainStart(chain: Serialized, runId: string) {
console.log(`[Chain start] runId: ${runId}`);
}
async handleChainEnd(outputs: any, runId: string) {
console.log(`[Chain end] runId: ${runId}`);
}
async handleText(text: string, runId: string) {
console.log(`[Text] runId: ${runId}: ${text}`);
}
}使用时 new LoggingCallbackHandler() 放进 callbacks 数组即可。配合流式场景,handleLLMNewToken 能拿到每个新生成的 token,适合做打字机效果。
整合示例
带记忆的对话链加上日志回调:
typescript
import { ChatOpenAI } from "@langchain/openai";
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import { RunnableWithMessageHistory } from "@langchain/core/runnables";
import { ChatMessageHistory } from "langchain/schema";
import { BaseCallbackHandler } from "@langchain/core/callbacks/base";
import type { LLMResult } from "@langchain/core/outputs";
class Logger extends BaseCallbackHandler {
name = "Logger";
async handleLLMStart(_llm: any, prompts: string[]) {
console.log(`LLM 调用开始,prompts:`, prompts[0].substring(0, 50) + "...");
}
async handleLLMEnd(output: LLMResult) {
console.log(`LLM 调用结束,tokens:`, output.llmOutput?.tokenUsage);
}
}
const prompt = ChatPromptTemplate.fromMessages([
["system", "你是一个友善的助手。"],
new MessagesPlaceholder("history"),
["human", "{input}"],
]);
const model = new ChatOpenAI({ modelName: "gpt-3.5-turbo" });
const chain = prompt.pipe(model);
const sessions = new Map<string, ChatMessageHistory>();
const withMemory = new RunnableWithMessageHistory({
runnable: chain,
getMessageHistory: async (sessionId) => {
if (!sessions.has(sessionId)) {
sessions.set(sessionId, new ChatMessageHistory());
}
return sessions.get(sessionId)!;
},
inputMessagesKey: "input",
historyMessagesKey: "history",
});
// 第一轮
const res1 = await withMemory.invoke(
{ input: "我爱吃苹果。" },
{
configurable: { sessionId: "u1" },
callbacks: [new Logger()],
}
);
console.log("AI:", res1.content);
// 第二轮
const res2 = await withMemory.invoke(
{ input: "我在前面说了什么?" },
{
configurable: { sessionId: "u1" },
callbacks: [new Logger()],
}
);
console.log("AI:", res2.content);运行输出类似:
LLM 调用开始,prompts: System: 你是一个友善的助手。...
LLM 调用结束,tokens: { completionTokens: 12, promptTokens: 34, totalTokens: 46 }
AI: 好的,我会记住你喜欢吃苹果。
LLM 调用开始,prompts: System: 你是一个友善的助手。...
LLM 调用结束,tokens: { completionTokens: 8, promptTokens: 52, totalTokens: 60 }
AI: 你说你爱吃苹果。回调打印了每次 LLM 调用的 prompt 片段和 token 用量。RunnableWithMessageHistory 在每次请求时把之前的历史消息注入 history 占位符,模型因此能引用前文。
注意点与调试技巧
sessionId 是隔离粒度。 每个 sessionId 对应一段独立对话历史。按用户维度隔离就用 userId,临时会话就生成唯一 ID。
history 不自动持久化。 上面例子的 Map 在内存里,进程重启就没了。要持久化,自己在 getMessageHistory 里实现从数据库或 Redis 加载和保存。
history 会无限膨胀。 长对话不裁剪会超出模型上下文窗口。可以在 getMessageHistory 里只返回最近 N 条,或使用支持窗口裁剪的 Memory 变体进行摘要。
回调异常会被吞掉。 回调方法抛错默认被捕获并忽略,链继续执行。所以回调内部要自己捕获异常,否则很难发现 bug。
多个回调串行执行。 按 callbacks 数组顺序依次调用。别在回调里做耗时操作。
查看详细执行树。 设置环境变量 LANGCHAIN_TRACING_V2=true 配合 LangSmith,能看到完整的链执行 trace,节点耗时、prompt、输出都在里面。这是排查"链内部到底发生了什么"最快的方式。
Token 用量只在 handleLLMEnd 里拿到。 需要计费或限流就在这里收集。
不要混用 deprecated Memory 和 RunnableWithMessageHistory。 前者独立于链,后者把记忆嵌入链,混用会导致历史重复或丢失。
historyMessagesKey 和 prompt 占位符不对齐不会报错,但模型看不到历史。 排查"为什么不记对话"时优先检查这个。
