从智能录入到流式 Agent:Output Parser 的工程价值,不只是“让模型吐 JSON”

16 阅读18分钟

很多团队第一次做 AI 应用时,都会把“结构化输出”理解成一个很小的实现细节:无非就是让模型别返回大段自然语言,改成 JSON 就行了。

这个理解太浅了。

在真实系统里,结构化输出解决的不是“格式好不好看”,而是一个更核心的问题:如何把模型生成的自然语言,转成业务系统可以直接消费、校验、存储和执行的数据契约。

如果这个契约不稳定,后面的数据库写入、工具调用、工作流编排、前端展示,都会变得脆弱。你今天也许只是想做一个“智能录入”,明天就会发现同一套能力其实还支撑着 Agent 的工具调用、流式预览、自动化执行这些更复杂的链路。

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

在大多数业务开发里,withStructuredOutput 应该是结构化提取的默认方案;而当你需要一边生成、一边展示工具参数时,才应该下沉到 output parser

围绕这个结论,本文用两个实战场景来展开:

  1. 把一段自然语言整理成可入库的联系人数据,也就是“智能录入”。
  2. 把一个带工具调用的代码 Agent 改造成流式反馈版本,也就是“流式 mini-cursor”。

两件事表面上差异很大,一个偏业务系统,一个偏 Agent 交互,但底层是同一个主题:让模型输出从“可读”变成“可执行”。

为什么 AI 应用一定会遇到“输出控制”问题

模型最擅长的是生成自然语言,但业务系统最不需要的往往也是自然语言。

数据库要的是字段。

工具系统要的是参数。

前端状态机要的是确定结构。

工作流引擎要的是稳定节点输入。

这就是为什么很多“演示能跑”的 AI Demo,一接业务就开始失真。因为 Demo 阶段只要人能看懂就行,工程阶段要求的是:

  • 字段必须完整
  • 缺失值必须有约定
  • 类型必须稳定
  • 异常必须可兜底
  • 输出必须能被下游直接消费

举个最典型的例子。

如果销售把一段聊天记录贴给系统:

张琳,女,杭州人,现在在阿里云做解决方案架构师,电话 13800138000,微信 zhanglin_arch。她说 1994 年出生,上个月刚换了工作。

对人来说,这段话完全能读懂;但对 CRM 系统来说,它不是数据,只是待解释文本。

你至少要回答这些问题:

  • 联系人到底是一条还是多条
  • 哪些字段是必填,哪些允许为空
  • 出生日期没有精确到日时,要不要猜
  • 公司和职位是当前信息还是历史信息
  • 识别失败时,是返回空数组、部分成功,还是整批报错

所以“输出控制”本质上不是一个模型技巧,而是 AI 系统与业务系统之间的接口设计

先把几个概念放到同一条链路里看

很多文章会把 tool callingJSON Schemaoutput parser 拆开讲,读者看完还是不知道该怎么选。更好的理解方式,是把它们放回同一条调用链路。

1. 自然语言输出

这是模型最原生的能力,适合对话、写作、解释,但不适合直接驱动业务逻辑。

2. 结构化输出

这是在模型输出层加一层“约束”,让返回结果符合预设结构,比如对象、数组、枚举、嵌套字段。

它的目标不是限制模型表达,而是让结果能进入程序世界。

3. Tool Calling

当模型不是“回答你”,而是“调用工具”时,它本质上输出的也是结构化数据,只不过结构变成了:

  • 调哪个工具
  • 工具参数是什么

所以从工程视角看,tool calling 也是结构化输出的一种。

4. JSON Schema

这是一种更原生、更严格的约束方式。某些模型供应商会直接在模型层支持 json_schema,模型返回时就会尽量满足这个 schema。

它的优势是约束强,输出纯净;代价是供应商差异更明显,兼容性和迁移成本要额外考虑。

5. Output Parser

Parser 的作用不是“让模型更聪明”,而是把模型输出转换成程序更容易消费的结果。

在非流式场景下,它常常像一个兜底解析层。

在流式场景下,它还有一个非常重要的角色:把中间状态逐步还原成可观察的数据。

这也是本文第二个实战的重点。

