LangChain 实战:让 LLM 拥有记忆与结构化输出能力

大家都在热烈讨论大模型应用落地。其中两个最常见、最棘手的痛点就是:

  1. LLM 没有记忆:每次调用都像第一次见面,问"你叫什么名字",它永远回答"我是AI助手"......
  2. LLM 输出不听话:让你返回 JSON,它偏要加解释、前言后语、甚至格式错得离谱。

今天,我们就用 LangChain.js彻底解决这两个问题。通过真实代码 + 底层原理剖析,手把手带你实现:

  • 有状态的多轮对话(带记忆)
  • 强制结构化 JSON 输出(带运行时校验)

一、为什么 LLM 天生"失忆"?

先来一个最简单的实验:

复制代码
import { ChatDeepSeek } from "@langchain/deepseek";
import "dotenv/config";


const model = new ChatDeepSeek({
  model: "deepseek-chat",
  temperature: 0,
});
const res1 = await model.invoke('我叫彭于晏,一个演员');
console.log(res1.content); // 助手愉快回应

const res2 = await model.invoke('我叫什么名字');
console.log(res2.content); // "我不知道你叫什么名字......"

为什么会这样?

因为所有主流 LLM API(OpenAI、DeepSeek、Claude 等)都是无状态的,就像普通的 HTTP 请求一样:

  • 你发一个请求 → 模型处理 → 返回响应
  • 下一次请求 → 模型完全不记得上一次发生了什么

这就像你去饭店点菜,每次都要重新自我介绍:"你好,我是彭于晏,今天想吃麻辣烫"......服务员永远一脸茫然。

传统解决方案:手动维护消息历史

最原始的做法是自己维护一个 messages 数组:

复制代码
import { ChatDeepSeek } from "@langchain/deepseek";
import "dotenv/config";


const model = new ChatDeepSeek({
  model: "deepseek-chat",
  temperature: 0,
});
let messages = [
  { role: "user", content: "我叫彭于晏,一个演员" },
  { role: "assistant", content: "好的,彭于晏先生!" },
  { role: "user", content: "我叫什么名字?" }
];

let res = await model.invoke(messages); // 这次就能答对了
console.log(res.content);

这确实能工作,但问题很快暴露:

  • 对话越长,messages 越长 → Token 消耗雪球式增长
  • 每次都要手动拼接历史,代码丑陋且容易出错
  • 多用户场景?需要为每个用户维护一个消息列表,复杂度爆炸

这时候,LangChain 登场了。


二、LangChain 如何优雅实现"记忆"?

复制代码
import { ChatDeepSeek } from "@langchain/deepseek";
import { ChatPromptTemplate } from "@langchain/core/prompts";

// 带历史记录能力的 Runnable
import { RunnableWithMessageHistory } from "@langchain/core/runnables";

// 加载 .env 环境变量
import "dotenv/config";

// 内存聊天记录
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";


// ====================
// 1. 初始化模型
// ====================
const model = new ChatDeepSeek({
  model: "deepseek-chat", // 使用的模型
  temperature: 0,         // 0 = 输出稳定
});


// ====================
// 2. 创建 Prompt 模板
// ====================
// fromMessages 按 Chat 格式组织消息
const prompt = ChatPromptTemplate.fromMessages([
  // 系统提示词
  ["system", "你是一个有记忆的助手"],

  // 历史记录占位符
  // RunnableWithMessageHistory 会自动把历史消息填充到这里
  ["placeholder", "{history}"],

  // 用户输入
  ["human", "{input}"],
]);


// ====================
// 3. 构建链
// Prompt -> Debug -> Model
// ====================
const runnable = prompt
  .pipe((input) => {
    // 调试节点
    // 查看最终发送给模型的数据
    console.log(">>> 最终传给模型的信息(包含历史记录)");
    console.log(input);

    // 必须返回数据给下一个节点
    return input;
  })
  .pipe(model);


