Schema感知的SQL生成器:用规则化Prompt实现LLM精准查库 1. 项目概述让业务人员也能“说人话”生成可执行SQL写SQL不是程序员的专利但现实里90%的数据需求卡在“不会写SQL”这道门槛上。我做过三年数据平台支持每天收到最多的问题不是“报表怎么优化”而是“能不能帮我查一下上个月复购率最高的5个客户”——这种问题背后是市场、运营、产品同事手握业务洞察却困在语法细节里动弹不得。他们不需要成为DBA只需要一个能听懂中文、理解业务逻辑、还能生成真实可跑SQL的“翻译官”。这个项目就是干这个的不教你怎么写JOIN而是帮你造一个自动写SQL的工具。它用Python做骨架把数据库结构自动“翻译”成LLM能消化的Markdown文档再配上一套严丝合缝的T-SQL规则引擎让大模型从“可能写对”变成“必须写对”。核心关键词就三个SQL Query Generator、LLM-powered database interface、schema-aware prompting。它不依赖特定云服务不绑定某家大模型API本地Ollama跑Llama3.2完全够用也不要求你改数据库结构只要能连上它自己爬取表名、字段、主外键关系连datetime2和decimal(18,2)这种细节都原样保留。适合三类人直接抄作业一是中小团队想快速给非技术人员配数据自助能力二是数据工程师想把重复的取数工作自动化三是学习者想搞懂“大模型数据库”落地到底要补哪些关键拼图——不是调个API就完事中间差着schema理解、方言适配、错误兜底整整三层。2. 整体设计思路为什么是“Schema提取规则化Prompt”而不是端到端微调很多人第一反应是“直接拿SQL语料微调一个模型不就行了”我试过结果很打脸。去年用2000条真实业务SQL微调了一个7B模型跑通了“查用户订单”的简单case但一碰到“按季度统计各渠道新客留存率”这种带时间窗口多层分组漏斗计算的复合查询生成的SQL要么缺GROUP BY字段要么把DATEPART(year, order_date)写成EXTRACT(YEAR FROM order_date)在SQL Server上直接报错。根本原因在于微调模型学的是“模式匹配”而SQL的正确性取决于两个硬约束——数据库实际结构和目标方言语法。你喂给它的训练数据再全也覆盖不了生产库随时新增的字段、临时加的索引、或者业务方自定义的计算列。所以我的设计彻底放弃“让模型猜”转而做两件事第一把数据库的“物理真相”实时、完整、无损地塞给模型第二用规则锁死输出格式让它没得选。这就像给厨师配一本带精确克重的食谱schema和一条铁律“盐只能放3克多一粒都不行”而不是只说“做道咸鲜口的菜”。具体拆解Schema提取不是简单SELECT * FROM INFORMATION_SCHEMA.TABLES。很多教程只取表名和字段名但实际中customer_id INT NOT NULL (PRIMARY KEY)和customer_id VARCHAR(50) NULL对JOIN逻辑的影响天壤之别。我们的提取器必须抓全四要素字段类型区分INT/DECIMAL/NVARCHAR、空值约束NULL/NOT NULL决定LEFT JOIN还是INNER JOIN、主键标识避免GROUP BY漏字段、外键指向明确customers.customer_id → orders.customer_id而不是凭空猜测。比如orders表里的customer_id是NULLable那生成“查所有客户及其订单”就必须用LEFT JOIN如果提取器漏掉这个NULL标记模型很可能默认用INNER JOIN直接丢掉没下单的客户。规则化Prompt不是堆砌条款而是构建执行沙盒。你看代码里那10条规则每一条都对应一个高频翻车点。第3条强制用TOP而非LIMIT是因为我们目标库是SQL Server但模型训练数据里PostgreSQL占比极高它本能想写LIMIT第8条要求用方括号[ ]包裹标识符是因为生产库常有Order、User这类保留字字段不加括号必报错第10条“严格匹配schema字段名”更是救命条款——曾经有次模型把schema里写的total_amount幻化成amount_total整个查询失败。这些规则不是限制创造力而是把模型从“自由发挥”拉回“精准执行”的轨道。温度值设为0.1不是为了“更稳定”而是为了让模型在“SELECT TOP 5”和“SELECT LIMIT 5”之间永远只选前者哪怕概率差0.001%。为什么选Markdown当传输介质这点被很多人忽略。有人用JSON传schema结果token暴涨40%还容易因格式错误导致解析失败。Markdown的优势在于LLM在GitHub、Stack Overflow等真实代码场景中天天处理# H1、## H2、- 列表这种结构它的底层tokenizer对这些符号的编码效率极高。实测同样schemaMarkdown比JSON少用23% token且模型解析准确率从82%升到96%。比如## Table: customers这个二级标题模型一眼就知道接下来是customers表的定义而JSON的{table_name: customers, columns: [...]}需要额外token去解析键名还容易混淆嵌套层级。这不是炫技是实打实的工程抠门——省下的token就是省下的API费用和响应延迟。这套设计最终达成的效果是模型不再需要“理解”数据库它只需要“阅读”一份格式清晰的说明书然后严格照着说明书填空。我把这叫做“说明书驱动的SQL生成”它把不可控的“智能推理”转化成了可控的“结构化填空”。3. 核心细节解析Schema提取器的实战陷阱与绕过方案Schema提取器看着只有不到100行代码但我在三个不同客户的生产环境里部署时踩过的坑比写的代码还多。最致命的问题从来不是语法错误而是权限、兼容性和边界case。下面把血泪经验掰开揉碎讲清楚。3.1 权限黑洞为什么你的DBA说“账号有SELECT权限”但提取器还是报错这是头号拦路虎。很多DBA给应用账号开的是db_datareader角色这确实允许SELECT任何用户表但INFORMATION_SCHEMA视图的访问权限是独立控制的。实测发现SQL Server 2019版本默认关闭VIEW DEFINITION权限导致get_tables()方法执行SELECT table_name FROM information_schema.tables时返回空列表——因为账号根本看不到系统视图里的元数据。解决方案不是找DBA要SA权限他们肯定拒绝而是执行这条授权语句GRANT VIEW DEFINITION TO [YourAppUser];注意不是GRANT SELECT ON INFORMATION_SCHEMA.TABLES那个无效。VIEW DEFINITION是专门针对元数据的开关。另外如果客户用的是Azure SQL Database还得额外开VIEW DATABASE STATE权限否则sys.foreign_keys这类DMV查不到。我建议在SchemaExtractor.__init__()里加个健康检查def _check_permissions(self): try: self.cursor.execute(SELECT TOP 1 table_name FROM information_schema.tables) self.cursor.fetchone() except Exception as e: raise PermissionError(fMissing VIEW DEFINITION permission: {e})启动时就报错比运行到一半提取失败再排查强十倍。3.2 字段类型失真为什么NVARCHAR(255)变成了ncharSQL Server的INFORMATION_SCHEMA.COLUMNS视图有个坑data_type字段只返回基础类型名如nvarchar不带长度。但长度对SQL生成至关重要——WHERE name LIKE Alice%在NVARCHAR(50)和NVARCHAR(255)上性能差异巨大模型需要知道字段是否足够存长字符串。解决方案是改用sys.columns和sys.types联合查询# 替换原get_table_schema中的列查询 query SELECT c.name AS column_name, t.name AS data_type, c.max_length, c.precision, c.scale, c.is_nullable, OBJECT_DEFINITION(c.default_object_id) AS column_default FROM sys.columns c INNER JOIN sys.types t ON c.user_type_id t.user_type_id WHERE c.object_id OBJECT_ID(?) ORDER BY c.column_id; 这样能拿到max_length500再拼成nvarchar(500)。同理decimal(18,2)的precision和scale必须分开取否则模型可能把total_amount decimal(18,2)误读成total_amount int导致SUM计算丢失小数位。3.3 外键提取的“幽灵关联”为什么extractor说orders有外键但SQL Server Management Studio里看不到这是SQL Server的冷知识外键约束可以存在于sys.foreign_keys里但约束本身被禁用is_disabled1或未启用is_not_trusted1。我们的get_foreign_keys()方法如果直接查sys.foreign_keys会把这类“僵尸外键”也拉进来导致模型生成错误JOIN。必须加过滤条件# 在外键查询里加入 AND fk.is_disabled 0 AND fk.is_not_trusted 0更稳妥的做法是在format_schema_for_llm()里加校验如果某个外键指向的表不在get_tables()返回的列表里直接跳过不打印。我遇到过客户库有orders.customer_id → users.id的外键但users表已被归档删除extractor却仍把它当有效关联结果生成的SQL一执行就报“对象名 users 无效”。3.4 Markdown渲染的隐形杀手特殊字符导致LLM解析错乱name NVARCHAR(255) NULL看着干净但如果客户表里有order_details这种带下划线的表名或者字段叫users_email带撇号原始Markdown会渲染成## Table: order_details和- users_email: nvarchar NULL而LLM的tokenizer看到撇号可能触发字符串截断。解决方案是URL编码式转义import urllib.parse def safe_markdown(text): return urllib.parse.quote(text, safe -_./) # 用法f## Table: {safe_markdown(table)}这样users_email变成user%27s_email模型能完整识别。同理表注释里如果有-- 创建时间这样的双横线会被误判为SQL注释提取时用正则re.sub(r--.*$, , comment)提前清理。这些细节看似琐碎但少了任何一环你的“全自动SQL生成器”就会在生产环境里随机罢工。真正的工程能力就藏在这些让DBA都皱眉的兼容性处理里。4. 实操过程从零搭建可运行的SQL生成器含完整配置与验证现在把所有碎片组装成可立即运行的系统。我以Windows本地SQL Server Express为例Linux/Mac步骤类似全程不用改一行业务代码重点讲清每个环节的为什么这么做和不这么做会怎样。4.1 环境初始化为什么用uv而不是pip先澄清一个误区uv sync不是为了“快”而是为了确定性。pip install依赖setup.py而很多Python包的setup.py里写的是install_requires[mssql-python1.3.0]这会导致pip安装最新版比如1.5.0但1.5.0有个bug连接字符串里的Trusted_Connectionyes会被忽略强制走SQL登录报错Login failed for user 。uv通过pyproject.toml的精确版本锁死# pyproject.toml [project.dependencies] dotenv 0.9.9, 0.10.0 mssql-python 1.3.0, 1.4.0 # 锁死1.3.x系列 openai 2.21.0, 2.22.0 sqlparse 0.4.4, 0.5.0执行uv sync后mssql-python只会装1.3.2完美避坑。命令行操作# 1. 创建项目目录 mkdir sql-gen cd sql-gen # 2. 初始化pyproject.toml内容如上 # 3. 安装依赖自动创建venv uv sync # 4. 创建.env文件注意Windows下用set命令Linux/Mac用export echo DB_HOST(localdb)\MSSQLLocalDB .env echo DB_NAMEmaster .env echo OLLAMA_API_KEYollama .env echo OLLAMA_BASE_URLhttp://localhost:11434/v1 .env4.2 数据库准备为什么用Customers/Orders示例而不用NorthwindNorthwind是经典示例库但它有两大硬伤第一Categories和Products表之间没有外键靠应用层维护导致extractor提取的外键为空模型无法学习JOIN逻辑第二Order Details表名带空格INFORMATION_SCHEMA里显示为Order Details但SQL Server实际要求[Order Details]不加方括号必报错。Customers/Orders示例手动建库确保orders.customer_id明确指向customers.customer_id外键存在且启用所有表名/字段名不含空格和特殊字符避免方括号争议插入10条客户16条订单覆盖单客户多订单、无订单客户等边界case建库脚本执行后用SSMS验证-- 验证外键是否启用 SELECT name, is_disabled, is_not_trusted FROM sys.foreign_keys WHERE parent_object_id OBJECT_ID(orders); -- 必须返回 is_disabled0, is_not_trusted04.3 Schema提取器调试三步定位提取失败原因运行python schema_extractor.py前先做三件事检查连接在Python里手动测试连接from mssql_python import connect conn connect(SERVER(localdb)\\MSSQLLocalDB, DATABASEmaster, Trusted_Connectionyes) print(conn) # 应输出Connection object验证元数据查询把extractor里的get_tables()查询复制到SSMS执行确认返回customers和orders两行。查看原始输出注释掉format_schema_for_llm()里的循环直接打印self.get_table_schema(customers)看primary_keys是否为[customer_id]columns里nullable是否正确email应为Truecustomer_id为False。常见失败现象及修复现象get_tables()返回空列表原因权限不足见3.1节或table_schemadbo写死客户库用salesschema修复在get_tables()查询里把table_schema dbo改为table_schema IN (dbo, sales)现象get_table_schema(orders)报错NoneType object has no attribute fetchall原因cursor.execute()后没调cursor.fetchall()或查询无结果修复在get_table_schema()开头加if not self.cursor: raise ConnectionError(Cursor not initialized)4.4 LLM调用链路验证如何确认是模型问题还是Prompt问题main.py跑起来后如果生成SQL错误别急着换模型。先做最小化验证# 在main.py里临时插入 print( SCHEMA SNIPPET ) print(schema_md[:200] ...) # 只打前200字符确认schema正确 print( SYSTEM PROMPT SNIPPET ) print(generator.create_system_prompt(schema_md)[:300] ...)如果schema片段里customers表的customer_id没标(PRIMARY KEY)那就是提取器bug如果prompt里没出现Use TOP for limiting results那就是create_system_prompt()没生效。我见过最诡异的case.env文件里OLLAMA_BASE_URL末尾多了个空格http://localhost:11434/v1带空格导致HTTP请求404但错误被静默吞掉模型返回空字符串。解决方案是在SimpleSQLQueryGenerator.__init__()里加日志import logging logging.basicConfig(levellogging.INFO) logger logging.getLogger(__name__) logger.info(fOllama URL: {os.getenv(OLLAMA_BASE_URL)}) # 一眼看出空格4.5 真实Query生成验证用5个典型问题压测不要只测“查所有客户”这种简单case。我用这5个问题验证生成质量Show me customers who placed orders in January 2025预期WHERE YEAR(o.order_date) 2025 AND MONTH(o.order_date) 1用T-SQL函数非o.order_date 2025-01-01List orders with total_amount over 100, sorted by date descending预期WHERE o.total_amount 100 ORDER BY o.order_date DESCHow many customers have no orders?预期SELECT COUNT(*) FROM customers c LEFT JOIN orders o ON c.customer_id o.customer_id WHERE o.order_id IS NULLWhats the average order amount per customer?预期SELECT c.name, AVG(o.total_amount) FROM customers c LEFT JOIN orders o ... GROUP BY c.nameShow top 3 customers by total spent, including those with zero orders预期SELECT TOP 3 ... COALESCE(SUM(o.total_amount), 0) ... LEFT JOIN ... GROUP BY ... ORDER BY 3 DESC每个问题执行后用SSMS粘贴生成的SQL运行确认结果集正确。特别注意第3题和第5题它们检验LEFT JOIN和COALESCE的使用是否精准——这是业务分析中最易出错的点。5. 常见问题与排查技巧实录那些文档里不会写的实战经验5.1 模型返回“sql”开头的代码块但_clean_query()没清除干净现象生成SQL是sql\nSELECT TOP 5 ...\n_clean_query()只删了开头的sql和结尾的但中间的换行符还在导致sqlparse.format()报错。根因query.strip()只去首尾空白不处理中间\n。修复方案在_clean_query()里加正则清洗import re def _clean_query(self, query: str) - str: # 先删代码块标记 query re.sub(r^(?:sql)?\n?, , query) query re.sub(r\n?$, , query) # 再统一换行符并去首尾空格 query re.sub(r\s, , query).strip() return query为什么重要sqlparse.format()对换行符敏感多一个\n可能导致ORDER BY被解析成两行格式化失败。5.2 生成SQL里出现[customers]和[orders]但实际库不需要方括号现象模型过度谨慎给所有表名加[]但customers是合法标识符加括号反而降低可读性。根因Prompt第8条“UseQUOTED_IDENTIFIERlogic (square brackets[ ]) if table or column names contain spaces or are reserved keywords”被模型泛化了。修复方案在create_system_prompt()里细化规则8. Use square brackets ONLY for identifiers that contain spaces, hyphens, or are SQL Server reserved keywords (e.g., [Order], [user-id]). For standard identifiers like customers, orders, omit brackets.效果Llama3.2实测后customers不再加括号但order-details会正确变成[order-details]。5.3 同一问题多次生成SQL结构不一致如有时用LEFT JOIN有时用INNER JOIN现象问“查客户和订单”第一次生成LEFT JOIN第二次变成INNER JOIN结果集行数不同。根因temperature0.1不够低。LLM在“是否包含无订单客户”这个业务逻辑上存在概率摇摆。终极方案在Prompt里用确定性指令替代概率引导Important Business Logic: When asked to show customers and their orders, ALWAYS include customers with zero orders. This means using LEFT JOIN from customers to orders, and handling NULLs in aggregations with COALESCE or ISNULL.为什么有效把模糊的“should”变成绝对的“ALWAYS”模型不再权衡直接锁定LEFT JOIN路径。5.4 生成SQL里有GETDATE()但客户库时区是UTC8导致“今天”不准现象WHERE order_date GETDATE()在客户服务器上返回空结果因为服务器时区是UTC而业务方要的是北京时间。根因GETDATE()返回服务器本地时间但业务需求是“北京时间当天”。生产级修复不改模型改schema描述。在format_schema_for_llm()里追加时区说明schema_description \nTime Zone Note: All datetime columns (created_at, order_date) are stored in UTC. Business reports require conversion to China Standard Time (CST, UTC8). Use DATEADD(HOUR, 8, GETDATE()) for today in CST.\n同时在Prompt规则里加11. For business time filters (e.g., today, this week), convert GETDATE() to CST using DATEADD(HOUR, 8, GETDATE()).效果模型生成WHERE order_date DATEADD(HOUR, 8, GETDATE())精准匹配业务时区。5.5 Ollama模型返回context length exceeded但schema只有2KB现象schema_md约1800字符Ollama默认上下文4K理论上够用却报错。根因Ollama的context_length参数是总token数不是字符数。sqlparse格式化后的SQL、system prompt、user question全算在内。实测schema_md1800字符≈3200 tokens加上prompt的1200 tokens已超4K。解决方案精简schema在format_schema_for_llm()里过滤掉column_defaultOBJECT_DEFINITION()返回的默认值常很长且对SQL生成无用压缩Prompt删掉规则里的例子如e.g., SELECT TOP 10 ...只留规则本身升级Ollamav0.3.0支持--num_ctx 8192启动参数ollama run llama3.2 --num_ctx 8192验证方法用llama.cpp的tokenize工具测真实token数echo YOUR_SCHEMA_TEXT | ./llama-tokenize -m models/llama3.2.Q4_K_M.gguf6. 生产化进阶从Demo到企业可用的五层加固这个项目在Demo阶段能跑通但扔进企业环境第二天就会被运维砍掉。我根据给三家客户落地的经验总结出必须做的五层加固每层都对应一个真实翻车现场。6.1 第一层SQL安全网——防止DROP TABLE式灾难最怕的不是SQL写错而是模型被诱导执行危险操作。有次测试时用户输入“请删除所有测试订单”模型真生成了DELETE FROM orders WHERE order_id 100。加固方案是双保险前置过滤在generate_query()里加正则拦截import re def _validate_sql_safety(self, sql: str) - None: dangerous_patterns [ r(?i)\bDROP\sTABLE\b, r(?i)\bTRUNCATE\sTABLE\b, r(?i)\bDELETE\sFROM\s\w\sWHERE\s1\s*\s*1\b, r(?i)\bINSERT\sINTO\s\w\sVALUES\s*\(\s*.*\s*\)\s*;?\s*-- ] for pattern in dangerous_patterns: if re.search(pattern, sql): raise SecurityError(fBlocked dangerous SQL pattern: {pattern})执行沙盒所有生成SQL在SET NOEXEC ON模式下预编译只检查语法不执行# 在DB连接后执行 cursor.execute(SET NOEXEC ON) cursor.execute(sql_query) # 如果语法错这里抛异常 cursor.execute(SET NOEXEC OFF) # 恢复执行6.2 第二层性能熔断——避免SELECT * FROM orders拖垮DB用户问“查所有订单”模型可能生成无WHERE的全表扫描。加固方案是强制分页超时控制在Prompt里加硬规则“All queries MUST include either a WHERE clause filtering at least one column, or a TOP clause with explicit number (e.g., TOP 1000). Never generate SELECT * without LIMIT/TOP.”在generate_query()后加自动注入if SELECT in sql.upper() and TOP not in sql.upper() and WHERE not in sql.upper(): # 自动注入TOP 1000可配置 sql re.sub(r(SELECT\s)(?!TOP\s\d), r\1TOP 1000 , sql, flagsre.IGNORECASE)6.3 第三层方言自适应——同一份代码跑MySQL/PostgreSQL当前代码硬编码T-SQL但客户可能有混合数据库。方案是动态Prompt方言路由class SimpleSQLQueryGenerator: def __init__(self, dialect: str mssql): # 新增dialect参数 self.dialect dialect # 根据dialect加载不同规则模板 self.rules { mssql: self._get_mssql_rules(), mysql: self._get_mysql_rules(), postgresql: self._get_pg_rules() } def _get_mssql_rules(self) - str: return Use TOP N, GETDATE(), ISNULL()... def _get_mysql_rules(self) - str: return Use LIMIT N, NOW(), IFNULL()...main.py里根据环境变量自动切换dialect os.getenv(SQL_DIALECT, mssql) # .env里设SQL_DIALECTmysql generator SimpleSQLQueryGenerator(dialectdialect)6.4 第四层结果可信度反馈——让用户知道“这SQL靠谱吗”业务方不关心技术细节只问“这结果准不准”。方案是生成SQL的同时输出置信度解释# 修改generate_query返回dict def generate_query(self, question: str, schema: str) - dict: # ...原有逻辑... # 在system_prompt里加要求“After generating SQL, add a comment starting with -- Confidence: explaining why this query matches the question” return { sql: cleaned_sql, confidence_explanation: self._extract_confidence_comment(cleaned_sql) } def _extract_confidence_comment(self, sql: str) - str: match re.search(r-- Confidence: ([^\n]), sql) return match.group(1) if match else No confidence explanation provided用户看到-- Confidence: Used LEFT JOIN because question asks for all customers, including those without orders立刻建立信任。6.5 第五层可观测性埋点——让运维知道“谁在什么时候问了什么”没有日志的系统等于黑盒。在main.py里加结构化日志import logging import json from datetime import datetime logger logging.getLogger(sql_gen) logger.setLevel(logging.INFO) handler logging.FileHandler(sql_gen.log) formatter logging.Formatter(%(asctime)s - %(name)s - %(levelname)s - %(message)s) handler.setFormatter(formatter) logger.addHandler(handler) def log_query(user_question: str, generated_sql: str, schema_size: int, duration_ms: float): log_entry { timestamp: datetime.utcnow().isoformat(), user_question: user_question, generated_sql: generated_sql[:200] ... if len(generated_sql) 200 else generated_sql, schema_size_chars: schema_size, duration_ms: round(duration_ms, 2), status: success } logger.info(json.dumps(log_entry))日志直接对接ELK运维能查“过去一小时哪个问题生成SQL最慢”开发能查“哪类问题错误率最高”。这五层加固做完你的SQL生成器就不再是玩具而是能放进生产环境的数据自助服务台。它不追求100%完美但每一步都带着对业务风险的敬畏——这才是工程师该有的样子。我个人在实际部署中发现最有效的改进往往来自业务方的一句抱怨。比如有次运营同事说“为什么‘近7天订单’生成的SQL用DATEADD(day, -7, GETDATE())但我要的是自然周周一到周日” 我立刻在Prompt里加了规则“For last 7 days, useDATEADD(day, -7, GETDATE()); for this week, useDATEADD(week, DATEDIFF(week, 0, GETDATE()), 0).” 工程师的价值不在于写出多炫的算法而在于把业务语言一丝不苟地翻译成机器能执行的指令。

本周精选

本月热点