第 2 章 结构化输出与工具调用

学习目标

  • 说明指令、JSON模式、基于schema的结构化输出各自保证的范围,并选择合适方式。
  • 从业务概念推导schema,按各厂商strict规则修改。
  • 设计按语法、schema、语义三层校验结果的代码。
  • 定义工具,将工具调用循环实现为带预算的状态机,以准确格式将结果回传到对话。
  • 结合并行调用、工具选择选项与执行权限,设计安全的工具循环。

引言

某个税务应用开发了一项功能,从收据照片中提取支出项目并录入账簿。演示时,仅用一句“用JSON回答”的指令就没有遇到问题。然而,上线运行的第一周就发生了三次故障。一次是在响应前面加上了“以下是结果”这句话,导致解析失败;一次是金额以"12,000韩元"这样的字符串返回,导致合计计算中断;最后一次虽然格式完全正确,总额却与各项目金额之和不一致。第一种故障是因为没有强制约束格式,第二种是因为没有根据业务需要设计schema,第三种则是因为误以为格式正确,内容就一定正确。

本章介绍两项功能,让程序能够放心地使用模型的答案。结构化输出用于固定响应的形状,工具调用则让模型能够请求使用应用程序的功能。前半部分介绍schema设计与校验;后半部分依次介绍工具定义、调用循环、结果回传、并行调用和执行权限。

核心概念

结构化输出是一副模具

手捏面团,每次形状略有不同;倒进模具,形状就固定了。如果“用JSON回答”是手捏,结构化输出就是模具。供应商将JSON schema编译为文法(grammar),约束生成过程遵循该文法。这通常称为约束解码(constrained decoding)。

方式 保证什么 不保证什么
指令 无(通常会遵守) 格式、字段、类型
JSON模式 有效JSON 遵循schema
结构化输出(strict) 遵循指定schema 拒绝或截断时的格式、值的准确性、业务规则

OpenAI文档明确指出,JSON模式(json_object)只保证有效JSON,不保证遵循schema。还警告说,使用JSON模式时,消息中某处必须出现“JSON”一词;如果不指示生成JSON,模型可能持续输出空白直到触及上限。

各厂商的启用方式与选择依据

厂商 对响应本身进行结构化 对工具参数进行结构化
OpenAI Responses API 在text.format中设置type: "json_schema"、name、schema、strict: true 在函数定义中设置strict: true
Anthropic Messages API 在output_config.format中设置type: "json_schema"、schema 在工具定义中设置strict: true
Gemini Interactions API 在response_format中设置type: "text"、mime_type: "application/json"、schema(generateContent API使用responseMimeType与responseSchema) 函数声明的parameters schema

根据结果用途选择。OpenAI文档建议,将模型连接到系统工具、功能、数据时使用函数调用;规定返回给用户的响应形状时,使用text.format。Gemini文档也区分:结构化输出用于最终响应的格式,函数调用用于对话中的行动。

首次使用某个schema的请求,需要生成文法,延迟会更长(Anthropic将编译后的文法缓存至最后一次使用后24小时)。OpenAI与Anthropic SDK支持用Python的Pydantic模型及TypeScript的Zod schema生成schema并解析响应,从而减少手写schema与代码类型不一致的问题。

strict规则与厂商差异

strict模式只接受JSON Schema的一个子集,并非全部。陷阱在于各供应商的子集不同。

规则 OpenAI Anthropic
additionalProperties 所有对象都必须为false 所有对象都必须为false
可选字段 所有字段放入required,通过允许null的类型表示 允许不在required中的字段(可选字段数量有限制)
数字与字符串约束 支持minimum、maximum、multipleOf、pattern、format(微调模型不支持) 不支持minimum、maximum等数字约束以及minLength、maxLength
递归schema 通过$ref支持 不支持
顶层(根) 必须为对象,根不允许anyOf(子属性可以) —
输出键顺序 按schema顺序 先必填字段,后可选字段
复杂度限制 属性5,000个、嵌套10层、枚举值1,000个等 strict工具20个、可选参数24个、联合类型参数16个

