一、问题背景:别让Prompt成为整个链路的性能瓶颈
我们团队在做内部数据分析平台,核心功能是让非技术员工用自然语言查询数据库。底层模型选了Claude-3.5-Sonnet(当时是2024年9月,版本号claude-3-5-sonnet-20240620)。最初版本上线后,用户反馈是“能用,但经常瞎编列名”“复杂查询完全不能看”。
我拉了一下后台日志,发现几个典型问题:
- 模型经常把
created_at猜成create_time,导致SQL执行直接报错。 - 对“近7天”这类相对时间,模型有时写死日期,有时用
INTERVAL,行为不稳定。 - 多表JOIN时,模型会忘记表别名,甚至把两张表的相同字段名混在一起。
这些问题本质上是Prompt设计缺陷——既没有给模型足够的“上下文约束”,也没有提供“输出规范”。于是我开始了一轮系统的Prompt迭代实验。实验环境固定,只改Prompt,其他变量全部锁死。
二、环境与版本:固定变量是实验有效性的前提
| 项目 | 配置 |
|---|---|
| 模型 | claude-3-5-sonnet-20240620 |
| 温度 | 0.0(关闭随机性) |
| max_tokens | 2048 |
| 测试集 | 1200条人工标注NL2SQL查询(覆盖单表/多表/聚合/时间条件) |
| 数据库 | PostgreSQL 14(含3张核心表:users, orders, products) |
| 评测指标 | 执行结果完全匹配率(严格模式)+ token消耗(含输入/输出) |
这里要特别强调:温度必须设0,否则同一个Prompt跑两次结果不一样,根本无法对比。另外,测试集的SQL执行结果要提前跑好存到文件里,脚本自动比对,避免人工判断的主观误差。
三、方案设计:五版Prompt的进化路径
我按照“从简到繁”的顺序设计了5个版本:
- V1(裸奔版):只给表结构+用户问题。
- V2(角色扮演版):加上“你是一个资深DBA”的身份设定,并明确禁止幻觉列名。
- V3(规则注入版):在V2基础上加入数据库方言规则(如时间函数用
date_trunc、字符串匹配用LIKE而非=)。 - V4(Few-shot版):在V3基础上加入3组“问题->正确SQL”示例,覆盖JOIN和聚合场景。
- V5(思维链+格式锁定版):在V4基础上要求模型先输出“思考过程”(提取表/字段/条件),再输出最终SQL,且用JSON格式包裹。
每个版本跑完1200条,记录准确率和平均token消耗。
四、核心实现:V5最终版Prompt及评测脚本
先上最终版Prompt,这是稳定运行在线上版本(代码块1):
SYSTEM_PROMPT = """你是一个PostgreSQL DBA,负责将用户自然语言问题转换为可执行的SQL。
严格遵守以下规则:
1. 仅使用提供的表结构中的字段,禁止创造列名。
2. 使用PostgreSQL 14语法(date_trunc, interval, etc.)。
3. 时间条件统一用 TIMESTAMP 类型比较。
你的输出必须是一个JSON对象,格式如下:
{
"thinking": "提取的表, 字段, 条件, 逻辑",
"sql": "SELECT ..."
}
只输出JSON,不要有任何额外文字。
示例1:
用户:近7天每个产品的订单总量
{"thinking": "表: orders, products; 条件: order_date >= now() - interval '7 days'", "sql": "SELECT p.name, COUNT(o.id) FROM orders o JOIN products p ON o.product_id = p.id WHERE o.order_date >= now() - interval '7 days' GROUP BY p.name"}
示例2:
用户:3月份消费超过1000元的用户
{"thinking": "表: orders, users; 条件: total_amount > 1000 AND order_date in March", "sql": "SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE o.total_amount > 1000 AND o.order_date >= '2024-03-01' AND o.order_date < '2024-04-01'"}
"""
def generate_sql(user_query, schema_ddl):
user_prompt = f"表结构如下,请回答问题:\n{schema_ddl}\n\n用户问题:{user_query}"
response = client.messages.create(
model="claude-3-5-sonnet-20240620",
max_tokens=2048,
temperature=0,
system=SYSTEM_PROMPT,
messages=[{"role": "user", "content": user_prompt}]
)
return json.loads(response.content[0].text)
注意几个细节:system字段里放了Few-shot示例,user字段只放表结构和问题。thinking字段必须保留,虽然它不参与SQL执行,但能让模型先“想清楚”再写SQL。另外,示例中故意覆盖了JOIN和GROUP BY——这两个是易错点。
评测脚本核心逻辑(代码块2):
def evaluate(prompt_version, test_set):
correct = 0
total_tokens = 0
for item in test_set:
sql = generate_sql(item["question"], schema_ddl)
exec_result = execute_sql(sql) # 连接PG执行
expected = item["expected_result"]
if exec_result == expected:
correct += 1
total_tokens += count_tokens(sql)
accuracy = correct / len(test_set) * 100
avg_tokens = total_tokens / len(test_set)
return accuracy, avg_tokens
# 运行V5
acc_v5, tokens_v5 = evaluate("v5", test_set)
print(f"V5 Accuracy: {acc_v5:.1f}%, Avg Tokens: {tokens_v5:.1f}")
五、踩坑与优化:三个让人抓狂的细节
坑1:JSON解析会偶发失败
V5上线后发现,约2%的请求返回的不是纯JSON,而是前面多了一行“好的,这是你的SQL”。原因:模型偶尔在系统提示之外“多嘴”。解决:在解析前加一层正则剥离,取第一个{到最后一个}之间的内容,同时把temperature从0.1降到0.0彻底锁定输出。
坑2:Few-shot示例不能“太完美”
V4阶段我放的示例都是简洁直接的SQL。但测试集里有很多查询涉及子查询和CTE,模型在示例中没有见过,会强行模仿示例风格而不敢用复杂语法。解决:在V5的Few-shot中故意加入一个带WITH子句的示例(代码中未展示,但线上版本有),让模型知道“复杂查询是允许的”。
坑3:token消耗反而下降了?
这是最反直觉的发现。V1裸奔版平均每次查询消耗2.1k tokens(因为模型容易多写无关注释和错误尝试)。V5虽然加了思维链和JSON格式,但模型不再输出大段解释性废话,最终平均token降至1.4k。结构化约束反而让模型更“惜字如金”——这个数据点很值得在PPT里讲。
六、效果数据:五个版本的横向对比
| 版本 | 准确率(严格匹配) | 平均tokens/次 | 失败案例Top原因 |
|---|---|---|---|
| V1 裸奔版 | 71.3% | 2.1k | 列名猜错(42%)、JOIN条件缺失(29%) |
| V2 角色扮演 | 76.8% | 2.0k | 时间函数语法错误(31%) |
| V3 规则注入 | 82.5% | 1.8k | 多表别名冲突(22%) |
| V4 Few-shot | 88.9% | 1.6k | 复杂子查询漏写别名(15%) |
| V5 思维链+格式锁定 | 94.1% | 1.4k | 极端罕见SQL(如窗口函数)仍失败(6%) |
从V1到V5,准确率提升了22.8个百分点,token消耗反而下降了33%。打脸了“更长的Prompt = 更多token = 更贵”的直觉。V5的失败案例集中在需要ROW_NUMBER()做排名或LAG()做环比这类分析函数上——模型在训练数据中见过,但示例和规则里没覆盖到,只能靠后续动态加Few-shot解决。
七、总结:Prompt Engineering不是玄学,是工程
这轮实验让我彻底抛弃了“随便写写Prompt”的懒散习惯。核心收获:
- 角色扮演 + 规则注入 + Few-shot + 思维链,四层叠加效果最好,但每一层都要做AB测试,否则不知道是哪层起的作用。
- token消耗和输出质量不是线性关系。V5用更少的token实现了更高的准确率,关键是把“思考”留在模型内部(
thinking字段),而不是让它用自然语言废话旁白。 - 固定测试集和评测脚本是底线。没有量化,一切优化都是空谈——这也是为什么我强调必须用1200条测试集跑完再下结论。
最后说句心里话:别迷信网上那些“万能Prompt模板”,每个业务场景的坑都不一样。拿测集跑一遍,让数据说话,这才是开发者该有的态度。如果你也在调NL2SQL,欢迎在评论区交流你的失败案例。