1. 问题背景:从正则到LLM的被迫迁移

上周接到一个需求:从5000条券商研报摘要中抽取“公司-指标-数值-日期”四元组。老方案用正则+词典,在测试集上的F1只有0.58,且每次新增指标口径都要重写规则。团队决定试试LLM,但老板给的硬性指标是F1≥0.85,token成本控制在$20以内。

我选了GPT-4o-mini(版本日期2025-05-20),温度设0.2,max_tokens=2048。第一轮直接扔了一个朴素的prompt,结果惨不忍睹——F1=0.12。但正是这个惨剧,逼我系统梳理了Prompt工程的调优路径。

2. 环境与版本:固定变量是罪恶之源

# 环境锁版本,避免玄学波动
openai==1.35.0
python-dotenv==1.0.1
pandas==2.2.2
scikit-learn==1.5.0

# 评测集:人工标注200条,其中60条含复杂嵌套实体
# 模型:gpt-4o-mini-2024-07-18
# 温度:0.2(固定)
# top_p:1.0(关闭采样)
# 重试:3次,指数退避

from openai import OpenAI
import os, json, time

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

def call_llm(prompt, temp=0.2):
    for i in range(3):
        try:
            resp = client.chat.completions.create(
                model="gpt-4o-mini-2024-07-18",
                messages=[{"role": "user", "content": prompt}],
                temperature=temp,
                max_tokens=2048,
                response_format={"type": "json_object"}
            )
            return json.loads(resp.choices[0].message.content)
        except Exception as e:
            time.sleep(2**i)
    return None

固定温度、固定模型、固定response_format,这是对比实验的底线。我见过太多人在不同温度下比prompt,得出“新版模型不行”的荒谬结论。

3. 方案设计:四个阶段的Prompt迭代矩阵

我设计了四轮实验,每轮在上一轮基础上增加一个组件:

轮次 策略 新增组件 预期效果
L1 零样本 基线
L2 少样本 3个示例 格式模仿
L3 思维链 推理步骤 复杂实体
L4 自洽性校验 多路投票 消除幻觉

关键设计:每轮消耗token精确记账(用tiktoken离线计算),输出质量用自定义F1脚本评估,避免主观印象。

4. 核心实现:Prompts与评测脚本全公开

4.1 四轮Prompt模板(节选)

L1_PROMPT = """
请从研报文本中抽取实体关系四元组(公司, 指标, 数值, 日期)。
输出JSON数组,每个元素包含company, indicator, value, date字段。
文本:{text}
"""

L3_PROMPT = """
请从研报文本中抽取实体关系四元组。严格按以下步骤推理:
1. 先找出所有公司名(含简称和曾用名)
2. 对每个公司,找出其对应的财务指标(如营收、净利润)
3. 提取数值时注意单位(亿/万/元),保留原始字符串
4. 日期只取报告截止日,不取发布日
5. 最后输出JSON数组

推理过程写在"reason"字段,结果写在"results"字段。
文本:{text}
"""

# 自洽性校验:调用3次,取出现次数最多的结果
def self_consistency(prompt, n=3):
    candidates = []
    for _ in range(n):
        r = call_llm(prompt, temp=0.7)  # 提高温度增加多样性
        if r and "results" in r:
            candidates.append(json.dumps(r["results"], sort_keys=True))
    from collections import Counter
    most_common = Counter(candidates).most_common(1)[0][0]
    return json.loads(most_common)

4.2 F1评测脚本(关键:实体级对齐)

def evaluate_f1(pred_list, gold_list):
    # 将四元组转为集合,比较时忽略顺序
    pred_set = {tuple(sorted(x.items())) for x in pred_list}
    gold_set = {tuple(sorted(x.items())) for x in gold_list}

    tp = len(pred_set & gold_set)
    fp = len(pred_set - gold_set)
    fn = len(gold_set - pred_set)

    precision = tp / (tp + fp) if tp + fp else 0
    recall = tp / (tp + fn) if tp + fn else 0
    f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0
    return {"P": precision, "R": recall, "F1": f1}

注意:这里比较的是“实体值”而非“模型输出的字符串”,否则模型把“1.2亿”改成“12000万”就会误判为错误。这个细节让我的F1虚高了0.15,后来才发现。

5. 踩坑与优化:三个让人抓狂的隐蔽问题

坑1:中文逗号污染JSON。L1阶段,模型偶尔在JSON值里输出中文逗号“,”,导致json.loads直接崩溃。我不得不增加一个清洗函数:text.replace(',', ',')。但这不是根治,最后靠response_format强制约束才解决。

坑2:数组嵌套丢失。L3阶段,模型偶尔输出{"results": [{"company": "A", "value": "1.2亿"}]}——漏了indicator和date字段。原因是思维链步骤让它“聚焦”了不完整的实体。修复方案:在prompt末尾加了“确保每个四元组包含全部四个字段,缺失字段用null填充”。

坑3:伪实体幻觉。L4自洽性投票虽然消除了随机错误,但引入了另一个问题:模型在不确定时强行编造“日期”。比如原文没提日期,模型就填“2025年6月30日”。我加了系统级指令:“若原文未提及日期,date字段必须为null,禁止猜测”。这条路让recall降了5个点,但precision从0.79升到0.94,F1净增。

6. 效果数据:从0.12到0.87的详细账单

轮次 Prompt长度(token) 平均输出(token) 总消耗(200条) 成本($) precision recall F1
L1 89 412 100,200 0.15 0.18 0.09 0.12
L2 256 388 128,800 0.19 0.61 0.47 0.53
L3 410 752 232,400 0.35 0.83 0.71 0.76
L4 410 2100(3次) 502,000 0.75 0.94 0.81 0.87

关键发现:L4的token消耗是L1的5倍,但F1提升仅11个点。如果你对精度要求没那么苛刻,L3+温度0.2是性价比之王(F1=0.76,成本仅$0.35)。

另外,我尝试过将L4的温度降到0.2来省token,结果F1反而跌到0.81——因为低温下三次输出几乎相同,投票失去意义。

7. 总结:Prompt工程不是玄学,是实验科学

这轮实战最大的收获是:prompt调优必须绑定“可量化的评估指标”和“可复现的实验配置”。没有F1脚本,你可能被模型流畅的输出欺骗;没有固定温度,你无法区分是prompt变好还是随机性变好。

我的建议:先花30%时间定义评测集,再花20%时间锁定环境变量,最后50%时间做迭代实验。如果你的任务也是结构化抽取,直接把我的L4模板拿去改改字段名就能用。

最后吐槽一句:别信“一个prompt打天下”的营销号。真实世界里的prompt,都是拿token和耐心磨出来的。