一、问题背景:为什么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. 模型分不清JOININNER JOIN的边界,经常多出冗余的子查询
2. 日期过滤条件经常被忽略——模型默认"当前时间",但数据里根本没有NOW()函数
3. 列名混淆严重,比如order_dateshipping_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仓库(链接见评论区),欢迎拿去跑自己的数据集。有问题评论区聊,看到会回。