一、问题背景:为什么"随便写个Prompt"会翻车

上个月接了个需求:给外卖平台的商家后台做评论情感分析,把用户评论分成「好评 / 中评 / 差评」三类,然后按商家维度聚合,输出一个"服务健康度"指标。

一开始我觉得这活儿太简单了,直接写了个Prompt:

判断下面这条评论是好评、中评还是差评:
{comment}

跑了一版,准确率(人工标注1000条作为测试集)只有 78.3%。问题集中在两类:

  • 模型把"味道还行,就是等太久了"判成了差评(其实是中评)
  • 遇到"五星好评,就是配送慢了点"这种,模型会随机摇摆

更要命的是输出格式完全不可控:有时候返回"好评",有时候返回"这是一条好评",有时候还会加一句解释。下游解析直接崩。

于是我决定正经做一次Prompt Engineering实验,把不同设计思路量化对比一遍。这篇文章就是实验记录。

二、环境与版本

  • Python 3.11.6
  • openai 1.51.0
  • dashscope 1.20.2(调用通义千问)
  • 模型:
  • gpt-4o-mini,快照 gpt-4o-mini-2024-07-18
  • qwen-plus,快照 qwen-plus-2024-09-19
  • temperature=0top_p=1.0max_tokens=64(分类任务不需要长输出)
  • 测试集:1000条人工标注的外卖评论,标签分布 好评612 / 中评231 / 差评157

价格按官方公示(写文章时的价格):
- gpt-4o-mini:输入 $0.15 / 1M tokens,输出 $0.60 / 1M tokens
- qwen-plus:输入 ¥0.0008 / 1K tokens,输出 ¥0.002 / 1K tokens

三、方案设计:6种Prompt

我设计了6个版本,从最朴素到逐步加料:

编号 名称 关键设计
P1 Naive 一句话指令,无格式约束
P2 Role+Format 加角色设定 + 强制输出单词
P3 Structured Output JSON输出 + 字段定义
P4 Few-shot(3) 3个示例
P5 Few-shot(6)+CoT 6个示例 + 先分析再判断
P6 CoT精简版 结构化输出 + 极简推理步骤

P6 是我最后想推荐给大家的方案,思路是:让模型先输出一个极短的"证据词",再输出标签,但不输出完整推理链。这样既利用了CoT的收益,又不让Token爆炸。

四、核心实现

先上通用的调用与评测代码。

import os, json, time
from typing import Callable
from openai import OpenAI
from dashscope import Generation

openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))

def call_gpt(prompt: str) -> tuple[str, dict]:
    resp = openai_client.chat.completions.create(
        model="gpt-4o-mini-2024-07-18",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.0,
        top_p=1.0,
        max_tokens=64,
    )
    text = resp.choices[0].message.content.strip()
    usage = {
        "in": resp.usage.prompt_tokens,
        "out": resp.usage.completion_tokens,
    }
    return text, usage

def call_qwen(prompt: str) -> tuple[str, dict]:
    resp = Generation.call(
        model="qwen-plus-2024-09-19",
        prompt=prompt,
        temperature=0.0,
        top_p=1.0,
        max_tokens=64,
        result_format="message",
    )
    text = resp.output.choices[0].message.content.strip()
    usage = {
        "in": resp.usage["input_tokens"],
        "out": resp.usage["output_tokens"],
    }
    return text, usage

LABELS = {"好评", "中评", "差评"}

def evaluate(caller: Callable, prompt_builder: Callable, dataset: list[dict]):
    correct = 0
    fmt_ok = 0
    total_in = total_out = 0
    errors = []
    for row in dataset:
        prompt = prompt_builder(row["comment"])
        try:
            raw, usage = caller(prompt)
        except Exception as e:
            errors.append((row["comment"], str(e)))
            continue
        total_in += usage["in"]
        total_out += usage["out"]
        pred = normalize(raw)          # 见下方 normalize
        if pred in LABELS:
            fmt_ok += 1
        if pred == row["label"]:
            correct += 1
        else:
            errors.append((row["comment"], pred, row["label"]))
    n = len(dataset)
    return {
        "acc": correct / n,
        "fmt": fmt_ok / n,
        "avg_in": total_in / n,
        "avg_out": total_out / n,
        "errors": errors[:20],
    }

