这是【问数系统拆解】系列第7篇。上篇讲流式续流------用户断网了对话怎么不丢。这篇讲一个被所有人忽略的环节:SQL跑出来了,结果也返回了,但用户还是看不懂。
一个被忽略的问题
智能问数系统上线一个月,SQL生成准确率做到了92%。权限隔离、流式续传、多Agent协作,该做的都做了。周会上我汇报:「问数系统基本可用了。」
第二天,运营总监在群里发了张截图:系统返回的200行签收率表格,配文「这东西还不如我自己导Excel」。
我第一反应是懵的。SQL是对的,数据是准的,为什么用户不满意?
当时的判断是:前端渲染的问题,加个echarts图表不就完了?
直到我去Metabase后台看了一眼------用户要的不是一个临时图表,是一张能保存、能定时刷新、能分享给老板的看板。前端画个echarts解决不了「保存」和「分享」的问题。
问题不在SQL,在交付------用户要的是一眼能看出高低排名的柱状图,不是200行需要滚动的纯文本。
前6篇全在讲上半段:意图识别、元数据映射、SQL生成、多Agent协作、权限隔离、流式续传。整个链路的核心目标是"生成正确的SQL"。
但"查得对"和"看得懂"之间,差了一个完整的展示层。
问数系统的链路断点
回头看问数系统的完整架构:
用户提问
↓
意图识别(Router)
↓
元数据映射(Skill Worker)
↓
SQL生成(LLM)
↓
数据查询(Metabase API)
↓
结果返回(JSON)
↓
??? ← 断在这里
↓
用户看到图表
SQL查完返回JSON,前端渲染成表格。技术上够用了,但实际场景下远远不够:
| 用户问的 | 纯表格的问题 |
|---|---|
| "对比各网点签收率" | 200行表格看不出高低 |
| "近30天趋势" | 30行数据没有趋势线 |
| "哪些网点低于80%" | 要自己排序筛选 |
| "给老板看个看板" | 完全做不到 |
用户要的不是数据,是洞察。表格给数据,图表给洞察。
补上最后一环:从SQL到图表
在我们的架构里,这一环通过 Metabase API 实现。不是在前端画个echarts图表那么简单------要把问数系统生成的SQL,变成 Metabase 里可保存、可刷新、可分享的持久化图表。
Metabase API 的核心调用链
整个展示层的构建过程,本质上是四步API调用:
1. POST /api/card → 创建SQL卡片(保存SQL + 可视化配置)
2. PUT /api/dashboard → 创建/更新Dashboard
3. POST /api/dashboard/{id}/dashcards → 把卡片放到Dashboard上
4. POST /api/card/{id}/query → 验证:保存后再跑一次
看起来不复杂。但每一步都有坑。
第一个坑:Card创建不是只存SQL
Metabase的Card(图表卡片)不只是存一段SQL。一个完整的Card创建请求长这样:
python
def create_card(client, spec):
"""通过Metabase API创建SQL卡片"""
payload = {
"name": spec["name"],
"display": spec["display"], # bar / line / scalar / table
"dataset_query": {
"type": "native",
"native": {
"query": spec["sql"],
"template-tags": {} # 参数化查询的变量
},
"database": spec["database_id"]
},
"visualization_settings": spec.get("viz_settings", {}),
"collection_id": spec.get("collection_id"),
"description": spec.get("description", "")
}
resp = client.post("/api/card", json=payload)
if resp.status_code != 200:
raise MetabaseError(f"创建卡片失败: {resp.json()}")
return resp.json()["id"]
最容易踩的坑:visualization_settings 不填。
SQL跑通了,Card也创建成功了,但打开Metabase一看,图表显示"请选择X轴和Y轴字段"。
原因是:Card创建时如果不显式指定可视化配置,Metabase不知道你的SQL返回的哪列是维度、哪列是度量。
必须显式指定:
python
# 柱状图:site_code做X轴,sign_rate做Y轴
viz_settings = {
"graph.dimensions": ["site_code"],
"graph.metrics": ["sign_rate"],
"graph.x_axis.title_text": "网点编码",
"graph.y_axis.title_text": "签收率",
"graph.y_axis.axis_enabled": True,
"column_settings": {
'["name","sign_rate"]': {
"number_style": "percent"
}
}
}
这里有个隐蔽的坑:graph.dimensions 和 graph.metrics 里的字段名,必须和SQL返回的列名完全一致,包括大小写。
SQL里写 SELECT site_code, sign_rate FROM ...,配置里写 ["site_code"]------能匹配。
SQL里用别名 SELECT site_code AS Site_Code,配置里还是写 ["site_code"]------匹配不上。
Metabase的列名匹配是大小写敏感的。这个坑我踩过一次,排查了半小时才发现是大小写不一致。
第二个坑:幂等创建------同一个问题问10次不能建10张卡片
问数系统是交互式的,用户可能反复问类似问题。"上个月各网点签收率"这个月问一次,下个月又问一次。如果每次都新建一张Card,一个月后Metabase里堆了几百张重复卡片。
幂等创建的逻辑:
python
def upsert_card(client, spec, collection_id):
"""幂等创建Card:存在则复用/更新,不存在则创建"""
# 1. 拉取collection下所有卡片
existing_cards = client.get(
f"/api/card?collection_id={collection_id}&archived=false"
)
# 2. 按名称匹配
cards_by_name = {c["name"]: c for c in existing_cards}
existing = cards_by_name.get(spec["name"])
if existing:
# 3. 检查SQL是否一致
existing_sql = existing["dataset_query"]["native"]["query"]
if normalize_sql(existing_sql) == normalize_sql(spec["sql"]):
# SQL完全一致,直接复用
return existing["id"], "reuse"
else:
# SQL有变化,更新
payload = {
"dataset_query": {
"type": "native",
"native": {"query": spec["sql"], "template-tags": {}},
"database": spec["database_id"]
},
"visualization_settings": spec.get("viz_settings", {}),
"display": spec["display"]
}
client.put(f"/api/card/{existing['id']}", json=payload)
return existing["id"], "updated"
# 4. 不存在,创建新卡片
return create_card(client, spec), "created"
normalize_sql 做的事情:去掉SQL中的多余空格、换行、注释,统一大小写。同一段SQL只是格式不同,normalize后应该一致。
python
def normalize_sql(sql):
"""SQL标准化:去注释、去多余空白、统一大小写"""
# 去掉 -- 注释
sql = re.sub(r'--[^\n]*\n', '', sql)
# 去掉 /* */ 注释
sql = re.sub(r'/\*.*?\*/', '', sql, flags=re.DOTALL)
# 多个空白合并成一个
sql = re.sub(r'\s+', ' ', sql)
# 去掉首尾空白
return sql.strip().lower()
但这里有个设计决策:SQL不一致时是更新还是新建?
我们的选择是更新。因为同名卡片说明是同一个分析需求,SQL变化可能是优化了查询逻辑或加了过滤条件,不应该新建一张卡片造成混乱。
但如果是SQL语义完全变了(比如从查签收率变成查延误率),虽然名字相同但内容完全不同------这种情况靠名称匹配就不够了。所以我们在卡片命名规范里加了前缀约束:[问数] 网点签收率排名,前缀固定,后缀描述分析内容。名字一样就认为是同一个分析。
第三个坑:Dashboard布局的grid计算
Card创建好了,接下来要放到Dashboard上。Metabase的Dashboard布局是一个grid系统,每个卡片有位置和大小:
python
def add_card_to_dashboard(client, dashboard_id, card_id, position):
"""把卡片添加到Dashboard指定位置"""
payload = {
"cardId": card_id,
"size_x": position.get("size_x", 4), # 宽度(grid单位,1格≈约200px)
"size_y": position.get("size_y", 3), # 高度
"row": position["row"], # 起始行
"col": position["col"] # 起始列
}
resp = client.post(
f"/api/dashboard/{dashboard_id}/dashcards",
json=payload
)
return resp.json()["id"] # dashcard_id
Metabase的Dashboard grid是 12列宽 ,行数无限。每个卡片占据 size_x × size_y 的矩形区域。
最危险的操作是布局覆盖------一个不小心就把别人建好的图表挤掉了。
安全策略是追加模式:不写死行号,每次从当前Dashboard的已有布局动态计算下一个可用位置:
python
def calc_next_position(dashboard_layout):
"""计算追加新卡片的起始位置"""
if not dashboard_layout:
return {"row": 0, "col": 0, "size_x": 4, "size_y": 3}
# 找到所有已有卡片的底部边界
max_bottom = max(
card["row"] + card["size_y"]
for card in dashboard_layout
)
return {
"row": max_bottom, # 从最底部开始追加
"col": 0,
"size_x": 4,
"size_y": 3
}
为什么不能写死行号?因为Dashboard是共享的,别人可能已经加过卡片了。如果你写死 row=4,但别人已经在row=4放了一个卡片,你的新卡片就会和它重叠。
Metabase对重叠的处理不是报错,而是直接覆盖------后放的卡片会挤掉先放的。这在生产环境中是个灾难。
更新前的布局快照
更安全的做法是:更新前先保存快照,更新后验证,失败就回滚:
python
def safe_update_dashboard(client, dashboard_id, new_cards):
"""安全更新Dashboard布局"""
# 1. 保存当前布局快照
current = client.get(f"/api/dashboard/{dashboard_id}")
original_layout = current["dashcards"]
try:
# 2. 追加新卡片(不修改已有卡片)
for card_spec in new_cards:
pos = calc_next_position(original_layout)
add_card_to_dashboard(
client, dashboard_id,
card_spec["card_id"], pos
)
# 3. 重新读取,验证布局
updated = client.get(f"/api/dashboard/{dashboard_id}")
new_layout = updated["dashcards"]
# 4. 检查原有卡片是否还在
original_ids = {c["id"] for c in original_layout}
new_ids = {c["id"] for c in new_layout}
missing = original_ids - new_ids
if missing:
raise MetabaseError(
f"布局验证失败:原有卡片 {missing} 被覆盖"
)
return new_layout
except Exception as e:
# 5. 回滚:恢复原始布局
logger.error(f"Dashboard更新失败,回滚: {e}")
client.put(
f"/api/dashboard/{dashboard_id}",
json={"dashcards": original_layout}
)
raise
这个快照-验证-回滚的机制,在一次实际事故中救过我们:批量创建5张卡片时第3张失败了,前2张已经加到Dashboard上。如果没有回滚机制,Dashboard上就会多出2张不完整的卡片。
第四个坑:保存后再跑一次------验证闭环
Card创建成功不代表它能正常渲染。最常见的问题:
- SQL有动态函数 :保存时能跑,但定时刷新时报错(比如
NOW()在保存时是7月,定时刷新到了8月,结果集结构变了) - 字段名大小写不一致 :SQL返回
Site_Code,可视化配置写的是site_code - 空结果:SQL条件过滤过严,返回空表,图表显示一个孤零零的"无数据"
验证闭环的代码:
python
def verify_card(client, card_id, spec):
"""保存后重新执行,验证卡片能正常渲染"""
# 1. 重新执行保存的卡片
result = client.post(f"/api/card/{card_id}/query")
data = result.json()
# 2. 检查执行状态
if data.get("status") != "completed":
raise MetabaseError(
f"卡片{card_id}执行失败: status={data.get('status')}"
)
# 3. 检查返回列名
cols = [c["name"] for c in data["data"]["cols"]]
viz = spec.get("viz_settings", {})
for dim in viz.get("graph.dimensions", []):
if dim not in cols:
raise MetabaseError(
f"维度字段'{dim}'不在返回列中: {cols}"
)
for metric in viz.get("graph.metrics", []):
if metric not in cols:
raise MetabaseError(
f"度量字段'{metric}'不在返回列中: {cols}"
)
# 4. 检查行数
row_count = data["data"]["rows_to_upload"]
if row_count == 0 and not spec.get("allow_empty", False):
raise MetabaseError(
f"卡片{card_id}返回空结果,预期非空"
)
# 5. 检查数值合理性
if spec.get("value_range"):
min_val, max_val = spec["value_range"]
values = [row[cols.index(spec["metric"])] for row in data["data"]["rows"]]
for v in values:
if v is not None and not (min_val <= v <= max_val):
raise MetabaseError(
f"数值{v}超出合理范围[{min_val}, {max_val}]"
)
return True
这个验证闭环在实际使用中抓住了好几个问题:
- 签收率字段返回的是小数(0.87),但可视化配置的
number_style没设成percent,图表显示0.87而不是87% - SQL里有
ORDER BY,但Metabase的Card保存后会用自己的排序逻辑覆盖SQL的ORDER BY,导致图表顺序不对 - 一张卡片SQL里用了
LIMIT 10,但用户想要看全部网点排名------保存的SQL带了LIMIT,但展示层不知道这个限制
第五个坑:问数系统和展示层的数据契约
问数系统生成SQL,展示层把SQL变成图表。这两层之间需要一个数据契约:问数系统不仅要输出SQL,还要输出"这段SQL该怎么展示"的元信息。
python
# 问数系统的输出不只是SQL,还有展示规格
query_result = {
"sql": """
SELECT site_code, site_name, sign_rate
FROM dw.dw_waybill_detail
WHERE dt = '${dt}'
ORDER BY sign_rate DESC
""",
"display_spec": {
"name": "[问数] 网点签收率排名",
"display": "bar", # 柱状图
"viz_settings": {
"graph.dimensions": ["site_name"],
"graph.metrics": ["sign_rate"],
"graph.y_axis.axis_enabled": True,
"column_settings": {
'["name","sign_rate"]': {
"number_style": "percent"
}
}
},
"value_range": [0, 1], # 签收率合理范围
"allow_empty": False, # 不允许空结果
"description": "各网点签收率降序排名"
}
}
这个数据契约是问数系统和展示层之间的接口。问数系统负责理解用户意图,生成SQL和展示规格;展示层负责把规格变成Metabase卡片。
关键设计:展示规格不是LLM直接生成的。LLM只输出SQL和用户的展示意图("柱状图""趋势线""排名表"),展示规格由一个规则引擎转换:
python
def build_display_spec(user_intent, sql_columns):
"""根据用户意图和SQL返回列,构建展示规格"""
spec = {"name": f"[问数] {user_intent['title']}"}
if user_intent["chart_type"] == "ranking":
spec["display"] = "bar"
spec["viz_settings"] = {
"graph.dimensions": [sql_columns[0]], # 第一列做X轴
"graph.metrics": [sql_columns[-1]], # 最后一列做Y轴
"graph.y_axis.axis_enabled": True
}
elif user_intent["chart_type"] == "trend":
spec["display"] = "line"
spec["viz_settings"] = {
"graph.dimensions": [sql_columns[0]], # 时间列做X轴
"graph.metrics": [sql_columns[1]], # 指标列做Y轴
}
elif user_intent["chart_type"] == "kpi":
spec["display"] = "scalar"
spec["viz_settings"] = {}
return spec
为什么不直接让LLM生成完整的 viz_settings?因为LLM对Metabase的配置字段不熟悉,容易编出不存在的key。规则引擎保证配置字段都是Metabase支持的。
在问数架构里的定位
展示层不是问数系统的核心,是交付层:
| 层级 | 职责 | 对应组件 | 核心产出 |
|---|---|---|---|
| 理解层 | 理解用户意图 | Router + 澄清机制 | 结构化查询意图 |
| 映射层 | 用户语义→数据库字段 | 元数据映射 | 字段映射关系 |
| 生成层 | 生成SQL | LLM + few-shot | SQL语句 |
| 执行层 | 跑SQL返回结果 | Metabase API | JSON数据 |
| 交付层 | 结果→图表→看板 | Metabase Card/Dashboard | 持久化图表 |
前4层解决"查得准"。交付层解决"看得懂"。
没有交付层,问数系统只是一个SQL生成器------用户拿到数据还得自己导Excel画图。有了交付层,问数系统才是一个完整的"问数→看数"闭环。
写在最后
智能问数系统最容易忽略的不是技术难点,是交付环节。
团队花了3个月优化SQL生成准确率,从75%提到92%。但用户的第一反馈是:"查是能查了,但我还得自己导出Excel画图。"
交付层要解决的技术问题不比SQL生成少:Card的幂等创建、Dashboard布局的grid计算、可视化配置的字段映射、保存后的验证闭环、问数系统和展示层的数据契约。每一个都有坑。
但少了这一环,问数系统就只是半成品。用户不会因为SQL生成准确率高5%而兴奋,但会因为"问完直接出图表"而留下来。
回头看,我们花了3个月把SQL准确率从75%提到92%,却忽略了最后这1公里。这1公里没有算法突破,全是工程细节的坑------幂等、布局、类型推断、验证闭环。每一个都不难,但每一个都能让交付失败。
做问数系统最大的教训:用户不关心你用了几个Agent、prompt怎么写,他只关心问完能不能直接看到图。
预告:下一篇讲问数系统的知识沉淀------怎么从历史问答中提取经验,让系统越用越准。