モデルはストリーミングしている。それでも文字が跳ねるのはなぜか
モデルがストリーミングしていても、画面上の文字まで滑らかに流れるとは限りません。
モデルはネイティブな差分を返し続けていても、その差分は Provider、Agent Runtime、イベントバッファ、WebSocket を通ってブラウザへ届きます。ブラウザが受け取る時点では、複数の差分がひとまとまりになっていることが少なくありません。Web UI がメッセージを受け取るたびにバッチ全体を React へ入れると、ユーザーには「しばらく止まり、突然まとまった文字が増え、また止まる」という動きに見えます。
これはモデルが生成を止めたという意味ではありません。ネットワークが遅くなったとも限りません。問題は、異なる二つの時計を一つとして扱っていたことです。
ストリーミング出力には二つの時計がある。イベントが到着する時刻と、文字が次の画面描画に入る時刻だ。
requestAnimationFrame だけでは滑らかにならない
mosoo のサーバーは、小さな差分ごとに個別の WebSocket メッセージを送りません。Viewer Event は圧縮・結合され、最大 150ms 待ちます。4KB または 64 Event に達したとき、最初の差分、Run の終端イベントでは早く Flush されます。これらの境界値は Delivery Buffer に直接定義されています。最初の差分と終端イベントの Fast Path にも独立した判定があります。
これはシステム上必要なトレードオフです。イベントをまとめれば、メッセージ数、Serialization、下流の State Update を減らせます。その代わり、ブラウザが受け取るリズムはモデルが生成するリズムと一致しなくなります。
以前の実装も requestAnimationFrame で描画処理をまとめていましたが、次のフレームで Queue 内の文字をすべて渡していました。rAF が答えるのは「いつ Commit するか」であって、「そのフレームでどれだけ Commit するか」ではありません。一つの WebSocket メッセージに約 150ms 分の出力が入っていれば、次のフレームで全量を渡しても表示は跳ねます。
固定のタイピング速度ではなく、未描画 backlog を制御する
最も直接的な解決策は、数ミリ秒ごとに一文字ずつ表示する Typewriter です。しかし固定速度では、遅いモデル、速いモデル、短い末尾、突然届く大きなバッチを同時に扱えません。
- 遅すぎると、モデルが終了した後もユーザーは数秒前の文字を見続けます。
- 速すぎると、転送バッチがそのまま目に見える跳ねになります。
- 一文字ごとの Timer は、大量の Callback と State Update も生みます。
mosoo が制御量にしたのは、すでに受信済みで、まだ描画していない Grapheme の数です。
pending = received - rendered
rate = clamp(pending / 0.25s, 20, 800)
budget += rate * elapsedSeconds
emit = floor(budget)
backlog が大きいほど、次のフレームは速く追いつきます。小さければ、表示は緩やかになります。時定数 τ = 250ms により、約 150ms 分の転送バッチは次のバッチが届く頃まで消化中になり、隣り合う階段が連続した流れに変わります。毎秒 20–800 Grapheme という上下限は、短い末尾がいつまでも残ることと、読めないほど速いアニメーションの両方を防ぎます。
ブラウザはモデルの Token を一つずつ再生するのではない。二つ目の時計で一つ目を追う。 各フレームの budget は固定のタイピング速度ではなく、backlog によって決まる。
budget には、各フレームを一律 16ms と仮定せず、実際の経過時間 Δt を使います。そのため 60Hz、120Hz、Throttle されたページでも、同じ実時間なら近い見かけの速度になります。一つの Grapheme に満たない budget は捨てず、小数の余りとして次のフレームへ持ち越します。budget の計算と Queue の切り分けは、一つの Scheduler に集約されています。
分割単位は UTF-16 の index ではなく Grapheme
JavaScript の文字列 index で切ると、Surrogate Pair、結合文字、ZWJ Emoji を途中で分割する場合があります。一時的に � や半分の Emoji が表示されかねません。
mosoo はイベントを Queue へ入れるとき、Intl.Segmenter で Grapheme Cluster に事前分割し、非対応環境では Code Point の反復へ fallback します。この処理は一度だけ行い、フレーム単位の budget は分割結果を再利用します。文字の正しさを守りながら、同じ文字列をフレームごとに再走査することも避けられます。
平滑化には、止める条件が必要
「ゆっくり表示する」ことしかできない Scheduler は不完全です。アニメーションはイベントの意味に従わなければなりません。
Queue に Message End、Reasoning End、Run の終端イベント、または再接続後の State / Messages Snapshot が現れると、mosoo はそれまでの backlog を直ちに渡します。サーバーがその区間の完了をすでに確定しているなら、古いアニメーションを続ける理由はありません。これらのイベントは、コード上で明示的な pacing barrier になっています。
同様に、backlog が 4,096 Grapheme を超えた場合、Scheduler はバッチ全体を直ちに渡します。最高速度で再生を続けても、画面が実際の State から数秒遅れる可能性があります。過負荷時に演出を止めることも、性能戦略の一部です。