// ====================
// 4. 创建聊天记录存储
// ====================
// 所有对话暂时保存在内存中
// 程序关闭后数据会丢失
const messageHistory = new InMemoryChatMessageHistory();


// ====================
// 5. 给链增加记忆能力
// ====================
const chain = new RunnableWithMessageHistory({
  runnable,

  // 获取历史记录
  // 实际项目中可以根据 sessionId
  // 从 Redis / 数据库中获取不同用户的聊天记录
  getMessageHistory: async (sessionId) => {
    return messageHistory;
  },

  // 用户输入字段名
  inputMessagesKey: "input",

  // Prompt 中历史记录占位符名称
  historyMessagesKey: "history",
});


// ====================
// 6. 第一次对话
// ====================
const res1 = await chain.invoke(
  {
    input: "我叫彭于晏,一个演员",
  },
  {
    configurable: {
      // 会话 ID
      // 相同 sessionId 共用历史记录
      sessionId: "makefriend",
    },
  }
);

console.log("AI:", res1.content);


// ====================
// 7. 第二次对话
// ====================
const res2 = await chain.invoke(
  {
    input: "我叫什么名字",
  },
  {
    configurable: {
      // 与第一次相同
      // 所以能读取之前的历史记录
      sessionId: "makefriend",
    },
  }
);

console.log("AI:", res2.content);
带记忆的链
复制代码
const chain = new RunnableWithMessageHistory({
    runnable,                                      // ①
    getMessageHistory: async () => messageHistory, // ②
    inputMessagesKey: 'input',                     // ③
    historyMessagesKey: 'history'                  // ④
})

这四个参数就是整个记忆机制的命脉,缺一个都不行。下面我详细解释每个参数的作用、为什么需要它、底层到底是怎么工作的。

① runnable: 你的"核心处理链"是什么?

作用:这是你要"加记忆"的那条原始链,也就是不带记忆时的完整处理流程。

在代码中:

复制代码
// ====================
// 3. 构建链
// Prompt -> Debug -> Model
// ====================
const runnable = prompt
  .pipe((input) => {
    // 调试节点
    // 查看最终发送给模型的数据
    console.log(">>> 最终传给模型的信息(包含历史记录)");
    console.log(input);

    // 必须返回数据给下一个节点
    return input;
  })
  .pipe(model);

它本质上是一个 Runnable 对象,负责把输入 → 处理 → 输出(即:把 {input, history} 格式化成消息列表 → 传给模型 → 返回回复)。

为什么需要它? RunnableWithMessageHistory 本身不负责业务逻辑,它只是一个"包装器"(wrapper)。它要包装的对象就是这个 runnable------它会自动在每次调用前,后对 runnable 的输入和输出做增强(注入历史 + 保存新消息)。

底层逻辑: 当你调用 chain.invoke(...) 时,实际上是 RunnableWithMessageHistory 先接管请求,改造输入后再调用 runnable.invoke(...),最后再处理输出。

② getMessageHistory: 如何获取/存储当前会话的历史?

作用:一个异步函数,每次调用时根据会话 ID 返回对应的聊天历史对象。

复制代码
  // 获取历史记录
  // 实际项目中可以根据 sessionId
  // 从 Redis / 数据库中获取不同用户的聊天记录
  getMessageHistory: async (sessionId) => {
    return messageHistory;
  },

这里用了同一个 InMemoryChatMessageHistory() 实例,相当于所有 sessionId 共用一个历史。

真实项目中应该怎么写?

复制代码
// 推荐:使用 Map 存储多个会话的聊天历史
// key:sessionId,会话 ID
// value:对应会话的聊天历史
const store = new Map();

/**
 * 创建带有消息历史记录的 Runnable
 */
const chain = new RunnableWithMessageHistory({
  // 根据 sessionId 获取对应的聊天历史
  getMessageHistory: async (sessionId) => {
    // 如果当前会话不存在
    if (!store.has(sessionId)) {
      // 创建新的聊天历史
      store.set(
        sessionId,
        new InMemoryChatMessageHistory()
      );
    }

    // 返回当前会话的聊天历史
    return store.get(sessionId);
  },

  // 其他配置...
});

