Python消费Responses SSE事件:增量文本、超时与取消

用户问"公司报销流程怎么走",模型可能十几秒后才交出完整段落。命令行像卡住了一样,用户又按一次回车,后台便多出一次请求。今天用 Python 做一个最小答疑程序:文字到达就显示,等太久会结束,按 Ctrl+C 可以取消。学到的是网络流的消费方法,换模型、换前端仍然用得上。

为什么不是把普通请求的结果切成小段

普通请求的服务器响应在生成结束后才返回。流式请求让服务器使用 SSE(Server-Sent Events,服务器发送事件)逐个传递事件。response.output_text.delta 携带新文本,response.completed 表示正常结束,response.failed 指向服务端失败。收到事件不等于答案已经可靠完成:如果网络断开,屏幕上可能只有半句话。因此示例明确区分"显示过内容"和"已完成"。

这篇文章以近期 Python SDK 对 Responses 流快照处理的更新为引子,刻意使用标准库直接读 REST(表述性状态传输)接口。这样可看清最底层的事件边界;应用生产环境时,官方 SDK 通常能减少协议细节工作。模型用 gpt-5,因为官方快速入门有该模型的 Responses 调用示例;实际账户可用性仍需在运行前确认。场景只给一段公共示例政策,不能把真实内部政策硬编码到程序里。
#mermaid-svg-oFz0U5gsw2jN0RN6{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-oFz0U5gsw2jN0RN6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-oFz0U5gsw2jN0RN6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-oFz0U5gsw2jN0RN6 .error-icon{fill:#552222;}#mermaid-svg-oFz0U5gsw2jN0RN6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-oFz0U5gsw2jN0RN6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-oFz0U5gsw2jN0RN6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-oFz0U5gsw2jN0RN6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-oFz0U5gsw2jN0RN6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-oFz0U5gsw2jN0RN6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-oFz0U5gsw2jN0RN6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-oFz0U5gsw2jN0RN6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-oFz0U5gsw2jN0RN6 .marker.cross{stroke:#333333;}#mermaid-svg-oFz0U5gsw2jN0RN6 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-oFz0U5gsw2jN0RN6 p{margin:0;}#mermaid-svg-oFz0U5gsw2jN0RN6 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-oFz0U5gsw2jN0RN6 .cluster-label text{fill:#333;}#mermaid-svg-oFz0U5gsw2jN0RN6 .cluster-label span{color:#333;}#mermaid-svg-oFz0U5gsw2jN0RN6 .cluster-label span p{background-color:transparent;}#mermaid-svg-oFz0U5gsw2jN0RN6 .label text,#mermaid-svg-oFz0U5gsw2jN0RN6 span{fill:#333;color:#333;}#mermaid-svg-oFz0U5gsw2jN0RN6 .node rect,#mermaid-svg-oFz0U5gsw2jN0RN6 .node circle,#mermaid-svg-oFz0U5gsw2jN0RN6 .node ellipse,#mermaid-svg-oFz0U5gsw2jN0RN6 .node polygon,#mermaid-svg-oFz0U5gsw2jN0RN6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-oFz0U5gsw2jN0RN6 .rough-node .label text,#mermaid-svg-oFz0U5gsw2jN0RN6 .node .label text,#mermaid-svg-oFz0U5gsw2jN0RN6 .image-shape .label,#mermaid-svg-oFz0U5gsw2jN0RN6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-oFz0U5gsw2jN0RN6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-oFz0U5gsw2jN0RN6 .rough-node .label,#mermaid-svg-oFz0U5gsw2jN0RN6 .node .label,#mermaid-svg-oFz0U5gsw2jN0RN6 .image-shape .label,#mermaid-svg-oFz0U5gsw2jN0RN6 .icon-shape .label{text-align:center;}#mermaid-svg-oFz0U5gsw2jN0RN6 .node.clickable{cursor:pointer;}#mermaid-svg-oFz0U5gsw2jN0RN6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-oFz0U5gsw2jN0RN6 .arrowheadPath{fill:#333333;}#mermaid-svg-oFz0U5gsw2jN0RN6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-oFz0U5gsw2jN0RN6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-oFz0U5gsw2jN0RN6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oFz0U5gsw2jN0RN6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-oFz0U5gsw2jN0RN6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oFz0U5gsw2jN0RN6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-oFz0U5gsw2jN0RN6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-oFz0U5gsw2jN0RN6 .cluster text{fill:#333;}#mermaid-svg-oFz0U5gsw2jN0RN6 .cluster span{color:#333;}#mermaid-svg-oFz0U5gsw2jN0RN6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-oFz0U5gsw2jN0RN6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-oFz0U5gsw2jN0RN6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-oFz0U5gsw2jN0RN6 .icon-shape,#mermaid-svg-oFz0U5gsw2jN0RN6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-oFz0U5gsw2jN0RN6 .icon-shape p,#mermaid-svg-oFz0U5gsw2jN0RN6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-oFz0U5gsw2jN0RN6 .icon-shape .label rect,#mermaid-svg-oFz0U5gsw2jN0RN6 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-oFz0U5gsw2jN0RN6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-oFz0U5gsw2jN0RN6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-oFz0U5gsw2jN0RN6 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} output_text.delta
completed
failed或断线
用户输入问题
POST Responses stream=true
读取SSE事件
立即显示文本
标记完整
提示答案不完整

