はじめに
LLM を呼んで返ってきた文字列、そのまま res.text を画面に出して終わっていませんか。
素朴な呼び出しコード自体は AI が数行で書いてくれる時代です。
ただ実アプリに載せると、その 1 行だけでは絶対に成立しない場所があります。
この記事では、その「成立しない場所」を 3 つの層で切り分ける視点をまとめます。
TL;DR
素の LLM 呼び出しは実アプリでは 3 つの層で壊れます。
配信層 / データ層 / 実行層 をそれぞれ Streaming / Structured Outputs / Function Calling で解きます。
3 パターンは択一ではなく、実アプリではほぼ同時に組み合わせて使うのが普通、と分かりました。
1. LLM の素朴な呼び出しは 3 層で壊れる
LLM を勉強し始めて最初に書くのは、だいたい「プロンプトを送って、返ってきた文字列を画面に出す」というコードだと思います。
ここまでは 1 分でできてしまいます。
ただ実アプリに載せようとした瞬間、別々の場所で 3 種類の壊れ方をすることに気づきました。
まずその壊れ方を並べて、共通の視点で捉え直します。
1-1 素朴な呼び出しはこう書く
擬似コードで書くとこんな感じです。プロンプト文字列を投げて、返ってきたテキストをそのまま返す。動きます。動くのですが、これで実アプリになるかは別問題でした。
# 素朴な最小構成
response = llm.generate(prompt="ロンドンの天気を教えて")
print(response.text)
1-2 実アプリでは 3 種類の壊れ方をする
自分で軽い LLM 機能を作って触っていて、「なんか調子悪いな」と感じたことを分解してみると、実は毎回別の場所の話でした。並べてみるとこうなります。
| 症状 | 実際に起きていること | 影響 |
|---|---|---|
| チャット画面が 30 秒真っ白 | 全文が生成し終わってから一括で返している | ユーザーは「反応がない」と感じてタブを閉じる |
| JSON パースで落ちる |
{} の前に説明文が混ざる/キー名が揺れる/タイポが混じる |
後段の処理が定期的に例外を吐く |
| 「今の天気」を聞いてもハズレを返す | モデルは学習時点までの情報しか持たない | もっともらしい嘘(ハルシネーション)で答えてしまう |
3 症状は別々の困りごとに見えます。
それぞれ「Streaming を入れる」「正規表現で頑張る」「自分で天気 API を叩いて結果をプロンプトに埋める」と対処してきました。
でもそれだと毎回ゼロから設計することになります。
1-3 3 層で捉え直す
3 症状を並べて眺めていて、これは 層が違う のだ、と腑に落ちました。
応答の届き方の話・出力形式の話・LLM が持てる情報の話は、それぞれ違うレイヤーの問題です。
この 3 層で捉え直すと、症状ごとに定石が 1 つずつ紐付きます。
配信層は Streaming、データ層は Structured Outputs、実行層は Function Calling。
以降 3 章はこの 3 層それぞれの解法を、順番に見ていきます。
2. 配信層 ── Streaming で「待ち時間の UX」を解く
前章の「30 秒真っ白」の症状は配信層の問題でした。ではその配信層はどう解けるか。
2-1 「30 秒真っ白」の正体
素朴な呼び出しは、モデルが全生成を終えてから応答を一括で返す方式です。
だから応答が長くなるほど、リクエストを送った直後は完全に沈黙します。
500 トークン規模の回答だと、体感 10〜30 秒何も出ません。
反応が返って来ない Web ページはだいたい 5 秒で閉じられる、と言われるので、これはそのまま UX 事故です。
ChatGPT や Claude のチャット UI で文字が 1 文字ずつ流れてくる、あの見た目は Streaming(ストリーミング応答) で作られています。
LLM がトークン(テキストの最小単位、文字より少し大きい塊)を生成したそばから、順次クライアントに送る方式です。
合計のバイト数はむしろ少し増えるくらいですが、「最初の 1 文字が見えるまで」の時間が劇的に縮みます。
だから体感がまったく別物になります。
2-2 chunked transfer と SSE で逐次送る
技術的な仕組みは、HTTP の chunked transfer encoding(レスポンス本文を塊で送る、全体サイズが決まる前に配信を開始できる方式)と、それをラップした SSE(Server-Sent Events)(HTTP でサーバー → クライアントへ一方向にイベントを流し続けるための標準仕様)が土台です。
名前は難しいのですが、やっていることは「サーバー側で 1 チャンク作ったら送る、クライアント側で来た順に読む」だけです。
サーバー側は「1 チャンクできたら enqueue して送る」だけ。
クライアント側は reader.read() を while ループで回して来た分だけ表示する。
これが最小構成です。
2-3 嬉しいことばかりではない
導入すれば全て解決、というわけでもなくて、Streaming には裏側で自分で拾わないと壊れる落とし穴があります。
| 落とし穴 | 何が起きているか | 対策の方向 |
|---|---|---|
| キャッシュしにくい | 途中まで送ったものを保存すべきか、完了後の全体を保存すべきかが曖昧 | キャッシュキーを「完了時のフル応答」で持つと割り切る |
| エラー処理が難しい | 半分だけ届いてから失敗するケースがある | クライアント側で「不完全な応答」を検知・再送する層を持つ |
| JSON ペイロードが 1 チャンクに収まらない | チャンク境界と JSON の区切りが一致しない、複数イベントが 1 チャンクに混ざる | 行単位でパースする実装にする |
特に 3 つ目は Structured Outputs と組み合わせるときに直撃します。
「JSON を Streaming で返す」場面ではだいたい行単位パーサーの実装が必要になる、と覚えておくと後で困りません。
3. データ層 ── Structured Outputs で「出力パースの信頼性」を解く
配信層は Streaming で解けました。次はデータ層です。「JSON で返してと書いたのに崩れる」の症状は、実は API レベルで解決策があります。
3-1 「JSON で返して」だけでは崩れる
プロンプトに「JSON で返して」と書けば JSON が返ってくる、というのは半分正解で半分嘘です。
返ってはくるのですが、実運用に載せると意外な壊れ方をします。
| 壊れ方 | 例 |
|---|---|
| 余計な説明文が付いてくる | もちろんです、以下が JSON です: {...} いかがでしょうか? |
| キー名が揺れる | 同じ商品情報が productName の日と product_name の日がある |
| タイポが混じる |
{"price": 100} のはずが {"prc": 100}
|
正規表現で { と } を取り出す、try/except で JSON パースの失敗を握り潰す、こういう対処を何度か書きました。
ただモデルのバージョンが上がるたびに崩れ方が変わって追いつかなくなります。
3-2 JSON Schema で API レベルで縛る
Structured Outputs(構造化出力) は「返す形式をこの JSON Schema に沿わせろ」と API レベルで指定する仕組みです。
JSON Schema はキー名・型・列挙値・必須項目を宣言的に定義する仕様で、感覚としては JSON 版の「型定義」です。
これを API 引数として渡すと、モデルの出力が必ずそのスキーマに沿います。
擬似コードで書くとこんな感じで、スキーマを渡すだけです。
schema = {
"type": "object",
"properties": {
"name": {"type": "string"},
"price": {"type": "integer"},
},
"required": ["name", "price"],
}
response = llm.generate(prompt="この商品情報を JSON で", response_schema=schema)
# response.output は必ず {"name": "...", "price": 数値} の形
Structured Outputs が保証してくれるのは 必須キーの省略なし・列挙値の逸脱なし・型の一致 の 3 つです。
try/except で頑張ってきた部分がそっくり消えます。
3-3 保証されるのは形式だけ、中身は別
ここで一番大事な話をしておきます。Structured Outputs は 形式 を保証してくれるだけで、中身の正しさ は別問題です。
たとえば商品情報を抽出するプロンプトで {"name": "Foo", "price": 100} が返ってきたとします。
100 が実際の販売価格として正しいかどうかは、Structured Outputs は関知しません。
ハルシネーション(もっともらしい嘘)そのものが消えるわけではないので、そこは別の仕組み(別モデルによる検証、DB との突き合わせ、人間確認)で担保する必要がある、と分かりました。
「JSON パースで落ちる」が「JSON パースは通るがデータが嘘」に変わるだけの場面もあり得るので、後者の対策とセットで導入する感覚が近いです。
3-4 いつ使うか、いつ使わないか
Structured Outputs が効くのは「LLM の出力を 次のプログラムが読む」ものです。
逆にユーザーに直接見せる自然言語の回答本文には向きません。
| 用途 | 使うか | 理由 |
|---|---|---|
| 非構造テキストからの情報抽出(会社名・金額・日付を取り出す) | 使う | 抽出結果を DB に入れる、後段で計算する |
| フォーム自動入力(申請書テキスト → フォーム項目) | 使う | フォーム項目は決まった型を持つ |
分類(category: enum で厳密に) |
使う | 列挙値の逸脱を防げる |
| チャットの回答本文をそのままユーザーに見せる | 使わない | 自然言語の柔軟さを殺す。プレーンテキストで良い |
「後段プログラムがパースするか」を判定軸にすると外しません。
4. 実行層 ── Function Calling で「外部システム連携」を解く
データ層は Structured Outputs で解けました。最後は実行層です。「LLM は今の天気を知らない」の症状はここで解きます。
4-1 LLM は「今」を知らない
LLM の学習は特定の時点で切り取られていて、今日の天気も、社内 DB のレコードも、メール送信 API の使い方も、モデル自体には入っていません。
「ロンドンの現在の天気は」と聞くと、それっぽい数値を捏造して返すこともあります。
これは Structured Outputs でも解けません。
モデルの外側にある情報や動作を、モデルに教える・使わせる仕組み が別途要ります。
4-2 tool 定義 → LLM が「呼べ」と指示 → ラッパーが実行
その仕組みが Function Calling です。
名前が「Calling」なので LLM が関数を呼びに行くように誤解しがちですが、実際は違いました。
LLM は「呼ぶべき指示」を JSON で出すだけで、実際に関数を実行するのはアプリケーション側の ラッパー(wrapper、LLM の入出力を仲介するアプリ側コード)です。
準備として、モデルに「使える関数の一覧」を渡します。
1 つの関数を tool と呼び、名前・説明・引数のスキーマ(これも JSON Schema です)からなります。
天気取得の例だと、get_current_weather(location, unit) を tool として登録しておく感じです。
ユーザーが「ロンドンの天気は」と聞くと、以下のループが走ります。
ユーザー入力を起点として、①〜⑤の 5 段のループを回すのがこの仕組みの本体です。
ラッパーは「LLM が要求したときだけ関数を実行して、結果を LLM に戻す」ことだけ責任を持ちます。
4-3 Structured Outputs との関係
Function Calling で LLM が返す「関数呼び出し JSON」も、内部的には Structured Outputs と同じ「JSON Schema による強制」で支えられています。
似ているので混ざりやすいのですが、目的で使い分けます。
| 目的 | 選ぶもの | 例 |
|---|---|---|
| モデルに アプリのツール・関数・データに接続 させたい | Function Calling | 天気 API を叩く、社内 DB を検索する、Slack に通知する |
| モデルが ユーザー宛の応答を構造化された形で返す ようにしたい | Structured Outputs | 商品情報を JSON で抽出する、分類ラベルを返す |
見分け方は「その JSON は 実行される のか、表示される/後段の DB に入る のか」です。
実行されるなら Function Calling、後段のデータになるなら Structured Outputs、で片付きます。
4-4 副作用ある tool は危ない
ここは強めに書きたいのですが、Function Calling で公開する tool に 副作用(実行するとシステムの状態が変わる操作、たとえば送信・削除・課金)が含まれるときは、必ず安全策を挟む必要があります。
LLM がユーザーの意図を誤って解釈して、想定と違う関数を呼ぶことは普通に起こるからです。
読み取り専用の tool(天気取得、DB 検索など)は自由に呼ばせて構いません。
ただし副作用ありの tool(メール送信、レコード削除、決済実行など)は、実行前に人間の確認を挟むか、権限ゲートで遮断する層を必ず用意します。
tool の設計時点で「これは副作用ありか読み取り専用か」を分けておくと、後で事故が減る、と理解しました。
4-5 設計のコツ
Function Calling の tool 設計は、慣れないと LLM に「どれを呼ぶか」を誤らせやすいところです。
以下の 4 点を守ると、誤呼び出し率がぐっと下がります。
- 1 tool = 1 目的: 「何でもできる万能 tool」は LLM が使い所を誤る
- description を丁寧に書く: モデルはこの説明を頼りにどれを呼ぶか決めるので、雑だと外す
- 引数を最小限にする: 引数が多いほど誤り率が上がる
- 副作用ありには人間確認レイヤを挟む: 実行前に yes/no ゲートを置く
5. 3 層を組み合わせる ── 選定の 3 問
3 パターン個別は見ました。ここまで層で並べて見てきましたが、実アプリではこの 3 つはほぼ同時に、1 つの機能の中で組み合わせて使うのが普通でした。
5-1 「社内ドキュメント検索チャット」で 3 パターン同居
たとえば「社内ドキュメントを検索してチャットで答える」機能を作るとします。中を分解すると、こんな流れになります。
Function Calling で社内 DB を叩き、返ってきた答えを Structured Outputs で「要約と引用元リンクの構造」に落とし、それを Streaming で逐次配信する。
1 つの機能の中で 3 パターンが順に発動しています。
実務ではだいたいこういう合体構成になります。
5-2 選定の 3 問
では新しい LLM 機能を設計するとき、この 3 パターンをどう組み合わせるかを決める判断軸が要ります。自分が使っているのはシンプルな 3 問です。
3 問はそれぞれ独立に判定できます。組み合わせは機能ごとに変わりますが、たとえばこんな具合です。
- 「バッチ処理で JSON を吐くだけ」なら No/Yes/No で Structured Outputs のみ
- 「単なる自然言語チャット」なら Yes/No/No で Streaming のみ
- 「LLM が外部システムを段階的に操作する機能(エージェント的な機能)」なら Yes/Yes/Yes で 3 つとも要る
5-3 3 問の掛け算で組み合わせを決める
3 問は独立なので、原理的には Yes/No の掛け算で 8 パターンあります。ただ実アプリでよく見るのは 3〜4 パターンに集約されます。
| 機能タイプ | 待ち UX | 後段パース | 外部連携 | 組み合わせ |
|---|---|---|---|---|
| バッチ処理でデータを抽出して DB に入れる | No | Yes | 場合による | Structured Outputs(+ 必要なら Function Calling) |
| チャット UI で自然言語だけを返す | Yes | No | No | Streaming のみ |
| 社内ドキュメント検索チャット | Yes | Yes | Yes | 3 つとも |
| コード生成 UI で説明 + JSON 出力 | Yes | Yes | No | Streaming + Structured Outputs |
機能ごとに「必要な層」を洗い出せば、組み合わせは自然に決まりました。
「AI 機能を追加してほしい」と言われた時、まずこの 3 問を自問する。
後付けで直す量がかなり減ります。
おわりに
3 層(配信 / データ / 実行)と、対応する 3 パターン(Streaming / Structured Outputs / Function Calling)を見てきました。
層で捉えれば、AI が生成した LLM 統合コードを人間がレビューする場面でも「この機能なのに Streaming が入っていない」「副作用ある tool に人間確認レイヤがない」といった抜けを見抜けます。
3 問を自分の機能に当てて、必要な組み合わせを言葉にできる状態になれば、素朴な呼び出しから 1 段上がった設計に届く感覚が持てました。