一、问题背景:为什么我要认真做这次Prompt对比

上个月接了个内部需求:把各招聘平台抓下来的JD文本(HTML清洗后的纯文本,平均1200字)抽取成结构化字段,入到我们的岗位库里。字段一共9个:job_title、company、city、salary_min、salary_max、salary_unit、experience_years、education、skills(数组)。

一开始我很自信,随手写了个Prompt:

prompt = f"从下面的招聘信息中提取职位、公司、城市、薪资范围、经验要求、学历要求和技能列表,输出JSON。\n\n{jd_text}"

跑完200条测试集,人工抽检50条,字段级准确率只有61.3%。主要错误集中在三类:

  1. 薪资字段乱填。15-25K·14薪 被输出成 salary_min: 15, salary_max: 25,14薪直接丢了;还有 面议 被填成 null 和 "面议" 两种,下游解析直接报错。
  2. 技能字段格式漂移。有时输出 ["Python", "Go"],有时输出 "Python, Go",有时还给你来个 {"language": ["Python"]}。
  3. 幻觉。JD里没写学历,模型自作主张填 "本科"。

这时候我意识到,Prompt不是"写一句话"的事,它是有设计空间、有成本、有可度量指标的工程问题。于是我决定系统性地做一轮对比实验。

二、环境与版本

先把实验环境固定住,不然对比没意义:

  • 模型:gpt-4o-2024-08-06(关闭自动升级,锁版本)
  • 参数:temperature=0、top_p=1、max_tokens=1024、response_format={"type": "json_object"}(从v2开始启用)
  • SDK:openai==1.40.3,Python 3.11.7
  • 测试集:200条JD,人工标注了9个字段的golden answer,字段级准确率 = 正确字段数 / (200 × 9)
  • 薪资字段判定:salary_min、salary_max、salary_unit、年薪月数四项全对才算对
  • token统计:用 response.usage.prompt_tokens 和 completion_tokens,按200条取平均

这里有个细节值得说:response_format={"type": "json_object"} 这个参数在 gpt-4o-2024-08-06 上是支持的,但它只保证输出是合法JSON,不保证字段名对。所以后面我还会试 JSON Schema 强约束。

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

我设计了6个版本,思路是逐层加约束,看每一层带来多少收益、多少成本:

版本 设计要点 预期
v1 自然语言一句话描述 baseline
v2 明确字段名+类型+枚举值 格式稳定性↑
v3 v2 + JSON Schema 完整描述 字段名/类型严格对齐
v4 v3 + 2个few-shot示例 边界case(面议、14薪)↑
v5 v4 + 输出前自检指令(CoT) 幻觉↓,但token↑
v6 精简Schema + 1个few-shot + 关键规则内联 在准确率和成本间找平衡

四、核心实现

先贴公共的执行与评测代码,后面每版Prompt都套这个runner:

import json, time
from openai import OpenAI

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

def run_prompt(system_prompt: str, user_template: str, dataset: list[dict],
               use_json_mode: bool = True) -> dict:
    results, total_in, total_out = [], 0, 0
    for item in dataset:
        messages = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_template.format(jd=item["jd_text"])},
        ]
        kwargs = dict(model="gpt-4o-2024-08-06", messages=messages,
                      temperature=0, top_p=1, max_tokens=1024)
        if use_json_mode:
            kwargs["response_format"] = {"type": "json_object"}
        resp = client.chat.completions.create(**kwargs)
        total_in += resp.usage.prompt_tokens
        total_out += resp.usage.completion_tokens
        try:
            parsed = json.loads(resp.choices[0].message.content)
        except json.JSONDecodeError:
            parsed = {}
        results.append({"pred": parsed, "gold": item["gold"]})
        time.sleep(0.05)  # 避免触发RPM限制
    return {"results": results,
            "avg_prompt_tokens": total_in / len(dataset),
            "avg_completion_tokens": total_out / len(dataset)}

def eval_field_accuracy(results: list[dict]) -> float:
    total, correct = 0, 0
    for r in results:
        for k, gold_v in r["gold"].items():
            total += 1
            pred_v = r["pred"].get(k)
            if k == "skills":
                # 技能按集合比较,忽略顺序和大小写
                ok = (isinstance(pred_v, list) and
                      {s.lower().strip() for s in pred_v} ==
                      {s.lower().strip() for s in gold_v})
            elif k.startswith("salary"):
                ok = pred_v == gold_v
            else:
                ok = str(pred_v).strip() == str(gold_v).strip()
            correct += int(ok)
    return correct / total

下面是v6(最终采用版本)的Prompt,也是我认为性价比最高的一版:

V6_SYSTEM = """你是招聘信息结构化抽取器。只输出JSON,不要任何解释。

输出字段(严格使用以下字段名和类型):
- job_title: string
- company: string
- city: string(如"北京",不带"市")
- salary_min: int|null(单位:元/月)
- salary_max: int|null(单位:元/月)
- salary_months: int(年薪月数,默认12)
- experience: string(枚举:"应届"、"1-3年"、"3-5年"、"5-10年"、"10年以上"、"不限")
- education: string(枚举:"大专"、"本科"、"硕士"、"博士"、"不限";JD未提及填"不限")
- skills: string[](技术关键词,去重,保留原文大小写)

关键规则:
1. "15-25K·14薪" → salary_min=15000, salary_max=25000, salary_months=14
2. "面议"或未提及薪资 → salary_min=null, salary_max=null, salary_months=12
3. 薪资单位是"元/天"时,乘以21.75换算为月薪并取整
4. 严禁编造JD中不存在的信息"""

V6_USER = """示例输入:
"字节跳动招聘后端开发工程师,北京,20-40K·15薪,3-5年经验,本科及以上,熟悉Go、Redis、Kafka"

示例输出:
{"job_title":"后端开发工程师","company":"字节跳动","city":"北京","salary_min":20000,"salary_max":40000,"salary_months":15,"experience":"3-5年","education":"本科","skills":["Go","Redis","Kafka"]}

现在抽取:
{jd}"""

注意v6和v4的关键差异:我把few-shot从2个减到1个,但把"14薪怎么拆""面议怎么填""按天怎么换算"这三条最容易错的规则显式内联进system prompt。这比再给一个示例更省token,因为示例本身也要占两三百token。

五、踩坑与优化记录

坑1:JSON mode 不等于 Schema 约束。 v2开了 json_object 后,格式合法率从78%升到100%,但字段名还是会出现 jobTitle、position 这种变体。v3我一开始想用 response_format={"type": "json_schema", ...} 的strict模式,但它对 null 联合类型(["integer", "null"])的支持在当时的SDK里要手动写全required,配置稍繁琐,且strict模式会额外增加约150 token的schema开销。权衡后我放弃strict,改用"Prompt里写死字段名+后处理校验",实测字段名合规率也能到99.5%。

坑2:CoT反而帮了倒忙。 v5我加了"先分析JD再输出JSON"的指令,结果 completion_tokens 从平均186涨到512,准确率只从94.7%涨到95.1%,而且有3条样本因为分析文字里带了markdown代码块导致 json.loads 失败。结论:抽取类任务、字段定义清晰时,CoT的边际收益很低。这版直接弃用。

坑3:temperature=0 不是完全确定。 同一批数据跑两次,v4有2条样本结果不同(都是skills数组顺序不同)。所以评测时我把skills改成集合比较,否则会有假阴性。

坑4:长JD截断。 测试集里有7条JD超过3000字,v6的输入token冲到1900+。我在预处理里加了"保留前2500字符+包含'要求'/'任职资格'的段落",输入token平均降了18%,准确率没掉。

六、效果数据

200条测试集,字段级准确率与token消耗(均为单条平均):

版本 字段准确率 输入token 输出token 单条成本(相对v1) 备注
v1 61.3% 312 142 1.00x 格式漂移严重
v2 74.8% 428 158 1.29x JSON合法率100%
v3 86.2% 736 171 1.98x Schema描述占大头
v4 94.7% 1187 189 3.02x 2个few-shot
v5 95.1% 1203 512 3.98x CoT,性价比差
v6 92.1% 604 176 1.71x 最终采用

分字段看v6的短板:skills 准确率88.4%(主要错在JD里写"熟悉Java生态"这类模糊表述),salary 系列96.8%,其余字段都在95%以上。v4在skills上是93.1%,说明第二个few-shot对技能抽取确实有帮助——如果业务上skills权重更高,可以接受v4的成本。

按gpt-4o-2024-08-06的定价(输入$2.5/1M tokens,输出$10/1M tokens)折算,v6处理100万条JD的输入成本约$1510,v4约$2968,差了近一倍。对我们这种百万级存量的场景,这个差距是实打实的预算。

七、总结

三点体会:

  1. Prompt优化要带着指标做。 没有golden set和字段级准确率,"这个Prompt更好"就是感觉。我这次200条标注花了大概4小时,但后面每改一版都能5分钟内出数。
  2. 约束要加在"最容易错的地方",而不是全面地加。 v3到v4加了两个完整示例,涨了8.5个点但贵了50%;v6把示例减到一个、把规则内联,只掉2.6个点却省了49%的输入token。边际收益递减是真实存在的。
  3. CoT不是万能药。 推理类任务它有用,结构化抽取这种"规则明确、答案唯一"的任务,它主要是烧token。

最后留个待办:v6在skills字段上还有空间,我打算下一步试试"技能词典做后处理归一化",而不是继续加Prompt——有些问题真的不该让Prompt背。