1. 问题背景:一个被低估的工程问题

做AI应用最烦的不是模型不行,而是Prompt不稳定。上个月我接到一个需求:为公司内部数据分析平台做一个NL2SQL的Copilot。业务方要求不高——“能用就行”。结果测试下来,GPT-4o直接写SQL,在Spider数据集的难例上准确率只有42.5%。更头疼的是,模型偶尔会胡编列名,或者把LEFT JOIN条件写反。

很多人觉得Prompt随便写两句就行,那是没做过生产级任务。真正的Prompt Engineering是在Token预算、模型能力和业务约束之间找平衡。本文不会讲“如何写一个完美的Prompt”这种玄学,只记录我针对SQL生成这个具体任务做的5轮实验数据。

2. 环境与版本:固定变量才能对比

所有实验均通过openaiPython库调用,版本如下:
- openai库版本:1.30.5
- 模型版本:gpt-4o-2024-05-13(主测)与 gpt-3.5-turbo-0125(对照)
- 温度:temperature=0.2(降低随机性,保证可复现)
- 测试集:从Spider dev集里按难度筛选200条(含GROUP BY、嵌套子查询、多表JOIN)
- 评估指标:执行准确率(Execution Accuracy,直接跑SQLite),不是匹配准确率,那个没意义。

先看基线Prompt长什么样:

# 基线版本 - 零样本直出
def make_prompt_baseline(schema_ddl: str, question: str) -> str:
    return f"""你是一个SQL专家。根据数据库结构和问题,输出SQLite SQL。
数据库结构:
{schema_ddl}

问题:{question}
SQL查询:
"""

我跑了下基线,结果如下表(Token按tiktokencl100k_base编码统计):

模型 执行准确率 平均输入Token 平均输出Token 单次总消耗
gpt-4o 42.5% 1,532 310 1,842
gpt-3.5-turbo 28.0% 1,421 285 1,706

4o准确率低我可以忍,但3.5直接没法看。接下来的调优全程只针对4o,3.5最后做对照。

3. 方案设计:从Few-shot到结构化约束

我设计了5个迭代版本,每个版本在前一版基础上增加一个约束维度:

  • V1(基线):零样本,直接拼接DDL和问题。
  • V2(Few-shot):在System消息里塞2个典型难例(含JOIN和子查询)。
  • V3(CoT + 中间表示):要求模型先输出“SQL逻辑分解”,再写SQL。
  • V4(Schema感知裁剪):只把涉及到的表结构拼进Prompt,减少噪声。
  • V5(输出格式锁定 + 自校验):强制JSON输出SQL与解释,并让模型检查列名是否存在。

V4这个点值得展开。Spider数据库大表有21个字段,全量DDL有3KB+,但一条查询只用到其中4个。把无关字段全喂进去,模型容易被误导选错列。所以我用字符串匹配粗筛了涉及的表,再补全外键关系。

4. 核心实现:代码与Prompt细节

先写一个Schema裁剪函数:

import sqlite3, re

def prune_schema(db_path: str, question: str, all_ddl: dict) -> str:
    """
    根据question中的关键词粗筛涉及的table,裁剪DDL。
    all_ddl: {table_name: create_sql_string}
    """
    conn = sqlite3.connect(db_path)
    cursor = conn.cursor()
    # 获取所有表名
    cursor.execute("SELECT name FROM sqlite_master WHERE type='table';")
    all_tables = [r[0] for r in cursor.fetchall()]

    # 简单匹配:问题中出现的表名或列名前缀
    matched = []
    for t in all_tables:
        # 表名如果是下划线分隔,拆开匹配
        t_parts = t.split('_')
        if any(part in question.lower() for part in t_parts):
            matched.append(t)

    # 必须包含主表,否则报错
    if not matched:
        matched = all_tables[:2]  # fallback

    # 补全外键引用的表(从原DDL里查)
    final_ddl = []
    for t in matched:
        ddl = all_ddl.get(t, "")
        final_ddl.append(ddl)
        # 用正则找REFERENCES xxx
        refs = re.findall(r'REFERENCES\s+(\w+)', ddl, re.IGNORECASE)
        for r in refs:
            if r not in matched and r in all_ddl:
                final_ddl.append(all_ddl[r])

    return "\n\n".join(final_ddl)

接下来是V5的Prompt模板,这里放弃了纯文本输出,改用JSON结构化要求:

V5_SYSTEM = """你是一个SQL代码生成器。你必须严格遵守以下规则:
1. 只输出一个JSON对象,不要输出任何多余文本。
2. JSON格式为:{"thinking": "你的逻辑分析", "sql": "SQL语句", "columns_used": ["col1", "col2"]}
3. 在生成SQL前,先检查columns_used中的每个列名是否存在于给定的表结构中。
4. 如果列名前缀不明确,必须使用"表名.列名"格式。
5. SQL必须是SQLite方言,不要使用TOP、GETDATE()等方言。

数据库结构如下:
{schema_ddl}

用户问题:{question}

现在开始,直接输出JSON:"""

