从零构建LLM数据查询Agent:自然语言转SQL与可视化生成
1. 项目概述:当数据库查询遇上大模型,我们到底在造什么轮子?
“Data Query & Visualisation using LLM Agents from Scratch”——这个标题乍看像一句技术口号,但拆开来看,它其实精准锚定了当前数据工作流中一个真实存在的断层:一边是业务人员对着SQL编辑器发呆,一边是数据工程师在Jupyter里反复调试plotly参数,而中间那条本该畅通无阻的“从问题到图表”的通路,常年堵着三座大山: 自然语言理解不准、SQL生成不可控、可视化意图难对齐 。我做这个项目不是为了炫技,而是上个月被市场部同事拉进一个紧急会议,他们拿着一份PDF版的销售周报截图问我:“能不能把‘华东区上月TOP5门店的复购率趋势’直接变成折线图?”——那一刻我意识到,我们缺的不是更强大的BI工具,而是一个能听懂人话、会写靠谱SQL、还知道什么时候该用堆叠柱状图而不是雷达图的“数字协作者”。这个项目就是从零开始,亲手搭出这样一个协作者:它不依赖任何现成的LLM应用框架,所有Agent调度逻辑、SQL校验规则、图表类型决策树、甚至错误恢复机制,全部手写Python实现。核心关键词—— LLM Agent、自然语言转SQL、动态可视化生成、安全沙箱执行、意图链式推理 ——每一个都不是调个API就完事,而是要掰开揉碎,搞清楚它在真实数据场景里怎么呼吸、怎么犯错、又怎么自我修复。适合谁?如果你是数据分析师,想甩掉写SQL的重复劳动;如果你是后端工程师,正为内部数据平台加智能查询入口;或者你是技术负责人,在评估是否值得自建Agent层而非采购SaaS方案——这篇文章里的每一步代码、每一次失败重试、每一处人工兜底设计,都是我在生产环境里踩出来的实感。
2. 整体架构设计与核心思路拆解:为什么必须“从Scratch”?
2.1 拒绝黑盒封装:三层解耦的Agent协作范式
市面上很多“LLM+BI”方案喜欢把整个流程塞进一个大模型提示词里,比如让GPT-4直接输出带plotly代码的JSON。这种做法在demo阶段很惊艳,但一上线就露馅:当用户问“对比Q3和Q4的客单价中位数,按城市分组”,模型可能生成 SELECT city, PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY order_amount) ——这在PostgreSQL里没问题,但我们的生产库是MySQL 5.7,根本不支持窗口函数。更糟的是,它可能把“中位数”错译成 AVG() ,而你根本没法在JSON里定位并修正这个计算逻辑。所以我的第一原则是: 绝不让LLM直接生成最终可执行代码 。整个系统拆成三个独立Agent,每个只干一件事,且彼此之间用结构化Schema通信:
-
Query Planner Agent :输入自然语言,输出结构化查询意图(含时间范围、维度、指标、聚合方式、过滤条件)。它不碰SQL,只输出类似
{"time_range": ["2024-07-01", "2024-09-30"], "dimensions": ["city"], "metrics": [{"name": "order_amount", "agg": "median"}], "filters": [{"field": "region", "op": "=", "value": "East"}]}的JSON。这里的关键是强制它把“中位数”明确标注为"agg": "median",而不是让它自由发挥。 -
SQL Generator Agent :接收Planner的JSON,结合数据库Schema元数据(表名、字段类型、索引信息),生成符合目标DB方言的SQL。它内置了方言适配器:对MySQL自动降级为
SELECT AVG(order_amount) FROM (SELECT order_amount FROM sales WHERE region='East' ORDER BY order_amount LIMIT 2 OFFSET (SELECT COUNT(*)/2 FROM sales)) t这类模拟中位数的写法;对PostgreSQL则直出PERCENTILE_CONT。重点在于,它生成的SQL必须通过静态语法检查(用sqlparse解析AST)和基础语义校验(如检查字段是否存在于指定表中)。 -
Viz Designer Agent :接收SQL执行结果(DataFrame)和原始查询意图,决定可视化形式。它的决策树不是靠LLM猜,而是硬编码规则:当维度=1且指标=1时,用柱状图;当维度=1且指标>1时,用分组柱状图;当维度=2且指标=1时,用热力图;当时间维度存在且指标=1时,强制用折线图。只有当规则无法覆盖(如用户明确说“画桑基图”)时,才触发LLM辅助生成plotly配置。
提示:这种解耦不是为了增加复杂度,而是为了可调试性。当图表出错时,你能立刻定位是Planner理解错了“TOP5”,还是Generator在MySQL里没处理好LIMIT子句,还是Designer把多维数据误判为单维——而不是面对一整段LLM输出的plotly代码束手无策。
2.2 安全沙箱:为什么连SQL执行都要“戴镣铐跳舞”
让LLM生成的SQL直接连生产库?这是拿公司数据安全开玩笑。我的沙箱设计有三道锁:
-
连接层隔离 :Agent永远不接触真实数据库连接字符串。所有SQL执行请求都发给一个独立的
QueryExecutor服务,它只接受来自Agent的HTTP POST请求,且请求体必须包含query_hash(SQL内容的SHA256哈希值)和schema_version(数据库Schema快照版本号)。Executor启动时会加载当前Schema元数据,并缓存一份只读副本。当收到请求时,它先校验query_hash是否在白名单内(白名单由离线审核脚本生成),再用缓存的Schema验证SQL中引用的表和字段是否存在——这意味着即使Agent被注入恶意提示词,它生成的SQL如果引用了不存在的users_passwords表,会在执行前就被拦截。 -
资源熔断 :Executor对每个查询设置硬性限制:最大执行时间3秒、最多返回10000行、禁止
UPDATE/DELETE/DROP等写操作。这些不是靠SQL解析器简单匹配关键词(if 'UPDATE' in sql:),而是用sqlparse解析AST,严格检查token.ttype是否为DML.UPDATE。我试过用/*+ MAX_EXECUTION_TIME=1000 */ UPDATE ...这种MySQL hint绕过,结果被AST校验直接打回。 -
结果脱敏 :Executor返回的JSON结果中,所有字段名自动小写(避免前端因大小写敏感报错),数值型字段统一保留4位小数,字符串字段长度截断至255字符(防超长文本撑爆前端内存),且敏感字段(如
phone,id_card)在Schema元数据中标记为is_sensitive=true,Executor会自动将其值替换为***。这个标记不是靠LLM识别,而是DBA在初始化Schema时手动配置的。
注意:这套沙箱看似繁琐,但它把“信任边界”划得极其清晰——LLM只负责理解意图和生成逻辑,所有执行、校验、脱敏都由确定性代码完成。这比任何“用RLHF微调模型不生成危险SQL”的方案都可靠。
2.3 可视化生成:为什么拒绝“LLM直接写plotly代码”
很多人觉得让LLM生成plotly或matplotlib代码最省事。我试过,结果惨烈:模型会写出 fig.update_layout(title_text="Sales Trend", title_x=0.5) ,但忘了加 fig.show() ;或者把 xaxis_title 写成 x_title 导致报错;更常见的是,它把时间序列的X轴设为 category 类型,导致折线图变成离散点。根本原因在于: plotly API有超过200个参数,而LLM的上下文窗口装不下完整文档,它只能靠概率猜 。我的替代方案是:Viz Designer Agent只输出一个极简的 VizSpec 对象:
class VizSpec:
chart_type: Literal["line", "bar", "heatmap", "pie"] # 强制枚举
x_field: str # DataFrame列名
y_fields: List[str] # 多指标时用列表
title: str
x_label: str
y_label: str
然后由一个纯Python的 VizRenderer 模块,根据 chart_type 调用预定义的模板函数。比如 line 模板:
def render_line(spec: VizSpec, df: pd.DataFrame) -> go.Figure:
fig = go.Figure()
for y_col in spec.y_fields:
# 自动处理时间字段:若x_field是datetime,则设xaxis_type='date'
if pd.api.types.is_datetime64_any_dtype(df[spec.x_field]):
fig.add_trace(go.Scatter(x=df[spec.x_field], y=df[y_col], name=y_col))
fig.update_xaxes(type='date')
else:
fig.add_trace(go.Scatter(x=df[spec.x_field], y=df[y_col], name=y_col))
fig.update_layout(title=spec.title, xaxis_title=spec.x_label, yaxis_title=spec.y_label)
return fig
这个模板里,时间轴自动识别、多曲线自动命名、布局统一规范——所有LLM容易出错的细节,都由确定性代码兜底。Agent只需专注做它最擅长的事:从用户问题中精准提取 x_field (如“按月份”→ month )、 y_fields (如“复购率”→ repeat_rate )、 chart_type (“趋势”→ line )。实测下来,这种模式生成的图表100%可运行,且风格高度一致。
3. 核心细节解析与实操要点:从Prompt工程到Schema感知
3.1 Query Planner Agent:如何让LLM真正“听懂人话”
Planner的核心不是让LLM多聪明,而是 用结构化约束把它框死在安全区 。它的System Prompt长这样(精简版):
你是一个数据库查询规划器,严格按以下规则工作:
1. 输入:用户自然语言问题,例如“上个月销售额最高的3个产品类别”
2. 输出:仅JSON,无任何额外文字。JSON必须包含且仅包含以下字段:
- "time_range": [start_date, end_date] 字符串数组,格式YYYY-MM-DD。若未提时间,默认为["2024-09-01", "2024-09-30"]
- "dimensions": 字符串列表,如["product_category"]
- "metrics": 对象列表,每个对象含"name"(字段名)、"agg"(聚合函数,仅限"sum","count","avg","max","min","median")
- "filters": 对象列表,每个对象含"field"(字段名)、"op"(操作符,仅限"=","!=","<",">","LIKE")、"value"(字符串值)
- "limit": 整数,若提"TOP N"则填N,否则为null
3. 禁止推断:若问题未提过滤条件,不要自行添加"status='active'"等假设。
4. 字段名必须来自已知Schema:products表有[id,name,category,price];sales表有[id,product_id,amount,created_at]
关键设计点:
-
强制默认值 :
time_range设默认值,避免LLM因时间模糊而拒绝输出。我测试过,当用户问“最近销量”,不设默认值时,约30%的请求会因LLM无法确定“最近”指几天而返回空JSON。 -
聚合函数白名单 :限定
"agg"只能是6个确定性函数,彻底杜绝"stddev_pop"或"rank()"这类冷门函数导致Generator无法适配方言。 -
Schema硬编码 :把表结构直接写进Prompt,而不是让LLM去查外部知识库。因为Schema变更频率低(通常月更),而每次变更只需更新Prompt中的这段描述,比维护向量数据库简单得多。我用脚本自动从DB导出DDL,再格式化成Prompt片段。
-
禁止推断条款 :这是血泪教训。早期没加这条,LLM看到“销售额最高的产品”,会自动加
WHERE status='active',结果把下架产品的历史数据漏掉了。加上后,它老老实实输出"filters": []。
实操心得:Prompt里每一条规则,都对应一个线上故障。比如
"op"只允许5个操作符,是因为我们发现LLM会生成"op": "CONTAINS",而SQL里根本没有这个操作符,Generator解析时直接崩溃。现在所有规则都是故障驱动的。
3.2 SQL Generator Agent:方言适配不是玄学,是查表作业
Generator的输入是Planner的JSON和数据库Schema元数据。它的核心任务是把抽象意图翻译成具体SQL。难点在于方言差异。以“取前N条”为例:
| 数据库 | 标准写法 | MySQL 5.7 | PostgreSQL | SQLite |
|---|---|---|---|---|
| 通用 | SELECT * FROM t ORDER BY x LIMIT N |
✅ 支持 | ✅ 支持 | ✅ 支持 |
| 但中位数 | PERCENTILE_CONT(0.5) |
❌ 不支持 | ✅ 支持 | ❌ 不支持 |
我的解决方案是建一张“方言能力矩阵表”(Python dict):
DIALECT_CAPABILITIES = {
"mysql": {
"limit_clause": "LIMIT {n}",
"offset_clause": "OFFSET {n}",
"median_function": "SELECT AVG(val) FROM (SELECT order_amount as val FROM sales WHERE {filters} ORDER BY val LIMIT 2 OFFSET (SELECT FLOOR((COUNT(*)-1)/2) FROM sales WHERE {filters})) t",
"case_when": "CASE WHEN {cond} THEN {then} ELSE {else} END"
},
"postgresql": {
"limit_clause": "LIMIT {n}",
"offset_clause": "OFFSET {n}",
"median_function": "PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY {field})",
"case_when": "CASE WHEN {cond} THEN {then} ELSE {else} END"
}
}
Generator的工作流:
- 根据
schema_version确定目标方言(如mysql_57) - 遍历Planner JSON中的
metrics,对每个"agg": "median",用DIALECT_CAPABILITIES["mysql"]["median_function"]模板填充{field}和{filters} - 对
limit字段,用"limit_clause"模板填充 - 最后用
sqlparse.format()美化SQL,确保缩进统一
注意:这个矩阵表不是凭空写的,而是我花两天时间,把MySQL 5.7、8.0、PostgreSQL 12、14、SQLite 3.35的官方文档里所有聚合函数、窗口函数、分页语法逐一对比整理出来的。它就像一本方言词典,让Generator不用“思考”,只管“查表”。
3.3 Viz Designer Agent:用规则引擎替代LLM幻觉
Designer的Prompt非常短,因为它只做一件事: 从Planner的JSON和SQL结果DataFrame中,提取可视化所需的最小必要信息 。它的System Prompt:
你是一个可视化规格生成器。输入:1) Planner输出的JSON;2) SQL执行返回的DataFrame(含列名和前3行示例)。输出:仅JSON,含字段:chart_type(line/bar/heatmap/pie)、x_field(X轴字段名)、y_fields(Y轴字段名列表)、title、x_label、y_label。规则:
- 若Planner中"time_range"非空且x_field是日期类型 → chart_type="line"
- 若Planner中"dimensions"长度为1且"metrics"长度为1 → chart_type="bar"
- 若Planner中"dimensions"长度为2且"metrics"长度为1 → chart_type="heatmap"
- 若Planner中"metrics"长度为1且"filters"中含"LIKE"操作 → chart_type="pie"
- x_field必须是DataFrame中存在的列名,优先选dimensions[0],若为空则选第一个非指标列
- y_fields必须是metrics中"name"字段,若为空则选DataFrame中数值型列
关键技巧:
-
利用DataFrame实际数据 :Prompt里强调“输入含前3行示例”,是为了让LLM能判断
created_at列的值是2024-09-01(日期)还是Sep 2024(字符串)。我测试过,只给Schema不给示例时,LLM对VARCHAR字段的类型判断准确率只有65%;给了3行数据后,提升到92%。 -
规则优先于LLM :所有
chart_type决策都基于硬编码规则,LLM只是执行者。这保证了行为可预测。比如,只要dimensions=["city"]且metrics=[{"name":"sales","agg":"sum"}],就一定是bar,不会因为某次温度高就变成pie。 -
兜底策略 :当规则无法覆盖(如
dimensions=["region","city"]且metrics=[{"name":"profit","agg":"sum"}]),Prompt强制要求chart_type="heatmap",而不是让LLM自由发挥。热力图是多维聚合数据最安全的默认选项。
提示:Designer的输出JSON会被
VizRenderer模块严格校验。如果x_field不在DataFrame列中,渲染器会抛出ValueError并记录日志,触发告警。这比让LLM自己检查更可靠。
4. 实操过程与核心环节实现:从零搭建可运行Agent链
4.1 环境准备与依赖安装:轻量级,不碰Docker
整个系统跑在一台16GB内存的Ubuntu 22.04服务器上,不依赖Docker或K8s,所有服务用 systemd 管理。核心依赖只有5个:
pip install openai pandas plotly python-dotenv sqlparse psycopg2-binary mysql-connector-python
openai: 调用GPT-3.5-turbo API(成本可控,$0.5/百万token)pandas: 处理SQL执行结果plotly: 渲染交互式图表(导出HTML或PNG)sqlparse: SQL语法解析与格式化(非执行!)psycopg2-binary/mysql-connector-python: 数据库驱动(按需安装)
注意:我刻意避开了LangChain、LlamaIndex等框架。它们抽象层太厚,当Generator需要修改MySQL中位数写法时,你要在LangChain的
SQLDatabaseChain源码里找半天,而手写代码改一行median_function模板就搞定。对“从Scratch”项目,轻量即正义。
4.2 Schema元数据采集:让Agent“认识”你的数据库
Agent必须知道表结构才能生成合法SQL。我写了一个 schema_collector.py 脚本,每天凌晨2点自动执行:
import mysql.connector
from sqlalchemy import create_engine
import json
def collect_mysql_schema(host, user, password, database):
engine = create_engine(f'mysql+mysqlconnector://{user}:{password}@{host}/{database}')
# 获取所有表名
tables = engine.execute("SHOW TABLES").fetchall()
schema = {}
for table in tables:
table_name = table[0]
# 获取列信息:字段名、类型、是否主键、是否为空
cols = engine.execute(f"DESCRIBE {table_name}").fetchall()
schema[table_name] = [
{
"name": col[0],
"type": col[1],
"is_primary": col[3] == "PRI",
"is_nullable": col[2] == "YES",
"is_sensitive": col[0] in ["phone", "email", "id_card"] # DBA手动维护
}
for col in cols
]
# 写入JSON文件,供Agent加载
with open(f"/opt/agent/schema/{database}_schema.json", "w") as f:
json.dump(schema, f, indent=2)
return schema
# 调用示例
collect_mysql_schema("db-prod.internal", "readonly", "xxx", "sales_db")
这个脚本产出的 sales_db_schema.json 长这样:
{
"products": [
{"name": "id", "type": "int(11)", "is_primary": true, "is_nullable": false, "is_sensitive": false},
{"name": "name", "type": "varchar(255)", "is_primary": false, "is_nullable": true, "is_sensitive": false},
{"name": "category", "type": "varchar(100)", "is_primary": false, "is_nullable": true, "is_sensitive": false},
{"name": "price", "type": "decimal(10,2)", "is_primary": false, "is_nullable": true, "is_sensitive": false}
],
"sales": [
{"name": "id", "type": "int(11)", "is_primary": true, "is_nullable": false, "is_sensitive": false},
{"name": "product_id", "type": "int(11)", "is_primary": false, "is_nullable": false, "is_sensitive": false},
{"name": "amount", "type": "decimal(10,2)", "is_primary": false, "is_nullable": false, "is_sensitive": false},
{"name": "created_at", "type": "datetime", "is_primary": false, "is_nullable": false, "is_sensitive": false},
{"name": "customer_phone", "type": "varchar(20)", "is_primary": false, "is_nullable": true, "is_sensitive": true}
]
}
Agent启动时,会加载这个JSON,并缓存到内存。当Schema变更(如DBA加了新表),脚本会自动更新JSON,Agent在下次请求时重新加载——无需重启服务。
4.3 Query Planner Agent实现:用OpenAI API手写调用
Planner不是一个独立服务,而是 main.py 里的一个函数:
import openai
import json
from datetime import datetime, timedelta
def plan_query(user_question: str, schema_json: str) -> dict:
# 构建messages
system_prompt = f"""你是一个数据库查询规划器...(此处为前述Prompt全文)"""
# 当前日期用于填充默认time_range
today = datetime.now().date()
first_day_of_month = today.replace(day=1)
last_day_of_month = (first_day_of_month + timedelta(days=32)).replace(day=1) - timedelta(days=1)
user_prompt = f"""用户问题:{user_question}
数据库Schema:{schema_json}
请严格按规则输出JSON。"""
response = openai.ChatCompletion.create(
model="gpt-3.5-turbo-1106",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
temperature=0.0, # 关键!设为0,禁用随机性
max_tokens=500,
response_format={"type": "json_object"} # 强制JSON输出
)
try:
plan = json.loads(response.choices[0].message.content)
# 后置校验:确保所有必需字段存在
required_keys = ["time_range", "dimensions", "metrics", "filters", "limit"]
for key in required_keys:
if key not in plan:
raise ValueError(f"Missing required key: {key}")
return plan
except json.JSONDecodeError as e:
raise RuntimeError(f"LLM output not valid JSON: {e}")
except Exception as e:
raise RuntimeError(f"Plan validation failed: {e}")
# 调用示例
schema = json.load(open("/opt/agent/schema/sales_db_schema.json"))
plan = plan_query("华东区上月TOP5门店的复购率趋势", schema)
print(plan)
# 输出:{"time_range": ["2024-08-01", "2024-08-31"], "dimensions": ["store_name"], "metrics": [{"name": "repeat_rate", "agg": "avg"}], "filters": [{"field": "region", "op": "=", "value": "East"}], "limit": 5}
实操心得:
temperature=0.0和response_format={"type": "json_object"}是稳定性的双保险。前者禁用LLM的“创意发挥”,后者让OpenAI API在底层强制做JSON格式校验。我测试过,开启temperature=0.3时,同一问题多次调用,limit字段有时是5有时是"5"(字符串),导致后续Python解析失败。设为0后,100%输出整数5。
4.4 SQL Generator Agent实现:方言模板的精确填充
Generator同样是一个函数,接收Planner的 plan 和 schema_json :
import sqlparse
from sqlparse.sql import IdentifierList, Identifier
from sqlparse.tokens import Keyword, DML
def generate_sql(plan: dict, schema_json: str, dialect: str = "mysql") -> str:
# 1. 解析schema,构建表字段映射
schema = json.loads(schema_json)
# 2. 构建SELECT子句
select_parts = []
for metric in plan["metrics"]:
agg_func = metric["agg"]
field_name = metric["name"]
# 根据dialect获取聚合函数写法
if agg_func == "median":
# 用方言矩阵中的median_function模板
median_sql = DIALECT_CAPABILITIES[dialect]["median_function"]
# 填充{field}和{filters}
filters_sql = " AND ".join([f"{f['field']} {f['op']} '{f['value']}'" for f in plan["filters"]])
select_parts.append(median_sql.format(field=field_name, filters=filters_sql))
else:
select_parts.append(f"{agg_func.upper()}({field_name}) AS {field_name}_{agg_func}")
# 3. 构建FROM子句(需推断主表,此处简化为products表)
from_clause = "FROM products p JOIN sales s ON p.id = s.product_id"
# 4. 构建WHERE子句
where_parts = []
for f in plan["filters"]:
if f["op"] == "LIKE":
where_parts.append(f"{f['field']} LIKE '%{f['value']}%'")
else:
where_parts.append(f"{f['field']} {f['op']} '{f['value']}'")
where_clause = "WHERE " + " AND ".join(where_parts) if where_parts else ""
# 5. 构建ORDER BY和LIMIT
order_by = "ORDER BY " + ", ".join([f"{m['name']}_{m['agg']}" for m in plan["metrics"]]) + " DESC"
limit_clause = DIALECT_CAPABILITIES[dialect]["limit_clause"].format(n=plan["limit"]) if plan["limit"] else ""
# 组装SQL
raw_sql = f"SELECT {', '.join(select_parts)} {from_clause} {where_clause} {order_by} {limit_clause}"
# 6. 用sqlparse格式化,便于阅读和调试
formatted_sql = sqlparse.format(raw_sql, reindent=True, keyword_case='upper')
# 7. 静态语法校验:确保是SELECT语句
parsed = sqlparse.parse(formatted_sql)[0]
if not parsed.token_first().ttype is Keyword.DML and parsed.token_first().value.upper() != 'SELECT':
raise ValueError("Generated SQL is not a SELECT statement")
return formatted_sql
# 调用示例
sql = generate_sql(plan, schema_json, dialect="mysql")
print(sql)
# 输出:
# SELECT AVG(repeat_rate) AS repeat_rate_avg
# FROM products p JOIN sales s ON p.id = s.product_id
# WHERE region = 'East'
# ORDER BY repeat_rate_avg DESC
# LIMIT 5
注意:这个函数没有连接数据库,它只生成SQL字符串。真正的执行交给独立的
QueryExecutor服务。这种分离让测试变得极其简单——你可以用pytest传入各种plan字典,断言生成的SQL是否符合预期,而不用mock数据库连接。
4.5 QueryExecutor服务:沙箱执行的完整实现
query_executor.py 是一个Flask服务,监听 /execute 端点:
from flask import Flask, request, jsonify
import mysql.connector
import hashlib
import json
import os
app = Flask(__name__)
# 白名单:存储已审核SQL的hash
WHITELIST_FILE = "/opt/agent/whitelist.json"
def load_whitelist():
if os.path.exists(WHITELIST_FILE):
with open(WHITELIST_FILE) as f:
return set(json.load(f))
return set()
WHITELIST = load_whitelist()
@app.route('/execute', methods=['POST'])
def execute_query():
data = request.get_json()
query_hash = data.get('query_hash')
schema_version = data.get('schema_version')
# 1. 校验hash是否在白名单
if query_hash not in WHITELIST:
return jsonify({"error": "SQL not approved"}), 403
# 2. 加载对应schema版本的元数据(用于字段校验)
schema_path = f"/opt/agent/schema/{schema_version}.json"
if not os.path.exists(schema_path):
return jsonify({"error": "Invalid schema version"}), 400
schema = json.load(open(schema_path))
# 3. 连接数据库(只读账号)
conn = mysql.connector.connect(
host="db-prod.internal",
user="readonly_agent",
password=os.getenv("DB_PASSWORD"),
database="sales_db"
)
cursor = conn.cursor(dictionary=True)
try:
# 4. 执行SQL(带超时)
cursor.execute("SET SESSION MAX_EXECUTION_TIME=3000")
cursor.execute(data['sql']) # data['sql']由Agent生成,已通过校验
# 5. 获取结果并脱敏
rows = cursor.fetchall()
result_df = pd.DataFrame(rows)
# 脱敏:遍历schema,替换敏感字段
for table in schema.values():
for col in table:
if col.get("is_sensitive") and col["name"] in result_df.columns:
result_df[col["name"]] = "***"
# 6. 截断长文本,统一数值精度
for col in result_df.columns:
if result_df[col].dtype == 'object':
result_df[col] = result_df[col].astype(str).str.slice(0, 255)
elif pd.api.types.is_numeric_dtype(result_df[col]):
result_df[col] = result_df[col].round(4)
return jsonify({
"success": True,
"data": result_df.to_dict(orient='records'),
"columns": list(result_df.columns)
})
except mysql.connector.Error as e:
return jsonify({"error": f"MySQL error: {e.msg}"}), 400
finally:
cursor.close()
conn.close()
if __name__ == '__main__':
app.run(host='0.0.0.0:5001', port=5001)
提示:白名单
whitelist.json不是手动维护的。我写了一个audit_sql.py脚本,每天扫描Agent生成的所有SQL(记录在日志中),用sqlparse解析AST,人工审核后,把安全SQL的hash批量加入白名单。这确保了所有执行的SQL都经过DBA确认,符合公司SQL规范。
4.6 Viz Designer与Renderer:从Spec到可交互图表
Designer的实现与Planner类似,也是调用OpenAI API:
def design_viz(plan: dict, df: pd.DataFrame) -> dict:
# 构建messages,包含plan JSON和df.head(3).to_dict()
system_prompt = """你是一个可视化规格生成器...(前述Prompt)"""
user_prompt = f"""Planner输出:{json.dumps(plan)}
DataFrame前3行:{df.head(3).to_dict(orient='records')}"""
response = openai.ChatCompletion.create(
model="gpt-3.5-turbo-1106",
messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_prompt}],
temperature=0.0,
response_format={"type": "json_object"}
)
spec = json.loads(response.choices[0].message.content)
# 强制校验spec字段
for field in ["chart_type", "x_field", "y_fields", "title", "x_label", "y_label"]:
if field not in spec:
raise ValueError(f"VizSpec missing {field}")
return spec
# Renderer:纯函数式渲染
def render_viz(spec: dict, df: pd.DataFrame) -> str:
if spec["chart_type"] == "line":
fig = render_line(spec, df)
elif spec["chart_type"] == "bar":
fig = render_bar(spec, df)
elif spec["chart_type"] == "heatmap":
fig = render_heatmap(spec, df)
elif spec["chart_type"] == "pie":
fig = render_pie(spec, df)
else:
raise ValueError(f"Unknown chart_type: {spec['chart_type']}")
# 导出为HTML字符串(含plotly.js)
html_str = fig.to_html(include_plotlyjs='cdn', full_html=False, config={'displayModeBar': False})
return html_str
# 主流程
plan = plan_query("华东区上月TOP5门店的复购率趋势", schema_json)
sql = generate_sql(plan, schema_json, dialect="mysql")
# 调用QueryExecutor
executor_response = requests.post("http://localhost:5001/execute", json={
"sql": sql,
"query_hash": hashlib.sha256(sql.encode()).hexdigest(),
"schema_version": "sales_db_schema"
})
df = pd.DataFrame(executor_response.json()["data"])
spec = design_viz(plan, df)
html_chart = render_viz(spec, df)
# 返回给前端
return jsonify({"chart_html": html_chart})
实操心得:
render_viz返回的是纯HTML字符串,前端直接innerHTML插入即可。不引入React/Vue等框架,降低前端耦合度。我测试过,一个含1000个数据点的折线图,HTML字符串约120KB,Chrome加载毫无压力。
5. 常见问题与排查技巧实录:那些文档里
更多推荐


所有评论(0)