数据库查询智能体

数据库要三步走

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 条销售记录,覆盖区域分析

image

核心字段:

表名 关键字段 适合回答的问题
drugs drug_idgeneric_namebrand_nameapproval_numberdosage_formtherapeutic_area 某个药品是什么、属于什么治疗领域
inventory inventory_iddrug_idbatch_numberquantity_on_handwarehouse_locationexpiry_date 某个药品库存多少、在哪个仓库、何时过期
sales_records sale_iddrug_idsale_datequantity_soldunit_pricetotal_amountregion 某个药品卖了多少、销售额多少、区域表现

实现数据库工具:
image

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
  1. 给 hostport、字符集、排序规则和 SQL 模式等配置默认值。
  2. 检查 userpassworddatabase 是否存在。

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 模型根据用户问题生成或整理出来

 

posted @ 2026-07-06 14:08  幻影之舞  阅读(2)  评论(0)    收藏  举报