46,000 条生产查询教会我们关于 LLM 生成 SQL 的哪些事

674 行 SQL 模式库让 Claude 在 3100 万行 DuckDB 上的 4.6 万次查询平均 2-3 秒返回结果。

中文
复制
题图:代码编辑器里的一段 Python 数据库配置,DB_NAME、DB_USER、DB_PASSWORD 等变量一览,画面带虚化

我们运营着一台金融数据服务器,底层是一个 3100 万行的 DuckDB 数据库。用户接入 LLM(通常是 Claude),用自然语言提问,模型针对我们的 schema 生成 SQL。

六个月里,我们记录了每一次查询:用户的自然语言提示词、LLM 生成的 SQL、是否执行成功、耗时多久、返回多少行。总共 4.6 万次查询。

以下是我们发现的结果。

数据库

数据库覆盖美国股市 64 年的数据,共 12 张表:日线价格与成交量(3100 万行)、季度和年度财务数据、每日估值、56 个技术指标、SEC 申报元数据、内部人交易、分析师预期,以及公司基础信息。NYSE 和 NASDAQ 上约 9,971 只证券(股票和 ETF)。

用户通过 MCP(Model Context Protocol)接入——这是让 LLM 调用外部工具的开放标准。当用户问“给我看连续 3 个季度营收增长、利润率持续扩大的美股”时,LLM 会读取我们的 schema,读取一个查询模式库,生成 SQL 并执行。从提示词到结果表的完整往返平均耗时 2-3 秒。

秘密武器:674 行的模式库

我们没有微调模型,也没有围绕示例查询搭一套 RAG 流水线。我们写了一份 674 行的 markdown 文档,里面是 23 个 SQL 模式,每次查询前作为上下文交给 LLM。

每个模式解决一个具体问题——这些问题会让 LLM 在大型时间序列数据库上栽跟头。最重要的一个:

-- Pattern P1: Latest row per symbol (multi-symbol safe)
WITH recent AS (
 SELECT symbol, date, close,
 ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
 FROM shibui.stock_quotes
 WHERE date >= CURRENT_DATE - INTERVAL '7 days' -- pre-filter first
)
SELECT symbol, date, close FROM recent WHERE rn = 1
LIMIT 50

这个模式里做了三件事。WHERE date >= CURRENT_DATE - INTERVAL '7 days' 在窗口函数执行前先做预过滤,把工作集控制得很小。ROW_NUMBER() ... rn = 1 这个写法能拿到每个标的最新的一行,不会出现 GROUP BY 的歧义。LIMIT 则防止结果集失控。

没有这个模式,LLM 会毫不犹豫地写出 SELECT * FROM stock_quotes ORDER BY date DESC,然后试图扫描 3100 万行。有了它,同样的意图生成的查询几毫秒就能跑完。

模式库还覆盖了另外 22 个模式:收益率计算、带 LAG 的环比增长、多表筛选 join、技术指标查询、带 MEDIAN 的行业基准对比、按日历周期 OHLC 柱线做回测,以及内部人交易聚合。

复杂度上限

46% 的查询用到 JOIN,44% 用到 CTE,40% 用到窗口函数,30% 涉及两张或更多表。最长的查询有 22,828 个字符。有一个查询用了 59 个窗口函数,另一个用了 19 个 JOIN。还有几个用递归 CTE 配合 LATERAL join 实现 zigzag 波段检测算法。

这些不是玩具查询。下面是两个真实例子。

单条查询里的多因子排名系统

一位用户让 LLM 构建一个横截面股票排名模型。LLM 生成了这个(有删节,完整查询约 9,800 个字符):

WITH
-- Monthly closing prices with momentum signals
mo AS (
 SELECT symbol, mes, close,
 close / NULLIF(LAG(close, 6) OVER (PARTITION BY symbol ORDER BY mes), 0) - 1 AS r6,
 close / NULLIF(LAG(close, 12) OVER (PARTITION BY symbol ORDER BY mes), 0) - 1 AS r12,
 close / NULLIF(MAX(close) OVER (PARTITION BY symbol ORDER BY mes
 ROWS BETWEEN 11 PRECEDING AND CURRENT ROW), 0) AS dist52,
 LEAD(close, 1) OVER (PARTITION BY symbol ORDER BY mes)
 / NULLIF(close, 0) - 1 AS fwd
 FROM px),

-- Quality and fundamental metrics joined in
b AS (
 SELECT mo.*, d.trailing_pe, d.free_cash_flow_yield,
 f.roe, f.pm, f.piot,
 f.rg - f.rg_ant AS acc_rev, -- revenue acceleration
 f.eg - f.eg_ant AS acc_eps -- earnings acceleration
 FROM mo
 JOIN v ON v.symbol = mo.symbol AND v.mes = mo.mes AND v.rn = 1
 JOIN d ON d.symbol = mo.symbol AND d.mes = mo.mes AND d.rn = 1
 JOIN shibui.general_info g ON g.symbol = mo.symbol
 ASOF JOIN fq f ON f.symbol = mo.symbol AND f.fdate 2e9 AND mo.close > 5),

-- Cross-sectional percentile ranks (both absolute and sector-relative)
p AS (
 SELECT *,
 PERCENT_RANK() OVER (PARTITION BY mes ORDER BY roe) AS g_roe,
 PERCENT_RANK() OVER (PARTITION BY mes ORDER BY r12) AS g_m12,
 -- Blended: 50% absolute rank + 50% within-sector rank
 50 * PERCENT_RANK() OVER (PARTITION BY mes ORDER BY roe)
 + 50 * PERCENT_RANK() OVER (PARTITION BY mes, sec ORDER BY roe) AS c_roe,
 -- ... 20+ more ranking columns
 FROM b),

-- Composite score: 30% quality + 25% growth + 20% value + 25% momentum
sc AS (
 SELECT *,
 0.30 * (g_roe + g_pm + g_pi) / 3
 + 0.25 * (g_rg + g_eg) / 2
 + 0.20 * (g_fcf + g_pe) / 2
 + 0.25 * (g_m6 + g_m12) / 2 AS S1
 FROM p)

-- Final output with bonus/penalty adjustments for momentum and cyclicality
SELECT mes, symbol, fwd, S1, S2
FROM fin
ORDER BY mes, S1 DESC

这是一个完整的量化股票排名模型。它计算 6 个月和 12 个月动量、距 52 周高点的距离、基本面质量指标,然后用 PERCENT_RANK 对全市场每只股票做绝对排名和行业相对排名。它把这些按明确的权重合成一个综合得分,对盈利加速加分、对周期性恶化扣分,并输出用于回测的前瞻收益。

完整查询用了 59 个窗口函数、5 个 JOIN(其中包括一个 ASOF JOIN)、8 个 CTE,跑在约 2,000 只股票 6 年的月度数据上。执行耗时约 5 秒。

这个没有基准测试。

渐进式筛选漏斗

另一位用户想筛选保守型股息股。LLM 构建的查询会在每一步筛选后报告有多少只股票存活、通过率相对上一步是多少:

WITH latest_val AS (
 SELECT symbol, market_cap,
 ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
 FROM shibui.valuation
 WHERE date >= CURRENT_DATE - INTERVAL '7 days'
),
latest_q AS (
 SELECT symbol, return_on_equity, current_ratio, net_income,
 ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
 FROM shibui.fundamentals_quarterly
 WHERE date >= CURRENT_DATE - INTERVAL '6 months'
),
latest_dd AS (
 SELECT symbol, dividend_yield, payout_ratio,
 ROW_NUMBER() OVER (PARTITION BY symbol ORDER BY date DESC) AS rn
 FROM shibui.fundamentals_derived_daily
 WHERE date >= CURRENT_DATE - INTERVAL '7 days'
)
SELECT '1. Base universe (US Common Stock)' AS filter_step, COUNT(*) AS passing, NULL AS pct_of_prev
 FROM shibui.general_info g WHERE g.type = 'Common Stock' AND g.country_iso = 'US'
UNION ALL
SELECT '2. + Market cap > $5B', COUNT(*), ROUND(...)
 FROM ... WHERE ... AND v.market_cap > 5e9
UNION ALL
SELECT '3. + Dividend yield > 3%', COUNT(*), ROUND(...)
 FROM ... WHERE ... AND dd.dividend_yield > 0.03
UNION ALL
SELECT '4. + Payout ratio 1.5', COUNT(*), ROUND(...)
 FROM ... WHERE ... AND f.current_ratio > 1.5
UNION ALL
SELECT '6. + Net income positive', COUNT(*), ROUND(...)
 FROM ... WHERE ... AND f.net_income > 0

这个查询用了 19 个 JOIN(每一步筛选都重新 join 那些 CTE)和 3 张表。它让用户能诊断自己的筛选条件:看清股票究竟在哪一步被筛掉,判断某个条件是不是太激进。

LLM 从这些模式中学到了什么

我们统计了 LLM 采用模式库中各个模式的频率:

模式采用率
LIMIT 子句98.1%
INTERVAL 日期预过滤39.6%
ROUND() 用于展示值36.4%
NULLIF() 除法安全21.9%
ROW_NUMBER() ... rn = 120.4%
多表筛选 join(3 张以上)17.7%
LAG/LEAD 用于期间对比10.9%
COALESCE 用于 NULL 处理4.4%
MEDIAN 做行业基准0.7%
LATERAL join0.4%

LIMIT 的效果最为突出。我们在模式库中要求 LLM 始终带上 LIMIT 子句,98.1% 的情况下它都照做了。仅凭这一条指令,就避免了在 3100 万行的表上产生失控的结果集。

更重要的是:46,131 条查询零超时。40% 的查询采用了 INTERVAL 预过滤模式,确保数据库永远不会全表扫描 stock_quotes。再加上 LIMIT,这两种模式就足以让每条查询都保持在超时阈值之下。

