工程 16 分钟阅读

结构化输出实战:模型能力、Agent 工具与可靠 JSON 交付

从模型 API 的 JSON Mode、JSON Schema 与严格工具参数讲起,用一图对照 pi、Codex、Claude 的 Agent 实践,再给出纯文本 JSON 的优化和稳妥交付方案。

  • Structured Outputs
  • Tool Calling
  • Agent Runtime
  • pi
  • Codex
  • Claude Code
目录

可靠性不只来自格式约束,还来自清楚的能力边界、失败处理和结果验收。

先看模型 API 能保证什么,再看 Agent 怎样使用它。 pi、Codex、Claude Agent SDK 是应用层实践;response_format、严格工具参数和约束解码属于它们依赖的基础能力。两层混在一起,容易把一种框架技巧误当成所有模型的限制。

本文按四个问题展开:模型支持什么 → Agent 怎样组织 → 不用工具时怎样返回 JSON → 怎样选一个稳妥的默认方案。

一、模型基础:会写 JSON,不等于支持结构化输出

1. 四种能力,约束的对象不同

方式 约束什么 能解决什么 不能据此认定什么
提示词要求 JSON 模型的回答习惯 尽量按示例输出,接入简单 语法、字段和类型一定正确
JSON Mode JSON 语法 减少多余说明、括号和引号错误 一定包含指定字段,或字段类型正确
原生 JSON Schema 输出 最终文本的结构 在支持的 Schema 子集内约束字段、类型、枚举 事实正确、证据充分、任务已完成
严格工具调用 被选中工具的参数 约束一次工具调用的输入 工具一定会被选中,或最终文本也符合结果 Schema

例如,{"count":"3"} 是合法 JSON,但不符合“count 必须为整数”的契约;{"count":3} 可以符合该契约,但实际数量仍可能是 5。语法正确、结构正确、业务正确,是三次不同的判断。 OpenAI 官方文档明确区分 JSON Mode 与 Schema 输出,以及工具参数与响应格式。格式说明 · 工具说明

原生约束可以在生成时限制不符合语法或 Schema 的后续 token,而不是生成结束后才用正则“抢救”。OpenAI 的实现说明以约束解码介绍这一机制;它解释了为什么提示词、解析器与生成约束不是同一层能力。机制说明

2. 能力要绑定端点,而不是只记模型名字

同一模型通过原生 API、OpenAI 兼容代理或自部署服务访问,可能暴露不同的格式能力。兼容路由至少记录:

// 应用自己的能力记录,不是供应商 API 字段。
const route = {
  provider: "...", endpoint: "...", model: "...",
  adapterVersion: "...", thinkingMode: "...",
  textSchema: "verified",        // 已验证最终文本 Schema
  strictToolArgs: "verified",    // 已验证工具参数约束
  toolsWithTextSchema: "unknown" // 两者分别支持,不代表组合已验证
};

对自部署模型,这一点尤其明显:vLLM 可以通过 XGrammar / Guidance 等后端实现结构化生成,因此能力还与推理服务配置有关,不能只从模型权重或“兼容 OpenAI”推断。vLLM 文档

3. 主流接口支持对照

下表记录官方文档可确认的能力与限制,不是全部型号、地域和代理的实测清单。模型版本、思考模式、Schema 特征及工具组合仍需逐项验证。

模型 / 服务 最终文本 JSON 的入口 工具与结果组合时的关键边界
OpenAI GPT · 官方 API Chat Completions:response_format;Responses:text.format。均区分 json_object 与 json_schema 兼容型号可用严格工具参数;Codex 的 Responses 路径展示了工具与文本 Schema 共存。不能外推到所有专用模型或兼容代理。格式 · 工具
Anthropic Claude · Messages output_config.format = {type:"json_schema", schema} 官方明确允许与工具 strict: true 同请求组合。支持范围以兼容表列出的 Opus、Sonnet、Haiku 等具体版本为准;这不是 Agent SDK 的 outputFormat。文档
Google Gemini · 原生 API GenerateContent 使用生成配置;Interactions 使用 response_format,内部含 MIME 类型与 Schema 文档把“结构化输出 + 工具”标为 Gemini 3 系列的预览能力。不要把单独支持 Schema 扩大为任意型号、端点及工具组合都支持。结构化输出 · 接口
DeepSeek · 官方 API 文档明确提供 response_format: {type:"json_object"} 工具 strict 是 /beta 下的另一项能力。不能据此认定最终文本也支持同名 json_schema;JSON 输出还需处理空内容和截断。JSON · 工具
Qwen · 百炼托管 json_object 已核实;json_schema 应按具体型号与区域单独核实 官方帮助页面存在版本差异,本文不把文本 Schema 标为全系已验证能力;尤其要核对 thinking 开关。参数不报错不等于约束已生效,工具组合也需单测。支持说明
开源模型 · 自部署服务 例如 vLLM 的结构化输出参数及 OpenAI 兼容格式入口 核对解码后端、模型模板与工具解析器的组合;部署同一权重不等于复制了托管服务的协议保证。文档

