1. 问题背景:为什么我需要折腾Prompt

上周接了个外包项目,要把200份PDF格式的技术合同自动录入系统。合同里“验收标准”和“违约责任”这两块,客户要求必须结构化存储——也就是说,不能给我一段原文,得拆成{验收项, 标准值, 违约后果}这样的字段。

我第一反应是微调一个BERT做序列标注,但被标注成本劝退了——200份合同,每份平均3000字,人工标注至少5个工作日。正好手头有GPT-4o的API额度,决定试试Prompt Engineering能不能扛住这个活。

说实话,一开始我是很悲观的。LLM做抽取,最大的问题不是“读不懂”,而是“格式乱”——同一句话,今天输出JSON,明天给你加个“根据以上内容,我认为”的前缀。这篇文章就是记录我怎么用四个版本的Prompt,把字段级准确率从61%逼到93%的过程。

2. 环境与版本:模型、参数、成本基线

  • 模型gpt-4o-2024-11-20(1106-preview,1:1的正式版)
  • SDKopenai>=1.30.0,Python 3.10
  • 关键参数temperature=0.2(默认0.7,实测抽取任务必须压低),max_tokens=4000
  • 成本基线:200份合同,平均每份输入约2800 token,输出约850 token。四套模板全量跑完,总消耗约1.2M token,按官方价格(输入$2.5/1M,输出$10/1M),总花费$8.64。如果走人工标注,按小时工资算,这个数连零头都不够。

代码基座如下,四个版本的Prompt以常量方式传入,方便对比:

# prompt_engineering_test.py
import json
from openai import OpenAI

client = OpenAI(api_key="sk-xxxx")
MODEL = "gpt-4o-2024-11-20"

def extract_contract(contract_text: str, prompt_template: str) -> dict:
    response = client.chat.completions.create(
        model=MODEL,
        messages=[
            {"role": "system", "content": "你是严谨的合同审计员。"},  # 仅v2/v3/v4生效
            {"role": "user", "content": prompt_template.format(text=contract_text)}
        ],
        temperature=0.2,
        max_tokens=4000
    )
    return response.choices[0].message.content

3. 方案设计:四套Prompt的递进逻辑

我的目标字段有5个:验收项(如“系统响应时间”)、验收标准值(如“≤3秒”)、违约情形(如“逾期交付”)、违约金计算方式(如“每逾期1日按合同总额0.1%”)、免责条款(布尔值)。每份合同抽取后,由两个标注员人工核对,争议部分仲裁。

v1:零样本直抽

从以下合同中抽取所有验收标准与违约责任信息:
{text}

v2:角色+边界约束

你是合同解析专家。只输出JSON,不要解释。
抽取字段:验收项、验收标准值、违约情形、违约金计算方式、免责条款。
若某字段不存在,置为null。
合同原文:{text}

v3:思维链(CoT)+ 示例

请按以下步骤处理:
1. 定位“验收”相关段落,列出验收项与标准值。
2. 定位“违约”相关段落,区分违约情形与违约金计算。
3. 判断是否存在免责条款。
输出JSON格式:{"验收项":[...], "验收标准值":[...], ...}

参考示例(节选):
输入:'系统需在1秒内响应,否则每延迟1分钟扣罚200元。'
输出:{"验收项":["系统响应"], "验收标准值":["≤1秒"], "违约情形":["响应延迟"], "违约金计算方式":["每延迟1分钟扣200元"]}

合同原文:{text}

v4:结构化输出强制约束

你是合同抽取引擎。请严格按以下JSON Schema输出:
{
  "验收项": ["string"],
  "验收标准值": ["string"],
  "违约情形": ["string"],
  "违约金计算方式": ["string"],
  "免责条款": "boolean"
}
合同原文:{text}

同时,v4在API调用时加了response_format={"type": "json_object"},强制模型输出合法JSON——这是v3和v4最大的区别:v3靠“说”约束,v4靠“机制”约束

4. 核心实现:完整评测代码与输出对比

评测脚本对每份合同跑四个版本,计算字段级准确率(预测值与该字段黄金标注完全一致的比例)和JSON解析成功率json.loads不抛异常的比例)。

# eval_prompts.py
import json, time
from collections import defaultdict

FIELD_NAMES = ["验收项", "验收标准值", "违约情形", "违约金计算方式", "免责条款"]

