流式响应 (SSE)

流式让首字延迟从「等完整答案」降到「等第一个 token」,长回答的体感差别是数量级的。 网关对四种协议的流式都做了完整支持,包括跨协议转换时的事件重组。

如何开启

协议开启方式
openai_chat / openai_responses请求体 "stream": true
anthropic_messages请求体 "stream": true
gemini_native :streamGenerateContent 动作,或在查询串上加 ?alt=sse

注意 Gemini 的差异:它没有 stream 字段,是否流式由 URL 里的 action 决定。 网关按同样的规则判定——只要动作是 streamGenerateContent 或带 alt=sse,就走流式管线。

响应头

response headers
Content-Type: text/event-stream
Cache-Control: no-cache

上游返回的其它响应头会被透传。HTTP 状态码沿用上游的状态码—— 流式请求出错时状态码可能仍是 200,错误信息在事件流内部,这一点见下面「流中途出错」。

各协议的事件格式

OpenAI Chat Completions

每个增量是一个 data: 行,末尾以 data: [DONE] 收束:

openai_chat stream
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant"},"finish_reason":null}]}

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"你"},"finish_reason":null}]}

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"好"},"finish_reason":null}]}

data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}

data: [DONE]

Anthropic Messages

带具名事件类型,结构比 OpenAI 细:

anthropic_messages stream
event: message_start
data: {"type":"message_start","message":{"id":"msg_...","role":"assistant","content":[]}}

event: content_block_start
data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}

event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"你好"}}

event: content_block_stop
data: {"type":"content_block_stop","index":0}

event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":12}}

event: message_stop
data: {"type":"message_stop"}

OpenAI Responses

事件按 response.* 命名,输出以 item / content part 为单位组织。 工具调用会作为独立的 output item 出现在 output_index 1 及之后(index 0 留给消息本体)。

openai_responses stream
event: response.created
data: {"type":"response.created","response":{"id":"resp_...","status":"in_progress"}}

event: response.output_text.delta
data: {"type":"response.output_text.delta","output_index":0,"delta":"你好"}

event: response.completed
data: {"type":"response.completed","response":{"id":"resp_...","status":"completed"}}

data: [DONE]

Gemini

?alt=sse 时每个 data: 行是一个完整的 GenerateContentResponse 片段; 不带 alt=sse 时上游返回的是 JSON 数组流。建议一律加 alt=sse,解析简单得多。

客户端写法

stream.py
stream = client.chat.completions.create(
    model="claude-sonnet-5",
    messages=[{"role": "user", "content": "写一首四行小诗"}],
    stream=True,
)

for chunk in stream:
    delta = chunk.choices[0].delta.content
    if delta:
        print(delta, end="", flush=True)
💡
curl 记得加 -N

不加 --no-buffer / -N,curl 会自己缓冲输出,看起来像「没有流式效果」。

流中途出错

流式响应的头在第一个字节发出时就已经写死了。因此一旦开始推流,就无法再改 HTTP 状态码—— 之后发生的错误只能作为事件写进流里。处理时要注意两类情况:

⚠️
不要只看 HTTP 状态码判断流式请求是否成功

可靠的判据是有没有收到终止事件:OpenAI 系看 data: [DONE], Anthropic 看 message_stop,Responses 看 response.completed。 没收到终止事件而流结束了,就是断流,按失败处理。

客户端主动中断

用户点「停止生成」时,直接关闭连接即可。网关感知到客户端断开会停止读取上游并释放并发槽位。

💰
中断不等于免费

已经生成的 token 上游照收,因此中断之前产生的用量仍会计费。 想省钱应该靠合理设置 max_tokens,而不是靠中途掐断。

部署时的缓冲陷阱

流式最常见的「线上不工作、本地正常」,九成是中间层把响应缓冲住了。自建反向代理时检查:

组件需要的配置
nginxproxy_buffering off;proxy_read_timeout 调长
APISIX / Kong关闭响应缓冲,不要挂会读全响应体的插件
CDNSSE 路径必须绕过缓存与压缩
应用层网关逐块写出后立即 flush,不要等 writer 自己攒满

单行长度上限

网关按行扫描上游 SSE,单行上限 1 MiB。正常的增量事件远小于此。 只有当上游把整段超大 JSON 塞进一个 data: 行时才可能触碰——实践中未见。