oh-my-muse/design-docs/临时-01-AI流SSE长任务超时修复方案.md
lili 09c35f5eee docs(ai-e2e): AI 流 SSE 长任务超时修复方案评审文档(待 review、不改 code)
studio e2e ai-generation 真红深诊出 SSE 协议/时序 code bug,按规范(产品行为变更先评审)出方案评审文档供 human review。两层根因:①server 启动漏 source acceptance env(AI 凭据)②后端 DEFAULT_TIMEOUT_MILLIS=30s 同时作 SSE 连接死线(:60)+poll deadline(:188)、< 上游自己 180s 总窗口(MUSE_AI_NEW_API_TOTAL_TIMEOUT_SECONDS),前端 connectAIStream 无重连(AIPanel 流结束不重连不报错)。

推荐 A1(启动脚本固化 AI 凭据)+候选③(后端放宽死线兜底+前端断连重连容错),排期紧分阶段 A1→①→②。3 决策点待 review:timeout 取值(应 ≥180s+余量、非初稿 120s)/是否上前端重连(推翻 AI 流一次性设计)/重连续传幂等候选去重。text+2 mermaid(因果时序+选项关系)+html 人读图;大纲注册 v7→v8。本轮未改 code、待 review 后执行。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 19:18:21 -07:00

20 KiB
Raw Blame History

临时-01 · AI 流 SSE 长任务候选丢失修复方案(评审版)

  • 版本v1评审版待人类 review 后执行)
  • 更新日期2026-06-25
  • 目标读者:架构 / 后端 / 前端 / QA / PR reviewer
  • 阅读时间1218 分钟
  • 文档性质:临时件(迁移依据,落定后归并入正式分册或删除)。本稿只做方案对比与决策收束,不改任何代码、不定义新概念;术语以 架构-02 为准SSE 接口语义归属 后端-05
  • 配套人读图:临时-01-AI流SSE长任务超时修复方案.html

一句话结论:用户长篇 AI 生成卡在「中止生成」、候选永不出现,根因是后端 SSE 通道在 30 秒硬死线到点后静默关连接、不补发任何终态事件,而前端 AI 流是一次性连接、不重连;当大模型真实时延越过 30 秒,候选就永久丢给了界面。推荐采用后端放宽死线兜底 + 前端断连重连容错的组合方案,并把启动脚本缺失 AI 凭据这一环境隐患一并固化。


1. 问题与根因

1.1 用户可见症状与工程证据

普通用户在写作台发起较长的 AI 续写或优化时,正文区一直停在生成中状态,候选迟迟不出现,最终只能点「中止生成」放弃;而同样的功能在生成较快时又能正常出候选。这种"时灵时不灵"不是偶发抖动,而是同一个缺陷在大模型时延分布两侧的两种表现。

端到端测试把这件事钉死成了铁证采纳建议用例accept-suggestion会 flaky 地变绿AI 生成用例ai-generation则稳定真红。差别只在于那一次大模型回话用了多久——抽到一次较快的生成实测约 15 秒)就绿,抽到一次较慢的生成(实测约 36 秒)就红,而服务端日志显示真正的完成事件比连接关闭整整晚了约 8 秒落库。换句话说,测试的成败由大模型这次"心情"决定,而不是由代码正确性决定,这本身就是缺陷已经发生的信号。

需要澄清的是:大模型上游服务本身是健康在线的,已用直接调用核验过,这不是环境不可用的问题。问题出在我们自己的 SSE 通道把"等待窗口"开得太短,以及前端在连接被提前关闭后没有任何补救。

1.2 两层根因

这里实际叠着两层独立的问题,必须分开讲清楚,否则容易把环境隐患和真实代码缺陷混为一谈。

第一层是环境与启动隐患(已临时手工绕过,但未固化)。 本地拉起后端的启动脚本只加载了基础设施凭据(数据库、缓存),却没有加载大模型接入所需的那一份凭据配置。后端进程因此拿不到大模型的接入信息,会按设计装上一个"不可用兜底"的运行时客户端,于是任何 AI 任务都会在十几毫秒内秒失败并报"接入不可用"。这一层已经通过手工带齐全部环境变量重启后端验证过、任务能正常完成,但脚本本身没改,下次有人按脚本重启就会复发。它会污染验证现场,让人误以为是别的问题,所以必须连同真实缺陷一起固化掉。

