1. 问题背景:当正则表达式遇上“本院认为”
上周接了个外包活儿:从3000份民事判决书里抽取“原告”“被告”“案由”“判决金额”“代理律师”五个字段。甲方要求字段级F1不低于0.85,且总预算控制在500元以内。我第一反应是用正则 + 关键词词典硬抠,结果发现判决书的表述千奇百怪——“原告某公司诉称”和“反诉原告某某”混在一起,“代理律师”有时写成“委托诉讼代理人”,金额还有“人民币123,456.78元”和“壹拾贰万叁仟肆佰伍拾陆元柒角捌分”两种写法。正则在第100份样本上直接崩了,准确率不到30%。
于是决定换成LLM抽取。选型是gpt-4o-mini-2024-07-18,价格便宜($0.15/1M input tokens),且对中文长文本的理解力够用。测试集是从3000份里人工标注的200条,每条判决书平均1200字。环境:Python 3.10 + openai 1.35.3 + pydantic 2.7。
2. 基线Prompt:裸奔的“告诉我字段”
第一版Prompt极其天真,就一句话:
from openai import OpenAI
client = OpenAI(api_key="sk-xxxx")
def extract_v1(text: str) -> str:
prompt = f"""从以下判决书中提取:原告、被告、案由、判决金额、代理律师。
判决书内容:{text}"""
resp = client.chat.completions.create(
model="gpt-4o-mini-2024-07-18",
messages=[{"role": "user", "content": prompt}],
temperature=0.0,
max_tokens=800
)
return resp.choices[0].message.content
结果惨不忍睹:200条测试集里,输出格式五花八门——有的返回JSON,有的返回markdown表格,有的干脆把“本院认为”整段抄下来。字段级F1只有0.52,其中“判决金额”错误率最高,因为模型经常把“案件受理费”也算进去。单条平均消耗input tokens高达1847(因为整篇判决书全塞进去了),输出token 412,成本约$0.00047/条。
关键失败案例:原文“被告王某某于判决生效之日起十日内向原告支付货款人民币120,000元”,模型抽出的判决金额是“120,000元人民币”,但漏掉了“十日内”这个违约金起算点,导致后续业务判断失误。这暴露了基线Prompt没有定义“精确边界”。
3. 第一轮优化:结构化输出 + 正则兜底
我意识到必须强制模型输出JSON Schema。用pydantic定义输出模型,同时把原文里出现金额的句子截断到前50个字符作为上下文提示。
from pydantic import BaseModel, Field
import json
class Judgment(BaseModel):
plaintiff: str = Field(description="原告全称")
defendant: str = Field(description="被告全称")
cause: str = Field(description="案由,如民间借贷纠纷")
amount: float = Field(description="判决支付金额(只含赔偿/货款,不含诉讼费)")
lawyer: str | None = Field(description="代理律师姓名,没有则null")
def extract_v2(text: str) -> Judgment:
schema_json = Judgment.model_json_schema()
prompt = f"""你是法律信息抽取助手。根据判决书生成JSON,严格遵守schema:{schema_json}
注意:金额只取"判决如下"之后出现的首个具体数字,排除"案件受理费""保全费"。
判决书:{text[:1500]}""" # 截断节省token
resp = client.chat.completions.create(
model="gpt-4o-mini-2024-07-18",
messages=[{"role": "user", "content": prompt}],
temperature=0.0,
response_format={"type": "json_object"},
max_tokens=600
)
raw = resp.choices[0].message.content
# 正则兜底:如果JSON解析失败,尝试提取字段
try:
return Judgment(**json.loads(raw))
except:
# 简单正则匹配金额
import re
m = re.search(r"([0-9,,]+\.?\d*)[元万]", text)
return Judgment(plaintiff="", defendant="", cause="", amount=float(m.group(1).replace(",","")) if m else 0.0, lawyer=None)
效果:JSON解析成功率提升到97%,格式问题基本解决。但准确率只到0.63——模型开始犯“张冠李戴”:把“反诉原告”当成“原告”,把“委托代理人”当成“代理律师”。原因是没有给模型“主诉/反诉”的区分规则。
4. 第二轮优化:Few-shot示例 + 反模式标注
我找了3个典型判决书片段作为Few-shot示例,每个示例里明确标注“反诉原告≠原告”“代理律师只取原告方律师”。同时加了“否定指令”:
few_shot_examples = [
{
"text": "反诉原告(本诉被告)甲公司诉称...本诉原告乙公司辩称...",
"answer": {"plaintiff": "乙公司", "defendant": "甲公司", "cause": "买卖合同纠纷", "amount": 0.0, "lawyer": null}
},
{
"text": "判决如下:一、被告丙公司于本判决生效后七日内赔偿原告丁某医疗费8,322.5元;二、驳回其他诉讼请求。",
"answer": {"plaintiff": "丁某", "defendant": "丙公司", "cause": "生命权健康权纠纷", "amount": 8322.5, "lawyer": null}
}
]
def build_prompt_v3(text: str) -> str:
ex_text = ""
for e in few_shot_examples:
ex_text += f"输入:{e['text']}\n输出:{json.dumps(e['answer'], ensure_ascii=False)}\n\n"
return f"""你是法律文书抽取引擎。规则:
1. 原告=本诉原告(即使原文写"反诉原告(本诉被告)")。
2. 代理律师仅指原告委托的诉讼代理人,被告律师填null。
3. 金额取判决主文第一条的赔偿数额,不含利息、诉讼费。
示例:{ex_text}
请抽取:{text[:1200]}"""
关键踩坑:Few-shot示例不能太长,否则超出模型上下文窗口。我把每个示例控制在80字以内。另外发现temperature=0.0时模型对示例的模仿非常机械——如果示例里amount是0.0,真实样本里也会倾向输出0。于是把示例里的金额改成不同量级。
效果:F1升至0.78。但“代理律师”字段仍是最弱环——模型经常把“被告代理人”也填进lawyer。查了错误样本,发现模型对“只取原告方”这个指令理解不彻底。
5. 第三轮优化:自洽性校验(Self-Consistency)
为了根治律师字段,我引入了两步校验:先让模型抽取,再让模型基于抽取结果做“合理性检查”——如果lawyer存在但原文中没出现“原告委托”字样,则置空。同时把输入token从1200压缩到800,利用text.split("判决如下")[0]只取判决书前半部分(当事人信息都在前面)。
def extract_v4(text: str) -> Judgment:
# 步骤1:粗抽取
pre_text = text.split("判决如下")[0][:800]
prompt1 = f"抽取JSON:{pre_text}"
j1 = json.loads(call_gpt(prompt1))
# 步骤2:校验律师字段
check_prompt = f"""抽取结果:{json.dumps(j1, ensure_ascii=False)}
原文片段:{pre_text}
如果"lawyer"对应的姓名在原文中不是紧邻"原告委托代理人"或"原告代理人",则把lawyer改为null。
只回答修正后的JSON。"""
j2 = json.loads(call_gpt(check_prompt))
# 步骤3:金额二次确认 - 从判决主文里找
if "判决如下" in text:
main_text = text.split("判决如下")[1][:200]
confirm_prompt = f"提取第一个赔偿金额数字(不含费):{main_text}"
amount = float(call_gpt(confirm_prompt))
j2["amount"] = amount
return Judgment(**j2)
效果:F1达到0.87,律师字段错误率从34%降到11%。但token消耗反弹——两轮调用合计input 950 + output 300,单条成本升至$0.00058。且步骤2的校验Prompt偶尔会误杀正确结果(当原文写“代理人张三”而非“委托代理人”时)。
6. 效果数据与成本核算
最终我用了三轮Prompt(v2 + v3 + v4的混合策略):首轮用v3结构输出,若JSON解析失败则降级到v1正则兜底,若lawyer字段有值且不确定则用v4的check_prompt。在200条测试集上的最终指标:
| 指标 | v1基线 | v2 | v3 | v4最终 |
|---|---|---|---|---|
| 字段级F1 | 0.52 | 0.63 | 0.78 | 0.91 |
| 金额准确率 | 41% | 58% | 72% | 89% |
| 平均输入tokens | 1847 | 1420 | 980 | 632 |
| 平均输出tokens | 412 | 350 | 280 | 215 |
| 单条成本($) | 0.00047 | 0.00036 | 0.00025 | 0.00018 |
额外发现:gpt-4o-mini对中文法律文本的敏感度比gpt-3.5-turbo高27%,且当max_tokens从800降到600时F1反而提升2%,因为模型不再“凑字数”导致幻觉。
最终总结:
- Prompt工程的关键不是“写得多”,而是“约束得准”。每加一个指令,需要验证是否引入新的偏差(比如Few-shot示例会主导输出分布)。
- 结构化输出 + 正则兜底是保底方案,但真正的提升来自“让模型先抽取再自我校验”——这是用成本换准确率,适合预算敏感场景。
- 对法律文本,切断“判决如下”之后的内容能减少90%的无关token,但牺牲了金额上下文——所以我在v4里单独对主文做二次抽取。
- 如果预算够,建议直接用gpt-4o;但如果和我一样抠门,gpt-4o-mini + 三轮校验完全能跑到F1>0.85。
以上代码在Python 3.10 + openai 1.35.3下实测通过。如果你也在做类似抽取任务,欢迎留言交流具体字段的调参技巧。