逐日AI
第 1 周 · D3约 4 小时

Responses API 与内置工具:函数调用、web search / file search / computer use、结构化输出

从 Codex 这样的成品往下看一层:Responses API 是怎么把模型、工具和多轮状态组织起来的,以及它跟 Chat Completions 到底差在哪。

今日目标 0/3

登录后可以勾选并保存进度。

今日目标

  1. 能说清 Responses API 与 Chat Completions 在输入形态、输出形态、状态管理上的三处区别
  2. 能写一个带函数调用与 web search 的最小脚本,并把 function_call_output 正确回填
  3. 能用 json_schema 严格模式拿到结构化输出,并处理 refusal 分支

前两天你用的是成品。今天拆开看一层:Codex 这类产品跟模型说话用的接口是什么样的,工具是怎么接进去的,多轮状态放在哪。读完做完,回到页面顶部把三条目标勾掉。

小白版讲解

从 messages 到 items:换掉的不只是字段名

还是两位搭档的类比。你给两家公司各派了一件活,两家的工单格式不一样。老格式(Chat Completions)是一张聊天记录:一条条消息,每条标着谁说的;新格式(Responses API)更像一份工作日志:里面不只有「说了什么」,还有「查了什么」「调了哪个工具」「工具回了什么」,每一项都是一个带类型的条目。OpenAI 把后者叫 items——输入是 items 的数组,输出也是 items 的数组,消息只是其中一种类型。

三处差别值得记准,因为它们决定了你写代码的方式:

  1. 输入形态:Chat Completions 收 messages 数组,系统提示是数组里的第一条;Responses 收 input(可以是一个字符串,也可以是 items 数组),系统级指令单独放在顶层的 instructions 字段。
  2. 输出形态:Chat Completions 返回 choices,你要取 choices[0].message.content;Responses 返回 output 数组,里面按顺序是模型这一轮做的每件事(工具调用、搜索、消息),SDK 额外给了一个 output_text 助手直接拿到最终文本。
  3. 状态管理:Chat Completions 是无状态的,历史每轮由你重发;Responses 默认 store 为 true,会把这次响应存起来,下一轮只要传 previous_response_id 就能接着聊,不用重发历史。你也可以关掉存储自己维护历史——两种做法的取舍最后一节讲。

最小的一次调用长这样:

hello.ts
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)

跟 30 天课 D1 那个 Chat Completions 请求对着看:messages 变成了 inputinstructionschoices[0].message.content 变成了 output_text。模型 id 这门课一律从环境变量 OPENAI_MODEL 读——OpenAI 的型号更新很快,写死在正文里几个月就过期,以官方文档当前推荐为准。

为什么要换格式?因为 Chat Completions 的「聊天记录」模型装不下工具。当模型不只是回话,还要调工具、搜网页、读文件时,把这些动作硬塞进「消息」里会很别扭。items 让每一种动作都有自己的类型,这也是下面几节要讲的内置工具能顺畅接入的前提。

函数调用:声明、请求、回填

搭档需要查你们内部的东西时,他不能自己查,得让你去查再把结果告诉他。函数调用(function calling)就是这个来回:你声明有哪些函数可以调、参数长什么样;模型判断需要时请求调用某个函数并给出参数;你的代码真的去执行,把结果回填给模型,模型再基于结果继续。

三步分别对应三种 item。声明是请求里 tools 数组的一项,类型 function,带 namedescription、JSON Schema 形式的 parameters,以及 strict: true 表示参数必须严格符合 schema。模型的请求是输出里一个 function_call 类型的 item,带 call_id(这次调用的编号)和 arguments(JSON 字符串)。回填是你在下一轮的 input 里放一个 function_call_output item,call_id 对上,output 是结果字符串。

tools.ts
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)

注意两个工程细节。第一,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,消息里的 annotationsfile_citation。在请求里加 include: ['file_search_call.results'] 可以把检索到的原文片段也一起返回,方便调试「它到底看到了什么」。这基本是一个托管版的 RAG,30 天课 D12 手写的那条流水线,这里被压成了一个字段。

