OpenAI API 流式响应怎样按事件类型更新界面
我第一次把 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 | 显示错误并允许重试 | 不要继续伪装成成功完成 |

这里的关键是职责隔离。一个事件可以被记录,也可以被转发,但只有内容增量能改变正文,完成和错误只能改变状态。这样即使以后增加工具调用、拒答或注释事件,也可以把它们接入新的分支,不必改写“追加文本”的核心逻辑。
用 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() 也不能省略,因为循环结束本身并不能说明响应是正常完成。

浏览器按自己的 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 只放在服务端环境变量中,浏览器永远不接触它。生产环境还要加鉴权、限流、超时和日志脱敏,本文的示例只负责说明事件到界面的映射。
验收不需要把所有事件打印出来,观察四个可见结果就够了:
- 请求建立后,页面进入“生成中”,按钮被禁用。
- 收到文本增量时,消息区逐段增长,已有内容没有被覆盖。
- 收到完成事件后,状态变成“已完成”,按钮恢复可用。
- 模拟网络断开或上游错误时,页面显示可重试提示,而不是继续显示“生成中”。
常见问题
为什么不直接把每个上游事件原样传给浏览器?
原样转发会让前端绑定 SDK 的字段结构,并且容易把不需要的元数据带到客户端。服务端先按事件类型收敛成自己的小协议,更容易兼容 SDK 更新和后续换用其他上游。
为什么不能只监听 delta?
只监听增量文本可以显示内容,但无法可靠恢复按钮、结束加载状态或区分传输失败。完成和错误属于界面状态事件,不能用“下一段文本没来”来猜。
浏览器断开后还要调用 stream.Close 吗?
要。用请求上下文创建流,并在函数返回时关闭流,可以让连接释放路径更明确;同时检查 stream.Err(),把真正的异常转成前端可处理的错误状态。
这次我最后保留的判断很简单:把流式响应当成“带类型的事件协议”,而不是一串可以随意拼接的字符串。正文增量只负责消息内容,完成和错误只负责状态,Go 服务端再用一层自己的 SSE 协议隔开 SDK 与页面,后续扩展工具调用或拒答事件时会轻松很多。
scikit-learn ColumnTransformer 怎样让数值与类别特征分别预处理
- 上一篇
- scikit-learn ColumnTransformer 怎样让数值与类别特征分别预处理
- 下一篇
- Hugging Face Hub 怎样为私有模型配置访问令牌
-
- 科技周边 · 人工智能 | 59分钟前 | API · 人工智能 · vLLM max_tokens 推理服务
- vLLM 怎样为在线服务设置请求最大 Token 数
- 326浏览 收藏
-
- 科技周边 · 人工智能 | 1小时前 | 人工智能 · 评估指标 MLFlow 模型版本 Model Registry 机器学习实验
- MLflow 怎样记录模型版本和评估指标
- 313浏览 收藏
-
- 科技周边 · 人工智能 | 1小时前 | 人工智能 · Profiling ONNX Runtime 算子耗时 推理性能 JSON trace
- ONNX Runtime 怎样生成算子级延迟 profiling JSON
- 178浏览 收藏
-
- 科技周边 · 人工智能 | 1小时前 |
- LlamaIndex 怎样把元数据加入向量检索过滤
- 334浏览 收藏
-
- 科技周边 · 人工智能 | 1小时前 |
- LangChain 怎样为工具调用保留中间消息
- 450浏览 收藏
-
- 科技周边 · 人工智能 | 1小时前 | 自定义词表 tokenizer Hugging Face Tokenizers tokenizer.json
- Hugging Face Tokenizers 怎样保存自定义词表
- 311浏览 收藏
-
- 科技周边 · 人工智能 | 1小时前 | Transformers bitsandbytes BitsAndBytesConfig 4bit量化
- BitsAndBytesConfig 怎样查看 4bit 量化参数
- 205浏览 收藏
-
- 科技周边 · 人工智能 | 1小时前 | 缓存 · snapshot_download Hugging Face Hub hf_hub_download revision
- Hugging Face Hub 怎样下载指定提交版本的模型文件
- 415浏览 收藏
-
- 科技周边 · 人工智能 | 1小时前 |
- Hugging Face Hub 怎样为私有模型配置访问令牌
- 127浏览 收藏
-
- 科技周边 · 人工智能 | 1小时前 |
- scikit-learn ColumnTransformer 怎样让数值与类别特征分别预处理
- 459浏览 收藏
-
- 科技周边 · 人工智能 | 1小时前 |
- scikit-learn DummyClassifier 怎样建立分类基线
- 189浏览 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 485次学习
-
- PubMedQA
- 深入了解PubMedQA生物医学问答数据集,涵盖其核心功能、使用方法及在临床决策、药物研发等场景的应用,助力提升NLP模型性能。
- 764次使用
-
- H2O EvalGPT
- H2O EvalGPT是H2O.ai推出的开源LLM评估平台,提供详细的大模型性能排行榜、行业特定基准测试及A/B测试功能,助您快速选择最适合项目的高性能大语言模型。
- 760次使用
-
- LMArena
- LMArena是加州大学伯克利分校推出的AI模型匿名评测平台。通过盲测投票机制,用户可对比不同大模型回答并生成实时排行榜,助力开发者优化模型及用户选择最佳AI工具。
- 842次使用
-
- HELM
- 深入了解斯坦福推出的HELM(Holistic Evaluation of Language Models)大模型评测体系。本文解析其核心功能、安装配置步骤及应用场景,涵盖准确性、公平性、鲁棒性等多维度指标,助力开发者全面优化语言模型性能。
- 813次使用
-
- MMBench
- MMBench是由上海人工智能实验室等机构联合推出的多模态基准测试平台,提供细粒度能力评估、大规模数据集及VLMEvalKit工具。本文详细介绍其核心功能、安装使用方法及应用场景,助力开发者全面评估多模态模型性能。
- 641次使用
-
- 量化模型本地推理的显存估算方法
- 2026-10-17 501浏览
-
- 本地大模型反复输出同一句话怎么调整生成参数
- 2026-09-06 501浏览
-
- Python 调用大模型时如何用结构化输出校验 JSON:从解析失败到可重试
- 2026-08-29 501浏览
-
- AI写作工具免费版安装教程(含豆包Clawdbot)
- 2026-05-30 501浏览
-
- WPS AI能自动生成PPT吗?输入主题一键制作演示文稿
- 2026-05-27 501浏览

