一、问题背景:为什么一个“抽取”任务值得反复调Prompt

事情本身不复杂:我们有一个合同管理系统,用户上传PDF后,后端会解析成纯文本,然后调用大模型抽取8个字段:

  • party_a(甲方)
  • party_b(乙方)
  • amount(合同金额,只要数字)
  • sign_date(签署日期,YYYY-MM-DD)
  • effective_date(生效日期)
  • contract_no(合同编号)
  • term(合同期限,月)
  • breach_clause(是否含违约金条款,布尔)

听起来像是NER任务,但合同文本格式极度不统一:有的金额写“人民币壹佰贰拾万元整”,有的写“¥1,200,000.00”,有的藏在表格里。早期我们直接上了一个“万能Prompt”,结果就是:字段能抽出来,但格式乱、金额带单位、日期有中文、布尔值返回“是/否/True”混杂,后处理代码写了一堆if-else,维护成本极高。

更麻烦的是成本。我们每天大约处理1.2万份合同,如果单次1200 tokens,按Claude 3.5 Sonnet输入$3/M tokens计算,一天光是输入就$43,一个月$1300,还不算输出。所以这次优化的目标很明确:在保证准确率的前提下,把Prompt压到最短。

二、环境与版本

  • 模型:Claude 3.5 Sonnet(claude-3-5-sonnet-20241022)
  • SDK:anthropic 0.39.0
  • Python:3.11.9
  • 测试集:200条真实合同文本,人工标注8字段作为ground truth
  • 评测脚本:字段级完全匹配(exact match),金额和日期做归一化后比较
  • 温度:temperature=0,max_tokens=512
  • 调用方式:messages API,单轮

我对比了6版Prompt,分别记为P1~P6。下面按迭代顺序讲。

三、方案设计:从“说明书”到“约束式模板”

P1:自然语言说明书版

最早的版本就是一段话:

P1 = """你是一个合同信息抽取助手。请从下面的合同文本中提取甲方、乙方、合同金额、签署日期、生效日期、合同编号、合同期限、是否含违约金条款。
合同文本:
{text}
请以JSON格式返回。"""

问题很明显:模型不知道“合同金额”要不要带单位,日期格式是什么,布尔值怎么表示。输出经常是:

{"amount": "人民币壹佰贰拾万元整", "sign_date": "二〇二三年五月十日", "breach_clause": "是"}

准确率78%,但后处理几乎要重写一遍。

P2:加字段说明

我给每个字段加了注释:

P2 = """从合同文本中抽取以下字段,严格按说明输出:
- party_a: 甲方名称,字符串
- party_b: 乙方名称,字符串
- amount: 合同金额,只返回数字,单位元,不带千分位
- sign_date: 签署日期,格式YYYY-MM-DD
- effective_date: 生效日期,格式YYYY-MM-DD,若无则null
- contract_no: 合同编号,字符串
- term: 合同期限,单位月,整数
- breach_clause: 是否含违约金条款,布尔值true/false

合同文本:
{text}

只返回JSON,不要解释。"""

准确率提到84%,但token涨到约950。原因是字段说明本身占了不少token,而且每次都要重复。

P3:Few-shot 示例版

我加了两个示例,想用“示范”替代“说明”:

P3 = """示例1:
文本:甲方:北京XX科技有限公司;乙方:上海YY信息有限公司;合同金额:人民币50万元;签署日期:2023年1月5日;合同编号:HT-2023-001;期限:12个月;含违约金条款。
输出:{"party_a":"北京XX科技有限公司","party_b":"上海YY信息有限公司","amount":500000,"sign_date":"2023-01-05","effective_date":null,"contract_no":"HT-2023-001","term":12,"breach_clause":true}

示例2:
...

合同文本:
{text}
只返回JSON。"""

准确率85.5%,但token直接飙到1200+。而且示例里的金额是“50万元”,模型有时会照抄“500000”的写法,遇到“壹佰贰拾万”反而不会转。Few-shot在这个任务上性价比很低。

P4:JSON Schema约束版

我换了个思路:不解释字段,直接给JSON Schema,让模型“填空”:

P4 = """请从合同文本中抽取信息,输出必须符合以下JSON Schema:
{
  "type": "object",
  "properties": {
    "party_a": {"type": "string"},
    "party_b": {"type": "string"},
    "amount": {"type": "number"},
    "sign_date": {"type": "string", "pattern": "^\\\\d{4}-\\\\d{2}-\\\\d{2}$"},
    "effective_date": {"type": ["string", "null"]},
    "contract_no": {"type": "string"},
    "term": {"type": "integer"},
    "breach_clause": {"type": "boolean"}
  },
  "required": ["party_a","party_b","amount","sign_date","contract_no","term","breach_clause"]
}

合同文本:
{text}
"""

这一版token约780,准确率86%。Schema比自然语言说明更紧凑,但pattern对模型来说约束力有限,日期偶尔还是返回“2023/1/5”。

P5:极简指令 + 输出契约

我开始做减法。核心思路是:字段名本身已经足够表意,不需要解释;格式要求用一行“输出契约”统一约束:

P5 = """抽取合同字段,输出JSON。amount为数字(元),日期为YYYY-MM-DD,term为整数(月),breach_clause为布尔值,缺失字段用null。

合同文本:
{text}
"""

token降到约420,准确率88%。这个版本已经接近可用,但金额中文大写转换仍有约7%的错误。

