系列:LangChain.js 实战 · 第 2 篇 / 共 6 篇 标签:LangChain.js · 对话记忆 · 多轮对话 · 前端转 AI
上篇我们用 model.invoke(input) 跑通了「单轮聊天」:你发一句话,模型回一句话,发完就忘。真实产品里 AI 得记得上一轮说了什么——"我叫左耳""我刚说的订单号是多少"。这一篇就在这个骨架上加「对话记忆」。
先给结论,免得你被各种 BufferMemory、ChatMessageHistory 名词绕晕:LangChain 的"记忆"本质就是"一条消息数组" 。本篇 demo 不依赖任何记忆抽象类,用一个最朴素的 Map 把历史存起来,反而最能让你看懂记忆到底在干什么。
配套仓库还是上篇的
langchain-langgraph-express-demos(后端 Node + Express + LangChain.js,前端 React + TS)。没看上篇的先去把环境跑起来,本篇直接接着写。
一、先说清:记忆 = 消息数组
单轮聊天时,我们给模型的是一个字符串:
const result = await model.invoke(body.input); // body.input 是 "你好"
多轮对话时,给模型的必须是一个消息数组,里面装着「用户的每句话」+「模型之前的每句回答」:
const result = await model.invoke(messages); // messages 是 [HumanMessage, AIMessage, HumanMessage, ...]
模型拿到这个数组,就能根据前面的上下文回答新问题。所谓"记忆",就是:把每次的对话 append 进这个数组,下次再把整个数组喂给模型。 没有魔法,就是这么简单。
LangChain 把每条消息封装成 HumanMessage(用户)、AIMessage(模型)、SystemMessage(系统提示)等类型,模型靠它们的类型识别角色。
二、后端记忆存储:一个 Map 搞定
打开 src/routes/langchain.routes.js,最上面就一行:
import { Router } from "express";
import { asyncRoutes } from '../shared/async-route.js';
import { threadInputSchema } from '../shared/validation.js';
import { getChatModel } from '../ai/models.js';
import { HumanMessage } from "@langchain/core/messages";
import { formateConveration } from "../ai/tools.js";
export const langchanRouter = Router();
const sessions = new Map();
sessions 就是一个「进程内的记事本」:
- key =
threadId(哪一段对话) - value = 这条对话的消息数组
BaseMessage[]
为什么用
threadId?因为不同用户/不同会话要分开记。上篇的threadInputSchema里已经埋了这个字段:threadId: z.string().min(1).default('demo-thread')。你不传也行,默认都归到demo-thread一条历史里。
三、带记忆聊天:POST /api/lc/agent/chat
核心接口就这几行:
// 带记忆
langchanRouter.post(
'/agent/chat',
asyncRoutes(async (req, res) => {
const body = threadInputSchema.parse(req.body);
const messages = sessions.get(body.threadId) ?? []; // 1. 取出这条会话的历史
messages.push(new HumanMessage(body.input)); // 2. 把用户这句压进数组
const aiMessage = await getChatModel().invoke(messages); // 3. 整段历史丢给模型
messages.push(aiMessage); // 4. 把模型回答也压进数组
sessions.set(body.threadId, messages); // 5. 存回去
res.json({
ok: true,
data: { output: aiMessage.content }
});
})
);
五步拆解:
sessions.get(body.threadId) ?? []:按threadId取历史;没有就空数组开新会话。messages.push(new HumanMessage(body.input)):把用户这轮输入包成HumanMessage追加进去。HumanMessage从@langchain/core/messages导入。getChatModel().invoke(messages):注意这里传的是数组messages,不是字符串。模型会把它当作完整上下文来回答。返回的aiMessage是一条AIMessage。messages.push(aiMessage):把模型刚说的话也追加进数组——这是"记忆"的关键一步,漏了下次模型就失忆。sessions.set(...):把更新后的数组存回Map,等下一轮取用。
和上篇
/chat/simple对比:那个是model.invoke(body.input)(字符串)、返回{ input, output };这个是model.invoke(messages)(数组)、只返回{ output }——因为历史已经存在sessions里了,没必要回传。
路由挂载:
routes/index.js里apiRouter.use('/lc', langchanRouter),所以完整路径是/api/lc/agent/chat。
四、查看记忆:GET /api/lc/agent/threads/:threadId
光存不看得见,读者会怀疑"到底记没记"。这个接口把历史读出来,肉眼可验证:
// 拿缓存
langchanRouter.get(
'/agent/threads/:threadId',
asyncRoutes(async (req, res) => {
const threadId = String(req.params.threadId);
const messages = sessions.get(threadId) ?? [];
res.json({
ok: true,
data: {
threadId,
output: formateConveration(messages)
}
});
})
)
格式化逻辑在 src/ai/tools.js:
export const formateConveration = (messages) => {
const _messages = messages.map((message) => ({
type: message._getType(), // human / ai / system ...
content: message.content,
}));
const filted = _messages.filter(msg => msg.type === "human" || msg.type === 'ai');
return filted.map(msg => `${msg.type}: ${msg.content}`).join('\n');
}
两个细节:
message._getType():LangChain 消息对象的方法,返回角色类型字符串('human'/'ai'/'system'等)。这就是模型识别"谁说了哪句"的依据——所以别自己拼{ role, content }普通对象,模型读不懂。- 这里只保留
human和ai,把系统提示、工具消息过滤掉,输出更易读。
最终返回长这样:human: 我叫左耳\nai: 你好左耳,我记住了...,一眼就能确认记忆生效。
五、前端怎么用(React + TS)
前端 web/src/App.tsx 的 ChatDemo 里,记忆相关就三个按钮,关键在 threadId 这个 state:
import { useState } from "react";
import { getJson, postJson } from "./api";
function ChatDemo() {
const [input, setInput] = useState("你是谁");
const [threadId, setThreadId] = useState("course-user-1");
// 带记忆聊天
const chatWithMemory = () =>
postJson("/api/lc/agent/chat", { threadId, input });
// 查看记忆
const viewMemory = () =>
getJson(`/api/lc/agent/threads/${threadId}`);
// ...省略 UI
}
关键点:
threadId是"会话身份" :同一个threadId连续点「带记忆聊天」,模型才会接着上文答;换一个threadId,就是一段全新对话。postJson/getJson是上篇封装的 fetch 工具(POST 发 JSON、GET 取 JSON,统一返回payload.data)。- 注意
getJson返回的是payload.data.output(因为查看记忆接口把数组格式化后放在data.output里)。
前端到这里只是「正确传 threadId + 正确点按钮」,真正的记忆逻辑全在后端那 6 行。
六、直接用 curl 验证(最直观)
开两个终端思维:先连续聊两轮,再查看记忆。
# 第一轮:告诉模型名字
curl -X POST http://127.0.0.1:3000/api/lc/agent/chat \
-H "Content-Type: application/json" \
-d '{"threadId":"demo-user-1","input":"我叫左耳,记住我的名字"}'
# 第二轮:问它记没记住(同一个 threadId!)
curl -X POST http://127.0.0.1:3000/api/lc/agent/chat \
-H "Content-Type: application/json" \
-d '{"threadId":"demo-user-1","input":"我刚才说我叫什么?"}'
# 查看这段会话的记忆
curl http://127.0.0.1:3000/api/lc/agent/threads/demo-user-1
第二轮模型能答出"你叫左耳",第三步返回里也能看到完整 human: / ai: 历史——记忆确实生效。
小实验:把第二轮的
threadId改成别的(比如demo-user-2),模型就会"失忆",因为它取不到demo-user-1的历史。这正好印证threadId就是记忆的钥匙。
七、踩坑提醒(都是实打实踩过的)
- 记忆存在
Map里 = 重启即丢、不跨进程:这是教学 demo 的极简写法,进程一停sessions清空,多实例部署也各自独立。生产环境要换 Redis / 数据库做持久化(LangChain 有ChatMessageHistory系列适配器)。 - 同一个
threadId才有连续记忆:想多轮就别每轮换 id;不传threadId会全落到默认demo-thread,所有请求共用一条历史,容易串台。 invoke传的是数组不是字符串:记忆接口是model.invoke(messages),基础聊天是model.invoke(input)。传错类型,模型会当成单条消息或报错。- 返回只有
output没有input:因为历史已存sessions,前端想展示完整对话得自己维护,或调「查看记忆」接口拉。 - 别自己拼消息对象:必须用
HumanMessage/AIMessage等 LangChain 类型,_getType()是模型识别角色的依据。 - 数组会无限增长:demo 没做截断,真实长对话要控制长度(滑动窗口 / 摘要),否则 token 爆炸、还可能超模型上下文。
- GET 路由里
String(req.params.threadId):路由参数本来就是字符串,这里转一下是防御性写法;POST 那边靠 Zod 的.string()兜底。
八、小结 & 下一篇
这一篇我们在「单轮聊天」骨架上加了记忆,做法朴素但直击本质:
- 用
Map<threadId, BaseMessage[]>存每段会话的历史 - 带记忆聊天:取出历史 → 追加
HumanMessage→model.invoke(数组)→ 追加AIMessage→ 存回 - 查看记忆:把数组格式化读出来,肉眼验证
- 前端只需正确传
threadId这个"会话钥匙"
记住一句话:LangChain 的记忆不是黑盒,它就是"把消息数组存好、下次整段喂回去" 。理解了这层,后面各种 Memory 封装你都能一眼看穿。
上一篇:用 Express + LangChain.js 跑通第一个 AI 聊天接口(前端 Node/React 全链路)
下一篇我们让回答"边生成边显示":流式输出(POST /api/lc/chat/stream + SSE),前端打字机效果从 0 跑通。
完整 LangChain.js + LangGraph 实战系列(含可运行源码与踩坑笔记)我持续在更新,想跟着敲的同学可以关注公众号「左耳击水兽」,后台回复 LC 拿每篇的配套代码~