LangChain 进阶实战:当 Memory 遇上 OutputParser,打造有记忆的结构化助手

在当前的 LLM 应用开发中,我们经常陷入两个极端的场景:

  1. 记性好的话痨:类似于 ChatBot,能记住上下文,聊天体验流畅,但输出全是不可控的自然语言。
  2. 一次性的 API:类似于信息提取工具,能返回标准的 JSON 数据,但它是"无状态"的,每一轮调用都是全新的开始。

然而,在复杂的业务系统中,我们往往需要二者兼备:既要像人一样拥有记忆上下文的能力,又要像传统 API 一样返回严格的结构化数据(JSON)。

本文将基于 LangChain (LCEL) 体系,讲解如何将 Memory (记忆模块)OutputParser (输出解析器) 结合,打造一个既懂业务逻辑又能规范输出的智能助手。

第一部分:记忆的载体 (Review)

我们在之前的工程实践中已经明确:LLM 本身是无状态的(Stateless)。为了维持对话的连续性,我们需要在应用层手动维护历史消息。

在 LangChain 中,RunnableWithMessageHistory 是实现这一功能的核心容器。它的工作原理非常直观:

  1. 读取:在调用大模型前,从存储介质(Memory)中读取历史对话。
  2. 注入:将历史对话填充到 Prompt 的占位符(Placeholder)中。
  3. 保存:模型返回结果后,将"用户输入"和"AI 回复"追加到 Memory 中。

这是让 AI "拥有记忆"的基础设施。

第二部分:输出的规整 (The Parser)

