数据与 BI

SQL 能运行,为什么问数结果还是错的?

从累计销量、查询权限和原因解释出发,理解自然语言问数背后的指标口径与后端职责。

1. 我一开始关注的是,模型能不能写 SQL

接触自然语言问数时,最容易理解的流程是:用户说一句话,模型生成 SQL,数据库返回结果,最后把数字整理成回答。

text1 行
用户问题 → 模型生成 SQL → 数据库执行 → 展示结果

站在 Java 后端开发的角度,这很像给查询接口换了一个入口。以前用户填写筛选表单,现在用户直接说“查一下华东地区的销量”。

但读到一份问数系统的方案讨论后,我更在意另一个问题:SQL 没有语法错误,查出的数字就一定回答了用户的问题吗?

比如,用户问:

看看华东最近几个月的销量,为什么在下降?最好画成图。

这里至少有几件事需要先弄清楚:销量是当月销量,还是年累计销量?“最近几个月”指哪段时间?用户有权看整个华东吗?数据真的在下降,还是问题里先带了一个未经确认的判断?

这些事情没有确定,SQL 写得再漂亮,也可能是在准确地计算一个错误的问题。

2. 一个 SUM,可能把累计销量算了好几遍

先看一组方便理解的示例数据,单位都是件,不对应真实业务结果:

月份 当月销量 年累计销量
1 月 100 100
2 月 80 180
3 月 70 250

用户问第一季度销量,如果把三个月的年累计值加起来,会得到:

text1 行
100 + 180 + 250 = 530

但第一季度的实际合计是:

text1 行
100 + 80 + 70 = 250

前一种算法重复计算了前面月份的销量。数据库不会报错,因为 SUM 的语法完全合法。

不过,“累计销量不能求和”也不够准确。假设同一个截止月份,两个互不重叠的分公司年累计销量分别是 250 和 150,且单位、统计口径一致,那么跨分公司合计可以是 400。

不能直接相加的是不同月份的累计快照;跨组织是否能相加,则需要看组织有没有重叠、数据是否同一时点、是否存在重复归属。

这让我开始理解,指标不能只有名称和字段,还需要说明它怎样随时间变化,以及允许沿哪些维度汇总。

还有一个细节:取累计值应该取目标时点的快照,不能随手用 MAX 替代。发生退货或历史调整时,最新累计值未必是最大的那个。如果多个组织的数据截止时间不同,也不能把各自“最新的一条”直接相加后,声称这是同一个月份的完整结果。

3. 让模型选指标,把怎么算交给后端

一种改法,是在提示词里增加规则:“不要把累计销量跨月求和。”

这能提醒模型,但我不希望关键口径只靠它每次记得。

我更容易理解的分工是:模型说清楚想查哪个业务指标,后端根据已维护的定义生成查询。

text9 行
“最近六个完整月份的销量趋势”
                 ↓
指标:当月销量
维度:月份
范围:华东
                 ↓
后端解析指标定义、权限和时间
                 ↓
生成并执行受约束的查询

dbt 的指标文档区分了简单、累计、比率和派生等指标。这里值得借鉴的是把计算含义集中维护,而不是要求每个调用方重新拼一遍公式。

假设确认要查最近六个完整月份,且数据已经完整更新到 2026 年 8 月,那么后端解析后的查询可以长这样:

json15 行
{
  "subject": "sales",
  "metrics": [
    { "id": "sales.monthly_actual", "version": "v1" }
  ],
  "dimensions": ["calendar.month"],
  "filters": {
    "org.region": "east_china"
  },
  "time": {
    "startInclusive": "2026-03-01",
    "endExclusive": "2026-09-01",
    "timezone": "Asia/Shanghai"
  }
}

这是本文的简化数据结构,不是 dbt 的配置格式。

模型不需要在这里填写物理表名,也不需要选择 SUM。指标 ID 和版本从后端提供的目录中解析并校验,不能接受模型随手编出的版本。后端再决定对应的数据来源、聚合方式和允许使用的维度。

