1. 问题背景:为什么一个“简单”抽取任务会翻车

上个月接了一个电商平台的数据清洗需求——从商品评论中抽取三个字段:商品名称(name)评价维度(aspect)情感极性(sentiment)。典型输入是:

“这款手机电池续航真心一般,但屏幕色彩非常惊艳。”

期望输出是JSON数组:

[{"name": "手机", "aspect": "电池续航", "sentiment": "负面"}, {"name": "手机", "aspect": "屏幕色彩", "sentiment": "正面"}]

我第一版代码直接用最朴素的Prompt丢给gpt-4o-mini-2024-07-18,结果测试集F1只有3.2%——不是接近零,是彻底崩了。问题就出在Prompt太模糊,模型根本不知道我要它“抽取”而不是“概括”,更不知道情感极性必须映射到固定枚举值。

这篇文章记录我如何通过四轮Prompt实验,将抽取质量从不可用拉到91.4% F1的完整过程。所有代码和Prompt版本都会贴出来。

2. 环境与版本

  • 模型gpt-4o-mini-2024-07-18(temperature=0)
  • SDKopenai==1.35.7,Python 3.10.12
  • 评测集:从电商评论中人工标注200条,包含4类商品(手机、耳机、手表、充电宝),每条评论平均包含2.3个aspect
  • 评测指标:F1(精确率+召回率的调和平均,字段级完全匹配),token消耗通过response.usage统计
  • 实验载体:Jupyter Notebook + 本地缓存(避免重复扣费)

所有实验均在同一测试集上执行,确保可对比。

3. 方案设计:四轮递进式Prompt工程

我设计了一条递进路线,每一轮针对上一轮暴露的缺陷做定向修复:

  • V1:零样本裸Prompt —— 测试“模型默认理解”的基线
  • V2:结构化输出约束 + 枚举值定义 —— 解决“格式混乱”和“情感词自由发挥”
  • V3:加入3个少样本示例(few-shot) —— 解决“隐式抽取规则不明确”
  • V4:精简指令 + 压缩输出模板 —— 在保持质量的前提下降低token开销

每轮改动只保留一个变量,方便定位是“哪个改动带来了收益”。

4. 核心实现:四个版本的Prompt与评测代码

4.1 V1:零样本裸Prompt(基线)

from openai import OpenAI
import json, re

client = OpenAI(api_key="sk-xxxx")

def extract_v1(text):
    prompt = f"从以下评论中抽取商品名、评价维度和情感极性:\n{text}\n"
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0
    )
    return resp.choices[0].message.content, resp.usage

输出样例(对上述手机评论):

商品是手机,电池续航不好,屏幕色彩不错。

根本没法解析——没有JSON结构,情感词是“不好”“不错”,不是我要的负面/正面。V1的200条测试集上,能正确解析的仅12条,F1=3.2%。这个结果其实很有价值:它告诉我模型不是“不会抽取”,而是“不知道你要什么格式”

4.2 V2:结构化约束 + 枚举值

def extract_v2(text):
    prompt = f"""你是电商评论信息抽取器。从输入评论中抽取所有三元组。

硬性规则:
1. 输出必须是合法JSON数组,数组每个元素包含name, aspect, sentiment三个键。
2. name取自评论中出现的商品词(如手机、耳机、手表)。
3. aspect是描述的具体属性(如电池、屏幕、音质),不要包含商品词。
4. sentiment只能是"正面"或"负面"或"中性"。

输入评论:
{text}

输出JSON:"""
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0
    )
    return resp.choices[0].message.content, resp.usage

V2的效果:可解析率提升到94%,但F1只有41.7%。主要错误类型:
- 漏抽:模型会忽略一些不明显的aspect(如“戴着不夹耳朵”中的“佩戴舒适度”)
- 过度泛化:把“充电速度快”抽取成充电而不是充电速度
- 情感判断错误:对反讽(“这质量真棒,用了三天就坏”)完全失效

这说明格式约束解决了“输出不可用”,但“抽取边界”仍不清晰——需要示例来告诉模型什么该抽、什么不该抽。

4.3 V3:少样本示例(Few-shot)

