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和耐心磨出来的。