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>
This commit is contained in:
parent
45ca6f4881
commit
09c35f5eee
@ -1,7 +1,7 @@
|
||||
# New-Design(新设计)V2 文档大纲(入口)
|
||||
|
||||
- 版本:v7
|
||||
- 更新日期:2026-06-20
|
||||
- 版本:v8
|
||||
- 更新日期:2026-06-25
|
||||
- 目标读者:产品 / 架构 / 前端 / 后端 / 文档维护者
|
||||
- 阅读时间:10–20 分钟
|
||||
- 边界说明:本文件只负责导航、边界和归属,不重复解释概念;概念解释必须落在对应主文档中。
|
||||
@ -102,6 +102,10 @@
|
||||
|
||||
说明:专题文档只负责跨文档收束,不抢走 Schema、状态机和统一 API 的单一归属。
|
||||
|
||||
### 临时件(评审/迁移依据,落定后归并或删除,不作长期 SSOT)
|
||||
|
||||
- [临时-01-AI流SSE长任务超时修复方案](临时-01-AI流SSE长任务超时修复方案.md)(评审版)——AI 真生成"长任务候选丢失"的 SSE 协议/时序 bug 修复方案对比,待人类 review 后执行;配套人读图 `.html`。SSE 接口语义归属仍属 `后端-05`,本件不重定义概念。
|
||||
|
||||
## 输入 / 输出闭环(你写的东西要能被别人用)
|
||||
|
||||
| 文档 | 输入(来自哪里) | 输出(给谁用) |
|
||||
|
||||
183
design-docs/临时-01-AI流SSE长任务超时修复方案.html
Normal file
183
design-docs/临时-01-AI流SSE长任务超时修复方案.html
Normal file
@ -0,0 +1,183 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>临时-01 · AI 流 SSE 长任务候选丢失修复方案(人读图)</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0f1419; --panel:#1a2129; --ink:#e6edf3; --sub:#9fb0bf; --line:#2b3641;
|
||||
--green:#3fb950; --green-bg:#11331c; --red:#f85149; --red-bg:#3a1718;
|
||||
--blue:#58a6ff; --blue-bg:#10243e; --amber:#d29922; --amber-bg:#3a2c0a;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;
|
||||
line-height:1.6;padding:32px 20px 80px}
|
||||
.wrap{max-width:1080px;margin:0 auto}
|
||||
h1{font-size:24px;margin:0 0 6px;letter-spacing:.3px}
|
||||
.meta{color:var(--sub);font-size:13px;margin-bottom:24px}
|
||||
.tldr{background:linear-gradient(135deg,#10243e,#142e1c);border:1px solid var(--line);
|
||||
border-radius:12px;padding:18px 22px;margin-bottom:28px;font-size:15px}
|
||||
.tldr b{color:var(--blue)}
|
||||
h2{font-size:17px;margin:34px 0 14px;padding-left:12px;border-left:4px solid var(--blue)}
|
||||
.grid2{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
||||
@media(max-width:760px){.grid2{grid-template-columns:1fr}}
|
||||
.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:18px}
|
||||
.card h3{margin:0 0 8px;font-size:15px}
|
||||
.card.layer1 h3{color:var(--amber)} .card.layer2 h3{color:var(--red)}
|
||||
.card p{margin:6px 0;font-size:13.5px;color:var(--sub)}
|
||||
.tag{display:inline-block;font-size:11px;padding:2px 9px;border-radius:20px;margin-bottom:8px;font-weight:600}
|
||||
.tag.fixed{background:var(--amber-bg);color:var(--amber);border:1px solid var(--amber)}
|
||||
.tag.bug{background:var(--red-bg);color:var(--red);border:1px solid var(--red)}
|
||||
/* 时序对比 */
|
||||
.flow{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:6px}
|
||||
@media(max-width:760px){.flow{grid-template-columns:1fr}}
|
||||
.path{border-radius:12px;padding:16px;border:1px solid var(--line)}
|
||||
.path.ok{background:var(--green-bg);border-color:#1f6f33}
|
||||
.path.bad{background:var(--red-bg);border-color:#7d2a2a}
|
||||
.path .ph{font-weight:700;font-size:14px;margin-bottom:10px;display:flex;align-items:center;gap:8px}
|
||||
.path.ok .ph{color:var(--green)} .path.bad .ph{color:var(--red)}
|
||||
.step{font-size:13px;padding:7px 0;border-top:1px dashed rgba(255,255,255,.08);color:var(--ink)}
|
||||
.step:first-of-type{border-top:none}
|
||||
.step .t{display:inline-block;min-width:52px;color:var(--sub);font-variant-numeric:tabular-nums}
|
||||
.verdict{margin-top:10px;font-weight:700;font-size:13.5px}
|
||||
.path.ok .verdict{color:var(--green)} .path.bad .verdict{color:var(--red)}
|
||||
/* 表格 */
|
||||
table{width:100%;border-collapse:collapse;margin-top:6px;font-size:13px;background:var(--panel);
|
||||
border-radius:12px;overflow:hidden}
|
||||
th,td{padding:11px 13px;text-align:left;border-bottom:1px solid var(--line);vertical-align:top}
|
||||
th{background:#222c36;color:var(--ink);font-weight:600;font-size:12.5px}
|
||||
td:first-child{font-weight:600;white-space:nowrap}
|
||||
.pill{display:inline-block;font-size:11px;padding:1px 8px;border-radius:5px;font-weight:600}
|
||||
.p-hi{background:var(--green-bg);color:var(--green)}
|
||||
.p-mid{background:var(--amber-bg);color:var(--amber)}
|
||||
.p-lo{background:var(--red-bg);color:var(--red)}
|
||||
.reco{background:var(--blue-bg);border:1px solid var(--blue);border-radius:12px;padding:18px 22px;margin-top:14px}
|
||||
.reco .big{font-size:16px;font-weight:700;color:var(--blue);margin-bottom:6px}
|
||||
.reco .order{font-size:13px;color:var(--sub);margin-top:8px}
|
||||
ol.decide{padding-left:20px}
|
||||
ol.decide li{margin:12px 0;font-size:13.5px}
|
||||
ol.decide li b{color:var(--amber)}
|
||||
.num{font-variant-numeric:tabular-nums;color:var(--blue);font-weight:700}
|
||||
.footer{margin-top:40px;color:var(--sub);font-size:12px;border-top:1px solid var(--line);padding-top:14px}
|
||||
code{background:#222c36;padding:1px 6px;border-radius:5px;font-size:12px;color:#ffd9a0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
|
||||
<h1>AI 流 SSE 长任务候选丢失 · 修复方案</h1>
|
||||
<div class="meta">临时-01 · 评审版 v1 · 2026-06-25 · 不改代码,供 review 后执行 · 配套主文档 临时-01-*.md</div>
|
||||
|
||||
<div class="tldr">
|
||||
用户长篇 AI 生成卡在「中止生成」、候选永不出现:根因是后端 SSE 在 <b>30 秒硬死线</b>到点后
|
||||
<b>静默关连接、不补发任何终态事件</b>,而前端 AI 流是<b>一次性连接、不重连</b>。一旦大模型真实时延
|
||||
(实测 11–60 秒)越过 30 秒,候选就被永久丢在后端库里送不到界面。
|
||||
推荐 <b>A1 启动固化 + 候选③(后端放宽死线 + 前端重连)</b>。
|
||||
</div>
|
||||
|
||||
<h2>两层根因(环境隐患 + 真代码缺陷,叠加才致命)</h2>
|
||||
<div class="grid2">
|
||||
<div class="card layer1">
|
||||
<span class="tag fixed">第一层 · 已手工绕过 / 未固化</span>
|
||||
<h3>环境与启动隐患</h3>
|
||||
<p>启动脚本只加载基础设施凭据,<b>漏加载大模型接入凭据</b>那一份配置。</p>
|
||||
<p>后端拿不到接入信息 → 装"不可用兜底"客户端 → AI 任务十几毫秒内秒失败报"接入不可用"。</p>
|
||||
<p>已手工带齐全部环境变量重启验证通过,但<b>脚本没改,下次重启复发</b>,会污染验证现场。</p>
|
||||
</div>
|
||||
<div class="card layer2">
|
||||
<span class="tag bug">第二层 · 真代码缺陷(前后端各一处)</span>
|
||||
<h3>SSE 协议 / 时序缺陷</h3>
|
||||
<p><b>后端:</b>连接死线与轮询上限都写死 30 秒;到点任务未完则只关连接,<b>不补 done、不补 error</b>。
|
||||
这 30 秒甚至短于后端等大模型的总窗口 180 秒。</p>
|
||||
<p><b>前端:</b>AI 流是一次性连接、<b>无重连</b>(全局事件流却带重连)。连接被提前关后,流自然结束、上层也不补救。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>因果对比:同一缺陷,被大模型时延切到两侧</h2>
|
||||
<div class="flow">
|
||||
<div class="path ok">
|
||||
<div class="ph">✓ 快路径 · 实测约 15 秒(< 30s)→ 碰巧绿</div>
|
||||
<div class="step"><span class="t">0s</span> 发起生成,建立一次性 SSE 连接</div>
|
||||
<div class="step"><span class="t">~15s</span> 大模型落 done 事件入库</div>
|
||||
<div class="step"><span class="t">~15s</span> 后端轮询读到 done → 推给前端</div>
|
||||
<div class="step"><span class="t">—</span> 前端渲染候选</div>
|
||||
<div class="verdict">候选正常出现 → accept-suggestion 用例 flaky 绿</div>
|
||||
</div>
|
||||
<div class="path bad">
|
||||
<div class="ph">✗ 慢路径 · 实测约 36 秒(> 30s)→ 稳定红</div>
|
||||
<div class="step"><span class="t">0s</span> 发起生成,建立一次性 SSE 连接</div>
|
||||
<div class="step"><span class="t">30s</span> 死线到,任务未完 → 后端静默关连接<br><b>不补 done / 不补 error</b></div>
|
||||
<div class="step"><span class="t">30s</span> 前端流自然结束 → <b>不重连</b></div>
|
||||
<div class="step"><span class="t">~36s</span> 大模型才落 done(晚约 8s)→ 候选只进了库</div>
|
||||
<div class="verdict">界面无候选也无报错,卡「中止生成」 → ai-generation 用例真红</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>修法选项与权衡(不夸大任何一项)</h2>
|
||||
<table>
|
||||
<thead><tr><th>选项</th><th>做法要点</th><th>根治度</th><th>复杂度</th><th>主要代价 / 权衡</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>第一层 A1<br>启动固化</td>
|
||||
<td>脚本一并加载大模型接入凭据,缺关键变量直接报错退出</td>
|
||||
<td><span class="pill p-hi">高·消除复发</span></td>
|
||||
<td><span class="pill p-hi">极低</span></td>
|
||||
<td>凭据成启动隐式依赖,需写明文档;<b>唯一从源头止复发,强烈建议必做</b></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>候选①<br>后端放宽死线</td>
|
||||
<td>把 30s 死线放宽到覆盖时延上限、尽量可配;一处同时管连接与轮询</td>
|
||||
<td><span class="pill p-mid">中·治标</span></td>
|
||||
<td><span class="pill p-hi">最低</span></td>
|
||||
<td>仍在赌大模型不更慢,极端慢仍无声卡死;SSE 长开占轮询线程更久</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>候选②<br>前端断连重连</td>
|
||||
<td>非终态结束按递增退避重连续传,至 done/error/前端总时长上限止</td>
|
||||
<td><span class="pill p-hi">高·根治断连</span></td>
|
||||
<td><span class="pill p-lo">偏高</span></td>
|
||||
<td>推翻"一次性"既有设计、协议变复杂;重连有重放开销,须保候选去重幂等</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>候选③<br>①+②组合</td>
|
||||
<td>后端窗口吸收正常偏慢,前端重连兜住极端慢与偶发断流</td>
|
||||
<td><span class="pill p-hi">最高</span></td>
|
||||
<td><span class="pill p-lo">最大</span></td>
|
||||
<td>前后端同改、回归面最广;但后端放足后重连频率低、重放代价随之降</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2>推荐</h2>
|
||||
<div class="reco">
|
||||
<div class="big">✅ A1 启动固化 + 候选③(后端放宽死线兜底 + 前端重连容错)</div>
|
||||
<div>缺陷本质是<b>长任务容错缺失</b>,大模型时延不可控且会漂移,单点修法治不全:
|
||||
只做①是把丢候选区间后移、仍赌时延;只做②则每次长生成都靠重连去捞、徒增开销。
|
||||
组合后正常偏慢由后端窗口稳稳吸收、重连只在真异常时触发,既根治又把代价压到最低,
|
||||
还顺带把 AI 流收敛到与全局事件流一致的容错模型。</div>
|
||||
<div class="order">排期紧时分阶段(每步独立可验可回滚):<b>A1 → 候选①(最小改快速止血)→ 候选②(补根治与一致性)</b></div>
|
||||
</div>
|
||||
|
||||
<h2>待人类拍板的三个关键决策点</h2>
|
||||
<ol class="decide">
|
||||
<li><b>后端死线放到多少 / 要不要可配?</b>
|
||||
时延实测 11–60s,上游总窗口已配 <span class="num">180s</span>。建议 SSE 死线
|
||||
<b>不低于上游 180s 再加排队与回放余量</b>——初稿提的 120s 不足以覆盖上游尾部,会重演"SSE 比上游先关门"。是否做成可配项请一并定。</li>
|
||||
<li><b>要不要上前端重连,即是否推翻 AI 流"一次性、不续传"的既有设计?</b>
|
||||
这是复杂度与根治度的分水岭。上重连换来真容错与跨流一致,代价是协议变复杂、需改写既有设计决策并补测试;若求最小风险、接受"极端慢仍可能卡死",可暂缓②、先做①与 A1。</li>
|
||||
<li><b>重连续传的幂等与候选去重边界谁保证?</b>
|
||||
一旦上重连,须明确:后端重放已发生事件时前端如何凭顺序游标避免重复渲染候选、前端总时长上限取多少。否则可能把"丢候选"换成"重复候选 / 无限重试"。</li>
|
||||
</ol>
|
||||
|
||||
<div class="footer">
|
||||
锚点(仅定位,不贴实现):后端 <code>MuseAiTaskStreamServiceImpl.DEFAULT_TIMEOUT_MILLIS</code>(连接+轮询双死线);
|
||||
前端 <code>sse.ts · connectAIStream</code>(一次性)对照 <code>connectEventStream</code>(带重连退避);
|
||||
启动 <code>start-muse-server-infra.sh</code>;调用方 <code>AIPanel.tsx</code>;
|
||||
验收 <code>e2e/ai-generation.spec.ts</code>、<code>e2e/accept-suggestion.spec.ts</code>。回滚=<code>git checkout</code>。
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
186
design-docs/临时-01-AI流SSE长任务超时修复方案.md
Normal file
186
design-docs/临时-01-AI流SSE长任务超时修复方案.md
Normal file
@ -0,0 +1,186 @@
|
||||
# 临时-01 · AI 流 SSE 长任务候选丢失修复方案(评审版)
|
||||
|
||||
- 版本:v1(评审版,待人类 review 后执行)
|
||||
- 更新日期:2026-06-25
|
||||
- 目标读者:架构 / 后端 / 前端 / QA / PR reviewer
|
||||
- 阅读时间:12–18 分钟
|
||||
- 文档性质:**临时件**(迁移依据,落定后归并入正式分册或删除)。本稿只做方案对比与决策收束,不改任何代码、不定义新概念;术语以 `架构-02` 为准,SSE 接口语义归属 `后端-05`。
|
||||
- 配套人读图:[`临时-01-AI流SSE长任务超时修复方案.html`](临时-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 慢路径红,同一缺陷两侧)
|
||||
|
||||
```mermaid
|
||||
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.ts`、`accept-suggestion.spec.ts`),在大模型真实时延越过原 30 秒死线的条件下反复执行多轮,要求稳定全绿而非概率性绿——重点是消灭"由大模型时延决定成败"的 flaky 性。验证前必须确认后端带齐了大模型接入凭据(即第一层已固化),否则会回到秒失败的假象,污染结论。
|
||||
|
||||
**连接与重连压力。** 针对候选①③,在并发长生成下观察轮询线程池是否被长连接占满、是否频繁触发饱和拒绝,确认放宽死线没有把线程池压垮。针对候选②③,制造连接中途断开的情形,确认前端能按退避重连并最终捞回完成事件,且重连有总时长上限、不会无限重试;同时确认重连不会让界面重复渲染候选、不会与用户主动中止冲突。
|
||||
|
||||
**数据落库不回归。** 确认候选最终的库内落库行为与改前一致——这次改的是"事件怎么送到浏览器",不是"事件怎么生成与持久化",库里 suggestion 的产生与内容不应有任何变化。
|
||||
|
||||
**回滚预案。** 三个候选都是局部改动,回滚方式统一为 `git checkout` 还原对应文件,无数据迁移、无不可逆状态,回滚成本极低。
|
||||
|
||||
---
|
||||
|
||||
## 5. 推荐
|
||||
|
||||
**推荐采用候选③(后端放宽死线兜底 + 前端断连重连容错),并同时落地第一层的 A1(启动脚本固化加载 AI 凭据)。**
|
||||
|
||||
理由是这次缺陷的本质是"长任务的容错缺失",而大模型时延天然不可控、还会随模型与负载漂移,任何单点修法都治不全。只做后端放宽死线(候选①)改动最小,但它只是把"会丢候选"的时延区间往后挪,赌的是大模型不会更慢,遇到极端慢或上游卡顿仍会无声卡死,治标不治本。只做前端重连(候选②)能根治"连接断了无人补救",但若后端死线仍是 30 秒,几乎每一次长生成都要靠重连去捞,等于把本可避免的断连变成常态、徒增重放开销与一次额外退避等待。把两者组合起来,后端窗口先把正常偏慢的生成稳稳吸收、让重连只在真正异常时才触发,前端重连再兜住后端窗口也兜不住的尾部,既根治又把代价控制在最低,还顺带把 AI 流收敛到与全局事件流一致的容错模型上。第一层 A1 是这一切验证能成立的前提,且改动极小,没有不做的理由。
|
||||
|
||||
若因排期必须分阶段,建议的落地顺序是:先 A1(消除复发与误诊)→ 再候选①(用最小改动立刻把绝大多数长生成救回来、快速止血)→ 后候选②(补齐根治与一致性)。这样每一步都独立可验、可回滚,且越往后越接近根治。
|
||||
|
||||
### 5.1 选项关系与覆盖边界(一图收束)
|
||||
|
||||
```mermaid
|
||||
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 的关键决策点
|
||||
|
||||
以下三处需要人类拍板,文档不替决策、只把利弊摆清。
|
||||
|
||||
**其一,后端死线放到多少、要不要做成可配。** 实测大模型时延 11–60 秒,而后端调用大模型的总时延上限已配成 180 秒。建议后端 SSE 死线**不应低于上游总时延上限(180 秒)再加 dispatcher 排队与回放余量**,否则就会重演"SSE 比上游先关门"的同一类错配——只放到任务初稿提的 120 秒并不足以覆盖上游 180 秒的尾部。是否进一步做成可配置项(而非又一个写死常量)也请一并定夺,可配的好处是日后随模型调整免改代码,代价是多一个配置面。
|
||||
|
||||
**其二,要不要上前端重连,即是否推翻 AI 流"一次性、不续传"的既有设计决策。** 这是本方案复杂度与根治度的主要分水岭。上重连换来真正的容错与跨流一致性,代价是 AI 流协议复杂度上升、且需要把现有那条明确写着"一次性"的设计决策连同其注释一起改写并补测试。若评审倾向最小风险、接受"极端慢仍可能卡死"的残余风险,可暂缓②、先只做①与 A1。
|
||||
|
||||
**其三,重连续传的幂等与候选去重边界谁来保证。** 一旦决定上重连,必须明确:重连后后端重放已发生事件时,前端如何凭顺序游标避免把同一候选重复渲染,以及前端总时长上限取多少才算"够久但不无限"。这条不定清楚,重连可能把"丢候选"换成"重复候选或无限重试",反而引入新缺陷。
|
||||
Loading…
x
Reference in New Issue
Block a user