一、问题背景:当通用Prompt撞上金融黑话

上个月接了个需求:从券商研报PDF提取“目标价”、“评级”、“EPS预测”三个字段,喂给下游量化模型。原本以为调个GPT-4o-mini就完事,结果第一次跑完测试集,准确率42%——大部分错误集中在“目标价”字段,模型把“12个月目标价区间18.5-21.3元”提取成了“18.5”,丢了区间;还有把“维持买入评级”里的“买入”识别成目标价。

这活儿远看是NER,细看是语义解析。通用Prompt直接问“请提取以下文本中的目标价”,模型对金融文本的隐含格式(区间、条件句、比较级)完全无感。我决定系统性地做一轮Prompt工程实验,记录每个版本的改动和代价。

二、环境与版本:固定模型,控制变量

  • 模型:gpt-4o-mini-2024-07-18(主实验)、deepseek-chat(对比实验)
  • 推理框架:OpenAI Python SDK v1.35.3,DeepSeek SDK v1.0.2
  • 测试集:120条人工标注的研报句子(来自Wind终端公开摘要),每条平均长度85字
  • 评估指标:字段级精确匹配(完全一致才算对)
  • 温度:0(所有实验固定)
  • Token计费:OpenAI $0.15/1M输入,$0.60/1M输出;DeepSeek 按官方价

我要强调的是:所有实验必须固定模型版本。有一次我在中途升级了gpt-4o-mini的deployment,导致结果不可比,重跑了一整天。

三、方案设计:从零样本到结构化输出的五步阶梯

我设计了一个递增复杂度的实验矩阵,每步只改一个变量:

  1. V1 零样本裸Prompt:直接问“提取XX”
  2. V2 加角色与输出格式:指定JSON结构
  3. V3 加入few-shot示例(2条高质量示例)
  4. V4 增加否定约束与边界条件(如“若为区间,保留完整区间”)
  5. V5 引入思维链引导(先判断文本类型,再抽取)

每一步都记录:准确率、平均输入token、平均输出token、单条成本。

四、核心实现:五个版本的Prompt演变与代码

下面是V1和V5的完整Prompt模板(实际发送的messages数组):

# V1 - 零样本裸Prompt(准确率42%)
def build_prompt_v1(text: str) -> list[dict]:
    return [
        {"role": "system", "content": "你是一个信息抽取助手。"},
        {"role": "user", "content": f"从以下研报句子中提取目标价、评级、EPS预测。\n句子:{text}\n输出:"}
    ]

# V5 - 思维链 + 格式锚定 + 边界约束(准确率91%)
def build_prompt_v5(text: str) -> list[dict]:
    system_prompt = """你是金融研报结构化抽取器。遵循以下规则:
1. 先判断句子类型:估值段、评级段、财务预测段。
2. 目标价规则:若是区间,输出完整区间如"18.5-21.3"; 若是单一值,输出单一值;若未提及,输出null。
3. 评级规则:只提取评级词(买入/增持/中性/减持)及其修饰词(维持/上调/下调),格式为"操作_评级"。
4. EPS预测:提取数字和单位(元/股),忽略“同比”等非数字信息。
5. 所有输出必须为合法JSON:{"目标价": "...", "评级": "...", "EPS": "..."}
不要输出任何额外解释。"""

    few_shot_1 = "句子:考虑公司增长确定性,给予目标价28.5元,对应2024年35倍PE,维持增持评级,预计2024年EPS为0.81元。"
    few_shot_1_out = '{"目标价": "28.5", "评级": "维持_增持", "EPS": "0.81"}'

    few_shot_2 = "句子:我们预计2024-2025年EPS分别为1.12/1.35元,目标价区间上调至45-50元,评级由中性上调至买入。"
    few_shot_2_out = '{"目标价": "45-50", "评级": "上调_买入", "EPS": "1.12/1.35"}'

    user_content = f"""句子:{text}
请先内部推理(不输出),然后严格输出JSON。"""

    return [
        {"role": "system", "content": system_prompt},
        {"role": "user", "content": few_shot_1},
        {"role": "assistant", "content": few_shot_1_out},
        {"role": "user", "content": few_shot_2},
        {"role": "assistant", "content": few_shot_2_out},
        {"role": "user", "content": user_content}
    ]

注意V5的system prompt里我用了“内部推理(不输出)”来暗扣思维链——这比让模型显式输出“思考过程”更省token,且避免被安全过滤器拦。实测显式思维链会让输出token暴涨到300+,而暗扣方式只增加约15个token。

五、踩坑与优化:三个隐藏的“准确率杀手”

坑1:few-shot示例顺序影响巨大。我把V5的两个示例调换顺序后,准确率从91%掉到87%。模型对最后的示例有“近因偏好”,跟人类做选择题似的。解决方案:把最典型的例子(含区间目标价)放最后。

坑2:输出格式漂移。V3版本我问“请提取”,模型偶尔输出自然语言“目标价是18.5元”,导致我json.loads直接崩。后来在system prompt里加了“必须为合法JSON”并给了两个严格示例,才把格式稳定性从76%拉到99.2%。

坑3:DeepSeek对“null”的处理。同一套V5模板,DeepSeek在未提及EPS时会输出"EPS": "无"而不是null。后来发现DeepSeek的中文对齐偏好导致它倾向于输出中文词。我在system prompt里加了一句“对于缺失字段,必须输出小写英文null”,问题解决。

成本方面,V5的输入token因为system prompt膨胀(从60到220),但输出token反而下降(从平均95降到62),因为模型不再废话。综合算下来:

版本 准确率 平均输入token 平均输出token 单条成本(¥)
V1 42% 98 76 0.083
V2 58% 112 83 0.091
V3 74% 210 91 0.107
V4 82% 245 88 0.094
V5 91% 268 62 0.047

注意V5成本比V1还低——因为输出token几乎砍半。价格是按token算,不是按Prompt长度算的。

六、效果数据与最终对比

120条测试集上,V5的失败模式分布如下:
- 目标价提取错误:7条(多为“保守/乐观目标价”这类修饰词干扰)
- 评级提取错误:2条(罕见“谨慎增持”未覆盖)
- EPS错误:2条(区间EPS如“1.10-1.20元”只提取了起点)

用DeepSeek-V3跑V5模板,准确率85%,主要差在“区间目标价”识别率低(它倾向于取区间中值)。但DeepSeek单条成本仅0.021元,如果业务对92%与85%的差距不敏感,DeepSeek性价比更高。

总结

Prompt工程不是玄学,它本质是对模型先验分布与任务分布之间差异的补偿。这次实验最大收获不是那91%的准确率,而是确立了“每个Prompt改动必须配一次成本核算”的流程——毕竟线上跑一天几百块钱,省下来的token都是利润。下一步我打算试一下动态few-shot(按句子相似度检索示例),预计能把目标价错误再压一半。

如果你在生产环境调Prompt,我的建议是:永远先写一个能输出JSON的裸Prompt,再逐步加约束,每步都要跑同一份测试集。别信感觉,数据说话。