流式响应 (SSE)
流式让首字延迟从「等完整答案」降到「等第一个 token」,长回答的体感差别是数量级的。 网关对四种协议的流式都做了完整支持,包括跨协议转换时的事件重组。
如何开启
| 协议 | 开启方式 |
|---|---|
| openai_chat / openai_responses | 请求体 "stream": true |
| anthropic_messages | 请求体 "stream": true |
| gemini_native | 用 :streamGenerateContent 动作,或在查询串上加 ?alt=sse |
注意 Gemini 的差异:它没有 stream 字段,是否流式由 URL 里的 action 决定。
网关按同样的规则判定——只要动作是 streamGenerateContent 或带 alt=sse,就走流式管线。
响应头
Content-Type: text/event-stream
Cache-Control: no-cache
上游返回的其它响应头会被透传。HTTP 状态码沿用上游的状态码—— 流式请求出错时状态码可能仍是 200,错误信息在事件流内部,这一点见下面「流中途出错」。
各协议的事件格式
OpenAI Chat Completions
每个增量是一个 data: 行,末尾以 data: [DONE] 收束:
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 细:
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 留给消息本体)。
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 = 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)
const stream = await client.chat.completions.create({
model: 'claude-sonnet-5',
messages: [{ role: 'user', content: '写一首四行小诗' }],
stream: true,
})
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? '')
}
curl -N https://www.apigoto.com/v1/chat/completions \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $APIGOTO_API_KEY" \
-d '{
"model": "claude-sonnet-5",
"messages": [{"role": "user", "content": "写一首四行小诗"}],
"stream": true
}'
-N不加 --no-buffer / -N,curl 会自己缓冲输出,看起来像「没有流式效果」。
流中途出错
流式响应的头在第一个字节发出时就已经写死了。因此一旦开始推流,就无法再改 HTTP 状态码—— 之后发生的错误只能作为事件写进流里。处理时要注意两类情况:
- 推流前失败(鉴权、限流、无可用上游、请求体错误):返回正常的 JSON 错误体, HTTP 状态码是真实的 4xx/5xx。这是绝大多数错误的形态。
- 推流中失败(上游中断、上游自身报错):状态码已经是 200, 错误以协议各自的错误事件形式出现,或者流被直接截断。
可靠的判据是有没有收到终止事件:OpenAI 系看 data: [DONE],
Anthropic 看 message_stop,Responses 看 response.completed。
没收到终止事件而流结束了,就是断流,按失败处理。
客户端主动中断
用户点「停止生成」时,直接关闭连接即可。网关感知到客户端断开会停止读取上游并释放并发槽位。
已经生成的 token 上游照收,因此中断之前产生的用量仍会计费。
想省钱应该靠合理设置 max_tokens,而不是靠中途掐断。
部署时的缓冲陷阱
流式最常见的「线上不工作、本地正常」,九成是中间层把响应缓冲住了。自建反向代理时检查:
| 组件 | 需要的配置 |
|---|---|
| nginx | proxy_buffering off; 且 proxy_read_timeout 调长 |
| APISIX / Kong | 关闭响应缓冲,不要挂会读全响应体的插件 |
| CDN | SSE 路径必须绕过缓存与压缩 |
| 应用层网关 | 逐块写出后立即 flush,不要等 writer 自己攒满 |
单行长度上限
网关按行扫描上游 SSE,单行上限 1 MiB。正常的增量事件远小于此。
只有当上游把整段超大 JSON 塞进一个 data: 行时才可能触碰——实践中未见。