1. 问题背景:看似简单的实体抽取,为何准确率卡在60%?

上个月接了个活儿:从某垂直行业的合同文本中抽取“甲方、乙方、履约截止日、违约金比例”四类实体。数据是PDF转的纯文本,单条约800-1200字,夹杂着“甲方(以下简称A公司)”这类指代干扰。

用GPT-4o-mini直接跑,效果惨不忍睹。我最初用的是最简单的单轮指令:

请从以下合同中抽取实体:甲方、乙方、履约截止日、违约金比例。

输出格式纯靠模型自觉,结果五花八门——有返回Markdown表格的,有把“甲方”写成“A公司”的,还有漏抽的。我统计了200条测试集,F1只有0.57。问题很典型:任务定义模糊 + 输出约束缺失 + 上下文干扰未处理

2. 环境与版本:LangChain 0.2.x + GPT-4o-mini-2024-07-18

开发环境如下,便于复现:

  • Python 3.10.12
  • openai==1.35.3
  • langchain==0.2.5
  • langchain-openai==0.1.8
  • 模型:gpt-4o-mini-2024-07-18,temperature=0.2,max_tokens=1024
  • 语料:200条人工标注测试集,实体分布均匀

注意:不要用gpt-3.5-turbo做这类细粒度抽取,我试过,它在指代消解上崩溃率极高,F1直接掉到0.3以下。4o-mini性价比尚可,但必须把Prompt写到位。

3. 方案设计:从零样本到结构化输出的四阶段演进

我设计了四个递进版本,每个版本都基于上一版的错误分析改进:

  • V1 零样本裸奔:一句话指令,无格式要求
  • V2 Few-shot示例:给2个完整示例,但格式仍自由
  • V3 结构化约束:JSON Schema + 输出解析器 + 负面排除指令
  • V4 自纠错循环:V3基础上增加一轮“合法性校验”反馈

核心思路:先解决“输出能解析”,再解决“内容正确”。很多开发者一上来就追求完美抽取,结果模型输出格式千奇百怪,后处理代码写了一大堆。先把格式钉死,再优化语义。

4. 核心实现:关键代码与Prompt模板

4.1 V3版本的核心Prompt模板(LangChain调用)

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import JsonOutputParser

MODEL_NAME = "gpt-4o-mini-2024-07-18"
llm = ChatOpenAI(model=MODEL_NAME, temperature=0.2, max_tokens=1024)

PROMPT_V3 = """你是一个法律合同信息抽取引擎。请从给定的合同文本中抽取以下实体,严格输出JSON对象。

抽取规则:
1. 甲方和乙方:只抽取公司全称或自然人身份证姓名,拒绝“甲方”“A公司”等指代词。
2. 履约截止日:格式必须是YYYY-MM-DD,若文本为“2024年3月31日前”,转换为2024-03-31。
3. 违约金比例:仅保留数字部分,输出浮点数,如文本“每日万分之五”输出0.0005。
4. 若某实体缺失,输出null,禁止编造。

输出JSONSchema:
{
  "party_a": {"type": ["string", "null"]},
  "party_b": {"type": ["string", "null"]},
  "deadline": {"type": ["string", "null"], "format": "date"},
  "penalty_rate": {"type": ["number", "null"]}
}

合同文本如下:
{{text}}
"""

parser = JsonOutputParser()
chain = ChatPromptTemplate.from_template(PROMPT_V3) | llm | parser
result = chain.invoke({"text": sample_contract})
print(result)
# 输出: {'party_a': '杭州云栖科技有限公司', 'party_b': '北京智算数据有限公司', 'deadline': '2024-12-31', 'penalty_rate': 0.0005}

4.2 V4自纠错循环的核心实现

V4在V3基础上增加了一次“校验”交互。代价是token翻倍,但能有效拦截幻觉。

def extract_with_self_correct(text, max_rounds=2):
    current_input = text
    for round_idx in range(max_rounds):
        raw_resp = chain.invoke({"text": current_input})
        # 校验器:检查日期格式、比例数值范围、指代残留
        issues = validate_entities(raw_resp)
        if not issues:
            return raw_resp, round_idx + 1

        # 构造纠错指令,追加原始结果和问题列表
        fix_prompt = f"""上一次抽取结果存在问题:{issues}
请基于原始合同文本修正输出。原始文本:
{text}
只输出修正后的JSON。"""
        current_input = fix_prompt  # 注意:这里故意不带上次错误结果,防止模型被带偏

    return raw_resp, max_rounds

踩坑提醒:千万不要把错误结果直接拼在修正Prompt里让模型“修改”——模型会倾向于局部修补而不是重读原文,导致错误固化。我改成只给问题描述+原文,强制模型重新推理,正确率明显回升。

5. 踩坑与优化:三个惊掉下巴的失败案例

坑1:日期格式的“中文惯性”。V2阶段,模型经常输出“2024年12月31日”而不遵守格式要求。我加了“转换”指令后,仍有3%的样本输出“2024-12-31T00:00:00Z”。最后在JSON Schema里加"format": "date",并在规则里写明“禁止ISO时间戳”,问题清零。

坑2:违约金比例的“百分比陷阱”。文本写“违约金为合同总额的5%”,模型老老实实输出0.05,但另一条写“每日按未付金额的万分之五”,模型输出0.0005。后来我发现规则描述越具体,模型越稳定。V3模板中我明确写了“每日万分之五输出0.0005”,并加了两个示例。

坑3:指代消解的漏网之鱼。合同中常出现“甲方(以下简称A公司)”,模型偶尔输出“A公司”。V4的校验器里我加了一条硬规则:如果party_a或party_b的值以“公司”结尾但不包含“有限”“股份”等词,标记为风险。实测能拦截约60%的指代残留错误。

6. 效果数据:token多花了67%,F1涨了61%

我在200条测试集上跑了完整对比,温度0.2,每次调用独立计费。结果如下表(均值):

版本 F1分数 单条平均token消耗 输出格式合法率 完全匹配率
V1 零样本 0.57 412 31% 0%
V2 Few-shot 0.71 558 44% 12%
V3 结构化 0.86 623 98% 71%
V4 自纠错 0.92 689 100% 84%

关键结论:V4比V1的token消耗增加了67%(689 vs 412),但完全匹配率从0%飙升到84%。对于生产环境,多花的这200个token(约0.0002美元/条)换来了近40%的准确率提升,ROI极高。另外我测过temperature:0.2最佳,0.0时模型过于机械,偶尔漏抽;0.5以上时格式开始飘。

7. 总结:Prompt工程不是玄学,是系统调试

这次实战让我彻底抛弃了“Prompt万能论”。有效提升来自三方面:任务分解(把日期转换、指代消解写成显式规则)、输出约束(JSON Schema + 解析器强制)、闭环校验(用代码检查模型输出再喂回)

如果只让我留一条经验:先用20条样本跑V1,把错误分类(格式错、语义错、缺失错),然后针对性加规则和示例,而不是盲目堆Few-shot。 至于自纠错循环,建议只在准确率瓶颈期使用——它确实能榨出最后5%的性能,但token成本翻倍,业务上要权衡。

最终我的生产管线采用V3 + 轻量校验器(不做第二轮完整推理),F1稳定在0.88,单条延迟约1.2秒,成本可控。V4作为离线重跑机制,用于高价值合同。这套方案已经跑了两个月,累计处理合同4.2万份,抽取出错率低于2%。希望这篇记录能帮你少踩几个坑。