一、问题背景:为什么我盯上了“情感分类+原因抽取”
上周接了一个电商数据分析的需求:从用户评论里自动判断情感倾向(正向/负向/中性),同时要抽取出导致该情感的核心原因短语(比如“物流太慢”)。用传统的BERT微调?标注数据只有500条,训出来泛化差。用LLM?感觉Prompt随便写写就能用,但实际测试时发现——模型经常把原因答成整句话,或者情感和原因对不上号。
这活儿看着简单,实际是个典型的“结构化抽取”任务。我决定用Prompt Engineering系统性调优,并记录每一版的输出质量和Token开销。实验环境:Python 3.10,openai库1.12.0,模型gpt-4o-2024-08-06(下文简称GPT-4o),temperature固定0.2,max_tokens=800。
二、环境与版本:先把实验台子搭稳
# 环境依赖
# openai==1.12.0
# python-dotenv==1.0.0
import os
import json
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
MODEL = "gpt-4o-2024-08-06"
TEMPERATURE = 0.2
MAX_TOKENS = 800
# 测试数据:手工标注了20条,用于快速验证;另用180条做最终统计(共200条)
TEST_SAMPLES = [
{"text": "手机屏幕很清晰,但电池掉电快,半天就没电了", "sentiment": "负向", "reason": "电池掉电快"},
{"text": "客服态度非常好,主动帮我解决了问题,点赞", "sentiment": "正向", "reason": "客服态度好"},
# ...剩余样本略
]
版本说明:之所以选gpt-4o-2024-08-06而不是gpt-4-turbo,是因为它在JSON mode下更稳定(官方文档也提到8月版修复了部分结构化输出的bug)。所有测试在2024年9月5日完成,API计费按$0.0025/1K输入tokens,$0.01/1K输出tokens。
三、方案设计:三版Prompt的演进逻辑
V1:零样本基线(最朴素写法)
你是电商评论分析助手。请判断评论情感(正向/负向/中性),并给出原因。
评论:{text}
输出格式:情感:xxx,原因:xxx
V2:结构化指令(明确任务分解+输出约束)
你是电商评论分析专家。任务分两步:
1. 判断整体情感倾向(仅限:正向/负向/中性)
2. 抽取导致该情感的核心原因短语(不超过8个字,必须是评论中的原词或原词压缩)
要求:
- 情感和原因必须逻辑一致(比如情感是“负向”,原因不能是“屏幕清晰”)
- 若情感为“中性”,原因写“无”
- 输出严格按JSON格式:{"sentiment": "...", "reason": "..."}
评论:{text}
V3:进阶版(Few-shot示例 + 边界条件定义)
在V2基础上,增加了2个典型示例(一个负向、一个正向),并且明确写了“边界情况处理”:
你是电商评论分析专家。请严格按以下规则处理:
【情感判定规则】
- 存在明显正面词(好/快/清晰/满意)且无负面词 → 正向
- 存在明显负面词(差/慢/模糊/掉电快)→ 负向
- 好坏参半时,取主导情绪(看负面词是否影响核心体验)
- 纯客观描述(如“发货地点是上海”)→ 中性
【原因抽取规则】
- 必须从评论中抽取原词或词性压缩,禁止生成评论中不存在的词
- 长度控制在2-6个汉字
- 若情感为中性,reason字段固定为"无"
【示例】
评论:这耳机音质不错,但降噪太差,坐地铁根本听不清
输出:{"sentiment": "负向", "reason": "降噪太差"}
评论:包装很精美,还送了小礼品,惊喜
输出:{"sentiment": "正向", "reason": "送小礼品"}
【任务】按上述规则处理以下评论,只输出JSON:
评论:{text}
设计逻辑:V2通过“任务分解+格式约束”解决输出乱的问题;V3通过Few-shot和边界规则解决“情感判断不准”和“原因抽取过长”的问题。
四、核心实现:三段式Call API代码(含踩坑)
def infer_with_prompt(text: str, prompt_version: str) -> dict:
if prompt_version == "v1":
messages = [
{"role": "system", "content": "你是电商评论分析助手。"},
{"role": "user", "content": f"请判断评论情感(正向/负向/中性),并给出原因。\n评论:{text}\n输出格式:情感:xxx,原因:xxx"}
]
elif prompt_version == "v2":
messages = [
{"role": "system", "content": "你是电商评论分析专家。"},
{"role": "user", "content": f"任务分两步:\n1. 判断情感(正向/负向/中性)\n2. 抽取原因(不超过8字,原词压缩)\n\n输出JSON:{{\"sentiment\": \"...\", \"reason\": \"...\"}}\n\n评论:{text}"}
]
else: # v3
messages = [
{"role": "system", "content": "你是电商评论分析专家,严格按规则输出。"},
{"role": "user", "content": f"【规则】...【示例】...【任务】\n评论:{text}\n只输出JSON"}
]
resp = client.chat.completions.create(
model=MODEL,
messages=messages,
temperature=TEMPERATURE,
max_tokens=MAX_TOKENS,
response_format={"type": "json_object"} # 仅v2/v3启用
)
# 解析输出
content = resp.choices[0].message.content
try:
result = json.loads(content)
except json.JSONDecodeError:
# 踩坑1:偶尔输出会带```json```标记或中文字符
content = content.replace("```json", "").replace("```", "").strip()
# 踩坑2:v1版输出是"情感:正向,原因:...",需手动切分
if "情感" in content and "原因" in content:
senti = content.split("情感:")[1].split(",")[0]
reason = content.split("原因:")[1].strip()
result = {"sentiment": senti, "reason": reason}
else:
result = {"sentiment": "解析失败", "reason": content[:30]}
token_usage = resp.usage.total_tokens
return result, token_usage
踩坑记录:
1. response_format={"type": "json_object"} 在v2/v3中必须设置,否则GPT-4o偶尔会输出markdown代码块包裹的JSON,解析直接报错。
2. v1版没有JSON约束,输出格式极不稳定(有时“情感:正向 原因:...”没有逗号)。后续统计时我做了字符串容错,但准确率依然被拖累。
3. 中文标点陷阱:v3示例里用了全角逗号“,”但模型偶尔输出半角“,”,json.loads能处理,但如果你用正则去匹配,必踩坑。
五、效果数据:Token消耗与输出质量的真实对比
我跑了200条测试评论(80正向、80负向、40中性),统计如下:
| 版本 | 平均输入Tokens | 平均输出Tokens | 总Tokens/请求 | 情感F1 | 原因精确匹配率 | 单条成本($) |
|---|---|---|---|---|---|---|
| V1基线 | 412 | 89 | 501 | 0.712 | 31.5% | 0.0012 |
| V2结构化 | 538 | 96 | 634 | 0.834 | 58.0% | 0.0016 |
| V3进阶 | 789 | 121 | 910 | 0.925 | 79.5% | 0.0023 |
关键发现:
- V2比V1提升17.1%的F1,代价是Token增加26.5%(结构指令本身消耗+更长的输出)。
- V3比V2提升9.1%的F1,但Token暴涨43.5%(因为塞了2个示例+详细规则)。
- 原因精确匹配率(要求抽取结果与人工标注完全一致)V1只有31.5%,V3达到79.5%。这说明Few-shot对“抽取类”任务的约束力远大于单纯指令描述。
成本核算:如果每天调用1万次,V3比V1贵约$11/天。但对业务来说,F1从0.712到0.925的差距意味着大量误判投诉,这$11花得值。
六、踩坑与优化:那些没写在教科书里的细节
-
规则别写太满:V3第一版我写了6条边界规则(比如“若出现‘但是’则以后半句为主”),结果模型过于纠结,输出延迟从1.2s涨到1.8s,F1反而降了2%。后来砍到3条核心规则,效果回升。LLM不是规则引擎,规则越多越容易混乱。
-
示例要选“争议样本”:我最初Few-shot选了两个非常典型的(纯正向、纯负向),F1只有0.89。换成“好坏参半”(如“屏幕清晰但电池差”)和“隐含情感”(如“这价格也就这样吧”)后,直接升到0.925。示例的代表性远比数量重要。
-
Token优化技巧:V3里我把JSON schema从
{"sentiment": "...", "reason": "..."}简化为{"s": "...", "r": "..."},Token立刻省了15%,但解析时需映射回全称。F1没变。如果成本敏感,可以牺牲可读性换token。 -
关于temperature:我试过0.0和0.5。0.0时输出稳定但偶尔机械重复;0.5时原因抽取更灵活(比如“掉电快”可能变成“电池不耐用”),但精确匹配率下降6%。最终选0.2是平衡点。
七、总结:Prompt Engineering的成本收益决策
回看这次实验,我最大的体感是:Prompt不是“写出来”的,是“试出来”的。V1到V3的每一步改动都有明确的性能数字支撑,而不是拍脑袋。
- 如果任务简单(比如只判断情感不抽原因),V1完全够用,省Token省心。
- 如果任务需要结构化输出且对准确性有硬要求(比如自动化工单分类),至少上V2。
- V3适合“原因抽取”这类对格式和语义双重敏感的任务。但注意,Few-shot的Token开销会随着示例数量线性增长,建议最多3个示例,再多收益会递减。
最后说个反直觉的发现:max_tokens=800其实大部分请求只用掉120左右。之前我习惯设512,后来发现GPT-4o对短输出任务,max_tokens设大反而会偶尔“凑字数”。设成800后,模型知道空间充裕,输出反而更克制。这算是意外收获。
以上测试代码和数据均可在我的GitHub仓库(链接见评论区)找到,欢迎跑一跑你的数据集——但记得,Prompt的优化永远依赖你的具体任务和数据分布,别人的配方只能当起点。