流式输出
通过 SSE 逐段接收模型输出:开启方式、事件顺序、用量分片、结算后发出的结束帧、断线恢复,以及如何测量首字延迟与 TPS。
流式输出让你在模型生成的同时逐段收到文本,适合聊天界面与长文本生成。TokenToSea 以 Server-Sent Events(SSE)原样转发厂商的流式帧,但会把结束帧保留到结算完成后再发出。本页说明这一行为对客户端的影响。
前提:已创建密钥并把它设置为环境变量 TOKENTOSEA_API_KEY。示例使用 curl、Python requests 或 Node.js 18+。
支持范围#
| 接口 | 开启方式 | 帧格式 |
|---|---|---|
POST /v1/chat/completions | "stream": true | OpenAI Chat Completions 分片,以 data: [DONE] 结束 |
POST /v1/messages | "stream": true | Anthropic Messages 事件,以 message_stop 结束 |
POST /v1/responses | "stream": true | Responses 事件,以 response.completed 结束 |
POST /v1/invoke | "stream": true | 厂商原生帧(如千问深度研究) |
- 只有
response_modes中sse为true的型号与接口可以流式调用,否则返回422 response_mode_not_verified。 - 少数型号只支持流式(
json为false),不带"stream": true时返回422 streaming_required。 - 图像、视频、语音合成、语音识别、向量等接口不提供流式输出。
发起流式请求#
curl -N https://api.tokentosea.com/v1/chat/completions \
-H "Authorization: Bearer $TOKENTOSEA_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"model": "qwen3.8-flash",
"messages": [{"role": "user", "content": "用 200 字介绍长城。"}],
"max_tokens": 600,
"stream": true
}'import json
import os
import time
import uuid
import requests
key = str(uuid.uuid4())
started = time.monotonic()
first = last = None
usage = None
with requests.post(
"https://api.tokentosea.com/v1/chat/completions",
headers={
"Authorization": f"Bearer {os.environ['TOKENTOSEA_API_KEY']}",
"Idempotency-Key": key,
},
json={
"model": "qwen3.8-flash",
"messages": [{"role": "user", "content": "用 200 字介绍长城。"}],
"max_tokens": 600,
"stream": True,
},
stream=True,
timeout=(10, 1300),
) as response:
if response.status_code != 200:
raise SystemExit(f"{response.status_code} {response.text}")
response.encoding = "utf-8" # text/event-stream 未声明字符集,需显式指定
event = None
for line in response.iter_lines(chunk_size=None, decode_unicode=True):
if not line:
event = None
continue
if line.startswith("event:"):
event = line[6:].strip()
continue
if not line.startswith("data:"):
continue
data = line[5:].strip()
if event == "error":
raise SystemExit(f"流在结算前中断,请查询 /v1/results/{key}:{data}")
if data == "[DONE]":
break
chunk = json.loads(data)
if chunk.get("usage"):
usage = chunk["usage"]
for choice in chunk.get("choices", []):
text = (choice.get("delta") or {}).get("content")
if text:
now = time.monotonic()
first = first or now
last = now
print(text, end="", flush=True)
print()
if first and usage:
print(f"首字延迟 {first - started:.2f}s")
if last > first:
print(f"TPS {usage['completion_tokens'] / (last - first):.1f}")import { randomUUID } from 'node:crypto';
const key = randomUUID();
const started = performance.now();
let first, last, usage;
const response = await fetch('https://api.tokentosea.com/v1/chat/completions', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.TOKENTOSEA_API_KEY}`,
'Content-Type': 'application/json',
'Idempotency-Key': key,
},
body: JSON.stringify({
model: 'qwen3.8-flash',
messages: [{ role: 'user', content: '用 200 字介绍长城。' }],
max_tokens: 600,
stream: true,
}),
});
if (response.status !== 200) throw new Error(`${response.status} ${await response.text()}`);
const decoder = new TextDecoder();
let buffer = '';
let done = false;
for await (const bytes of response.body) {
buffer = (buffer + decoder.decode(bytes, { stream: true })).replace(/\r\n/g, '\n');
let end;
while (!done && (end = buffer.indexOf('\n\n')) !== -1) {
const frame = buffer.slice(0, end);
buffer = buffer.slice(end + 2);
const event = /^event:\s*(.*)$/m.exec(frame)?.[1];
const data = frame.split('\n').filter((l) => l.startsWith('data:')).map((l) => l.slice(5).trim()).join('\n');
if (event === 'error') throw new Error(`流在结算前中断,请查询 /v1/results/${key}:${data}`);
if (!data) continue;
if (data === '[DONE]') { done = true; break; }
const chunk = JSON.parse(data);
if (chunk.usage) usage = chunk.usage;
for (const choice of chunk.choices ?? []) {
const text = choice.delta?.content;
if (!text) continue;
first ??= performance.now();
last = performance.now();
process.stdout.write(text);
}
}
if (done) break;
}
console.log();
if (first && usage) {
console.log(`首字延迟 ${((first - started) / 1000).toFixed(2)}s`);
if (last > first) console.log(`TPS ${(usage.completion_tokens / ((last - first) / 1000)).toFixed(1)}`);
}使用官方 SDK 的流式写法见用官方 SDK 接入。
事件顺序与结束帧#
平台在转发过程中实时统计厂商报告的用量,并在流结束时完成扣费。为确保“收到结束帧 = 已完成结算”,以下结束帧会被暂存,结算成功后才发出:
| 接口 | 被暂存的帧 |
|---|---|
| Chat Completions | 带 finish_reason 的分片,以及 data: [DONE] |
| Messages | message_stop |
| Responses | response.completed |
由此带来三点客户端需要注意的行为:
- 用量分片可能先于结束分片到达。 Chat Completions 流式请求会被平台加上
stream_options.include_usage = true,带usage、choices为空的分片不属于结束帧,会立即转发,因此可能出现在带finish_reason的分片之前。不要假设usage一定在最后。 - 最后一段等待包含结算时间。 最后一个内容分片之后,结束帧通常会稍后才到达。
- 测量速度时用内容分片的时间。 首字延迟用“第一个内容分片到达时间 − 请求发出时间”;TPS 用“
completion_tokens÷(最后一个内容分片时间 − 第一个内容分片时间)”。不要用流结束时间计算,否则会把结算时间算进去。
流中途出错#
如果厂商的流不完整、出现错误事件,或平台没能完成结算,平台不会发出结束帧,而是用一个 SSE 错误事件结束响应:
SSE
event: error
data: {"error":{"code":"execution_result_pending"}}收到它时:
- 已收到的文本可能不完整;
- 调用的计费状态尚未确定,可能进入待核对;
- 用原幂等键查询
GET /v1/results/{键}获取最终状态,不要换新键重发。
开始流式输出之前出现的错误(鉴权、参数校验、余额等)仍以普通 HTTP 错误返回,见错误处理。
客户端断开#
客户端断开连接不会中止上游生成:平台会把厂商的流读到结束,按实际用量结算,并保存完整结果。因此:
- 断开连接不能用于节省费用。需要控制成本时,请设置合理的
max_tokens。 - 断线后用原幂等键查询
/v1/results/{键}。结果信封的response_type为text/event-stream,output.stream中是完整的 SSE 文本,可按同样方式解析。 - 用同一个键重发同一请求,也会一次性返回完整的 SSE 文本,不会重新生成。
代理与网关配置#
流式响应带 Content-Type: text/event-stream、Cache-Control: no-store 和 X-Accel-Buffering: no。如果你的服务在 Nginx 等反向代理后转发流,请关闭代理缓冲(例如 Nginx 的 proxy_buffering off),并把读超时设为几分钟以上;平台对单次上游调用最长等待 21 分钟。