一、问题背景:为什么我要死磕这个Prompt

上个月接了个活儿,从采购合同里抽取12个关键字段:合同编号、甲方乙方、签订日期、金额、税率、付款方式、违约责任条款、交付周期等。业务方一开始想让我微调模型,我算了笔账:标注2000条合同数据,加上训练和迭代,至少两周,而且合同模板一改就得重来。

我决定先用Prompt Engineering硬扛。直觉告诉我,这类"结构化信息抽取"任务,Prompt设计的好坏可能比换模型更重要。事实证明我是对的——同一批50份合同测试集,我最差的Prompt准确率54%,最好的95%,差了41个百分点,而Token消耗差了3.3倍。

这篇文章就是把整个实验过程摊开给你看。

二、环境与版本

  • 模型:gpt-4o-2024-08-06(主测),claude-3-5-sonnet-20241022(对照)
  • SDK:openai==1.54.0anthropic==0.39.0
  • 温度:全部固定 temperature=0top_p=1,减少随机性干扰
  • 评测:50份真实采购合同(脱敏),12个字段,人工标注Ground Truth
  • 指标:字段级准确率(Field Accuracy)、平均Token消耗、平均延迟
  • 关键参数:response_format={"type": "json_object"}(GPT-4o支持)

测试脚本骨架如下:

# eval_runner.py
import json, time
from openai import OpenAI
from typing import Callable

client = OpenAI(api_key="sk-xxx")

def run_eval(prompt_builder: Callable, contracts: list[dict], model="gpt-4o-2024-08-06"):
    total_tokens, total_acc, total_latency = 0, 0, 0
    for item in contracts:
        prompt = prompt_builder(item["text"])
        t0 = time.time()
        resp = client.chat.completions.create(
            model=model,
            temperature=0,
            top_p=1,
            response_format={"type": "json_object"},
            messages=[{"role": "user", "content": prompt}],
        )
        latency = time.time() - t0
        pred = json.loads(resp.choices[0].message.content)
        acc = field_accuracy(pred, item["label"])  # 自定义字段级比对
        total_tokens += resp.usage.total_tokens
        total_acc += acc
        total_latency += latency
    n = len(contracts)
    return {
        "avg_accuracy": round(total_acc / n, 4),
        "avg_tokens": round(total_tokens / n, 1),
        "avg_latency_s": round(total_latency / n, 2),
    }

def field_accuracy(pred: dict, label: dict) -> float:
    keys = label.keys()
    hit = sum(1 for k in keys if str(pred.get(k, "")).strip() == str(label[k]).strip())
    return hit / len(keys)

三、方案设计:5版Prompt的演进路线

我按照"信息量递增"的思路设计了5个版本,每版只改动一个变量,方便归因:

版本 核心策略 关键改动
V1 朴素提问 直接说"抽取以下字段"
V2 字段定义 每个字段加一句说明
V3 结构化输出约束 强制JSON Schema + 空值规则
V4 Few-shot 加2个完整示例(含难例)
V5 Few-shot + 思维链 + 字段级CoT 金额/日期先推理再输出

四、核心实现:从V1到V5的Prompt代码

V1:朴素提问(别笑,很多人真这么写)

def v1(text):
    return f"""从下面的合同中抽取字段:合同编号、甲方、乙方、签订日期、金额、税率、付款方式、违约责任、交付周期、签约地点、联系人、联系电话。

合同内容:
{text}
"""

V1的问题很明显:模型不知道"金额"要不要含税、"签订日期"用什么格式、"违约责任"是抽原文还是总结。结果就是格式五花八门,有的返回"2024年3月5日",有的返回"2024-03-05",比对全挂。

V3:结构化输出约束(质变开始)

SCHEMA = """{
  "contract_no": "string, 合同编号,若无则填 null",
  "party_a": "string, 甲方全称",
  "party_b": "string, 乙方全称",
  "sign_date": "string, 格式严格为 YYYY-MM-DD",
  "amount": "number, 合同总金额(含税),单位元,去掉千分位",
  "tax_rate": "number, 税率,如 0.13 表示13%,无法确定填 null",
  "payment_method": "string, 付款方式原文摘要,不超过50字",
  "liability": "string, 违约责任原文摘要,不超过100字",
  "delivery_period": "string, 交付周期,如 '30个自然日'",
  "sign_place": "string, 签约地点",
  "contact": "string, 联系人姓名",
  "phone": "string, 联系电话,保留原始格式"
}"""

