Python 处理 Responses SSE 流:超时、取消和增量渲染

模型开始能完成更长的工作后,前端最容易坏的反而不是"答得对不对",而是等待。用户问了一句复杂问题,页面沉默十秒,他已经改题、切页或按下停止。一个好用的聊天界面应当立即显示增量文字,并在用户取消时停止读取、保留已经得到的内容,而不是把一次断连误报为模型失败。学完本文,你能用 Python 标准库完成这条最小而可靠的链路。

10 月 7 日 OpenAI 发布 GPT-6 与 Intelligent UI,官方发布页把模型能力与可操作界面放在同一产品叙事里。这里不把这理解成"接口一定更快"的承诺;它提醒开发者,模型输出必须进入可见、可打断的交互。Responses API 的流式响应使用 Server-Sent Events(SSE,服务器持续推送文本事件)。SSE 适合单向"服务器到浏览器"输出:客户端建立请求,随后按行收到 event: 和 data:。它不是可靠消息队列,因此应用要自己定义超时、取消、重试和落库边界。

flowchart LR U[用户提交] --> R[POST Responses stream=true] R --> S[SSE 字节流] S --> P[解析 data 行] P --> V[增量渲染] V --> C{用户取消或超时?} C -- 否 --> S C -- 是 --> X[关闭连接并保存 partial] S --> E[completed] E --> F[保存 final]

环境准备

本例只用 Python 3.10+ 标准库,不需要 SDK。macOS/Linux 先设置密钥;不要把密钥写进代码或提交到仓库:

bash 复制代码
export OPENAI_API_KEY='你的密钥'
export OPENAI_MODEL='gpt-6'
python stream_chat.py

模型 ID 与可用权限因账号而异;上线前在官方 Models 页面确认。官方流式文档是接口字段的唯一依据。下面为了能安全离线检查,提供 --demo,它不联网;常规运行会发起实际请求。

完整代码

python 复制代码
# stream_chat.py
import json, os, sys, time
from urllib import request, error

URL = "https://api.openai.com/v1/responses"

def event_lines(response, cancelled):
    """把 SSE 的 data 行还原为 JSON 事件。"""
    for raw in response:
        if cancelled():
            return "cancelled"
        line = raw.decode("utf-8").strip()
        if not line.startswith("data:"):
            continue
        payload = line[5:].strip()
        if payload == "[DONE]":
            return "completed"
        try:
            yield json.loads(payload)
        except json.JSONDecodeError:
            print("\n[跳过无法解析的事件]", file=sys.stderr)

def run(prompt, cancel_after=None, opener=request.urlopen):
    key = os.environ.get("OPENAI_API_KEY")
    model = os.environ.get("OPENAI_MODEL", "gpt-6")
    if not key:
        raise RuntimeError("请设置 OPENAI_API_KEY;不要把密钥写入源码")
    body = json.dumps({"model": model, "input": prompt, "stream": True}).encode()
    req = request.Request(URL, data=body, method="POST", headers={
        "Authorization": f"Bearer {key}", "Content-Type": "application/json",
        "Accept": "text/event-stream"})
    started, parts = time.monotonic(), []
    try:
        with opener(req, timeout=30) as response:
            for event in event_lines(response, lambda: cancel_after is not None and
                                     time.monotonic() - started >= cancel_after):
                if event.get("type") == "response.output_text.delta":
                    text = event.get("delta", "")
                    print(text, end="", flush=True)
                    parts.append(text)
            print()
    except error.HTTPError as e:
        raise RuntimeError(f"HTTP {e.code}: {e.read().decode(errors='replace')[:300]}") from e
    except TimeoutError as e:
        raise RuntimeError("连接或读取超过 30 秒;可重试,但不要重复执行有副作用的工具") from e
    return "".join(parts)

