一、问题背景:为什么我要死磕这个Prompt

上个月接了个内部需求:把采购系统里历史留存的PDF合同,批量结构化入库。字段不多,就四个——甲方名称、乙方名称、合同金额、签署日期。听起来像是个「调个API就完事」的活,但我第一次跑完500份合同,人工抽检100份,字段级准确率只有62%。最离谱的是金额,模型会把「人民币壹拾贰万元整」原样返回,或者把税率当成合同金额。

团队一开始想上微调,我算了下:标注1000条数据大概要3人天,训练+验证又是2天,而且业务字段还会变。于是我决定先把Prompt Engineering这条路走到黑——毕竟改Prompt是分钟级迭代,改模型是周级迭代。

这篇文章就是我这两周7组Prompt的完整实验记录,包括每组的token消耗、准确率、翻车点。所有数据都是真实跑出来的,代码可以直接复制去跑。

二、环境与版本

先说清楚实验环境,不然数据没意义:

  • 模型:gpt-4o-mini,快照版本 gpt-4o-mini-2024-07-18
  • SDK:openai==1.40.3
  • Python:3.11.9
  • 参数:temperature=0top_p=1max_tokens=512response_format 按实验组切换
  • 测试集:从500份真实采购合同中随机抽100份,人工标注4个字段作为Ground Truth
  • 评测脚本:字段级完全匹配(金额统一转成「元」为单位的浮点数,日期统一成 YYYY-MM-DD
  • 成本计算:按官方价 $0.15/1M input tokens$0.60/1M output tokens(2024年7月价格)

合同文本平均长度约 1100 tokens,最长的有一份 3400 tokens(附了一堆附件清单)。

三、方案设计:7组Prompt的递进思路

我把7组Prompt按「信息量」和「约束强度」两个维度递进设计:

组号 策略 核心变化
P1 Zero-shot 直问 只给任务描述
P2 角色设定 加「你是资深法务」
P3 输出格式约束 要求返回JSON
P4 JSON Schema + response_format 用结构化输出强约束
P5 CoT 思维链 要求先分析再输出
P6 Few-shot(2例) 加2个正例
P7 组合拳:角色+Schema+边界规则+Few-shot 全量约束

我的假设是:P4的Schema约束能解决格式问题,P6的Few-shot能解决语义歧义(比如「甲方」在不同合同里叫「采购方」「买方」),P7应该是天花板。

四、核心实现:代码与Prompt

先上调用封装,所有实验组共用:

import json
import time
from openai import OpenAI

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

SYSTEM_ROLE = "你是一名有10年经验的合同法务,擅长从合同文本中精确抽取结构化信息。"

def call_llm(prompt: str, use_json_mode: bool = False) -> dict:
    start = time.time()
    kwargs = {
        "model": "gpt-4o-mini-2024-07-18",
        "messages": [
            {"role": "system", "content": SYSTEM_ROLE},
            {"role": "user", "content": prompt},
        ],
        "temperature": 0,
        "top_p": 1,
        "max_tokens": 512,
    }
    if use_json_mode:
        kwargs["response_format"] = {"type": "json_object"}

    resp = client.chat.completions.create(**kwargs)
    latency = time.time() - start
    content = resp.choices[0].message.content
    usage = resp.usage
    return {
        "content": content,
        "prompt_tokens": usage.prompt_tokens,
        "completion_tokens": usage.completion_tokens,
        "latency": latency,
    }

P1 到 P3 的 Prompt 长这样(示意):

P1 = f"从下面的合同里抽出甲方、乙方、合同金额、签署日期:\n{contract_text}"

P2 = f"你是资深法务,请从下面的合同里抽出甲方、乙方、合同金额、签署日期:\n{contract_text}"

P3 = f"""你是资深法务,从下面的合同里抽出甲方、乙方、合同金额、签署日期,
以JSON格式返回,key为party_a、party_b、amount、sign_date:
{contract_text}"""

P7 是重点,贴完整版:

P7 = f"""你是资深法务,请从合同文本中抽取以下4个字段,并以JSON返回。

【字段定义】
- party_a: 甲方(又称采购方、买方、委托方)的完整工商注册名称,不含「有限公司」以外的后缀修饰
- party_b: 乙方(又称供应方、卖方、受托方)的完整工商注册名称
- amount: 合同总金额,统一转换为「元」为单位的数字(如「壹拾贰万元整」→120000.0),不含税率、不含分项小计
- sign_date: 合同签署日期,格式 YYYY-MM-DD,若正文有多个日期,取「签署页」或「落款」处的日期

【边界规则】
1. 若某字段在文本中不存在,值填 null,不要编造
2. 金额若含「含税」「不含税」描述,取含税总额
3. 日期若只有「2024年3月」,补全为 2024-03-01
4. 名称中的括号统一用中文全角

【示例1】
输入:...甲方:杭州某某科技有限公司;乙方:上海某某供应链有限公司;合同总价人民币贰拾万元整(含税);签署日期:2024年5月10日
输出:{{"party_a":"杭州某某科技有限公司","party_b":"上海某某供应链有限公司","amount":200000.0,"sign_date":"2024-05-10"}}

【示例2】
输入:...采购方:北京某某信息技术有限公司;供应方:深圳某某电子有限公司;本合同金额为人民币伍拾万元(不含税),另计13%增值税;2024年6月1日签订
输出:{{"party_a":"北京某某信息技术有限公司","party_b":"深圳某某电子有限公司","amount":565000.0,"sign_date":"2024-06-01"}}

【待抽取合同】
{contract_text}

只返回JSON,不要任何解释。"""

注意示例2里我特意放了一个「含税/不含税」的坑,因为真实合同里这个坑踩得最多。P7 调用时同时开 response_format={"type": "json_object"}

五、踩坑与优化

坑1:JSON模式并不能保证字段齐全。 P4 开了 json_object 后,格式是稳了,但模型遇到没有签署日期的合同,会直接不返回 sign_date 字段,而不是返回 null。评测脚本里我一开始用 data["sign_date"] 直接 KeyError,后来改成 .get(),同时也把这个现象单独统计为「字段缺失」。

坑2:CoT 反而拉低了准确率。 P5 让模型先输出分析过程再给JSON,结果 completion_tokens 从平均 68 涨到 210,延迟从 1.2s 涨到 2.8s,准确率只涨了3个点。原因是模型在「分析」阶段会自我怀疑,把已经抽对的甲方名称又改掉。CoT 在抽取任务上性价比很低,这是我没预料到的。

坑3:Few-shot 的示例顺序敏感。 我一开始把「含税」的例子放第一个,结果模型对「不含税」的合同也倾向返回含税总额。把两个例子调换顺序后,准确率波动了4个点。结论:Few-shot 示例要覆盖边界情况,而且边界情况建议放在最后(离待抽取文本最近,注意力更强)。

坑4:超长合同被截断。 有一份3400 tokens 的合同,加上 P7 的 Prompt 后总 input 超过 4096,被 max_tokens 之外的限制截了尾部,签署页正好在尾部。解决办法是在代码层做分块:先定位「签署」「落款」关键段,再拼接头部+签署段送模型。

六、效果数据

100份合同,每组跑一次,结果如下(金额单位:美元):

组号 字段准确率 平均 prompt_tokens 平均 completion_tokens 平均延迟 单次成本
P1 62% 1120 52 1.1s $0.000199
P2 64% 1145 55 1.2s $0.000205
P3 71% 1180 71 1.3s $0.000220
P4 78% 1195 64 1.2s $0.000218
P5 81% 1200 210 2.8s $0.000306
P6 89% 1580 68 1.5s $0.000278
P7 94% 1620 66 1.6s $0.000277

几个值得说的点:

  1. P7 比 P1 准确率提升 32 个百分点,成本只涨了 39%(从0.000199到0.000277)。考虑到人工复核成本,这个投入产出比非常划算。
  2. P5 是唯一的负优化,成本最高、延迟最长,准确率还不如 P6。CoT 不是万金油。
  3. P4 到 P6 的跃升最大(+11个点),说明 Few-shot 在语义歧义消解上的作用远大于格式约束。
  4. P7 相对 P6 只涨5个点,边际收益开始递减。如果要继续优化,得从数据分块和字段定义粒度入手,而不是继续堆Prompt。

剩下6%的错误集中在两类:一是合同里有多个金额(总价+分项+违约金),模型偶尔取错;二是手写扫描件OCR后「甲方」写成「甲万」,模型跟着抄错。这两类问题已经是Prompt的边界了,需要上游OCR或后处理规则兜底。

七、总结

这轮实验给我最大的三个认知:

第一,Prompt Engineering 的收益不是线性的,而是「约束强度」的函数。角色设定、格式要求这些软约束,收益大概在5-10个点;Schema、Few-shot、边界规则这些硬约束,收益能到20个点以上。

第二,CoT 在抽取类任务上是负资产。它适合推理类任务(数学、逻辑),不适合信息抽取。别盲目抄别人的Prompt模板。

第三,Prompt 优化的终点是「字段定义工程」。P7 里我最花时间的不是写Prompt,而是把「amount」这个字段的边界(含税、不含税、分项、违约金)一条条列清楚。当你能把字段定义写到没有歧义时,Prompt 自然就收敛了。

最后给个建议:如果你的任务字段超过8个,或者字段间有复杂依赖,别硬刚Prompt,该上微调就上微调。Prompt Engineering 是性价比最高的第一跳,但不是终点。

完整评测脚本和100份测试集我脱敏后放在内网Git了,需要的同学可以找我要仓库地址。