# 流式响应

来源: https://platform.lyrouter.com/ai_gateway/docs/streaming
更新日期: 2026-10-22

请求中设置 `"stream": true` 后，Chat Completions、Responses 和 Anthropic Messages 三种对话接口都以 SSE（Server-Sent Events）逐段返回内容。三种协议的事件格式、结束标记和 `usage` 的位置各不相同，客户端要按所用协议解析，并以协议的结束标记判断输出是否完整。本章讲协议格式与客户端实现；流式中断、超时的排查见[超时与流式中断](https://platform.lyrouter.com/ai_gateway/docs/stream-errors)。

## 开启流式

| 接口 | 开启方式 | 响应 `Content-Type` |
| --- | --- | --- |
| [Chat Completions](https://platform.lyrouter.com/ai_gateway/docs/chat-completions)（`/v1/chat/completions`） | `"stream": true` | `text/event-stream` |
| [Responses](https://platform.lyrouter.com/ai_gateway/docs/responses-api)（`/v1/responses`） | `"stream": true` | `text/event-stream` |
| [Anthropic Messages](https://platform.lyrouter.com/ai_gateway/docs/anthropic-messages)（`/v1/messages`） | `"stream": true` | `text/event-stream` |

- 请求在开始输出之前就被拒绝时（鉴权失败、余额不足、模型不可用等），返回的是普通的 HTTP 错误响应，不是事件流：网关拒绝的请求是 JSON 错误体（`/v1/messages` 为 Anthropic 错误格式），500 / 504 是 HTML 页面。解析事件流之前先检查状态码和 `Content-Type`。
- 流式输出一旦开始，网关不再换通道；上游失败时的处理见下文「中途失败」。

## SSE 的基本格式

一个事件由若干行组成，以一个空行结束：

```text
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]` 结束：

```text
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` 拼接，见[工具调用](https://platform.lyrouter.com/ai_gateway/docs/tool-call) |
| 结束原因 | 倒数几个 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` 不含缓存命中，缓存数量请在[请求日志](https://platform.lyrouter.com/ai_gateway/docs/logs)中查看。
- 字段的含义与日志四项 Token 的对应关系见 [Chat Completions](https://platform.lyrouter.com/ai_gateway/docs/chat-completions)。

## Responses

每个事件带事件名，`data` 中的 `type` 与事件名相同，`sequence_number` 递增。每个事件前还有一行 `id:`，下面的片段省略了它：

```text
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` | 函数调用参数增量 / 完整参数，见[工具调用](https://platform.lyrouter.com/ai_gateway/docs/tool-call) |
| `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

```text
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` 不含缓存读取和缓存写入，计算方法见[调用费用与缓存计费](https://platform.lyrouter.com/ai_gateway/docs/estimate)。

## 结束标记与 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` |

没有收到正常结束标记就断开的流，一律按失败处理。

## 中途失败

流式输出开始后上游中断或超时，网关不换通道，在流的末尾补一个错误事件后结束连接：

```text
data: {"error": {"message": "upstream timed out after the response had started", "type": "api_error", "code": "stream_interrupted"}}
```

```text
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` 接着流中已发出的事件编号：

```text
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，原因和重试建议见[超时与流式中断](https://platform.lyrouter.com/ai_gateway/docs/stream-errors)。

## 客户端实现要点

- **按完整事件解析**：累积数据、遇到空行再处理；拼接多行 `data:`；忽略注释行和不认识的事件；`data: [DONE]` 不是 JSON，要先判断再解析。
- **以结束标记判断完整**：收到结束标记才把结果当作完整输出；收到错误事件或连接提前关闭，都按失败处理，重试前做好去重（例如已经执行过的工具操作）。
- **超时**：网关等待上游时，两段数据之间最多等 6 分钟。客户端的读取超时设得比 6 分钟长，并优先使用两次读取之间的空闲超时，而不是整次请求的总超时。
- **代理不缓冲**：应用与 LYRouter 之间有 Nginx 等反向代理时，关闭响应缓冲（Nginx 设置 `proxy_buffering off`），并把代理的读超时设得比 6 分钟长，否则内容会攒成一大块到达，或被代理提前断开。
- **命令行测试**：`curl` 加 `-N` 关闭输出缓冲，才能看到逐段到达的事件。

下面是不依赖 SDK、手动解析 Chat Completions 事件流的写法，可作为其他语言实现的参考：

**流式请求示例**

cURL:
```bash
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":"写两行关于秋天的诗"}]}'
```

Python:
```python
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("流在结束标记之前中断，输出不完整")
```

Node.js:
```js
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](https://platform.lyrouter.com/ai_gateway/docs/chat-completions) 的流式示例；Responses 与 Anthropic SDK 的流式写法见 [Responses](https://platform.lyrouter.com/ai_gateway/docs/responses-api) 和 [Anthropic Messages](https://platform.lyrouter.com/ai_gateway/docs/anthropic-messages)。

## 常见问题

### 最后一个 chunk 的 `choices` 为什么是空数组？

**答：** 网关总会开启 `include_usage`，上游在结束前单独发送一个只带 `usage` 的 chunk，它的 `choices` 为空。代码中直接读取 `choices[0]` 会出错，请先判断数组非空。这个 chunk 无法关闭。

### `stream: true` 却收到了一个 JSON 错误，而不是事件流？

**答：** 请求在开始输出之前就被拒绝了，例如鉴权失败、余额不足或模型不可用，网关按普通响应返回状态码和错误体。按状态码处理，见[错误码与请求标识](https://platform.lyrouter.com/ai_gateway/docs/errors)。

### 流式输出很久才一次性出现？

**答：** 通常是中间的代理缓冲了响应。用 `curl -N` 直接请求 LYRouter 对比；直连正常、经过代理不正常时，关闭代理的响应缓冲。如果直连也要等很久才有第一段内容，在[请求日志](https://platform.lyrouter.com/ai_gateway/docs/logs)中查看「首 Token」，见[超时与流式中断](https://platform.lyrouter.com/ai_gateway/docs/stream-errors)。

### 没有收到结束标记，已经收到的内容能用吗？

**答：** 不能当作完整结果。网关在中途失败时补发错误事件，上游直接断开时可能连错误事件都没有。请把这次调用视为失败，按需重试，并处理好重复内容和已执行工具的去重。
