一、问题背景:一个看似简单的抽取任务,为什么值得较真

上个月接手一个合同关键信息抽取的需求:从采购合同文本里抽出甲方、乙方、合同金额、签订日期、付款方式五个字段,输出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%

几个值得说的结论:

  1. 少样本是性价比拐点。 相比零样本,F1提升21.3个百分点(0.712→0.925),token成本涨2.6倍,但绝对成本仍然很低——单条平均约1590 token,按gpt-4o-mini定价算,120条也就几分钱。这个投入产出比是划算的。
  2. CoT在这个任务上是负收益。 F1只比少样本高1.8个点,token却多了26,000(+13.6%),解析失败率还更高。抽取类任务字段边界清晰,不需要推理链条,CoT更适合多步推理场景。
  3. Schema约束的真正价值不在F1,在稳定性。 P4的F1和P2几乎持平(0.938 vs 0.925),但解析失败率从3.1%降到0.6%,下游不用再写一堆兜底正则。生产环境里,0.6%的失败率意味着人工复核量从每千条31条降到6条,这才是省下来的真金白银。
  4. 两个模型差距不大。 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 落到工程上,本质就是版本管理和回归测试,和写代码没什么两样。