GMT+8 SG --:-- 0000 X 0000 Y /
← 全部文章← All writing

智能体

一致性维度可以是一列,不必是一层

让大模型直接写 SQL,问题不在模型弱。问题在于它的解空间里有一堆它不该碰的东西:物理表名、列名、JOIN 键。只要这些留在解空间里,它就有概率编出一个不存在的列,而且编得很像真的。

消除这件事有两条路。行业共识是第一条:建语义层——把指标口径、维度层级、JOIN 图、同义词写成结构化定义,让模型只面对业务语义,再由一个确定性编译器把它翻译成 SQL。dbt MetricFlow、Cube、Snowflake Semantic Views 都是这条路的产品化。

我们走的是第二条,而且走到今天没有回头。

把可查询的东西穷举成参数

我们的数据 Agent 不产出 SQL,它产出一个结构化记录:五十多个带类型的具名参数。度量选哪个、按什么维度分组、加哪些筛选、排除什么、时间窗多大——每一项都是一个字段,Pydantic 校验通过才往下走。下游是一个零大模型的函数,把这份记录编译成 ClickHouse 查询。

模型从头到尾没有见过表名。

用 OLAP 的语汇说,这个参数表其实就是一个 cube 的查询面:两个计数度量、十七个分组维度、若干切片条件,外加一个明细钻取模式。**模型在写的是一句切片说明,只不过语法是 JSON。**所以它不是 Text-to-SQL,是 Text-to-Cube-Query。

这个方案在单张事实表上几乎是白送的:不用维护一份本体,类型检查由框架提供,幻觉列名在结构上不可能发生。

它最该被质疑的地方

代价也很清楚:一个函数就是一个 cube。

我们现在有三个——历史航班、空腿、医疗构型飞机。三个各自独立的查询面,各自定义自己的度量。于是一个很自然的怀疑浮上来:

同一个业务问题,在两个 cube 里会不会给出两个答案?

这不是理论担心。这三个 cube 说的是同一批飞机、同一批机场、同一批运营商,而「一架飞机」怎么算,是各自代码里的一行。语义层存在的主要理由之一,就是防止这种漂移——它提供一致性维度(conformed dimension),保证同一个概念在所有地方是同一个定义。

所以我去对账了:取一批同时落在两个 cube 里的飞机,分别问「这些飞机有多少架」。

两个数字一致。

为什么没漂

一致不是因为我们更自律。是因为规范化发生在写入侧,不在读取侧

数据库里同时存着两列:原始注册号,和一列已经规范化过的注册号——去掉连字符、空格、点、下划线,统一小写。所有 cube 都键在后面那一列上。读取时哪怕再规范化一次,也是幂等操作,什么都不改变。

也就是说,同一性不是在查询时被计算出来的,是在建表时被物化下来的。

这一条推广开来就是这篇文章的全部意思:

一致性维度可以是一列,不必是一层。

机型、机场、运营商、品牌、城市、国家,我们都有对应的规范 ID,都是一样的处理。这些 ID 不会漂移,因为不存在「两处实现」这回事——没有实现,只有一列数据。它也不需要执行引擎,因为 JOIN 直接键在它上面。

用一列规范键换一份语义层定义,在这个场景里是笔划算的交易。

但它有明确的边界

我不打算把这写成普适结论,因为它有一个很硬的前提:

这个概念必须存在唯一的规范键。

飞机有——注册号。机场有——ICAO 代码。运营商、机型、城市也有,都是一个 ID。这些概念的共同点是:它们指向世界上一个客观存在、边界清楚的东西。规范键能被物化,是因为它本来就存在。

但「活跃企业」没有。「客户价值」没有。「有效任务」没有。这些是复合概念——它们由一串条件定义,而那串条件是人做的决定,随业务阶段变。你无法把它物化成一列,因为它今天等于这个式子,下个季度可能等于另一个。

那才是语义层真正不可替代的地方:它管的不是「谁是谁」,是「什么算数」。前者可以固化在数据里,后者只能沉淀成可版本化的定义。

所以判据不是「有几张表」,是:**你要保证一致的那个概念,有没有可物化的规范键。**有,用列;没有,才需要层。

我们最脆的地方

按同一个标准审自己:这套方案里有一部分口径没有被物化,也没有被定义,而是活在提示词字符串里。

机型类别的代码映射(哪个数字代表轻型公务机)、大区的 ID 映射,现在都写在给模型的参数说明里。它们是标准的语义层内容——同义词与枚举——却住在最不该住的地方:一段既不能被测试、也不能被 diff、改起来还要重新验证模型行为的文本里。

这是我确定要改的一处。规范键那部分我不打算动,但枚举必须从提示词里搬出去。


顺带说一句:不让模型写 SQL,和后面要讲的「不让视频模型写可读文字」,是同一个动作——**把必须精确对的那部分,从生成模型的解空间里拿走。**它在不同介质上会长成完全不同的样子,但判断是同一个。