一、问题背景:为什么我要做这组对比
上个月接手一个招聘数据聚合项目,需求很朴素:把爬下来的招聘JD(纯文本,格式混乱)抽成结构化字段——job_title、city、salary_min、salary_max、salary_months、experience_years、education、skills[]。
第一版我随手写了个Prompt,跑通就上线了。结果运营反馈:薪资字段错得离谱,15-25K·14薪 有时候被解析成 salary_min=15, salary_max=25, salary_months=null,有时候 salary_months 又变成 14;经验 字段里 3-5年 和 经验不限 混着来。我抽了50条人工核对,字段级F1只有0.71。
于是决定认真做一轮Prompt对比实验。之所以强调「实验」而不是「调优」,是因为我发现很多Prompt Engineering的文章只给结论不给数据,读者没法判断结论是否适用于自己的场景。这篇就把我的实验过程、代码、token账单和踩坑全摊开。
二、环境与版本
- 模型:
gpt-4o-mini(快照gpt-4o-mini-2024-07-18)、claude-3-5-sonnet-20240620 - SDK:
openai==1.40.3、anthropic==0.34.2 - Python:3.11.9
- 参数统一:
temperature=0、max_tokens=1024、response_format={"type":"json_object"}(OpenAI侧) - 测试集:120条人工标注JD,覆盖互联网/制造业/销售三类,标注字段9个
- 评测:字段级精确匹配算F1(字符串字段做归一化后比较,数组字段按集合比较)
先给个基线数字:第一版「随手Prompt」在gpt-4o-mini上,120条共消耗 input 41,280 token / output 18,960 token,F1=0.71,JSON解析失败3条。
三、方案设计:6个版本的Prompt
我把变量控制成「只改Prompt,其他全不动」,设计了6版:
| 版本 | 策略 | 核心差异 |
|---|---|---|
| V1 | Zero-shot 自然语言 | 直接说“请抽取以下字段” |
| V2 | Zero-shot + JSON Schema | 给出字段类型和枚举 |
| V3 | Few-shot 3例 | 3个输入输出示例 |
| V4 | Few-shot 3例 + Schema | V2+V3 合并 |
| V5 | CoT(先分析再输出) | 要求模型先输出推理再给JSON |
| V6 | 分步抽取 + 自校验 | 第一步抽原始片段,第二步规范化 |
V5和V6是我最想验证的:社区里很多人说CoT能提升抽取质量,但我怀疑在「结构化抽取」这种任务上,CoT的推理链会引入幻觉,而且token成本不可控。
四、核心实现
4.1 统一调用与计量封装
import json, time
from openai import OpenAI
client = OpenAI(api_key="sk-xxx")
def call_llm(prompt: str, system: str = "") -> dict:
t0 = time.time()
resp = client.chat.completions.create(
model="gpt-4o-mini-2024-07-18",
messages=[{"role": "system", "content": system},
{"role": "user", "content": prompt}],
temperature=0,
max_tokens=1024,
response_format={"type": "json_object"},
)
latency = time.time() - t0
usage = resp.usage
raw = resp.choices[0].message.content
try:
parsed = json.loads(raw)
parse_ok = True
except json.JSONDecodeError:
parsed, parse_ok = {}, False
return {
"parsed": parsed,
"parse_ok": parse_ok,
"input_tokens": usage.prompt_tokens,
"output_tokens": usage.completion_tokens,
"latency": latency,
"raw": raw,
}
4.2 V4(Few-shot + Schema)的Prompt构造
这是最终胜出的版本,贴一下构造逻辑:
SCHEMA_BLOCK = """
字段定义(严格遵守类型):
- job_title: string,职位名称,去掉薪资/城市等修饰
- city: string,城市名,多个城市取第一个
- salary_min: int|null,月薪下限(单位:元),无则null
- salary_max: int|null,月薪上限(单位:元),无则null
- salary_months: int|null,年薪月数,默认12,明确写"14薪"则为14
- experience_years: string,枚举 ["不限","1年以下","1-3年","3-5年","5-10年","10年以上"]
- education: string,枚举 ["不限","大专","本科","硕士","博士"]
- skills: string[],技能关键词,最多10个,去重
注意:15-25K 表示 15000-25000;"面议" 时 salary_min/max 均为 null。
"""
FEW_SHOT = """
示例1:
输入:上海-浦东 高级Java工程师 20-35K·15薪 3-5年 本科 熟悉Spring Cloud、MySQL
输出:{"job_title":"高级Java工程师","city":"上海","salary_min":20000,"salary_max":35000,"salary_months":15,"experience_years":"3-5年","education":"本科","skills":["Java","Spring Cloud","MySQL"]}
示例2:
输入:深圳 销售代表 薪资面议 经验不限 学历不限 有ToB经验优先
输出:{"job_title":"销售代表","city":"深圳","salary_min":null,"salary_max":null,"salary_months":12,"experience_years":"不限","education":"不限","skills":["ToB销售"]}
"""
def build_v4_prompt(jd_text: str) -> str:
return f"{SCHEMA_BLOCK}\n{FEW_SHOT}\n仅输出JSON,不要任何解释。\n\n输入:{jd_text}\n输出:"
V6的分步版本则拆成两次调用:第一次让模型只输出「薪资原文片段」「经验原文片段」等证据,第二次把证据+Schema交给模型规范化。两次调用意味着token翻倍,后面数据会看到。
五、踩坑与优化
坑1:response_format=json_object 不等于字段正确。 它只保证输出是合法JSON,不保证schema对。V1里模型经常把 salary_months 输出成字符串 "14薪",导致后续入库报错。V2加了类型说明后这个问题消失。
坑2:Few-shot示例会「污染」输出。 我最初放了3个互联网岗位示例,结果制造业JD的 skills 字段被强行塞进 Java、微服务 这类词。后来把示例改成「互联网+制造业+销售」各一条,F1立刻回升。示例的分布代表性比数量重要。
坑3:CoT在抽取任务上是负收益。 V5要求先写分析,结果模型在推理链里自己编造了「根据行业惯例,该岗位月薪约25K」,然后把这个幻觉写进JSON。而且output token从平均158涨到412。
坑4:temperature=0 并非完全确定。 同一批数据跑两次,V1有2条输出不同。做对比实验时我每条只跑一次,严格来说应该跑3次取均值,这是本文实验设计的一个缺陷,读者复现时建议补上。
六、效果数据
120条测试集,gpt-4o-mini,单次运行:
| 版本 | 字段F1 | input token | output token | 总token | 解析失败 | 平均延迟 |
|---|---|---|---|---|---|---|
| V1 Zero-shot | 0.71 | 41,280 | 18,960 | 60,240 | 3 | 1.9s |
| V2 +Schema | 0.84 | 52,080 | 19,440 | 71,520 | 0 | 2.0s |
| V3 Few-shot | 0.88 | 88,560 | 20,160 | 108,720 | 1 | 2.4s |
| V4 Few-shot+Schema | 0.93 | 99,600 | 19,800 | 119,400 | 0 | 2.5s |
| V5 CoT | 0.89 | 41,280 | 49,440 | 90,720 | 2 | 3.8s |
| V6 分步+校验 | 0.92 | 138,000 | 37,200 | 175,200 | 0 | 5.1s |
几个值得说的点:
- V4 vs V3:只加Schema块,F1从0.88到0.93,input token只多11,040。Schema是性价比最高的一步。
- V5 CoT:F1 0.89,比V2高但比V3/V4低,output token是V4的2.5倍,延迟接近翻倍。在抽取任务上不划算。
- V6 分步:F1 0.92略低于V4,token却是V4的1.47倍。分步的价值在于可解释——能拿到证据片段做人工复核,如果业务需要审计链路可以上,纯追求性价比没必要。
- 单位成本:按gpt-4o-mini定价(input $0.15/1M,output $0.60/1M),V4处理120条约 $0.0268,V6约 $0.0430。量级上都不贵,但放大到百万级就是3倍差距。
Claude 3.5 Sonnet 上我抽了V1和V4做交叉验证:V1 F1=0.74,V4 F1=0.94,趋势一致,说明结论不是单一模型的偶然。
七、总结
这轮实验下来,我的结论比较明确:
- 结构化抽取任务,优先级排序是:Schema约束 > Few-shot > CoT > 分步校验。先把字段类型、枚举、边界case写清楚,收益最大且成本最低。
- Few-shot的示例分布比数量重要,3条覆盖不同品类比10条同品类有效。
- CoT不是万能药。它适合推理类任务,但在「照着schema填空」这种任务上会引入幻觉且烧token。判断标准很简单:任务是否需要模型「想」,还是只需要模型「抄+归一化」。
- 实验设计上我踩的坑:单次运行、没做显著性检验、测试集只有120条。真实项目里如果F1差异在2个点以内,建议扩到500条以上再下结论。
最后一句实话:Prompt Engineering 的上限由模型能力决定,这轮把F1从0.71干到0.93,剩下7个点里有一半是标注本身的歧义(比如「3-5年」到底归哪个枚举),靠改Prompt已经榨不出多少了,得靠后处理规则或换更大模型。知道什么时候停手,可能比知道怎么调更重要。