登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  科技周边 >  人工智能

OpenAI 后台响应被取消后客户端怎么保存最终状态

来源:17golang原创

时间:2026-11-11 05:49:31 165浏览 收藏

我在接入后台响应时最容易踩的坑,是把“用户发起了取消”当成“本地任务已经取消”。真正可靠的做法是保存 OpenAI 返回的 response_id,把 response.cancelled 作为独立终态事件写入任务表,并用条件更新保证重复 webhook 和迟到事件不会把终态改回运行中。

官方地址:https://developers.openai.com/api/docs/guides/background

结论:本地任务至少要区分 queued、running、completed、failed、cancelled 和 incomplete;收到取消事件后只允许幂等地落为 cancelled,后续事件不能覆盖已经确认的终态。

先把取消当成一种独立终态

后台模式会异步创建响应,客户端可以轮询响应对象,也可以接收 webhook。官方文档把 queued、in_progress 和离开这两个状态后的最终状态区分开;只有设置了 background=true 的响应才支持取消。webhook 事件中,response.cancelled 表示后台响应已经被取消,它不应该和普通失败共用一个模糊的 error 状态。

我会在本地任务表中保存外部响应和业务状态的双重信息:response_id 用于关联 OpenAI 对象,status 用于驱动客户端展示,version 或更新时间用于挡住旧事件。下面这张图只表达状态关系,是静态说明图,不是实际运行结果。

response_id 从后台响应状态进入取消 webhook 并落到本地 cancelled 终态的静态关系图
图1:响应状态关系说明图,展示取消事件如何落到本地任务的 cancelled 终态。

任务表不要只存一个布尔值

如果只有 is_done,取消、失败和正常完成都会变成同一种结果,客户端无法决定是否重试,也无法向用户解释任务为什么停止。最小表结构可以这样设计:

CREATE TABLE ai_tasks (
  id BIGINT PRIMARY KEY,
  response_id VARCHAR(128) NOT NULL UNIQUE,
  status VARCHAR(24) NOT NULL,
  version INT NOT NULL DEFAULT 0,
  last_event_id VARCHAR(128),
  updated_at TIMESTAMP NOT NULL
);

-- response_id 建唯一约束,防止同一个后台响应被重复创建成本地任务。
-- status 保存业务终态,last_event_id 用于记录最近一次已处理的事件。
UPDATE ai_tasks
SET status = 'cancelled', version = version + 1,
    last_event_id = :event_id, updated_at = CURRENT_TIMESTAMP
WHERE response_id = :response_id
  AND status IN ('queued', 'running');

关键不是字段多,而是更新条件。只有 queued 或 running 允许转成 cancelled;如果同一个事件被投递两次,第二次更新不到行即可视为幂等成功。如果 completed 已经先落库,迟到的 cancelled 不应覆盖它;如果业务明确规定取消优先级,也应该把这条优先级写进状态机,而不是靠最后到达的网络包决定。

Webhook 接收层要验签并快速返回

OpenAI 的 webhook 指南建议在服务端验证签名,且验签应使用尚未解析的原始请求体。接收函数只做验签、记录事件和投递内部任务,然后尽快返回 2xx;耗时的数据库联动或通知放到后台 worker。官方文档还说明,失败响应会触发重试,webhook-id 可以作为去重键。

下面的 Python 示例展示处理思路,具体 SDK 版本以项目实际依赖为准。代码没有把密钥写入文件,unwrap 处理的是原始字节,事件业务处理则交给一个幂等函数。

import os
from openai import OpenAI, InvalidWebhookSignatureError

client = OpenAI(webhook_secret=os.environ["OPENAI_WEBHOOK_SECRET"])

def receive_webhook(raw_body: bytes, headers: dict) -> int:
    # 先用原始请求体验签,不能先 json.loads 再把重排后的内容拿去验证。
    try:
        event = client.webhooks.unwrap(raw_body, headers)
    except InvalidWebhookSignatureError:
        # 签名不可信时直接拒绝,不把事件写入任务队列。
        return 400

    event_key = headers.get("webhook-id")
    # 先按 event_key 去重,再把业务更新交给可重试的 worker。
    if not event_key or already_seen(event_key):
        return 200

    save_event_once(event_key, event.type, event.data.id)
    enqueue_state_update(event_key, event.type, event.data.id)
    # 快速确认收到,避免非必要的 webhook 重试。
    return 200

def apply_state_update(event_key: str, event_type: str, response_id: str) -> None:
    # 只有未结束任务允许被取消、完成或失败事件推进。
    target = {
        "response.cancelled": "cancelled",
        "response.completed": "completed",
        "response.failed": "failed",
        "response.incomplete": "incomplete",
    }.get(event_type)
    if target is not None:
        conditional_update(response_id, target, event_key)

这里的 already_seen 和 save_event_once 应该共享唯一约束,不能只放在进程内存里;服务重启或多副本部署后,内存集合会失效。事件类型不在映射表时可以记录并返回成功,避免未知事件阻塞整个投递链,同时保留告警供后续补充。

原始 webhook 请求经过验签和 webhook-id 去重后执行条件 UPDATE 的静态结构图
图2:幂等落库结构图,展示重复或迟到事件不会覆盖已保存的取消终态。

把恢复和清理策略也写进状态模型

取消不是异常重试的同义词。客户端收到 cancelled 后,可以把任务展示为“已取消”,允许用户新建一次任务;failed 才适合根据错误类型决定自动重试;incomplete 则应单独保留原因,避免把不完整结果误当成成功。查询详情时仍可带上 response_id,方便把本地记录和远端对象对应起来。

清理时也不要马上删除所有行。至少保留事件去重键、最终状态和更新时间一段时间,让重试到达时仍能得到幂等结果;超过保留期再归档或删除事件明细。对外返回状态时只暴露业务需要的字段,不要把 webhook 签名、内部队列名或密钥相关信息放进客户端响应。

常见问题

收到 response.cancelled 后还要继续轮询吗?

通常不必把取消当成继续等待的中间态。保存为 cancelled 后结束本地任务;如果需要补充审计信息,再异步读取或记录,不要让用户界面一直显示处理中。

重复 webhook 会不会把 version 加很多次?

用 webhook-id 唯一约束做事件级去重,再用状态条件做任务级幂等。重复事件第一次更新成功,后续请求应直接返回成功但不重复推进业务状态。

为什么不能只在前端点击取消时改状态?

前端可能断网、刷新或重复点击,真正的远端状态仍由 API 和 webhook 传递。服务端收到 response.cancelled 后再落库,才能让多个客户端看到同一个最终状态。

我现在会把这类任务拆成两层:外部用 response_id 追踪 OpenAI 响应,内部用带条件的状态机追踪业务任务。这样取消、失败、完成和不完整结果各自有明确含义,网络重试也不会把已经确定的 cancelled 改回处理中。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>