一、问题背景
上个月接手一个法务侧的需求:从采购合同里抽取 8 个关键字段——甲方名称、乙方名称、合同金额、签订日期、生效日期、到期日期、付款方式、违约金比例。合同是 PDF 转出来的纯文本,长度在 3000~15000 字符之间,格式极不统一,有的用「甲 方:」,有的用「甲方(全称):」,还有的干脆写在表格里被转成了一堆空格。
一开始想的是上正则+规则,写了两天发现维护成本爆炸,光日期格式就有十几种。于是转向 LLM 抽取。但直接调 API 的效果并不理想——模型经常自由发挥,把「付款方式」写成一段解释而不是原文值,JSON 也经常缺字段。
所以这篇文章的核心不是「怎么调 API」,而是:针对结构化抽取这个具体任务,Prompt 到底怎么设计,不同设计的成本和收益差多少。
二、环境与版本
- Python 3.11.7
- openai 1.40.2
- anthropic 0.34.2
- 模型:
gpt-4o-2024-08-06(主)、claude-3-5-sonnet-20240620(对照) - 参数:
temperature=0,top_p=1,max_tokens=1024,response_format={"type":"json_object"}(仅在支持的版本启用) - 测试集:50 份真实采购合同(脱敏),人工标注了 8 个字段作为 ground truth
- 评测指标:字段级准确率(完全匹配才算对)、平均 Token 消耗(prompt+completion)、P95 延迟
三、方案设计:6 版 Prompt 的演进
我把 Prompt 分成 6 个版本,逐步加约束:
| 版本 | 核心改动 |
|---|---|
| V1 | 直接提问,无格式约束 |
| V2 | 加 JSON 输出格式说明 |
| V3 | 加字段定义 + 缺失填 null |
| V4 | 加 1 个 Few-shot 示例 |
| V5 | 加 3 个 Few-shot + 边界规则 |
| V6 | V5 + JSON Schema 强约束 + 字段顺序固定 |
四、核心实现
先封装一个统一的调用函数,方便切换 Prompt:
import json, time
from openai import OpenAI
client = OpenAI(api_key="sk-xxx")
def extract(text: str, prompt_template: str, model="gpt-4o-2024-08-06"):
prompt = prompt_template.format(contract=text)
t0 = time.time()
resp = client.chat.completions.create(
model=model,
temperature=0,
top_p=1,
max_tokens=1024,
response_format={"type": "json_object"},
messages=[{"role": "user", "content": prompt}],
)
latency = time.time() - t0
usage = resp.usage
return {
"raw": resp.choices[0].message.content,
"prompt_tokens": usage.prompt_tokens,
"completion_tokens": usage.completion_tokens,
"latency": latency,
}
V1 到 V3 的 Prompt 差异(简化展示):
V1 = "从下面合同里提取甲方、乙方、金额、签订日期、生效日期、到期日期、付款方式、违约金比例:\n{contract}"
V2 = """从下面合同里提取甲方、乙方、金额、签订日期、生效日期、到期日期、付款方式、违约金比例。
以 JSON 格式输出。
合同:
{contract}"""
V3 = """你是合同信息抽取助手。从合同文本中提取以下字段,严格按定义执行:
- party_a: 甲方全称,若无则 null
- party_b: 乙方全称,若无则 null
- amount: 合同金额,仅保留数字与币种,如 "128000 CNY"
- sign_date: 签订日期,格式 YYYY-MM-DD
- effective_date: 生效日期,格式 YYYY-MM-DD
- expire_date: 到期日期,格式 YYYY-MM-DD
- payment_method: 付款方式原文,不要改写
- penalty_rate: 违约金比例,如 "0.05%"
只输出 JSON,不要解释。
合同:
{contract}"""
V6 的关键改动是把 JSON Schema 塞进 Prompt 并用 response_format 强约束:
SCHEMA = {
"type": "object",
"properties": {
"party_a": {"type": ["string", "null"]},
"party_b": {"type": ["string", "null"]},
"amount": {"type": ["string", "null"]},
"sign_date": {"type": ["string", "null"]},
"effective_date": {"type": ["string", "null"]},
"expire_date": {"type": ["string", "null"]},
"payment_method": {"type": ["string", "null"]},
"penalty_rate": {"type": ["string", "null"]}
},
"required": ["party_a","party_b","amount","sign_date",
"effective_date","expire_date","payment_method","penalty_rate"],
"additionalProperties": False
}
V6 = f"""你是合同信息抽取引擎。严格按照下列 JSON Schema 输出,字段顺序不可变,
字段缺失一律填 null,禁止输出 Schema 之外的内容。
Schema:
{json.dumps(SCHEMA, ensure_ascii=False)}
规则:
1. 日期统一 YYYY-MM-DD,无法确定填 null
2. 金额保留原始币种,不要换算
3. payment_method 必须是合同原文片段,不得改写
4. 违约金优先取百分比,若无则取固定金额并保留原文
示例:
输入:...甲方:XX科技有限公司 乙方:YY贸易 合同金额 128,000 元 ...
输出:{{"party_a":"XX科技有限公司","party_b":"YY贸易","amount":"128000 CNY",...}}
合同:
{{contract}}"""
五、踩坑与优化
坑 1:response_format=json_object 不是万能的。 它只保证输出是合法 JSON,不保证字段齐全。V2 阶段有 12% 的样本直接少了 penalty_rate 字段,程序解析时 KeyError。必须配合 required 声明。
坑 2:Few-shot 示例会“污染”抽取。 V4 只给了一个示例,模型在遇到格式差异大的合同时会往示例的句式上靠,比如把「付款方式」硬套成示例里的「分期付款」。V5 加到 3 个示例、覆盖不同排版后,这个问题基本消失。
坑 3:字段顺序影响 completion token。 把 payment_method 这种长文本字段放最后,可以让模型先输出短字段,实测 completion token 平均减少约 9%。
坑 4:Claude 3.5 Sonnet 在长合同上更稳但更贵。 同一批 50 份合同,Claude 准确率 95%,但平均 Token 消耗 1120(比 GPT-4o V6 高 46%),P95 延迟 4.2s vs 2.8s。最后选了 GPT-4o。
六、效果数据
50 份合同、8 字段 = 400 个字段的评测结果:
| 版本 | 字段准确率 | 平均 Prompt Token | 平均 Completion Token | 平均总 Token | P95 延迟 |
|---|---|---|---|---|---|
| V1 | 61.3% | 1620 | 210 | 1830 | 3.1s |
| V2 | 68.5% | 1650 | 180 | 1830 | 3.0s |
| V3 | 79.8% | 1780 | 165 | 1945 | 3.2s |
| V4 | 86.0% | 2050 | 170 | 2220 | 3.6s |
| V5 | 91.5% | 2280 | 175 | 2455 | 3.9s |
| V6 | 94.0% | 610 | 158 | 768 | 2.8s |
注意 V6 的 Prompt Token 反而大幅下降——因为把冗长的自然语言规则换成了紧凑的 JSON Schema,同时 Few-shot 从 3 个精简到 1 个。准确率还涨了 2.5 个百分点。约束越结构化,Token 越省,效果越好。
按 GPT-4o 输入 $2.5/1M、输出 $10/1M 计算,单份合同成本从 V1 的 $0.0062 降到 V6 的 $0.0031,降幅 50%。如果按 10 万份/年算,一年省下约 310 美元——不算多,但延迟从 3.1s 降到 2.8s,对用户体验是实打实的。
七、总结
这次实验最大的收获是:Prompt Engineering 不是「把话说得客气点」,而是把任务约束显式化、结构化。 具体到抽取任务:
- 字段定义要写清楚,缺失语义(null)要显式声明,别指望模型猜。
- Few-shot 要覆盖边界情况,数量不是越多越好,3 个以内、覆盖不同排版即可。
- JSON Schema 约束比自然语言描述省 Token 且更准,能用就用。
- 字段顺序、temperature=0、max_tokens 这些参数对稳定性影响比想象中大。
下一步打算把 V6 的 Schema 抽成配置化,让法务同事自己加字段,不用改代码。如果你们也在做类似的抽取任务,欢迎评论区交流。