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

上个月接了个活儿,帮一家做供应链金融的公司处理采购合同。需求很朴素:从PDF转出来的合同文本里,抽取「甲方名称、乙方名称、合同金额、签订日期、付款方式、违约金比例」这6个字段。

一开始我想得很简单,GPT-4o这么强,直接问不就行了?结果第一版Prompt跑完100份合同,人工核对后发现准确率只有72%,主要错在:
- 金额把「含税总价」和「不含税价」搞混
- 日期抽成了合同里的其他日期(比如交货日期)
- 付款方式输出一大段自由文本,下游没法解析

更肉疼的是成本。我们每天要处理约5000份合同,第一版Prompt平均每次消耗1200 token,按GPT-4o输入$2.5/1M token算,一天就是15美元,一个月450美元。老板虽然没说啥,但我自己看着账单难受。

所以我决定系统性地做一轮Prompt Engineering实验,目标很明确:准确率≥95%,单次Token≤400

二、环境与版本

所有实验环境固定如下,避免变量干扰:

  • 模型:gpt-4o-2024-08-06
  • SDK:openai==1.40.0
  • Python:3.11.6
  • 参数:temperature=0(抽取任务不需要创造性),top_p=1max_tokens=500
  • 测试集:100份真实采购合同,人工标注了标准答案
  • 评测脚本:逐字段精确匹配,6个字段全对才算「完全正确」

这里强调一下temperature=0。我试过0.2,同一份合同跑三次,金额字段居然出现了两种结果,抽取任务必须锁死随机性。

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

我把实验分成6版,每版只改一个变量,方便归因:

版本 核心策略 关键改动
V1 直接提问 无结构,自然语言问
V2 角色设定 加"你是合同分析专家"
V3 字段说明 每个字段给定义
V4 JSON输出约束 强制JSON格式
V5 Few-shot 加2个标注样例
V6 Few-shot + Schema + 边界规则 完整结构化

四、核心实现(含代码)

先看V1到V4的通用调用框架:

import json
from openai import OpenAI

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

def extract_contract(text: str, prompt_template: str) -> dict:
    resp = client.chat.completions.create(
        model="gpt-4o-2024-08-06",
        temperature=0,
        top_p=1,
        max_tokens=500,
        messages=[
            {"role": "system", "content": prompt_template},
            {"role": "user", "content": f"合同文本:\n{text}"}
        ]
    )
    content = resp.choices[0].message.content
    return {
        "raw": content,
        "prompt_tokens": resp.usage.prompt_tokens,
        "completion_tokens": resp.usage.completion_tokens,
        "total_tokens": resp.usage.total_tokens
    }

V6的完整Prompt(这是最终版本,重点看):

V6_PROMPT = """你是合同信息抽取引擎。从合同文本中抽取指定字段,严格按JSON Schema输出。

## 字段定义
- party_a: 甲方全称,以营业执照名称为准,不含"甲方:"前缀
- party_b: 乙方全称,同上
- amount: 合同总金额,数字类型,单位元。含税与不含税并存时,取含税总价
- sign_date: 签订日期,格式YYYY-MM-DD。无明确签订日期时取合同生效日期
- payment_method: 付款方式,枚举值["一次性付款","分期付款","账期付款","其他"]
- penalty_rate: 违约金比例,数字类型,如"万分之五"输出0.0005,无则输出null

## 输出Schema
{
  "party_a": string,
  "party_b": string,
  "amount": number,
  "sign_date": string,
  "payment_method": string,
  "penalty_rate": number | null
}

## 边界规则
1. 金额若为中文大写,转换为阿拉伯数字
2. 日期若为"2024年3月5日",输出"2024-03-05"
3. 找不到的字段输出null,禁止编造
4. 只输出JSON,不要任何解释文字

## 示例
输入:甲方:杭州XX科技有限公司。乙方:上海YY贸易有限公司。合同总价(含税)人民币壹拾贰万元整。签订于2024年3月5日。款项一次性支付。违约金按未付款项万分之五计算。
输出:{"party_a":"杭州XX科技有限公司","party_b":"上海YY贸易有限公司","amount":120000,"sign_date":"2024-03-05","payment_method":"一次性付款","penalty_rate":0.0005}
"""

五、踩坑与优化

坑1:JSON输出不稳定。 V4即使说了输出JSON,GPT-4o偶尔还是会加"好的,以下是抽取结果:"。解决办法是用OpenAI的response_format={"type": "json_object"},这个参数2024-08-06版本已稳定支持,加上后100%纯JSON。

坑2:Few-shot样例反噬。 V5我放了2个样例,结果模型开始"抄"样例里的字段名。比如样例里甲方叫"杭州XX科技",遇到类似结构的合同它直接输出样例的公司名。后来我把样例里的公司名换成明显不相干的"ABC公司",问题消失。

坑3:枚举值不收敛。 付款方式一开始自由输出,出现了"一次付清""一次性支付""全额付款"等8种表达。改成枚举后,模型必须在4个值里选,下游解析直接省事。

坑4:Token没降反升。 V5、V6的Prompt本身变长了(约280 token),但因为Schema约束让模型输出更短(从平均180 token降到60 token),总Token反而下降。这是很多人忽略的点:Prompt变长不一定总成本变高,输出端的节省往往更可观。

六、效果数据

100份合同测试集,实测结果:

版本 完全正确率 平均Prompt Tokens 平均Completion Tokens 平均总Tokens
V1 72% 420 780 1200
V2 74% 445 770 1215
V3 81% 620 640 1260
V4 85% 640 210 850
V5 91% 890 90 980
V6 96% 320 60 380

几个关键观察:
- V3加字段定义,准确率+7%,但Token涨了,因为Prompt变长且输出还是自由文本
- V4加JSON约束是转折点,输出Token直接砍到1/3
- V6最终版:Prompt 320 + Completion 60 = 380 token,比V1的1200降了68%
- 成本:按每天5000份算,V1每天15美元,V6每天4.75美元,一个月省下约300美元

准确率96%里,剩下4%的错误集中在「违约金比例」字段,主要是一些合同表述过于绕(比如"按日万分之三累计"),这个后续打算用规则兜底。

七、总结

这轮实验下来,我的几个核心结论:

  1. 结构化输出是性价比最高的一步。加JSON Schema,输出Token能砍60%以上,准确率还能涨。
  2. Few-shot要慎用样例内容。样例的字段值要"无害化",否则模型会抄。
  3. 枚举和边界规则比堆字数有用。与其写500字描述,不如给4条明确的边界规则。
  4. 看总Token,不要只看Prompt长度。约束输出往往比压缩Prompt更省钱。

Prompt Engineering不是玄学,把它当成一个可以量化、可以A/B测试的工程问题,数据会告诉你答案。下一步我准备把这套方法迁移到发票抽取上,到时候再写一篇。有踩过类似坑的朋友欢迎评论区交流。


代码已脱敏,实际API Key请从环境变量读取。测试集和评测脚本因涉及客户数据不便开源,但Prompt模板可直接复用。