なぜLLMにSQLスキーマと読み取り専用接続を与える設計が検討されたのか

健康データのような複雑かつ個人的なドメインにおいて、LLMにデータベースのスキーマ情報と読み取り専用接続を直接付与し、自由なSQLクエリの生成を委ねるアプローチは、多くのプロジェクトで慎重に検討されます。この判断の背景には、モデルがSQLを生成する過程で生じるデータトラップによる誤答の可能性が看過できないレベルであるという現実があります。

多くの初期プロトタイプでは、「LLMがSQLを書いてくれたら柔軟だ」という誘惑に駆られがちです。しかし、健康アプリの文脈では、ユーザーが尋ねる質問は「昨日のカロリー摂取量」のような一見単純なものでも、裏側では欠損値や重複データ、集計ロジックの解釈違いといった課題へと繋がります。

モデルに直接SQLを生成させる設計が見送られるケースが多いのは、プロンプトエンジニアリングでこれらの罠を完全に封じ込めることが困難だと判断されるためです。特にApple HealthやGoogle Fitのような外部サービスから取得したデータは、プラットフォーム固有のノイズを含んでいることが多く、純粋な技術的な制約(スキーマ設計)だけでなく、データの質に対する慎重さが求められます。

ADR 0006:プロンプトではなくツールの構造でトラップを封じ込める設計判断

この課題に対して下された決定が、Architecture Decision Record (ADR) 0006として文書化されました。その核心は、「プロンプト内の指示に頼るのではなく、ツール(関数)の型定義と構造自体でデータトラップを封じ込める」という方針です。

これにより、LLMが扱えるのは生のSQLクエリではなく、事前に定義された「型付き関数」の呼び出しのみという制約がかかります。LLMは「SELECT * FROM calories WHERE date = ...」のような曖昧な命令を出力するのではなく、`get_daily_nutrition(date: string)` という特定のシグネチャを持つツールを呼び出すことを強いられます。

このアーキテクチャの利点は、セキュリティと正確性の両面で顕著です。LLMがデータベースの構造そのものを知らされないため、スキーマ情報の漏洩リスクを低減できます。また、集計ロジックやフィルタリング条件といった「どう計算するか」というビジネスルールを、コードベース(ツール側)で一元管理できるため、プロンプトの変更だけでロジックが壊れるリスクを回避できます。

flowchart TD
    N1["LLM"] --> N2{"ツール呼び出し"}
    N2{"ツール呼び出し"} -- 型付き関数 --> N3["計算エンジン"]
    N3["計算エンジン"] --> N4["安全なSQL生成"]
    N4["安全なSQL生成"] --> N5["データベース"]
    N5["データベース"] --> N6["検証済み結果"]
    N6["検証済み結果"] --> N3["計算エンジン"]
    N3["計算エンジン"] --> N7{"定型化されたJSONペイロード"}
    N7{"定型化されたJSONペイロード"} --> N1["LLM"]

具体的な防御的プログラミング:データトラップへの実装レベルでの対応

ツール定義内で、具体的にどのようなデータトラップを回避するロジックを実装しているかを確認します。ここでは特に「部分的な日の平均化」と「エネルギー収支の過大評価」への対策が重要です。

例えば、「今日のカロリー消費量」を尋ねられた場合、LLMがツールを呼び出す時点ではまだ当日が終わっていないため、データは不完全です。これをそのまま返すとユーザーに誤解を与えます。そのため、ツール内部ではウィンドウクエリを「今日より前の日付」に制限するロジックを組み込むケースがあります。

また、Apple Healthなどのソースから取得したトレーニングデータには重複が含まれることがあり、さらに欠損日も存在します。これをそのままゼロとして扱ったり除外したりすると、エネルギー収支の計算が極端に歪む可能性があります。対策として、欠損日は「存在しない行」として扱い、必要に応じて補足情報を含むペイロードでのみ取得可能にするなど、データの文脈を失わない設計にすることがあります。

データトラップSQL直接生成時のリスク構造化ツールによる対応策
部分的な日の平均化当日の未完成データを平均値として返すツール内部で今日より前の日付のみを対象とする
欠損値のゼロ扱い欠落データを0とみなし、総量や平均を過小評価する欠損日は存在しない行として扱い、ペイロードに含める
トレーニングデータの重複同じセッションが複数回カウントされ消費量が過大評価されるツール側で重複排除ロジックを実行する
エネルギー収支の過大評価欠損値や重複を補正しないまま計算される補足情報を含む構造化ペイロードでのみ出力を制限

カタログの欠陥とLEFT JOINによる補完:未登録指標への対応

ツールベースのアプローチを採用していても、データのマッピング自体に盲点が残る場合があります。具体的には、`metric_catalog`テーブルの定義不備です。このカタログがすべての指標をカバーしていない場合、重要な指標が欠落するバグが存在している可能性があります。

著者の検証によると、`metric_catalog`テーブルは81ある指標のうち38しかカバーしておらず、残りの43指標(歩行距離など)が欠落するバグがあったことが判明しました。これはLLMの挙動とは関係なく、単なるデータ定義の問題ですが、結果としてユーザーに「データがありません」という誤った回答をさせる原因となります。

