一、问题背景:为什么我要认真做这件事

上个月我们上线了一个电商评论分析功能,需求很朴素:把用户评论变成结构化数据,字段包括sentiment(positive/neutral/negative)、aspects(涉及的品类维度,数组)、summary(一句话摘要)、has_complaint(布尔)。

第一版我随手写了个Prompt,本地跑demo的时候效果看着还行,一上量就崩了。问题集中在三块:

  1. JSON不合法。模型偶尔会在JSON前后加“好的,以下是提取结果:”这种废话,json.loads直接抛异常,占比约30%。
  2. 字段缺失或类型错。has_complaint有时候返回字符串"是",aspects有时候返回单个字符串而不是数组。
  3. Token成本失控。我们一天大概处理12万条评论,输入Token每降100,一个月能省下一笔肉眼可见的钱。

于是我决定较真一次:把Prompt当成一个需要做A/B实验的工程对象,设计5个方案,量化对比Token消耗和输出质量。这篇文章就是实验记录。

二、环境与版本

先说清楚环境,不然数据没法复现:

  • Python 3.11.6
  • openai 1.40.3(调用GPT-4o-mini,模型快照gpt-4o-mini-2024-07-18)
  • dashscope 1.20.1(调用Qwen2.5-7B-Instruct,做交叉验证)
  • 评测集:人工标注的200条真实电商评论,覆盖数码、服饰、食品三个类目,正负中性大致均衡
  • 生成参数统一:temperature=0.2,top_p=0.9,max_tokens=256,response_format在第一轮实验中不开启(我想先看纯Prompt的效果,避免API能力掩盖Prompt设计差异)

评测指标定义:

  • JSON合法率:json.loads能通过、且字段齐全、类型正确的比例
  • 字段准确率:sentiment和has_complaint与人工标注一致的比例
  • 平均输入Token:用tiktoken(cl100k_base)统计,含系统提示
  • 平均输出Token:同上
  • 平均延迟:单条请求端到端毫秒数

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

我设计的5个版本,思路是逐步加约束,看每一层约束带来的边际收益:

