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

Go 调用 OpenAI Responses API 后台模式:从超时请求迁移到可轮询任务

来源:17golang原创

时间:2026-07-24 16:03:15 388浏览 收藏

接入长推理模型或带工具调用的 Responses API 时,走同步请求最先碰到的往往不是模型能力上限,而是HTTP请求的生命周期限制:客户端等不到结果就主动断开,服务端却可能还在后台跑任务。Go 服务端更稳妥的迁移方案,就是把单次调用拆成「提交后台响应任务」和「按响应ID轮询结果」两步,把任务状态直接落到自己的业务数据表中。

要点速览
  • background: true 提交长任务,首个接口返回只负责回传响应ID。
  • 轮询接口只认 queuedin_progresscompleted 等状态值,不用靠猜判断结果是否可读。
  • 轮询间隔建议从2秒起步,同时配置总运行时限、最大查询次数和退避策略,避免把超时问题转化为请求风暴。
  • OpenAI后台响应数据最多保留10分钟左右,不适合作为长期任务仓库,敏感项目还要重新核对Zero Data Retention的合规约束。

先看清同步调用为什么会失效

原来的常规写法基本是一次 POST /v1/responses,Go 客户端等着把全量结果读完才给前端返回。短文本场景下完全没问题,但长推理、文件分析或者多工具联动的链路,会把等待时间拉得很长。网关的30秒超时、客户端的60秒超时,和模型真正跑完的耗时,上限根本对不齐。

这时候别直接把HTTP超时一口气调到十分钟。长连接持续占用会挤压连接池配额,用户侧反复重试又容易生成大量重复任务。更合理的边界是:提交请求阶段只等「任务已成功创建」的回执,后续结果拉取全交给后台worker或者定时任务处理。

Go 提交 OpenAI Responses API 后台响应后获得 response ID 的任务创建流程

迁移时真正要改的三个核心字段

从同步模式切换到后台模式,业务代码至少要重新定义三个字段:外部响应ID、内部任务状态、最后查询时间。外部ID用来向OpenAI侧查询结果,内部状态直接服务于用户界面展示、重试逻辑和审计流程,绝对不能直接把OpenAI返回的外部状态当成自己数据库里的任务状态。

同步写法后台写法迁移后的检查点
等待完整响应返回提交任务后立刻存好response ID提交成功但任务未完成也算可恢复状态
一次读取返回的output_text任务标记completed之后再读取output字段未完成状态不能直接解析最终文本内容
HTTP超时直接判定任务失败区分查询失败、任务本身失败和本地超时三种场景判定失败的场景支持重试且不会重复生成新任务

Go 最小实现:提交一次,再按 ID 查询

下面的示例直接用标准库发送两个请求,能清晰看到协议层的边界。API密钥从环境变量读取,生产环境下应该放到密钥服务或者运行环境的保密配置里,绝对不能硬写进代码配置和输出到日志中。

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "time"
)

type responseEnvelope struct {
    ID     string `json:"id"`
    Status string `json:"status"`
    Output []struct {
        Type    string `json:"type"`
        Content []struct {
            Text string `json:"text"`
        } `json:"content"`
    } `json:"output"`
}

func submit(ctx context.Context, prompt string) (string, error) {
    body := map[string]any{
        "model": "gpt-5.4-pro",
        "input": prompt,
        "background": true,
    }
    raw, err := json.Marshal(body)
    if err != nil { return "", err }
    req, err := http.NewRequestWithContext(ctx, http.MethodPost,
        "https://api.openai.com/v1/responses", bytesReader(raw))
    if err != nil { return "", err }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("OPENAI_API_KEY"))
    req.Header.Set("Content-Type", "application/json")
    res, err := http.DefaultClient.Do(req)
    if err != nil { return "", err }
    defer res.Body.Close()
    if res.StatusCode/100 != 2 { data, _ := io.ReadAll(res.Body); return "", fmt.Errorf("submit status %s: %s", res.Status, data) }
    var out responseEnvelope
    if err := json.NewDecoder(res.Body).Decode(&out); err != nil { return "", err }
    if out.ID == "" { return "", fmt.Errorf("missing response id") }
    return out.ID, nil
}

// bytesReader 省略了业务无关的 reader 封装,实际项目可直接使用 bytes.NewReader。
func bytesReader(raw []byte) io.Reader { return &sliceReader{data: raw} }
type sliceReader struct { data []byte; pos int }
func (r *sliceReader) Read(p []byte) (int, error) { if r.pos >= len(r.data) { return 0, io.EOF }; n := copy(p, r.data[r.pos:]); r.pos += n; return n, nil }

