Skip to content

fix(trae):上游没给完成事件(event: done)就结束时不再当成正常收尾 —— 静默截断要看得见 - #294

Open
shadyrispy wants to merge 1 commit into
aimod-cc:betafrom
shadyrispy:fix/trae-missing-terminal
Open

shadyrispy wants to merge 1 commit into
aimod-cc:betafrom
shadyrispy:fix/trae-missing-terminal

Conversation

@shadyrispy

Copy link
Copy Markdown
Contributor

这条修什么

上游 issue #286:trae 的 deepseek-v4.1-flash-solo 推理失控(报告人实测同一简单任务的推理量 13,619 字符,同家其它模型低 100–900 倍),上游把连接撑断时没发结束帧 event: done,而网关把「流结束了」当成了「上游说完成了」:

  • 流式:SoloStream::finish() 在 EOF 只补一个 [DONE] —— 没有错误帧,也没有任何带 finish_reason 的 chunk;
  • 非流式:aggregate() 的 finish_reason 初值就是 "stop",直接写进 200。

于是客户端拿到的是期望输出的严格前缀(报告人 13/15 次,非流式 12/15),而网关侧 status=200、错误列为空、一次都不重试。最坏的一点是它不像失败:自动续写(#233)也接不住 —— 判据要的 finish_reason 被兜成了 stop,第 1 条就落空。

根因不是「读错误被吞」:真报错时两条路都留痕(流式记 note_error 并下传 Err,非流式给 502)。报告人观察到的是干净 EOF —— 上游在 HTTP 层把响应体正常结束了,只是没给 event: done。缺的是「上游到底收没收场」这个判别,而它一直就在手上。

改了什么

  1. 转换层只如实报告事实,回包形状一个字不改:SoloStream 记 saw_done / saw_output 并开只读访问器;aggregate 返回 Aggregated { body, saw_done, saw_output }(body 逐字不变)。参考实现的向量把「没给 done 也结束」当正常形状(上游没给 done(EOF 兜底 [DONE]) 与 usage 之后没有 done 两条),因此那两条向量与对拍测试一条都没动。
  2. 判据 missing_terminal(saw_done, saw_output, require):产出过内容(正文 / 思考 / 工具调用任一非空)且上游没给过完成事件。两个例外刻意不判:一句不吐就结束(归既有的空回答兜底链)、event: error 之后结束(saw_done 同样为真,错误已经如实下发过)。
  3. 两条路各自处置(同一句文案,同一个成因不两个说法):
    • 流式:在 [DONE] 之前补一帧 data: {"error": {…, "type": "upstream_error"}}(形状与流内错误一致),并把该轮写进请求日志的错误列;
    • 非流式:直接 502,不再返回「200 + 前缀」。这条能直接失败,是因为内容一个字节都还没发给客户端 —— 一份带前缀的 200 比一次明确的失败坏得多。
  4. 兜底开关 AGENT2API_TRAE_NO_REQUIRE_TERMINAL=1 关掉这条判据(读一次定死,与 ZCODE_REQUIRE_CAPTCHA 同一取向)。上面那两条向量说明这条判据是有意偏离参考实现,万一在没见过的上游形状上误伤,兜底不该依赖发版。

测试

cargo test -p agent2api-server:591 passed / 0 failed / 6 ignored;基点 beta a55e1eb 同命令实跑 585 passed / 6 ignored ⇒ 净增 6 条。

新增用例:无 done 有内容(流式要补错误帧且错误进遥测、非流式要 502 而不是 200)、有 done 的正对照(字节不多一个、无错误留痕)、「一句不吐就结束」仍容忍、判据表与开关解析(不碰进程环境变量)、聚合层如实回报两个标志位(body 形状不变)。

反转验证:把开关打上重跑同一批 trae 用例 ⇒ 恰好那两条行为用例转红(a_stream_that_ends_without_done_is_flagged_in_band、an_aggregate_that_ends_without_done_fails_instead_of_a_prefix),其余 137 条仍绿。

已知边界

  • 没量「真实流量里有多少轮以无 done 收场」:3.0.4 起 request_raw 不再写入,只能靠 debugMode 采集或加计数器,本 PR 未加计数器。
  • 是否存在别的合法上游形状会以「无 done」收场,未证伪 —— 这正是开关存在的理由。
  • 报告人提到「最后一帧 finish_reason=stop」:按流式路径只会下发 [DONE]、不带 finish_reason,只有非流式显式写 stop,这一处描述未向其确认。
  • 自动续写与本判据不叠加:错误帧会让累计器的 saw_error() 为真 ⇒ 不触发续写(刻意的:先让客户端知道这轮不完整,再谈续写)。

「流结束了」与「上游说完成了」此前被压成一件:SoloStream::finish 在 EOF 时只补
[DONE]、aggregate 的 finish_reason 直接兜 stop(照抄参考实现,向量的两条
「没给 done」形状钉着)。于是上游把连接撑断时,那份前缀在客户端看起来是一次
完全正常的完成 —— 上游 issue aimod-cc#286:trae 的 deepseek-v4.1-flash-solo 推理失控,
报告人 13/15 次只收到前缀,网关侧 status=200 / 错误列为空 / 一次都不重试,
连自动续写都接不住(它要的 finish_reason 被兜成了 stop)。

判据是现成的:产出过内容(正文/思考/工具调用任一非空)且上游没给过完成事件。
- 流式:在 [DONE] 之前补一帧错误(形状与流内错误一致),错误进请求日志;
- 非流式:直接 502,不再返回「200 + 前缀」那份假成功;
- 「一句不吐就结束」不算截断(归既有空回答兜底链),event: error 之后结束也不算
  (错误已经如实下发过)。
- 兜底开关 AGENT2API_TRAE_NO_REQUIRE_TERMINAL=1 关掉这条判据(读一次定死)。

转换层只如实报告事实(SoloStream 记 saw_done/saw_output,aggregate 返回
Aggregated),回包形状一个字不改 —— 那两条对拍向量因此一条都没动。
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant