46,000 条生产查询教会我们关于 LLM 生成 SQL 的哪些事
674 行 SQL 模式库让 Claude 在 3100 万行 DuckDB 上的 4.6 万次查询平均 2-3 秒返回结果。
中文
复制

我们运营着一台金融数据服务器,底层是一个 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 = 1 | 20.4% |
| 多表筛选 join(3 张以上) | 17.7% |
LAG/LEAD 用于期间对比 | 10.9% |
COALESCE 用于 NULL 处理 | 4.4% |
用 MEDIAN 做行业基准 | 0.7% |
LATERAL join | 0.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,404 | 79.1% |
| 语法错误 | 267 | 15.0% |
| 超出限制 | 78 | 4.4% |
| 内存(OOM) | 7 | 0.4% |
| 类型不匹配 | 4 | 0.2% |
79% 的错误是“列不存在”。 这类错误发生在我们于两次数据发布之间重命名或迁移列、而 LLM 的 schema 上下文还没跟上时。例如,2026 年 8 月我们把 return_on_equity 从 fundamentals_quarterly 移到了 fundamentals_derived_quarterly。有几天,引用旧位置的查询全部失败。这是 schema 同步问题,不是 SQL 生成问题。
只有 15% 是真正的语法错误。LLM 很少生成结构上无效的 SQL。
而对大数据库最要命的错误——超时和 OOM 被杀——几乎不存在。46,131 条查询中只有 7 次 OOM,零超时。模式库值回票价。
对话上下文因素
我们观察到极端的 prompt 到 SQL 膨胀比。中位数是 7.1 倍(50 字符的 prompt 生成 354 字符的 SQL)。但长尾非常夸张:
| Prompt | Prompt 长度 | 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),并给它可组合的积木来搭建复杂查询。这比微调更便宜、更易维护,而且跨模型通用。
最重要的模式是那些防止灾难性失败的模式。 LIMIT 和 INTERVAL 的预过滤不是什么高深 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← 返回首页