Hey LLM, Listen to Me — From Anxious to Steady Developer: SQL Verification

Part 1 · Freedom to the LLM, accuracy to the system

L
Seungjae Lee · NHN AD
Platform Service Lab · October 2026 · about 6 min

Introduction

Hi, I'm Seungjae Lee, an anxious developer who wants to keep LLMs under control. This post is a record of how I came to control LLM-written SQL deterministically. I plan to write it in two parts: this first part covers everything from the moment the LLM writes the SQL until it runs, and the second part covers getting the LLM to write the SQL right.

I build an ad management service used by agency marketers. My part of it is a feature for asking about performance in chat. Marketers used to download performance data and aggregate it with Excel formulas, and I wanted that to take a single question.

Why won't this work?

At first the SQL was built in code. It was the SQL builder from my previous post: an AI pulled dimensions, metrics, periods and filters out of the sentence, and the builder assembled the SQL clause by clause from those values. The same input always produced the same SQL, so it was deterministic and easy to test.

But it took too much work. Every new requirement meant large changes to the parameters and assembly rules. Above all, users' questions never end, while the builder could only answer the branches built in advance. A question like “Among the keywords whose conversions dropped since last week, raise the budget by 15% and then show the top 10 by ad spend” meant writing a new branch every time.

So I switched to having the LLM write the SQL itself.

Before · code assembles SQLNatural-language questionAI extracts parametersdimension·metric·period·filterBuilder assembles clausesprebuilt branches onlySQLQuestions outside the branches are blockedNow · the LLM writes SQLNatural-language questionLLM writes SQLthe question as isCode checksrules · EXPLAIN · approvalRunFreedom to the LLM, accuracy to code
From code assembling SQL along fixed branches to the LLM writing it and code checking it

High freedom, but deterministic, 99% accurate, and please drop a result file too

But the requirements stayed the same. These are numbers advertisers see, so the SQL had to be deterministic and at least 99% accurate. Yet an LLM writes different SQL for the same question every time. It is not a function that maps the same input to the same output, so that is only natural, and there is no guarantee it always writes correct SQL either.

The hardest cases are when the syntax is fine but the SQL is not what we meant, and when the SQL is right but the LLM's answer is wrong. For example, if you join performance and conversions row by row instead of aggregating each first, the ad spend rows are duplicated once per conversion type, yet the query runs fine and the table fills in plausibly.

PerformanceCampaignAd spendA10,000ConversionsCampaignConv. typeAPurchaseACartASign-upRow-levelJOINJoin resultCampaignConv. typeAd spendAPurchase10,000ACart10,000ASign-up10,000SUM(spend)30,000Actually 10,000 · 3×
The syntax is fine and the query runs, but ad spend inflates by the number of conversion types

Many advertisers share one table, so a single wrong account condition mixes in someone else's data, and there were date mistakes too, such as taking “the last 7 days” as 6 days.

Without the account conditionAdvertiserAd spendA10,000B20,000C5,000A10,000Asked for A's ad spend35,000 · others' data mixed in“Last 7 days”24th25th26th27th28th29th30th31stToday · no dataCorrect · 7 days to yesterdayLLM · 6 daysStart counted from today, end pulled to yesterday: a day lost
One account condition or one day off, and still no error

The answers were wrong too. Even when the SQL ran correctly, the model slipped while relaying the results: it called the sum of only 30 rows the account total, or lumped uneven values like 35, 114 and 81 into “around 100 a month”.

Asking the same model to review its work just repeated the same mistakes. So I decided to leave interpreting the question and writing the SQL to the LLM as much as possible, and to control everything until that SQL runs strictly in code.

LLMFreedomCodeDeterministicInterpretpick a typePick examplesgolden set BM25Write SQLplaceholdersID · periodserver fillsChecksqlglotEXPLAINApprovaluser confirmsRunsummary valuesAnswerquotes serverTo the LLM interpret endless questions, write SQL, explain results in wordsTo code pick examples, fill values, check, get approval, run, count numbers
Interpreting and writing go to the LLM; picking, filling, checking and counting go to code

Controlling the SQL

This section covers only what happens after the LLM finishes writing the SQL until it runs. What happens before the SQL is written comes in part 2.

The LLM does not write the account ID or the period into the SQL; it only leaves placeholders such as :id, :start_date and :end_date. These are not SQL syntax but a convention inside our system meaning “the server fills this in”. The ID is filled with the account the user picked on screen, passed by the backend through the session, and for the period the LLM passes a parameter such as period_days=7 and the server computes the dates and fills them in.

User question“Show ad spend by campaign for the last 7 days” SQL the LLM writesSELECT camp_no, SUM(spend)FROM ad_spendWHERE id = :id -- write this token, not a number AND event_day BETWEEN :start_date AND :end_date:id ← session accountaccount picked on screen · passed by the backend:start_date · :end_date ← server computesperiod_days=7 from the LLM → 20261024~20261030SQL that actually runsSELECT camp_no, SUM(spend)FROM ad_spendWHERE id = '1234' AND event_day BETWEEN '20261024' AND '20261030'
The LLM marks the slots; the server fills the values

The completed SQL is parsed with sqlglot and checked on its syntax tree. Write queries, SQL missing a partition condition, and SQL reading an ID other than the session's are rejected. What I cared about most was JOINs. Joining performance and conversions row by row without aggregating each side first is treated as a JOIN whose grains don't match, and is rejected. The LLM often uses CTEs as well, so this check runs for each CTE separately.

User question“Show ad spend and conversions by campaign for the last 7 days” Example SQL from the LLMWITH 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_noParseSyntax tree checkSELECTCTE sad_spend✓ has ID · periodCTE cad_actions✗ no ID conditionJOINs ⋈ c✗ spend duplicated perGROUP BYcamp_no✓ OKconversion type → inflatesRejected · reason to LLMRewrites SQLCTE c fixed by the LLMc AS (SELECT camp_no, SUM(action_cnt) AS action_cnt FROM ad_actions WHERE id = '1234' … GROUP BY camp_no) -- 1 row per campaign✓ Passes recheck → to executionEach CTE is checked for ID and period, each JOIN for grain. Rejections go back to the LLM, and the fix is re-checkedExample data · ID 1234 · 10/24~10/30ad_spendcamp_nospendA10,000B5,000ad_actionscamp_noaction_kindaction_cntAPurchase3ACart5ASign-up2BPurchase1Wrong SQL · c joined as isJoin result · spend duplicated per typecamp_nospendaction_kindaction_cntA10,000Purchase3A10,000Cart5A10,000Sign-up2B5,000Purchase1A spend 30,000 ✗ conv 10B spend 5,000 conv 1A's 10,000 added three times, once per conversion typeFixed SQL · c aggregated per campaign firstJoin result · 1 row per campaigncamp_nospendaction_cntA10,00010B5,0001A spend 10,000 ✓ conv 10B spend 5,000 ✓ conv 1Both SQLs run without errors
CTEs and JOINs are judged on the syntax tree; on the same data, the wrong one inflates ad spend

The same thing happens when you look at campaign A's conversions by type. Attach ad spend to each per-type row and the same spend repeats, inflating the moment you total it in the result file or Excel. So the checker rejects such SQL and returns the reason: spread the types into columns. The fixed SQL keeps one row per campaign and puts purchase, cart and sign-up into separate columns.

