语义编译边界
语义优先的系统里,每条问题都要先经过语义模型编译。核心设计问题是: 模型没覆盖到的地方怎么办——全有全无地拒绝,等于把「开箱即用」的旧毛病 换个地方长出来;无声放行,等于放弃语义边界。Trove 的答案是 分级逃生梯:能编译的部分权威化、缺的部分交给生成通道带着契约补齐、 结构性错误响亮拒绝并反问着把模型长大。这页讲四级梯子的分界线、 两把校验尺,以及「AI 起草、确定性验证、按档入库」的扩展闭环。
边界不是一条线,是一架梯子
语义模型(semantics.yml)是生成的主视角与可答边界。
但「边界」有两种画法:一条线(在模型里就编译、不在就拒绝)和一架梯子
(按缺口性质分级:能保真的保真,能补齐的补齐,真错的才拒绝)。
线上方案的失败模式被 Trove 的早期版本实测过:一条没有「月均余额」声明的数据源,
对「上个月月均余额多少」的回答不该是拒绝——那是词表缺口,
不是能力缺口。
于是编译器的返回值本身就是一架梯子 compiler.py:1340 CompileResult / compiler.py:1374 PartialCompile / compiler.py:1361 CompileMiss, 三种类型、四条出口:
flowchart TB
Q["query_sketch: 按语义模型编译计划"]:::gen --> C{"编译结果"}:::gate
C -->|"覆盖内"| OK["CompileResult
权威 SQL 骨架注入 plan"]:::gen
C -->|"软 MISS"| P["PartialCompile
可解析部分权威化
未解析组件记 compile_misses"]:::gen
C -->|"硬 MISS"| M["CompileMiss
触发 refuse 出口"]:::gate
OK --> EXEC["execute_sql
compiled_sql_matches · 照抄校验"]:::exec
P --> GEN["gen_sql 按 plan 补齐未解析组件"]:::gen
GEN --> EXEC2["execute_sql
skeleton_preserved · 骨架保真校验"]:::exec
M --> REF["refuse: 拒绝 + 反问扩展模型"]:::gate
M -->|"模型整体缺失"| INIT["引导先建模 /kb init"]:::gate
classDef gen fill:#6366f114,stroke:#6366f199,stroke-width:1.5px;
classDef exec fill:#14b8a614,stroke:#14b8a699,stroke-width:1.5px;
classDef gate fill:#f59e0b14,stroke:#f59e0b99,stroke-width:1.5px;
| 出口 | 触发条件 | 交付语义 |
|---|---|---|
| 权威编译 | 计划落在模型覆盖内 | 编译器拼出完整 SQL 骨架注入 plan,生成通道以它为规格 |
| PartialCompile 骨架 | 软 MISS:词表/值/口径未声明(未声明 metric/字段/枚举值/时间分桶) | 可解析部分权威化(join/过滤/分组),未解析组件交 gen_sql 按 plan 文本补齐——回答照常交付 |
| 拒绝 + 反问 | 硬 MISS:结构性错误——fan-out、join 二义、未覆盖表、坏派生定义(环/超深/未解析) | 不生成查询;同时 LLM 起草一份最小扩展声明,走确认流程后重答 |
| 整体拒绝 | 数据源根本没有语义模型 | 明确提示先 /kb init——没有模型就没有边界可谈 |
硬与软:闭集分类,未知按硬处理
分级不是模糊判断,是闭集分类:
MISS_REASONS 列举全部 34 种编译缺口
compiler.py:1396 MISS_REASONS,
再劈成两半——HARD_MISS_REASONS 13 种
compiler.py:1437 HARD_MISS_REASONS、
SOFT_MISS_REASONS
compiler.py:1456 SOFT_MISS_REASONS。
分界判据是「跳过这个组件继续编译,产出还安全吗」:
- 硬——放行会产出行倍增/笛卡尔积/引用未覆盖表/坏定义的 SQL。 这是一类 SQL 级的错误:查询看起来能跑,结果是错的。必须拒绝并把缺口暴露给用户。
- 软——组件不在声明词表里,但跳过它继续编译是安全的: 骨架的 join/过滤/分组仍然权威,未解析部分由生成通道补,再由骨架保真校验、 rules 链、版本回归三层兜底。
分类表的维护纪律写在源码注释里:新增 MISS 分支时必须归入其中一边——
该集合是路由判定的唯一权威。而 is_hard_miss
compiler.py:1483 is_hard_miss
对未知 reason 默认返回硬——宁可拒绝,不冒险产错 SQL;
漏分类会被 eval 归因暴露,而放行过的错误 SQL 不会自己浮出来。
骨架保真:partial 与 compiled 的两把尺
骨架权威化的关键问题是下游是否真的照骨架走。execute_sql 对两条通道用两把不同的尺 (execute_sql.py:192 skeleton_preserved / execute_sql.py:194 compiled_sql_matches):
compiled_sql_matches|照抄校验 | skeleton_preserved|骨架保真 | |
|---|---|---|
| 用在 | 完整编译(CompileResult) |
软 MISS 骨架(PartialCompile) |
| 比什么 | 编译期抽好的结果形状签名:聚合/过滤值/投影宽度/表/投影与过滤的列身份 | join 边、WHERE 条件、分组宽度——投影列不参与比较(LLM 需要补未解析组件) |
| 容忍什么 | 别名、格式、大小写、COUNT(*) vs COUNT(col)、裸列 vs 限定列(语义等价) |
join 顺序、分桶表达式方言差异 |
| 打回的边界 | 生成 SQL 解析不了也打回——它连是不是查询都定不下来,不能算保真(此前这种情况静默放行,是 A1 收口的 fail-open) | 骨架的退化形态(计划本身只可部分解析)会放松 WHERE 比对——把「计划文本恰好能解析的部分」冻成权威,会把生成侧的正确改写打回 |
两把尺共用一个工程判断:校验读契约,不反推 SQL。
编译器在编译期就把结果形状抽成纯 JSON 契约(join 边/过滤/分组宽度 +
缺口列表)随 state 走(state.py:188 contract),
校验节点直接读字段——不再拿 compiled_sql 用 AST 反推回结构。
骨架的覆盖情况也在 state 上留下痕迹:compile_partial
state.py:180 compile_partial
与 compile_misses
state.py:181 compile_misses
(未覆盖组件 [{reason, component}])——它们既是执行侧的判据,也是学习侧的输入(见下)。
拒绝:不是死胡同,是模型的生长期
硬 MISS 出口在 refuse 节点
(refuse.py:69 _no_model_message):
每条拒绝都随一份一键下一步(next_actions,形状统一为
[{id, kind, label, href, admin_only, payload?}],后端给全双语 label、
前端只渲染不分类)。数据源整体没模型时,这一出口直接给 /kb init 引导,
管理端待办里对应 datasource_uninitialized 一类
(overview.py:102 todo kind)。
拒绝的同一节点还做第二件事:起草扩展。LLM 根据缺口草拟一份 最小补充声明(字段或指标) semantic_draft.py:229 draft_refusal_extension, 但草稿能不能落、能不能免确认,全部由确定性门说了算:
flowchart LR
MISS["硬 MISS 原因"]:::gate --> DRAFT["LLM 草拟最小扩展声明
(字段 / 指标,不出表达式新语义)"]:::gen
DRAFT --> CONF{"确定性冲突检测
同名 / 表达式不可解析 / 数据集未声明"}:::gate
CONF -->|"agg_duplicate 已有同口径"| TIP["不落库
引导换问法加过滤条件"]:::entry
CONF -->|"可人工修正的冲突"| PEND["落 pending 收件箱
管理员修正后新建"]:::gate
CONF -->|"无冲突"| GATE{"A / B 档确定性验证门"}:::gate
GATE -->|"A 档: 字段在物理表中确认存在"| AUTO["自动入库(跳过 pending)
同轮路由回重答"]:::gen
GATE -->|"B 档: 机械聚合 + 完整编译 + 真实执行过 shape"| AUTO
GATE -->|"任一不过"| PEND
classDef entry fill:#71717a14,stroke:#71717a59,stroke-width:1px;
classDef gen fill:#6366f114,stroke:#6366f199,stroke-width:1.5px;
classDef gate fill:#f59e0b14,stroke:#f59e0b99,stroke-width:1.5px;
三道门的细节,每道都在拦一类具体错误:
| 门 | 位置 | 拦什么 |
|---|---|---|
| 确定性冲突检测 | refuse.py:595 _detect_conflict,code 闭集:同名/表达式不可解析/数据集未声明/… | 草稿与现有模型冲突分两类:可人工修正的(重名、表达式错)照落 pending 待审;agg_duplicate(已有同聚合签名指标)既不落库也不给建档动作——落一条必然重复的指标是诱导管理员确认错误,补救是换问法加过滤条件 |
| A 档:物理列字段 | refuse.py:678 _physical_column_field | 字段名在物理表里确认存在(读连接器 catalog,零 LLM)——「这个列真的存在」是结构事实,不需要人再背书一次 |
| B 档:机械聚合指标 | refuse.py:729 _mechanical_metric_ok | 四步全过才免人工:①表达式是单个值无关机械聚合(count/sum/avg,白名单 _MECHANICAL_AGGS,带 CASE/WHERE/FILTER/子查询 = 值被写死,不算);②单数据集、表可达、引用列存在(超大表跳过真实执行退回人工);③草稿指标并入模型副本走权威编译器——完整编译(软 MISS 骨架不算)+ guardrail;④真实执行权威 SQL、过 rules 的 shape 链(单行单列标量)。任一不过 → pending 走人工 |
免确认入库后,auto_confirmed 标记让图同轮路由回重答——
用户感知是「系统补充了声明,正在重新回答」而不是一次拒绝
(refuse.py:1220 A/B 判定)。
防打转的护栏是 AUTO_CONFIRM_MAX_ROUNDS = 2
refuse.py:719 单问上限:
同一问题连续补两个字段还编译不过,就停止自动、回退人工——自动确认是省人工的便道,
不是绕过评审的暗门。
软 MISS 的生长方向:候选收件箱
软 MISS 的语义是「回答照常交付,但计划里有用例模型没声明的组件」—— 这正是语义层该生长的地方。候选收件箱 candidates.py 模块头 把这些缺口翻译成 pending 语义草稿,落进管理端待办。三条硬约束值得逐条看:
- 零 LLM——捕获跑在每次查询的收尾路径上
(session.py:2030 _capture_candidates),
所有翻译只是对
compile_misses的确定性改写。 - 只落 pending——只写
semantic_drafts.yml,不碰semantics.yml:自动内容绝不绕过管理员确认(与记忆子系统的 auto-write 同一条纪律)。 - 只针对模型里没有的声明——已声明的名字一律跳过:候选若诱导管理员 确认出一条重复/错误的声明,比没有候选更糟。
证据门槛同样是分类型的,各有各的「硬」:字段类只收解析失败的理由
(未声明字段/时间字段未声明/分析分区列未解析——「模型里缺这个声明」就是缺口本身);
指标类只收 no_metric_match,计划给的聚合表达式原文即权威定义,可原样落为
expression;值类别(enum_value_unresolved)仅当字段带实测取值
且提问字面量命中其一才落别名——命中即「库里确实这么存」;
不命中/未探测一律不猜(机器无法从翻译缺口推出目标码,自映射会把错字面量
冻结进骨架)。单轮捕获上限 MAX_CANDIDATES_PER_RUN = 3
candidates.py:50 单轮上限——
一个多缺口计划不许把收件箱刷屏。
向用户披露:答案来源四档
编译梯子的每一级对应一种答案来源,披露装置把它们显示成四档
answer_source.py:27 AnswerSource:
certified(复用已认证模板)/ reused(复用 KB 资产,未认证)/
compiled(编译器产物)/ generated(LLM 生成,含编译失败回退与软 MISS 骨架)。
拆成四档而不是三档的原因写在模块头:「机制」与「有没有人背书」是两个维度——
压成一档会把「根本没进过模型的 SQL」说成「LLM 生成」。
判定只有一份
answer_source.py:94 resolve
(答案渲染、SSE、评测归因、台账落账共用一个入口),方向刻意不对称:
判定不出的一律 GENERATED——把编译器产物标低,用户少得一点信息;
把模型编的标高,用户得到一个不该有的信任。同类纪律还有快径的治理门:
fast_match 的第五道门只放行 status == "certified" 的模板
(fast_match.py:19 治理门)。
怎么用
- 用户侧——什么都不用做:覆盖内直接答;软 MISS 照常答(答案来源标
generated);硬 MISS 得到一条带一键下一步的拒绝;A/B 档自动确认时看到 「已自动补充声明,正在重新回答」。见提问与读懂答案。 - 管理员侧——待办收件箱(
/v1/admin/todos)集中处理 semantic_draft 条目:确认(立即生效并重答)或拒绝。快速路径是「以草稿为蓝本新建」。 - 建模侧——数据源第一次接入必须
/kb init;之后模型随使用生长: 硬 MISS 带出大缺口,软 MISS 的候选收件箱带出小缺口。
编译决策的观测字段(compiled / compile_partial /
compile_misses / contract)恒写在 state 上
(state.py:173 compiled_sql)——
eval 归因按 path: compiled|llm 分账,学习回路(
闭环与契约)也读同一份缺口记录。
取舍
| 选择 | 放弃了什么 | 为什么值得 |
|---|---|---|
| 分级逃生梯而非一条边界线 | 「不在模型里就拒绝」的简单叙事 | 词表缺口占真实提问的多数;全有全无会让语义优先退化成「建模不完备就不能用」。梯子把「能不能安全跳过」当成真正的判据 |
| 软 MISS 骨架交由生成补齐 | 未解析组件的确定性(那部分由 LLM 补) | 保真校验(骨架保真 + rules 链 + 版本回归)三层兜底把风险压在「结构被改」上;跳过组件本身是安全的 |
| 未知 MISS 原因按硬处理 | 新分支漏分类时的「宽容」 | 方向不对称:错拒一次可以靠 eval 归因发现并修;放行一条会产错结果的 SQL,没人会回头查 |
| 编译期抽契约(纯 JSON)而不是校验时反推 | 一份契约字段的维护成本 | 反推要把 SQL 重新解析回结构,各节点各推一次迟早推歪;契约随 state 走,校验方与编译方看的是同一份事实 |
| A/B 免确认是白名单式的(物理列 / 机械聚合) | 「AI 草稿大多是对的,都自动确认」的吞吐 | 免人工的资格按可确定性验证发:列存在与否、单值无关聚合能否跑出标量,都是机器能证的事实;派生/比率/复杂口径仍走人工——那才是语义风险所在 |
| agg_duplicate 连 pending 都不落 | 「先收进收件箱再说」的省事 | 落一条必然重复的指标是诱导管理员确认错误;正确补救是换问法(加过滤条件),不是补声明——收件箱的工作量也是资产,不能被噪声淹没 |
| 值类别别名只在「实测命中」时落 | 对未探测值的推断 | 自映射会把错字面量冻结进骨架,聚合题 0 行过滤没有守卫兜底——静默算错比拒绝更糟 |
| 来源四档 + 判定不出倒向 GENERATED | 更漂亮的「编译占比」数字 | 披露装置的资产是它说的话可信;虚报一次,四档一起作废 |
优势在哪
- 边界既守得住又长得出。硬 MISS 拒错 SQL、软 MISS 不停答、候选收件箱把每次缺口变成一次生长机会——语义优先不再是「建模不全就不可用」。
- 免确认的资格由机器证明,不由模型自评。A/B 档四步验证全过才跳过人工,其余一律 pending——「快」与「稳」的分界是可复算的事实。
- 校验读契约,不猜结构。骨架保真与照抄校验共用编译期抽出的形状契约;生成通道的偏离在执行之前被打回。
- 放行与拒绝都有记账。
compile_misses进 state、进 eval 归因、进收件箱——每一级梯子的行为都能回答「为什么走了这条路」。 - 来源披露与编译梯子同构。答案来源四档把梯子的每一级翻译成用户语言,且保守方向一致——宁可标低,不标高。