1. 问题背景:为什么Text-to-SQL需要精细的Prompt?

上个月接了一个内部数据中台的需求,要把自然语言查询转成SQL。模型选型定的是CodeLlama-34B-Instruct(huggingface.co/codellama/CodeLlama-34b-Instruct-hf),量化后部署在单张A100上。

基线测试很残酷:直接用"Translate to SQL: {query}"这种朴素模板,在Spider验证集随机抽的500条样本上,执行准确率只有42.3%。更头疼的是,模型生成的SQL经常出现语法正确但逻辑错误的情况,比如混淆LEFT JOININNER JOIN,或者漏掉DISTINCT

老板的deadline是三周,我决定从Prompt Engineering下手,而不是换模型。

2. 环境与版本:锁定实验变量

  • 模型:CodeLlama-34B-Instruct,4-bit量化(bitsandbytes),load_in_4bit=True
  • 推理框架:vLLM 0.4.2,max_tokens=512temperature=0.1(降低随机性保证可复现)
  • 数据:Spider验证集随机500条,按表结构复杂度分层抽样
  • 评估指标:执行准确率(EXEC Accuracy,即SQL在SQLite上跑通且结果与gold一致)+ Token消耗(vLLM的prompt_logprobs统计)

重要声明:所有实验均在相同种子下进行,每轮跑3次取均值,避免抖动。

3. 方案设计:从零样本到结构化Prompt的5轮迭代

我设计了7种Prompt模板,分5轮实验。核心思路是逐步增加上下文约束任务分解

3.1 第1轮:基线 vs 简单指令增强

# 实验1:零样本模板
zero_shot_prompt = """### Instruction:
Translate the following natural language query into SQL.

### Input:
{question}

### Schema:
{schema}

### SQL:
"""

# 实验2:指令增强模板(加了任务描述和输出格式)
instruct_prompt = """### Instruction:
You are an expert SQL developer. Given the database schema and a question, generate a SQLite SQL query that answers the question. 
- Only output the SQL statement, no explanations.
- Use only columns and tables from the given schema.
- If the question is ambiguous, make a reasonable assumption and note it in a comment.

### Schema:
{schema}

### Question:
{question}

### SQL:
"""

结果对比

模板 EXEC准确率 平均Token消耗(prompt+completion)
基线 42.3% 312
指令增强 55.8% 358

指令增强直接拉高了13个百分点。但仔细看错误输出,发现模型经常忽略schema中的类型约束,比如对VARCHAR列做数值比较。这说明模型没有真正“理解”schema的结构。

3.2 第2轮:Few-shot + 思维链(CoT)

Few-shot选了3个训练集样本,覆盖JOINGROUP BY子查询三种常见模式。CoT部分要求模型先“思考”再写SQL。

few_shot_cot_prompt = """### Instruction:
You are an expert SQL developer. For each question, first reason about the steps to solve it, then output the SQL.

### Examples:
Q: How many students have a GPA above 3.5?
A: 
1. Identify the table: student.
2. Filter condition: GPA > 3.5.
3. Count rows.
SQL: SELECT COUNT(*) FROM student WHERE gpa > 3.5;

Q: List the names of departments that have more than 2 courses.
A:
1. Join department and course on dept_id.
2. Group by department name.
3. Having count(course_id) > 2.
SQL: SELECT d.name FROM department d JOIN course c ON d.dept_id = c.dept_id GROUP BY d.name HAVING COUNT(c.course_id) > 2;

### Schema:
{schema}

### Question:
{question}

### Reasoning:
"""

# 注意:这里为了让模型输出推理过程,需要修改采样参数
sampling_params = SamplingParams(temperature=0.1, max_tokens=1024, stop=["SQL:"])

结果

模板 EXEC准确率 平均Token消耗
指令增强 55.8% 358
Few-shot (3-shot) 61.2% 512
Few-shot + CoT 68.5% 847

CoT带来了7个点提升,但Token消耗翻倍。关键发现:CoT的推理过程并不总是正确,有时候模型“想错了”但SQL写对了,或者反过来。

3.3 第3轮:引入“错误修复”示例(这是我踩坑最深的)

灵感来自OpenAI的文档——给模型看“错误的输出和修正后的输出”。我故意构造了2个错误案例:

error_fix_prompt = """### Instruction:
... (同上)

### Examples with errors:
Q: Which department has the highest average salary?
Wrong SQL: SELECT dept_name FROM department ORDER BY avg(salary) LIMIT 1;
(Error: salary is in employee table, need join)
Correct SQL: SELECT d.dept_name FROM department d JOIN employee e ON d.dept_id = e.dept_id GROUP BY d.dept_name ORDER BY AVG(e.salary) DESC LIMIT 1;

### Schema:
{schema}

### Question:
{question}

### Reasoning:
"""

结果:准确率提升到74.3%,但Token消耗飙升至1102。但这里有个大坑:模型会“模仿”错误的写法,然后自己画蛇添足地修正。比如某次生成:SELECT DISTINCT d.dept_name ... ORDER BY AVG(DISTINCT e.salary)——它把DISTINCT加到了聚合函数里,这在SQLite里是语法错误。

结论:错误修复示例有效,但必须配合temperature=0和严格的格式约束。

3.4 第4轮:结构化输出 + Schema精简

这是提升最明显的一轮。我做了两件事:
1. Schema只保留相关表(用关键词匹配预筛,比如问题提到“salary”就保留包含salary列的表)
2. 强制输出JSON格式{"thought": "...", "sql": "..."}

