一、问题背景:为什么我要认真做这次对照实验

我们有个内部工具,用来从候选人简历(纯文本,PDF解析后残留大量换行和空格)里抽取结构化字段:姓名、手机号、邮箱、最高学历、毕业院校、工作年限、当前公司、技能列表。下游要写数据库,所以字段必须严格对齐,不能自由发挥。

最初上线的Prompt是我十分钟写出来的零样本版本,效果“看起来还行”——人工抽查十几条,大部分对。但上线两周后报警:字段缺失率11%,技能列表格式五花八门(有时是数组,有时是逗号分隔字符串),还有把“期望薪资”误抽成“当前薪资”的情况。更麻烦的是解析失败要重试,重试又要烧Token。

于是我决定老老实实做一次Prompt对照实验。目标很明确:在可接受的Token成本下,把字段级准确率提上去,同时把解析失败率压下来。这篇文章就是实验记录,包含6个版本的Prompt、真实Token数字和踩过的坑。

先交代一下我关心的三个指标:

  • 字段F1:8个字段逐一比对,取宏平均。人工标注了120条简历作为测试集。
  • 单条平均Token:输入+输出之和,按OpenAI计费口径统计。
  • 解析成功率:输出能被 json.loads 直接吃下的比例,失败即触发重试。

二、环境与版本

实验环境不算复杂,列清楚方便你复现:

  • Python 3.11.6
  • openai SDK 1.30.1
  • 模型:gpt-4o-mini(主力,便宜)与 gpt-4o(对照),均通过 response_format={"type": "json_object"} 开启JSON模式
  • 参数:temperature=0,max_tokens=800,top_p=1
  • 测试集:120条简历文本,平均长度 1,850 字符(约 620 Token)
  • 评测脚本:本地跑,字段比对用 difflib 做模糊匹配(手机号/邮箱要求完全一致)

选择 gpt-4o-mini 做主模型,是因为这个任务量大(每天约2万条),gpt-4o 的成本扛不住。后面会给出两者在同一Prompt下的对比数据,供你判断值不值得升级。

三、方案设计:6个版本的Prompt

我把实验分成6组,控制变量,只改Prompt:

  • v1 零样本:一句话指令 + 输出示例字段名。
  • v2 零样本+JSON Schema描述:把每个字段的类型、格式、边界条件写清楚。
  • v3 少样本(3个示例):给3组输入输出对,覆盖“信息缺失”“多段工作经历”两种情况。
  • v4 少样本+字段约束:在v3基础上加“找不到就填null,不要编造”“手机号只保留11位数字”这类硬约束。
  • v5 两阶段:第一次调用只做字段定位(输出原文片段),第二次调用做规范化(把片段转成标准格式)。
  • v6 两阶段+自校验:在v5基础上,第二次调用要求模型先输出一个 checks 数组,逐条确认“手机号是否为11位”“邮箱是否含@”,再输出最终JSON。

这里有个设计取舍值得说:v5/v6 是两次调用,Token肯定更贵。我一开始也犹豫,但后来想明白——如果单次调用因为格式错误重试,成本可能比两次调用还高。事实证明这个判断是对的,后面有数据。

四、核心实现(含代码)

先给调用与评测的骨架代码,这段是所有版本共用的:

import json, time
from openai import OpenAI

client = OpenAI(api_key="sk-xxx")

def call_llm(prompt: str, resume: str, model="gpt-4o-mini"):
    """单次调用,返回 (解析后的dict或None, 输入token, 输出token, 耗时)"""
    t0 = time.time()
    resp = client.chat.completions.create(
        model=model,
        temperature=0,
        max_tokens=800,
        response_format={"type": "json_object"},
        messages=[
            {"role": "system", "content": prompt},
            {"role": "user", "content": resume},
        ],
    )
    cost = time.time() - t0
    usage = resp.usage
    raw = resp.choices[0].message.content
    try:
        data = json.loads(raw)
    except json.JSONDecodeError:
        data = None
    return data, usage.prompt_tokens, usage.completion_tokens, cost