默认方案怎么选:不要一上来就手写 Parser

如果只说功能,三种方案都能做结构化输出;但从工程默认值来看,它们并不等价。

方案适合场景优点代价
withStructuredOutput大多数信息提取、表单回填、知识整理API 简洁,跨模型更友好,默认值最好底层细节相对被封装
原生 json_schema对字段严格性要求极高,且模型明确支持约束强,输出通常更“干净”供应商耦合更强
直接使用 output parser流式预览、工具参数增量解析、定制解析链路灵活度最高代码复杂度明显上升

我的建议很明确:

  • 做信息提取,先上 withStructuredOutput
  • 需要模型原生严格 schema,再考虑 json_schema
  • 只有当你真的要处理流式 chunk、工具参数增量显示、复杂解析逻辑时,再自己下沉到 output parser

很多人一看到 Parser 就兴奋,觉得“更底层、更灵活”。但在业务开发里,灵活不是默认目标,稳定才是默认目标。能用高层抽象解决,就不要主动把系统做复杂。

实战一:把“聊天记录”变成可入库联系人,这才是智能录入的本质

先把场景改造得更像真实业务一点。

不是“好友录入”,而是一个常见得多的企业场景:销售或运营把聊天记录、会议纪要、名片 OCR 文本贴进系统,AI 自动抽取联系人信息并写入 CRM。

这个场景比教学 Demo 更接近工程现实,因为它天然包含三个约束:

  1. 输入文本是非结构化的,而且可能一次出现多个人。
  2. 输出结果要直接进入数据库,不能只给人看。
  3. 缺失字段很多,系统必须定义“不确定时怎么办”。

这里最关键的设计,不是提示词,而是 Schema 设计

先定数据边界,再让模型提取

很多 Demo 喜欢让模型“帮忙补全”字段,比如根据“30 出头”推算生日。这在课堂演示里很顺,但在业务系统里风险很大。

因为一旦入库,猜出来的数据就会伪装成事实。

所以我的建议是:

  • 可以提取,就提取
  • 明确缺失,就返回 null
  • 不要为了结构完整去伪造业务事实

下面这段 Schema 写法,比“能跑通”更接近可上线版本:

import { z } from "zod";

export const contactSchema = z.object({
  name: z.string().min(1).describe("联系人姓名"),
  gender: z.enum(["男", "女"]).nullable().describe("无法判断时返回 null"),
  birthDate: z.string().nullable().describe("格式 YYYY-MM-DD,未知时返回 null"),
  company: z.string().nullable().describe("当前任职公司,未知返回 null"),
  title: z.string().nullable().describe("当前职位,未知返回 null"),
  phone: z.string().nullable().describe("手机号,未知返回 null"),
  wechat: z.string().nullable().describe("微信号,未知返回 null"),
}).strict();

export const contactsSchema = z.array(contactSchema).describe("待入库联系人列表");

这里有几个点值得专门解释。

为什么要用数组 Schema

因为业务输入天然可能是一对多。

你今天导入一段名片文字,可能只有一个人;明天导入会议纪要,可能同一段文本里出现三位参会人。把顶层直接设计成数组,系统就不用为了“单人 / 多人”做两套分支逻辑。

为什么大量字段要允许 null

这是工程里非常关键的一步。

如果你把字段全写成必填,模型为了满足结构,很容易开始编造。表面上你得到了“完整数据”,实际上你是在给数据库注入低质量事实。

结构化输出的目标不是让字段填满,而是让不确定性被显式表达。

为什么要 .strict()

因为数据库和业务系统最怕“多出来的字段悄悄混进来”。

严格模式可以防止模型顺手返回你没定义的键,避免下游逻辑在字段演进时变得不可控。

withStructuredOutput 做提取,重点不在“能生成”,而在“能入库”

抽取层可以这样写:

import "dotenv/config";
import { ChatOpenAI } from "@langchain/openai";
import { SystemMessage, HumanMessage } from "@langchain/core/messages";
import { contactsSchema } from "./schema.js";

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

const extractor = llm.withStructuredOutput(contactsSchema);

