1. 问题背景:看似简单的SQL生成,为何准确率卡在40%?

事情源于上季度的一个内部数据中台项目。业务方要求实现自然语言查数功能,我们最初基于GPT-4-Turbo(1106版本)直接构建了一个零样本Prompt,把用户问题+表结构拼接丢给模型。上线后一周,实测准确率惨不忍睹——仅42%。具体问题集中在这几类:

  • 列名混淆user_iduser_uid被模型随意混用
  • 隐式连接缺失:模型不会根据外键自动JOIN
  • 聚合逻辑错误:对“近30天平均消费”这类时间窗口理解偏差

最头疼的是,模型经常“一本正经”地生成语法完全正确但逻辑错误的SQL,人工review成本极高。于是我开始系统性研究如何用Prompt Engineering来根治这个顽疾。

2. 实验环境与基线设定

本次实验全部在本地Python 3.10 + OpenAI SDK 1.12.0环境中完成,模型为gpt-4-1106-preview(temperature=0, max_tokens=512)。评测数据从Spider验证集中随机抽取200条查询,覆盖20张关联表。我定义了两个核心指标:

  • 执行准确率(Exec Acc):生成的SQL在SQLite引擎上跑出的结果与标准答案完全一致
  • Token消耗:单次请求的Prompt+Completion总Token数,按OpenAI计价$0.01/1K input, $0.03/1K output估算

基线(V1)Prompt长这样:

def build_prompt_v1(question, schema):
    return f"""Given the following SQL schema, answer the question with a SQL query.
Schema: {schema}
Question: {question}
SQL Query:"""

简单粗暴,结果Exec Acc 42%,平均单次消耗Token 891。这个基线惨烈到什么程度?有15%的SQL因为表名拼写错误直接报语法错。

3. 方案设计:从“裸奔”到“结构化约束”的演进路径

我设计了三条优化路线,每条路线内部做A/B测试:

路线A:Schema语义增强 —— 不改变Prompt框架,只把建表语句替换为带列注释和值分布的描述
路线B:Few-shot示例工程 —— 在Prompt中注入3个精选的难例(含JOIN、子查询、GROUP BY)
路线C:执行反馈闭环 —— 加入“如果SQL报错,请根据以下错误信息修正”的自我纠错指令

关键决策点:是否告诉模型“你是SQL专家”? 实测发现这类角色设定对准确率无显著影响(+0.5%以内),反而增加Token开销。果断放弃。

最终V5 Prompt结构如下:

[SYSTEM] 你是数据库工程师。根据SCHEMA和QUESTION生成SQLite查询语句。
要求:
1. 仅当需要关联表时使用INNER JOIN,禁止笛卡尔积
2. 使用strftime('%Y-%m-%d', date_col)处理日期过滤
3. 若QUESTION含“每/各”字眼,必须使用GROUP BY
4. 输出前自我检查:表名是否存在?列名是否准确?聚合函数是否匹配?

[USER]
SCHEMA:
{table1: user(id INT PK, name TEXT, reg_date TEXT)}
{table1: order(order_id INT PK, user_id INT FK, amount REAL, order_date TEXT)}

QUESTION: 查询2023年每月注册用户数,按月份升序

SQL:

4. 核心实现:动态Schema注入与错误修复示例

这里展示最关键的两个实现片段。第一个是Schema压缩器,它把原生SQL建表语句转成模型友好的字典格式:

def schema_compressor(create_statements: dict) -> str:
    """压缩schema为模型友好的描述格式"""
    lines = []
    for table, cols in create_statements.items():
        col_desc = []
        for col in cols:
            # 提取列名和类型,并附加外键关系
            fk_note = f" FK->{col['ref_table']}" if col.get('ref_table') else ""
            sample_val = col.get('sample_value', '')
            col_desc.append(f"{col['name']}({col['type']}{fk_note})")
        lines.append(f"{table}: {', '.join(col_desc)}")
    return "\n".join(lines)

