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?

プロンプトを返して終わりにしない ── LLM 統合の 3 層

0
Posted at

はじめに

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 段上がった設計に届く感覚が持てました。

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?