一、问题背景:一个被低估的成本黑洞

前段时间接手一个电商评论分析模块,需求很朴素:把用户评论分成「正面 / 负面 / 中性」三类,顺便抽一个情绪强度分(1-5)。

一开始我想得很简单,直接调API不就行了?结果上线第一天就翻车了:

  • 准确率只有78%左右,中性评论几乎全被判成正面
  • 输出格式忽好忽坏,有时候返回JSON,有时候返回一句"这条评论是正面的"
  • 200条测试数据跑了3次,结果都不太一样,完全没法做回归测试

更麻烦的是成本。我们日均评论量在5万条左右,如果每条多花200个Token,按gpt-4o-mini的$0.15/1M input tokens算,一个月就是450美元——这还只是input。

所以这篇文章不是讲"怎么写Prompt",而是讲我怎么用实验数据决定该写多复杂的Prompt。核心问题是:Prompt越精细,质量越高,但Token也越贵,拐点在哪?

二、环境与版本

先把实验环境交代清楚,不然数据没法复现:

Python: 3.11.6
openai: 1.35.0
模型: gpt-4o-mini (snapshot: 2024-07-18)
temperature: 0(固定,减少随机性)
max_tokens: 128
测试集: 200条人工标注的电商评论
评测指标: 准确率 / 平均Token消耗 / 格式合规率 / 平均延迟

关于temperature=0我要多说一句:即使设为0,OpenAI也不保证完全确定性(底层有MoE路由和浮点误差),但实测下来同一Prompt跑3次的准确率波动在±1.5%以内,够用了。

三、方案设计:三版Prompt的递进逻辑

我设计了三个版本,每一版只增加一个变量,方便定位效果来源:

V1 零样本基线:直接给任务描述,不加任何约束
V2 角色扮演版:加系统角色 + 明确分类标准
V3 Few-shot + 格式约束版:在V2基础上加3个示例 + 强JSON约束 + 情绪强度rubric

这里有个设计原则:每次只改一个东西。如果V3同时加了示例又换了模型,你根本不知道是哪个因素起了作用。

四、核心实现

V1:零样本基线

from openai import OpenAI
import json, time

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

def classify_v1(text: str) -> dict:
    prompt = f"""判断这条电商评论的情感倾向,正面、负面还是中性:
{text}"""

    start = time.time()
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
        max_tokens=128,
    )
    latency = time.time() - start
    content = resp.choices[0].message.content.strip()

    # 暴力解析,先看能不能用
    label = "unknown"
    for k in ["正面", "负面", "中性"]:
        if k in content:
            label = k
            break

    return {
        "label": label,
        "raw": content,
        "tokens": resp.usage.total_tokens,
        "latency": round(latency, 3),
    }

跑完200条,结果很难看:

准确率: 78.5%
平均Token: 142
格式合规率: 61%  (能稳定解析出标签的比例)
平均延迟: 0.82s

问题主要出在中性评论,"还行吧"、"一般般"这种全被判成正面。另外有39%的输出根本没法用正则解析。

V3:Few-shot + JSON约束版

SYSTEM_PROMPT = """你是一个电商评论情感分析引擎,专门处理中文用户评论。
你的输出必须严格符合以下JSON schema,不要输出任何其他文字:
{"label": "正面|负面|中性", "intensity": 1-5, "reason": "20字以内的判断依据"}

分类标准:
- 正面:明确表达满意、推荐、复购意愿
- 负面:明确表达不满、投诉、退货意愿  
- 中性:仅陈述事实、无明确情绪倾向,或正负抵消

intensity评分标准:
1 = 极其负面(要求退款/差评威胁)
2 = 轻微负面(小瑕疵但能接受)
3 = 完全中性(纯事实描述)
4 = 轻微正面(基本满意)
5 = 极其正面(强烈推荐/复购)"""

FEW_SHOT = [
    {"role": "user", "content": "这衣服质量还行,就是物流有点慢"},
    {"role": "assistant", "content": '{"label":"中性","intensity":3,"reason":"褒贬抵消"}'},
    {"role": "user", "content": "客服态度太差了,申请退款!"},
    {"role": "assistant", "content": '{"label":"负面","intensity":1,"reason":"投诉退款意图"}'},
    {"role": "user", "content": "用了一周,真心好用,已经推荐给朋友了"},
    {"role": "assistant", "content": '{"label":"正面","intensity":5,"reason":"推荐复购意愿"}'},
]

