一、问题背景:当LLM遇上结构化抽取
上个月接了个活儿,要从两千份裁判文书中抽取「被告人姓名、涉案金额、量刑结果」三个字段。本来想用正则硬刚,结果发现文书里“人民币贰拾叁万肆仟元”这种大小写混写、金额区间(“二十万至三十万之间”)直接让规则崩了。
于是转向Llama-3-8B-Instruct做zero-shot抽取。当时天真地以为”大模型嘛,给个指令就行“,结果第一轮测试就翻车了——F1只有62.3%,而且输出格式五花八门:有的返回自然语言段落,有的在JSON里塞了多余字段,还有的直接拒绝回答(“I cannot assist with legal advice”)。
这篇文章记录了我从“能用”到“稳定用”的全过程,重点对比不同Prompt策略下输出质量与Token成本的撕扯关系。环境是单张RTX 4090(24GB),vLLM 0.6.3部署,量化方式为AWQ 4bit。
二、环境与版本:LLM部署底座
具体软件栈如下:
- 模型:NousResearch/Llama-3-8B-Instruct-AWQ-4bit
- 推理框架:vLLM 0.6.3(
--max-model-len 8192 --gpu-memory-utilization 0.92) - Python:3.11.8,
openai库1.35.3(兼容vLLM的/v1/chat/completions) - 评测集:从真实文书中随机抽200份,人工标注好标准实体,作为gold set
- Token计算:vLLM返回的
usage.completion_tokens+usage.prompt_tokens,单位是模型分词后的token数(Llama-3词表约128K,中文约0.7字/token)
我封装了一个统一的调用函数,后续所有实验都走这个口子,保证对比公平:
# llm_client.py
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:8000/v1",
api_key="EMPTY"
)
def extract_entities(prompt: str, system_prompt: str = "", temperature: float = 0.1) -> dict:
"""统一抽取入口,返回原始文本及token统计"""
resp = client.chat.completions.create(
model="llama-3-8b-instruct-awq",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": prompt}
],
temperature=temperature,
max_tokens=1024,
top_p=0.9
)
return {
"content": resp.choices[0].message.content,
"prompt_tokens": resp.usage.prompt_tokens,
"completion_tokens": resp.usage.completion_tokens
}
三、方案设计:五组Prompt策略对比
我的核心实验矩阵如下,每组跑200条测试数据,记录F1和平均Token消耗:
| 策略编号 | Prompt设计要点 | 预期优势 |
|---|---|---|
| P1 | 裸指令:“抽取实体,输出JSON” | 基线零样本 |
| P2 | 角色设定:“你是资深法律助理” + 输出字段定义 | 约束输出域 |
| P3 | P2 + JSON Schema强制格式 | 减少解析错误 |
| P4 | P3 + 1个少样本示例(带标准输出) | 模仿学习 |
| P5 | P4 + 动态示例选择(按关键词相似度) | 降低Token浪费 |
这里重点说P5的设计思路:每个测试文书先跑一个轻量TF-IDF向量化(sklearn 1.4.2),从预先准备好的20个标注示例中选最相似的2个拼进Prompt。这样避免了P4那种“无论什么案件都塞同一个示例”带来的噪声。
四、核心实现:动态示例选择的Prompt模板
先看静态版(P4)的模板构造代码:
# prompt_builder.py
def build_prompt_p4(text: str, example_text: str, example_ans: str) -> str:
return f"""
任务:从下列法律文书中抽取三个字段:被告人姓名、涉案金额、量刑结果。
要求:
1. 涉案金额若为区间,取上限值(单位:元)。
2. 输出严格JSON格式,不要多余解释。
参考示例:
文书:{example_text}
输出:{example_ans}
目标文书:{text}
输出:"""
而P5的动态版,核心是示例检索器:
# dynamic_example.py
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity
import numpy as np
# 预置20个标注示例
EXAMPLES = [
{"text": "被告人张三,因盗窃罪被判处有期徒刑三年,涉案金额人民币五万元。", "ans": "{\"被告人姓名\":\"张三\",\"涉案金额\":50000,\"量刑结果\":\"有期徒刑三年\"}"},
# ... 其余19个略
]
vectorizer = TfidfVectorizer(ngram_range=(1, 2), max_features=5000)
# 预计算示例向量
example_vecs = vectorizer.fit_transform([e["text"] for e in EXAMPLES])
def select_examples(query_text: str, k: int = 2) -> list:
query_vec = vectorizer.transform([query_text])
sims = cosine_similarity(query_vec, example_vecs)[0]
top_k = np.argsort(sims)[-k:][::-1]
return [EXAMPLES[i] for i in top_k]
def build_prompt_p5(text: str) -> str:
exs = select_examples(text, k=2)
ex_str = "\n\n".join([f"示例{i+1}:\n文书:{e['text']}\n输出:{e['ans']}" for i, e in enumerate(exs)])
return f"任务:抽取实体...\n{ex_str}\n\n目标文书:{text}\n输出:"
这里有个关键细节:示例的ans必须和gold set的格式完全一致(比如金额统一存为数字而非字符串),否则模型会学到错误的对齐模式。
五、踩坑与优化:那些Prompt里的隐形地雷
坑1:角色设定注入反而降低准确率?
P2加了“资深法律助理”后,F1从62.3%降到58.9%。排查发现模型开始输出“根据《刑法》第XX条”之类的法律条文引用,占用了输出token。解决办法是在system prompt里加一句“不要引用法条,只看给定的文书内容”。
坑2:JSON Schema越严格,解析失败率越高
P3强制要求输出{"被告人姓名": "string", "涉案金额": number, "量刑结果": "string"},F1涨到74.1%,但json.loads()失败率高达31%——因为模型偶尔输出金额: 50000(键名少引号)或50000元(带单位)。最后妥协方案:用json_repair库(0.8.0版本)做容错解析,失败率降到2.7%。
坑3:少样本示例选择不当会反向引导
P4固定用“张三盗窃”作为示例,当目标文书是“李四受贿”时,模型倾向于把涉案金额也按“五万”的尺度输出。后来改为P5动态选择后,这个问题基本消失——但代价是每次请求都要做一次TF-IDF计算,延迟增加约80ms,可接受。
坑4:Temperature对实体抽取的影响
测试了temperature=0.0/0.1/0.3三档。0.0时输出确实稳定,但偶尔会重复同一字段(如“被告人姓名”出现两次)。0.1是甜点,F1最高且无重复。0.3以上开始出现幻觉金额。
六、效果数据:Token成本与F1的权衡曲线
最终200条测试集上的完整数据:
| 策略 | F1 (%) | 平均Prompt Tokens | 平均Completion Tokens | 总Token/案 | 解析失败率(%) |
|---|---|---|---|---|---|
| P1裸指令 | 62.3 | 128 | 284 | 412 | 38.5 |
| P2角色设定 | 58.9 | 156 | 312 | 468 | 35.2 |
| P3 + JSON Schema | 74.1 | 189 | 296 | 485 | 31.0 |
| P4 + 静态示例 | 84.7 | 1510 | 383 | 1893 | 3.4 |
| P5 + 动态示例 | 83.1 | 404 | 368 | 772 | 2.7 |
关键解读:
- P4虽然F1最高(84.7%),但每案要烧掉1893 token。按vLLM部署成本算,每百万token约$0.3(自托管GPU+电力),2000份文书的调用成本从P1的$0.25涨到$1.14,翻了4.5倍。
- P5在F1只降了1.6个百分点的代价下,Token消耗比P4减少了59.2%。实际生产环境我选了P5,因为任务对绝对准确率要求没那么苛刻,但成本预算卡得死。
- 单字段错误类型分析:P5的失败案例中,70%是“涉案金额”的实体边界问题(比如“赃物折合人民币七万元”被漏掉),20%是量刑结果里的缓刑条款(“有期徒刑三年,缓刑四年”只抽出了前半句),10%是重名干扰。
输出质量样例对比:
P1典型错误输出:
{"被告": "张三", "金额": "五万", "刑期": "三年"}
P5正确输出:
{"被告人姓名": "张三", "涉案金额": 50000, "量刑结果": "有期徒刑三年"}
总结:Prompt工程的本质是约束信息熵
这一轮实验下来,最大的感悟是:Prompt不是玄学,是信息论。裸指令给了模型太多自由度(熵高),输出质量差;但堆砌示例和角色又会引入大量无关token(成本高)。动态示例选择本质上是在“给模型看什么”和“让模型想什么”之间做折中。
具体可复用的经验:
- 实体抽取类任务,JSON Schema + 容错解析比堆示例更划算(P3到P4的F1提升其实主要来自示例,但Schema先保证了可解析性)。
- 少样本示例宁缺毋滥——一个不相关的示例比没有示例更糟。用相似度检索选示例,成本降低60%,质量只降2%。
- Temperature 0.1是实体抽取的黄金值,0会陷入重复,0.3以上开始发散。
- 如果部署环境支持,建议用vLLM的
logprobs参数检查模型对每个字段的置信度,低于0.7的case自动转人工复核——这个我下期文章再写。
现在这套P5方案已经上线跑了两周,2000份文书全部处理完毕,平均每案耗时1.2秒(含网络开销)。中间有13份文书因内容格式太怪(比如扫描OCR错字)导致抽取失败,回退到人工处理。整体可用性在预期范围内。
如果你也在做类似的结构化抽取,欢迎评论区交流你的Prompt调优经验——特别是那些“看似有效实则翻车”的案例,我这还有一箩筐。