一、问题背景:为什么我要做这个实验

上个月接手一个工单自动分类系统,需求很朴素:把用户提交的工单文本分到 8 个类别里(退款、物流、账号、支付、商品咨询、投诉、技术故障、其他)。每天大约 12 万条,走 API 调用。

一开始我信心满满地写了一版"豪华 Prompt":角色设定、任务说明、8 个类别的详细定义、每个类别配 2 个 few-shot 示例、输出格式约束、边界情况处理规则……洋洋洒洒 600 多字。跑了一周,账单出来我人傻了——单条平均消耗 312 Token,一天光 input token 就是 3700 万,成本压不住。

更气的是,我抽了 200 条人工核对,发现准确率并没有想象中高,尤其是"投诉"和"其他"两类混淆严重。于是我开始怀疑:这些精心堆砌的 Prompt 内容,到底哪些是真有用的?哪些纯粹在烧钱?

这就有了后面这轮实验。我的目标很明确:在保证分类质量的前提下,找到 Token 消耗的最优区间。

二、环境与版本

实验环境如下,都是当时的最新稳定版:

  • Python 3.11.7
  • openai SDK 1.51.0(调用 gpt-4o-mini-2024-07-18)
  • anthropic SDK 0.34.2(调用 claude-3-5-haiku-20241022)
  • 评测数据集:从生产环境脱敏抽样 800 条工单,人工标注 ground truth
  • 温度参数统一 temperature=0.0,max_tokens=16(只输出类别标签)
  • 计费按官方定价折算:gpt-4o-mini input $0.15/1M token,claude-3-5-haiku input $0.80/1M token

评测指标用 macro-F1,因为 8 个类别样本不均衡("其他"占了 31%),单纯看准确率会失真。

三、方案设计:Prompt 的五个梯度

我没有瞎试,而是把 Prompt 拆成几个可插拔的模块,做梯度对比:

组别 构成 说明
P1 仅任务指令 "将以下工单分类到8个类别之一:..."
P2 P1 + 类别定义 每个类别加一句简短说明
P3 P2 + 输出格式约束 强制只输出标签,禁止解释
P4 P3 + 2-shot 示例 每类抽 2 条典型样本
P5 完整豪华版 角色设定 + 详细规则 + 8-shot + 边界处理

另外我还单独测了一个变体 P3*:把类别定义从"自然语言描述"改成"关键词列表"(比如退款类写成"退款/退货/退钱/申请退"),看看哪种表达更省 token 又有效。

每条样本跑 3 次取多数投票(其实 temperature=0 基本稳定,主要防偶发波动),最终统计平均 token 和 F1。

四、核心实现

先看 Prompt 模板的组织方式。我用字典管理各梯度,方便批量跑:

import json
import time
from openai import OpenAI

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

CATEGORIES = ["退款", "物流", "账号", "支付", "商品咨询", "投诉", "技术故障", "其他"]

PROMPTS = {
    "P1": "将以下工单分类到这些类别之一:{cats}。\n工单:{text}\n类别:",

    "P2": (
        "将以下工单分类到这些类别之一:{cats}。\n"
        "类别说明:退款=申请退款退货;物流=配送延迟或丢件;账号=登录注册问题;"
        "支付=扣款异常;商品咨询=售前参数询问;投诉=服务态度不满;"
        "技术故障=App或网站报错;其他=以上都不是。\n"
        "工单:{text}\n类别:"
    ),

    "P3": (
        "将以下工单分类到这些类别之一:{cats}。\n"
        "类别说明:退款=申请退款退货;物流=配送延迟或丢件;账号=登录注册问题;"
        "支付=扣款异常;商品咨询=售前参数询问;投诉=服务态度不满;"
        "技术故障=App或网站报错;其他=以上都不是。\n"
        "只输出类别名称,不要任何解释。\n"
        "工单:{text}\n类别:"
    ),
}

def build_prompt(tier: str, text: str) -> str:
    return PROMPTS[tier].format(cats="、".join(CATEGORIES), text=text)

