一、问题背景:一个看似简单的抽取任务,翻车了

事情起因很简单。我在做一个合同管理系统,需要从采购合同文本里抽取结构化字段:合同编号、甲方、乙方、签订日期、合同金额、付款方式、违约金比例。输入是几百字的合同片段,输出是 JSON。

一开始我觉得这活儿太简单了,直接丢给 GPT-4o 就行。Prompt 大概是这样的:

从下面的合同文本中抽取信息,输出JSON:
{contract_text}

跑了一百条测试集,人工核对后准确率只有 61.3%。问题集中在几类:

  • 日期格式混乱,有的输出 2024年3月1日,有的输出 2024-03-01
  • 金额带单位,¥1,200,0001200000 混着来
  • 违约金比例有时候抽的是"日万分之五",有时候抽的是"0.05%"
  • 最要命的是字段缺失时,模型会编造一个看起来合理的值

这就是典型的 Prompt Engineering 问题:不是模型不行,是我没说清楚。于是我决定系统性地做一次对照实验。

二、环境与版本

先交代清楚实验环境,避免有人说结果不可复现:

  • 模型:gpt-4o-2024-08-06
  • SDK:openai==1.40.0
  • Python:3.11.6
  • 参数:temperature=0top_p=1max_tokens=1024response_format 按方案不同
  • 测试集:120 条真实合同片段,人工标注了 gold label
  • 评测:字段级 exact match,6 个字段全对才算整条正确

成本核算按 OpenAI 官方定价:输入 $2.5 / 1M tokens,输出 $10 / 1M tokens。

三、方案设计:三种 Prompt 粒度

我设计了三个版本,控制变量,只改 Prompt:

方案 A:朴素指令(Baseline)
就是上面那个,一句话描述任务。

方案 B:Few-shot + 字段规范
给 3 个示例,并且明确每个字段的类型、格式、缺失时的处理方式(统一填 null)。

方案 C:Schema 约束 + 分步推理(CoT)
在 B 的基础上,要求模型先输出推理过程(哪些字段找到了、依据是什么),再输出 JSON;同时用 JSON Schema 描述字段,并开启 response_format={"type": "json_object"}

四、核心实现

先写一个统一的调用与评测框架:

import json
import time
from openai import OpenAI
from typing import Callable

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

FIELDS = ["contract_no", "party_a", "party_b",
          "sign_date", "amount", "penalty_rate"]

def call_llm(prompt: str, use_json_mode: bool = False) -> dict:
    kwargs = dict(
        model="gpt-4o-2024-08-06",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
        top_p=1,
        max_tokens=1024,
    )
    if use_json_mode:
        kwargs["response_format"] = {"type": "json_object"}

    t0 = time.time()
    resp = client.chat.completions.create(**kwargs)
    latency = time.time() - t0

    return {
        "content": resp.choices[0].message.content,
        "prompt_tokens": resp.usage.prompt_tokens,
        "completion_tokens": resp.usage.completion_tokens,
        "latency": latency,
    }

def evaluate(predict_fn: Callable[[str], dict], testset: list) -> dict:
    total, correct = 0, 0
    total_prompt_tok, total_completion_tok = 0, 0
    field_hit = {f: 0 for f in FIELDS}

    for item in testset:
        text, gold = item["text"], item["label"]
        result = predict_fn(text)
        total += 1
        total_prompt_tok += result["prompt_tokens"]
        total_completion_tok += result["completion_tokens"]

        try:
            pred = json.loads(result["content"])
        except json.JSONDecodeError:
            continue

        all_ok = True
        for f in FIELDS:
            if str(pred.get(f)).strip() == str(gold.get(f)).strip():
                field_hit[f] += 1
            else:
                all_ok = False
        if all_ok:
            correct += 1

    n = len(testset)
    return {
        "accuracy": round(correct / n, 4),
        "field_acc": {f: round(hit / n, 4) for f, hit in field_hit.items()},
        "avg_prompt_tokens": round(total_prompt_tok / n, 1),
        "avg_completion_tokens": round(total_completion_tok / n, 1),
    }

然后是三个方案的 Prompt 构造函数:

FIELD_SPEC = """
字段规范:
- contract_no: 字符串,合同编号,原样保留不含"合同编号:"前缀
- party_a: 字符串,甲方全称
- party_b: 字符串,乙方全称
- sign_date: 字符串,格式必须为 YYYY-MM-DD,无法确定填 null
- amount: 数字,单位元,去掉货币符号和千分位逗号
- penalty_rate: 字符串,违约金比例,统一转为"日万分之X"或"合同总额的X%"
"""