第二层是真正的代码缺陷,落在 SSE 协议与时序上,分布在后端和前端两处,二者叠加才酿成候选永久丢失。

后端这一侧AI 任务的流式服务把连接死线和后台轮询上限都写死成了 30 秒(MuseAiTaskStreamServiceImpl 中的 DEFAULT_TIMEOUT_MILLIS,既用于构造流式连接的超时,也用于后台轮询循环的截止时间)。它的工作方式是:连接建立后起一个后台循环,反复去库里读这个任务新落的事件并推给浏览器,直到读到完成或失败这种终态事件才正常收尾。问题在于,一旦这 30 秒到点而任务尚未完成,它只是把连接关掉,既不补发完成事件、也不补发错误事件——浏览器那头收到的是一个干干净净、什么终态都没有的"流结束"。而大模型的真实出话时延实测在 11 到 60 秒之间剧烈抖动,结构性地骑跨在这条 30 秒死线上。更要命的是,这条 30 秒死线甚至比后端自己调用大模型时所允许的总时延上限180 秒)还短——也就是说后端给浏览器开的等待窗口,比它自己等大模型的窗口还窄,只要大模型这次用了 30 到 180 秒,上游其实还在正常等、我们的 SSE 却已经先关门了。

前端这一侧AI 生成走的是一条专用的一次性流连接(sse.ts 中的 connectAIStream)。它读完这一程流就结束,没有任何重连机制。对照之下,同一文件里的全局系统事件流(connectEventStream是带重连的连接非正常结束时会按一组递增退避时延自动重连。AI 流却被刻意设计成一次性、不续传。这就意味着,当后端在 30 秒静默关连接后,前端这条流自然结束,既收不到完成事件去渲染候选,也收不到错误事件去提示失败;它不会再去重连把后端随后几秒才落库的完成事件捞回来。再往上一层,消费这条流的写作台 AI 面板在流结束时同样既不重连也不报错——于是界面就永远停在生成中,用户唯一能做的就是点「中止生成」。

把两侧合起来看因果链就清楚了:大模型时延一旦越过 30 秒,后端到点静默空关、不带终态,前端不重连、上层不补救,那条本应稍后到达的候选就被永久地丢在了后端库里,再也送不到界面。这正好解释了为什么"快的生成能出候选、慢的生成出不来",也解释了两个端到端用例为什么一个 flaky 绿、一个稳定红——它们只是同一个缺陷被大模型时延切到了两侧。

1.3 因果时序(快路径绿 vs 慢路径红,同一缺陷两侧)

sequenceDiagram
    autonumber
    participant U as 用户/AI面板
    participant FE as 前端 AI 流<br/>(一次性·不重连)
    participant BE as 后端 SSE<br/>(30s 死线)
    participant DB as 事件库
    participant LLM as 大模型上游<br/>(总窗口 180s)

    U->>FE: 发起长 AI 生成
    FE->>BE: 建立 SSE 连接(一次性)
    BE->>BE: 起后台轮询(死线=30s)
    BE->>LLM: 触发生成

    rect rgb(225,245,230)
    note over U,LLM: 快路径(实测约15s<30s)——碰巧绿
    LLM-->>DB: 约15s 落 done 事件
    BE->>DB: 轮询读到 done
    BE-->>FE: 推 done 并正常收尾
    FE-->>U: 渲染候选 ✓
    end

    rect rgb(250,228,228)
    note over U,LLM: 慢路径(实测约36s>30s)——稳定红
    BE-->>BE: 到 30s 死线,任务未完
    BE-->>FE: 静默 complete()<br/>不补 done / 不补 error
    FE-->>FE: 流自然结束,无重连
    FE-->>U: 既无候选也无报错<br/>界面卡「中止生成」 ✗
    LLM-->>DB: 约36s 才落 done(晚 ~8s)
    note over DB: 候选已落库,却永远送不到界面
    end

2. 修法选项对比

下面按"第一层环境"和"第二层代码缺陷"分别给出可选项。每项给出意图与做法要点,以及在根治程度、资源占用、用户体验、改动复杂度、兼容性五个维度上的如实权衡——不夸大任何一项。

2.1 第一层:环境与启动固化(三选一,互不冲突,建议任选其一落地)

选项 做法要点 根治程度 复杂度 权衡
A1 脚本固化加载 AI 凭据 启动脚本在加载基础设施凭据之外,再加载大模型接入凭据那一份配置,缺失关键变量时直接报错退出 高:从源头消除复发 极低:脚本几行 凭据文件路径成为启动隐式依赖,需在文档写明;不入库不打印
A2 仅文档固化启动步骤 不改脚本,在端到端启动文档里写死"必须带齐两份凭据"的操作步骤 低:仍靠人记得 极低 复发风险仍在,依赖自觉,与项目"门禁优先、不靠自觉"的原则相悖
A3 后端缺 AI 凭据时启动告警 后端启动自检大模型接入是否就绪,缺失时打一条显眼告警(不泄露凭据值) 中:缩短误诊时间但不阻止复发 治标不治本,更像 A1 的补充而非替代

第一层的取舍很直接:A1 是唯一从源头消除复发的做法,且改动极小A2 违背门禁优先原则不宜单独采用A3 可作为 A1 之外的诊断增益,但不能替代 A1。

2.2 第二层SSE 时序缺陷(三个候选,①②可独立、③为组合)

候选① — 后端放宽死线(最小改)。 把后端那条 30 秒硬死线放宽到足以覆盖大模型真实时延上限,并尽量做成可配置而非又一个写死的魔数。意图是让 SSE 的等待窗口不再短于上游允许的时延,使绝大多数长生成能在连接存活期内等到完成事件。做法上,由于这条死线常量同时控制连接超时和后台轮询截止,调一处即可同时放宽两者,改动面非常小。

  • 根治程度:中。它把"会丢候选"的时延区间从"超过 30 秒"压缩到"超过新死线",覆盖了实测时延分布,但本质仍是"赌大模型不会比死线更慢"——只要出现极端慢的生成或上游卡顿,到点静默空关、前端不补救的根本结构没变,候选仍会丢。
  • 资源占用SSE 连接会长开更久,单连接占用一个后台轮询线程的时间相应拉长;在高并发长生成下,线程池压力上升,需要关注轮询线程池容量。
  • 体验:长生成能稳定出候选,体验明显改善;但极端慢的情形仍会无声卡死。
  • 复杂度:最低,单点改动。
  • 兼容:不动 SSE 线格式与事件契约,前端无需任何配合,对其他走同一流路由的路径零影响。

候选② — 前端断连重连(根治侧重)。 给 AI 流加上断连重连:当流在非终态情况下结束,就按既有的那组递增退避时延自动重连、继续从库里把后续事件捞回来,直到真正读到完成、读到错误、或触及一个前端侧的总时长上限才停。意图是仿照全局系统事件流已经验证过的重连模式,让前端不再"一次性赌一程连接成功"。做法上,把现在那条一次性流改造成可重连的循环,复用现有的退避时延与解析器,并补上一个前端总时长上限作为最终止损。

  • 根治程度:高。它直击"连接被提前关闭后无人补救"这一根本,即便后端死线不变、即便偶发网络抖动断流,前端也能自己续上把候选捞回来;与全局事件流的容错模型归于一致。
  • 资源占用:重连期间会产生若干次额外的重放请求,每次重连后端都要从库里重读并回放已发生的事件,存在重复读放开销;需要确认重放是幂等、不会向界面重复渲染候选。
  • 体验:最稳,长生成、偶发断流都能恢复;代价是出候选可能比一程到底多等一个退避间隔。
  • 复杂度偏高。AI 流要新增连接状态机、重连计数、续传游标与前端总时长上限,协议复杂度上升;这条流当前注释明确写着"一次性、不续传",改造等于推翻这条既有设计决策,需要相应的测试覆盖。
  • 兼容:不改后端;但 AI 流从"一次性"变为"可重连"是行为语义变化,需回归确认重连不破坏既有的中止生成、候选去重与采纳入参归一。

候选③ — ①+② 组合(兜底加容错)。 后端把死线放足以覆盖时延上限作兜底,前端再加重连作容错。意图是让两道防线互补:后端死线负责让"正常偏慢"的生成根本不触发断连,前端重连负责兜住"极端慢或偶发断流"这类后端死线也兜不住的尾部情形。

  • 根治程度:最高。正常偏慢由后端窗口吸收、极端与异常由前端重连兜底,两类失败都被覆盖。
  • 资源占用:兼有①的长连接占用与②的重连重放开销,但因为后端窗口已放足,真正触发重连的频率会比单用②低,重放开销随之下降。
  • 体验:最稳,覆盖面最广。
  • 复杂度:等于①与②之和,是三者里最大的。
  • 兼容:同时涉及前后端,回归面最广,需要前后端联调与端到端长任务验证。

3. 影响面

这次改动触及的是 SSE 协议与时序、用户的 AI 生成体验,以及连接资源占用,需要把波及范围一次说清,避免改完才发现牵连。

SSE 协议与时序。 候选①只动死线数值、不动线格式与事件契约,对协议无感知影响。候选②把 AI 流从一次性变为可重连,虽然不改单条事件的格式,却改变了"连接生命周期"这一时序契约——它会依赖事件的顺序游标做续传、依赖后端把已发生事件可重放这一既有能力。所幸后端本就是"读库回放"模型、每次连接都会先重放历史事件,这为前端重连续传提供了天然支撑,但前端必须正确携带续传游标,才能避免重连后从头重放。

用户 AI 生成体验。 三个候选都直接改善长生成出候选的稳定性。需要专门保障的是不能从一个坏体验换出另一个坏体验:重连不能让界面重复渲染候选,也不能干扰用户主动「中止生成」的语义——用户点了中止就该彻底停,而不是被重连机制又拉起来。

连接资源占用。 这是候选①和③最需要盯的代价。死线放宽意味着每条长生成连接存活更久、相应占用后台轮询线程更久,高并发长生成场景下轮询线程池的容量与饱和拒绝策略必须复核——后端目前在轮询线程池饱和时是直接结束连接的,放宽死线后这条饱和路径被触发的概率会上升。候选②的重连则会带来额外的重放请求量。

与全局事件流的一致性。 候选②让 AI 流向全局系统事件流的重连模型靠拢这是正向收敛——两条流此后共用同一套退避与续传心智降低长期维护成本。反过来说如果只做候选①而不做②AI 流就继续是全局体系里唯一一条"不重连"的特例,这个不一致会长期留着。

回归面。 后端这条 SSE 路由的唯一入口是 streamTask,调它的流端点单一;前端这条一次性流的唯一业务调用方是写作台的 AI 面板。回归面是收敛的、清晰的,但正因为入口唯一,任何破坏都会全量影响 AI 生成主路径,验证必须到位。候选③因前后端同改,回归面是三者里最广的。


4. 验证计划

验证必须以"长任务能否稳定走通"为第一目标,而不是只看一次碰巧变绿。沿用项目"无自动化绿证据不得声称完成"的脊柱原则。

主验收:端到端长任务稳定转绿。 用现有的 AI 生成与采纳建议两个端到端用例(muse-studio/e2e/ai-generation.spec.tsaccept-suggestion.spec.ts),在大模型真实时延越过原 30 秒死线的条件下反复执行多轮,要求稳定全绿而非概率性绿——重点是消灭"由大模型时延决定成败"的 flaky 性。验证前必须确认后端带齐了大模型接入凭据(即第一层已固化),否则会回到秒失败的假象,污染结论。

连接与重连压力。 针对候选①③,在并发长生成下观察轮询线程池是否被长连接占满、是否频繁触发饱和拒绝,确认放宽死线没有把线程池压垮。针对候选②③,制造连接中途断开的情形,确认前端能按退避重连并最终捞回完成事件,且重连有总时长上限、不会无限重试;同时确认重连不会让界面重复渲染候选、不会与用户主动中止冲突。

数据落库不回归。 确认候选最终的库内落库行为与改前一致——这次改的是"事件怎么送到浏览器",不是"事件怎么生成与持久化",库里 suggestion 的产生与内容不应有任何变化。

回滚预案。 三个候选都是局部改动,回滚方式统一为 git checkout 还原对应文件,无数据迁移、无不可逆状态,回滚成本极低。


5. 推荐

推荐采用候选③(后端放宽死线兜底 + 前端断连重连容错),并同时落地第一层的 A1启动脚本固化加载 AI 凭据)。

