数据库查询智能体
数据库要三步走
1. 不能直接生成SQL
最直接的想法是:用户问题 -> 大模型生成 SQL -> 执行 SQL
这看起来简单,但非常容易出错。
模型可能不知道真实表名,就编一个 medicine 表;不知道字段名,就编一个 stock_count 字段,所以数据库助手要让模型先获得真实上下文。
2. 三个工具的关系
| 工具名 | 作用 |
|---|---|
list_sql_tables |
先看数据库里有哪些表 |
get_table_data |
查看某张表的列名和前 100 行数据,理解字段和格式 |
execute_sql_query |
在确认表名、字段和格式后,执行自定义 SQL 查询 |
前两个工具其实是在给第三个工具铺路。模型先知道真实表名和数据样子,最后生成 SQL 才更靠谱。
3. get_table_data需要返回数据样例
因为字段名只能告诉模型“有哪些列”,不能告诉它“数据长什么样”。
如果模型不知道真实格式,WHERE 条件就容易写错。返回前 100 行样例数据,可以让模型同时看到列名和数据格式
本业务的三张关系表
| 表名 | 数据规模 | 主要用途 |
|---|---|---|
drugs |
50 条 | 药品主数据 |
inventory |
150 条 | 每种药品 3 个库存批次 |
sales_records |
100 条 | 每种药品 2 条销售记录,覆盖区域分析 |

