一、问题背景:一个看似简单的NER任务为何翻车
上个月接到一个需求:从医疗问诊记录中抽取“药品名”、“剂量”、“频次”三个实体。数据是真实的门诊文本,长这样:
“患者昨日开始服用阿莫西林胶囊,每次0.5g,每日三次,饭后服用,同时配合布洛芬缓释片0.3g bid。”
我第一反应是直接调GPT-3.5-turbo的API,写个Prompt塞进去就完事。结果第一次测试,30条样本的F1只有0.38——几乎等于随机猜。药名抽出来了,但“0.5g”被识别成药品名,“bid”直接丢失。
这让我意识到:不是模型不行,是Prompt没设计好。于是花了两天时间系统性地做了一轮Prompt工程实验,本文完整记录这个过程。
二、环境与版本
- 模型:gpt-3.5-turbo-0613(非最新版,但特性稳定,适合复现)
- temperature:0(所有实验保持一致,避免随机性干扰)
- max_tokens:512
- 测试集:从真实病历中抽取30条,人工标注实体(药品名/剂量/频次),共87个实体
- 评估指标:F1(严格匹配,实体文本和类型完全一致才得分)
- 成本统计:使用OpenAI的usage字段精确记录prompt_tokens和completion_tokens
所有实验代码基于Python 3.10 + openai库0.28.0,API调用封装如下:
import openai
import json
openai.api_key = "sk-xxx"
def call_gpt(prompt: str) -> tuple[str, dict]:
"""返回(输出文本, token统计)"""
resp = openai.ChatCompletion.create(
model="gpt-3.5-turbo-0613",
messages=[{"role": "user", "content": prompt}],
temperature=0,
max_tokens=512
)
content = resp.choices[0].message.content
usage = resp.usage
return content, {
"prompt_tokens": usage.prompt_tokens,
"completion_tokens": usage.completion_tokens,
"total_tokens": usage.total_tokens
}
三、方案设计:五种Prompt策略对比
我设计了5种Prompt,覆盖从零样本到复杂推理的完整梯度:
- P1 - 零样本基础版:直接指令抽取
- P2 - 格式约束版:指定JSON输出格式
- P3 - 少样本示例版:提供3个示例
- P4 - 角色扮演+结构化:让模型当“医学信息抽取专家”
- P5 - 两步推理CoT:先找候选词,再分类
关键设计原则:每个Prompt只改变一个变量。P1到P2只加格式要求,P3只加示例,P4只加角色,P5在前者基础上加推理步骤。
四、核心实现:五个Prompt的完整文本与结果
P1 - 零样本基础版(基线)
从以下医疗文本中抽取药品名、剂量、频次三个实体。
文本:{text}
输出格式:药品名:xxx,剂量:xxx,频次:xxx
结果:F1=0.38,平均单次token消耗=412。典型错误——把“阿莫西林胶囊”拆成“阿莫西林”和“胶囊”,频次“每日三次”和“饭后服用”都归为“频次”。
P2 - 格式约束版(JSON输出)
从以下医疗文本中抽取实体,严格按照JSON格式输出:
{"药品名": [], "剂量": [], "频次": []}
文本:{text}
结果:F1=0.52,token消耗=356。JSON格式解决了结构化问题,但“bid”(拉丁文缩写,即每日两次)仍然丢失。模型不理解医学缩写。
P3 - 少样本示例版
在P2的基础上,加入3个带标注的示例(药品名、剂量、频次各一个)。
结果:F1=0.67,token消耗=489(示例增加了prompt长度)。示例让模型学会了“bid”=每日两次,但出现了新问题——“0.5g”被重复抽取。
P4 - 角色扮演+结构化(第一次突破)
你是一位拥有20年临床经验的医学信息抽取专家。你的任务是从门诊记录中准确抽取药品信息。注意:
1. 药品名只保留通用名,不要剂型(如“胶囊”)
2. 剂量只保留数字+单位
3. 频次只保留时间描述(如“每日三次”、“bid”)
严格按照JSON格式输出:{"药品名": [], "剂量": [], "频次": []}
文本:{text}
结果:F1=0.83,token消耗=298(角色设定反而让模型更专注,减少了无效输出)。关键提升——模型自动理解了“剂型不归入药品名”。
P5 - 两步推理CoT(最终版)
你是一位医学信息抽取专家。请按以下步骤处理文本:
第一步:找出所有与用药相关的候选词(药品、数字+单位、时间词)
第二步:将每个候选词分类到“药品名”、“剂量”、“频次”中,注意“bid”、“tid”等缩写
输出JSON:{"药品名": [], "剂量": [], "频次": []}
文本:{text}
结果:F1=0.91,token消耗=314。最后的瓶颈是“阿莫西林胶囊”这种剂型词仍然偶尔被包含进去,需要后处理。
效果对比总表
| Prompt | F1 | avg tokens | 失败案例数 |
|---|---|---|---|
| P1 | 0.38 | 412 | 19 |
| P2 | 0.52 | 356 | 15 |
| P3 | 0.67 | 489 | 10 |
| P4 | 0.83 | 298 | 5 |
| P5 | 0.91 | 314 | 3 |
五、踩坑与优化:三个关键教训
坑1:示例不要贪多。P3加了3个示例,F1反而从P2的0.52跳到0.67,但token消耗涨了37%。后来我试着加到5个示例,F1只涨了0.02,token消耗却多了150。少样本示例在第二个之后边际效应急剧递减。
坑2:角色设定能显著减少无效输出。P4的token消耗比P2还低(298 vs 356),因为模型不再输出“根据您的要求,以下是抽取结果:”这种废话。角色prompt的本质是隐式约束,比显式指令更有效。
坑3:CoT不是万能的。P5的F1只比P4提高0.08,但对“bid”这种高频缩写的识别率从70%提升到了95%。如果你处理的文本没有太多缩写,CoT的收益可能不如直接优化格式。
后处理优化(最终F1提升到0.93):对输出做一次正则清洗:
import re
def post_process(entities: dict) -> dict:
"""清洗模型输出:去除剂型词,合并重复项"""
drug_pattern = re.compile(r'(胶囊|片剂|颗粒|口服液)$')
cleaned = {"药品名": [], "剂量": [], "频次": []}
for drug in entities.get("药品名", []):
drug = drug_pattern.sub("", drug) # 去掉"胶囊"等后缀
if drug not in cleaned["药品名"]:
cleaned["药品名"].append(drug)
# 剂量去重(同一剂量可能被抽取多次)
cleaned["剂量"] = list(dict.fromkeys(entities.get("剂量", [])))
return cleaned
加上这段后处理,F1最终稳定在0.93,平均token消耗不变。
六、效果数据与成本分析
30条测试样本,最终统计(含后处理):
- F1:0.93(精确率0.95,召回率0.91)
- 平均单次token:314(约0.0006美元/次,30条共消耗9420 tokens,花费约0.02美元)
- 最差case:一条文本中同时出现“阿莫西林胶囊”和“阿莫西林分散片”,模型只识别了第一个。原因是实体列表去重逻辑太简单,需要按上下文区分。
如果换成gpt-4-0613,同样的P5策略,F1能到0.95,但单次token消耗高达1100+(4倍价格)。对于这个场景,gpt-3.5-turbo+后处理已经足够。
结论:Prompt工程不是玄学,是结构性优化。从P1到P5,每个阶段都有明确的改进点和代价。最值得投入的是角色设定(提升0.31)和格式约束(提升0.14),而非无脑加示例或堆CoT。
最后留个思考:如果你的文本是英文,P4的收益可能没那么大,因为英文NER本身对GPT来说更简单。中文的难点在于分词和缩写,这决定了你的Prompt应该在哪个环节发力。