1. 我一开始关注的是,模型能不能写 SQL
接触自然语言问数时,最容易理解的流程是:用户说一句话,模型生成 SQL,数据库返回结果,最后把数字整理成回答。
用户问题 → 模型生成 SQL → 数据库执行 → 展示结果站在 Java 后端开发的角度,这很像给查询接口换了一个入口。以前用户填写筛选表单,现在用户直接说“查一下华东地区的销量”。
但读到一份问数系统的方案讨论后,我更在意另一个问题:SQL 没有语法错误,查出的数字就一定回答了用户的问题吗?
比如,用户问:
看看华东最近几个月的销量,为什么在下降?最好画成图。
这里至少有几件事需要先弄清楚:销量是当月销量,还是年累计销量?“最近几个月”指哪段时间?用户有权看整个华东吗?数据真的在下降,还是问题里先带了一个未经确认的判断?
这些事情没有确定,SQL 写得再漂亮,也可能是在准确地计算一个错误的问题。
2. 一个 SUM,可能把累计销量算了好几遍
先看一组方便理解的示例数据,单位都是件,不对应真实业务结果:
| 月份 | 当月销量 | 年累计销量 |
|---|---|---|
| 1 月 | 100 | 100 |
| 2 月 | 80 | 180 |
| 3 月 | 70 | 250 |
用户问第一季度销量,如果把三个月的年累计值加起来,会得到:
100 + 180 + 250 = 530但第一季度的实际合计是:
100 + 80 + 70 = 250前一种算法重复计算了前面月份的销量。数据库不会报错,因为 SUM 的语法完全合法。
不过,“累计销量不能求和”也不够准确。假设同一个截止月份,两个互不重叠的分公司年累计销量分别是 250 和 150,且单位、统计口径一致,那么跨分公司合计可以是 400。
不能直接相加的是不同月份的累计快照;跨组织是否能相加,则需要看组织有没有重叠、数据是否同一时点、是否存在重复归属。
这让我开始理解,指标不能只有名称和字段,还需要说明它怎样随时间变化,以及允许沿哪些维度汇总。
还有一个细节:取累计值应该取目标时点的快照,不能随手用 MAX 替代。发生退货或历史调整时,最新累计值未必是最大的那个。如果多个组织的数据截止时间不同,也不能把各自“最新的一条”直接相加后,声称这是同一个月份的完整结果。
3. 让模型选指标,把怎么算交给后端
一种改法,是在提示词里增加规则:“不要把累计销量跨月求和。”
这能提醒模型,但我不希望关键口径只靠它每次记得。
我更容易理解的分工是:模型说清楚想查哪个业务指标,后端根据已维护的定义生成查询。
“最近六个完整月份的销量趋势”
↓
指标:当月销量
维度:月份
范围:华东
↓
后端解析指标定义、权限和时间
↓
生成并执行受约束的查询dbt 的指标文档区分了简单、累计、比率和派生等指标。这里值得借鉴的是把计算含义集中维护,而不是要求每个调用方重新拼一遍公式。
假设确认要查最近六个完整月份,且数据已经完整更新到 2026 年 8 月,那么后端解析后的查询可以长这样:
{
"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. 图表是怎么展示,权限是能不能查
“帮我画趋势图”同时包含两件事:分析销量趋势,以及把结果画成图。
如果只把它分类成“画图请求”,就容易丢掉时间范围和指标含义。因此,可以把分析内容与输出形式分开:
{
"analysis": ["trend"],
"output": "chart"
}用户随后说“换成表格”,改变的是展示方式;只要查询条件和数据版本没变,就不应当悄悄换一套指标口径。
权限则是另一层判断。“导出全部客户明细”可能是某个授权用户的正常需求,也可能超出另一个用户的范围。不能仅凭句子里出现“全部”,就把它认定为一种特殊的非法意图。
我会先识别用户想做的业务操作,再由服务端根据登录身份判断能否执行。模型输出的 role: admin 或 policy: allowed,都不能作为授权依据。
Cube 的访问策略文档分别讨论了行级过滤与成员访问等控制。放到这个例子中,限制用户只能看哪个区域,以及能否读取客户明细,是需要后端实际执行的约束。
如果用户要求整个华东,却只获准访问其中一部分,也不能悄悄查出那一部分,再把标题写成“华东总销量”。这里可以先拒绝原范围,并提示选择可访问范围;需要部分结果时,应明确展示范围已经缩小。
5. Service 里真正需要处理的逻辑
从 Java 的角度看,模型给出的结构化查询仍然是一份外部输入。它需要经过校验,才有资格进入数据查询。
下面用伪代码表示执行顺序,类名和方法都是为了说明职责:
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”,还需要知道这是哪个月份、哪个范围、哪个口径算出来的。
可以让查询结果同时携带这些信息:
查询编号
指标 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,更接近我想继续学习的工程问题。
相关阅读
- 实时 BI 平台怎样交付可信指标:明细层、聚合层与物化视图
- 一笔订单怎样进入实时 BI:Kafka、Flink 与 StarRocks
- 企业 RAG 数据怎样进入 Milvus:文档版本、切分、索引与权限
- 仰望星空:从 Databricks、Snowflake 与 Palantir 看数据平台走向 Agent