一、问题背景:为什么合同抽取值得单独调Prompt
先说场景。我们做的是一个采购合规系统,需要从供应商合同里抽取结构化字段:甲方、乙方、合同总金额、付款节点(数组)、违约责任条款原文。下游要做金额校验和节点提醒,所以字段必须准,格式必须稳。
一开始我觉得这活儿不难,直接丢给GPT-4o就行。结果第一版跑下来,50份合同里有14份返回了非法JSON,还有一堆把“含税总价”和“不含税总价”搞混的。更离谱的是付款节点,模型有时候返回字符串,有时候返回对象数组,下游解析直接崩。
于是老老实实做Prompt Engineering。这篇文章就是把整个过程摊开讲,包括每一版为什么改、改完Token和质量怎么变。
二、环境与版本
- 模型:
gpt-4o-2024-08-06(支持 Structured Outputs) - SDK:
openai==1.40.0 - Python:3.11.9
- 测试集:50份真实采购合同,平均长度3800字(约5200 Token)
- 评测方式:字段级 exact match + JSON 可解析率
- 参数:
temperature=0,top_p=1,max_tokens=1024 - 成本按官方价:输入 $2.5/1M Token,输出 $10/1M Token
三、方案设计:6版Prompt的演进路线
我按“控制变量”的思路,每次只改一个维度:
| 版本 | 核心改动 | 关键参数 |
|---|---|---|
| V1 | 自然语言描述任务 | 无格式约束 |
| V2 | 加 JSON 示例 | few-shot 1例 |
| V3 | 加字段定义和边界规则 | few-shot 2例 |
| V4 | 改用 Structured Outputs | response_format=json_schema |
| V5 | 压缩指令,去冗余 | system+user 拆分 |
| V6 | 动态裁剪合同+字段级校验 | 只传相关段落 |
V4 是质变点,V5/V6 是成本优化。
四、核心实现:从V1到V6的关键代码
先看 V1,最朴素的写法:
from openai import OpenAI
import json
client = OpenAI(api_key="sk-xxx")
def extract_v1(contract_text: str) -> dict:
prompt = f"""请从下面的合同中抽取甲方、乙方、合同金额、付款节点、违约责任。
合同内容:
{contract_text}
"""
resp = client.chat.completions.create(
model="gpt-4o-2024-08-06",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=1024,
)
return resp.choices[0].message.content
V1 的问题很明显:返回的是自然语言,我得再写正则去抠。50份里只有36份能解析出完整字段。
V4 改用 Structured Outputs,这是官方在 2024-08-06 版本引入的能力,能保证输出严格符合 JSON Schema:
from openai import OpenAI
from pydantic import BaseModel
from typing import List
client = OpenAI(api_key="sk-xxx")
class PaymentNode(BaseModel):
stage: str
ratio: str
condition: str
class ContractInfo(BaseModel):
party_a: str
party_b: str
total_amount: str
payment_nodes: List[PaymentNode]
breach_clause: str
def extract_v4(contract_text: str) -> ContractInfo:
completion = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06",
messages=[
{"role": "system", "content": "你是合同信息抽取引擎,只输出结构化结果,不解释。"},
{"role": "user", "content": f"抽取以下合同字段:\n{contract_text}"},
],
response_format=ContractInfo,
temperature=0,
)
return completion.choices[0].message.parsed
V4 一上,JSON 可解析率直接到 100%。但 Token 消耗上去了,因为 Schema 本身要占 Token,而且我把整份合同都塞进去了。
V5 做了两件事:把 system prompt 压到 28 个字;把字段定义从 prompt 移到 Schema 的 description 里(OpenAI 的 Structured Outputs 支持 description,模型会读)。这样 prompt 部分省了约 180 Token。
V6 是真正的成本杀手:先用 text-embedding-3-small 做段落召回,只把和“金额、付款、违约”相关的段落送进去。合同平均 5200 Token,裁剪后平均 1900 Token。
import numpy as np
from openai import OpenAI
client = OpenAI(api_key="sk-xxx")
def split_paragraphs(text: str, max_len: int = 400):
paras, buf = [], ""
for line in text.split("\n"):
if len(buf) + len(line) > max_len:
paras.append(buf.strip())
buf = line
else:
buf += "\n" + line
if buf.strip():
paras.append(buf.strip())
return paras
def embed(texts):
resp = client.embeddings.create(
model="text-embedding-3-small",
input=texts,
)
return np.array([d.embedding for d in resp.data])
def recall_relevant(contract: str, top_k: int = 8) -> str:
paras = split_paragraphs(contract)
if len(paras) <= top_k:
return contract
query = "合同金额 付款节点 付款比例 违约责任 违约金 甲方 乙方"
q_vec = embed([query])[0]
p_vecs = embed(paras)
sims = p_vecs @ q_vec / (np.linalg.norm(p_vecs, axis=1) * np.linalg.norm(q_vec) + 1e-8)
idx = np.argsort(sims)[::-1][:top_k]
idx = sorted(idx)
return "\n".join(paras[i] for i in idx)
V6 的完整调用就是把 recall_relevant 的结果喂给 V5 的抽取函数。注意 embedding 也要算钱,text-embedding-3-small 是 $0.02/1M Token,50份合同总共花了不到 $0.001,可以忽略。
五、踩坑与优化
坑1:temperature=0 不等于完全确定。我跑了两遍 V3,付款节点字段有 3 份合同结果不一样。后来换 Structured Outputs 才彻底稳定。
坑2:Schema 里 payment_nodes 一开始写成 List[str],模型把所有节点拼成一句话塞进一个元素。改成嵌套对象后才正常。
坑3:金额字段。合同里同时有“含税总价”“不含税总价”“大写金额”,V2/V3 经常抽错。V5 在 Schema description 里明确写“抽取含税总价,若无则取合同总金额”,准确率从 78% 提到 96%。
坑4:V6 的召回会漏。有 2 份合同的违约条款写在附件里,被裁掉了。后来我把 top_k 从 6 提到 8,并在 query 里加了“附件”,漏抽降到 0。
坑5:max_tokens=1024 对长付款节点不够。有一份合同 12 个付款节点,输出被截断。改成 2048 后正常,但因为只有这一份超,我最后是按合同长度动态设 max_tokens。
六、效果数据
50份合同,每版跑一次,统计如下:
| 版本 | 平均输入Token | 平均输出Token | 平均总Token | JSON可解析率 | 字段准确率 | 单份成本 |
|---|---|---|---|---|---|---|
| V1 | 5320 | 210 | 5530 | 72% | 68% | $0.0154 |
| V2 | 5480 | 240 | 5720 | 88% | 76% | $0.0161 |
| V3 | 5610 | 260 | 5870 | 92% | 81% | $0.0166 |
| V4 | 5720 | 280 | 6000 | 100% | 89% | $0.0171 |
| V5 | 5540 | 270 | 5810 | 100% | 91% | $0.0165 |
| V6 | 1820 | 260 | 2080 | 100% | 94% | $0.0072 |
注:V6 的输入 Token 含 embedding 的费用,但 embedding 单价极低,实际单份成本约 $0.0068,比 V1 降了 56%,准确率反而涨了 26 个百分点。
延迟方面,V1 平均 3.2s,V6 平均 1.8s,因为输入短了,首 Token 时间也降了。
七、总结
这轮迭代最大的体会是:Prompt Engineering 不是“把话说得更漂亮”,而是把不确定性一点点挤出去。
- Structured Outputs 是分水岭,它把“格式问题”从 Prompt 里彻底拿掉,省下的精力可以全砸在语义准确率上。
- 字段定义不要堆在 prompt 里,塞进 Schema 的 description,既省 Token 又更稳。
- 成本优化的大头在输入侧。RAG 式裁剪对长文本任务几乎是必选项,50份合同省了一半以上成本。
- 别迷信
temperature=0,要确定性就上结构化输出。
如果你的任务也是长文本抽取,建议直接从 V4 起步,再按 V6 的思路做召回裁剪。省下来的钱和时间,够你多跑几轮评测了。