このカタログの欠陥を修正するため、`metric_catalog`テーブルへの依存を弱め、観測データ(observations)からLEFT JOINする方式に変更しました。これにより、カタログに登録されていなくても、実際の観測データが存在すればその指標を表示されるようにしています。データベースのスキーマ設計とフロントエンドのロジックの間に生じるギャップを、JOIN操作によって埋めることで、システムの堅牢性を高めるアプローチです。

flowchart LR
    N1["metric_catalog"] -.->|LEFT JOIN| N2["観測データ"]
    N3["LLM要求"] --> N4["ツールロジック"]
    N2["観測データ"] --> N4["ツールロジック"]
    N4["ツールロジック"] --> N5["指標マッピング"]
    N5["指標マッピング"] --> N6["結果の返却"]

浮動小数点の偽精度排除とトークンコスト削減:ツール境界での丸め処理

健康データの数値には、センサー由来の「偽精度」が含まれることがあります。例えば、消費カロリーが `1370.903333 kcal` のように出力されると、ユーザーやLLMに対して不必要な詳細さを提示してしまう可能性があります。

これを避けるため、ツールの境界(出力直前)で値を2桁に丸める処理を入れています。これにより、人間が読みやすい形式になるとともに、LLMへの入力トークン数を削減できます。浮動小数点の細かい桁まで含めると、それだけで不要なトークンを消費し、かつ「なぜこの数字なのか」という誤解を招く要因になり得ます。

丸め処理は単なる表示の問題ではありません。モデルが数値の微小な違いに過剰に反応することを防ぎ、本質的なトレンド(例:1370 kcal vs 1371 kcal)ではなく、大きな変化(例:1370 kcal vs 2000 kcal)に注目させる役割を果たすことがあります。

検証スクリプトによる実証:過剰トレーニングシグナルの実際の発火確認

設計上の懸念が実際にシステムで発生しているか、検証スクリプトを用いて確認することがあります。著者の環境では、過去2,409日間のデータで「過剰トレーニング」シグナルが実際に1回しか発火していないことが `npm run verify-signals` により確認されました。

著者のメモでは「未発火」と記録されていた時期もありましたが、スクリプトの実行により2022年4月25日に1回発火していた事実が修正されました。これは、人間の手作業による確認の限界と、コードベースでの検証の信頼性の差を示す好例です。

このように、設計判断が正しいかどうかは、プロンプトやドキュメントではなく、実際のデータに対するスクリプトの実行結果によって裏付ける必要があります。特に、健康アプリケーションではユーザーの安全に関わるため、シグナルの発火履歴を正確に把握することは重要です。

sequenceDiagram
    participant S as 検証スクリプト
    participant DB as データベース
    participant V as シグナル判定ロジック
    S->>DB: 全期間データを取得
    DB-->>S: 生データセット
    S->>V: データセットを渡す
    V-->>S: 発火回数を返却
    S->>S: レポート生成

コストとレイテンシの測定:プローブスクリプトによる継続的な監視

LLMをツール呼び出しの判断役として使用する場合、そのランニングコストとレイテンシはシステムのパフォーマンスに直結します。これを把握するために、claude-opus-5モデルを使用するプローブスクリプト(probe-coach.ts)を導入しています。

このスクリプトは、実際のクエリシナリオを模擬実行し、各呼び出しにかかったコスト(トークン数換算)とレイテンシを記録します。これにより、「LLMを使うことで応答が遅くなりすぎるのではないか」「ランニングコストが許容範囲内か」というビジネス要件を定量的に評価できます。

プロンプトのみでフィルタリングを行う場合と比較して、構造化ツールを使用するアプローチは、初期の実装コストは増えますが、その後の維持管理コストや誤答による修正コストを大きく削減できる可能性があります。プローブスクリプトは、このトレードオフをデータに基づいて判断するための重要な指標となります。

まとめ:実務で使える「構造化されたパラメータ渡し」のパターン

LLMにSQLスキーマやデータベース接続を直接渡す設計は、セキュリティリスクとデータの質による誤答の観点から推奨されないケースがあります。代わりに、ADRのようにアーキテクチャ決定を記録し、「ツール呼び出し」という構造化されたインターフェースを通じてのみデータアクセスを行うパターンが有効です。

このアプローチでは、データトラップの回避、偽精度の排除、未登録指標への対応などを、プロンプトではなくコードレベルで防御的に実装できます。また、検証スクリプトやプローブスクリプトを用いた定量的な評価を組み合わせることで、信頼性の高いAIアプリケーションを構築・維持することが可能になります。

大規模言語モデルを活用する際、その柔軟性ゆえに「何でもできる」と過信しがちですが、制約のある環境下では、むしろその制約(型定義やツール境界)こそがシステムの堅牢さを生むことがあります。実務では、プロンプトのフィルタリングだけでなく、この実装レベルでの防御的プログラミングを徹底することが、失敗しにくいAI導入の鍵となります。

関連記事

参考

本記事は海外の技術トレンド「Don't Give the Model SQL」(DEV)で話題のテーマをもとに、両儀システムソリューションズが独自に解説したものです。