LLMよ、話を聞いて - 不安型開発者が安定型になるまで SQL検証編

第1部 · 自由度はLLMに、正確さはシステムに

李
李昇宰 · NHN AD
プラットフォームサービスラボ · 2026年10月 · 約6分

はじめに

こんにちは、LLMを制御したい不安型開発者の李昇宰です。この記事は、LLMが書くSQLを決定論的に制御するまでの記録です。2回に分けて書く予定で、第1部の本記事ではLLMがSQLを書いてから実行されるまでを、第2部ではLLMにSQLを正しく書かせるまでを扱います。

私は代理店のマーケターが使う広告管理サービスを開発しています。その中で私が担当しているのは、成果をチャットで質問できる機能です。マーケターが成果データをダウンロードしてExcelの数式で集計していた作業を、質問ひとつで終わらせたいと考えました。

これ、なんでダメなの?

最初はSQLをコードで組み立てていました。前回の記事で紹介したSQLビルダーで、AIが文章からディメンション・指標・期間・フィルタを抜き出すと、ビルダーがその値でSQLを句単位で組み立てていました。同じ入力からは常に同じSQLが出るので決定論的で、テストもしやすい仕組みでした。

ただ、手間がかかりすぎました。新しい要件が出るたびにパラメータや組み立てルールを大きく直す必要がありました。何より、ユーザーの質問には終わりがないのに、ビルダーはあらかじめ用意した分岐の範囲でしか答えられません。「先週よりコンバージョンが減ったキーワードのうち、予算を15%増やしたうえで広告費の大きい順に10件見せて」のような質問が来るたびに、新しい分岐を書かなければなりませんでした。

そこで、SQLはLLMに直接書かせることにしました。

以前 · コードがSQLを組み立て自然言語の質問AIがパラメータ抽出ディメンション·指標·期間·フィルタビルダーが句単位で組立用意した分岐のみSQL分岐にない質問は止まる現在 · LLMがSQLを作成自然言語の質問LLMがSQLを作成質問をそのままコードが検査ルール · EXPLAIN · 承認実行自由度はLLM、正確さはコード
コードが決まった分岐でSQLを組み立てる構造から、LLMが書いてコードが検査する構造へ

自由度は高く、でも決定論的で正確さは99%、結果ファイルも出してほしいです

ところが要件は変わりませんでした。広告主が見る数字なのでSQLは決定論的でなければならず、正確さも99%以上が求められました。しかしLLMは同じ質問にも毎回違うSQLを書きます。同じ入力に同じ出力を返す関数ではないので当然のことで、いつも正しいSQLを書く保証もありません。

一番難しいのは、文法は正しいのにSQLが意図と違うとき、そしてSQLは正しいのにLLMの回答が不正確なときです。たとえば成果とコンバージョンをそれぞれ集計せずに行単位でそのまま結合すると、コンバージョン種別の数だけ広告費の行が複製されますが、クエリは問題なく動き、表ももっともらしく埋まります。

成果キャンペーン広告費A10,000コンバージョンキャンペーンCV種別A購入AカートA会員登録行単位JOIN結合結果キャンペーンCV種別広告費A購入10,000Aカート10,000A会員登録10,000SUM(広告費)30,000実際は10,000 · 3倍
文法は正しくクエリも動くが、CV種別の数だけ広告費が膨らむ

複数の広告主が1つのテーブルを共有しているため、アカウント条件をひとつ間違えるだけで他人のデータが混ざり、「直近7日」を6日で取る日付のミスも起きました。

アカウント条件が抜けると広告主広告費A10,000B20,000C5,000A10,000Aの広告費を聞いたのに35,000 · 他人のデータが混入「直近7日」24日25日26日27日28日29日30日31日今日 · データなし正解 · 昨日までの7日LLM · 6日開始は今日から数え、終了は昨日に寄せて1日欠ける
アカウント条件ひとつ、日付1日ずれても、エラーは出ない

回答も間違えました。SQLが正しく動いても、モデルが結果を書き写す途中でミスをして、30行分だけの合計をアカウント全体の合計と言ったり、35、114、81のようにばらつく値を「月100回前後」とひとまとめにしたりしました。

同じモデルにもう一度レビューさせても、同じミスを繰り返すだけでした。そこで、質問の解釈とSQLの作成はできるだけLLMに任せ、そのSQLが実行されるまではコードで厳格に管理することにしました。

LLM自由度コード決定論質問の解釈種別判断例の選択ゴールデンセットBM25SQL作成プレースホルダのみアカウント·期間サーバーが埋める検査sqlglotEXPLAIN承認カードユーザー確認実行要約値回答作成サーバー値を引用LLMには 終わりのない質問を解釈し、SQLを書き、結果を言葉で説明するコードには 例を選び、値を埋め、検査し、確認を取り、実行し、数を数える
解釈と作成はLLMに、選択・補完・検査・集計はコードに

SQLを制御する

この節では、LLMがSQLを書き終えてから実行されるまでの過程だけを扱います。SQLを書く前の話は第2部で書きます。

