给 AI 加记忆:LangChain.js 多轮对话两种写法(带记忆 / 查看记忆)

0 阅读7分钟

系列:LangChain.js 实战 · 第 2 篇 / 共 6 篇 标签:LangChain.js · 对话记忆 · 多轮对话 · 前端转 AI

上篇我们用 model.invoke(input) 跑通了「单轮聊天」:你发一句话,模型回一句话,发完就忘。真实产品里 AI 得记得上一轮说了什么——"我叫左耳""我刚说的订单号是多少"。这一篇就在这个骨架上加「对话记忆」。

先给结论,免得你被各种 BufferMemoryChatMessageHistory 名词绕晕: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 }
    });
  })
);

五步拆解:

  1. sessions.get(body.threadId) ?? [] :按 threadId 取历史;没有就空数组开新会话。
  2. messages.push(new HumanMessage(body.input)) :把用户这轮输入包成 HumanMessage 追加进去。HumanMessage@langchain/core/messages 导入。
  3. getChatModel().invoke(messages) :注意这里传的是数组 messages,不是字符串。模型会把它当作完整上下文来回答。返回的 aiMessage 是一条 AIMessage
  4. messages.push(aiMessage) :把模型刚说的话也追加进数组——这是"记忆"的关键一步,漏了下次模型就失忆。
  5. sessions.set(...) :把更新后的数组存回 Map,等下一轮取用。

和上篇 /chat/simple 对比:那个是 model.invoke(body.input)(字符串)、返回 { input, output };这个是 model.invoke(messages)(数组)、只返回 { output }——因为历史已经存在 sessions 里了,没必要回传。

路由挂载:routes/index.jsapiRouter.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 } 普通对象,模型读不懂。
  • 这里只保留 humanai,把系统提示、工具消息过滤掉,输出更易读。

最终返回长这样:human: 我叫左耳\nai: 你好左耳,我记住了...,一眼就能确认记忆生效。


五、前端怎么用(React + TS)

前端 web/src/App.tsxChatDemo 里,记忆相关就三个按钮,关键在 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 就是记忆的钥匙。


七、踩坑提醒(都是实打实踩过的)

  1. 记忆存在 Map 里 = 重启即丢、不跨进程:这是教学 demo 的极简写法,进程一停 sessions 清空,多实例部署也各自独立。生产环境要换 Redis / 数据库做持久化(LangChain 有 ChatMessageHistory 系列适配器)。
  2. 同一个 threadId 才有连续记忆:想多轮就别每轮换 id;不传 threadId 会全落到默认 demo-thread,所有请求共用一条历史,容易串台。
  3. invoke 传的是数组不是字符串:记忆接口是 model.invoke(messages),基础聊天是 model.invoke(input)。传错类型,模型会当成单条消息或报错。
  4. 返回只有 output 没有 input:因为历史已存 sessions,前端想展示完整对话得自己维护,或调「查看记忆」接口拉。
  5. 别自己拼消息对象:必须用 HumanMessage / AIMessage 等 LangChain 类型,_getType() 是模型识别角色的依据。
  6. 数组会无限增长:demo 没做截断,真实长对话要控制长度(滑动窗口 / 摘要),否则 token 爆炸、还可能超模型上下文。
  7. GET 路由里 String(req.params.threadId) :路由参数本来就是字符串,这里转一下是防御性写法;POST 那边靠 Zod 的 .string() 兜底。

八、小结 & 下一篇

这一篇我们在「单轮聊天」骨架上加了记忆,做法朴素但直击本质:

  • Map<threadId, BaseMessage[]> 存每段会话的历史
  • 带记忆聊天:取出历史 → 追加 HumanMessagemodel.invoke(数组) → 追加 AIMessage → 存回
  • 查看记忆:把数组格式化读出来,肉眼验证
  • 前端只需正确传 threadId 这个"会话钥匙"

记住一句话:LangChain 的记忆不是黑盒,它就是"把消息数组存好、下次整段喂回去" 。理解了这层,后面各种 Memory 封装你都能一眼看穿。

上一篇:用 Express + LangChain.js 跑通第一个 AI 聊天接口(前端 Node/React 全链路)

下一篇我们让回答"边生成边显示":流式输出POST /api/lc/chat/stream + SSE),前端打字机效果从 0 跑通。


完整 LangChain.js + LangGraph 实战系列(含可运行源码与踩坑笔记)我持续在更新,想跟着敲的同学可以关注公众号「左耳击水兽」,后台回复 LC 拿每篇的配套代码~