LangChain 进阶实战:当 Memory 遇上 OutputParser,打造有记忆的结构化助手
在当前的 LLM 应用开发中,我们经常陷入两个极端的场景:
- 记性好的话痨:类似于 ChatBot,能记住上下文,聊天体验流畅,但输出全是不可控的自然语言。
- 一次性的 API:类似于信息提取工具,能返回标准的 JSON 数据,但它是“无状态”的,每一轮调用都是全新的开始。
然而,在复杂的业务系统中,我们往往需要二者兼备:既要像人一样拥有记忆上下文的能力,又要像传统 API 一样返回严格的结构化数据(JSON)。
本文将基于 LangChain (LCEL) 体系,讲解如何将 Memory (记忆模块) 与 OutputParser (输出解析器) 结合,打造一个既懂业务逻辑又能规范输出的智能助手。
第一部分:记忆的载体 (Review)
我们在之前的工程实践中已经明确:LLM 本身是无状态的(Stateless)。为了维持对话的连续性,我们需要在应用层手动维护历史消息。
在 LangChain 中,RunnableWithMessageHistory 是实现这一功能的核心容器。它的工作原理非常直观:
- 读取:在调用大模型前,从存储介质(Memory)中读取历史对话。
- 注入:将历史对话填充到 Prompt 的占位符(Placeholder)中。
- 保存:模型返回结果后,将“用户输入”和“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 的必经之路。