流式响应
请求中设置 "stream": true 后,Chat Completions、Responses 和 Anthropic Messages 三种对话接口都以 SSE(Server-Sent Events)逐段返回内容。三种协议的事件格式、结束标记和 usage 的位置各不相同,客户端要按所用协议解析,并以协议的结束标记判断输出是否完整。本章讲协议格式与客户端实现;流式中断、超时的排查见超时与流式中断。
开启流式
| 接口 | 开启方式 | 响应 Content-Type |
|---|---|---|
Chat Completions(/v1/chat/completions) |
"stream": true |
text/event-stream |
Responses(/v1/responses) |
"stream": true |
text/event-stream |
Anthropic Messages(/v1/messages) |
"stream": true |
text/event-stream |
- 请求在开始输出之前就被拒绝时(鉴权失败、余额不足、模型不可用等),返回的是普通的 HTTP 错误响应,不是事件流:网关拒绝的请求是 JSON 错误体(
/v1/messages为 Anthropic 错误格式),500 / 504 是 HTML 页面。解析事件流之前先检查状态码和Content-Type。 - 流式输出一旦开始,网关不再换通道;上游失败时的处理见下文「中途失败」。
SSE 的基本格式
一个事件由若干行组成,以一个空行结束:
event: <事件名>
data: <JSON 或文本>
data:行是事件内容;同一事件有多行data:时,按换行拼接。Chat Completions 只有data:行,Responses 和 Anthropic Messages 还有event:行给出事件名。- 冒号后的空格可有可无:
data: {...}与data:{...}按 SSE 规范都合法,实际的流中两种都会出现,自己写解析器时要兼容。 - 还可能有
id:等其他字段行,例如 Responses 流的每个事件前都有一行id:,不需要时忽略即可。 - 以
:开头的行是注释,部分上游用它保持连接,解析时忽略。 - 网络上的一个数据块可能只包含半个事件,也可能包含多个事件。请把数据累积到缓冲区,遇到空行再处理一个完整事件。
Chat Completions
每个事件是一行 data: <chat.completion.chunk>,最后以 data: [DONE] 结束:
data: {"id":"chatcmpl-9f1c2a7e4b","object":"chat.completion.chunk","created":1791950400,"model":"MODEL_ID","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]}
data: {"id":"chatcmpl-9f1c2a7e4b","object":"chat.completion.chunk","created":1791950400,"model":"MODEL_ID","choices":[{"index":0,"delta":{"content":"秋风起,"},"finish_reason":null}]}
data: {"id":"chatcmpl-9f1c2a7e4b","object":"chat.completion.chunk","created":1791950400,"model":"MODEL_ID","choices":[{"index":0,"delta":{"content":"落叶黄。"},"finish_reason":null}]}
data: {"id":"chatcmpl-9f1c2a7e4b","object":"chat.completion.chunk","created":1791950400,"model":"MODEL_ID","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
data: {"id":"chatcmpl-9f1c2a7e4b","object":"chat.completion.chunk","created":1791950400,"model":"MODEL_ID","choices":[],"usage":{"prompt_tokens":18,"completion_tokens":12,"total_tokens":30,"prompt_tokens_details":{"cached_tokens":0}}}
data: [DONE]
| 内容 | 位置 |
|---|---|
| 正文增量 | choices[0].delta.content |
| 思考过程增量 | choices[0].delta.reasoning_content(个别上游为 delta.reasoning) |
| 工具调用增量 | choices[0].delta.tool_calls,按 index 拼接,见工具调用 |
| 结束原因 | 倒数几个 chunk 中的 choices[0].finish_reason |
| 用量 | choices 为空数组的那个 chunk 的 usage |
| 结束标记 | data: [DONE] |
- 网关总会把
stream_options设为{"include_usage": true},所以一定有带usage的 chunk,它的choices是空数组。读取choices[0]前先判断数组非空。 - 由 Anthropic 协议通道服务时,chunk 由网关转换生成:带
usage的 chunk 只有prompt_tokens、completion_tokens、total_tokens,其中prompt_tokens不含缓存命中,缓存数量请在请求日志中查看。 - 字段的含义与日志四项 Token 的对应关系见 Chat Completions。
Responses
每个事件带事件名,data 中的 type 与事件名相同,sequence_number 递增。每个事件前还有一行 id:,下面的片段省略了它:
event: response.created
data: {"type":"response.created","sequence_number":0,"response":{"id":"resp_68f1a2b3","object":"response","created_at":1791950400,"status":"queued","model":"MODEL_ID","output":[],"usage":null}}
event: response.output_item.added
data: {"type":"response.output_item.added","sequence_number":2,"output_index":0,"item":{"id":"msg_68f1a2c4","type":"message","status":"in_progress","role":"assistant","content":[]}}
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":4,"item_id":"msg_68f1a2c4","output_index":0,"content_index":0,"delta":"秋风起,"}
event: response.output_text.delta
data: {"type":"response.output_text.delta","sequence_number":5,"item_id":"msg_68f1a2c4","output_index":0,"content_index":0,"delta":"落叶黄。"}
event: response.output_text.done
data: {"type":"response.output_text.done","sequence_number":6,"item_id":"msg_68f1a2c4","output_index":0,"content_index":0,"text":"秋风起,落叶黄。"}
event: response.completed
data: {"type":"response.completed","sequence_number":9,"response":{"id":"resp_68f1a2b3","object":"response","status":"completed","model":"MODEL_ID","output":[{"id":"msg_68f1a2c4","type":"message","status":"completed","role":"assistant","content":[{"type":"output_text","text":"秋风起,落叶黄。","annotations":[]}]}],"usage":{"input_tokens":18,"input_tokens_details":{"cached_tokens":0},"output_tokens":12,"output_tokens_details":{"reasoning_tokens":0},"total_tokens":30}}}
| 事件 | 含义 |
|---|---|
response.created、response.in_progress |
响应已创建、正在生成。response.created 中的 status 可能是 queued |
response.output_item.added / .done |
一个输出项开始 / 结束,类型为 message、reasoning、function_call 等 |
response.content_part.added / .done |
消息中的一个内容块开始 / 结束 |
response.output_text.delta / .done |
文本增量(delta)/ 该内容块的完整文本(text) |
response.reasoning_text.delta / .done |
推理文本增量 / 完整推理文本,推理模型会返回 |
response.function_call_arguments.delta / .done |
函数调用参数增量 / 完整参数,见工具调用 |
response.completed |
正常结束,response.usage 是本次用量 |
response.incomplete |
未完整生成就结束,例如达到 max_output_tokens |
response.failed、error |
生成失败、上游报告错误 |
- 网关原样转发通道返回的 Responses 事件,不追加
stream_options,也通常没有data: [DONE]。 - 用量在
response.completed的response.usage中:input_tokens包含缓存命中,命中数量在input_tokens_details.cached_tokens。
Anthropic Messages
event: message_start
data: {"type":"message_start","message":{"id":"msg_01XFDUDYJgAACzvnptvVoYEL","type":"message","role":"assistant","model":"MODEL_ID","content":[],"usage":{"input_tokens":18,"output_tokens":0}}}
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_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":{"input_tokens":18,"output_tokens":12}}
event: message_stop
data: {"type":"message_stop"}
| 事件 | 含义 |
|---|---|
message_start |
消息开始,message.usage 中有输入 Token 数 |
content_block_start |
一个内容块开始,类型为 text、thinking 或 tool_use |
content_block_delta |
增量:text_delta(正文)、thinking_delta(思考)、signature_delta(思考块签名)、input_json_delta(工具参数) |
content_block_stop |
内容块结束 |
message_delta |
delta.stop_reason(end_turn、max_tokens、tool_use 等)和最终 usage |
message_stop |
正常结束 |
ping |
保持连接,忽略即可 |
error |
错误 |
- 以
message_delta中的usage为准。由 OpenAI 兼容通道服务时,事件由网关转换生成,message_start中的input_tokens为 0,最终数量只在message_delta中。有缓存读取或写入时,message_delta.usage还分别带cache_read_input_tokens、cache_creation_input_tokens。 input_tokens不含缓存读取和缓存写入,计算方法见调用费用与缓存计费。
结束标记与 usage 一览
| 接口 | 正常结束 | usage 在哪里 | 中途失败的错误事件 |
|---|---|---|---|
| Chat Completions | data: [DONE] |
choices 为空数组的 chunk |
data: {"error": {...}} |
| Responses | response.completed 事件 |
response.completed 的 response.usage |
response.failed 事件 |
| Anthropic Messages | message_stop 事件 |
message_delta 的 usage |
event: error |
没有收到正常结束标记就断开的流,一律按失败处理。
中途失败
流式输出开始后上游中断或超时,网关不换通道,在流的末尾补一个错误事件后结束连接:
data: {"error": {"message": "upstream timed out after the response had started", "type": "api_error", "code": "stream_interrupted"}}
event: error
data: {"type": "error", "error": {"type": "api_error", "message": "upstream timed out after the response had started"}}
- 第一种用于 Chat Completions,第二种用于 Anthropic Messages。Responses 补的是
response.failed事件,response.error.code为server_error,sequence_number接着流中已发出的事件编号:
event: response.failed
data: {"type": "response.failed", "sequence_number": 42, "response": {"id": "resp_...", "object": "response", "status": "failed", "error": {"code": "server_error", "message": "upstream timed out after the response had started"}}}
- 超时时
message为upstream timed out after the response had started,连接或读取失败时为upstream failed after the response had started。 - 出现错误事件后不会再有
data: [DONE]、message_stop或response.completed。上游自己在流中发出的错误事件(例如 Anthropic 的overloaded_error)会按你调用的协议转给你。 - OpenAI、Anthropic 官方 SDK 收到对应格式的错误事件会抛出异常。日志中这类请求的状态是 200,原因和重试建议见超时与流式中断。
客户端实现要点
- 按完整事件解析:累积数据、遇到空行再处理;拼接多行
data:;忽略注释行和不认识的事件;data: [DONE]不是 JSON,要先判断再解析。 - 以结束标记判断完整:收到结束标记才把结果当作完整输出;收到错误事件或连接提前关闭,都按失败处理,重试前做好去重(例如已经执行过的工具操作)。
- 超时:网关等待上游时,两段数据之间最多等 6 分钟。客户端的读取超时设得比 6 分钟长,并优先使用两次读取之间的空闲超时,而不是整次请求的总超时。
- 代理不缓冲:应用与 LYRouter 之间有 Nginx 等反向代理时,关闭响应缓冲(Nginx 设置
proxy_buffering off),并把代理的读超时设得比 6 分钟长,否则内容会攒成一大块到达,或被代理提前断开。 - 命令行测试:
curl加-N关闭输出缓冲,才能看到逐段到达的事件。
下面是不依赖 SDK、手动解析 Chat Completions 事件流的写法,可作为其他语言实现的参考:
curl -N https://api.lyrouter.com/v1/chat/completions \
-H "Authorization: Bearer $LYROUTER_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model":"MODEL_ID","stream":true,"messages":[{"role":"user","content":"写两行关于秋天的诗"}]}'
import json
import os
import requests
resp = requests.post(
"https://api.lyrouter.com/v1/chat/completions",
headers={"Authorization": f"Bearer {os.environ['LYROUTER_API_KEY']}"},
json={
"model": "MODEL_ID",
"stream": True,
"messages": [{"role": "user", "content": "写两行关于秋天的诗"}],
},
stream=True,
timeout=(10, 400), # 连接超时 10 秒;两次读取之间最多等 400 秒
)
resp.raise_for_status()
resp.encoding = "utf-8"
done = False
data_lines = []
for line in resp.iter_lines(decode_unicode=True):
if line.startswith(":"):
continue # 注释行
if line.startswith("data:"):
data_lines.append(line[5:].lstrip())
continue
if line != "" or not data_lines:
continue # event: 等其他字段,或多余的空行
data = "\n".join(data_lines) # 空行:一个完整事件
data_lines = []
if data == "[DONE]":
done = True
break
event = json.loads(data)
if "error" in event:
raise RuntimeError(event["error"].get("message"))
for choice in event.get("choices", []):
print(choice.get("delta", {}).get("content") or "", end="", flush=True)
if event.get("usage"):
print("\n", event["usage"])
if not done:
raise RuntimeError("流在结束标记之前中断,输出不完整")
const resp = await fetch("https://api.lyrouter.com/v1/chat/completions", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.LYROUTER_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "MODEL_ID",
stream: true,
messages: [{ role: "user", content: "写两行关于秋天的诗" }],
}),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);
const decoder = new TextDecoder();
let buffer = "";
let done = false;
for await (const bytes of resp.body) {
buffer = (buffer + decoder.decode(bytes, { stream: true })).replace(/\r\n/g, "\n");
let end;
while (!done && (end = buffer.indexOf("\n\n")) !== -1) {
const block = buffer.slice(0, end); // 一个完整事件
buffer = buffer.slice(end + 2);
const data = block
.split("\n")
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice(5).trimStart())
.join("\n");
if (!data) continue; // 注释或其他字段
if (data === "[DONE]") {
done = true;
break;
}
const event = JSON.parse(data);
if (event.error) throw new Error(event.error.message);
const text = event.choices?.[0]?.delta?.content;
if (text) process.stdout.write(text);
if (event.usage) console.log("\n", event.usage);
}
if (done) break;
}
if (!done) throw new Error("流在结束标记之前中断,输出不完整");
使用官方 SDK 时,SDK 已经处理了事件解析和结束标记,用法见 Chat Completions 的流式示例;Responses 与 Anthropic SDK 的流式写法见 Responses 和 Anthropic Messages。
常见问题
最后一个 chunk 的 choices 为什么是空数组?
答: 网关总会开启 include_usage,上游在结束前单独发送一个只带 usage 的 chunk,它的 choices 为空。代码中直接读取 choices[0] 会出错,请先判断数组非空。这个 chunk 无法关闭。
stream: true 却收到了一个 JSON 错误,而不是事件流?
答: 请求在开始输出之前就被拒绝了,例如鉴权失败、余额不足或模型不可用,网关按普通响应返回状态码和错误体。按状态码处理,见错误码与请求标识。
流式输出很久才一次性出现?
答: 通常是中间的代理缓冲了响应。用 curl -N 直接请求 LYRouter 对比;直连正常、经过代理不正常时,关闭代理的响应缓冲。如果直连也要等很久才有第一段内容,在请求日志中查看「首 Token」,见超时与流式中断。
没有收到结束标记,已经收到的内容能用吗?
答: 不能当作完整结果。网关在中途失败时补发错误事件,上游直接断开时可能连错误事件都没有。请把这次调用视为失败,按需重试,并处理好重复内容和已执行工具的去重。