为什么是 async? 因为生产环境你可能要从 Redis、MongoDB、MySQL 等外部存储读取历史,必须是异步操作。

每次 invoke 时发生了什么?

  1. 你传了 configurable: { sessionId: 'makefriend' }
  2. LangChain 自动从 getMessageHistory('makefriend') 拿到历史对象
  3. 取出里面的所有历史消息(AIMessage / HumanMessage 列表)
③ inputMessagesKey: 'input'

作用 :告诉 LangChain,"你每次 invoke 传进来的对象里,哪一个 key 是当前用户的新输入"。

你调用时是这样写的:

复制代码
const res1 = await chain.invoke(
  {
    input: "我叫彭于晏,一个演员",
  },
  {
    configurable: {
      // 会话 ID
      // 相同 sessionId 共用历史记录
      sessionId: "makefriend",
    },
  }
);

所以这里必须写 'input'。

它干了三件事

  1. 注入到 Prompt:把这个值填充到 prompt 里的 {input} 占位符(对应 'human', "{input}"
  2. 转为 HumanMessage:内部会把 input 的值包装成一条 HumanMessage
  3. 保存到历史:调用结束后,这条 HumanMessage + 模型的回复(AIMessage)会被自动添加到 messageHistory 中

如果写错会怎样? 比如你写成 inputMessagesKey: 'question',但 invoke 时传的是 { input: '...' } → 报错:找不到当前输入消息。

④ historyMessagesKey: 'history'

作用:告诉 LangChain,"我要把历史消息列表注入到 runnable 的输入对象里,用哪个 key 名?"

它和 prompt 模板里的 'placeholder', "{history}" 必须完全对应。 我们一步步看 LangChain 是怎么把输入变成最终发给模型的消息列表的。

底层执行流程是这样的

每次 chain.invoke 时,LangChain 会构造一个新输入对象:

复制代码
{
  input: "用户当前说的话",                  // 来自 inputMessagesKey
  history: [                               // 来自 getMessageHistory 取出的历史
    HumanMessage("我叫彭于晏,一个演员"),
    AIMessage("好的,记住了!"),
    // ... 更多历史
  ]
}

然后把这个对象传给你的 runnable(也就是 prompt → model)。

你的 prompt 正好有:

复制代码
['placeholder', "{history}"],  // 会把整个历史消息列表塞进去
['human', "{input}"]

所以模型看到的完整消息列表就是:

复制代码
System: 你是一个有记忆的助手
... 所有历史消息(从 {history} 注入)
Human: 我叫什么名字(从 {input} 注入)

如果 key 不匹配会怎样? 比如你写 historyMessagesKey: 'chatHistory',但 prompt 用的是 {history} → 历史根本不会注入,模型还是失忆。


完整执行时序图

这就是为什么第二次问"我叫什么名字"时,模型能答对------历史已经被正确注入并保存了。

易错点大汇总(必看!)
  1. sessionId 没传或传错 → 每次都是新历史,永远失忆
  2. inputMessagesKey / historyMessagesKey 和 prompt 不匹配 → 历史不注入或当前输入丢失
  3. getMessageHistory 没根据 sessionId 分隔存储 → 多用户串历史(演示代码的坑)
  4. 用了 InMemoryChatMessageHistory 但没考虑服务重启 → 重启后记忆全丢(生产必须换持久化存储)
  5. prompt 没用 MessagesPlaceholder(即 placeholder) → 历史不会正确展开成多条消息
我们来深入了解一下placeholder
情况1:正确使用 MessagesPlaceholder(placeholder)
复制代码
{
  input: "我叫什么名字",
  history: [
    HumanMessage("我叫彭于晏,一个演员"),
    AIMessage("好的,记住了!你好彭于晏!")
  ]
}

经过 ChatPromptTemplate 处理后,最终发给模型的消息数组是:

复制代码
[
  { role: "system", content: "你是一个有记忆的助手" },
  { role: "human", content: "我叫彭于晏,一个演员" },     // 来自 history 展开
  { role: "assistant", content: "好的,记住了!你好彭于晏!" }, // 来自 history 展开
  { role: "human", content: "我叫什么名字" }                 // 来自 {input}
]

注意:历史消息被一条一条展开,每一轮的 role 都完整保留!

模型看到的是一个完整的多轮对话上下文,自然就能"记住"你叫彭于晏。

情况2:错误地用普通字符串替换 {history}

同样的输入对象:

复制代码
{
  input: "我叫什么名字",
  history: [ ... 两条消息对象 ... ]
}

但因为你用了普通字符串模板,LangChain 在填充 {history} 时,会调用 .toString() 或直接序列化这个消息数组。

结果可能是:

复制代码
历史对话:[{"type":"human","content":"我叫彭于晏,一个演员"},{"type":"assistant","content":"好的,记住了!你好彭于晏!"}]

最终发给模型的消息数组变成:

复制代码
[
  { role: "system", content: "你是一个有记忆的助手" },
  { role: "human", content: "历史对话:[{"type":"human",...}]" },  // 一大坨 JSON 字符串!
  { role: "human", content: "我叫什么名字" }
]

这时候模型看到的上下文是:

复制代码
系统:你是一个有记忆的助手
用户:历史对话:[{"type":"human","content":"我叫彭于晏,一个演员"}, ... ]
用户:我叫什么名字

模型根本不知道这坨 JSON 字符串是上一轮对话!它只会觉得你在说一段奇怪的代码,或者直接忽略。

结果:完全失忆


总结:这四个参数的"灵魂配合"
参数 对应位置 作用本质
runnable 你的核心链 被包装的对象
getMessageHistory 外部存储 / Map 读写历史的地方
inputMessagesKey invoke 时传的 key + prompt 当前用户输入的标识与注入点
historyMessagesKey prompt 中的 placeholder 历史消息列表的注入点

它们就像四个齿轮,缺一不可,咬合得天衣无缝,才实现了"有状态的 LLM 调用"。

三、为什么 LLM 输出 JSON 这么不靠谱?

另一个经典场景:你想让模型返回结构化数据。

复制代码
prompt = "请用 JSON 格式返回前端概念信息,包含 name、core、useCase、difficulty 字段。话题:Promise"

结果往往是:

复制代码
好的,以下是 Promise 的信息:

{
  "name": "Promise",
  "core": "...",
  // ... 可能缺字段、多字段、键名写错
}

如果你有其他问题欢迎继续提问!

问题出在:

  • 模型是"生成型"的,不是"服从型"的
  • 它更倾向于"自然对话",而不是严格遵守格式
  • 即使提示写得再严,也偶尔会"叛变"
传统解决方案:正则 + 手动解析
复制代码
const jsonStr = response.match(/\{.*\}/s)[0];
JSON.parse(jsonStr); // 祈祷别出错

风险极高,一出错整个链崩。

四、LangChain + Zod:强制结构化输出的终极方案

来看最佳实践:

复制代码
import { ChatDeepSeek } from "@langchain/deepseek";
import { z } from "zod";

import "dotenv/config";

// ====================
// 1. 初始化模型
// ====================

const model = new ChatDeepSeek({
  model: "deepseek-chat",
  temperature: 0,
});

// ====================
// 2. 定义返回的数据结构
// ====================

const FrontendConceptSchema = z.object({
  name: z.string().describe("概念名称"),

  core: z.string().describe("核心要点"),

  useCase: z
    .array(z.string())
    .describe("常见使用场景"),

  difficulty: z
    .enum(["简单", "中等", "复杂"])
    .describe("学习难度"),
});

// ====================
// 3. 创建结构化输出模型
// ====================

const structuredModel = model.withStructuredOutput(
  FrontendConceptSchema
);

// ====================
// 4. 调用模型
// ====================

const result = await structuredModel.invoke(
  "请介绍一下 Vue 的 computed"
);

// ====================
// 5. 输出结果
// ====================

console.log(result);
Zod 是什么?为什么这么强?

Zod 是一个 TypeScript 第一的运行时类型校验库

你用代码定义数据契约:

复制代码
type FrontendConcept = z.infer<typeof FrontendConceptSchema>;
// 自动推导为:
interface FrontendConcept {
  name: string;
  core: string;
  useCase: string[];
  difficulty: '简单' | '中等' | '复杂';
}
为什么这比手动写 prompt 强 100 倍?
项目 手动写 prompt Zod + JsonOutputParser
格式说明一致性 容易写错、漏改 自动生成,永远正确
修改字段成本 要改多处 prompt 只改一处 Schema
运行时安全 无校验,祈祷模型听话 严格 parse,错就报错
TypeScript 支持 res 是 any 自动推导精确类型,IDE 提示完美
复杂结构支持 嵌套、联合类型很难描述 原生支持 transform、refine 等
可复用性 每个链都要复制 prompt Schema 定义一次,全局复用

真实案例 :你后来想加 relatedConcepts: string[] 字段

→ 只需改一行 Zod,其他全部自动同步!

永远记得:Zod Schema 的 key 名必须和提示中要求的完全一致!


五、从早期 JS 模块化看现代工程化演进

早期前端的尴尬:

复制代码
<script src="./a.js"></script>
<script>
  const p = new Person('张三',18);
  p.sayName();
</script>
  • 全局污染严重
  • 依赖顺序必须手动控制
  • 没有作用域隔离

这才有了:

  • CommonJS(Node.js)
  • AMD/CMD(RequireJS)
  • 最终 ES6 Modules(import/export

LangChain 的设计哲学也是如此:

  • 早期:手动拼接 messages、手动解析 JSON
  • 现在:模块化、可组合、类型安全、自动管理

这正是现代 AI 工程化的方向。


总结:两个核心能力,缺一不可

能力 解决方案 核心类/工具 推荐程度
多轮对话记忆 RunnableWithMessageHistory InMemoryChatMessageHistory / Redis ⭐⭐⭐⭐⭐
结构化输出 JsonOutputParser + Zod z.object() + getFormatInstructions() ⭐⭐⭐⭐⭐

掌握了这两招,你的 LLM 应用就从"玩具"升级为"生产级工具":

  • 聊天机器人能记住用户
  • 数据提取接口稳定可靠
  • 前端直接对接类型安全的响应
  • 维护成本大幅降低

最后送上一句心得

大模型很强大,但"强大"不等于"可靠"。

真正的工程能力,是在不可靠的生成模型之上,构建一层可靠的、类型安全的、可维护的系统。

这才是 LangChain 存在的意义。

相关推荐
chaors7 小时前
DeepResearchSystem 0x08:KB 知识记忆
langchain·agent·ai编程
北斗落凡尘12 小时前
LangGraph 入门实战(2)
python·langchain
北斗落凡尘16 小时前
如何使用LangGraph(1)
python·langchain
JaydenAI17 小时前
[基于OpenEvals的自动化评估-11]针对Agent对话的评估[下篇]
ai·langchain·agent·evaluation·openevals
小白的后端世界20 小时前
LangChain 模型初始化参数详解:从基础配置到企业级实践
java·人工智能·langchain
做前端的娜娜子1 天前
第一个AI调用——TypeScript 工程初始化与配置管理(实战跟练)
langchain·openai·掘金·金石计划
为你学会写情书1 天前
从 NestJS 基础到 LangChain 实战:构建可维护的 AI 应用架构
langchain
circuitsosk1 天前
Prompt Engineering进阶:面向复杂业务场景的模板化管理与动态注入策略
python·langchain·prompt·跨境电商·rag·上下文管理·动态注入
JaydenAI2 天前
[基于OpenEvals的自动化评估-07]评估Agent输出文本的质量[下篇]
ai·langchain·agent·evaluation·openevals