OpenAI strict模式下,可选值以允许null的类型表示,而不是省略字段。

{
  "type": "object",
  "properties": {
    "order_id": { "type": "string" },
    "memo": { "type": ["string", "null"] }
  },
  "required": ["order_id", "memo"],
  "additionalProperties": false
}

在Anthropic中使用不支持的功能,会返回400错误。Anthropic SDK辅助工具会从schema中移除不支持的约束,转入字段说明;基于Pydantic的messages.parse()与TypeScript的zodOutputFormat()会按原约束再次校验响应。使用不依赖Zod的jsonSchemaOutputFormat(),或不使用SDK直接调用时,应用必须自行做相同校验。Gemini也只接受子集,过大或嵌套过深的schema可能被拒绝。

从业务概念推导schema

schema不只是响应的形状,更是应用程序与模型之间的契约。设计时,需要确定以下四件事。

  1. 缺失值的语义。 “字段缺失”、null、空字符串、空数组应各有不同含义。收据没有印总额,与印了却模糊无法识别,若都合并为null,后续处理就无法分流。应另设说明原因的枚举字段。
  2. 单位与表示方式。 例如金额使用以韩元为单位的整数,日期使用ISO 8601(2026-10-20),比例使用0~1实数。需要原始表述时,通过独立字段(raw_date)接收。
  3. 模型生成值与服务器赋值。 订单号、创建时间、用户ID等系统已知的值,不让模型生成。
  4. 版本。 生产运行期间更改字段,会影响界面、存储和批处理。删除字段与变更语义会导致不兼容,新增字段对于使用additionalProperties: false校验的下游消费者也同样如此。应设置schema_version,让消费者按版本分开处理并同步更新。
{
  "type": "object",
  "properties": {
    "schema_version": { "type": "string", "enum": ["2"] },
    "total_krw": { "type": ["integer", "null"] },
    "total_status": { "type": "string", "enum": ["read", "not_printed", "illegible"] },
    "purchased_on": { "type": ["string", "null"], "format": "date" },
    "category": { "type": "string", "enum": ["meal", "supplies", "transport", "other"] }
  },
  "required": ["schema_version", "total_krw", "total_status", "purchased_on", "category"],
  "additionalProperties": false
}

分三层校验

用了结构化输出,校验仍未结束。分成三层,才能明确失败原因与应对措施。

层级 问题 失败时
0. 响应状态 是否拒绝或截断 不解析,转入第1章停止原因处理
1. 语法 能否解析为JSON 结构化输出中少见,应怀疑截断或拒绝
2. schema 字段、类型、枚举值是否正确 代码缺陷或schema不一致,记录并重试1次
3. 语义 是否符合业务规则 人工审核队列、向用户确认,或暂缓处理

第0层很容易漏掉。Anthropic文档说明,拒绝(refusal)与达到输出上限(max_tokens)的响应可能不符合schema;OpenAI也建议先检查status: "incomplete"。第3层包括“项目金额之和=总额”“退款金额≤付款金额”“结束日≥开始日”等关系检查。正如Gemini文档所说,即使输出是语法正确的JSON,应用仍应始终校验其值。

还有细节陷阱。Anthropic文档说明,结构化输出不能保证枚举值大小写,建议忽略大小写比较,不设置仅大小写不同的枚举值。此外,编译后的schema独立于消息内容缓存,最长至最后使用后24小时,因此不要在字段名、说明、枚举值中放入客户姓名等敏感信息。

工具定义是执行契约

工具调用(函数调用)是这样一项功能:先告诉模型“有这些功能可用”,再让模型在需要时提出工具名称和调用参数。模型不会直接执行我们定义的工具。真正执行工具的是应用程序(网页搜索这类由供应商代为执行的服务器工具除外)。工具定义由名称、说明和输入schema组成。对模型来说,这段说明就是它所能看到的全部使用手册。

Anthropic文档将详细的工具说明列为对工具表现影响最大的因素,并建议每个工具至少用3~4句话来说明:工具做什么,什么时候应该使用、什么时候不应使用,每个参数的含义,以及不会返回哪些信息等限制。