支持原生 Schema,也只意味着支持一个子集。 OpenAI 严格模式要求对象关闭额外属性,并把属性列为必填,可用 null 表达缺失;其他提供方的可选字段、联合类型和约束支持不完全相同。应维护“应用原始契约”和“供应商生成 Schema”,验收始终回到前者。OpenAI 要求 · Claude Schema 适配

Claude 当前文档还提示枚举值大小写可能偏离定义。这类接口例外也说明,本地校验不应因为请求写了 strict 就被删除。机器状态宜用简短稳定的标识;大小写归一化只能在业务明确允许时进行。异常边界

二、直接看代码:同一份契约,怎样接到不同 API

用一个很小的交付契约贯穿示例。缺少信息时允许 incomplete 和 null,而不是逼模型填一个正常但虚假的答案。

const schema = {
  type: "object",
  properties: {
    status: { type: "string", enum: ["complete", "incomplete"] },
    answer: { type: ["string", "null"] },
    evidence_ids: { type: "array", items: { type: "string" } }
  },
  required: ["status", "answer", "evidence_ids"],
  additionalProperties: false
};

这是生成结构,不包含全部业务规则。例如“complete 时必须有答案”“引用 ID 必须存在”“该证据确实支持答案”,仍需应用检查。

以下是请求关键片段:model、prompt、已初始化的 client 和业务工具由调用方提供,认证、超时与完整事件循环省略。代码用于对照参数位置,不是让四套请求同时执行。

OpenAI:格式约束和工具选择可以各管一件事

// Responses API:工具调用走工具协议,text.format 约束文本。
const request = {
  model, input: prompt,
  tools: businessTools,       // 工具参数的 strict 在工具定义中配置
  tool_choice: "auto",
  text: { format: {
    type: "json_schema", name: "result", strict: true, schema
  } }
};
const response = await openai.responses.create(request);

换成 Chat Completions 时,对应的格式字段是:

response_format: {
  type: "json_schema",
  json_schema: { name: "result", strict: true, schema }
}

结果 Schema 不要求工具调用本身也长成结果对象。 tool_choice: "auto" 允许模型选择工具;工具被选中之后,才由它自己的参数契约约束。终态判断和工具执行循环由应用处理。格式 · 工具

工具与文本的返回也不是一个统一的结果对象。以 Responses 的输出项为例,下面仅示意两类 item 的形状:

const toolItem = {
  type: "function_call", call_id: "call_1", name: "read_doc",
  arguments: '{"doc_id":"doc_1"}'
};
const textItem = {
  type: "message",
  content: [{ type: "output_text", text: '{"status":"incomplete","answer":null,"evidence_ids":[]}' }]
};

运行时应先检查调用状态和拒绝,再扫描全部输出项:存在待处理工具就校验参数、执行并按调用 ID 回填;没有待处理动作、且确认任务完成后才验收文本。一次响应也可能包含多种内容项,不能先取 output_text 再假设它一定是最终交付。SDK 的 parse() 是便利入口,不会凭空赋予底层端点原生约束能力。工具协议

Claude:原生 API 配置,不是 Claude Code 工作流选项

const response = await anthropic.messages.create({
  model, max_tokens: 2048,
  messages: [{ role: "user", content: prompt }],
  tools: businessTools,       // 定义使用 input_schema,可配置 strict: true
  tool_choice: { type: "auto" },
  output_config: { format: { type: "json_schema", schema } }
});

