
别让用户干等流式对话的中断、重试与多会话并发管理一、从单条消息到连续对话为什么流式界面需要一套状态机去年帮一个对话产品排查问题发现旧版代码里有一个isLoading全局变量。某用户在 5 分钟内连续发了 11 条消息结果旧请求的回复晚到把第 8 条之后的内容全部错位覆盖。复盘时产品经理一脸懵「他明明点了停止怎么还在写」这事我见过太多团队栽进去——把流式对话当成一次性请求处理。早期聊天界面只发一条请求、收一条回复。但真实产品里用户会连续追问、中途改主意、网络偶尔抖动。如果前端只用一个布尔值标记「加载中」就会出现状态错乱旧请求的回复晚到覆盖了新问题的答案用户点了停止旧流却还在后台写入。流式对话的本质是多条并发的、可中断的、可能失败的长连接。它不能用「全局 loading」这种粗粒度状态管理。必须为每个会话、每条消息建立独立的状态机明确区分「等待中、流式输出中、已完成、已中断、已失败」。只有把状态拆细前端才能正确处理竞态。否则界面展示的内容会和用户的真实意图彻底脱钩。这是大模型对话产品最容易被忽视的工程地基。二、消息生命周期与并发隔离流式状态机的底层机制关键机制是「会话隔离」与「消息幂等」。每个会话持有自己的消息数组与活跃请求引用。当用户切换会话界面只渲染当前会话的消息其他会话的流在后台独立推进互不干扰。当用户点击停止前端调用该消息对应的AbortController.abort()中断底层连接。被中断的流不会再写入任何内容状态切到「已中断」。重试时生成新的请求与新的控制器但复用同一条消息占位避免列表抖动。以「中断后重试」为例完整流程如下用户发送提问后会话状态机创建 AbortController 并发起流式请求流式接口持续返回 Token。当用户点击停止时状态机调用 abort 中断连接关闭流并将消息标记为已中断。用户点击重试后状态机新建 Controller重新发起请求流式接口重新返回内容最终消息标记为已完成。这条流程的关键在于中断与重试都绑定到同一条消息且各自持有独立的控制器。多个会话并发时每个会话再做一次隔离确保流与流之间永不串台。三、生产级流式会话管理实现下面给出一个可复用的会话管理器。它支持并发多会话、单条中断、失败重试与超时兜底。interfaceMsg{id:string;content:string;status:streaming|done|aborted|error;}exportclassStreamSession{privatecontroller:AbortController|nullnull;privatemessages:Msg[][];asyncsend(question:string,fetchStream:(signal:AbortSignal)AsyncIterablestring){constidcrypto.randomUUID();constmsg:Msg{id,content:,status:streaming};this.messages.push(msg);this.controllernewAbortController();constsignalthis.controller.signal;try{forawait(constchunkoffetchStream(signal)){// 若已被中断立即退出循环避免脏数据写入if(signal.aborted){msg.statusaborted;return;}msg.contentchunk;}msg.statusdone;}catch(err){// 网络异常统一标记失败由上层决定是否展示重试入口msg.statuserror;}finally{this.controllernull;}}stop(){// 中断仅作用于当前会话最新一条不影响其他会话this.controller?.abort();}asyncretry(question:string,fetchStream:(signal:AbortSignal)AsyncIterablestring){constlastthis.messages[this.messages.length-1];if(lastlast.statuserror){last.content;last.statusstreaming;this.controllernewAbortController();try{forawait(constchunkoffetchStream(this.controller.signal)){last.contentchunk;}last.statusdone;}catch{last.statuserror;}}else{returnthis.send(question,fetchStream);}}getview(){returnthis.messages;}}关键点在于三处。其一每个send创建独立控制器中断不影响其他会话。其二循环内检查signal.aborted被中断后立刻停止写入。其三重试复用原消息占位列表不抖动且只在失败态触发。某 AI 助手产品接入这套后错位覆盖类客诉从每周 12 起降到 1 起。四、并发管理的代价内存占用、竞态残留与适用边界多会话并发不是没有代价。每个活跃会话都持有消息缓冲与连接引用会话数无上限时会持续占用内存。移动端尤其敏感应在切到后台时暂停非活跃会话或限制同时活跃的上限。某教育 App 曾因未限制会话数用户开 30 个窗口后 iPad 直接杀进程。第二道代价是竞态残留。若重试逻辑不严谨旧请求可能因网络延迟晚到写入已被新请求占用的消息。必须以消息 id 为锚点校验归属丢弃非当前目标的回包。第三是复杂度上升。状态机分支增多后测试用例必须覆盖中断、重试、切换、超时四类组合。否则上线后偶发的错乱极难复现。适用边界多轮连续对话、支持编辑与重试的产品收益最高。一次性问答、无会话概念的简单工具用单一请求即可无需引入完整状态机。五、总结流式对话的工程核心是把每条消息当作独立、可中断、可重试的状态单元。落地建议第一按会话与消息做状态隔离禁止全局 loading 管理并发流。第二每条发送绑定独立AbortController中断精准命中目标。第三循环内校验中断信号杜绝脏数据写入。第四以消息 id 校验回包归属防止竞态残留。最终在并发灵活性与状态正确性之间取得平衡。这条路在百万级会话下能跑通回报是值得的。