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

OpenAI API 流式响应怎样按事件类型更新界面

来源:17golang原创

时间:2026-10-29 01:42:25 228浏览 收藏

我第一次把 OpenAI API 的流式文本接到页面时,最先踩到的坑不是请求没发出去,而是界面把“新文本”“生成结束”和“发生错误”都当成了同一种消息。结果是按钮状态不可靠,断流时输入框还显示“生成中”,而完成事件偶尔又被当作空文本追加。

更稳的做法是把事件的 type 当作协议入口:response.output_text.delta 只负责追加文本,response.completed 只负责结束状态,error 只负责展示可重试提示。本文用一个小型 Go SSE 转发服务串起这条链路,重点放在事件分流和界面状态,而不是把所有原始事件原样暴露给浏览器。

要点速览
  • Responses API 的流式输出是带类型的语义事件,页面更新应先判断 type,再读取对应字段。
  • 增量文本、完成信号和错误信号分别映射到消息区、按钮状态和状态提示。
  • 服务端必须检查 stream.Err(),前端也要为网络断开保留可重试状态。

官方地址:https://platform.openai.com/docs/guides/streaming-responses

先把事件分成内容、生命周期和异常

Responses API 使用带类型的语义事件。对一个只展示文本的页面来说,不需要先处理完整事件表,可以先建立一个最小映射:创建事件表示请求进入生成状态,文本增量表示消息区追加内容,完成事件表示可以恢复提交按钮,错误事件表示当前回答不可继续。

事件类型页面职责不要做什么
response.created进入生成中,清空旧提示不要把它当作正文
response.output_text.delta把增量文本追加到消息区不要覆盖已经显示的文本
response.completed标记完成,恢复按钮不要再次追加空字符串
error显示错误并允许重试不要继续伪装成成功完成
OpenAI API 流式响应事件分类与前端状态模型、消息区、状态提示的静态关系图
图1:事件分类说明图,展示流式响应的生命周期、内容增量和界面状态关系;这是原创静态说明图,不是运行截图。

这里的关键是职责隔离。一个事件可以被记录,也可以被转发,但只有内容增量能改变正文,完成和错误只能改变状态。这样即使以后增加工具调用、拒答或注释事件,也可以把它们接入新的分支,不必改写“追加文本”的核心逻辑。

用 Go SDK 建立一个最小流式接口

官方 Go SDK 的 Responses 流式接口会逐个返回事件对象。下面的服务把上游事件缩成前端自己的 SSE 消息格式:浏览器只关心 type、text 和 message,不会依赖 SDK 内部对象的全部字段。

package main

import (
    "context"
    "encoding/json"
    "fmt"
    "net/http"

    "github.com/openai/openai-go/v3"
    "github.com/openai/openai-go/v3/responses"
)

// streamEvent 是发给浏览器的最小协议,避免把上游对象直接暴露给前端。
type streamEvent struct {
    Type    string `json:"type"`
    Text    string `json:"text,omitempty"`
    Message string `json:"message,omitempty"`
}

func writeSSE(w http.ResponseWriter, event streamEvent) error {
    payload, err := json.Marshal(event)
    if err != nil {
        return err
    }
    // 每条 SSE 消息以空行结束,浏览器才能把它识别为一个完整事件。
    _, err = fmt.Fprintf(w, "data: %s\n\n", payload)
    if flusher, ok := w.(http.Flusher); ok {
        flusher.Flush()
    }
    return err
}

func streamHandler(w http.ResponseWriter, r *http.Request) {
    // SSE 需要保持连接,并明确告诉浏览器响应不是一次性 JSON。
    w.Header().Set("Content-Type", "text/event-stream")
    w.Header().Set("Cache-Control", "no-cache")
    w.Header().Set("Connection", "keep-alive")

    client := openai.NewClient()
    stream := client.Responses.NewStreaming(r.Context(), responses.ResponseNewParams{
        Model: openai.ChatModelGPT5_2,
        Input: responses.ResponseNewParamsInputUnion{
            OfString: openai.String("用两句话说明 Go 的 context 如何取消请求"),
        },
    })
    defer func() { _ = stream.Close() }()

    // 只处理页面需要的事件类型,其余事件不误当成文本。
    for stream.Next() {
        event := stream.Current()
        switch event.Type {
        case "response.created":
            if err := writeSSE(w, streamEvent{Type: "started"}); err != nil {
                return
            }
        case "response.output_text.delta":
            if event.Delta == "" {
                continue
            }
            if err := writeSSE(w, streamEvent{Type: "delta", Text: event.Delta}); err != nil {
                return
            }
        case "response.completed":
            if err := writeSSE(w, streamEvent{Type: "completed"}); err != nil {
                return
            }
        case "error":
            // 错误只转发可读提示,不把令牌或原始请求信息写给浏览器。
            if err := writeSSE(w, streamEvent{Type: "error", Message: "生成请求失败,请稍后重试"}); err != nil {
                return
            }
        }
    }

    // Next 返回 false 可能是自然结束,也可能是网络或解码错误,必须区分。
    if err := stream.Err(); err != nil {
        _ = writeSSE(w, streamEvent{Type: "error", Message: "流连接中断,请重试"})
    }
}

