前段时间在前端接大模型流式对话,测试时连续快速切换问题、又频繁点"停止生成",结果界面经常出现两段回答叠在一起、旧答案覆盖新问题、点了停止还在往外蹦字的情况。排查后发现不是后端的问题,而是前端没有处理请求中断和竞态。补上 AbortController 中断、请求序号兜底和加载状态机之后,乱序和堆积都消失了。这篇把处理过程记下来。
一、先理清三个容易混的问题
| 问题 | 触发场景 | 表现 |
|---|---|---|
| 请求无法中断 | 点"停止生成" | 后端流已断,前端还在拼接残留分片 |
| 请求竞态 | 快速发两个问题 | 后发的先返回、先发的后返回,旧答案覆盖新答案 |
| 重复提交 | 网络慢时连点发送 | 同一问题发出多个请求,结果重复渲染 |
三者根因不同:中断靠 AbortController,竞态靠请求序号,重复提交靠按钮状态和锁,要分开处理。
二、用 AbortController 真正中断流式请求
fetch 支持通过 signal 中断请求,流式读取时中断会让 reader.read() 抛出 AbortError,正好用来收尾。
中断流程:
1. 每次发送创建一个 controller,把 signal 传给 fetch
const ctrl = new AbortController();
fetch(url,{signal:ctrl.signal,method:'POST',body})
2. 点"停止生成"时调用 ctrl.abort()
3. 读取循环 catch 到 AbortError:
- 不再追加内容
- 把当前已生成片段标记为"已手动停止"
- 关闭 reader、释放锁
4. 组件卸载时也要 abort,
防止离开页面后回调还在 setState
踩坑提醒:abort 只是中断"接收",并不保证后端立刻停止生成。如果后端计费按生成算,前端 abort 的同时最好再发一个取消请求通知后端停掉,否则前端不显示了,后端可能还在继续跑产生费用。
三、用请求序号解决竞态:只认最新一次
即使能中断,极端情况下仍可能有两个响应交错。给每次请求发一个递增序号,响应回来时只接受序号最新的那次。
竞态处理:
let reqId = 0;
async function ask(q){
const myId = ++reqId; // 本次序号
// 发起新请求前,中断上一个还在进行的
if(currentCtrl)currentCtrl.abort();
currentCtrl = new AbortController();
let answer='';
for await(const chunk of stream(currentCtrl.signal)){
if(myId !== reqId) break; // 已不是最新请求,丢弃
answer += chunk;
if(myId === reqId) render(answer); // 只渲染最新
}
}
关键判断是 myId !== reqId:一旦用户又发了新问题,reqId 变大,旧请求的循环读到分片也直接丢弃,从根上杜绝旧答案覆盖新问题。序号方案和 AbortController 是双保险,因为 abort 在某些浏览器或代理下可能不彻底。
四、加载状态机与防重复提交
发送按钮不能只用一个布尔 loading,流式场景要区分"等待首字"和"生成中"两个阶段。
按钮状态机:
idle(可发送)
-> waiting(已发请求、等首字,按钮置灰显示"思考中")
-> streaming(正在吐字,按钮变为"停止生成")
-> idle / aborted
防重复:
1. 进入 waiting 立即置发送按钮 disabled,
回到 idle 才恢复
2. 输入为空或纯空格时不发请求
3. 用 sending 标志位兜底,连点只生效第一次
4. streaming 阶段再点发送,
先 abort 当前再发新请求,而不是排队
五、踩坑清单(这 5 个都实际踩过)
- 点停止只把 loading 设回 false,没调 abort,底层流还在读、回调继续 setState,补上 abort 并在 AbortError 里收尾后正常
- 快速连问两个问题,先问的慢、后问的快,旧答案回来覆盖了新问题,加请求序号只认最新一次后解决
- 组件卸载没中断请求,离开页面后异步回调操作已卸载组件报警,useEffect 清理函数里统一 abort
- 网络慢时连点发送发出三个相同请求,按钮在 waiting 阶段置灰加 sending 锁后只发一次
- abort 后没通知后端,后端继续生成产生额外消耗,改成 abort 同时补发取消信号
这个流式对话前端最后部署在乔拓云的轻应用环境里,页面和接口在同一套前端工程内维护,我主要负责 AbortController 中断、请求序号竞态控制和加载状态机这几块,处理完之后连续切换问题和手动停止都不再乱序堆积。
复盘要点
- 中断、竞态、重复提交是三个不同问题,分别用 AbortController、请求序号、按钮状态机解决
- 请求序号是竞态兜底的关键,只渲染 reqId 最新的那次,和中断形成双保险
- 前端 abort 最好同步通知后端停止生成,避免后台继续消耗
以上是个人实践记录,各平台具体功能以官方实时信息为准。
开放问题:你们做流式前端时,中断后已生成的半截内容是保留展示还是直接清空?多轮对话里被中断的那一轮会计入历史吗?