1. 问题背景:为什么我非要跟Prompt死磕
上周接了个活儿,要给某央企做历史合同结构化。3000份PDF转成文本后,需要抽取“付款条件”“违约金比例”“知识产权归属”三个关键字段。老板给的选择:微调ERNIE 3.5(训练费预估2万+,工期两周)或者用Prompt硬刚。
我选了后者。原因很简单:合同文本领域差异太大,微调模型换个客户就废。但Prompt方案如果搞不定,F1低于0.7,业务那边直接不验收。
实验环境:百度智能云千帆平台,ERNIE 4.0-8K(2024年3月版本),Python SDK 0.2.11,单次请求max_tokens=1024,temperature=0.1(注意:抽取任务必须低随机性)。
2. 环境与版本:别再用默认参数,这是第一个坑
先看我的初始调用代码,这是所有悲剧的起点:
from qianfan import ChatCompletion
import json
# 千帆SDK 0.2.11,ERNIE 4.0-8K (2024-03-15部署版本)
client = ChatCompletion(model="ernie-4.0-8k", endpoint="https://qianfan.baidubce.com/v2")
def extract_contract(text, prompt_style="v1"):
prompt = build_prompt(text, prompt_style) # 不同版本Prompt构造
resp = client.create(
messages=[{"role": "user", "content": prompt}],
temperature=0.1, # 必须低,否则输出随机性大到无法解析
top_p=0.9, # 配合temperature,别用默认的1.0
penalty_score=1.0, # 默认是1.0,别改,改了会重复
system="你是一个法律文档信息抽取助手。", # v1版本没有system
max_output_tokens=1024
)
return resp["body"]["result"], resp["usage"]["total_tokens"]
血泪教训:SDK默认temperature=0.95,top_p=1.0。如果你直接跑抽取任务,输出里会出现“嗯,让我们看看”“根据我的理解”这种废话,而且同样的文本跑两次结果不一样。必须手动压到0.1。
3. 方案设计:四版Prompt迭代路线
我设计了四个版本,每个版本解决上一个版本的明确缺陷:
- v1(裸奔版):只有一句“抽取以下合同中的付款条件、违约金比例、知识产权归属”。测试结果:模型把“付款条件”理解成“付款方式”,输出了一堆“银行转账”“支票”这种废话。
- v2(角色+输出格式版):加入“你是资深法律知识工程师”+“输出JSON格式,key必须是payment_terms/penalty_ratio/ip_ownership”。测试结果:格式对了,但抽取内容不完整,违约金比例经常漏掉百分号。
- v3(v2+否定指令版):加入“不要输出解释,不要输出额外字段,不要输出合同原文”。测试结果:F1反而掉了5%,模型开始漏抽关键信息。
- v4(v3+示例驱动版):加入一个完整的最小可验证示例(few-shot),包含输入片段和期望输出。测试结果:F1 0.85,token下降37%。
4. 核心实现:v4版本完整代码(含踩坑修复)
直接上最终版Prompt构造代码。这里有个关键细节:我用了和这种自定义分隔符,这比“根据以下内容”这种自然语言描述更稳定——实测分隔符风格能将格式错误率从11.2%降到3.8%。
def build_prompt(text: str, version: str = "v4") -> str:
if version == "v1":
return f"抽取以下合同中的付款条件、违约金比例、知识产权归属:\n{text}"
if version == "v4":
# few-shot示例用了真实合同片段,但做了脱敏处理
example_input = """甲方应于收到乙方发票后30日内支付合同总额的90%,剩余10%作为质保金,质保期满后7日内无息支付。若甲方逾期付款,每逾期一日,按未付金额的0.05%支付违约金。本合同项下软件著作权归乙方所有。"""
example_output = """{"payment_terms": "收到发票后30日内支付90%,剩余10%质保金在质保期满后7日内支付", "penalty_ratio": "0.05%/日", "ip_ownership": "乙方"}"""
return f"""请从中抽取指定信息,严格按返回JSON。
[角色] 你是有10年经验的合同审查律师,擅长条款拆解和指标准确提取。
[任务] 抽取三个字段:付款条件(payment_terms)、违约金比例(penalty_ratio)、知识产权归属(ip_ownership)。
[规则]
1. 金额保留数字和单位,比例必须带%或明确单位。
2. 如果原文未提及某字段,输出null,不要编造。
3. 只输出JSON对象,不要包含```json标记,不要有任何解释文字。
4. 抽取粒度:违约金只抽取比例数值,不要抽取计算方式。
[示例]
输入:{example_input}
输出:{example_output}
[合同原文]
{text}
[输出格式]
{{"payment_terms": "付款时间+付款比例+支付条件", "penalty_ratio": "百分比或具体数值", "ip_ownership": "归属方"}}
"""
调用方式和结果解析也有坑。模型偶尔会输出BOM头或多余换行,我用了一个正则清洗:
import re, json
def parse_result(raw_text: str) -> dict:
# 清洗模型输出:去掉BOM、多余空行、可能的markdown标记
cleaned = raw_text.strip().lstrip('\ufeff').strip()
cleaned = re.sub(r'^```json\s*|\s*```$', '', cleaned).strip()
# 有些时候模型会输出"以下是JSON:"这种前缀
if cleaned.startswith('{') is False:
json_start = cleaned.find('{')
cleaned = cleaned[json_start:]
return json.loads(cleaned)
5. 踩坑与优化:否定指令的迷惑行为
这是我最想骂人的部分。v3加了“不要输出解释”后,模型开始把“违约金比例”直接输出为"违约金比例": "0.05%"而不是"penalty_ratio"——它把“不要”理解成了“不要按JSON格式来”?后来我查了ERNIE 4.0的技术文档,发现它对否定指令的处理方式是降低整个指令域的置信度,而不是精确屏蔽某个行为。
解决方案:把否定指令改成肯定指令。v4中我彻底删掉了所有“不要”,改为“只输出JSON对象”+“如果未提及输出null”。效果立竿见影。
另一个优化是动态截断。ERNIE 4.0-8K的上下文窗口虽然是8K,但合同文本经常超过4000字。如果硬塞,模型会丢失开头内容(注意力漂移)。我用了滑动窗口:先让模型抽取每500字的局部片段,再合并去重。但这会带来额外的token消耗,所以我只对超过3500字的文档启用。
6. 效果数据:四版对比与成本核算
直接上实测数据(测试集:真实合同50份,人工标注答案,评估工具为seqeval库):
| 版本 | 平均F1 | 平均token/请求 | 格式错误率 | 抽取延迟(ms) |
|---|---|---|---|---|
| v1裸奔 | 0.63 | 4821 | 23.4% | 1850 |
| v2角色+格式 | 0.71 | 5033 | 8.2% | 1920 |
| v3否定指令 | 0.66 | 5102 | 15.7% | 1880 |
| v4示例驱动 | 0.85 | 3052 | 3.8% | 1530 |
token消耗计算:v4比v1省了37%,主要原因是我在few-shot示例中用了短句(示例输入只有60字),而v1的Prompt虽然短,但模型因为理解偏差会反复“思考”输出额外内容。千帆按token计费,ERNIE 4.0是0.03元/千token,单份合同从0.14元降到0.09元,3000份合同省了150元。虽然不多,但积少成多。
质量对比:v1的典型错误——把“付款条件”抽成“付款方式为电汇”;v4的成功案例——penalty_ratio从“每逾期一日,按合同总价款的万分之五支付违约金”中准确抽出0.05%/日,这个比例在v2版本中会被误抽成万分之五(没换算成百分比)。
7. 总结与建议
- 永远先试Prompt,再谈微调。v4版本的效果已经接近专业标注员(0.85 vs 0.88),成本几乎为零。
- 否定指令是双刃剑。ERNIE 4.0对“不要X”的处理是降低相关权重,可能连正确内容一起丢。优先用“只输出Y”来替代。
- few-shot示例要选“边缘案例”。我选的那个示例包含“质保金”和“违约金”同时出现的情况,这比选一个简单例子更能约束模型行为。
- token消耗看的是总输出。v1虽然Prompt短,但模型生成的废话多;v4 Prompt长,但输出稳定且短。算总账v4更划算。
最后留个问题:如果合同里出现“付款条件:见补充协议”这种引用,你的Prompt能处理吗?我目前的做法是加一条规则“如果字段值为空或指向其他文件,输出null并单独标记”。这又是另一个故事了。