0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

TypeScriptでLLMストリームの描画をまとめる:初回表示を遅らせないCoalescing Buffer

0
Posted at

LLM ストリームを集約して描画するフロー

LLM のストリーミング応答をそのまま画面へ流すと、短い文字列ごとに状態更新が起きます。逆に、すべてをためてから描画すると最初の一文字が遅くなります。

この記事では、最初の token は即時表示し、続く token だけを短い時間窓でまとめる coalescing buffer を TypeScript で実装します。チャット UI、音声文字起こし、リアルタイム面接支援のように「待たせず、描画を荒らさない」画面で使える小さな部品です。

結論

設計上のポイントは次の 3 つです。

  • 最初の token は buffer に残さず即時に描画する
  • その後の token は最大 40 ms だけ集約する
  • 画面のアンマウントや abort 時は、保留中の文字列を明示的に flush する

これはネットワークの受信を遅らせる仕組みではありません。受信した token はすぐメモリに積み、UI を更新する回数だけ制御します。

なぜ token ごとの state 更新が問題になるのか

ストリームのチャンク境界は文や単語の境界とは限りません。受信イベントごとに setState すると、応答が長いほど描画要求、Markdown の再計算、スクロール追従が細かく発生します。

React 18 には自動 batching がありますが、それだけで「どの頻度で描画するか」が常に望ましい状態になるわけではありません。特に Markdown の構文解析、コードハイライト、長い会話ログの仮想化が同じ更新にぶら下がる場合は、受信回数と UI 更新回数を分けて観測できるようにしておく方が安全です。

一方で、最初から 100 ms ためる実装も避けたいところです。ユーザーが最初に感じるのは全文の完了時間より、「反応が始まったか」です。

framework 非依存の実装

以下のクラスは timer を注入できるようにしてあります。ブラウザ以外でもテストしやすく、timer のキャンセルも一箇所に閉じます。

type TimerHandle = number;
type Schedule = (task: () => void, delayMs: number) => TimerHandle;
type Cancel = (handle: TimerHandle) => void;

export class StreamCoalescer {
  private pending = '';
  private timer: TimerHandle | undefined;

  constructor(
    private readonly render: (text: string) => void,
    private readonly schedule: Schedule,
    private readonly cancel: Cancel,
    private readonly windowMs = 40,
  ) {}

  push(token: string) {
    this.pending += token;

    // timer がない最初の token だけはすぐに見せる
    if (this.timer !== undefined) return;
    this.flush();

    this.timer = this.schedule(() => {
      this.timer = undefined;
      this.flush();
    }, this.windowMs);
  }

  close() {
    if (this.timer !== undefined) this.cancel(this.timer);
    this.timer = undefined;
    this.flush();
  }

  private flush() {
    if (!this.pending) return;
    const text = this.pending;
    this.pending = '';
    this.render(text);
  }
}

push の最初の呼び出しでは flush() が走るため、先頭の token は待ちません。その直後に timer を一つだけセットします。timer が生きている間に届いた文字列は pending へ追加され、時間窓の終わりに一回だけ描画されます。

close() は重要です。リクエストを abort したときや画面を離れるときに timer だけを消すと、すでに受信済みの末尾が表示されないことがあります。

実行可能なテスト

本当に最初の文字だけ即時に出て、次の文字列が一つにまとまるかを fake clock で確認します。

import { strict as assert } from 'node:assert';

function fakeClock() {
  let now = 0;
  let next = 0;
  const tasks = new Map<number, { at: number; task: () => void }>();

  return {
    schedule(task: () => void, delayMs: number) {
      const id = next++;
      tasks.set(id, { at: now + delayMs, task });
      return id;
    },
    cancel(id: number) {
      tasks.delete(id);
    },
    advance(ms: number) {
      now += ms;
      for (const [id, entry] of [...tasks]) {
        if (entry.at <= now) {
          tasks.delete(id);
          entry.task();
        }
      }
    },
  };
}

const rendered: string[] = [];
const clock = fakeClock();
const stream = new StreamCoalescer(
  (batch) => rendered.push(batch),
  (task, delay) => clock.schedule(task, delay),
  (id) => clock.cancel(id),
);

stream.push('');
clock.advance(10);
stream.push('');
stream.push('');

assert.deepEqual(rendered, ['']);

clock.advance(30);
assert.deepEqual(rendered, ['', '接で']);

stream.push('');
stream.push('');
stream.close();

assert.deepEqual(rendered, ['', '接で', '', '']);

このテストで確認している不変条件は二つです。

  1. 先頭の は timer を待たない
  2. 接で は同じ時間窓内なので一度の描画になる

最後の close() が落ちないことも同時に確認できます。

React ではどうつなぐか

render に state の関数更新を渡すだけです。timer handle はブラウザの number になるので、先ほどの型をそのまま使えます。

const stream = new StreamCoalescer(
  (batch) => setAnswer((previous) => previous + batch),
  (task, delayMs) => window.setTimeout(task, delayMs),
  (handle) => window.clearTimeout(handle),
  40,
);

// fetch の ReadableStream や SSE の受信箇所で呼ぶ
stream.push(token);

// useEffect の cleanup や AbortSignal の listener で呼ぶ
stream.close();

実際には StreamCoalesceruseRef に保持し、リクエスト単位で作り直すのが扱いやすいです。古いリクエストの token が新しい回答へ混ざらないよう、abort と close() を同じ cleanup に置いてください。

windowMs はどの値から始めるか

40 ms は万能な正解ではありません。まずは 16〜50 ms 程度から始め、次を計測して調整します。

  • 最初の token を受信してから画面に見えるまでの時間
  • 1 回の回答あたりの UI 更新回数
  • 長い回答中の入力・スクロールの滑らかさ
  • abort 後に末尾が欠けていないか

低性能端末で Markdown の再描画が重いなら時間窓を長くする余地があります。反対に、一文字ずつ出る感覚を重視する画面では短くします。値を先に固定せず、回答長と UI の仕事量をログで見て決めるのが実装後の近道です。

requestAnimationFrame だけでは足りない場面

requestAnimationFrame で次フレームまで更新を遅らせる方法もあります。画面更新をフレームにそろえられるので有効ですが、最初の token まで最大一フレーム待つことになります。また、バックグラウンドタブでは呼び出しが抑制されます。

今回のように「先頭だけ即時」「以降は bounded に集約」という要件なら、先頭を flush してから timer を置く方が意図をコードで説明しやすくなります。描画の予算をさらに厳密に管理する必要が出た段階で、buffer の flush を requestAnimationFrame に載せ替えれば十分です。

LLM のストリーム処理では、受信、保存、描画を同じ頻度で動かさないことが大切です。この小さな buffer を境界にすると、描画負荷を調整しても、上流の token 受信ロジックには手を入れずに済みます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?