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

相关推荐
mCell1 天前
如何零成本搭建个人站点
前端·程序员·github
mCell1 天前
为什么 Memo Code 先做 CLI:以及终端输入框到底有多难搞
前端·设计模式·agent
恋猫de小郭1 天前
AI 在提高你工作效率的同时,也一直在增加你的疲惫和焦虑
前端·人工智能·ai编程
少云清1 天前
【安全测试】2_客户端脚本安全测试 _XSS和CSRF
前端·xss·csrf
deephub1 天前
Agent Lightning:微软开源的框架无关 Agent 训练方案,LangChain/AutoGen 都能用
人工智能·microsoft·langchain·大语言模型·agent·强化学习
银烛木1 天前
黑马程序员前端h5+css3
前端·css·css3
m0_607076601 天前
CSS3 转换,快手前端面试经验,隔壁都馋哭了
前端·面试·css3
听海边涛声1 天前
CSS3 图片模糊处理
前端·css·css3
IT、木易1 天前
css3 backdrop-filter 在移动端 Safari 上导致渲染性能急剧下降的优化方案有哪些?
前端·css3·safari
0思必得01 天前
[Web自动化] Selenium无头模式
前端·爬虫·selenium·自动化·web自动化