结构化输出别再只盯着 Output Parser:工程上为什么更该默认用 Tool

16 阅读13分钟

很多团队第一次做 LLM 应用时,都会写出这样一段代码:在 prompt 里补一句“请返回 JSON”,然后在代码里 JSON.parse() 一把梭。它在 demo 阶段通常能跑,但一进真实业务,很快就会暴露问题。

模型会多说一句解释,前后包一层 Markdown 代码块,字段名偶尔漂移,数组有时变字符串,流式输出时还会拿到半截 JSON。你以为自己遇到的是“解析问题”,但本质上这是一个结构化输出策略的问题。

这篇文章想讲清楚一个结论:

如果你的目标是“稳定拿到结构化结果”,默认优先选 tool call,在 LangChain 里通常就是 withStructuredOutput

Output Parser 不是没用了,但它更适合流式输出、非 JSON 格式,以及模型或平台对 tool call 支持不稳定的场景。

这不是 API 偏好,而是工程上的默认选型。

为什么“让模型返回 JSON”会成为工程问题

很多人把这个问题理解成“让模型按格式说话”,但在真实系统里,结构化输出承担的职责远不止“看起来像 JSON”。

它通常位于这条链路的中间:

用户输入 -> LLM 理解 -> 结构化结果 -> 业务逻辑/工具调用/数据库写入/工作流分支

一旦结构化结果不稳定,后面的链路都会出问题。例如:

  • 工单分类系统会因为 priority 字段缺失而无法分流
  • Agent 会因为参数类型不对而调用工具失败
  • 知识入库链路会因为 tags 不是数组而写库报错
  • 审核系统会因为布尔值漂移成 "yes""true""需要人工" 而出现分支混乱

所以你真正要解决的,不是“让模型输出一段 JSON 文本”,而是:

如何让模型尽可能稳定地产生一个可消费、可校验、可继续流转的结构化对象。

这也是 Output Parsertool call 需要被放在一起讨论的原因。

先把两个概念分清楚

很多文章会把它们都归为“结构化输出”,这没错,但两者解决问题的方式并不一样。

Output Parser 在做什么

Output Parser 的思路是:

  1. 先告诉模型你要什么格式
  2. 让模型按这个格式输出文本
  3. 再把这段文本解析成对象

它本质上仍然是“模型先生成文本,你再解析文本”。

比如 StructuredOutputParser 会根据 schema 生成格式说明,把这段说明塞进 prompt;模型返回结果后,再由 parser 做解析和基础纠错。

这条路线的优点是灵活:

  • 不依赖模型必须支持 tool call
  • 可以处理 XML、YAML 等非 JSON 格式
  • 适合边生成边展示、最后再解析的场景

它的代价也很明显:

  • 结构约束主要靠 prompt,不是模型原生输出通道
  • 返回内容本质上还是文本,更容易被“多说一句”污染
  • schema 越复杂,prompt 越长,维护成本越高

Tool Call 在做什么

tool call 的思路完全不同:

  1. 先定义一个工具或结构化 schema
  2. 让模型不要“回答一段文本”,而是“生成一个工具参数对象”
  3. 框架直接从工具调用结果里拿参数

它本质上是把结构化结果放到了模型更原生、更受约束的输出通道里。

这也是为什么在支持 tool calling 的模型上,它通常比“提示模型吐 JSON”更稳。你拿到的不是一段“长得像 JSON 的自然语言”,而是一次结构化的参数生成结果。

如果只看工程默认值,这条路线更像你真正想要的能力。

工程上的默认结论:优先 withStructuredOutput

在 LangChain 里,讨论结构化输出时,一个非常实用的入口是 withStructuredOutput

它的价值不只是“API 简洁”,而是它把底层策略封装掉了:

  • 模型支持 tool call,就优先走 tool call
  • 不支持时,再回退到 parser 路线

也就是说,它体现的不是第三种方案,而是**“结构化输出的统一入口”**。

如果你的业务只是想稳定拿到对象,这通常就是默认选择。

下面用一个更贴近业务的 demo 来看为什么。

实战案例:把用户投诉文本转成客服工单对象

很多博客喜欢用“介绍爱因斯坦”做例子,能演示 API,但业务感太弱。真实系统里更常见的需求是:把自然语言输入转成可落库、可分流、可继续处理的对象。

