大模型 API 流式输出的工程细节:SSE 分帧、中断与重试边界

接大模型接口时,把 stream=True 加上去只是第一步。

真正耗时间的是后面这些事:输出偶尔出现乱码 、网络抖一下重试后内容重复了 、用户点了停止但后台还在计费。

这些问题的根源都在同一个地方------流式响应返回的不再是一个完整的 JSON,而是一串被网络任意切分的事件。客户端要自己把它拼回去。

下面按「报文结构 → 三个坑 → 可运行的实现」来讲。

一、流式响应到底返回了什么

流式接口用的是 SSE(Server-Sent Events)。响应的 Content-Type 是 text/event-stream,body 是一串以空行分隔的帧:

代码块

css 复制代码
data: {"id":"chatcmpl-1","choices":[{"delta":{"role":"assistant"}}]}

data: {"id":"chatcmpl-1","choices":[{"delta":{"content":"你"}}]}

data: {"id":"chatcmpl-1","choices":[{"delta":{"content":"好"}}]}

data: [DONE]

三个要点:

  1. 每帧以 data: 开头 ,帧与帧之间用一个空行 (\n\n)分隔
  2. 内容是增量的 ,在 choices[0].delta 里,不是 message
  3. 结束标志是一帧 data: [DONE] ,收到就该主动关闭连接

delta 里除了 content,还可能出现 role(第一帧)、tool_calls(函数调用)、以及部分厂商的 reasoning_content(思维链)。解析时不要假设 delta.content 一定存在 ,否则遇到工具调用那一帧就会 KeyError。

二、三个必踩的坑

坑 1:按 chunk 切分会切断 JSON

最容易写错的版本是这样的------注意它把 chunk 当成了一行:

python

scss 复制代码
for chunk in resp.iter_bytes():
    for line in chunk.decode("utf-8").split("\n"):   # ← 问题在这一行
        if line.startswith("data:"):
            obj = json.loads(line[5:])                # 偶发 JSONDecodeError

这段代码在本地跑往往没问题,上线后却偶尔抛 JSONDecodeError。原因是:一个 chunk 不等于一行。

TCP 是字节流,一次 read 返回多少字节,由网络、缓冲区和对端的写入节奏共同决定。一个完整的帧被拆进两次 read,完全正常:

代码块

