自然语言转SQL:用DeepSeek API构建SQLite智能查询助手
1. 为什么是“自然语言转SQL”而不是“直接问答”——先想清楚架构再动手说实话我第一次拿到这个需求的时候脑子里第一反应是既然大模型这么能聊直接把用户的问题甩给它让它从已有的数据里总结答案不就完了吗为什么非得绕一圈去生成SQL再到SQLite里执行这个想法看起来省事但实际跑一轮你就知道问题出在哪了。让我们先明确这个助手的核心价值它不是一个泛泛的聊天机器人而是一个能听懂人话、并且只围绕你数据库里的真实数据来回答问题的工具。如果你跳过SQL生成这一步让模型“直接回答”会遇到两类非常头疼的情况第一模型会一本正经地编数据。它可能基于训练时见过的相似问题给出一个看起来合理但实际上库里根本没有的数据这是大模型最经典的幻觉问题。你问“上个月销售额前五的产品是什么”它给你列了五个产品名和数字你拿去对账发现连产品名都是编的。第二模型无法回答“我们库里的数据”。除非你每次提问都把整库数据塞进上下文否则它对你的表结构和具体记录一无所知。把全库数据塞进去又完全不可行几万行数据直接撑爆上下文窗口。所以这个项目在架构上必须走一条清晰的路先用提示词让模型理解数据库结构再让模型把用户的问题转成结构化的SQL语句最后在本地SQLite引擎里真实执行拿到结果后如有需要再做一次自然语言表述的润色。这个架构的本质是大模型只做“翻译”数据库引擎只做“计算”各司其职。模型干它擅长的语义理解SQLite干它擅长的精确查询。这样做有两个直接好处一是结果可追溯模型生成的每条SQL都能在本地复现二是安全性可控你完全可以在执行SQL之前加一道校验只允许SELECT查询从机制上掐断删库风险。我后来跟几个做数据平台的朋友聊过大家一致认同这个思路。市面上大部分成熟的“智能问数”产品基本也都是这个套路区别只是把生成SQL的环节换成更强的模型或者加了一层查询优化器。我们这篇文章要做的就是用Python和DeepSeek API把这个闭环跑起来。适合来读这篇文章的人大概是这几类想在本地用自然语言查数据的小团队开发者、正在评估大模型API在数据查询场景下效果的技术人员、以及已经接触过SQLite但不想写SQL的分析师。下面的内容我会从API接入、代码实现、踩坑复盘一路讲到底直接可以照着复现。2. 环境准备与API接入选对模型、管好密钥是动手前最重要的事2.1 DeepSeek API的基本用法与模型选择DeepSeek API走的是OpenAI兼容的接口格式这意味着它的请求和响应结构你很可能已经见过不需要额外学习一套调用范式。核心就两步配置API Key然后向对话补全接口发送消息列表。在正式开始写代码之前有两件特别容易被忽略的小事值得先说清楚。第一件是模型选择。DeepSeek平台一般提供两类模型一类是通用对话模型响应速度快、成本低另一类是推理增强模型遇到复杂数学、逻辑推理问题时表现更强但响应会更慢、消耗的token也更多。做SQLite自然语言转SQL这个场景我实测下来通用对话模型完全够用甚至可以说是更优的选择。原因在于把自然语言翻译成SQL这件事本质上是一个“格式化翻译”任务而不是一个“复杂推理”任务通用模型已经专门训练过大量SQL生成的数据效果足够好。推理模型在多步推理上确实更强但带来的延迟在交互式查询场景下体感很明显而且成本会成倍增加。除非你的查询语句经常包含复杂的多表关联和子查询嵌套否则不必默认上推理模型。第二件是密钥管理。千万别把API Key硬编码写在代码文件里。这倒不一定是怕被偷更重要的是当你想把这段代码分享给同事、或者推到代码仓库时一旦密钥随着文件一起被提交就意味着任何能看到仓库的人都能用你的额度调用API。正确做法是用环境变量或者一个本地.env文件来存放密钥并且在代码里通过os.getenv读取。import os from dotenv import load_dotenv load_dotenv() # 读取项目根目录下的 .env 文件 DEEPSEEK_API_KEY os.getenv(DEEPSEEK_API_KEY)对应的.env文件内容是这样的DEEPSEEK_API_KEY你的密钥 DEEPSEEK_BASE_URLhttps://api.deepseek.com只要遵循这个简单的习惯你的代码就能从“运行一次就行”变成“可以被安全地分发和复用”。2.2 网络请求封装用requests还是httpx调用API需要一个HTTP客户端。Python生态里最常用的就是requests如果你喜欢异步编程也可以用httpx。这里我以requests为例因为它的同步调用方式对大多数脚本和工具型应用来说足够直观。封装一个统一的对话函数为后续所有调用打好底子import requests import json def chat_with_deepseek(messages, temperature0.2): 通用对话函数返回模型生成的文本内容。 headers { Authorization: fBearer {DEEPSEEK_API_KEY}, Content-Type: application/json } payload { model: deepseek-chat, # 通用对话模型 messages: messages, temperature: temperature, stream: False } resp requests.post( f{DEEPSEEK_BASE_URL}/chat/completions, headersheaders, jsonpayload, timeout30 ) resp.raise_for_status() data resp.json() return data[choices][0][message][content]注意这里的temperature参数我特意设成了0.2。temperature控制的是模型输出的随机性数值越低越稳定、越倾向于按最可能的路径输出。在SQL生成这类场景里我们需要的是确定性极强的结果而不是“每次生成的SQL都不一样”的随机惊喜所以温度要压低常规建议在0到0.3之间。timeout30也很重要API调用本身就是一次外部IO如果网络环境不稳定没有超时控制的话脚本可能会一直卡在那里不返回。这个30秒是一个经验值正常NL转SQL的请求几秒钟就能回来如果超过30秒大概率是网络或者服务端出了问题直接报错退出比无限等待要合理得多。2.3 环境依赖清单把这些依赖整理一下方便你一次性装齐依赖用途python-dotenv读取 .env 配置requests调用HTTP接口内建的sqlite3模块连接SQLite数据库无需额外安装sqlite3是Python标准库自带的模块这一点很友好意味着你不需要为数据库引擎再装任何驱动。整个项目用到的第三方依赖其实就requests和python-dotenv两个装起来很简单pip install requests python-dotenv到这里基础设施就算准备好了。接下来进入最核心的部分怎么把数据库结构“喂”给模型怎么让它生成靠谱的SQL以及怎么安全地执行这些SQL。3. 核心实现从“人话”到SQL的完整管线3.1 Schema感知让模型先“看清”你的数据库结构任何自然语言转SQL的系统第一步都是让模型知道你的数据库长什么样。不了解表结构、字段名、字段类型模型再聪明也生成不了合法的SQL。最直接的做法是从SQLite的系统表里读取建表语句。SQLite每次建表都会把表结构记录在内部系统表中你可以直接查询它import sqlite3 def get_schema(db_path): 提取数据库的完整表结构返回便于放入提示词的字符串。 conn sqlite3.connect(db_path) cursor conn.cursor() cursor.execute(SELECT sql FROM sqlite_master WHERE typetable AND sql IS NOT NULL) schema_lines [row[0] for row in cursor.fetchall()] conn.close() return \n\n.join(schema_lines)这个方法返回的就是你建表时写的那些CREATE TABLE语句。把它们原封不动地塞进提示词里模型的上下文里就有了完整的数据字典。但是这里有个关键细节不要无脑把全部表结构都塞进去。如果你的库只有两三个表那无所谓但如果一个库里有几十张表每张表还有十几个字段整个schema文本会非常长浪费大量token不说还会稀释模型对关键字段的注意力导致生成的SQL质量下降。我自己的做法是维护一个“重点关注表”的开关。要么让用户指定要查询哪张表要么先从SQLite的系统表里把表名列表列出来让用户或模型先选表再把选中表的明细结构喂进去。两步走的方案在真实场景里更好用def get_schema(db_path, table_namesNone): conn sqlite3.connect(db_path) cursor conn.cursor() if table_names: placeholders ,.join(? * len(table_names)) cursor.execute( fSELECT sql FROM sqlite_master WHERE typetable AND name IN ({placeholders}), table_names ) else: cursor.execute(SELECT sql FROM sqlite_master WHERE typetable AND sql IS NOT NULL) schema_lines [row[0] for row in cursor.fetchall()] conn.close() return \n\n.join(schema_lines)可以先用下面这条命令快速列出所有表SELECT name FROM sqlite_master WHERE typetable ORDER BY name;如果你只有一张业务主表这一步可以跳过如果是多表场景强烈推荐把表选择做成一个前置步骤。3.2 提示词设计把“翻译规则”讲清楚有了schema之后接下来要写的就是本项目最重要的提示词模板。模板写得好不好直接决定SQL生成的准确率我前后改过十几个版本最有用的几条经验都浓缩在下面这套模板里。SYSTEM_PROMPT 你是一个SQLite数据库专家你的任务是把用户的自然语言问题转换为可以直接执行的SQLite SQL语句。 数据库结构如下 {schema} 规则要求 1. 只输出SQL语句本身不要输出任何解释、注释或Markdown代码块标记。 2. 如果用户的问题无法用已有表结构实现请输出“无法查询”四个字。 3. 只允许单条SELECT查询严禁多语句严禁任何写操作。 4. 字段名统一使用小写或保持与原表一致不要臆造字段。 5. 如果涉及时间和日期过滤先检查字段的实际类型再决定是否需要使用date()函数。 6. 对于模糊的聚合需求如“平均”“最新”“每个”要正确选择GROUP BY、ORDER BY、AVG等关键词。 7. 若用户未指定精确条件默认取所有记录并给出合理排序。 这个模板里每条规则背后都对应着一个我真实踩过的坑“只输出SQL语句本身”如果不加这条模型经常会好心地加一句“以下是您需要的SQL”或者把SQL包在Markdown代码块里。提取的时候确实可以靠正则去掉但让模型直接输出干净文本省去解析步骤明显更可靠。“无法查询就输出固定标识”模型面对一个它无法回答的问题时宁可让你告诉它输出什么也别让它自由发挥。自由发挥的结果就是生成一段看起来像SQL但实际上跑不通的语句。“严禁多语句”这条同时关系到安全和正确。有些模型会在一条回答里生成多条SQL分号分隔。多条SELECT串在一起虽然不会删数据但如果被人恶意构造提示词注入后果不可控所以一刀切禁止最省心。“字段名不要臆造”模型有一种倾向就是根据自然语言中的词汇“猜”字段名。比如用户问“产品价格”库里字段实际叫prod_price模型可能会写成product_price。把“保持与原表一致”写进规则能明显减少这种低级错误。在user一侧则比较简单直接把用户的那句“人话”放进去user_input 帮我查一下上个月销量最高的5个产品按销量降序排 messages [ {role: system, content: SYSTEM_PROMPT.format(schemaschema_text)}, {role: user, content: user_input} ] sql_text chat_with_deepseek(messages)3.3 SQL安全校验生成结果不能直接拿去执行模型生成的那段文本必须经过一道校验才能交给SQLite执行。这个校验的核心目的就一句话把“模型翻译的结果”和“直接执行的代码”之间加一道隔离墙。我写了一个轻量级的校验函数一般不需要过于复杂的SQL解析器比如sqlparse只要做两件基础但必要的事一是确保生成的以SELECT开头二是确保没有分号和危险关键字。import re def validate_sql(sql_text): 校验模型生成的SQL只允许单条SELECT查询。 返回 (是否合法, 处理后的SQL) sql_text sql_text.strip() if sql_text 无法查询: return False, None # 去掉结尾分号方便拼接 if sql_text.endswith(;): sql_text sql_text[:-1] # 必须以SELECT开头允许WITH开头的CTE写法 if not re.match(r^(SELECT|WITH)\s, sql_text, re.IGNORECASE): return False, None # 禁止分号二次出现防止多语句 if ; in sql_text: return False, None # 危险关键字黑名单 dangerous_keywords [DROP, DELETE, UPDATE, INSERT, ALTER, CREATE, REPLACE, ATTACH] upper_sql sql_text.upper() for kw in dangerous_keywords: if re.search(rf\b{kw}\b, upper_sql): return False, None return True, sql_text这套校验的核心思路是“白名单加黑名单”。开放SELECT和WITH两种开头是因为许多复杂查询会用到CTE公共表表达式禁止其它开头是从语法层面锁死写操作。黑名单里保留那些SQLite标准DML/DDL关键字是防止模型在极端情况下“犯糊涂”。虽然正常情况下通用模型不会无缘无故生成DELETE语句但人在江湖多一层防护总是好的。3.4 执行查询并回填结果SQL通过校验之后剩下的就是执行了。SQLite是Python内置支持的直接连接数据库执行SQL抓取结果。这里有一个执行层面的经验之谈加上timeout参数给查询语句一个最大执行时间。因为有些自然语言查询转出来的SQL可能存在性能问题比如缺少索引导致的超大范围扫描。如果一条SQL运行了十几秒仍然没有返回多半是查询写得有问题直接抛超时错误比干等更合理。def run_query(db_path, sql_text): conn sqlite3.connect(db_path, timeout10) cursor conn.cursor() try: cursor.execute(sql_text) columns [desc[0] for desc in cursor.description] rows cursor.fetchall() return columns, rows except Exception as e: return None, str(e) finally: conn.close()拿到结果之后怎么展示也很重要。直接把cursor.fetchall()返回的原始元组丢给用户体验很差数字没有格式化可读性接近于零。这里我分两步处理第一步把结果转成Markdown表格形式的文本第二步可选地再调一次API让模型基于这个表格写一句自然语言总结。def rows_to_markdown(columns, rows): if not rows: return 查询结果为空。 header | | .join(columns) | separator | | .join([---] * len(columns)) | lines [header, separator] for row in rows[:20]: # 限制展示前20行 lines.append(| | .join(str(cell) for cell in row) |) return \n.join(lines)这段代码里我额外加了一个细节rows[:20]。大数据量的查询结果如果全部展示输出会非常长而且大部分情况下用户用不到所有行。限制为前20行是“展示概览”如果用户想看更多可以通过后续追问来获取。这个小细节在真实使用中能让整个助手的响应速度体感快很多因为网络传输的文本量被控制住了。3.5 端到端的最小可运行Demo把上面所有函数串起来就是我们这个助手的完整闭环def main(): db_path business.db schema_text get_schema(db_path) # 默认带全部表 while True: user_input input(请输入你的问题输入exit退出).strip() if not user_input: continue if user_input.lower() in (exit, quit): break # 第一步自然语言转SQL messages [ {role: system, content: SYSTEM_PROMPT.format(schemaschema_text)}, {role: user, content: user_input} ] sql_text chat_with_deepseek(messages) print(f[执行SQL] {sql_text}) # 第二步校验与执行 ok, validated_sql validate_sql(sql_text) if not ok: print(无法安全执行模型生成的SQL请尝试换个问法。) continue # 第三步查询并展示 columns, result run_query(db_path, validated_sql) if isinstance(result, str): print(f[查询出错] {result}) continue markdown_text rows_to_markdown(columns, result) print(markdown_text) # 第四步可选的自然语言总结 summarize_messages [ {role: system, content: 你是一个数据解读助手请用一句话总结用户看到的查询结果。}, {role: user, content: f表结构\n{schema_text}\n\n执行SQL\n{validated_sql}\n\n查询结果\n{markdown_text}\n\n请用简洁中文总结。} ] summary chat_with_deepseek(summarize_messages, temperature0.5) print(f[总结] {summary}\n) if __name__ __main__: main()跑到这一步你已经拥有了一个“能听懂人话”的SQLite智能助手的最小可用版本。从输出里既能直观看到每一步的处理结果也可以把中间产物尤其是SQL文本单独打印出来便于调试和复盘。4. 实测复盘我在复现过程中遇到的五个真实问题写完上面的Demo只是第一步真正把它用起来是在反复测试和修补的过程中实现的。下面这几个问题是我在完整跑通流程时遇到的“真坑”每个都值得展开说一下。4.1 Schema文本过长导致Token爆掉第一次拿一个业务库试里面大概有20多张表每张表的建表语句都不短全部拼接出来的schema文本将近一万字。把它放进system prompt再等它生成一条SQL一次调用就消耗了上万token成本倒是还能接受但响应速度已经从秒级变成了十几秒。解决思路前面已经提到了就是缩小schema范围。实际操作中我实现了一个“先选表再查询”的中间层先执行一条SQL把表名列表拉出来展示给用户让用户决定要查哪张表然后只把选中表的schema喂给模型。如果你的场景比较固定甚至可以在配置里写死几个业务核心表。这一步不仅省token更重要的是减少了模型“分心”的可能。schema越长模型越容易在一个不相关的表里找字段生成SQL的错误率和“臆造字段”的概率也越高。所以schema信息不是越多越好够用才是最好。4.2 模型生成的SQL用了不兼容的写法SQLite的SQL方言和其它数据库不完全一样这是很多初次接触这个项目的人最容易忽略的点。比如MySQL里的反引号包围字段名、LIMIT 5 OFFSET 2的写法在SQLite里虽然兼容但IFNULL和NVL这类函数名就会踩坑——模型在训练时见过大量的MySQL和PostgreSQL数据有时候生成的SQL会带上其它方言的痕迹。我的解决办法是在系统提示词里就把“SQLite”这个关键词写清楚同时把上面这条规则改成“只使用SQLite原生支持的语法和函数”。这听起来像句废话但亲手加上这几个词之后方言错误的出现频率确实明显降低了。如果生成的SQL里确实混入了不兼容的函数SQLite执行时会直接报错这部分错误信息会被run_query函数捕获并显示给用户。我的处理原则是不自动重试修正因为这可能引入语义偏差宁可把错误信息原样展示出来让用户自己决定是否换个问法。4.3 用户自然语言里的“相对时间”处理“上个月”“最近一周”“今年”这类相对时间描述是自然语言转SQL里最棘手的问题之一。模型默认情况下不知道“今天”是几号如果你不告诉它当前日期它要么生成一个错误的时间区间要么生硬地用CURRENT_DATE函数了事但“上个月”这种需要动态计算的场景简单用CURRENT_DATE并不够。我在实际工程里加了一个步骤在每次调用之前动态拼接一条“当前日期信息”到系统提示词中。代码大概是这样的from datetime import datetime, timedelta def get_date_context(): today datetime.now() first_day_of_month today.replace(day1) last_month_end first_day_of_month - timedelta(days1) last_month_start last_month_end.replace(day1) return ( f当前日期{today.strftime(%Y-%m-%d)} f昨天{(today - timedelta(days1)).strftime(%Y-%m-%d)} f本周起始日{(today - timedelta(daystoday.weekday())).strftime(%Y-%m-%d)} f上个月范围{last_month_start.strftime(%Y-%m-%d)} 到 {last_month_end.strftime(%Y-%m-%d)}。 )把它追加到prompt的末尾模型就有了精确的日期参考。这个改动看起来很小但它把“自然语言中的时间表达”这个模糊问题变成了“模型可以查表计算的确定问题”实测对查询准确率提升非常明显。4.4 空结果的用户体验问题当查询结果为空用户往往会很困惑“我的数据明明有记录为什么查出来是空的”这时候模型的SQL生成、查询执行、结果展示三个环节都需要参与排查。我的经验是让助手在结果为空时自动生成一句带有SQL和表名的解释比如“查询结果为空。执行的SQL是SELECT ...可能的原因是记录的日期范围不匹配或筛选条件下没有满足条件的数据。”这样用户能区分“库里确实没有数据”还是“我的查询条件写错了”而不是看到一片空白。实现上很简单就是在上一步的总结提示词里加条件分支if not rows: summary_prompt f用户执行的SQL是{validated_sql}查询结果为空。请用一句话解释可能的原因并提示用户调整问题。这算是一个非常小的体验优化但对于一个“面向非技术用户”的软件来说这种细节恰恰是决定它能不能被真正用起来的因素。4.5 API调用的超时与重试外部API调用最常见的问题就是网络波动。requests库的请求偶尔会抛出超时异常或者连接错误。我的处理方式是封装一个带重试机制的调用函数最多重试两次然后才真正报错import time def chat_with_retry(messages, retry2): for attempt in range(retry 1): try: return chat_with_deepseek(messages) except Exception as e: if attempt retry: print(f[API调用失败正在重试 {attempt 1}/{retry}]{e}) time.sleep(2 * (attempt 1)) # 退避等待 else: raise e注意这里的退避等待策略第一次重试等2秒第二次等4秒避免连续快速重试给服务端造成不必要的压力。这个重试机制放在对话函数的外层意味着无论是自然语言转SQL还是最后的总结阶段都能享受到这个保护。5. 让它比“能用”更好用缓存机制与扩展方向5.1 给常见问题加一层默认模板我在使用过程中渐渐发现很多高频问题其实是同一句业务话的变体比如“查一下今天的订单量”“今天卖了多少单”“看看今天订单数”——语义不同但转出来的SQL其实就是同一条。对于这类问题每次都走一次API就有点奢侈了。于是我在系统里加了一个小型的“意图模板缓存”把用户问题的关键词与预设的SQL模板做映射。比如输入里包含“今天”和“订单”就直接套用预设SQL去查。这不是复杂的语义理解只是一个简单的关键词规则匹配但它能覆盖掉大约三成的重复提问省下的API调用量相当可观。这个设计也给了我一个启发大模型适合处理“非标”查询但“标”的部分尽可能用确定性逻辑去解决。把模型用在刀刃上项目的成本和响应速度都会好看很多。5.2 嵌入向量缓存让相似问题直接命中如果嫌关键词模板覆盖度不够进阶做法是引入向量缓存。把每条用户问过的“自然语言问题它对应的SQL”存下来等下一个新问题进来时先做一次向量相似度检索如果找到相似度高于阈值的旧问题直接复用旧SQL完全不调API。这一步需要引入一个embedding模型DeepSeek平台一般也提供相关的向量能力OpenAI生态里也有对应的接口选型空间很大。但从零搭建的时候我不建议一上来就上向量检索先用关键词模板跑通流程、观察业务问题分布再决定要不要引入embedding这个节奏会稳得多。5.3 扩展支持更多数据库这个架构完全不需要绑定在SQLite上。如果你把schema获取函数和查询执行函数这两个“适配层”抽象成接口就能很自然地把能力扩展到其它数据库。比如在适配层里接一个MySQL驱动那NL转SQL的整个提示词逻辑几乎不用改。我把它抽象成三个简单接口class DBAdapter: def get_schema(self) - str: ... def execute_query(self, sql: str) - tuple[list, list]: ... def list_tables(self) - list[str]: ...SQLite、MySQL、PostgreSQL各自实现一套即可。如果你的项目要面向不同的数据库环境这个抽象能帮你省下大量重复劳动。5.4 后续还能加什么语音输入接入把语音转文字能力接进来用户对着手机说一句“查一下本月电费”然后直接触发整个NL转SQL管线体验会非常自然。权限控制现在只允许SELECT这个安全基线可以拓展成“按角色控制字段可见性”比如普通用户不能查薪资字段管理员可以。这个逻辑可以在SQL生成后增加一层字段过滤来实现。查询历史与报表导出把用户提问和生成的SQL记录下来定期分析哪些问题被反复问到可以作为优化模板缓存的数据来源。错误回灌把执行失败的那些SQL收集起来人工修正后做成一个纠错样本集后续可以用来做few-shot微调或给模型做示例参考。每次给这个助手加一个新能力我都会回头检查一遍最底层的那个架构闭环翻译归翻译执行归执行。只要这个边界不模糊往上叠功能就不会出大乱子。6. 最后分享几条心得项目做到这里基本的“智能助手”形态已经很完整了。回看整个开发过程我觉得最有价值的不是那一堆代码而是几条认识放在这里给想动手尝试的朋友一个参考第一别迷信模型的“直接回答”能力。大模型确实能聊但数据查询这个场景准确率就是生命线。哪怕模型的回答听起来再合理也不如一条真实执行出来的SQL可信。让模型做“翻译官”、让数据库做“账房先生”这个分工从第一天就得想明白。第二提示词里的每条规则都要有对应的失败案例。没有踩过坑就去堆砌提示词规则得到的往往是又长又无效的约束。我建议你在开发过程中把模型生成的典型错误SQL收集起来然后针对性地写提示词规则这样每一句规则都有实际价值。第三从“最小闭环”起步比一开始就设计宏大架构有效得多。我先实现了一个只支持单表查询的版本跑通之后才逐步加上日期上下文、SQL校验、Markdown展示这些功能。每一步都能看到效果变化调整起来心里也有底。最后想说的是这类基于大模型API做本地工具的小项目真正的瓶颈往往不在于模型能力而在于你对业务数据结构的理解深度和提示词的迭代耐心。把这两块做扎实了DeepSeek API能发挥出来的价值会比你在Demo里看到的要大得多。