AI 对话与流式界面
面向 AI 对话产品的流式 UI 工程综合笔记,聚焦 transport、消息状态机、渐进渲染、取消恢复与性能边界;不承担通用 Chat UI 概念定义。
#frontend
#synthesis
#growing
[!info] related notes
- 所属 MOC: 前端基础 MOC, 前端工程化 MOC
- 相关概念: frontend-streaming-response-patterns, ai-chat-page, ai-product-frontend-reliability, ai-output-uncertainty-and-ux, ai-chat-performance-optimization
- 传输关系: 前端流式响应模式, SSE
- 体验关系: AI 输出不确定性与 UX
- 概念边界: Chat UI 定义通用 Agent 对话界面能力;本页只负责流式运行时与恢复语义
- 实现参考: assistant-ui;组件与方案选型见 AI 聊天 UI 设计与组件选型
AI 对话与流式界面
AI 产品前端难点,往往不在“把文本显示出来”,而在于如何把生成中的不确定性、长任务状态和流式反馈做成稳定体验。
一句话定义
AI 对话与流式界面,本质是在前端持续接收增量内容的同时,管理消息状态、交互反馈、长文本渲染和异常恢复。
与 Chat UI 的边界
- Chat UI 是上位概念:回答“Agent 对话界面需要具备哪些交互能力”。
- 本页是工程综合层:回答“增量事件进入前端后,transport、message state、rendering、cancel/recovery 如何协作”。
- AI 聊天 UI 设计与组件选型 负责把这些需求映射到组件与框架方案。
- assistant-ui 是其中一个具体资源/实现,不应反向成为本页的概念别名。
因此判断一段内容应该放哪页时,可以问:它是在定义界面能力,还是在解释流式运行时语义? 后者才属于本页。
前端通常要处理什么
- 消息列表
- 逐步追加内容
- 生成中、完成、失败、中止等状态
- 重新生成、继续追问、取消请求
- markdown 和 code block 渲染
端到端链路
一次流式 AI 回复通常可以拆成:
提交用户消息
→ 创建 assistant 占位状态
→ 建立流式 transport
→ 持续接收并解析增量事件
→ 合并到当前消息 projection
→ 收到 durable terminal / 完成信号
→ 落盘或刷新最终状态
这里真正需要前端维护的是 transport、消息状态、渲染和恢复之间的协作,而不是“能不能把 chunk 拼起来”。
Transport 只是其中一层
SSE、Fetch Streams 与 WebSocket 的选型统一看 前端流式响应模式。
本页只保留一个原则:
Transport 负责把增量事件送到前端;消息状态机、恢复、取消语义和渲染节奏属于更上层 UI Runtime。
一个常见状态模型
- idle
- generating
- success
- error
- aborted
一个更完整的消息状态设计
对 AI 聊天前端来说,光有“请求成功 / 失败”是不够的,通常还要区分:
- 消息级状态:生成中、完成、失败、中止
- 请求级状态:发送中、接收中、结束、超时
- 展示级状态:是否在流式追加、是否需要滚动到底部、是否渲染完成
一个常见做法是:
type MessageStatus = 'generating' | 'success' | 'error' | 'aborted'
type ChatMessage = {
id: string
role: 'user' | 'assistant'
content: string
status: MessageStatus
errorMessage?: string
}
这样“流式进行中”和“最终失败”不会混在一起。
流式更新时前端通常怎么做
基本思路
- 用户发送消息
- 先插入一条空的 assistant 占位消息
- 收到 chunk 后,持续把内容 append 到这条消息
- 流结束后,把状态切到
success - 如果失败或用户取消,切到
error或aborted
为什么要先插占位消息
- UI 不会等到首个 chunk 才突然出现 assistant 消息
- 更方便统一处理中止、失败、重试
- 更适合做“生成中”骨架和按钮状态
一个最小伪代码
async function sendMessage(input: string) {
insertUserMessage(input)
const assistantId = insertAssistantPlaceholder()
try {
updateMessageStatus(assistantId, 'generating')
for await (const chunk of streamResponse(input)) {
appendMessageContent(assistantId, chunk)
}
updateMessageStatus(assistantId, 'success')
} catch (error) {
updateMessageStatus(assistantId, 'error')
}
}
面试里很容易被追问的工程点
1. 如何取消生成
- 前端用
AbortController取消请求 - 后端感知
req.on('close')停止模型流 - 把消息状态切到
aborted,保留已生成内容 - 流式回调中检查消息状态,丢弃过期 chunk
详见 SSE 流式输出的暂停与中断。
2. 如何处理重试
- 保留原始用户输入
- 新开一次 assistant 生成
- 或者基于上一轮失败消息做“重新生成”
3. 如何避免高频重渲染
- 不要每个 token 都触发整页大范围更新
- 可以按 chunk、按时间片,或者按可见区域合并更新
- 长列表场景要关注滚动和虚拟化
4. 如何处理异常
- 网络断开
- 服务端中途结束
- chunk 解析失败
- 重复内容或乱序内容
这题真正想考什么
- 你是否真的做过流式输出
- 你是否理解前端不只是“把字显示出来”
- 你有没有考虑状态、性能、异常、中止、重试和体验一致性
为什么它比普通消息列表更难
- 内容是增量到达的
- 渲染频率更高
- 用户会关注首 token 时间和完整回答时长
- 失败和中断体验比普通接口更敏感
常见误区
- 每个 token 到来都直接触发大面积渲染
- 没有取消、重试和异常兜底
- 只关注展示,不关注可信性和可解释性
最短记忆方式
AI 对话页不是普通聊天页,它要同时管理流、状态、性能和不确定性。
学习检查
不看正文回答:
- AI 对话页为什么比普通“提交表单 → 等 JSON”多出一个持续运行状态?
- transport、event parser、message reducer、rendering 分别应该承担什么职责?
- 为什么开始请求前先创建 assistant 占位消息有利于状态一致性?
- generating、done、error、aborted/cancelled 为什么不应只靠一个
loadingboolean 表达? - 高频 token/chunk 更新为什么可能造成渲染抖动?有哪些合并更新策略?
- 网络连接中断与服务端 Run 终止为什么不是同一个事实?
- Retry / Regenerate / Continue 分别应该基于什么持久状态,而不是盲目重复原请求?
- 给一个“流式输出到一半断网”的案例,画出 UI 状态、transport 和 durable recovery 的恢复路径。