环境准备与完整代码

需要 Python 3.10 或以上;代码只用标准库,无第三方依赖。先在自己的终端设置 OPENAI_API_KEY。macOS/Linux 可运行 export OPENAI_API_KEY='你的密钥';Windows PowerShell 使用 $env:OPENAI_API_KEY='你的密钥'。这两个命令是本机操作说明,不要把真实密钥提交到仓库。将下段保存为 stream_qa.py,运行 python3 stream_qa.py。示例默认网络连接超时 10 秒,整体读取超时 45 秒。

python 复制代码
import json
import os
import socket
import sys
import urllib.error
import urllib.request

URL = "https://api.openai.com/v1/responses"
MODEL = "gpt-5"


def ask(question: str) -> None:
    key = os.environ.get("OPENAI_API_KEY")
    if not key:
        raise RuntimeError("请先设置 OPENAI_API_KEY")
    if not question.strip():
        raise ValueError("问题不能为空")

    payload = {
        "model": MODEL,
        "stream": True,
        "input": [
            {"role": "developer", "content":
             "只根据这段示例政策回答:报销须提交发票,并由直属主管审批。"
             "没有依据时明确说不知道。"},
            {"role": "user", "content": question},
        ],
    }
    request = urllib.request.Request(
        URL,
        data=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
        headers={"Authorization": f"Bearer {key}",
                 "Content-Type": "application/json"},
        method="POST",
    )
    completed = False
    shown = False
    try:
        with urllib.request.urlopen(request, timeout=45) as response:
            for raw in response:
                line = raw.decode("utf-8", errors="replace").strip()
                if not line.startswith("data: "):
                    continue
                if line == "data: [DONE]":
                    break
                event = json.loads(line[6:])
                kind = event.get("type")
                if kind == "response.output_text.delta":
                    print(event.get("delta", ""), end="", flush=True)
                    shown = True
                elif kind == "response.completed":
                    completed = True
                elif kind in ("response.failed", "error"):
                    raise RuntimeError(f"服务端事件失败:{kind}")
    except urllib.error.HTTPError as exc:
        detail = exc.read(400).decode("utf-8", errors="replace")
        raise RuntimeError(f"HTTP {exc.code}: {detail}") from exc
    except (urllib.error.URLError, socket.timeout, TimeoutError) as exc:
        raise RuntimeError(f"网络或读取超时:{exc}") from exc
    finally:
        if shown:
            print()
    if not completed:
        raise RuntimeError("连接结束但没有完成事件;上方内容可能不完整")


if __name__ == "__main__":
    try:
        ask(input("请输入报销问题:"))
    except KeyboardInterrupt:
        print("\n已取消;请勿将已显示的片段当成完整答案", file=sys.stderr)
        sys.exit(130)
    except (RuntimeError, ValueError) as exc:
        print(f"失败:{exc}", file=sys.stderr)
        sys.exit(1)

逐段看关键逻辑

请求体里 stream: true 决定传输形态;用户问题与示例政策分为两个角色,便于后来把政策替换为检索结果。for raw in response 按行读取,空行和 event: 行被跳过,只有 data: 行进入 JSON(JavaScript Object Notation,结构化数据格式)解析。这是一个适合入门的 SSE 消费器;如果代理把超长事件拆成多行 data:,需要改成按空行聚合一整个事件。

每个 delta 都直接打印,flush=True 使终端立即刷新。completed 标志是完整性门槛:看见了字却没有结束事件,程序仍报错。按 Ctrl+C 时关闭上下文中的连接,并明确提醒用户片段不完整。这里的 timeout=45 是套接字等待超时,不是严谨的整个请求总时限;生产环境还要加应用层截止时间,以及请求标识和取消日志。

成功时会先出现逐字或逐块增长的答复,例如"按照示例政策,提交发票并请直属主管审批"。输出内容由模型生成,不保证逐字相同。没有密钥时应立即报"请先设置";若模型不可用,可能收到 404 或权限类错误。示例未在本次任务中实际调用线上API。

