流式传输失败很少是模型本身的问题——问题通常出在您与模型之间的连接链路上。空闲超时代理会杀死安静的连接,nginx 缓冲会吞掉事件,而只消费了一半的流看起来就像“API 停止响应”。先排查连接链路。
错误
- Stream stops mid-sentence, connection closed (no error event)
- Client hangs after the last token, never sees [DONE]
- usage is null on streamed responses
- Works in curl, dies behind nginx / a corporate proxy原因和解决方法一览
| 原因 | 解决方法 |
|---|---|
| 代理/负载均衡器空闲超时(许多配置默认为 60 秒) | 提高 API 路径的读取超时时间;长时间的思考暂停对代理来说看起来像空闲。 |
| SSE 前的缓冲(nginx proxy_buffering、某些 CDN) | 为流式传输路由禁用缓冲(X-Accel-Buffering: no / proxy_buffering off)。 |
| 客户端停止消费(缺少 await,迭代器被丢弃) | 消费到流结束,或显式关闭流——在流式传输中途被垃圾回收的迭代器,与流被截断无法区分。 |
| 期望获得使用量,却没有请求它 | OpenAI-wire:传递 stream_options: {"include_usage": true}——使用量会出现在最后一个数据块中。 |
使用 curl -N 直接连接 API 复现
绕过所有代理。如果原始 SSE 能完整生成,说明问题出在应用路径——逐个重新加入中间环节:
curl -N https://api.kunavo.com/v1/chat/completions \
-H "Authorization: Bearer $KUNAVO_API_KEY" \
-H "content-type: application/json" \
-d '{"model":"claude-sonnet-5","stream":true,
"stream_options":{"include_usage":true},
"max_tokens":300,
"messages":[{"role":"user","content":"Count slowly to 20 in words."}]}'修复导致中断的环节
nginx:为该路由设置 proxy_buffering off + proxy_read_timeout 300s。无服务器环境:检查平台对响应流式传输的限制。企业代理:有些完全不支持 SSE——在这些环境中回退到非流式传输。
正确处理尾部
流式计费数据最后到达:最后一个数据块会携带使用量(如果已请求),然后才是 [DONE]。汇总增量,从最后一个数据块读取使用量,并将提前关闭(没有 finish_reason)视为可重试。
“SSE stream ended without [DONE]”——答案完整吗?
这条消息是客户端自己的检查结果,并不是 API 发送的错误:连接在收到 OpenAI 兼容流结束时使用的 data: [DONE] 行之前关闭了。原因有三个:中间环节关闭了连接(前面提到的代理超时和缓冲问题);服务器在回答中途失败,并在没有终止帧的情况下关闭;或者答案已经完成,只是哨兵标记丢失。您收到的最后一个数据块可以决定是哪一种:其中有 finish_reason 表示文本完整,没有 finish_reason 表示答案被截断,应当重试。不要把这项检查交给 SDK——当连接在没有 [DONE] 的情况下关闭时,OpenAI Python SDK 会安静地结束循环,只有在某个数据块携带错误对象时才会抛出异常。请在自己的代码中执行检查:
from openai import OpenAI
client = OpenAI(base_url="https://api.kunavo.com/v1", api_key="sk-kn-...")
finish_reason, parts = None, []
stream = client.chat.completions.create(
model="claude-sonnet-5",
messages=[{"role": "user", "content": "Count slowly to 20 in words."}],
stream=True,
)
for chunk in stream: # raises openai.APIError on a chunk that carries "error"
for choice in chunk.choices:
parts.append(choice.delta.content or "")
finish_reason = choice.finish_reason or finish_reason
if finish_reason is None:
raise RuntimeError("stream closed without a finish_reason: cut off, retry it")
print("".join(parts))空流:200,然后什么都没有
有时流会以 HTTP 200 打开,但在没有任何内容数据块的情况下结束——没有文本、没有工具调用,有时甚至没有 role 数据块。这几乎总是上游在响应头发出后失败:提供商过载、网关自身的上游拒绝请求,或代理丢弃了响应正文。将其视为流被截断,并采用退避重试;同时记录一次失败响应的原始正文,因为原因通常是流中存在 SDK 跳过的错误事件。Claude Code 遇到相同情况时,会在不使用流式传输的情况下重试请求。
如果你通过 Kunavo 调用
Kunavo 在 /v1/messages 上提供标准 OpenAI-wire SSE(支持 stream_options.include_usage)和 Anthropic-wire 事件,因此上面的 curl 复现也是兼容性测试。对于 /v1/chat/completions 上的 Claude 和 GPT 模型,无论答案是否完成,Kunavo 流都会以 data: [DONE] 结束:答案完成时,该标记位于 finish_reason 数据块之后;上游在答案中途断开时,该标记位于错误数据块之后——错误类型为 upstream_error,代码为 upstream_disconnect 或 upstream_timeout。这样,OpenAI SDK 会抛出 APIError,而不会把片段交给您作为完整回复。上游流在任何内容之前结束,或在第一个令牌之前失败时,不会以空的 200 响应到达您:如果该模型有其他渠道,系统会在另一个渠道重试;否则返回 HTTP 错误。在任何输出到达您之前失败的流式请求不会计费;中途中断的请求则不保证免费——请重新发送,不要假定它无需付费。
常见问题
为什么我的流式响应中的 usage 是 null?
在 OpenAI-wire API 中,除非传递 stream_options: {"include_usage": true},否则流中不会包含 usage;传递后,usage 会出现在最后一个数据块中。Anthropic 的原生流会在 message_start/message_delta 事件中报告 usage。
如何检测流是被截断了,还是已经完成?
完成的流会以 finish_reason(或 Anthropic 的 message_stop)结束,然后是 [DONE]。连接在没有这些标记的情况下关闭,说明流被截断——将其视为可重试的失败,而不是较短的答案。
“SSE stream ended without [DONE]”是什么意思?
您的客户端读取流直到连接结束,却始终没有看到用于关闭 OpenAI 兼容流的 data: [DONE] 行。这条消息不是 API 发送的,而是由您的客户端或代理写入的。如果最后一个数据块携带 finish_reason,答案就是完整的,只是关闭标记丢失了。如果没有携带,答案被代理、超时或服务器端故障截断,应当重试请求。
“stream ended without finish_reason”是什么意思?
客户端读取流直到结束,但没有任何数据块携带 finish_reason——这是 OpenAI 兼容流用来表示答案已完成的字段(stop、length、tool_calls)。没有该字段,您获得的文本就是片段,无论最后一句看起来多么自然。请重试请求;如果问题重复,请检查您与 API 之间是否存在代理超时或缓冲。
为什么 LLM API 会返回空流?
因为失败发生在 HTTP 响应头发送之后:提供商过载、网关的上游拒绝请求,或代理丢弃了响应正文。状态行仍然显示 200,因此仅检查状态无法发现问题。将不包含任何内容数据块的流视为失败请求,采用退避重试,并记录一条原始响应,以找出其中的错误事件。
如果我已经获得文本,可以安全地忽略缺失的 [DONE] 吗?
只有最后一个数据块携带 finish_reason 时才可以。没有该字段,您获得的文本就是片段,可能在句子中间或工具调用中间结束,JSON 参数也可能不完整。请重试,不要将其保存为答案。