func main() {
    // 路由只负责演示;生产环境还应加入鉴权、限流和请求参数校验。
    http.HandleFunc("/api/stream", streamHandler)
    if err := http.ListenAndServe(":8080", nil); err != nil {
        panic(err)
    }
}

示例里保留了 context.Context:浏览器关闭连接后,r.Context() 会取消上游请求,服务端不会继续无意义地读取流。stream.Err() 也不能省略,因为循环结束本身并不能说明响应是正常完成。

Go StreamingEvent 的 type、delta、message 字段与文本缓冲区、完成状态、错误状态和 HTTP 输出层的结构图
图2:Go 事件映射结构图,展示事件字段与服务端输出状态的对应关系;这是原创结构图,不是运行证据。

浏览器按自己的 type 更新三个区域

服务端已经把上游事件转换成 started、delta、completed 和 error。前端只需要维护一个消息字符串、一个状态文本和一个按钮状态,事件之间不会互相覆盖。

const output = document.querySelector("#output");
const status = document.querySelector("#status");
const submit = document.querySelector("#submit");

// 通过浏览器原生 EventSource 接收服务端的 SSE 数据。
const source = new EventSource("/api/stream");

source.onmessage = (message) => {
  const event = JSON.parse(message.data);

  // 增量事件只追加文本,不重置已有内容。
  if (event.type === "delta") {
    output.textContent += event.text || "";
    return;
  }

  // 开始、完成和错误只改变状态区域与按钮状态。
  if (event.type === "started") {
    status.textContent = "生成中";
    submit.disabled = true;
  } else if (event.type === "completed") {
    status.textContent = "已完成";
    submit.disabled = false;
    source.close();
  } else if (event.type === "error") {
    status.textContent = event.message || "生成失败,可重试";
    submit.disabled = false;
    source.close();
  }
};

source.onerror = () => {
  // 浏览器层面的断开没有业务事件,也要恢复可重试状态。
  status.textContent = "连接中断,可重试";
  submit.disabled = false;
  source.close();
};

如果页面不是一次性请求,而是每点击一次按钮就创建一条新流,就不要把 EventSource 写成全局单例。可以在每次请求前清空旧监听器和消息区,或者改成带请求参数的 SSE 地址;核心原则不变:正文只由 delta 追加,状态只由生命周期事件驱动。

断流、空增量和重复结束怎样处理

实际接入时最容易被忽略的是“没有正文”不等于“发生错误”。创建事件、完成事件都可能没有文本字段;某些事件还可能因为 SDK 版本或模型能力不同而出现在同一条连接中。因此可以按下面的边界处理:

现象判断界面动作
delta 为空不是正文增量忽略,不覆盖已有消息
循环自然结束且 Err() 为空SDK 已正常结束读取等待完成事件或采用服务端兜底状态
Err() 不为空传输或解码链路异常发送 error,并允许重试
完成事件重复到达状态幂等处理重复设置“已完成”,不重复追加文本

我更倾向于让前端状态更新具备幂等性:按钮已经是可用状态时再次收到完成事件,不会产生副作用;消息区只接受非空文本增量。这样服务端即使在关闭连接前做一次兜底通知,也不会把页面推入不可恢复的状态。

部署和验收时只看这几个结果

这个小项目接入反向代理时,要确认代理不会把 SSE 缓冲成整段响应;同时把 OPENAI_API_KEY 只放在服务端环境变量中,浏览器永远不接触它。生产环境还要加鉴权、限流、超时和日志脱敏,本文的示例只负责说明事件到界面的映射。

验收不需要把所有事件打印出来,观察四个可见结果就够了:

  1. 请求建立后,页面进入“生成中”,按钮被禁用。
  2. 收到文本增量时,消息区逐段增长,已有内容没有被覆盖。
  3. 收到完成事件后,状态变成“已完成”,按钮恢复可用。
  4. 模拟网络断开或上游错误时,页面显示可重试提示,而不是继续显示“生成中”。

常见问题

为什么不直接把每个上游事件原样传给浏览器?

原样转发会让前端绑定 SDK 的字段结构,并且容易把不需要的元数据带到客户端。服务端先按事件类型收敛成自己的小协议,更容易兼容 SDK 更新和后续换用其他上游。

为什么不能只监听 delta?

只监听增量文本可以显示内容,但无法可靠恢复按钮、结束加载状态或区分传输失败。完成和错误属于界面状态事件,不能用“下一段文本没来”来猜。

浏览器断开后还要调用 stream.Close 吗?

要。用请求上下文创建流,并在函数返回时关闭流,可以让连接释放路径更明确;同时检查 stream.Err(),把真正的异常转成前端可处理的错误状态。

这次我最后保留的判断很简单:把流式响应当成“带类型的事件协议”,而不是一串可以随意拼接的字符串。正文增量只负责消息内容,完成和错误只负责状态,Go 服务端再用一层自己的 SSE 协议隔开 SDK 与页面,后续扩展工具调用或拒答事件时会轻松很多。

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