大模型 API 流式调用实践:从 SSE 分帧到重试边界

简介: 本文详解大模型流式接口(SSE)在生产环境中的三大客户端陷阱:分帧(跨chunk切帧导致JSON解析失败)、解码(UTF-8字符被截断产生)、重试(已输出后重试引发内容重复)。提供经实战验证的httpx实现,涵盖缓冲区聚合、增量解码、零输出才重试、中断主动关闭等关键处理,并强调TTFT(首token延迟)才是核心体感指标。

在生产环境接大模型接口,多数团队会选流式(`stream=True`),因为首 token 延迟直接决定用户体感。

但流式也把一部分复杂度从服务端推到了客户端:响应不再是完整的 JSON,而是一串可能在任何字节位置被切断的事件帧。分帧、解码、重试、中断,这四件事都需要客户端自己处理对。

## 一、流式把哪些问题推给了客户端

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

```

data: {"id":"chatcmpl-1","choices":[{"delta":{"role":"assistant"}}]}

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

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

data: [DONE]

```

每帧以 `data:` 开头,帧之间用一个空行(`\n\n`)分隔。内容是增量的,放在 `choices[0].delta` 里而不是 `message`。最后会来一帧 `data: [DONE]`,收到就该主动关闭连接。

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

## 二、三个边界:分帧、解码、重试

### 2.1 帧被切开

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

```python

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`,完全正常:

```

第一次 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.2 字符被切开

这个问题更隐蔽,表现为**输出里偶尔冒出 `�`**。

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

```

第一次 read 的字节: ... E4 BD        ← 「你」的前 2 个字节

第二次 read 的字节: A0 ...            ← 「你」的最后 1 个字节

```

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

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

```python

import codecs

decoder = codecs.getincrementaldecoder("utf-8")()

text = decoder.decode(raw_bytes, final=False)

```

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

### 2.3 重试的边界条件

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

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

```

第一次请求的输出:今天天气

连接断开 → 自动重试

第二次请求的输出:今天天气真不错

最终用户看到的:  今天天气今天天气真不错

```

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

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

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

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

另外还有两个配套细节:

- **只重试可重试的错误**:网络层面的传输错误、`429`(限流)、`5xx`(服务端错误)可以重试;`401`(密钥错)、`400`(参数错)、`404`(模型不存在)重试多少次都一样,属于浪费

- **`429` 要读 `Retry-After` 响应头**,服务端已经告诉你该等多久了,自己瞎猜的退避时间可能太短

## 三、生产可用实现

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

```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

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))

```

## 四、体感指标:TTFT

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

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

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

| 指标 | 含义 | 用户感知 |

| --- | --- | --- |

| 首 token 延迟(TTFT) | 从发起请求到收到第一个字 | 「快不快」 |

| 总耗时 | 全部生成完 | 「什么时候能复制走」 |

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

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

## 五、中断与成本

还有一个成本问题容易被忽略。

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

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

```python

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 |

这份实现可以直接用,也可以按需裁剪。如果要落地到生产,建议额外补两件事:

一是把 TTFT 的 p50/p95 和总耗时**分开上报**,只看平均值会掩盖长尾;

二是给「已输出后失败」定义明确的业务处理方式——是提示用户重试,还是保留已生成的部分。这两个决定比代码本身更影响用户体验。

相关文章
|
8天前
|
JavaScript API iOS开发
DeepSeek Harness下载、安装、配置一篇搞定(2026整理,送常用插件)
DeepSeek Harness(DSH)是DeepSeek于2026年开源的MIT协议AI Agent框架,支持插件化组装模型、工具、沙箱与UI,兼容主流大模型。本文提供免Node.js的桌面端安装包(Win/macOS),含详细图文教程及热门插件推荐。
3529 34
|
7天前
|
Linux 开发工具 iOS开发
Codex保姆级教程(下载+安装+登录+使用,含所有版本,2026最新)
Codex是OpenAI推出的开源AI编程智能体,本地运行,可理解整个代码库,支持自然语言指令改代码、执行命令、跑测试、修Bug,兼容Win/macOS/Linux。(239字)
|
3天前
|
云安全 人工智能 安全
|
8天前
|
iOS开发 Docker MacOS
【2026实测】Docker Desktop下载、安装、汉化、配置教程(附官网安装包)
Docker Desktop 是 Docker 官方推出的桌面容器管理工具,集成引擎、CLI、图形界面与 Kubernetes,支持 Win/macOS/Linux。2026 最新版 4.88.1 强化 ARM 原生运行、镜像加速及资源可视化,附中文汉化与国内镜像配置指南。
1604 6
|
8天前
|
安全 算法 定位技术
《高性价比人生指南》完整原版:普通人用最少的资源,过好这一生的底层逻辑~How To Live Better
📖《高性价比人生指南》是一份基于实证、拒绝焦虑的生存手册,倡导以“人生时薪”为视角,重新计算时间、金钱与精力的成本与收益。它不承诺逆袭,只教人算清账:省下的钱若耗掉时间便亏;买体验比买物品更值;睡眠是最高杠杆的健康投资;关系贵在情绪价值而非表面热络;学习重在改变行为而非自我安慰。核心理念:用低成本、高回报的微小行动,换取长期的选择权——不是成为谁,而是掌控自己人生的主动权。
|
云安全 安全 网络安全
|
21天前
|
人工智能 API 开发工具
DeepSeek Harness 神插件:白嫖 13 家大厂模型
`dsh-our-free-model` 是 DeepSeek Harness 神级插件:零配置、零API Key、零账号,聚合13家大厂免费模型(含DeepSeek V4.1 Flash、Kimi K3等),支持本地网关、用量看板与自动降级,真正实现“白嫖即用”。
3758 55
|
13天前
|
人工智能 JSON Linux
【保姆级】ComfyUI部署教程(下载+安装+配置+工作流,2026最新)
ComfyUI 是一款开源节点式AI生成工具,支持图像、视频、音频及3D内容创作。通过拖拽连线组合模型加载、提示词、采样等节点,构建可复用、易修改的工作流,兼容SD1.5、SDXL、Flux、Qwen Image等主流模型。

热门文章

最新文章