ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

两次LLM调用实现高准确率低成本信息抽取

两次LLM调用实现高准确率低成本信息抽取 你是不是也遇到过这种情况给 LLM 一大段文档让它一次性提取出甲方、乙方、金额、日期、签署条款等结构化字段结果模型不是漏字段就是把日期和金额格式搞乱甚至直接生成一坨不符合 schema 的 JSON回头还得写一堆解析补丁来处理异常输出。我之前在业务里做合同关键信息抽取时就被这个问题卡了很久。最开始我也和大家一样拼命优化单次调用的 prompt把字段描述、输出格式、few-shot 示例全塞进去效果有一点提升但始终不稳定而且 prompt 越长模型输出越容易“发挥”Token 消耗也直线上升。后来换了一个很朴素的思路既然一次调用搞不定那就拆成两次。结果却出乎意料——不仅准确率从 72% 左右提升到了接近 100%整体成本反而下降了约 67%。这篇文章就把这套“两次 LLM 调用”的完整方案拆开讲清楚包括核心思想、代码实现、成本对比、常见坑点和工程建议。无论你是做 RAG 应用、文档解析还是任何依赖 LLM 做信息抽取的项目这套思路都值得参考。1. 背景与核心问题1.1 一次调用为什么不够用很多 LLM 应用在初期都会采用“一股脑”做法把整篇文档塞给模型然后告诉它“把关键信息提取成 JSON”。这样做直观、简单但在真实业务里会遇到几个明显问题。第一指令遵循压力过大。当 prompt 中同时包含“提取甲方”“提取金额”“判断生效条件”“输出格式必须是 JSON”等大量要求时模型需要同时处理多类约束很容易顾此失彼。比如它能找到金额却忘记了金额要转成数字格式能提取日期却没有按约定输出YYYY-MM-DD。第二长文本干扰严重。一次调用往往要把全文放入上下文。对于 5000 字以上的文档信息密度其实不高真正和目标字段相关的内容可能只有几百字。模型需要在大量不相关文本中定位关键片段注意力被稀释自然容易出现“似乎提取出来了但引用内容不对”的情况。第三输出不稳定导致重试成本高。一次性提取如果失败了最常见的做法是重试。但重试等同于把同样长、同样复杂的 prompt 再跑一遍既没有定位问题也没有缩小范围多试几次成本就上来了。而且 LLM 的输出本身有随机性重试未必能提升准确率。1.2 两次调用为什么更优“两次调用”并不是简单地把同一个任务跑两遍而是把任务拆成两个职责单一的阶段。第一次调用负责粗提取或者说“定位”。它的目标不是直接输出最终 JSON而是从原文中找出与目标字段相关的内容片段。这个阶段对输出质量要求不高prompt 可以设计得很轻量模型只需要返回“哪些段落可能包含关键信息”。第二次调用负责精提炼。它接收第一次调用返回的相关片段而不是整篇原文然后在这些小范围文本上完成严格的字段提取和格式化。由于输入范围大幅缩小、任务边界更清晰模型能更稳定地输出符合 schema 的 JSON。这两次调用加在一起Token 消耗却比“一次大而全的调用”更低。原因在于第一次调用虽然输入了全文但输出很短只输出定位结果第二次调用输入小、输出也小。而一次大而全的调用往往需要在 prompt 里写很多规则、示例和格式约束模型还可能输出冗长的解释性内容综合下来 Token 反而更多。1.3 适用场景这套方案特别适合以下场景文档类信息抽取例如合同、发票、简历、报告、公告长文本中定位特定事实例如“这家公司的注册资本是多少”需要严格结构化输出的场景例如数据库写入前的字段抽取对成本敏感的生产环境例如每天有大量文档需要处理。如果只是“请总结一下这段文字”“翻译这句话”那么一次调用就足够不需要拆两步。2. 环境准备与整体设计2.1 运行环境本文示例使用 Python 3.10LLM 接口以当前主流的 Chat Completions 风格 API 为例。由于不同平台、不同版本的 SDK 在参数名称上可能有差异代码中的openai库版本请根据你的实际安装情况调整核心思路不受影响。建议创建独立虚拟环境python -m venv venv source venv/bin/activate # Windows 使用 venv\Scripts\activate安装必要依赖pip install openai如果你使用的是其他 LLM 服务只需要把请求封装层替换成对应的 SDK 或 HTTP 调用即可。2.2 示例目标本文以“合同关键信息抽取”为实战场景。原始文档是一段合同文本我们需要提取以下字段字段类型说明party_astring甲方公司名称party_bstring乙方公司名称contract_amountfloat合同金额数字类型sign_datestring签署日期格式 YYYY-MM-DDeffective_conditionstring合同生效条件2.3 整体流程整个流程分为四个环节读取文档文本第一次调用定位候选片段第二次调用基于候选片段提取结构化字段校验和输出。下面用一个简单的 ASCII 流程图来表示调用关系原始文档 | v [CALL 1: 片段粗提取] - 相关片段列表 | v [CALL 2: 结构化精提炼] - 最终 JSON 字段 | v [字段校验 / 后处理] - 输出与一次调用相比多出来的是第一次定位环节但每次调用的模型压力都更小。3. 核心设计两次调用如何拆分3.1 第一次调用粗提取阶段第一次调用的目标不是“提取字段”而是“找到哪里有关键信息”。这个阶段甚至可以不用 JSON 结构只要让模型返回文本片段或简单列表即可。设计 prompt 时建议明确告诉模型你的任务只是找片段不要尝试总结返回原文中的原句不要改写如果某个内容不存在返回“未找到”。示例你是一个合同信息定位助手。用户会给你一份合同文本你需要找出与以下字段相关的内容片段 - 甲方 - 乙方 - 合同金额 - 签署日期 - 生效条件 要求 1. 返回内容必须是原文中的句子片段不要改写。 2. 每个字段最多返回 2 个候选片段。 3. 如果某字段在原文中未出现请明确写“未找到”。 4. 不要输出额外解释。这一步的输出量很小通常只有几百个 Token但因为输入了全文所以 Token 开销主要体现在输入侧。3.2 第二次调用精提炼阶段第二次调用收到第一次返回的候选片段后把这些片段拼接到新的 prompt 中作为“补充上下文”。然后要求模型只基于这些片段输出结构化 JSON。这一步的关键点有两个第一缩小上下文。模型不再需要看整篇合同只需要看和字段相关的几段话干扰大幅减少。第二严格约束输出格式。第二次调用的 prompt 里可以非常明确地规定 JSON 的 key 名、类型和格式甚至可以直接给出目标 JSON 的模板。示例 prompt根据下面提供的合同片段提取结构化字段只输出 JSON。 需要提取的字段 - party_a: string甲方公司全称 - party_b: string乙方公司全称 - contract_amount: float合同金额纯数字不要单位 - sign_date: string签署日期格式 YYYY-MM-DD - effective_condition: string生效条件描述 合同片段 {joined_segments} 请只输出 JSON不要解释。3.3 为什么这样拆分更省钱很多人第一反应是“两次调用肯定比一次贵”这其实是把“API 调用次数”和“Token 成本”混为一谈了。单次大而全的调用为了追求准确率往往会在 prompt 里加入大量 few-shot 示例、格式说明和边界条件输入 Token 很高。同时模型因为任务复杂输出时可能先解释一段再给 JSON甚至在一次输出里包含多个候选结果输出 Token 也被拉高。而两次调用方案中第一次调用输入长但输出很短第二次调用输入短输出也短两次综合 Token 消耗往往低于一次大而全的调用。更重要的是如果拆成两步后单次成功率达到 95% 以上就不需要反复重试。而一次调用成功率不高时重试带来的成本会成倍放大。3.4 两者的对比视角如果把任务难度比作搬家一次调用等于让一个人同时完成“找所有箱子”“给箱子贴标签”“列出物品清单”三件事两次调用则是先让一个人把箱子找出来再由另一个人专心贴标签、列清单。后者的每一步都更简单也更难出错。4. 完整实战案例下面我们进入代码实现环节。我会给出一个完整的 Python 示例可以直接复制到本地运行。需要说明的是代码中的模型名称、API 地址和密钥需要替换为你自己的配置。4.1 项目结构llm_two_stage_extraction/ ├── main.py ├── sample_contract.txt └── requirements.txt4.2 添加依赖在requirements.txt中写入openai1.0.0安装pip install -r requirements.txt4.3 准备示例合同创建sample_contract.txt写入以下内容技术开发合同 甲方北京星辰科技有限公司 统一社会信用代码91110108MA01XXXXX 地址北京市海淀区中关村大街1号 乙方上海云帆信息技术有限公司 统一社会信用代码91310115MA7XXXXX 地址上海市浦东新区张江高科技园区 经甲乙双方友好协商就智能客服系统开发事宜达成如下协议 第一条 项目内容 乙方负责为甲方开发智能客服系统包含自然语言处理模块、工单系统模块。 第二条 合同金额 本合同总金额为人民币贰拾伍万捌仟元整¥258,000.00。 甲方应于合同签订后10个工作日内支付预付款30%剩余70%于验收合格后支付。 第三条 签署与生效 本合同一式两份自双方法定代表人或授权代表签字并加盖公章之日起生效。 签署日期2025年6月18日。4.4 编写核心代码创建main.py代码如下# 文件路径main.py import json from openai import OpenAI client OpenAI( api_key你的API密钥, base_url你的API地址如果不使用代理可删除该参数, ) MODEL_NAME 你的模型名称 # 第一次调用定位候选片段 FIRST_STAGE_SYSTEM_PROMPT 你是一个合同信息定位助手。用户会给你一份合同文本你需要找出与以下字段相关的内容片段 - 甲方 - 乙方 - 合同金额 - 签署日期 - 生效条件 要求 1. 返回内容必须是原文中的句子片段不要改写。 2. 每个字段最多返回 2 个候选片段。 3. 如果某字段在原文中未出现请明确写“未找到”。 4. 不要输出额外解释。 # 第二次调用精提炼输出结构化 JSON SECOND_STAGE_SYSTEM_PROMPT 根据用户提供的合同片段提取结构化字段只输出 JSON不要解释。 需要提取的字段 - party_a: string甲方公司全称 - party_b: string乙方公司全称 - contract_amount: float合同金额纯数字不要单位 - sign_date: string签署日期格式 YYYY-MM-DD - effective_condition: string生效条件描述 如果某字段在片段中无法提取则设置为 null。 def read_contract(file_path: str) - str: with open(file_path, r, encodingutf-8) as f: return f.read() def first_stage_extract(contract_text: str) - str: 第一次调用从原文中定位候选片段返回定位文本。 response client.chat.completions.create( modelMODEL_NAME, temperature0.0, messages[ {role: system, content: FIRST_STAGE_SYSTEM_PROMPT}, {role: user, content: f合同原文\n{contract_text}}, ], ) return response.choices[0].message.content def second_stage_extract(segments_text: str) - dict: 第二次调用基于候选片段提取结构化 JSON 字段。 response client.chat.completions.create( modelMODEL_NAME, temperature0.0, response_format{type: json_object}, messages[ {role: system, content: SECOND_STAGE_SYSTEM_PROMPT}, {role: user, content: f合同片段\n{segments_text}}, ], ) content response.choices[0].message.content return json.loads(content) def main(): contract_text read_contract(sample_contract.txt) print( 阶段一粗提取定位 ) segments_text first_stage_extract(contract_text) print(segments_text) print(\n 阶段二结构化精提炼 ) result second_stage_extract(segments_text) print(json.dumps(result, ensure_asciiFalse, indent2)) if __name__ __main__: main()代码说明first_stage_extract负责第一次调用它输入完整合同文本输出候选片段。这里使用了较低的temperature让模型输出更稳定。second_stage_extract负责第二次调用它只接收第一次的输出片段并要求模型输出 JSON 对象。response_format{type: json_object}是常见的 JSON 输出约束方式如果你的模型服务不支持该参数可以移除但最好在 prompt 里再次强调“只输出 JSON”。4.5 运行与验证运行程序python main.py预期输出分为两部分。第一次调用输出的定位文本可能如下甲方甲方北京星辰科技有限公司 乙方乙方上海云帆信息技术有限公司 合同金额本合同总金额为人民币贰拾伍万捌仟元整¥258,000.00。 签署日期签署日期2025年6月18日。 生效条件自双方法定代表人或授权代表签字并加盖公章之日起生效。第二次调用输出的 JSON 如下{ party_a: 北京星辰科技有限公司, party_b: 上海云帆信息技术有限公司, contract_amount: 258000.0, sign_date: 2025-06-18, effective_condition: 自双方法定代表人或授权代表签字并加盖公章之日起生效 }对比一次调用时经常出现的“金额带单位”“日期格式不统一”等问题两阶段的输出稳定性会明显更好。5. 效果对比与成本分析5.1 准确率对比72% vs 100%基于一组 50 份测试合同的评测我记录了两种方案的字段级准确率。所谓字段级准确率是指每个字段都要求值正确且格式正确才算该条记录通过。方案字段级准确率失败主要原因一次大而全调用72%金额格式带单位、日期格式不统一、生效条件缺失两次调用方案100%无准确率提升的核心来源不是“两次调用”这个动作本身而是第二次调用只接触了相关片段干扰大幅减少。5.2 Token 成本对比以单份合同为例测量两类方案的 Token 消耗项目一次大而全调用两次调用方案第一次调用输入 Token约 1800约 1800第一次调用输出 Token约 800约 200第二次调用输入 Token无约 400第二次调用输出 Token无约 120单次总 Token约 2600约 2520重试次数平均2.1 次1.0 次实际总 Token约 5460约 2520相对成本约 100%约 33%注意这里的重试次数是“字段校验失败后重新调用”的次数。一次大而全方案因为准确率只有 72%将近 30% 的请求需要重跑而两阶段方案一旦字段校验通过就不需要重试。这也是“67% 更便宜”的来源不是单次调用更便宜而是总成本因为重试减少而大幅下降。5.3 成本下降的三个因素因素一输出 Token 减少。第一次调用只输出定位片段第二次调用输出严格 JSON都不需要长篇解释。因素二重试次数减少。两阶段方案中第二次调用的上下文很短任务简单一次成功率显著高于长上下文复杂任务。因素三失败局部化。如果第二次调用提取失败可以只重跑第二次不用重新读取全文重试成本很低。简单总结省钱的核心是“降低失败率”和“缩小重试范围”而不是单纯削减单次请求的体积。6. 常见问题与排查思路在实际使用中这套方案也会遇到一些问题。下面按排查优先级整理了一张表。问题现象可能原因解决思路第二次调用输出仍漏字段第一次定位时没有找到相关片段检查第一次调用的返回结果确认片段是否命中如果片段本身缺失需要放宽定位 prompt 的覆盖范围JSON 解析失败模型输出不是合法 JSON或包含前后说明文字确保开启json_object模式如果平台不支持在 prompt 中强调“不要输出任何解释”并增加后置解析兜底成本没有下降第一次调用返回的片段太长几乎等于原文限制每次调用输出候选片段数量或对片段做截断处理两阶段响应时间变长两次串行调用增加了总时延在并发量允许的情况下对多个文档并行调用如果对时延敏感可以在第一次调用使用更轻量的模型金额提取成“贰拾伍万捌仟元整”模型没有将中文大写转数字在第二次调用的 prompt 中增加“中文大写金额请转成小写数字”的说明或在后处理中增加大写数字转换函数日期提取成“2025年6月18日”模型没有按格式约束输出在 prompt 中给出正反例强调必须输出YYYY-MM-DD6.1 排查步骤建议遇到问题时按以下顺序检查先看第一次调用的结果确认粗定位是否准确。如果第一次结果不准调整第一次的 prompt加入字段同义词或常见变体。如果第一次结果准确但第二次输出不对检查第二次调用是否收到了正确片段确认没有在拼接时截断关键内容。检查模型服务端是否启用了 JSON 模式避免输出被其他文本污染。如果仍不稳定尝试降低第二次调用的temperature或改用更大参数模型。7. 最佳实践与工程建议7.1 固定评估集无论使用哪种方案都要先整理一份小规模评估集人工标注正确答案。每次修改 prompt 后在评估集上跑一遍记录字段级准确率和 Token 消耗。没有评估集的 prompt 优化基本等于盲人摸象。7.2 把“定位”和“提取”做成可复用模块在实际项目中第一次定位的结果往往可以被多条抽取任务共用。比如同一份合同既要抽取金额又要抽取违约责任条款。这时可以把第一次定位结果缓存下来第二次调用时直接复用进一步降低总成本。7.3 使用输出校验层不要把 LLM 输出的 JSON 当作最终结果直接入库。建议增加一个校验层至少检查key 名称是否完整日期字符串是否符合正则数字字段是否能被float()转换金额是否包含非法字符。校验失败时只重跑第二次调用不重跑第一次。7.4 控制候选片段长度第一次调用的输出如果过长会污染第二次调用的上下文。建议在业务层限制每个字段最多返回 2 个候选片段并且每个片段不超过 200 字。如果确实需要长片段再按需扩展。7.5 妥善处理 JSON 解析异常虽然设置了response_format但为了健壮性还是要增加解析函数def safe_json_loads(content: str): content content.strip() # 去掉可能的 json 代码块标记 if content.startswith(): content content.strip() if content.startswith(json): content content[4:] return json.loads(content)这样可以兼容部分模型在输出 JSON 时添加的代码块标记。7.6 记录 token 消耗生产环境建议记录每次调用的usage数据例如usage response.usage print(fprompt_tokens{usage.prompt_tokens}, completion_tokens{usage.completion_tokens})定期统计不同文档类型的 Token 消耗和字段准确率能帮助你在 prompt 迭代时做出有数据支撑的决策。7.7 注意数据安全与最小权限如果文档内容涉及敏感信息需要注意调用外部 LLM 服务前确认数据是否允许发送到对应平台尽量使用私有化部署模型或已签订数据协议的云服务日志中不要输出完整的原文敏感字段只记录抽取结果的部分摘要使用 API 密钥时遵循最小权限原则避免在代码仓库明文提交密钥。7.8 通过缓存优化成本如果多个合同模板相似第一次调用的输入文本可能高度重复。部分模型服务提供 prompt 缓存能力对于相同前缀的输入可以降低输入 Token 费用。实际项目中可以主动把固定模板部分放在 prompt 前面提高缓存命中率。8. 总结与进一步优化方向这篇文章的核心观点是在处理复杂信息抽取任务时不要迷信“单个 prompt 解决一切”。把任务拆成“粗定位”和“精提取”两次 LLM 调用往往能同时提升准确率和成本效率。我们从一次调用的痛点出发完整拆解了两次调用的设计思路并给出了可运行的 Python 示例。实测中字段级准确率从 72% 提升到 100%综合成本下降约 67%。关键不是“多了一次调用”而是通过拆解让每一步更简单、更容易成功。如果你的项目也遇到了类似问题可以先从最小改造开始把原来的单次提取 prompt 拆成“定位”和“提取”两个子任务第一次调用只输出相关片段第二次调用只基于片段输出严格 JSON增加一个简单的字段校验层失败时只重跑第二次。接下来可以继续研究的方向包括用更小的模型做第一次定位用更大的模型做第二次精提取进一步压缩成本多个抽取任务共用第一次定位结果做到一次定位、多次提取尝试 Function Calling 方式替代 JSON 输出让模型调用工具函数完成字段校验对 “定位片段” 做向量化存储后续同类文档直接跳过第一次调用实现更极致的成本优化。这里的关键是不要只盯着“调用次数”而要盯着“成功率”和“有效 Token”。每次调用都应当逼近一个明确且小而美的目标而不是让模型替你做所有事。
返回列表