从 Text2SQL 到语义层 BI
原创 半吊子全栈工匠 2026-11-08 20:26 北京
语义层这件事,技术只占一半,另一半是把公司到底怎么算这笔账从人脑里搬到一个机器能读、能审、能版本化的地方。搬的过程很枯燥,要开会、要吵架、要让业务方签字,一点都不酷。但它是唯一能让 agent 的输出变得可信的办法。
【引】模型没有骗你。它老老实实把「上个月的客户流失率」翻译成了一段能跑的 SQL,只是它不知道你们公司是怎么定义流失的。缺的那一层,才是这两年 BI 领域真正在抢的东西。本文是对《本体驱动AI大模型:方法与实践》一书的补充与解读。
Text2SQL 解决的是「翻译」,不是「理解」。翻译可以做到零语法错误,答案照样可以是错的。错的代价变了。有人在会议室里盯着大屏的时候,错一个数最多被同事当场指出来;agent 直接调工作流的时候,错一个数就可能是五万块广告预算。
补这个洞的办法不是换个更强的模型,而是在模型和数据库中间加一层能被版本控制、能被审计、能被所有工具共用的业务定义。这层东西就是语义层。
语义层不是什么新发明,它只是从 BI 工具的一个附赠功能,变成了 agent 时代的基础设施。
一、Text2SQL 到底在做什么
这件事的定义干净得不能再干净:给一个问题,给一个数据库的表结构,让模型输出一段能执行、且能答对问题的 SQL。
学术界的标准例子长这样:
-- 问题:What cartoons were written by Joseph Kuhr?
SELECT * FROM Cartoon WHERE Written_by = "Joseph Kuhr";配上表结构一起喂给模型:Cartoon表有Title、Directed_by、Written_by、Production_code、Channel几列,Channel外键指向TV_Channel。模型输出上面那句 SQL,数据库跑出「Batman Series」,问题答完了。
为什么这件事值得做?Stack Overflow 2023 年的开发者调查里有个数字:51.52% 的专业开发者日常在用 SQL,但只有 35.29% 受过系统训练。也就是说,绝大多数人是在工作中边查边写边出错的。让不会写 SQL 的人能用自然语言问数,动机一直都很充分。
2023 年之后,这个任务的主流做法分成两条线。
一条是不改模型、只改 prompt 的 in-context learning。DIN-SQL 把任务拆成四步——schema linking、问题分类与拆解、SQL 生成、自我纠错;MAC-SQL 拆成选择器、分解器、精修器三个角色;CHESS 先做实体和证据检索再写 SQL。共同点是承认一步到位很难,用流程换准确率。
另一条是微调。拿开源模型在领域数据上继续训练,比如 DTS-SQL 用 7B 的模型在 Spider 上做到了接近大模型的成绩,代价是你要有标注数据、有 GPU、还要接受它只在你的方言上表现好。
学术数据集上的进展很漂亮。Spider 这类库小、问题规整的基准,执行准确率早就做到 90% 上下。但换到 BIRD——95 个真实企业的数据库、1.2 万条问题、平均每个库 7.3 张表、54.9 万行数据、跨领域还带外部知识——最好的方法长期在 50% 到 70% 这个区间里打转。
一个 90%,一个 60%。差的那 30 个百分点,不是模型突然变笨了,是真实数据库里塞满了模型没见过的业务约定。
二、语法完美,语义全错
先把这条失败路径画出来,整条链路只有四步:
用户输入Show customer churn。模型没有任何业务上下文,只能猜表和过滤条件,于是写出了这个:
SELECT u.id AS user_id,
COUNT(o.id) AS orers_count
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.activity_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)
GROUP BY u.id
HAVING COUNT(o.id) = 0;数据库很配合,执行成功,返回一串user_id和order_count = 0。界面显示:这些用户流失了。
但这不是流失。这只是「最近 90 天没有下单的用户」。
中间隔着好几层东西:有人退了款但还在订阅期,有人是年付只在年初下一单,有人是测试账号本来就不该算,还有些订单在支付确认里有几天延迟,昨天的单今天才落库。这些规则一条都不在表结构里,模型一条都猜不到。
顺便说个细节,这张图其实还暴露了另一件事。上面那段 SQL 照抄下去跑,大概率返回空集:INNER JOIN加上WHERE o.activity_date >= ...,已经把 90 天内没有订单的用户全过滤掉了,剩下的每一行COUNT(o.id)至少是 1,HAVING COUNT(o.id) = 0永远不成立。要写成LEFT JOIN才是图上那个结果。连列别名都拼成了orers_count。
数据库不检查语义,只检查语法。别名拼错它不管,join 逻辑自相矛盾它也不管,只要语句合法就执行、就返回、就用一个漂亮的表格显示出来。语法正确这件事,比大多数人以为的要廉价得多。
2.1 模型不是不懂 SQL,是不懂你们公司
Sergey Gromov 那段话我一直记着,说得比我准:
语言模型在精确地执行它被设计来做的事:把人类的措辞翻译成可能的数据查询。它不需要理解业务含义……公司期望得到一位分析师,结果得到了一位翻译。把问题翻译成 SQL 不是分析,只是事实检索。分析从事实被解释的那一刻才开始。
一个干了三年的分析师听到流失率,脑子里会自动跑一遍公司口径;模型听到同一个词,脑子里跑的是 token 概率。它找到last_activity_date,套一个时间间隔,数人头。查询完美运行,数字完全错误。
学术上把这类问题归成三类:自然语言本身的歧义(「上个月」是自然月还是滚动 30 天)、schema 理解(表和列的名字跟业务词对不上)、罕见 SQL 操作(嵌套子查询、外连接、窗口函数在训练数据里出现得少)。再加上一个更现实的问题,提问的人往往说不清楚,同义词、错别字、模糊表达混在一起,靠字面匹配做 schema linking 很容易被带偏。
2.2 那些指标数字,会骗人
Text2SQL 有一套挺成熟的评估指标,整理出来是这样一张表:
指标 | 全称 | 在衡量什么 | 会漏掉什么 |
|---|---|---|---|
EM | Exact Match | 生成的 SQL 和标准答案是否逐字等价 | 写法不同但结果相同,一律判错 |
EX | Execution Accuracy | 两边执行结果集是否一致 | 口径错但恰好撞对结果,判对 |
VES | Valid Efficiency Score | 结果对的前提下,执行效率如何 | 与业务正确性无关 |
TS | Test-Suite Accuracy | 多种等价 SQL 下结果是否都稳定 | 构造成本高 |
FLEX | False-Less Execution | 严格校验执行结果,压误报 | 极端保守 |
Answer Accuracy | 用 GPT 判断答案是否回答了问题 | 更贴近人的判断 | 引入另一个模型的主观性 |
企业里最常用的是 EX,因为它最接近「对不对」这个直觉。但 EX 有个隐蔽的坑:它只比较结果集,不检查口径。
举个具体场景。测试集里问上月收入,标准答案用的是含税口径,模型生成的是不含税口径。某个月公司没有任何折扣和退单,两个口径算出来的数完全一样,EX 判它正确。等这个模型上线,遇到第一个打折月,数字就飘了。而你在测试报告上看到的是 90 分。
这也是为什么学术界还在提新指标,包括语义匹配率、查询复杂度、字面值检索准确率、schema 对齐度、多正确答案场景等。指标越加越多,恰恰说明“SQL 对不对”和“答案对不对”是两件事。
三、错的代价,已经从会议室转移到了预算表
再看一组对照,比讲道理直观得多:
传统 BI 的错误链条是这样的:指标定义过期(比如 MRR 错误地把折扣算进去了)→ 图表画出一个漂亮的上升曲线 → 周会上有人皱眉头说“这个数不对吧”→ 当场核对,改掉,没造成损失。人是最后一道校验,而且这道校验是免费的。
Agent 的错误链条是这样的:LLM 编了一条 join 路径,把收入重复计算了一遍 → LTV 算出来 1250 美元,真实值 620 美元,高估 102% → agent 判断“高价值用户值得加投”,自动触发优化决策、开广告计划 → 五万元预算出去了。
两条链条的差别不在错误大小,在人还在不在环路。
这也是为什么语义层这两年从治理上的一个加分项变成了上线前的准入条件。在人工看图的时代,语义碎了顶多是两个部门在季度会上报出不同的销售额,争执一下午也就过去了;在 agent 直接连着工作流的时代,语义碎了就是自动化系统拿着错数字做决策,而且做得又快又坚决。
四、语义层不是新东西,是位置变了
把时间线拉开看,这层东西挪过三次位置:
阶段 | 语义逻辑待在哪 | 谁维护 | 谁能用 | 典型毛病 |
|---|---|---|---|---|
单体 BI(1990s–2010s) | BusinessObjects 的 Universe、SSAS 的 cube、MicroStrategy 的元数据库 | 集中的语义架构师团队 | 只有这家 BI 工具 | 锁死在工具里,出了这个门就得重写 SQL |
现代数据栈(2010s–2020s) | dbt 模型里,直接编译成物理表和视图 | 分析工程师 | 所有能连仓库的工具 | 语义被压平成表,动态逻辑丢了,各工具各自重新定义指标 |
无头语义层(现在) | 独立的、代码化的、版本控制的定义层 | 数据工程 + 业务共同维护 | 仪表盘、笔记本、agent、应用,同一个 API | 建设成本真实存在,冷启动慢 |
第一代的问题是好用但封闭。分析师想在 Python Notebook里跑个模型,或者把数据拉到 Excel 里做点临时测算,只能绕过语义层直接写裸 SQL——绕过去的那一刻,治理就失效了。
第二代的问题是开放但散装。dbt 把转换逻辑变成了代码,仓库干净了,可指标定义也跟着被拍成了物理表。Maxime Beauchemin(Apache Superset 的作者)有个比喻我特别喜欢:
过去十年我们一直在开自助餐。自助餐适合大规模喂养数据饥渴的组织,但菜会变质,食物中毒也会发生。没人引导的时候,人们会做出很可疑的搭配——小孩一定会把果冻和意面放在一个盘子里。
自助餐的后果是数据无政府状态:三四个 BI 工具,各自在同样的干净表上重新定义一遍「活跃用户」,然后开会吵架。
第三代就是现在这波。逻辑从可视化层里抽出来,也从物理表里抽出来,做成一个独立的服务,通过一个统一接口同时喂仪表盘、喂 notebook、喂 agent。所谓“无头”,说的就是这个——它自己没有界面。
推动这次搬家的是 agent。人看图的时候,语义稍微有点出入还能靠常识兜住;agent 没有常识,它只有接口。
五、语义层里到底存了什么
一个完整的语义引擎,拆开是四个零件:
第一个零件是对象图。声明实体、维度和它们之间的关系,本质上是在数据库之上画一张有向图,把 join 路径写死在图里,而不是留给客户端现编。
这件事听起来像洁癖,其实是在防一类非常具体的错误:扇出。orders一对多order_items,再一对多payments,三张表一 join,一行订单变成 3 乘 2 等于 6 行,金额翻六倍。SQL 完全合法,结果毫无意义。人类工程师踩过这个坑所以会警惕,模型不会——它看到orders和payments有个共同的主键,join 上去很合理。
第二个零件是声明式指标。指标不写成硬编码的数字,写成一个表达式:gross_profit = revenue - cogs,revenue再去引用它自己的定义和过滤条件。好处是只有一处定义。无论这个指标最后是 agent 调的、Power BI 画的,还是某个 Python 脚本临时算的,出来的数一定是同一个。
第三个零件是编译引擎。它把上层的抽象请求翻译成具体方言的 SQL。同一个请求打到 Snowflake、Databricks、BigQuery 还是 DuckDB,生成的 SQL 不一样,但逻辑必须一致。多遍聚合、窗口函数、同比环比这类时间智能,都在这一层处理掉,不往上抛。
第四个零件是性能与安全核心。在 SQL 打到数据库之前,把当前用户的行级权限、列级脱敏规则追加进去;把重复的查询路由到预聚合表或缓存,别每次都去扫原始分区。
四层架构在 agent 时代格外重要,原因很朴素:agent 是迭代型的。它制定一个计划可能要问十几次、几十次,人问一次它问二十次,算力账单也是二十倍。
六、Agent 该拿到的接口,长什么样
现在大多数 Text2SQL 的做法是:把表结构塞进 prompt,让模型写 SQL,执行,看结果。这个做法在小库上能用,在大库上必死。算个账就明白:一个中等规模的企业仓库,2000 张表,每张 30 列,光 DDL 文本就是几十万 token,任何上下文窗口都装不下。就算用向量检索只挑出 50 张「相关」表,也有一万多 token;而这 50 张里挑错一张,join 就错了,答案就废了,你还看不出来。
语义层的做法是换一个方向:不把表结构给模型,把业务对象给模型。
{
"metric": "customer_lifetime_value",
"dimensions": ["region", "cohort_year"],
"filters": {
"region": "EMEA",
"cohort_year": 2025
}
}模型要做的只有一件事:把EMEA 地区 2025 年这批客户的 LTV 是多少翻译成上面这个 JSON。它不需要知道 LTV 背后有哪几张表、要不要剔除退款、跨币种怎么换算、订阅和一次性付费怎么合并——这些都在语义层里写好了。
语义层收到请求之后做的活是:解析customer_lifetime_value的定义,沿着对象图找到customers → orders → subscriptions → fx_rates的 join 路径,套上当前用户的 EMEA 行级权限,注入时间窗口和币种换算,编译成目标方言的 SQL,执行,返回一个确定的结果。
这个分工的关键在于模型的输出空间被收窄了。它不再有自由写任意 SQL 的权力,只能从受治理的指标和维度里做选择。选错了口径也会被定义本身拦住,选错了维度最多是看得不细。这跟「让一个实习生随便写 SQL」完全是两种风险结构。
行业老兵 Andrew Brust 的说法很到位:
「语义」这个词以前是理想化的,现在它变得字面化了。如果 AI 不理解你的数据是什么意思,它只能猜或者幻觉……AI 很积极,它想取悦你。没有护栏,它会返回看起来很权威、其实不是那么回事的结果。
5.1 一份真实的最小实现
有一套基于 MCP 的开源实现值得拆开看,因为它正好卡在 Text2SQL 和语义层的分界线上。
它暴露了三个工具:retrieve_schema、list_tables、execute_sql_query。
"""
src/tools/mcp_tools.py: Defines tools for text-to-SQL operations.
- Uses direct function calls instead of fastmcp for simplicity.
- Includes a LangChain-compatible SchemaRetrievalTool with Pydantic fields.
"""
from langchain_core.tools import BaseTool
from pydantic import Field
from src.config.config import CONFIG
from src.core import schema_retrieval
from src.utils.logging import setup_logging
logger = setup_logging(__name__)
def execute_sql_query(query: str, db) -> str:
"""Execute a SQL query and return results or error message."""
try:
result = db.run(query)
return str(result)
except Exception as e:
return f"Error: {str(e)}"
def list_tables(db) -> str:
"""List available tables in the database."""
return str(db.get_usable_table_names())
def retrieve_schema_tool(query: str, vector_store) -> str:
"""Retrieve relevant schema from ChromaDB."""
if vector_store is None:
# Fallback: Use schema from config if vector_store is not provided
logger.info("Using fallback schema from CONFIG")
return CONFIG["schema"]
return schema_retrieval.retrieve_schema(vector_store, query)
class MCPClient:
def call_tool(self, tool_name: str, args: dict):
"""Mock MCP client to call tools directly."""
if tool_name == "execute_sql_query":
return execute_sql_query(args["query"], args["db"])
elif tool_name == "list_tables":
return list_tables(args["db"])
elif tool_name == "retrieve_schema":
return retrieve_schema_tool(args["query"], args.get("vector_store"))
raise ValueError(f"Unknown tool: {tool_name}")
def initialize_mcp_tools(db, vector_store):
"""Initialize mock MCP client."""
return MCPClient()
class SchemaRetrievalTool(BaseTool):
name: str = "schema_retrieval"
description: str = "Retrieve relevant database schema for a given query."
mcp_client: object = Field(description="MCP client for schema retrieval")
def __init__(self, mcp_client):
super().__init__(mcp_client=mcp_client)
def _run(self, query: str) -> str:
"""Synchronous execution of schema retrieval."""
return self.mcp_client.call_tool("retrieve_schema", {"query": query, "vector_store": None})
async def _arun(self, query: str) -> str:
"""Asynchronous execution (not used, but required by BaseTool)."""
return self._run(query)外层是一个 LangChain 的 ReAct agent,循环里明确了「想—做—看—验」四步,并且限制最多五轮:
"""
src/agents/agent.py: Initializes and runs the LangChain ReAct agent for text-to-SQL.
- Uses tools for SQL execution, table listing, and schema retrieval.
- Executes generated queries and returns accurate results.
- Supports self-correction via error analysis and retries.
"""
from langchain.agents import create_react_agent, AgentExecutor
from langchain_community.tools.sql_database.tool import (
QuerySQLDataBaseTool,
ListSQLDatabaseTool,
)
from langchain_core.prompts import PromptTemplate
from src.tools.mcp_tools import SchemaRetrievalTool
from src.utils.logging import setup_logging
from src.config.config import CONFIG
from langchain_community.utilities import SQLDatabase
logger = setup_logging(__name__)
def initialize_agent(llm, db, mcp_client):
"""Initialize LangChain ReAct agent."""
# LangChain tools
query_tool = QuerySQLDataBaseTool(db=db)
list_tables_tool = ListSQLDatabaseTool(db=db)
schema_tool = SchemaRetrievalTool(mcp_client)
# ReAct-compatible prompt template
SQL_PROMPT = PromptTemplate.from_template(
"""
You are an expert SQL assistant tasked with answering user questions by generating and executing SQL queries based on the provided database schema.
Your goal is to:
1. Generate a syntactically correct SQL query to answer the question.
2. Execute the query using the sql_db_query tool to retrieve the result.
3. Verify the result makes sense given the schema and question.
If the query fails or the result seems incorrect, analyze the error, correct the query, and retry.
Question: {question}
Schema: {schema}
Available tools:
{tools}
Tool names: {tool_names}
Follow this process:
Thought: [Explain your reasoning for the query]
Action: sql_db_query
Input: [The SQL query to execute]
After execution:
Observation: [The result returned by the tool]
Thought: [Verify if the result answers the question correctly]
Final Answer: [The query result, e.g., [(3)]]
If the question explicitly asks for only the query, return:
Final Answer: [SQL query]
Scratchpad for intermediate steps:
{agent_scratchpad}
"""
)
# Create agent
agent = create_react_agent(
llm=llm,
tools=[query_tool, list_tables_tool, schema_tool],
prompt=SQL_PROMPT,
)
# Create executor
executor = AgentExecutor(
agent=agent,
tools=[query_tool, list_tables_tool, schema_tool],
verbose=True,
max_iterations=5,
handle_parsing_errors=True,
)
logger.info("Initialized LangChain agent")
return executor
def process_text_to_sql(executor, mcp_client, question: str) -> dict:
"""Process a user query and return SQL query/result."""
try:
schema = mcp_client.call_tool("retrieve_schema", {"query": question, "vector_store": None})
logger.info(f"Retrieved schema: {schema}")
result = executor.invoke({"question": question, "schema": schema})
logger.info(f"Agent result: {result}")
output = result.get("output", "No result returned.")
# Extract query and result from output
query = None
query_result = output
# If output contains a query, extract it
if "SELECT" in output:
# Try to find the query in the agent's reasoning steps
for step in result.get("intermediate_steps", []):
if step[0].tool == "sql_db_query":
query = step[0].tool_input
query_result = step[1] if step[1] else output
break
# Verify result by re-executing the query
if query and query_result and query_result != "No result returned.":
db = SQLDatabase.from_uri(CONFIG["database_uri"])
try:
verified_result = db.run(query)
if verified_result != query_result:
logger.warning(f"Agent result {query_result} differs from verified result {verified_result}")
query_result = verified_result
except Exception as e:
logger.error(f"Verification failed: {str(e)}")
query_result = f"Error verifying result: {str(e)}"
return {
"question": question,
"sql_query": query,
"result": query_result
}
except Exception as e:
logger.error(f"Error processing query: {str(e)}")
return {"question": question, "error": str(e)}这段代码写得挺实在,几个工程细节值得留意:retrieve_schema用 ChromaDB 做 schema 的向量检索,检索不到就回落到配置里的全量 schema,保证流程不断;max_iterations=5限制住循环,防止模型自己跟自己较劲烧钱;最后还拿结果重跑一遍做校验,日志里记下不一致的情况。
但它同时也把边界暴露得很清楚。这里的retrieve_schema检索的是表,不是指标。它能让模型更快找到airplanes表有哪几列,但没有任何机制告诉模型“什么样的用户算流失”。换句话说,上下文变窄了,语义还是空着的。检索对了表,不代表检索到了口径。
这就是从 Text2SQL 到语义层 BI 的那条分界线。两者的输入都是自然语言,区别在于模型最终被允许动什么:是任意一张表,还是一组被定义过的指标和维度。
七、市面上现在有几条路线
语义层这个市场这两年突然挤满了人,路线其实差别很大:
方案 | 定义写在哪 | 出口 | 适合谁 | 代价 |
|---|---|---|---|---|
dbt MetricFlow | dbt 项目里的 YAML,和模型代码放一起 | 通过 dbt server / JDBC 供下游消费 | 已经把转换放在 dbt 里的团队 | 绑在 dbt 生态上,指标改动要走数据团队 |
Cube | JS / TS / YAML 定义,内置预聚合缓存 | SQL API、REST、GraphQL 三套 | 要做嵌入式分析、要给 agent 供数 | 自己运维一套服务 |
AtScale | 虚拟化层,不搬数据 | XMLA / MDX,对旧 BI 工具伪装成多维库 | 多套 BI 并存的大企业 | 重,实施周期长,价格不便宜 |
Snowflake Semantic Views | 建在 Snowflake 里的语义视图 | Cortex AI 直接消费 | 数据本来就在 Snowflake | 逻辑绑在这朵云上 |
Databricks UC Metric Views | Unity Catalog 里,和治理、血缘放一起 | AI/BI、外部客户端通过 Delta Sharing | 湖仓路线,多格式数据 | 依赖 Unity Catalog 体系 |
SAP Datasphere | 从 S/4HANA 继承,不是手写 | Joule / 知识核心 | SAP 是主系统的企业 | 只对 SAP 世界有效 |
7.1 dbt:把指标当代码管
MetricFlow 是 dbt 收购 Transform 之后整合进来的,思路很对工程团队的胃口。指标就是仓库里的一堆 YAML,和模型放一起,走同一个 Git 流程。
# dbt MetricFlow 定义示例
semantic_model:
name: orders
node_relation:
relation_name: ref('fct_orders')
entities:
- name: order_id
type: primary
measures:
- name: order_amount
agg: SUM
metrics:
- name: total_revenue
type: simple
type_params:
measure: order_amount好处不用多说:改指标要提 PR,要有人 review,有 CI 拦着,历史能回溯。坏处也在同一件事上,业务方想调个口径,得排进数据团队的迭代。这是所有代码化治理都要付的税。
7.2 Snowflake 和 Databricks的两种性格
Snowflake 的做法是把语义标注直接嵌进表结构旁边,让 Cortex 消费。用户体验最好——问一句就出结果,优化器、权限、缓存全是原生的。代价是逻辑定义跟这朵云计算绑在了一起,将来想换个引擎,得重新搬一遍。
Databricks 反过来,把语义放进了 Unity Catalog,当成治理和血缘的一部分,底层是 Delta Lake 这种开放格式。好处是企业可以在外部客户端、多云笔记本之间共享同一套受治理的定义;代价是链路上多了几层,出问题要一层层排查。
两家的差异说白了不是技术优劣,是「简单」和「开放」的取舍。
7.3 那个不打算上牌桌的SAP
整个行业都在推开放语义标准,SAP 一直很淡定。原因不复杂:它不需要在别人的数据上重建语义,因为它本身就是语义的产地。
从 SAP 抽过数的人都知道那是什么体验。会计凭证行项目表叫BSEG,物料主数据叫MARA,几万个德语缩写,加上配置层、嵌套账本逻辑、严格的凭证流关系。把这些原始表原样抽到 Snowflake 或 BigQuery,技术上是成功的——数据落地了。但凭证分割规则、税码推导、跨币种换算、物料层级,全留在了 SAP 里面。
于是外部团队开始干一件非常痛苦的事:在 YAML 里手工重写几百万行 ERP 业务规则。写得出来,但没人敢保证写对,每次 SAP 升级还要跟着改。
Datasphere 的做法是把这条路反过来:语义不从外面逆向,而是从 S/4 HANA 直接继承下来。系统里有个叫知识核心的东西,把业务实体、关联、计算元素、权限这套东西原样暴露给 LLM。agent 问的时候不用猜「财务表和物料表怎么连」,平台本来就知道。
这件事的启示跟技术选型无关:管理上下文最省事的办法,是一开始就别把它丢掉。
八、真要落地,五条规则
8.1 语义定义只能走代码评审
不要让任何人在生产环境的界面上点着改指标。实体、join 拓扑、指标公式、维度属性,全部落在版本控制里,改动必须走 PR、过自动校验、有人签字。
这条最难执行,因为业务方会嫌慢。但它是所有其他规矩的前提——定义一旦能被随手改,你后面所有的测试、审计、血缘都失去意义。
8.2 定义必须在消费层的上游
指标只在语义仓库里定义一次,Power BI、Excel、Text2SQL agent、下游运营系统都从这里取。如果某个分析师在单个仪表盘里做了一个指标覆盖,那不是灵活,那是治理失守的第一块砖。
判断标准很土但很好用:改一个口径,需要动几个地方?答案必须是 1。
8.3 指标要分层,别搞一刀切
所有指标都走最严的流程,团队会被拖死;所有指标都放开,半年后你会有三百个互相冲突的活跃用户。分层是唯一可行的中间态:
层级 | 范围 | 治理方式 | 谁能改 |
|---|---|---|---|
Tier 1 核心指标 | 收入、ARR、毛利、人力成本 | 集中治理,数据委员会审批 | 数据团队 + 财务/业务负责人 |
Tier 2 领域指标 | 单次点击成本、归因窗口 | 联邦治理,业务单元的分析工程师负责 | 领域内数据工程 |
Tier 3 实验指标 | 临时探索用的一次性口径 | 沙箱隔离,禁止 agent 自动调用 | 分析师本人,过期自动清理 |
关键在最后一条:Tier 3 必须挡在 agent 之外。人可以看着一个自己都知道不严谨的指标做探索,agent 不会。
8.4、每次改定义都跑回归测试
语义模型一改,PR 流水线就该拿一批标准问题去打沙箱环境,把结果和已知的历史基准对比。重点不是SQL 能不能跑通,是数有没有莫名变大。
join 结构改一下导致扇出、过滤器少一层导致口径放宽,这两类问题在 code review 里看不出来,只有跑数能发现。测试用例不用多,二三十个核心指标各配两三个典型场景就够拦住大部分事故。
8.5、给 agent 上预算和配额
Agent 是迭代型的,一个计划问二十次很正常。如果每次都是全表扫描,账单会非常难看。
在语义层这一层做三件事:单次查询的行数上限、单个用户/单个 agent 的查询预算、高频请求自动路由到预聚合表或缓存。这些都必须在语义层做,因为下游的 agent 你管不住。
五个常见的错误如下:
错误做法 | 当时的想法 | 后来的结果 |
|---|---|---|
在 BI 工具里改指标 | 「就这一次,先应付业务」 | 半年后没人知道线上到底是哪个口径 |
把 join 逻辑写进 prompt | 「多写几句提示词就行了」 | prompt 变成第二套语义层,还没人 review |
拿表注释当语义 | 「注释写得详细点就够了」 | 注释是描述,不是计算逻辑,模型照样不知道口径 |
语义层只做文档不接执行 | 「先梳理清楚,接不接以后再说」 | 文档三个月就过期,没人再打开 |
一次性建全公司的语义模型 | 「要建就建完整」 | 六个月没上线,业务方失去耐心,项目黄了 |
最后一条尤其值得警惕。语义层是个持续演进的东西,不是一次性交付的工程。先把一个业务域的十个核心指标跑通、真正接到 agent 上,比画一张覆盖全公司的蓝图有用得多。
九、还没解决的问题
语义层不是银弹,它自己也有一堆没解决的事。
歧义消不掉。自然语言本身不做承诺。「上个月」是自然月还是过去 30 天,「新客」是注册算还是首单算,这些在语义层里可以有明确答案,但用户脑子里想的可能是另一个。语义层能做的是把公司的标准答案固定下来,然后在用户问的时候把口径一起返回——而不是假装歧义不存在。
schema 规模是硬约束。前面算过那笔账,2000 张表的 DDL 塞不进任何上下文窗口。语义层通过收窄接口缓解了这个问题,但「从几千个指标里挑对那三个」本身又变成了一个新的检索问题。
成本。agent 的迭代特性决定了它的查询量是人的几十倍。预聚合、缓存、配额能压住大部分,但遇到真正需要全表扫描的探索型问题,账单还是会跳。
标准还在路上。开放语义互换(OSI)这类倡议想做的是让语义模型跨平台通用——在开放仓库里写的定义,Snowflake 能直接解释,Cube 能对外暴露,外部 agent 能直接导航。方向是对的,但现在还没到能依赖的阶段。真要做选型,还是要按「这套定义将来能不能搬走」来评估。
生成式语义听着很美。有些平台开始在做了:分析查询日志、血缘路径、甚至组织内的沟通记录,反推业务含义,自动生成语义定义、标记断掉的 join 路径、提出新指标配置供人审批。这件事如果能成,语义建设的人工成本会降一个数量级。但它同样需要一个能审的人,机器猜出来的口径,你敢直接让它上生产吗。
小结
SQL 只是一张语法正确的答卷,它不负责回答你们公司怎么定义这件事。这个定义过去藏在分析师的脑子里、藏在某个 Excel 的隐藏列里、藏在三年前一次会议的口头约定里。人还在的时候,这些藏起来的东西能靠追问捞出来;agent 来了,没人可问,它只能猜。
所以语义层这件事,技术只占一半,另一半是把公司到底怎么算这笔账从人脑里搬到一个机器能读、能审、能版本化的地方。搬的过程很枯燥,要开会、要吵架、要让业务方签字,一点都不酷。
但它是唯一能让 agent 的输出变得可信的办法。
数据是燃料,语义是地图。没有地图,燃料只会让 AI 在企业这座迷宫里跑得更快,错得更远。而地图这个东西,谁画的,路就按谁的走法走。
【关联阅读】