1. 问题背景:为什么突然开始死磕Prompt

上周接到一个需求:从2000份裁判文书中自动抽取“原被告名称”、“案由”、“赔偿金额”三个实体,喂给下游的判决预测模型。我一开始用的是正则+领域词典,F1只有0.61,遇到“原告A公司(原名B厂)”这种嵌套表达直接崩。于是决定上LLM,用的gpt-4-1106-preview,temperature=0.3。

但我很快发现,同一个模型,换一种说法,结果能差20个百分点。Prompt不是写作文,是调参。这篇文章就是我这5天迭代的全记录,包括每个版本的Prompt设计、实际返回结果、token消耗和最终取舍。

2. 环境与版本:别再用默认参数了

  • 模型:gpt-4-1106-preview (截止2024.01,已支持JSON模式)
  • SDK:openai Python包 1.10.0,Python 3.10.12
  • 关键配置:temperature=0.2(实体抽取要确定性),max_tokens=1024
  • token计量:tiktoken库,编码器cl100k_base
  • 数据:从中国裁判文书网爬的100份民间借贷纠纷文书,人工标注了实体位置

踩坑第一弹:千万不要用text-davinci-003来做实体抽取,它的输出格式不稳定,经常在实体后面跟一句“请问还有什么需要帮助的吗”。GPT-4-turbo也要设置response_format={"type": "json_object"},否则有30%概率返回非JSON。

3. 方案设计:五版Prompt的递进逻辑

我不做炼丹式乱试,每一版都有明确假设:

版本 设计核心 假设
V1 零样本,直接问“抽取实体” 模型本身能力足够
V2 少样本,给2个完整示例 格式一致性比知识更重要
V3 CoT(思维链),先推理再抽取 复杂嵌套需要中间推理步骤
V4 V3 + Self-Consistency(采样3次投票) 降低随机性,提升鲁棒性
V5 V2 + 角色约束(“你是资深法律助理”) 领域术语和上下文理解更准

核心实现(V5最关键代码)

import json
from openai import OpenAI
import tiktoken

client = OpenAI(api_key="sk-xxx", base_url="https://api.openai.com/v1")
enc = tiktoken.get_encoding("cl100k_base")

SYSTEM_PROMPT = """你是资深法律助理,专精于民间借贷纠纷文书解析。
你的任务是抽取以下三个实体,必须遵守:
1. 只输出JSON,格式为{"原告": ["name1", "name2"], "被告": [...], "赔偿金额": [...]}
2. 如果实体在文中出现多次,全部列出
3. 对于"原告A公司(原名B厂)",需要同时抽取A公司和B厂,用"|"分隔标注嵌套关系
4. 不要输出任何解释性文字
"""

FEW_SHOT_EXAMPLE = """
输入:"原告张三(曾用名李四)诉被告王五民间借贷纠纷一案,要求赔偿50000元。"
输出:{"原告": ["张三|李四"], "被告": ["王五"], "赔偿金额": ["50000元"]}
"""

def extract_entities(text: str, prompt_version: str) -> dict:
    messages = []
    if prompt_version == "v5":
        messages = [
            {"role": "system", "content": SYSTEM_PROMPT},
            {"role": "user", "content": f"示例:{FEW_SHOT_EXAMPLE}\n\n正式输入:{text}"}
        ]
    elif prompt_version == "v1":
        messages = [{"role": "user", "content": f"从以下文本抽取实体:{text}"}]
    # ... 其他版本略

    resp = client.chat.completions.create(
        model="gpt-4-1106-preview",
        messages=messages,
        temperature=0.2,
        response_format={"type": "json_object"}
    )

    # 统计token消耗
    prompt_tokens = resp.usage.prompt_tokens
    completion_tokens = resp.usage.completion_tokens
    return json.loads(resp.choices[0].message.content), prompt_tokens, completion_tokens

4. 核心实现与迭代:5轮实验的真实数据

实验1(V1零样本):直接问“抽取原告被告赔偿金额”。结果:40%的输出不是合法JSON,且“赔偿金额”经常和“借款本金”混淆。F1=0.52。

实验2(V2少样本):加了2个示例,格式好多了,但嵌套实体“XXX公司(原YY公司)”只抽到了外层。F1=0.63,token消耗比V1多280 tokens/条。

实验3(V3思维链):加入“请先分析句子成分,找出所有名词短语,再判断是否为实体”。效果让我意外——嵌套实体召回率从0.55升到0.71,但代价是每条输出平均多了350 tokens,而且偶尔会在JSON里夹带分析文字(比如"原告": ["张三", "分析:这里张三确实是原告"])。需要加正则清理。

实验4(V4自一致性):对V3采样3次(temperature=0.7),然后投票取最多出现的实体。F1提升到0.79,但成本爆炸——单条处理成本从0.3元涨到1.1元。我果断放弃这个方案,生产环境不可能这么烧钱。

实验5(V5角色+少样本):这是最终版本。系统提示词里加“你是资深法律助理”,并明确要求“只输出JSON”。F1直接到0.87,嵌套实体A公司|B厂的输出格式稳定。token消耗比V2只多1.2倍,比V4便宜一半。

关键的token消耗对比表(100条文书的平均值)

版本 平均输入tokens 平均输出tokens 单条成本(¥) F1值
V1 820 210 0.12 0.52
V2 1150 260 0.18 0.63
V3 1180 610 0.22 0.74
V4 3540 1830 0.66 0.79
V5 1420 290 0.21 0.87

5. 踩坑与优化:三个让我熬夜的bug

坑1:JSON模式下的角色提示词冲突。我在V5里试过“你是一个法官”,结果模型开始输出“本院认为”等废话,直接破坏JSON结构。最后改成“法律助理”才正常。结论:角色描述要和任务语气匹配,不要跨越权力层级。

坑2:嵌套实体用列表还是字符串。我一开始设计"原告": [["张三","李四"]],模型经常搞成"原告": {"张三": "李四"}。后来改成用|分隔符,并放在few-shot示例里展示,准确率瞬间从68%升到87%。结构化约束必须写在示例里,而不是规则里

坑3:token统计误差。我最初用len(text.split())估算,误差高达40%。必须用tiktoken,且注意gpt-4-1106的tokenizer和cl100k_base一致,但system prompt和few shot示例也会占用token,别漏算。

优化方案:最终部署时,我加了缓存层——对相同文本(MD5后)直接返回上次结果,实测缓存命中率约25%,整体成本再降20%。

6. 效果数据与最终总结

最后在100份测试集上,V5的完整指标:

  • 精确率(Precision):0.91
  • 召回率(Recall):0.84
  • F1:0.87
  • 平均延迟:2.3秒/条(比V1多0.9秒,但可接受)
  • 单条成本:0.21元(1000条文书约210元,预算内)

我的最终建议

  1. 不要迷信CoT——对简单实体抽取,CoT收益有限且token翻倍。只在嵌套、歧义场景用。
  2. 少样本示例比任何“高级技巧”都管用——V5只加了1个示例,F1比V1高35个点。
  3. 生产环境永远别用Self-Consistency——除非你的利润率高过API成本。
  4. 角色提示词要克制——“资深法律助理”有效,“法官”就过拟合了。

如果你也在做类似的结构化抽取任务,直接抄V5的Prompt框架,把few-shot示例换成你的领域数据,大概率能到0.8以上。如果你有更好的方案,欢迎评论区交流。