这里我们做一个客服工单结构化抽取:

  • 输入:用户的一段自然语言投诉或咨询
  • 输出:工单对象
  • 后续用途:分配队列、设置优先级、决定是否转人工、打标签

我们希望模型输出这样的结构:

const ticketSchema = z.object({
  intent: z.enum(["refund", "logistics", "after_sales", "product_question", "complaint"])
    .describe("用户意图"),
  priority: z.enum(["low", "medium", "high"])
    .describe("工单优先级"),
  product: z.string().describe("涉及的产品或服务"),
  requires_human: z.boolean().describe("是否需要转人工"),
  summary: z.string().describe("工单摘要,50 字以内"),
  tags: z.array(z.string()).describe("标签列表"),
})

这个 schema 不是为了好看,而是直接决定后续业务逻辑能否稳定运行。

方案一:Prompt + JSON.parse,为什么它不该是默认方案

最朴素的写法一般是这样:

import "dotenv/config"
import { ChatOpenAI } from "@langchain/openai"

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
  temperature: 0,
})

const prompt = `
请把下面这段用户消息整理成 JSON:

用户消息:
昨天买的破壁机今天就不转了,客服一直没人回。我明天要带老人去医院,没时间反复沟通,请尽快处理。

返回字段:
- intent
- priority
- product
- requires_human
- summary
- tags
`

const response = await model.invoke(prompt)
const result = JSON.parse(response.content)

这段代码的问题不是“不能跑”,而是它把系统稳定性建立在多个隐含假设上:

  • 模型一定只输出 JSON,不会加解释
  • 字段一定齐全
  • 字段类型一定正确
  • 布尔值一定是真正的 true/false
  • 出错时你有足够上下文做恢复

这些假设在 demo 中常常成立,在线上并不可靠。

方案二:withStructuredOutput,为什么它应该成为默认选择

更合理的做法是直接把“结构要求”变成模型的结构化输出约束,而不是交给普通文本生成。

import "dotenv/config"
import { z } from "zod"
import { ChatOpenAI } from "@langchain/openai"

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
  temperature: 0,
})

const ticketSchema = z.object({
  intent: z.enum(["refund", "logistics", "after_sales", "product_question", "complaint"])
    .describe("用户意图"),
  priority: z.enum(["low", "medium", "high"])
    .describe("工单优先级"),
  product: z.string().describe("涉及的产品或服务"),
  requires_human: z.boolean().describe("是否需要转人工"),
  summary: z.string().describe("工单摘要,50 字以内"),
  tags: z.array(z.string()).describe("标签列表"),
})

const structuredModel = model.withStructuredOutput(ticketSchema)

const result = await structuredModel.invoke(`
用户消息:
昨天买的破壁机今天就不转了,客服一直没人回。我明天要带老人去医院,没时间反复沟通,请尽快处理。
`)

console.log(result)

这里最关键的不是“代码更短”,而是职责划分更对:

  • zod schema 负责定义业务需要的结构
  • 模型负责生成符合该结构的结果
  • 你的应用直接消费对象,而不是清洗文本

为什么这种方式更适合作为默认方案

原因主要有三点。

第一,它更接近真实业务要的东西。
你的系统最终要的不是一段 JSON 字符串,而是一个能进流程、能做判断、能落库的对象。

第二,它的约束更强。
当模型支持 tool call 时,你利用的是模型原生的工具参数生成能力,不是单纯靠 prompt “劝它听话”。

第三,它更容易统一。
很多团队最后都会把结构化抽取、工具调用、Agent 参数生成放进同一套 schema 管理里。withStructuredOutput 正好符合这个方向。

Output Parser 到底还有没有价值

有,而且不是边角料价值。

Output Parser 今天最大的价值,不在“替代 tool”,而在补足 tool 不擅长的场景

最典型的是两个场景。

场景一:你需要真正的流式输出

withStructuredOutput 在普通调用里很好用,但如果你想边生成边展示结构内容,它往往不是最理想的选择。

原因很简单:底层如果走的是 tool call,框架通常会等结构足够完整、校验通过后,再把结果交给你。你拿到的是“完整对象”,而不是“逐字段渐进成型的结果”。