arduino 复制代码
第一次 read:  data: {"id":"chat","choices":[{"delta":{"con
第二次 read:  tent":"你好"}}]}

两次 split("\n") 拿到的都是残缺的 JSON,json.loads 必然失败。

正确做法是维护一个缓冲区,先拼、再按空行切帧。

这里有个细节值得说清楚:如果你用的是 iter_lines(),它内部已经做了行缓冲,会把跨 chunk 的半行拼回来,所以不会出现上面的问题------这也是为什么很多人写了很久都没踩到这个坑。

但即使如此,仍有两件事要处理:

  • 有些反向代理会把换行改写成 \r\n,分隔符要同时兼容 \n\n 和 \r\n\r\n
  • SSE 规范允许一帧里出现多行 data:,按规范它们应当用 \n 连接后再解析,不能只取第一行

所以自己按空行聚合成帧仍然是最稳妥的写法。

坑 2:UTF-8 多字节字符被截断

这个坑更隐蔽,表现为输出里偶尔冒出 �。

一个中文字在 UTF-8 里占 3 个字节,emoji 占 4 个。如果一次 read 正好切在字符中间:

代码块

arduino 复制代码
第一次 read 的字节: ... E4 BD        ← 「你」的前 2 个字节
第二次 read 的字节: A0 ...            ← 「你」的最后 1 个字节

每次拿到 chunk 都直接 .decode("utf-8") 的话,第一次会因为字节不完整而抛异常,或者用了 errors="replace" 就变成一个 �。

正确做法是用增量解码器,让它把不完整的字节留在内部缓冲里,等下一个 chunk 到了再拼:

python

ini 复制代码
import codecs

decoder = codecs.getincrementaldecoder("utf-8")()
text = decoder.decode(raw_bytes, final=False)

好消息是:如果用的是 httpx 这类现代 HTTP 客户端,它的 iter_text() 内部已经用了增量解码器,不会有这个问题。但如果你的代码是直接读 socket、或者用某些老库逐 chunk 拿 bytes,就必须自己处理。

坑 3:流已经开始就不能简单重试

这是最容易被忽略、后果也最严重的一个。

假设你在客户端加了「失败自动重试」。当连接在吐出 20 个字之后断开,重试会发生什么?

代码块

复制代码
第一次请求的输出:今天天气
连接断开 → 自动重试
第二次请求的输出:今天天气真不错
最终用户看到的:  今天天气今天天气真不错

内容重复了,而且用户已经开始阅读了。

所以重试必须加一个前置条件:

只有「一个 token 都还没吐出来」时才能重试。

一旦已经产生过输出,唯一正确的做法是把错误抛给上层,由业务决定是提示用户重试、还是把已输出的内容保留下来。

另外还有两个配套细节:

  • 只重试可重试的错误 :网络层面的传输错误、429(限流)、5xx(服务端错误)可以重试;401(密钥错)、400(参数错)、404(模型不存在)重试多少次都一样,属于浪费
  • 429 要读 Retry-After 响应头,服务端已经告诉你该等多久了,自己瞎猜的退避时间可能太短

三、一份可运行的实现

下面这段用 httpx,把上面三个坑都处理了。依赖:pip install httpx。

python

python 复制代码
# -*- coding: utf-8 -*-
"""流式调用:SSE 分帧、增量解码、重试边界。"""
import json
import random
import time

import httpx


def stream_once(url, key, payload, timeout=120.0):
    """单次流式请求,逐段 yield 文本增量。"""
    headers = {"Authorization": f"Bearer {key}", "Content-Type": "application/json"}
    buf = ""

    with httpx.Client(timeout=timeout) as client:
        with client.stream("POST", url, json=payload, headers=headers) as resp:
            if resp.status_code >= 400:
                resp.read()  # 错误体是普通 JSON,先读出来再抛
                resp.raise_for_status()

            # iter_text() 内部用增量解码器,跨 chunk 的多字节字符不会被切坏
            for chunk in resp.iter_text():
                # 统一换行,兼容网关把 \n 改写成 \r\n 的情况
                buf += chunk.replace("\r\n", "\n")

                # 关键:先聚合,再按空行切帧;不要直接按行 json.loads
                while "\n\n" in buf:
                    frame, buf = buf.split("\n\n", 1)
                    for line in frame.split("\n"):
                        if not line.startswith("data:"):
                            continue
                        data = line[5:].strip()
                        if data == "[DONE]":
                            return
                        try:
                            obj = json.loads(data)
                        except json.JSONDecodeError:
                            continue  # 畸形帧跳过,不要因此中断整个流
                        choices = obj.get("choices") or []
                        if not choices:
                            continue
                        delta = choices[0].get("delta") or {}
                        text = delta.get("content")
                        if text:
                            yield text


def stream_chat(url, key, payload, max_retries=2, base_delay=0.5):
    """带重试的流式调用:只在尚未产出任何内容时才重试。"""
    for attempt in range(max_retries + 1):
        emitted = False
        try:
            for piece in stream_once(url, key, payload):
                emitted = True
                yield piece
            return
        except httpx.HTTPStatusError as err:
            status = err.response.status_code
            retryable = status == 429 or status >= 500
            if emitted or not retryable or attempt == max_retries:
                raise
            # 429 优先听服务端的 Retry-After
            wait = None
            if status == 429:
                raw = err.response.headers.get("Retry-After")
                if raw and raw.isdigit():
                    wait = float(raw)
            if wait is None:
                wait = base_delay * (2 ** attempt) + random.uniform(0, 0.2)
            time.sleep(wait)
        except httpx.TransportError:
            if emitted or attempt == max_retries:
                raise
            time.sleep(base_delay * (2 ** attempt) + random.uniform(0, 0.2))

调用方式和量首 token 延迟:

python

python 复制代码
import os

payload = {
    "model": "your-model",
    "messages": [{"role": "user", "content": "用三句话介绍什么是 SSE"}],
    "stream": True,
}

# 接口地址和密钥从环境变量读,不要写死在代码里
base_url = os.environ["LLM_BASE_URL"]      # 形如「协议://主机/v1」
api_key = os.environ["LLM_API_KEY"]

t0 = time.perf_counter()
ttft = None
pieces = []

gen = stream_chat(base_url + "/chat/completions", api_key, payload)
try:
    for piece in gen:
        if ttft is None:
            ttft = time.perf_counter() - t0   # 首 token 延迟
        pieces.append(piece)
finally:
    gen.close()   # 关键:中断时主动关闭,否则上游会继续生成、继续计费

total = time.perf_counter() - t0
print(f"首 token 延迟 {ttft:.2f}s,总耗时 {total:.2f}s")
print("".join(pieces))

四、为什么一定要单独量首 token 延迟

很多人用「总耗时」来评估接口快慢,这在流式场景下会得出错误结论。

一个输出 800 字的回答,总耗时 20 秒,体感却可能很好------因为它第 0.8 秒就开始往外吐字了。反过来,一个总耗时 3 秒但首 token 要等 2.8 秒的接口,用户会觉得它「卡住了」。

这两个指标衡量的是完全不同的东西:

指标 含义 用户感知
首 token 延迟(TTFT) 从发起请求到收到第一个字 「快不快」
总耗时 全部生成完 「什么时候能复制走」

优化方向也不一样。TTFT 高通常是排队、路由或模型预热的问题;总耗时高则多半是输出太长或解码速度不够。

所以在做接口选型或压测时,建议把 TTFT 的 p50/p95 和总耗时分开记录。只看平均值会掩盖长尾------而长尾才是用户抱怨的来源。

五、中断处理:点了停止就该真的停

最后说一个容易被忽略的成本问题。

用户点了「停止生成」,如果客户端只是停止读取、不关闭连接,上游可能仍在继续生成 token,而这些都是要计费的。

用生成器实现时,正确的做法是在 finally 里显式关闭:

python

csharp 复制代码
gen = stream_chat(url, key, payload)
try:
    for piece in gen:
        print(piece, end="", flush=True)
        if user_pressed_stop():
            break
finally:
    gen.close()

httpx 的 client.stream() 在上下文退出时会关闭连接,而生成器被 close() 时会触发 GeneratorExit,把上下文一起退掉。如果漏了这一步,那个「已经没人看的回答」会一直生成到最大 token 数。

如果是 asyncio 场景,还要注意 asyncio.CancelledError 会在 await 点抛出,finally 里的清理代码要能容忍它。

六、小结

问题 根因 处理方式
JSON 解析偶发失败 网络把一帧切成了两半 维护缓冲区,按空行聚合后再解析
输出出现 � 多字节字符被 chunk 边界截断 用增量解码器(httpx 的 iter_text 已内置)
重试后内容重复 流已产出内容仍在重试 只有「零输出」时才允许重试
白烧 token 中断时没关闭连接 在 finally 里 gen.close()
觉得接口慢但说不清 用总耗时代替了 TTFT 两个指标分开统计 p50/p95

流式接口的代码量不大,但边界条件集中在很短的几行里。把这四个处理对,线上基本就不会再出这类问题了。

相关推荐
神鹰手哥_起飞版24 分钟前
别让大模型写 HTML:一个省 token 的分工实验
人工智能
AI日报派送佬25 分钟前
高效目标检测利器:X-AnyLabeling 加载自定义 YOLO 模型自动标注实操指南
人工智能·yolo·目标检测
猛码象限29 分钟前
【实践随笔】我们在用 AI,还是 AI 在用我们?
人工智能
硅谷秋水34 分钟前
DiscoveryRSI:通过递归学习发现有价值的假设
人工智能·机器学习·语言模型
陈嘿萌38 分钟前
ECCV 2026 | CausalDrive:澳门大学×小米汽车,面向自动驾驶的实时因果世界模型
图像处理·人工智能·深度学习·计算机视觉·自动驾驶·学术研究与理论基础
kaixin_啊啊1 小时前
【零基础学AI】第 24 章课后练习与答案
人工智能
EchoMind-Henry1 小时前
娱乐资本论发布AI短剧剧本对比文章
人工智能·娱乐
谁在黄金彼岸1 小时前
Lightpanda无头浏览器实战-Playwright接入与Agent化
人工智能
hkNaruto1 小时前
【AI】游戏大模型智能体OODA过程记忆问题解决方案:AgenticSTS 有界记忆契约:技术分析与流水线改造操作手册
人工智能·游戏·机器学习