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

0 阅读6分钟

在当前的 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

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 的必经之路。