尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

用LLM搭建自然语言数据库查询机器人:Text-to-SQL实战

用LLM搭建自然语言数据库查询机器人:Text-to-SQL实战 每天都在跟数据库打交道却又不得不反复在“写 SQL”和“等数据”之间来回切换这应该是很多 Web 应用开发者的共同痛点。更麻烦的是业务同事或者运营同学经常需要临时查数据他们不会 SQL只能提需求、排队、等开发排期。本文要解决的问题就是如何用 LLM大语言模型给 Web 应用接入一个“数据库查询机器人”让用户用自然语言就能查数据库。整个方案不追求大而全而是围绕一条一天内能走通的落地路线展开覆盖核心原理、环境准备、完整代码、常见问题和工程实践。这套方案适合三类读者第一类是后端开发者想给内部管理系统加一个“自然语言查数”入口第二类是正在做 AI 应用落地的工程师想快速验证 Text-to-SQL 的真实效果第三类是技术管理者想评估 LLM 查询机器人在业务中的可行性和风险边界。读完本文后你将掌握一个最简可运行的 LLM Database Query Bot 的实现方式能够把它接进 FastAPI Web 应用同时了解如何控制 SQL 生成的安全性和准确性。1. 背景与核心概念1.1 什么是 LLM-Powered Database Query BotLLM-Powered Database Query Bot中文可以理解为“由大语言模型驱动的数据库查询机器人”。它的核心价值在于用户不需要学习 SQL 语法也不需要知道数据表结构只要用日常语言描述查询需求机器人就能自动生成 SQL、执行查询并把结果整理成人类能直接看懂的内容。一个典型的交互过程如下用户输入“上个月每个地区的订单总额是多少”系统内部生成 SQLSELECT region, SUM(amount) FROM orders WHERE order_date 2024-08-01 AND order_date 2024-09-01 GROUP BY region;系统执行 SQL并把查询结果转换为自然语言回复“上个月各地区订单总额分别为华东 120 万元、华南 98 万元……”这件事在传统开发模式下很难做因为自然语言到 SQL 的映射需要理解表结构、字段含义、业务口径和 SQL 语法。而大语言模型在代码生成和语义理解上的能力恰好让这条路线变得可行。不过需要提前说明的是它不是“银弹”如果业务口径复杂、表结构混乱、数据量大仍然需要大量工程化约束才能稳定运行。1.2 它解决什么业务问题在企业内部数据查询需求长期存在一个结构性矛盾数据在数据库里但大多数业务人员不具备 SQL 能力。常见的解法有几种让业务人员学习 SQL门槛高、培训成本大。由开发人员代查占用研发时间需求排队周期长。构建固定报表系统开发成本高且无法覆盖临时性查询。LLM 查询机器人正好切入这个空白地带。它能让业务人员用自然语言提问系统自动完成“语义理解 → SQL 生成 → 查询执行 → 结果解释”的全链路。对于 Web 应用来说这意味着可以直接在页面里嵌入一个类似聊天窗口的查询入口让用户在权限允许的范围内自助取数。1.3 Text-to-SQL 与传统查询方案的区别Text-to-SQL 是自然语言处理领域的一个经典任务目标是让模型把自然语言问题转换成可执行的 SQL 语句。传统方案依赖规则模板和词典匹配比如预先定义“订单”“金额”“时间”等关键词与表字段的映射关系。这种方案适合字段少、查询模式固定的场景但一旦业务复杂规则数量会爆炸式增长维护成本很高。LLM 方案的最大不同在于它把“语义理解”和“SQL 生成”交给了预训练模型不需要我们手工编写大量规则。它天然能处理同义表达、模糊指代、复杂条件组合等问题。但 LLM 也带来了新的挑战比如生成错误 SQL、编造不存在的字段名、忽略权限边界等。因此实际落地时不能只把 LLM 当成“SQL 生成器”来用还要在它的外面包一层安全控制逻辑。1.4 开发者在落地前需要知道的三个事实第一大模型不保证 100% 生成正确 SQL必须设计校验和重试机制。第二查询机器人的本质仍然是“执行数据库操作”所以数据库权限控制、敏感数据保护、SQL 注入风险必须前置考虑。第三Prompt 工程和 Schema 设计直接决定成功率跳过这一步直接调用 LLM大概率只能做出一个 Demo而不是一个能用的功能。把这三个事实放在心里后面看代码的时候就更容易理解“为什么每一步都要这么做”。2. 环境准备与版本说明2.1 技术选型说明本文的示例采用 Python 语言Web 框架使用 FastAPI数据库使用 SQLite。选择 SQLite 主要是为了演示方便它不需要额外安装数据库服务文件即库适合快速跑通流程。如果你的生产环境使用 MySQL 或 PostgreSQL核心思路完全一致只需要替换数据库连接驱动和连接参数即可。LLM 调用部分本文采用 OpenAI 兼容的 HTTP 接口方式而不是绑定某个具体厂商的 SDK。这样做的好处是无论是 OpenAI、DeepSeek、通义千问、Moonshot 还是其他提供兼容接口的服务都可以通过修改环境变量快速切换。相关热搜词里能看到大量关于“免费 LLM 模式”“LLM API 未配置”的讨论实际开发中这确实是第一个容易卡住的地方。版本方面本文示例以常见环境为准Python 使用 3.10 及以上版本FastAPI 使用 0.100 以上版本即可。但由于依赖库迭代速度较快建议你在运行时以pip install实际安装到的最新稳定版本为准不要死盯着某个固定版本号。2.2 安装依赖创建一个项目目录并准备虚拟环境mkdir llm-query-bot cd llm-query-bot python -m venv venv source venv/bin/activate # Windows 下使用 venv\Scripts\activate接着安装依赖。核心依赖包括 FastAPIWeb 服务、uvicornASGI 服务器、SQLAlchemy数据库操作、openaiOpenAI 兼容客户端库。pip install fastapi uvicorn sqlalchemy openai python-dotenv如果你的网络环境不方便安装 openai 库也可以直接用requests调用 HTTP 接口。本文为了通用性示例代码采用requests方式实现 LLM 调用避免绑定具体 SDK 版本这样你只需要关注接口协议而不需要担心 SDK 升级带来的破坏性变更。因此再补装一个 requestspip install requests同时创建一个.env文件用于保存 API Key 和接口地址LLM_API_KEYyour_api_key_here LLM_BASE_URLhttps://api.example.com/v1 LLM_MODELgpt-4o-mini DATABASE_URLsqlite:///./app.db这里的LLM_BASE_URL需要根据你实际使用的服务商填写不要照抄。2.3 项目结构规划为了让代码逻辑清晰我们按职责拆分为下面几个模块llm-query-bot/ ├── .env ├── requirements.txt ├── app/ │ ├── __init__.py │ ├── database.py # 数据库连接与 Schema 提取 │ ├── llm_client.py # LLM 接口封装 │ ├── query_bot.py # 查询机器人核心逻辑 │ ├── security.py # SQL 安全校验 │ └── main.py # FastAPI 入口 └── templates/ └── index.html # 简易前端页面这个结构不是必须的但建议遵循“数据库操作、LLM 调用、业务编排、接口暴露”四层分离的原则。这样后续替换数据库、更换模型供应商、增加安全策略时都不需要改动全部代码。3. 核心原理拆解3.1 查询机器人的完整工作流LLM 数据库查询机器人的工作流程可以拆成五个阶段用户输入自然语言问题。系统从数据库中提取 Schema 信息也就是表名、字段名、字段类型、备注信息等。系统把 Schema 和用户问题拼接成 Prompt发送给 LLM。LLM 返回 SQL 语句系统进行安全校验。系统执行 SQL把查询结果交给 LLM 或模板转换为自然语言回复。这五个阶段中最容易被忽略的是第 2 步。我们不要试图让 LLM“猜测”数据库里有什么表、有什么字段而是要把真实存在的表结构主动提供给模型。这个过程在业界被称为 Schema Grounding也就是“模式接地”。Schema 信息越准确LLM 生成的 SQL 就越可靠。3.2 Prompt 设计的关键策略Prompt 是 Text-to-SQL 成功率的核心杠杆。一个可用的 System Prompt 至少应该包含以下内容角色定义你是一个专业的 SQL 工程师负责根据用户问题生成 SQL 查询语句。数据库方言说明你是针对 SQLite / MySQL / PostgreSQL 哪一种方言。Schema 信息列出所有可用的表和字段。规则约束只允许生成 SELECT 查询不允许修改数据不允许删除表字段名必须来自给定 Schema。输出格式只输出 SQL 语句本身不要输出多余解释。这里给出一段示例 Prompt 模板结构的伪代码思路你是数据库查询助手。数据库使用 SQLite。 以下是数据库表结构 { schema } 请根据用户问题生成 SQL 查询语句。 要求 1. 只生成 SELECT 语句。 2. 字段名和表名必须来自上面的表结构。 3. 不要使用 DELETE、UPDATE、INSERT、DROP、ALTER 等语句。 4. 如果用户的问题无法用给定表结构回答请输出 ERROR。 5. 只输出 SQL 语句不要输出其他内容。 用户问题{ question }把 Schema 放在 Prompt 中可以让模型获得“当前数据库长什么样”的上下文。这种方案在表数量少、字段量可控时效果最好。如果表数量非常多超出了模型上下文窗口的限制就需要引入更复杂的 Schema 裁剪策略比如根据用户问题先用向量检索召回相关表再把相关表结构送给模型。这是后续优化的方向本文暂不展开。3.3 SQL 生成后的安全校验LLM 生成 SQL 之后绝对不能直接拿去执行。哪怕 Prompt 中已经约束了“只生成 SELECT”模型仍然可能因为各种原因输出危险语句。安全校验层是查询机器人的底线。基本校验逻辑包括使用正则或 SQL 解析器检查语句前缀只允许 SELECT。禁止多条 SQL 语句同时执行。如果在语句中检测到分号后还有内容直接拒绝。禁止常见危险关键字如 DELETE、UPDATE、DROP、ALTER、INSERT、CREATE、ATTACH。限制查询结果返回行数统一加上LIMIT子句。使用数据库只读账号从权限层面兜底。这里特别强调正则校验不是万能的它只能挡住明显恶意或明显违规的语句。真正可靠的安全保障是数据库账号本身只有只读权限。示例中我们使用一个单独创建的只读账号来执行 SQL即使 LLM 生成了危险语句数据库也会拒绝执行。3.4 结果解释环节查询机器人执行 SQL 后返回给用户的不能是二维表数组而应该是自然语言的答案。这里有两种常见做法。第一种是纯模板方案把查询结果拼接成表格字符串再用模板生成描述。优点是成本低、速度快、稳定性高缺点是回答风格比较死板。第二种是 LLM 总结方案把查询结果 JSON 和用户问题一起再发给 LLM让它生成自然语言总结。优点是对用户更友好能处理汇总、对比、异常发现等复杂表达缺点是增加了一次 LLM 调用成本和延迟都会上升。在实际项目中建议先返回结构化的表格数据同时给用户一个“总结回答”。如果对成本敏感可以先做模板方案后续再叠加 LLM 总结。4. 完整实战案例下面进入代码部分。我们将从零搭建一个可以在 Web 页面中交互的 LLM 数据库查询机器人。为了突出重点本文先初始化一个示例数据库然后依次实现数据库操作、LLM 调用、查询机器人核心逻辑、Web 接口和前端页面。4.1 初始化示例数据库为了演示我们创建一个电商订单相关的 SQLite 数据库包含用户表、订单表和订单明细表。这个结构能覆盖常见的 Join、聚合、分组、时间过滤等查询场景。文件路径app/database.pyimport os from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker DATABASE_URL os.getenv(DATABASE_URL, sqlite:///./app.db) engine create_engine( DATABASE_URL, connect_args{check_same_thread: False} if DATABASE_URL.startswith(sqlite) else {}, ) SessionLocal sessionmaker(bindengine, autoflushFalse, autocommitFalse) def init_db(): 初始化示例表和数据 with engine.begin() as conn: conn.execute(text( CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, city TEXT NOT NULL, created_at TEXT NOT NULL ) )) conn.execute(text( CREATE TABLE IF NOT EXISTS orders ( id INTEGER PRIMARY KEY, user_id INTEGER NOT NULL, amount REAL NOT NULL, status TEXT NOT NULL, order_date TEXT NOT NULL ) )) conn.execute(text( CREATE TABLE IF NOT EXISTS order_items ( id INTEGER PRIMARY KEY, order_id INTEGER NOT NULL, product_name TEXT NOT NULL, quantity INTEGER NOT NULL, price REAL NOT NULL ) )) with engine.begin() as conn: conn.execute(text(DELETE FROM order_items)) conn.execute(text(DELETE FROM orders)) conn.execute(text(DELETE FROM users)) conn.execute(text( INSERT INTO users (id, name, city, created_at) VALUES (1, 张三, 上海, 2024-01-10), (2, 李四, 北京, 2024-02-15), (3, 王五, 广州, 2024-03-20) )) conn.execute(text( INSERT INTO orders (id, user_id, amount, status, order_date) VALUES (101, 1, 3200.00, 已完成, 2024-05-01), (102, 2, 1500.50, 已完成, 2024-05-03), (103, 3, 2800.00, 待付款, 2024-05-05), (104, 1, 990.00, 已完成, 2024-06-11), (105, 2, 4200.00, 已退款, 2024-06-15) )) conn.execute(text( INSERT INTO order_items (order_id, product_name, quantity, price) VALUES (101, 手机, 1, 3200.00), (102, 键盘, 2, 750.25), (103, 显示器, 1, 2800.00), (104, 鼠标, 3, 330.00), (105, 笔记本, 1, 4200.00) ))这里使用 SQLAlchemy 只是为了统一数据库操作方式。如果你更习惯原生 sqlite3也可以替换。初始化脚本在每次启动时都会清空并重建示例数据避免测试过程产生脏数据。生产环境不要这样设计这里只为了演示方便。4.2 实现 Schema 提取LLM 需要知道数据库长什么样所以我们先写一个获取表结构的函数。这里简单直接地查询 SQLite 的系统表sqlite_master和PRAGMA table_info来组装 Schema 文本。还是在app/database.py中追加def get_schema() - str: 获取数据库表结构返回可读的 Schema 文本 schema_lines [] with engine.connect() as conn: tables conn.execute( text(SELECT name FROM sqlite_master WHERE typetable AND name NOT LIKE sqlite_%) ).fetchall() for table in tables: table_name table[0] columns conn.execute(text(fPRAGMA table_info({table_name}))).fetchall() col_desc [] for col in columns: col_desc.append(f{col[1]} {col[2]}) schema_lines.append(f表 {table_name}: , .join(col_desc)) return \n.join(schema_lines)这里要注意table_name来自数据库系统表不包含用户输入所以拼进 SQL 语句没有注入风险。实际项目中如果表名来自外部输入必须先做白名单校验。如果数据库是 MySQL可以通过information_schema.columns表查询字段信息。核心思路差不多都是把元数据转成自然语言描述。这一段的输出效果如下表 users: id INTEGER, name TEXT, city TEXT, created_at TEXT 表 orders: id INTEGER, user_id INTEGER, amount REAL, status TEXT, order_date TEXT 表 order_items: id INTEGER, order_id INTEGER, product_name TEXT, quantity INTEGER, price REAL4.3 封装 LLM 客户端为了不绑定特定厂商 SDK我们用requests调用 OpenAI 兼容接口。这个接口格式被大部分模型服务商支持只是base_url和api_key不同。文件路径app/llm_client.pyimport os import requests class LLMClient: def __init__(self): self.api_key os.getenv(LLM_API_KEY) self.base_url os.getenv(LLM_BASE_URL, https://api.openai.com/v1) self.model os.getenv(LLM_MODEL, gpt-4o-mini) def chat(self, system_prompt: str, user_prompt: str, temperature: float 0.0) - str: 调用 LLM 对话接口返回文本内容 if not self.api_key: raise RuntimeError(未配置 LLM_API_KEY请在 .env 文件中设置) url f{self.base_url}/chat/completions headers { Authorization: fBearer {self.api_key}, Content-Type: application/json, } payload { model: self.model, messages: [ {role: system, content: system_prompt}, {role: user, content: user_prompt}, ], temperature: temperature, } resp requests.post(url, headersheaders, jsonpayload, timeout60) resp.raise_for_status() data resp.json() return data[choices][0][message][content].strip()这里的temperature默认设为 0.0因为 SQL 生成是确定性任务我们不希望模型“自由发挥”。温度越低输出越稳定。如果发现模型频繁返回格式不正确的 SQL可以先检查温度是否过高。4.4 实现 SQL 安全校验文件路径app/security.pyimport re # 禁止出现的危险关键字 FORBIDDEN_KEYWORDS [ DELETE, UPDATE, INSERT, DROP, ALTER, CREATE, ATTACH, DETACH, REINDEX, VACUUM, ] def validate_sql(sql: str) - str: 校验生成的 SQL 是否安全返回清理后的 SQL。 如果校验不通过抛出 ValueError。 sql sql.strip().rstrip(;).strip() # 只允许以 SELECT 开头 if not re.match(r^SELECT\s, sql, re.IGNORECASE): raise ValueError(只允许执行 SELECT 查询) # 检查危险关键字使用词边界避免误匹配字段名包含的情况 for keyword in FORBIDDEN_KEYWORDS: if re.search(rf\b{keyword}\b, sql, re.IGNORECASE): raise ValueError(fSQL 中包含禁止的关键字: {keyword}) # 禁止多条语句简单判断分号后是否还有内容 if ; in sql: raise ValueError(不支持多条 SQL 语句) return sql这个校验函数虽然简单但已经能挡住大部分误生成和恶意生成。强调一下它不能替代数据库账号权限控制安全必须是纵深防御不能只依赖一层校验。4.5 实现查询机器人核心逻辑文件路径app/query_bot.py查询机器人负责串联整个流程。它要完成三件事一是把 Schema 和用户问题组装成 Prompt 并调用 LLM 生成 SQL二是校验 SQL三是执行 SQL 并返回结果。为了让结果更友好我们也支持把结果交给 LLM 做自然语言总结。import json from sqlalchemy import text from .database import engine, get_schema from .llm_client import LLMClient from .security import validate_sql SYSTEM_PROMPT_TEMPLATE 你是数据库查询助手。数据库使用 SQLite。 以下是数据库表结构 {schema} 请根据用户问题生成 SQL 查询语句。 要求 1. 只生成 SELECT 语句禁止生成 INSERT、UPDATE、DELETE、DROP、ALTER 等语句。 2. 表名和字段名必须来自上面的表结构禁止虚构不存在的字段。 3. 如果用户的问题无法用给定表结构回答请输出 ERROR。 4. 不要使用事务。 5. 只输出 SQL 语句本身不要输出任何解释。 用户问题{question} SUMMARIZE_PROMPT_TEMPLATE 用户的问题是{question} 数据库查询结果如下 {result} 请用简洁的中文回答用户的问题。不要虚构数据不要编造查询结果中不存在的信息。 class QueryBot: def __init__(self): self.llm LLMClient() def generate_sql(self, question: str) - str: schema get_schema() system_prompt SYSTEM_PROMPT_TEMPLATE.format(schemaschema, questionquestion) raw_sql self.llm.chat(system_prompt, question) # 去掉可能的 markdown 代码块 raw_sql raw_sql.replace(sql, ).replace(, ).strip() return validate_sql(raw_sql) def execute_sql(self, sql: str) - list[dict]: with engine.connect() as conn: result conn.execute(text(sql)) columns list(result.keys()) rows result.fetchall() return [dict(zip(columns, row)) for row in rows] def query(self, question: str, summarize: bool True) - dict: sql self.generate_sql(question) result self.execute_sql(sql) # 限制返回给前端的数据量避免结果过大 limited_result result[:50] answer None if summarize: summary_prompt SUMMARIZE_PROMPT_TEMPLATE.format( questionquestion, resultjson.dumps(limited_result, ensure_asciiFalse, indent2) ) answer self.llm.chat(你是一个数据汇报助手。, summary_prompt) return { sql: sql, columns: list(limited_result[0].keys()) if limited_result else [], rows: limited_result, answer: answer, }这段代码有几个细节值得注意。第一generate_sql中把模型可能输出的 Markdown 代码块符号去掉了因为很多模型在生成 SQL 时喜欢带上sql标记。第二execute_sql使用 SQLAlchemy 的text()执行 SQL查询结果会转换成字典列表方便前端渲染。第三最终返回的 rows 做了截断避免一次性返回上百万行数据打爆 Web 接口。4.6 创建 FastAPI 接口文件路径app/main.py接下来我们用 FastAPI 把查询机器人暴露成 API同时提供一个页面入口。from fastapi import FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, FileResponse from pydantic import BaseModel from .database import init_db from .query_bot import QueryBot app FastAPI(titleLLM Database Query Bot) bot QueryBot() app.add_middleware( CORSMiddleware, allow_origins[*], allow_methods[*], allow_headers[*], ) class QueryRequest(BaseModel): question: str summarize: bool True app.on_event(startup) def startup(): init_db() app.get(/) def index(): return FileResponse(templates/index.html) app.post(/api/query) def query_api(req: QueryRequest): if not req.question.strip(): raise HTTPException(status_code400, detail问题不能为空) try: return bot.query(req.question, summarizereq.summarize) except ValueError as e: raise HTTPException(status_code400, detailstr(e)) except Exception as e: # 生产环境不要直接把异常细节返回给客户端这里仅作演示 raise HTTPException(status_code500, detailf查询失败: {str(e)})这里on_event(startup)是 FastAPI 的旧写法新版本推荐使用 lifespan 上下文管理器。但由于不同版本差异较大为了减少兼容性问题本文使用大多数版本仍然可用的on_event写法。如果你使用的是最新版 FastAPI可以按官方文档迁移到 lifespan。启动服务的命令uvicorn app.main:app --reload --port 80004.7 编写简易前端页面文件路径templates/index.html为了不做前后端分离也能立即体验效果我们写一个简单的 HTML 页面。它包含一个输入框、一个发送按钮和一个结果展示区域。用户输入问题后页面会调用/api/query接口并把返回的 SQL 和结果展示出来。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleLLM 数据库查询机器人/title style body { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, sans-serif; max-width: 900px; margin: 40px auto; padding: 0 20px; background: #f7f7f8; } .card { background: #fff; border-radius: 12px; padding: 24px; box-shadow: 0 2px 8px rgba(0,0,0,0.06); } h1 { font-size: 22px; color: #1a1a1a; } textarea { width: 100%; height: 80px; border: 1px solid #ddd; border-radius: 8px; padding: 10px; font-size: 15px; } button { margin-top: 10px; padding: 10px 20px; border: none; background: #1677ff; color: #fff; border-radius: 8px; font-size: 15px; cursor: pointer; } button:disabled { background: #aaa; cursor: not-allowed; } .result { margin-top: 20px; } .sql-box { background: #f6f8fa; border-radius: 8px; padding: 12px; font-family: monospace; font-size: 13px; white-space: pre-wrap; word-break: break-all; } table { width: 100%; border-collapse: collapse; margin-top: 16px; font-size: 14px; } th, td { padding: 8px 10px; border: 1px solid #eee; text-align: left; } th { background: #fafafa; font-weight: 600; } .answer { margin-top: 16px; padding: 12px; background: #f0f9eb; border-radius: 8px; color: #333; } /style /head body div classcard h1LLM 数据库查询机器人/h1 p输入自然语言问题例如em“5月份已完成订单的总金额是多少”/em/p textarea idquestion placeholder请输入你的查询问题.../textarea div button idsendBtn onclicksendQuery()发送查询/button /div div classresult idresult styledisplay:none; h3生成的 SQL/h3 div classsql-box idsql/div div classanswer idanswer/div h3查询结果/h3 table idresultTable thead idresultHead/thead tbody idresultBody/tbody /table /div /div script async function sendQuery() { const question document.getElementById(question).value.trim(); if (!question) { alert(请输入查询问题); return; } const btn document.getElementById(sendBtn); btn.disabled true; btn.textContent 查询中...; try { const resp await fetch(/api/query, { method: POST, headers: {Content-Type: application/json}, body: JSON.stringify({question: question, summarize: true}) }); const data await resp.json(); if (!resp.ok) { throw new Error(data.detail || 查询失败); } document.getElementById(result).style.display block; document.getElementById(sql).textContent data.sql; document.getElementById(answer).textContent data.answer || ; const thead document.getElementById(resultHead); const tbody document.getElementById(resultBody); thead.innerHTML ; tbody.innerHTML ; if (data.columns.length 0) { const tr document.createElement(tr); const td document.createElement(td); td.textContent 无数据; td.style.textAlign center; tr.appendChild(td); tbody.appendChild(tr); return; } const headTr document.createElement(tr); data.columns.forEach(col { const th document.createElement(th); th.textContent col; headTr.appendChild(th); }); thead.appendChild(headTr); data.rows.forEach(row { const tr document.createElement(tr); data.columns.forEach(col { const td document.createElement(td); td.textContent row[col] ?? NULL; tr.appendChild(td); }); tbody.appendChild(tr); }); } catch (e) { alert(e.message); } finally { btn.disabled false; btn.textContent 发送查询; } } /script /body /html4.8 运行与验证启动服务后打开浏览器访问http://localhost:8000输入问题“5月份已完成订单的总金额是多少”预期会得到类似下面的结果生成的 SQLSELECT SUM(amount) FROM orders WHERE status 已完成 AND order_date LIKE 2024-05%;查询结果4700.5自然语言回答“2024 年 5 月已完成订单的总金额为 4700.5 元。”再来测试一个多表关联查询“上海用户的订单数量是多少”预期 SQL 会关联users和orders两张表通过user_id连接并使用city过滤条件。如果用户输入“删除所有订单”安全校验层会拦截接口返回 400提示包含禁止的关键字。这是符合预期的因为我们的机器人只允许查询不允许修改数据。5. 常见问题与排查思路5.1 LLM 生成的 SQL 经常带有多余内容错误现象模型返回的 SQL 前面带有“以下是生成的 SQL”这类解释文字或者包裹在 Markdown 代码块中。解决思路是先在代码层面对生成结果做清理比如去掉sql和再按行取第一个以 SELECT 开头的语句。更稳妥的做法是在 Prompt 中明确要求“只输出 SQL不要输出解释”同时在代码里做二次清洗。5.2 模型生成不存在的字段名这是 Text-to-SQL 最常见的失败模式之一。根本原因通常是 Schema 信息不完整、字段名称含义不明确或者模型上下文窗口太小导致漏掉部分字段。排查时可以先把get_schema()的返回结果打印出来看 Prompt 里到底给模型提供了哪些信息。如果是字段名本身太晦涩比如a1、b2建议在数据库层增加字段 COMMENT并在 Schema 文本中带上中文注释。5.3 查询结果太大导致接口超时当用户问“统计所有订单”这类问题时SQL 可能返回数十万行数据导致接口延迟或内存溢出。解决方法是强制添加LIMIT比如在安全校验阶段自动为 SQL 末尾追加LIMIT 100。注意如果原 SQL 已经包含LIMIT需要先去掉再追加或者使用正则判断。另一个优化方向是检测到聚合类问题时不返回明细只返回汇总结果。5.4 LLM API 调用超时大模型接口的延迟通常在几秒到几十秒之间受模型负载、输入长度影响较大。排查时可以先确认网络连通性再检查 Prompt 长度。如果 Schema 很长每次请求都带全量 Schema 会导致 token 消耗大、响应慢。此时可以考虑对 Schema 做裁剪只保留相关的表和字段。代码中可以把timeout60调大但更好的方向是减少输入长度。5.5 为什么必须用只读账号有些开发者觉得安全校验已经做了就不需要再单独建只读账号。这个想法在 Demo 阶段没问题但生产环境风险很大。正则校验可以被绕过比如注释符号、编码混淆等手段都可能绕过简单过滤。使用数据库只读账号后即使 LLM 生成了恶意语句数据库权限层面也会直接拒绝这是纵深防御的意义所在。生产环境的数据库账号不应该拥有 DDL、DML 权限只保留 SELECT 权限。5.6 常见问题汇总表问题现象常见原因解决思路SQL 生成结果带解释文字Prompt 约束不足或模型未遵循增加输出格式约束代码层二次清洗字段名不存在Schema 信息缺失或字段名歧义完善 Schema补充字段注释执行结果过大缺少 LIMIT 限制自动追加 LIMIT限制返回行数接口响应超时Prompt 过长或模型负载高裁剪 Schema、切换更快模型危险语句未被拦截安全校验规则不完善使用数据库只读账号兜底中文列名乱码字符集配置问题确保连接字符串指定 UTF-8多轮对话上下文丢失没有维护历史消息增加会话记忆或让用户重新描述需求6. 最佳实践与工程建议6.1 从权限与安全角度设计系统边界在生产环境绝对不要让 LLM 生成的 SQL 直接连生产库执行。更合理的做法是为查询机器人单独创建一个账号授予只读权限并且限制只能访问特定的表或视图。如果可能优先让机器人查询数据仓库中的汇总表或宽表而不是直接查询高频写入的在线业务表。这样既能减少对业务库的性能影响也能降低数据泄露风险。对于含敏感信息的列比如手机号、身份证号应该在数据库层或应用层做脱敏处理避免自然语言查询接口成为数据泄露通道。6.2 Schema 裁剪与维护是核心工程很多团队在 Text-to-SQL 落地时忽视 Schema 维护认为“把表结构丢给模型就行了”。其实 Schema 信息的质量直接决定生成 SQL 的准确性。建议为每个表字段补充业务注释比如status字段要说明“已完成、待付款、已退款”等枚举含义。当表数量超过 20 张时就不再适合全量塞入 Prompt需要引入字段级别的召回机制比如先用向量检索把用户问题映射到相关表和字段再构建精简后的 Schema Prompt。这一步是 Text-to-SQL 从 Demo 走向生产的关键分水岭。6.3 引入查询反馈与结果评估Text-to-SQL 很难做到一次完美生产环境必须引入反馈闭环。一个简单有效的方法是在前端增加“结果是否有用”的点赞和点踩按钮把用户反馈回传到数据库。当用户点踩时记录当时的用户问题、生成的 SQL、数据库报错信息形成一份“失败样本库”。工程师定期分析这些样本更新 Prompt、修正 Schema 注释、补充 Few-shot 示例持续提升准确率。没有反馈闭环的查询机器人效果只会停留在上线当天的水平不会自动变好。6.4 控制成本与延迟大语言模型 API 按 token 计费查询机器人的成本主要来自两部分一是生成 SQL 的请求二是如果启用总结回答还会产生第二次请求。控制成本可以从几个方向入手尽量使用性价比高的模型比如轻量级模型用于 SQL 生成不一定要用最强模型。对高频问题增加缓存把“用户问题 SQL 结果”缓存起来命中缓存时直接返回。对 Schema 做裁剪减少每次请求的输入 token。对总结回答增加开关内部系统可以默认只返回表格不调用 LLM 总结。延迟方面的优化思路类似。如果用户能接受两秒左右的等待当前方案足够。如果要求毫秒级响应就不适合走“每次请求都调 LLM”的路线而应该提前把常见查询预生成好走缓存或固定接口。6.5 日志、监控与可审计性所有查询机器人发起的 SQL 都必须记录日志。日志内容至少包括用户标识、原始问题、生成 SQL、执行状态、返回行数、耗时、LLM 调用消耗 token 数。这样做的目的不仅仅是排查问题更是为了满足审计要求。如果某个用户通过自然语言查询获取了超出权限的数据事后需要有迹可循。另外数据库慢查询日志也要开启因为 AI 生成的 SQL 往往不是最优执行计划可能全表扫描。监控到慢查询后需要根据 SQL 特征优化索引或者增加查询限制。7. 总结与下一步学习路线今天这套实践我们从零搭建了一个基于 LLM 的数据库查询机器人核心链路是“自然语言 → Schema Prompt → SQL 生成 → 安全校验 → 执行 → 结果解释”。代码量不大但它包含了一个可落地功能所需要的核心模块数据库访问、LLM 客户端、查询编排、安全校验、Web 接口和前端页面。你可以直接把它当作一个内部数据查询工具的原型也可以基于这套结构继续扩展。如果接下来想继续深入我建议按下面的顺序学习。第一深入学习 Few-shot Prompting收集 5 到 10 个典型查询问题把问题和正确答案一起放进 Prompt往往能明显提升准确率。第二学习 Schema Linking也就是如何根据用户问题自动筛选相关表和字段这能解决表数量多时 Prompt 过长的问题。第三引入 RAG 或向量数据库把业务口径、查询规范、历史错误样本存入知识库让机器人在生成 SQL 前先检索相关规范。第四考虑把查询机器人接入飞书、钉钉或企业微信等内部 IM通过机器人对话完成查询这会让工具的触达范围更广。最后提醒一点LLM 查询机器人真正上线之前一定要和业务方一起梳理“哪些数据可以查、哪些数据不能查、口径是什么”。技术可以让用户用自然语言查数但业务规则和权限边界需要人来定义。先把限定范围划清楚再放开给用户使用这个工具才能真正成为提效助手而不是新的风险点。如果你在实践过程中遇到了其他问题欢迎在评论区把你的报错信息和 Prompt 配置发出来我们可以一起分析。动手跑一遍比只看文章理解深得多。
返回列表