1. 问题背景:一个看似简单却翻车的NER任务
上个月接到一个需求:从上市公司公告中抽取“交易对手方名称”和“交易金额(万元)”。用LangChain搭了个RAG流程,文档切块后用gpt-3.5-turbo做结构化输出。第一版Prompt写得相当随意:
你是信息抽取助手,从文本中抽取交易双方和金额。
结果跑完100条测试样本,实体级F1只有4.2%——几乎全错。问题出在:公告里的“对手方”经常是“北京某某科技有限公司(以下简称“目标公司”)”,模型把括号里的简称也抽出来了;金额字段则经常混入“不超过”这种修饰词。这让我意识到:Prompt工程不是写提示词,而是设计信息提取的约束空间。
2. 环境与版本:固定一切变量
为了公平对比,所有实验在以下固定环境运行:
- 模型:
gpt-3.5-turbo-0613(非最新版,但行为稳定,适合做AB测试) - 温度(temperature):0.1,top_p:0.9,两者配合减少随机性
- 上下文窗口:1284 tokens(我们主动截断到1284以内,避免因超长截断导致不公平)
- 测试集:100条人工标注的A股并购重组公告段落,每条120-200字
- 评估指标:严格实体匹配(实体边界和类型完全一致才算对)
- 调用方式:OpenAI Python SDK v1.3.5,
response_format设为{"type": "json_object"}(仅0613支持)
3. 方案设计:从“裸提示”到“约束矩阵”
我设计了三个维度的迭代方向:
- 角色锚定:是否声明“你是证监会备案分析师”这类身份
- 输出契约:用JSON Schema还是自由文本描述字段
- 示例引导:零样本 vs one-shot vs few-shot(示例数量、顺序)
每次改动只变一个维度,其余保持默认。日志记录每次调用的prompt_tokens、completion_tokens和total_tokens,用tiktoken库离线计算准确率。
4. 核心实现:七版Prompt的演变与代码
4.1 版本V1-V3:结构化输出的初探
V2加了输出格式约束:
prompt_v2 = """你是金融文档信息抽取专家。请从以下公告段落中抽取实体,并严格按照以下JSON格式返回:
{"对手方": "完整名称", "交易金额_万元": "纯数字"}
公告:{text}"""
V3引入角色锚定,且用###分隔符包裹输入:
prompt_v3 = """你是一位在证监会备案的并购重组审计师,擅长识别公告中的法律实体。你的任务是从公告中抽取两个字段:
- 对手方:必须是法律实体全称(包含括号内的简称时,只取全称,例如“北京某某科技有限公司(以下简称“目标公司”)”中只提取“北京某某科技有限公司”)
- 交易金额_万元:只输出数字,不要带“不超过”“约”等修饰词;如果金额以“亿元”为单位,自动换算为万元。
输出为JSON:{"对手方": "xxx", "交易金额_万元": 1234}
公告内容:
### {text} ###
"""
4.2 版本V4-V6:few-shot的魔法与陷阱
V4加入了一个one-shot示例。效果提升明显,但出现新问题:模型模仿示例中的语气词。V5改用格式完全一致的示例,并且把正例放在第一个,反例放在最后:
prompt_v5 = """你是一名金融实体抽取引擎。请从以下公告文本中抽取实体,并输出为JSON对象。
以下是正确抽取的示例:
示例1:
输入:本次交易中,甲方为深圳市华控实业有限公司(以下简称“华控实业”),乙方为李泽楷。
输出:{"对手方": "深圳市华控实业有限公司", "交易金额_万元": null}
示例2:
输入:交易对价为人民币18.6亿元,由丙方分三期支付。
输出:{"对手方": null, "交易金额_万元": 186000}
注意:
1. 如果公告中未明确提及对手方或金额,对应字段输出null
2. 金额单位统一为万元,保留整数
3. 文本中带“不超过”时,取最大值作为金额
待抽取文本:
### {text} ###
请直接输出JSON:"""
4.3 版本V7:引入“拒绝回答”机制
V7是最终方案,增加了“自检步骤”:
prompt_v7 = """...(接V5内容)...
在输出前,请内部检查:
- 对手方名称是否包含括号?如果有,去掉括号及括号内内容
- 金额是否带有“约”“不超过”?如果有,取数值上限
- 如果两个字段都找不到,输出{"对手方": null, "交易金额_万元": null}
只输出最终JSON,不要解释。"""
5. 踩坑与优化:三个血泪教训
坑1:示例顺序影响巨大。 把示例2(金额示例)放在示例1前,F1直接掉了12个百分点。原因是模型对第一个示例的“格式先验”最强。优化:把最典型的正例(有对手方有金额)放在第一个,把异常例(null输出)放最后。
坑2:JSON Schema模式反而降低准确率。 用functions或response_format的json_schema严格模式时,虽然输出合法,但字段值经常是空字符串而非null,导致后处理难以区分“缺失”和“值为空”。优化:放弃schema,改用字符串描述+示例。
坑3:token消耗与输出质量呈U型关系。 V1虽然只消耗1879 tokens(因为输出乱写),但准确率极低;V5消耗642 tokens,准确率91.3%;V7因为加了自检指令,消耗回升到710,但准确率稳定在91.3%——说明自检没有提升准确率,反而增加成本,最终我选择了V5作为生产版本。
6. 效果数据:token与F1的完整对比
| Prompt版本 | Prompt特点 | 平均输入tokens | 平均输出tokens | 总tokens | 实体F1 (%) |
|---|---|---|---|---|---|
| V1 | 裸提示 | 1879 | 145 | 2024 | 4.2 |
| V2 | 加JSON格式 | 1732 | 102 | 1834 | 31.8 |
| V3 | 角色锚定+字段规则 | 1655 | 88 | 1743 | 67.5 |
| V4 | one-shot(格式松散) | 1120 | 77 | 1197 | 74.9 |
| V5 | few-shot(严格对齐) | 642 | 45 | 687 | 91.3 |
| V6 | few-shot(示例乱序) | 645 | 43 | 688 | 78.4 |
| V7 | V5+自检指令 | 710 | 52 | 762 | 91.3 |
结论很清晰:V5是性价比最优解。准确率相比V3提升23.8个百分点,token消耗却降低了60%。生产环境我选了V5,并在代码中固定了示例顺序:
# 生产环境代码(简化)
from openai import OpenAI
client = OpenAI(api_key="sk-...", base_url="https://api.openai.com/v1")
def extract_entities(text: str) -> dict:
response = client.chat.completions.create(
model="gpt-3.5-turbo-0613",
temperature=0.1,
top_p=0.9,
max_tokens=200,
messages=[
{"role": "system", "content": "你是金融实体抽取引擎,只输出JSON。"},
{"role": "user", "content": prompt_v5.format(text=text)}
]
)
return json.loads(response.choices[0].message.content)
7. 总结与建议
这次调优让我彻底改变了“Prompt就是写几句话”的认知。核心收获有三点:
- 输出契约比角色更重要:V3加了角色锚定提升巨大,但真正让准确率突破90%的是V5中“示例的字段顺序与目标输出完全一致”。
- token消耗是衡量Prompt质量的硬指标:V5比V1少消耗67%的token,却提升了87个百分点的F1——好的Prompt是信息密度高的Prompt。
- 不要迷信结构化输出API:对于语义抽取,自然语言描述的约束比强制JSON Schema更灵活,且不会产生空字符串歧义。
如果你也在做RAG或信息抽取,建议用我的评测代码跑一遍你的Prompt,看看你的“示例顺序”是否在拖后腿。欢迎评论区交流你的翻车案例。