修改前 修改后
"description": "查询预约" "description": "按预约号查询客户已有的维修预约。查找可预约时段时不要使用,应使用find_slots。预约号以'R-'开头,共10位。返回状态、上门时间和负责技师姓名,不返回付款信息。"

其他设计原则如下。

  • 对格式敏感的参数,要附上具体示例。Anthropic提供了input_examples字段,可以在其中放入能够通过schema校验的示例。
  • 将相互关联的操作合并为一个带有action参数的工具。如果工具涉及多个服务,则在名称前加上服务前缀(billing_、calendar_)。
  • 结果中只放入下一步判断所需的信息和稳定的标识符;对于已经知道的值(如用户ID),不要再让模型填写(OpenAI)。
  • 尽量保持较少的工具数量。OpenAI建议在每一轮开始时提供少于20个函数,Gemini则建议将启用的工具控制在10~20个以内。

将工具调用循环实现为状态机

工具调用并非一次请求就结束,而是形成循环。

  1. 携带工具定义发出请求。
  2. 从响应中提取工具调用(名称、参数、调用ID)。
  3. 校验参数与权限,在应用中执行。
  4. 将结果与调用ID配对,加入对话,再次请求。
  5. 收到最终答案则结束,再次收到工具调用则返回第3步。

从简单的while开始,容易产生无限调用、错误结束、费用失控。用状态机思考,就能看出遗漏路径。状态沿“生成中→调用请求→校验→执行→回传结果→生成中”循环,最终进入“完成”或“预算超出”。预算分为步数、运行耗时、token费用三类。

const budget = { maxSteps: 6, deadlineMs: Date.now() + 30_000, maxTokens: 60_000 };
let usedTokens = 0;
for (let step = 0; ; step++) {
  if (step >= budget.maxSteps || Date.now() > budget.deadlineMs || usedTokens > budget.maxTokens) {
    return handOff("budget_exceeded"); // 转接人工客服,保留部分结果
  }
  const res = await callModel(messages, tools);
  usedTokens += totalTokens(res.usage); // 缓存token也合计在内
  const outcome = classify(res.stop_reason); // 第1章的分类器
  if (outcome !== "needs_tool") return finish(res, outcome);
  const results = await runTools(res); // 校验 → 执行 → 结果封装
  messages.push(assistantTurn(res), toolResultTurn(results));
}

不一定要亲自编写循环。Anthropic SDK的Tool Runner等辅助工具可以自动处理工具执行与结果回传循环。但无论如何,预算、权限检查和审计记录都由应用负责。

将结果回传到对话

模型调用与结果通过调用ID配对,各厂商格式不同。

厂商 模型调用 结果回传
OpenAI function_call条目:call_id、name、JSON字符串arguments function_call_output条目:相同call_id、output(通常为字符串)
Anthropic tool_use区块:id、name、对象input 下一条user消息中的tool_result区块:tool_use_id、content,必要时加is_error
Gemini 函数调用步骤:名称、参数、调用ID function_result:name、call_id、result

Anthropic的排列规则严格。tool_result必须位于相应tool_use紧接的下一条user消息,中间不能插入其他消息;在内容数组中还必须先于文本。在结果紧后附加文本,有时也会导致空响应。OpenAI的arguments是JSON字符串,需要解析;没有返回值的函数,应返回表示成功与否的字符串。

失败也是结果。Anthropic应附上is_error: true,说明哪里出错、下一步可以尝试什么。比起“failed”,“库存系统无响应,60秒后重试或查询其他网点”更有帮助。参数错误的调用收到错误结果后,Claude通常会修改并重试2~3次。各工具返回的数据结构不同会增加解读难度,因此应确定公共封装格式。

{ "status": "needs_info", "data": null, "error": null, "hint": "需要上门地址的楼栋与房号" }

status使用ok、error、needs_info几种就足够。限制结果大小,并在发送前剔除模型无须知道的个人信息。

并行工具调用

