一、问题背景:一个看似简单的抽取任务,翻车了
事情起因很简单。我在做一个合同管理系统,需要从采购合同文本里抽取结构化字段:合同编号、甲方、乙方、签订日期、合同金额、付款方式、违约金比例。输入是几百字的合同片段,输出是 JSON。
一开始我觉得这活儿太简单了,直接丢给 GPT-4o 就行。Prompt 大概是这样的:
从下面的合同文本中抽取信息,输出JSON:
{contract_text}
跑了一百条测试集,人工核对后准确率只有 61.3%。问题集中在几类:
- 日期格式混乱,有的输出
2024年3月1日,有的输出2024-03-01 - 金额带单位,
¥1,200,000和1200000混着来 - 违约金比例有时候抽的是"日万分之五",有时候抽的是"0.05%"
- 最要命的是字段缺失时,模型会编造一个看起来合理的值
这就是典型的 Prompt Engineering 问题:不是模型不行,是我没说清楚。于是我决定系统性地做一次对照实验。
二、环境与版本
先交代清楚实验环境,避免有人说结果不可复现:
- 模型:
gpt-4o-2024-08-06 - SDK:
openai==1.40.0 - Python:3.11.6
- 参数:
temperature=0,top_p=1,max_tokens=1024,response_format按方案不同 - 测试集:120 条真实合同片段,人工标注了 gold label
- 评测:字段级 exact match,6 个字段全对才算整条正确
成本核算按 OpenAI 官方定价:输入 $2.5 / 1M tokens,输出 $10 / 1M tokens。
三、方案设计:三种 Prompt 粒度
我设计了三个版本,控制变量,只改 Prompt:
方案 A:朴素指令(Baseline)
就是上面那个,一句话描述任务。
方案 B:Few-shot + 字段规范
给 3 个示例,并且明确每个字段的类型、格式、缺失时的处理方式(统一填 null)。
方案 C:Schema 约束 + 分步推理(CoT)
在 B 的基础上,要求模型先输出推理过程(哪些字段找到了、依据是什么),再输出 JSON;同时用 JSON Schema 描述字段,并开启 response_format={"type": "json_object"}。
四、核心实现
先写一个统一的调用与评测框架:
import json
import time
from openai import OpenAI
from typing import Callable
client = OpenAI(api_key="sk-xxx")
FIELDS = ["contract_no", "party_a", "party_b",
"sign_date", "amount", "penalty_rate"]
def call_llm(prompt: str, use_json_mode: bool = False) -> dict:
kwargs = dict(
model="gpt-4o-2024-08-06",
messages=[{"role": "user", "content": prompt}],
temperature=0,
top_p=1,
max_tokens=1024,
)
if use_json_mode:
kwargs["response_format"] = {"type": "json_object"}
t0 = time.time()
resp = client.chat.completions.create(**kwargs)
latency = time.time() - t0
return {
"content": resp.choices[0].message.content,
"prompt_tokens": resp.usage.prompt_tokens,
"completion_tokens": resp.usage.completion_tokens,
"latency": latency,
}
def evaluate(predict_fn: Callable[[str], dict], testset: list) -> dict:
total, correct = 0, 0
total_prompt_tok, total_completion_tok = 0, 0
field_hit = {f: 0 for f in FIELDS}
for item in testset:
text, gold = item["text"], item["label"]
result = predict_fn(text)
total += 1
total_prompt_tok += result["prompt_tokens"]
total_completion_tok += result["completion_tokens"]
try:
pred = json.loads(result["content"])
except json.JSONDecodeError:
continue
all_ok = True
for f in FIELDS:
if str(pred.get(f)).strip() == str(gold.get(f)).strip():
field_hit[f] += 1
else:
all_ok = False
if all_ok:
correct += 1
n = len(testset)
return {
"accuracy": round(correct / n, 4),
"field_acc": {f: round(hit / n, 4) for f, hit in field_hit.items()},
"avg_prompt_tokens": round(total_prompt_tok / n, 1),
"avg_completion_tokens": round(total_completion_tok / n, 1),
}
然后是三个方案的 Prompt 构造函数:
FIELD_SPEC = """
字段规范:
- contract_no: 字符串,合同编号,原样保留不含"合同编号:"前缀
- party_a: 字符串,甲方全称
- party_b: 字符串,乙方全称
- sign_date: 字符串,格式必须为 YYYY-MM-DD,无法确定填 null
- amount: 数字,单位元,去掉货币符号和千分位逗号
- penalty_rate: 字符串,违约金比例,统一转为"日万分之X"或"合同总额的X%"
"""
def prompt_a(text: str) -> str:
return f"从下面的合同文本中抽取信息,输出JSON:\n{text}"
def prompt_b(text: str, examples: list) -> str:
shots = "\n\n".join(
f"输入:\n{ex['text']}\n输出:\n{json.dumps(ex['label'], ensure_ascii=False)}"
for ex in examples
)
return (
f"你是一个合同信息抽取助手。请严格按字段规范抽取信息,"
f"未出现的字段填 null,不要编造。\n{FIELD_SPEC}\n\n"
f"示例:\n{shots}\n\n"
f"现在抽取:\n{text}\n只输出JSON。"
)
def prompt_c(text: str, examples: list) -> str:
base = prompt_b(text, examples)
return base + (
"\n\n请先用一句话说明每个字段的抽取依据(找不到的写'未提及'),"
"然后再输出最终JSON。输出格式:\n"
'{"reasoning": "...", "result": {...}}'
)
方案 C 的输出需要从 result 字段里取,评测时稍微包装一下即可。
五、踩坑与优化
实验过程中踩了几个坑,值得单独说:
坑 1:temperature=0 不等于完全确定。
同一输入多次调用,方案 A 偶尔还是会给出不同格式。开了 response_format=json_object 之后稳定很多,但注意这个参数要求 Prompt 里必须出现 "JSON" 字样,否则会报 400。我第一次就栽在这。
坑 2:Few-shot 示例的格式会"污染"输出。
我一开始示例里的金额写成 1200000(数字),结果模型遇到带小数的情况也硬要转成整数。后来把示例改成 1200000.00 才好转。示例即契约,这句话是真的。
坑 3:CoT 让输出 Token 暴涨。
方案 C 的 reasoning 字段平均占了 180 个 completion token,但准确率提升显著,尤其是 penalty_rate 这个最难字段。这里有个取舍:如果只做批处理、不在意延迟,CoT 很值;如果是实时接口,得掂量。
坑 4:max_tokens=1024 在长合同上会截断。
有几条 800 字以上的合同,方案 C 输出被截断导致 JSON 解析失败。后来调到 2048 才解决,但成本又上去了。建议按实际文本长度动态设置。
六、效果数据
120 条测试集,三种方案对比:
| 方案 | 整条准确率 | contract_no | sign_date | amount | penalty_rate | 平均输入Token | 平均输出Token | 单条成本 |
|---|---|---|---|---|---|---|---|---|
| A 朴素 | 61.3% | 92.5% | 74.2% | 80.8% | 55.0% | 186 | 32 | $0.00079 |
| B Few-shot | 86.7% | 99.2% | 95.8% | 97.5% | 78.3% | 624 | 38 | $0.00194 |
| C Schema+CoT | 94.0% | 99.2% | 98.3% | 99.2% | 90.8% | 651 | 221 | $0.00384 |
几个观察:
- Few-shot 的性价比最高。从 61.3% 到 86.7%,成本只涨了 2.5 倍,但准确率涨了 25 个百分点。
- CoT 主要救的是难字段。
penalty_rate从 78.3% 到 90.8%,是提升最大的字段,其他字段边际收益已经很小。 - Token 消耗和准确率不是线性的。方案 C 比 B 多花了近一倍成本(输出 token 从 38 涨到 221),只换来 7.3 个百分点的提升。
最终我上线的是 方案 B+:在 B 的基础上,只对 penalty_rate 字段追加一句"若涉及违约金,请先复述原文再判定"。整条准确率 91.7%,单条成本 $0.0021,比方案 C 省了 45% 的钱,只损失 2.3 个百分点。
七、总结
这轮实验给我几个明确结论:
- Prompt 的边际收益递减非常明显。从 0 到 1 的规范描述收益最大,从 1 到 10 的 CoT 收益开始变贵。
- 一定要量化对比。凭感觉调 Prompt 很容易陷入"感觉变好了"的幻觉,跑个 120 条测试集,准确率和 Token 一摆出来,取舍立刻清晰。
- 成本敏感场景下,混合策略是最优解。不是所有字段都值得上 CoT,把贵的技巧用在最难的那 1-2 个字段上,性价比最高。
response_format=json_object是生产必备,能省掉大量解析失败的兜底逻辑,但记得 Prompt 里带上 "JSON" 关键词。
Prompt Engineering 不是玄学,它就是一个可以用数据说话、可以用成本衡量的工程问题。希望这篇实验记录能帮到正在调 Prompt 的你。