这里控制的是一次 Messages 请求。还需处理 tool_use、回填工具结果和后续请求;若只做文本抽取,可以直接不提供工具。拒绝、输出截断不能当成普通 JSON 格式错误重试。原生 API

Gemini:同一家,也有不同的 API 形状

// @google/genai · GenerateContent
const response = await google.models.generateContent({
  model, contents: prompt,
  config: {
    responseMimeType: "application/json",
    responseJsonSchema: schema
  }
});

// Interactions API 的对应配置,不要混进上面的 config。
const interaction = await google.interactions.create({
  model, input: prompt,
  response_format: {
    type: "text", mime_type: "application/json", schema
  }
});

不要给 Gemini 生搬一个 OpenAI 的 strict: true。上面展示无工具的文本结构化入口;增加工具时,另行验证具体模型与端点的组合能力。最终分别读取该接口的文本结果,再执行应用校验。GenerateContent · Interactions 示例

只有 JSON Mode 时:语法约束仍然值得用

// 适用于明确支持 JSON Mode 的 Chat Completions 兼容端点。
const response = await client.chat.completions.create({
  model,
  messages: [{ role: "user", content: prompt + "\n仅返回 JSON 对象。" }],
  response_format: { type: "json_object" }
});
// 先确认完成、非拒绝、非空;然后 JSON.parse,再按 schema 校验。

这已经比只写一句“请返回 JSON”多了一层协议约束,但仍然不能替代字段校验。DeepSeek 明确要求提示词包含 JSON,并提醒空内容与长度上限问题。JSON Mode

三、Agent 实践:pi、Codex、Claude 怎样组织这些基础能力

下面保留同一张三分支图。它比较的是结果交付方式,不是三家的模型能力排名,也不是所有会话的默认行为。

flowchart TB A["任务:工具调用 + 结构化结果"] subgraph PI["pi · 官方扩展示例"] P0["把结果 Schema<br/>定义成工具参数"] P1["调用结果工具<br/>structured_output"] P2["参数校验 → details<br/>整批 terminate 才停止"] P0 --> P1 --> P2 end subgraph CX["Codex · CLI / SDK"] C0["同一次请求<br/>tools + text.format"] C1["选择工具调用<br/>或输出结构化文本"] C2["确认 turn 完成<br/>读取 finalResponse"] C0 --> C1 --> C2 end subgraph CL["Claude Agent SDK"] H0["允许业务工具<br/>设置 outputFormat"] H1["StructuredOutput 提交<br/>SDK 校验 / 有限重试"] H2["成功且结果存在<br/>读取 structured_output"] H0 --> H1 --> H2 end A --> P0 A --> C0 A --> H0 P2 --> Z["应用验收后正式交付"] C2 --> Z H2 --> Z

业务工具可以多轮执行,图中只展开结果出口。三条分支是不同实现,不是同时运行的三个 Agent。底部的业务验收是应用责任,不是框架自动保证。

pi:把“交卷”定义成一个工具

官方扩展的核心可以缩到下面这段。工具注册时还需提供名字、说明和参数 Schema;执行前由运行时校验参数。扩展示例 · 工具循环

// structured_output 工具定义中的 execute 核心片段。
async execute(_toolCallId, params) {
  return {
    content: [{ type: "text", text: "结构化结果已提交" }],
    details: params,
    terminate: true
  };
}

只有当前批次每个最终工具结果都要求终止,pi 才跳过自动续轮。 混入普通业务工具时,不能凭一次 structured_output 就宣告整个任务完成。扩展的 execute 可能已运行,因此“跳过续轮”不等于事务原子提交;严格业务系统宜先暂存候选,再验收交付。终止条件

这一模式的价值是显式表达提交意图;代价是占用工具协议,并需要定义混合批次、重复提交和校验失败策略。它不是“没有原生 response format 时唯一可用的方法”。

Codex:正常请求就携带结果 Schema

codex exec "检查仓库,按给定结构交付结果" \
  --output-schema ./result.schema.json -o ./result.json

对应 TypeScript SDK 的关键用法:

