从一次“重新发送 / 重新生成”开始,聊聊流式聊天状态机到底解决了什么问题
从一次“重新发送 / 重新生成”开始聊聊流式聊天状态机到底解决了什么问题最近在整理自己的 HarmonyOS 聊天 Demo发现聊天页里有一个问题很容易被忽略AI 回复失败以后到底应该让用户点“重新发送”还是点“重新生成”这个问题看起来只是一个按钮文案问题但真正往下想会发现它其实牵出了整个流式聊天模块的状态设计。比如用户消息什么时候算发送成功 AI 消息什么时候从“思考中”变成“生成中” 最后一帧 done 里如果带 error算成功还是失败 流式返回一半断了应该让用户重新发送问题还是让 AI 重新生成回答 历史记录恢复时如果某条消息还停在 STREAMING应该怎么处理这些问题如果一开始没有想清楚代码很容易变成一堆if else、魔法字符串和临时兜底。所以这次重构我没有继续在旧逻辑上补判断而是把聊天消息抽象成了一套更明确的状态机并且把错误也类型化。这篇文章就从一个最常见的场景开始聊聊这次重构到底解决了什么问题。一. 先想一个最简单的需求假设用户在聊天框里输入帮我生成一份日报然后点击发送。站在用户角度他只看到两件事我的问题发出去了 AI 开始回复了但站在代码角度这个过程其实至少包含两条消息userMessage用户发出的那条问题 aiMessageAI 即将生成的那条回复所以正常发送时代码要先做几件事创建用户消息 userMessage状态为 SENDING 创建 AI 占位消息 aiMessage状态为 THINKING 生成历史上下文快照 historySnapshot 把这两条消息 push 到 historyMessage 调用 runStream 发起 SSE 流式请求也就是说用户点一次发送页面上其实先插入了两条消息。用户消息负责展示“我刚刚问了什么”。AI 消息负责接收后续一段段流式返回的内容。二. 最粗暴的方案用字符串和 boolean 硬判断一开始最容易想到的写法是全局用 isLoading 判断是否正在生成 AI 空内容时显示“AI 思考中...” 失败时把 content 改成“生成失败请稍后重试” 停止时往正文里拼一个 “[已停止]”这种写法能不能跑能。但问题也很明显。第一消息状态不清楚。同样是content为空它可能代表AI 正在思考 AI 没有收到任何回复 历史记录里内容丢失 刚创建了占位消息这些情况在 UI 上应该是不一样的但如果只靠content.length 0判断就很容易混在一起。第二失败原因不清楚。如果直接写aiMessage.content生成失败请稍后重试那这句话到底代表什么断网了 接口超时了 服务端返回业务错误 done 帧里没有任何文本和卡片用户看到的都是失败但开发时排查问题完全不是一回事。第三UI 逻辑会被文案绑架。如果代码里到处判断content生成失败请稍后重试content.includes([已停止])那以后产品说文案要改成“服务开小差了”逻辑也可能跟着出问题。这就是魔法字符串的问题它看起来只是文案实际上却偷偷承担了状态判断的职责。三. 为什么需要 MessageStatus这次重构里我新建了MessageStatus.ets。核心就是把一条消息可能处于的状态明确列出来exportenumMessageStatus{SENDINGsending,THINKINGthinking,STREAMINGstreaming,DONEdone,STOPPEDstopped,FAILEDfailed,}这样以后看一条消息不需要猜它现在是什么情况直接看status就行。用户消息主要有三种状态SENDING用户消息已加入列表等待服务端受理 DONE服务端已经受理这条用户消息发送成功 FAILED消息没发出去可以重新发送AI 消息主要有五种状态THINKING请求已受理等待首个 token STREAMING正在流式输出 DONE正常完成 STOPPED用户主动停止生成 FAILED生成失败可以重新生成这里有一个很关键的问题为什么 STREAMING 不是终态因为STREAMING只是一个过程。它后面还会继续变化STREAMING - DONE STREAMING - FAILED STREAMING - STOPPED真正的终态是DONE STOPPED FAILED终态的意思是这条消息不会再自己继续变化了。除非用户手动点击“重新发送”或者“重新生成”否则它就停在这里。所以MessageStatus.ets里还加了一个isTerminalexportfunctionisTerminal(status:MessageStatus):boolean{returnstatusMessageStatus.DONE||statusMessageStatus.STOPPED||statusMessageStatus.FAILED}这个函数在历史记录恢复时很有用。比如 App 被杀掉之前某条消息还处于STREAMING。等下次打开 App 时这条流不可能继续接上。所以读历史时不能让它继续显示“正在生成”而应该把中途态统一改成失败态让用户可以重新操作。四. 为什么还需要 ChatError有了消息状态以后还需要解决另一个问题失败原因。以前失败可能只是一个文案生成失败请稍后重试但这次重构里我把错误拆成了类型exportenumChatErrorType{NETWORK_ERRORNETWORK_ERROR,SERVER_ERRORSERVER_ERROR,EMPTY_REPLYEMPTY_REPLY,}这三个错误分别对应三种情况。第一种是网络错误断网 超时 连接被重置 SSE 请求异常这种通常来自onError。第二种是服务端业务错误SSE 本身正常结束 但是 done 帧里的 meta.error 告诉前端这次业务失败了这种必须在onDone里处理。第三种是空回复流正常结束 但是既没有文本也没有卡片这种不能当成功否则页面上会出现一条空的 AI 回复。所以这次重构里错误不再直接写成用户文案而是先变成ChatErrorChatError.network(errMsg)ChatError.server(meta.error)ChatError.empty()然后再通过error.toUserHint()映射成用户能看到的提示。这样做的好处是日志里能看到真实错误类型 UI 文案可以集中管理 业务逻辑不用再 match 某一句中文五. 为什么 meta.error 必须在 onDone 里处理这个点一开始很容易想错。很多人会觉得既然失败了那不就应该走 onError 吗但 SSE 里不一定是这样。onError更偏网络层或者请求层错误。比如请求发不出去 连接断了 网络超时但还有一种情况是请求正常发出去了 服务端也正常返回了最后一帧 done 只是 done 帧里告诉你这次业务失败这时候网络是成功的SSE 也是正常结束的所以不会走onError。如果前端只处理onError就会把这种业务错误误判成成功。所以runStream里的onDone需要先判断if(meta.error){this.finalizeFailure(ChatError.server(meta.error))return}然后再判断是否真的有内容consthasContentthis.activeAiMessage!nullthis.activeAiMessage.content.length0consthasCardthis.activeAiMessage!nullthis.activeAiMessage.card!nullif(!hasContent!hasCard){this.finalizeFailure(ChatError.empty())return}也就是说done不等于成功。真正成功至少要满足没有 meta.error 并且有文本内容或者有卡片数据六. runStream 到底统一了什么这次ChatController.ets最大的变化是把三类入口都收口到runStream。三类入口分别是sendMessage用户正常输入并发送 resendMessage用户消息失败后重新发送 regenerateAI 回复失败或停止后重新生成这三个入口看起来不一样但真正发起 SSE 请求、处理 chunk、处理 done、处理 error 的流程是一样的。如果每个入口都写一遍流式逻辑后面一定会出问题。比如sendMessage 处理了 meta.error resendMessage 忘了处理 meta.error regenerate 忘了清空 card 某个入口忘了清 currentRequest 某个入口 onDone 和 onError 重复收尾所以更合理的做法是三个入口只负责准备现场 runStream 负责统一执行流式流程正常发送时读取 inputContent 创建 userMessage状态 SENDING 创建 aiMessage状态 THINKING 在 push 新消息前生成历史快照 把两条消息加入 historyMessage 清空输入框 调用 runStream重新发送时拿到之前 FAILED 的用户消息 做空值和下标安全检查 复用这条 userMessage 把它改回 SENDING 重新插入一个 AI 占位消息 调用 runStream重新生成时拿到 UI 传入的 AI 消息 往前找到最近的一条用户消息作为 prompt 复用这条 AI 气泡 清空 content 和 card 把 AI 状态改回 THINKING 调用 runStream这里最重要的区别是重新发送复用用户消息重新创建 AI 占位 重新生成复用 AI 消息用户消息不重新创建这样就能避免重复气泡。七. 首个 chunk 到底代表什么流式请求开始后AI 还没有立刻返回完整内容。但只要收到了第一个 chunk就说明一件事这条用户消息已经被服务端受理了所以onChunk里会做两个状态推进userMessage: SENDING - DONE aiMessage: THINKING - STREAMING然后把 chunk 追加到 AI 消息this.activeAiMessage.content(chunk?chunk:)这个设计很自然。用户消息一旦被服务端受理就不应该继续显示发送中。AI 一旦开始吐字就不应该继续显示思考中。所以首个 chunk 是一个很关键的分界点。八. 失败时为什么要分“重新发送”和“重新生成”这是这次重构里最容易理解但也最容易写错的地方。失败以后不能一律显示“重试”。因为失败可能发生在两个不同阶段。第一种情况AI 一个字都没返回。这说明用户消息可能还没有真正完成这一轮请求。这种情况下应该把用户消息标记为失败userMessage.status FAILEDUI 上显示重新发送第二种情况AI 已经返回了一半。比如今天完成了聊天模块的状态机重构主要包括...然后网络断了。这时候用户消息肯定已经被服务端接收了失败的是 AI 生成过程。所以应该把用户消息标记为成功userMessage.status DONE然后把 AI 消息标记为失败aiMessage.status FAILED aiMessage.errorHint error.toUserHint()UI 上显示重新生成这就是“按有没有半截内容分流”的核心。代码里的判断大概就是consthasPartialai!null(ai.content.length0||ai.card!null)如果有半截内容说明失败发生在 AI 回复阶段。如果没有半截内容再根据当前入口是否允许用户重发来决定怎么收尾。九. stopGeneration 为什么不是重新发送这里也很容易混。用户点击停止生成时不是重新发送。停止只是把当前请求中断掉destroy 当前请求 让旧请求回调失效 AI 消息状态改成 STOPPED 用户消息状态改成 DONE 解锁 isLoading也就是说停止生成不会立刻再次调用runStream。它只是把当前这轮变成终态aiMessage.status STOPPED如果用户后面想继续让 AI 回答再点“重新生成”那才会进入regenerate。这次重构还有一个细节不再把[已停止]拼进正文。以前可能会这样AI 正文内容 [已停止]但这会污染真实回复内容。现在更合理的做法是正文还是正文 停止状态交给 status 表达 UI 根据 STOPPED 渲染“已停止生成”状态和正文分开后续保存历史也更干净。十. 为什么要加 requestSeq 和 finalized流式请求还有一个隐蔽问题回调可能乱序或者重复触发。比如用户点击停止以后旧请求可能还有残留回调回来。如果不处理旧回调可能继续改historyMessage。所以这次加了requestSeq。每发起一轮请求序号自增seq requestSeq回调里先判断如果当前 seq 已经过期就直接 return这样旧请求就不能再污染新状态。另外还有finalized。它解决的是另一类问题onDone 和 onError 都触发了怎么办 onDone 里已经失败收尾后面又来了一个 error 怎么办所以每轮请求只允许 finalize 一次。这两个字段看起来不起眼但它们让流式请求收尾更稳。十一. 为什么序列化要下沉到模型聊天记录需要持久化。但 UI 用的ChatMessage不是普通对象它里面有ObservedV2 Trace content Trace card Trace status这种响应式对象不适合直接存储。所以项目里分了两套模型ChatMessageUI 层使用负责响应式刷新 ChatMessagePlain持久化使用负责 RDB / JSON 读写以前如果在 Controller 里手写转换就会变成Controller 既要管发送请求 又要管保存会话 还要管每个字段怎么拷贝职责就混在一起了。所以这次把转换逻辑下沉到模型ChatMessage.fromPlain(...)message.toPlain(...)这样职责就清楚了ChatMessage 自己知道怎么从 plain 恢复 ChatMessage 自己知道怎么转成 plain ChatSessionController 只负责什么时候读取、什么时候保存还有一个很重要的点fromPlain里会做中途态归一化。比如历史记录里读到SENDING THINKING STREAMING这些状态在 App 重启后都不可能继续自动流转。所以应该统一改成FAILED这样用户打开历史记录时不会看到一条永远转圈的消息。十二. 为什么要删除 ChatPersist.ets项目之前有一套 Preferences 版本的聊天持久化。后来已经切到 RDB也就是ChatRdb.ets。这个时候旧的ChatPersist.ets如果还留着就会造成一种错觉项目里好像有两套聊天记录存储 到底该改 Preferences 还是 RDB 历史记录到底从哪里读 删除逻辑到底在哪一套所以删除死代码本身也是重构的一部分。它的意义不是少一个文件而是减少误导。现在项目里只保留一套明确的持久化方案ChatRdb 负责聊天会话和消息落库 ChatSessionController 负责调度读写 ChatMessage.fromPlain / toPlain 负责模型转换这条链路比之前清楚很多。十三. 录屏时应该展示什么这次功能不太适合只截图因为状态变化是动态的。更建议录一个 1 分钟左右的视频按下面顺序展示1. 正常发送一条消息 2. 展示“思考中”变成流式输出 3. 等最后一帧完成消息进入 DONE 4. 模拟断网或服务端错误展示用户消息 FAILED 5. 点击“重新发送” 6. 再模拟 AI 已经输出一半后失败展示 AI 消息 FAILED 7. 点击“重新生成” 8. 展示停止生成后出现“已停止生成” 9. 退出再进入历史记录确认历史消息恢复正常录屏时可以重点口播这几句话这次不是单纯加了一个重试按钮而是把聊天消息拆成了状态机。 用户消息失败和 AI 生成失败不是一回事所以 UI 上分别对应重新发送和重新生成。 done 帧不一定代表成功因为 done 里也可能带 meta.error。 历史记录恢复时中途态不能继续转圈所以会归一化成 FAILED。这样别人看视频时不只是看到按钮能点而是能理解你为什么这么设计。十四. UI 样式可以怎么顺手优化这个功能本身偏逻辑但如果想让演示更直观可以给状态加一点轻量 UI。比如THINKING状态可以做一个小的动态 loading 图标。如果项目里想做得更有辨识度可以做一个类似“大风车”的旋转图标AI 思考中小风车慢速旋转 STREAMING风车旋转 文本逐段出现 FAILED风车停止显示错误提示和操作按钮 STOPPED显示一条“已停止生成”的分割提示背景色也可以稍微区分状态THINKING浅蓝灰背景表达等待 STREAMING普通聊天背景表达正在输出 FAILED浅红或浅橙提示不要太刺眼 STOPPED浅灰分割线表达用户主动中断 DONE正常展示不突出状态按钮也可以按语义区分重新发送放在用户气泡旁边 重新生成放在 AI 回复下方 停止生成放在输入框发送按钮位置这样 UI 和状态机是对应的。不是为了炫技加动效而是让用户一眼知道现在到底是正在想、正在生成、失败了、还是我主动停了十五. 总结这次重构最核心的收获不是多写了几个文件而是把原来模糊的聊天流程拆清楚了。以前可能是靠 isLoading 判断生成中 靠 content 为空判断思考中 靠某句中文判断失败 靠拼接 [已停止] 表示停止现在变成MessageStatus 表达消息状态 ChatError 表达失败原因 ChatConstants 收口角色和文案 runStream 统一处理 SSE 生命周期 fromPlain / toPlain 负责模型转换 ChatRdb 负责唯一的持久化实现整个聊天流程也更清楚了用户发送 ↓ userMessage SENDING aiMessage THINKING ↓ 首个 chunk 返回 ↓ userMessage DONE aiMessage STREAMING ↓ done 帧成功 ↓ aiMessage DONE失败时也不再一刀切AI 没有半截内容用户消息 FAILED显示重新发送 AI 已有半截内容AI 消息 FAILED显示重新生成 用户主动停止AI 消息 STOPPED后续可重新生成所以状态机不是为了把代码写复杂。它恰恰是为了解决一个真实问题当聊天流程里出现发送中、思考中、生成中、完成、失败、停止这些状态时 我们不能再靠字符串和临时判断猜消息处于哪里。把状态、错误、文案、序列化和请求流程拆清楚以后代码反而更容易维护。后面再加更多卡片类型、更多错误类型、更多重试入口也不会乱成一团。这也是我这次最大的体会流式聊天最难的不是把 chunk 追加到页面上 而是把每一条消息在每个时刻到底处于什么状态讲清楚。