模型可在一次响应中同时请求多个工具。Anthropic文档说明,API不规定执行顺序;独立读操作可同时执行,有副作用、共享状态或顺序重要的操作应依次执行。无论采用哪种执行方式,都应遵守以下规则。

  • 每个调用都返回一个结果。即使决定不执行,也应返回包含is_error: true和“因前一个调用失败而未执行”等简短说明的结果。
  • Anthropic要求把全部结果放在一条user消息中。文档警告,分成多条消息发送,会让模型“学会”回避并行调用,减少并行使用。
  • 可以关闭并行调用。Anthropic在tool_choice内设置disable_parallel_tool_use: true(并非顶层参数),在auto模式下每次响应最多调用一个工具。OpenAI使用parallel_tool_calls: false,将调用限制为0或1个。

Gemini文档区分独立的并行调用,与前一结果作为后一调用输入的组合式(compositional)调用。存在依赖关系时,应用必须强制顺序。

tool_choice与强制调用

tool_choice决定模型是否使用工具、使用哪个。

意图 OpenAI Anthropic Gemini
由模型判断 auto(默认) auto(有工具时默认) auto(默认)
必须使用工具 required或指定函数 any或tool any
禁止使用 none none none
限制允许列表 allowed_tools — allowed_tools

强制调用的支持因模型而异。根据Anthropic文档,Claude Opus 5.5、Sonnet 5.5、Fable 5.1、Mythos 5.1会以400拒绝any与tool,因此应结合auto和strict工具;若需要固定JSON响应,则使用结构化输出。即便模型支持,强制调用也会省略思考过程与工具调用前的解释。Anthropic中修改tool_choice会使缓存的消息部分失效;OpenAI说明,在工具列表不变的前提下,仅用allowed_tools限制调用范围,有助于保留缓存节省效果。

推理模型与工具循环

推理模型会随工具调用返回推理信息。这些信息必须在下一请求中原样回传。

  • OpenAI:将与工具调用一同返回的推理条目(reasoning item)和函数结果一起重发。可使用previous_response_id,或将先前输出条目原样放入输入。无状态使用时,传递推理条目的encrypted_content。
  • Anthropic:回传工具结果时,最新assistant消息中的thinking与redacted_thinking(为安全而部分遮蔽的思考区块)必须连同签名(signature)原样返回。编辑、重排或省略这些区块会导致400,即使内容为空的区块也不例外。
  • Gemini:自行无状态管理历史时,原样重发模型生成的步骤,因为步骤中包含必要签名。使用SDK会自动处理。

问题通常出现在为节省token而过滤区块,或重新序列化历史的中间件中。原则是“历史只追加”。

执行权限由服务器掌握

strict模式仅保证参数形状,是否可以用这些参数执行,是另一个问题。OWASP Top 10 for LLM Applications 2025将因功能、权限、自主性过多而让模型输出导致有害行动的风险称为过度授权(LLM06 Excessive Agency),将未经充分校验与清理就把模型输出传给其他系统的风险称为不当输出处理(LLM05 Improper Output Handling)。

  1. 用户与组织标识符由服务器从会话填入,不让模型选择客户ID参数。
  2. 执行前重新检查权限与业务规则(状态、限额、所有权)。Gemini文档也建议执行函数调用前进行校验。
  3. 区分读工具与写工具,写工具仅授予必要的最小范围。
  4. 难以撤销的操作需人工批准。保存待批准状态,将批准结果作为工具结果回传,继续对话。
  5. 不信任工具结果中夹杂的外部文本(第1章信任边界)。

深入阅读

工具越来越多时

逐一接入系统后,工具可能增至数十甚至数百个,成本与准确率都会恶化。工具定义按输入token收费(OpenAI与Anthropic均明确说明),Anthropic还会加入独立的工具使用系统提示词。相似工具过多,也会增加模型选错的情况。

策略 方法 代价
合并 用action参数整合相关操作 单个工具说明变长
分阶段子集 各对话阶段只放入所需工具 工具列表变化会破坏缓存
允许列表 列表不变,仅限制可调用范围(allowed_tools) 定义token费用不变
工具搜索 延迟加载定义(defer_loading),由模型搜索并仅加载所需工具 增加搜索步骤,支持模型有限

