对话产品前端,为什么流式输出体验值得单独设计
补档说明:本文属于「AI 工程落地周记」系列,计划发布时间为 2025-04-28 20:15。当前先保留为草稿,后续补充真实案例、代码片段和复盘细节后再发布。
如果只拿一个具体页面来说这个问题,我会选“运营助手”里的改写面板。
这个面板的交互表面很普通:用户贴一段原始文案,点“优化”,系统开始生成一版可发布草稿。我们最早做的时候,认为流式输出只是锦上添花,于是做了一个很自然的版本:后端流式返回 token,前端就把 token 不断 append 到页面里。能跑,效果也看起来很“AI”。
但上线一周后,反馈几乎都集中在体验而不是效果上:
- “它到底是还没生成完,还是卡住了?”
- “我想复制刚才那句,结果它还在跳。”
- “中间突然停了三秒,我以为挂了。”
- “我点了继续生成,怎么前面那段也被改了?”
这时候我才真正意识到:对话产品前端里,流式输出不是一个展示细节,而是一整套状态管理问题。模型决定内容质量,前端决定用户是否愿意把这个过程当成一个可靠工具来使用。
这类页面到底在和什么战斗
很多人会把流式输出理解成“渲染性能问题”,但在真实产品里,它同时在和四种东西战斗:
- 用户对等待的焦虑
- 后端 token 流的不稳定节奏
- 页面局部状态的频繁切换
- 用户操作和生成过程的冲突
如果只盯着“字是不是在流动”,你很容易忽略掉真正影响体验的,其实是这些状态有没有被清楚表达。
这个页面后来暴露出的 3 个真实问题
1. 流和停顿没有语义
最早的实现里,只要 token 进来就往输出区拼。后端快的时候,用户会觉得很丝滑;一旦中途出现 2-3 秒停顿,页面没有任何额外反馈,用户就会直接判断“它卡住了”。
技术上讲,这只是一次正常等待;产品上讲,这是一次信任断裂。
2. 生成中的内容和最终内容混在一起
用户在中途复制、回看、选择段落时,文本还在继续变化。结果就是:
- 复制到一半,后面内容改了
- 光标位置被打断
- 刚看到的那句下一秒被顶没了
这不是模型问题,而是前端没有区分“暂态内容”和“稳定内容”。
3. 用户动作和生成动作互相抢状态
例如用户点“重试”“继续生成”“停止”“套模板”时,如果状态边界没设计好,就会很容易把旧流和新流串在一起,甚至把上一轮结果也污染掉。
所以流式前端真正难的,从来不是“边输出边显示”,而是“多状态并存时还能不乱”。
后来我们把页面状态拆成了 4 层
这次改版后,前端不再把“流式输出”看成一个单独状态,而是拆成四层:
1. submitted
用户请求已经被系统接收,按钮状态、输入框状态立即切换。这个阶段的目标不是展示内容,而是先消除“我到底点没点上”的不确定感。
2. processing
系统还没开始真正吐内容,但可能在做检索、工具调用、上下文拼装。这一层必须明确告诉用户“系统正在处理”,否则等待会被误解为卡死。
3. streaming
内容开始逐步生成,但这时的内容仍然是“暂态”。我们允许它显示,但不会把它当作最终结果写进历史记录。
4. settled
当流结束、校验通过、内容稳定后,才把结果正式落到消息列表或结果区里。只有到这一步,复制、继续编辑、派生动作才变得安全。
状态拆开之后,页面一下就顺了很多。不是因为动画更花,而是因为用户终于知道系统现在处在哪一步。
代码上最关键的一次改动
最早我们只有一个 answer 状态。后来改成同时维护“暂态流”和“稳定结果”两份状态:
type StreamState =
| {phase: 'idle'}
| {phase: 'processing'}
| {phase: 'streaming'; draft: string}
| {phase: 'settled'; content: string}
| {phase: 'error'; message: string}
function useAnswerStream() {
const [state, setState] = React.useState<StreamState>({phase: 'idle'})
async function run(input: string) {
setState({phase: 'processing'})
const stream = await startAnswerStream(input)
let draft = ''
for await (const chunk of stream) {
draft += chunk
setState({phase: 'streaming', draft})
}
setState({phase: 'settled', content: draft})
}
return {state, run}
}
这段代码看起来很普通,但价值很大。它把“生成中”和“生成完”明确分开了,后面的 UI 和交互才能真正有边界。
我们后来专门加了两个前端约束
1. 生成中的文本不进正式历史
这样做的好处是,历史记录永远是稳定快照,不会因为流式过程被污染。
2. 生成中的用户动作必须受限
我们保留“停止生成”和“重新开始”,但不允许在 streaming 状态里触发会污染历史或上下文的动作。这相当于把交互权限和生成阶段绑定了起来。
这两个约束直接减少了很多“看起来像小概率,其实很烦人”的问题。
一个产品上特别值钱的改动:把停顿解释出来
后来我们在 processing 和 streaming 之间,加了一层更具体的状态提示,比如:
- 正在检索资料
- 正在整理回答结构
- 正在生成正文
本质上这些提示并没有让后端更快,但它把“空白等待”变成了“可理解等待”。用户对这类等待的容忍度会高很多。
我越来越认同一个观点:在 AI 产品里,前端最重要的工作之一,不是缩短所有等待,而是让等待有可理解的语义。
这件事为什么值得单独设计
因为流式输出不是普通页面里的一个小功能,而是对话产品的主交互过程本身。
它会影响:
- 用户是否觉得系统可靠
- 用户是否愿意在等待时继续停留
- 用户是否敢在生成中做后续操作
- 用户是否能把结果顺畅带入下一步工作流
这些都已经超出“展示细节”范畴了。
最后给一张可直接拿去做评审的清单
如果一个页面要做流式输出,我现在会先过这 7 个问题:
- 用户能不能一眼看出请求已经被接收?
- 等待中的空白期有没有明确状态说明?
- 暂态流和最终结果是不是两套状态?
- 生成中是否允许复制、停止、重试,各自边界是否清楚?
- 流式中断时是报错、回退,还是保留当前草稿?
- 多轮流是否会污染历史记录?
- 页面在慢网或慢工具调用下,仍然能不能让用户理解“系统还活着”?
总结
对话产品前端里,流式输出值得单独设计,不是因为它更炫,而是因为它直接决定用户如何理解等待、如何信任系统、以及如何在一个不断变化的界面里保持控制感。
模型输出的是内容,前端输出的是节奏和确定感。后者一旦没设计好,再聪明的模型也会显得像一个不稳定的 Demo。
- 读者:关注 AI 应用落地、全栈工程化、工作流自动化和技术内容系统的开发者。
- 场景:补充 2025 年到 2026 年初这段时间里缺失的技术观察和工程复盘。
- 目标:不写成新闻转述,而是写成可以复用到项目里的判断框架。