理由是这次缺陷的本质是"长任务的容错缺失",而大模型时延天然不可控、还会随模型与负载漂移,任何单点修法都治不全。只做后端放宽死线(候选①)改动最小,但它只是把"会丢候选"的时延区间往后挪,赌的是大模型不会更慢,遇到极端慢或上游卡顿仍会无声卡死,治标不治本。只做前端重连(候选②)能根治"连接断了无人补救",但若后端死线仍是 30 秒,几乎每一次长生成都要靠重连去捞,等于把本可避免的断连变成常态、徒增重放开销与一次额外退避等待。把两者组合起来,后端窗口先把正常偏慢的生成稳稳吸收、让重连只在真正异常时才触发,前端重连再兜住后端窗口也兜不住的尾部,既根治又把代价控制在最低,还顺带把 AI 流收敛到与全局事件流一致的容错模型上。第一层 A1 是这一切验证能成立的前提,且改动极小,没有不做的理由。

若因排期必须分阶段,建议的落地顺序是:先 A1消除复发与误诊→ 再候选①(用最小改动立刻把绝大多数长生成救回来、快速止血)→ 后候选②(补齐根治与一致性)。这样每一步都独立可验、可回滚,且越往后越接近根治。

5.1 选项关系与覆盖边界(一图收束)