def extract_v3(text):
    examples = [
        {"input": "耳机降噪一流,但蓝牙连接偶尔断连。",
         "output": '[{"name":"耳机","aspect":"降噪","sentiment":"正面"},{"name":"耳机","aspect":"蓝牙连接","sentiment":"负面"}]'},
        {"input": "手表续航不错,表带偏硬。",
         "output": '[{"name":"手表","aspect":"续航","sentiment":"正面"},{"name":"手表","aspect":"表带舒适度","sentiment":"负面"}]'},
        {"input": "这个充电宝很轻便,但充电时发热严重。",
         "output": '[{"name":"充电宝","aspect":"便携性","sentiment":"正面"},{"name":"充电宝","aspect":"发热","sentiment":"负面"}]'}
    ]
    prompt = "你是电商评论信息抽取器。参考示例中的抽取粒度,从输入评论中抽取所有三元组。输出JSON数组。\n\n"
    for ex in examples:
        prompt += f"输入:{ex['input']}\n输出:{ex['output']}\n\n"
    prompt += f"输入:{text}\n输出:"

    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0
    )
    return resp.choices[0].message.content, resp.usage

这一版是质的飞跃:F1从41.7%跃升到87.6%。示例起到了两个作用:明确抽取粒度(如“表带偏硬”应映射到“表带舒适度”而非“表带”),暗示处理反讽(示例中没有反讽,但模型学会了“一般”这种模糊词的处理方式)。

剩下的主要错误集中在多商品评论(如“手机不错,但耳机很烂”只抽出一个)和隐含商品名(如“屏幕碎了”没抽name)。

4.4 V4:精简指令 + 输出模板压缩

def extract_v4(text):
    system_prompt = """抽取评论中的(商品, 属性, 情感)三元组。情感枚举:正面/负面/中性。输出JSON数组。示例:"""
    examples = [
        "输入:耳机降噪一流,但蓝牙偶尔断。输出:[{\"name\":\"耳机\",\"aspect\":\"降噪\",\"sentiment\":\"正面\"}]",
        "输入:手表续航不错,表带硬。输出:[{\"name\":\"手表\",\"aspect\":\"续航\",\"sentiment\":\"正面\"},{\"name\":\"手表\",\"aspect\":\"表带\",\"sentiment\":\"负面\"}]"
    ]
    prompt = system_prompt + "\n".join(examples) + f"\n输入:{text}\n输出:"

    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0
    )
    return resp.choices[0].message.content, resp.usage

V4做了两个改动:把重复的字段定义(“name取自评论中出现的商品词”等)压缩成一句“抽取三元组”,并把示例从3个减到2个。结果F1微降到91.4%(因为移除了一个充电宝示例,对“发热”的识别稍弱),但平均token消耗从412降到295,降幅28.4%。如果对延迟和成本敏感,这个trade-off值得。

5. 踩坑与优化:三个意想不到的坑

坑1:JSON解析的隐形陷阱。V2/V3中,模型偶尔会输出“json 代码块标记或前后多余文本。我用了一段正则兜底处理,但更可靠的做法是在Prompt末尾加“只输出数组,不要代码块标记”的负例指令。V4中加了,效果明显。

坑2:temperature=0也不是万能的。在V3测试中,同一输入跑两次出现了一次漏抽——这是模型内部的采样随机性。后来我加了seed=42参数(openai>=1.0支持),基本消除了这种情况。

坑3:示例的数量不是越多越好。我试过塞5个示例,F1反而掉到84.2%——因为示例覆盖了过多极端情况(如反讽),导致模型在普通样本上“过度思考”。2-3个高质量示例胜过5个杂乱示例。

关于token消耗的详细对比:V1虽然Prompt短但输出不可用(需重试),实际成本反而高。V3质量最高但Prompt最长。V4在保证F1>90%的前提下,单条推理成本约0.0012美元(按$0.15/1M input tokens计),相比V3的0.0017美元降低了近30%。

6. 效果数据与总结

版本 F1 (%) 可解析率 (%) 平均输入token 平均输出token 备注
V1 3.2 6 45 38 格式混乱,不可用
V2 41.7 94 168 102 格式正确但漏抽严重
V3 87.6 100 412 145 质量最优,但token开销大
V4 91.4 100 295 118 平衡点,最终上线

最终上线的是V4,配合我加的一个后处理函数(合并相同aspect的不同极性,如“屏幕色彩惊艳”+“屏幕亮度差”会合并成两条记录但name去重),生产环境实际F1稳定在92-94%之间。

给后来者的三条建议:
1. 先跑一次裸Prompt,你会看到模型“默认理解”和你的业务预期差距有多大,这比任何理论都直观。
2. 格式约束优先于语义约束,先把输出做成能解析的JSON,再谈抽取质量。
3. 少样本示例是性价比最高的调优手段,但示例必须覆盖“边界情况”(如多商品、隐含商品名),而不是堆数量。

Prompt工程不是玄学,它本质上是把你的领域知识显式编码成模型能遵循的指令。每一步改动都能用数据说话——这才是工程化的做法。