search.ts
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)
    }
  }
}

内置工具和自己写的函数怎么选?口径是:数据在外面且通用的(公网、你上传的文档)用内置工具,数据在你系统里的(数据库、内部服务、业务逻辑)写函数。内置工具省掉了执行和回填,但你控制不了它怎么搜、搜到什么;函数工具样样要自己写,但每一步都在你手里。生产系统里两者几乎总是混用。

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 里已经是解析好的对象。

structured.ts
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)
}

refusal 这个分支容易被忽略。当模型因为安全原因拒绝回答时,它不会硬塞一个不合法的 JSON 给你,而是返回一个 refusal 类型的内容块。不处理它,你的代码会拿到一个 output_parsed 为空的响应然后在下游炸掉;处理了,你就能把「模型不愿意」和「模型没做对」区分开——这在做审查、分类这类自动化流水线时是必需的。

strict 模式解决了「格式对不对」,没解决「内容对不对」。schema 保证 verdict 一定是两个枚举值之一,但不保证判断正确。这一点面试里常被追问:结构化输出让你省掉了解析层的防御代码,业务层的校验一行都不能省。

多轮状态:previous_response_id 还是自己带历史

最后一个问题:多轮对话的历史放哪。Responses 给了两条路。

让服务端存:默认 store: true,每次响应都被存下来,下一轮传 previous_response_id 就接上了,请求体只放新内容。好处是请求小、代码简单,第二节的函数调用例子就是这么串的。代价是状态在别人那里:你想「回到第三轮重来」得记住每一轮的 id;你想审计完整对话得另外拉;合规要求数据不出境时这条路走不通。

自己维护store: false,每轮把完整的 items 数组(包括之前的 function_callfunction_call_output)作为 input 重发。这就是 30 天课 D1 那套「历史靠你自己搬」,成本随轮数增长,但每一个字节都在你手里,想压缩、想截断、想换模型重放都行。

选哪条?原型和内部工具用前者,省事;面向用户的生产系统多半用后者,或者混合——用 previous_response_id 串短链,同时自己落一份历史做审计与恢复。明天讲的 Agents SDK 把这个选择包成了 sessions,你会看到同一个取舍换了一层外壳。

源码导读

动手实验

🧪 D3 实验:带内置工具的 Responses API 最小脚本

代码位置:labs/codex-mastery/day-03-responses-tools

验收标准:

  1. MOCK=1 pnpm start "还有哪些没做完" 能看到一次完整的函数调用来回:模型请求 list_todos,脚本回填,最终回答里出现 TODO 标题。
  2. 带上 --search 时回答后面列出了至少一条 url_citation 来源(MOCK 下是固定的假来源)。
  3. --structured 模式下输出的是解析好的对象,故意输入一段会被拒绝的内容时打印的是「模型拒绝了」而不是报错栈。
  4. 去掉 MOCK 用真 key 跑一次,观察 previous_response_id 串起来的第二轮不需要重发历史。

