Skip to content

fix(polish): 长稿润色不再卡死在 30 秒——超时按稿子长度伸缩,流式改用首字/空闲两把尺子 - #966

Merged
H-Chris233 merged 1 commit into
Open-Less:betafrom
bigsongeth:fix/polish-timeout-scales-with-length
Aug 19, 2026
Merged

fix(polish): 长稿润色不再卡死在 30 秒——超时按稿子长度伸缩,流式改用首字/空闲两把尺子#966
H-Chris233 merged 1 commit into
Open-Less:betafrom
bigsongeth:fix/polish-timeout-scales-with-length

Conversation

@bigsongeth

@bigsongeth bigsongeth commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

这个 PR 解决什么

录一段长的——一次会议纪要、一篇口述日记——落到编辑器里的可能是没润色过的原始转写,满屏「嗯、呃、然后呢」。点重新润色,还是一样。

不是模型出错。以 StepFun step-3.7-flash 为例,它在吐正文之前先跑一整段思考:一段 1758 字的稿子,实测要 43~75 秒才吐出第一个字(思考量 8572 字,是正文的 9 倍)。而我们第 30 秒就把请求砍了。服务端每次都返回了完整结果,是我们的判据太短。

短句也会中招。同一段 119 字的输入连跑三次,首字延迟 8.3s / 19.4s / 32.7s —— 第三次就撞线了。任何思考型模型都有这个特征,长稿只是把它放大到必然发生。

改了什么

一、流式不再用「整个请求 30 秒」这一把尺子。

这把尺子分不清两件事:模型还在正常吐字、只是这段稿子本来就长;和服务端真的卡死了。30 秒一到,两者一起砍。

现在拆成两个判据:

  • 首字预算——用户盯着空屏干等的上限,随输入长度伸缩;
  • 空闲预算——出字过程中卡多久算死,固定 20 秒(流一旦开始,chunk 间隔都是毫秒级)。

总时长不再单独设限:只要还在稳定出字,长稿就该让它写完。

这里有个容易踩的点:推理模型思考期间 reasoning_content 是一串正常 chunk。特意不让它给首字预算续命,否则「干等多久」就失去上限——一段 8572 字的思考能把人晾在空屏前一分多钟还不超时。

二、预算随输入长度伸缩,流式和非流式(重新润色)两条路都改。

ASR 侧早就有三套 max(30, 系数 × 量 + 余量) 的动态超时(whisper_transcribe_timeout 一族),润色这条路上一直漏着。现在补上,公式沿用同样的写法。短输入仍落在 30 秒地板上,行为与改动前逐字节一致。

三、补首字延迟日志。 之前日志里没有这个读数,排查时只能靠外部实测才量出那 43 秒。有了它,下次看日志就能分清「模型思考太久」和「网络卡住」。

技术细节
  • polish_first_token_timeout_secs(chars) = max(30, ceil(chars × 0.05) + 30);1758 字 → 118s,覆盖实测最坏的 75s 仍有余量。
  • polish_total_timeout_secs(chars) = 首字预算 + max(30, ceil(chars × 0.03) + 20),供拿不到「第一个字」这个中间信号的非流式路径使用。
  • 客户端超时为什么不直接改成动态值:cached_client 以 timeout 为缓存键,每句话长度不同就会造出一个新 client,连接池全部作废——每次润色都要重新 TLS 握手,正是那层缓存当初要消灭的成本。改为新增一个只带连接硬顶(900s,纯防连接泄漏)的 polish_client,业务判据全部下放到调用点,缓存键因此保持唯一。
  • SSE 读循环用 tokio::time::timeout 包住 response.chunk(),按「首字是否已到」在两个预算之间切换。
  • 超时返回 LLMError::Timeout 的语义不变,上层 dictation 的失败分支照旧拿已落屏的 typed_text 当 final_text,屏幕 / history / 剪贴板保持一致。

未覆盖:划词追问(QA)的流式路径仍是老语义;Gemini 与 Codex provider 同理。留给后续。

验证

  • 拿触发这个问题的原稿真打 StepFun 重放两次:首字 44.3s / 95.7s,旧判据两次都被砍,新判据两次都跑完拿到完整润色。
  • 真机装机验证:装机后对同一条稿子点重新润色,93 秒成功返回(旧代码 30 秒必失败)。
  • 新增 8 个单测,起真实 TCP mock server 跑 SSE,覆盖:正常慢流不被砍、首字超时、reasoning chunk 不续命、出字中途卡死时已落屏的字保留、非流式预算生效。
  • 其中两个关键行为做了变异验证:把超时退回整请求语义、让 reasoning chunk 续命——对应测试都如期失败,确认测试不是空转。
  • cargo test --lib 1137 passed / 0 failed;tsc --noEmit 干净。

7 分钟录音那条(1758 字)落进 Notion 的是满屏「嗯、呃、然后呢」的原始转写,
事后手动重润色 3 次全失败。查下来模型没出错——它每次都返回了完整结果,只是
step-3.7-flash 在吐正文之前先思考了 43 秒(实测 43~75s,思考量 8572 字),
而我们第 30 秒就撒手了。

两处判据不对:

1. 流式用的是 reqwest 的整请求超时。这把尺子分不清「模型还在正常吐字,只是
   这段稿子本来就长」和「服务端卡死了」,30s 一到把两者一起砍。现在拆成首字
   预算(用户盯着空屏干等的上限)和空闲预算(出字中途卡多久算死),总时长不
   再单独设限——还在稳定出字就让它写完。

   推理模型思考期的 reasoning_content 是一串正常 chunk,特意不让它给首字预算
   续命,否则「干等多久」就失去上限。

2. 30s 不随输入长度伸缩。ASR 侧早有三套 max(30, 系数 × 量 + 余量) 的动态公式,
   润色这条路上漏了。现在流式和非流式(重润色)都按输入字数算预算。

顺带补上首字延迟日志——之前日志里没有这个读数,这次只能靠外部实测才量出 43s。

验证:拿失败那条原文真打 stepfun 重放两次,首字 44.3s / 95.7s,旧判据全被砍,
新判据(首字 118s / 空闲 20s)两次都跑完拿到完整润色。单测新增 8 个,其中两个
关键行为做了变异验证:把超时退回整请求语义、让 reasoning chunk 续命,对应测试
都如期失败。全量 1082 passed。

未覆盖:QA 划词追问的流式路径仍是老语义,Gemini / Codex provider 同理。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@H-Chris233
H-Chris233 merged commit dbda116 into Open-Less:beta Aug 19, 2026
4 checks passed
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.

2 participants