dbt 关于集中定义指标的说明也讨论了从指标请求生成查询的方式。这样,物理表发生变化时,可以维护指标到数据的映射;如果业务含义发生了变化,则需要明确更新指标版本。

“最近”这样的相对时间,也要在执行前变成明确区间,并在回答中展示。否则同一句话换一天问,查到的范围可能已经不同,却无法从历史记录里看出来。

4. 图表是怎么展示,权限是能不能查

“帮我画趋势图”同时包含两件事:分析销量趋势,以及把结果画成图。

如果只把它分类成“画图请求”,就容易丢掉时间范围和指标含义。因此,可以把分析内容与输出形式分开:

json4 行
{
  "analysis": ["trend"],
  "output": "chart"
}

用户随后说“换成表格”,改变的是展示方式;只要查询条件和数据版本没变,就不应当悄悄换一套指标口径。

权限则是另一层判断。“导出全部客户明细”可能是某个授权用户的正常需求,也可能超出另一个用户的范围。不能仅凭句子里出现“全部”,就把它认定为一种特殊的非法意图。

我会先识别用户想做的业务操作,再由服务端根据登录身份判断能否执行。模型输出的 role: admin 或 policy: allowed,都不能作为授权依据。

Cube 的访问策略文档分别讨论了行级过滤与成员访问等控制。放到这个例子中,限制用户只能看哪个区域,以及能否读取客户明细,是需要后端实际执行的约束。

如果用户要求整个华东,却只获准访问其中一部分,也不能悄悄查出那一部分,再把标题写成“华东总销量”。这里可以先拒绝原范围,并提示选择可访问范围;需要部分结果时,应明确展示范围已经缩小。

5. Service 里真正需要处理的逻辑

从 Java 的角度看,模型给出的结构化查询仍然是一份外部输入。它需要经过校验,才有资格进入数据查询。

下面用伪代码表示执行顺序,类名和方法都是为了说明职责:

java12 行
public AnalysisResult analyze(SemanticQuery request) {
    User user = session.requireCurrentUser();

    BoundQuery query = registry.resolveAndValidate(request);
    AuthorizedScope scope = policy.requireFullyAllowed(user, query);
    QueryPlan plan = compiler.compile(query, scope);

    budget.requireWithinLimits(plan);
    QueryResult result = runner.executeReadOnly(plan);

    return resultBinder.bind(query, scope, plan, result);
}

resolveAndValidate 需要确认指标版本存在、维度和筛选合法、时间已经明确;累计值不能进行的聚合,也在这一层被拒绝。输入存在歧义时,返回具体问题让用户确认,不继续猜着执行。

编译器只使用受信的指标定义和允许的查询结构,筛选值通过参数绑定传入。不能把自然语言原文、检索片段或模型给出的任意表达式直接拼进 SQL。

查询完成后,指标版本、实际范围和结果状态也要一起返回。规则不通过,可以用明确的错误码表达,例如 AGG_NOT_ALLOWED 或 PERM_DENIED。这些名字应由一份统一接口契约维护,文档、调用方和页面都引用它,而不是各写一套相近名称。

还需要限制扫描和执行时间。只加 LIMIT 10,只能限制返回行数,不能保证数据库为了算出这十行没有扫描大量数据。只读账号也只能减少误写风险,不能代替访问控制和查询成本约束。

这段代码没有替模型变聪明,却让后端能够拒绝不符合约定的请求。规则与配置本身仍然可能有错误,所以“确定性执行”也不是业务正确性的自动保证。

6. 哪些内容放进提示词,哪些必须写成规则

读这类方案时,我很容易产生一个念头:把所有表结构、指标和限制都塞进提示词,模型就能知道得更多。

但有些信息是在帮助理解,有些信息是在规定系统必须怎么做。它们不适合承担同一种职责。

内容 更适合放在哪里
先确认指标和时间,不确定时澄清 提示词中的工作流程
“大客户”和“重点客户”在本业务中的对应关系 可维护的术语资料
指标定义、允许的维度、计算与版本 后端指标目录和校验规则
用户可访问的区域、明细权限 服务端权限系统
错误码和结果字段 统一接口契约