v4(少样本+字段约束)的Prompt长这样,注意我把约束写成“否定式+正例”的组合,比单纯说“不要编造”有效:

PROMPT_V4 = """你是简历信息抽取器。从用户提供的简历文本中抽取字段,只输出JSON,不要输出任何解释。

字段定义:
- name: 字符串,候选人姓名;找不到填 null
- phone: 字符串,11位中国大陆手机号,只保留数字;找不到或非11位填 null
- email: 字符串,标准邮箱格式;找不到填 null
- degree: 枚举,取值 ["大专","本科","硕士","博士","其他"];找不到填 null
- school: 字符串,最高学历毕业院校;找不到填 null
- years: 数字,工作年限,按“当前年份 - 最早工作起始年份”估算,实习不计;无法判断填 null
- company: 字符串,当前公司;已离职或无法判断填 null
- skills: 字符串数组,技能关键词,最多10个,去重;找不到填 []

硬约束:
1. 绝对不要编造。原文没有的信息一律填 null,不要用“未知”“N/A”等字符串代替。
2. phone 只保留数字,去掉空格、横线、+86 前缀。示例:原文“手机:+86 138-0013-8000” → "13800138000"
3. 多段工作经历时,company 取时间上最近的一段;若该段标注“至今”,才算当前公司。
4. 输出必须是一个合法JSON对象,键名与上面完全一致,不要嵌套。

示例输入:
张三,男,2018年7月至今在字节跳动做后端开发。2016-2018在美团实习。
电话 13800138000,邮箱 zhangsan@example.com,本科毕业于浙江大学。

示例输出:
{"name":"张三","phone":"13800138000","email":"zhangsan@example.com","degree":"本科","school":"浙江大学","years":6,"company":"字节跳动","skills":[]}
"""

v6 的两阶段实现,第二阶段把“自校验”塞进同一次调用,靠 checks 字段逼模型自查:

PROMPT_V6_STAGE2 = """你将收到两段内容:①简历原文 ②第一阶段抽取的字段片段(可能不完整)。
请先逐条校验,再输出最终JSON。

输出格式(严格按此结构,先 checks 后 result):
{
  "checks": [
    {"field": "phone", "ok": true/false, "reason": "简短说明"}
  ],
  "result": { ...8个字段... }
}

校验规则:
- phone 必须为11位数字且以1开头,否则 ok=false 并在 result 中置 null
- email 必须包含 @ 和域名点号,否则 ok=false 并置 null
- years 必须是0-50之间的数字,否则 ok=false 并置 null
- skills 必须是数组且元素为字符串,否则 ok=false 并置 []

注意:checks 只针对上述4个易错字段,其余字段直接放入 result。
"""

评测脚本核心逻辑(字段比对),这段决定了我后面所有数字的可信度:

def evaluate(pred: dict, gold: dict) -> dict:
    """返回每个字段是否命中;skills 用集合Jaccard>=0.6算命中"""
    hit = {}
    for f in ["name","phone","email","degree","school","years","company"]:
        p, g = pred.get(f), gold.get(f)
        if g is None:
            hit[f] = (p is None)
        else:
            hit[f] = (p is not None and str(p).strip() == str(g).strip())
    ps = set(pred.get("skills") or [])
    gs = set(gold.get("skills") or [])
    hit["skills"] = (len(ps & gs) / len(gs) >= 0.6) if gs else (len(ps) == 0)
    return hit

五、踩坑与优化

坑1:JSON模式不等于格式正确。 开了 response_format={"type":"json_object"} 之后,v1 的解析失败率仍有 9%。原因是模型会把 skills 输出成 "Python, Java" 这种字符串,json.loads 能过,但下游类型校验挂掉。所以“解析成功率”这个指标要拆成两层:JSON可解析 + 类型符合。我后来在v2里显式写了 skills: 字符串数组,这个问题才压下去。

