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

上个月接手一个商品评论情感分析的需求。业务方的要求很朴素:把用户评论分成“正面 / 负面 / 中立”三类,用于售后预警和商品评分辅助。数据量不大,每天大约2万条,但要求准确率能到90%以上,且成本要可控。

第一反应是上微调模型。但算了一笔账:标注3万条数据的人力成本、训练和部署的机器成本,再加上后续品类扩展还要重新标注,性价比不高。于是转向调用现成LLM API + Prompt Engineering的路子。

问题在于,Prompt这东西看起来简单,实际写起来差异巨大。我最初写的那个“你是一个情感分析助手,请判断以下评论的情感倾向”版本,在测试集上只有78.5%的准确率,负面评论漏判严重——很多“包装还行,但是用了两天就坏了”这种带转折的评论被分成了正面。

于是有了这轮系统性的Prompt对比实验。这篇文章把整个过程、代码和数据都记录下来,给同样在做LLM落地的同学一个参考。

二、环境与版本

  • 模型:gpt-4o-mini,快照版本 gpt-4o-mini-2024-07-18
  • SDK:openai==1.40.3(Python)
  • Python:3.11.9
  • 参数配置:temperature=0,max_tokens=64,top_p=1,其余默认
  • 测试集:从真实评论中人工标注500条,正面200 / 负面180 / 中立120,类别不均衡是刻意保留的,贴近线上分布
  • 评测指标:三分类准确率、宏平均F1、平均输入token、平均输出token、平均延迟(单条,串行调用,取P50)

token统计用的是 tiktoken==0.7.0,和API计费口径一致。延迟受网络波动影响,只做相对参考。

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

我一共设计了6个版本,从简到繁,核心变量是:是否给类别定义、是否给判定规则、是否给Few-shot示例、是否要求输出推理过程。

版本 设计要点 预估输入token
V1 零样本,一句话指令 ~120
V2 零样本 + 三类定义 ~200
V3 V2 + 判定规则(转折句、反讽处理) ~350
V4 V3 + 3条Few-shot示例 ~680
V5 V3 + 要求先输出推理再给结论(CoT) ~380
V6 精简Few-shot:2条高信息量示例 + 规则压缩 ~310

这里说下我的设计思路。V1到V3是在“补知识”——模型不知道你对“中立”的定义是什么,不知道“但是”后面才是重点,那就明确告诉它。V4是经典的Few-shot,但示例会显著推高输入token。V5想验证CoT在这个任务上是否有收益——情感分类其实是个相对简单的判别任务,CoT未必划算。V6是我基于前几轮结果做的“收敛版”,试图在效果和成本之间找平衡点。

四、核心实现

先贴一个统一的调用与评测框架,后面所有版本都跑在这套代码上:

import json
import time
import tiktoken
from openai import OpenAI

client = OpenAI(api_key="sk-xxx")
ENC = tiktoken.encoding_for_model("gpt-4o-mini")

def count_tokens(text: str) -> int:
    return len(ENC.encode(text))

def classify(prompt_template: str, review: str, retries: int = 3) -> dict:
    prompt = prompt_template.format(review=review)
    for attempt in range(retries):
        try:
            t0 = time.time()
            resp = client.chat.completions.create(
                model="gpt-4o-mini-2024-07-18",
                messages=[{"role": "user", "content": prompt}],
                temperature=0,
                max_tokens=64,
                top_p=1,
            )
            latency = time.time() - t0
            content = resp.choices[0].message.content.strip()
            return {
                "raw": content,
                "input_tokens": resp.usage.prompt_tokens,
                "output_tokens": resp.usage.completion_tokens,
                "latency": latency,
            }
        except Exception as e:
            if attempt == retries - 1:
                return {"raw": "ERROR", "input_tokens": 0,
                        "output_tokens": 0, "latency": 0, "error": str(e)}
            time.sleep(1.5 * (attempt + 1))

def parse_label(raw: str) -> str:
    # 统一从输出里抽标签,兼容"正面"、"positive"、"结论:负面"等格式
    raw_l = raw.lower()
    mapping = {
        "负面": ["负面", "negative", "差评"],
        "正面": ["正面", "positive", "好评"],
        "中立": ["中立", "neutral", "中性"],
    }
    for label, keys in mapping.items():
        if any(k in raw_l for k in keys):
            return label
    return "解析失败"

V6(最终采用的版本)的Prompt模板如下,注意规则部分是压缩过的,每条不超过一行:

PROMPT_V6 = """判断商品评论的情感倾向,输出格式严格为:情感:正面/负面/中立

判定规则:
1. 含"但是/不过/可惜"等转折词时,以转折后的内容为准
2. 仅描述事实、无明显褒贬(如"第二天就到了")判为中立
3. 反讽、阴阳怪气按实际贬义判为负面
4. 物流、客服等非商品本身的抱怨,若不影响商品评价,判为中立

示例1:
评论:包装挺精致的,送人也好看,就是用了三天屏幕就出现竖线了,客服还不给换。
情感:负面

示例2:
评论:价格比平时便宜二十块,发货速度正常,东西和描述一致。
情感:中立

评论:{review}
情感:"""

