一、问题背景:一个看似简单的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,覆盖从零样本到复杂推理的完整梯度:

  1. P1 - 零样本基础版:直接指令抽取
  2. P2 - 格式约束版:指定JSON输出格式
  3. P3 - 少样本示例版:提供3个示例
  4. P4 - 角色扮演+结构化:让模型当“医学信息抽取专家”
  5. 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应该在哪个环节发力。