一、问题背景
上个月接了个需求:从用户对商品的评论里抽结构化字段——sentiment(正/负/中)、category(物流/质量/价格/客服/其他)、keywords(最多 3 个名词短语)、has_refund_intent(布尔)。下游要直接入库,所以对 JSON 合法性要求很硬。
一开始我随手写了版 Prompt 就上线了,结果监控里 JSON 解析失败率 8% 左右,category 经常被模型自由发挥成“售后服务”“配送速度”这种同义变体,下游枚举对不上。于是决定老老实实做一轮 Prompt 对比实验,而不是凭感觉改。
二、环境与版本
- 模型:
gpt-4o-mini,快照gpt-4o-mini-2024-07-18 - SDK:
openai==1.40.2(Python) - 参数:
temperature=0,max_tokens=256,top_p=1 - 评测集:人工标注的 300 条中文电商评论,覆盖 5 个类目,正负中各 40%
- 评测指标:字段级 F1(4 个字段分别算再平均)、JSON 解析成功率、平均输入/输出 token、P95 延迟
- 计费参考:
gpt-4o-mini输入 $0.15 / 1M tokens,输出 $0.60 / 1M tokens(2024 年 8 月价格)
评测脚本核心逻辑就是批量跑 + 用 json.loads 判合法性 + 和标注比对,下面直接给代码。
三、方案设计
我设计了 4 个版本,逐步加约束,想看看到底是哪一步在起作用:
| 版本 | 思路 | 关键差异 |
|---|---|---|
| V1 | 自然语言描述任务 | 无格式约束,靠模型自觉 |
| V2 | 加 JSON schema 示例 | few-shot 1 条 |
| V3 | 枚举 + 强约束 | 明确枚举值、禁止额外字段 |
| V4 | V3 + 输出裁剪 + 缓存前缀 | 精简指令,利用 prompt caching |
V1 就是典型的“帮我抽一下”:
你是一个评论分析助手,请从下面的评论中提取情感、类别、关键词,以及是否有退款意图。
评论:{text}
V2 加了一条 few-shot,V3 把枚举写死并加了“只输出 JSON,不要 markdown 代码块”,V4 则把 system prompt 精简到 90 token 以内,并把固定指令放前面以命中 OpenAI 的自动 prefix caching(≥1024 token 才触发,我们这批短 prompt 其实没命中,后面细说这个坑)。
四、核心实现
先给评测脚本,可直接跑(把 OPENAI_API_KEY 换成自己的):
import json, time, statistics
from openai import OpenAI
client = OpenAI(api_key="sk-xxx")
MODEL = "gpt-4o-mini-2024-07-18"
PROMPTS = {
"v1": "你是一个评论分析助手,请从下面的评论中提取情感、类别、关键词,以及是否有退款意图。\n评论:{text}",
"v3": (
"从评论中抽取字段,只输出 JSON,不要代码块、不要解释。\n"
"字段:sentiment∈{positive,negative,neutral};"
"category∈{logistics,quality,price,service,other};"
"keywords: 最多3个名词短语的数组;has_refund_intent: boolean。\n"
"评论:{text}"
),
}
def run(prompt_tpl, texts):
rows = []
for t in texts:
t0 = time.time()
resp = client.chat.completions.create(
model=MODEL,
temperature=0,
max_tokens=256,
messages=[{"role": "user", "content": prompt_tpl.format(text=t)}],
)
dt = (time.time() - t0) * 1000
raw = resp.choices[0].message.content
ok = True
try:
obj = json.loads(raw)
except Exception:
ok, obj = False, None
rows.append({
"raw": raw, "ok": ok, "obj": obj, "latency_ms": dt,
"in_tok": resp.usage.prompt_tokens,
"out_tok": resp.usage.completion_tokens,
})
return rows
def summarize(rows):
ok_rate = sum(r["ok"] for r in rows) / len(rows)
lat = sorted(r["latency_ms"] for r in rows)
return {
"parse_ok": round(ok_rate, 4),
"avg_in_tok": round(statistics.mean(r["in_tok"] for r in rows), 1),
"avg_out_tok": round(statistics.mean(r["out_tok"] for r in rows), 1),
"p95_latency_ms": round(lat[int(len(lat) * 0.95) - 1], 1),
}
字段级 F1 的比对函数(枚举字段用精确匹配,keywords 用集合重叠):
def field_f1(pred, gold):
if not pred:
return 0.0
scores = []
for f in ["sentiment", "category", "has_refund_intent"]:
scores.append(1.0 if pred.get(f) == gold[f] else 0.0)
p, g = set(pred.get("keywords", [])), set(gold["keywords"])
tp = len(p & g)
prec = tp / len(p) if p else 0
rec = tp / len(g) if g else 0
scores.append(2 * prec * rec / (prec + rec) if prec + rec else 0.0)
return sum(scores) / len(scores)
五、踩坑与优化
几个真实踩到的点:
-
V1 的
category发散严重。模型会输出“配送”“售后”“性价比”这类同义变体,精确匹配全挂。V3 把枚举写进 prompt 并加一句“不在枚举内则归 other”后,这个问题基本消失。教训:枚举类字段必须在 prompt 里显式给闭集,别指望模型猜你的 schema。 -
markdown 代码块污染。V2 时期模型爱输出
```json ... ```,json.loads直接炸。V3 明确“不要代码块”后解析失败率从 8.3% 掉到 1% 以下。更稳的做法是配合response_format={"type": "json_object"},我后来在 V4 加上了,失败率进一步到 0.4%。 -
prompt caching 没生效。我原以为把固定指令放前面就能省钱,结果
usage.prompt_tokens_details.cached_tokens一直是 0——因为 OpenAI 的自动缓存要求前缀 ≥1024 token,我们这批 system 才 90 token,压根不够门槛。所以 V4 的“缓存优化”在这场景是无效的,短 prompt 别指望 caching 降本,这是我看文档不仔细的锅。 -
max_tokens=256偶尔截断。关键词多的评论输出会超,后来调到 320,输出 token 均值只涨了 6,但截断导致的解析失败清零。 -
temperature=0 不等于完全确定。同一输入重跑仍有极小概率字段抖动,评测时我对每条跑了 1 次,样本量 300 够用,但做严格回归建议每条跑 3 次取多数。
六、效果数据
300 条评测集上的结果:
| 版本 | 解析成功率 | 字段平均 F1 | 平均输入 token | 平均输出 token | P95 延迟 |
|---|---|---|---|---|---|
| V1 | 91.7% | 0.71 | 612 | 118 | 1240 ms |
| V2 | 94.3% | 0.79 | 748 | 131 | 1385 ms |
| V3 | 99.0% | 0.90 | 301 | 96 | 980 ms |
| V4 | 99.6% | 0.93 | 287 | 92 | 940 ms |
按 10 万条/月估算成本:V1 约 $12.4,V4 约 $5.9,降本 52% 的同时 F1 涨了 0.22。最有意思的是 V2 反而更贵更慢——few-shot 示例把输入 token 顶上去了,但 F1 只涨了 0.08,性价比最差。真正起作用的是 V3 的“枚举 + 禁代码块 + 精简指令”,一刀砍掉 300 多 token 输入还提了准确率。
七、总结
这轮实验最大的收获不是某个 Prompt 模板,而是别把 Prompt 当玄学调:固定 temperature、固定模型快照、用带标注的小评测集量化 F1 和 token,改一版跑一次,数据会告诉你哪句指令是废话。对我们这个任务,结论很明确——闭集枚举 + 强制 JSON 输出 + 精简指令,比堆 few-shot 划算得多。如果你的任务也是结构化抽取,建议先照 V3 的骨架搭,再按评测数据微调,别一上来就 few-shot 伺候。