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. 方案设计:从“裸提示”到“约束矩阵”

我设计了三个维度的迭代方向:

  1. 角色锚定:是否声明“你是证监会备案分析师”这类身份
  2. 输出契约:用JSON Schema还是自由文本描述字段
  3. 示例引导:零样本 vs one-shot vs few-shot(示例数量、顺序)

每次改动只变一个维度,其余保持默认。日志记录每次调用的prompt_tokenscompletion_tokenstotal_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模式反而降低准确率。functionsresponse_formatjson_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就是写几句话”的认知。核心收获有三点:

  1. 输出契约比角色更重要:V3加了角色锚定提升巨大,但真正让准确率突破90%的是V5中“示例的字段顺序与目标输出完全一致”。
  2. token消耗是衡量Prompt质量的硬指标:V5比V1少消耗67%的token,却提升了87个百分点的F1——好的Prompt是信息密度高的Prompt。
  3. 不要迷信结构化输出API:对于语义抽取,自然语言描述的约束比强制JSON Schema更灵活,且不会产生空字符串歧义。

如果你也在做RAG或信息抽取,建议用我的评测代码跑一遍你的Prompt,看看你的“示例顺序”是否在拖后腿。欢迎评论区交流你的翻车案例。