const thread = new Codex().startThread();
const turn = await thread.run(prompt, { outputSchema: schema });
const candidate = JSON.parse(turn.finalResponse);
// 此处执行应用自己的结构与业务验收。

公开源码把输出 Schema 放入正常模型请求,同时保留工具自动选择;不是先自由输出、失败后才附加 Schema。--json 另指运行事件流;消费端不能取第一段合法 JSON 就结束,也不能把 SDK 返回的字符串当成已经完成业务校验的对象。请求 · 格式序列化 · SDK · CLI

Claude Agent SDK:交付、校验与重试属于工作流

// 在调用方的 async 函数中;query 的异常也需要向上层传播处理。
let candidate: unknown;
for await (const msg of query({
  prompt,
  options: { outputFormat: { type: "json_schema", schema } }
})) {
  if (msg.type !== "result") continue;
  if (msg.subtype !== "success" || msg.structured_output === undefined) {
    throw new Error("Agent 未交付有效结构化结果");
  }
  candidate = msg.structured_output;
}
if (candidate === undefined) throw new Error("缺少终态结果");
// 再执行应用验收;避免在过程消息出现时提前触发下游。

官方文档说明了多轮工具、结果字段与失败处理,发布记录确认存在内部 StructuredOutput 工具。这支持流程归纳,不支持声称已经还原闭源实现的每次模型请求或工具注入时机。 success 与结果字段存在也必须同时检查。SDK 文档 · 发布记录

四、纯文本 JSON:什么时候必要,怎样优化

1. “文本通道输出 JSON”和“只靠提示词”不是同义词

不调用工具的分类、抽取、批处理、界面组件数据生成,往往只需要一个机器可读结果。这时优先直接使用原生文本 Schema,没有必要为了 JSON 额外造一个业务工具,也没有“首轮必须不带 format”的理由。

另一方面,文本通道可以由原生约束保护,也可以只靠提示词生成。选择要看需求和端点,而不是看结果最终是不是字符串。

路径 必要性 / 主要收益 代价与适用边界
原生文本 Schema 固定字段的抽取、分类和下游接口,通常是直接路径 需适配 Schema 子集、处理拒绝和截断;不保证事实正确
JSON Mode + 本地校验 没有文本 Schema 时,仍能降低语法错误 字段和类型可能漂移,失败时需要受控修复
提示词 JSON + 本地校验 旧端点、特殊模型或暂不支持原生格式时的兼容退路 最依赖提示词遵从,格式失败率与修复成本需实测
独立 finalizer 执行与格式能力分离;复杂 Agent 可以专门收尾 可能增加一次请求、上下文传输和语义漂移风险
应用直接序列化 字段已由数据库或工具明确给出时,避免再生成事实 需要能确定地映射字段;自由文本理解仍可能需要模型

上表的选型判断属于工程建议;协议上的 Schema 与 JSON Mode 差别见基础文档。OpenAI · Gemini · DeepSeek

先问能不能不调用模型。 如果工具已返回确定的 order_id、金额、状态,使用代码组装并 JSON.stringify,通常比让另一个模型照抄这些字段更可控。金额、总数、ID 等确定性数据可由代码保留或计算,模型仅生成需要理解与归纳的字段。

2. 提示词只补充业务语义,不和 Schema 重复堆砌

只依据 evidence 中的材料回答。
status 只能为 complete 或 incomplete。
证据不足时 answer 为 null,不编造缺失信息。
evidence_ids 只能引用输入中存在的 ID。
仅输出一个符合给定 Schema 的 JSON 对象,不添加解释或代码围栏。

这段适用于本文示例契约;采用原生 Schema 时,不必反复粘贴同一份字段说明。更有价值的是解释字段含义、缺失值和证据边界。evidence 内的文本按不可信数据处理,不因其中的指令改变系统规则。

优化重点不是“把 temperature 一律改为 0”,而是契约更小、输入更明确、输出预算足够、完成状态可识别。采样参数只在模型支持时设置;降低随机性不构成 Schema 保证。

3. 避免把修复变成改写事实

建议只允许确定性的外层处理,例如去掉首尾空白;如果兼容层允许剥离代码围栏,也仅接受包住整个响应的完整单一围栏。不要搜索第一个大括号就截取,不要执行 eval,也不要默认把缺字段补成 0、false 或空字符串。