P6:极简指令 + 预处理归一化

最终版把“中文金额”和“中文日期”的归一化从模型侧挪到了代码侧。Prompt只负责“定位”,不负责“换算”:

P6 = """抽取合同字段,输出JSON。字段:party_a, party_b, amount, sign_date, effective_date, contract_no, term, breach_clause。缺失用null。

合同文本:
{text}
"""

配合一个预处理函数,把“壹佰贰拾万元整”先转成“1200000”,把“二〇二三年五月十日”转成“2023-05-10”,再喂给模型。token进一步降到约380,准确率反而升到90.3%。

四、核心实现:可运行的评测代码

下面是我实际用的评测脚本,包含Prompt模板、调用和字段级对比。你可以直接替换ANTHROPIC_API_KEY运行。

import os
import json
import re
from anthropic import Anthropic

client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])

PROMPT_P6 = """抽取合同字段,输出JSON。字段:party_a, party_b, amount, sign_date, effective_date, contract_no, term, breach_clause。缺失用null。

合同文本:
{text}
"""

def normalize_amount(text: str) -> str:
    """把中文大写金额转成数字字符串,简单版"""
    cn_num = {"壹":"1","贰":"2","叁":"3","肆":"4","伍":"5","陆":"6","柒":"7","捌":"8","玖":"9","零":"0"}
    # 实际项目用更完整的转换,这里仅示意
    for k, v in cn_num.items():
        text = text.replace(k, v)
    return text

def extract(text: str) -> dict:
    text = normalize_amount(text)
    resp = client.messages.create(
        model="claude-3-5-sonnet-20241022",
        max_tokens=512,
        temperature=0,
        messages=[{"role": "user", "content": PROMPT_P6.format(text=text)}]
    )
    raw = resp.content[0].text.strip()
    # 去掉可能的markdown代码块
    raw = re.sub(r"^```json|```$", "", raw, flags=re.MULTILINE).strip()
    return json.loads(raw)

def exact_match(pred: dict, gold: dict, fields: list) -> float:
    hit = 0
    for f in fields:
        p, g = pred.get(f), gold.get(f)
        if isinstance(p, str) and isinstance(g, str):
            p, g = p.strip(), g.strip()
        if p == g:
            hit += 1
    return hit / len(fields)

if __name__ == "__main__":
    fields = ["party_a","party_b","amount","sign_date","effective_date","contract_no","term","breach_clause"]
    with open("testset.json", encoding="utf-8") as f:
        data = json.load(f)  # [{"text": "...", "gold": {...}}, ...]
    total = 0.0
    for item in data:
        pred = extract(item["text"])
        total += exact_match(pred, item["gold"], fields)
    print(f"字段级准确率: {total/len(data):.3f}")

另一个是Token统计脚本,用来对比不同Prompt的消耗:

import tiktoken  # 注意:Claude不直接用tiktoken,这里用近似估算
from anthropic import Anthropic

client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"])

def count_tokens(prompt: str) -> int:
    # 实际项目建议用 anthropic.count_tokens API
    return client.count_tokens(prompt)

prompts = {"P1": P1, "P2": P2, "P3": P3, "P4": P4, "P5": P5, "P6": P6}
sample_text = open("sample_contract.txt", encoding="utf-8").read()

for name, tpl in prompts.items():
    full = tpl.format(text=sample_text)
    print(f"{name}: {count_tokens(full)} tokens")

实测结果(200条平均):

Prompt 平均输入tokens 字段准确率 金额错误率 日期格式错误率
P1 1210 78.0% 19% 22%
P2 950 84.0% 12% 9%
P3 1240 85.5% 11% 7%
P4 780 86.0% 10% 8%
P5 420 88.0% 7% 4%
P6 380 90.3% 3% 2%

五、踩坑与优化

第一个坑是“模型会自作聪明补全”。合同里没写生效日期,P1~P4经常返回签署日期作为生效日期。P5开始明确写“缺失用null”,这个问题才压下去。

第二个坑是中文金额。我一开始指望模型直接转“壹佰贰拾万元整”,结果它有时返回1200000,有时返回120万,有时返回“1200000元”。后来用代码预处理,模型只负责抽“1200000”这个字符串,准确率立刻稳定。

第三个坑是markdown包裹。Claude 3.5 Sonnet在temperature=0时仍有约5%概率返回json ...。必须用正则清洗,否则json.loads直接崩。

第四个坑是字段名。早期我用“甲方”“乙方”做key,后处理还要映射。后来统一用英文key,代码干净很多。

六、总结

这次迭代最大的收获是:Prompt Engineering不是“写得更详细”,而是“把模型不擅长的事交给代码”。P6之所以又短又准,是因为它只做“定位”,不做“换算”。金额归一化、日期归一化、布尔值映射,全部前置或后置到Python里,模型只输出最原始的字符串。

如果你也在做结构化抽取,我的建议是:

  1. 先用Schema或极简指令跑一版baseline,别一上来就Few-shot。
  2. 统计每个字段的错误类型,能代码归一化的绝不交给模型。
  3. count_tokens实测,别凭感觉估。
  4. temperature=0,max_tokens给够,但别太大,避免模型“自由发挥”。

最终P6方案上线后,单条成本从约$0.0036降到$0.0011,按每天1.2万条算,一个月省下约$900。准确率从78%提到90.3%,后处理代码从300多行降到80行。这大概是我今年做过ROI最高的一次Prompt优化。