流式响应
POST /v1/chat/completions 和 POST /v1/responses 在请求中设置 "stream": true 后返回 Server-Sent Events(SSE)。响应的 Content-Type 是 text/event-stream。
流式响应不是一个 JSON 文档:服务器会持续发送事件,每个事件可能只包含一小段增量内容。客户端必须逐行读取、按空行分隔事件,并分别解析每个事件的 data。正常结束通常以 data: [DONE] 表示;Responses 的事件还可能带有协议定义的事件类型和结构。
PowerShell / cURL
PowerShell 中使用 curl.exe -N 禁止缓冲输出。Chat Completions 示例:
$body = '{"model":"gpt-5.6-terra","messages":[{"role":"user","content":"Reply with OK"}],"stream":true}'
curl.exe -N https://api.yuniversity.cc/v1/chat/completions `
-H "Authorization: Bearer $env:YUNIVERSITY_API_KEY" `
-H "Content-Type: application/json" `
-d $body
Responses 示例:
$body = '{"model":"gpt-5.6-terra","input":"Reply with OK","stream":true,"store":false}'
curl.exe -N https://api.yuniversity.cc/v1/responses `
-H "Authorization: Bearer $env:YUNIVERSITY_API_KEY" `
-H "Content-Type: application/json" `
-d $body
Python(标准库)
urllib.request 的响应对象可以逐行迭代。下面的示例保留 SSE 的 data: 行,并在收到 data: [DONE] 后停止:
import json
import os
from urllib.request import Request, urlopen
payload = {
"model": "gpt-5.6-terra",
"messages": [{"role": "user", "content": "Reply with OK"}],
"stream": True,
}
request = Request(
"https://api.yuniversity.cc/v1/chat/completions",
data=json.dumps(payload).encode(),
headers={
"Authorization": f"Bearer {os.environ['YUNIVERSITY_API_KEY']}",
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
)
with urlopen(request) as response:
for raw_line in response:
line = raw_line.decode("utf-8").rstrip("\r\n")
if not line.startswith("data: "):
continue
data = line[6:]
if data == "[DONE]":
break
event = json.loads(data)
print(event)
真实客户端应按空行组装完整事件;一个事件可能包含多行 data:,不能假设每一行都是独立 JSON。
JavaScript fetch
ReadableStream 返回的是字节块,不保证每块恰好对应一个事件。以下代码通过 TextDecoder 和缓冲区按空行处理事件,并使用 AbortSignal.timeout 设置客户端超时:
const response = await fetch("https://api.yuniversity.cc/v1/responses", {
method: "POST",
signal: AbortSignal.timeout(120000),
headers: {
"Authorization": `Bearer ${process.env.YUNIVERSITY_API_KEY}`,
"Content-Type": "application/json",
"Accept": "text/event-stream",
},
body: JSON.stringify({
model: "gpt-5.6-terra",
input: "Reply with OK",
stream: true,
store: false,
}),
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let finished = false;
while (!finished) {
const { value, done } = await reader.read();
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
const events = buffer.split("\n\n");
buffer = events.pop() || "";
for (const event of events) {
for (const line of event.split("\n")) {
if (!line.startsWith("data: ")) continue;
const data = line.slice(6);
if (data === "[DONE]") {
finished = true;
break;
}
console.log(JSON.parse(data));
}
if (finished) break;
}
if (done) break;
}
await reader.cancel();
浏览器前端不应直接暴露 API Key;把此类请求放在受保护的后端。调用 reader.cancel() 或断开网络只表示客户端停止读取,不能保证上游请求已经在计费前取消。需要可靠的取消和费用判断时,应以服务端最终状态和用量信息为准。
处理建议
- 先检查
Content-Type,再进入 SSE 解析器;非 2xx 响应应按普通错误响应读取。 - 保存未完成的字节和事件,直到遇到空行;不要对每个网络 chunk 直接调用 JSON 解析。
- 将增量文本追加到本地缓冲区,并在
data: [DONE]或协议完成事件后结束 UI 状态。 - 网络断开时记录请求 ID(不要记录 API Key),并避免在请求是否已被接受不明确时盲目重试。