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) - SDK:
openai==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工程不是玄学,它本质上是把你的领域知识显式编码成模型能遵循的指令。每一步改动都能用数据说话——这才是工程化的做法。