1. 为什么 Text-to-SQL 在 PostgreSQL 上总是差一口气
Text-to-SQL 这件事,听起来像是把一句中文或英文丢给大模型,它就能吐出一段能跑的 SQL。但真正在 PostgreSQL 上落地过的人都知道,第一次生成的语句往往「看着像那么回事,一执行就报错」。问题通常不在模型本身,而在提示工程没有把数据库的上下文喂到位。
Text-to-SQL 指的是把自然语言问题翻译成可执行 SQL 查询的技术,它能让不熟悉 SQL 语法的人直接问数据。适合谁?数据分析师、后端开发、做 BI 报表的同学,以及想把「问数」能力嵌进自己产品的工程师。PostgreSQL 作为对象关系型数据库,有 schema、大小写敏感标识符、类型转换这些细节,模型如果不知道表结构,就会凭空编字段名。
我试过用同一句「找出每个岛上数量最多的企鹅种类」去问不同提示写法,结果差别很大:只给问题,模型返回SELECT species FROM penguins,漏了分组和计数;给了表结构但没给示例,模型把island写成Island,PostgreSQL 直接报column "Island" does not exist;加上 Few-shot 示例和「只输出 SQL」的约束后,才稳定返回SELECT species, island, COUNT(*) FROM penguins GROUP BY species, island。
这一篇就围绕 PostgreSQL 场景,把 Schema 描述、Few-shot 示例、约束解码这几步拆开讲,给出可复制的 Prompt 模板,并用 TaoToken 的统一 Key 把请求跑通。你会看到对同一个自然语言问题做多轮提示迭代,再用执行结果比对来验证生成 SQL 是否正确。核心检索词就是 Text-to-SQL、Prompt Engineering、PostgreSQL,全文围绕这三者展开。
2. 用 TaoToken 统一 Key 接入 LLM 的前置准备
做 Text-to-SQL 提示工程,第一步不是写 Prompt,而是先把模型调用通道固定下来。因为你要反复迭代提示、对比不同模型输出,如果每次换模型都要改一套鉴权和 Base URL,迭代成本会很高。TaoToken 在这里的作用是提供统一的 API Key 和兼容 OpenAI 协议的入口,让你用同一套代码切换模型。
你需要准备三样东西:Base URL、API Key、Model ID。这三件套是后面所有配置的基础,缺一不可。Base URL 用https://taotoken.net/api,注意这个地址不带任何查询参数。API Key 在控制台的 API Keys 页面创建,创建后只显示一次,复制下来存到环境变量里,别硬编码进代码。
Model ID 按你的场景选:做 Text-to-SQL 这种需要强代码能力的任务,选代码能力强的模型;如果只是做简单查询翻译,通用模型也够用。具体有哪些可选,去模型对话页面看当前支持的列表,那里会实时更新。
环境变量这样设置,Linux/macOS 用 export,Windows 用 set:
export TAOTOKEN_API_KEY="sk-你的key" export TAOTOKEN_BASE_URL="https://taotoken.net/api"Python 里读取就用os.getenv。这里有个坑:很多人把 Base URL 写成带/v1的完整路径,结果 SDK 又拼了一次/v1,变成/v1/v1/chat/completions,直接 404。TaoToken 的 Base URL 就是https://taotoken.net/api,OpenAI SDK 会自动补/v1/chat/completions,你不用手动加。
如果你用的是 Claude Code 这类工具,配置方式不太一样,需要走 Anthropic 兼容入口,具体看接入文档里的说明。但无论哪种方式,Base URL、Key、Model ID 这三件套的逻辑是一样的。把这一步做扎实,后面迭代提示时你只需要改 Prompt 字符串,不用碰任何网络配置。
3. 可复制的 Prompt 模板与 TaoToken 配置片段
这一节是全文的核心,给出能直接抄的配置和 Prompt。先说配置,用 OpenAI Python SDK 指向 TaoToken:
import os from openai import OpenAI client = OpenAI( api_key=os.getenv("TAOTOKEN_API_KEY"), base_url="https://taotoken.net/api", ) MODEL_ID = "你的模型ID" # 从模型对话页面确认然后是 Prompt 模板。Text-to-SQL 的提示结构分四块:语言声明、Schema 描述、Few-shot 示例、输出约束。我把它写成一个可复用的函数:
def build_prompt(question, schema, examples=None): lines = [] lines.append("-- Language: PostgreSQL") lines.append(f"-- Schema: {schema}") if examples: lines.append("-- Examples:") for q, sql in examples: lines.append(f"-- Q: {q}") lines.append(f"-- A: {sql}") lines.append("-- 只输出一条可直接执行的 PostgreSQL 查询,不要解释,不要 markdown 代码块。") lines.append(f"-- Question: {question}") lines.append("SELECT 1;") return "\n".join(lines)Schema 描述不要直接把pg_dump的建表语句全塞进去,那样 token 消耗大还容易让模型分心。用精简格式,只保留表名、列名、类型:
schema = ( "Table penguins, columns = [" "species text, island text, bill_length_mm double precision, " "bill_depth_mm double precision, flipper_length_mm bigint, " "body_mass_g bigint, sex text, year bigint]" )Few-shot 示例给一到两个就够,多了反而占 token。示例要覆盖你关心的查询模式,比如分组聚合:
examples = [ ("统计每个岛上的企鹅数量", "SELECT island, COUNT(*) FROM penguins GROUP BY island"), ]调用的时候把 temperature 设低,Text-to-SQL 不需要创造性,0 到 0.2 之间比较稳:
def generate_sql(question, schema, examples=None): prompt = build_prompt(question, schema, examples) resp = client.chat.completions.create( model=MODEL_ID, messages=[{"role": "user", "content": prompt}], temperature=0, ) return resp.choices[0].message.content.strip()这里解释一下为什么 Prompt 最后放一句SELECT 1;。这是从 pg-text-query 项目里学到的技巧:给模型一个「续写」的锚点,让它倾向于直接输出 SQL 而不是先写一段解释。实测下来,加了这句之后,模型返回纯 SQL 的概率明显提高,省去了从 markdown 代码块里抠 SQL 的麻烦。
约束解码这块,除了在 Prompt 里写「只输出 SQL」,还可以在调用参数上做限制。比如用stop参数在遇到分号加换行时停止,避免模型画蛇添足。不过不同模型对 stop 的支持不一样,先用 Prompt 约束,不够再加参数。
4. 多轮迭代与执行结果比对验证
Prompt 写好了不代表就完事,Text-to-SQL 的准确率是靠迭代磨出来的。这一节演示对同一个问题做多轮提示迭代,并用真实执行结果来验证。
准备一个测试问题:「找出每个岛上数量最多的企鹅种类」。第一轮,只给问题和 Schema,不给示例:
q = "找出每个岛上数量最多的企鹅种类" sql_v1 = generate_sql(q, schema) print(sql_v1)第一轮大概率返回类似SELECT species, island, COUNT(*) FROM penguins GROUP BY species, island的语句。它能跑,但语义不对——它返回的是每个岛上每种企鹅的数量,不是「数量最多的那一种」。这就是典型的「语法正确、语义偏差」。
第二轮,在 Prompt 里补充业务语义,明确「最多」的含义:
q2 = "找出每个岛上数量最多的企鹅种类,即对每个 island,按 species 分组计数后取计数最大的那一行" sql_v2 = generate_sql(q2, schema, examples) print(sql_v2)这一轮模型可能返回带窗口函数的语句:
SELECT island, species, cnt FROM ( SELECT island, species, COUNT(*) AS cnt, ROW_NUMBER() OVER (PARTITION BY island ORDER BY COUNT(*) DESC) AS rn FROM penguins GROUP BY island, species ) t WHERE rn = 1第三轮,如果发现模型对窗口函数写法不稳定,可以在 Few-shot 里加一个窗口函数的示例,把它「教」会。迭代的关键是每次只改一个变量:要么改问题描述,要么加示例,要么调 Schema 粒度,别一次全改,否则你不知道是哪个改动起了作用。
验证环节,把生成的 SQL 丢进 PostgreSQL 执行,和手写的标准答案比对结果集。用 psycopg2 连接:
import psycopg2 conn = psycopg2.connect( host="localhost", dbname="testdb", user="postgres", password="yourpass" ) cur = conn.cursor() def run_sql(sql): cur.execute(sql) return cur.fetchall() golden = run_sql(""" SELECT island, species FROM ( SELECT island, species, COUNT(*) AS cnt, ROW_NUMBER() OVER (PARTITION BY island ORDER BY COUNT(*) DESC) AS rn FROM penguins GROUP BY island, species ) t WHERE rn = 1 """) generated = run_sql(sql_v2) print("一致" if set(generated) == set(golden) else "不一致")比对时注意用集合而不是列表,因为 SQL 不保证返回顺序。如果结果不一致,把两边的结果打印出来看差异,再回到 Prompt 里补信息。这个「生成—执行—比对—改 Prompt」的循环,就是 Text-to-SQL 提示工程的日常。
5. 常见报错排查:401、local proxy failed、reading choices
迭代过程中会遇到各种报错,这一节把高频的几个列出来,对照真实错误信息给排查方向。
401 Unauthorized。错误信息通常是Error code: 401 - {'error': {'message': 'Invalid API key'}}。原因就两个:Key 没设置对,或者 Key 前面多了空格。检查os.getenv("TAOTOKEN_API_KEY")是否返回了值,打印出来看首尾有没有空白字符。另外确认你用的是 TaoToken 控制台创建的 Key,不是别处的。
local proxy failed / connection error。这类错误信息类似APIConnectionError: Connection error或local proxy failed。先确认 Base URL 写的是https://taotoken.net/api,没有多余路径。然后检查网络能不能正常访问这个域名,用 curl 测一下:
curl -s -o /dev/null -w "%{http_code}" https://taotoken.net/api/v1/models \ -H "Authorization: Bearer $TAOTOKEN_API_KEY"返回 200 说明通道正常,返回 000 说明网络层有问题。注意不要用任何非官方的网络工具,直接走正常网络即可。
reading choices 报错。错误信息类似KeyError: 'choices'或TypeError: 'NoneType' object is not subscriptable,出现在resp.choices[0]这一行。这通常是因为返回体结构和你预期的不一样,可能是模型 ID 写错了,或者请求被拒绝返回了错误对象。先把resp整个打印出来看结构:
resp = client.chat.completions.create(...) print(resp)如果返回的是错误信息而不是正常的 completion 对象,里面会有error字段,照着改。
OAuth / 鉴权相关报错。如果你用的是 Claude Code 或类似工具,报 OAuth 错误,说明鉴权方式没配对。这类工具需要走 Anthropic 兼容入口,配置里要写全 Base URL、Key、Model ID 三件套,缺一个都会鉴权失败。具体路径看接入文档,别自己猜。
模型返回带 markdown 代码块。这不是报错,但会让你的 SQL 执行失败,因为```sql这行不是合法 SQL。解决办法是在 Prompt 里明确「不要 markdown 代码块」,或者在代码里做清洗:
import re def clean_sql(text): text = re.sub(r"```sql\s*", "", text) text = re.sub(r"```", "", text) return text.strip()排查的核心思路是:先看错误信息属于哪一类(鉴权、网络、返回结构、内容格式),再针对性检查对应的配置项。别一上来就改 Prompt,很多问题根本不在 Prompt 层。
6. 把 Text-to-SQL 接进你的工作流
走到这里,你已经有了可复制的 Prompt 模板、统一的 TaoToken 配置、多轮迭代方法和排错清单。接下来就是把它接进实际工作流。
如果你只是偶尔验证模型输出,用模型对话页面手动试几句最快,不用写代码。如果你要长期做编码类任务、把 Text-to-SQL 做成 Agent 的一个工具,那 Coding Plan 更合适,它按长期使用场景设计,成本更可控。接入文档里有完整的参数说明和示例,遇到配置问题先翻那里。
实际落地时,有几个经验值得记一下。Schema 描述要跟着数据库变更走,表结构改了 Prompt 里的 schema 字符串也得改,否则模型会引用不存在的列。Few-shot 示例要定期更新,把线上跑错的 case 补进去当新示例,这是提升准确率最直接的办法。生成的 SQL 在执行前一定要过一遍审查,尤其是带UPDATE、DELETE的语句,别让模型直接改生产数据。
Text-to-SQL 不是一次调通就完事的,它更像一个需要持续喂养示例和 schema 的系统。把迭代循环跑顺了,准确率会随着你积累的示例稳步上升。