如果你的需求是:

  • 前端边展示模型生成过程
  • 边显示摘要、边补全标签
  • 后台实时观察模型逐步形成的结构

那就更适合用 parser 路线。

比如:

import "dotenv/config"
import { z } from "zod"
import { ChatOpenAI } from "@langchain/openai"
import { StructuredOutputParser } from "@langchain/core/output_parsers"

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
  temperature: 0,
})

const ticketSchema = z.object({
  intent: z.string(),
  priority: z.string(),
  product: z.string(),
  requires_human: z.boolean(),
  summary: z.string(),
  tags: z.array(z.string()),
})

const parser = StructuredOutputParser.fromZodSchema(ticketSchema)

const prompt = `
请把下面用户消息整理成 JSON:
用户表示新买的空气炸锅加热异常,连续两次联系客服没有结果,希望尽快退款。

${parser.getFormatInstructions()}
`

const stream = await model.stream(prompt)

let fullText = ""
for await (const chunk of stream) {
  fullText += chunk.content
  process.stdout.write(chunk.content)
}

const result = await parser.parse(fullText)
console.log(result)

这段代码的重点是:

  • 生成阶段按文本流式输出
  • 结束后再统一解析

它的取舍也很明确:

  • 优点是流式体验自然
  • 缺点是生成过程里你拿到的仍然是文本,不是完全可靠的结构对象

这就是为什么我说,Output Parser 不是默认方案,但它在流式场景依然很重要。

场景二:你要的不是 JSON,而是 XML / YAML 等格式

tool call 天然更适合“生成一个参数对象”。如果你业务上要求模型输出 XML、YAML、特定 DSL,或者要兼容一套老系统的格式协议,那 parser 才是正路。

例如:

import "dotenv/config"
import { ChatOpenAI } from "@langchain/openai"
import { XMLOutputParser } from "@langchain/core/output_parsers"

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
  temperature: 0,
})

const parser = new XMLOutputParser()

const response = await model.invoke(`
请把下面投诉信息整理成 XML:
用户反馈咖啡机漏水,要求售后上门检修。

${parser.getFormatInstructions()}
`)

const result = await parser.parse(response.content)
console.log(result)

这里 parser 的价值,不是“更高级”,而是它真的能处理 tool 不覆盖的输出类型。

如果你既想走 Tool,又想要流式怎么办

这属于进阶需求。

很多人发现 withStructuredOutput().stream() 拿不到想要的“边生成边可用”,于是会得出一个错误结论:tool 不适合流式。

更准确地说是:

tool 的默认消费方式不适合“文本式流式体验”,但 tool call 本身在流式传输时依然会逐步产生参数片段。

LangChain 里可以用 JsonOutputToolsParser 去解析这些 tool_call_chunks,把还没完全收完的参数片段尽量拼成当前可用的 JSON 对象。

import "dotenv/config"
import { z } from "zod"
import { ChatOpenAI } from "@langchain/openai"
import { JsonOutputToolsParser } from "@langchain/core/output_parsers/openai_tools"

const model = new ChatOpenAI({
  modelName: process.env.MODEL_NAME,
  apiKey: process.env.OPENAI_API_KEY,
  configuration: {
    baseURL: process.env.OPENAI_BASE_URL,
  },
  temperature: 0,
})

const ticketSchema = z.object({
  intent: z.string(),
  priority: z.string(),
  product: z.string(),
  requires_human: z.boolean(),
  summary: z.string(),
  tags: z.array(z.string()),
})

const modelWithTool = model.bindTools([
  {
    name: "extract_ticket",
    description: "提取客服工单结构化信息",
    schema: ticketSchema,
  },
])

const parser = new JsonOutputToolsParser()
const chain = modelWithTool.pipe(parser)

const stream = await chain.stream("用户反馈新买的净水器安装后一直报警,希望尽快联系处理。")

for await (const chunk of stream) {
  if (chunk.length > 0) {
    console.log(chunk[0].args)
  }
}

这条路线适合什么场景?

  • 你已经在 Agent 或工具体系里统一使用 tool call
  • 你希望在流式过程中尽早拿到部分结构
  • 你愿意承担更高的实现复杂度