export async function extractContacts(rawText) {
  return extractor.invoke([
    new SystemMessage(
      "你是 CRM 录入助手。只提取文本中明确出现的信息;不确定时返回 null,不要猜测。"
    ),
    new HumanMessage(rawText),
  ]);
}

这段代码表面很短,但每个参数背后都有原因。

temperature: 0 为什么重要

结构化提取不是创作任务,它要的是稳定,不是发散。温度越高,模型越容易在边界字段上“自由发挥”。做写作可以高一点,做提取最好保守。

为什么系统提示词要强调“不确定返回 null”

因为 Schema 只能约束“长什么样”,不能完全约束“值是不是胡猜的”。

也就是说,结构约束和事实约束不是一回事。前者靠 schema,后者还得靠提示词和业务校验。

withStructuredOutput 到底帮你省了什么

它省掉的不是几十行代码,而是一整层“让模型按结构说话”的胶水逻辑。底层到底是 tool calling、原生 json_schema,还是 parser 兜底,由框架和模型能力去决定。

这也是我推荐它作为默认方案的原因:你在写业务约束,不是在手搓协议适配器。

入库阶段不要直接信模型,至少做两层收口

提取成功,不代表可以无脑写库。

我更推荐的链路是:

  1. 模型按 schema 返回结构化结果
  2. 服务端做二次归一化和校验
  3. 批量写入数据库

例如:

import mysql from "mysql2/promise";
import { extractContacts } from "./extractor.js";

const db = await mysql.createConnection({
  host: "localhost",
  port: 3306,
  user: "root",
  password: "admin",
});

export async function importContacts(rawText) {
  const contacts = await extractContacts(rawText);

  const rows = contacts
    .filter((item) => item.name)
    .map((item) => [
      item.name.trim(),
      item.gender,
      item.birthDate,
      item.company,
      item.title,
      item.phone,
      item.wechat,
    ]);

  if (rows.length === 0) return { inserted: 0 };

  await db.query("USE crm;");
  await db.query(
    `
      INSERT INTO contacts (
        name, gender, birth_date, company, title, phone, wechat
      ) VALUES ?;
    `,
    [rows]
  );

  return { inserted: rows.length };
}

这里真正重要的不是 SQL 本身,而是两个工程动作。

第一,过滤和归一化

哪怕模型已经被 schema 限制过,也仍然建议在服务端做:

  • 去掉空姓名
  • 统一手机号格式
  • 统一日期格式
  • 把空字符串转成 null

因为数据库面对的是长期数据质量,不是单次 Demo 成功率。

第二,批量写入而不是逐条写入

批量写入不仅性能更好,也更方便做一次性事务控制、失败回滚、批次审计。

如果这是生产系统,我还会再加两项:

  • phonewechat 的唯一索引 / 去重策略
  • source_textimport_batch_id 之类的审计字段

这样你以后才能追溯“这条记录是谁、在什么文本里、通过哪次导入生成的”。

下面这张图就是结构化提取后写库的结果示意:

智能录入结果

withStructuredOutput 底层不只一种实现

很多人用久了会有一个误解:好像 withStructuredOutput 就等于“LangChain 帮我把 JSON parse 一下”。

不是。

它底层可能走的是不同机制,常见可以理解成三类:

  1. 模型原生支持结构化输出,比如 json_schema
  2. 模型通过 tool calling 返回结构化参数
  3. 框架在必要时借助 output parser 进行结果约束

这意味着一个很实用的判断:

平时你不需要纠结底层到底用了哪一种,只需要关心当前模型是否能稳定满足你的 schema。

只有当你对“实时中间态”本身有需求时,比如要在流式过程中逐步显示工具参数,Parser 才会从幕后走到台前。

这就进入第二个场景了。

实战二:为什么很多 Agent 看起来“卡死了”,其实只是你没把中间结果露出来

很多代码 Agent 或 mini-cursor 类应用,第一次做出来都会有一个糟糕体验:

你输入一个任务,界面沉默几十秒,然后突然文件写完了。

用户会以为系统卡住了,实际上模型可能一直在生成,只是你没有把中间过程展示出来。

这里最容易踩的误区是:以为“流式输出”只等于把 content 逐字打印出来。

对普通聊天这没问题。

