一、问题背景:为什么我不再相信“一句话Prompt”
上个月接了个活儿:从采购合同里抽取8个关键字段——合同编号、甲方、乙方、签订日期、合同金额、税率、付款方式、违约金比例。客户给的样本是120份PDF转出来的纯文本,长度在800到3500字之间。
我第一版Prompt就一句话:“从下面的合同文本中提取合同编号、甲方、乙方……以JSON输出。”跑完一看,字段级准确率71.3%,主要错在三个地方:
- 金额和税率混在一起,模型把“含税总价113万(税率13%)”里的113万填进了税率字段;
- 日期格式五花八门,有“2024年3月5日”,有“2024.03.05”,还有“二〇二四年三月五日”;
- 甲方乙方搞反,因为合同里经常写“甲方(采购方):XX公司,乙方(供货方):YY公司”,模型偶尔按出现顺序填。
于是我决定系统性地做一轮Prompt对比实验,而不是凭感觉改。下面把整个过程完整写出来。
二、环境与版本
- 模型:
gpt-4o-mini,快照gpt-4o-mini-2024-07-18 - SDK:
openai==1.40.2,Python 3.11.9 - 参数:
temperature=0,top_p=1,max_tokens=1024,response_format按版本决定是否启用 - 数据集:120份采购合同文本,人工标注了8个字段作为golden set
- 评测脚本:字段级精确匹配,日期归一化后比较,金额统一转成“元”再比
- 硬件:跟模型无关,但本地跑评测的机器是 M2 MacBook Air 16G
先放评测代码的骨架,后面每版Prompt都复用这套:
import json, re
from openai import OpenAI
client = OpenAI(api_key="sk-xxx")
FIELDS = ["contract_no", "party_a", "party_b", "sign_date",
"amount", "tax_rate", "payment_method", "penalty_rate"]
def normalize(field, value):
if value is None:
return None
v = str(value).strip()
if field in ("amount",):
nums = re.findall(r"[\d,]+\.?\d*", v.replace(",", ""))
return float(nums[0]) if nums else None
if field == "tax_rate":
nums = re.findall(r"\d+\.?\d*", v)
return float(nums[0]) / 100 if nums and float(nums[0]) > 1 else (float(nums[0]) if nums else None)
if field == "sign_date":
m = re.search(r"(\d{4})\D+(\d{1,2})\D+(\d{1,2})", v)
return f"{m.group(1)}-{int(m.group(2)):02d}-{int(m.group(3)):02d}" if m else v
return re.sub(r"\s+", "", v)
def evaluate(prompt_builder, use_json_mode=False):
correct = total = 0
token_in = token_out = 0
for text, gold in load_dataset(): # load_dataset 返回 (文本, 标注dict)
prompt = prompt_builder(text)
kwargs = dict(model="gpt-4o-mini-2024-07-18", temperature=0,
max_tokens=1024, messages=[{"role": "user", "content": prompt}])
if use_json_mode:
kwargs["response_format"] = {"type": "json_object"}
resp = client.chat.completions.create(**kwargs)
token_in += resp.usage.prompt_tokens
token_out += resp.usage.completion_tokens
try:
pred = json.loads(resp.choices[0].message.content)
except json.JSONDecodeError:
pred = {}
for f in FIELDS:
total += 1
if normalize(f, pred.get(f)) == normalize(f, gold.get(f)):
correct += 1
return {
"acc": round(correct / total, 4),
"avg_in": round(token_in / 120, 1),
"avg_out": round(token_out / 120, 1),
}
三、方案设计:6版Prompt的演进路线
我按“控制变量”的思路,一版只改一个东西,方便定位收益来自哪里:
| 版本 | 核心变化 | 是否JSON模式 |
|---|---|---|
| V1 | 直接罗列字段,无格式约束 | 否 |
| V2 | 加角色设定 + 输出JSON | 否 |
| V3 | 加字段定义与归一化规则 | 否 |
| V4 | 加2个少样本示例 | 是 |
| V5 | 加JSON Schema描述 + 强制字段 | 是 |
| V6 | 加“先抽取再自检”两步推理 | 是 |
四、核心实现:关键几版Prompt原文
V1到V3比较直白,我只贴V3的字段规则部分,重点看V4和V6。
V3 的规则片段:
字段定义:
- amount:合同含税总金额,单位统一为元,只输出数字,不要带“元”“万”。
若写“113万元”,输出 1130000。
- tax_rate:税率,输出小数,如 13% 输出 0.13。
- sign_date:签订日期,统一为 YYYY-MM-DD。中文数字需转换。
- party_a / party_b:按合同中的“甲方”“乙方”标签取值,不要按出现顺序。
V6 的自检两步Prompt(完整可运行):
V6_TEMPLATE = """你是合同信息抽取引擎。请从合同文本中抽取8个字段。
字段规则:
- contract_no: 合同编号,保留原始格式
- party_a: “甲方”对应的公司全称
- party_b: “乙方”对应的公司全称
- sign_date: 签订日期,YYYY-MM-DD,中文数字要转换
- amount: 含税总金额,单位元,纯数字,“113万元”->1130000
- tax_rate: 税率小数,13%->0.13
- payment_method: 付款方式原文摘要,不超过20字
- penalty_rate: 违约金比例,小数,如“每日万分之五”->0.0005
示例:
输入:甲方:杭州甲公司 乙方:上海乙公司 签订于二〇二四年三月五日,
合同金额壹佰壹拾叁万元整,税率13%,违约金按日万分之五。
输出:{"contract_no": null, "party_a": "杭州甲公司", "party_b": "上海乙公司",
"sign_date": "2024-03-05", "amount": 1130000, "tax_rate": 0.13,
"payment_method": null, "penalty_rate": 0.0005}
现在请分两步:
第一步,逐字段在原文中定位证据,用一句话说明依据。
第二步,基于证据输出最终JSON。
合同文本:
{text}
请严格按以下格式输出,不要多余文字:
【证据】
...
【结果】
{{...}}
"""
def build_v6(text):
return V6_TEMPLATE.format(text=text)
V6跑的时候需要把【结果】后面的JSON抠出来,我在评测里加了个后处理:
def parse_v6(content):
if "【结果】" in content:
content = content.split("【结果】")[-1]
content = content.strip().strip("`").replace("json\n", "")
return json.loads(content)
五、踩坑与优化
坑1:JSON模式不是万能的。 V4开了response_format={"type":"json_object"}后,模型偶尔输出{"contract_no": "未知"}而不是null,导致归一化时对不上。后来在Prompt里明确写“缺失字段输出null,不要写‘未知’‘无’”,准确率立刻回了2个点。
坑2:少样本示例选得不好会带偏。 我一开始放的示例里金额是“壹佰壹拾叁万元”,结果模型对阿拉伯数字的合同反而开始瞎猜。后来示例改成一份中文数字、一份阿拉伯数字,覆盖两种写法。
坑3:V6的Token涨得最猛。 自检步骤让输出Token从平均86涨到412,因为模型要把证据也写出来。但对penalty_rate这种容易错的字段,V6比V5准了11个点。这笔账划不划算,取决于你的字段值不值钱。
坑4:temperature=0并不保证完全确定。 同一份文本跑两次,V4有3份合同结果不一致。所以生产环境我加了重试+投票:跑3次取多数,成本乘以3,但准确率又提了1.8个点。
六、效果数据
120份合同,8字段,共960个字段级判断:
| 版本 | 准确率 | 平均输入Token | 平均输出Token | 单份成本(美元) |
|---|---|---|---|---|
| V1 | 71.3% | 312 | 74 | 0.00009 |
| V2 | 76.8% | 358 | 81 | 0.00010 |
| V3 | 83.5% | 512 | 88 | 0.00013 |
| V4 | 88.2% | 741 | 86 | 0.00016 |
| V5 | 90.6% | 803 | 91 | 0.00017 |
| V6 | 94.1% | 1287 | 412 | 0.00044 |
按 gpt-4o-mini 输入$0.15/1M、输出$0.60/1M 计算,V6单份成本是V1的4.9倍,但错误字段从275个降到57个。如果一个人工复核一个字段要30秒,V6每1000份合同能省大约60小时的复核时间——这个账很明显。
分字段看,V6提升最大的是penalty_rate(从68%到93%)和tax_rate(从79%到97%),都是“需要单位换算”的字段。而contract_no这种直接抄的字段,V1就有96%,加Prompt收益很小。
七、总结
这轮实验给我的结论很直接:
- Prompt的收益不是线性的。 从V1到V3,加规则就能拿到12个点;从V5到V6,多花50%Token只换3.5个点。要先看你的瓶颈在“模型不理解任务”还是“模型不够细心”。
- JSON模式 + 字段规则 + 少样本 是性价比最高的三件套,V4已经能到88%,大多数内部工具够用了。
- 自检两步推理适合高价值、低并发的场景。如果你一天要跑几十万份,V6的成本会肉疼,建议只对V5抽出来“低置信字段”二次调用。
- 别忘了
temperature=0也会抖,生产环境留好重试和校验。
代码和120份评测数据我整理成了一个repo,需要的可以评论区留言。如果你也在做信息抽取,欢迎交流你的Prompt版本和踩坑经历。