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

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

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

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

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

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

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

先来一个最简单的实验:

js 复制代码
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 数组:

js 复制代码
let messages = [
  { role: "user", content: "我叫彭于晏,一个演员" },
  { role: "assistant", content: "好的,彭于晏先生!" },
  { role: "user", content: "我叫什么名字?" }
];

await model.invoke(messages); // 这次就能答对了

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

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

这时候,LangChain 登场了。


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

先看完整代码

javascript 复制代码
import { ChatDeepSeek } from "@langchain/deepseek";
import { ChatPromptTemplate } from "@langchain/core/prompts";
//带上历史记录的可运行对象
import { RunnableWithMessageHistory } from "@langchain/core/runnables";
import 'dotenv/config';
//存在内存之中
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";


const model = new ChatDeepSeek({
    model:'deepseek-chat',
    temperature:0
});
//chat 模式 数组
const prompt = ChatPromptTemplate.fromMessages([
    ['system',"你是一个有记忆的助手"],
    ['placeholder',"{history}"],
    ['human',"{input}"]
] )

const runnable = prompt
    .pipe((input)=>{// debug 节点
        console.log('>>>最终传给模型的信息(prompt 内存)');
        console.log(input);
        return input
        
    })
    .pipe(model);
//对话历史实例
    const messageHistory = new InMemoryChatMessageHistory();
    const chain = new RunnableWithMessageHistory({
        runnable,
        getMessageHistory:async ()=> messageHistory,
        inputMessagesKey:'input' ,
        historyMessagesKey:'history'
    })

    const res1 = await chain.invoke({
        input:'我叫彭于晏,一个演员',
        
    },
    {
            configurable:{
                sessionId:'makefriend'
            }
        }
    )

    console.log(res1.content);

  const res2 = await chain.invoke({
        input:'我叫什么名字',
        
    },
    {
            configurable:{
                sessionId:'makefriend'
            }
        }
    )
console.log(res2.content);

    

带记忆的链

JavaScript

dart 复制代码
const chain = new RunnableWithMessageHistory({
    runnable,                                      // ①
    getMessageHistory: async () => messageHistory, // ②
    inputMessagesKey: 'input',                     // ③
    historyMessagesKey: 'history'                  // ④
})

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


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

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

在代码中:

JavaScript

ini 复制代码
const runnable = prompt
    .pipe(debug节点)
    .pipe(model);

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

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

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


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

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

JavaScript

vbnet 复制代码
getMessageHistory: async () => messageHistory

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

真实项目中应该怎么写?

JavaScript

csharp 复制代码
// 推荐:用 Map 存储多个会话的历史
const store = new Map<string, InMemoryChatMessageHistory>();

const chain = new RunnableWithMessageHistory({
  // ...
  getMessageHistory: async (sessionId: string) => {
    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 是当前用户的新输入"。

你调用时是这样写的:

JavaScript

css 复制代码
chain.invoke({
  input: '我叫彭于晏,一个演员'
}, { configurable: { 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 会构造一个新输入对象:

JavaScript

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

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

你的 prompt 正好有:

JavaScript

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

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

text

css 复制代码
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)

输入对象:

JavaScript

css 复制代码
{
  input: "我叫什么名字",
  history: [
    HumanMessage("我叫彭于晏,一个演员"),
    AIMessage("好的,记住了!你好彭于晏!")
  ]
}

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

JavaScript

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

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

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

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

同样的输入对象:

JavaScript

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

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

结果可能是:

text

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

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

JavaScript

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

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

text

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

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

结果:完全失忆


总结:这四个参数的"灵魂配合"

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

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

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

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

最 naive 的写法:

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

结果往往是:

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

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

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

问题出在:

  • 模型是"生成型"的,不是"服从型"的
  • 它更倾向于"自然对话",而不是严格遵守格式
  • 即使提示写得再严,也偶尔会"叛变"

传统解决方案:正则 + 手动解析

js 复制代码
const jsonStr = response.match(/\{.*\}/s)[0];
JSON.parse(jsonStr); // 祈祷别出错

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


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

来看最佳实践:

js 复制代码
const FrontendConceptSchema = z.object({
  name: z.string().describe("概念名称"),
  core: z.string().describe("核心要点"),
  useCase: z.array(z.string()).describe("常见使用场景"),
  difficulty: z.enum(['简单','中等','复杂']).describe("学习难度")
});

const jsonParser = new JsonOutputParser(FrontendConceptSchema);

Zod 是什么?为什么这么强?

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

你用代码定义数据契约:

ts 复制代码
type FrontendConcept = z.infer<typeof FrontendConceptSchema>;
// 自动推导为:
interface FrontendConcept {
  name: string;
  core: string;
  useCase: string[];
  difficulty: '简单' | '中等' | '复杂';
}

完整链路是怎么工作的?

js 复制代码
const chain = prompt.pipe(model).pipe(jsonParser);

执行流程:

  1. prompt 中插入 jsonParser.getFormatInstructions()
    • 自动生成一段精确的 JSON 格式说明,注入到 {format_instructions}
  2. 模型看到严格指令,更大概率输出正确 JSON
  3. 模型输出文本 → 进入 JsonOutputParser
  4. 解析器做两件事:
    • JSON.parse() 转对象
    • schema.parse() 用 Zod 严格校验
  5. 任意一项失败 → 抛错(你可以 catch 重试)

实际生成的 format_instructions(自动!)

text 复制代码
The output should be a valid JSON formatted according to the following schema:
{
  "name": "string",
  "core": "string",
  "useCase": ["string"],
  "difficulty": "enum(['简单', '中等', '复杂'])"
}
Only return the JSON object, no additional text.

为什么这比手动写 prompt 强 100 倍?

项目 手动写 prompt Zod + JsonOutputParser
格式说明一致性 容易写错、漏改 自动生成,永远正确
修改字段成本 要改多处 prompt 只改一处 Schema
运行时安全 无校验,祈祷模型听话 严格 parse,错就报错
TypeScript 支持 res 是 any 自动推导精确类型,IDE 提示完美
复杂结构支持 嵌套、联合类型很难描述 原生支持 transform、refine 等
可复用性 每个链都要复制 prompt Schema 定义一次,全局复用

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

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

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


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

早期前端的尴尬:

html 复制代码
<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 存在的意义。

相关推荐
Maxkim2 小时前
「✍️JS原子笔记 」一文搞懂 call、apply、bind 特征及手写实现
前端·javascript·面试
A24207349302 小时前
深入理解JS DOM:从基础操作到性能优化的全面指南
开发语言·javascript·ecmascript
Zyx20072 小时前
手写 `new`:揭开 JavaScript 实例化背后的秘密
javascript
布局呆星2 小时前
Vue 3 事件处理与列表渲染---02
前端·javascript·vue.js
syt_10132 小时前
设计模式之-状态模式
javascript·设计模式·状态模式
Mintopia2 小时前
🤖 AI 对话斜街的文件处理秘笈:PDF、图片与 ClaudeCod 的花式对接之道
人工智能·llm·aigc
未寒2 小时前
关于uni app vue2 和vue3 的区别
前端·javascript·vue.js·uni-app
Mintopia2 小时前
🌌 多模态 AI 的崛起:语言、图像与视频的融合革命
人工智能·llm·aigc
Aevget2 小时前
DevExtreme JS & ASP.NET Core v25.2新功能预览 - 字体栈、可访问性升级增强
javascript·asp.net·界面控件·devexpress·ui开发·devextreme