一、为什么我盯着“招标公告”做抽取

上周接到一个需求:从PDF招标公告里抽取出“项目编号”、“预算金额”、“投标截止时间”、“资质要求”四个字段,然后灌入内部CRM系统。看起来简单,但公告文本有大量噪声——比如“本项目预算为人民币壹佰贰拾万元整(¥1,200,000.00)”,既有中文大写又有阿拉伯数字;又比如“时间:2024年3月15日9:30(北京时间)”,但前面可能还有一句“开标时间另行通知”。

我第一个想法是写正则,结果被现实教育——同一家代理机构发的公告,格式能有三套变体。于是转向Prompt Engineering,目标是让gpt-3.5-turbo稳定输出JSON。环境配置如下:

  • 模型:gpt-3.5-turbo-1106(temperature=0.2,response_format=json_object)
  • 调用库:openai-python v1.14.2
  • 测试数据:100条真实招标公告,人工标注字段
  • 评估指标:字段级精确率/召回率/F1,以及单次调用Token数(通过usage.prompt_tokens + completion_tokens统计)

二、第一轮:零样本直抽——基线惨不忍睹

第一版Prompt简单粗暴:

def extract_v1(text):
    prompt = f"""从以下招标公告中提取字段:项目编号、预算金额、投标截止时间、资质要求。
    输出JSON格式,键名分别为:project_no, budget, deadline, qualification。
    公告内容:{text}"""
    resp = client.chat.completions.create(
        model="gpt-3.5-turbo-1106",
        response_format={"type": "json_object"},
        temperature=0.2,
        messages=[{"role": "user", "content": prompt}]
    )
    return resp.choices[0].message.content

100条跑完,结果让我皱眉:准确率52%,F1=0.58。问题集中在三类:

  1. 预算金额:模型经常把“约120万元”抽成“120”,丢了币种和单位;或者把“最高限价”和“预算”混淆。
  2. 投标截止时间:如果正文里同时出现“报名截止”和“开标时间”,模型会把“开标时间”当成deadline,但业务要求的是“递交投标文件的截止时间”。
  3. 资质要求:公告里写“需具备建筑装修装饰工程专业承包二级及以上”,模型只输出“二级”,漏掉“及以上”的范围限定。

Token消耗倒是中规中矩:平均prompt_tokens=1240,completion_tokens=607,总计1847。但输出质量不达标,根本无法上线。

三、第二轮:角色约束+格式强控——提升有限且Token暴涨

网上都说“加角色设定能提升效果”,我试了:

def extract_v2(text):
    prompt = f"""你是一位从事政府采购10年的资深评标专家,熟悉《招标投标法》及各类公告范本。
    现在请从给定公告中提取结构化信息。注意:
    1. 预算金额必须包含货币符号和单位,如"人民币120万元"。
    2. 投标截止时间指"递交投标文件的截止时间",若公告未明确,则输出"未明确"。
    3. 资质要求保留完整原文,包括等级后缀(如"及以上"、"(含)")。
    输出JSON:{{"project_no": "", "budget": "", "deadline": "", "qualification": ""}}
    公告:{text}"""
    # 调用逻辑同v1

效果:准确率提升到56%,F1=0.61。只涨了4个点,但prompt_tokens飙到1580,completion_tokens变成690,总Token达到2270。为什么Token涨?因为角色设定和“注意”部分占了额外空间,而模型输出时又试图“复述”一部分规则。

坑1:角色设定对这类规则明确的抽取任务帮助甚微。模型不是不懂业务,而是不知道怎么在长文本里定位关键边界。与其给身份,不如给“定位策略”。

四、第三轮:思维链(CoT)拆解步骤——转折点

我重新分析错误样本,发现模型的问题本质是“跳过定位直接抽取”。于是改成两步走:先让模型找出包含关键信息的句子(定位),再基于句子填字段(抽取)。

def extract_v3(text):
    prompt = f"""请按以下步骤处理招标公告:

    步骤1-边界定位:依次找出以下四个字段对应的原文句子,用【】标出:
    - 项目编号:通常是“项目编号”或“采购编号”后紧跟的编号串
    - 预算金额:包含“预算”、“最高限价”、“采购预算”的句子
    - 投标截止时间:包含“递交投标文件截止”、“投标截止时间”的句子
    - 资质要求:包含“资格要求”、“资质条件”的段落

    步骤2-字段抽取:从步骤1找到的句子中提取对应值,按规则处理:
    - 预算金额:保留“人民币”和单位,数字转阿拉伯
    - 时间:格式化为YYYY-MM-DD HH:MM
    - 资质:保留完整修饰语

    步骤3-输出JSON:{{"project_no": "", "budget": "", "deadline": "", "qualification": ""}}

    公告原文如下:
    {text}"""
    # 调用同v1,但增加max_tokens=1200

