news 2026/6/15 21:15:05

从一次“重新发送 / 重新生成”开始,聊聊流式聊天状态机到底解决了什么问题

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从一次“重新发送 / 重新生成”开始,聊聊流式聊天状态机到底解决了什么问题

从一次“重新发送 / 重新生成”开始,聊聊流式聊天状态机到底解决了什么问题

最近在整理自己的 HarmonyOS 聊天 Demo,发现聊天页里有一个问题很容易被忽略:

AI 回复失败以后,到底应该让用户点“重新发送”,还是点“重新生成”?

这个问题看起来只是一个按钮文案问题,但真正往下想,会发现它其实牵出了整个流式聊天模块的状态设计。

比如:

用户消息什么时候算发送成功? AI 消息什么时候从“思考中”变成“生成中”? 最后一帧 done 里如果带 error,算成功还是失败? 流式返回一半断了,应该让用户重新发送问题,还是让 AI 重新生成回答? 历史记录恢复时,如果某条消息还停在 STREAMING,应该怎么处理?

这些问题如果一开始没有想清楚,代码很容易变成一堆if else、魔法字符串和临时兜底。

所以这次重构,我没有继续在旧逻辑上补判断,而是把聊天消息抽象成了一套更明确的状态机,并且把错误也类型化。

这篇文章就从一个最常见的场景开始,聊聊这次重构到底解决了什么问题。

一. 先想一个最简单的需求

假设用户在聊天框里输入:

帮我生成一份日报

然后点击发送。

站在用户角度,他只看到两件事:

我的问题发出去了 AI 开始回复了

但站在代码角度,这个过程其实至少包含两条消息:

userMessage:用户发出的那条问题 aiMessage:AI 即将生成的那条回复

所以正常发送时,代码要先做几件事:

创建用户消息 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{SENDING='sending',THINKING='thinking',STREAMING='streaming',DONE='done',STOPPED='stopped',FAILED='failed',}

这样以后看一条消息,不需要猜它现在是什么情况,直接看status就行。

用户消息主要有三种状态:

SENDING:用户消息已加入列表,等待服务端受理 DONE:服务端已经受理,这条用户消息发送成功 FAILED:消息没发出去,可以重新发送

AI 消息主要有五种状态:

THINKING:请求已受理,等待首个 token STREAMING:正在流式输出 DONE:正常完成 STOPPED:用户主动停止生成 FAILED:生成失败,可以重新生成

这里有一个很关键的问题:

为什么 STREAMING 不是终态?

因为STREAMING只是一个过程。

它后面还会继续变化:

STREAMING -> DONE STREAMING -> FAILED STREAMING -> STOPPED

真正的终态是:

DONE STOPPED FAILED

终态的意思是:这条消息不会再自己继续变化了。除非用户手动点击“重新发送”或者“重新生成”,否则它就停在这里。

所以MessageStatus.ets里还加了一个isTerminal

exportfunctionisTerminal(status:MessageStatus):boolean{returnstatus===MessageStatus.DONE||status===MessageStatus.STOPPED||status===MessageStatus.FAILED}

这个函数在历史记录恢复时很有用。

比如 App 被杀掉之前,某条消息还处于STREAMING。等下次打开 App 时,这条流不可能继续接上。

所以读历史时不能让它继续显示“正在生成”,而应该把中途态统一改成失败态,让用户可以重新操作。

四. 为什么还需要 ChatError

有了消息状态以后,还需要解决另一个问题:失败原因。

以前失败可能只是一个文案:

生成失败,请稍后重试

但这次重构里,我把错误拆成了类型:

exportenumChatErrorType{NETWORK_ERROR='NETWORK_ERROR',SERVER_ERROR='SERVER_ERROR',EMPTY_REPLY='EMPTY_REPLY',}

这三个错误分别对应三种情况。

第一种是网络错误:

断网 超时 连接被重置 SSE 请求异常

这种通常来自onError

第二种是服务端业务错误:

SSE 本身正常结束 但是 done 帧里的 meta.error 告诉前端这次业务失败了

这种必须在onDone里处理。

第三种是空回复:

流正常结束 但是既没有文本,也没有卡片

这种不能当成功,否则页面上会出现一条空的 AI 回复。

所以这次重构里,错误不再直接写成用户文案,而是先变成ChatError

ChatError.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}

然后再判断是否真的有内容:

consthasContent=this.activeAiMessage!==null&&this.activeAiMessage.content.length>0consthasCard=this.activeAiMessage!==null&&this.activeAiMessage.card!==nullif(!hasContent&&!hasCard){this.finalizeFailure(ChatError.empty())return}

也就是说,done不等于成功。

真正成功至少要满足:

没有 meta.error 并且有文本内容或者有卡片数据

六. runStream 到底统一了什么

这次ChatController.ets最大的变化,是把三类入口都收口到runStream

三类入口分别是:

sendMessage:用户正常输入并发送 resendMessage:用户消息失败后重新发送 regenerate:AI 回复失败或停止后重新生成

这三个入口看起来不一样,但真正发起 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 = FAILED

UI 上显示:

重新发送

第二种情况:AI 已经返回了一半。

比如:

今天完成了聊天模块的状态机重构,主要包括...

然后网络断了。

这时候用户消息肯定已经被服务端接收了,失败的是 AI 生成过程。

所以应该把用户消息标记为成功:

userMessage.status = DONE

然后把 AI 消息标记为失败:

aiMessage.status = FAILED aiMessage.errorHint = error.toUserHint()

UI 上显示:

重新生成

这就是“按有没有半截内容分流”的核心。

代码里的判断大概就是:

consthasPartial=ai!==null&&(ai.content.length>0||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

这种响应式对象不适合直接存储。

所以项目里分了两套模型:

ChatMessage:UI 层使用,负责响应式刷新 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 追加到页面上, 而是把每一条消息在每个时刻到底处于什么状态讲清楚。
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/6/15 21:13:50

外企中国研发中心(CRC)大面积调整?海归留学生合规转岗去向精算「蒸汽求职分享」

在全球科技大厂组织架构频繁迭代的动态周期下,外企中国研发中心(CRC)因其双语环境、扁平管理以及健康的工作生活平衡,一直被海归家庭视为留学生回国落脚的理想港湾。针对许多留学生家庭在后方高频反馈的孩子在入职外企中国分部后&…

作者头像 李华
网站建设 2026/6/15 21:11:06

CaddyManager进阶技巧:从新手到专家的10个实用操作

CaddyManager进阶技巧:从新手到专家的10个实用操作 【免费下载链接】caddymanager Easily manage your Caddy2 servers using a modern web-UI, built on the MEVN stack. 项目地址: https://gitcode.com/gh_mirrors/ca/caddymanager CaddyManager是一款基于…

作者头像 李华
网站建设 2026/6/15 20:58:55

大模型MoE稀疏激活原理与2%参数激活真相

1. 项目概述:参数规模与稀疏激活的真相拆解“GPT-4 Has 1.8 Trillion Parameters. It Uses 2% of Them Per Token.”——这句话过去两年在技术社区反复刷屏,常被当作“AI算力爆炸”的标志性论断。但如果你真去翻OpenAI官方技术报告、arXiv预印本、微软研…

作者头像 李华
网站建设 2026/6/15 20:56:10

CAP 定理在实践中的权衡:分布式系统设计的取舍逻辑

CAP 定理在实践中的权衡:分布式系统设计的取舍逻辑 一、CAP 不是选择题:网络分区是客观现实,不是假设 CAP 定理指出,分布式系统在一致性(Consistency)、可用性(Availability)和分区容…

作者头像 李华