flowchart TD
    P["缺陷:长任务候选丢失"] --> L1["第一层:环境/启动"]
    P --> L2["第二层:SSE 时序代码缺陷"]

    L1 --> A1["A1 脚本固化加载 AI 凭据<br/>(消除复发,推荐)"]
    L1 -.弱.-> A2["A2 仅文档(靠自觉,不推荐)"]
    L1 -.补充.-> A3["A3 缺 AI 凭据启动告警(缩短误诊)"]

    L2 --> C1["候选① 后端放宽死线<br/>最小改 / 治标:赌时延上限"]
    L2 --> C2["候选② 前端断连重连<br/>根治断连 / 复杂度升·推翻一次性设计"]
    C1 --> C3["候选③ ①+② 组合<br/>正常偏慢靠后端窗口·尾部靠前端重连"]
    C2 --> C3

    C3 --> R["✅ 推荐:A1 + 候选③<br/>排期紧则 A1→①→②"]

    style A1 fill:#d8f0e0
    style C3 fill:#d8f0e0
    style R fill:#cfe8ff
    style A2 fill:#f0e0e0

6. 待人类 review 的关键决策点

以下三处需要人类拍板,文档不替决策、只把利弊摆清。

其一,后端死线放到多少、要不要做成可配。 实测大模型时延 1160 秒,而后端调用大模型的总时延上限已配成 180 秒。建议后端 SSE 死线不应低于上游总时延上限180 秒)再加 dispatcher 排队与回放余量,否则就会重演"SSE 比上游先关门"的同一类错配——只放到任务初稿提的 120 秒并不足以覆盖上游 180 秒的尾部。是否进一步做成可配置项(而非又一个写死常量)也请一并定夺,可配的好处是日后随模型调整免改代码,代价是多一个配置面。

其二,要不要上前端重连,即是否推翻 AI 流"一次性、不续传"的既有设计决策。 这是本方案复杂度与根治度的主要分水岭。上重连换来真正的容错与跨流一致性,代价是 AI 流协议复杂度上升、且需要把现有那条明确写着"一次性"的设计决策连同其注释一起改写并补测试。若评审倾向最小风险、接受"极端慢仍可能卡死"的残余风险,可暂缓②、先只做①与 A1。

其三,重连续传的幂等与候选去重边界谁来保证。 一旦决定上重连,必须明确:重连后后端重放已发生事件时,前端如何凭顺序游标避免把同一候选重复渲染,以及前端总时长上限取多少才算"够久但不无限"。这条不定清楚,重连可能把"丢候选"换成"重复候选或无限重试",反而引入新缺陷。