NL2DSL技术
本文先讲清楚 NL2SQL 与 NL2DSL 的本质差异,再用一个从中文到 DSL、再到 SQL 的完整闭环示例,展示 LLM 是如何生成 DSL 的。
一、NL2SQL vs NL2DSL:核心差异
维度 | NL2SQL(自然语言→SQL) | NL2DSL(自然语言→领域专用语言,再编译到SQL/其他) |
---|---|---|
中间抽象 | 无,直接生成 SQL | 有,先生成受限的 DSL(JSON/EBNF 等) |
学习/泛化 | 强依赖具体库表命名与结构;跨库复用差 | 先抽象“业务语义”(指标、维度、实体),可跨库/跨引擎(SQL、API、图数据库)复用 |
正确性与安全 | SQL 语法/语义容易出错;难以限制危险语句(删库、巨表扫描) | DSL 可被“语法+语义”双重约束;可白名单函数、指标、列、行级权限 |
复杂查询 | 需要 LLM 自行推断 join 路径、聚合、窗口等 | 将 join/指标封装为 DSL 原语或“度量(measure)”,LLM 只需组合 |
可维护性 | 需求变更要改 Prompt/微调或强绑 schema | 主要改 DSL-后端编译器或指标库;Prompt 更稳定 |
调试与可观测 | 直接看 SQL(友好但易过长) | 看结构化 DSL(短小、可验证),再查看编译产物 |
延迟与成本 | Token 多(长 SQL)、重试成本高 | DSL Token 少、结构化更易让模型“一次到位” |
上线门槛 | 快速试用 | 需先设计 DSL 与编译器,但长期收益更大 |
一句话总结
- 如果你要在单一数据库、短期验证:NL2SQL上手最快。
- 如果你要稳定上线、可控安全、跨多数据源/多形态:NL2DSL更具工程化优势。
二、典型架构对比
NL2SQL:
NL →(LLM)→ SQL →(数据库)→ 结果
- 关键难点:列/表对齐(schema linking)、join 推断、聚合/窗口函数、权限与代价控制。
NL2DSL:
NL →(LLM)→ DSL(结构化 JSON/EBNF)→(校验+补全)→ 编译器(到 SQL/引擎 API)→ 结果
-
关键机制:
- 受限语法(JSON Schema/EBNF)确保结构与类型正确;
- 指标/维度/实体库预封装业务语义;
- 编译器做 join/函数/权限/代价优化;
- 回退/澄清:DSL 校验不通过 → 返问或自修正。
三、实现要点(做 NL2DSL 的关键“抓手”)
- DSL 设计最小可用集:Filter / Project / Derive / Aggregate / GroupBy / Sort / Limit / Join / TimeBucket 等。
- 业务语义内置化:把“月活 MAU、GMV、留存”等沉淀为 measure(如何计算、依赖哪些表)。
- 强约束输出:使用 JSON Schema / function calling / grammar-based decoding,强制 LLM 只产出合法 DSL。
- Schema Linking:同义词词典+列名映射(如“国家=country”,“是否测试=is_test”)。
- 安全与代价控制:黑/白名单函数、最大扫描行数、强制走物化视图等。
- 可观测与回放:保留 NL→DSL→SQL 全链路,以便回归测试与审计。
四、实例:LLM 如何从中文生成 DSL(再编译到 SQL)
下面给出一个能直接照抄/改造的示例。我们假设有两张表:
-- 表结构(示意)
users(id, name, is_test BOOLEAN, country TEXT)
events(user_id, ts TIMESTAMP, event_type TEXT)
需求(自然语言):
“统计 2024 年每个月的活跃用户数(MAU),只包含中国地区,剔除测试账号,按月份升序,显示月份和人数,最多 10 行。”
1) 设计一个极简 Analytics DSL(JSON 版)
约束目标: 只允许预定义的算子与字段,便于 LLM 正确生成、便于我们做 JSON Schema 验证。
EBNF(简化):
Query := { Step }
Step := Load | Join | Filter | Derive | Aggregate | Sort | Limit | Project
Load := { "op":"load", "table": <identifier> }
Join := { "op":"join", "left_key":<id>, "right_key":<id>, "type":"inner"|"left" }
Filter := { "op":"filter", "expr": <boolean_expression> }
Derive := { "op":"derive", "as":<id>, "expr":<expression> }
Aggregate := { "op":"aggregate", "group_by":[<id>...], "metrics":[{ "as":<id>, "expr":<agg_expr> }...] }
Sort := { "op":"sort", "by":[{ "key":<id>, "dir":"asc"|"desc" }] }
Limit := { "op":"limit", "n":<int> }
Project := { "op":"project", "fields":[<id>...] }
表达式白名单(示例):
- 布尔:
and/or/not/=/!=/>=/>/<
- 时间:
month(ts)
(提取年月YYYY-MM
)、year(ts)
- 聚合:
count_distinct(expr)
、count(*)
2) 给 LLM 的 Prompt(片段)
你是 AnalyticsDSL v1 的编译器前端,只输出 JSON,不要自然语言。
允许的算子与字段见下方。若语义含糊请合理默认并在assumptions
字段写明。
字段映射:国家=country;测试账号=is_test;活跃=当月产生任意 events 的用户。
只统计 2024 年数据。
…(此处附上 JSON Schema 或示例)…
3) LLM 生成的 DSL(期望产物)
[
{"op":"load","table":"events"},
{"op":"filter","expr":"year(ts)=2024"},
{"op":"derive","as":"month","expr":"month(ts)"},
{"op":"aggregate",
"group_by":["month"],
"metrics":[{"as":"mau","expr":"count_distinct(user_id)"}]
},
{"op":"load","table":"users"},
{"op":"filter","expr":"country='China' and is_test=false"},
{"op":"join","left_key":"user_id","right_key":"id","type":"inner"},
{"op":"aggregate",
"group_by":["month"],
"metrics":[{"as":"mau","expr":"sum(mau)"}],
"note":"按连接后每月活跃用户去重。若编译器支持,应在 join 前就去重用户集合以免双计。"
},
{"op":"sort","by":[{"key":"month","dir":"asc"}]},
{"op":"limit","n":10},
{"op":"project","fields":["month","mau"]},
{"assumptions":"活跃用户=当月产生至少一次事件的用户;month 格式为 YYYY-MM。"}
]
说明:很多实现里会把“活跃用户集合”在 events 内先按月去重出 (month, user_id) 集,再与 users 过滤后 inner join,可避免重复计数。上面 DSL 用两段 aggregate 表意,编译器要做 去重语义下推 的优化(见下一小节)。
4) 编译器将 DSL 转为 SQL(示例 SQL)
为了避免重复计数,编译器可以把语义“先求 (month, user_id) 去重,再 join 过滤中国非测试用户,再按月 count distinct”具体化为:
WITH active AS (
SELECT DISTINCT
TO_CHAR(ts, 'YYYY-MM') AS month,
user_id
FROM events
WHERE EXTRACT(YEAR FROM ts) = 2024
),
cn_users AS (
SELECT id AS user_id
FROM users
WHERE country = 'China' AND is_test = FALSE
)
SELECT a.month,
COUNT(DISTINCT a.user_id) AS mau
FROM active a
JOIN cn_users u ON a.user_id = u.user_id
GROUP BY a.month
ORDER BY a.month ASC
LIMIT 10;
注意:具体日期函数因数据库而异(PostgreSQL/ClickHouse/BigQuery 语法略有差别)。编译器应做方言适配:
TO_CHAR
/FORMAT_DATETIME
/toYYYYMM
等。
5) 一个极简“编译器”思路(Python 伪代码)
def compile_to_sql(dsl: list, dialect: str = "postgres") -> str:
# 1. 解析步骤,构建一个逻辑计划(逻辑算子树)
# 2. 做规则优化:如将 count_distinct(user_id) 与 join 次序重写为:先月粒度去重 -> 再 join -> 再聚合
# 3. 生成方言 SQL(字段/函数映射)
# 4. 注入权限/代价控制(如限制扫描表、限制时间窗口)
return sql
from typing import List, Dict, Any
import textwrap
class LogicalPlan:
"""极简逻辑计划节点"""
def __init__(self, op: str, args: Dict[str, Any], children: List["LogicalPlan"] = None):
self.op = op
self.args = args
self.children = children or []
def __repr__(self):
return f"Plan({self.op}, {self.args}, children={len(self.children)})"
def parse_dsl_to_plan(dsl: List[Dict[str, Any]]) -> LogicalPlan:
"""
Step 1: DSL -> Logical Plan
这里简化处理:顺序遍历 DSL,构建一个树形结构。
"""
root = None
current = None
for step in dsl:
node = LogicalPlan(step["op"], step)
if root is None:
root = node
else:
current.children.append(node)
current = node
return root
def optimize_plan(plan: LogicalPlan) -> LogicalPlan:
"""
Step 2: 规则优化
演示:将 count_distinct(user_id) 下推到 join 之前,避免重复计数。
在真实场景,这里会有一系列规则:谓词下推、投影裁剪、聚合重写……
"""
# 简化:遍历 aggregate 节点,把 count_distinct 重写为先 group by user_id,再 count
if plan.op == "aggregate":
new_metrics = []
for m in plan.args.get("metrics", []):
expr = m["expr"]
if expr.startswith("count_distinct("):
col = expr[len("count_distinct("):-1]
# 改写为两个阶段:
# 1. 子查询先 select distinct month, user_id
# 2. 外层再 count(*)
plan.args["__rewrite"] = f"distinct {col} before join"
new_metrics.append(m)
plan.args["metrics"] = new_metrics
for c in plan.children:
optimize_plan(c)
return plan
def generate_sql(plan: LogicalPlan, dialect: str = "postgres") -> str:
"""
Step 3: 根据逻辑计划生成 SQL
这里只做演示:递归生成 WITH CTE。
"""
ctes = []
def walk(node: LogicalPlan, name: str) -> str:
if node.op == "load":
sql = f"SELECT * FROM {node.args['table']}"
elif node.op == "filter":
expr = node.args["expr"]
child = walk(node.children[0], f"{name}_c")
sql = f"SELECT * FROM ({child}) sub WHERE {expr}"
elif node.op == "derive":
expr = node.args["expr"]
as_name = node.args["as"]
child = walk(node.children[0], f"{name}_c")
sql = f"SELECT *, TO_CHAR(ts, 'YYYY-MM') AS {as_name} FROM ({child}) sub"
elif node.op == "aggregate":
group_by = ", ".join(node.args.get("group_by", []))
metrics = []
for m in node.args["metrics"]:
if m["expr"].startswith("count_distinct("):
col = m["expr"][len("count_distinct("):-1]
metrics.append(f"COUNT(DISTINCT {col}) AS {m['as']}")
else:
metrics.append(f"{m['expr']} AS {m['as']}")
child = walk(node.children[0], f"{name}_c")
sql = f"SELECT {group_by}, {', '.join(metrics)} FROM ({child}) sub GROUP BY {group_by}"
else:
# fallback:直接递归子节点
if node.children:
return walk(node.children[0], f"{name}_c")
else:
return "SELECT 1"
ctes.append((name, sql))
return f"{name}"
root_name = walk(plan, "cte0")
cte_sqls = []
for nm, body in ctes:
cte_sqls.append(f"{nm} AS (\n{textwrap.indent(body, ' ')}\n)")
return "WITH\n" + ",\n".join(cte_sqls) + f"\nSELECT * FROM {root_name};"
def apply_policies(sql: str) -> str:
"""
Step 4: 权限与代价控制
示例:限制时间范围(2020~2025)、禁止 SELECT *
"""
if "SELECT *" in sql:
sql = sql.replace("SELECT *", "SELECT /* columns pruned */ *")
sql += "\n-- POLICY: 时间范围已限制 (2020-2025)"
return sql
def compile_to_sql(dsl: List[Dict[str, Any]], dialect: str = "postgres") -> str:
# 1. 解析 DSL
plan = parse_dsl_to_plan(dsl)
# 2. 规则优化
plan = optimize_plan(plan)
# 3. 生成方言 SQL
sql = generate_sql(plan, dialect=dialect)
# 4. 注入权限/代价控制
sql = apply_policies(sql)
return sql
parse_dsl_to_plan → 把 JSON DSL 变成逻辑计划树
optimize_plan → 可以放各种优化规则
generate_sql → 按方言拼接 SQL(我这里只写了 Postgres 的一部分)
apply_policies → 注入安全策略
落地提示:
- 用 JSON Schema 校验 DSL(字段名、算子、表达式类型);
- 用 方言适配表 做函数/时间处理;
- 在编译时挂接 行/列级权限(RLS/CGR),对 DSL 的 Filter/Project 做裁剪;
- 给每个 measure(如 MAU)建立快表或物化视图,编译器优先命中以降延迟。
五、何时选 NL2SQL,何时选 NL2DSL?
- 短平快、单库原型验证:NL2SQL 足够;可加“模板化 SQL + 参数槽位”降低出错。
- 中长期、多人共用、强安全合规:优先 NL2DSL;把业务语义沉淀为指标库,编译器做统一治理。
- 异构后端(SQL+向量库+图数据库+REST API):NL2DSL 天然适配;同一 DSL 产出多种执行计划。
- 需要 A/B 可观测(回归测试、结果对齐):DSL 作为“金标准语义层”,支持离线回放与版本化。
六、进一步增强:把“问答”做成可靠系统
-
自修正回路:执行器报错→把错误(缺字段/类型不匹配/超时)反馈给 LLM→生成修订 DSL。
-
歧义澄清:当 NL 含糊(如“活跃定义”不清)时,DSL 增加
assumptions
字段;或触发澄清问题。 -
Few-shot 提示:给 LLM 多组“中文→DSL”的范例(覆盖 Filter/Join/Agg/时间窗口),显著提高命中率。
-
评测指标:
- NL2SQL:执行级准确率、逻辑形式匹配(如 Spider EM);
- NL2DSL:DSL 结构匹配率 + 结果级一致性 + 编译器覆盖率(方言/函数)。
如果这篇文章帮助到了你,你可以请作者喝一杯咖啡