def evaluate(prompt_version: str, contracts: list, golden: list):
    stats = defaultdict(lambda: {"correct": 0, "total": 0, "parse_fail": 0})
    total_tokens = 0
    for idx, (ct, gt) in enumerate(zip(contracts, golden)):
        raw = extract_contract(ct, PROMPTS[prompt_version])
        total_tokens += len(raw.split())  # token近似估算
        try:
            pred = json.loads(raw)
        except json.JSONDecodeError:
            for f in FIELD_NAMES:
                stats[f]["parse_fail"] += 1
            continue
        for f in FIELD_NAMES:
            stats[f]["total"] += 1
            if pred.get(f) == gt.get(f):
                stats[f]["correct"] += 1
        if idx % 20 == 0:
            print(f"[{prompt_version}] progress: {idx}/{len(contracts)}")
    return stats, total_tokens

跑完后,v1的结果惨不忍睹。下面是一份真实合同的v1输出(节选):

{
  "验收标准": "系统响应时间不超过3秒",
  "违约金": "每逾期一日,按合同总金额的0.05%支付违约金",
  "免责": "因不可抗力导致的延迟不承担责任"
}

看着挺对,但字段名和我的Schema对不上——我要的是验收标准值(要拆出“3秒”这个数值),它直接给了我整个句子。v1的准确率只有61%,主要死在字段拆分上,而不是抽取能力。v2稍微好点,字段名对了,但违约金计算方式经常多输出无关信息,准确率71%。v3靠示例纠正了格式,准确率跳到84%,但JSON解析失败率还有8%——模型偶尔会在JSON前后加“分析:”之类的废话。

5. 踩坑与优化:三个让我血压升高的细节

坑1:temperature=0.7会让抽取结果随机漂移。 第一次跑v1用的默认temperature,同一份合同跑三次,抽出来的验收标准值一次是“3秒”,一次是“不超过3秒”,一次是“3s”。把temperature降到0.2后,字段值基本稳定了,准确率直接+3%。

坑2:response_format必须在messages里同时声明。 我以为加了参数就行,结果第一次跑v4报错:Invalid parameter: response_format is only supported with a system message present。后来在system里随便塞了一句“你是JSON生成器”,问题解决。这是OpenAI的API设计坑,文档里写得很隐晦,不踩一次真记不住。

坑3:max_tokens=2000根本不够用。 v3的CoT模板会诱导模型输出很长的中间推理,2000的预算经常被截断,导致JSON不完整。后来把max_tokens提到4000,解析失败率从21%降到0.5%。但注意,token消耗也涨了——v3单次平均输出1300 token,而v4因为不写推理过程,平均只有780 token。

6. 效果数据:四套模板的硬对比

版本 字段级准确率 JSON解析成功率 平均输出token/份 200份总成本(估算)
v1 零样本 61% 100%(但字段错) 620 $1.24
v2 角色+边界 71% 94% 680 $1.36
v3 思维链+示例 84% 92% 1300 $2.60
v4 结构化输出 93% 99.5% 780 $1.56

重点看v3和v4的对比:v3靠“示例”引导,准确率上来了,但CoT让输出token翻倍,成本涨了67%。v4不依赖模型自觉,用response_format和Schema双保险,准确率最高,成本反而比v3低。最终我选了v4上生产

有个细节值得提:v4的误判集中在免责条款这个布尔字段——模型会把“因甲方原因导致延期”也判断为免责。这是语义理解问题,不是格式问题,暂时无解,只能在后续加一条规则过滤掉“甲方原因”这一类的false positive。

7. 总结:Prompt Engineering的投入产出比

总结一下这次实验的教训:

  1. 先定Schema,再写Prompt。 我一开始没列字段清单,导致v1的输出没法直接用。字段定义越细,Prompt越好写,模型也越不容易跑偏。
  2. 结构化输出是抽取任务的银弹。 如果你的API支持response_format,优先用它,不要依赖Prompt里的“请输出JSON”这种软约束。
  3. 不要迷信思维链。 CoT在推理类任务(数学、逻辑)上确实强,但在抽取任务上,它带来的额外token消耗大概率不划算。这次v3的准确率提升主要来自“示例”,而不是“思维链”本身。
  4. 成本可以精确控制。 通过压缩输出token(v4比v3少40%),200份合同的总成本从$2.60压到$1.56,而且准确率更高。这个优化思路比换便宜模型更值得先试。

最后说句掏心窝的:Prompt Engineering不是玄学,本质上是在“模型能力”和“任务约束”之间找平衡点。这次实验让我确信,在固定模型下,一个结构良好的Prompt能做到微调80%的效果——前提是你愿意花两小时做A/B测试,而不是拍脑袋写一段话就扔给模型。