1. 问题背景:当正则表达式遇上非结构化文本
上个月接了一个保险理赔系统的需求:从PDF转出的纯文本中抽取「就诊医院」「诊断疾病」「赔付金额」三个实体。数据是真实的理赔单,但质量极其糟糕——有手写扫描件的OCR残留,有表格线被解析成乱码,还有同一字段在文书中出现多次但值不一致的情况。
初始方案用的是正则+规则引擎,在50条测试集上准确率勉强到65%。但一旦遇到「中国人民解放军总医院(301医院)」这种别名,或者「赔付人民币贰仟叁佰元整」这种大写金额,规则就彻底失效。于是决定换成LLM方案,用GPT-4o-mini(成本考虑),开始我的Prompt工程踩坑之旅。
2. 环境与版本:确定实验基线
模型:gpt-4o-mini-2024-07-18
API:openai-python 1.35.0
温度:0.3(初始值)
max_tokens:500
测试集:50条真实理赔单文本(平均长度320字符)
评估指标:F1(实体级别,精确匹配)
这里多说一句,为什么用mini而不是full版?因为业务方要求单次调用成本控制在¥0.01以内,mini的价格是$0.15/1M input tokens,full版要$2.50,差了一个数量级。但mini的能力天花板低,所以Prompt的设计空间更小,对工程化要求更高。
3. 方案设计:第一轮——最朴素的零样本Prompt
我的第一个Prompt长这样,简单直接但天真:
# 实验1:零样本基础版
def extract_entities_v1(text):
prompt = f"""从以下理赔文本中抽取实体:
1. 就诊医院
2. 诊断疾病
3. 赔付金额
文本:{text}
"""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
temperature=0.3
)
return response.choices[0].message.content
结果惨不忍睹。50条测试集,F1只有3.2%——几乎全军覆没。问题有三:
- 输出格式不可控:模型返回的是自然语言句子,比如「医院是北京协和医院,疾病是肺炎,赔付了三千元」,我解析的时候正则匹配全乱套。
- 实体值不标准化:金额有「3000元」「叁仟元整」「人民币3000元」三种写法,模型照单全收,没有统一。
- 缺失不处理:有些文本里没有「就诊医院」,模型就自己编一个,这种幻觉在零样本下特别严重。
4. 核心实现:第二轮——结构化JSON约束
痛定思痛,我加了三个关键改动:JSON输出格式、system prompt里声明角色、few-shot示例。这是效果提升最大的一轮。
# 实验2:结构化约束+few-shot
SYSTEM_PROMPT_V2 = """你是保险理赔信息抽取专家。严格遵循以下规则:
1. 只输出JSON对象,不要任何解释文字
2. 医院名称必须是全称,不得缩写(如"301医院"需补全为"中国人民解放军总医院")
3. 金额统一转为数字格式,单位默认为元,如"叁仟元整"转为3000
4. 字段缺失时输出null,禁止编造
5. 如有多个医院/疾病,取第一个出现的
示例:
输入:患者因急性阑尾炎在北京市朝阳区人民医院住院,自付金额贰仟壹佰元。
输出:{{"hospital": "北京市朝阳区人民医院", "disease": "急性阑尾炎", "amount": 2100}}"""
def extract_entities_v2(text):
messages = [
{"role": "system", "content": SYSTEM_PROMPT_V2},
{"role": "user", "content": f"请抽取以下文本:{text}"}
]
response = client.chat.completions.create(
model="gpt-4o-mini", messages=messages,
temperature=0.1, # 降低温度,减少随机性
response_format={"type": "json_object"} # 强制JSON模式
)
import json
return json.loads(response.choices[0].message.content)
这一轮直接跳到了F1=28.6%。关键差异分析:
- response_format强制JSON:解决了格式解析问题,输出直接可json.loads
- few-shot中的金额转换示例:模型学会了把中文大写转阿拉伯数字,这比写规则正则靠谱得多
- 温度从0.3降到0.1:抽取任务需要确定性,太高温度会导致同一文本多次抽取结果不一致
但28.6%还是不够看。我去翻了一下错误case,发现主要问题在于长文本截断——有些理赔单超过500字符,max_tokens=500导致输出被截断,JSON解析直接报错。还有一部分是多实体冲突:文本里出现了「初诊:高血压,后确诊为冠心病」,模型取了第一个但实际赔付针对的是冠心病。
5. 踩坑与优化:第三轮——上下文窗口与冲突消解
这一轮我做了两个针对性优化:
优化1:动态max_tokens。根据输入长度计算输出空间,公式很简单:max_tokens = min(2000, len(text) * 1.5 + 200)。同时把输入文本做截断,超过1500字符时取前后各750字符(理赔单关键信息通常分布在首尾)。
优化2:多实体消解规则。在system prompt里加了一条:「如果出现多个诊断,选择与费用明细关联最紧密的那个。如果费用明细提到'手术费',优先选择需要手术的疾病。」这个业务规则虽然笨拙,但确实有效——因为LLM不懂保险精算,但给个判定优先级它能执行。
# 实验3:动态长度+冲突消解规则
SYSTEM_PROMPT_V3 = SYSTEM_PROMPT_V2 + """
冲突消解规则(按优先级):
- 若出现多个医院,选择住院时间最长的那个
- 若出现多个疾病诊断,选择与手术/治疗项目关联最紧密的
- 若金额出现多个数值,选择最大的那个
"""
def extract_entities_v3(text):
# 动态截断:保留首尾,中间用省略号
if len(text) > 1500:
text = text[:750] + "……" + text[-750:]
max_tokens = min(2000, int(len(text) * 1.5) + 200)
messages = [
{"role": "system", "content": SYSTEM_PROMPT_V3},
{"role": "user", "content": f"请抽取以下文本:{text}"}
]
response = client.chat.completions.create(
model="gpt-4o-mini", messages=messages,
temperature=0.1, max_tokens=max_tokens,
response_format={"type": "json_object"}
)
return json.loads(response.choices[0].message.content)
这一轮F1提升到35.9%。但暴露了一个新问题:token消耗暴涨。因为截断后的文本+system prompt变长,平均每次调用input tokens从320涨到780,成本翻倍。我看了一下账单,50条测试集花了$0.23,虽然绝对值不高,但生产环境一天调用10万次就hold不住了。
6. 效果数据:第四轮——成本控制的终极方案
最后一轮优化聚焦在prompt压缩和缓存策略上:
- system prompt从180字压缩到95字:删掉冗余的「你是专家」描述,保留纯规则。实测发现模型对角色扮演的敏感度远低于对具体规则的敏感度。
- few-shot示例从5个减到2个:因为JSON格式已经约束了输出,示例过多反而让模型模仿示例中的特定措辞(比如金额单位),不如减少干扰。
- 引入语义缓存:用embedding相似度>0.95的请求直接复用上次结果,实测命中率约30%,省了这部分token。
最终效果对比:
| 版本 | F1 | 平均input tokens | 平均输出tokens | 单次成本($) |
|---|---|---|---|---|
| V1零样本 | 3.2% | 320 | 180 | 0.0002 |
| V2结构化 | 28.6% | 410 | 120 | 0.0003 |
| V3动态长度 | 35.9% | 780 | 260 | 0.0008 |
| V4压缩+缓存 | 41.7% | 460 | 190 | 0.0004 |
V4比V3成本降了50%,F1反而涨了5.8个百分点——因为压缩后的prompt去掉了噪音,模型更容易聚焦在关键规则上。
7. 总结与工程化建议
四轮迭代下来,我的核心感受是:
- Prompt工程不是写作文,是写代码——每条规则都要可测试、可回滚。我用git管理prompt版本,每个版本对应一组测试集结果。
- 结构化输出是底线:不强制JSON格式,后面所有后处理都是灾难。
response_format参数省了90%的解析错误。 - 温度参数要按任务调:抽取任务0.1以下,生成任务0.7以上,不要一个参数走天下。
- token消耗要算总账:省prompt长度可能增加回复错误率,但压缩+缓存可以兼顾。我的经验是,把不常用的规则从system prompt移到user prompt里的条件分支,能省15-20%的input tokens。
最后说句大实话:41.7%的F1距离生产可用(通常要85%+)还差得远。后续如果做正事,我会换gpt-4o或者微调一个BERT-NER模型,但那是另一篇文章了。这次实验给我的教训是:不要指望一个Prompt解决所有问题,工程化的核心是约束、迭代和测量。希望这篇记录能帮你少踩几个坑。