代码在 labs/codex-mastery/day-03-responses-toolsstarter/ 挖了四个练习点,solution/ 是完整答案。这是一次性脚本,不是 REPL,所以直接带参数跑,不要用管道喂输入。

  1. MOCK=1 pnpm start "你好",看到一次最简单的 responses.create 往返,打印出 output_text
  2. 实现练习 1 与 2:声明 list_todos 函数工具,从输出里筛出 function_call,执行后用 function_call_output 回填,跑 MOCK=1 pnpm start "还有哪些没做完" 看到 TODO 标题出现在最终回答里。
  3. 实现练习 3:加 --search 时在 tools 里加 web_search,从 message 的 annotations 里把 url_citation 单独打印。
  4. 实现练习 4:加 --structured 时改用 responses.parsezodTextFormat,先检查 refusal 再读 output_parsed
  5. 配好 OPENAI_API_KEYOPENAI_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_callfunction_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

    分析过程 · 先想清楚再作答

    1. 这题考的是你有没有真迁移过,而不是能不能背出字段名。只答「新接口更强」会被判为看过文档没写过代码。
    2. 拆成三个维度:输入形态(messages 数组变成 input 加顶层 instructions)、输出形态(choices 变成按类型排列的 output items,SDK 给 output_text 助手)、状态管理(无状态变成默认 store 加 previous_response_id)。
    3. 再说为什么要改:聊天记录模型装不下工具动作;items 让搜索、函数调用、回填各有自己的类型,这是内置工具能接进来的前提。
    4. 迁移坑给三条:默认 store 为 true 意味着数据会被存下来,合规场景要显式关掉;output 是数组不是单个消息,取文本要用 output_text 或遍历 message item;函数调用的回填从 role 为 tool 的消息变成 function_call_output item,call_id 要对上。
    5. 可预期的追问:previous_response_id 和自己维护历史怎么选?原型用前者省事,生产多半自己落一份历史做审计与恢复,或两者混用。

    How to reason about it · think before answering

    1. This tests whether you have actually migrated code, not whether you can recite field names.
    2. 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).
    3. 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.
    4. 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.
    5. 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

    分析过程 · 先想清楚再作答

    1. 题眼有两个:一是「谁来执行」,二是「副作用有多大」。只答功能对比不谈执行方与风险,就是没做过工程。
    2. 先给执行方的判据:内置工具由平台在服务端执行,你只声明、不回填、也控制不了它怎么搜;函数工具由你执行,样样自己写,但每一步都在你手里。
    3. 落到场景:数据在外面且通用(公网、你上传的文档)用内置工具;数据在你系统里(数据库、内部服务、业务逻辑)写函数;生产系统几乎总是混用。
    4. 再按副作用排一条光谱:web search 只读公网,file search 只读你给的文件,函数调用的副作用由你的代码决定,computer use 由模型直接产生副作用——越往右能力越强,需要的隔离越重。
    5. computer use 单独对待的原因:它能点任何按钮、输任何文字,还可能被页面内容诱导,所以正确起点是隔离环境、受限账号和站点与动作白名单,不是代码。
    6. 可预期的追问:内置的 file search 和自己搭 RAG 怎么选?前者是托管版,省掉切分、向量化、检索三步,代价是可控性与可观测性弱,需要自定义切分或重排时才自己搭。

    How to reason about it · think before answering

    1. Two cruxes: who executes the tool, and how large its side effects are; comparing features alone signals no production experience.
    2. 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.
    3. 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.
    4. 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.
    5. 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.
    6. 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

    分析过程 · 先想清楚再作答

    1. 这题考的是对「格式正确」与「内容正确」的区分,答成「有了 strict 就不用校验了」是典型的错误。
    2. 先说解决了什么:strict 加 json_schema 保证输出一定能通过 schema 校验——枚举只会是给定值、必填字段一定在、类型不会错,解析层的 try/catch 与重试基本可以删掉。
    3. 再说没解决什么:schema 管不了语义。verdict 一定是两个枚举之一,但判断可能是错的;数字一定是数字,但可能是编的。业务层校验一行不能省。
    4. 然后是拒答分支:模型因安全原因拒绝时返回 refusal 类型的内容块,而不是硬塞一个不合法的 JSON;不处理它,下游会拿到空的解析结果直接崩,处理了才能区分「不愿意」与「没做对」。
    5. 给出代码里的顺序:先查 refusal,再读 output_parsed,再做业务校验(范围、引用是否存在、与上下文是否一致),最后才落库或执行。
    6. 可预期的追问:strict 对 schema 有什么限制?每个对象都要 additionalProperties 为 false、字段都要在 required 里,可选字段用可空类型表达;这些限制正是它能给出保证的原因。

    How to reason about it · think before answering

    1. This tests the distinction between well-formed and correct; claiming strict mode removes the need for validation is the classic mistake.
    2. 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.
    3. 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.
    4. 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.
    5. Give the order in code: check refusal, read output_parsed, run business checks (ranges, referenced entities exist, consistency with context), then persist or act.
    6. 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

评论

登录后即可参与讨论

还没有评论,来说第一句。