一、问题背景

上个月接手一个法务侧的需求:从采购合同里抽取 8 个关键字段——甲方名称、乙方名称、合同金额、签订日期、生效日期、到期日期、付款方式、违约金比例。合同是 PDF 转出来的纯文本,长度在 3000~15000 字符之间,格式极不统一,有的用「甲 方:」,有的用「甲方(全称):」,还有的干脆写在表格里被转成了一堆空格。

一开始想的是上正则+规则,写了两天发现维护成本爆炸,光日期格式就有十几种。于是转向 LLM 抽取。但直接调 API 的效果并不理想——模型经常自由发挥,把「付款方式」写成一段解释而不是原文值,JSON 也经常缺字段。

所以这篇文章的核心不是「怎么调 API」,而是:针对结构化抽取这个具体任务,Prompt 到底怎么设计,不同设计的成本和收益差多少。

二、环境与版本

  • Python 3.11.7
  • openai 1.40.2
  • anthropic 0.34.2
  • 模型:gpt-4o-2024-08-06(主)、claude-3-5-sonnet-20240620(对照)
  • 参数:temperature=0,top_p=1,max_tokens=1024,response_format={"type":"json_object"}(仅在支持的版本启用)
  • 测试集:50 份真实采购合同(脱敏),人工标注了 8 个字段作为 ground truth
  • 评测指标:字段级准确率(完全匹配才算对)、平均 Token 消耗(prompt+completion)、P95 延迟

三、方案设计:6 版 Prompt 的演进

我把 Prompt 分成 6 个版本,逐步加约束:

版本 核心改动
V1 直接提问,无格式约束
V2 加 JSON 输出格式说明
V3 加字段定义 + 缺失填 null
V4 加 1 个 Few-shot 示例
V5 加 3 个 Few-shot + 边界规则
V6 V5 + JSON Schema 强约束 + 字段顺序固定

四、核心实现

先封装一个统一的调用函数,方便切换 Prompt:

import json, time
from openai import OpenAI

client = OpenAI(api_key="sk-xxx")

def extract(text: str, prompt_template: str, model="gpt-4o-2024-08-06"):
    prompt = prompt_template.format(contract=text)
    t0 = time.time()
    resp = client.chat.completions.create(
        model=model,
        temperature=0,
        top_p=1,
        max_tokens=1024,
        response_format={"type": "json_object"},
        messages=[{"role": "user", "content": prompt}],
    )
    latency = time.time() - t0
    usage = resp.usage
    return {
        "raw": resp.choices[0].message.content,
        "prompt_tokens": usage.prompt_tokens,
        "completion_tokens": usage.completion_tokens,
        "latency": latency,
    }

V1 到 V3 的 Prompt 差异(简化展示):

V1 = "从下面合同里提取甲方、乙方、金额、签订日期、生效日期、到期日期、付款方式、违约金比例:\n{contract}"

V2 = """从下面合同里提取甲方、乙方、金额、签订日期、生效日期、到期日期、付款方式、违约金比例。
以 JSON 格式输出。
合同:
{contract}"""

V3 = """你是合同信息抽取助手。从合同文本中提取以下字段,严格按定义执行:
- party_a: 甲方全称,若无则 null
- party_b: 乙方全称,若无则 null
- amount: 合同金额,仅保留数字与币种,如 "128000 CNY"
- sign_date: 签订日期,格式 YYYY-MM-DD
- effective_date: 生效日期,格式 YYYY-MM-DD
- expire_date: 到期日期,格式 YYYY-MM-DD
- payment_method: 付款方式原文,不要改写
- penalty_rate: 违约金比例,如 "0.05%"

只输出 JSON,不要解释。
合同:
{contract}"""

V6 的关键改动是把 JSON Schema 塞进 Prompt 并用 response_format 强约束:

SCHEMA = {
  "type": "object",
  "properties": {
    "party_a": {"type": ["string", "null"]},
    "party_b": {"type": ["string", "null"]},
    "amount": {"type": ["string", "null"]},
    "sign_date": {"type": ["string", "null"]},
    "effective_date": {"type": ["string", "null"]},
    "expire_date": {"type": ["string", "null"]},
    "payment_method": {"type": ["string", "null"]},
    "penalty_rate": {"type": ["string", "null"]}
  },
  "required": ["party_a","party_b","amount","sign_date",
               "effective_date","expire_date","payment_method","penalty_rate"],
  "additionalProperties": False
}

V6 = f"""你是合同信息抽取引擎。严格按照下列 JSON Schema 输出,字段顺序不可变,
字段缺失一律填 null,禁止输出 Schema 之外的内容。

Schema:
{json.dumps(SCHEMA, ensure_ascii=False)}

规则:
1. 日期统一 YYYY-MM-DD,无法确定填 null
2. 金额保留原始币种,不要换算
3. payment_method 必须是合同原文片段,不得改写
4. 违约金优先取百分比,若无则取固定金额并保留原文

示例:
输入:...甲方:XX科技有限公司 乙方:YY贸易 合同金额 128,000 元 ...
输出:{{"party_a":"XX科技有限公司","party_b":"YY贸易","amount":"128000 CNY",...}}

合同:
{{contract}}"""

五、踩坑与优化

坑 1:response_format=json_object 不是万能的。 它只保证输出是合法 JSON,不保证字段齐全。V2 阶段有 12% 的样本直接少了 penalty_rate 字段,程序解析时 KeyError。必须配合 required 声明。

坑 2:Few-shot 示例会“污染”抽取。 V4 只给了一个示例,模型在遇到格式差异大的合同时会往示例的句式上靠,比如把「付款方式」硬套成示例里的「分期付款」。V5 加到 3 个示例、覆盖不同排版后,这个问题基本消失。

坑 3:字段顺序影响 completion token。 把 payment_method 这种长文本字段放最后,可以让模型先输出短字段,实测 completion token 平均减少约 9%。

坑 4:Claude 3.5 Sonnet 在长合同上更稳但更贵。 同一批 50 份合同,Claude 准确率 95%,但平均 Token 消耗 1120(比 GPT-4o V6 高 46%),P95 延迟 4.2s vs 2.8s。最后选了 GPT-4o。

六、效果数据

50 份合同、8 字段 = 400 个字段的评测结果:

版本 字段准确率 平均 Prompt Token 平均 Completion Token 平均总 Token P95 延迟
V1 61.3% 1620 210 1830 3.1s
V2 68.5% 1650 180 1830 3.0s
V3 79.8% 1780 165 1945 3.2s
V4 86.0% 2050 170 2220 3.6s
V5 91.5% 2280 175 2455 3.9s
V6 94.0% 610 158 768 2.8s

注意 V6 的 Prompt Token 反而大幅下降——因为把冗长的自然语言规则换成了紧凑的 JSON Schema,同时 Few-shot 从 3 个精简到 1 个。准确率还涨了 2.5 个百分点。约束越结构化,Token 越省,效果越好。

按 GPT-4o 输入 $2.5/1M、输出 $10/1M 计算,单份合同成本从 V1 的 $0.0062 降到 V6 的 $0.0031,降幅 50%。如果按 10 万份/年算,一年省下约 310 美元——不算多,但延迟从 3.1s 降到 2.8s,对用户体验是实打实的。

七、总结

这次实验最大的收获是:Prompt Engineering 不是「把话说得客气点」,而是把任务约束显式化、结构化。 具体到抽取任务:

  1. 字段定义要写清楚,缺失语义(null)要显式声明,别指望模型猜。
  2. Few-shot 要覆盖边界情况,数量不是越多越好,3 个以内、覆盖不同排版即可。
  3. JSON Schema 约束比自然语言描述省 Token 且更准,能用就用。
  4. 字段顺序、temperature=0、max_tokens 这些参数对稳定性影响比想象中大。

下一步打算把 V6 的 Schema 抽成配置化,让法务同事自己加字段,不用改代码。如果你们也在做类似的抽取任务,欢迎评论区交流。