一、问题背景:为什么一个看似简单的抽取任务让我怀疑人生
上个月接到一个需求:从医疗文献摘要中抽取药物-疾病-疗效三元组。样本长这样:"阿托伐他汀在原发性高胆固醇血症患者中使LDL-C降低42%(p<0.001)",期望输出{"drug": "阿托伐他汀", "disease": "原发性高胆固醇血症", "effect": "LDL-C降低42%"}。
我第一反应是“这还不简单”,直接用LangChain调GPT-4,写了个十几行的中文Prompt扔进去。结果测试集200条数据,F1只有0.42——药物和疾病还行,effect字段基本是乱抽,经常把统计量或者无关从句塞进去。更离谱的是,同样的输入跑三次,三次结果都不一样。
这不是模型不行,是我Prompt写得像屎。于是我决定系统性地做一轮Prompt工程实验,核心关注三个指标:准确率、token消耗、输出稳定性(同一输入跑5次的方差)。
二、环境与版本:固定变量才能看出差异
实验配置:
- 模型:gpt-4-turbo-0125-preview(OpenAI,2024年1月版)
- 温度:0.3(太低会重复,太高会乱飘,0.3是我试出的甜点)
- 最大输出Tokens:512
- 测试集:200条人工标注的医疗摘要,已清洗,平均输入长度142 tokens
- 评估方式:严格JSON匹配 + 字段级F1(drug/disease/effect分别算)
- 调用库:openai==1.12.0,tiktoken==0.5.1(用来算token)
我用一个Python脚本批量跑,每轮实验重复5次取均值。所有Prompt模板放在一个prompts.py文件里,方便替换。
三、方案设计:三轮迭代,从“裸奔”到“穿上铠甲”
我设计了三个递进的Prompt方案,对应三个核心假设:
- 假设A:模型不知道“三元组”的边界,需要显式定义。
- 假设B:模型对“effect”的粒度判断不准,需要给正反例。
- 假设C:模型输出的JSON格式不稳定,需要强制结构化+自校验。
三轮实验如下:
第一轮:零样本直给
提取以下文本中的药物、疾病、疗效三元组,输出JSON。
文本:{text}
结果:F1 0.42,effect错误率极高,经常把“显著降低”和“LDL-C降低42%”都塞进去。
第二轮:少样本+角色约束
你是一个专业的医学信息抽取引擎。从输入文本中提取三元组(drug, disease, effect)。
规则:
- drug必须是具体药物名称,不含剂量
- disease必须是疾病全称,不含修饰语
- effect必须包含具体指标和数值,格式为"指标+变化方向+数值"
示例:
输入:二甲双胍在2型糖尿病患者中使HbA1c下降1.2%。
输出:{"drug": "二甲双胍", "disease": "2型糖尿病", "effect": "HbA1c下降1.2%"}
---
文本:{text}
结果:F1 0.68,effect字段改善明显,但偶尔还是会把p值或者置信区间混进来。
第三轮:结构化输出+JSON Schema+自校验
请严格按以下JSON Schema输出,不要输出任何其他内容:
{"drug": string, "disease": string, "effect": string}
抽取完成后,请检查:effect中是否包含数字和单位?如果没有,请置为"未明确"。
文本:{text}
结果:F1 0.87,效果突破,但token消耗比第一轮多了近40%。
四、核心实现:代码是怎么写的
下面是我最终使用的核心调用代码,openai库1.12.0版本,注意response_format参数只有新模型才支持。
# prompt_engine_experiment.py
import openai
import tiktoken
import json
import time
client = openai.OpenAI(api_key="sk-xxx")
enc = tiktoken.encoding_for_model("gpt-4-turbo-0125-preview")
def run_extraction(text, prompt_template, use_json_mode=False):
prompt = prompt_template.format(text=text)
params = {
"model": "gpt-4-turbo-0125-preview",
"temperature": 0.3,
"max_tokens": 512,
"messages": [{"role": "system", "content": "你是精准的医学信息抽取器。"},
{"role": "user", "content": prompt}]
}
if use_json_mode:
params["response_format"] = {"type": "json_object"}
start = time.time()
resp = client.chat.completions.create(**params)
latency = time.time() - start
content = resp.choices[0].message.content
prompt_tokens = resp.usage.prompt_tokens
completion_tokens = resp.usage.completion_tokens
# 尝试解析JSON,失败则标记
try:
result = json.loads(content)
except:
result = {"error": content[:200]}
return result, prompt_tokens, completion_tokens, latency
# 计算token消耗
def count_tokens(prompt):
return len(enc.encode(prompt))
这是实验脚本的核心循环,跑200条测试数据,每条重复5次,记录方差。
# run_experiment.py
test_samples = load_annotated_data("test_200.jsonl") # 每条含text和gold_dict
prompts = {
"zero_shot": zero_shot_template,
"few_shot": few_shot_template,
"schema_selfcheck": schema_selfcheck_template
}
results = {}
for name, template in prompts.items():
f1_list, token_list = [], []
for sample in test_samples[:20]: # 先跑20条快速验证
for rep in range(5):
pred, pt, ct, lt = run_extraction(sample["text"], template,
use_json_mode=(name=="schema_selfcheck"))
f1 = compute_f1(pred, sample["gold"])
f1_list.append(f1)
token_list.append(pt + ct)
avg_f1 = sum(f1_list) / len(f1_list)
avg_tokens = sum(token_list) / len(token_list)
variance = statistics.variance(f1_list)
results[name] = {"avg_f1": avg_f1, "avg_tokens": avg_tokens, "variance": variance}
print(f"{name}: F1={avg_f1:.3f}, tokens={avg_tokens:.0f}, var={variance:.4f}")
五、踩坑与优化:三个坑,每个都让我浪费了半天
坑1:JSON模式不是万能药
第三轮我用了response_format={"type": "json_object"},结果发现如果Prompt里没显式出现“JSON”这个词,模型会返回空对象。后来我必须在Prompt里写上输出JSON四个字,否则解析失败率高达30%。
坑2:少样本的示例必须和测试集同分布
第二轮我用了两个示例,一个关于二甲双胍,一个关于阿司匹林。结果测试集里出现“瑞舒伐他汀”时,模型硬套“LDL-C降低”模板,把“非HDL-C降低”也写成“LDL-C降低”。后来我把示例改成覆盖不同指标类型(LDL-C、HbA1c、收缩压),F1直接涨了0.09。
坑3:token消耗的隐性杀手是“重复输出”
第一轮零样本时,模型经常把输入文本复述一遍再给结果,导致completion tokens飙到400+。加了max_tokens=256后,反而因为截断导致JSON解析失败率上升。最终我用了“输出前加一个\n引导”的技巧——在Prompt末尾加一个{",强制模型直接输出JSON开头,这样能省80-120个completion tokens。
六、效果数据:三轮实验的完整对比
| 实验轮次 | Prompt类型 | 平均F1 | 平均token/条 | 5次运行方差 | 解析失败率 |
|---|---|---|---|---|---|
| 第一轮 | 零样本直给 | 0.42 | 186 | 0.18 | 12% |
| 第二轮 | 少样本+规则 | 0.68 | 214 | 0.09 | 5% |
| 第三轮 | Schema+自校验 | 0.87 | 241 | 0.02 | 1% |
最终我选了第三轮方案,但做了个小优化:把自校验步骤从Prompt中移到了后处理代码里。因为自校验会让模型多生成30-50个tokens,而在代码里用正则检查effect字段是否含数字,速度快且不耗token。优化后总消耗降到203 tokens/条,相比第一轮只增加9%,F1却翻了一倍。
七、总结:Prompt工程不是玄学,是实验科学
这次经历让我明白,Prompt工程的核心不是“会写提示词”,而是建立可量化的实验基准。每一步改动都要有数据支撑,而不是“感觉效果变好了”。另外,别迷信零样本——除非你的任务足够简单,否则少样本+结构化约束是性价比最高的组合。
最后留个彩蛋:把第三轮的Prompt翻译成英文,F1反而降了0.04,可能是医疗术语的中文语料更充分。所以,别盲目用英文Prompt跑中文任务,自己测了才知道。
如果你也在做类似的信息抽取,建议直接抄我第三轮的模板,把示例换成你的领域数据,跑一遍看看。有坑欢迎评论区交流。