AB
AiBoss
チュートリアル

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 建索引比依赖下标更稳妥。

问题之间的依赖

同一请求里的问题共享输入,但彼此看不到对方的答案。如果后一个问题需要前一个的结果,必须拆成两次请求,先检查第一次的返回,再构造第二次。