核心字段:
| 表名 | 关键字段 | 适合回答的问题 |
|---|---|---|
drugs |
drug_id、generic_name、brand_name、approval_number、dosage_form、therapeutic_area |
某个药品是什么、属于什么治疗领域 |
inventory |
inventory_id、drug_id、batch_number、quantity_on_hand、warehouse_location、expiry_date |
某个药品库存多少、在哪个仓库、何时过期 |
sales_records |
sale_id、drug_id、sale_date、quantity_sold、unit_price、total_amount、region |
某个药品卖了多少、销售额多少、区域表现 |
实现数据库工具:
get_db_config:
"""
MySQL 数据库查询工具模块
封装数据库查询助手使用的三个 LangChain 工具:
list_sql_tables 用于发现真实表名,get_table_data 用于预览字段和样例数据,
execute_sql_query 用于在确认结构后执行自定义查询。
"""
import os
from dotenv import load_dotenv
from langchain_core.tools import tool
from mysql.connector import Error, connect
from app.api.monitor import monitor
load_dotenv()
# 集中读取数据库配置,后续三个工具都复用这份连接参数
def get_db_config():
"""
从环境变量读取 MySQL 连接配置
所有数据库工具都通过此函数拿到同一份连接参数,避免每个工具重复读取环境变量
:return: mysql.connector.connect 可直接使用的连接参数
"""
config = {
"host": os.getenv("MYSQL_HOST", "localhost"),
"port": int(os.getenv("MYSQL_PORT", "3306")),
"user": os.getenv("MYSQL_USER"),
"password": os.getenv("MYSQL_PASSWORD"),
"database": os.getenv("MYSQL_DATABASE"),
"charset": os.getenv("MYSQL_CHARSET", "utf8mb4"),
"collation": os.getenv("MYSQL_COLLATION", "utf8mb4_unicode_ci"),
"autocommit": True,
"sql_mode": os.getenv("MYSQL_SQL_MODE", "TRADITIONAL"),
}
# 去掉未配置的可选项,避免把 None 传给 mysql.connector 造成连接参数异常
config = {k: v for k, v in config.items() if v is not None}
# user/password/database 是本教程工具能正常查询业务库的最小必要配置
required_keys = ["user", "password", "database"]
missing_keys = [k for k in required_keys if k not in config]
if missing_keys:
raise ValueError(f"缺失数据库核心配置:{', '.join(missing_keys)}")
return config
- 给
host、port、字符集、排序规则和 SQL 模式等配置默认值。 - 检查
user、password、database是否存在。
list_sql_table: 列出真实表名
@tool
def list_sql_tables() -> str:
"""
查询当前数据库中所有可用表
作用:让模型先识别真实可用的表名,方便后续预览表结构和编写自定义 SQL。
:return: 有表:可用的表有:表1,表2,表3...
没有表:没有可用的表
出现异常:查询出现异常:异常信息
"""
# 埋点:工具一被调用,前端可以展示当前正在查询数据库表名
monitor.report_tool(tool_name="数据库表名查询工具:list_sql_tables", args={})
# 加载数据库连接信息
config = get_db_config()
# MySQL 查询的固定步骤:
# 1. 创建连接
# 2. 创建 cursor
# 3. 执行 SQL
# 4. 获取返回结果
# 5. 释放连接和 cursor 资源
# 这里捕获异常并返回中文提示,避免工具报错直接中断 Agent 执行链路
try:
# 使用 with 管理连接和游标,查询结束后自动释放数据库资源
with connect(**config) as conn:
with conn.cursor() as cursor:
sql = "SHOW TABLES"
cursor.execute(sql)
# SHOW TABLES 返回形如:[("drugs",), ("inventory",), ("sales_records",)]
tables = cursor.fetchall()
if not tables:
return "没有可用的表"
# 取每个元组的第一个元素,拼成模型容易阅读的表名列表
table_names = [table[0] for table in tables]
return f"可用的表有:{', '.join(table_names)}"
except Error as e:
return f"查询出现异常:{str(e)}"
这段代码本质上就是 MySQL 查询的标准流程:
读取连接配置 -> connect 建立连接 -> cursor 创建游标 -> execute 执行 SHOW TABLES -> fetchall 获取结果 -> 格式化返回
get_table_data: 预览字段与实例数据
@tool
def get_table_data(table_name) -> str:
"""
查询指定表的前 100 行数据
当前工具调用之前,应先调用 list_sql_tables 完成表名校验。
此工具的作用:
1. 完成单表样例数据查询
2. 为多表查询提供表结构信息和数据格式参考
:param table_name: 表名
:return: CSV 格式数据
1. 第一行是列信息,列之间使用英文逗号分隔
2. 第二行开始是表数据,值之间也使用英文逗号分隔
3. 行和行之间使用 \n 分隔
4. 至多查询 100 条表数据
例如:
id,name,age\n -> 列头
1,张三,18\n
1,张三,18\n
1,张三,18\n -> 至多查询 100 条
"""
# 埋点:工具二被调用,前端可以展示当前正在预览哪张表
monitor.report_tool(
tool_name="数据库表数据查询工具:get_table_data",
args={"table_name": table_name},
)
# 获取数据库参数
config = get_db_config()
# 查询流程同样是:连接 -> cursor -> 执行 SQL -> 获取列信息和数据 -> 自动释放资源
try:
with connect(**config) as conn:
with conn.cursor() as cursor:
# 教程代码直接拼接表名,重点演示 Agent 查询链路;生产环境应改为白名单校验
sql = f"SELECT * FROM {table_name} LIMIT 100"
cursor.execute(sql)
# cursor.description 保存查询结果的列元信息
# 例如:[("id", ...), ("name", ...), ("age", ...)]
# 如果 SQL 没有结果集,description 可能为 None
description = cursor.description
if not description:
return f"数据表 {table_name} 暂无数据。"
# 只取每个列信息元组的第一个元素,也就是列名
# 例如:["id", "name", "age"]
columns = [desc[0] for desc in description]
# fetchall 返回表数据,形如:[(1, "张三", 18), (2, "李四", 20)]
rows = cursor.fetchall()
# 把每一行数据从元组转成 CSV 行文本
# 例如:(1, "张三", 18) -> "1,张三,18"
results = [",".join(map(str, row)) for row in rows]
# columns 组成 CSV 头部,rows 组成 CSV 数据体
# 最终返回:
# id,name,age
# 1,张三,18
header_str = ",".join(columns)
data_str = "\n".join(results)
return f"{header_str}\n{data_str}"
except Error as e:
return f"查询出现异常:{str(e)}"
cursor.description与CSV返回格式
我们只需要每个元组里的第一个元素,也就是列名:
columns = [desc[0] for desc in description]
拿到列名后,再把查询结果转成 CSV 格式:
drug_id,generic_name,brand_name
1,阿莫西林胶囊,阿莫仙
2,布洛芬缓释胶囊,芬必得
CSV 对模型来说比较容易读,它既能看到字段名,也能看到每一列的数据样子
表名安全与只读安全
safe_table_name = table_name.replace("`", "").replace(";", "").split()[0]
cursor.execute(f"SELECT * FROM {safe_table_name} LIMIT 100")
这段代码做了三件事:
| 处理动作 | 作用 |
|---|---|
| 去掉反引号字符 | 避免通过反引号构造异常表名 |
去掉分号 ; |
降低一条输入里拼接多条 SQL 的风险 |
split()[0] 取第一段 |
避免表名后面继续夹带额外 SQL 片段 |
| 工具 | SQL 来源 |
|---|---|
get_table_data |
系统固定生成 SELECT * ... |
execute_sql_query |
模型根据用户问题生成或整理出来 |

浙公网安备 33010602011771号