withStructuredOutput 一行封装的背后:method 三选一 + 方法总表

0 阅读4分钟

系列第 4 篇。第 3 篇手写 bindTools 拿到了 tool_calls.args,但你看——要自己建工具对象、自己判空、自己担心模型不配合。LangChain 把这些都封装进了一个方法:withStructuredOutput

一行替代手写版

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

const model = new ChatOpenAI({ /* 照旧 */ });

const scientistSchema = z.object({
  name: z.string().describe("科学家的姓名"),
  birth_year: z.number().describe("出生年份"),
  nationality: z.string().describe("国籍"),
  fields: z.array(z.string()).describe("研究领域列表"),
  major_achievements: z.array(z.string()).describe("主要成就列表"),
});

const structuredModel = model.withStructuredOutput(scientistSchema, {
  method: "functionCalling",  // ⚠️ 见下文:这个参数很关键
  includeRaw: true,           // true → 返回 { raw, parsed }
});

const { parsed, raw } = await structuredModel.invoke("介绍一下爱因斯坦");

它的内部(源码可见)正好就是第 3 篇手写那套:bindTools 注册 schema 为工具 → invoke → 从 tool_calls 里按名字找到那个调用 → 取出 .args → 再按 schema 校验。你手写一遍,它封装一行,还额外带校验。

includeRaw: true 还能同时看到"校验好的对象"和"原始消息",数据来源一目了然:

parsed = { name: "爱因斯坦", birth_year: 1879, nationality: "德国", ... }
raw.tool_calls[0] = { name: "extract", args: {...}, type: "tool_call", id: "call_xxx" }

parsed 就是框架从 raw.tool_calls[0].args 抽出来再校验的结果,两者同源。没传工具名时默认叫 "extract"

必知大坑:不传 method 直接 400

config.method 支持三个值(源码里 SUPPORTED_METHODS):

method背后做了什么硬在哪门槛
functionCallingschema 当工具,答案走 tool_calls.args工具参数语法强制模型支持 tools(大多支持)
jsonModeresponse_format: {type:"json_object"}平台保证 content 是合法 JSON语法硬、schema 不管,本地再校验
jsonSchemaresponse_format: {type:"json_schema",...} 原生结构化解码端按 schema 强制服务端要认这个参数

坑在这:库对不以 gpt-3/gpt-4 开头的模型名,不传 method 时默认返回 "jsonSchema"(源码逻辑:它假设非 gpt-3/4 = 现代模型都支持原生 json_schema)。

而你的 baseURL 背后若是 DeepSeek 这类 OpenAI 兼容服务,它大概率不认识 response_format: json_schema → 一调用就 400。

你:model.withStructuredOutput(schema)      // 没写 method
库:模型名不是 gpt-3/4 → 默认 method = "jsonSchema"
你 baseURL 服务:response_format json_schema?不认 → 400 ❌

对策:显式传 method: "functionCalling"(最普适,DeepSeek 支持 function calling,实测能跑通)。等确认服务端支持原生 json_schema 了,再换成 "jsonSchema" 升级到最硬档。

实测长这样

method: "functionCalling" 跑通后的真实返回:

{
  name: "阿尔伯特·爱因斯坦",
  birth_year: 1879,               // number,不是字符串——schema 约束生效
  nationality: "德国",
  fields: ["理论物理学", "相对论", "量子力学"],
  major_achievements: ["提出狭义相对论", "提出广义相对论", ...]
}

注意 birth_year: 1879 是真数字、fields 是真数组——zod 的约束在两端都生效:既让模型按这个格式填表,又对结果做了逐项校验。

全景:确保拿到 JSON 对象的所有方法

把这一系列四篇的方法收进一张总表:

软 ←──────────────────────────────────────────────────→ 硬
prompt哄 → JSON.parse → JsonOutputParser → StructuredOutputParser
        → bindTools → jsonMode → json_schema(native) → withStructuredOutput
  靠模型自觉 ↑                          ↑ 靠协议层保证
   (可被打破)                (字段语义仍可能错 → 仍需校验兜底)
方法数据落点校验一句话
prompt 哄 + JSON.parsecontent模型裹围栏/夹废话就崩
JsonOutputParsercontent剥围栏 + parse,宽容
StructuredOutputParsercontentzod讲 schema + 严格验收(仍赌文本)
bindTools 手写tool_calls.args无(可自加)通道级,但要自己抽 args
withStructuredOutput工具槽 / response_formatzod一行封装 + method 可选

选型建议:现代应用直接上 withStructuredOutput + method:"functionCalling";服务端支持原生再升 jsonSchema。纯流式逐字渲染场景才回头用 JsonOutputParser。模型太老不支持 tools 时,退回 StructuredOutputParser 文本派。

最后一句大实话

不存在"让模型保证输出 JSON"的单点魔法。 "确保"是个组合拳:① 选对通道(文本解析 or 结构槽)+ ② 本地 schema 校验 + ③ 失败重试。层级越高,越靠协议而不是靠运气;但哪怕最强的 jsonSchema,字段语义也可能错(年份填 3000 也是合法 number),所以兜底的本地校验永远值得留一层。


系列导航