Bedrock Tool Useの構造化出力をSSEでトークン単位ストリーミングする — 増分JSONパーサの状態機械設計
はじめに
LLMの出力を確実に構造化JSONとして得るために、Bedrock(Claude)の Tool Use を使う方法は こちらの記事 で紹介しました。tool_choice で特定のツールを強制すれば、APIレベルでスキーマに準拠したJSONが返ります。正規表現や json.loads でLLMの生テキストをパースする脆弱性から解放される、優れたパターンです。
しかし、ここで新たな問題が生まれます。
Tool Useの出力は、ストリーミング時に input_json デルタとして届くため、完全なJSONが組み立てられるまでフロントエンドに何も表示できない。 ユーザーは数秒間、何も表示されない画面を眺めることになります。
この記事では、Bedrock Tool Useのストリーミングレスポンスを状態機械(State Machine)で増分パースし、テキストフィールドはトークン単位で即座にSSE配信、構造化オブジェクト(チケット情報等)は完全なJSON単位で配信するという、「構造化」と「リアルタイム」を両立させる設計を紹介します。
課題: 構造化出力とストリーミングのトレードオフ
まず、2つのアプローチを整理します。
| アプローチ | 構造保証 | リアルタイム性 |
|---|---|---|
生テキスト → json.loads |
なし(壊れやすい) | トークン単位で即座にストリーム可能 |
Tool Use (tool_choice) |
APIレベルで保証 | 完全なJSONが揃うまで待機が必要 |
多くのチュートリアルは「生テキストをストリーム」か「完全なレスポンスを待つ」のどちらかを扱います。しかし実際のプロダクトでは、両方を同時に求められることが珍しくありません。
今回構築したRAGベースのサービスデスクAIを例にすると、LLMのレスポンスは以下の構造です。
{
"response_type": "results",
"general_advice": "この問題はVPN接続の認証設定に起因する可能性が...",
"results": [
{"issue_id": 12345, "subject": "VPN接続エラー", "summary": "..."},
{"issue_id": 12346, "subject": "認証設定変更手順", "summary": "..."}
],
"reasoning": "ユーザーの症状からVPN関連のチケットが最も関連..."
}
ユーザー体験として求められるのは:
general_advice(テキスト)→ 1文字ずつストリーミング表示(ChatGPTのような逐次表示)results(オブジェクト配列)→ 1件ずつ完全なオブジェクトとして即座に表示- 全体の構造 → Tool Useでスキーマ保証
解決策: 6状態の増分JSONパーサ
核心のアイデア
Bedrockのストリーミングレスポンスでは、Tool Useの入力JSONが input_json イベントとして断片的に届きます。この断片を文字単位で追跡し、JSONの構造を理解する状態機械を通すことで、特定のフィールドだけをリアルタイムに抽出できます。
Pydanticフィールド順序の活用
ここで重要な前提があります。Bedrock Tool Useは、Pydanticモデルのフィールド宣言順にJSONを生成します。 つまり、フィールドの順序を制御すれば、ストリーミングの順序も制御できます。
class ChatToolInput(BaseModel):
"""フィールド順序が重要: Bedrock Tool Useは宣言順にJSONをストリームする。
general_adviceを先頭近くに置くことで、テキストトークンが即座にフロントエンドに届く。
"""
response_type: Literal["clarification", "results"]
general_advice: str = Field(default="", description="担当者向けの回答(マークダウン形式)")
question: str = ""
options: list[str] = Field(default_factory=list)
results: list[TicketResult] = [] # 配列は後ろに配置 → テキスト完了後にストリーム
reasoning: str = Field(default="")
general_advice を results より前に宣言することで、LLMがテキストを生成している間にフロントエンドへトークンを流し、その後で構造化されたチケットオブジェクトを順次配信する、という流れが成立します。
状態機械の設計
状態は6つ定義します。
# JSON escape sequences inside string values
_JSON_ESCAPE_MAP: dict[str, str] = {
"n": "\n", "t": "\t", "r": "\r",
'"': '"', "\\": "\\", "/": "/",
"b": "\b", "f": "\f",
}
# 状態定義
SCANNING = 0 # "general_advice": を探索中
IN_VALUE = 1 # general_advice の文字列値をストリーミング中
SCANNING_RESULTS = 2 # "results":[ を探索中
IN_RESULTS_ARRAY = 3 # results配列内で { または ] を待機中
IN_TICKET_OBJ = 4 # チケットオブジェクト {...} 内、ブレース深度を追跡中
TICKETS_DONE = 5 # results の ] を通過 — チケット処理終了
_MARKER = '"general_advice":'
_RESULTS_MARKER = '"results":['
状態遷移を図示すると以下の通りです。

