一、问题背景:为什么“随便写个Prompt”不够用

上个月接了个需求:从供应商采购合同里抽取 8 个关键字段——合同编号、甲方、乙方、签约日期、合同金额、税率、付款方式、违约条款摘要。合同是 PDF 转出来的纯文本,平均 3000~6000 字,中英文混排,金额有的写“人民币壹佰贰拾万元整”,有的写“¥1,200,000.00”。

一开始我觉得这活儿很简单,直接丢给 GPT-4o 一句话:

prompt = f"从下面的合同里提取合同编号、甲方、乙方、签约日期、合同金额、税率、付款方式、违约条款摘要,用JSON返回。\n\n{contract_text}"

结果打脸来得很快:

  • 输出经常不是合法 JSON,前后带一堆“好的,以下是提取结果:”
  • 金额字段有时候返回“壹佰贰拾万元整”,有时候返回“1200000”,格式不统一
  • 长合同(>5000字)时,违约条款摘要直接漏掉
  • 8 个字段平均只能对 6.2 个,准确率约 78%

于是我开始系统性地做 Prompt 迭代实验。这篇文章就是把整个过程摊开讲。

二、环境与版本

  • 模型:gpt-4o-2024-08-06(支持 Structured Outputs)
  • SDK:openai==1.40.0
  • Python:3.11.6
  • 测试集:50 份真实采购合同(脱敏),人工标注了 8 个字段的 ground truth
  • 评测脚本:字段级 exact match(金额做归一化后比较)
  • 关键参数:temperatureresponse_formatmax_tokens=1024

成本按 OpenAI 当时价格估算:输入 $2.5/1M tokens,输出 $10/1M tokens。

三、方案设计:7 版 Prompt 的迭代路线

我给自己定了个原则:每次只改一个变量,记录 Token 消耗和准确率。7 个版本大致分三个阶段:

阶段一:自然语言描述(V1-V2)
- V1:一句话指令 + 要求 JSON
- V2:加上字段定义和示例

阶段二:结构化模板(V3-V5)
- V3:System/User 角色分离,字段逐条列出
- V4:引入 few-shot 示例(2 个)
- V5:加输出格式约束和空值处理规则

阶段三:Structured Outputs + 精简(V6-V7)
- V6:改用 response_format={"type": "json_schema"} 强制 schema
- V7:精简 Prompt,删掉冗余描述,压缩 few-shot 到 1 个

核心实现代码(V7 最终版):

import json
from openai import OpenAI

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

SYSTEM_PROMPT = """你是合同信息抽取引擎。从用户提供的合同文本中抽取指定字段。
规则:
1. 金额统一转为数字(单位:元),如"壹佰贰拾万元整"→1200000
2. 税率转为小数,如"13%"→0.13
3. 日期统一为 YYYY-MM-DD
4. 字段无法确定时填 null,不要猜测
5. 违约条款摘要不超过80字"""

JSON_SCHEMA = {
    "name": "contract_fields",
    "strict": True,
    "schema": {
        "type": "object",
        "properties": {
            "contract_no": {"type": ["string", "null"]},
            "party_a": {"type": ["string", "null"]},
            "party_b": {"type": ["string", "null"]},
            "sign_date": {"type": ["string", "null"]},
            "amount": {"type": ["number", "null"]},
            "tax_rate": {"type": ["number", "null"]},
            "payment_method": {"type": ["string", "null"]},
            "breach_summary": {"type": ["string", "null"]},
        },
        "required": ["contract_no", "party_a", "party_b", "sign_date",
                     "amount", "tax_rate", "payment_method", "breach_summary"],
        "additionalProperties": False,
    },
}

def extract(contract_text: str) -> dict:
    resp = client.chat.completions.create(
        model="gpt-4o-2024-08-06",
        temperature=0,
        max_tokens=1024,
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": f"合同文本:\n{contract_text}"},
        ],
        response_format={"type": "json_schema", "json_schema": JSON_SCHEMA},
    )
    return json.loads(resp.choices[0].message.content)

评测脚本(简化版):

def evaluate(prompt_version, dataset):
    correct, total_tokens = 0, 0
    for item in dataset:
        result = extract(item["text"])  # 按版本切换prompt
        total_tokens += result["_usage"]["total_tokens"]
        for field, gold in item["labels"].items():
            if normalize(result.get(field)) == normalize(gold):
                correct += 1
    n = len(dataset) * 8
    return {"acc": correct / n, "avg_tokens": total_tokens / len(dataset)}

四、踩坑与优化:几个真实翻车现场

坑 1:JSON 输出不稳定。 V1-V4 用自然语言要求“返回 JSON”,即使加了“只返回 JSON,不要解释”,仍有约 12% 的样本带 markdown 代码块或前后缀。V6 换成 json_schema 后,合法率 100%,这是准确率跳升的最大原因。

坑 2:few-shot 不是越多越好。 V4 放了 2 个示例,准确率确实涨了 6 个点,但 Token 从 900 飙到 2137。V7 只保留 1 个最典型的示例(含大写金额转换),准确率没掉,Token 直接砍半。

坑 3:长合同截断。 超过 6000 字的合同,违约条款在末尾,模型容易“看不见”。我的处理是:先做一次关键词定位,把含“违约”“违约责任”的段落拼到文本开头,再送模型。这一步让 breach_summary 的准确率从 71% 提到 94%。

坑 4:temperature 不是越低越好。 我测了 0、0.2、0.5。temperature=0 时字段抽取最稳,但违约条款摘要过于机械、丢信息;0.2 时摘要质量更好,且字段准确率没降。最终选 0.2。

五、效果数据:7 版 Prompt 全对比

版本 Prompt 策略 平均输入Token 准确率 JSON合法率
V1 一句话指令 812 78.0% 88%
V2 加字段定义 1043 83.5% 90%
V3 角色分离 1180 85.2% 91%
V4 2-shot 2137 91.3% 93%
V5 格式约束+空值规则 1965 92.8% 95%
V6 json_schema 强制 1120 95.1% 100%
V7 精简+1-shot+段落重排 684 96.0% 100%

从 V1 到 V7:Token 下降 15.8%(相对峰值 V4 下降 68%),准确率提升 18 个百分点。按 50 份合同、每天 200 份的调用量估算,V7 相比 V4 每天省约 29 万输入 Token,成本下降约 60%。

六、总结

这轮迭代给我最大的三个结论:

  1. Structured Outputs 是刚需,不是锦上添花。 只要任务要结构化结果,json_schema 带来的稳定性远超任何自然语言约束。
  2. Prompt 要“减脂”。 很多人(包括我)第一反应是不断加描述、加示例,但 Token 成本和准确率不是线性关系,找到信息密度最高的那几句才是关键。
  3. 数据预处理比 Prompt 更值钱。 长文本重排这一步没动 Prompt,却把最难的字段准确率提了 23 个点。

如果你也在做信息抽取类任务,建议先跑通 json_schema,再逐步精简 Prompt,最后再考虑 few-shot。顺序反了,很容易在 Token 上花冤枉钱。