一、问题背景:为什么我又开始折腾 Prompt

事情起因很简单。我们有个内部工单系统,每天大概 3000 条用户反馈进来,需要自动打上分类标签(退款、物流、账号、产品咨询、投诉、其他)。之前用的是微调后的 BERT-base,准确率 89%,但有两个问题:

  1. 新类别出现时要重新标注 + 重训,周期至少一周;
  2. 模型部署要占一张 T4,推理延迟 80ms,但维护成本高。

于是我想试试用 LLM + Prompt Engineering 能不能直接替代。目标很明确:准确率不低于 89%,单条成本低于 0.0005 美元,延迟低于 1.5s。

但 Prompt 这东西,写法和写法之间差距巨大。我不想凭感觉说“这个 Prompt 更好”,所以干脆做了一次系统性对比。

二、环境与版本

  • 模型:gpt-4o-mini,快照 gpt-4o-mini-2024-07-18
  • SDK:openai==1.40.0
  • Python:3.11.6
  • 测试集:200 条人工标注工单,6 类分布均衡(每类 33-34 条)
  • 温度:temperature=0,top_p=1,保证可复现
  • 计费口径:按 OpenAI 官方 2024-07 价格,输入 $0.15/1M tokens,输出 $0.60/1M tokens
  • 评测脚本:准确率 + 混淆矩阵 + Token 统计

三、方案设计:6 个版本的 Prompt

我把 Prompt 拆成几个可控变量:是否给类别定义、是否给 Few-shot、是否要求思维链、是否强制 JSON 输出。组合出 6 版:

版本 特征 大致思路
V1 零样本裸提示 “把下面文本分类”
V2 加类别定义 每类给一句解释
V3 加 Few-shot(每类 1 条) 6 条示例
V4 Few-shot + JSON 输出 强制结构化
V5 V4 + 思维链 先解释再给标签
V6 V5 + Few-shot 每类 3 条 18 条示例

四、核心实现

先看评测主循环,这块所有版本共用:

import json
import time
from openai import OpenAI

client = OpenAI(api_key="sk-xxx")

def run_eval(prompt_builder, testset, model="gpt-4o-mini-2024-07-18"):
    results = []
    total_in, total_out = 0, 0
    for item in testset:
        messages = prompt_builder(item["text"])
        t0 = time.time()
        resp = client.chat.completions.create(
            model=model,
            messages=messages,
            temperature=0,
            top_p=1,
            max_tokens=200,
        )
        latency = time.time() - t0
        content = resp.choices[0].message.content.strip()
        total_in += resp.usage.prompt_tokens
        total_out += resp.usage.completion_tokens
        results.append({
            "text": item["text"],
            "gold": item["label"],
            "pred": parse_label(content),
            "raw": content,
            "latency": latency,
        })
    acc = sum(r["pred"] == r["gold"] for r in results) / len(results)
    cost = total_in / 1e6 * 0.15 + total_out / 1e6 * 0.60
    return {
        "acc": acc,
        "avg_in": total_in / len(results),
        "avg_out": total_out / len(results),
        "cost_per_item": cost / len(results),
        "avg_latency": sum(r["latency"] for r in results) / len(results),
        "details": results,
    }

再看 V1 和 V4 的 Prompt 构造,对比最明显:

# V1:零样本裸提示
def v1_builder(text):
    return [
        {"role": "system", "content": "你是一个文本分类助手。"},
        {"role": "user", "content": f"请把下面的文本分类:\n{text}"},
    ]

# V4:Few-shot + JSON 输出
CATEGORIES = ["退款", "物流", "账号", "产品咨询", "投诉", "其他"]

FEW_SHOT = [
    ("我要退货,订单号 12345", "退款"),
    ("快递三天没动了", "物流"),
    ("登录一直提示密码错误", "账号"),
    ("你们这个功能怎么用", "产品咨询"),
    ("客服态度太差了,我要投诉", "投诉"),
    ("你们公司地址在哪", "其他"),
]