ストリーミングジェネレータの実装
核となるジェネレータ関数です。Bedrock SDKの messages.stream() から届く input_json イベントを文字単位で処理します。
async def generate_chat_answer(
bedrock_messages: list[dict],
source_chunks_count: int,
):
"""Tool Useストリーミングの増分パーサ。
str(general_adviceの1文字)と TicketResult(完全なチケットオブジェクト)を
非対称にyieldする。
"""
tool_schema = ChatToolInput.model_json_schema()
full_tool_input = ""
streamed_advice = "" # max_tokens到達時のフォールバック用
state = SCANNING
key_buf = ""
escape_next = False
unicode_buf: str | None = None
# チケットストリーミング用の状態
results_key_buf = ""
ticket_buf = ""
ticket_depth = 0
ticket_in_str = False
ticket_esc = False
async with client.messages.stream(
model=MODEL_ID,
messages=sdk_messages,
max_tokens=8192,
tools=[{
"name": "submit_response",
"description": "Submit the structured helpdesk response",
"input_schema": tool_schema,
}],
tool_choice={"type": "tool", "name": "submit_response"},
) as stream:
async for event in stream:
if event.type != "input_json":
continue
chunk = event.partial_json
if not chunk:
continue
full_tool_input += chunk
for ch in chunk:
# ... 状態機械のロジック(後述)
pass
general_advice のトークンストリーミング
IN_VALUE 状態では、JSONの文字列値を1文字ずつデコードして yield します。JSONエスケープシーケンスの処理が重要です。
elif state == IN_VALUE:
# \uXXXX ユニコードエスケープの処理
if unicode_buf is not None:
unicode_buf += ch
if len(unicode_buf) == 4:
decoded = chr(int(unicode_buf, 16))
yield decoded
streamed_advice += decoded
unicode_buf = None
continue
if escape_next:
if ch == "u":
unicode_buf = "" # \uXXXX の開始
else:
decoded = _JSON_ESCAPE_MAP.get(ch, ch)
yield decoded
streamed_advice += decoded
escape_next = False
elif ch == "\\":
escape_next = True
elif ch == '"':
state = SCANNING_RESULTS # general_advice完了 → チケット探索へ
else:
yield ch
streamed_advice += ch
ここでの yield ch が、SSE経由でフロントエンドに1文字ずつ届くトークンになります。LLMが日本語テキストを生成している最中に、フロントエンドではリアルタイムにテキストが表示されます。
チケットオブジェクトの段階的配信
IN_TICKET_OBJ 状態では、ブレース {} の深度を追跡し、完全なオブジェクトが揃った時点でPydanticでバリデーションして yield します。
elif state == IN_TICKET_OBJ:
ticket_buf += ch # 生のJSON文字列を蓄積(エスケープはそのまま)
if ticket_esc:
ticket_esc = False
elif ticket_in_str:
if ch == "\\":
ticket_esc = True
elif ch == '"':
ticket_in_str = False
else:
if ch == '"':
ticket_in_str = True
elif ch == "{":
ticket_depth += 1
elif ch == "}":
ticket_depth -= 1
if ticket_depth == 0:
# 完全なオブジェクトが揃った → バリデーション&yield
try:
ticket = TicketResult.model_validate_json(ticket_buf)
yield ticket
except Exception:
logger.warning("Failed to parse streamed ticket: %.120s", ticket_buf)
ticket_buf = ""
state = IN_RESULTS_ARRAY # 次のチケットを待機
注意すべきポイント: JSON文字列内のブレース {"key": "value with { braces }"} を誤カウントしないよう、ticket_in_str フラグで文字列内かどうかを追跡しています。
非対称な yield の活用
このジェネレータは 2種類の値 を yield します。
| yield する値 | 型 | 意味 | SSEイベント |
|---|---|---|---|
yield ch |
str |
general_adviceの1文字 | token |
yield ticket |
TicketResult |
完全なチケットオブジェクト | ticket |
呼び出し側(FastAPIのSSEハンドラ)で型に応じて振り分けます。
async for item in generate_chat_answer(bedrock_messages, source_chunks_count):
if isinstance(item, str):
yield _sse("token", {"content": item})
elif isinstance(item, TicketResult):
yield _sse("ticket", item.model_dump())
else:
# ChatResponse(最終結果)
yield _sse("result", item.model_dump())
SSEイベント設計: 7種類のイベントレジストリ
SSEイベントはレジストリとして一元管理し、OpenAPIドキュメントも自動生成します。
SSE_EVENTS: dict[str, tuple[type, str]] = {
"progress": (SseProgressData, "処理ステップの進捗(analyzing→retrieving→fetching→generating)"),
"kb_query": (SseKbQueryData, "Knowledge Base 検索に使用したクエリ文字列"),
"kb_chunks": (SseKbChunksData, "Knowledge Base から取得したチャンクのプレビュー一覧"),
"token": (SseTokenData, "AIが生成するテキストチャンク(ストリーミング)"),
"ticket": (TicketResult, "検索結果チケット1件(段階的に配信)"),
"result": (ChatResponse, "最終結果(会話ターン完了時に1回送出)"),
"error": (SseErrorData, "エラー発生時の詳細メッセージ"),
}
各イベントの配信タイミングとUIへの影響をシーケンスで示します。

