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

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

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

text 复制代码
AI 回复失败以后,到底应该让用户点"重新发送",还是点"重新生成"?

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

比如:

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

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

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

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

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

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

text 复制代码
帮我生成一份日报

然后点击发送。

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

text 复制代码
我的问题发出去了
AI 开始回复了

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

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

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

text 复制代码
创建用户消息 userMessage,状态为 SENDING
创建 AI 占位消息 aiMessage,状态为 THINKING
生成历史上下文快照 historySnapshot
把这两条消息 push 到 historyMessage
调用 runStream 发起 SSE 流式请求

也就是说,用户点一次发送,页面上其实先插入了两条消息。

用户消息负责展示"我刚刚问了什么"。

AI 消息负责接收后续一段段流式返回的内容。

二. 最粗暴的方案:用字符串和 boolean 硬判断

一开始最容易想到的写法是:

text 复制代码
全局用 isLoading 判断是否正在生成
AI 空内容时显示"AI 思考中..."
失败时把 content 改成"生成失败,请稍后重试"
停止时往正文里拼一个 "[已停止]"

这种写法能不能跑?

能。

但问题也很明显。

第一,消息状态不清楚。

同样是 content 为空,它可能代表:

text 复制代码
AI 正在思考
AI 没有收到任何回复
历史记录里内容丢失
刚创建了占位消息

这些情况在 UI 上应该是不一样的,但如果只靠 content.length === 0 判断,就很容易混在一起。

第二,失败原因不清楚。

如果直接写:

ts 复制代码
aiMessage.content = '生成失败,请稍后重试'

那这句话到底代表什么?

text 复制代码
断网了?
接口超时了?
服务端返回业务错误?
done 帧里没有任何文本和卡片?

用户看到的都是失败,但开发时排查问题完全不是一回事。

第三,UI 逻辑会被文案绑架。

如果代码里到处判断:

ts 复制代码
content === '生成失败,请稍后重试'
content.includes('[已停止]')

那以后产品说文案要改成"服务开小差了",逻辑也可能跟着出问题。

这就是魔法字符串的问题:它看起来只是文案,实际上却偷偷承担了状态判断的职责。

三. 为什么需要 MessageStatus

这次重构里,我新建了 MessageStatus.ets。

核心就是把一条消息可能处于的状态明确列出来:

ts 复制代码
export enum MessageStatus {
  SENDING = 'sending',
  THINKING = 'thinking',
  STREAMING = 'streaming',
  DONE = 'done',
  STOPPED = 'stopped',
  FAILED = 'failed',
}

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

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

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

AI 消息主要有五种状态:

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

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

text 复制代码
为什么 STREAMING 不是终态?

因为 STREAMING 只是一个过程。

它后面还会继续变化:

text 复制代码
STREAMING -> DONE
STREAMING -> FAILED
STREAMING -> STOPPED

真正的终态是:

text 复制代码
DONE
STOPPED
FAILED

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

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

ts 复制代码
export function isTerminal(status: MessageStatus): boolean {
  return status === MessageStatus.DONE ||
    status === MessageStatus.STOPPED ||
    status === MessageStatus.FAILED
}

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

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

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

四. 为什么还需要 ChatError

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

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

text 复制代码
生成失败,请稍后重试

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

ts 复制代码
export enum ChatErrorType {
  NETWORK_ERROR = 'NETWORK_ERROR',
  SERVER_ERROR = 'SERVER_ERROR',
  EMPTY_REPLY = 'EMPTY_REPLY',
}

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

第一种是网络错误:

text 复制代码
断网
超时
连接被重置
SSE 请求异常

这种通常来自 onError。

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

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

这种必须在 onDone 里处理。

第三种是空回复:

text 复制代码
流正常结束
但是既没有文本,也没有卡片

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

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

ts 复制代码
ChatError.network(errMsg)
ChatError.server(meta.error)
ChatError.empty()

然后再通过:

ts 复制代码
error.toUserHint()

映射成用户能看到的提示。

这样做的好处是:

text 复制代码
日志里能看到真实错误类型
UI 文案可以集中管理
业务逻辑不用再 match 某一句中文

五. 为什么 meta.error 必须在 onDone 里处理

这个点一开始很容易想错。

很多人会觉得:

text 复制代码
既然失败了,那不就应该走 onError 吗?

但 SSE 里不一定是这样。

onError 更偏网络层或者请求层错误。

比如:

text 复制代码
请求发不出去
连接断了
网络超时

但还有一种情况是:

text 复制代码
请求正常发出去了
服务端也正常返回了最后一帧 done
只是 done 帧里告诉你:这次业务失败

这时候网络是成功的,SSE 也是正常结束的,所以不会走 onError。

如果前端只处理 onError,就会把这种业务错误误判成成功。

所以 runStream 里的 onDone 需要先判断:

ts 复制代码
if (meta.error) {
  this.finalizeFailure(ChatError.server(meta.error))
  return
}

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

ts 复制代码
const hasContent = this.activeAiMessage !== null &&
  this.activeAiMessage.content.length > 0

const hasCard = this.activeAiMessage !== null &&
  this.activeAiMessage.card !== null

if (!hasContent && !hasCard) {
  this.finalizeFailure(ChatError.empty())
  return
}

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

真正成功至少要满足:

text 复制代码
没有 meta.error
并且有文本内容或者有卡片数据

六. runStream 到底统一了什么

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

三类入口分别是:

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

这三个入口看起来不一样,但真正发起 SSE 请求、处理 chunk、处理 done、处理 error 的流程是一样的。

如果每个入口都写一遍流式逻辑,后面一定会出问题。

比如:

text 复制代码
sendMessage 处理了 meta.error
resendMessage 忘了处理 meta.error
regenerate 忘了清空 card
某个入口忘了清 currentRequest
某个入口 onDone 和 onError 重复收尾

所以更合理的做法是:

text 复制代码
三个入口只负责准备现场
runStream 负责统一执行流式流程

正常发送时:

text 复制代码
读取 inputContent
创建 userMessage,状态 SENDING
创建 aiMessage,状态 THINKING
在 push 新消息前生成历史快照
把两条消息加入 historyMessage
清空输入框
调用 runStream

重新发送时:

text 复制代码
拿到之前 FAILED 的用户消息
做空值和下标安全检查
复用这条 userMessage
把它改回 SENDING
重新插入一个 AI 占位消息
调用 runStream

重新生成时:

text 复制代码
拿到 UI 传入的 AI 消息
往前找到最近的一条用户消息作为 prompt
复用这条 AI 气泡
清空 content 和 card
把 AI 状态改回 THINKING
调用 runStream

这里最重要的区别是:

text 复制代码
重新发送:复用用户消息,重新创建 AI 占位
重新生成:复用 AI 消息,用户消息不重新创建

这样就能避免重复气泡。

七. 首个 chunk 到底代表什么

流式请求开始后,AI 还没有立刻返回完整内容。

但只要收到了第一个 chunk,就说明一件事:

text 复制代码
这条用户消息已经被服务端受理了

所以 onChunk 里会做两个状态推进:

text 复制代码
userMessage: SENDING -> DONE
aiMessage: THINKING -> STREAMING

然后把 chunk 追加到 AI 消息:

ts 复制代码
this.activeAiMessage.content += (chunk ? chunk : '')

这个设计很自然。

用户消息一旦被服务端受理,就不应该继续显示发送中。

AI 一旦开始吐字,就不应该继续显示思考中。

所以首个 chunk 是一个很关键的分界点。

八. 失败时为什么要分"重新发送"和"重新生成"

这是这次重构里最容易理解,但也最容易写错的地方。

失败以后,不能一律显示"重试"。

因为失败可能发生在两个不同阶段。

第一种情况:AI 一个字都没返回。

这说明用户消息可能还没有真正完成这一轮请求。

这种情况下,应该把用户消息标记为失败:

text 复制代码
userMessage.status = FAILED

UI 上显示:

text 复制代码
重新发送

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

比如:

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

然后网络断了。

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

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

text 复制代码
userMessage.status = DONE

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

text 复制代码
aiMessage.status = FAILED
aiMessage.errorHint = error.toUserHint()

UI 上显示:

text 复制代码
重新生成

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

代码里的判断大概就是:

ts 复制代码
const hasPartial = ai !== null &&
  (ai.content.length > 0 || ai.card !== null)

如果有半截内容,说明失败发生在 AI 回复阶段。

如果没有半截内容,再根据当前入口是否允许用户重发来决定怎么收尾。

九. stopGeneration 为什么不是重新发送

这里也很容易混。

用户点击停止生成时,不是重新发送。

停止只是把当前请求中断掉:

