一、问题背景:一个“看起来很简单”的抽取任务

上个月接了个需求:从采购合同里抽取结构化字段,包括 party_aparty_bsign_dateamountpayment_termspenalty_rate 六个字段,然后写进内部 ERP。

一开始我觉得这不就是个信息抽取嘛,GPT-4o-mini 便宜得跟不要钱一样,直接上就行。结果第一版跑完,200 条测试集上字段级准确率只有 71.3%,主要错在:

  • amount 把“含税总价”和“不含税价”搞混;
  • sign_date 抽成了合同生效日而不是签署日;
  • payment_terms 输出一大段自然语言,没法直接入库;
  • 偶尔输出 markdown 代码块包裹的 JSON,解析直接炸。

于是我决定认真做一轮 Prompt Engineering,把每一版的差异量化出来,而不是凭感觉“调一调”。

二、环境与版本

  • Python 3.11.6
  • openai==1.51.0
  • tiktoken==0.7.0
  • 模型:gpt-4o-minitemperature=0top_p=1max_tokens=512
  • 测试集:200 份真实采购合同(已脱敏),人工标注 gold label
  • 评测脚本:字段级 exact match,金额做归一化(去千分位、统一“元”)
  • 成本口径:按 tiktoken 计算 input+output token,价格按 $0.15/1M input、$0.60/1M output 折算

三、方案设计:7 版 Prompt 的演进路线

我把实验分成 7 版,每版只改一个变量,方便归因:

版本 策略 关键改动
V1 Zero-shot 直问 “请抽取以下字段……”
V2 角色设定 加“你是资深法务”
V3 字段定义 + 枚举 明确每个字段含义
V4 Few-shot 3 例 加 3 个输入输出示例
V5 JSON Schema 约束 规定输出结构
V6 CoT 分步 先定位再抽取
V7 分步 + 自校验 抽取后自查一次

四、核心实现

先看最朴素的 V1,问题很典型:

import json
from openai import OpenAI
import tiktoken

client = OpenAI(api_key="sk-xxx")
enc = tiktoken.encoding_for_model("gpt-4o-mini")

def count_tokens(text: str) -> int:
    return len(enc.encode(text))

def extract_v1(contract: str) -> dict:
    prompt = f"""请从下面的合同中抽取 party_a, party_b, sign_date,
amount, payment_terms, penalty_rate 六个字段。

合同:
{contract}
"""
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
        max_tokens=512,
    )
    return resp.choices[0].message.content

V1 的问题:输出是自然语言,payment_terms 经常是一整段话,还得再写正则去抠,token 也浪费在解释上。

到 V5 我开始用 JSON Schema 强约束输出,这是准确率提升最明显的一版:

SCHEMA = {
    "type": "object",
    "properties": {
        "party_a": {"type": "string"},
        "party_b": {"type": "string"},
        "sign_date": {"type": "string", "description": "YYYY-MM-DD,签署日"},
        "amount": {"type": "number", "description": "含税总价,单位元"},
        "payment_terms": {
            "type": "array",
            "items": {"type": "string"},
            "description": "付款节点,如 '30%预付'"
        },
        "penalty_rate": {"type": "number", "description": "日违约金比例,如 0.0005"}
    },
    "required": ["party_a", "party_b", "sign_date", "amount"]
}

def extract_v5(contract: str) -> dict:
    system = (
        "你是合同信息抽取引擎。只输出 JSON,不要 markdown,不要解释。"
        f"必须符合以下 JSON Schema:\n{json.dumps(SCHEMA, ensure_ascii=False)}"
    )
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": system},
            {"role": "user", "content": contract},
        ],
        temperature=0,
        max_tokens=512,
        response_format={"type": "json_object"},
    )
    return json.loads(resp.choices[0].message.content)

V7 在 V5 基础上加了“自校验”步骤:先抽一遍,再让模型对照原文检查金额、日期是否一致,不一致就修正。代价是多一次调用,但把 amount 的错误率从 11% 压到了 3.5%。

五、踩坑与优化

坑 1:response_format 不是万能的。 早期我以为开了 json_object 就稳了,结果模型偶尔还是会返回 {"result": {...}} 这种包一层的情况。后来在 system 里明确写“顶层就是字段,不要嵌套”,才稳定。

坑 2:few-shot 不是越多越好。 我试过 8 个示例,token 从 620 涨到 1450,准确率只从 88.1% 提到 88.6%,边际收益极低。最后定在 3 个,且示例要覆盖“金额含税/不含税”“日期多格式”这两类难例。

坑 3:CoT 会放大 token。 V6 让模型先输出推理过程,准确率确实到了 91%,但平均 token 冲到 1320。后来改成“内部推理不输出”,只在需要时触发二次校验,才把成本压下来。

坑 4:temperature=0 也不完全确定。 同一批数据跑两次,仍有约 1.2% 的字段不一致。生产上我加了结果缓存 + 关键字段正则兜底。

六、效果数据

200 条测试集,字段级准确率与 token 对比:

版本 准确率 平均 input token 平均 output token 单条成本(USD)
V1 71.3% 812 368 0.000343
V2 73.5% 836 352 0.000337
V3 79.8% 921 301 0.000319
V4 88.1% 1104 286 0.000337
V5 90.6% 968 142 0.000230
V6 91.2% 1021 299 0.000333
V7 94.2% 962 168 0.000213

V7 相比 V1,准确率 +22.9 个百分点,单条成本从 $0.000343 降到 $0.000213,降幅约 38%。按日均 5000 条算,一个月省下约 19.5 美元——钱不多,但准确率带来的返工成本下降才是大头。

最后给一个我线上用的 V7 精简模板:

SYSTEM_V7 = """你是合同抽取引擎,只输出 JSON。
字段定义:
- party_a/party_b: 甲乙方全称
- sign_date: 签署日,YYYY-MM-DD
- amount: 含税总价,数字,单位元
- payment_terms: 字符串数组
- penalty_rate: 日违约金比例,小数
输出后自查:金额是否含税、日期是否为签署日。
若不一致,直接给修正后的最终结果,不要输出过程。"""

def extract_v7(contract: str, retry: int = 2) -> dict:
    for _ in range(retry):
        try:
            resp = client.chat.completions.create(
                model="gpt-4o-mini",
                messages=[
                    {"role": "system", "content": SYSTEM_V7},
                    {"role": "user", "content": contract},
                ],
                temperature=0,
                max_tokens=512,
                response_format={"type": "json_object"},
            )
            return json.loads(resp.choices[0].message.content)
        except json.JSONDecodeError:
            continue
    raise RuntimeError("抽取失败")

七、总结

这轮实验最大的收获不是那 22 个百分点,而是把 Prompt 当成可量化的工程对象:每版只改一个变量,记录准确率和 token,用数据决定留哪版。几个可以直接抄的结论:

  1. JSON Schema 约束是性价比最高的一步,V3→V5 用更少的 token 换来 10 个点准确率;
  2. few-shot 控制在 3 个以内,且必须覆盖难例,堆数量纯属浪费;
  3. CoT 要“内推不外显”,否则 token 涨得比准确率快;
  4. 永远加一层正则/枚举兜底,别把生产稳定性押在 temperature=0 上。

Prompt Engineering 不是什么玄学,它就是一次次受控实验。你把变量控住了,数据自然会告诉你答案。