
OpenAI Decisions API 实用教程:用谓词、选择与评分做快速决策
OpenAI Decisions API 实用教程:用谓词、选择与评分做快速决策
Decisions API 把「有明确答案的问题」单独做成一个接口:谓词返回概率、选择返回候选项、评分返回有序等级,适合消息分流、文档信息检查、图片判断等场景。本文从环境准备讲起,给出三个可直接运行的 Python 示例,并说明它与生成式接口、与纯代码规则之间的取舍。
很多应用并不需要模型写出一段话,只需要模型回答一个答案范围明确的问题:这条消息该进哪个队列?这段文字里有没有写明退货期限?这张照片里的包装有没有明显破损?把这类问题交给通用的生成接口,往往要额外解析 JSON、处理格式漂移,还要为整段输出付费。Decisions API 把这类「有确定答案形态」的判断单独做成一个接口,请求里给出输入和一组问题,返回的是概率、候选项或等级,可以直接进入程序逻辑。它适合做消息路由、索引选择、在允许的动作列表里挑下一步,也支持把图片作为输入。这篇教程按实际操作顺序讲清楚怎么装、怎么调、三个典型场景怎么写,以及哪些问题不该交给它。
准备工作
账号与密钥
调用这个接口需要一个 OpenAI 账号和一枚 API key。如果还没有账号,需要先完成注册,并在账户里添加付款方式、充值一定额度,否则请求会因为余额不足而失败。密钥在控制台的 API keys 页面创建:登录后进入该页面,按提示新建一个 secret key,创建后立即复制保存,因为页面关闭后通常无法再次完整查看。
拿到密钥后,把它放进环境变量 OPENAI_API_KEY,不要硬编码进脚本。在 PowerShell 里可以这样设置当前会话的变量:
c:\> $env:OPENAI_API_KEY="your-api-key"在 bash 或 zsh 里则是:
export OPENAI_API_KEY="your-api-key"环境变量只在当前终端会话有效,换一个窗口就要重新设置。生产环境应通过密钥管理服务注入,不要写进代码仓库。
安装 SDK
官方 Python SDK 从 3.26.0 版本开始支持这个接口。用 pip 安装指定版本,避免因为版本过旧而找不到对应方法:
c:\> python -m pip install openai==3.26.0安装完成后,SDK 会暴露 client.decisions.create() 方法,请求发往 /v1/decisions 端点。注意这只是客户端实现,真正的推理在服务端完成,所以每次运行示例都会产生一次计费请求。
输入与问题类型
一次请求包含三部分:模型名、输入内容、问题列表。输入可以是纯文本、图片,或者两者混合。当前 beta 阶段支持的模型是 gpt-6-luna。问题有三种类型,返回结构各不相同:
| 类型 | 问题示例 | 返回结果 |
|---|---|---|
| predicate(谓词) | 文档里是否写明了退货期限? | 0 到 1 之间的概率 |
| choice(选择) | 这条消息应该进入哪个支持队列? | 被选中的值、各选项概率与置信度 |
| score(评分) | 这起事件对客户的影响有多严重? | 有序等级上的得分、概率与置信度 |
几个关键点需要先记住。谓词不会返回布尔值,它返回概率,由你的代码决定阈值,把概率翻译成动作。返回的答案按提问顺序排列,并且接口可以单独拒绝某个问题,所以在读取其他字段之前,必须先检查答案的类型。所有问题共享同一份输入,因此互不依赖的问题可以放进同一个请求,减少往返次数。
操作步骤
第一步:设计答案空间
在写代码之前,先把「可能的答案」定下来,这是应用设计的一部分,不是接口的事。假设你的队列只有 billing 和 delivery 两类,而客户问的是营业时间,两个选项都不合适,系统就会被迫做出错误归类。加一个 general 兜底类别,这类请求才有合理的去处。
类别定好之后,要拿真实的历史消息去测,尤其是那些明显不属于任何一类、语义模糊、或者同时涉及多个类别的消息。选项的 description 字段不是装饰,它承担区分职责:比如「破损商品」和「包裹未送达」都属于售后,但描述写清楚之后,模型才更容易把两者分开。
第二步:写第一个请求,做消息分流
下面这个程序把客服消息分到四个队列之一。新建文件 decisions_route.py:
from openai import OpenAI
MODEL = "gpt-6-luna"
def main() -> None:
with OpenAI(timeout=20.0) as client:
result = client.decisions.create(
model=MODEL,
input="My keyboard arrived with three broken keys. Can you replace it?",
questions=[{
"type": "choice",
"name": "queue",
"instructions": "Choose the queue for the customer's main request.",
"choices": [
{"value": "returns", "description": "Damaged goods or replacements."},
{"value": "billing", "description": "Charges or invoice errors."},
{"value": "delivery", "description": "Missing or delayed deliveries."},
{"value": "general", "description": "Everything else or unclear intent."},
],
}],
)
answer = result.answers[0]
if answer.type == "refusal":
print("Send to manual triage")
elif answer.type == "choice":
print(answer.choice, answer.confidence)
queue = answer.choice if answer.confidence >= 0.8 else "manual_triage"
print("Queue:", queue)
if __name__ == "__main__":
main()对「键盘到了,三个键是坏的,能换吗」这类输入,返回的队列是 returns,置信度为 1.0。换成「你们有网站可以看吗」这种与售后无关的问题,返回的是 general,置信度 0.8。这正是兜底类别存在的意义。
代码里的 0.8 只是一个示例阈值,它不是官方推荐值,也不代表 80% 的准确率。正确的做法是:拿已经人工分好类的历史消息做测试集,在多个阈值上分别统计错误率,再决定自己的阈值。置信度低于阈值的请求应该转人工,而不是硬塞进某个队列。
第三步:用谓词检查文档缺了什么
第二个场景是检查退货说明是否遗漏关键信息。员工写的说明格式各异,我们希望把「没写期限」或「没写邮寄地址」的挑出来。新建 decisions_document.py:
from openai import OpenAI, RateLimitError
MODEL = "gpt-6-luna"
def main() -> None:
with OpenAI(timeout=20.0) as client:
checks = {
"deadline": "Does the text explicitly give a deadline for returning an item?",
"address": "Does the text explicitly provide a postal return address?",
}
result = client.decisions.create(
model=MODEL,
input="Return the item within 30 days. Email support to request our address.",
questions=[
{"type": "predicate", "name": name, "instructions": question}
for name, question in checks.items()
],
)
for answer in result.answers:
if answer.type == "refusal":
print(answer.name, "review required")
elif answer.type == "predicate":
status = "present" if answer.probability >= 0.9 else "check manually"
print(answer.name, status, answer.probability)
if __name__ == "__main__":
try:
main()
except RateLimitError as exc:
if exc.code == "credit_balance_exhausted":
raise SystemExit(
"Your OpenAI API credit balance is exhausted. Add credits for "
"the organisation associated with your API key and then run "
"this example again."
) from None
raise对上面那段说明文字,deadline 判定为 present,概率 1.0;address 判定为 check manually,概率 0.0。这个结果值得注意:文本里说「发邮件索取地址」,并不等于「给出了地址」。如果只用关键词搜索 address,就会把这两种情况混为一谈,而谓词问的是「是否明确提供了」,语义上更贴近真实需求。
两个问题互不依赖,所以放在同一个请求里,一次调用拿到两个答案。如果后一个问题需要前一个问题的结果才能确定,就必须先看结果,再发第二次请求。
还要分清边界:这一步只检查信息是否出现在文本里。地址是否真实存在、期限是否符合你的业务规则,属于另一层校验,需要单独实现。
第四步:把图片作为输入
第三个场景处理包裹照片。准备三张 PNG 图片,分别命名为 undamaged.png、heavy_damage.png、slight_damage.png,放在与脚本相同的目录下。新建 decisions_image.py:
import base64
from pathlib import Path
from openai import OpenAI, RateLimitError
MODEL = "gpt-6-luna"
PARCEL_IMAGES = ("undamaged.png", "heavy_damage.png", "slight_damage.png")
def main() -> None:
with OpenAI(timeout=20.0) as client:
for filename in PARCEL_IMAGES:
image_path = Path(__file__).with_name(filename)
encoded = base64.b64encode(image_path.read_bytes()).decode("ascii")
result = client.decisions.create(
model=MODEL,
input=[{
"role": "user",
"content": [
{"type": "input_text", "text": "Image of a delivered parcel."},
{"type": "input_image",
"image_url": f"data:image/png;base64,{encoded}"},
],
}],
questions=[{
"type": "predicate",
"name": "visible_damage",
"instructions": (
"Does the packaging visibly have a tear, "
"hole or crushed corner?"
),
}],
)
answer = result.answers[0]
if answer.type == "refusal":
print(f"{filename}: Inspect the photograph manually")
elif answer.type == "predicate":
print(filename, answer.probability)
if __name__ == "__main__":
main()图片以 base64 编码后放进 data:image/png;base64,... 形式的 URL 里,与一段说明文字一起构成输入内容。三张图分别请求一次,每次都会产生计费调用。支持图片输入是这类接口相对纯文本分类方案的一个明显优势:包装破损、商品外观这类判断,文字描述很难替代图像本身。
一个完整示例
把上面的片段串成一个可运行的流程:读取一条客服消息,先判断它是否涉及退货,再决定进哪个队列,最后根据置信度决定是否转人工。
from openai import OpenAI
MODEL = "gpt-6-luna"
def triage(message: str) -> str:
with OpenAI(timeout=20.0) as client:
result = client.decisions.create(
model=MODEL,
input=message,
questions=[
{
"type": "predicate",
"name": "is_return_request",
"instructions": "Is the customer asking to return or replace an item?",
},
{
"type": "choice",
"name": "queue",
"instructions": "Choose the queue for the customer's main request.",
"choices": [
{"value": "returns", "description": "Damaged goods or replacements."},
{"value": "billing", "description": "Charges or invoice errors."},
{"value": "delivery", "description": "Missing or delayed deliveries."},
{"value": "general", "description": "Everything else or unclear intent."},
],
},
],
)
answers = {a.name: a for a in result.answers}
ret = answers["is_return_request"]
if ret.type == "refusal":
return "manual_triage"
if ret.probability < 0.7:
return "manual_triage"
q = answers["queue"]
if q.type == "refusal":
return "manual_triage"
if q.confidence < 0.8:
return "manual_triage"
return q.choice
if __name__ == "__main__":
print(triage("My keyboard arrived with three broken keys. Can you replace it?"))
print(triage("Do you have a website I could look at?"))这个例子里,两个问题共享同一份输入,一次请求返回两个答案,用 name 字段建立索引。谓词先做一层粗筛,选择再做具体归类,任何一步出现拒绝或置信度不足,都退回人工。阈值同样只是示例,需要用自己的历史数据校准。
注意事项
计费与配额
这个接口有独立的计价方式。上线时公布的价格是每百万输入 token 0.10 美元,输出 token、缓存读取和缓存写入不单独计费。长上下文倍率和特定区域的处理附加费仍然适用。按这个基础费率粗算,一百万次请求、平均每次 1000 个计费输入 token,成本约为 100 美元。估算输入规模时,别忘了把问题文本和它们的描述也算进去,它们同样占用 token。价格、配额和可用区域都可能调整,实际以官网当前信息为准。
余额不足的处理
如果账户余额耗尽,SDK 会抛出 RateLimitError,其 code 为 credit_balance_exhausted。示例里捕获了这个异常并给出明确提示,生产代码也应该区分「余额不足」和「触发限流」这两种情况,前者需要充值,后者需要退避重试。
什么时候不该用它
需要写一段解释、或者需要抽取任意字段组成自定义 JSON 结构时,应该用 Responses API 配合结构化输出,而不是把抽取任务硬塞成二十个分类问题。发票号、客户姓名、购买清单这类信息,属于抽取而不是决策。
能用简单规则判断的,就留在代码里。比如优先级只取决于订单金额是否超过某个数值,直接在 Python 里比较即可,没必要为此发一次网络请求。模型的价值在于处理规则难以识别的表达,比如同一个故障被客户用各种不同说法描述。即便如此,也要权衡:这次额外的网络调用节省的下游工作量,是否值得它带来的延迟。
阈值与验证
谓词返回的是概率,不是结论。阈值由业务决定,并且应该用真实数据在多个候选阈值上测量错误率之后再定。不要把它当成准确率承诺。同样,置信度也不是「正确概率」的同义词,它只是分流决策的一个参考信号。
答案类型必须先检查
接口可以拒绝单个问题。读取 choice、probability、confidence 这些字段之前,先判断 answer.type 是否为 refusal,否则可能读到不存在的字段。答案按提问顺序返回,用 name 建索引比依赖下标更稳妥。
问题之间的依赖
同一请求里的问题共享输入,但彼此看不到对方的答案。如果后一个问题需要前一个的结果,必须拆成两次请求,先检查第一次的返回,再构造第二次。