def v4_builder(text):
    examples = "\n".join(
        f'文本:{t}\n输出:{{"label": "{l}"}}' for t, l in FEW_SHOT
    )
    system = (
        "你是一个工单分类器。只能从以下类别中选择一个:"
        + "、".join(CATEGORIES)
        + "。必须只输出 JSON,格式为 {\"label\": \"类别\"},不要输出任何其他内容。"
    )
    user = f"示例:\n{examples}\n\n现在分类:\n文本:{text}\n输出:"
    return [
        {"role": "system", "content": system},
        {"role": "user", "content": user},
    ]

V5 在 V4 基础上加了一句“先在心里判断关键词,再输出 JSON”,但要求最终只输出 JSON;V6 则把 Few-shot 从 6 条扩到 18 条。

五、踩坑与优化

坑 1:V1 输出根本没法解析。 模型会返回“这段文本属于退款类,因为提到了退货”。parse_label 只能靠关键词匹配,准确率自然崩。

坑 2:V3 的 Few-shot 顺序有影响。 我一开始把“其他”放在第一条,结果模型偏向输出“其他”,F1 掉了 4 个点。后来把类别按业务频率排序,才稳定下来。

坑 3:V5 的思维链在 temperature=0 下反而拖后腿。 模型有时会在解释里自我否定,最后给的标签和解释矛盾。加了一句“解释只用于内部推理,最终标签以 JSON 为准”后才好转。

坑 4:JSON 输出偶尔带 markdown 代码块。 即使 system 里写了“不要输出其他内容”,还是有 3% 概率包 json。解决办法是用 response_format={"type": "json_object"},但注意这个参数要求 prompt 里必须出现 "JSON" 字样,否则会报 400。

优化后 V4 的解析成功率从 97% 提到 100%。

六、效果数据

200 条测试集上的结果:

版本 准确率 平均输入 Token 平均输出 Token 单条成本(USD) 平均延迟(s)
V1 71.5% 42 16 0.0000159 0.62
V2 82.0% 118 14 0.0000261 0.71
V3 90.5% 246 12 0.0000441 0.83
V4 93.0% 268 11 0.0000468 0.86
V5 94.0% 312 48 0.0000756 1.21
V6 94.5% 412 11 0.0000684 1.05

几个关键结论:

  1. Few-shot 是准确率跃迁的关键:V2→V3 涨了 8.5 个点,成本只多了 0.000018 美元。
  2. JSON 强制输出性价比极高:V4 比 V3 只多 22 个输入 Token,准确率 +2.5%,还省了后处理。
  3. 思维链不是万能药:V5 准确率只比 V4 高 1 个点,但输出 Token 翻了 4 倍多,延迟 +40%。
  4. Few-shot 加到 18 条边际收益递减:V6 比 V5 只高 0.5 个点,但输入 Token 多了 100。

最终我选了 V4:准确率 93%,已经超过 BERT 的 89%,单条成本 0.0000468 美元,按每天 3000 条算,一个月成本约 4.2 美元,比养一张 T4 便宜太多。延迟 0.86s 也在可接受范围。

如果对准确率要求更高,可以上 V6,但我会建议先把 V4 的 Few-shot 示例质量再打磨一遍,而不是盲目加数量。

七、总结

这次实验最大的感受是:Prompt Engineering 不是玄学,是可以量化的工程问题。同样一个分类任务,Prompt 从 42 个输入 Token 写到 412 个,准确率差 23 个点,成本差 4.3 倍。关键不是“写得越长越好”,而是找到那个性价比拐点。

我的经验是:

  • 先写 V1 摸清模型 baseline;
  • 加类别定义,通常能涨 5-10 个点;
  • 加 Few-shot,每类 1-2 条足够,顺序按业务频率排;
  • 强制 JSON 输出,省后处理还提准确率;
  • 思维链只在推理类任务上用,分类任务慎用;
  • 每次改动都跑固定测试集,记录 Token 和准确率,别凭感觉。

代码和测试集脱敏后我放在了内部仓库,核心逻辑就是上面那两段。如果你也在做类似的事,欢迎交流。