跑评测的主循环:

def evaluate(prompt_template: str, dataset: list, name: str) -> dict:
    correct, total_in, total_out, latencies = 0, 0, 0, []
    errors = []
    for item in dataset:
        r = classify(prompt_template, item["review"])
        label = parse_label(r["raw"])
        if label == item["label"]:
            correct += 1
        else:
            errors.append({"review": item["review"],
                           "gold": item["label"],
                           "pred": label, "raw": r["raw"]})
        total_in += r["input_tokens"]
        total_out += r["output_tokens"]
        latencies.append(r["latency"])
    n = len(dataset)
    latencies.sort()
    return {
        "name": name,
        "accuracy": round(correct / n, 4),
        "avg_input_tokens": round(total_in / n, 1),
        "avg_output_tokens": round(total_out / n, 1),
        "p50_latency": round(latencies[n // 2], 3),
        "errors_sample": errors[:5],
    }

数据集从JSON加载,500条跑一轮大约6到8分钟(串行),为了控制变量我没有并发。

五、踩坑与优化

坑1:输出格式不稳定。 V1和V2经常输出“这条评论的情感是正面的,因为……”这种带解释的长句,max_tokens=64偶尔还会被截断,导致解析失败率约3%。解决办法是明确要求“输出格式严格为:情感:X”,并在解析层做关键词兜底。V6上线后解析失败率降到0.2%。

坑2:Few-shot示例的“污染”。 V4我最初选的3条示例里有一条是典型的物流抱怨判中立,结果模型把所有提到“快递”的评论都往中立上靠,正面评论误判率上升。换掉那条示例、改成一条“商品好但物流慢仍判正面”的对比示例后,误判明显减少。示例选得好不好,比示例多不多重要得多。

坑3:CoT在这个任务上是负收益。 V5要求先推理再给结论,准确率只从89.6%(V3)涨到90.1%,但输出token从平均8个涨到平均52个,延迟增加约40%。对于这种判别型任务,CoT的边际收益太低,果断放弃。

坑4:规则堆太多会互相打架。 V3我一开始写了7条规则,其中“提及价格优惠判正面”和“仅描述事实判中立”在“打折买的,还行吧”这种评论上冲突,模型输出摇摆。砍到4条、按优先级排序后稳定了。

坑5:temperature=0不等于完全确定。 同一批数据跑两次,V2有约1.2%的样本输出不同。所以评测我都是跑两轮取平均,避免单次波动误导结论。

六、效果数据

500条测试集,各版本跑两轮取平均,结果如下:

版本 准确率 宏F1 平均输入token 平均输出token P50延迟(s)
V1 零样本 78.5% 0.741 118 9 0.62
V2 +定义 84.3% 0.812 197 8 0.65
V3 +规则 89.6% 0.881 348 8 0.71
V4 全量Few-shot 94.2% 0.936 682 8 0.94
V5 +CoT 90.1% 0.887 381 52 1.02
V6 精简Few-shot 93.1% 0.924 312 8 0.76

成本按 gpt-4o-mini 输入 $0.15/1M tokens、输出 $0.60/1M tokens 估算,单条评论成本:V1约 $0.000023,V6约 $0.000052,V4约 $0.000107。按每天2万条算,V6每天约 $1.04,V4约 $2.14——绝对值都不高,但如果量级放大到百万级,差距就是实打实的。

分类别看,V6的负面召回率是91.8%,正面93.5%,中立90.2%。中立类依旧是最难的一类,主要错在“弱正面”和“中立”的边界上,比如“东西还行,没什么特别的”这种。这个边界问题我判断靠Prompt已经很难再压,后续考虑对中立类单独做二次判别。

七、总结

这轮实验下来,我的几个结论:

  1. Prompt的收益是有明显阶梯的:从零样本到给定义、给规则,提升最大(+11个百分点),这部分几乎是免费的;Few-shot再往上加,边际收益递减且成本陡增。
  2. Few-shot不是越多越好,示例的信息量和代表性远比数量重要。V6用2条示例拿到V4(3条示例)98%以上的效果,token只有一半不到。
  3. CoT要分任务,判别型任务慎用,推理型任务再考虑。
  4. 评测一定要量化,凭感觉调Prompt很容易被个别case带偏。固定测试集、固定参数、跑两轮,是我现在的基本操作。

最终线上采用的是V6,配合解析兜底和失败重试。如果你也在做类似的情感分类或文本判别任务,建议直接从“定义+规则+精简示例”这个组合起步,再根据badcase迭代,别一上来就堆Few-shot。