术语资料可以帮助模型把说法映射到业务概念,但不能因为检索到一段“忽略权限”的文字,就改变服务端规则。

工具也不需要随着每个问法增加。一个小型问数入口,可以先提供查指标目录、提交语义查询和读取结果几类能力。同比、趋势与图表分别由明确的分析和展示参数表达,前提是后端已经支持相应计算;不支持时直接说明,而不是临时生成任意查询绕过限制。

即使用 MCP 连接模型和后端,它也只是工具通信的一层。权限校验和指标计算仍然要由工具背后的程序完成。

7. 一个数字,需要带着它的来历返回

用户看到“销量为 800”,还需要知道这是哪个月份、哪个范围、哪个口径算出来的。

可以让查询结果同时携带这些信息:

text6 行
查询编号
指标 ID 与版本
实际时间区间、时区与单位
实际获授权的数据范围
数据截至时间或快照标识
结果是否完整、是否被截断

指标版本固定的是计算定义,数据快照固定的是输入内容。相同定义遇到历史补录,结果仍可能变化,所以不能只记录一个 metricVersion 就承诺随时得到相同数字。

如果需要缓存查询结果,缓存键也应反映指标版本、查询条件、有效权限范围和数据新鲜度约定。不同用户使用相同问法,不代表他们可以共享同一份数据结果。

结果为空时,要区分没有记录、尚未更新和确实为零。同比的上一期为零时,也不能直接给出一个普通增长率。返回状态与适用条件,往往比给模型多写一句“请谨慎回答”更能约束后续表达。

画图时同样要检查返回范围。只拿到前十行,就不能把图叫作“全部区域分布”;缺少月份,也不能默认补成零之后声称销量出现了断崖。

8. 能算出谁贡献了下降,不等于知道为什么下降

回到最初的问题:“销量为什么在下降?”

我会先确认下降这件事,再决定要不要继续解释原因。如果数据没有显示下降,就应纠正问题中的前提。

下面换一组说明用数据,两个分部互不重叠,且覆盖本次查询范围:

分部 上期销量 本期销量 变化
A 600 450 -150
B 400 350 -50
合计 1000 800 -200

可以直接计算:本期比上期减少 200,降幅为 20%;A 分部的减少量占总减少量的 75%。

但这还不能推出“A 分部因为竞争对手降价而下滑”。前面是数值分解,后面是需要额外依据的原因判断。

如果有一份业务记录提到 A 分部缺货,还要核对它对应的时间、商品和组织范围。表述可以是“同期记录提到缺货,可能与下降有关”,而不能把一段文本直接当成已经证实的因果关系。

如果有的分部上涨、有的下跌,贡献比例还可能超过 100% 或出现负数;若总变化为零,按总变化作分母就没有意义。不能为了让图好看,强行把所有贡献都整理成正数且加起来等于 100%。

所以,我更愿意让数字和表格从查询结果直接生成,再让模型围绕这些结果解释。每一个原因判断,都应该能找到相应依据;没有依据,就停在已经确定的变化事实。

9. 这让我重新理解了问数项目里的后端工作

最初看问数功能,我关注的是模型能不能把一句话翻译成 SQL。沿着这些例子看下来,后端真正需要守住的东西具体得多。

一个累计指标,要说明哪些维度能汇总;一次查询,要确定当前用户可以看到什么;一个答案,要能追到口径、范围和数据来源。自然语言只是入口,业务规则依然需要落实到程序里。

如果继续做这个方向,我会先从销量一个主题开始,只开放少量已经定义清楚的指标与维度,把单值查询和月度趋势串起来。再逐步加入对比、结果展示和来源追踪,原因解释放在确实有对应资料以后。

这样每增加一种能力,都能说清楚输入是什么、后端检查什么,以及哪些情况应当停止或澄清。

这也让我对 Java 后端与 AI 的结合有了更具体的认识:模型负责处理语言中的灵活性,后端负责把已经确认的业务含义变成有约束、可检查的操作。能把两者之间的分工讲清楚,比只展示一段能运行的 SQL,更接近我想继续学习的工程问题。

相关阅读

← 返回文章