结构校验与业务校验分开。类型不符可以请求修复;金额、实体和证据矛盾不是格式错误。解析成功后仍按原始业务契约检查,必要的类型转换要显式记录,不能静默改变语义。

流式分片只用于预览,不把“当前刚好能解析”的 JSON 当作终态。完整接收后,检查接口完成状态、再解析和验收。长列表分批输出,避免用 } 或换行作为通用停止符切断合法内容。Google 文档也把流式结果描述为可拼接的部分 JSON 字符串,而非每个分片都是独立对象。流式说明

4. “首次自由输出,失败再修复”值不值得?

有价值,但主要是兼容或成本优化,不是结构化输出的默认必要步骤。 当主模型擅长任务却不稳定支持目标格式,或自由输出成功率已经很高时,可以先校验,再只为失败样本付修复成本。百炼文档也给出这类兜底流程,而非把“省略 format”规定成所有思考模型的要求。Qwen 说明

设首次执行成本为 C,格式失败概率为 p,一次修复成本为 R,则乐观修复的简化期望成本为 C + pR。但必须同时测量重传、缓存、尾延迟与错误接受率;不能只看平均调用次数。Claude 文档明确提醒格式配置变化会影响 prompt cache。缓存说明

对于本来就没有工具、且可靠支持原生文本 Schema 的请求,先自由生成再补救往往没有充分必要。先从最直接路径起步,再用实际数据证明额外阶段值得存在。

五、一个不容易选错的默认方案

1. 契约统一,执行与交付解耦

推荐基线:应用维护一份结果契约;需要工具时先完成执行;最终用无业务工具的交付阶段生成或组装结果;本地验收通过后才触发下游。

这不是“永远多调用一次模型”,而是给交付建立独立边界:

当前任务 默认走法
字段已由可信数据确定 应用组装 → 验收 → 序列化;不再调用模型
纯文本抽取 / 分类 从第一次请求就使用原生 Schema → 验收
多轮工具 Agent,组合能力未验证 执行工具 → 冻结证据 → 无工具 Schema finalizer → 验收
已有框架稳定交付结构化候选 沿用该出口;验收即可,避免再套一层无意义 finalizer

该基线最重要的优点是:不依赖某家一定支持“工具 + 文本 Schema 同请求”,也不依赖模型自觉知道什么时候该结束。 代价是工具工作流可能多一次收尾请求。追求最低延迟时,可以在组合测试通过后合并阶段,保留相同验收接口。

2. finalizer 也需要明确的能力退路

优先使用已验证支持目标 Schema 的原生文本输出。只有严格工具参数可用时,可以在隔离交付阶段只暴露一个 submit_result,该工具无业务副作用。再弱的端点才退到 JSON Mode 或提示词 JSON,并坚持本地验收与有限重试。

这里的退路按能力注册表选择,不是在一次失败后盲目遍历所有厂商。敏感材料不得为了修格式自动发给另一家提供方;替代模型必须在同一授权与数据治理范围内。API 参数不支持应更新适配或路由,不能当成模型格式错误反复重试。

3. 一段核心控制逻辑

下面是框架无关的 TypeScript 编排示意。generateFinal、validateCanonical、verifyBusiness、commitOnce 是应用接口,不是某个 SDK 的现成函数。

// snapshot 来自已完成的工具结果,或直接来自本次待抽取输入。
// generateFinal 使用预先验证的 Schema 路径,禁用业务工具。
const snapshot = await prepareCompletedEvidence(task);
let feedback: { candidate: unknown; errors: unknown } | undefined;

for (let attempt = 0; attempt < 2; attempt++) { // 首次 + 最多一次修复
  const reply = await generateFinal({ schema, snapshot, feedback });
  if (reply.status !== "completed" || reply.hasPendingTools) {
    throw new Error("拒绝、截断、失败或未完成调用:转交对应处理器");
  }

  const checked = validateCanonical(reply.value); // 解析 + 原始 Schema
  if (!checked.ok) {
    feedback = { candidate: reply.value, errors: checked.errors };
    continue;
  }

  // 放在格式重试之外:业务失败不能伪装成 JSON 错误。
  await verifyBusiness(checked.value, snapshot);
  return await commitOnce(task.id, snapshot.version, checked.value);
}
throw new Error("结构化交付失败:保留诊断,不提交坏数据");