フロントエンド: SSEイベントに応じた段階的UI更新
フロントエンドでは、fetch + ReadableStream を使ってSSEイベントを手動パースします。
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split("\n\n");
buffer = parts.pop() ?? "";
for (const part of parts) {
if (!part.trim()) continue;
let eventType = "message";
let dataStr = "";
for (const line of part.split("\n")) {
if (line.startsWith("event: ")) eventType = line.slice(7).trim();
else if (line.startsWith("data: ")) dataStr = line.slice(6);
}
if (!dataStr) continue;
const data = JSON.parse(dataStr);
// イベント種別に応じてUIバブルを更新
switch (eventType) {
case "progress": /* プログレスバーを更新 */ break;
case "token": /* ストリーミングバブルにテキスト追加 */ break;
case "ticket": /* 結果バブルにチケットカード追加 */ break;
case "result": /* 最終結果でUI確定 */ break;
case "error": /* エラー表示 */ break;
}
}
}
UIの状態遷移は以下のようになります。

token イベントでは既存のストリーミングバブルにテキストを追記し、最初の ticket イベントが到着した時点で streaming バブルから results バブルに遷移します。その際、すでにストリームされたテキストは results バブルに引き継がれます。
// 最初のticket到着時: streaming → results に遷移(テキストを引き継ぐ)
const streamingBubble = prev.find(
(b) => b.type === "streaming" && b.queryId === queryId,
);
return [
...prev.filter((b) => !(b.type === "streaming" && b.queryId === queryId)),
{
type: "results",
results: [ticket],
general_advice: streamingBubble?.general_advice ?? "",
// ...
},
];
max_tokens到達時のフォールバック
Tool Useでは通常、完全なJSONが保証されます。しかし max_tokens に到達した場合、JSONが途中で切断されます。
この状態機械設計では、streamed_advice に既にストリーム済みのテキストを蓄積しているため、JSON全体のパースが失敗してもユーザーが既に見たテキストは失われません。
try:
tool_input = ChatToolInput.model_validate_json(full_tool_input)
result = ChatResponse(...)
except Exception:
# max_tokens到達でJSONが不完全 → ストリーム済みテキストをフォールバックに使用
logger.warning("Tool input validation failed (likely max_tokens hit); using streamed advice")
result = ChatResponse(
response_type="results",
general_advice=streamed_advice or "回答の生成中にエラーが発生しました。",
source_chunks_count=source_chunks_count,
)
yield result
フロントエンドには既にトークンが届いているため、ユーザーは途切れたテキストまでの内容を読めます。最終結果の result イベントでは、フォールバックのテキストが改めて確定値として送られます。
設計から得られた知見
1. フィールド宣言順序 = ストリーミング順序
Pydanticモデルのフィールド順序がLLMのJSON生成順を決定するという性質は、ドキュメントに明記されていないBedrockの挙動です。テキストフィールドを配列フィールドより前に置くことで、テキストのストリーミングを先行させ、ユーザーが体感する待機時間を大幅に短縮できます。
2. 非対称ストリーミング
すべてを同じ粒度でストリーミングする必要はありません。
- テキスト(
general_advice): 1文字単位でストリーム → レスポンシブなUI - オブジェクト(
resultsの各チケット): 完全なJSON単位でストリーム → データ整合性の保証
この「非対称」が、構造化出力とリアルタイム性を両立させる鍵です。
3. 状態機械のエッジケース
JSONの文字列値内にブレースが含まれる場合("summary": "設定画面の{詳細}タブを開く")、ブレース深度の誤カウントでオブジェクト境界を見誤るバグが発生しえます。ticket_in_str フラグとエスケープ追跡でこれを防いでいます。
4. SSEイベントレジストリの一元管理
SSE_EVENTS 辞書をSingle Source of Truthとし、OpenAPIドキュメントの x-sse-events を自動生成する設計にすることで、新しいイベント追加時にドキュメントとの乖離を防げます。
まとめ
Bedrock Tool Useの構造化出力を維持しながらリアルタイムストリーミングを実現するには、ストリーミングレスポンスの input_json デルタを状態機械で増分パースするアプローチが有効です。
ポイントを整理すると:
- Pydanticのフィールド順序を活用してストリーミング順序を制御する
- 6状態の状態機械でJSON構造を文字単位で追跡する
- テキストは1文字単位、オブジェクトは完全なJSON単位で非対称にyieldする
- 7種類のSSEイベントで段階的にUIを更新する
streamed_adviceの蓄積でmax_tokens到達時にも既存テキストを保全する
「構造化出力かストリーミングか」は二者択一ではなく、状態機械を挟むことで両立できます。