但对带工具调用的 Agent,不够。

因为这时模型真正重要的输出,经常不在自然语言正文里,而在 tool_call 参数里。比如:

  • 要写入哪个文件
  • 文件内容是什么
  • 要执行什么命令
  • 命令参数是什么

如果你只流式打印 content,但工具参数还藏在内部结构里,用户依然看不到关键进展。

流式 Agent 的两个真正难点

这部分是整个文章最值得记住的地方。

当你把非流式 Agent 改成流式之后,核心变化只有两个,但每个都很关键。

难点一:你拿到的不再是完整 AIMessage,而是一串 AIMessageChunk

非流式模式下,模型一次性返回完整消息,你直接:

  • 存入 memory
  • 读取 tool_calls
  • 执行工具

流式模式下,这个完整消息被拆成很多 chunk。你必须先把它们重新拼起来,才能得到最终可执行的 AIMessage

这一步通常用 concat 完成。

难点二:工具参数在流式阶段往往是不完整片段

也就是说,你中途看到的不是一个完整 JSON,而是一段一段的参数碎片。

这时候如果你自己手写字符串拼接和 JSON 解析,不是不能做,而是很容易把代码写烂。

更稳妥的做法,是使用 JsonOutputToolsParser 去解析已经累积到当前阶段的结果,让它帮你把零碎的 tool_call_chunks 逐步还原成可观察的参数对象。

这张图能直观看到 parser 把片段逐步还原后的效果:

工具参数流式解析

一个可落地的流式 mini-cursor 写法

下面是核心代码,我把它压缩成最值得理解的部分:

import { JsonOutputToolsParser } from "@langchain/core/output_parsers/openai_tools";

let mergedMessage = null;
const previewOffsets = new Map();
const toolParser = new JsonOutputToolsParser();

for await (const chunk of rawStream) {
  mergedMessage = mergedMessage ? mergedMessage.concat(chunk) : chunk;

  let parsedTools = [];
  try {
    parsedTools = await toolParser.parseResult([{ message: mergedMessage }]);
  } catch {
    // 参数还没完整,继续累积即可
  }

  if (parsedTools.length > 0) {
    for (const call of parsedTools) {
      if (call.type !== "write_file" || !call.args?.content) continue;

      const key = call.id ?? call.args.filePath ?? "default";
      const previous = previewOffsets.get(key) ?? 0;
      const current = String(call.args.content);

      if (current.length > previous) {
        process.stdout.write(current.slice(previous));
        previewOffsets.set(key, current.length);
      }
    }
    continue;
  }

  if (typeof chunk.content === "string") {
    process.stdout.write(chunk.content);
  }
}

这段代码要分成三层来理解。

第一层:concat 的职责是还原最终消息

mergedMessage 不是为了打印,而是为了在流结束后得到完整的 AIMessage。只有完整消息才能安全进入 memory,也只有它身上的 tool_calls 才适合真正执行。

也就是说:

  • concat 是为了“可执行”
  • 不是只为了“可显示”

第二层:Parser 的职责是做“中间态可视化”

JsonOutputToolsParser 在这里的价值,不是替代最终执行,而是把原本不可读的 chunk 变成“此刻已经累积到哪一步了”的预览结果。

对于 write_file 这种工具,这特别有用。因为用户最关心的往往不是“模型正在思考”,而是“代码已经写到哪里了”。

第三层:增量打印必须记录偏移量

如果不记录上一次已经打印到哪里,每轮都会把整段内容重新输出一遍。

所以这里用 Map 存储每个工具调用已经打印过的长度,下一次只打印新增部分。这个思路很简单,但它决定了最终体验是不是“像打字机”,还是“像日志刷屏”。

预览归预览,真正执行工具仍然要用完整消息

这是流式 Agent 最容易被写错的地方。

很多人看到 Parser 已经能解析出一部分工具参数,就想边解析边执行。这通常不是一个好主意。

正确顺序应该是:

  1. 流式阶段只做展示和预览
  2. chunk 全部结束后,得到完整 AIMessage
  3. 把完整消息存入 memory
  4. 再读取完整的 tool_calls 去执行工具

原因很简单:中间态参数不是最终态参数。

