OpenAI Python SDK流式响应怎么收完整?用离线SSE样本检查delta和结束状态

OpenAI Python SDK流式响应怎么收完整?用离线SSE样本检查delta和结束状态

作者说明:由FlowBarCN团队提供,涉及自家服务,经AI辅助整理。以下运行与测试全部使用本地模拟HTTP传输,不需要真实密钥,不请求线上模型。

给非流式代码加上 stream=True,随后继续读取 chunk.choices[0].message.content,往往就是流式接入的第一个问题:这里收到的是增量块,结构和完整响应不同。

还有一个更隐蔽的问题:你可能已经收到一些文字,但最终结束原因是 length。如果程序只要“有文字”就返回成功,下游拿到的就是一份没有标明缺失的半成品。

本文给出一个仅收集单候选文本、验证完成状态后才返回的聚合器。你会得到一份可运行脚本,以及能离线复现的异常样本。前一篇已经讨论SDK超时与重试,这篇只处理流式内容与结果完整性,不重写重试循环。

1. 先确定这段代码适用到哪里

本例处理Chat Completions的普通文本流,不处理Responses事件、音频、工具调用、多候选合并或断点续传。

官方流式示例展示了 delta 中的角色信息、内容增量和空增量;流式块参考还说明某些用量块的 choices 可以为空。这里不能不加判断地读取 choices[0]。

这些是上游协议文档。某个兼容网关和具体模型是否支持流式、stream_options或工具参数,需要独立确认。FlowBarCN文档也明确要求按具体模型核对能力。本篇没有进行站内流式实测。

10月22日的官方API更新日志涉及另一API的新能力,不表示这些能力自动出现在兼容的Chat Completions端点里。本篇以常青协议处理为主,近期更新只用来提醒核对接口边界。

2. 准备可重复的运行环境

本轮实际验证:Windows、Python 3.11.9、openai==3.35.1、httpx==0.28.1。这是本文验证组合,不宣称它们永远是最新版本。

在空目录的PowerShell里运行:

python -m venv .venv
.venv\Scripts\python.exe -m pip install openai==3.35.1 httpx==0.28.1

安装依赖会访问包仓库,但下面的演示请求由MockTransport在内存中处理,不会访问模型服务。示例使用占位密钥与 example.invalid,不要换成真实密钥来“验证”这段离线代码。

3. 保存完整脚本stream_text.py

"""A single-choice text-stream collector; the demo is fully offline."""
import json
from dataclasses import dataclass

import httpx
from openai import OpenAI


class StreamRejected(ValueError):
    """Fixed local policy code, not an upstream HTTP status."""


@dataclass(frozen=True)
class TextResult:
    text: str
    finish_reason: str


def collect_text(client, *, model: str, prompt: str,
                 max_chars: int = 4096) -> TextResult:
    if type(max_chars) is not int or max_chars < 1:
        raise ValueError("invalid_limit")
    pieces, size, finish = [], 0, None
    with client.chat.completions.create(
        model=model, messages=[{"role": "user", "content": prompt}],
        stream=True,
    ) as stream:
        for chunk in stream:
            # A usage-only chunk can have choices=[].
            if not chunk.choices:
                continue
            if len(chunk.choices) != 1 or chunk.choices[0].index != 0:
                raise StreamRejected("single_choice_only")
            if finish is not None:
                raise StreamRejected("content_after_finish")
            choice = chunk.choices[0]
            if choice.delta.tool_calls or choice.delta.function_call:
                raise StreamRejected("tool_output_not_supported")
            piece = choice.delta.content
            if piece is not None:
                if not isinstance(piece, str):
                    raise StreamRejected("invalid_text_delta")
                size += len(piece)
                if size > max_chars:
                    raise StreamRejected("text_limit_exceeded")
                pieces.append(piece)
            if choice.finish_reason is not None:
                finish = choice.finish_reason
    if finish != "stop":
        raise StreamRejected("not_complete")
    text = "".join(pieces)
    if not text.strip():
        raise StreamRejected("empty_text")
    return TextResult(text=text, finish_reason=finish)


def demo_client():
    # Every request is handled in memory. No socket or real API key is used.
    def handle(request):
        assert request.url.path == "/v1/chat/completions"
        payload = json.loads(request.content)
        assert payload["stream"] is True
        chunks = []
        for delta, reason in [
            ({"role": "assistant", "content": ""}, None),
            ({"content": "Hello "}, None),
            ({"content": "stream"}, None),
            ({}, "stop"),
        ]:
            item = {
                "id": "offline-demo", "object": "chat.completion.chunk",
                "created": 0, "model": "DEMO_MODEL",
                "choices": [{"index": 0, "delta": delta,
                             "finish_reason": reason}],
            }
            chunks.append("data: " + json.dumps(item) + "\n\n")
        body = "".join(chunks) + "data: [DONE]\n\n"
        return httpx.Response(
            200, headers={"content-type": "text/event-stream"},
            content=body.encode("utf-8"),
        )

    return OpenAI(
        api_key="PLACEHOLDER_NOT_A_REAL_KEY",
        base_url="https://example.invalid/v1", max_retries=0,
        http_client=httpx.Client(transport=httpx.MockTransport(handle)),
    )


if __name__ == "__main__":
    with demo_client() as client:
        result = collect_text(
            client, model="DEMO_MODEL", prompt="Offline demo",
        )
        print(result)

