一、问题背景:被“智能”反噬的医疗文本抽取
接手这个项目时,业务方要求从出院小结中抽取“药物-剂量-频次”三元组。最初我直接用LangChain+GPT-4o-mini的零样本Prompt,结果惨不忍睹:20条病历中,模型输出了大量“疑似”实体,但正确率只有4.2%(3/71)。更气人的是,模型把“每日三次”识别成了药物名,把“阿莫西林胶囊0.5g”中的“0.5g”当成了频次。这种幻觉在医疗场景是致命的。
二、环境与版本:一次Prompt实验的“基础设施”
# 实验环境
openai==1.35.0
langchain==0.2.1
Python 3.10.12
模型: gpt-4o-mini-2024-07-18
temperature=0.0 (绝对理性)
max_tokens=1024
测试集: 20条真实出院小结(已脱敏)
这里有个坑:langchain的ChatPromptTemplate在0.2版本后,from_template方法对{字符有转义要求。如果你在prompt里写JSON示例,必须用双重花括号{{}},否则直接报KeyError。我卡了半小时才反应过来。
三、方案设计:从“说人话”到“结构化约束”
我设计了5个版本的Prompt,核心变量是:示例数量、输出格式约束、领域词典。
| 版本 | Prompt特点 | token消耗/条 | 输出正确率 |
|---|---|---|---|
| v1 | 零样本,仅描述任务 | 642 | 4.2% |
| v2 | 增加“只输出JSON”约束 | 685 | 18.7% |
| v3 | 加入1个few-shot示例 | 812 | 45.3% |
| v4 | 3个示例+输出schema强制 | 1,204 | 71.2% |
| v5 | 5个示例+负面提示+词典 | 1,632 | 82.6% |
关键发现:token消耗增加154%,准确率提升近20倍。但v5的token消耗已达1,632/条,全量10万条病历的推理成本是$1632(按$0.075/1K token)。这逼着我在效果和成本间找平衡。
四、核心实现:v5最终版Prompt与调用代码
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
PROMPT_V5 = """你是医疗信息抽取专家。从出院小结中抽取药物实体,严格遵循以下规则:
【输出格式】仅返回JSON数组,每个对象包含:
{{"drug": "药物通用名", "dose": "单次剂量", "frequency": "用药频次"}}
【必须遵守】
1. 只抽取明确提到的“药物-剂量-频次”三元组,缺失的字段用null
2. 剂量单位必须统一为mg或ml,不能出现“粒”“片”等模糊单位
3. 频次只允许:qd/bid/tid/qid/qn(每日1次/2次/3次/4次/睡前),其他表述需转换
【参考示例】
输入: "患者术后口服阿莫西林胶囊0.5g,每日三次,连用7天"
输出: [{{"drug": "阿莫西林", "dose": "500mg", "frequency": "tid"}}]
输入: "拜新同30mg qd 口服,美托洛尔12.5mg bid"
输出: [{{"drug": "硝苯地平", "dose": "30mg", "frequency": "qd"}}, {{"drug": "美托洛尔", "dose": "12.5mg", "frequency": "bid"}}]
【负面示例】以下情况不要抽取:
- “每日三次”单独出现,无药物名 → 忽略
- “停药后改为”中的“改为”不构成新处方
现在处理以下文本:
{input_text}"""
llm = ChatOpenAI(model="gpt-4o-mini-2024-07-18", temperature=0.0)
prompt = ChatPromptTemplate.from_template(PROMPT_V5)
chain = prompt | llm.with_structured_output(schema=MedSchema)
# 实际调用时发现,必须用with_structured_output才能强制JSON
result = chain.invoke({"input_text": "患者因感染入院,给予头孢曲松2g ivgtt qd,同时口服对乙酰氨基酚650mg q6h prn"})
print(result.json())
# 输出: [{"drug": "头孢曲松", "dose": "2000mg", "frequency": "qd"}, {"drug": "对乙酰氨基酚", "dose": "650mg", "frequency": "q6h"}]
五、踩坑与优化:三个让人血压升高的瞬间
坑1:剂量单位换算陷阱。v4版本中,模型输出“0.5g”而不是“500mg”,导致下游匹配失败。我在v5中加入“统一为mg”的硬性规则,但代价是增加了token消耗——因为模型需要“思考”换算过程。
坑2:负面提示反而引入幻觉。当我写下“不要抽取‘每日三次’单独出现”后,模型开始对所有“每日”开头的文本都过度谨慎。最终用示例驱动替代规则禁止,把负面情况写成了完整的输入-输出对,准确率才稳定。
坑3:with_structured_output的隐式schema。直接用llm.with_structured_output()时,如果schema定义里用了Optional[str],模型会频繁输出None而不是null。需要显式声明field: str = Field(description="...", default=None)。
六、效果数据与成本分析
最终在20条测试集上(共112个真实三元组):
| 指标 | v1 | v3 | v5 |
|---|---|---|---|
| 精确率 | 4.2% | 45.3% | 82.6% |
| 召回率 | 2.7% | 38.4% | 77.7% |
| F1 | 3.3% | 41.6% | 80.1% |
| 平均延迟/条 | 1.2s | 1.8s | 2.4s |
但v5的token消耗是v1的2.5倍。我最终采用两阶段策略:先用v3做粗筛(成本低),再用v5精校(只对置信度低的样本)。实际生产环境F1达78.9%,成本降低41%。
七、总结:Prompt不是玄学,是工程
这次实验让我确信:在固定模型下,Prompt迭代是性价比最高的优化手段。但别迷信“万能Prompt模板”,你应该像调试代码一样调试Prompt——记录每次改动的变量、评估指标、token消耗。我的建议是:先跑零样本建立baseline,然后每次只改一个变量(要么加示例,要么改输出约束),用统一测试集对比。最后,别忘了经济学:当准确率提升带来的收益低于额外token成本时,停止优化。
另外,GPT-4o-mini的with_structured_output在复杂schema下仍会出错,如果业务对格式要求极高,建议在模型输出后加一层JSON校验和字段修正(我用pydantic做二次清洗)。下次我会写一篇关于“结构化输出陷阱与pydantic救援”的博客,敬请期待。