def normalize(raw: str) -> str:
    for lab in ["好评", "中评", "差评"]:
        if lab in raw:
            return lab
    return "UNKNOWN"

然后是6个Prompt构造函数,这才是文章的核心。

def p1_naive(c):
    return f"判断下面这条评论是好评、中评还是差评:\n{c}"

def p2_role_format(c):
    return (
        "你是一名电商评论情感分析专家。\n"
        "请判断以下评论的情感极性,只能从『好评』『中评』『差评』中选一个词输出,"
        "不要输出任何其他内容。\n"
        f"评论:{c}\n"
        "答案:"
    )

def p3_structured(c):
    return (
        "你是电商评论情感分析专家。请严格输出JSON,不要包含```json```围栏。\n"
        "JSON schema: {\"label\": \"好评|中评|差评\"}\n"
        "判定规则:\n"
        "- 好评:整体满意,无明显负面\n"
        "- 差评:核心体验差(口味/卫生/严重迟到>30min)\n"
        "- 中评:有明确正面也有明确负面,或情绪平淡\n"
        f"评论:{c}"
    )

FEWSHOT_3 = [
    ("味道很好,分量也足,下次还来", "好评"),
    ("味道一般般,不难吃也不惊艳", "中评"),
    ("等了两个小时,饭都凉了,差评", "差评"),
]

def p4_fewshot3(c):
    shots = "\n".join(f"评论:{x}\n答案:{y}" for x, y in FEWSHOT_3)
    return (
        "你是电商评论情感分析专家,只能输出『好评』『中评』『差评』之一。\n"
        f"{shots}\n"
        f"评论:{c}\n答案:"
    )

FEWSHOT_6 = FEWSHOT_3 + [
    ("五星好评,就是配送慢了点", "好评"),
    ("味道还行,就是等太久了", "中评"),
    ("包装破了,汤洒了一半,再也不点了", "差评"),
]

def p5_fewshot6_cot(c):
    shots = "\n".join(
        f"评论:{x}\n分析:先看口味/服务/配送,再综合判断。\n答案:{y}"
        for x, y in FEWSHOT_6
    )
    return (
        "你是电商评论情感分析专家。请先简要分析,再给出标签。\n"
        f"{shots}\n"
        f"评论:{c}\n分析:"
    )

def p6_cot_lite(c):
    return (
        "你是电商评论情感分析专家。\n"
        "步骤:\n"
        "1) 用不超过8个字写出关键证据(口味/服务/配送/包装之一);\n"
        "2) 输出标签。\n"
        "严格按以下格式,两行,不要多余内容:\n"
        "证据:\n"
        "标签:\n"
        f"评论:{c}"
    )

评测入口:

import pandas as pd
dataset = pd.read_json("reviews_labeled.jsonl", lines=True).to_dict("records")

PROMPTS = {
    "P1_naive": p1_naive,
    "P2_role_format": p2_role_format,
    "P3_structured": p3_structured,
    "P4_fewshot3": p4_fewshot3,
    "P5_fewshot6_cot": p5_fewshot6_cot,
    "P6_cot_lite": p6_cot_lite,
}

results = {}
for name, builder in PROMPTS.items():
    results[name] = evaluate(call_gpt, builder, dataset)
    print(name, results[name]["acc"], results[name]["fmt"],
          results[name]["avg_in"], results[name]["avg_out"])

五、踩坑与优化

坑1:max_tokens=64 把CoT方案坑了。 P5第一次跑的时候,格式合规率只有 62%。原因是模型分析写到一半被截断,标签根本没输出。后来我在P5的评测里单独把max_tokens调到256,P6则因为输出极短,64完全够用。结论:CoT任务必须给足输出预算,否则得不偿失。

坑2:JSON围栏。 P3一开始模型习惯性输出 ```json {...} ```,导致json.loads失败。加了"不要包含json围栏"这句后合规率从71%涨到96%。但仍有4%的案例会带围栏,所以我在normalize里做了兜底。

