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:
openaiPython包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元,预算内)
我的最终建议:
- 不要迷信CoT——对简单实体抽取,CoT收益有限且token翻倍。只在嵌套、歧义场景用。
- 少样本示例比任何“高级技巧”都管用——V5只加了1个示例,F1比V1高35个点。
- 生产环境永远别用Self-Consistency——除非你的利润率高过API成本。
- 角色提示词要克制——“资深法律助理”有效,“法官”就过拟合了。
如果你也在做类似的结构化抽取任务,直接抄V5的Prompt框架,把few-shot示例换成你的领域数据,大概率能到0.8以上。如果你有更好的方案,欢迎评论区交流。