structured_prompt = """### Instruction:
You are an expert SQLite developer. Given the schema and question, output a JSON object with:
- "reasoning": a brief step-by-step plan (max 3 steps)
- "sql": the final SQL query

Rules:
- Only use tables/columns in the schema.
- SQL must be valid SQLite syntax.
- Use proper JOIN conditions.

### Schema (relevant tables only):
{schema_relevant}

### Question:
{question}

### Output (JSON):
"""

# 解析JSON并提取SQL
import json
def extract_sql(llm_output):
    try:
        data = json.loads(llm_output)
        return data["sql"]
    except:
        # fallback: 正则提取
        return re.search(r'"sql"\s*:\s*"([^"]+)"', llm_output).group(1)

结果

模板 EXEC准确率 平均Token消耗
错误修复 74.3% 1102
结构化+精简schema 82.6% 672

准确率提升到82.6%,Token消耗反而降下来了。原因很简单:schema精简减少了输入中的干扰信息,JSON格式约束让模型更专注于SQL生成而不是解释性文本。

3.5 第5轮:动态Few-shot选择 + 自一致性(Self-Consistency)

最后一步,我把Few-shot样本从“固定3个”改成“根据问题类型动态选择”。比如问题包含“average”就选聚合的示例,包含“compare”就选JOIN的示例。

同时,用n=5的self-consistency(生成5条SQL,投票选最终结果)。

sampling_params = SamplingParams(temperature=0.3, max_tokens=1024, n=5)

# 投票逻辑:执行所有候选SQL,选择结果集出现次数最多的
from collections import Counter
def self_consistency_sql(candidates, db_path):
    results = []
    for sql in candidates:
        try:
            conn = sqlite3.connect(db_path)
            result = conn.execute(sql).fetchall()
            results.append((sql, str(result)))
            conn.close()
        except:
            results.append((sql, "__ERROR__"))
    counter = Counter([r[1] for r in results if r[1] != "__ERROR__"])
    if not counter:
        return None
    most_common = counter.most_common(1)[0][0]
    return [r[0] for r in results if r[1] == most_common][0]

最终结果

模板 EXEC准确率 平均Token消耗
结构化+精简schema 82.6% 672
动态Few-shot + Self-Consistency 87.1% 986

准确率87.1%,Token消耗比基线多了3倍,但考虑到业务场景(内部工具,高延迟可接受),这个trade-off是值得的。

4. 核心实现:最终版Prompt模板

这是我在生产环境用的完整模板,直接复制可用:

### System:
You are an expert SQLite query generator. Follow these rules strictly:
1. Only use tables and columns listed in the schema.
2. If the question requires aggregation (avg, max, count), always use GROUP BY on the non-aggregated columns.
3. Use LEFT JOIN only when the question explicitly asks for "all" records from one side.
4. Always use DISTINCT when joining to avoid duplicate rows, unless the question implies duplicates should be kept.

### Schema (relevant tables):
{schema_relevant}

### Previous examples of correct SQL:
{dynamic_few_shot}

### Question:
{question}

### Output JSON format:
{"reasoning": "step-by-step plan", "sql": "the SQL query"}

注意:{dynamic_few_shot}这里我用了2个示例,每个示例包含question, reasoning, sql三个字段,但不包含错误示例——因为我发现在最终版本中,错误示例反而会干扰模型的置信度。

5. 踩坑与优化:三个典型问题

坑1:Schema顺序影响生成质量

一开始我把schema按字母序排列(表名a-z),但发现模型对“主表”的识别会出错。优化:把问题中明确提到的实体表放在schema最前面。比如问题提到“employee”,就把employee表放第一。准确率再+2.3%。

坑2:Token消耗估算偏差

vLLM的prompt_logprobs统计的是prompt部分,但实际计费还包括completion。我一开始只统计了prompt token,导致后续成本预测偏差了40%。建议:用total_tokens = prompt_tokens + completion_tokens计算。

坑3:JSON格式的脆弱性

temperature=0.1时,偶尔模型会输出{"reasoning": "..." }(带多余空格)导致json.loads失败。优化:先做一次正则清理:re.sub(r'\s+', ' ', output)

6. 效果数据:全量验证集结果

在Spider全量验证集(1034条)上,最终方案结果:

指标 数值
执行准确率 87.1%
语法正确率 99.2%
平均生成延迟 1.8s(A100 40GB)
平均Token消耗/请求 986(p+ c)
相比基线的准确率提升 +44.8个百分点

对比另一个方案:直接微调CodeLlama-7B(LoRA),准确率也只有79.4%,且需要2小时训练。Prompt Engineering的性价比远高于微调,尤其在这个场景下。

7. 总结与反思

这轮调优的核心结论:

  1. 结构化的输出格式(JSON)比思维链更关键——它强制模型在固定框架内推理。
  2. Schema精简是最被低估的优化手段——减少输入噪音比增加示例更有效。
  3. Self-Consistency是最后的杀手锏——代价是3-5倍延迟,适合对延迟不敏感的批处理场景。
  4. 不要盲目堆Few-shot——增加示例到5个以上时,准确率反而下降(可能是上下文干扰)。

后续我会尝试把动态Few-shot换成RAG(从训练集检索相似问题),预计还能再涨2-3个点。但就目前而言,87.1%的准确率已经达到业务上线标准了。

最后说一句:Prompt Engineering不是玄学,每一步都值得用数据说话。建议大家在项目中做一个这样的实验矩阵,比空想“怎么提示更好”高效得多。