prepareCompletedEvidence 需要确认工具、审批和子任务均已收敛;generateFinal 需要超时、输出预算与拒绝识别。真实 API 错误进入传输或能力处理器;纯格式错误才消耗上面的修复次数。一次修复是保守起点,不是可靠率承诺。

verifyBusiness 不能只检查 ID 存在:存在的证据也可能不支持答案。能确定计算的字段用代码复核;语义判断按风险采用引用核对、规则或人工复核。incomplete 可以是合法交付状态,但下游不能把它当作完整成功。

最小本地校验代码:不做静默类型转换

以下示例使用 Zod 4;结构校验在本地执行,并展示“Schema 正确”和“业务规则正确”的分工。输入大小限制是示例应用策略,应按实际数据规模调整。

import { z } from "zod";

const Result = z.strictObject({
  status: z.enum(["complete", "incomplete"]),
  answer: z.string().nullable(),
  evidence_ids: z.array(z.string())
});

function validateCanonical(raw: unknown) {
  let value = raw;
  if (typeof raw === "string") {
    if (raw.length > 100_000) {
      return { ok: false as const, errors: ["结果超过应用大小限制"] };
    }
    try { value = JSON.parse(raw); }
    catch { return { ok: false as const, errors: ["无效 JSON"] }; }
  }
  const parsed = Result.safeParse(value);
  return parsed.success
    ? { ok: true as const, value: parsed.data }
    : { ok: false as const, errors: parsed.error.issues };
}

function verifyBasicEvidence(
  result: z.infer<typeof Result>, allowedIds: ReadonlySet<string>
) {
  if (result.status === "complete" && !result.answer?.trim()) {
    throw new Error("完整结果缺少答案");
  }
  if (result.status === "complete" && result.evidence_ids.length === 0) {
    throw new Error("完整结果缺少证据引用");
  }
  if (result.status === "incomplete" && result.answer !== null) {
    throw new Error("本契约要求信息不足时不提供确定答案");
  }
  if (result.evidence_ids.some(id => !allowedIds.has(id))) {
    throw new Error("引用了不存在的证据");
  }
  // 这里只验证基本规则;尚未证明答案真的得到证据支持。
}

可从同一份 Zod 定义生成 JSON Schema,减少类型与生成契约漂移;仍需适配提供方支持的子集。格式错误反馈要截断并去除敏感信息,候选文本始终按不可信数据处理。Zod 文档

4. 上线前,验证组合而不是只验证参数能传

必测场景 通过标准
无工具直接交付、多轮工具后交付 两条路径都能进入同一个验收器
文本与工具同响应、结果工具与业务工具混批 不提前接受结果,不漏回填调用
合法 JSON 但类型错、合法结构但证据错 前者可有限修复,后者进入业务失败处理
拒绝、截断、空结果、超时、预算耗尽 明确区分状态,不落库为成功
修复、重试、并发回调 不重复执行副作用,最终提交幂等

“大概率不选错”来自不变量,而不是押中某种 API:生成能力可替换,结果契约不漂移;格式可修复,事实不能补造;候选可以失败,验收不能跳过。 这能降低选型与集成风险,但不保证模型永不出错,也不能用格式通过率代替任务正确率。

官方资料与源码范围

模型基础:OpenAI 结构化输出、Function calling、约束解码说明;Claude 原生结构化输出;Gemini 结构化输出、GenerateContent;DeepSeek JSON、严格工具;Qwen 托管支持说明;vLLM。

Agent 实践:pi 结果工具扩展、批次终止固定到 898ab804;Codex 请求构造、格式序列化、SDK固定到 8a3c4ea3;Claude SDK 文档与 StructuredOutput 发布记录,发布记录固定到 58d2e4b8。

模型接口说明按本次官方文档核对;图为既有源码证据的归纳。示例契约、默认方案、成本表达式与验收流程是工程设计建议,不构成跨端点兼容性实测或闭源内部实现复现。

图表

使用 + / − 缩放,按 0 适应窗口;放大后可拖动或滚动查看,Esc 关闭。

评论公开保存在 GitHub Discussions。