两次LLM调用反而更便宜更准?粗提取+精校验降本增效实战

两次LLM调用反而更便宜更准?粗提取+精校验降本增效实战 你有没有遇到过这种情况为了从一份长文档里抽取结构化字段你把全文一次性丢给大模型让它直接输出 JSON。结果模型要么漏字段要么把原文里的数字改错甚至格式根本不合法。回头一看账单输入 token 高得吓人准确率却卡在 70% 上下。前阵子我看到一个很有意思的案例同样是做信息抽取把「一次大模型调用」改成「两次小任务调用」成本反而降了 67%抽取准确率从 72% 提升到了近乎 100%。第一反应可能觉得离谱——调用次数翻倍成本怎么会下降准确率怎么会上升这篇文章就把这件事讲透为什么一次调用又贵又不准两次调用的设计思路是什么以及如何用 Python 实现一个可运行的「粗提取 精校验」抽取流程。本文不讨论 fp16、fp32、bf16 这类推理数值精度问题只聚焦业务层面的 extraction accuracy。如果你正在做文档解析、简历抽取、合同关键信息提取或者想优化 LLM 调用的成本这篇文章值得读完。1. 为什么单次调用又贵又不准1.1 长文本抽取的真实困境先还原一个最常见的场景。你手里有一份简历、一份合同或者一份发票扫描件转出来的文本。你需要从中抽取姓名、公司、邮箱、金额、日期这些结构化字段。最直接的做法就是写一个 prompt把整份文本塞进去要求大模型输出一个 JSON。从下面的文本中提取字段只输出 JSON {name: , company: , email: , amount: } 文本 ...这种方法在短文本上表现还行但一旦文本变长问题就来了。第一个问题是模型「一心多用」容易出错。抽取本身需要定位、理解、去重、格式化在几千甚至上万 token 的长文本里模型要同时完成「找到姓名」「找到公司」「保证 JSON 合法」多个子任务。任务越复杂出错的概率越高。第二个问题是上下文越长注意力越分散。大模型在超长上下文里并不会线性地记住所有细节中间部分的字段被遗漏是常见现象。尤其是当答案分散在文本不同位置时单次调用很难稳定地把所有字段都捞全。第三个问题是成本被 few-shot 放大了。为了提升准确率你会在 prompt 里加示例、加字段说明、加输出格式约束。这些内容每一轮都要重复计费。文本越长few-shot 越多输入 token 就越高。1.2 单次调用的隐性成本很多人只盯着输出 token 的价格忽略了输入 token 才是大头。一次长文本抽取调用假设输入 8000 token输出 500 token。如果使用的是高性能大模型费用主要花在输入侧。而为了「一次搞定」你又不得不在 prompt 里塞入大量指令和示例进一步推高输入成本。更隐蔽的是失败重试成本。抽取结果一旦字段缺失或 JSON 格式非法你就要重新调用一次。一次变两次、两次变三次实际成本远远超过账单上看到的第一次调用费用。1.3 准确率上不去的三个原因把准确率问题拆开看通常有三个原因一是模型没有真正理解「要什么」。字段定义模糊抽取目标不清晰。二是错误不可校验。模型输出一个错误字段时你没有任何机制发现它错了。三是定位和格式化混在一个任务里。让模型既做信息定位又做 JSON 格式化等于同时要求它做两件不同复杂度的事后者会把前者的结果带偏。所以说单次调用的问题不是模型能力不够而是任务形态不合理。2. 两次调用为什么能赢粗提取 精校验2.1 核心思想是任务分解两次调用的本质是把「一次复杂的抽取任务」拆成「一次粗提取 一次精校验」。第一次调用负责粗提取。它的任务是在长文本里定位与目标字段相关的候选片段不需要输出最终 JSON只需要把原文中相关的句子或段落原样捞出来。这一步可以用便宜的小模型完成。第二次调用负责精校验。它不再面对整篇长文本而是只看到第一次调用返回的候选片段。任务非常简单根据候选片段结合字段定义输出严格 JSON。如果你理解 Agent 的工作方式会发现这就是一种极简的编排思想先检索定位相关信息再基于限定范围做精准决策。2.2 成本为什么反而降低这是整个方案最反直觉的地方调用次数变多为什么总成本反而更低关键在于第二次调用的输入被第一次调用「裁剪」了。单次调用时大模型要处理完整长文本假设输入 8000 token。两次调用时第二次输入可能只需要候选片段假设只有 800 到 1200 token。第一次调用虽然也处理完整文本但使用的是便宜的小模型。最终总成本可能低于单次使用大模型处理完整文本的成本。用一个简化的成本公式说明单次调用成本 C_big × (prompt_full completion_full) 两次调用成本 C_small × (prompt_full completion_coarse) C_big × (prompt_focused completion_final)其中 C_small 远小于 C_bigprompt_focused 远小于 prompt_full那么即使调用两次总成本也可能更低。原文案例中出现 67% 的成本下降本质上就是这个原因虽然调用次数翻倍但「贵模型处理的 token 量」大幅缩水便宜模型承担了大部分粗活。2.3 准确率为什么反而提升成本降低可以用 token 计算解释准确率提升则来自两点。第一是上下文聚焦。第二次调用只看到候选片段没有无关文本干扰模型可以更专注地完成字段精确提取。这好比审稿人不需要读完整本书只需要看被标记出来的几页。第二是错误有了二次修正机会。第一次调用可能多捞了一些无关内容第二次调用会做一次「校对」。即使第一次粗提取漏掉某个字段你也可以在精校验阶段加入规则要求模型对缺失字段再次确认。在原文案例的测试集上单次调用的准确率是 72%两次调用之后提升到了 100%。这里的 100% 是指特定测试集上的表现不代表任何场景都能达到。它说明的是任务拆分带来的收益有时候比换更强的模型更明显。2.4 单次调用与两次调用的对比对比维度单次调用两次调用粗提取 精校验任务复杂度定位 理解 格式化混合任务拆分每步目标单一大模型输入量完整长文本 few-shot聚焦后的候选片段成本结构贵模型处理全部文本便宜模型做粗活贵模型做精活准确率稳定性长文本下容易漏字段上下文聚焦错误可二次修正延迟一次调用两次顺序调用略有增加失败恢复失败后整篇重跑可只针对缺失字段局部重试看到这里你会发现两次调用真正的优势不在于「调用了两次」而在于「把错误分散到小范围内把成本集中到小模型上」。3. 适用场景与边界3.1 适合什么场景最典型的场景是长文本结构化抽取。比如简历信息抽取姓名、城市、公司、职位、技能、邮箱。合同关键信息提取甲方、乙方、金额、日期、付款条件。发票或票据识别发票号、金额、税率、销售方。政策文档和公告解析标题、发布时间、正文要点。这些场景有几个共同点文本较长、字段分散、错误容忍度低且抽取结果需要被下游系统使用。任何一次字段错误都可能导致业务异常因此「二次校验」带来的准确率提升非常值钱。3.2 不适合什么场景短文本抽取不适合用这套方案。如果输入只有一两句话一次调用完全能胜任两次调用只会增加延迟和复杂度。延迟敏感的场景也要谨慎。两次调用是顺序执行总延迟等于两次调用耗时之和。在实时对话、在线检索这类要求快速响应的场景里二次调用的收益可能被延迟成本抵消。另外如果一个小模型已经能在你的文本上达到 95% 以上的准确率那也没必要强行拆成两次。拆分的收益曲线不是线性的它更适合「准确率卡在中间水平」的尴尬阶段。3.3 与 RAG 的关系有人会问第一次调用定位候选片段这不就是 RAG 里的检索吗不完全一样。RAG 通常用向量检索召回候选段落再把候选段落送给大模型生成答案。本文说的第一步是让 LLM 自己定位候选片段相当于一个轻量的语义路由或预筛过程。它不需要提前建立向量索引对于字段定义经常变化的场景更灵活。如果项目里已经使用了 RAG完全可以把「粗提取 精校验」作为其中一环。第一次调用负责从检索结果中过滤与字段相关的片段第二次调用负责精确抽取。这两者是互补关系不是替代关系。4. 环境准备动手之前先把运行环境准备好。本示例使用 Python 3.9依赖 openai 库调用兼容 OpenAI 协议的模型服务。4.1 安装依赖pip install openai python-dotenv这里使用 openai 库是因为目前绝大多数国内模型服务都提供 OpenAI 兼容接口。你只需要修改 base_url 和 model 名称就能切换到实际使用的模型不需要额外安装专用 SDK。4.2 配置 API Key建议把 API Key 放在环境变量中不要硬编码到代码里。项目根目录创建一个.env文件LLM_API_KEYyour-api-key LLM_BASE_URLhttps://api.example.com/v1 SMALL_MODELyour-small-model BIG_MODELyour-big-model其中LLM_API_KEY你的模型服务 API Key。LLM_BASE_URL兼容 OpenAI 协议的网关地址。SMALL_MODEL粗提取使用的小模型建议选择速度快、单价低的模型。BIG_MODEL精校验使用的大模型建议选择指令理解能力更强的模型。具体的模型名称以你的服务商文档为准。本文示例用SMALL_MODEL和BIG_MODEL两个变量代替避免写死某一家的模型名。5. 完整代码实现下面代码演示一套完整的「粗提取 精校验」流程并提供一个单次调用的 baseline 作为对比。5.1 准备测试文本先用一份模拟简历作为测试输入。实际项目中这段文本可能来自 PDF 解析、OCR 或数据库字段。# 文件路径demo_text.py RESUME_TEXT 张三男1991年6月出生现居杭州。 2014年毕业于浙江理工大学计算机科学与技术专业。 毕业后加入杭州某中小型互联网公司从事Java后端开发主要参与电商交易系统的订单模块研发。 在之后五年里他先后接触过Spring Boot、MySQL、Redis、Kafka等技术并在2019年晋升为技术小组组长。 2020年跳槽到一家在线教育公司开始负责研发团队的日常管理和核心架构设计。 他熟悉微服务治理、分布式事务和容器化部署同时有较强的跨部门沟通能力。 目前所在公司为杭州某某科技有限公司担任后端技术负责人。 联系方式zhangsanexample.com。 最近三年他主导过多个中大型项目的落地重点方向包括高并发接口优化、数据迁移和链路追踪。 个人兴趣包括开源项目、技术写作和长跑。 FIELDS [name, city, company, email, position, tech_stack]这里设计了 6 个抽取字段。tech_stack需要从文本中的技术名词中汇总成数组适合展示「候选片段 精校验」的好处。5.2 基础工具函数封装一个统一的 LLM 调用函数方便记录 token 用量同时加一个简单的 JSON 解析容错函数。# 文件路径llm_client.py import json import os from openai import OpenAI from demo_text import RESUME_TEXT, FIELDS client OpenAI( api_keyos.getenv(LLM_API_KEY, your-api-key), base_urlos.getenv(LLM_BASE_URL, https://api.example.com/v1), ) SMALL_MODEL os.getenv(SMALL_MODEL, your-small-model) BIG_MODEL os.getenv(BIG_MODEL, your-big-model) def call_llm(model: str, system_prompt: str, user_prompt: str, temperature: float 0.0): 调用大模型返回文本内容和 token 用量。 response client.chat.completions.create( modelmodel, messages[ {role: system, content: system_prompt}, {role: user, content: user_prompt}, ], temperaturetemperature, ) content response.choices[0].message.content usage { prompt_tokens: response.usage.prompt_tokens, completion_tokens: response.usage.completion_tokens, } return content, usage def parse_json_loose(text: str): 容错解析模型输出的 JSON。 text text.strip() if text.startswith(): text text.strip() text text.replace(json, , 1) start text.find({) end text.rfind(}) if start -1 or end -1: return None try: return json.loads(text[start:end 1]) except json.JSONDecodeError: return None5.3 第一次调用粗提取候选片段第一次调用的目标不是输出最终字段而是定位与目标字段相关的原文片段。# 文件路径stage1_coarse_extract.py COARSE_SYSTEM_PROMPT 你是信息抽取助手。请从给定的文本中定位与目标字段相关的原文片段。 目标字段{fields} 规则 1. 只要某个句子或短语与目标字段相关就把原文原样抄录下来。 2. 不要改写原文不要翻译不要补充文本里没有的信息。 3. 输出 JSON 数组每个元素包含 field、quote、reason 三个字段。 4. 如果某个字段在文本中没有出现quote 填 null。 只输出 JSON。 def stage1_coarse_extract(text: str): fields_str 、.join(FIELDS) user_prompt f目标字段{fields_str}\n\n文本\n{text} content, usage call_llm( SMALL_MODEL, COARSE_SYSTEM_PROMPT.format(fieldsfields_str), user_prompt, ) quotes parse_json_loose(content) return quotes, usage这一步允许模型多捞一些内容不需要太精确。因为后面还有第二次调用做精校验。5.4 第二次调用精校验输出第二次调用只接收候选片段输出严格 JSON。# 文件路径stage2_precise_extract.py PRECISE_SYSTEM_PROMPT 你是信息抽取专家。请根据候选片段从原文中精确抽取以下字段 - name姓名 - city所在城市 - company当前公司 - email邮箱 - position当前职位 - tech_stack技术栈列表 规则 1. 只能使用候选片段中出现的原文信息。 2. tech_stack 输出字符串数组没有的内容填空数组。 3. 不要凭空补充字段值。 4. 只输出一个 JSON 对象。 def stage2_precise_extract(quotes: list, usage_coarse: dict): quote_block \n.join( f[{item.get(field)}] {item.get(quote)} for item in quotes ) user_prompt f候选片段\n{quote_block} content, usage call_llm( BIG_MODEL, PRECISE_SYSTEM_PROMPT, user_prompt, ) result parse_json_loose(content) return result, usage第二次调用的输入只有第一次返回的候选片段通常在 800 到 1500 token 之间远小于原文的完整长度。5.5 单次调用 baseline作为对照组再写一个单次调用版本。它直接把整篇文本和字段定义一起交给大模型。# 文件路径baseline_single_pass.py SINGLE_PASS_SYSTEM_PROMPT 你是信息抽取专家。请从文本中直接抽取以下字段并输出 JSON - name姓名 - city所在城市 - company当前公司 - email邮箱 - position当前职位 - tech_stack技术栈列表 规则 1. 只能使用原文中出现的信息。 2. tech_stack 输出字符串数组。 3. 不要输出 JSON 之外的任何内容。 def single_pass_extract(text: str): content, usage call_llm( BIG_MODEL, SINGLE_PASS_SYSTEM_PROMPT, f文本\n{text}, ) result parse_json_loose(content) return result, usage5.6 成本估算与主流程代码中给出一个示例价格模型只为演示成本比例。实际使用时请替换为你的模型服务商价格。# 文件路径main.py PRICE_PER_TOKEN { small_input: 0.0000002, small_output: 0.0000008, big_input: 0.000002, big_output: 0.000008, } def estimate_cost(usage, is_small: bool): if is_small: input_price PRICE_PER_TOKEN[small_input] output_price PRICE_PER_TOKEN[small_output] else: input_price PRICE_PER_TOKEN[big_input] output_price PRICE_PER_TOKEN[big_output] return ( usage[prompt_tokens] * input_price usage[completion_tokens] * output_price ) def run_pipeline(): # 单次调用 single_result, single_usage single_pass_extract(RESUME_TEXT) single_cost estimate_cost(single_usage, is_smallFalse) # 两次调用 quotes, coarse_usage stage1_coarse_extract(RESUME_TEXT) final_result, precise_usage stage2_precise_extract(quotes, coarse_usage) coarse_cost estimate_cost(coarse_usage, is_smallTrue) precise_cost estimate_cost(precise_usage, is_smallFalse) two_stage_cost coarse_cost precise_cost print(【单次调用结果】) print(single_result) print(prompt_tokens:, single_usage[prompt_tokens], completion_tokens:, single_usage[completion_tokens]) print(cost:, round(single_cost, 6)) print(\n【两次调用结果】) print(粗提取候选:) for item in quotes: print( , item) print(精校验结果:) print(final_result) print(粗提取 prompt_tokens:, coarse_usage[prompt_tokens], completion_tokens:, coarse_usage[completion_tokens]) print(精校验 prompt_tokens:, precise_usage[prompt_tokens], completion_tokens:, precise_usage[completion_tokens]) print(cost:, round(two_stage_cost, 6)) if __name__ __main__: run_pipeline()这套代码的核心逻辑很清晰先让小模型在海量文本中圈出重点再让大模型只看重点做精确输出。两次调用的收益不是来自某个神秘的模型能力而是来自「大模型处理的信息量大幅缩小」。6. 运行结果与效果验证6.1 运行方式在项目根目录执行python main.py运行成功后你会看到单次调用和两次调用的结果对比、token 用量和估算成本。下面是一个模拟输出实际数值会因模型服务商和文本内容不同而变化。6.2 输出对比【单次调用结果】 {name: 张三, city: 杭州, company: 杭州某某科技有限公司, email: zhangsanexample.com, position: 后端技术负责人, tech_stack: [Java, Spring Boot, MySQL]} prompt_tokens: 8500, completion_tokens: 450 cost: 0.0206 【两次调用结果】 粗提取候选: [{field: name, quote: 张三男1991年6月出生现居杭州。, reason: 姓名信息} {field: tech_stack, quote: 先后接触过Spring Boot、MySQL、Redis、Kafka等技术}, ...] 精校验结果: {name: 张三, city: 杭州, company: 杭州某某科技有限公司, email: zhangsanexample.com, position: 后端技术负责人, tech_stack: [Java, Spring Boot, MySQL, Redis, Kafka, 微服务, 分布式事务, Docker]} 粗提取 prompt_tokens: 8500, completion_tokens: 800 精校验 prompt_tokens: 1200, completion_tokens: 420 cost: 0.0101注意这段输出是示意性的。核心观察点是两处第一第二次调用的「贵模型」输入 token 从单次调用的 8500 降到了 1200 左右这是成本下降的关键。第二精校验结果往往能补全单次调用漏掉的技能点。单次调用可能只输出两三个技能就结束了而精校验因为只看候选片段更不容易遗漏。6.3 如何判断方案是否有效不要只看一次运行的结果。正确的验证方式是对同一批测试样本分别运行两套流程按字段计算准确率。# 文件路径evaluate.py GROUND_TRUTH { name: 张三, city: 杭州, company: 杭州某某科技有限公司, email: zhangsanexample.com, position: 后端技术负责人, tech_stack: [Java, Spring Boot, MySQL, Redis, Kafka, 微服务, 分布式事务, Docker], } def evaluate_one(actual: dict): if not actual: return 0.0 correct 0 for key, expect in GROUND_TRUTH.items(): if actual.get(key) expect: correct 1 return correct / len(GROUND_TRUTH)在一个固定测试集上分别统计两套流程的平均字段准确率你大概率会看到类似趋势单次调用在 70% 到 80% 之间波动两次调用能稳定在 90% 以上。这不是模型能力突变了而是任务拆分减少了单次模型输出的复杂度。6.4 失败时先看哪里如果运行失败第一步看模型返回的 content 是否包含完整 JSON第二步看 parse_json_loose 是否解析成功。大模型偶尔会在 JSON 前后加解释文字容错解析函数已经帮你处理了多余前缀后缀。如果 candidate 列表为空优先检查第一步的粗提取 prompt 是否限制了「只能输出 JSON」之外的内容。7. 常见问题与排查思路问题现象可能原因排查方式解决方案第二次校验后准确率反而更差粗提取返回的候选片段本身噪声太多或丢失关键信息打印粗提取返回的 quotes对比原文调整粗提取 prompt允许模型多捞内容宁可多不可漏粗提取阶段漏掉关键片段小模型指令理解能力有限检查粗提取输出的 reason 字段降低输出约束增加示例或换更强的小模型模型输出不是合法 JSON温度过高或 prompt 约束不足查看原始返回内容温度设为 0在 prompt 末尾加上「只输出 JSON」成本并没有明显下降粗提取输出过长导致精校验输入仍很大打印两次调用各自的 prompt_tokens限制粗提取 quote 长度只保留与字段直接相关的句子长文本超出模型上下文窗口原文过长小模型也装不下查看模型上下文限制先做文本切块对每块做粗提取再汇总候选片段做精校验两次调用延迟太高两个模型都偏慢在日志中记录每次调用耗时小模型选低延迟型号精校验只处理候选片段通常不会太慢tech_stack 这类列表字段总是漏项列表字段分散在多个句子中粗提取只捞了第一处检查 quotes 中 tech_stack 条目数量粗提取规则中明确「列出所有相关片段不要合并去重」8. 最佳实践与工程建议8.1 粗提取 prompt 宁可多捞不可漏掉粗提取是整条链路的漏斗入口。如果粗提取漏了精校验就永远看不到正确的原文。所以粗提取阶段的 prompt 应该允许模型多返回一些上下文不要急着做字段级精确过滤。把「精确」这个任务交给第二次调用。8.2 小模型和大模型的选择小模型不是越便宜越好。如果小模型粗提取质量太差精校验拿不到足够的候选信息准确率会直线下降。建议先用一个小模型做一轮粗提取打印 quotes 看看覆盖率再决定是否升级。大模型的选择标准也不是参数越多越好而是指令跟随能力要强。精校验阶段其实是一个「基于小范围文本做结构化输出」的任务大模型只需在这个范围内发挥优势。8.3 对列表类字段做特殊处理像技能、人员名单这类列表字段值分散在多个句子中。粗提取阶段容易只捞到第一处。建议在字段定义中明确说明「该字段需要列出所有相关片段」或者在精校验 prompt 中提示「检查候选片段中是否还有遗漏的同类信息」。8.4 成本监控和日志记录每次调用都要记录 prompt_tokens、completion_tokens、模型名称、耗时。成本优化不是一次性工作而是持续的过程。字段变化、文本长度变化都会影响 token 消耗。如果没有监控很难发现哪一次 prompt 调整让成本翻倍了。8.5 评测集先行不要靠肉眼判断「好像准确了」。准备一个 30 到 50 条的固定测试集包含各种边界情况。每次修改 prompt、切换模型后先在测试集上跑一遍对比字段准确率和成本变化。在这个方案里评测集就像刹车系统防止你越调越偏。8.6 注意合规与数据安全身份证号、手机号、邮箱、公司内部信息都属于敏感数据。调用云端模型服务前确认数据脱敏策略和合规要求。优先使用机构批准或私有化部署的模型服务。API Key 不要提交到代码仓库用环境变量或密钥管理服务管理。9. 总结「两次 LLM 调用反而更便宜更准」这件事拆开看逻辑并不复杂一次调用让模型同时干太多事任务复杂度和 token 成本都在膨胀拆成「粗提取 精校验」后便宜模型承担文本定位的繁重工作贵模型只做小范围内的精确抽取成本和准确率都得到优化。这篇文章讲清楚了单次调用为什么卡在 70% 左右、两次调用的成本模型为什么反而更低、以及完整代码怎么落地。如果你是做信息抽取相关项目建议先在自己的测试集上跑一遍这个流程对比单次调用和两次调用的准确率与成本曲线。下一步值得深入的方向包括将这套思路扩展到 RAG 的答案精炼环节或者在粗提取阶段引入置信度机制只有低置信度字段才触发第二次调用。成本优化和准确率优化永远不只是换模型那么简单任务拆分的思路在任何阶段都不过时。