流式输出实战:SSE 解析、chunk 拼装与断流重试

流式输出实战:SSE 解析、chunk 拼装与断流重试

结论先放这儿

事项 要点
SSE 格式 data: 前缀逐行读,[DONE] 收尾,冒号开头是心跳注释
usage 统计 请求参数带 include_usage,最后一个 chunk 里给
断流重试 首字节前可安全重试,输出中途断了重试会重复计费
前端渲染 按 50-100ms 节流重绘,别每个 token 都刷

流式是体验工程的第一优先级,首 token 延迟比总时长更影响体感。这篇把流式接入的工程细节补齐,与语音篇的流水线、扛量篇的限流配着看。

一、SSE 长什么样

流式接口返回的是一条持续打开的 HTTP 连接,服务端逐块推数据,每块都是一小段 JSON。OpenAI 兼容接口的每一块长这样:

复制代码
data: {"choices":[{"delta":{"content":"你"}}]}

data: {"choices":[{"delta":{"content":"好"}}]}

data: [DONE]

三件事要注意。每块以 data: 开头,解析时按行切、去掉前缀再 json.loads。结尾的 [DONE] 是文本不是 JSON,要单独判断。还有一类以冒号开头的行(: keep-alive),是 SSE 规范里的心跳注释,直接跳过。

二、chunk 拼装与 usage

每块 delta 里只有增量文本,要自己拼。拼接的同时统计 token 数:

python 复制代码
import json, httpx

def stream_chat(messages):
    content = []
    usage = None
    with httpx.stream('POST', BASE_URL + '/chat/completions',
                      headers=HEADERS, json={
                          'model': MODEL, 'messages': messages,
                          'stream': True,
                          'stream_options': {'include_usage': True},
                      }, timeout=120) as r:
        for line in r.iter_lines():
            if not line.startswith('data:'):
                continue
            payload = line[5:].strip()
            if payload == '[DONE]':
                break
            chunk = json.loads(payload)
            if chunk.get('usage'):
                usage = chunk['usage']       # 最后一块才有
            for choice in chunk.get('choices', []):
                delta = choice.get('delta', {}).get('content')
                if delta:
                    content.append(delta)
                    yield delta
    print('usage:', usage)

两个细节容易踩。usage 挂在最后一个 chunk 里,且要显式传 include_usage 才有,不传就永远拿不到对账数据。choices 可能为空列表(只有 usage 的那块),遍历前判一下。

三、断流重试的重试边界

流式的重试比普通请求危险。连接还没建立、首字节还没到的时候重试是安全的,什么都没发生。输出到一半断了,重试等于从头再生成一次,用户看到内容闪重,账单计两次。

所以重试逻辑按阶段分。首字节超时,放心重试。输出中途断,有两种处理,要完整答案就带着已拼内容重新请求(让模型从已有内容续写),要体感就只提示用户重发。对账以 usage 为准,重试导致的双份计费要在日志里能对出来。

python 复制代码
def stream_with_retry(messages, retries=2):
    for i in range(retries + 1):
        try:
            first = None
            gen = stream_chat(messages)
            for delta in gen:
                if first is None:
                    first = delta          # 首字节到了,之后断流不再整体重试
                yield delta
            return
        except httpx.ReadTimeout:
            if first is not None:
                raise                      # 输出中途断,交给上层决定
            if i == retries:
                raise

四、前端渲染别每个 token 都刷

拿到 delta 直接往 DOM 里塞,Markdown 渲染会闪,半个代码块会错位。两个惯用做法。按时间节流,50-100ms 批量刷一次。按句渲染,攒到标点再重绘,顺带给 TTS 之类的下游切句用。

写在最后

流式接入三件事,SSE 逐行解析(心跳注释和 [DONE] 单独处理),增量拼接加 usage 对账,重试按阶段分层(首字节前安全,输出中途要小心)。配上节流渲染,体感和账单都稳。

本文方法与代码按自己的项目实践整理,与语音篇的流水线、扛量篇的对账配着用。

相关推荐
SamChan901 小时前
图片翻译后文字挤出边框?用 Pillow 测量译文,不再靠字符数猜宽度
图像处理·python·pillow·本地化·图片翻译
2601_962780911 小时前
2027风险管理岗准备路线:数学与应用数学专业如何补齐金融、SQL与Python
python·sql·金融
老唐7771 小时前
Transformer原理大白话讲解
人工智能·python·深度学习·神经网络·计算机视觉·ai·transformer
佳木逢钺1 小时前
从火灾模拟到系统热寂:一个双非运维如何用零 GPU 推导出多智能体系统的终极死锁
人工智能·python·算法·多智能体
林伽一1 小时前
端侧模型与运行框架双轮驱动,智能体工程化进入深水区|2026年10月16日
人工智能·科技·安全·ai·开源
PPIO派欧云2 小时前
Grok、Cursor 纳入同一订阅后,AI Agent 任务成本怎么算?
人工智能·ai
xiezhr2 小时前
WorkBuddy白送500积分,大家快来领
ai·deepseek·deepseek大模型·workbuddy
fwc2 小时前
第T9周:猫狗识别2
python·深度学习
砚底藏山河2 小时前
数据新鲜度 SLA 监控
java·大数据·python·maven