对话界面的状态机
聊天界面看起来简单:一个列表、一个输入框。但它和常见的表单类界面有一个根本区别——一条消息在很长时间里处于"未完成"状态,而且中间会变好几次。
把这个"未完成"建模清楚,是 AI 前端最核心的一件事。做不好,表现是:用户看到内容闪回、重复渲染、中断后残留半截内容、以及刷新页面后对话消失。
1. 状态不是布尔值
很多第一版实现用一个布尔的 loading。它不够用,因为一次对话的状态要分两层看。
会话级只有三态:idle(没有在生成)、busy(有一条在生成)、handing-off(已转人工)。它决定输入框能不能发、发送按钮显示什么。
消息级才是那个状态机,一条消息至少经历六种状态:
| 状态 | 含义 | 界面该做什么 |
|---|---|---|
ready | 空闲 | 输入框可用,发送按钮可点 |
submitted | 已发出,还没收到第一个字节 | 显示"已发送",还没内容可渲染 |
streaming | 正在收流 | 逐段渲染,按钮变成"停止" |
stopped | 用户主动停止 | 保留已收到的内容,不给"重试"给"继续" |
regenerating | 重新生成中 | 旧内容淡出/保留占位,避免瞬间空白 |
error | 失败 | 保留已收到的部分,给出重试入口 |
submitted 和 streaming 分开是关键。大模型首字节延迟动辄几秒——这段时间里如果界面什么都不显示,用户会以为卡住了;如果和 streaming 混在一起,就没法区分"在等"和"在收"。
分开之后,"等了 8 秒还没出字"这件事就变成了一个可以单独监控的指标,而不是用户的主观抱怨。
stopped 单独一态同样重要:它不是 error,不该出现"出错了,点击重试";也不是 ready,因为内容只完成了一半。把它和 error 混用,是"用户点了停止却看到报错"这类观感事故的来源。regenerating 存在的意义是:重新生成时旧答案不要立刻消失——用户需要它作为对照。
会话级和消息级不能混。 一条消息在 streaming,不代表整个会话只能干这一件事:另一条历史消息可以处于 error 等待重试。反过来,会话 busy 时又说"这条消息 ready",界面就会自相矛盾。做状态设计时先把两层分开画,再连边。
2. 一条消息不是字符串
第二个关键设计:消息内容是 parts 数组,不是一段文本。
一次回答可能同时包含:思考过程(reasoning 模型)、正文、工具调用及其结果、引用来源、图片。它们在同一条消息里按顺序出现,各有各的渲染方式和中间态。
如果用字符串存,所有这些都要靠解析文本硬拆——而模型输出是非结构化的,任何基于正则的拆分都会在某次输出上翻车。
{message.parts.map((part, i) => {
switch (part.type) {
case 'text': return <Markdown key={i} text={part.text} />
case 'reasoning': return <Reasoning key={i} text={part.text} open={part.done ? false : true} />
case 'tool-searchOrder':
return <ToolCall key={i} state={part.state} input={part.input} output={part.output} />
case 'source': return <Source key={i} url={part.url} />
}
})}
每个 part 自己带状态。工具调用尤其明显:它有"正在调用 / 等待确认 / 已完成 / 失败"几段,每一段要显示的东西都不一样。
3. 流式渲染的三个坑
3.1 每个 chunk 都重新解析一遍会卡
Markdown 渲染不是廉价操作。流式场景下每个 chunk 到达都触发一次全量解析,很快就会掉帧。
做法是把解析节流到动画帧(或者按时间间隔),让渲染速度去追接收速度,而不是接收一次渲染一次。相关做法在《AI 对话的流式输出实现》里有更完整的讨论。
3.2 光标与自动滚动打架
流式输出时用户可能在往上翻看历史。无条件滚到底会把人拽回去。
正确的行为是:用户贴在底部时才跟随,一旦手动上滚就停止跟随,并显示一个"回到底部"的入口。判断"贴在底部"要留一点容差(比如距底部 50px 内算贴底),否则边界上会抖动。
3.3 中断之后要留什么
用户点停止,已生成的部分该保留还是丢弃?
保留。理由很实际:那是已经付费生成的内容,而且用户可能是觉得"够了"。界面上要明确标记这条被中断("已停止"),并给"继续"或"重新生成"的入口。
中断必须连服务端一起取消——只断前端,服务端还在生成、还在计费。这一点在《AbortController 与请求取消》的基础上多一层:信号要一路传到模型服务。
4. 乐观更新
用户消息应该立刻上屏,不等服务端确认。
这不是优化,是必需:网络往返加首字节延迟可能有好几秒,如果这段时间输入框里的内容不动,用户会再发一次。
配合的两件事:发送后清空输入框;失败时把这条用户消息标记为"发送失败"并给出重发入口,而不是悄悄删掉。
5. 会话与持久化
刷新页面后对话还在,是基本要求。做法上有两个层面:
服务端有会话 ID。 历史存在服务端,前端只持 ID。这样换设备也能继续,也便于做审计和配额。
前端存草稿与滚动位置。 正在输入的草稿、当前会话的滚动位置这类本地状态,存在客户端即可(localStorage 或 IndexedDB,见《浏览器存储之 IndexedDB》)。
一个常被忽略的点:会话树。用户从某一轮"重新生成"或"编辑重发",会产生分支。线性数组存不下分叉,要么存成树(每条消息带 parentId),要么明确约定"重新生成覆盖原消息"——两种都可以,但不能默认不处理,否则分支会互相覆盖。
6. 长对话的性能
对话一长,三个问题同时出现:DOM 节点数爆炸、内存占用上升、渲染变慢。
- 虚拟列表只渲染可视区域(见《虚拟列表原理与不定高处理》)。流式场景下高度是动态变化的,要配合动态高度测量。
- Markdown 分段渲染,不要一次性解析整条超长消息。
- 重的解析放进 Web Worker(高亮、大段 Markdown 解析)。
7. 一个自测清单
- 断网时发送,界面是不是有明确反馈,消息是不是还在
- 生成中途点停止,已生成内容是否保留、是否标记了中断
- 生成中途刷新页面,恢复后状态对不对
- 上传一张图片再问,图片在 parts 里有没有正确渲染
- 连续快速发三条,会不会串台(请求与消息的对应关系要靠 ID,不能靠顺序)
- 对话到一百条时还流不流畅
最后一条最容易被跳过,而它恰恰是真实用户一定会遇到的场景。这条标准和《给大模型链路装仪表》里那句"两分钟内拿到 trace"是同一个思路:看不见的状态,改不动。