一、问题背景
上个月接了个活儿:从采购合同PDF里抽结构化要素——合同编号、甲乙方名称、签订日期、总金额、付款方式、违约金比例,一共6个字段,下游要入库做对账。PDF先用PyMuPDF转文本,单份合同正文大概3000到8000字不等。
一开始我觉得这活儿简单,写个指令让模型输出JSON就完了。第一版Prompt上线,人工抽了120份做测试集,字段级F1只有0.71。主要错在三个地方:金额带千分位和"人民币"前缀时被截断、付款方式这种长文本字段模型爱自己总结、日期格式五花八门("二〇二四年三月"这种中文数字直接摆烂)。
于是老老实实做了一轮Prompt对照实验。这篇就是把过程和数据摊开写。
二、环境与版本
- 模型:
gpt-4o-mini,快照2024-07-18,temperature=0.1,top_p=1.0,max_tokens=1024 - SDK:
openai==1.40.2(Python) - 测试集:120份真实采购合同,人工标注6字段作为golden set
- 评测脚本:字段级精确匹配算P/R/F1,金额和日期做归一化后再比
- 硬件无所谓,纯API调用,主要成本是token和时间
token统计直接用 response.usage,别自己估,估出来跟账单差挺多。
三、方案设计
设计了5个版本,逐层加约束,变量控制住只改Prompt:
| 版本 | 策略 | 关键变化 |
|---|---|---|
| V1 | 零样本自然语言指令 | 只说"抽这6个字段,输出JSON" |
| V2 | 加字段定义和格式规范 | 每个字段给类型、格式、缺失时的默认值 |
| V3 | 结构化输出(response_format={"type":"json_schema"}) |
用JSON Schema硬约束 |
| V4 | V3 + 3个few-shot示例 | 示例覆盖中文数字日期、带前缀金额 |
| V5 | V4 + 两段式自校验 | 第一次抽取,第二次只做字段核对和修正 |
V5的"自校验"是让模型拿原文和第一次的抽取结果做比对,输出修正后的JSON。想法很美好,后面会说它怎么坑我的。
四、核心实现
先贴V3的核心代码,这是性价比最高的一版,也是后面V4/V5的底座:
import json
from openai import OpenAI
client = OpenAI(api_key="sk-xxx")
SCHEMA = {
"type": "object",
"properties": {
"contract_no": {"type": ["string", "null"]},
"party_a": {"type": ["string", "null"]},
"party_b": {"type": ["string", "null"]},
"sign_date": {"type": ["string", "null"],
"description": "ISO 8601, YYYY-MM-DD,无法确定填null"},
"total_amount": {"type": ["number", "null"],
"description": "单位元,去掉千分位和货币符号"},
"payment_terms": {"type": ["string", "null"],
"description": "原文摘录,不超过80字,不要改写"},
},
"required": ["contract_no", "party_a", "party_b",
"sign_date", "total_amount", "payment_terms"],
"additionalProperties": False,
}
SYSTEM_PROMPT = """你是合同信息抽取器。只做抽取,不做推理和总结。
规则:
1. 字段值必须能在原文中找到依据,找不到填 null,禁止编造。
2. 金额统一换算为元,去掉"人民币""¥"和千分位逗号。
3. 中文数字日期(如"二〇二四年三月五日")转为 YYYY-MM-DD。
4. payment_terms 必须是原文连续摘录,不要概括。"""
def extract_v3(text: str) -> dict:
resp = client.chat.completions.create(
model="gpt-4o-mini",
temperature=0.1,
max_tokens=1024,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"合同原文:\n{text}"},
],
response_format={
"type": "json_schema",
"json_schema": {"name": "contract", "strict": True, "schema": SCHEMA},
},
)
return json.loads(resp.choices[0].message.content), resp.usage
V4就是在 messages 里插3组 user/assistant 示例,注意示例要放在system之后、真实输入之前。V5是串两次调用:
def extract_v5(text: str) -> dict:
first, u1 = extract_v3(text)
check_prompt = f"""原文:
{text}
第一次抽取结果:
{json.dumps(first, ensure_ascii=False)}
逐字段核对:值是否能在原文找到依据?金额单位是否正确?日期是否为YYYY-MM-DD?
只输出修正后的JSON,字段和类型不变。"""
resp = client.chat.completions.create(
model="gpt-4o-mini",
temperature=0.0,
max_tokens=1024,
messages=[{"role": "user", "content": check_prompt}],
response_format={
"type": "json_schema",
"json_schema": {"name": "contract", "strict": True, "schema": SCHEMA},
},
)
return json.loads(resp.choices[0].message.content), (u1, resp.usage)
评测脚本按字段算精确匹配,金额容差0.01,日期严格字符串相等,长文本字段用去空格后完全匹配(不做语义相似度,因为下游要的是原文摘录)。
五、踩坑与优化
坑1:JSON模式偶发截断。 max_tokens=1024 时,V3有大概2%的请求返回的JSON不完整,解析直接抛异常。原因是有几份合同付款方式特别长,加上schema本身占token。把 max_tokens 提到1536并给 payment_terms 加80字上限描述后,截断率降到0。顺带说一句,strict: True 模式下schema本身也吃token,别把description写太长。
坑2:few-shot示例污染。 V4一开始示例里放了"甲方:XX科技有限公司",结果模型对真实合同里名字相似的公司开始瞎套,party_a字段F1反而比V3掉了0.02。后来把示例里的公司名、金额全换成明显虚构的占位符("甲方:示例甲公司"),并且示例只保留格式示范作用,才恢复正常。示例是双刃剑,别放真实业务数据。
坑3:V5自校验翻车。 自校验确实把金额字段F1从0.91拉到0.95,但延迟从平均1.8s涨到4.1s,token直接翻倍,而且出现了新的错误模式——模型在核对时"过度修正",把本来正确的日期改成null。最后只对 total_amount 和 sign_date 两个高危字段做定向校验,其他字段跳过,才把成本压回来。
坑4:温度设0.1不是0。 试过0,结果同一份合同重复跑两次结果一致率是100%,但个别边界case(金额写在表格里)会稳定错。0.1反而在多次采样里能救回来一部分。生产上我用了3次采样投票,成本可控。
六、效果数据
120份合同,单份平均输入约4200 token,结果如下(F1为6字段宏平均):
| 版本 | 字段F1 | 平均输入token | 平均输出token | 平均延迟 | JSON解析失败率 |
|---|---|---|---|---|---|
| V1 | 0.71 | 4180 | 210 | 1.6s | 6.7% |
| V2 | 0.82 | 4390 | 260 | 1.7s | 2.5% |
| V3 | 0.89 | 4520 | 240 | 1.8s | 0% |
| V4 | 0.91 | 5180 | 250 | 2.0s | 0% |
| V5 | 0.94 | 9860 | 520 | 4.1s | 0% |
| V5定向校验 | 0.93 | 6350 | 330 | 2.6s | 0% |
注意V1的token其实不低——它爱在JSON前后加解释性文字,输出反而更啰嗦。所以"Prompt写简单点省token"这个直觉是错的,约束写清楚反而省。最终上线用的是V5定向校验版:F1 0.93,单份合同token从V1的4390降到约1350(这里算的是去掉重复系统提示后的净增部分,实际账单口径按全量算),延迟2.6s,业务方接受。
七、总结
三句话:
- 结构化输出(JSON Schema)是投入产出比最高的一步,一行配置换来解析失败率归零,别省。
- few-shot解决的是格式和边界case,不是知识,示例里千万别放真实数据。
- 自校验不是免费的,全字段校验的ROI是负的,只对高危字段定向做。
Prompt Engineering这事儿,感觉像调参:每次只改一个变量,用固定测试集量出来,别凭感觉。我这5版加起来跑了大概1400次调用,账单不到3美元,比拍脑袋改一版上线再回滚便宜多了。