语义层(下篇):MQL、API 设计与一次查询的内部旅程

内容纲要

语义层(下篇):MQL、API 设计与一次查询的内部旅程

这是下篇,接上篇继续。上篇说了语义层是什么、里面装什么、有哪些方案。这篇来聊语义层怎么对外暴露能力、API 长什么样、一次查询在里面是怎么转的。

没看上篇也没关系,这篇可以独立阅读。


先解决一个思维定式:为什么不直接让 LLM 写 SQL

大多数人第一次想到"AI 查数据",脑子里画的图是这样的:

用户说话 → LLM → SQL → 数据库 → 结果

直觉上对,工程上是个坑。

原因很简单:你让 LLM 同时干了两件不同性质的事——理解业务意图,以及翻译成技术实现。前者是语义问题,后者是工程问题。混在一起,出错了你都不知道是哪一步错的。

常见的翻车场景:

  • LLM 自信地写了一段 SQL,字段名幻觉,查出来报错
  • 两张表的 join key 搞反了,数据被放大了 10 倍,但 LLM 不知道
  • <code>fee</code> 字段被 LLM 理解成了预算而不是扣费,结果对不上

更要命的是,这些错误看起来很像正确结果——没有报错,返回了一个数,就是数字不对。用户如果不去比对,根本发现不了。


MQL:给 LLM 一个「安全的操场」

解法是引入一层中间语言,叫 MQL(Metrics Query Language),让 LLM 只在这个操场里玩,别的地方不让碰。

改造后的链路长这样:

用户说话 → LLM → MQL → MQL 引擎 → SQL → 数据库 → 结果
             ↑ LLM只用业务词汇      ↑ 语义层负责所有技术细节

MQL 是一种结构化的业务查询语言,它的词汇表来自语义模型——都是指标名、维度名这类业务概念,没有表名,没有字段名,没有 join。

一条 MQL 长这样:

{
  &quot;metrics&quot;: [&quot;fee&quot;, &quot;cpm&quot;],
  &quot;dimensions&quot;: [&quot;brand&quot;],
  &quot;time_range&quot;: { &quot;type&quot;: &quot;last_7_days&quot; },
  &quot;having&quot;: [{ &quot;metric&quot;: &quot;cpm&quot;, &quot;operator&quot;: &quot;gt&quot;, &quot;value&quot;: 200 }],
  &quot;order_by&quot;: [{ &quot;metric&quot;: &quot;fee&quot;, &quot;direction&quot;: &quot;desc&quot; }]
}

看到没有——LLM 只需要从有限的词汇表里组装一个 JSON。<code>fee</code> 具体怎么聚合、<code>cpm</code> 怎么计算、这两个指标分别在哪张表、需不需要 join,这些它完全不需要知道。交给 MQL 引擎处理。

这个设计带来两个好处:

第一,LLM 的错误类型变了。从"SQL 语法错/字段名幻觉"变成了"业务字段名填错"。后者语义层可以用同义词做兜底纠正,前者只能让用户看到报错。

第二,出了问题好定位。NL → MQL 这一步是 LLM 的活,MQL → SQL 是引擎的活,两段分开,一查就知道锅在哪。不是一个黑盒。


语义层对外的四个 API

有了 MQL 这个中间层,语义层对外暴露的能力就清晰了。整体上是四个接口:

/query     自然语言 → MQL → SQL → 执行 → 结果     (主链路,最常用)
/parse     自然语言 → MQL only                    (看意图,不执行)
/execute   直接执行 MQL → 结果                    (程序化调用)
/catalog   查语义元数据                            (有哪些指标/维度/规则)

逐个展开说。


接口一:<code>POST /query</code> — 主链路

上层 Agent 或对话框的主要入口。一次调用,从自然语言到结果全搞定。

你传进去:

{
  &quot;query&quot;: &quot;上周各品牌消耗和CPM,CPM超200的标出来&quot;,
  &quot;context&quot;: {
    &quot;account_ids&quot;: [&quot;acct_001&quot;, &quot;acct_002&quot;],
    &quot;user_role&quot;: &quot;投放运营&quot;
  },
  &quot;session_id&quot;: &quot;sess_abc123&quot;,
  &quot;options&quot;: {
    &quot;return_mql&quot;: true,
    &quot;explain&quot;: true
  }
}

<code>session_id</code> 用于多轮对话,语义层记住上一轮的查询范围,用户追问"那 CPM 最高的是哪个计划"时不需要再重复说"上周"、"哪些账户"。

<code>return_mql: true</code> 让你能看到 LLM 生成的 MQL 长什么样,方便调试——如果结果不对,先看 MQL 对不对,再决定是改 Prompt 还是改语义模型。

语义层吐出来:

{
  &quot;status&quot;: &quot;success&quot;,
  &quot;data&quot;: {
    &quot;columns&quot;: [&quot;品牌&quot;, &quot;消耗(元)&quot;, &quot;CPM&quot;, &quot;CPM异常&quot;],
    &quot;rows&quot;: [
      [&quot;品牌A&quot;, 12580.0, 168.3, false],
      [&quot;品牌B&quot;, 8920.0, 231.5, true],
      [&quot;品牌C&quot;, 5460.0, 89.2, false]
    ]
  },
  &quot;mql&quot;: {
    &quot;metrics&quot;: [&quot;fee&quot;, &quot;cpm&quot;],
    &quot;dimensions&quot;: [&quot;brand&quot;],
    &quot;time_range&quot;: { &quot;type&quot;: &quot;last_7_days&quot; }
  },
  &quot;meta&quot;: {
    &quot;metrics_resolved&quot;: {
      &quot;fee&quot;: &quot;SUM(fee)&quot;,
      &quot;cpm&quot;: &quot;SUM(fee) / SUM(impression) * 1000&quot;
    },
    &quot;anomaly_rules_applied&quot;: [&quot;cpm &gt; 200&quot;],
    &quot;time_range_resolved&quot;: &quot;2026-03-17 ~ 2026-03-23&quot;,
    &quot;confidence&quot;: 0.94
  },
  &quot;explanation&quot;: &quot;品牌B的CPM为231.5,超过阈值200,其余品牌正常。&quot;
}

几个细节值得注意:

<code>meta.metrics_resolved</code> 告诉你 <code>fee</code> 实际是 <code>SUM(fee)</code>、<code>cpm</code> 实际是那个公式。数字可以溯源,不是一个黑盒子里出来的神秘结果。

<code>anomaly_rules_applied</code> 说明了哪些业务规则被触发了。"CPM > 200 标红"不是 LLM 随机发挥,是语义层里定义好的规则。

<code>explanation</code> 是语义层拿到数据后,再调一次 LLM 写的业务解读。这一步可选,但对非技术用户体验很好——直接告诉你"品牌B有问题",而不是让用户自己盯着表格找。


接口二:<code>POST /parse</code> — 意图预览

只翻译,不执行。

// 返回
{
  &quot;mql&quot;: { &quot;metrics&quot;: [&quot;fee&quot;, &quot;cpm&quot;], &quot;dimensions&quot;: [&quot;brand&quot;], ... },
  &quot;interpretation&quot;: &quot;查询上周(3月17日~3月23日),按品牌分组,统计消耗和CPM,筛选CPM超过200的行&quot;,
  &quot;ambiguities&quot;: [
    &quot;消耗存在多种口径(付费/自然流),已默认使用聚光扣费口径,如需其他口径请说明&quot;
  ]
}

这个接口最适合做"查询确认"交互:AI 先展示"我理解你要查的是这个",用户点确认再执行。有歧义的地方也在 <code>ambiguities</code> 里提前暴露,让用户澄清,而不是悄悄按某个默认值算了、结果不对再扯皮。


接口三:<code>POST /execute</code> — 直接跑 MQL

给程序化调用场景用。调用方自己构建好 MQL,不走 NL 解析,直接提交:

{
  &quot;mql&quot;: {
    &quot;metrics&quot;: [&quot;fee&quot;, &quot;cpm&quot;, &quot;ctr&quot;],
    &quot;dimensions&quot;: [&quot;brand&quot;, &quot;plan_name&quot;],
    &quot;time_range&quot;: { &quot;start&quot;: &quot;2026-03-01&quot;, &quot;end&quot;: &quot;2026-03-23&quot; },
    &quot;filters&quot;: [
      { &quot;dimension&quot;: &quot;brand&quot;, &quot;operator&quot;: &quot;eq&quot;, &quot;value&quot;: &quot;品牌A&quot; }
    ],
    &quot;order_by&quot;: [{ &quot;metric&quot;: &quot;fee&quot;, &quot;direction&quot;: &quot;desc&quot; }],
    &quot;limit&quot;: 20
  },
  &quot;context&quot;: { &quot;account_ids&quot;: [&quot;acct_001&quot;] }
}

适合定时报表、批量分析、或者上层 Agent 在确认意图后直接提交执行的场景。


接口四:<code>GET /catalog</code> — 语义元数据

GET /catalog?type=metrics&amp;keyword=消耗

返回指标的完整定义:

{
  &quot;metrics&quot;: [{
    &quot;key&quot;: &quot;fee&quot;,
    &quot;label&quot;: &quot;消耗&quot;,
    &quot;synonyms&quot;: [&quot;花费&quot;, &quot;扣费&quot;, &quot;投放金额&quot;],
    &quot;formula&quot;: &quot;SUM(fee)&quot;,
    &quot;unit&quot;: &quot;元&quot;,
    &quot;description&quot;: &quot;聚光实际扣费,T+1口径,不含退款&quot;,
    &quot;related&quot;: [&quot;cpm&quot;, &quot;cpc&quot;, &quot;roi&quot;]
  }]
}

这个接口最主要的消费方不是人,是LLM 的 Prompt 构建模块。每次构建 Prompt 之前,从这里动态拉取最新的指标定义注入上下文,保证 LLM 用的词汇表和语义模型永远同步。有人改了 <code>fee</code> 的口径说明?下一次查询 LLM 就知道了,不需要改 Prompt。


一次查询在语义层内部是怎么流转的

说完了 API 边界,来看内部实现。以"上周各品牌消耗和 CPM,CPM 超 200 的标出来"为例,走一遍完整的六阶段旅程。

用户输入进来
    │
    ▼
【第一站:Prompt 组装】
  调 /catalog 拉取 fee、cpm、brand 的最新定义
  RAG 检索历史里相似的 MQL 查询示例 2~3 条
  把系统指令 + 指标定义 + 维度定义 + 示例 + 用户输入
  打包成一个完整的 Prompt
    │
    ▼
【第二站:LLM 生成 MQL】
  LLM 阅读 Prompt,输出结构化 JSON
  { metrics: [&quot;fee&quot;,&quot;cpm&quot;], dimensions: [&quot;brand&quot;],
    time_range: {type:&quot;last_7_days&quot;},
    having: [{metric:&quot;cpm&quot;, op:&quot;gt&quot;, val:200}] }

  注意:LLM 只输出了业务概念,没有表名,没有 SQL
    │
    ▼