def classify(tier: str, text: str) -> tuple[str, int]:
    prompt = build_prompt(tier, text)
    resp = client.chat.completions.create(
        model="gpt-4o-mini-2024-07-18",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.0,
        max_tokens=16,
    )
    label = resp.choices[0].message.content.strip()
    return label, resp.usage.prompt_tokens

批量评测脚本,统计 token 和 F1:

from sklearn.metrics import f1_score
import pandas as pd

df = pd.read_json("tickets_800.jsonl", lines=True)  # columns: text, label

def evaluate(tier: str):
    preds, golds, tokens = [], [], []
    for _, row in df.iterrows():
        try:
            pred, tk = classify(tier, row["text"])
        except Exception as e:
            pred, tk = "其他", 0
            print(f"[WARN] {e}")
        preds.append(pred if pred in CATEGORIES else "其他")
        golds.append(row["label"])
        tokens.append(tk)
        time.sleep(0.02)  # 防限流
    f1 = f1_score(golds, preds, average="macro")
    return {
        "tier": tier,
        "macro_f1": round(f1, 4),
        "avg_input_tokens": round(sum(tokens) / len(tokens), 1),
        "total_tokens": sum(tokens),
    }

results = [evaluate(t) for t in ["P1", "P2", "P3"]]
print(json.dumps(results, ensure_ascii=False, indent=2))

五、踩坑与优化

坑 1:P1 的输出不稳定。 只给类别列表时,模型经常输出"该工单属于退款类别"这种整句,max_tokens=16 直接被截断,拿到半句话导致匹配失败。所以 P3 加的输出格式约束不是锦上添花,是刚需——它把"自由生成"变成"受限选择",token 反而更省(因为不会啰嗦)。

坑 2:few-shot 示例的边际收益递减极快。 P4 用了 16 条示例(每类 2 条),token 从 P3 的 89 飙到 241,F1 只从 0.89 涨到 0.90。P5 更夸张,312 token 换 0.91。说白了,对 8 分类这种"类别边界清晰"的任务,示例主要帮模型理解输出格式,而不是理解语义。语义靠类别定义就够了。

坑 3:类别定义的写法影响巨大。 P3 用自然语言描述("申请退款退货"),我改成关键词列表后(P3*),token 从 89 降到 71,F1 反而从 0.89 升到 0.90。原因推测是关键词更贴近用户真实表达,减少了模型做"语义翻译"的损耗。这个发现挺反直觉的。

优化:动态 Prompt。 最终上线版本不是固定模板,而是先跑一个轻量规则(关键词命中)处理 40% 的简单样本,剩下的才走 P3*,整体平均 token 压到 52。

六、效果数据

800 条样本,gpt-4o-mini 结果:

组别 平均Input Token Macro-F1 相对P5成本 备注
P1 42 0.74 13% 输出格式失控
P2 78 0.86 25% 定义有效
P3 89 0.89 29% 格式约束关键
P3* 71 0.90 23% 关键词定义更优
P4 241 0.90 77% 边际收益低
P5 312 0.91 100% 性价比最差

Claude 3.5 Haiku 上趋势一致,P3* 的 F1 是 0.88,token 略高(约 76),但单价贵 5 倍多,最终没选它。

按日 12 万条算,P5 日成本约 $5.6,P3 约 $1.3,降本 77%,F1 还高了 0.01*。

七、总结

这轮实验给我最大的教训是:Prompt 工程不是"写得多就写得好",而是"每一段都要能证明自己的边际价值"。对分类这类结构化任务,有效的顺序是:输出格式约束 > 类别定义 > few-shot 示例 > 角色设定。角色设定那套"你是一位资深客服专家",实测对 F1 的影响在 ±0.005 以内,纯属心理安慰。

另外提醒两点:一是务必用真实业务样本做评测集,公开 benchmark 上的结论迁移过来经常翻车;二是 token 统计一定要用 API 返回的 usage 字段,别自己用 tiktoken 估,中文场景误差能有 15%。

下次再有人跟你说"Prompt 越详细越好",把这张表甩给他。