版本 设计思路 预估输入Token
P1 零样本自然语言指令,不给格式要求 ~60
P2 P1 + 明确JSON字段说明(自然语言描述) ~180
P3 P2 + 一个完整少样本示例(few-shot) ~420
P4 P3 + 输出前缀强制(assistant prefill {) ~420
P5 P4 + 精简Schema表达(用紧凑的伪代码式Schema替代长自然语言) ~290

P5是我最后想验证的假设:约束的密度比约束的字数更重要。用紧凑的Schema描述,理论上能在更少Token下达到同等甚至更好的约束效果。

P1长这样,很朴素:

请分析这条商品评论的情感倾向、涉及的品类维度、一句话摘要,以及是否包含投诉。
评论:{review}

P5的核心片段(注意我用的是伪代码式Schema,不是JSON Schema,因为实测JSON Schema本身太占Token):

# 任务:评论结构化抽取
# 输出:仅输出JSON,无任何前后缀
# Schema:
# sentiment: enum[positive|neutral|negative]
# aspects: list[str], 0-3项, 取自[质量,物流,价格,外观,客服,口味]
# summary: str,  tuple[bool, dict | None]:
    try:
        obj = json.loads(raw)
    except json.JSONDecodeError:
        return False, None
    if not isinstance(obj, dict):
        return False, None
    if obj.get("sentiment") not in VALID_SENTI:
        return False, None
    if not isinstance(obj.get("aspects"), list):
        return False, None
    if not all(isinstance(a, str) and a in VALID_ASPECTS for a in obj["aspects"]):
        return False, None
    if not isinstance(obj.get("summary"), str) or len(obj["summary"]) > 20:
        return False, None
    if not isinstance(obj.get("has_complaint"), bool):
        return False, None
    return True, obj

def run_one(prompt_tpl: str, review: str, prefill: str | None = None):
    messages = [{"role": "user", "content": prompt_tpl.format(review=review)}]
    if prefill:
        messages.append({"role": "assistant", "content": prefill})
    t0 = time.time()
    resp = client.chat.completions.create(
        model="gpt-4o-mini-2024-07-18",
        messages=messages,
        temperature=0.2,
        top_p=0.9,
        max_tokens=256,
        stop=None if not prefill else ["\n\n"],
    )
    latency = (time.time() - t0) * 1000
    raw = resp.choices[0].message.content
    if prefill:
        raw = prefill + raw  # 把前缀拼回去再解析
    in_tok = sum(len(enc.encode(m["content"])) for m in messages)
    out_tok = len(enc.encode(resp.choices[0].message.content))
    return raw, in_tok, out_tok, latency

第二个代码块是跑全量评测并汇总,顺便把失败样本落盘,方便我人工看:

import pandas as pd

def evaluate(prompt_key: str, dataset: list[dict], prefill=None):
    rows, fails = [], []
    for item in dataset:
        raw, in_tok, out_tok, lat = run_one(PROMPTS[prompt_key], item["review"], prefill)
        ok, obj = validate(raw)
        if not ok:
            fails.append({"review": item["review"], "raw": raw})
        rows.append({
            "ok": ok,
            "sentiment_hit": ok and obj["sentiment"] == item["sentiment"],
            "complaint_hit": ok and obj["has_complaint"] == item["has_complaint"],
            "in_tok": in_tok, "out_tok": out_tok, "latency": lat,
        })
    df = pd.DataFrame(rows)
    result = {
        "json_valid_rate": round(df["ok"].mean() * 100, 1),
        "sentiment_acc": round(df.loc[df.ok, "sentiment_hit"].mean() * 100, 1),
        "complaint_acc": round(df.loc[df.ok, "complaint_hit"].mean() * 100, 1),
        "avg_in_tok": round(df["in_tok"].mean()),
        "avg_out_tok": round(df["out_tok"].mean()),
        "avg_latency_ms": round(df["latency"].mean()),
    }
    pd.DataFrame(fails).to_csv(f"fails_{prompt_key}.csv", index=False, encoding="utf-8-sig")
    return result

跑的时候用for k in PROMPTS: print(k, evaluate(k, dataset, prefill="{" if k in ("P4","P5") else None)),200条样本每个版本跑一遍,总耗时约11分钟。

五、踩坑与优化

坑1:response_format={"type":"json_object"}不是万能药。 我一开始想偷懒,直接开JSON mode,结果发现模型虽然保证合法JSON,但字段该缺还是缺——它只是保证语法,不保证语义。而且JSON mode下模型有时会把aspects塞成null。结论:格式约束和内容约束是两件事,Prompt里都得管。

坑2:少样本示例放一个就够,放三个反而变差。 P3我最初放了3个示例,输入Token冲到900+,但JSON合法率只比1个示例高0.5个百分点,而且模型开始“模仿”示例的措辞,summary字段风格僵化。改成1个覆盖边界情况的示例(带投诉+双aspect)后,效果持平,Token砍半。

坑3:prefill前缀要配合stop用。 P4用assistant prefill {之后,模型偶尔会在JSON后面继续写解释,导致解析失败。加了stop=["\n\n"]之后解决。另外注意prefill的内容要拼回去再json.loads,我第一次忘了拼,合法率统计出来是0,白白浪费了一轮实验。

优化点:P5里我把aspects的候选值直接写进Schema。这一步收益最大——字段准确率从P4的91.2%提到96.4%,因为模型不再瞎编“性价比”“做工”这类不在枚举里的维度。代价是每新增一个品类就要维护枚举表,属于用工程复杂度换模型稳定性,我认为值。

六、效果数据

200条样本,GPT-4o-mini,结果如下:

版本 JSON合法率 情感准确率 投诉准确率 平均输入Token 平均输出Token 平均延迟
P1 68.0% 84.6% 79.3% 62 48 720ms
P2 86.5% 88.1% 85.2% 178 51 760ms
P3 95.0% 90.3% 89.7% 418 53 850ms
P4 98.5% 90.8% 90.1% 421 49 830ms
P5 99.3% 91.5% 92.4% 287 50 790ms

几个值得说的点:

  • P1到P2,合法率涨了18.5个点,说明把字段类型写清楚是性价比最高的一步,几乎零成本。
  • P3到P4,只加了个{前缀,合法率从95%到98.5%,输入Token几乎不变。这是全文最“便宜”的优化。
  • P5相比P3,输入Token降了31%,合法率和准确率反而更高。约束密度 > 约束字数这个假设成立。
  • 输出Token五个版本都在50上下,说明输出长度主要由Schema决定,跟Prompt写法关系不大——这点和我预期不同。

Qwen2.5-7B-Instruct上跑同样的5个版本,趋势一致但绝对值低一些:P5的JSON合法率是94.5%,P1只有51%。小模型对Prompt结构更敏感,P4的prefill技巧在小模型上收益更大(P3→P4合法率提升9个点)。这也合理:小模型指令遵循能力弱,越“硬”的约束越管用。

按我们日均12万条的量算,P5相比P3每月省下的输入Token约等于(418-287) * 120000 * 30 / 1e6 ≈ 464百万Token,按GPT-4o-mini输入价$0.15/1M算,一个月省不到1美元——钱不是重点,重点是合法率从95%到99.3%,意味着每天少处理约5000条异常,人工兜底的工单量直接砍掉一多半。

七、总结

这次实验我最大的收获不是那5个Prompt本身,而是建立了一套可量化、可复现的Prompt迭代流程:固定评测集、固定生成参数、定义清楚的验收指标、失败样本落盘。有了这套东西,Prompt优化就从“感觉好像好一点”变成了“合法率提升3.2个点,Token降31%”。

如果只能记三条结论:

  1. 先加格式约束(字段名+类型),再加内容约束(枚举值),最后才考虑少样本示例。顺序反了会浪费Token。
  2. 输出前缀prefill是低成本高收益的技巧,尤其对小模型。
  3. 用紧凑Schema替代长自然语言描述,Token和效果可以双赢,但需要维护枚举表,别在品类频繁变动的场景硬套。

后续我打算把response_format打开再跑一轮,看看JSON mode和P5叠加后能不能摸到99.9%,以及把评测集扩到1000条看数据是否稳定。有结果再写。