一、问题背景

上个月接了个活儿:从采购合同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,业务方接受。

七、总结

三句话:

  1. 结构化输出(JSON Schema)是投入产出比最高的一步,一行配置换来解析失败率归零,别省。
  2. few-shot解决的是格式和边界case,不是知识,示例里千万别放真实数据。
  3. 自校验不是免费的,全字段校验的ROI是负的,只对高危字段定向做。

Prompt Engineering这事儿,感觉像调参:每次只改一个变量,用固定测试集量出来,别凭感觉。我这5版加起来跑了大概1400次调用,账单不到3美元,比拍脑袋改一版上线再回滚便宜多了。