Anthropic提供“工具搜索”功能,可在需要时查找并加载数千个工具,OpenAI也在最新模型中支持延迟加载与工具搜索。Anthropic中修改工具定义会使全部缓存失效,因此每阶段重组列表可能抵消成本节省。无论哪种策略,都要用评估集测量工具选择准确率。

流式传输中的结构化结果

结构化输出和工具参数同样可以通过流式传输接收。这样能够更早在界面上展示内容,但真正执行操作,必须等到接收全部结束之后。

  • Anthropic通过input_json_delta将工具输入作为不完整的JSON字符串发送。应用应收集这些片段,在收到content_block_stop时进行解析。如果希望把较大的参数值拆成更小的片段接收,可以逐个工具启用eager_input_streaming,使用细粒度流式传输。
  • OpenAI通过response.function_call_arguments.delta发送参数片段,通过.done发送已经完整生成的参数。
  • Gemini文档说明,流式传输的各个片段是不完整的JSON字符串,将它们依次拼接后才会得到最终对象。

规则很简单。界面预览可以用部分结果来绘制,但工具执行和数据保存,都只能在完整参数通过三层校验之后进行。如果流在中途断开,正如Anthropic文档所说明的,工具使用区块无法进行部分恢复。因此,不要将已经收到的片段拼在一起执行,而要重新发出请求(第4章)。

工作场景

沙粒会计:重新设计收据提取schema

面向个体经营者的记账应用“沙粒会计”,从收据照片的OCR文本中提取支出项目。原先用指令接收JSON,每天出现数十次解析失败与类型错误,开发者成主管因此重新设计schema。

首稿将总额设为可选字段,无法读取时省略。但OpenAI strict模式要求所有字段都在required中;更关键的是,要区分“未印刷”与“模糊无法读取”:前者用项目金额之和代替,后者应请用户重拍。因此将total_krw设为可为null的整数,状态通过total_status枚举接收。金额固定为韩元整数,日期固定ISO格式,原始日期字符串另用raw_date接收。账目ID和登记时间由服务器添加。

用Claude测试同一功能,又出现一个问题:要求金额不小于0的minimum: 0约束,Anthropic结构化输出不支持。团队将约束移入字段说明,并在接收响应后由应用再次检查。语义校验则加入规则:项目之和与总额相差超过10韩元时,进入用户确认界面。

在JSON模式中增加示例的方案无法阻止schema不一致;无法读取就填0的方案,又无法与真实0韩元收据区分,因此都被否决。部署一个月后,解析失败消失;总量的1.2%因总额不一致而进入确认界面,由用户自行修正。

巧手维修中心:给维修受理智能体划清边界

家电维修企业“巧手维修中心”开发聊天受理维修的智能体,包含保修查询(lookup_warranty)、可上门时段查询(find_slots)、创建受理单(create_repair_ticket)三个工具。试运行中发生三类事故:用户提供他人产品序列号时,系统查询了该产品保修记录;模型在同一响应中同时请求查时段与建受理单,导致空闲时段未确认就创建受理;一条模糊请求让循环运行九轮,消耗费用。

首席工程师都经理没有先修改提示词,而是先改变了系统结构。

  1. 从工具参数中移除客户ID,由服务器从登录会话填入,执行前检查序列号是否为该客户登记的产品。
  2. create_repair_ticket必须接收find_slots返回的slot_hold_id。预留记录设有效期;缺少预留或已过期时,服务器不执行,而返回要求重新查询的needs_info结果。即使收到并行请求,也由服务器强制依赖关系,避免乱序。
  3. 两个读工具允许并行;同一响应混有写工具时,先执行读取,再校验写入。未执行的调用返回说明原因的错误结果。
  4. 循环预算设为六步、30秒,超出时携带对话摘要转人工客服。

只添加“不要查询他人产品”的指令,在模型不遵守时没有拦截机制,因此决定仅与服务器检查配合使用。固定强制调用创建受理单的方案,既不被所选模型支持,也剥夺了信息不足时提问的机会,因此被否决。之后受理错误消失,转人工比例维持在7%。