func query(ctx context.Context, id string) (responseEnvelope, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
        "https://api.openai.com/v1/responses/"+id, nil)
    if err != nil { return responseEnvelope{}, err }
    req.Header.Set("Authorization", "Bearer "+os.Getenv("OPENAI_API_KEY"))
    res, err := http.DefaultClient.Do(req)
    if err != nil { return responseEnvelope{}, err }
    defer res.Body.Close()
    if res.StatusCode/100 != 2 { return responseEnvelope{}, fmt.Errorf("query status %s", res.Status) }
    var out responseEnvelope
    err = json.NewDecoder(res.Body).Decode(&out)
    return out, err
}

func wait(ctx context.Context, id string) (responseEnvelope, error) {
    ticker := time.NewTicker(2 * time.Second)
    defer ticker.Stop()
    for {
        out, err := query(ctx, id)
        if err != nil { return responseEnvelope{}, err }
        switch out.Status {
        case "completed": return out, nil
        case "failed", "cancelled", "incomplete": return out, fmt.Errorf("background response ended with %s", out.Status)
        case "queued", "in_progress":
        default: return out, fmt.Errorf("unknown response status %q", out.Status)
        }
        select { case 

示例故意把提交和查询两个逻辑拆开。真实项目里可以换成官方维护的 openai-go SDK,但是核心逻辑要保留同样的状态机:New 只负责创建响应任务,查询阶段负责等待任务跑完并统一收口。需要把轮询结果映射到自己的 ai_tasks 表时,至少要存下 provider_idstatusattemptsnext_check_atlast_error

轮询不是死循环:给任务加上合理边界

最小示例里的固定2秒间隔只适合演示场景。线上跑的worker应该设置总时限,比如8分钟;每次查询失败就累加一次attempts计数,短暂网络错误可以做有限次数重试,连续多次失败后直接把内部任务标记为 provider_check_failed,等人工介入或者补偿任务后续处理。

拿到成功返回也不要只判断HTTP状态码200。只有响应里的任务状态为 completed 时,才可以读取返回文本;如果业务需要结构化结果,还要在本地额外做JSON解码和字段合法性校验。模型侧返回调用成功,不等于你的订单、工单或者摘要结果已经通过业务层校验。

Go 轮询 OpenAI Responses API 响应状态并在完成、失败和超时之间收口
const maxWait = 8 * time.Minute
ctx, cancel := context.WithTimeout(context.Background(), maxWait)
defer cancel()

result, err := wait(ctx, responseID)
if err != nil {
    // 保留 responseID 和错误,不要在这里无条件重新提交一份任务。
    return markTaskRetryable(responseID, err)
}
return saveOutput(responseID, result.Output)

保留窗口、数据策略和回归检查

后台模式适合把长耗时响应从前端长连接里转移出来,但是不能当成长期任务队列用。官方公开说明里后台响应数据大约只保留10分钟供查询,这个时间窗口远小于很多业务「隔天重试」的周期。需要长期留存任务记录的场景,要等响应完全跑完后,把经过脱敏和校验的业务结果写入自己的存储服务。

另一个容易被忽略的边界是数据策略:后台模式和Zero Data Retention规则不兼容。涉及用户个人资料、内部文档或者合规要求较高的场景,先和安全同事确认数据留存要求,再决定是否采用该模式,不要觉得请求变成异步的就默认数据风险完全消失。

  • 提交超时:先确认没有生成有效的response ID,再决定是否重试,避免生成大量重复任务。
  • 查询返回404:优先检查ID是否正确、项目权限是否正常、是否超出数据保留窗口,不要立刻新建任务覆盖原有记录。
  • 状态标记失败:保存完整错误信息和最后一次返回的响应内容,给业务侧留一个可解释的失败结果。
  • 结果校验:文本内容、JSON结构、业务自定义字段分别做校验,不能只靠服务商返回的成功状态判断结果可用。

常见问题

后台模式能不能替代消息队列?

不能。它解决的是模型侧长耗时响应的连接生命周期问题,不负责你的重试、幂等、优先级调度和长期存储。生产系统还是要用数据库任务表或者消息队列来管理业务侧的全流程任务。

轮询间隔固定为 2 秒可以吗?

小流量场景下可以作为起点。任务量上来之后要做退避和随机抖动,同时设置并发查询上限,不然查询请求本身会成为新的限流触发来源。

为什么不能拿到响应 ID 就读取 output?

提交阶段返回的只是响应对象基础信息和初始状态,不代表最终内容已经生成完成。只有状态变为 completed 之后,输出字段的内容才适合作为业务流程的输入。

使用 Go SDK 还是标准库?

官方SDK能减少请求结构和类型定义的重复工作;标准库更适合先把协议逻辑和状态机跑通验证。不管选哪一个实现方案,都要留存好response ID、内部状态和失败原因这三类数据。

迁移清单

把同步调用改成后台任务,核心不是把某个布尔字段设为true,而是把「等待结果」这个动作改造成可恢复的业务流程:先提交任务落库,再限时轮询结果,跑完之后校验输出,出问题时保留完整现场。这样即使前端网关连接中途断开,任务也不会跟着用户页面一起消失。

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