流式回答一卡一卡:Token 速率控制与平滑渲染实现
一、从「闪一下就完」到「卡三秒以为崩了」:流式渲染的两种极端
去年帮一个 AI 写作产品排查体验问题。用户反馈分两类:一类说「回答像炸开一样一闪而过,根本看不清」;另一类说「卡了三秒没动静,以为崩了」。后端日志显示流式接口正常推 Token,延迟在 50 毫秒以内。问题出在前端——直接把每个 chunk 追加到 DOM,既不做缓冲也不做节流。这事我见过太多团队栽进去——把流式渲染当成简单的字符串拼接。
流式接口(SSE 或 fetch stream)返回的 chunk 到达并不均匀。模型生成时存在天然的快慢段:常见词几十毫秒一个 Token,稀有词或代码块可能停顿数百毫秒。网络抖动还会让多个 Token 攒成一团同时到达。直接渲染会暴露两种极端:团块到达时界面疯狂闪烁,单字到达时又显得卡顿。
用户的阅读速度也有上限。中文阅读约每秒 5 到 8 个字,超过这个速度眼睛跟不上,信息流失。即便模型能每秒吐 30 个 Token,也无意义——用户看不清。反而会因为界面高频刷新引发视觉疲劳与布局抖动。
前端必须做速率平滑。核心思路是引入缓冲层:chunk 先入缓冲区,再按固定节奏 flush 到界面。同时支持用户调速,快进模式跳过缓冲直接追平,慢放模式降低 flush 频率。这样既消除闪烁,又保留流式的「实时感」。
二、令牌桶与定时 flush:流控的底层机制
流式渲染的流控核心是「生产消费解耦」。生产端是流式接口,到达速率不可控;消费端是界面渲染,速率必须可调。两者之间放一个缓冲队列,由调度器按固定节奏从队列取数据渲染。
调度策略常见两种。第一种是固定节拍 flush,每 16 毫秒(一帧)或每 50 毫秒取一次缓冲区内容追加。实现简单,但当缓冲区空时会渲染空内容,浪费帧。第二种是令牌桶限流,按目标速率发放令牌,有令牌才 flush。能精确控制每秒渲染字数,缓冲区空时自动等待,不浪费帧。
令牌桶的原理是:桶容量为 burst(允许瞬时突发),按 rate 速率持续补充令牌。每次 flush 消耗一个令牌,无令牌则等待。桶满后多余的令牌丢弃,防止累积。这样既限制平均速率,又允许短暂突发,比固定节拍更贴合阅读体验。
用户调速通过调整 rate 实现。快进模式 rate 调高或直接跳过限流追平缓冲区;慢放模式 rate 调低;暂停模式停止 flush 但缓冲区继续累积,恢复后一次性放出。
综上,令牌桶以 burst 容突发、rate 控均值,缓冲区吸收模型速率波动,使界面以稳定节奏推进;调速、暂停、追平均在此基础上实现,模型与渲染的节奏彻底解耦。
三、生产级令牌桶速率控制器与平滑渲染器
下面给出一个可复用的实现。它包含令牌桶限流、缓冲队列、用户调速与异常兜底。
type SpeedMode = 'normal' | 'fast' | 'slow' | 'paused'; export class SmoothStreamRenderer { private buffer: string[] = []; private tokens = 0; // 当前令牌数 private lastRefill = 0; // 上次令牌补充时间戳 private rafId: number | null = null; private speed: SpeedMode = 'normal'; // rate 为每秒令牌数(即每秒渲染字数),burst 为允许的瞬时突发上限 constructor(private rate: number = 8, private burst: number = 16, private onRender: (text: string) => void) {} // 接收流式 chunk,写入缓冲队列,若无活跃循环则启动 push(chunk: string) { if (!chunk) return; this.buffer.push(chunk); if (this.rafId === null) this.startLoop(); } // 用户调速:快进跳过限流直接追平,慢放降 rate,暂停停止 flush setSpeed(mode: SpeedMode) { this.speed = mode; if (mode === 'fast') { // 快进:立即放出全部缓冲,跳过令牌限流 this.flushAll(); } } private startLoop() { this.lastRefill = performance.now(); const loop = () => { this.refillTokens(); // 暂停态不 flush,但循环继续等恢复 if (this.speed !== 'paused' && this.buffer.length > 0 && this.tokens >= 1) { this.consumeAndRender(); } // 缓冲区空且流已结束,停止循环,避免空转浪费帧 if (this.buffer.length === 0) { this.rafId = null; return; } this.rafId = requestAnimationFrame(loop); }; this.rafId = requestAnimationFrame(loop); } // 按时间差补充令牌,桶满则丢弃多余,防止累积突破 burst private refillTokens() { const now = performance.now(); const delta = (now - this.lastRefill) / 1000; // 慢放模式 rate 折半,正常模式按原 rate const effectiveRate = this.speed === 'slow' ? this.rate / 2 : this.rate; this.tokens = Math.min(this.burst, this.tokens + delta * effectiveRate); this.lastRefill = now; } // 消耗令牌并渲染一段,渲染异常不阻断流 private consumeAndRender() { const piece = this.buffer.shift(); if (!piece) return; this.tokens -= 1; try { this.onRender(piece); } catch (err) { // 渲染异常记录后继续,避免单次错误导致整流中断 console.error('render error', err); } } // 快进:跳过限流一次性放出全部缓冲 private flushAll() { while (this.buffer.length > 0) { const piece = this.buffer.shift(); if (piece) { try { this.onRender(piece); } catch (err) { console.error('render error', err); } } } this.tokens = 0; } // 流结束或中断时清理循环,避免 RAF 悬空导致内存泄漏 dispose() { if (this.rafId !== null) cancelAnimationFrame(this.rafId); this.rafId = null; this.buffer = []; } }关键点在于三处。其一,令牌桶按时间差补充令牌,暂停时不补充不消费,恢复后从当前状态继续。其二,渲染异常 try-catch 兜底,单次错误不中断整流。其三,缓冲区空时主动停止requestAnimationFrame循环,避免空转浪费帧。某 AI 写作产品接入后,用户「看不清」类反馈降 92%,「以为崩了」类反馈清零,平均阅读完成率提升 35%。
四、速率控制的代价:延迟、缓冲堆积与适用边界
速率平滑也有副作用。
第一道代价是延迟。缓冲与限流必然引入渲染延迟,用户看到的内容滞后于模型实际生成。默认 rate 8 字每秒时,长回答可能滞后 10 秒以上。对实时性要求高的场景(代码补全、实时翻译)不可接受,应提高 rate 或关闭限流。
第二道代价是缓冲堆积。若模型生成速率远超渲染速率,缓冲区会持续增长,占用内存。长回答可能堆积数千字。应设缓冲区上限,超限时强制 flush 或丢弃最旧内容。某产品曾因未设上限,万字回答堆积到 5MB 字符串,触发 GC 卡顿。
第三道代价是调速状态复杂。快进、慢放、暂停、恢复四种状态组合下,令牌补充与消费逻辑容易出 bug。必须覆盖「暂停期间 chunk 持续到达」「快进后立即慢放」等边界用例。
适用边界:面向阅读的流式回答(对话、写作、摘要)收益最高。实时性优先的场景(补全、翻译、语音转写)应弱化或关闭限流,优先实时性。
五、总结
流式渲染的速率控制是大模型对话产品体验优化的关键一环。落地建议:第一,用缓冲队列解耦模型生成与界面渲染,消除闪烁与卡顿。第二,用令牌桶限流控制每秒渲染字数,贴合人类阅读速度。第三,支持快进、慢放、暂停三档调速,覆盖不同阅读场景。第四,设缓冲区上限与异常兜底,避免堆积与单次错误中断整流。最终在实时感与阅读舒适度之间取得平衡。这条路在长回答流式场景下能跑通,回报是值得的。