一、问题背景:为什么把Prompt当“玄学”是最大的坑

上个月在做一个内部数据中台项目,需要让业务人员用自然语言查询数据库。技术选型定了Text-to-SQL方案,直接调GPT-4-turbo API。第一版Prompt写得非常“简单粗暴”:

你是一个SQL专家,请根据用户的问题生成SQL。数据库是MySQL。

上线后发现:复杂查询(多表JOIN、嵌套子查询)准确率惨不忍睹,只有61.3%。更头疼的是,因为模型经常“自由发挥”生成不存在的列名,导致需要重试,平均每个查询API调用次数达到1.7次。算下来一次查询成本接近0.03美元(按$0.01/1K input tokens, $0.03/1K output tokens计算),体感就是钱在燃烧。

调研了一圈,发现Prompt Engineering不是简单“写几句好话”就行。它本质上是约束模型概率分布的工程手段。我决定用一整天时间,专门针对“Spider数据集(开发集,包含1,034条查询)”做一个控制变量的对比实验。

二、环境与版本:固定一切不确定因素

为了确保实验可复现,硬件与软件环境如下:

  • 模型gpt-4-1106-preview(注意:不是gpt-4,后者贵30%且支持函数调用格式不同)
  • API接口openai Python库 v1.6.1
  • 超参数固化temperature=0.3(初期),后续改为0.1;max_tokens=600frequency_penalty=0presence_penalty=0
  • 评估指标Execution Accuracy(在MySQL 8.0实例上跑查询,比对结果集)
  • Token统计:使用tiktoken库的cl100k_base编码器

这里有个血泪教训:必须关掉top_p的默认设置。官方文档说建议修改temperature或top_p其中之一,但我在实验中发现两者同时非默认值会导致输出随机性增大,所以固定top_p=1

三、方案设计:三轮Prompt迭代,每轮改一个变量

我设计了三个版本的Prompt模板,严格遵循“控制变量法”:

  • V1(零样本基础版):仅角色设定+任务描述,不提供任何示例。
  • V2(结构化Schema+Few-shot):提供完整的数据库表结构(字段名、类型、主外键),并给3条不同难度的“问题-SQL”示例。
  • V3(反例增强+输出约束):在V2基础上,加入1个“错误SQL及原因分析”的反例,并强制模型输出JSON格式(包含thoughtsql字段)。

每条测试SQL都会清空对话上下文,模拟真实API无状态调用。测试集从Spider开发集中按难度分层抽样100条(简单40/中等40/困难20)。

四、核心实现:代码里的关键细节

V1 Prompt构造(初版,极简)

def build_prompt_v1(question: str) -> str:
    return f"""You are a senior SQL expert. 
Given the user's question, generate a MySQL query.
Question: {question}
SQL:"""

V3 Prompt构造(完整版,含Schema与反例)

def build_prompt_v3(question: str, schema_sql: str, examples: list, counter_example: dict) -> str:
    prompt = f"""You are a MySQL expert. Follow the rules strictly.

### Database Schema (from information_schema):
{schema_sql}

### Examples (Question -> SQL):
"""
    for ex in examples:
        prompt += f"Q: {ex['question']}\nSQL: {ex['sql']}\n\n"

    prompt += f"""### Counter-Example to Avoid:
Question: {counter_example['question']}
Bad SQL: {counter_example['bad_sql']}
Error Reason: {counter_example['reason']}

### Output Format:
Respond in JSON only: {{"thought": "brief reasoning", "sql": "the final SQL"}}

### User Question:
{question}

Your JSON response:"""
    return prompt

注意一个细节:Schema一定要用CREATE TABLE语句的原始文本,而不是自然语言描述。实测用自然语言描述表结构会让模型对数据类型产生幻觉,比如乱用VARCHAR当主键。

五、踩坑与优化:token消耗下降背后是“注意力”聚焦

第一轮跑完V1,数据惨烈:准确率61.3%,平均消耗2,847 tokens(其中输出平均423 tokens,因为模型经常“啰嗦”地解释)。最大的坑是模型生成了一堆不存在的列名(比如SELECT department_name,但表里实际叫dept_name)。

V2加入Few-shot后,准确率提升到74.8%,但token消耗不降反升——平均3,102 tokens。原因是我给的示例太长了,每条示例超过300 tokens,模型在上下文里“迷失”了。

关键优化:我把Schema从“全库表结构”裁剪为只包含查询涉及到的3张表(通过关键词预匹配),并给每个字段加了注释(如-- 员工姓名)。这个操作立竿见影:V3版本平均token消耗降到1,934 tokens,比V1还低32%。

另外,发现一个反直觉现象:加入反例后,输出质量提升但偶尔生成非法JSON。解决方案是在response_format参数里设置{"type": "json_object"}(GPT-4-Turbo专属),但注意这会让token消耗略微增加3-5%(强制JSON模式需要额外指令)。最终我做了取舍:保留JSON约束,但在解析失败时降级为纯文本正则提取SQL。

六、效果数据:准确率与成本的双重胜利

最终在100条抽样集上的数据如下(取3次运行平均值):

版本 Execution Accuracy 平均Input Tokens 平均Output Tokens 总tokens/条 估算成本($/千条)
V1 61.3% 2,102 745 2,847 0.031
V2 74.8% 2,587 515 3,102 0.034
V3 86.5% 1,489 445 1,934 0.019

注意V3的总成本比V1低38.7%。主要原因有两个:一是准确率提升后,不需要重试(V1重试率高达27%,V3只有6%);二是裁剪Schema和反例让输入长度缩减了29%。

在困难子集(20条)上,V1只有35%准确率,V3达到了75%。这证明反例注入对于复杂查询(多JOIN、GROUP BY + HAVING)的纠错效果显著

七、总结:Prompt Engineering不是艺术,是信息论

回看这次实验,最大的感悟是:Prompt的本质是“上下文窗口的预算分配”。V1把预算全浪费在模型瞎猜上;V2虽然给了示例但信息密度太低;V3通过Schema裁剪和反例,让模型把注意力集中在正确的表名和列名上。

最后分享一个实用建议:如果你也在做类似任务,先跑100条数据统计“错误类型分布”(列名错误、JOIN条件错误、聚合函数误用等),然后针对Top2的错误类型写反例。这比盲目堆Few-shot数量有效得多。目前这个方案已上线到内部系统,每天处理约2,000次查询,成本从每月约$60降到了$41左右——省下的钱够我喝一个月生椰拿铁了。