结果让人振奋:准确率78%,F1=0.78,其中预算金额的召回从62%提升到89%。为什么有效?因为“步骤1-边界定位”强制模型先做信息检索,再做信息变换。这符合大模型的处理逻辑——直接问答案容易跨步,分步走能降低推理负担。

但代价是Token消耗继续上涨:prompt_tokens=1420,completion_tokens=950,总计2370。因为模型真的在输出里写了“步骤1找到的句子是...”以及中间推理过程。效果虽好,成本太高,100条数据花了约6美元(按$0.0015/1K token计算)。

五、第四轮:Few-shot对齐格式 + 自校验——准确率91%且Token下降

第三轮效果已可接受,但Token浪费在“中间推理过程”上。我观察了几条成功输出,发现模型会在推理中重复公告原文,比如把完整句子复制一遍再抽取。这显然是冗余。

优化思路:把CoT步骤隐含在Few-shot示例中,而不是显式要求模型输出推理过程。同时增加一个“自校验”指令——让模型检查输出值是否与原文一致,不一致则修正。

def extract_v4(text):
    prompt = f"""你是信息抽取模型。严格按照示例格式抽取字段。

    【示例1】
    原文:项目编号:HJZB-2024-0158。采购预算为人民币捌拾万元整(¥800,000.00)。投标截止时间:2024年5月10日9:00。资质要求:具备电子与智能化工程专业承包二级及以上资质。
    输出:{{"project_no": "HJZB-2024-0158", "budget": "人民币80万元", "deadline": "2024-05-10 09:00", "qualification": "电子与智能化工程专业承包二级及以上"}}

    【示例2】
    原文:编号:XM-2024-102。最高限价120.5万元(含税)。递交投标文件截止时间为2024年6月1日14时30分。资格条件:投标人须具有建筑机电安装工程专业承包三级(含)以上资质。
    输出:{{"project_no": "XM-2024-102", "budget": "人民币120.5万元", "deadline": "2024-06-01 14:30", "qualification": "建筑机电安装工程专业承包三级(含)以上"}}

    现在抽取以下公告:
    {text}

    要求:
    1. 严格输出JSON,不要额外文字。
    2. 输出前自查:检查budget是否包含币种,deadline是否精确到分钟,qualification是否包含“及以上/(含)”等范围词。如有遗漏,立即修正。
    """
    # 调用同v1,max_tokens=800

效果数据:

  • 准确率:91.2%(比v3提升13.2%)
  • F1:0.91
  • Token消耗:prompt_tokens=980(示例占空间但可接受),completion_tokens=140(大幅下降,因为不再输出推理过程),总计1120 —— 反而比v1的1847还低39%。

坑2:温度参数的反直觉影响。我试过temperature=0.0,结果反而偶尔输出非JSON(比如漏掉左花括号)。后来在OpenAI官方文档看到,response_format=json_object时,模型会强制JSON但不会保证schema正确。把温度调到0.2,配合Few-shot示例中的格式锚定,反而更稳定。

另一个坑:自校验指令不能写成“如果错误则修正”,因为模型没有“错误”的参照系。要写具体校验点,比如“检查budget是否包含币种”,否则模型会跳过校验步骤。

六、数据对比与工程落地建议

版本 准确率 F1 平均Token 单条成本(USD)
v1 零样本 52% 0.58 1847 $0.0028
v2 角色约束 56% 0.61 2270 $0.0034
v3 CoT分步 78% 0.78 2370 $0.0036
v4 Few-shot+自检 91.2% 0.91 1120 $0.0017

落地建议(针对生产环境):

  1. 不要迷信角色设定:抽取类任务中,角色带来的先验知识模型本来就有,真正缺的是任务分解与格式锚定。
  2. Few-shot示例要覆盖边界情况:预算字段至少给一个“大写+数字”混合的示例,否则模型会按示例格式硬套。
  3. 自校验指令必须具体:抽象校验(如“请确保准确”)无效,要列举字段级的检查点。
  4. Token优化优先级:减少completion_tokens比减少prompt_tokens更划算。prompt_tokens是输入($0.001/1K),completion_tokens是输出($0.002/1K),价格差一倍。用Few-shot压缩输出格式是关键。
  5. 温度设置在0.1-0.3之间:过高会引入随机格式错误,过低(0.0)在json_object模式下偶发解码异常。

最后说句实话:这份91%的准确率是在100条样本上测的。换一批公告,如果出现“联合体投标”或“进口产品”等特殊条款,可能还会掉点。但至少,从v1到v4的迭代路径是清晰的——如果你也在做信息抽取,建议直接跳过v2的角色设定,从v1基线直接跳到v3的CoT,然后花时间打磨v4的Few-shot示例。

代码已上传至GitHub(仓库链接见评论区),欢迎跑一下自己的数据。有更好的Prompt策略,评论区交流。