def prompt_a(text: str) -> str:
    return f"从下面的合同文本中抽取信息,输出JSON:\n{text}"

def prompt_b(text: str, examples: list) -> str:
    shots = "\n\n".join(
        f"输入:\n{ex['text']}\n输出:\n{json.dumps(ex['label'], ensure_ascii=False)}"
        for ex in examples
    )
    return (
        f"你是一个合同信息抽取助手。请严格按字段规范抽取信息,"
        f"未出现的字段填 null,不要编造。\n{FIELD_SPEC}\n\n"
        f"示例:\n{shots}\n\n"
        f"现在抽取:\n{text}\n只输出JSON。"
    )

def prompt_c(text: str, examples: list) -> str:
    base = prompt_b(text, examples)
    return base + (
        "\n\n请先用一句话说明每个字段的抽取依据(找不到的写'未提及'),"
        "然后再输出最终JSON。输出格式:\n"
        '{"reasoning": "...", "result": {...}}'
    )

方案 C 的输出需要从 result 字段里取,评测时稍微包装一下即可。

五、踩坑与优化

实验过程中踩了几个坑,值得单独说:

坑 1:temperature=0 不等于完全确定。
同一输入多次调用,方案 A 偶尔还是会给出不同格式。开了 response_format=json_object 之后稳定很多,但注意这个参数要求 Prompt 里必须出现 "JSON" 字样,否则会报 400。我第一次就栽在这。

坑 2:Few-shot 示例的格式会"污染"输出。
我一开始示例里的金额写成 1200000(数字),结果模型遇到带小数的情况也硬要转成整数。后来把示例改成 1200000.00 才好转。示例即契约,这句话是真的。

坑 3:CoT 让输出 Token 暴涨。
方案 C 的 reasoning 字段平均占了 180 个 completion token,但准确率提升显著,尤其是 penalty_rate 这个最难字段。这里有个取舍:如果只做批处理、不在意延迟,CoT 很值;如果是实时接口,得掂量。

坑 4:max_tokens=1024 在长合同上会截断。
有几条 800 字以上的合同,方案 C 输出被截断导致 JSON 解析失败。后来调到 2048 才解决,但成本又上去了。建议按实际文本长度动态设置。

六、效果数据

120 条测试集,三种方案对比:

方案 整条准确率 contract_no sign_date amount penalty_rate 平均输入Token 平均输出Token 单条成本
A 朴素 61.3% 92.5% 74.2% 80.8% 55.0% 186 32 $0.00079
B Few-shot 86.7% 99.2% 95.8% 97.5% 78.3% 624 38 $0.00194
C Schema+CoT 94.0% 99.2% 98.3% 99.2% 90.8% 651 221 $0.00384

几个观察:

  1. Few-shot 的性价比最高。从 61.3% 到 86.7%,成本只涨了 2.5 倍,但准确率涨了 25 个百分点。
  2. CoT 主要救的是难字段penalty_rate 从 78.3% 到 90.8%,是提升最大的字段,其他字段边际收益已经很小。
  3. Token 消耗和准确率不是线性的。方案 C 比 B 多花了近一倍成本(输出 token 从 38 涨到 221),只换来 7.3 个百分点的提升。

最终我上线的是 方案 B+:在 B 的基础上,只对 penalty_rate 字段追加一句"若涉及违约金,请先复述原文再判定"。整条准确率 91.7%,单条成本 $0.0021,比方案 C 省了 45% 的钱,只损失 2.3 个百分点。

七、总结

这轮实验给我几个明确结论:

  • Prompt 的边际收益递减非常明显。从 0 到 1 的规范描述收益最大,从 1 到 10 的 CoT 收益开始变贵。
  • 一定要量化对比。凭感觉调 Prompt 很容易陷入"感觉变好了"的幻觉,跑个 120 条测试集,准确率和 Token 一摆出来,取舍立刻清晰。
  • 成本敏感场景下,混合策略是最优解。不是所有字段都值得上 CoT,把贵的技巧用在最难的那 1-2 个字段上,性价比最高。
  • response_format=json_object 是生产必备,能省掉大量解析失败的兜底逻辑,但记得 Prompt 里带上 "JSON" 关键词。

Prompt Engineering 不是玄学,它就是一个可以用数据说话、可以用成本衡量的工程问题。希望这篇实验记录能帮到正在调 Prompt 的你。