一、问题背景:一个看似简单的抽取任务
事情起因很简单。我们做的是一个电商评论分析后台,需要从用户评论里抽取结构化信息,字段包括:
sentiment:情感倾向,枚举 positive / neutral / negativeproduct_aspect:评论针对的产品维度,枚举 物流 / 质量 / 价格 / 客服 / 外观 / 其他rating_mention:评论中是否明确提到评分,布尔值summary:一句话摘要,不超过20字
输入是类似这样的中文评论:
"东西收到了,包装挺用心的,但是用了两天就发现按键有点失灵,客服态度还行但处理速度太慢,整体有点失望。"
我一开始觉得这任务不难,直接丢给模型就行。结果第一版Prompt上线后,人工抽检200条,准确率只有72%,而且有11%的输出根本没法 json.loads。这篇博客就是把这次调优过程完整记录下来——不是讲理论,是讲我实际怎么改、每次改完token涨了多少、质量涨了多少。
二、环境与版本
先交代清楚实验环境,不然数据没法复现:
- 模型:
gpt-4o-mini,快照版本gpt-4o-mini-2024-07-18 - SDK:
openai==1.40.0(Python) - 参数:
temperature=0,top_p=1,max_tokens=512 - 测试集:200条真实中文电商评论,人工标注了golden label
- 评测脚本:字段级完全匹配(exact match),四个字段全对才算这条通过
- 计费口径:按OpenAI官方
gpt-4o-mini价格,输入 $0.15 / 1M tokens,输出 $0.60 / 1M tokens
评测代码的核心逻辑大概长这样:
import json
from openai import OpenAI
client = OpenAI(api_key="sk-xxx")
def run_prompt(prompt_template, comment):
resp = client.chat.completions.create(
model="gpt-4o-mini-2024-07-18",
temperature=0,
max_tokens=512,
messages=[
{"role": "system", "content": prompt_template},
{"role": "user", "content": comment},
],
)
return resp.usage.prompt_tokens, resp.usage.completion_tokens, resp.choices[0].message.content
def evaluate(prompt_template, dataset):
passed, total = 0, 0
prompt_tokens_sum, completion_tokens_sum = 0, 0
parse_fail = 0
for item in dataset:
pt, ct, raw = run_prompt(prompt_template, item["comment"])
prompt_tokens_sum += pt
completion_tokens_sum += ct
try:
pred = json.loads(raw)
except json.JSONDecodeError:
parse_fail += 1
continue
total += 1
if pred == item["label"]:
passed += 1
return {
"accuracy": passed / total if total else 0,
"parse_fail_rate": parse_fail / len(dataset),
"avg_prompt_tokens": prompt_tokens_sum / len(dataset),
"avg_completion_tokens": completion_tokens_sum / len(dataset),
}
三、方案设计:四个版本的Prompt
我设计了四个版本,逐层加约束。核心变量是:字段定义、few-shot示例、输出格式约束、边界规则。
V1:直白提问
你是一个信息抽取助手,请从下面的评论中抽取情感、产品维度、是否提到评分和摘要,用JSON输出。
结果:准确率72%,平均prompt token 38,completion token 74,解析失败率11%。问题很明显——模型不知道枚举值范围,sentiment 有时输出"正面"、有时输出"positive",product_aspect 更是自由发挥。
V2:加字段定义和枚举
你是一个信息抽取助手。请从用户评论中抽取以下字段,严格输出JSON:
- sentiment: 情感倾向,只能是 "positive" / "neutral" / "negative" 之一
- product_aspect: 评论针对的产品维度,只能是 "物流" / "质量" / "价格" / "客服" / "外观" / "其他" 之一
- rating_mention: 是否明确提到评分,布尔值 true / false
- summary: 一句话摘要,不超过20字
只输出JSON,不要输出任何解释。
结果:准确率85%,prompt token 121,completion token 68,解析失败率降到3%。枚举约束一加,字段值错乱的问题基本消失。
V3:加few-shot示例
在V2基础上加了2个示例(一正一负,覆盖多维度评论):
示例1:
评论:物流很快,第二天就到了,但是价格比别家贵了不少。
输出:{"sentiment": "neutral", "product_aspect": "物流", "rating_mention": false, "summary": "物流快但价格偏贵"}
示例2:
评论:质量太差了,用了三天就坏,再也不会买。
输出:{"sentiment": "negative", "product_aspect": "质量", "rating_mention": false, "summary": "质量差,三天损坏"}
结果:准确率91%,prompt token 289,completion token 67,解析失败率1.5%。few-shot对"多维度评论该选哪个aspect"的歧义帮助最大。
V4:加边界规则 + 输出schema约束
V3之后我去看bad case,发现三类问题反复出现:
- 评论同时提到物流和质量时,
product_aspect该选哪个?模型摇摆不定。 summary经常超过20字。- 少数输出带markdown代码块
json ...,导致解析失败。
针对这三点,V4加了优先级规则、字数硬约束,并用了 OpenAI 的 response_format={"type": "json_object"}:
你是一个信息抽取助手。请从用户评论中抽取以下字段,严格输出JSON对象:
字段定义:
- sentiment: "positive" / "neutral" / "negative"
- product_aspect: "物流" / "质量" / "价格" / "客服" / "外观" / "其他"
- rating_mention: true / false
- summary: 中文摘要,不超过20字
边界规则:
1. 如果评论同时提到多个维度,选择被吐槽或称赞最强烈的那一个;无法判断时选"其他"。
2. 如果评论没有明确情感词,但有明显负面事实(如"坏了""太慢"),判为 negative。
3. summary 必须≤20个中文字符,不得包含标点以外的英文。
只输出JSON,不要markdown代码块,不要解释。
调用侧改成:
resp = client.chat.completions.create(
model="gpt-4o-mini-2024-07-18",
temperature=0,
max_tokens=512,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": V4_PROMPT},
{"role": "user", "content": comment},
],
)
结果:准确率96%,prompt token 342,completion token 71,解析失败率0.5%。
四、踩坑与优化
坑1:few-shot不是越多越好。 我试过加到5个示例,准确率只从91%涨到92%,但prompt token从289涨到610,单条成本几乎翻倍。2个示例是性价比拐点。
坑2:response_format=json_object 不是万能的。 它保证输出是合法JSON,但不保证字段名和枚举值对。V3时期我一度以为开了这个就不用管格式了,结果模型输出了 {"sentiment": "好评"} 这种合法但错误的JSON。格式约束和语义约束是两回事。
坑3:temperature=0 也有波动。 同样的输入,跑两次偶尔结果不同。所以评测必须跑全量200条,不能抽样几条看。我第一版只测了20条,准确率虚高到90%,全量一跑就掉到72%。
坑4:中文token计数。 "不超过20字"这个约束,模型经常理解成20个token。中文一个字大约1-2个token,导致summary实际偏长。后来我改成在prompt里明确写"≤20个中文字符",并加了后处理截断兜底。
五、效果数据
四个版本在200条测试集上的对比:
| 版本 | 准确率 | 解析失败率 | 平均prompt token | 平均completion token | 单条成本(美元) |
|---|---|---|---|---|---|
| V1 | 72% | 11.0% | 38 | 74 | $0.000050 |
| V2 | 85% | 3.0% | 121 | 68 | $0.000059 |
| V3 | 91% | 1.5% | 289 | 67 | $0.000084 |
| V4 | 96% | 0.5% | 342 | 71 | $0.000094 |
单条成本从V1的 $0.000050 涨到V4的 $0.000094,接近翻倍。但注意——V1有11%的解析失败,失败后要么重试要么人工兜底,一次重试就多花一份钱。算上重试成本,V1的实际单条成本约 $0.000056,而准确率只有72%。用96%的准确率换不到一倍的成本,这笔账很划算。
按每天10万条评论算:V1每天约$5.0,V4每天约$9.4。多花4.4美元,少处理2.4万条错误数据,人力成本省下来的远不止这点。
六、总结
这次调优让我最深的三个体会:
第一,Prompt工程的核心是消除歧义,不是堆字数。V2只加了枚举定义,准确率就涨13个点,比任何花哨技巧都管用。
第二,token消耗要看总账,不是单条账。长Prompt贵,但错误重试和人工兜底更贵。V4的Prompt是V1的9倍长,但综合成本只高不到一倍。
第三,评测必须全量、必须固定temperature、必须用golden label。凭感觉调Prompt,最后一定翻车。
下一步我打算把V4的Prompt再压一压——把few-shot示例从完整JSON改成字段级说明,看能不能在保持96%的同时把prompt token压到250以内。有结果再写一篇。