模型原生的输出是 BaseMessage 或纯文本字符串。直接在业务代码中使用 JSON.parse() 处理模型输出是非常危险的,原因如下:

  • 幻觉与废话:模型可能会在 JSON 前后添加 "Here is your JSON" 之类的自然语言。
  • 格式错误:Markdown 代码块符号(```json)会破坏 JSON 结构。
  • 字段缺失:模型可能忘记输出某些关键字段。

LangChain 提供了 OutputParser 组件来充当"翻译官"和"校验员"。

1. StringOutputParser

最基础的解析器。它将模型的输出(Message 对象)转换为字符串,并自动去除首尾的空白字符。这在处理简单的文本生成任务时非常有用。

2. StructuredOutputParser (重点)

这是工程化中最常用的解析器。它通常与 Zod 库结合使用,能够:

  • 生成提示词:自动生成一段 Prompt,告诉模型"你需要按照这个 JSON Schema 输出"。
  • 解析结果:将模型返回的文本清洗并解析为标准的 JavaScript 对象。
  • 校验数据:确保返回的数据类型符合定义(如 age 必须是数字)。

第三部分:核心实战 (The Fusion)

接下来,我们将构建一个**"用户信息收集助手"**。
需求:助手与用户对话,记住用户的名字(Memory),并根据对话内容提取用户的详细信息(Parser),最终输出包含 { name, age, job } 的标准 JSON 对象。

以下是基于 LangChain LCEL 的完整实现代码:

1. 环境准备与依赖

确保安装了 @langchain/core, @langchain/deepseek, zod。

2. 代码实现

JavaScript

javascript 复制代码
import { ChatDeepSeek } from "@langchain/deepseek";
import { ChatPromptTemplate, MessagesPlaceholder } from "@langchain/core/prompts";
import { RunnableWithMessageHistory } from "@langchain/core/runnables";
import { InMemoryChatMessageHistory } from "@langchain/core/chat_history";
import { StructuredOutputParser } from "@langchain/core/output_parsers";
import { z } from "zod";
import 'dotenv/config';

// 1. 定义输出结构 (Schema)
// 我们希望模型最终返回的数据格式
const parser = StructuredOutputParser.fromZodSchema(
  z.object({
    name: z.string().describe("用户的姓名,如果未知则为 null"),
    age: z.number().nullable().describe("用户的年龄,如果未知则为 null"),
    job: z.string().nullable().describe("用户的职业,如果未知则为 null"),
    response: z.string().describe("AI 对用户的自然语言回复")
  })
);

// 获取格式化指令,这会自动生成一段类似 "You must format your output as a JSON value..." 的文本
const formatInstructions = parser.getFormatInstructions();

// 2. 初始化模型
const model = new ChatDeepSeek({
  model: "deepseek-chat", // 使用适合对话的模型
  temperature: 0, // 设为 0 以提高结构化输出的稳定性
});

// 3. 构建 Prompt 模板
// 关键点:
// - history: 用于存放历史记忆
// - format_instructions: 用于告诉模型如何输出 JSON
const prompt = ChatPromptTemplate.fromMessages([
  ["system", "你是一个用户信息收集助手。你的目标是从对话中提取用户信息。\n{format_instructions}"],
  ["placeholder", "{history}"], // 历史消息占位符
  ["human", "{input}"]
]);

// 4. 构建处理链 (Chain)
// 数据流向:Prompt -> Model -> Parser
const chain = prompt.pipe(model).pipe(parser);

// 5. 挂载记忆模块
// 使用内存存储历史记录 (生产环境应替换为 Redis 等)
const messageHistory = new InMemoryChatMessageHistory();

const chainWithHistory = new RunnableWithMessageHistory({
  runnable: chain,
  getMessageHistory: async (sessionId) => {
    // 实际业务中应根据 sessionId 获取对应的历史记录
    return messageHistory;
  },
  inputMessagesKey: "input",
  historyMessagesKey: "history",
});

// 6. 执行与测试
async function run() {
  const sessionId = "user_session_123";

  console.log("--- 第一轮对话 ---");
  const res1 = await chainWithHistory.invoke(
    {
      input: "你好,我叫陈总,我是一名全栈工程师。",
      format_instructions: formatInstructions // 注入格式化指令
    },
    { configurable: { sessionId } }
  );
  
  // 此时 res1 已经是一个标准的 JSON 对象,而不是字符串
  console.log("解析后的输出:", res1);
  // 输出示例: { name: '陈总', age: null, job: '全栈工程师', response: '你好陈总,很高兴认识你!' }

  console.log("\n--- 第二轮对话 ---");
  const res2 = await chainWithHistory.invoke(
    {
      input: "我今年35岁了。",
      format_instructions: formatInstructions
    },
    { configurable: { sessionId } }
  );

  console.log("解析后的输出:", res2);
  // 输出示例: { name: '陈总', age: 35, job: '全栈工程师', response: '好的,记录下来了,你今年35岁。' }
}

run();

第四部分:工程化思考

在将 Memory 和 Parser 结合时,有几个关键的工程细节需要注意:

1. 数据流向与调试

在上面的代码中,数据流向是:

User Input -> Prompt Template (注入 History + Format Instructions) -> LLM -> String Output -> Output Parser -> JSON Object。

如果你发现报错,通常是因为模型没有严格遵循 formatInstructions。建议在开发阶段使用 ConsoleCallbackHandler 或 LangSmith 监控中间步骤,查看传递给模型的最终 Prompt 是否包含了正确的 JSON Schema 定义。

2. 记忆存储的内容

这是一个极其容易被忽略的点:Memory 中到底存了什么?

在 RunnableWithMessageHistory 的默认行为中,它会尝试存储 Chain 的输入和输出。

  • 输入:{ input: "..." } (文本)
  • 输出 :经过 Parser 处理后的 JSON 对象

当下一轮对话开始时,LangChain 会尝试将这个 JSON 对象注入到 Prompt 的 {history} 中。虽然 LangChain 会尝试将其序列化为字符串,但为了保证 Prompt 的语义清晰,建议模型生成的 response 字段专门用于维持对话上下文,而结构化数据则用于业务逻辑处理。

3. Token 消耗

引入 StructuredOutputParser 会显著增加 Prompt 的长度(因为它注入了复杂的 Schema 定义)。在多轮对话中,如果历史记录也越来越长,很容易超出上下文窗口或导致 API 费用激增。务必配合 ConversationSummaryMemory(摘要记忆)或限制历史消息条数。

结语

LangChain 的魅力在于其组件的积木式组合。通过将 RunnableWithMessageHistory(状态管理)与 StructuredOutputParser(输出规整)串联,我们将 LLM 从一个"不可控的聊天机器人"进化为了一个"有状态的业务处理单元"。

掌握这一套组合拳,是在生产环境构建复杂 AI Agent 的必经之路。

相关推荐
小兵张健10 小时前
价值1000的 AI 工作流:Codex 通用前端协作模式
前端·aigc·ai编程
sunny_10 小时前
面试踩大坑!同一段 Node.js 代码,CJS 和 ESM 的执行顺序居然是反的?!99% 的人都答错了
前端·面试·node.js
拉不动的猪10 小时前
移动端调试工具VConsole初始化时的加载阻塞问题
前端·javascript·微信小程序
ayqy贾杰12 小时前
Agent First Engineering
前端·vue.js·面试
IT_陈寒12 小时前
SpringBoot实战:5个让你的API性能翻倍的隐藏技巧
前端·人工智能·后端
iceiceiceice13 小时前
iOS PDF阅读器段评实现:如何从 PDFSelection 精准还原一个自然段
前端·人工智能·ios
大金乄13 小时前
封装一个vue2的elementUI 表格组件(包含表格编辑以及多级表头)
前端·javascript
葡萄城技术团队14 小时前
【性能优化篇】面对万行数据也不卡顿?揭秘协同服务器的“片段机制 (Fragments)”
前端
程序员阿峰14 小时前
2026前端必备:TensorFlow.js,浏览器里的AI引擎,不写Python也能玩转智能
前端
Jans14 小时前
Shipfe — Rust 写的前端静态部署工具:一条命令上线 + 零停机 + 可回滚 + 自动清理
前端