一、问题背景

上个月接了个需求:从用户对商品的评论里抽结构化字段——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)

五、踩坑与优化

几个真实踩到的点:

  1. V1 的 category 发散严重。模型会输出“配送”“售后”“性价比”这类同义变体,精确匹配全挂。V3 把枚举写进 prompt 并加一句“不在枚举内则归 other”后,这个问题基本消失。教训:枚举类字段必须在 prompt 里显式给闭集,别指望模型猜你的 schema。

  2. markdown 代码块污染。V2 时期模型爱输出 ```json ... ```,json.loads 直接炸。V3 明确“不要代码块”后解析失败率从 8.3% 掉到 1% 以下。更稳的做法是配合 response_format={"type": "json_object"},我后来在 V4 加上了,失败率进一步到 0.4%。

  3. prompt caching 没生效。我原以为把固定指令放前面就能省钱,结果 usage.prompt_tokens_details.cached_tokens 一直是 0——因为 OpenAI 的自动缓存要求前缀 ≥1024 token,我们这批 system 才 90 token,压根不够门槛。所以 V4 的“缓存优化”在这场景是无效的,短 prompt 别指望 caching 降本,这是我看文档不仔细的锅。

  4. max_tokens=256 偶尔截断。关键词多的评论输出会超,后来调到 320,输出 token 均值只涨了 6,但截断导致的解析失败清零。

  5. 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 伺候。