def make_prompt_v5(schema_ddl: str, question: str, example_qa: list) -> str:
    # example_qa 是Few-shot示例,每条是(question, sql, columns)
    ex_text = ""
    for q, s, cols in example_qa:
        ex_text += f"问题:{q}\nSQL:{s}\n涉及列:{', '.join(cols)}\n---\n"

    return V5_SYSTEM.format(schema_ddl=schema_ddl, question=question)

这里有个隐藏坑:GPT-4o的JSON模式不是100%稳定。即使你在Prompt里写了“只输出JSON”,偶尔也会蹦出“```json”或者前面加个“好的”。所以我在调用端加了重试机制——解析失败就重新调用,最多3次。

import json, time

def call_gpt_with_retry(client, prompt, max_retry=3):
    for attempt in range(max_retry):
        try:
            resp = client.chat.completions.create(
                model="gpt-4o-2024-05-13",
                messages=[
                    {"role": "system", "content": V5_SYSTEM},
                    {"role": "user", "content": prompt}
                ],
                temperature=0.2,
                response_format={"type": "json_object"}  # 关键参数
            )
            content = resp.choices[0].message.content
            data = json.loads(content)
            return data["sql"]
        except Exception as e:
            print(f"Attempt {attempt+1} failed: {e}")
            time.sleep(1)
    return None

注意那个response_format={"type": "json_object"},这是OpenAI提供的强制JSON模式。但实测发现,这个参数会显著增加输入Token(约多130个左右),因为模型内部会加约束。如果预算紧,可以去掉这个参数改用正则提取,但准确率会掉3-5个百分点。

5. 踩坑与优化:Token花在哪了?

调优过程中我踩了几个大坑,可能对你有参考价值:

坑1: Few-shot示例选错比没有更糟。 我V2版本随机从训练集抽了2个例子,结果准确率反而掉到39%。分析发现,示例里有个LEFT JOIN,模型被带偏,遇到INNER JOIN场景也强制仿写。后来我改成故意选和测试问题结构相反的示例(一个简单聚合+一个复杂嵌套),效果才好转。

坑2: CoT的“thinking”会消耗大量输出Token。 V3版本要求模型先写逻辑分析再写SQL,准确率提升到61%,但平均输出Token从310涨到680。这意味着单次成本翻了近一倍。后来我在V5里把thinking限制在50字以内——效果没掉多少,Token却省了30%。

坑3: Schema裁剪需要处理表间依赖。 你裁剪表A后,如果A有外键引用表B,而B不在裁剪结果里,SQL执行时直接报错“no such table”。所以我写了REFERENCES正则补全逻辑,但这个逻辑偶尔会误伤——把一些没用的历史表拉进来。最终方案是:只补全被引用且在主查询中出现的表

坑4: 3.5-turbo在V5上不稳定。 同样Prompt,4o能稳定输出JSON,3.5经常把SQL字段写成query。折腾了很久,后来发现3.5对response_format的支持不如4o,所以直接放弃3.5跑生产,只在离线测试里做对照。

6. 效果数据:量化收益

最终测试结果如下(200条难例,执行准确率):

版本 4o准确率 平均输入Token 平均输出Token 总Token消耗 相比基线消耗变化
V1 基线 42.5% 1,532 310 1,842 -
V2 Few-shot 39.0% 1,788 352 2,140 +16%
V3 CoT 61.5% 1,820 680 2,500 +36%
V4 Schema裁剪 73.0% 1,210 315 1,525 -17%
V5 全部 89.0% 1,042 164 1,206 -35%

几个关键数据点值得强调:
- V4→V5的提升是最大的(73%→89%),主要贡献来自JSON输出约束和列名校验。这验证了“让模型先确认列存在”比直接生成SQL靠谱得多。
- Token消耗反而比基线降了35%,原因是Schema裁剪减少了输入,而JSON约束减少了模型废话输出(之前模型经常自问自答或解释两句)。
- 错误案例里,剩余11%的失败集中在:日期函数处理(date('now') vs CURRENT_DATE)和隐式类型转换。这已经不是Prompt能解决的了,需要加后处理规则。

7. 总结与工程建议

这次调优给我最大的教训:Prompt Engineering不是写作文,而是做特征工程。对NL2SQL来说,Schema裁剪和输出格式约束比花哨的CoT更有效。

几点实用建议:
1. 先量化基线,别凭感觉说“效果变好了”。用执行准确率,别用ROUGE或BLEU。
2. Token消耗必须和准确率一起看。V3的CoT准确率高了,但成本也高了,生产环境很难接受。
3. 不要迷信Few-shot。示例选不好,负迁移比零样本还严重。
4. 如果任务有结构化输出需求,直接上JSON mode,别让模型自由发挥。
5. 生产环境务必加重试机制,模型偶尔会发疯(比如输出个Base64编码)。

现在这套V5 Prompt已经在内部系统跑了一个月,平均每天调用2,000次,准确率稳定在87-90%之间。后续我计划尝试把Schema裁剪换成基于语义的embedding检索,看看能不能再压一点Token。有任何问题欢迎评论区交流。


注:实验代码和数据已脱敏,完整版在内部仓库。文中价格估算基于OpenAI官方定价,gpt-4o输入$5/1M tokens,输出$15/1M tokens。