Stream ended without finish_reason 怎么办?pi 接第三方端点时先分清是断流还是端点不发

2026-09-28

用 pi 接一个 OpenAI 兼容的端点(本地的 Ollama、vLLM,或者某个中转站),跑着跑着蹦出来这么一句:

Stream ended without finish_reason

如果你用的是 Gemini 这类 Google 模型,文案会是另一个样子:

Google stream ended without a finish reason

这两句的意思一样:流已经结束了,但 pi 没等到模型说「我说完了」的那个信号。

先把结论放前面:这句报错背后有两种完全不同的情况,处理方向正好相反。一种是流被半路掐断,该查网络;另一种是端点本来就不发这个字段,该改配置。分错了,要么白折腾网络,要么把真正的截断藏起来。

一、finish_reason 是什么,pi 为什么非要它

OpenAI 兼容接口的流式输出,是一小块一小块往回推的。模型说完以后,最后一块里会带一个 finish_reason 字段,常见值是 stop(正常说完)、length(到了长度上限)、tool_calls(要调工具)。

对一个编程 Agent 来说,这个字段决定下一步做什么:是这一轮结束了,还是该去执行工具,还是被截断了要续写。所以 pi 默认把它当成必须收到的信号。

pi 源码(packages/ai/src/api/openai-completions.ts)里,流读完之后有这么一个判断,意思翻成中文是:

  • 如果这个端点被认为「会发 finish_reason」,但整条流里一次都没收到,就抛 Stream ended without finish_reason;
  • 如果停止原因到最后还处在「待定」状态,也抛这句。

Google 的实现(google-generative-ai.ts)逻辑类似:流结束时停止原因还是「待定」,就抛 Google stream ended without a finish reason。

关键在第一条里那个「被认为会发」。它对应 pi 的一个兼容开关 supportsFinishReason,默认是 true。

二、两种情况,怎么一眼分开

情况一:流被半路掐断了。 模型本来会发 finish_reason,但连接在最后一块到达之前断了。这时回答通常是写到一半的,报错也是时有时无的:同一个端点,有时成功有时失败。

情况二:端点本来就不发。 有些本地推理服务和中转站,流式输出里干脆没有这个字段,或者只在非流式返回里有。这时的特征非常稳定:同一个端点,每次都报,而且你看到的回答往往是完整的。

所以判断的第一步不用看任何日志,只看一个问题:是偶尔报,还是每次都报?

如果不确定,可以绕过 pi 直接问端点。下面这条命令把流式原样打出来,看最后几行里有没有 finish_reason 且不为 null(地址、模型名和 key 换成你自己的):

curl -N https://你的端点/v1/chat/completions \
  -H "Authorization: Bearer $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model":"你的模型","stream":true,"messages":[{"role":"user","content":"说一个字"}]}'

连续跑几次,最后一块都带着 "finish_reason":"stop",就是情况一;每次都只有 null 或者根本没这个字段,就是情况二。

三、情况二的解法:在 models.json 里关掉这个开关

pi 从 coding-agent 0.84.0(2026-08-06)起支持一个兼容项:compat.supportsFinishReason。把它设成 false,pi 就不再等这个信号,而是在流结束时自己推断——内容里有工具调用就当 toolUse,没有就当 stop。

配置写在 ~/.pi/agent/models.json(目录可以用 PI_CODING_AGENT_DIR 改)。既可以放在整个提供方上,也可以只放在某个模型上:

{
  "providers": {
    "my-endpoint": {
      "baseUrl": "http://localhost:8000/v1",
      "api": "openai-completions",
      "apiKey": "$MY_KEY",
      "compat": { "supportsFinishReason": false },
      "models": [{ "id": "my-model" }]
    }
  }
}

改完在 pi 里打开一次 /model 就会重新加载这个文件。

有一个小坑:0.84.0 刚加这个开关时,models.json 的类型定义漏了它,到 0.84.3(2026-08-24)才补上。如果你用的是这两个版本之间的 pi,建议先升级再配。

四、为什么情况一千万别用这个开关「修」

这是整篇最想说的一点。

看上一节 pi 的推断规则:关掉开关以后,流一结束,pi 就当它正常说完了。 它没有办法再区分「模型真的说完了」和「连接在中间断了」。

所以如果你的问题其实是情况一(网络不稳、代理掐连接),关掉开关以后报错确实消失了,但代价是:被截断的半截回答会被当成完整回答接着用。对写代码的 Agent 来说,这比报错糟糕得多——半截的文件改动、写了一半的命令,都会被当成完成了的结果往下走。