对于 write_file 这种工具尤其如此。你可以实时预览文件内容,但不要在内容还没结束时就开始写盘,否则非常容易得到半截代码、损坏文件或者反复覆盖。

一个更安全的执行骨架大概是这样:

await history.addMessage(mergedMessage);

for (const toolCall of mergedMessage.tool_calls ?? []) {
  const tool = tools.find((item) => item.name === toolCall.name);
  if (!tool) continue;

  const result = await tool.invoke(toolCall.args);
  await history.addMessage(
    new ToolMessage({
      content: result,
      tool_call_id: toolCall.id,
    })
  );
}

这也是为什么我前面说,output parser 不应该是默认方案。

不是它不好,而是它处理的是更底层、更接近协议层的问题。一旦进入这里,你就得自己处理 chunk 拼接、解析时机、异常吞吐、增量显示、最终执行这些复杂性。

智能录入和流式 Agent,本质上是同一件事

如果你把两个实战连起来看,会发现它们底层完全一致。

智能录入解决的是:

  • 如何把自然语言变成数据库可以接受的字段集合

流式 Agent 解决的是:

  • 如何把自然语言生成过程变成用户可观察、系统可执行的工具参数

一个面向“存储”,一个面向“执行”,但本质上都在做:

把语言模型输出变成程序世界里的确定结构。

这也是为什么结构化输出在 AI 应用里不是边缘能力,而是核心基础设施。

常见误区:这些做法看起来合理,实际上很危险

误区一:为了入库完整,允许模型脑补缺失字段

这是最常见的问题。比如根据“30 多岁”反推出生日期,根据“在大厂做技术”猜职位级别。

这类数据一旦入库,后面很难区分它到底是事实还是推测。更好的做法是返回 null,把“不确定”显式保存下来。

误区二:把 Parser 当默认方案

Parser 很强,但不应该滥用。只做结构化提取时,优先上 withStructuredOutput,维护成本更低。

误区三:流式阶段直接执行工具

中间参数只是“目前为止拼出来的样子”,不是最终参数。展示可以提前,执行不要提前。

误区四:把结构正确等同于业务正确

模型返回了合法 JSON,不代表数据就可信。手机号格式、日期合法性、重复联系人、公司名称标准化,这些仍然要靠服务端规则兜住。

误区五:只关心模型成功率,不关心链路可观测性

在 Agent 场景里,用户体验经常不是死在最终结果上,而是死在“中间什么都看不见”。流式预览的价值,很多时候比再提高一点点模型准确率还大。

工程上怎么选,给一个可直接执行的判断

如果你正在做 AI 应用,我建议按下面这个顺序决策:

场景一:文本抽取、表单回填、智能录入

默认选择 withStructuredOutput

原因是它足够稳,表达能力也足够覆盖大多数对象 / 数组提取需求。重点放在 schema 和服务端校验,不要沉迷底层实现细节。

场景二:供应商明确支持原生严格 schema,且你非常在意输出纯净度

可以考虑 json_schema

但要先接受一个事实:你在换取更强约束的同时,也在增加模型能力绑定。只要涉及多模型切换,这个成本就会出现。

场景三:带工具调用的流式 Agent、需要实时预览工具参数

这时再用 output parser,尤其是 JsonOutputToolsParser 这类面向工具参数的解析器。

它解决的不是“结构化提取”本身,而是“结构化输出的中间态可视化”。

最后总结

Output Parser 真正重要的地方,不是它能不能把字符串 parse 成 JSON,而是它把大模型输出接回了工程系统。

在智能录入里,它所在的这一整层能力,让自然语言可以稳定地落进数据库。

在流式 Agent 里,它让工具参数不再是黑盒,用户终于能看到系统到底在生成什么、准备执行什么。

所以更准确地说:

  • withStructuredOutput 解决的是“让模型结果能直接进入业务链路”
  • output parser 解决的是“让结构化结果在流式过程中也能被观察和利用”

如果你只记住一句话,我希望是这句:

先用高层抽象拿到稳定结构,再在确实需要中间态的时候,才下沉到 Parser。

这才是大多数 AI 应用里更合理的工程默认值。