User question“Show ad spend and conversions by type, per campaign, for the last 7 days” Example SQL from the LLM · types as rowsSELECT 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 -- campaign × type = many rows) c ON s.camp_no = c.camp_no -- s.spend attached to each type rowFixed SQL · types as columnsSELECT s.camp_no, s.spend, c.purchase, c.cart, c.signupFROM s JOIN ( SELECT camp_no, SUM(CASE WHEN action_kind = 'purchase' THEN action_cnt ELSE 0 END) AS purchase, SUM(CASE WHEN action_kind = 'cart' THEN action_cnt ELSE 0 END) AS cart, SUM(CASE WHEN action_kind = 'signup' 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_noExample data · ID 1234 · 10/24~10/30ad_spendcamp_nospendA10,000B5,000ad_actionscamp_noaction_kindaction_cntAPurchase3ACart5ASign-up2BPurchase1Wrong SQL resultcamp_nospendaction_kindaction_cntA10,000Purchase3A10,000Cart5A10,000Sign-up2B5,000Purchase1Spend total 35,000 ✗ actual 15,000Spend repeats on every type row and the total inflates → rejectedFixed SQL result · types as columnscamp_nospendPurchaseCartSign-upA10,000352B5,000100Spend total 15,000 ✓1 row per campaign, one column per type
For a per-type view, spread the types into columns, not rows

SQL that passes the rules is checked once more with Athena's EXPLAIN. It only builds an execution plan without reading data, so there is no execution overhead in cost or time, and it catches engine errors such as missing columns or comparisons with mismatched types in advance.

LLM calls SQL toolLLM · placeholdersFill ID · periodID session · datessqlglot rulesSyntax tree checkEXPLAINscans 0 bytesTo executionSQL that passed allRejection reason · engine error returned → LLM fixes and calls again · up to 3 times
Checks after the SQL is written. On rejection it goes back to the LLM with the reason

Oh, come to think of it, could the answer include insights too?

There was also a request to include light insights in the answers. So that the LLM can give a rich answer, the server builds a summary query wrapping that SQL and computes the row count, totals, distributions and top and bottom rows along with it. The LLM uses these values to point out what is large and what is empty.

The summary query is built by rule from the kinds of output columns read with sqlglot. Additive metrics get totals and distributions, ratios get sum of numerators ÷ sum of denominators, and names and IDs get distinct counts. Comparisons between rows use only values from the calculation tool my colleague built, and whether the numbers in the answer exist in the result is matched with regular expressions and logged.

Output columns of the LLM's SQL · parsed with sqlglotcamp_nospendclicksimprctrColumn kind · name dictionary → name suffix (_ctr etc.) → output expression (SUM · COUNT)Name · IDcamp_no· distinct count· empty count· labels for top/bottomAdditive metricspend · clicks · impr· SUM total· distribution p25 · p50 · p75 · p90· rows equal to 0· top · bottom 5 rowsRatioctr· add numerator/denominator to a copy· recompute as SUM(clicks) ÷ SUM(impr)· never average row ratiosAssembled by ruleSummary query built by the server · runs with the main querySELECT COUNT(*) AS row_count, SUM(spend) AS spend_total, SUM(clicks) * 1.0 / NULLIF(SUM(impr), 0) AS overall_ctr, approx_percentile(spend, 0.5) AS spend_median, max_by(camp_no || '=' || spend, spend, 5) AS top5, min_by(camp_no || '=' || spend, spend, 5) AS bottom5FROM ( <the LLM's SQL as is> ) AS rRows · totals · stats · top · bottomSent to the LLM with the result → quoted as is
Column kinds are judged by rule to build the summary query, and its values go to the LLM
Before running · approval cardConfirm queryPeriod2026-10-24 ~ 2026-10-30ColumnsCampaign · spend · conversionsScopeSelected ad accountPreview · campaign A 10,000 · 10 …ApproveEditCancelOKAfter running · result signals0 rowsno matching dataNegativeimpossible valueMany NULLsvalues missingNo guessing,reports lackof evidenceSample answer“No data matches this period, so I can't judge.”ApproveAthena runs SQLResult CSV fileCSV + LLM answer
Only human-approved SQL runs, and results go out as a CSV and an answer

Closing

I recently went to WOOWACON, a conference hosted by Woowa Brothers. There were many LLM-based services, and while talking with a speaker who presented a translation feature, I heard the phrase “an LLM is a translator that translates the system” and strongly agreed.

I think the same. An LLM translates the user's request into our system's language. Tell it the system's intent and purpose, who uses it and what it can do, and the LLM interprets requests on that basis and answers. In a way, you could also call it a compiler that compiles the service.

In part 2, I'll cover getting this translator to write the SQL right.

Thanks for reading.

References