pi 自己的文档也专门提醒过这类兼容项:它们应该描述端点经过验证的行为差异,不要因为一个端点自称「兼容 OpenAI」就去开关它们。第二节那条 curl,就是为了让你先验证再改。

情况一该做的是:

  1. 换一个网络环境复现一次(比如关掉代理或换热点),看报错频率有没有变化;
  2. 如果走的是中转站,看它有没有流式超时或连接数限制;
  3. 本地推理服务的话,看服务端日志里这一次请求是不是异常结束了(显存不足、进程被杀都会让流中途停下)。

五、默认设置下,你看到它时重试已经用完了

有一点容易被忽略:pi 自己默认就会重试这类错误。 pi 的重试规则里,报错文本包含 ended without 的都算「可以重试的临时故障」;coding-agent 的自动重试默认开启、默认最多 3 次(设置项 retry.enabled 和 retry.maxRetries)。

所以在 pi 里最终看到这句报错,一般意味着自动重试已经用完了还是没成。这一点对判断很有帮助:偶发的断流通常在重试里就被消化掉了,能冒到你面前的,要么断得很频繁,要么就是第二节说的情况二(每次都不发,重试多少次都一样)。

deepseek-harness(dsh)里也是类似的逻辑。dsh 有一条通过 pi 的 AI 层(pi-ai 适配器)调模型的路径,据它仓库里 2026-07-22 的一篇设计笔记,这个适配器会把 Stream ended without finish_reason 以及同类的「流在终止事件之前就结束了」的报错归为传输层错误,而传输层错误在 dsh 里默认会被重试。dsh 的重试次数和超时怎么配,见下面的相关阅读。

六、Google 模型那句怎么处理

Google stream ended without a finish reason 走的是 pi 的 Google 实现,没有上面那个兼容开关可以关。它只在流结束时停止原因仍是「待定」才抛,所以基本可以直接按情况一处理:查网络、查代理、换个时间再试。

附:pi 里的同族报错对照

pi 给每一家模型接口各写了一套流式解析,所以「流结束了却没等到终止信号」这件事,在不同接口下是不同的文案。下面是 pi 源码(2026-09-28)里这一族的主要写法:

你看到的报错走的是哪种接口有没有兼容开关可关
Stream ended without finish_reasonOpenAI 兼容的 Chat Completions有,compat.supportsFinishReason
OpenAI Responses stream ended without a stop reasonOpenAI Responses无
OpenAI Responses stream ended before a terminal response eventOpenAI Responses无
Azure OpenAI Responses stream ended without a stop reasonAzure OpenAI无
Codex stream ended without a stop reasonOpenAI Codex Responses(源码文件 openai-codex-responses.ts)无
Anthropic stream ended before message_stopAnthropic Messages无
Anthropic stream ended without a stop reasonAnthropic Messages无
Google stream ended without a finish reasonGoogle Generative AI无
Google Vertex stream ended without a finish reasonGoogle Vertex无
Mistral stream ended without a finish reasonMistral无
Bedrock stream ended without a stop reasonAWS Bedrock无
<提供方名> stream ended without a terminal eventpi 自己的消息接口(pi-messages.ts),开头是提供方名无

这张表能帮你做两件事。第一,从报错反推你实际走的是哪条接口:比如你以为自己配的是 OpenAI 兼容端点,报出来的却是 Responses 那一句,说明 models.json 里的 api 字段不是你以为的那个。第二,判断有没有配置可以改:只有第一行那种有兼容开关,其余的都只能按「流被掐断」去查网络和上游。

七、一句话总结

  • 偶尔报:流被掐了,查网络,别动开关;
  • 每次都报、回答又是完整的:端点不发 finish_reason,先用 curl 验证,再在 models.json 里对这个端点设 compat.supportsFinishReason: false;
  • Google 那句:没有开关,按断流处理。

本文依据 pi 仓库截至 2026-09-28 的源码与文档(当时最新版本 v0.87.1),以及 deepseek-harness 仓库的设计笔记。

相关阅读

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。

留言讨论

评论发布后会被人工复核,违规内容将被删除。

    还没有人评论,来说说你的看法

    如果发表没有反应,可以前往联系我们告诉我们。

    这个页面有问题?

    提交时会附带当前页面地址和浏览器信息,帮助我们定位问题。不填联系方式即为匿名。