def v3(text):
    return f"""你是合同信息抽取专家。请从合同文本中抽取字段,严格按以下JSON Schema输出,不要输出任何解释。

Schema:
{SCHEMA}

规则:
1. 找不到的字段填 null,禁止编造。
2. 日期统一 YYYY-MM-DD。
3. 金额只保留数字,不含单位和逗号。

合同文本:
{text}"""

V3一上来准确率就从54%跳到78%。核心功臣是"找不到填null,禁止编造"和日期/金额格式的硬约束。

V5:Few-shot + 字段级思维链(最终生产版)

FEW_SHOT = """
示例1(简单):
输入:甲方:XX科技有限公司;乙方:YY贸易有限公司;合同金额:人民币1,250,000元(含税13%)...
输出:{"contract_no": "HT-2024-001", "party_a": "XX科技有限公司", ...}

示例2(难例,金额分散且有歧义):
输入:...本合同总价为人民币捌拾万元整,其中不含税金额707,964.60元,增值税税额92,035.40元...
输出:{"amount": 800000, "tax_rate": 0.13, ...}
注意:amount 取含税总价,不要取不含税金额。
"""

def v5(text):
    return f"""你是合同信息抽取专家,严格按Schema输出JSON。

Schema:
{SCHEMA}

{FEW_SHOT}

抽取步骤(在内心执行,不要输出):
1. 先定位金额相关句子,判断是含税还是不含税总价。
2. 再定位日期,区分签订日期与生效日期。
3. 最后逐字段填充,缺失填 null。

合同文本:
{text}"""

注意:V5里我写了"在内心执行,不要输出",这是关键。早期我让它显式输出推理过程,结果Token暴涨到1600+,而且JSON经常被推理文本污染导致解析失败。改成隐式CoT后,Token只比V4多了不到80,准确率却涨了9个点。

五、踩坑与优化

坑1:JSON截断。 V4加长示例后,max_tokens默认值不够,长合同输出被截断,json.loads直接报错。解决:显式设 max_tokens=1024,并在Prompt末尾强调"输出必须完整闭合"。

坑2:幻觉字段。 V2时模型会给不存在的"签约地点"编一个"北京市朝阳区"。V3的"禁止编造+null"规则直接治好。

坑3:数字格式。 "1,250,000元"和"1250000"在字符串比对时不等。统一在Prompt里要求纯数字,评测侧也做了归一化。

坑4:温度。 一开始用默认temperature=1,同一份合同跑三次结果不同,评测完全没法复现。改成0之后稳定。

六、效果数据

50份合同测试集,结果如下:

版本 字段准确率 平均Token 平均延迟
V1 54.2% 312 1.8s
V2 66.8% 487 2.1s
V3 78.4% 623 2.4s
V4 86.1% 968 3.2s
V5 95.3% 1047 3.5s

对照Claude 3.5 Sonnet跑V5 Prompt:准确率94.1%,Token 1120,延迟4.1s。GPT-4o在这个任务上略胜,但差距不大。

成本核算:GPT-4o输入$2.5/1M tokens、输出$10/1M tokens。V5平均1047 tokens(约800输入+247输出),单份合同成本约 $0.0045,一万份合同45美元。相比微调方案(标注+训练+推理)省了至少一个数量级的钱和时间。

七、总结

三点体会:

  1. 结构化约束是ROI最高的改动。 从V1到V3只加了一段Schema和几条规则,准确率+24个点,Token只涨一倍。
  2. Few-shot要放难例,不要放简单例。 我V4的两个示例一个是标准格式,一个是金额歧义难例,后者贡献了大部分提升。
  3. CoT要隐式,不要显式。 显式推理在抽取任务里是负优化,Token翻倍还污染JSON。

最终生产环境我用的就是V5,跑了三周,线上准确率稳定在94%-96%。模型没换,只是把Prompt当代码一样迭代——这大概就是Prompt Engineering最朴素的价值。