【第三站:MQL 语义校验】
  ① 字段合法性:fee / cpm / brand 是否在 catalog 里有记录?
  ② 别名解析:&quot;消耗&quot;→fee, &quot;品牌&quot;→brand_name(LLM有时会用中文)
  ③ 权限注入:自动追加 account_id IN (&#039;acct_001&#039;,&#039;acct_002&#039;)
  ④ 口径一致性:fee 和 cpm 来自同一张宽表,不需要 join ✓

  ✅ 全部通过,继续
  ❌ 有问题,返回错误(字段不存在 / 存在歧义需要澄清)
    │
    ▼
【第四站:MQL → SQL(核心翻译)】
  指标展开:
    fee  →  SUM(fee) AS fee
    cpm  →  SUM(fee) / SUM(impression) * 1000 AS cpm

  维度映射:
    brand  →  brand_name

  时间解析:
    last_7_days  →  stat_date BETWEEN &#039;2026-03-17&#039; AND &#039;2026-03-23&#039;

  HAVING 转换:
    cpm &gt; 200  →  HAVING SUM(fee)/SUM(impression)*1000 &gt; 200

  权限过滤自动加上:
    WHERE account_id IN (&#039;acct_001&#039;, &#039;acct_002&#039;)

  最终 SQL 组装完毕,没有一行是 LLM 写的
    │
    ▼
【第五站:执行,拿原始数据】
  SQL 发给数据仓库
  拿回原始行数据
    │
    ▼
【第六站:后处理 &amp; 语义增强】
  ① 业务规则判断:cpm &gt; 200 → is_anomaly = true,标红
  ② 数字格式化:12580.00 → &quot;12,580元&quot;
  ③ 调 LLM 写一句业务解读:&quot;品牌B CPM 偏高,建议排查定向&quot;
  ④ 打包 meta:用了哪些指标、触发了哪些规则、时间范围是什么
    │
    ▼
  整理好,打包返回给上层 Agent

整个链路最关键的设计思路是职责分离,三段各司其职:

  • LLM 段(第一站到第二站):只管理解意图,把用户的话翻译成 MQL,不碰任何技术细节
  • 引擎段(第三站到第五站):只管技术执行,把 MQL 翻成 SQL,执行,拿数据,不依赖 LLM
  • 后处理段(第六站):只管业务增值,把原始数据加工成有业务意义的结果

三段分开,出了问题一眼定位。LLM 生成的 MQL 字段填错了?第三站校验阶段直接拦截。MQL 到 SQL 的翻译有 bug?第四站的逻辑里找。业务规则没触发?第六站排查。不会再出现"我也不知道哪里错了"的窘境。


这套架构真正值在哪

聊了这么多,最后说三个最实际的收益,不讲虚的。

收益一:指标口径从此有了"法律文本"

<code>cpm</code> 的计算公式只在语义层定义一次。所有工具、所有 Agent、所有报表消费的都是同一个定义。下次有人说"我这边的 CPM 和你那边对不上",你可以理直气壮地说:来,看语义层,这是唯一的权威版本。

公式改了?改语义层一处,全局生效。不用找十个地方挨个改,也不会改了三个忘了一个。

收益二:LLM 出错成本断崖式下降

以前 LLM 直接写 SQL,出错类型是字段名幻觉、join 逻辑写错、SQL 语法错——这些错误往往直接报错,或者更惨,不报错但数字是错的。

引入 MQL 后,LLM 的出错类型变成"从有限词汇表里选错了一个词"。这类错误,语义层的同义词兜底可以部分纠正;就算纠正不了,第三站校验也能在执行前拦截,不会把错误结果悄悄交付给用户。

收益三:多轮对话真的像在说人话

Session 机制让语义层记住上一轮的查询上下文。用户问完"上周各品牌消耗和 CPM",接着追问"那 CPM 最高的是哪个计划"——不需要重新说时间范围、不需要重新说账户,语义层自己补全。

对话体验从"每次都得说一堆背景"变成了真正的追问式交流。这个体验差异,用过之后就回不去了。


最后

语义层和 MQL 不是什么高深的东西,本质上是把散落在各处的业务知识系统化地组织起来,让 AI 和各种工具都能消费这份知识,而不是各自为政、各定义一套。

建它的过程会有点痛——因为你会发现很多"大家都以为一样,其实各说各的"的地方。但一旦建起来,你会发现之前很多扯不清楚的问题,都有了一个干净的答案。

从哪里开始?最小可行版本就是:把核心指标的中文别名 + 计算公式 + 口径说明整理成一份结构化 JSON,把它注入进 LLM 的 Prompt。就这一步,效果就会明显不一样。

剩下的,边用边补。


← 回到上篇:语义层(上篇):AI 真的不懂你的业务,除非你告诉它

参考项目:Vanna.ai · Wren AI · dbt MetricFlow · Cube.js · Snowflake Cortex Analyst

滚动至顶部