一、问题背景:为什么我要死磕这个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=1,max_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%的错误集中在「违约金比例」字段,主要是一些合同表述过于绕(比如"按日万分之三累计"),这个后续打算用规则兜底。
七、总结
这轮实验下来,我的几个核心结论:
- 结构化输出是性价比最高的一步。加JSON Schema,输出Token能砍60%以上,准确率还能涨。
- Few-shot要慎用样例内容。样例的字段值要"无害化",否则模型会抄。
- 枚举和边界规则比堆字数有用。与其写500字描述,不如给4条明确的边界规则。
- 看总Token,不要只看Prompt长度。约束输出往往比压缩Prompt更省钱。
Prompt Engineering不是玄学,把它当成一个可以量化、可以A/B测试的工程问题,数据会告诉你答案。下一步我准备把这套方法迁移到发票抽取上,到时候再写一篇。有踩过类似坑的朋友欢迎评论区交流。
代码已脱敏,实际API Key请从环境变量读取。测试集和评测脚本因涉及客户数据不便开源,但Prompt模板可直接复用。