易错点

  • JSON模式也保证schema。 JSON模式只保证语法,字段与类型由基于schema的结构化输出保证。
  • 通过schema就说明内容正确。 格式正确但值错误完全可能。总额、日期、状态等关系要在语义校验中确认。
  • 可选字段从required移除即可。 OpenAI strict模式要求所有字段放入required,通过允许null的类型表示。
  • schema中写出的约束,供应商都会遵守。 支持范围不同。Anthropic不接受数字和字符串长度约束,需在应用中校验。
  • 未执行的调用可以省略结果。 每个调用都必须有结果,未执行也应通过错误结果说明。
  • 开启strict,就能安全地用这些参数执行。 strict只保证形状。权限、所有权、业务规则需在执行前由服务器检查。
  • 并行调用结果可以拆成多条消息发送。 Anthropic要求集中在一条user消息中,拆分会减少并行调用。

要点速览

主题 需要记住的内容
保证范围 指令 < JSON模式(语法) < 结构化输出(schema)。值的准确性不在保证范围
strict规则 additionalProperties: false。OpenAI为全字段required加null,Anthropic不支持数字、长度约束与递归
schema设计 区分缺失含义,固定单位,服务器已知值由服务器填入,设置版本字段
校验 状态 → 语法 → schema → 语义
工具定义与循环 说明是全部使用依据。循环采用状态机,加上步数、时间、token预算
结果回传 调用ID配对,每个调用有结果。Anthropic放入一条消息且结果在前
强制调用与推理条目 支持强制调用的模型有限(替代方案:auto加strict、结构化输出)。推理条目原样回传
权限 标识符由服务器填入,执行前重新检查

查阅官方文档

自测

2-1. 列出从业务概念推导schema时应确定的四件事,并举例展示如何区分“值无法读取”与“原本就没有值”。

2-2. 列出OpenAI与Anthropic strict规则的两项差异,说明同时使用两家供应商的服务在管理schema时应注意什么。

2-3. 各用一句话定义语法、schema、语义校验,并写出这三层之前应检查什么。

2-4. 确定工具结果公共封装格式的字段,解释为什么要在失败结果中说明下一步可尝试的操作。

2-5. 列出工具调用循环状态机所需状态、应设置的三类预算以及超出预算时的处理。

2-6. 举出三个不应让模型选择为参数、而应由服务器直接填入的值,并结合OWASP Top 10 for LLM Applications中的风险项目,解释原因。

2-7. 设计企业内部会议室预约助手。用表格整理工具列表、各工具说明中必须包含的内容、服务器直接填入的值及执行前检查项。

实操练习

练习

schema与三层校验器(45分钟)

  1. 从收据、订单、咨询邮件中选择一种,从业务概念出发推导schema。加入能够区分不同缺失含义的枚举、单位、由服务器赋予的值,以及schema_version。
  2. 根据所用供应商的strict规则修改schema,把供应商不支持的约束移到说明和应用程序的检查中。
  3. 编写一个按响应状态→语法→schema→语义顺序进行检查的函数,将不同阶段的失败分别送入不同处理路径(重试、向用户提问确认、暂缓处理)。
  4. 运行20个输入。其中5个输入应设计为缺少值或各值之间相互矛盾的情况。

完成标准: 用表格展示全部20条输入分别在哪个阶段通过或失败,并确认5条矛盾输入均在语义校验阶段被拦截,即为完成。

练习

带预算的状态机工具循环(60分钟)

  1. 定义两个查询工具和一个修改工具,并启用strict。用户ID由服务器填入。
  2. 将修改工具设为只有提供查询结果中返回的标识符才能执行,并在即将执行之前检查权限和状态。
  3. 为循环设置步数预算和时间预算,超出预算时,将部分结果一并转交给人工处理。
  4. 注入故障,让修改工具每执行三次就失败一次,并把失败作为公共封装格式的错误结果回传。对于没有执行的调用,也要返回结果。
  5. 运行十次对话,检查运行日志。

完成标准: 所有工具调用都有正确配对的结果,未通过检查的调用没有被执行,超出预算的对话已转交人工处理,即为完成。