流式 UI:让等待不像等待
实测到达节奏不匀速;以及流式和输出审核的天然冲突
ap1.2 测过:流式让首字出现快 8.2 倍。那是接口层的事实,这一节讲怎么把它变成界面上的体验。
三个实测发现,每一个都会改变你的前端设计:
- 到达节奏不匀速——开头 0.7 秒一个字都没有,然后突然一秒钟涌 200 多字,末尾又慢下来;
- 中断按钮省下 43% 的生成,但它省的钱不一定能算进你的成本模型;
- 流式和输出审核天生打架——实测里违禁表述在 2.69 秒就已经出现在屏幕上,而生成到 3.91 秒才结束。你在结束后才审核,用户已经看了 1.22 秒。
点外卖时,"预计 30 分钟送达"和"骑手已取餐,正在路上"是完全不同的体验——总时长一样,焦虑感差一倍。
流式就是那个"骑手正在路上"。但如果骑手前 5 分钟一动不动,你还是会焦虑——所以光有流式不够,空窗期也得有反馈。
实测一:到达节奏长什么样
首字时间(TTFT):0.67s 全部生成完:3.10s 共 458 字
每 0.5 秒到达的字符数:
0.5s 46 ███████████████
1.0s 105 ███████████████████████████████████
1.5s 109 ████████████████████████████████████
2.0s 98 ████████████████████████████████
2.5s 81 ███████████████████████████
3.0s 19 ██████
三条前端设计结论:
① 空窗期必须有反馈。开头那 0.67 秒一个字都没有——这段时间界面上必须有东西在动("正在思考…"、骨架屏、光标闪烁)。不动的空白框 = 用户以为你挂了。
② 别做进度条。节奏是不匀的(46 → 105 → 109 → 98 → 81 → 19),你没法预测总长度,做出来的进度条只会骗人。用"正在生成"的不定态动画,或者显示已生成字数。
③ 别每个 chunk 都重渲染。一秒钟涌进 200 多个字,如果每个 delta 都触发一次完整的 Markdown 重解析 + DOM 更新,低端手机会卡到掉帧。做法:按帧节流(约 60ms 合并一次)或按句子边界更新。
实测二:中断按钮
跑完:457 字,耗时 3.1s
2 秒中断:262 字,耗时 2.0s(已中断:True)
→ 少生成了 43% 的内容
中断首先是体验功能:用户看到前两行就知道方向不对,让他能立刻停下来重问,比让他干等 3 秒强得多。
但别把它写进成本模型,两个原因:
- 断开连接后上游是否立刻停止生成,取决于服务商实现——你的连接断了,不代表对面的算力立刻释放;
- 流式响应通常不返回完整的
usage,你只能按收到的字数估算并记进 ap1.6 的流水。估算值可以用来做趋势,不能用来对账。
工程上要做的三件事:前端 AbortController 断开请求;后端捕获客户端断开(别当成错误告警);把"已生成部分"也记进流水——否则你的成本账会漏。
实测三:流式和输出审核打架
完整输出:这不仅仅是一只保温杯,它是被驯服的极地冰川……以航天级真空绝技……
生成结束于 3.91s
违禁词在 2.69s 就已经出现在用户屏幕上了
→ 如果你只在生成结束后审核,用户已经看了 1.22 秒的违规内容
这是流式最容易被忽略的代价:ap6.3 讲的输出审核,默认前提是"你先拿到完整输出,再决定给不给用户看"。流式把这个前提拆了——内容是边生成边送到用户眼前的。
三种解法,按场景选:
| 解法 | 做法 | 代价 | 适合 |
|---|---|---|---|
| ① 边流边审 | 每积累 N 个字跑一次本地规则检查,命中立刻掐断并替换整段 | 只能用便宜的规则(模型审核太慢),漏检率高 | 大多数场景 |
| ② 延迟一拍 | 前端缓冲 1-2 句再显示,给审核留时间 | 牺牲一点 TTFT | 中等风险 |
| ③ 不流式 | 等完整结果审核通过,一次性给出 | 完全失去流式体验 | 医疗/金融/法律等高风险 |
掐断之后要处理得体:别让半截话留在屏幕上,整段替换成"这部分内容不便展示",并给一个重试或联系人工的入口(ap6.1 的兜底文案在这里复用)。
流式还有三个坑
① JSON 输出没法流式渲染。AP3 的结构化输出在流式下是一串不完整的 JSON 片段,中途没法解析。要么不流式,要么只流式化"给人看的那部分",结构化字段等结束后一次性给出。
② Markdown 半截会闪。**加粗 只到一半时渲染器会把星号显示出来,下一帧又变成加粗——画面在抖。做法:渲染前补全未闭合的标记,或按段落边界更新。
③ 流到一半失败了怎么办。网络断、上游报错——用户已经看到半个答案了,你不能只弹一个红色 toast。做法:保留已生成内容 + 明确提示"生成中断" + 一个"继续/重试"按钮。重试时把已生成部分作为上下文续写,而不是从头再来。
🔧 动手做:把流式做进你的界面(20 分钟)
- 跑本节代码,看到达节奏的直方图和违禁词出现在 2.69s 而生成 3.91s 才结束。
- 把你的接口改成 SSE(
text/event-stream),前端用EventSource或fetch+ReadableStream。 - 补齐三个状态:空窗期动效、打字态、停止按钮。空窗期那个最容易漏,也最影响体感。
- 加渲染节流(约 60ms 合并一次)和 Markdown 补全,在低端手机上试一次。
- 接边流边审:把 ap6.3 的本地规则跑在流上,命中就掐断 + 整段替换。
- 处理中断和失败:保留已生成内容,给"继续/重试"按钮,并把已生成部分记进 ap1.6 的流水。
✅ 做到这里你应该有:SSE 接口 + 完整前端状态机 + 渲染节流 + 边流边审 + 中断/失败的体面处理。
为什么:流式是 AI 产品性价比最高的体验改造——不改模型、不改提示词,只改传输方式,用户的"卡"感就消失了一大半。但它同时把三件原本简单的事变复杂了:审核、结构化输出、错误处理。这一节的价值不在于教你怎么流,而在于让你提前知道流之后会遇到什么。
非流式的重试很简单:失败了就再调一次。流式失败时,用户已经看到半个答案了。
三种处理,别混用:
- 续写:把已生成部分作为上下文让模型接着写——便宜,但接缝处经常重复或跑题;
- 重来:清空重新生成——干净,但用户会看到内容"跳变",而且已生成的那部分钱白花了;
- 保留 + 手动:保留内容,给用户"继续生成"按钮,由他决定——最诚实,推荐默认用这个。
无论选哪种,都要在流的每一步记录已生成长度和中断原因,否则你的监控里只会看到一个 200 状态码,根本不知道有多少用户其实只看到了半个答案(ap7.4 会把这个变成一个必监控的指标)。
我的后端接口是【描述,或贴出非流式版本】,前端用【React/Vue/原生】。请帮我改成流式:1) 后端 SSE 实现(含正确的 header、心跳、客户端断开处理);2) 前端消费代码,完整状态机:空窗期动效 → 打字态 → 停止 → 中断恢复 → 正常结束;3) 渲染节流(约 60ms 合并)和未闭合 Markdown 的补全逻辑;4) 边流边审的接入点(每 N 字跑一次本地规则,命中如何掐断和替换);5) 流中断时的用户提示和「继续生成」实现;6) 该打的埋点(TTFT、总时长、已生成长度、中断原因)。
流式三件事:空窗期要有反馈、节奏不匀不要做进度条、按帧节流别每个字都重渲染;中断是体验功能,省的钱别写进成本模型;流式和输出审核天生打架——边流边审 / 延迟一拍 / 高风险不流式,三选一。
下一节讲你上线后最依赖的东西:可观测性——用户不会告诉你"变慢了",但你的 P95 会。
🔎 来源与核验· 3 条,点开核对
