Fork me on GitHub

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)→ 结果

  • 关键机制:

    1. 受限语法(JSON Schema/EBNF)确保结构与类型正确;
    2. 指标/维度/实体库预封装业务语义;
    3. 编译器做 join/函数/权限/代价优化;
    4. 回退/澄清:DSL 校验不通过 → 返问或自修正。

三、实现要点(做 NL2DSL 的关键“抓手”)

  1. DSL 设计最小可用集:Filter / Project / Derive / Aggregate / GroupBy / Sort / Limit / Join / TimeBucket 等。
  2. 业务语义内置化:把“月活 MAU、GMV、留存”等沉淀为 measure(如何计算、依赖哪些表)。
  3. 强约束输出:使用 JSON Schema / function calling / grammar-based decoding,强制 LLM 只产出合法 DSL。
  4. Schema Linking:同义词词典+列名映射(如“国家=country”,“是否测试=is_test”)。
  5. 安全与代价控制:黑/白名单函数、最大扫描行数、强制走物化视图等。
  6. 可观测与回放:保留 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 作为“金标准语义层”,支持离线回放与版本化。

六、进一步增强:把“问答”做成可靠系统

  1. 自修正回路:执行器报错→把错误(缺字段/类型不匹配/超时)反馈给 LLM→生成修订 DSL。

  2. 歧义澄清:当 NL 含糊(如“活跃定义”不清)时,DSL 增加 assumptions 字段;或触发澄清问题。

  3. Few-shot 提示:给 LLM 多组“中文→DSL”的范例(覆盖 Filter/Join/Agg/时间窗口),显著提高命中率。

  4. 评测指标

    • NL2SQL:执行级准确率、逻辑形式匹配(如 Spider EM);
    • NL2DSL:DSL 结构匹配率 + 结果级一致性 + 编译器覆盖率(方言/函数)。

posted @ 2025-08-28 15:22  stardsd  阅读(253)  评论(0)    收藏  举报