常见错误与边界

第一,401 常是密钥未设置或失效;先确认环境变量存在,别把密钥打印到日志。第二,429 表示限流或额度问题;减少并发或按响应头退避,不能无上限重试。第三,终端显示半句后结束:检查是否收到完成事件,网络错误时应在界面标记"未完成"。第四,如果回答引用了示例之外的政策,说明输入约束不足,业务系统还需检索证据和人工校验。

适用场景是长回答、互动问答和需要尽早展示进度的前端。不适用的是必须一次性交付完整且可验证 JSON 的机器间事务;这种场景应先拿到完整结果再校验。工程化时可将事件消费层与展示层分开,记录首字延迟、完成率、取消率,并对同一问题设置请求去重键。缓存也要区分"完整答案"和"中途片段",避免断线内容污染知识库。

把例子接到网页时,服务器应继续作为唯一持有密钥的一端,浏览器只连接自家后端。后端还要给每位用户设置并发上限。流式输出会占住连接,用户反复刷新页面可能耗掉连接池;这和模型推理费用是两种资源。先测并发请求的首字时间与完整结束率,再决定规模。

"停止显示"和"停止计费"也并非同一件事。按下取消后本地连接关闭,服务端是否已处理部分请求,取决于取消传播时机。日志最好保留自己的请求 ID、开始与取消时间、最后一个事件类型,便于排查重复请求;用户问题只在确有业务需要时留存。若用流式结果驱动工具,切勿把未闭合的 JSON 片段直接交给执行器。展示文本可以不完整,数据库写入却必须等参数完整、解析成功并通过权限校验。

5 分钟实践:把示例政策改为"报销须在 30 天内提交",问一个文档没有回答的问题,观察模型能否说不知道;然后按 Ctrl+C,确认程序不会输出"完成"标记。你的答疑产品更需要首字速度,还是完整答案的可靠性?

还有一处产品细节:用户看到文字逐步出现,容易以为模型已经"确定"了答案。实际上,流事件表达的是生成过程,不是审核过程。界面可以在进行中显示"草稿生成中",收到完整事件后再标记"回答结束";若后续还要做引用核对,应另设"证据已核验"状态。三个状态分开,能减少用户把半成品截图当正式结论的情况。对客服业务尤其如此,涉及报销期限、审批人的句子最好在后端查到现行政策版本后再展示。

处理中文时,终端输出看似按字出现,但事件并不保证一次一个汉字。网络缓冲、代理和模型生成节奏都会改变块大小,因此不要以块数估算 Token 或费用。也不要在每个小块后立即写数据库:高频写入会拖慢请求,还可能造成大量碎片记录。一个常见办法是把片段暂存在内存,每隔数百毫秒刷新一次界面,只在完整事件之后保存正式答案;断线片段可以单独存为排障资料并设置短保留期。

若公司网络通过代理访问 API,要把代理关闭连接、重试和超时行为纳入测试。连接在第 44 秒静默断开,与服务器返回明确的失败事件,在用户界面上都应显示"本次答案不完整",但排查路径不同。第一种应检查网络和代理日志,第二种看接口错误字段。把这两类失败混成"AI不会回答",会使团队不断改提示词,却解决不了真正的连接问题。

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


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

相关推荐
YYYing.3 小时前
【Agent系列 (二) 】大语言模型基础
人工智能·语言模型·自然语言处理·agent
零基础1234 小时前
Ubuntu 常用命令汇总
linux·运维·开发语言
xsd202411184 小时前
步态识别算法全解析:从剪影提取到跨视角身份识别的技术拆解
人工智能
外收内放4 小时前
Python基础语法练习题(57-58)
开发语言·python
时间的拾荒人4 小时前
Qt 界面美化实战:QSS 样式表
开发语言·qt·面试
朝朝辞暮i4 小时前
VLA 系统学习第 3 课:从一次机器人示范,到真正送进神经网络的 Batch
人工智能·python·深度学习·神经网络·vla
Joy T4 小时前
Spring AI 接入已有 Java 项目的三种架构设计
java·人工智能·springai·ai入门·chatclient·ai service·ai能力接入
码事漫谈4 小时前
三步改掉 AI 味,附可直接复制的去 AI 味提示词
后端
杨运交4 小时前
[076][核心模块]构建优雅的Java异常处理框架:从错误码到全局异常处理
java·开发语言
m4Rk_4 小时前
【论文阅读】Agent 记忆机制(94):MemGen——在推理过程中动态生成并织入潜在记忆
论文阅读·人工智能·学习·开源·github