一、问题背景:为什么一个“抽取”任务值得反复调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里,模型只输出最原始的字符串。
如果你也在做结构化抽取,我的建议是:
- 先用Schema或极简指令跑一版baseline,别一上来就Few-shot。
- 统计每个字段的错误类型,能代码归一化的绝不交给模型。
- 用
count_tokens实测,别凭感觉估。 - temperature=0,max_tokens给够,但别太大,避免模型“自由发挥”。
最终P6方案上线后,单条成本从约$0.0036降到$0.0011,按每天1.2万条算,一个月省下约$900。准确率从78%提到90.3%,后处理代码从300多行降到80行。这大概是我今年做过ROI最高的一次Prompt优化。