if __name__ == "__main__":
    if "--demo" in sys.argv:
        sample = [b"event: x\n", '{"type":"response.output_text.delta","delta":"你好"}\n'.join(["data: ", ""]).encode(),
                  '{"type":"response.output_text.delta","delta":",世界"}\n'.join(["data: ", ""]).encode()]
        print("".join(e["delta"] for e in event_lines(sample, lambda: False)))
    else:
        print("最终文本:", run("用两句话解释 SSE", cancel_after=20))

运行 python stream_chat.py --demo 预期输出 你好,世界。真实请求则会边收到边打印,再返回拼接后的最终文本。这里的"取消"是关闭本次 HTTP 读取,不等同于撤销已经发出的外部工具动作;如果你的 Agent 能发邮件、下单或改数据库,必须在工具端另外设计幂等键和确认点。

关键点与常见错误

第一,事件类型不要靠猜。只在 response.output_text.delta 时拼接 delta,其它事件留给日志或状态机;不要把完整快照反复追加。第二,30 秒 timeout 是网络保护,不是产品 SLA。生产中应分别记录建连、首 token、总耗时,并由服务端根据任务大小设上限。第三,用户取消后保存 partial 及原因,下一次可以让用户"继续生成",而不是伪装成完整答案。

最常见的失败有三类:环境变量为空会得到认证失败,本例在发送前就报出可读错误;模型未向账号开放会得到 HTTP 4xx,应换成官方 Models 页可用的 ID;代理缓冲会让"流式"变成最后一次性输出,需要检查反向代理的缓冲设置。另一个隐蔽错误是用线程强杀请求:资源没有关闭,连接会泄漏。with 块和明确返回能保证本次连接收尾。

这个模式适合聊天、长摘要、代码解释等"中间文本也有价值"的场景。不适合把每个 token 直接写进不可逆业务记录,也不适合需要双向低延迟音频的实时通话。工程化时,给事件加 request_id,将原始事件与展示文本分开存,增加断线续传策略,并把"停止"做成显式产品状态。

还有两个常被忽视的产品细节。增量文本不是最终答案,用户复制或点击操作按钮时,界面应知道回答是否完成。重连也不是再发一次相同提示;带工具的会话必须带上已执行动作,避免断线后重复发券或建工单。事件流留作调试证据时,要限制留存期并掩去敏感字段。至少记录创建、首片段、完成或取消原因、输出字符数和 HTTP 状态;取消率突然升高可能是模型变慢,也可能是代理开始缓冲。

5 分钟实践题:把 cancel_after=20 改成读取 input() 的线程标志,再为取消事件写一条 JSON 日志。思考:你的产品会把"用户关闭页面"记为取消、失败,还是一次可恢复的会话?

关注「蜗牛聊AI」,一起看懂技术变化背后的真正机会。


本文首发于 java4u.cn,转载请注明出处。

相关推荐
企智汇-项目管理软件1 小时前
离散制造管理系统怎么选?一套系统管住从研发到交付的全周期
java·人工智能·制造·项目经理·项目管理软件·项目管理系统·项目管理软件工程
Imagination官方博客1 小时前
PowerVR 上的 LLM 性能与加速
人工智能·架构·llama·gpu算力
2601_968900771 小时前
面向Agent的视频编译链路:从语义标记到分层渲染
人工智能
王中阳Go1 小时前
做了 8 年前端,简历改漂亮后面一个挂一个:差的不是前端,是这 3 块底子
人工智能·后端·面试
南方者1 小时前
AI卡在授权?ToDesk手机点一下,不用跑回电脑
人工智能·智能手机·远程工作·远程控制
坐吃山猪1 小时前
从 Prompt 到 Harness:AI 能力边界扩展的完整脉络
人工智能·prompt
我的愿望是成为富婆1 小时前
2027校招AI产品经理能力清单:数据科学与大数据技术专业如何准备
大数据·人工智能·产品经理
空空dream1 小时前
03 | 更新:Ollama 换成 llama.cpp构建更小的离线镜像
人工智能·语言模型·llama
单向箔1 小时前
08|一套可复制的 AI Coding 流程:从需求到提交怎么走
人工智能·prompt·ai编程