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的正式版) - SDK:
openai>=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的投入产出比
总结一下这次实验的教训:
- 先定Schema,再写Prompt。 我一开始没列字段清单,导致v1的输出没法直接用。字段定义越细,Prompt越好写,模型也越不容易跑偏。
- 结构化输出是抽取任务的银弹。 如果你的API支持
response_format,优先用它,不要依赖Prompt里的“请输出JSON”这种软约束。 - 不要迷信思维链。 CoT在推理类任务(数学、逻辑)上确实强,但在抽取任务上,它带来的额外token消耗大概率不划算。这次v3的准确率提升主要来自“示例”,而不是“思维链”本身。
- 成本可以精确控制。 通过压缩输出token(v4比v3少40%),200份合同的总成本从$2.60压到$1.56,而且准确率更高。这个优化思路比换便宜模型更值得先试。
最后说句掏心窝的:Prompt Engineering不是玄学,本质上是在“模型能力”和“任务约束”之间找平衡点。这次实验让我确信,在固定模型下,一个结构良好的Prompt能做到微调80%的效果——前提是你愿意花两小时做A/B测试,而不是拍脑袋写一段话就扔给模型。