AdMob の日次レポートを要約するカードを、自分の管理画面に足したときのことです。Gemini に数値を渡し、responseSchema で「見出し・要点・推奨アクション・スコア」を返してもらう、よくある構成でした。
動きはしました。ただ、押してから結果が出るまで、画面は数秒間まっさらのままでした。裏ではトークンが届いているのに、JSONが最後の } で閉じるまで JSON.parse が投げ続けるので、何も描けなかったのです。
ストリーミングにしているのに、体感は一括取得と変わりませんでした。むしろ「押したのに無反応」に見える分だけ、印象は悪くなっていました。
その真っ白な数秒を、完成したフィールドから順に埋めていく数百ミリ秒へ。個人開発の自分の画面で実際にそう変えられた、部分パースと逆流防止の設計を、私自身のつまずきも含めて動くコードとともに残します。
完成待ちが体感を悪くする理由
構造化出力のストリーミングでは、モデルは JSON を頭から少しずつ吐き出します。届くのはこういう断片の連なりです。
| 到着段階 | バッファの中身(イメージ) | JSON.parse |
|---|---|---|
| 序盤 | {"title":"省エネ壁紙 | 失敗 |
| 中盤 | {"title":"省エネ壁紙12選","summary":"暗色 | 失敗 |
| 終盤 | {...,"score":87} | 成功 |
標準の JSON.parse は、閉じていない文字列やオブジェクトを一切許しません。ですから終盤で全体が閉じるまで、私たちは何も取り出せません。
けれど中身をよく見ると、title は序盤の時点で既に確定しています。読者に見せられる情報が手元にあるのに、末尾が閉じていないという一点で握りつぶしている。ここが体感の損失点でした。
やりたいことは単純です。バッファが届くたびに、その時点で確定している先頭のフィールドだけを安全に取り出して、順番に画面へ流す。これだけです。
不完全なJSONを安全に補完して読む
方針は、バッファをそのままパースして失敗したら、閉じられる最小限の補完をしてからもう一度パースする、というものです。開いたままの文字列・オブジェクト・配列を数えて、足りない分の閉じ括弧を足します。
半端に書かれている末尾の値(打ちかけの数値やキーだけの箇所)は、揺れの原因になるので落とします。完成した部分だけを見せるのが目的だからです。
// 開いたままの構造を閉じ、半端な末尾を落として「今わかる範囲」を作る
function completePartial(buf: string): string {
const stack: string[] = [];
let inStr = false;
let esc = false;
let out = buf;
for (let i = 0; i < buf.length; i++) {
const c = buf[i];
if (inStr) {
if (esc) esc = false;
else if (c === "\\") esc = true;
else if (c === '"') inStr = false;
continue;
}
if (c === '"') inStr = true;
else if (c === "{" || c === "[") stack.push(c);
else if (c === "}" || c === "]") stack.pop();
}
if (inStr) out += '"'; // 開いたままの文字列を閉じる
out = out
.replace(/,\s*$/, "") // 末尾のカンマを落とす
.replace(/:\s*$/, ": null") // 値のないキーは null で埋める
.replace(/([0-9])\.$/, "$1"); // 打ちかけの小数点を切る
for (let i = stack.length - 1; i >= 0; i--) {
out += stack[i] === "{" ? "}" : "]";
}
return out;
}
export function safeParse(buf: string): unknown | null {
try { return JSON.parse(buf); } catch { /* まだ閉じていない */ }
try { return JSON.parse(completePartial(buf)); } catch { return null; }
}completePartial の肝は、文字列の内側を数えないことです。"{" のような括弧が値の文字列に含まれていても、inStr フラグで無視します。これを忘れると、本文に括弧が入った瞬間に補完が壊れます。
これを Gemini のストリームに繋ぎます。チャンクが届くたびにバッファへ足し、safeParse で「今わかる範囲」を取り出します。
import { GoogleGenAI } from "@google/genai";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY! });
async function streamCard(prompt: string, onField: (k: string, v: unknown) => void) {
const stream = await ai.models.generateContentStream({
model: "gemini-flash-latest",
contents: prompt,
config: {
responseMimeType: "application/json",
responseSchema: {
type: "object",
properties: {
title: { type: "string" },
summary: { type: "string" },
actions: { type: "array", items: { type: "string" } },
score: { type: "integer" },
},
propertyOrdering: ["title", "summary", "actions", "score"],
required: ["title", "summary", "actions", "score"],
},
},
});
let buf = "";
const emitter = fencedEmitter(onField);
for await (const chunk of stream) {
buf += chunk.text ?? "";
const obj = safeParse(buf);
if (obj && typeof obj === "object") emitter(obj as Record<string, unknown>);
}
}