text 复制代码
destroy 当前请求
让旧请求回调失效
AI 消息状态改成 STOPPED
用户消息状态改成 DONE
解锁 isLoading

也就是说,停止生成不会立刻再次调用 runStream。

它只是把当前这轮变成终态:

text 复制代码
aiMessage.status = STOPPED

如果用户后面想继续让 AI 回答,再点"重新生成",那才会进入 regenerate。

这次重构还有一个细节:不再把 [已停止] 拼进正文。

以前可能会这样:

text 复制代码
AI 正文内容

[已停止]

但这会污染真实回复内容。

现在更合理的做法是:

text 复制代码
正文还是正文
停止状态交给 status 表达
UI 根据 STOPPED 渲染"已停止生成"

状态和正文分开,后续保存历史也更干净。

十. 为什么要加 requestSeq 和 finalized

流式请求还有一个隐蔽问题:回调可能乱序或者重复触发。

比如用户点击停止以后,旧请求可能还有残留回调回来。

如果不处理,旧回调可能继续改 historyMessage。

所以这次加了 requestSeq。

每发起一轮请求,序号自增:

text 复制代码
seq = ++requestSeq

回调里先判断:

text 复制代码
如果当前 seq 已经过期,就直接 return

这样旧请求就不能再污染新状态。

另外还有 finalized。

它解决的是另一类问题:

text 复制代码
onDone 和 onError 都触发了怎么办?
onDone 里已经失败收尾,后面又来了一个 error 怎么办?

所以每轮请求只允许 finalize 一次。

这两个字段看起来不起眼,但它们让流式请求收尾更稳。

十一. 为什么序列化要下沉到模型

聊天记录需要持久化。

但 UI 用的 ChatMessage 不是普通对象,它里面有:

text 复制代码
@ObservedV2
@Trace content
@Trace card
@Trace status

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

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

text 复制代码
ChatMessage:UI 层使用,负责响应式刷新
ChatMessagePlain:持久化使用,负责 RDB / JSON 读写

以前如果在 Controller 里手写转换,就会变成:

text 复制代码
Controller 既要管发送请求
又要管保存会话
还要管每个字段怎么拷贝

职责就混在一起了。

所以这次把转换逻辑下沉到模型:

ts 复制代码
ChatMessage.fromPlain(...)
message.toPlain(...)

这样职责就清楚了:

text 复制代码
ChatMessage 自己知道怎么从 plain 恢复
ChatMessage 自己知道怎么转成 plain
ChatSessionController 只负责什么时候读取、什么时候保存

还有一个很重要的点:fromPlain 里会做中途态归一化。

比如历史记录里读到:

text 复制代码
SENDING
THINKING
STREAMING

这些状态在 App 重启后都不可能继续自动流转。

所以应该统一改成:

text 复制代码
FAILED

这样用户打开历史记录时,不会看到一条永远转圈的消息。

十二. 为什么要删除 ChatPersist.ets

项目之前有一套 Preferences 版本的聊天持久化。

后来已经切到 RDB,也就是 ChatRdb.ets。

这个时候旧的 ChatPersist.ets 如果还留着,就会造成一种错觉:

text 复制代码
项目里好像有两套聊天记录存储
到底该改 Preferences 还是 RDB?
历史记录到底从哪里读?
删除逻辑到底在哪一套?

所以删除死代码本身也是重构的一部分。

它的意义不是少一个文件,而是减少误导。

现在项目里只保留一套明确的持久化方案:

text 复制代码
ChatRdb 负责聊天会话和消息落库
ChatSessionController 负责调度读写
ChatMessage.fromPlain / toPlain 负责模型转换

这条链路比之前清楚很多。

十三. 录屏时应该展示什么

这次功能不太适合只截图,因为状态变化是动态的。

更建议录一个 1 分钟左右的视频,按下面顺序展示:

text 复制代码
1. 正常发送一条消息
2. 展示"思考中"变成流式输出
3. 等最后一帧完成,消息进入 DONE
4. 模拟断网或服务端错误,展示用户消息 FAILED
5. 点击"重新发送"
6. 再模拟 AI 已经输出一半后失败,展示 AI 消息 FAILED
7. 点击"重新生成"
8. 展示停止生成后出现"已停止生成"
9. 退出再进入历史记录,确认历史消息恢复正常

录屏时可以重点口播这几句话:

text 复制代码
这次不是单纯加了一个重试按钮,而是把聊天消息拆成了状态机。
用户消息失败和 AI 生成失败不是一回事,所以 UI 上分别对应重新发送和重新生成。
done 帧不一定代表成功,因为 done 里也可能带 meta.error。
历史记录恢复时,中途态不能继续转圈,所以会归一化成 FAILED。

这样别人看视频时,不只是看到按钮能点,而是能理解你为什么这么设计。

十四. UI 样式可以怎么顺手优化

这个功能本身偏逻辑,但如果想让演示更直观,可以给状态加一点轻量 UI。

比如 THINKING 状态可以做一个小的动态 loading 图标。

如果项目里想做得更有辨识度,可以做一个类似"大风车"的旋转图标:

text 复制代码
AI 思考中:小风车慢速旋转
STREAMING:风车旋转 + 文本逐段出现
FAILED:风车停止,显示错误提示和操作按钮
STOPPED:显示一条"已停止生成"的分割提示

背景色也可以稍微区分状态:

text 复制代码
THINKING:浅蓝灰背景,表达等待
STREAMING:普通聊天背景,表达正在输出
FAILED:浅红或浅橙提示,不要太刺眼
STOPPED:浅灰分割线,表达用户主动中断
DONE:正常展示,不突出状态

按钮也可以按语义区分:

text 复制代码
重新发送:放在用户气泡旁边
重新生成:放在 AI 回复下方
停止生成:放在输入框发送按钮位置

这样 UI 和状态机是对应的。

不是为了炫技加动效,而是让用户一眼知道:

text 复制代码
现在到底是正在想、正在生成、失败了、还是我主动停了

十五. 总结

这次重构最核心的收获,不是多写了几个文件,而是把原来模糊的聊天流程拆清楚了。

以前可能是:

text 复制代码
靠 isLoading 判断生成中
靠 content 为空判断思考中
靠某句中文判断失败
靠拼接 [已停止] 表示停止

现在变成:

text 复制代码
MessageStatus 表达消息状态
ChatError 表达失败原因
ChatConstants 收口角色和文案
runStream 统一处理 SSE 生命周期
fromPlain / toPlain 负责模型转换
ChatRdb 负责唯一的持久化实现

整个聊天流程也更清楚了:

text 复制代码
用户发送
↓
userMessage = SENDING
aiMessage = THINKING
↓
首个 chunk 返回
↓
userMessage = DONE
aiMessage = STREAMING
↓
done 帧成功
↓
aiMessage = DONE

失败时也不再一刀切:

text 复制代码
AI 没有半截内容:用户消息 FAILED,显示重新发送
AI 已有半截内容:AI 消息 FAILED,显示重新生成
用户主动停止:AI 消息 STOPPED,后续可重新生成

所以状态机不是为了把代码写复杂。

它恰恰是为了解决一个真实问题:

text 复制代码
当聊天流程里出现发送中、思考中、生成中、完成、失败、停止这些状态时,
我们不能再靠字符串和临时判断猜消息处于哪里。

把状态、错误、文案、序列化和请求流程拆清楚以后,代码反而更容易维护。

后面再加更多卡片类型、更多错误类型、更多重试入口,也不会乱成一团。

这也是我这次最大的体会:

text 复制代码
流式聊天最难的不是把 chunk 追加到页面上,
而是把每一条消息在每个时刻到底处于什么状态讲清楚。
相关推荐
卸任7 小时前
Vibe coding工具使用心得
前端·ai编程
brien97 小时前
为什么模型标称 1M,Google Antigravity 聊到 256K 却突然失忆?我们给它做了个原生行内监控圆环
前端
修炼的dance8 小时前
录屏关键帧间隔多少合适?六档实测,2秒到10秒小了75.6%
前端
Lank_M8 小时前
关键帧从1秒拉到10秒,录屏小87.3%,摄像头小10.8%
前端
补码缩写8 小时前
用 ffprobe 拆录屏,八成字节在 29 个关键帧上
前端
百思不得小李8 小时前
一个复杂的动态表单是如何实现的
前端·设计模式
陈随易8 小时前
Cmder 停更 17 个月,2.0 支持 Windows Terminal
前端·后端·程序员
风骏时光牛马8 小时前
前沿视界
前端
kyriewen8 小时前
我扫了 814 个依赖,97 个还敢要 Node 10
前端·javascript·程序员
陈随易9 小时前
3000 万人在用的翻译插件,重写一遍就是一门生意
前端·后端·程序员