一、问题背景:为什么Prompt才是真正的瓶颈
上周接了个活,给公司内部的BI系统做自然语言查询接口。需求很简单:用户输入一句中文,系统返回SQL,然后查库出图表。听起来很常规对吧?结果一测,GPT-4直接给我拉了胯——问"上个月华东区销量Top10的产品",它居然生成了SELECT * FROM products ORDER BY sales DESC LIMIT 10,完全无视了"上个月"和"华东区"两个条件。
这不是模型笨,是我Prompt写得糙。痛定思痛,我决定系统地做一轮Prompt Engineering实验,目标明确:在Spider数据集(耶鲁大学Text-to-SQL benchmark)的一个300条子集上,把准确率干到85%以上,同时控制Token成本。
环境版本:
- 模型:OpenAI gpt-4-1106-preview(温度0.1,top_p 0.9)
- 对比模型:Claude-3.5-Sonnet(anthropic claude-3-5-sonnet-20240620)
- 数据集:Spider dev子集,300条,涉及11张表(电商场景)
- 评估指标:Execution Accuracy(执行准确率,直接跑SQLite比对结果)
- Token统计:使用tiktoken库精确计数
二、基线方案:一个朴素的零样本Prompt
第一版Prompt长这样,基本就是把网上教程抄了一遍:
def build_zero_shot_prompt(schema, question):
return f"""### 数据库Schema:
{schema}
### 用户问题:
{question}
请根据以上信息生成SQL查询语句。直接输出SQL,不要输出任何解释。"""
测完50条,准确率42.3%。问题非常明显:
1. 模型分不清JOIN和INNER JOIN的边界,经常多出冗余的子查询
2. 日期过滤条件经常被忽略——模型默认"当前时间",但数据里根本没有NOW()函数
3. 列名混淆严重,比如order_date和shipping_date经常搞混
Token消耗平均每次1.2K(输入1.0K + 输出0.2K),不算贵,但质量不行。
三、方案设计:四种策略的对比框架
我设计了一个2x2的对比矩阵,四个策略:
| 策略 | 描述 | 期望效果 |
|---|---|---|
| A. 零样本 + 格式化约束 | 在Prompt里严格规定输出格式(JSON包裹),禁止多余文字 | 减少非SQL噪音 |
| B. 少样本(3-shot) | 给出3个"问题-SQL"对例子,覆盖JOIN和GROUP BY | 提升复杂查询能力 |
| C. 结构化Schema描述 | 用CREATE TABLE语句替代自然语言描述,附带字段注释 | 减少列名歧义 |
| D. 思维链 + 隐式约束 | 让模型先写"SQL意图分解",再生成SQL | 提升多条件过滤的准确率 |
每个策略在相同的50条验证集上跑,用相同的评价脚本,不搞玄学。
四、核心实现:调优过程中的关键代码
4.1 Schema序列化方式(策略C的核心)
最初我用自然语言描述Schema,后来发现模型对CREATE TABLE语法更敏感。这段代码把数据库Schema转成模型的"母语":
import sqlite3
from typing import Dict, List
def schema_to_ddl(db_path: str) -> str:
"""把SQLite数据库转为CREATE TABLE语句,附带行数统计和样例值"""
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
# 获取所有表名
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'")
tables = [row[0] for row in cursor.fetchall()]
ddl_parts = []
for table in tables:
# 获取建表语句
cursor.execute(f"SELECT sql FROM sqlite_master WHERE name='{table}'")
create_sql = cursor.fetchone()[0]
# 追加注释:行数 + 前3个样例值(关键优化点)
cursor.execute(f"SELECT COUNT(*) FROM {table}")
row_count = cursor.fetchone()[0]
cursor.execute(f"SELECT * FROM {table} LIMIT 3")
sample_rows = cursor.fetchall()
sample_str = "; ".join([str(row[:3]) for row in sample_rows]) # 截断长字段
ddl_parts.append(f"{create_sql} -- 行数: {row_count}, 样例: {sample_str}")
return "\n\n".join(ddl_parts)
4.2 思维链模板(策略D)
注意这里有个坑:思维链不是让模型"自由发挥",而是强制它填空。用了JSON模板约束:
PROMPT_TEMPLATE_D = """
任务:根据用户问题生成SQLite查询语句。
数据库Schema如下:
{schema}
用户问题:{question}
请严格按照以下JSON格式输出(不要输出任何其他内容):
{{
"intent_breakdown": {{
"filter_conditions": ["这里列出所有WHERE条件,逐个列出"],
"join_tables": ["这里列出需要JOIN的表及连接键"],
"aggregations": ["这里列出GROUP BY或聚合函数"],
"order_by": "这里列出排序要求,无则写null"
}},
"sql": "最终生成的SQL语句,不要带分号"
}}
注意:
1. 如果用户提到时间,必须转换为具体日期范围,当前日期固定为2024-01-31
2. 所有字符串值必须加单引号
3. 禁止使用SELECT *,必须显式列出字段
"""
五、踩坑与优化:那些让人崩溃的隐性陷阱
坑1:模型输出的SQL带Markdown代码块标记。 这是个低级但高频的问题——有17%的生成结果被sql包裹,直接执行会报错。解决方式:在后处理里加正则`re.sub(r'sql|``', '', sql),但更根本的是在Prompt里强调"不要用Markdown代码块"。
坑2:日期过滤的"时区幻觉"。 模型喜欢用BETWEEN '2024-01-01' AND '2024-01-31',但如果表里存的是datetime类型,这种比较会漏掉1月31日当天的数据。正确做法是>= '2024-01-01' AND < '2024-02-01'。这个在策略D里,通过强制写出"date_range"字段来约束。
坑3:Token消耗的隐性暴涨。 思维链(策略D)虽然准确率高,但输出Token从0.2K涨到0.9K。为了控制成本,我把intent_breakdown的格式从自由文本改成了固定字段填空,Token降到0.6K。
坑4:少样本(策略B)的负面效应。 3个例子确实提升了JOIN准确率,但模型开始模仿例子的错误风格——比如例子里有ORDER BY COUNT(*) DESC,它就到处乱用。最终我把少样本的例子里故意加入一条"错误示范+纠正"(这招很有效,后面数据会说)。
六、效果数据:18轮迭代后的最终对比
跑完所有组合(每种策略在50条验证集上3次取平均,再在300条全量上复测),关键数据如下:
| 策略 | 准确率(50条) | 准确率(300条) | 平均Token/次 | 失败主因 |
|---|---|---|---|---|
| A. 零样本+格式化 | 48.7% | 45.2% | 1.0K | 多条件漏过滤 |
| B. 3-shot(标准) | 61.3% | 58.9% | 1.4K | 模仿错误风格 |
| B2. 3-shot(含纠错例) | 72.0% | 69.5% | 1.4K | 复杂嵌套查询 |
| C. DDL Schema | 76.0% | 74.8% | 1.1K | 时间条件处理 |
| D. 思维链+JSON | 84.0% | 82.1% | 1.3K | 输出格式偶发断裂 |
| D+(最终版) | 91.3% | 89.1% | 0.8K | 极端多表JOIN |
最终版D+的组合拳:
1. 用策略C的DDL Schema(含样例值)
2. 用策略D的JSON思维链模板
3. 在模板里加入"date_range"强制转换规则
4. 后处理:剥离Markdown + 分号清理 + 简单语法校验(用sqlite3 execute试跑,失败则重试1次)
成本核算:300条全量测试,D+策略总Token消耗约240K(0.8K x 300),按GPT-4-1106-preview价格(输入$0.01/1K,输出$0.03/1K)计算,总成本约$5.7。相比基线策略A(总Token 300K,成本约$6.9),准确率从45%提升到89%,单位成本下降17%,准确率提升97%。
Claude-3.5-Sonnet在同一测试集上,用同样的D+模板,准确率85.6%,略低于GPT-4但差距不大。有意思的是,Claude对JSON格式的遵循更稳定(输出断裂率仅2% vs GPT-4的5%)。
七、总结:Prompt Engineering不是玄学
这次调优最大的感悟是:好的Prompt是"约束的艺术"而不是"话术的艺术"。关键不在你说了多少,而在你限制了多少——限制输出格式、限制推理步骤、限制时间处理逻辑,每一条硬约束都在帮模型少犯一类错误。
具体建议:
1. Schema一定要给DDL+样例值,这比自然语言描述有效得多,准确率直接+20%
2. 思维链不要自由发挥,用JSON模板填空,Token消耗可控且输出稳定
3. 少样本要包含错误纠正示例,比单纯给正例有效
4. 后处理是必需品,别指望模型输出一次成型
这套方法论可以平移到代码生成、实体抽取等任务上,核心思路一致:先定输出格式,再约束推理路径,最后用规则兜底。代码我放在GitHub仓库(链接见评论区),欢迎拿去跑自己的数据集。有问题评论区聊,看到会回。