语义层(下篇):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 长这样:
{
"metrics": ["fee", "cpm"],
"dimensions": ["brand"],
"time_range": { "type": "last_7_days" },
"having": [{ "metric": "cpm", "operator": "gt", "value": 200 }],
"order_by": [{ "metric": "fee", "direction": "desc" }]
}
看到没有——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 或对话框的主要入口。一次调用,从自然语言到结果全搞定。
你传进去:
{
"query": "上周各品牌消耗和CPM,CPM超200的标出来",
"context": {
"account_ids": ["acct_001", "acct_002"],
"user_role": "投放运营"
},
"session_id": "sess_abc123",
"options": {
"return_mql": true,
"explain": true
}
}
<code>session_id</code> 用于多轮对话,语义层记住上一轮的查询范围,用户追问"那 CPM 最高的是哪个计划"时不需要再重复说"上周"、"哪些账户"。
<code>return_mql: true</code> 让你能看到 LLM 生成的 MQL 长什么样,方便调试——如果结果不对,先看 MQL 对不对,再决定是改 Prompt 还是改语义模型。
语义层吐出来:
{
"status": "success",
"data": {
"columns": ["品牌", "消耗(元)", "CPM", "CPM异常"],
"rows": [
["品牌A", 12580.0, 168.3, false],
["品牌B", 8920.0, 231.5, true],
["品牌C", 5460.0, 89.2, false]
]
},
"mql": {
"metrics": ["fee", "cpm"],
"dimensions": ["brand"],
"time_range": { "type": "last_7_days" }
},
"meta": {
"metrics_resolved": {
"fee": "SUM(fee)",
"cpm": "SUM(fee) / SUM(impression) * 1000"
},
"anomaly_rules_applied": ["cpm > 200"],
"time_range_resolved": "2026-03-17 ~ 2026-03-23",
"confidence": 0.94
},
"explanation": "品牌B的CPM为231.5,超过阈值200,其余品牌正常。"
}
几个细节值得注意:
<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> — 意图预览
只翻译,不执行。
// 返回
{
"mql": { "metrics": ["fee", "cpm"], "dimensions": ["brand"], ... },
"interpretation": "查询上周(3月17日~3月23日),按品牌分组,统计消耗和CPM,筛选CPM超过200的行",
"ambiguities": [
"消耗存在多种口径(付费/自然流),已默认使用聚光扣费口径,如需其他口径请说明"
]
}
这个接口最适合做"查询确认"交互:AI 先展示"我理解你要查的是这个",用户点确认再执行。有歧义的地方也在 <code>ambiguities</code> 里提前暴露,让用户澄清,而不是悄悄按某个默认值算了、结果不对再扯皮。
接口三:<code>POST /execute</code> — 直接跑 MQL
给程序化调用场景用。调用方自己构建好 MQL,不走 NL 解析,直接提交:
{
"mql": {
"metrics": ["fee", "cpm", "ctr"],
"dimensions": ["brand", "plan_name"],
"time_range": { "start": "2026-03-01", "end": "2026-03-23" },
"filters": [
{ "dimension": "brand", "operator": "eq", "value": "品牌A" }
],
"order_by": [{ "metric": "fee", "direction": "desc" }],
"limit": 20
},
"context": { "account_ids": ["acct_001"] }
}
适合定时报表、批量分析、或者上层 Agent 在确认意图后直接提交执行的场景。
接口四:<code>GET /catalog</code> — 语义元数据
GET /catalog?type=metrics&keyword=消耗
返回指标的完整定义:
{
"metrics": [{
"key": "fee",
"label": "消耗",
"synonyms": ["花费", "扣费", "投放金额"],
"formula": "SUM(fee)",
"unit": "元",
"description": "聚光实际扣费,T+1口径,不含退款",
"related": ["cpm", "cpc", "roi"]
}]
}
这个接口最主要的消费方不是人,是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: ["fee","cpm"], dimensions: ["brand"],
time_range: {type:"last_7_days"},
having: [{metric:"cpm", op:"gt", val:200}] }
注意:LLM 只输出了业务概念,没有表名,没有 SQL
│
▼
【第三站:MQL 语义校验】
① 字段合法性:fee / cpm / brand 是否在 catalog 里有记录?
② 别名解析:"消耗"→fee, "品牌"→brand_name(LLM有时会用中文)
③ 权限注入:自动追加 account_id IN ('acct_001','acct_002')
④ 口径一致性: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 '2026-03-17' AND '2026-03-23'
HAVING 转换:
cpm > 200 → HAVING SUM(fee)/SUM(impression)*1000 > 200
权限过滤自动加上:
WHERE account_id IN ('acct_001', 'acct_002')
最终 SQL 组装完毕,没有一行是 LLM 写的
│
▼
【第五站:执行,拿原始数据】
SQL 发给数据仓库
拿回原始行数据
│
▼
【第六站:后处理 & 语义增强】
① 业务规则判断:cpm > 200 → is_anomaly = true,标红
② 数字格式化:12580.00 → "12,580元"
③ 调 LLM 写一句业务解读:"品牌B CPM 偏高,建议排查定向"
④ 打包 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