运行:

.venv\Scripts\python.exe stream_text.py

本轮演示输出:

TextResult(text='Hello stream', finish_reason='stop')

Hello stream来自脚本写死的SSE数据,不是模型回答,也不是线上网络测试。

4. 为什么这样处理流式块?

4.1 文本读取delta,不读取message

非流式完整结果常从 message.content 读取,流式文本则读取 delta.content。角色块可能没有正文;结束块也可能只有结束原因。判断 piece is not None 后再收集,能避免把空值直接拼进字符串。

4.2 choices为空时,不能直接下标访问

某些用量块可能是 choices=[],所以代码先判断列表。本文没有发送 stream_options,也没有把用量写入 TextResult;测试中人工加入用量块,是为了检查聚合器不会越界,不代表本站一定发送或支持对应参数。

如果业务需要算用量,应该另行解析已确认的字段;没拿到用量不能当成消耗为0。

4.3 让SDK处理SSE和UTF-8分块

一个网络数据块不保证恰好等于一个JSON对象,更不保证刚好在汉字边界结束。这里用SDK解析SSE,不对原始网络字节逐块 json.loads。

测试把SSE字节按1字节切分,故意把多字节中文拆开,最终仍按预期得到完整中文。这验证的是本地SDK解析这组样本,不是对所有服务器实现的兼容承诺。

4.4 收到文字不等于已经完成

本例把 finish_reason == "stop" 作为文本完成策略。结束原因为 length、content_filter、缺少结束原因,统一返回本地错误 not_complete。这几个状态本身含义不同;业务可以进一步分类展示,但不能静默升级成“完整成功”。

[DONE]是流式传输中的结束标记;SDK不会把它当普通文字交给本循环。这个聚合器检查SDK解析出的结束原因,不另外实现原始SSE终止标记检查,也不宣称 stop 已证明业务事实正确。

4.5 本例为什么拒绝工具输出和其他候选?

工具调用的参数增量不是普通回答文本。代码检测到 tool_calls 或旧式 function_call 就拒绝,不帮它执行工具。

同样,本例只接受 index=0 的单个候选。如果以后明确需要多候选,就应按index分别维护缓冲区,不能把多个候选混成一句话。

5. 错误分类和排查顺序

本地错误触发条件下一步
not_complete非stop或没有结束原因标明不完整;结合结束原因检查限制与处理策略
empty_text正常结束但没有非空白文本核对期望输出类型,不直接归因网络故障
tool_output_not_supported出现工具调用增量转交独立工具流程,或选择确认为纯文本的调用方式
single_choice_only非零index或一个块含多个候选明确多候选策略后再扩展
text_limit_exceeded累计文本超出本例字符上限调整本地结果预算,不能当作Token上限
content_after_finish同一文本候选完成后又收到候选内容留下脱敏协议样本,核对服务端或适配器行为

这些固定代码是本文的本地策略,不是上游HTTP状态,也不是OpenAI SDK官方异常名。

网络异常仍可能从SDK抛出,本例不在聚合器里重试。与此前教程一样,重试与重复费用要单独设计,不能在这个循环外无条件再跑一遍。

6. “完整收集后返回”的取舍

为了便于验证,本例把文本保存在内存里,完成后一次返回,没有实现前端逐字显示。如果业务要实时展示,可以增加增量回调,但在最终完成前,应把内容标为临时结果;取消、截断或断连后,保留“不完整”状态。

max_chars=4096只是本地累计文本字符限制,不能限制上游已经生成的Token或费用,也不能代替总时限、事件数、并发限制。过早退出时,with负责关闭这个流的资源;它不保证服务端立即停止计费或任务处理。

把演示接入真实服务前,应核对模型ID、流式支持、费用规则和超时策略。FlowBarCN公开接入地址见当前文档,计费见定价页。本文不包含真实请求入口;真实生成操作可能收费,不能把占位模型DEMO_MODEL直接拿去请求线上。

7. 本轮离线检查结果

配套测试文件(本轮内部验证材料)中,流式部分有9个测试方法,覆盖:

  • 角色空块、中文UTF-8按单字节拆分,以及空choices用量块。
  • 收到文字却缺少结束原因;没有正常结束原因时直接EOF。
  • length和content_filter结束、空文本结果。
  • 工具增量、超过本地大小限制、错误候选index、完成后又出现内容。

每组流式样本都断言只发生一次模拟请求、响应流被关闭。工单JSON校验另有7个测试方法,两组共16个方法通过。运行脚本也得到上面的人工SSE样本文本。

上文stream_text.py是完整的离线演示,可单独复制运行。本文报告的16个测试方法为内部回归检查;其中流式部分9个、工单校验7个,不把未公开的测试文件说成可下载资源。

今日练习与下一篇

在聚合器外面为 not_complete 增加一个“显示部分内容但禁止业务提交”的状态设计。先写清楚:部分文本保存在哪里、谁能看到、是否允许重新生成,再决定是否扩展返回类型。不要先加自动重试。

下一篇将继续做接入前预检:如何从精确模型ID出发,区分公开目录、账户可见和实际调用这几层证据。

欢迎提供脱敏后的字段结构或错误分类,不需要提交真实密钥、请求头和完整业务内容。

来源

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值