平滑化が存在するのは、生成が継続中で backlog を制御できる間だけである。正しさ、終端 State、鮮度が優先される。
ほかにも重要な境界があります。
- すべてのイベントは厳密な FIFO 順を保ちます。後から来た Tool Event や State Event が、表示途中の文字を追い越すことはありません。
- 一フレームで渡すのは最大 512 Event です。病的な Event Storm が一度に巨大な React Commit を発生させるのを防ぎます。
- 非表示ページで
requestAnimationFrameが停止したときは、50ms Timer が Queue を排出し続けます。 - Socket を閉じるとき、Active Session は
flushNowを呼びます。非 Active Session の Queue は消去し、別の Session へ内容が混ざることを防ぎます。
WebSocket でイベントを受け取ると、Scheduler は一フレームにつき一つのバッチだけを Live State へ渡します。Connection を閉じるときは明示的に Flush します。この呼び出し境界は Session Stream Socket 内に保たれています。
低負荷は魔法の数値ではない
今回の変更は Animation Library を追加せず、一文字ごとの Timer も作りません。ブラウザのフレーム時計を再利用し、一つの Queue で budget、分割、State Update のバッチ化を行います。
- Grapheme の分割は Queue に入るとき一度だけ行う。
- 各フレームで、実際の経過時間から整数の budget を計算する。
- 一フレームにつき、バッチ化された State Update は最大一回。
- 平滑化するのはユーザーに見える Text Delta だけで、構造 Event には一文字ずつのアニメーションを付けない。
- 終端 State と過大な backlog には、どちらも直接の Exit Path がある。
これは低負荷を保つための構造的な制約であって、計測していない性能を約束するものではありません。PR #450 の検証対象は時間の Semantics です。制御可能な時計で、60Hz / 120Hz の一貫性、Emoji の境界、終端時の Flush、再接続 Snapshot、厳密な順序、非表示ページの fallback、過大な backlog を確認しています。各 Case はテストファイルで確認できます。今回は Production の CPU や Long Task の Benchmark を行っていないため、「より滑らか」を「計測上より速い」という主張には置き換えません。
再利用できる原則
モデルのネイティブ出力上に Web UI を作るなら、まず次の四つのルールに分けられます。
- 最初の差分は転送 Buffer を迂回させ、最初の文字が出るまでの時間を守る。
- 生成中は固定のタイピング速度ではなく、backlog に応じてフレーム単位の速度を変える。
- 終端イベントと Snapshot は直ちに Flush する。Connection の終了や Active Session の切り替えでは、Queue を明示的に Flush または消去する。
- backlog を制御できなくなったらアニメーションを止める。滑らかさより鮮度を優先する。
転送層はシステム負荷を減らし、描画層は知覚上の連続性を作ります。そして、両者がいつ譲るべきかをイベントの Semantics が決めます。
これが mosoo PR #450 の核心です。モデルの出力方法を変えたわけでも、Token を水増ししたわけでもありません。明確な Exit Path を持つ有界な表示時計をブラウザに与え、モデルのネイティブな Stream へ忠実に追いつけるようにしたのです。