def classify_v3(text: str) -> dict:
    messages = [{"role": "system", "content": SYSTEM_PROMPT}]
    messages.extend(FEW_SHOT)
    messages.append({"role": "user", "content": text})

    start = time.time()
    resp = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=messages,
        temperature=0,
        max_tokens=128,
        response_format={"type": "json_object"},  # 关键:强制JSON
    )
    latency = time.time() - start
    content = resp.choices[0].message.content

    try:
        parsed = json.loads(content)
        label = parsed.get("label", "unknown")
    except json.JSONDecodeError:
        parsed, label = {}, "unknown"

    return {
        "label": label,
        "parsed": parsed,
        "tokens": resp.usage.total_tokens,
        "latency": round(latency, 3),
    }

注意这里用了两个约束叠加:response_format={"type":"json_object"} 强制JSON输出,以及system prompt里的schema描述。实测两者缺一不可——只靠prompt描述schema,合规率会从100%掉到92%左右。

五、踩坑与优化

坑1:Few-shot示例的"污染效应"

一开始我选的3个示例都是很典型的评论,结果模型开始"套模板":遇到稍微模糊的就往示例的类别上靠。后来我把其中一个示例换成边界案例(褒贬抵消的中性评论),准确率立刻涨了2个点。

坑2:response_format 的隐藏代价

开了JSON mode之后,输出Token反而变多了,因为模型会规规矩矩地把所有字段都填满,包括reason。如果不需要reason,去掉它能省大约35个Token。

坑3:中文示例的Token爆炸

中文在gpt-4o-mini的tokenizer下,平均1个汉字≈1.2个Token。我第一版Few-shot写了6个示例,光示例就占了380个Token,成本直接失控。最后砍到3个示例,准确率只掉了0.5个点——示例不是越多越好,3-5个是性价比甜点

坑4:缓存命中率

OpenAI对Prompt前缀有自动缓存(命中部分input费用打5折)。把system prompt和Few-shot放在前面、用户输入放在后面,实测缓存命中率能到70%以上,实际成本比理论值低不少。这一点在计算ROI时不能忽略。

六、效果数据

200条测试集,三版Prompt对比:

版本 准确率 平均Token 格式合规率 平均延迟 单条成本(估)
V1 零样本 78.5% 142 61% 0.82s $0.000021
V2 角色扮演 88.0% 268 84% 0.95s $0.000040
V3 Few-shot+JSON 96.0% 487 100% 1.24s $0.000073

换算到日均5万条:

  • V1:$1.05/天,但39%的输出要人工兜底,实际不可用
  • V3:$3.65/天,全自动可用

看起来V3贵了3.4倍,但V1那39%的返工成本(人工审核或重试)远不止这个数。决策拐点在于:格式合规率低于90%的Prompt,不管多便宜都不能上生产。

另外补充一个V3的优化版V3.1:去掉reason字段 + 开启前缀缓存,平均Token降到312,准确率保持在95.5%,单条成本降到$0.000047,这才是最终上线的版本。

七、总结

回到开头的问题:Prompt该写多复杂?

我的结论是三句话:

  1. 先跑基线,再优化。V1虽然烂,但它告诉你任务的"自然难度"在哪。如果V1就有92%,别折腾了。
  2. 格式约束比语义优化更值钱。V2到V3准确率涨8个点,但格式合规率从84%到100%才是质变——它决定了你能不能自动化。
  3. Token要算总账。Few-shot、reason字段、JSON mode都在加钱,但缓存、字段精简能省回来。别只看单条消耗,要看"每条可用输出的成本"。

最后提醒一句:这套数据是在gpt-4o-mini上跑的,换到gpt-4o或者Claude 3.5 Sonnet,绝对数值会变,但V1→V2→V3的递进规律基本一致。Prompt Engineering没有银弹,只有针对你具体任务的实验记录。