一、问题背景:当BERT被Prompt按在地上摩擦
三个月前接手一个法律文书命名实体识别(NER)项目,需求是从十万份判决书中抽取当事人、案由、金额。初版用fine-tune的RoBERTa-wwm-ext,F1值0.92,一切岁月静好。直到产品经理要求支持“未登录罪名”动态添加——每季度新增20+罪名,重训一次模型要烧掉4张A100跑6小时。
于是转向LLM方案。用GPT-3.5-turbo-0301做zero-shot抽取,结果惨不忍睹:F1值仅0.37,且对“有期徒刑三年缓刑五年”这类复合刑期,抽取结果时好时坏。痛定思痛,决定系统性研究Prompt设计——这才发现,看似简单的提示词工程,水比想象中深得多。
二、环境与版本:一套可复现的基线
实验固定以下配置,避免变量干扰:
openai.__version__ = '0.28.1'
model = 'gpt-3.5-turbo-0301'
temperature = 0 # 必须为零,保证可复现
max_tokens = 512
评估集:500条手工标注的法律文书片段,包含6类实体(当事人/律所/法官/案由/金额/刑期),每条平均实体数4.2个。评估脚本用seqeval库,严格匹配(含边界与类别)。
三、方案设计:四维Prompt变量矩阵
我拆解出4个关键变量,每个变量3种取值,按正交实验法设计出12组Prompt:
- 角色设定:无角色 / 简单角色(“你是法律专家”)/ 锚定角色(“你是执业15年的刑事审判庭法官,擅长从裁判文书中提取结构化信息”)
- 示例格式:纯文本描述 / JSON字典 / 带标注的Markdown表格
- 示例顺序:按难度递增 / 按实体类型聚类 / 随机打乱
- 输出约束:自由格式 / 强制JSON / 限定枚举值
每组Prompt用相同40条few-shot示例(示例集固定),测试集500条逐条调用。为节省成本,先跑50条子集筛选,再对胜出组跑全量。
四、核心实现:Prompt模板与评估代码
先看效果最差的初版Prompt(基线,F1=0.37):
baseline_prompt = """
请从以下法律文书中提取实体。
文书:{text}
输出格式:实体类型: 实体值,用分号分隔。
"""
而杀出重围的v9版本长这样:
expert_prompt = """
你是海淀区人民法院刑事审判庭的资深法官,审理过2000+件经济犯罪案件。
请严格按以下JSON Schema抽取实体:
{
"当事人": ["姓名"],
"案由": ["罪名"],
"刑期": {"主刑": "X年X月", "缓刑": "X年X月"},
"金额": {"数字": 10000, "币种": "人民币"}
}
约束:
- 金额只保留数字与币种,不写“元”
- 若刑期包含缓刑,必须拆开写
- 未出现的实体返回空数组
示例1:
文书:“被告人张三犯诈骗罪,判处有期徒刑三年,缓刑五年,并处罚金人民币两万元。”
输出:{"当事人": ["张三"], "案由": ["诈骗罪"], "刑期": {"主刑": "三年", "缓刑": "五年"}, "金额": {"数字": 20000, "币种": "人民币"}}
现在处理:
文书:{text}
输出:
"""
注意几个细节:角色设定里加了“海淀区”和“2000+件”——这种地缘锚定让模型对法律术语更敏感;金额要求拆数字与币种,彻底杜绝了“两万元”vs“20000”的格式漂移。
评估脚本的核心片段:
from seqeval.metrics import classification_report
import json, openai
def evaluate_prompt(prompt_template, test_samples):
preds, truths = [], []
for sample in test_samples:
resp = openai.ChatCompletion.create(
model='gpt-3.5-turbo-0301',
messages=[{"role": "user", "content": prompt_template.format(text=sample['text'])}],
temperature=0,
max_tokens=512
)
try:
pred = parse_json(resp.choices[0].message.content)
except:
pred = {} # 解析失败记为全空
preds.append(convert_to_entities(pred))
truths.append(sample['entities'])
print(classification_report(truths, preds, digits=4))
五、踩坑与优化:三个血泪教训
教训1:示例顺序比示例数量更重要。同是16条示例,按难度递增排序F1=0.73,随机打乱F1=0.58,按实体类型聚类F1=0.69。模型对上下文的位置编码有强偏置,开头和结尾的示例影响力最大。最终方案把最难抽的“刑期复合型”放首位和末位。
教训2:JSON Schema必须配合“零容忍”约束。v5版本只写“输出JSON”,结果模型在20%的case里把刑期写成“三年缓五年”这种非法字符串。加上“必须拆开”后,该错误率降至2%。但代价是token消耗从平均230涨到271——多出的40个token全在指令里。
教训3:角色设定不是越复杂越好。尝试过“你是精通《刑法》第266条…”,反而过拟合到诈骗罪,对盗窃罪识别率下降15%。最终版本只用“资深法官”加“刑事审判庭”,保持泛化性。
六、效果数据与成本曲线
12组实验全量结果(50条子集验证后,胜出组跑全量500条):
| 组别 | 角色设定 | 示例格式 | 示例顺序 | 输出约束 | F1值 | 平均token |
|---|---|---|---|---|---|---|
| v1 | 无 | 文本 | 随机 | 自由 | 0.37 | 155 |
| v3 | 简单 | JSON | 递增 | 强制JSON | 0.62 | 198 |
| v9 | 锚定 | JSON | 聚类+难前置 | JSON+枚举约束 | 0.89 | 271 |
| v12 | 锚定 | Markdown | 随机 | 自由 | 0.55 | 243 |
最终v9全量测试:F1=0.89,相比基线提升140%。但token消耗从155涨到271,涨幅75%。按OpenAI价格($0.002/1K token),每条成本从$0.00031涨到$0.00054,处理10万条文书多花$23——对于换来的准确率提升,这笔账划算。
值得玩味的是,把v9的few-shot示例从16条减到8条,F1掉到0.81;加到32条,F1只涨到0.90——边际效应明显。生产环境最终锁定16条示例,平衡成本与效果。
七、总结与建议
这轮实验最大的认知刷新:Prompt工程不是玄学,是可度量的系统优化。核心收益来自三处——结构化输出约束(+0.17)、角色锚定(+0.11)、示例顺序调整(+0.08)。如果你的NER任务还在用“请提取实体”这种裸Prompt,建议立刻按本文矩阵做一轮正交实验。
最后留个坑:GPT-4-turbo下同样的v9模板,F1冲到0.94,但延迟从800ms涨到1.9s。对于高吞吐场景,3.5-turbo配合v9可能是性价比最优解。后续我会测试微调版Prompt与RAG的结合,届时再出续篇。