一、问题背景:为什么我不再相信“一句话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收益很小。

七、总结

这轮实验给我的结论很直接:

  1. Prompt的收益不是线性的。 从V1到V3,加规则就能拿到12个点;从V5到V6,多花50%Token只换3.5个点。要先看你的瓶颈在“模型不理解任务”还是“模型不够细心”。
  2. JSON模式 + 字段规则 + 少样本 是性价比最高的三件套,V4已经能到88%,大多数内部工具够用了。
  3. 自检两步推理适合高价值、低并发的场景。如果你一天要跑几十万份,V6的成本会肉疼,建议只对V5抽出来“低置信字段”二次调用。
  4. 别忘了temperature=0也会抖,生产环境留好重试和校验。

代码和120份评测数据我整理成了一个repo,需要的可以评论区留言。如果你也在做信息抽取,欢迎交流你的Prompt版本和踩坑经历。