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%——几乎全军覆没。问题有三:

  1. 输出格式不可控:模型返回的是自然语言句子,比如「医院是北京协和医院,疾病是肺炎,赔付了三千元」,我解析的时候正则匹配全乱套。
  2. 实体值不标准化:金额有「3000元」「叁仟元整」「人民币3000元」三种写法,模型照单全收,没有统一。
  3. 缺失不处理:有些文本里没有「就诊医院」,模型就自己编一个,这种幻觉在零样本下特别严重。

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压缩缓存策略上:

  1. system prompt从180字压缩到95字:删掉冗余的「你是专家」描述,保留纯规则。实测发现模型对角色扮演的敏感度远低于对具体规则的敏感度。
  2. few-shot示例从5个减到2个:因为JSON格式已经约束了输出,示例过多反而让模型模仿示例中的特定措辞(比如金额单位),不如减少干扰。
  3. 引入语义缓存:用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解决所有问题,工程化的核心是约束、迭代和测量。希望这篇记录能帮你少踩几个坑。