系列第 4 篇。第 3 篇手写
bindTools拿到了tool_calls.args,但你看------要自己建工具对象、自己判空、自己担心模型不配合。LangChain 把这些都封装进了一个方法:withStructuredOutput。
一行替代手写版
js
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 还能同时看到"校验好的对象"和"原始消息",数据来源一目了然:
js
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 | 背后做了什么 | 硬在哪 | 门槛 |
|---|---|---|---|
functionCalling |
schema 当工具,答案走 tool_calls.args |
工具参数语法强制 | 模型支持 tools(大多支持) |
jsonMode |
response_format: {type:"json_object"} |
平台保证 content 是合法 JSON | 语法硬、schema 不管,本地再校验 |
jsonSchema |
response_format: {type:"json_schema",...} 原生结构化 |
解码端按 schema 强制 | 服务端要认这个参数 |
坑在这:库对不以 gpt-3/gpt-4 开头的模型名,不传 method 时默认返回 "jsonSchema"(源码逻辑:它假设非 gpt-3/4 = 现代模型都支持原生 json_schema)。
而你的 baseURL 背后若是 DeepSeek 这类 OpenAI 兼容服务,它大概率不认识 response_format: json_schema → 一调用就 400。
ini
你: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" 跑通后的真实返回:
js
{
name: "阿尔伯特·爱因斯坦",
birth_year: 1879, // number,不是字符串------schema 约束生效
nationality: "德国",
fields: ["理论物理学", "相对论", "量子力学"],
major_achievements: ["提出狭义相对论", "提出广义相对论", ...]
}
注意 birth_year: 1879 是真数字、fields 是真数组------zod 的约束在两端都生效:既让模型按这个格式填表,又对结果做了逐项校验。
全景:确保拿到 JSON 对象的所有方法
把这一系列四篇的方法收进一张总表:
scss
软 ←──────────────────────────────────────────────────→ 硬
prompt哄 → JSON.parse → JsonOutputParser → StructuredOutputParser
→ bindTools → jsonMode → json_schema(native) → withStructuredOutput
靠模型自觉 ↑ ↑ 靠协议层保证
(可被打破) (字段语义仍可能错 → 仍需校验兜底)
| 方法 | 数据落点 | 校验 | 一句话 |
|---|---|---|---|
prompt 哄 + JSON.parse |
content | 无 | 模型裹围栏/夹废话就崩 |
JsonOutputParser |
content | 无 | 剥围栏 + parse,宽容 |
StructuredOutputParser |
content | zod | 讲 schema + 严格验收(仍赌文本) |
bindTools 手写 |
tool_calls.args |
无(可自加) | 通道级,但要自己抽 args |
withStructuredOutput |
工具槽 / response_format | zod | 一行封装 + method 可选 |
选型建议 :现代应用直接上 withStructuredOutput + method:"functionCalling";服务端支持原生再升 jsonSchema。纯流式逐字渲染场景才回头用 JsonOutputParser。模型太老不支持 tools 时,退回 StructuredOutputParser 文本派。
最后一句大实话
不存在"让模型保证输出 JSON"的单点魔法。 "确保"是个组合拳:① 选对通道(文本解析 or 结构槽)+ ② 本地 schema 校验 + ③ 失败重试。层级越高,越靠协议而不是靠运气;但哪怕最强的 jsonSchema,字段语义也可能错(年份填 3000 也是合法 number),所以兜底的本地校验永远值得留一层。
系列导航
- 第 1 篇:大模型只吐字符串,凭什么你能拿到 JSON 对象?
- 第 2 篇:StructuredOutputParser------别再靠"哄",把 schema 讲清楚再验收
- 第 3 篇:工具调用------让模型把答案"填进表格"而不是"写在作文里"
- 第 4 篇(本篇):withStructuredOutput 一行封装的背后:method 三选一 + 方法总表