一、问题背景:为什么我要做这件事

上个月接手一个合同信息抽取服务,输入是一段非结构化的中文合同文本,输出是固定Schema的JSON:合同编号、甲方、乙方、签订日期、金额、违约条款摘要。业务方给的验收线是字段级F1 ≥ 0.90。

第一版我随手写了个Prompt,跑完测试集F1只有0.68,而且输出经常带markdown代码块、字段名大小写不一致、金额把“人民币”三个字也带进去了。团队里有人说“加点few-shot就好了”,有人说“换Claude”,还有人说“上微调”。但没人说得清:加几个例子最划算?CoT到底有没有用?结构化输出约束会不会降低语义准确率?

于是我决定不做拍脑袋优化,而是设计一组可控对照实验,把Prompt Engineering从“玄学”变成“可测量的工程”。这篇文章就是实验记录。

二、环境与版本

先把可复现性交代清楚,不然数据没意义。

  • 模型:gpt-4o-2024-08-06(主实验)、claude-3-5-sonnet-20241022(交叉验证)
  • SDK:openai==1.51.0、anthropic==0.34.2
  • 运行时:Python 3.11.7,单机,串行调用(避免并发限流干扰延迟统计)
  • 参数基线:temperature=0、top_p=1、max_tokens=1024、response_format={"type":"json_object"}(仅OpenAI侧)
  • 测试集:120条真实合同片段(已脱敏),人工标注金标准,字段级精确匹配计分
  • 计费口径:按官方单价折算成美元,Token数取自API返回的usage字段,不用tiktoken估算

这里有个细节值得说:temperature=0并不保证完全确定性,OpenAI官方文档也承认存在微小非确定性。所以我每个配置跑3次取平均,减少单次抖动。

三、方案设计:四类策略、47个变体

我把Prompt拆成四个可独立开关的维度,做正交组合:

  1. 角色与任务描述:一句话版 vs 分点版(明确输入输出边界)
  2. 示例数量:0 / 1 / 3 / 5 / 8 个few-shot
  3. 推理引导:无 / 轻量CoT(“先定位原文片段再填字段”)/ 完整CoT(分步推理链)
  4. 输出约束:纯文本要求 / JSON Mode / JSON Schema + 枚举校验

组合后去掉明显冗余,最终得到47个有效变体。评价指标三个:字段级F1、单条平均Token(输入+输出)、单条平均延迟。

四、核心实现

先看评测主循环。关键在于:Prompt模板参数化、usage逐条落盘、评分函数严格按字段精确匹配。

import json, time, statistics
from openai import OpenAI

client = OpenAI(api_key="sk-xxx")

SCHEMA_FIELDS = ["contract_no", "party_a", "party_b", "sign_date", "amount", "breach_summary"]

def build_prompt(text: str, n_shots: int, cot: str, strict: bool) -> list[dict]:
    system = "你是合同信息抽取引擎。只输出JSON,不要输出任何解释性文字。"
    if strict:
        system += (
            "\n输出必须严格符合以下Schema:"
            '{"contract_no":str,"party_a":str,"party_b":str,'
            '"sign_date":"YYYY-MM-DD","amount":number,"breach_summary":str}'
            "\n缺失字段填null,金额只保留数字,日期统一为YYYY-MM-DD。"
        )
    msgs = [{"role": "system", "content": system}]
    for ex in FEW_SHOT_POOL[:n_shots]:
        msgs.append({"role": "user", "content": ex["text"]})
        msgs.append({"role": "assistant", "content": json.dumps(ex["label"], ensure_ascii=False)})
    if cot == "light":
        text = "请先在心中定位每个字段对应的原文片段,再填写JSON。\n\n" + text
    elif cot == "full":
        text = ("请按步骤处理:1) 通读全文;2) 逐字段摘录原文证据;"
                "3) 归一化格式(日期、金额);4) 输出最终JSON。\n\n" + text)
    msgs.append({"role": "user", "content": text})
    return msgs

def run_one(text: str, cfg: dict) -> dict:
    t0 = time.time()
    resp = client.chat.completions.create(
        model="gpt-4o-2024-08-06",
        messages=build_prompt(text, cfg["n_shots"], cfg["cot"], cfg["strict"]),
        temperature=0,
        max_tokens=1024,
        response_format={"type": "json_object"} if cfg["json_mode"] else None,
    )
    latency = time.time() - t0
    raw = resp.choices[0].message.content
    try:
        pred = json.loads(raw)
    except json.JSONDecodeError:
        pred = {}
    return {
        "pred": pred,
        "prompt_tokens": resp.usage.prompt_tokens,
        "completion_tokens": resp.usage.completion_tokens,
        "latency": latency,
    }

评分函数我用的是字段级严格匹配,字符串字段做去空格和全半角归一化,金额允许±0.01误差:

