1. 问题背景:不是所有Prompt都叫工程

上个月接了个需求:从电商评论里抽取“产品属性-评价维度-情感极性”三元组,比如“屏幕-清晰度-正向”。数据量不大,但脏得要命——中英混杂、emoji乱飞、还有“这手机屏幕不错但电池拉胯”这种一句话带两个实体的长尾样本。

一开始图省事,直接用了最简单的零样本Prompt:

从以下评论中抽取三元组(属性, 维度, 极性),用JSON格式输出。
评论:{text}

跑完一看结果,F1只有0.63,而且输出格式五花八门——有输出数组的,有输出对象的,还有把“屏幕”和“清晰度”合并成一个字符串的。最要命的是,解析JSON的try-catch块平均每5条就炸一次。

那一刻我意识到,Prompt不是写出来就完事,它得被当成一个工程模块来设计、测试、调优。这篇文章记录了我从0.63到0.87 F1的完整调优过程,包括每一版Prompt的token消耗、失败模式和最终的解决方案。

2. 环境与版本:选型定生死

  • 模型:gpt-4-0125-preview(OpenAI API),temperature=0.2max_tokens=512
  • 调用库:openai Python包 v1.14.2
  • 解析层:json 标准库 + tenacity v8.2.3 重试装饰器
  • 评估集:500条人工标注的电商评论,涉及手机、笔记本、耳机三类产品,共标注了1270个三元组
  • 成本基准:GPT-4-turbo输入价格 $0.01/1K tokens,输出 $0.03/1K tokens

需要说明的是,temperature=0.2不是拍脑袋定的。我跑过0.0和0.5的对照组,0.0在Few-shot场景下会导致重复生成,0.5则容易在边界样本上产生幻觉。0.2是迭代5轮后折中的结果。

3. 方案设计:从零样本到结构化约束的四级跳

我设计了四个递进版本的Prompt,每个版本针对上一个版本暴露的特定缺陷:

版本 策略 核心改动 预期解决的问题
V1 零样本 仅一句话指令 基线,摸清模型默认行为
V2 少样本 加入2个完整示例 格式漂移、抽取不完整
V3 思维链 + 少样本 加入“先列候选属性,再抽取”步骤 长尾样本漏抽
V4 结构化约束 定义JSON Schema + 枚举值限制 解析失败、非法值

每个版本都在相同的500条测试集上跑,用PromptManager类统一管理,避免手工复制粘贴导致版本混乱。下面是V4版本的核心实现。

4. 核心实现:V4结构化约束Prompt

# prompt_engineer_v4.py
# Python 3.11.7, openai 1.14.2
import json
from tenacity import retry, stop_after_attempt, wait_exponential

SYSTEM_PROMPT_V4 = """你是一个电商评论属性抽取引擎。严格遵循以下规则:
1. 只抽取评论中明确提及的属性,禁止推测。
2. 属性结构必须是[产品部件, 具体维度, 极性],极性只能是"正向"/"负向"/"中性"。
3. 输出必须是合法JSON数组,数组元素为对象,每个对象包含"attr", "dim", "polarity"三个键。
4. 如果某属性缺失,用null填充,不要省略键。
5. 禁止输出任何JSON以外的解释文本。"""

FEWSHOT_EXAMPLES_V4 = """示例1:
评论:手机屏幕色彩鲜艳,但指纹解锁偶尔失灵。
输出:[{"attr": "屏幕", "dim": "色彩", "polarity": "正向"}, {"attr": "指纹解锁", "dim": "识别率", "polarity": "负向"}]

示例2:
评论:笔记本散热一般,键盘手感不错。
输出:[{"attr": "散热系统", "dim": "散热效率", "polarity": "负向"}, {"attr": "键盘", "dim": "手感", "polarity": "正向"}]"""

USER_PROMPT_TEMPLATE = """请抽取以下评论中的属性三元组:
评论:{comment}

请直接输出JSON数组:"""

@retry(stop=stop_after_attempt(3), wait=wait_exponential(multiplier=1, min=2, max=10))
def extract_attributes(comment: str, client) -> list[dict]:
    response = client.chat.completions.create(
        model="gpt-4-0125-preview",
        temperature=0.2,
        max_tokens=512,
        messages=[
            {"role": "system", "content": SYSTEM_PROMPT_V4},
            {"role": "user", "content": FEWSHOT_EXAMPLES_V4},
            {"role": "user", "content": USER_PROMPT_TEMPLATE.format(comment=comment)}
        ]
    )
    raw_output = response.choices[0].message.content.strip()
    # 防御性解析:去除可能的markdown代码块标记
    if raw_output.startswith("```"):
        raw_output = raw_output.strip("`")
        if raw_output.startswith("json"):
            raw_output = raw_output[4:]
    result = json.loads(raw_output)
    assert isinstance(result, list), "输出不是数组"
    return result