它不适合作为默认入门方案,因为理解和调试成本明显更高。

方案对比:到底该怎么选

方案默认推荐度可靠性流式体验非 JSON 格式复杂度
Prompt + JSON.parse一般
Output Parser
tool call / withStructuredOutput默认一般低到中
tool call + JsonOutputToolsParser特定场景较好

如果只给一句工程建议,我会这样说:

  • 普通结构化输出:先用 withStructuredOutput
  • 需要边生成边展示:优先考虑 Output Parser
  • 需要 XML / YAML:用对应 Output Parser
  • 已经重度依赖 tool 体系且要流式增量结构:再考虑 JsonOutputToolsParser

几个很常见的误区

误区一:能 parse 成功,就说明方案可靠

不是。
很多方案偶尔成功,只是因为输入简单、模型配合、温度低,不代表它适合线上。

你要看的是:

  • 复杂输入下字段是否漂移
  • 缺字段时如何恢复
  • 类型错误是否可控
  • 出错后日志是否容易定位

误区二:Output Parser 和 tool call 只是写法不同

不是。
两者最大的差异在于约束施加的位置不同:

  • parser 主要约束“文本长什么样”
  • tool call 主要约束“结构对象长什么样”

这会直接影响稳定性上限。

误区三:只要用了 schema,就一定安全

也不是。
schema 只能定义你要什么结构,不能替你解决所有业务语义问题。

例如:

  • priorityhigh 还是 medium,仍然取决于你的提示词定义是否清晰
  • summary 多长算合格,要不要截断,依然需要你自己设计
  • 枚举值怎么映射到内部系统,也需要业务层明确

schema 解决的是结构,不是全部语义。

误区四:流式输出就是“越早拿到对象越好”

不一定。
有些业务更看重完整性而不是即时性,比如写库、调用下游工具、发起退款流程。这时你宁可等完整对象,也不要拿半成品误触发流程。

所以“是否必须流式”本身就是一个业务决策,不是前端体验偏好。

几个关键参数和设计点,别只会照抄

temperature

做结构化输出时通常建议低一些,常见做法是设为 0 或接近 0。原因不是“更聪明”,而是减少结构漂移和表达发散。

schema 里的 .describe()

很多人把它当注释,其实它很重要。
模型理解字段语义时,字段描述往往比字段名本身更关键。尤其在:

  • 枚举值含义接近
  • 布尔值判断标准不清
  • 摘要、标签、分类需要业务口径时

描述越清晰,结构化结果越稳定。

枚举优于自由文本

如果业务字段有固定取值,尽量不要用 z.string() 放任模型自由发挥,而应该用 z.enum() 收紧空间。结构化输出不是写作文,约束越明确,后续系统越稳。

“是否转人工”最好是布尔值,不要是字符串

如果后面要做流程分支,true/false"yes""urgent""需要人工" 这类字符串稳定得多。能在 schema 里卡死的事情,不要留给业务代码猜。

我的工程建议

如果你今天要做一个新的 AI 应用,我建议把结构化输出按下面的顺序考虑:

  1. 先问自己:后面消费的是“文本”还是“对象”
  2. 如果后面消费的是对象,默认从 withStructuredOutput 开始
  3. 如果你明确需要流式文本体验,再评估 Output Parser
  4. 如果你需要 XML、YAML 或其他自定义格式,直接走 parser
  5. 如果你已经全面采用 tool 体系,且确实要流式增量结构,再引入 JsonOutputToolsParser

换句话说,不要再把“让模型输出 JSON”当成 prompt 技巧,而要把它当成AI 应用架构中的一个接口设计问题

总结

Output Parsertool call 都能做结构化输出,但它们不是平替关系。

如果你只是想稳定拿到结构化对象,默认优先 tool call,在 LangChain 里通常就是 withStructuredOutput。这条路线更符合真实业务,也更适合作为团队默认规范。

Output Parser 依然重要,但它更像是补位方案:

  • 你需要真正的流式展示
  • 你要处理 XML、YAML 等非 JSON 格式
  • 你需要在特殊环境里兼容不稳定的模型能力

真正成熟的工程判断不是“哪个 API 更新潮”,而是:

先把结构化结果当成系统接口,再去选最合适的生成通道。