【问数系统】SQL跑对了图表却空了?打通问数系统最后1公里的3个API坑

这是【问数系统拆解】系列第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.dimensionsgraph.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创建成功不代表它能正常渲染。最常见的问题:

  1. SQL有动态函数 :保存时能跑,但定时刷新时报错(比如 NOW() 在保存时是7月,定时刷新到了8月,结果集结构变了)
  2. 字段名大小写不一致 :SQL返回 Site_Code,可视化配置写的是 site_code
  3. 空结果: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怎么写,他只关心问完能不能直接看到图。

预告:下一篇讲问数系统的知识沉淀------怎么从历史问答中提取经验,让系统越用越准。

相关推荐
Capricorn198831 分钟前
解决全网抄 Karpathy 导致的 LLM Wiki 污染?基于知芽 Notebook Skill 的 raw/ 笔记法排障指南
大数据·论文阅读·人工智能·笔记·论文笔记
保定公民35 分钟前
达梦数据库存储过程中的数组类型详解:基础数组与记录数组的差异化应用
数据库·达梦·存储过程·达梦数据库·dm8·dm
梦Arrebol1 小时前
Redis 内容及相关实验
数据库·redis
临沂GEO1 小时前
GEO搜索优化科普|正规地理位置流量运营入门指南
大数据·人工智能·python·流量运营
大模型码小白1 小时前
Spring AI Tool 实现自然语言操作 MySQL 数据库详解
服务器·开发语言·数据库·人工智能·python·mysql·spring
人民新视野2 小时前
党建引领促交流 互学互鉴共提升 —— 赴启信创格(北京)营销科技有限公司交流学习活动总结
大数据·人工智能·科技
DBA大董3 小时前
TDengine3.0 DBA常用的运维命令和SQL2
运维·数据库·时序数据库·dba·tdengine
中科高级技工学校3 小时前
乌鲁木齐中医康复学习经历分享
大数据·学习
咖啡星人k4 小时前
2026 AI Agent 长期记忆:为什么 AI 助手总“聊完就忘“?MonkeyCode 免费上手
人工智能·深度学习·机器学习·语言模型·自然语言处理