OpenRouter 流式响应中断怎么查:SSE 这一层能看到什么
流式响应断在半路,是接 OpenRouter 时最容易变成悬案的一类问题:前端的文字停住了,后端日志里没有异常,HTTP 状态是 200,重试一次又好了。这种情况下最没用的做法是加日志打印「收到多少字符」,最有用的做法是把 SSE 的原始行原封不动打出来看——OpenRouter 官方文档《Streaming》页(openrouter.ai/docs/api_reference/streaming)把这一层里能出现的东西写得相当具体,够你把大部分中断归到一个明确的类别里。
下面按排查顺序走一遍。所有依据都来自那一页文档,文档没写的地方我会明说。
一、先把「一条正常的流长什么样」定死
判定的前提是知道正常是什么样。文档给出的流式请求就是在请求体里加一个参数:
{
"model": "openai/gpt-4o",
"messages": [{"role": "user", "content": "..."}],
"stream": true
}
(openai/gpt-4o 是官方文档当时示例里用的值,平台上有哪些模型随时在变,别把它当清单用。)
流开起来之后,客户端在传输层会看到三类行:
| 行的样子 | 含义 | 文档写明的处理方式 |
|---|---|---|
data: {...} | 一个数据块,JSON 里有 choices[0].delta.content | 解析后取增量内容 |
data: [DONE] | 流正常结束的终止标记 | 判到它就跳出循环 |
以 : 开头的行,例如 : OPENROUTER PROCESSING | SSE 注释,文档说明是为防止连接超时而偶尔发送的 | 按 SSE 规范可以安全忽略 |
文档还写明:最后一个数据块里带 usage 统计(TypeScript SDK 示例里的注释是 Final chunk includes usage stats)。这一点对排查很有用——如果你的循环从来没见过带 usage 的块,也没见过 [DONE],那这条流就不是正常结束的。 这是最省事的一条判据,值得固化进你的客户端埋点。
关于那个 X-Generation-Id:文档写明所有端点(chat completions、completions、responses、messages)的响应头里都会返回 generation ID,用途是调试与关联请求。排查中断时把它随日志一起记下来,后面无论是自查还是找官方对账都少绕一圈。
二、判定动作:把原始行 dump 出来,从状态码往下分岔
动作一:先看 HTTP 状态码,这一步决定后面所有分岔。
文档把错误明确分成两个阶段,分界线是「有没有已经吐出 token」:
- 还没吐任何 token 就出错 → OpenRouter 返回标准的 JSON 错误体,带对应的 HTTP 状态码,形状是这样:
{
"error": {
"code": 400,
"message": "Invalid model specified"
}
}
文档列出的常见状态码有六个:400 参数不合法、401 API key 无效、402 余额不足、429 触发限流、502 上游供应商出错、503 没有可用的供应商。(这里只抄状态码语义,不涉及任何额度与限流数值。)
- 已经吐出 token 之后才出错 → 文档明确写着 OpenRouter 改不了 HTTP 状态码(那时候响应头早就发出去了,状态就是 200 OK),错误只能以 SSE 事件的形式补发。
所以,状态码非 200 的中断根本不是「流断了」,是流压根没开起来,直接读 body 的 JSON 就有答案。文档在 eventsource-parser 那段示例里的注释说得很直白:Errors that occur before streaming starts are plain JSON, not SSE,对应的判定写法是:
// Errors that occur before streaming starts are plain JSON, not SSE
if (!response.ok) {
const error = await response.json();
throw new Error(error.error.message);
}
动作二:状态是 200 却断了,就去看最后几行原始文本。
不要在解析后的对象上找线索,要看解析前的字节。文档在讲取消的那段 Python 示例里正好有一个不解析、直接打印原始行的写法,拿来当观察工具刚好:
for line in response.iter_lines():
if line:
print(line.decode(), end="", flush=True)
把这几行原样接到你现有的请求上,跑一次复现,然后看流的尾巴:
- 尾巴是
data: [DONE]→ 流是正常收尾的,问题在你自己的下游(渲染、拼接、超时关闭),不在 OpenRouter 这一层。 - 尾巴是一条带
error字段的data:事件 → 命中「中途错误」,看下一节。 - 什么都没有,连接直接没了 → 文档没有描述这种形态对应的语义,别硬套上面两类。
动作三:如果症状是「循环直接崩了」,先怀疑注释行。
文档里有一条明确的 Warning:手写解析时要跳过以 : 开头的行再调 JSON.parse,把 : OPENROUTER PROCESSING 这样的注释行喂给 JSON.parse 会抛错,没接住就会让你的流循环崩掉。这个坑的特征很好认——中断时机随机、内容长度没有规律、异常栈落在 JSON 解析上。文档给的手写解析示例里都带了这一句守卫:
// Skip SSE comments (lines starting with ":"), e.g. the
// ": OPENROUTER PROCESSING" keep-alive — they are not JSON
if (line.startsWith(':')) continue;
三、中途错误事件长什么样
这是本文的落点。文档给出的中途错误事件是一条完整的 SSE 数据行,结构上和普通数据块是同一套:
data: {"id":"cmpl-abc123","object":"chat.completion.chunk","created":1234567890,"model":"openai/gpt-4o","provider":"openai","error":{"code":"server_error","message":"Provider disconnected unexpectedly"},"choices":[{"index":0,"delta":{"content":""},"finish_reason":"error"}]}
文档把它的特征列成四条,逐条都值得记:错误对象出现在顶层,和 id、object、created 这些常规字段并列,不在 choices 里面;同时带一个 choices 数组,finish_reason 是 "error",用来正常终止这条流;HTTP 状态仍然是 200 OK;这条统一错误事件之后流即终止。
对应到客户端,判定条件就是两个字段:
if (parsed.error) {
console.error(`Stream error: ${parsed.error.message}`);
if (parsed.choices?.[0]?.finish_reason === 'error') {
console.log('Stream terminated due to error');
}
return;
}
有一点容易踩空:换成规范的 SSE 解析库并不能替你处理这类错误。 文档推荐了 eventsource-parser、OpenAI SDK、Vercel AI SDK 三个客户端,并说明规范解析器会替你处理注释、多行 data: 字段和缓冲;但紧接着补了一句——解析器只负责 SSE 的分帧,生成过程中出现的错误仍然会以带 error 字段的普通 data: 事件到达。也就是说换库能治「动作三」那类崩溃,治不了「动作二」查出来的中途错误,那段判定你还是得自己写。
四、处置之后怎么验证
验证不看「感觉好了」,看三个可观察的信号:
- 复现同一个请求,原始行 dump 里能稳定看到
data: [DONE],或者看到带usage的最后一个数据块; - 注释行不再进
JSON.parse——把: OPENROUTER PROCESSING那一行手工塞进你的解析函数做一次单测,函数应当返回而不是抛异常; - 中途错误路径也要造一次可控输入走一遍:断言你的处理分支能同时读到顶层
error.message和finish_reason === "error",而不是把这条事件当成一个content为空字符串的普通块吞掉——注意那条示例事件里delta.content确实是空串,只判内容非空的老代码会静默丢掉它。
Windows 与 Linux/macOS 的差别集中在两处,都不在 OpenRouter 这一侧,但会实实在在耽误你排查(以下为通用 shell 做法,非官方文档内容):
- 密钥注入:PowerShell 用
$env:OPENROUTER_API_KEY="<YOUR_API_KEY>",Linux/macOS 用export OPENROUTER_API_KEY="<YOUR_API_KEY>"。文档的eventsource-parser示例里读的就是process.env.OPENROUTER_API_KEY,两边环境变量名保持一致就行。 - 换行符:文档给的两个手写解析示例都是按
\n找行边界(buffer.find('\n')与buffer.indexOf('\n'))。在 Windows 上把原始流重定向存成文件再回看时,注意别让工具链把换行改写掉,否则你看到的行边界和程序实际切出来的不是一回事。
以上为按官方文档中的字段语义组合的示例,未经实测,以官方文档与 API 的实际响应为准。
五、什么情况说明不是这个原因
这一节比前面几节更值钱,因为把力气花在错误方向上的成本最高。
流「提前结束」但没有任何 error 字段,不要当成中途错误。 文档在最后的「API-specific behavior」里写明:OpenAI Responses API 可能把某些错误码(文档举的例子是 context_length_exceeded)转换成一个成功的响应、finish_reason 给 "length",而不是按错误处理。所以看到 finish_reason: "length" 就该往输入长度那条线去查,跟 SSE 传输层没关系。同一段还写明,OpenAI Chat Completions API 在一个 chunk 都没处理时直接返回 ErrorResponse,处理过部分 chunk 时则把错误信息带在响应里——两个端点的行为不完全一样,跨端点搬结论要小心。
流是你自己取消的,也不是中断。 文档说明流式请求可以通过中止连接来取消(TypeScript 侧用 AbortController 的 signal,Python 示例里用 response.close()),对于支持的供应商,取消会立即停止模型处理与计费。但文档同时给了一条 Warning:取消只对流式请求且支持的供应商有效;非流式请求或不支持的供应商,模型会继续处理,你会按完整响应计费。文档里用一个折叠块分别列出了支持与不支持取消的供应商两组名单——名单会变,请直接以官方文档那一页为准,本文不转抄。这条的排查意义在于:如果你在客户端做了超时中止,那么日志里「流没跑完」是预期行为,别再去 SSE 里找 bug;真正要确认的是你用的那家供应商在不在支持列里。
状态码不是 200 的一律不属于本文范围。 前面说过,那是流开始之前就失败了,错误体是普通 JSON,按 400/401/402/429/502/503 的语义各查各的,跟 SSE 事件形态无关。
最后一类:原始行里明明有 [DONE],用户仍然说「话说到一半」。 这时候 SSE 这一层是干净的,官方文档也没有为这种情形提供别的可见信号;该往内容侧查(停止条件、长度限制、你自己的截断逻辑),而不是继续在传输层挖。文档没有说明这一点,硬猜没有意义。
排查这类问题的整体次序其实很固定:状态码 → 原始行尾部 → 是否有顶层 error → finish_reason 的具体值。四步走完还没有结论,再带上 X-Generation-Id 去做后续对账。
本文依据 OpenRouter 官方文档(openrouter.ai/docs)于 2026-08-18 的公开内容整理。
该平台闭源,本文只复述官方文档写明的机制,不推断其内部实现;
我们没有对文中涉及的功能做过实测,因此不涉及界面外观与运行表现的任何描述。
该平台的供应商、模型与路由策略随时变动,文中不列具体供应商名单与模型清单;
价格、额度与限流的具体数值请以官方定价页与用量说明为准。
该页文档中未出现 beta / preview / deprecated 之类的状态标记,文中所述字段与事件形态
随文档更新可能变化,请以官方文档最新内容为准。
安全与合规相关做法请结合自身环境评估,本文不构成安全方案建议。