坑3:Few-shot的示例选择比数量更重要。 我最初P4的三个示例都是"典型"样本,模型对"五星好评但配送慢"这种混合型完全没概念。换成FEWSHOT_6里的那三条边界样本之后,中评类的F1从0.61涨到0.79。做Few-shot时,优先放容易混淆的边界样本,而不是教科书式的正例。

坑4:qwen-plus对中文标点更敏感。 同样的Prompt,qwen-plus在P2下偶尔输出"好评。"(带句号),normalize里我用in做包含匹配才兜住。如果下游用严格相等,这里必挂。

坑5:温度0并不等于完全确定。 我在同一批数据上跑了两遍P6,有7条结果不一样。所以别指望temperature=0给你100%可复现,做A/B测试时把评测集拉大到千级以上才靠谱。

六、效果数据

gpt-4o-mini 结果(1000条测试集,temperature=0):

Prompt 准确率 格式合规率 平均输入Token 平均输出Token 单条成本(美元)
P1 Naive 78.3% 63.1% 42 9 $0.0000117
P2 Role+Format 83.7% 97.4% 78 3 $0.0000135
P3 Structured 85.1% 96.0% 152 12 $0.0000300
P4 Few-shot(3) 88.9% 99.2% 218 3 $0.0000345
P5 Few-shot(6)+CoT 91.6% 94.3% 452 68 $0.0001086
P6 CoT精简版 89.2% 99.6% 176 14 $0.0000348

qwen-plus 结果(同测试集):

Prompt 准确率 格式合规率 平均输入Token 平均输出Token 单条成本(元)
P1 Naive 76.8% 58.4% 40 10 ¥0.000052
P2 Role+Format 82.1% 95.8% 76 3 ¥0.000067
P3 Structured 84.3% 93.7% 150 13 ¥0.000146
P4 Few-shot(3) 87.4% 98.5% 215 3 ¥0.000178
P5 Few-shot(6)+CoT 90.2% 92.1% 448 71 ¥0.000500
P6 CoT精简版 88.6% 99.1% 174 15 ¥0.000169

几个关键结论:

  1. 从P1到P2,只加了一句"只能输出三个词之一",准确率+5.4pt,Token只+36。 这是性价比最高的一步。
  2. P5是准确率天花板,但成本是P2的8倍。 如果业务只跑一次离线分析,无所谓;如果每天要处理百万条评论,P5的账单会让你怀疑人生。
  3. P6是我最终上线的版本。 相比P5,准确率只掉2.4pt,但成本只有P5的1/3,格式合规率反而更高(99.6%)。原因很简单:CoT的收益来自"让模型分步思考",而不是"让模型把思考过程吐出来"。P6只要求一个8字以内的证据词,等于给了模型一个锚点,又没让它自由发挥。
  4. 两个模型在趋势上完全一致,说明这些Prompt设计规律是跨模型的,不是某一家模型的特性。

最终线上用的是P6,1000条评论跑批耗时约4分12秒(8并发),整体准确率稳定在89%上下浮动,格式合规率99.6%,下游解析零报错。

七、总结

这次实验让我重新理解了几件事:

Prompt Engineering 是工程,不是玄学。 每改一版,都要有可量化的指标:准确率、格式合规率、Token、成本。凭感觉调Prompt,你永远不知道自己是进步了还是运气好。

Token预算是硬约束。 Few-shot和CoT都是"用钱换准确率",你要先算清楚业务能承受的单条成本上限,再倒推Prompt复杂度。我这次就是先定了"单条成本不超过P2的3倍"这个红线,才把P5淘汰掉的。

CoT不一定要把思考过程输出。 这是P6给我的最大启发。让模型"想",但别让它"说",往往能同时拿下准确率和成本。

评测集必须够大、够脏。 我一开始用200条测,P6和P5的差距是5pt;扩到1000条之后差距收敛到2.4pt。小样本评测会放大噪声,做Prompt对比至少1000条起步。

代码和数据集我都放在本地仓库了,有需要的同学可以按上面的片段自己复现。如果你也在做类似的分类任务,建议从P2起步,直接跳到P6验证,中间几版当作消融实验理解就好。