坑2:少样本示例会“传染”格式。 v3 我给了3个示例,其中一条示例的 skills 是空数组,结果模型对几乎所有简历都倾向输出空 skills——示例的分布偏差被放大了。优化办法是让3个示例覆盖“技能多/技能少/无技能”三种情况,并且在Prompt里加一句“示例仅示意格式,不代表实际分布”。这句话看着像废话,实测把 skills 的召回从 0.62 拉到 0.81。

坑3:两阶段不一定更贵。 这是我最大的认知修正。v5/v6 单次成功调用是两次API,Token翻倍;但因为格式错误率从 v4 的 14% 降到 3%,重试成本大幅下降。算总账(含重试)后,v6 的单条平均Token只比 v4 高 18%,见下节数据。

坑4:temperature=0 也有波动。 同一输入跑两次,v2 在 degree 字段上偶发不一致(“本科” vs “学士”)。这不是随机性,是模型对枚举边界理解不稳。解决办法是在Prompt里把枚举值写死并给同义词映射:“学士→本科,研究生→硕士”。这类“枚举归一化”最好放在Prompt里做,别留给下游。

坑5:长简历会截断。 超过 3,000 字符的简历,v1/v2 的输出经常在 skills 中途断掉(max_tokens=800 不够)。v6 因为分两阶段,第一阶段输出短,反而更稳。如果你的简历普遍很长,建议把 max_tokens 提到 1200,或者先做文本清洗去掉页眉页脚。

六、效果数据

120条测试集,gpt-4o-mini,temperature=0,每个版本跑一遍(v5/v6 计两次调用)。数字如下:

版本 字段F1 单条平均Token(含重试) 解析成功率 平均延迟
v1 零样本 0.71 712 91% 1.8s
v2 +Schema 0.79 748 96% 1.9s
v3 少样本 0.83 1,046 97% 2.2s
v4 +约束 0.87 1,092 86% 2.3s
v5 两阶段 0.91 1,238 97% 3.6s
v6 两阶段+自校验 0.94 1,289 99% 3.9s

几个值得注意的点:

  • v4 的解析成功率反而掉到 86%,因为约束写多了,模型偶尔会输出额外说明文字(“根据简历……”),破坏了纯JSON。这也说明“约束”和“格式稳定”是两件事,要分开优化。
  • v6 相比 v4,F1 提升 0.07,Token 只多 18%,重试率从 32% 降到 4%。按我们每天2万条算,v6 每天多花约 400 万 Token,但省下的重试和人工兜底成本远高于这个数。
  • degree 字段是老大难,v6 也只有 0.89;phone 和 email 在 v6 达到 0.99。所以如果你的任务里枚举字段多,别指望Prompt一步到位,考虑加一个后处理映射表。

顺带做了 gpt-4o 的对照(v6 Prompt):F1 0.96,单条 Token 1,301,延迟 5.2s。提升只有 0.02,成本却是 15 倍以上,我们最终没升级。这也印证一个经验:在结构化抽取任务上,Prompt 的信息密度比模型档位更影响边际收益。

七、总结

这次实验给我的三条结论,都是拿数字换来的:

  1. 先加Schema,再加示例,最后才上两阶段。 v2 用 5% 的Token增幅换来 0.08 的F1,是性价比最高的一步;两阶段是“格式稳定性”出问题后的补丁,不是默认选项。
  2. 少样本示例要控分布。 示例偏差会被放大,3个示例的覆盖度比示例数量更重要。
  3. 算成本要算总账。 单次调用便宜不等于总成本低,重试率和人工兜底都要折进去。v6 看着“啰嗦”,实际是最省的。

最后一句实话:Prompt Engineering 的尽头是评测集。没有那120条人工标注,我上面所有判断都是拍脑袋。如果你要做类似优化,先把评测脚本和标注集搭起来,再谈改Prompt。