# 实际调用: V5版Prompt比V1多注入50-80个token,但换来了对列名含义的消歧

第二个是错误修正示例对。我收集了开发阶段最常见的三种报错(no such columnmisuse of aggregatenear "GROUP"),构造了Few-shot修复对:

FIX_EXAMPLES = [
    {
        "wrong_sql": "SELECT COUNT(*) FROM orders GROUP BY date(order_date);",
        "error_msg": "no such function: date",
        "fixed_sql": "SELECT COUNT(*) FROM orders GROUP BY strftime('%Y-%m-%d', order_date);"
    },
    # 更多示例...
]

def build_fix_shot_prompt(question, schema, wrong_sql, error_msg):
    return f"""
参考以下错误修复案例:
Q: {FIX_EXAMPLES[0]['wrong_sql']} -> 错误: {FIX_EXAMPLES[0]['error_msg']} -> 修正: {FIX_EXAMPLES[0]['fixed_sql']}

现在请修正:
Q: {wrong_sql}
错误: {error_msg}
修正后的SQL:"""

这个函数在首次生成SQL执行失败后触发,正好利用模型对“纠错”任务天然的高容错性。实测这个二阶段重试机制,把最终可执行率拉到了98%。

5. 踩坑与优化:那些效率杀手与Token陷阱

坑1:全Schema注入导致注意力涣散
V2版本我把整个数据库20张表全部塞入Prompt(约1800 Token)。模型反而开始“选择困难症”,动不动就生成不存在的表。后来改用关键词检索动态拼接Schema,仅注入问题中出现的实体及关联表。Token消耗直降38%,准确率反而提升12%。

坑2:Few-shot示例的“反作用力”
V3版本我放了5个示例,覆盖了子查询、CASE WHEN等复杂语法。结果模型开始过度模仿——明明简单COUNT就能解决的问题,非要用窗口函数。最后精简为3个示例,且明确标注“示例仅展示语法模式,不要复制结构”。

坑3:自我检查指令的双刃剑
V4加入“请检查你的SQL”后,模型偶尔会把自己原本正确的SQL推翻重写。解决方式是用System Message固定格式:“只需输出最终SQL,不要输出检查过程”。这样既保留了思考链路的正确引导,又杜绝了无意义的自我否定。

最终效果数据(200条评测集):

版本 Exec Acc 平均Token 单次成本($) 重试率
V1零样本 42% 891 0.019 58%
V3+Schema 67% 1032 0.023 31%
V5+纠错闭环 91% 1058 0.024 9%

Token消耗仅增长18.7%,但重试率从58%降到9%。算上重试成本,实际总费用反而下降约52%——这才是Prompt Engineering真正的价值杠杆。

6. 总结:可复用的Prompt调优方法论

这次实战沉淀出三条经验,适用于绝大多数Text-to-SQL乃至代码生成场景:

  1. 先诊断错误类型,再设计Prompt。我花了整整两天分析那58%的错误SQL,发现列名混淆占34%、JOIN缺失占29%、函数误用占22%。每个问题都对应一个具体的Prompt指令,而不是笼统地说“请生成正确SQL”。

  2. Token预算向关键信息倾斜。与其堆砌“你是高手”这类废话,不如把字符预算花在列名枚举、值格式示例、外键关系描述上。V5版Prompt中,结构化schema描述占比73%,指令仅占27%。

  3. 用“重试成本”而非“单次准确率”衡量收益。虽然单次Token涨了,但整体开销降了。在成本敏感的生产环境,这个指标更真实。

最后说句大实话:Prompt Engineering没有银弹。如果任务逻辑复杂度超过一定阈值(比如多跳推理+时间窗口),再精妙的Prompt也打不过微调或RAG。但至少在这类结构化生成任务里,它用不到100行代码撬动了2倍多的性能提升——这笔账,怎么算都值。