一、问题背景:为什么“随便写个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(金额做归一化后比较)
- 关键参数:
temperature、response_format、max_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%。
六、总结
这轮迭代给我最大的三个结论:
- Structured Outputs 是刚需,不是锦上添花。 只要任务要结构化结果,
json_schema带来的稳定性远超任何自然语言约束。 - Prompt 要“减脂”。 很多人(包括我)第一反应是不断加描述、加示例,但 Token 成本和准确率不是线性关系,找到信息密度最高的那几句才是关键。
- 数据预处理比 Prompt 更值钱。 长文本重排这一步没动 Prompt,却把最难的字段准确率提了 23 个点。
如果你也在做信息抽取类任务,建议先跑通 json_schema,再逐步精简 Prompt,最后再考虑 few-shot。顺序反了,很容易在 Token 上花冤枉钱。