这里有个细节:tenacity的重试逻辑不是无脑重试,我设置了指数退避。因为json.loads失败后立即重试大概率还会失败(模型有惯性),等2-5秒再请求,让模型重新采样,成功率会高不少。

5. 踩坑与优化:一次真实的崩溃事故

V3版本曾经让我差点想放弃。V3加入思维链后,模型确实能抽到更细的属性了,但代价是输出里经常混入推理过程。看看这个真实输出:

好的,我先找出评论中提到的实体。评论是“手机屏幕色彩鲜艳”,属性是屏幕,维度是色彩,情感是正向。然后我继续看“指纹解锁偶尔失灵”,属性是指纹解锁,维度是识别率,情感是负向。所以输出为:
[{"attr": "屏幕", "dim": "色彩", "polarity": "正向"}, ...]

这段输出直接导致json.loads抛出JSONDecodeError。当时我统计了一下,V3版本的解析失败率高达18%,也就是每5条评论就有1条白费。这不仅浪费token(失败重试会重复计费),还严重拖慢处理速度。

解决办法是在System Prompt里加了第五条规则:“禁止输出任何JSON以外的解释文本”。同时,我在解析层做了防御处理:

import re

def robust_parse(raw_output: str) -> list[dict]:
    """从模型输出中提取第一个JSON数组"""
    # 策略1:直接尝试完整解析
    try:
        return json.loads(raw_output)
    except json.JSONDecodeError:
        pass
    # 策略2:用正则找到第一个[和最后一个]
    match = re.search(r'\[.*\]', raw_output, re.DOTALL)
    if match:
        try:
            return json.loads(match.group())
        except json.JSONDecodeError:
            pass
    # 策略3:尝试截取到最后一个}
    last_brace = raw_output.rfind('}')
    if last_brace != -1:
        try:
            return json.loads(raw_output[:last_brace+1] + ']')
        except:
            raise ValueError(f"无法解析的输出: {raw_output[:200]}")

这个robust_parse函数把解析失败率从18%降到了0.2%——但注意,0.2%的失败仍然存在。如果你做的是金融或医疗场景,建议配合人工审核兜底。

另一个坑是token消耗。V4版本因为加入了System Prompt和Few-shot示例,每条请求的输入token从V1的约350涨到了约1450,增加了314%。但V4的F1比V1高了0.24,且解析失败率几乎为零,节省了重试成本。算一笔账:跑完500条数据,V1总成本约$3.2,V4总成本约$9.8。多花了$6.6,但F1从0.63提到了0.87,且审核时间从3小时缩短到40分钟。对于生产环境,这钱花得值。

6. 效果数据:量化对比与最终选择

指标 V1 (零样本) V2 (少样本) V3 (思维链) V4 (结构化约束)
F1 (微观平均) 0.63 0.71 0.78 0.87
精确率 0.72 0.76 0.81 0.89
召回率 0.56 0.66 0.75 0.85
JSON解析失败率 7.2% 4.8% 18.0% 0.2%
输入tokens/条 351 892 1240 1450
输出tokens/条 82 91 156 104
单条成本($) 0.006 0.012 0.017 0.019

几个值得注意的结论:

  1. 思维链不是银弹:V3的召回率提升明显,但代价是输出长度失控(156 tokens,比V4多了50%),且解析失败率飙升。思维链适合开放域生成,不适合严格结构化输出。
  2. 结构化约束是性价比之王:V4的F1比V3高0.09,成本只多了11%,但解析失败率从18%降到几乎为零。如果你的任务需要程序化消费输出,V4这种“输出Schema写死在Prompt里”的方式是最优解。
  3. 温度参数被低估:我跑过temperature=0.0的V4版本,F1反而降到0.84,因为模型在Few-shot示例影响下出现了重复输出。0.2是甜点值。

最终我选择了V4部署到生产环境,并搭配了robust_parse作为最后的保险丝。如果你也在做类似的抽取任务,建议直接抄V4的作业,然后把Few-shot示例换成你自己的领域数据——注意示例的多样性,尽量覆盖边界情况(多属性、否定句、隐含情感)。

7. 总结:Prompt工程的三个原则

这次调优踩了无数坑,总结三条血泪经验:

  1. 先定评估集,再写Prompt。没有量化指标,你根本不知道改动是变好还是变坏。我用500条标注数据跑F1,每次改动都记录,迭代了14个版本才到V4。
  2. 把输出约束写进System Prompt,而不是在User Prompt里苦口婆心。模型对System Prompt的遵循度远高于User Prompt,这是GPT-4-turbo的一个隐蔽行为特征。
  3. 解析层要做好抗崩溃设计。无论Prompt写得多完美,模型总有概率输出垃圾。robust_parse这种防御性代码不是可有可无,而是生产级的必备组件。

最后,token消耗和输出质量永远是一对矛盾。V4比V1贵3倍,但F1高了38%,且人工修正成本大幅下降。在真实业务里,请务必把“返工人工成本”也算进去,别只看API账单。