【问数系统】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怎么写,他只关心问完能不能直接看到图。

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

相关推荐
名侦探72240 分钟前
构建 Agent 的五大难点与解决方案
java·大数据·人工智能
海外数字观察家1 小时前
品未云:泰国华商批发零售一体化 ERP,破解本土软件适配难题|CSDN 技术分享
网络·数据库·人工智能
测功机之家1 小时前
精密传动,可靠测试:杭州索川科技减速机测试台解决方案深度解析
大数据·网络·科技
科力锐品牌君1 小时前
医院数据存储“乱象”:如何兼顾兼容、高效与合规?
大数据·安全·备份·存储·存储方案
联盟分享专家1 小时前
品牌如何零代码搭建专属联盟营销项目,实现被动增长?
大数据·人工智能
陳陈陳2 小时前
LLM的"消化系统":一文讲透RAG文档加载与智能切割
数据库·langchain·llm
IvorySQL2 小时前
云环境下PostgreSQL的Cgroup内存管理实践
java·数据库·postgresql
Chockmans2 小时前
春秋云境CVE-2023-51385(保姆级教学)
大数据·web安全·elasticsearch·搜索引擎·网络安全·春秋云境·cve-2023-51385
霸道流氓气质2 小时前
CodeBuddy中配置Redis MCP 连接与排错实战
数据库·redis·缓存