一、问题背景:为什么我要死磕这个Prompt
上个月接了个活儿,从采购合同里抽取12个关键字段:合同编号、甲方乙方、签订日期、金额、税率、付款方式、违约责任条款、交付周期等。业务方一开始想让我微调模型,我算了笔账:标注2000条合同数据,加上训练和迭代,至少两周,而且合同模板一改就得重来。
我决定先用Prompt Engineering硬扛。直觉告诉我,这类"结构化信息抽取"任务,Prompt设计的好坏可能比换模型更重要。事实证明我是对的——同一批50份合同测试集,我最差的Prompt准确率54%,最好的95%,差了41个百分点,而Token消耗差了3.3倍。
这篇文章就是把整个实验过程摊开给你看。
二、环境与版本
- 模型:
gpt-4o-2024-08-06(主测),claude-3-5-sonnet-20241022(对照) - SDK:
openai==1.54.0,anthropic==0.39.0 - 温度:全部固定
temperature=0,top_p=1,减少随机性干扰 - 评测:50份真实采购合同(脱敏),12个字段,人工标注Ground Truth
- 指标:字段级准确率(Field Accuracy)、平均Token消耗、平均延迟
- 关键参数:
response_format={"type": "json_object"}(GPT-4o支持)
测试脚本骨架如下:
# eval_runner.py
import json, time
from openai import OpenAI
from typing import Callable
client = OpenAI(api_key="sk-xxx")
def run_eval(prompt_builder: Callable, contracts: list[dict], model="gpt-4o-2024-08-06"):
total_tokens, total_acc, total_latency = 0, 0, 0
for item in contracts:
prompt = prompt_builder(item["text"])
t0 = time.time()
resp = client.chat.completions.create(
model=model,
temperature=0,
top_p=1,
response_format={"type": "json_object"},
messages=[{"role": "user", "content": prompt}],
)
latency = time.time() - t0
pred = json.loads(resp.choices[0].message.content)
acc = field_accuracy(pred, item["label"]) # 自定义字段级比对
total_tokens += resp.usage.total_tokens
total_acc += acc
total_latency += latency
n = len(contracts)
return {
"avg_accuracy": round(total_acc / n, 4),
"avg_tokens": round(total_tokens / n, 1),
"avg_latency_s": round(total_latency / n, 2),
}
def field_accuracy(pred: dict, label: dict) -> float:
keys = label.keys()
hit = sum(1 for k in keys if str(pred.get(k, "")).strip() == str(label[k]).strip())
return hit / len(keys)
三、方案设计:5版Prompt的演进路线
我按照"信息量递增"的思路设计了5个版本,每版只改动一个变量,方便归因:
| 版本 | 核心策略 | 关键改动 |
|---|---|---|
| V1 | 朴素提问 | 直接说"抽取以下字段" |
| V2 | 字段定义 | 每个字段加一句说明 |
| V3 | 结构化输出约束 | 强制JSON Schema + 空值规则 |
| V4 | Few-shot | 加2个完整示例(含难例) |
| V5 | Few-shot + 思维链 + 字段级CoT | 金额/日期先推理再输出 |
四、核心实现:从V1到V5的Prompt代码
V1:朴素提问(别笑,很多人真这么写)
def v1(text):
return f"""从下面的合同中抽取字段:合同编号、甲方、乙方、签订日期、金额、税率、付款方式、违约责任、交付周期、签约地点、联系人、联系电话。
合同内容:
{text}
"""
V1的问题很明显:模型不知道"金额"要不要含税、"签订日期"用什么格式、"违约责任"是抽原文还是总结。结果就是格式五花八门,有的返回"2024年3月5日",有的返回"2024-03-05",比对全挂。
V3:结构化输出约束(质变开始)
SCHEMA = """{
"contract_no": "string, 合同编号,若无则填 null",
"party_a": "string, 甲方全称",
"party_b": "string, 乙方全称",
"sign_date": "string, 格式严格为 YYYY-MM-DD",
"amount": "number, 合同总金额(含税),单位元,去掉千分位",
"tax_rate": "number, 税率,如 0.13 表示13%,无法确定填 null",
"payment_method": "string, 付款方式原文摘要,不超过50字",
"liability": "string, 违约责任原文摘要,不超过100字",
"delivery_period": "string, 交付周期,如 '30个自然日'",
"sign_place": "string, 签约地点",
"contact": "string, 联系人姓名",
"phone": "string, 联系电话,保留原始格式"
}"""
def v3(text):
return f"""你是合同信息抽取专家。请从合同文本中抽取字段,严格按以下JSON Schema输出,不要输出任何解释。
Schema:
{SCHEMA}
规则:
1. 找不到的字段填 null,禁止编造。
2. 日期统一 YYYY-MM-DD。
3. 金额只保留数字,不含单位和逗号。
合同文本:
{text}"""
V3一上来准确率就从54%跳到78%。核心功臣是"找不到填null,禁止编造"和日期/金额格式的硬约束。
V5:Few-shot + 字段级思维链(最终生产版)
FEW_SHOT = """
示例1(简单):
输入:甲方:XX科技有限公司;乙方:YY贸易有限公司;合同金额:人民币1,250,000元(含税13%)...
输出:{"contract_no": "HT-2024-001", "party_a": "XX科技有限公司", ...}
示例2(难例,金额分散且有歧义):
输入:...本合同总价为人民币捌拾万元整,其中不含税金额707,964.60元,增值税税额92,035.40元...
输出:{"amount": 800000, "tax_rate": 0.13, ...}
注意:amount 取含税总价,不要取不含税金额。
"""
def v5(text):
return f"""你是合同信息抽取专家,严格按Schema输出JSON。
Schema:
{SCHEMA}
{FEW_SHOT}
抽取步骤(在内心执行,不要输出):
1. 先定位金额相关句子,判断是含税还是不含税总价。
2. 再定位日期,区分签订日期与生效日期。
3. 最后逐字段填充,缺失填 null。
合同文本:
{text}"""
注意:V5里我写了"在内心执行,不要输出",这是关键。早期我让它显式输出推理过程,结果Token暴涨到1600+,而且JSON经常被推理文本污染导致解析失败。改成隐式CoT后,Token只比V4多了不到80,准确率却涨了9个点。
五、踩坑与优化
坑1:JSON截断。 V4加长示例后,max_tokens默认值不够,长合同输出被截断,json.loads直接报错。解决:显式设 max_tokens=1024,并在Prompt末尾强调"输出必须完整闭合"。
坑2:幻觉字段。 V2时模型会给不存在的"签约地点"编一个"北京市朝阳区"。V3的"禁止编造+null"规则直接治好。
坑3:数字格式。 "1,250,000元"和"1250000"在字符串比对时不等。统一在Prompt里要求纯数字,评测侧也做了归一化。
坑4:温度。 一开始用默认temperature=1,同一份合同跑三次结果不同,评测完全没法复现。改成0之后稳定。
六、效果数据
50份合同测试集,结果如下:
| 版本 | 字段准确率 | 平均Token | 平均延迟 |
|---|---|---|---|
| V1 | 54.2% | 312 | 1.8s |
| V2 | 66.8% | 487 | 2.1s |
| V3 | 78.4% | 623 | 2.4s |
| V4 | 86.1% | 968 | 3.2s |
| V5 | 95.3% | 1047 | 3.5s |
对照Claude 3.5 Sonnet跑V5 Prompt:准确率94.1%,Token 1120,延迟4.1s。GPT-4o在这个任务上略胜,但差距不大。
成本核算:GPT-4o输入$2.5/1M tokens、输出$10/1M tokens。V5平均1047 tokens(约800输入+247输出),单份合同成本约 $0.0045,一万份合同45美元。相比微调方案(标注+训练+推理)省了至少一个数量级的钱和时间。
七、总结
三点体会:
- 结构化约束是ROI最高的改动。 从V1到V3只加了一段Schema和几条规则,准确率+24个点,Token只涨一倍。
- Few-shot要放难例,不要放简单例。 我V4的两个示例一个是标准格式,一个是金额歧义难例,后者贡献了大部分提升。
- CoT要隐式,不要显式。 显式推理在抽取任务里是负优化,Token翻倍还污染JSON。
最终生产环境我用的就是V5,跑了三周,线上准确率稳定在94%-96%。模型没换,只是把Prompt当代码一样迭代——这大概就是Prompt Engineering最朴素的价值。