Responses API 与内置工具:函数调用、web search / file search / computer use、结构化输出
从 Codex 这样的成品往下看一层:Responses API 是怎么把模型、工具和多轮状态组织起来的,以及它跟 Chat Completions 到底差在哪。
今日目标
- 能说清 Responses API 与 Chat Completions 在输入形态、输出形态、状态管理上的三处区别
- 能写一个带函数调用与 web search 的最小脚本,并把 function_call_output 正确回填
- 能用 json_schema 严格模式拿到结构化输出,并处理 refusal 分支
前两天你用的是成品。今天拆开看一层:Codex 这类产品跟模型说话用的接口是什么样的,工具是怎么接进去的,多轮状态放在哪。读完做完,回到页面顶部把三条目标勾掉。
小白版讲解
从 messages 到 items:换掉的不只是字段名
还是两位搭档的类比。你给两家公司各派了一件活,两家的工单格式不一样。老格式(Chat Completions)是一张聊天记录:一条条消息,每条标着谁说的;新格式(Responses API)更像一份工作日志:里面不只有「说了什么」,还有「查了什么」「调了哪个工具」「工具回了什么」,每一项都是一个带类型的条目。OpenAI 把后者叫 items——输入是 items 的数组,输出也是 items 的数组,消息只是其中一种类型。
三处差别值得记准,因为它们决定了你写代码的方式:
- 输入形态:Chat Completions 收
messages数组,系统提示是数组里的第一条;Responses 收input(可以是一个字符串,也可以是 items 数组),系统级指令单独放在顶层的instructions字段。 - 输出形态:Chat Completions 返回
choices,你要取choices[0].message.content;Responses 返回output数组,里面按顺序是模型这一轮做的每件事(工具调用、搜索、消息),SDK 额外给了一个output_text助手直接拿到最终文本。 - 状态管理:Chat Completions 是无状态的,历史每轮由你重发;Responses 默认
store为 true,会把这次响应存起来,下一轮只要传previous_response_id就能接着聊,不用重发历史。你也可以关掉存储自己维护历史——两种做法的取舍最后一节讲。
最小的一次调用长这样:
import OpenAI from 'openai'
const client = new OpenAI() // 读环境变量 OPENAI_API_KEY
const response = await client.responses.create({
model: process.env.OPENAI_MODEL ?? '',
instructions: '你是一个 TODO API 项目的助手,回答简短。',
input: '用一句话解释什么是输入校验',
})
console.log(response.output_text)import os
from openai import OpenAI
client = OpenAI() # 读环境变量 OPENAI_API_KEY
response = client.responses.create(
model=os.environ["OPENAI_MODEL"],
instructions="你是一个 TODO API 项目的助手,回答简短。",
input="用一句话解释什么是输入校验",
)
print(response.output_text)跟 30 天课 D1 那个 Chat Completions 请求对着看:messages 变成了 input 加 instructions,choices[0].message.content 变成了 output_text。模型 id 这门课一律从环境变量 OPENAI_MODEL 读——OpenAI 的型号更新很快,写死在正文里几个月就过期,以官方文档当前推荐为准。
为什么要换格式?因为 Chat Completions 的「聊天记录」模型装不下工具。当模型不只是回话,还要调工具、搜网页、读文件时,把这些动作硬塞进「消息」里会很别扭。items 让每一种动作都有自己的类型,这也是下面几节要讲的内置工具能顺畅接入的前提。
函数调用:声明、请求、回填
搭档需要查你们内部的东西时,他不能自己查,得让你去查再把结果告诉他。函数调用(function calling)就是这个来回:你声明有哪些函数可以调、参数长什么样;模型判断需要时请求调用某个函数并给出参数;你的代码真的去执行,把结果回填给模型,模型再基于结果继续。
三步分别对应三种 item。声明是请求里 tools 数组的一项,类型 function,带 name、description、JSON Schema 形式的 parameters,以及 strict: true 表示参数必须严格符合 schema。模型的请求是输出里一个 function_call 类型的 item,带 call_id(这次调用的编号)和 arguments(JSON 字符串)。回填是你在下一轮的 input 里放一个 function_call_output item,call_id 对上,output 是结果字符串。
import OpenAI from 'openai'
const client = new OpenAI()
const model = process.env.OPENAI_MODEL ?? ''
const todos = [{ id: 1, title: '给 POST /todos 补校验', done: false }]
const tools: OpenAI.Responses.Tool[] = [
{
type: 'function',
name: 'list_todos',
description: '列出当前所有 TODO',
parameters: { type: 'object', properties: {}, additionalProperties: false },
strict: true,
},
]
const first = await client.responses.create({
model,
tools,
input: '现在还有哪些没做完的事?',
})
// 找出模型请求的函数调用,真的去执行,再把结果回填
const calls = first.output.filter((item) => item.type === 'function_call')
const outputs = calls.map((call) => ({
type: 'function_call_output' as const,
call_id: call.call_id,
output: JSON.stringify(todos.filter((t) => !t.done)),
}))
const second = await client.responses.create({
model,
tools,
previous_response_id: first.id, // 接着上一轮,历史不用重发
input: outputs,
})
console.log(second.output_text)import json
import os
from openai import OpenAI
client = OpenAI()
model = os.environ["OPENAI_MODEL"]
todos = [{"id": 1, "title": "给 POST /todos 补校验", "done": False}]
tools = [
{
"type": "function",
"name": "list_todos",
"description": "列出当前所有 TODO",
"parameters": {"type": "object", "properties": {}, "additionalProperties": False},
"strict": True,
}
]
first = client.responses.create(model=model, tools=tools, input="现在还有哪些没做完的事?")
# 找出模型请求的函数调用,真的去执行,再把结果回填
outputs = [
{
"type": "function_call_output",
"call_id": item.call_id,
"output": json.dumps([t for t in todos if not t["done"]], ensure_ascii=False),
}
for item in first.output
if item.type == "function_call"
]
second = client.responses.create(
model=model,
tools=tools,
previous_response_id=first.id, # 接着上一轮,历史不用重发
input=outputs,
)
print(second.output_text)注意两个工程细节。第一,call_id 必须原样对上,模型靠它把结果和请求配对,一轮里请求了三个函数你就要回填三个。第二,strict: true 加上 additionalProperties: false 是一对,前者让模型的参数严格符合 schema,后者禁止多出字段——少了任何一个,你就得在代码里防御性地处理奇形怪状的参数。这两行代码换来的是:参数解析这一层基本不用写 try/catch。
回头看 D1:Codex 读文件、跑命令,底层就是这个来回,只是它的工具是 shell 和文件系统,而且循环由它自己转。你现在手里这个二十几行的脚本,就是一个最小的「Agent 循环」的一次迭代。
内置工具:web search 与 file search 是平台替你跑的
上一节的函数由你执行。有一类工具太通用了,OpenAI 干脆在服务端替你执行,你只要在 tools 里声明一下——这就是内置工具(built-in tools)。声明方式和函数工具在同一个数组里,区别是 type 不同,而且不需要你回填任何东西。
web search:{ type: 'web_search' }。模型判断需要时自己搜,输出里会多一个 web_search_call item 记录它搜了什么,最终的 message item 里带 annotations,每条 url_citation 指向一个来源。可以用 search_context_size(low / medium / high)控制拉多少网页内容进上下文,用 filters.allowed_domains 限制只搜某些站——做企业内知识问答时这一条很实用。
file search:先把文件上传并放进一个向量库(vector store),然后声明 { type: 'file_search', vector_store_ids: ['...'], max_num_results: 3 }。模型需要时去库里检索,输出里多一个 file_search_call item,消息里的 annotations 是 file_citation。在请求里加 include: ['file_search_call.results'] 可以把检索到的原文片段也一起返回,方便调试「它到底看到了什么」。这基本是一个托管版的 RAG,30 天课 D12 手写的那条流水线,这里被压成了一个字段。
import OpenAI from 'openai'
const client = new OpenAI()
const response = await client.responses.create({
model: process.env.OPENAI_MODEL ?? '',
tools: [{ type: 'web_search', search_context_size: 'low' }],
input: 'Express 里做请求体校验,目前社区常用哪几个库?给出来源。',
})
console.log(response.output_text)
// 把引用来源单独列出来:annotations 在 message item 的 content 里
for (const item of response.output) {
if (item.type !== 'message') continue
for (const part of item.content) {
if (part.type !== 'output_text') continue
for (const a of part.annotations) {
if (a.type === 'url_citation') console.log('来源:', a.url)
}
}
}import os
from openai import OpenAI
client = OpenAI()
response = client.responses.create(
model=os.environ["OPENAI_MODEL"],
tools=[{"type": "web_search", "search_context_size": "low"}],
input="Express 里做请求体校验,目前社区常用哪几个库?给出来源。",
)
print(response.output_text)
# 把引用来源单独列出来:annotations 在 message item 的 content 里
for item in response.output:
if item.type != "message":
continue
for part in item.content:
if part.type != "output_text":
continue
for a in part.annotations:
if a.type == "url_citation":
print("来源:", a.url)内置工具和自己写的函数怎么选?口径是:数据在外面且通用的(公网、你上传的文档)用内置工具,数据在你系统里的(数据库、内部服务、业务逻辑)写函数。内置工具省掉了执行和回填,但你控制不了它怎么搜、搜到什么;函数工具样样要自己写,但每一步都在你手里。生产系统里两者几乎总是混用。
computer use:让模型操作界面,以及为什么要关进隔离环境
再往前一步:如果任务是「打开这个后台,把这批工单的状态改掉」,没有 API 可调,只有一个网页界面呢?这就是 computer use 要解决的问题——让模型看屏幕截图、决定点哪里、输入什么。官方文档目前给了两条路:一是让模型写代码去操作界面(比如生成 Playwright 脚本,在隔离环境里执行,环境在多次调用间保持),二是用 { type: 'computer' } 这个计算机工具,模型返回结构化的动作(click、type、scroll、keypress、screenshot 等),你的代码执行后把新的截图送回去,循环往复。
今天不给这段代码,原因是它不该出现在一个「最小脚本」里。文档反复强调的是:把它关进隔离的浏览器或虚拟机,并给一个允许访问的站点与动作清单。一个能点击任何按钮、输入任何文字的模型,在你的日常桌面上是不能跑的——它可能点到「删除全部」,也可能被网页上的一段文字诱导去做别的事(这就是提示词注入在界面操作上的版本)。所以 computer use 的正确起点不是代码,是环境:一个一次性的容器、一个受限的账号、一个白名单。
把它放在这里讲,是为了让你看到内置工具的完整光谱:从最安全的 web search(只读公网),到 file search(只读你给的文件),到函数调用(你写的代码、你控制副作用),再到 computer use(模型直接产生副作用)。越往右能力越强,需要的隔离也越重。D1 讲的 Codex 沙箱,就是这条光谱上「让模型执行 shell」那一格的隔离方案。
结构化输出:strict 模式、parse 助手与 refusal
回到搭档。你让他汇报进度,他写了一大段散文,你还得从里面把数字挑出来——不如给他一张表让他填。结构化输出(structured output)就是那张表:你给一个 JSON Schema,模型的输出保证符合它。
Responses API 里写在 text.format 字段:{ type: 'json_schema', name, schema, strict: true }。strict 是关键——开了它,输出保证能通过 schema 校验,不再是「大概率是合法 JSON」。两边 SDK 都有助手让你少写 schema:TypeScript 用 zodTextFormat 把 zod schema 转过去,Python 直接传 pydantic 模型;然后用 responses.parse 代替 create,结果在 output_parsed 里已经是解析好的对象。
import OpenAI from 'openai'
import { z } from 'zod'
import { zodTextFormat } from 'openai/helpers/zod'
const client = new OpenAI()
const Review = z.object({
verdict: z.enum(['approve', 'request_changes']),
issues: z.array(z.object({ file: z.string(), line: z.number(), note: z.string() })),
})
const response = await client.responses.parse({
model: process.env.OPENAI_MODEL ?? '',
input: '审查这段 diff:……(略)',
text: { format: zodTextFormat(Review, 'review') },
})
// 先看有没有拒答:refusal 是一种独立的 content 类型,不是解析失败
const refused = response.output
.filter((item) => item.type === 'message')
.flatMap((item) => item.content)
.find((part) => part.type === 'refusal')
if (refused) {
console.log('模型拒绝了:', refused.refusal)
} else {
console.log(response.output_parsed?.verdict, response.output_parsed?.issues.length)
}import os
from openai import OpenAI
from pydantic import BaseModel
client = OpenAI()
class Issue(BaseModel):
file: str
line: int
note: str
class Review(BaseModel):
verdict: str # approve / request_changes
issues: list[Issue]
response = client.responses.parse(
model=os.environ["OPENAI_MODEL"],
input="审查这段 diff:……(略)",
text_format=Review,
)
# 先看有没有拒答:refusal 是一种独立的 content 类型,不是解析失败
refused = next(
(
part
for item in response.output
if item.type == "message"
for part in item.content
if part.type == "refusal"
),
None,
)
if refused:
print("模型拒绝了:", refused.refusal)
else:
review = response.output_parsed
print(review.verdict, len(review.issues))refusal 这个分支容易被忽略。当模型因为安全原因拒绝回答时,它不会硬塞一个不合法的 JSON 给你,而是返回一个 refusal 类型的内容块。不处理它,你的代码会拿到一个 output_parsed 为空的响应然后在下游炸掉;处理了,你就能把「模型不愿意」和「模型没做对」区分开——这在做审查、分类这类自动化流水线时是必需的。
strict 模式解决了「格式对不对」,没解决「内容对不对」。schema 保证 verdict 一定是两个枚举值之一,但不保证判断正确。这一点面试里常被追问:结构化输出让你省掉了解析层的防御代码,业务层的校验一行都不能省。
多轮状态:previous_response_id 还是自己带历史
最后一个问题:多轮对话的历史放哪。Responses 给了两条路。
让服务端存:默认 store: true,每次响应都被存下来,下一轮传 previous_response_id 就接上了,请求体只放新内容。好处是请求小、代码简单,第二节的函数调用例子就是这么串的。代价是状态在别人那里:你想「回到第三轮重来」得记住每一轮的 id;你想审计完整对话得另外拉;合规要求数据不出境时这条路走不通。
自己维护:store: false,每轮把完整的 items 数组(包括之前的 function_call 和 function_call_output)作为 input 重发。这就是 30 天课 D1 那套「历史靠你自己搬」,成本随轮数增长,但每一个字节都在你手里,想压缩、想截断、想换模型重放都行。
选哪条?原型和内部工具用前者,省事;面向用户的生产系统多半用后者,或者混合——用 previous_response_id 串短链,同时自己落一份历史做审计与恢复。明天讲的 Agents SDK 把这个选择包成了 sessions,你会看到同一个取舍换了一层外壳。
源码导读
动手实验
代码在 labs/codex-mastery/day-03-responses-tools,starter/ 挖了四个练习点,solution/ 是完整答案。这是一次性脚本,不是 REPL,所以直接带参数跑,不要用管道喂输入。
- 跑
MOCK=1 pnpm start "你好",看到一次最简单的responses.create往返,打印出output_text。 - 实现练习 1 与 2:声明
list_todos函数工具,从输出里筛出function_call,执行后用function_call_output回填,跑MOCK=1 pnpm start "还有哪些没做完"看到 TODO 标题出现在最终回答里。 - 实现练习 3:加
--search时在tools里加web_search,从 message 的 annotations 里把url_citation单独打印。 - 实现练习 4:加
--structured时改用responses.parse与zodTextFormat,先检查refusal再读output_parsed。 - 配好
OPENAI_API_KEY与OPENAI_MODEL,去掉 MOCK 跑一遍,对比 MOCK 输出与真实输出的形状是否一致。
面试题
今天 3 道题在下方题库区,侧重 Responses API 与 Chat Completions 的取舍、内置工具与自定义函数的边界、结构化输出的可靠性。展开后先看「分析过程」再看要点——照着推导练,比背要点管用。
检查清单与明日预告
- 能说清 Responses API 与 Chat Completions 在输入形态、输出形态、状态管理上的三处区别
- 能写一个带函数调用与 web search 的最小脚本,并把 function_call_output 正确回填
- 能用 json_schema 严格模式拿到结构化输出,并处理 refusal 分支
- 能按「能力越强隔离越重」排出 web search、file search、函数调用、computer use 的顺序并说明理由
- 实验的 4 条验收标准全部通过
- 3 道面试题不看要点也能答出至少 2 道
明天(D4)我们用官方的 Agents SDK 把今天手写的循环收进几行 API:Agent 对象装下指令和工具,run 函数替你转循环;然后讨论三个今天还没碰的问题——多个 Agent 之间怎么交接(handoffs)、怎么在入口和出口设护栏(guardrails)、多轮记忆怎么交给 SDK 存(sessions)。先手写再用 SDK 是有意的顺序:你已经知道 function_call 和 function_call_output 的来回是什么,明天看到 SDK 把它们藏起来时,才知道它藏了什么、出问题时该往哪一层找。
面试题库
Responses API 和 Chat Completions 的区别是什么?从 Chat Completions 迁移过去最容易踩什么坑?How does the Responses API differ from Chat Completions, and what are the common pitfalls when migrating?
国内高频海外高频基础#responses-api#openai#migration分析过程 · 先想清楚再作答
- 这题考的是你有没有真迁移过,而不是能不能背出字段名。只答「新接口更强」会被判为看过文档没写过代码。
- 拆成三个维度:输入形态(messages 数组变成 input 加顶层 instructions)、输出形态(choices 变成按类型排列的 output items,SDK 给 output_text 助手)、状态管理(无状态变成默认 store 加 previous_response_id)。
- 再说为什么要改:聊天记录模型装不下工具动作;items 让搜索、函数调用、回填各有自己的类型,这是内置工具能接进来的前提。
- 迁移坑给三条:默认 store 为 true 意味着数据会被存下来,合规场景要显式关掉;output 是数组不是单个消息,取文本要用 output_text 或遍历 message item;函数调用的回填从 role 为 tool 的消息变成 function_call_output item,call_id 要对上。
- 可预期的追问:previous_response_id 和自己维护历史怎么选?原型用前者省事,生产多半自己落一份历史做审计与恢复,或两者混用。
How to reason about it · think before answering
- This tests whether you have actually migrated code, not whether you can recite field names.
- Split into three axes: input shape (messages array becomes input plus top-level instructions), output shape (choices becomes typed output items with an output_text helper), and state (stateless becomes store by default plus previous_response_id).
- Explain the motivation: a chat-transcript model cannot hold tool actions; items give search, function calls and their outputs distinct types, which is what makes built-in tools possible.
- Name three pitfalls: store defaults to true so compliance-sensitive apps must disable it; output is an array, so read output_text or walk message items; tool results move from role tool messages to function_call_output items keyed by call_id.
- Expect the follow-up: previous_response_id versus self-managed history? Prototypes take the former; production usually keeps its own history for audit and recovery, or mixes both.
答题要点
- 输入:messages 变 input 加顶层 instructions;输出:choices 变按类型排列的 output items 与 output_text
- 状态:默认 store 为 true,用 previous_response_id 接上一轮,不再每轮重发历史
- 改的动机是给工具动作独立的 item 类型,内置工具由此接入
- 迁移坑:store 默认开、output 是数组、回填要用 function_call_output 且 call_id 对上
Key points
- Input: messages become input plus top-level instructions; output: choices become typed output items plus output_text
- State: store defaults to true and previous_response_id chains turns without resending history
- The motivation is distinct item types for tool actions, enabling built-in tools
- Pitfalls: store on by default, output is an array, tool results go back as function_call_output keyed by call_id
平台内置的工具(web search、file search、computer use)和自己写的函数工具,各适合什么场景?为什么 computer use 要单独对待?When do you use platform built-in tools (web search, file search, computer use) versus your own function tools, and why does computer use deserve special treatment?
国内高频海外高频进阶#tools#responses-api#security分析过程 · 先想清楚再作答
- 题眼有两个:一是「谁来执行」,二是「副作用有多大」。只答功能对比不谈执行方与风险,就是没做过工程。
- 先给执行方的判据:内置工具由平台在服务端执行,你只声明、不回填、也控制不了它怎么搜;函数工具由你执行,样样自己写,但每一步都在你手里。
- 落到场景:数据在外面且通用(公网、你上传的文档)用内置工具;数据在你系统里(数据库、内部服务、业务逻辑)写函数;生产系统几乎总是混用。
- 再按副作用排一条光谱:web search 只读公网,file search 只读你给的文件,函数调用的副作用由你的代码决定,computer use 由模型直接产生副作用——越往右能力越强,需要的隔离越重。
- computer use 单独对待的原因:它能点任何按钮、输任何文字,还可能被页面内容诱导,所以正确起点是隔离环境、受限账号和站点与动作白名单,不是代码。
- 可预期的追问:内置的 file search 和自己搭 RAG 怎么选?前者是托管版,省掉切分、向量化、检索三步,代价是可控性与可观测性弱,需要自定义切分或重排时才自己搭。
How to reason about it · think before answering
- Two cruxes: who executes the tool, and how large its side effects are; comparing features alone signals no production experience.
- Executor test: built-in tools run server-side, you declare but never fill results and cannot steer the search; function tools run in your code, more work but full control.
- Map to scenarios: external, generic data (the web, your uploaded documents) fits built-ins; data inside your systems (databases, internal services, business logic) needs functions; production mixes both.
- Order by side effects: web search reads the public web, file search reads your files, function calls have whatever side effects your code allows, computer use lets the model act directly; more capability demands heavier isolation.
- Computer use is special because it can click anything, type anything and be steered by on-screen content, so the starting point is an isolated environment, a restricted account and an allow-list, not code.
- Expect the follow-up: built-in file search versus your own RAG? The built-in is a managed pipeline that skips chunking, embedding and retrieval work at the cost of control and observability; build your own when you need custom chunking or reranking.
答题要点
- 内置工具由平台执行、不用回填、不可干预;函数工具由你执行、全部可控
- 外部通用数据用内置工具,系统内数据与业务逻辑写函数,生产混用
- 按副作用排序:web search、file search、函数调用、computer use,能力越强隔离越重
- computer use 的起点是隔离环境与白名单,不是代码
Key points
- Built-ins run on the platform with no result filling and no steering; functions run in your code with full control
- External generic data suits built-ins, in-system data and business logic need functions, production mixes both
- Rank by side effects: web search, file search, function calls, computer use; more power needs more isolation
- Computer use starts with an isolated environment and an allow-list, not with code
结构化输出的 strict 模式解决了什么问题,没解决什么问题?拿到输出之后代码里还要做什么?What does strict mode in structured outputs solve, what does it not solve, and what must your code still do after receiving the output?
国内高频海外高频进阶#structured-output#responses-api#validation分析过程 · 先想清楚再作答
- 这题考的是对「格式正确」与「内容正确」的区分,答成「有了 strict 就不用校验了」是典型的错误。
- 先说解决了什么:strict 加 json_schema 保证输出一定能通过 schema 校验——枚举只会是给定值、必填字段一定在、类型不会错,解析层的 try/catch 与重试基本可以删掉。
- 再说没解决什么:schema 管不了语义。verdict 一定是两个枚举之一,但判断可能是错的;数字一定是数字,但可能是编的。业务层校验一行不能省。
- 然后是拒答分支:模型因安全原因拒绝时返回 refusal 类型的内容块,而不是硬塞一个不合法的 JSON;不处理它,下游会拿到空的解析结果直接崩,处理了才能区分「不愿意」与「没做对」。
- 给出代码里的顺序:先查 refusal,再读 output_parsed,再做业务校验(范围、引用是否存在、与上下文是否一致),最后才落库或执行。
- 可预期的追问:strict 对 schema 有什么限制?每个对象都要 additionalProperties 为 false、字段都要在 required 里,可选字段用可空类型表达;这些限制正是它能给出保证的原因。
How to reason about it · think before answering
- This tests the distinction between well-formed and correct; claiming strict mode removes the need for validation is the classic mistake.
- What it solves: strict plus json_schema guarantees the output validates against the schema, so enums are always allowed values, required fields exist and types are right; parsing-layer try/catch and retries can largely go.
- What it does not solve: semantics. The verdict is one of two enums but may be wrong; a number is a number but may be invented. Business validation stays.
- Then refusals: a safety refusal comes back as a refusal content block, not malformed JSON; unhandled, downstream code crashes on an empty parse, handled, you can separate unwilling from incorrect.
- Give the order in code: check refusal, read output_parsed, run business checks (ranges, referenced entities exist, consistency with context), then persist or act.
- Expect the follow-up: schema restrictions under strict? Every object needs additionalProperties false and all fields in required, optional fields become nullable; these constraints are exactly what makes the guarantee possible.
答题要点
- 解决:输出保证符合 schema,解析层防御代码可以删
- 没解决:语义正确性,业务校验一行不能省
- 先查 refusal 再读 output_parsed,再做业务校验,最后落库
- strict 要求 additionalProperties 为 false、字段全在 required 里,可选用可空类型表达
Key points
- Solves: output is guaranteed to match the schema, so parsing defenses can go
- Does not solve: semantic correctness, so business validation stays
- Check refusal first, then output_parsed, then business checks, then persist
- Strict requires additionalProperties false and all fields required, optional fields become nullable
评论
登录后即可参与讨论
还没有评论,来说第一句。