一、问题背景:一个看似简单的抽取任务,为什么值得较真
上个月接手一个合同关键信息抽取的需求:从采购合同文本里抽出甲方、乙方、合同金额、签订日期、付款方式五个字段,输出JSON给下游入库。文本长度在800~3000字之间,格式五花八门,有扫描件OCR后的脏文本,也有规整的Word导出文本。
第一版我随手写了个零样本Prompt,本地测了20条样本,感觉"能用"。上线灰度后问题全来了:模型时不时在JSON前后加一句"以下是抽取结果:",导致json.loads直接抛异常;金额字段有时输出"人民币壹拾万元整"而不是数字;日期格式在2024-03-01和2024年3月1日之间反复横跳。
这时候我意识到,Prompt不是"写一句能用的话",而是一个需要量化对比的工程问题。于是我决定停下来,做一轮系统性实验:固定任务、固定评测集,只变Prompt策略,把准确率、token消耗、解析失败率三个指标全部记录下来。
二、环境与版本
实验环境尽量贴近生产,避免"本地跑得好、线上拉胯":
- Python 3.11.6
- openai SDK 1.35.0(走Azure OpenAI终结点)
- 模型A:gpt-4o-mini,
temperature=0,max_tokens=512,response_format按策略决定是否启用 - 模型B:qwen2.5-72b-instruct(DashScope兼容OpenAI协议),
temperature=0.01(该接口不支持严格0),max_tokens=512 - 评测集:从历史合同里人工标注120条,字段级gold label,覆盖规整文本62条、OCR脏文本58条
- 评测脚本:自写的字段级F1计算器,金额做归一化(中文大写转数字)后再比对,日期统一转ISO格式
评测集规模不大,但胜在每条都人工核过,字段级标注共600个,足够看出策略间的差异趋势。
三、方案设计:四种Prompt策略
我设计了四组Prompt,控制变量只改Prompt本身:
P1 零样本:直接描述任务,要求输出JSON,不做任何格式约束。
P2 少样本:给2个示例(一正一"脏文本"),示例里包含完整的输入输出对。
P3 思维链:要求模型先逐字段列出"原文片段→判断依据",最后再输出JSON。
P4 Schema约束:在P2基础上,把字段定义、类型、枚举值写成JSON Schema塞进System Prompt,并启用response_format={"type":"json_object"}(gpt-4o-mini支持;qwen侧用其response_format参数)。
每组Prompt在2个模型上各跑120条,共8组主实验;后续调优又追加了若干变体,总计47组配置、约186万token。
四、核心实现
先看Prompt模板的组织方式。我把System Prompt和User内容分离,方便做A/B:
# prompt_templates.py
SYSTEM_ZERO_SHOT = "你是一个合同信息抽取助手,请从用户给出的合同文本中抽取甲方、乙方、合同金额、签订日期、付款方式,以JSON输出。"
SYSTEM_FEW_SHOT = """你是一个合同信息抽取助手。请从合同文本中抽取指定字段,严格以JSON输出,不要输出任何解释文字。
字段说明:
- party_a: 甲方名称,字符串
- party_b: 乙方名称,字符串
- amount: 合同金额,纯数字(元),不要带单位
- sign_date: 签订日期,格式 YYYY-MM-DD
- payment: 付款方式,字符串,取原文描述
示例1:
输入:甲方:杭州某某科技有限公司;乙方:北京某某信息技术有限公司。合同总金额为人民币伍拾万元整(¥500,000.00),于2024年3月1日签订。付款方式为分三期支付。
输出:{"party_a":"杭州某某科技有限公司","party_b":"北京某某信息技术有限公司","amount":500000,"sign_date":"2024-03-01","payment":"分三期支付"}
示例2(OCR脏文本):
输入:甲 方: 深训市XX 实业公司 乙万: 广州XX贸易有限公司 合同金额 人民币 1200000 元 签定日期 2024 年 12 月 5 日 付款 方式: 货到付款
输出:{"party_a":"深训市XX实业公司","party_b":"广州XX贸易有限公司","amount":1200000,"sign_date":"2024-12-05","payment":"货到付款"}
"""
SYSTEM_SCHEMA = """你是一个合同信息抽取助手。严格按照以下JSON Schema输出,禁止输出Schema之外的任何内容。
{
"type": "object",
"properties": {
"party_a": {"type": "string", "description": "甲方全称"},
"party_b": {"type": "string", "description": "乙方全称"},
"amount": {"type": "number", "description": "合同金额,单位元,纯数字"},
"sign_date": {"type": "string", "pattern": "^\\\\d{4}-\\\\d{2}-\\\\d{2}$"},
"payment": {"type": "string"}
},
"required": ["party_a", "party_b", "amount", "sign_date", "payment"]
}
"""
调用与评测的主循环,重点是记录每次请求的token用量和解析结果:
# run_eval.py
import json, time, re
from openai import AzureOpenAI
from prompt_templates import SYSTEM_ZERO_SHOT, SYSTEM_FEW_SHOT, SYSTEM_SCHEMA
from metrics import field_f1 # 自写字段级F1,金额/日期先归一化
client = AzureOpenAI(api_key=..., api_version="2024-06-01", azure_endpoint=...)
STRATEGIES = {
"zero_shot": {"system": SYSTEM_ZERO_SHOT, "json_mode": False},
"few_shot": {"system": SYSTEM_FEW_SHOT, "json_mode": False},
"schema": {"system": SYSTEM_SCHEMA, "json_mode": True},
"cot": {"system": SYSTEM_FEW_SHOT + "\n请先逐字段写出判断依据,再输出JSON。", "json_mode": False},
}
def parse_json_safe(text: str):
# 兜底:剥离markdown代码块、截取首个{到最后一个}
text = re.sub(r"^```(json)?|```$", "", text.strip(), flags=re.M).strip()
m = re.search(r"\{.*\}", text, flags=re.S)
if not m:
return None
try:
return json.loads(m.group(0))
except json.JSONDecodeError:
return None
def run(strategy_name, samples, model="gpt-4o-mini"):
cfg = STRATEGIES[strategy_name]
total_prompt_tok = total_completion_tok = 0
parse_fail = 0
preds, golds = [], []
for s in samples:
kwargs = dict(model=model, temperature=0, max_tokens=512,
messages=[{"role":"system","content":cfg["system"]},
{"role":"user","content":s["text"]}])
if cfg["json_mode"]:
kwargs["response_format"] = {"type": "json_object"}
resp = client.chat.completions.create(**kwargs)
total_prompt_tok += resp.usage.prompt_tokens
total_completion_tok += resp.usage.completion_tokens
obj = parse_json_safe(resp.choices[0].message.content)
if obj is None:
parse_fail += 1
obj = {}
preds.append(obj)
golds.append(s["label"])
time.sleep(0.05) # 避免触发限流
f1 = field_f1(preds, golds)
return {
"strategy": strategy_name, "model": model,
"f1": round(f1, 4),
"prompt_tokens": total_prompt_tok,
"completion_tokens": total_completion_tok,
"total_tokens": total_prompt_tok + total_completion_tok,
"parse_fail_rate": round(parse_fail / len(samples), 4),
}
这个脚本跑完8组主实验大概花了40分钟,主要是qwen侧限流较严,加了sleep。
五、踩坑与优化
坑1:JSON模式不等于Schema校验。 我一开始以为开了response_format={"type":"json_object"}就万事大吉,结果模型输出{"amount": "五十万"}——合法JSON,但类型错了。JSON模式只保证语法合法,不保证字段类型和枚举符合预期。后来我在Prompt里显式写了"amount必须为number类型",并在下游加了一层Pydantic校验(model_validate_json),解析失败率才真正压下去。
坑2:思维链让token成本失控。 P3要求先写判断依据,completion token平均从98涨到312,翻了3倍多,但F1只比少样本高1.8个百分点,性价比很差。而且CoT输出里夹带的解释文字让parse_json_safe的正则更容易误匹配——有一次文本里出现两个花括号对象,正则截到了错误的那个。后来我改成"先输出依据,最后一行以FINAL:开头输出JSON",解析时只取FINAL:之后的内容,才稳定下来。
坑3:少样本示例的"污染"。 P2的示例2里有个OCR错字"深训市",本意是演示脏文本处理,结果模型在真实样本里把正确的"深圳市"也"纠正"成了"深训市"——示例里的错误被模型当成了模式去模仿。教训是:少样本示例必须是"输入脏、输出干净"的配对,且示例里的错字要在输出侧被正确修复,否则模型会学歪。我后来把示例2的输出改成正确的"深圳市XX实业有限公司"(同时保留输入侧的脏文本),这个污染问题就消失了。
坑4:qwen侧temperature=0.01仍不稳定。 同一输入跑两次,amount字段一次输出500000一次输出500000.0。评测脚本里我做了数值归一化(float()后再比),否则会误判为错误。生产环境建议对数值字段做类型强转兜底。
优化后的最终Prompt(P4的增强版),关键改动是:Schema内联、禁止解释文字、金额给归一化规则、日期给格式示例:
你是一个合同信息抽取助手。严格按JSON Schema输出,禁止输出任何Schema之外的文字、注释或代码块标记。
字段规则:
1. amount 必须为 number 类型,单位为元。若原文为中文大写(如"伍拾万元整"),请转换为数字 500000。
2. sign_date 必须为 YYYY-MM-DD 格式。
3. 若某字段在原文中确实不存在,填空字符串 "",不要编造。
[JSON Schema 同前]
六、效果数据
主实验结果(120条评测集,字段级F1,token为全量累计):
| 策略 | 模型 | F1 | prompt tok | completion tok | 总token | 解析失败率 |
|---|---|---|---|---|---|---|
| P1 零样本 | gpt-4o-mini | 0.712 | 61,400 | 11,800 | 73,200 | 12.4% |
| P2 少样本 | gpt-4o-mini | 0.925 | 178,900 | 12,100 | 191,000 | 3.1% |
| P3 思维链 | gpt-4o-mini | 0.943 | 179,500 | 37,400 | 216,900 | 5.8% |
| P4 Schema | gpt-4o-mini | 0.938 | 182,300 | 11,600 | 193,900 | 0.6% |
| P1 零样本 | qwen2.5-72b | 0.698 | 60,800 | 12,900 | 73,700 | 15.2% |
| P2 少样本 | qwen2.5-72b | 0.911 | 177,600 | 13,400 | 191,000 | 4.6% |
| P3 思维链 | qwen2.5-72b | 0.929 | 178,200 | 41,300 | 219,500 | 7.3% |
| P4 Schema | qwen2.5-72b | 0.926 | 181,000 | 12,800 | 193,800 | 1.2% |
几个值得说的结论:
- 少样本是性价比拐点。 相比零样本,F1提升21.3个百分点(0.712→0.925),token成本涨2.6倍,但绝对成本仍然很低——单条平均约1590 token,按gpt-4o-mini定价算,120条也就几分钱。这个投入产出比是划算的。
- CoT在这个任务上是负收益。 F1只比少样本高1.8个点,token却多了26,000(+13.6%),解析失败率还更高。抽取类任务字段边界清晰,不需要推理链条,CoT更适合多步推理场景。
- Schema约束的真正价值不在F1,在稳定性。 P4的F1和P2几乎持平(0.938 vs 0.925),但解析失败率从3.1%降到0.6%,下游不用再写一堆兜底正则。生产环境里,0.6%的失败率意味着人工复核量从每千条31条降到6条,这才是省下来的真金白银。
- 两个模型差距不大。 qwen2.5-72b在零样本上略差,少样本后基本追平,Schema模式下解析失败率略高(1.2% vs 0.6%),推测与其
response_format实现的严格程度有关。
最终选型:P4 Schema + gpt-4o-mini,单条平均token约1616,字段级F1 0.938,解析失败率0.6%。上线两周跑了约4万条合同,人工抽检200条准确率93.5%,和离线评测基本吻合。
七、总结
这轮实验最大的收获不是"哪个Prompt最好",而是建立了一套可复用的评估流程:固定评测集、字段级F1、token统计、解析失败率四个指标一起看,才能避免"感觉能用"的错觉。几条可以直接拿去用的经验:
- 抽取任务优先上少样本+Schema约束,别一上来就CoT;
response_format的JSON模式只保语法不保类型,下游必须加Pydantic或手写校验;- 少样本示例里的错误要谨慎,模型会模仿示例中的"脏"模式;
- 评测时对金额、日期做归一化再比对,否则会把格式差异误判成抽取错误。
下一步我打算把这套评测脚本封装成CI里的回归测试,每次改Prompt都自动跑一遍120条评测集,防止"改一处、坏三处"。Prompt Engineering 落到工程上,本质就是版本管理和回归测试,和写代码没什么两样。