一、问题背景:为什么我要认真做这件事
上个月我们上线了一个电商评论分析功能,需求很朴素:把用户评论变成结构化数据,字段包括sentiment(positive/neutral/negative)、aspects(涉及的品类维度,数组)、summary(一句话摘要)、has_complaint(布尔)。
第一版我随手写了个Prompt,本地跑demo的时候效果看着还行,一上量就崩了。问题集中在三块:
- JSON不合法。模型偶尔会在JSON前后加“好的,以下是提取结果:”这种废话,
json.loads直接抛异常,占比约30%。 - 字段缺失或类型错。
has_complaint有时候返回字符串"是",aspects有时候返回单个字符串而不是数组。 - 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%”。
如果只能记三条结论:
- 先加格式约束(字段名+类型),再加内容约束(枚举值),最后才考虑少样本示例。顺序反了会浪费Token。
- 输出前缀prefill是低成本高收益的技巧,尤其对小模型。
- 用紧凑Schema替代长自然语言描述,Token和效果可以双赢,但需要维护枚举表,别在品类频繁变动的场景硬套。
后续我打算把response_format打开再跑一轮,看看JSON mode和P5叠加后能不能摸到99.9%,以及把评测集扩到1000条看数据是否稳定。有结果再写。