一、问题背景:当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(成本高)。动态示例选择本质上是在“给模型看什么”和“让模型想什么”之间做折中。

具体可复用的经验:

  1. 实体抽取类任务,JSON Schema + 容错解析比堆示例更划算(P3到P4的F1提升其实主要来自示例,但Schema先保证了可解析性)。
  2. 少样本示例宁缺毋滥——一个不相关的示例比没有示例更糟。用相似度检索选示例,成本降低60%,质量只降2%。
  3. Temperature 0.1是实体抽取的黄金值,0会陷入重复,0.3以上开始发散。
  4. 如果部署环境支持,建议用vLLM的logprobs参数检查模型对每个字段的置信度,低于0.7的case自动转人工复核——这个我下期文章再写。

现在这套P5方案已经上线跑了两周,2000份文书全部处理完毕,平均每案耗时1.2秒(含网络开销)。中间有13份文书因内容格式太怪(比如扫描OCR错字)导致抽取失败,回退到人工处理。整体可用性在预期范围内。

如果你也在做类似的结构化抽取,欢迎评论区交流你的Prompt调优经验——特别是那些“看似有效实则翻车”的案例,我这还有一箩筐。