如果你还在用老式Chat Completions接口逐条去调对话模型,而账单每个月还在肉眼可见地涨,那这篇内容值得看完。我指的是 OpenAI 官方的Responses API——这两年官方力推的统一接口。说实话,很多人听说过这个名字,但并不知道它真正的价值不在于“换一种调用方式”,而在于把对话、工具调用、离线批量处理这些能力收拢到一个入口之后,成本结构会发生非常明显的变化。
我自己把这套接口用起来之后,最直接的体感是:对非实时任务,配合官方Batch API,同样的模型、同样的任务量,成本能做到原来实时接口的一半以下;如果再叠加上Prompt Caching(提示词缓存),输入侧的费用还能再往下压一截。这不是什么特殊渠道优惠,也不是什么充一送一的套路,而是官方计费规则里本来就有的玩法,只是很多人没有把接口姿势用对。
这篇内容我会先讲清楚“便宜一半”到底便宜在哪,然后从基础调用开始,把Responses API的常规写法、批量调用的完整流程、缓存命中的关键条件一次讲透,最后附上我做成本核算时的表格和踩坑记录。适合正在从老接口迁移、或者想优化 API 调用成本的开发者参考。
1. 便宜一半的前提:你很可能没用对官方接口
1.1 “比官方便宜”这句话到底是什么意思
先别急着看白菜价就划走。这里说的便宜,不是让你去找什么第三方渠道,也不是让你去折腾非官方接口,而是用 OpenAI 官方自己给的优惠计价方式。
最典型的就是Batch API。官方对批量处理任务给出的 token 单价,基本是实时接口的一半左右。什么意思呢?同一个模型,同一段文本,实时调用的价格如果是 100 元,那走批量通道提交,官方只按 50 元来结算。这个折扣是写在官方定价规则里的,不要求你额外申请,也不要求你有什么企业合同,只要任务能接受等待,就可以用。
而Responses API恰好支持这种批量模式。你可以把一堆任务先写进一个 JSONL 文件,上传到官方服务器,然后让官方统一排队执行。等任务跑完之后,再一次性把结果下载回来。整个过程,你依然用的是原版模型、原版接口能力,但单价变成了五折。
所以说,真正让你“比官方便宜一半以上”的,不是绕过官方,而是用对官方接口。实时接口适合聊天、客服、实时审核这种必须在秒级返回的场景;但如果你的任务是批量翻译、批量分类、批量摘要、数据清洗、离线打分,那再用实时接口逐条调用,就是白白把一半的钱扔在账单上。
1.2 接口碎片化才是隐形成本的大头
老项目里最常见的 API 调用方式,是Chat Completions时代留下的习惯:处理对话要调一个接口,让模型调用工具要自己写一套循环,做结构化输出要自己解析 JSON,做文件检索还要再挂一个向量数据库。这些碎片化代码看起来只是“开发麻烦”,但只要稍微跑量,就会发现它同时也在烧钱。
Responses API做的事情,是把对话、工具调用、结构化输出、文件搜索、网络搜索等能力收进同一个接口。上层逻辑变简单了,底层请求也就更容易被官方基础设施优化。比如同样的模型,你用一个统一入口去调,官方可以更自然地复用缓存、更精准地控制输出格式,这些都会反映在最后的usage账单里。
所以我的建议是:不要把它理解成“又一个新接口”,而是把它理解成一整套成本优化方案的底座。后面的所有省钱技巧,都是建立在这个底座上的。
2. 先把 Responses API 的基础调用跑通
2.1 安装与客户端初始化
使用Responses API之前,先把官方 Python SDK 升到较新的版本。很多旧版 SDK 里只有client.beta.responses,新版已经直接提供了client.responses,用起来顺手很多。
pip install -U openai然后写好客户端。不要直接把密钥硬编码在代码里,建议放到环境变量中,方便后续切换测试账号和正式账号:
import os from openai import OpenAI client = OpenAI( api_key=os.environ.get("OPENAI_API_KEY"), )这一步本身没什么技术含量,但容易踩的坑是:如果你本地同时装了多个环境,openai版本可能不是最新的。建议装完后先确认版本,再继续写调用代码。
2.2 第一次调用:记住“input”而不是“messages”
Responses API和老接口最大的体感差异,就是请求体里不再叫messages,而是叫input。很多从老接口迁移过来的同事,第一反应是把原来的messages原样搬过去,结果直接报参数错误。
一个最基础的调用是这样的:
response = client.responses.create( model="gpt-4o-mini", input="用三句话说明 Responses API 和 Chat Completions 的区别", ) print(response.output_text)这里input可以直接传一个字符串,也可以传一段消息数组。如果你想传系统提示词,可以利用instructions参数,也可以把developer或system角色消息放进input列表里。以我常用的写法为例:
response = client.responses.create( model="gpt-4o-mini", instructions="你是一个严谨的技术文档编辑。回答时先说结论,再解释原因。", input=[ { "role": "user", "content": "帮我判断一下:响应式缓存是不是只要开启就一定能命中?", } ], ) print(response.output_text)这样写的好处是,把固定不变的系统指令放在instructions里,把每次变化的内容放在input里,后面讲缓存优化时,这个习惯会直接帮你省钱。
2.3 响应对象别再用老命中了
拿到response之后,很多人习惯去找choices[0].message.content。这个字段在Responses API里不存在,新接口的响应结构更扁平,但也更贴近“消息列表”这个直觉。
你可以先用response.output_text直接拿纯文本结果,这是官方 SDK 提供的快捷属性。如果要做更细的解析,可以遍历response.output数组:
for item in response.output: if item.type == "message": for content in item.content: print(content.type, content.text)我建议一开始就把对响应结构的依赖封装成一个函数。因为你后面接入批量任务后,拿到的结果是同样的结构,统一解析逻辑会让代码干净很多。
3. 用 Batch API 把非实时请求砍到半价
3.1 官方批量通道的计费逻辑
OpenAI Batch API的设计思路很直接:你把任务打包成文件提交上来,官方在有空闲算力的时候再跑这些任务,作为交换,单价给你打折。官方给出的折扣通常是 50%,也就是说,同一个模型、同样的 token 用量,批量通道的输入和输出单价都是实时接口的一半左右。
这个折扣意义非常大。假设你每天要处理 1000 份文档摘要,如果逐条实时调用,费用完全按实时价计算;如果把这 1000 份文档的请求写成一个 JSONL 文件,提交到批量任务里,那同样的 1000 次调用只需要支付一半的钱。
当然,代价是延迟。官方批量任务的完成窗口一般是 24 小时,任务提交后不会立刻返回,适合日志分析、周报生成、夜间数据清洗、离线评论分类这类不需要秒级反馈的场景。
3.2 JSONL 请求文件怎么写
批量任务的入口是一个 JSONL 文件,每一行是一个完整的请求参数。如果你用的是/v1/responses这个批量端点,每一行的基本结构是:
{"type": "responses", "parameters": {"model": "gpt-4o-mini", "input": "给下面的文章生成一段100字摘要:……"}} {"type": "responses", "parameters": {"model": "gpt-4o-mini", "input": "把下面的用户评论分成正面和负面:……", "max_output_tokens": 256}}注意,每一行必须是一个独立的 JSON 对象,不能有缩进换行,也不能在一个文件里混入非responses类型的请求。我第一次写这个文件时,因为在一行末尾加了多余逗号,上传后任务直接秒挂。后来养成了习惯:先写一个小文件测试两条,确认能跑通后再把完整任务放进去。
另外,如果你用的是老版本的 SDK 或者官方文档里的旧格式,可能会看到method、url、body这种写法。那个格式我试过也能用,但如果你已经上了Responses API,建议优先用type+parameters的新格式,避免把两套参数体系搞混。
3.3 上传、跑批、下载结果的完整流程
先写一个生成batch_input.jsonl的脚本,把所有需要批量处理的任务写进去。这里举一个简单的例子,假设你有三个任务:
import json tasks = [ "将这句话翻译成英文:OpenAI批量接口真的能省一半钱吗?", "提取下面地址中的城市和街道:北京市朝阳区某某路88号", "把这段话压缩到30字以内:今天天气不错,我们决定去郊外散步,顺便拍一些照片。", ] with open("batch_input.jsonl", "w", encoding="utf-8") as f: for t in tasks: line = { "type": "responses", "parameters": { "model": "gpt-4o-mini", "input": t, "max_output_tokens": 300, }, } f.write(json.dumps(line, ensure_ascii=False) + "\n")然后上传文件并创建批量任务:
batch_file = client.files.create( file=open("batch_input.jsonl", "rb"), purpose="batch", ) batch = client.batches.create( input_file_id=batch_file.id, endpoint="/v1/responses", completion_window="24h", ) print(batch.id)创建之后,用轮询的方式等任务完成。建议不要每秒刷一次,官方接口有速率限制,最好 30 秒到 60 秒轮询一次:
import time while batch.status not in ("completed", "failed", "cancelled"): time.sleep(30) batch = client.batches.retrieve(batch.id) if batch.status == "completed": result = client.files.content(batch.output_file_id) with open("batch_output.jsonl", "wb") as f: f.write(result.read()) print("完成,结果已写到 batch_output.jsonl") else: print("任务失败,请查看错误文件:", batch.error_file_id)拿到batch_output.jsonl之后,解析方式也不难。文件里每一行对应一个结果,结构上和你在实时接口拿到的response对象基本一致。我只抽取output_text的话,可以这样处理:
with open("batch_output.jsonl", "r", encoding="utf-8") as f: for idx, line in enumerate(f): data = json.loads(line) print(idx, data.get("response", {}).get("output_text", ""))我第一次跑完批量任务,看到账单里 token 费用直接打了五折的时候,才意识到一件事:很多时候不是模型贵,而是调用姿势贵。任务明明可以等,却非要用实时接口逐条去跑,等于主动放弃了官方给的五折券。
4. 再省一笔:Prompt Caching 和结构化输出组合拳
4.1 缓存命中的关键不是靠运气
很多人知道 OpenAI 有消息缓存,但不知道自己为什么一次都没命中。缓存命中的核心条件很简单:输入前缀要完全一致,而且缓存是按前缀匹配的。
举个例子,你的系统提示词如果这样写:
instructions=f"你是一个客服助手。今天日期是{today},请回答用户关于订单的问题。"只要日期一变,整个前缀就变了,缓存直接失效。正确写法是,把固定不变的部分全部放在前面,把变化的日期、用户上下文放在input的最后:
response = client.responses.create( model="gpt-4o-mini", instructions="你是一个客服助手。请根据用户问题给出简洁回答。回答必须包含两部分:结论、原因。", input=f"今日日期:{today}\n用户问题:{question}", )这样系统提示词那一段长文本就是稳定的输入前缀,在下一次请求到来时更容易命中缓存。官方对缓存读入 token 的计费,比普通输入 token 便宜不少;如果再加上批量折扣,输入侧成本可以从“全价输入”变成“半价再打折”的力度。
我在实际项目里的做法是:把所有公共指令和业务规则统一放在instructions里,动态参数一律追加到input尾部,而且保持前缀部分一个字都不改。这么改完,usage里缓存命中的比例肉眼可见地提升了。
4.2 结构化输出如何减少返工
很多调用场景不需要自由文本,而是需要模型返回一段 JSON 给你解析。如果不用结构化输出,模型可能输出带有说明文字的 Markdown 代码块,也可能把字段名写错,你就要在代码里反复清洗、重试。每重试一次,就多花一次输出 token 的钱。
Responses API支持直接声明输出格式,让模型严格按你的JSON Schema输出。比如提取订单信息:
response = client.responses.create( model="gpt-4o-mini", input="提取下面的订单信息:订单号是 AB12345,金额是 99.8 美元,币种是 USD。", text={ "format": { "type": "json_schema", "name": "order_info", "strict": True, "schema": { "type": "object", "properties": { "order_no": {"type": "string"}, "amount": {"type": "number"}, "currency": {"type": "string"}, }, "required": ["order_no", "amount", "currency"], "additionalProperties": False, }, } }, )这样返回的内容就是干净的 JSON,不需要你再写正则去剥掉 Markdown 代码块,也不需要因为字段名不一致而反复重试。省下来的都是输出 token,而且下游解析代码会一下子变得很清爽。
4.3 一个肉眼可见的省钱示例
假设你每天要处理 2000 条订单信息抽取。每条请求的输入大约 200 token,输出大约 150 token。如果不做任何优化,实时调用还要重试一部分脏数据,那月成本很可能比想象中高很多。
我常用的优化组合是:把公共指令稳定占位,批量提交任务,结构化输出限制格式。这样每条请求的输入前缀可以缓存命中一部分,输出 token 因为格式规范而减少重试,整体费用通常能压到原来的 50% 甚至更低。具体比例要看你的任务场景,但方向是确定的:不要等账单出来再去追悔,而是在写请求时就按省钱的方式设计。
5. 一次真实的成本核算:从账本看省在哪
5.1 计算口径和价格口径
做成本核算之前,先明确几个口径。官方计费主要看三个数:输入 token、输出 token、缓存是否命中。实时接口按全价算,批量接口按半价算,缓存命中输入按更低的缓存单价算。
为方便展示,我拿一个简化模型来做测算:假设某模型的输入单价是每 100 万 token 收 1 美元,输出单价是每 100 万 token 收 4 美元。如果你的任务每月需要消耗 1000 万输入 token、100 万输出 token,那实时调用的月成本就是:
- 输入成本:1000 万 / 100 万 × 1 = 10 美元
- 输出成本:100 万 / 100 万 × 4 = 4 美元
- 实时总成本 = 14 美元
如果走批量通道,输出和输入单价都打五折,成本就是 7 美元。再假设你的输入前缀稳定,其中 60% 命中了缓存,缓存价格再低一档,输入侧费用还能继续降:
- 非缓存输入:1000 万 × 40% × 0.5 = 2 美元
- 缓存输入:1000 万 × 60% × 0.25 = 1.5 美元(这里的 0.25 是为了演示缓存折上折,实际以官方页面价格为准)
- 输出:100 万 × 4 × 0.5 = 2 美元
- 优化总成本 = 5.5 美元
对比 14 美元和 5.5 美元,成本降幅已经超过一半。当然,真实价格会有波动,不同模型、不同账号、不同时期的活动都会影响最终数字,但“批量 + 缓存 + 结构化输出”这套组合的省钱逻辑是通用的。
5.2 场景对比表
为了方便你直接套用,我把几种典型玩法放在一起比较:
| 调用方式 | 输入计费 | 输出计费 | 适合场景 | 感受 |
|---|---|---|---|---|
| 实时普通调用 | 全价 | 全价 | 聊天、实时问答、低延迟任务 | 最贵,但响应最快 |
| 实时 + 缓存命中 | 缓存价 | 全价 | 固定系统提示词的在线服务 | 输入侧明显下降 |
| 批量普通调用 | 半价 | 半价 | 离线分类、批量翻译、夜间任务 | 总成本直接减半 |
| 批量 + 缓存 + 结构化 | 半价 + 缓存价 | 半价 + 少重试 | 数据标注、批量抽取、文档处理 | 成本最低,但需要等待 |
这张表不需要背,核心就一句话:任务能等,就上批量;前缀能稳,就吃缓存;输出能定 Schema,就不让它乱写。三个开关叠在一起,才是真正的“比官方便宜一半以上”。
5.3 什么情况下不推荐用批量
批量虽然便宜,但并适合所有场景。如果你做的是用户聊天机器人,用户发来消息,你必须秒回,那就不能把消息丢进 24 小时的批量队列里。同样,如果任务量本身只有几条,你还要写 JSONL、上传文件、建任务、轮询、下载结果,光这些操作时间就够你手动调好几遍了,这种情况下批量反而麻烦。
我的建议是,给项目定义一个简单的判定规则:延迟要求在 5 分钟以内,走实时;延迟要求是小时级,走批量;任务量小到一次 10 条以内,不值得建批量文件,就直接实时调用。按这个规则分流,不会让成本失控,也不会引入无谓的复杂度。
6. 踩过的坑和我的建议
6.1 批量任务秒失败:一次完整的排查过程
我记得第一次跑批量任务,上传文件后没几分钟,状态就变成了failed。当时第一反应是看错误文件,但下载下来的错误文件里只有一条“Invalid line format”,信息量约等于零。
我后来一步一步排查,把问题锁在了几处。第一处是最容易犯的:JSONL 文件虽然每行都是 JSON,但我的第一行文件里带了 BOM,也就是文件头多了一个不可见字符。这个问题用记事本保存时容易出现,上传后服务端解析第一行就报错。第二处是行尾多了一个逗号,我把 Python 字典写成 JSON 时手滑加了 Tail 逗号,本地合法,但严格 JSON 解析会拒绝。第三处是用purpose="batch"上传文件时,如果文件大小或编码不对,也会导致后续任务无法读取。
那次之后,我给自己定了一个规矩:批量文件生成后,先不急着上传,用脚本本地读一遍,逐行json.loads验证所有行都能被正确解析。只有本地校验全部通过,才交给官方批量接口。这一步虽然多花十秒钟,但能省下后面好几个小时的排查时间。
6.2 Responses API 使用中容易被忽略的细节
第一,不要在请求体里继续使用messages。很多人迁移时下意识沿用Chat Completions的参数,而Responses API接受的是input。如果两个参数混在一起,服务端会要么忽略、要么直接报错,调试起来很绕。
第二,文本获取方式变了。老接口里choices[0].message.content是核心字段,新接口里可能是response.output_text,也可能是output数组里的一项。如果你在一个大项目里做迁移,最好先输出完整响应结构,确认你拿到的字段有没有值,再批量替换。
第三,结构化输出不是所有模型都支持得一样好。有些模型版本对strict: true的 Schema 支持比较新,如果你的账号或模型版本偏老,可能接口直接拒绝。建议先在单条测试里确认能力,再推广到批量场景。
6.3 关于成本优化的个人建议
整个优化流程走下来,我最大的感受是:省钱这件事,不是等账单出来以后去砍需求,而是在写代码的时候就把接口姿势选对。
我记得自己最初是在一个模拟项目里做批量文本分类,原本用实时接口逐条跑,一天下来费用高到被提醒要控制预算。后来我把任务改成Responses API+ 批量提交,很快语料少了,而且效果并没有打折。从那以后,我每次设计 API 调用方案,都会先问自己三个问题:这个任务能不能等?输入前缀能不能固定?输出格式能不能用 Schema 限制死?
三个问题的答案,直接决定了你最终愿意把钱花在实时便利上,还是花在更聪明的调用方式上。如果你也想优化 OpenAI API 的成本,我建议先从最小的一批离线任务开始,迁到批量模式跑一次,对比一下账单,你会很直观地看到差距。