LLMはSQLにアカウントIDと期間を直接書かず、:id、:start_date、:end_dateで場所だけを残します。この表記はSQLの文法ではなく、サーバーが埋める場所だというシステム内の約束です。IDはユーザーが画面で選んだアカウントをバックエンドがセッションで渡した値で埋め、期間はLLMがperiod_days=7のようなパラメータで渡すと、サーバーが日付を計算して埋めます。

ユーザーの質問「直近7日のキャンペーン別広告費を見せて」 LLMが書くSQLSELECT camp_no, SUM(spend)FROM ad_spendWHERE id = :id -- 数字の代わりにこの文字をそのまま書く AND event_day BETWEEN :start_date AND :end_date:id ← セッションのアカウントユーザーが画面で選んだアカウント · バックエンドが渡す:start_date · :end_date ← サーバーが計算LLMが渡したperiod_days=7 → 20261024~20261030実際に実行されるSQLSELECT camp_no, SUM(spend)FROM ad_spendWHERE id = '1234' AND event_day BETWEEN '20261024' AND '20261030'
LLMは場所を示すだけで、値はサーバーが埋める

完成したSQLはsqlglotでパースし、構文木の上で検査します。書き込みクエリ、パーティション条件のないSQL、セッションと違うIDを読むSQLは拒否します。いちばん気を遣ったのはJOINです。成果とコンバージョンをそれぞれ集計せずに行単位で結合すると、grainが合わないJOINとみなして拒否します。LLMはCTEもよく使うので、この検査はCTEごとに個別に行います。

ユーザーの質問「直近7日のキャンペーン別広告費とCV数を一緒に見せて」 LLMが書いたSQLの例WITH s AS ( SELECT camp_no, spend FROM ad_spend WHERE id = '1234' AND event_day BETWEEN '20261024' AND '20261030'),c AS ( SELECT camp_no, action_kind, action_cnt FROM ad_actions WHERE event_day BETWEEN '20261024' AND '20261030')SELECT s.camp_no, SUM(s.spend) AS spend, SUM(c.action_cnt) AS convFROM s JOIN c ON s.camp_no = c.camp_noGROUP BY s.camp_noパース構文木の検査SELECTCTE sad_spend✓ ID · 期間条件ありCTE cad_actions✗ ID条件なしJOINs ⋈ c✗ 広告費がCV種別のGROUP BYcamp_no✓ 問題なし数だけ複製 → 膨張拒否 · 理由をLLMへ理由を見て修正LLMが直したCTE cc AS (SELECT camp_no, SUM(action_cnt) AS action_cnt FROM ad_actions WHERE id = '1234' … GROUP BY camp_no) -- キャンペーン1つ = 1行✓ 再検査に合格 → 実行へCTEごとにID·期間条件、JOINはgrainの一致を見る。拒否理由はLLMへ渡り、直したSQLを再検査する例のデータ · ID 1234 · 10/24~10/30ad_spendcamp_nospendA10,000B5,000ad_actionscamp_noaction_kindaction_cntA購入3Aカート5A会員登録2B購入1誤ったSQL · cをそのままJOINJOIN結果 · 広告費が種別の数だけ複製camp_nospendaction_kindaction_cntA10,000購入3A10,000カート5A10,000会員登録2B5,000購入1A 広告費 30,000 ✗ CV 10B 広告費 5,000 CV 1Aの10,000がCV種別の数だけ3回足される直したSQL · cを先にキャンペーン単位で集計JOIN結果 · キャンペーン1つ = 1行camp_nospendaction_cntA10,00010B5,0001A 広告費 10,000 ✓ CV 10B 広告費 5,000 ✓ CV 1どちらのSQLもエラーなく実行される
構文木でCTEとJOINを判定し、同じデータで実行すると誤った方は広告費が膨らむ

キャンペーンAのコンバージョンを種別ごとに見るときも同じです。種別ごとの行に広告費を付けると同じ広告費が繰り返され、結果ファイルやExcelで合計した瞬間に膨らみます。そのため検査器はこうしたSQLを拒否し、種別を列に展開するよう理由を返します。直したSQLはキャンペーン1つを1行に保ち、購入・カート・会員登録をそれぞれ列で出します。

