1. 问题背景:当Prompt成为性能瓶颈
上季度我们接手一个金融BI项目,核心需求是将自然语言问句转换为可执行的SQL查询。初期直接调用GPT-4o-turbo,效果惨不忍睹:在100条业务问句的测试集上,SQL执行准确率仅68%,且每次调用的Token消耗高达2,184(约0.0065美元/次)。进一步分析发现三个致命问题:
- Schema注入过载:我们一股脑将27张表的完整DDL(约9,800字符)塞进system prompt,模型在长上下文中丢失了关键外键关系。
- 零样本输出失控:模型偶尔生成
SELECT *或遗漏WHERE条件,尤其在多表JOIN场景下。 - 成本不可控:为了提升准确率尝试加长prompt,结果Token消耗线性增长,但准确率停滞在75%左右。
这促使我系统性地对Prompt进行工程化改造,而不是继续“玄学调参”。
2. 环境与版本
- 模型:
gpt-4o-turbo-2024-08-06(OpenAI API, temperature=0.0, max_tokens=1024) - 工具链:Python 3.10 + openai==1.35.7 + sqlite3(本地验证执行)
- 数据集:内部构造的100条中文问句,覆盖单表筛选、多表JOIN、聚合分组、子查询四类场景
- 评测指标:SQL执行结果与标准答案的逐行比对准确率(严格模式,不考虑列顺序);Token计数通过API返回的
usage.prompt_tokens与completion_tokens累加
3. 方案设计:三层Prompt架构
放弃“单一大prompt”思路,拆分为三个可独立优化的模块:
3.1 Schema感知裁剪(解决上下文污染)
不再注入全部DDL,而是预解析Schema,构建一个表-列-类型-注释的紧凑字典,并按问句中的关键词动态筛选相关表。例如问句出现“成交额”时,只注入trades表结构,而非全量。
3.2 示例动态检索(解决零样本不稳定性)
利用Embedding(text-embedding-3-small)对历史高频问句做向量化,每次请求时检索最相似的3个示例(few-shot)。示例中明确包含“错误的SQL+正确SQL”对比,强制模型学习模式。
3.3 输出格式约束(解决解析成本)
在prompt末尾追加严格的JSON输出指令,要求模型返回{"sql": "...", "explanation": "..."},并声明“只输出JSON,不要Markdown”。
4. 核心实现:从v1到v5的迭代
4.1 v1:基线(单一大prompt)
def build_prompt_v1(question: str, schema_ddl: str) -> str:
return f"""
你是SQL专家。根据以下数据库结构,将问题翻译为SQLite SQL。
数据库结构:
{schema_ddl}
问题:{question}
请输出SQL:
"""
这个版本的问题在于:DDL太长,模型注意力分散,经常忽略INTEGER PRIMARY KEY之类的约束。
4.2 v5:三层结构(最终版)
def build_prompt_v5(question: str, schema_subset: str, similar_examples: list[dict]) -> str:
# 1. 系统角色与硬性规则
system = """你是金融数据库查询专家。必须遵守:
1. 只输出JSON对象,格式为{"sql": "...", "explanation": "..."}
2. SQL必须使用SQLite语法,禁止SELECT *
3. 如果问句有歧义,在explanation中说明假设"""
# 2. 动态Schema(裁剪后)
schema_part = f"可用表结构:\n{schema_subset}"
# 3. 动态示例(检索到的3条)
example_part = "参考示例(注意错误的写法):\n"
for ex in similar_examples:
example_part += f"输入:{ex['question']}\n错误SQL:{ex['wrong_sql']}\n正确SQL:{ex['right_sql']}\n"
# 4. 当前问句
query_part = f"当前问题:{question}\n请输出JSON:"
return system + "\n\n" + schema_part + "\n\n" + example_part + "\n\n" + query_part
关键改动:把system指令放到最前面(实验发现GPT-4o对前200字符的注意力权重更高),且Schema部分用###分隔符显式标注。
5. 踩坑与优化过程
5.1 坑1:示例中的“错误SQL”不可过多
v3版本尝试给5个示例,其中2个是错误案例。结果模型学会了“故意犯错”——准确率反而下降至82%。最终调整为3个示例,且错误案例只保留1个,并加注释“这是反例”。
5.2 坑2:Token计数的隐藏成本
动态检索本身需要Embedding调用,每次额外消耗约200 Token(text-embedding-3-small的输入)。但相比v1的2,184 Token,v5的总消耗(1,267 + 200 = 1,467)依然有33%的降幅。必须把检索成本算入总账,否则会得出错误结论。
5.3 坑3:SQLite与MySQL的语法差异
模型默认输出MySQL风格(反引号、LIMIT不带分号),导致本地SQLite执行报错。在system prompt中增加一句“所有标识符用双引号,分号结尾”,并在输出后强制.lower()过滤掉USE、SET等语句。
5.4 优化:缓存Schema裁剪结果
对于频繁出现的表结构(如trades),提前将裁剪后的Schema序列化为字符串并缓存,避免每次重新遍历元数据。这一改动将单次请求的预处理时间从80ms降至12ms。
6. 效果数据与对比
在100条测试集上的严格评测结果:
| 版本 | Prompt设计 | 准确率 | Prompt Token | Completion Token | 总Token | P95延迟(s) |
|---|---|---|---|---|---|---|
| v1 | 全量DDL,零样本 | 68% | 2,184 | 342 | 2,526 | 3.8 |
| v2 | Schema裁剪,零样本 | 75% | 1,452 | 318 | 1,770 | 3.1 |
| v3 | v2+5示例 | 82% | 1,987 | 356 | 2,343 | 4.2 |
| v4 | v2+3示例(含1反例) | 86% | 1,703 | 331 | 2,034 | 3.4 |
| v5 | v4+JSON约束+指令前置 | 91% | 1,267 | 289 | 1,556 | 2.1 |
额外验证:
- 在20条未见过的新问句上,v5准确率88%,v1为61%,说明泛化性提升。
- 错误分析:v5剩余的9条错误中,6条是问句本身歧义(如“最近一周”未定义),3条是模型对GROUP BY后HAVING的误用。
7. 总结与可复用经验
这次调优的核心收获不是某个prompt模板,而是建立了一套可度量、可迭代的Prompt工程流程:
- 精准裁剪优于堆砌信息:模型不需要知道全部真相,只需要知道与当前任务相关的部分。
- 示例是双刃剑:太多示例或错误示例会导致模型模仿噪声,控制在3个以内且明确标注反例。
- 指令位置很重要:GPT-4o对前置指令的遵循率比后置高约15%(我们测试了10个变体)。
- 成本必须端到端计算:任何prompt优化都要把检索、预处理等附加成本算进去,否则可能得不偿失。
最后吐槽一句:网上那些“万能Prompt模板”基本是扯淡,真正有效的prompt一定是针对你的数据、场景和模型版本调出来的。建议大