一、问题背景:为什么我要认真做这次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%。主要错误集中在三类:
- 薪资字段乱填。
15-25K·14薪被输出成salary_min: 15, salary_max: 25,14薪直接丢了;还有面议被填成null和"面议"两种,下游解析直接报错。 - 技能字段格式漂移。有时输出
["Python", "Go"],有时输出"Python, Go",有时还给你来个{"language": ["Python"]}。 - 幻觉。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,差了近一倍。对我们这种百万级存量的场景,这个差距是实打实的预算。
七、总结
三点体会:
- Prompt优化要带着指标做。 没有golden set和字段级准确率,"这个Prompt更好"就是感觉。我这次200条标注花了大概4小时,但后面每改一版都能5分钟内出数。
- 约束要加在"最容易错的地方",而不是全面地加。 v3到v4加了两个完整示例,涨了8.5个点但贵了50%;v6把示例减到一个、把规则内联,只掉2.6个点却省了49%的输入token。边际收益递减是真实存在的。
- CoT不是万能药。 推理类任务它有用,结构化抽取这种"规则明确、答案唯一"的任务,它主要是烧token。
最后留个待办:v6在skills字段上还有空间,我打算下一步试试"技能词典做后处理归一化",而不是继续加Prompt——有些问题真的不该让Prompt背。