基于 LLM 的智能数据问答系统技术方案
让业务人员用"人话"查数据,而不是写 SQL
一、问题引入:数据分析师的"翻译困境"
最近跟一位做电商数据分析的朋友聊天,他跟我吐槽:
“业务部门每天问我几百个问题,‘上周哪个品类退货率最高’、‘北京区 3 月份销售额环比多少’、‘复购率超过 30% 的用户画像是什么’… 我 80% 的时间都在写 SQL,真正做深度分析的时间反而没多少。”
这场景是不是很熟悉?
数据分析师成了"SQL 翻译机"——业务人员有需求,但不懂 SQL;分析师懂 SQL,但重复劳动太多。本质上,这是"业务语言"和"数据语言"之间的鸿沟。
有没有办法让业务人员直接用"人话"问数据,系统自己把 SQL 写出来?
这就是我们今天要聊的——基于 LLM 的智能数据问答系统。
二、方案分析:为什么是 LLM?
2.1 传统方案的局限
在 LLM 火起来之前,业界也尝试过一些方案:
| 方案 | 原理 | 问题 |
|---|---|---|
| 固定报表 | 预定义查询模板 | 灵活性差,无法应对临时问题 |
| BI 拖拽 | 可视化配置筛选条件 | 学习成本高,复杂分析搞不定 |
| 规则引擎 | 关键词匹配 → 预置 SQL | 维护成本高,泛化能力弱 |
这些方案的共同问题是:不够灵活。业务问题千变万化,靠穷举或规则很难覆盖。
2.2 LLM 带来的可能性
LLM(大语言模型)最擅长的就是理解和生成自然语言。如果我们能让 LLM:
- 听懂业务人员的"人话"问题
- 结合数据表的元信息(字段含义、表关系)
- 生成准确的 SQL
- 把查询结果再用"人话"解释回去
那不就打通了吗?
说白了,LLM 在这里充当了一个智能翻译官:
业务问题(自然语言) → LLM → SQL → 数据库 → 结果 → LLM → 业务答案(自然语言)当然,这个方案也不是万能的,后面会聊到它的边界和坑。
三、系统架构设计
3.1 整体架构图
┌─────────────────────────────────────────────────────────────┐ │ 用户交互层 │ │ (Web 界面 / 钉钉/企微机器人 / API) │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 智能问答引擎层 │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │ │ 问题理解 │ │ SQL 生成 │ │ 结果解释与总结 │ │ │ │ (NLU) │→ │ (NL2SQL) │→ │ (Result2NL) │ │ │ └─────────────┘ └─────────────┘ └─────────────────────┘ │ │ │ │ │ ┌─────────┴─────────┐ │ │ ▼ ▼ │ │ ┌──────────┐ ┌──────────┐ │ │ │ 意图识别 │ │ 字段映射 │ │ │ │ 实体抽取 │ │ 表选择 │ │ │ └──────────┘ └──────────┘ │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 数据服务层 │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────┐ │ │ │ 元数据管理 │ │ 查询执行 │ │ 结果缓存与优化 │ │ │ │ (Schema) │ │ (Executor) │ │ (Cache) │ │ │ └─────────────┘ └─────────────┘ └─────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────┐ │ 数据存储层 │ │ (MySQL / ClickHouse / StarRocks...) │ └─────────────────────────────────────────────────────────────┘3.2 核心模块说明
模块 1:元数据管理(Schema Manager)
这是整个系统的地基。LLM 再聪明,也不知道你表里每个字段是什么意思。我们需要告诉它:
- 有哪些表?每张表是干什么的?
- 有哪些字段?字段类型、含义、取值范围?
- 表与表之间怎么关联?
# 示例:元数据配置(简化版)tables:-name:ordersdescription:"订单主表,记录用户下单信息"columns:-name:order_idtype:BIGINTdescription:"订单唯一标识"-name:user_idtype:BIGINTdescription:"用户 ID"-name:order_amounttype:DECIMAL(18,2)description:"订单金额(元)"-name:order_statustype:TINYINTdescription:"订单状态:1-待支付 2-已支付 3-已发货 4-已完成 5-已退款"-name:create_timetype:DATETIMEdescription:"下单时间"-name:usersdescription:"用户表"columns:-name:user_idtype:BIGINTdescription:"用户 ID"-name:citytype:VARCHAR(50)description:"所在城市"-name:register_timetype:DATETIMEdescription:"注册时间"relations:-from:orders.user_idto:users.user_idtype:LEFT JOINdescription:"订单关联用户信息"关键点:字段描述要写得像"人话",不要只写字段名。比如order_status的 description 里把枚举值都列出来,LLM 生成 SQL 时才知道该用哪些值。
模块 2:问题理解(NLU)
用户的问题五花八门,系统得先"听懂":
用户问:"上周北京区退货率最高的品类是什么?" 系统理解: ├── 意图:查询(QUERY) ├── 时间范围:上周(2026-03-18 至 2026-03-24) ├── 筛选条件:城市 = "北京" ├── 指标:退货率 = 退款订单数 / 总订单数 ├── 维度:品类 └── 排序:退货率降序,取 TOP 1这里可以用 LLM 做意图识别和实体抽取,也可以用小模型 + 规则做预处理,降低成本。
模块 3:SQL 生成(NL2SQL)
这是最核心的环节。把理解后的问题 + 元数据,一起喂给 LLM,让它生成 SQL。
Prompt 设计(关键!):
你是一名资深数据分析师,精通 SQL。请根据以下数据库Schema和用户需求,生成准确的SQL查询。 【数据库Schema】 {schema_description} 【注意事项】 1. 字段名必须与Schema中一致,不要编造字段 2. 时间范围使用 BETWEEN,注意日期格式 3. 关联查询使用正确的JOIN条件 4. 聚合函数注意NULL值处理 5. 只返回SQL,不要解释 【用户问题】 {user_question} 【SQL】实际生成的 SQL 示例:
-- 用户问题:"上周北京区退货率最高的品类是什么?"SELECTp.category_nameAS品类,COUNT(CASEWHENo.order_status=5THEN1END)*1.0/COUNT(*)AS退货率FROMorders oLEFTJOINusers uONo.user_id=u.user_idLEFTJOINorder_items oiONo.order_id=oi.order_idLEFTJOINproducts pONoi.product_id=p.product_idWHEREu.city='北京'ANDo.create_timeBETWEEN'2026-03-18 00:00:00'AND'2026-03-24 23:59:59'GROUPBYp.category_nameORDERBY退货率DESCLIMIT1;模块 4:查询执行与安全防护
生成 SQL 后,不能直接执行!安全第一:
# 伪代码:查询执行的安全检查defsafe_execute(sql:str,user:User)->Result:# 1. SQL 语法校验(防止注入)ifnotsql_parser.is_valid(sql):raise"SQL 语法错误"# 2. 敏感操作拦截(禁止 DDL、DML)forbidden_keywords=["DROP","DELETE","UPDATE","INSERT","ALTER","TRUNCATE"]ifany(kwinsql.upper()forkwinforbidden_keywords):raise"检测到危险操作,已拦截"# 3. 权限校验(用户只能查有权限的表)tables=sql_parser.extract_tables(sql)ifnotuser.has_permission(tables):raise"无权限访问相关数据表"# 4. 添加查询限制(防止慢查询拖垮库)if"LIMIT"notinsql.upper():sql+=" LIMIT 1000"# 5. 执行查询(只读账号)returnreadonly_db.execute(sql)几个关键安全措施:
- 使用只读账号连接数据库
- 拦截所有写操作关键字
- 强制加
LIMIT,防止全表扫描 - 查询超时控制(比如 30 秒自动 kill)
模块 5:结果解释(Result2NL)
查出来是一堆数字,业务人员可能还是看不懂。让 LLM 再"翻译"一次:
查询结果: 品类:数码配件,退货率:18.5% LLM 解释: "上周北京区退货率最高的品类是数码配件,退货率达到 18.5%, 意味着每 100 单约有 18-19 单发生退货。建议重点关注该品类的 产品质量和物流体验。"这个环节可选,但对于非技术用户很友好。
四、关键实现细节与踩坑经验
4.1 Prompt 工程:怎么让 LLM 生成更准的 SQL?
这是整个系统成败的关键。分享几个实战经验:
坑 1:LLM 瞎编字段名
用户问"用户的平均客单价",LLM 可能生成AVG(order_price),但表里实际叫order_amount。
解法:在 Prompt 里明确约束——“字段名必须与 Schema 中完全一致,禁止编造”。同时做后校验,生成的字段名必须在 Schema 里存在。
坑 2:时间理解错误
用户说"上周",LLM 可能理解错日期范围。
解法:在 Prompt 里注入当前日期,明确时间计算规则。或者在前置模块把"上周"解析成具体日期范围,再传给 LLM。
坑 3:多表关联搞错
用户问"北京用户的平均订单金额",LLM 可能忘了关联用户表,直接用订单表里的地址字段(如果有的话)。
解法:在 Schema 里明确标注表关系和关联条件,Prompt 里强调"必须使用正确的 JOIN"。
Prompt 优化前后对比:
【优化前 - 容易出错】 请根据以下表结构生成SQL: 表:orders(user_id, amount, status, create_time) 问题:上周北京用户的平均订单金额 【优化后 - 更稳定】 你是一名资深数据分析师,请根据以下信息生成标准SQL: 【当前日期】2026-03-25 【表结构】 orders表:订单表 - user_id: 用户ID(关联users.user_id) - amount: 订单金额(元) - status: 订单状态(1待支付 2已支付...) - create_time: 下单时间 users表:用户表 - user_id: 用户ID - city: 所在城市 【关联方式】 orders.user_id = users.user_id(LEFT JOIN) 【用户问题】 上周北京用户的平均订单金额 【要求】 1. 时间范围:上周 = 2026-03-18 至 2026-03-24 2. 城市筛选:users.city = '北京' 3. 只统计已支付订单:status IN (2,3,4) 4. 使用LEFT JOIN关联users表 5. 字段名必须与Schema一致4.2 模型选择:用 GPT-4 还是国产模型?
| 场景 | 推荐方案 | 说明 |
|---|---|---|
| 复杂查询、多表关联 | GPT-4 / Claude 3 | 理解能力强,准确率高 |
| 简单单表查询 | 国产大模型(通义/文心/智谱) | 成本低,响应快 |
| 高频场景 | 小模型微调 | 固定场景准确率更高,成本最低 |
建议:先用大模型验证方案可行性,跑通后针对高频场景用小模型或 Prompt 优化降低成本。
4.3 准确率提升:RAG + 示例增强
如果 LLM 生成的 SQL 老出错,可以试试少样本学习(Few-Shot):
【示例 1】 问题:"昨天销售额是多少?" SQL:SELECT SUM(amount) FROM orders WHERE DATE(create_time) = '2026-03-24' AND status IN (2,3,4) 【示例 2】 问题:"各城市 3 月订单量排名" SQL:SELECT u.city, COUNT(*) FROM orders o JOIN users u ON o.user_id = u.user_id WHERE o.create_time BETWEEN '2026-03-01' AND '2026-03-31' GROUP BY u.city ORDER BY COUNT(*) DESC 【当前问题】 ...把历史"问题-SQL"对作为示例放进 Prompt,LLM 会"照葫芦画瓢",准确率显著提升。
更进一步,可以做成向量检索(RAG):
用户问题 → 向量化 → 检索相似历史问题 → 取 TOP 3 示例 → 拼进 Prompt → LLM 生成 SQL4.4 结果校验:怎么知道 SQL 对不对?
LLM 生成的 SQL 不一定对,需要校验机制:
# 伪代码:SQL 结果校验defvalidate_result(sql:str,result:Result,question:str)->bool:# 1. 语法校验:能正常执行吗?# 2. 结果非空校验:有数据返回吗?(空结果可能是条件写错)# 3. 合理性校验:数值是否在合理范围?# 比如"销售额"结果是负数,肯定有问题# 4. LLM 二次校验(可选):把 SQL + 结果 + 原问题再给 LLM,让它判断是否合理validation_prompt=f""" 请判断以下SQL是否正确回答了用户问题: 用户问题:{question}生成SQL:{sql}查询结果:{result}请回答:合理 / 不合理,并说明原因。 """returnllm_check(validation_prompt)五、完整流程演示
来个完整的例子,看看系统怎么跑起来的:
【Step 1】用户提问 用户:"帮我看看最近 7 天,上海和杭州哪个城市的销售额更高?" 【Step 2】问题理解 → 意图:对比查询 → 时间:最近 7 天(2026-03-19 至 2026-03-25) → 维度:城市(上海、杭州) → 指标:销售额(SUM(amount)) → 对比方式:两个城市分别计算,比较大小 【Step 3】SQL 生成 LLM 生成: SELECT u.city, SUM(o.amount) AS 销售额 FROM orders o LEFT JOIN users u ON o.user_id = u.user_id WHERE o.create_time BETWEEN '2026-03-19' AND '2026-03-25' AND u.city IN ('上海', '杭州') AND o.status IN (2,3,4) GROUP BY u.city; 【Step 4】安全检查 ✓ 无危险关键字 ✓ 字段存在于 Schema ✓ 用户有权限 ✓ 自动添加 LIMIT 1000 【Step 5】执行查询 结果: | 城市 | 销售额 | |------|----------| | 上海 | 1580000 | | 杭州 | 920000 | 【Step 6】结果解释 "最近 7 天,上海的销售额为 158 万元,杭州为 92 万元。 上海的销售额比杭州高出约 71.7%。" 【Step 7】返回用户 (展示自然语言结论 + 数据表格 + 可选的 SQL 展开)六、方案边界与局限
聊了这么多优点,也得诚实说说这个方案不适合的场景:
| 场景 | 问题 | 建议 |
|---|---|---|
| 超复杂分析 | 多步推理、因果分析、预测类问题 | LLM 生成 SQL 搞不定,仍需人工分析 |
| 数据权限极敏感 | 不同用户看到的数据差异很大 | 需要更细粒度的行级权限控制 |
| 实时性要求极高 | 期望毫秒级响应 | LLM 调用本身有延迟(1-3秒),不适合 |
| 数据质量差 | 表字段混乱、无文档 | 先治理数据,再搭问答系统 |
说白了,这个系统最适合的场景是:
- 业务人员的高频、相对标准化的取数需求
- 问题可以转化为单条 SQL 解决的场景
- 数据表结构清晰、有良好文档
七、总结
基于 LLM 的智能数据问答系统,核心思路很简单:
用 LLM 做"翻译",打通业务语言和 SQL 之间的鸿沟。
关键成功因素:
- 元数据要准:字段描述写清楚,关系标明白
- Prompt 要精:约束足够多,示例足够好
- 安全要严:只读账号、危险操作拦截、LIMIT 保护
- 校验要全:语法检查、结果合理性判断
- 预期要合理:适合标准化取数,不适合复杂分析
写在最后
这个方案我们已经在一个企业级数据平台上落地了,覆盖了 80% 的日常取数需求,数据分析师终于有时间做真正的分析了。
当然,实现过程中踩的坑远不止文中所写——比如 LLM 偶尔会把"杭州"写成"杭州市"导致查不到数据,比如用户问"最近"有时候指 7 天有时候指 30 天…
你在做类似系统时遇到过什么坑?或者对这个方案有什么疑问?欢迎在评论区交流!