一、问题背景:一个“看起来很简单”的抽取任务
上个月接了个需求:从采购合同里抽取结构化字段,包括 party_a、party_b、sign_date、amount、payment_terms、penalty_rate 六个字段,然后写进内部 ERP。
一开始我觉得这不就是个信息抽取嘛,GPT-4o-mini 便宜得跟不要钱一样,直接上就行。结果第一版跑完,200 条测试集上字段级准确率只有 71.3%,主要错在:
amount把“含税总价”和“不含税价”搞混;sign_date抽成了合同生效日而不是签署日;payment_terms输出一大段自然语言,没法直接入库;- 偶尔输出 markdown 代码块包裹的 JSON,解析直接炸。
于是我决定认真做一轮 Prompt Engineering,把每一版的差异量化出来,而不是凭感觉“调一调”。
二、环境与版本
- Python 3.11.6
- openai==1.51.0
- tiktoken==0.7.0
- 模型:
gpt-4o-mini,temperature=0,top_p=1,max_tokens=512 - 测试集:200 份真实采购合同(已脱敏),人工标注 gold label
- 评测脚本:字段级 exact match,金额做归一化(去千分位、统一“元”)
- 成本口径:按 tiktoken 计算 input+output token,价格按 $0.15/1M input、$0.60/1M output 折算
三、方案设计:7 版 Prompt 的演进路线
我把实验分成 7 版,每版只改一个变量,方便归因:
| 版本 | 策略 | 关键改动 |
|---|---|---|
| V1 | Zero-shot 直问 | “请抽取以下字段……” |
| V2 | 角色设定 | 加“你是资深法务” |
| V3 | 字段定义 + 枚举 | 明确每个字段含义 |
| V4 | Few-shot 3 例 | 加 3 个输入输出示例 |
| V5 | JSON Schema 约束 | 规定输出结构 |
| V6 | CoT 分步 | 先定位再抽取 |
| V7 | 分步 + 自校验 | 抽取后自查一次 |
四、核心实现
先看最朴素的 V1,问题很典型:
import json
from openai import OpenAI
import tiktoken
client = OpenAI(api_key="sk-xxx")
enc = tiktoken.encoding_for_model("gpt-4o-mini")
def count_tokens(text: str) -> int:
return len(enc.encode(text))
def extract_v1(contract: str) -> dict:
prompt = f"""请从下面的合同中抽取 party_a, party_b, sign_date,
amount, payment_terms, penalty_rate 六个字段。
合同:
{contract}
"""
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=512,
)
return resp.choices[0].message.content
V1 的问题:输出是自然语言,payment_terms 经常是一整段话,还得再写正则去抠,token 也浪费在解释上。
到 V5 我开始用 JSON Schema 强约束输出,这是准确率提升最明显的一版:
SCHEMA = {
"type": "object",
"properties": {
"party_a": {"type": "string"},
"party_b": {"type": "string"},
"sign_date": {"type": "string", "description": "YYYY-MM-DD,签署日"},
"amount": {"type": "number", "description": "含税总价,单位元"},
"payment_terms": {
"type": "array",
"items": {"type": "string"},
"description": "付款节点,如 '30%预付'"
},
"penalty_rate": {"type": "number", "description": "日违约金比例,如 0.0005"}
},
"required": ["party_a", "party_b", "sign_date", "amount"]
}
def extract_v5(contract: str) -> dict:
system = (
"你是合同信息抽取引擎。只输出 JSON,不要 markdown,不要解释。"
f"必须符合以下 JSON Schema:\n{json.dumps(SCHEMA, ensure_ascii=False)}"
)
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": system},
{"role": "user", "content": contract},
],
temperature=0,
max_tokens=512,
response_format={"type": "json_object"},
)
return json.loads(resp.choices[0].message.content)
V7 在 V5 基础上加了“自校验”步骤:先抽一遍,再让模型对照原文检查金额、日期是否一致,不一致就修正。代价是多一次调用,但把 amount 的错误率从 11% 压到了 3.5%。
五、踩坑与优化
坑 1:response_format 不是万能的。 早期我以为开了 json_object 就稳了,结果模型偶尔还是会返回 {"result": {...}} 这种包一层的情况。后来在 system 里明确写“顶层就是字段,不要嵌套”,才稳定。
坑 2:few-shot 不是越多越好。 我试过 8 个示例,token 从 620 涨到 1450,准确率只从 88.1% 提到 88.6%,边际收益极低。最后定在 3 个,且示例要覆盖“金额含税/不含税”“日期多格式”这两类难例。
坑 3:CoT 会放大 token。 V6 让模型先输出推理过程,准确率确实到了 91%,但平均 token 冲到 1320。后来改成“内部推理不输出”,只在需要时触发二次校验,才把成本压下来。
坑 4:temperature=0 也不完全确定。 同一批数据跑两次,仍有约 1.2% 的字段不一致。生产上我加了结果缓存 + 关键字段正则兜底。
六、效果数据
200 条测试集,字段级准确率与 token 对比:
| 版本 | 准确率 | 平均 input token | 平均 output token | 单条成本(USD) |
|---|---|---|---|---|
| V1 | 71.3% | 812 | 368 | 0.000343 |
| V2 | 73.5% | 836 | 352 | 0.000337 |
| V3 | 79.8% | 921 | 301 | 0.000319 |
| V4 | 88.1% | 1104 | 286 | 0.000337 |
| V5 | 90.6% | 968 | 142 | 0.000230 |
| V6 | 91.2% | 1021 | 299 | 0.000333 |
| V7 | 94.2% | 962 | 168 | 0.000213 |
V7 相比 V1,准确率 +22.9 个百分点,单条成本从 $0.000343 降到 $0.000213,降幅约 38%。按日均 5000 条算,一个月省下约 19.5 美元——钱不多,但准确率带来的返工成本下降才是大头。
最后给一个我线上用的 V7 精简模板:
SYSTEM_V7 = """你是合同抽取引擎,只输出 JSON。
字段定义:
- party_a/party_b: 甲乙方全称
- sign_date: 签署日,YYYY-MM-DD
- amount: 含税总价,数字,单位元
- payment_terms: 字符串数组
- penalty_rate: 日违约金比例,小数
输出后自查:金额是否含税、日期是否为签署日。
若不一致,直接给修正后的最终结果,不要输出过程。"""
def extract_v7(contract: str, retry: int = 2) -> dict:
for _ in range(retry):
try:
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": SYSTEM_V7},
{"role": "user", "content": contract},
],
temperature=0,
max_tokens=512,
response_format={"type": "json_object"},
)
return json.loads(resp.choices[0].message.content)
except json.JSONDecodeError:
continue
raise RuntimeError("抽取失败")
七、总结
这轮实验最大的收获不是那 22 个百分点,而是把 Prompt 当成可量化的工程对象:每版只改一个变量,记录准确率和 token,用数据决定留哪版。几个可以直接抄的结论:
- JSON Schema 约束是性价比最高的一步,V3→V5 用更少的 token 换来 10 个点准确率;
- few-shot 控制在 3 个以内,且必须覆盖难例,堆数量纯属浪费;
- CoT 要“内推不外显”,否则 token 涨得比准确率快;
- 永远加一层正则/枚举兜底,别把生产稳定性押在
temperature=0上。
Prompt Engineering 不是什么玄学,它就是一次次受控实验。你把变量控住了,数据自然会告诉你答案。