LLM 不只是照搬模式,它还会组合模式。一条典型的筛选查询会用到 P1 的 ROW_NUMBER ... rn = 1、P5 的 INTERVAL 预过滤、P7 的 NULLIF,以及 P4 的多表连接结构,全部编织进一条 CTE 链。模式是积木,LLM 是建筑师。

错误分析:3.8% 的失败率

46,131 条查询中有 1,776 条失败,成功率 96.2%。

错误明细才说明真正的问题:

错误类型数量占错误比例
列不存在1,40479.1%
语法错误26715.0%
超出限制784.4%
内存(OOM)70.4%
类型不匹配40.2%

79% 的错误是“列不存在”。 这类错误发生在我们于两次数据发布之间重命名或迁移列、而 LLM 的 schema 上下文还没跟上时。例如,2026 年 8 月我们把 return_on_equityfundamentals_quarterly 移到了 fundamentals_derived_quarterly。有几天,引用旧位置的查询全部失败。这是 schema 同步问题,不是 SQL 生成问题。

只有 15% 是真正的语法错误。LLM 很少生成结构上无效的 SQL。

而对大数据库最要命的错误——超时和 OOM 被杀——几乎不存在。46,131 条查询中只有 7 次 OOM,零超时。模式库值回票价。

对话上下文因素

我们观察到极端的 prompt 到 SQL 膨胀比。中位数是 7.1 倍(50 字符的 prompt 生成 354 字符的 SQL)。但长尾非常夸张:

PromptPrompt 长度SQL 长度倍数
“Quarterly momentum net CAGR, fixed units.”41 字符20,725 字符505x
“Semi-annual momentum net CAGR net of fees across 6 offsets.”59 字符20,695 字符351x
完整股票分析请求(非英文,27 字符)27 字符8,397 字符311x

这些数字单独看会误导人。41 个字符的 prompt 不可能凭空生成 20,000 个字符的 SQL。LLM 携带了完整的对话历史。那条“固定单位”的 prompt 出现在同一回测查询的 10 多轮迭代之后,用户在此过程中调整参数、修正边界情况,逐步搭起一套策略。

生产环境中的 NL-to-SQL 就是这样运作的:迭代式,而非一次成型。用户跨轮次不断细化,LLM 累积上下文。标准 benchmark 把每条查询当作独立的,完全忽略了这一点。

扩展比在首轮查询上仍有意义,那时一句英文确实能生成数百行 SQL。但极端的长尾来自对话的累积。

多语言 SQL 生成

我们的用户用英语、普通话、韩语、日语、西班牙语、荷兰语和德语写 prompt。模式库是英文的。schema 是英文的。无论输入什么语言,LLM 都能生成正确的 SQL。

同样类型的复杂查询——多表筛选 join、带 walk-forward 验证的回测、自动监控、完整的投资组合分析——在所有语言中都会出现。一条 27 个字符的普通话 prompt 生成了 8,397 个字符的正确 SQL,join 了 6 张表。LLM 不需要 prompt 语言与 schema 语言或模式库语言一致。

查询语料库中有 1,896 个不同的 ticker,覆盖从 mega-cap 到 micro-cap。被查询最多的是 AAPL(650 次),其次是 MSFT(570)、GOOGL(410)和 NVDA(390)。

实践要点

如果你正在构建一个由 LLM 生成 SQL 的系统,以下是我们从 46,000 条生产查询中学到的东西:

写模式库,而不是微调模型。 一份 markdown 文档里的 23 个模式,让我们在 3,100 万行的数据库上达到 96.2% 的成功率,零超时。这些模式教会 LLM 防御性习惯(窗口函数前先预过滤、除法用 NULLIF、永远加 LIMIT),并给它可组合的积木来搭建复杂查询。这比微调更便宜、更易维护,而且跨模型通用。

最重要的模式是那些防止灾难性失败的模式。 LIMITINTERVAL 的预过滤不是什么高深 SQL。但它们决定了一条查询是 200ms 跑完,还是扫描 3,100 万行然后超时。98.1% 的 LIMIT 合规率和零超时,来自两条简单的指令。

schema 漂移造成的错误比糟糕的 SQL 逻辑更多。 我们 79% 的错误是 column-not-found,由版本之间的 schema 变更触发。如果你要演进 schema,就得预料到 LLM 的上下文会滞后。给 schema 文档做版本管理。LLM 的 SQL 生成很扎实;让它与现实保持同步才是更难的问题。

为对话而设计,而不是为单条查询。 真实用户会迭代。他们在 10-20 轮中细化筛选条件、调整时间窗口、修正边界情况、逐步搭建复杂分析。你的系统应该支持这一点。会话上下文不是开销;它正是生产环境 NL-to-SQL 能跑起来的原因。

披露:我开发并运营 Shibui Finance,也就是本文所介绍的金融数据 MCP 服务器。所有查询统计均为汇总后的匿名数据,不会泄露任何个人用户数据或专有查询。

来源: HackerNoon← 返回首页