ユーザーの質問「直近7日のキャンペーン別広告費とCVを種別ごとに見せて」 LLMが書いたSQLの例 · 種別を行に展開SELECT s.camp_no, s.spend, c.action_kind, c.action_cntFROM s JOIN ( SELECT camp_no, action_kind, SUM(action_cnt) AS action_cnt FROM ad_actions WHERE id = '1234' AND … GROUP BY camp_no, action_kind -- キャンペーン × 種別 = 複数行) c ON s.camp_no = c.camp_no -- s.spendが種別の行ごとに付く直したSQL · 種別を列に展開SELECT s.camp_no, s.spend, c.purchase, c.cart, c.signupFROM s JOIN ( SELECT camp_no, SUM(CASE WHEN action_kind = '購入' THEN action_cnt ELSE 0 END) AS purchase, SUM(CASE WHEN action_kind = 'カート' THEN action_cnt ELSE 0 END) AS cart, SUM(CASE WHEN action_kind = '会員登録' THEN action_cnt ELSE 0 END) AS signup FROM ad_actions WHERE id = '1234' AND … GROUP BY camp_no) c ON s.camp_no = c.camp_no例のデータ · ID 1234 · 10/24~10/30ad_spendcamp_nospendA10,000B5,000ad_actionscamp_noaction_kindaction_cntA購入3Aカート5A会員登録2B購入1誤ったSQLの結果camp_nospendaction_kindaction_cntA10,000購入3A10,000カート5A10,000会員登録2B5,000購入1広告費合計 35,000 ✗ 実際 15,000種別の行ごとに広告費が繰り返され合計が膨張 → 拒否直したSQLの結果 · 種別を列に展開camp_nospend購入カート会員登録A10,000352B5,000100広告費合計 15,000 ✓キャンペーン1つ = 1行、種別は1列ずつ
種別ごとに見るときは、種別を行ではなく列に展開する

ルールを通過したSQLは、AthenaのEXPLAINでもう一度確認します。データは読まずに実行計画だけを作るので、コストや時間といった実行のオーバーヘッドがなく、存在しない列や型の合わない比較といったエンジンのエラーを事前に捕まえます。

SQL実行ツールを呼ぶLLM · プレースホルダのみID · 期間を埋めるIDはセッション · 期間は計算sqlglotルール構文木の検査EXPLAINスキャン0バイト実行へすべて通過したSQLのみ拒否理由 · エンジンのエラーメッセージを返す → LLMが直して再呼び出し · 最大3回
SQLができた後の検問。拒否されると理由と一緒にLLMへ戻る

あ、考えてみたら回答にインサイトも一緒に出してほしいです

回答に軽いインサイトまで入れてほしいという要望もありました。LLMが豊かな回答を出せるように、サーバーがそのSQLを包む要約クエリを作り、行数や合計、分布、上位・下位の行を一緒に計算して渡します。LLMはこの値を根拠に、どこが大きくどこが空いているかを指摘します。

要約クエリは、sqlglotで読み取った出力列の種類に応じてルール通りに作ります。加算できる指標は合計と分布、比率は分子の合計 ÷ 分母の合計、名前とIDはユニーク数を出します。行どうしを比べる判断は同僚が作った計算ツールの値だけを使い、回答の中の数字が結果にある値かどうかは正規表現で照合して記録します。

LLMが書いたSQLの出力列 · sqlglotでパースcamp_nospendclicksimprctr列の種類の判定 · 名前辞書 → 名前の末尾トークン(_ctrなど) → 出力式(SUM · COUNT)の順名前 · IDcamp_no· ユニーク数· 空の値の数· 上位·下位行のラベル加算できる指標spend · clicks · impr· SUM 合計· 分布 p25 · p50 · p75 · p90· 0の行数· 上位 · 下位5行比率ctr· SQLの複製に分子 · 分母の列を追加し· SUM(clicks) ÷ SUM(impr)で再計算· 行ごとの比率の平均は使わないルール通りに組立サーバーが作った要約クエリ · 本クエリと一緒に実行SELECT COUNT(*) AS 行数, SUM(spend) AS 広告費合計, SUM(clicks) * 1.0 / NULLIF(SUM(impr), 0) AS 全体CTR, approx_percentile(spend, 0.5) AS 広告費中央値, max_by(camp_no || '=' || spend, spend, 5) AS 上位5, min_by(camp_no || '=' || spend, spend, 5) AS 下位5FROM ( <LLMが書いたSQLそのまま> ) AS r行数 · 合計 · 統計 · 上位 · 下位の行結果と一緒にLLMへ渡す → 回答でそのまま引用
列の種類をルールで判定して要約クエリを作り、その値をLLMに渡す
実行前 · 承認カード照会の確認期間2026-10-24 ~ 2026-10-30列キャンペーン · 広告費 · CV数範囲選択した広告アカウントプレビュー · キャンペーンA 10,000 · 10 …承認修正取消承認後実行後 · 結果のシグナル0行条件に合うデータなし負の値ありえない値NULLが多い値が空推測せず根拠不足を伝える回答例「この期間は条件に合うデータがなく判断できません。」承認AthenaがSQLを実行結果CSVファイルCSV + LLMの回答
人が承認したSQLだけを実行し、結果はCSVと回答で返す

おわりに

先日、ウアハン兄弟が主催するカンファレンス、WOOWACONに行ってきました。LLMを使ったサービスがたくさんあり、その中で翻訳機能について発表された方と話していて、「LLMはシステムを翻訳する翻訳機だ」という言葉を聞き、大きくうなずきました。

私も同じ考えです。LLMはユーザーの要望を私たちのシステムの言葉に置き換えます。システムの企画意図と目的、誰が使うのか、何ができるのかを伝えれば、LLMはそれをもとに要望を解釈して答えを出します。ある意味では、サービスをコンパイルするコンパイラだとも言えそうです。

第2部では、この翻訳機にSQLを正しく書かせるまでの過程を扱います。

読んでいただきありがとうございました。

参考