def field_f1(pred: dict, gold: dict) -> float:
    tp = fp = fn = 0
    for f in SCHEMA_FIELDS:
        p, g = pred.get(f), gold.get(f)
        if f == "amount" and isinstance(p, (int, float)) and isinstance(g, (int, float)):
            ok = abs(p - g) <= 0.01
        elif isinstance(p, str) and isinstance(g, str):
            ok = p.strip().replace(",", ",") == g.strip().replace(",", ",")
        else:
            ok = p == g
        if ok: tp += 1
        elif p is not None: fp += 1
        else: fn += 1
    prec = tp / (tp + fp) if tp + fp else 0
    rec = tp / (tp + fn) if tp + fn else 0
    return 2 * prec * rec / (prec + rec) if prec + rec else 0

跑批时每个配置3次、每次120条,落盘成CSV。下面这张表是我从结果里摘的核心对比(数值为3次平均):

配置 Few-shot CoT 严格Schema F1 平均Prompt Token 平均Completion Token 平均延迟
A 基线 0 无 否 0.713 412 118 1.9s
B 3 无 否 0.824 1,286 121 2.6s
C 8 无 否 0.841 2,914 119 4.1s
D 0 轻量 否 0.886 534 236 2.4s
E 0 轻量 是 0.902 618 241 2.5s
F 3 轻量 是 0.917 1,492 244 3.2s
G 3 完整 是 0.908 1,531 612 3.6s

一眼能看出的结论:CoT的边际收益远高于堆few-shot。从A到D,Token只多了约122,F1涨了17.3个点;而从B到C,多塞5个例子、多烧1,628 Token,只换来1.7个点。完整CoT(G)反而比轻量CoT(F)低,因为分步推理把输出拉长到612 Token,中间步骤偶发“自我纠偏”把已正确的字段改错了。

五、踩坑与优化

坑一:JSON Mode不等于Schema约束。 我一开始以为开了response_format={"type":"json_object"}就万事大吉,结果模型仍然会输出"amount": "人民币50万元"这种字符串。JSON Mode只保证语法合法,不保证语义类型。真正的类型约束要靠Prompt里写死Schema + 后处理校验。上表E和D的差距(0.886→0.902)主要就来自这里,而不是模型变聪明了。

坑二:few-shot示例的顺序有影响。 我把8个示例随机打乱跑了5组,F1在0.812~0.849之间波动,最大差值3.7个点。后来固定为“按合同类型分组、简单样例在前”,方差才收窄到1个点以内。示例不是越多越好,分布代表性比数量重要。

坑三:max_tokens设太小会静默截断。 完整CoT配置下我把max_tokens设成256试了一次,输出被截断成半截JSON,json.loads直接抛异常,那一批12条全部记0分。后来统一设1024,并在解析失败时做一次带“请只输出JSON”的修复重试,重试率从6.7%降到0.8%。

坑四:Claude侧的差异。 交叉验证时,claude-3-5-sonnet-20241022在配置F上F1是0.921,略高于GPT-4o的0.917,但平均延迟高约0.4s,且它的Prompt Caching需要显式加cache_control才能命中,我最初忘了加,长Prompt场景成本比预期高30%。加上缓存后,配置F的重复调用成本下降约62%。

坑五:别用tiktoken估算成本。 我早期用tiktoken算Token,和API返回的usage.prompt_tokens对不上,差最多到8%。原因是system message和内部格式化有额外开销。做成本决策必须用真实usage。

六、效果数据与最终选型

最终我上线的是配置F的改良版:3个few-shot(覆盖采购、服务、劳动三类合同)+ 轻量CoT + 严格Schema + 解析失败重试。

  • 测试集F1:0.917(验收线0.90,达标)
  • 单条平均Token:输入1,492 + 输出244 ≈ 1,736
  • 单条平均延迟:3.2s(P95为5.8s)
  • 相比基线A,F1提升20.4个点,单条成本增加约3.2倍
  • 相比配置C(8-shot),F1高7.6个点,Token反而少41%

如果业务对成本极度敏感,我会退到配置D(0-shot + 轻量CoT + 严格Schema),F1 0.886,Token只有618,成本约为F的36%。这就是工程取舍:先用CoT和结构化约束榨干零样本的潜力,再决定要不要为最后几个点付few-shot的钱。

七、总结

这47组实验给我最实在的三条经验:

第一,Prompt优化要先量化再动手。凭感觉加示例、加“请你仔细思考”,很可能是在花几倍成本买一两个点。

第二,CoT的性价比在中小任务上被严重低估。一句“先定位证据再填字段”,比五个精心挑选的示例更有效,因为它改变的是模型的解题路径,而不是给它更多记忆负担。

第三,输出约束是独立于语义能力的工程层。JSON Mode、Schema、后处理校验解决的是“格式可用性”,它能把F1往上抬1~2个点,但别指望它救回语义错误。

最后提醒一句:以上数字绑定gpt-4o-2024-08-06和我的测试集,换模型、换领域都要重跑。Prompt Engineering没有银弹,只有可复现的实验记录。代码和CSV我整理成了一个脚本,改改FEW_SHOT_POOL就能在你的数据上复现。