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を組み立てる構造から、LLMが書いてコードが検査する構造へ
自由度は高く、でも決定論的で正確さは99%、結果ファイルも出してほしいです
ところが要件は変わりませんでした。広告主が見る数字なのでSQLは決定論的でなければならず、正確さも99%以上が求められました。しかしLLMは同じ質問にも毎回違うSQLを書きます。同じ入力に同じ出力を返す関数ではないので当然のことで、いつも正しいSQLを書く保証もありません。
一番難しいのは、文法は正しいのにSQLが意図と違うとき、そしてSQLは正しいのにLLMの回答が不正確なときです。たとえば成果とコンバージョンをそれぞれ集計せずに行単位でそのまま結合すると、コンバージョン種別の数だけ広告費の行が複製されますが、クエリは問題なく動き、表ももっともらしく埋まります。
文法は正しくクエリも動くが、CV種別の数だけ広告費が膨らむ
複数の広告主が1つのテーブルを共有しているため、アカウント条件をひとつ間違えるだけで他人のデータが混ざり、「直近7日」を6日で取る日付のミスも起きました。
アカウント条件ひとつ、日付1日ずれても、エラーは出ない
回答も間違えました。SQLが正しく動いても、モデルが結果を書き写す途中でミスをして、30行分だけの合計をアカウント全体の合計と言ったり、35、114、81のようにばらつく値を「月100回前後」とひとまとめにしたりしました。
同じモデルにもう一度レビューさせても、同じミスを繰り返すだけでした。そこで、質問の解釈とSQLの作成はできるだけLLMに任せ、そのSQLが実行されるまではコードで厳格に管理することにしました。
解釈と作成はLLMに、選択・補完・検査・集計はコードに
SQLを制御する
この節では、LLMがSQLを書き終えてから実行されるまでの過程だけを扱います。SQLを書く前の話は第2部で書きます。
LLMはSQLにアカウントIDと期間を直接書かず、:id、:start_date、:end_dateで場所だけを残します。この表記はSQLの文法ではなく、サーバーが埋める場所だというシステム内の約束です。IDはユーザーが画面で選んだアカウントをバックエンドがセッションで渡した値で埋め、期間はLLMがperiod_days=7のようなパラメータで渡すと、サーバーが日付を計算して埋めます。
LLMは場所を示すだけで、値はサーバーが埋める
完成したSQLはsqlglotでパースし、構文木の上で検査します。書き込みクエリ、パーティション条件のないSQL、セッションと違うIDを読むSQLは拒否します。いちばん気を遣ったのはJOINです。成果とコンバージョンをそれぞれ集計せずに行単位で結合すると、grainが合わないJOINとみなして拒否します。LLMはCTEもよく使うので、この検査はCTEごとに個別に行います。
構文木でCTEとJOINを判定し、同じデータで実行すると誤った方は広告費が膨らむ
キャンペーンAのコンバージョンを種別ごとに見るときも同じです。種別ごとの行に広告費を付けると同じ広告費が繰り返され、結果ファイルやExcelで合計した瞬間に膨らみます。そのため検査器はこうしたSQLを拒否し、種別を列に展開するよう理由を返します。直したSQLはキャンペーン1つを1行に保ち、購入・カート・会員登録をそれぞれ列で出します。
種別ごとに見るときは、種別を行ではなく列に展開する
ルールを通過したSQLは、AthenaのEXPLAINでもう一度確認します。データは読まずに実行計画だけを作るので、コストや時間といった実行のオーバーヘッドがなく、存在しない列や型の合わない比較といったエンジンのエラーを事前に捕まえます。
SQLができた後の検問。拒否されると理由と一緒にLLMへ戻る
あ、考えてみたら回答にインサイトも一緒に出してほしいです
回答に軽いインサイトまで入れてほしいという要望もありました。LLMが豊かな回答を出せるように、サーバーがそのSQLを包む要約クエリを作り、行数や合計、分布、上位・下位の行を一緒に計算して渡します。LLMはこの値を根拠に、どこが大きくどこが空いているかを指摘します。
要約クエリは、sqlglotで読み取った出力列の種類に応じてルール通りに作ります。加算できる指標は合計と分布、比率は分子の合計 ÷ 分母の合計、名前とIDはユニーク数を出します。行どうしを比べる判断は同僚が作った計算ツールの値だけを使い、回答の中の数字が結果にある値かどうかは正規表現で照合して記録します。
列の種類をルールで判定して要約クエリを作り、その値をLLMに渡す
人が承認したSQLだけを実行し、結果はCSVと回答で返す
おわりに
先日、ウアハン兄弟が主催するカンファレンス、WOOWACONに行ってきました。LLMを使ったサービスがたくさんあり、その中で翻訳機能について発表された方と話していて、「LLMはシステムを翻訳する翻訳機だ」という言葉を聞き、大きくうなずきました。
私も同じ考えです。LLMはユーザーの要望を私たちのシステムの言葉に置き換えます。システムの企画意図と目的、誰が使うのか、何ができるのかを伝えれば、LLMはそれをもとに要望を解釈して答えを出します。ある意味では、サービスをコンパイルするコンパイラだとも言えそうです。
第2部では、この翻訳機にSQLを正しく書かせるまでの過程を扱います。
読んでいただきありがとうございました。