前提
サジェストは、次の形のJSONオブジェクトとしてストリーミングで返ってくる。
{"type": "...", "title": "...", "content": "..."}
画面に逐次出したいのは content の本文だけだ。type と title はメタ情報として扱う。
プロバイダごとの差
OpenAI と Google には response_format があり、json_schema を渡せる。出力の形をAPI側で縛れる。
Anthropic は事情が異なる。Anthropic の前段に置いた OpenAI 互換レイヤーは、response_format を受け取っても無視する。Anthropic には response_format 自体が存在しない。そのため、JSONの形はシステムプロンプト内の指示として書くことになる。
指示で形を伝えているだけなので、キーの並び順まで固定できるわけではない。実際、Claude は type より先に content を出力することがある。
{"content": "...", "type": "...", "title": "..."}
素朴なパーサで起きたこと
最初の実装は、届いたトークンをそのまま表示側へ流していた。キーの順序が想定どおりなら、それらしく見える。順序が崩れると、content の値が閉じた後の ", "type": " のようなJSONの残りがユーザーに見えるテキストとして画面に出てしまう。
どのトークンが値の中身で、どのトークンが構文なのかを、パーサ側で判定する必要がある。
状態機械とリザーブ
修正後は、状態機械でJSONを読み進める。いまキーを読んでいるのか値を読んでいるのか、値ならどのフィールドのものかを追跡する。表示に回すのは content の値の中身だけになる。
さらに、表示へ渡す前にキューの一部をリザーブとして手元に保持する。リザーブは、20文字たまるか100msが経過した時点でフラッシュする。トークン単位で細切れに描画せず、ある程度まとまった単位で画面を更新できる。
type Emit = (text: string) => void;
class ContentReserve {
private reserve = "";
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(private readonly emit: Emit) {}
push(chunk: string): void {
this.reserve += chunk;
if (this.reserve.length >= 20) {
this.flush();
return;
}
if (this.timer === null) {
this.timer = setTimeout(() => this.flush(), 100);
}
}
flush(): void {
if (this.timer !== null) {
clearTimeout(this.timer);
this.timer = null;
}
if (!this.reserve) return;
this.emit(this.reserve);
this.reserve = "";
}
}
push に渡すのは、状態機械が content の値の内側だと判定した文字列だけだ。構文の文字はここに入らない。
欠けたフィールドの扱い
キー順が固定されないので、表示を始める時点で揃っていないフィールドが出てくる。
-
typeがストリーム開始時点で未着の場合は、finalize のときに埋める。 -
titleは、contentが終わった後に届くことがある。
表示側は、content が先に完結し、メタ情報が後から確定するケースを前提に組んでおく。content の描画を type や title の到着待ちでブロックしない。
まとめ
response_format で形を縛れるプロバイダと、プロンプトの指示に頼るしかないプロバイダが混在する場合、キー順を前提にしたパーサはどこかで壊れる。状態機械で値の内側だけを取り出し、リザーブを20文字か100msでフラッシュし、欠けたフィールドは finalize で補う。この構成で、順序が入れ替わってもJSONの残りが画面に出なくなった。リアルタイムでサジェストを表示する LiveSuggest のストリーミング処理は、この形で動いている。