流式输出:首字延迟与中断处理
点发送之后,界面上先出现一行 正在思考 。三秒后,字开始一个一个往外蹦。
流式输出:首字延迟与中断处理
点发送之后,界面上先出现一行”正在思考”。三秒后,字开始一个一个往外蹦。
这三秒是整套交互里最贵的部分。用户不关心总耗时是二十秒还是三十秒,他在意的是什么时候看到第一个字。
一、流式解决的不是速度,是等待感
一次模型调用,服务端要先把整个回答生成完再返回,还是边生成边推?
前者的体验是:转圈三十秒,然后一整段文字突然出现。后者的体验是:转圈两秒,然后文字持续涌出,二十秒结束。
总时长一样,感受完全不同。因为人对等待的判断不基于终点,基于有没有进展。
所以流式不是性能优化,是交互设计。它把一段无法观测的黑箱时间,切成了可以被看见的进度。
二、一条流有三个状态
看起来只是”文字陆续出现”,实现上要区分三个阶段:
| 状态 | 判据 | 界面表现 |
|---|---|---|
| 等待首包 | 还没收到第一个 chunk | “正在思考” |
| 流式中 | 已收到 chunk,未收到结束 | 文字增长、工具块展开 |
| 已结束 | 收到结束标记 | 收起加载态,落库 |
第一个状态最容易被忽略,也最容易出问题。请求发出去了,但服务端可能正在排队、正在推理、正在被限流。这段时间里没有任何数据,你无法区分”慢”和”死”。
所以等待首包需要单独的超时策略:不是等总时长,是等第一口数据。超过阈值还没来,就该考虑重试或降级。
三、增量到达不等于增量渲染
每收到一个 delta 就重渲染一次,是最直觉的做法,也是最贵的。
Markdown 渲染不是把字符串追加到末尾,它要重新解析整段:标题、列表、代码块、表格,全部重来。一段两千字的回答,如果按每个字渲染一次,总工作量是平方级的。
所以中间要做节流。而且节流间隔不是固定的——文本越长,渲染越贵,间隔应该越大:
| 累积长度 | 刷新间隔 |
|---|---|
| < 500 字 | 200 ms |
| < 2000 字 | 300 ms |
| < 32000 字 | 500 ms |
| < 64000 字 | 1 s |
| < 128000 字 | 1.5 s |
| 更长 | 2 s |
短回答保持跟手,长回答主动降帧。用户感知不到这个切换,但 CPU 占用差一个量级。
这条策略可以推广到所有”高频增量 + 昂贵渲染”的场景:刷新频率应该由成本决定,不由事件频率决定。
四、中断不是把开关关掉
用户点”停止”,看起来只是不再接收数据。实际要收尾的东西有一串:
- 取消正在读流的协程;
- 保留已经产出的内容,不能因为中断就清空;
- 把所有”正在流式”的标志位复位,否则界面永远转圈;
- 把已经收到的部分落库,让它成为一条完整的历史;
- 告诉模型这一轮是被用户打断的,不是自然结束。
最后一条尤其重要。如果不说,模型会以为自己的回答已经完整,下一轮可能接着一个半截的结论往下推。
还有一个隐蔽的并发问题:停止之后用户可能立刻发下一条。上一条的收尾逻辑如果跑得慢,可能会写到新消息上——把新一轮的流式状态清掉,或者把旧内容追加到新气泡里。
解决办法是给每一轮一个代际号:收尾时只处理属于自己那一轮的状态,代际不匹配就跳过。
五、超时该看间隙,不看总长
流式还有一个反直觉的地方:总超时会误杀健康的请求。
一次长回答可能持续三分钟,数据一直在来,速度稳定。如果设一个 150 秒的总超时,它会在正常运行时被砍掉。
正确的指标是空闲时间:两次数据之间隔了多久。只要一直在收,就不算卡住;连着几十秒没有新数据,才判定为断流。
有个真实的调参记录很能说明问题:一次压缩任务在 150 秒时被取消,但那次运行已经收到七千多个事件、一万九千字符,速率稳定在每秒四十个 token。数据全程在流动,唯一的问题是”内容很长”。换成空闲超时之后,同样的任务正常完成。
用错误的指标做保护,保护本身就变成了故障源。
六、断流之后,已经收到的算不算
算,但要标记。
流在中途断了,已收到的文字是真实产出的,用户也看到了,不能因为没收到结束标记就全部丢弃。正确做法是把它作为一条不完整的消息保留,明确标注”输出中断”。
下一次请求时,这条消息会作为历史发出去——这时候要注意缓存:如果重试时把内容重新拼接、重新格式化,前缀就变了,缓存全废。所以要尽量保持原样。
这也是上一篇那条规则的延伸:历史一旦产生,就不要再改写它。
七、流式的代价
最后说清楚它换来了什么、付出了什么。
换来的是可感知的进度,以及更早的中断机会——用户看到方向不对,可以立刻停,不必等三十秒。
付出的是实现的复杂度:三种状态、两套超时、节流策略、代际收尾、断流后的完整性标记。每一条都可能出错,而且错法都很隐蔽——界面卡在”正在思考”、内容重复显示、停止后还在跑。
这些 bug 不会让程序崩溃,只会让用户觉得”这个 App 有点卡”。而体验的好坏,往往就藏在这些不崩溃的地方。