长文本信息抽取准确率低?两次LLM调用,成本降60%准确率近100%
2026/9/6 8:58:25 网站建设 项目流程

如果你最近在用大模型做信息抽取,大概率遇到过一种很“玄学”的情况:同样的模型、同样的提示词,抽十条短文本效果很不错,但一旦把任务丢给一条几千字的长文本,准确率会肉眼可见地往下掉。你以为是指令不够清楚,于是把提示词改得越来越细;发现效果还是不好,又换成参数更大的模型;成本上去了,问题却还在。

这篇文章想聊一个更高杠杆的思路:不要追求“一次调用解决所有问题”,而是把抽取任务拆成两次调用。很多场景下,两次 LLM 调用不仅不会更贵,反而能比一次大而全的调用便宜 60% 以上,同时把抽取准确率从 72% 左右拉到接近 100%。这不是玄学,而是把长文本抽取任务从“一个大 prompt 硬啃”改成了“先定位、再精抽”的流水线。

文章会从一次调用的失败原因讲起,给出两次调用的核心设计思路、适用边界、完整代码示例和工程化建议。读完你就能在自己的项目里跑通这套方案,并且能根据自己的数据重新评测准确率和成本。

1. 为什么“一次 LLM 调用”做抽取总是不够稳

先看一个典型场景:给 LLM 一段企业公告或合同原文,要求提取“合同金额”“签约对象”“签约日期”“付款方式”等结构化字段。早期的实现通常是写一个大 prompt,告诉模型“你是信息抽取助手,请从以下文本中提取字段,并以 JSON 返回”。

听上去简单,真实项目里问题不少。

1.1 上下文过长,注意力被稀释

LLM 的注意力机制不是均匀分布的。当输入文本超过几千 token 后,模型对关键信息的聚焦能力会明显下降。尤其是目标字段藏在文本中段、又有大量无关描述干扰时,模型容易漏掉字段,或者把看似相关、实则错误的句子当成依据。

这跟人脑看长文章是一个道理:一篇两万字的合同,让你一口气把关键条款全部摘出来,你也会漏。但如果你先定位“甲方的付款义务在第三章”,再去第三章里细看,就不太会错。

1.2 指令复杂度太高,格式一致性变差

一次调用里,你要同时完成两件事:

  1. 理解长文本,定位关键信息。
  2. 把所有字段组织成严格 JSON 输出。

这两件事对 LLM 来说是两类不同的认知负荷。当任务指令太长、字段太多时,模型经常出现 JSON 格式错误、字段缺失、枚举值回答成自由文本等问题。表面看是“格式漂移”,本质是模型在同一个上下文窗口里承担了过多目标。

1.3 输出 token 越长,幻觉概率越高

抽取任务里,输出 token 不是越多越好。字段越多、输出越长,模型“编”的空间就越大。尤其当某个字段在原文中确实不存在时,一次调用很容易给你“合理推断”出一个值,而不是诚实地返回 null。

于是很多团队陷入一个循环:效果不好 → 加大模型 → 成本升高 → 为了让大模型发挥价值又加字段 → 效果依然不稳定。真正的问题不是模型不够强,而是任务颗粒度太粗

维度一次大而全调用两次拆分调用
上下文长度全部原文 + 全量指令裁剪后局部文本 + 单一指令
指令复杂度高,定位与格式化同时完成低,每次只做一件事
输出需求字段多、JSON 长第一次输出短,第二次输出结构化
失败模式漏字段、格式乱、幻觉可定位,可重试,可降级
成本结构全量 prompt 重复计费第一次粗筛,第二次精抽

2. 两次调用的核心设计思路:先定位,再精抽

两次调用的思路非常简单:把“从长文本中抽取信息”拆成“先找到信息在哪里”,再“从局部文本中结构化抽取”。

2.1 第一次调用:定位与裁剪

第一次调用不要求输出最终 JSON,只要求模型回答“目标字段可能出现在哪些区域”。输出可以是:

  • 原文中的句子序号。
  • 关键段落定位。
  • 裁剪后的子文本。

这一步的 prompt 可以很短,输出 token 也少。模型不需要操心格式,只要判断“这段文本里有哪些地方提到了签约金额相关信息”,因此准确率会比一步到位高很多。

2.2 第二次调用:结构化抽取

拿到第一次调用的定位结果后,把裁剪出来的局部文本作为第二次调用的输入。因为此时上下文已经短了很多,指令也可以做得非常聚焦:只针对少数几个字段,规定返回格式,要求严格按原文内容输出。

2.3 为什么准确率会提升

核心原因有三点:

  1. 注意力更集中:第二次调用只读局部文本,模型更容易抓住关键值。
  2. 指令更简单:每次调用只解决一个问题,格式漂移概率下降。
  3. 有纠错空间:第一次调用如果定位错了,可以打印定位结果交给人工或规则检查;第二次调用如果输出格式坏了,只需要重试第二次,成本远低于重试整段长文本。

2.4 为什么总成本反而下降

很多人本能地觉得“调两次 API 肯定更贵”,但成本由 token 决定,不是由调用次数决定。

假设一次调用需要发送 8000 token 的完整原文,再加上 2000 token 的复杂指令,输出 800 token 的 JSON。那么一次调用的总 token 大约是 8000 + 2000 + 800。

换成两次调用:

  • 第一次:输入 8000 token 原文 + 300 token 定位指令,输出 300 token 定位结果。
  • 第二次:输入 1200 token 裁剪片段 + 400 token 抽取指令,输出 300 token JSON。
  • 总 token 大约是 8000 + 300 + 300 + 1200 + 400 + 300 = 10500 token,看起来和一次调用差不多。

但这里有两个容易被忽略的变量:

第一,裁剪后可以把长上下文从第二次调用中去掉。如果长上下文不是每次都需要的,而只是偶尔需要,那第一次定位后第二次只需要处理裁剪片段,重复调用时的输入成本会大幅降低。

第二,第一次调用可以用更小的模型。定位任务对语义理解要求低,可以用参数更小、单价更低的模型;第二次调用虽然需要较强的结构化能力,但输入变短之后,也可以使用中小型模型完成。很多团队实际测算下来,两次调用的总成本比一次大模型全量调用便宜 50% 到 70%。

标题里的“67% cheaper”不是数学定理,而是特定任务、特定模型、特定文本长度下的实测结果。但它背后的结构性原因——用“局部上下文 + 简单指令”替代“全量上下文 + 复杂指令”——是普遍成立的

3. 准确率提升的真实来源与评测边界

看到“100% vs. 72%”这种对比,第一反应应该是:会不会是测试集太小?会不会是 prompt 写得偏科?

这种怀疑是对的。任何准确率数字都必须放在特定测试集、特定字段集合和特定提示词模板下才有意义。我的判断是:

  • 如果测试集覆盖了多种类型的文档(长公告、合同、新闻稿、半结构化文本),且字段有明确的标准答案,那么两次调用相比一次调用提升 10 到 20 个百分点是常见结果。
  • 100% 这个数字大概率来自小规模验证集上的结果。这不代表你的场景也能达到 100%,但足以说明两次调用在准确率上通常不会弱于一次调用,并且稳定性明显更好。
  • 更关键的评测指标不是“某一次跑对了没有”,而是多次运行的标准差。一次调用经常出现“这次对了,下次漏了一个字段”的抖动,两次调用通过把任务拆小,能显著降低这种随机性。

所以,这篇方法论的核心价值不在于“某个数字”,而在于:把不可控的长文本全量抽取,变成了可控的两段式抽取流水线。每段都有自己的错误模式、自己的日志、自己的重试策略。这正是工程上最需要的特性。

4. 适用场景与边界条件

两次调用不是银弹。应该先判断你的场景是不是真的需要它。

4.1 适合的场景

  • 长文档抽取:合同、招股书、研报、病历、法律文书中抽取结构化字段。
  • 多字段抽取:单次要抽 10 个以上字段,且字段分布在不同区域。
  • 高准确率要求:抽取结果直接进入下游审批、核算、风控流程。
  • 文本噪声大:原文包含大量表头、页脚、免责声明等无关信息。

4.2 不适合的场景

  • 短文本抽取:如果输入只有几十到几百字,一次调用足够,额外拆分只会增加延迟和复杂度。
  • 已有稳定正则/规则方案:如果目标字段可以通过正则稳定提取,先用规则,LLM 只处理规则覆盖不了的异常情况。
  • 强实时交互:如果接口要求 P99 延迟低于几百毫秒,两次串行调用不一定划算。可考虑并发执行第一次定位,或者改用更小的定位模型。

4.3 成本与延迟的计算方式

建议不要凭感觉判断“贵不贵”,先用一个脚本统计:

  • 第一次调用的平均输入/输出 token。
  • 第二次调用的平均输入/输出 token。
  • 重试率。
  • 各模型单价。

有了这四个数据,就能精确算出单次抽取的期望成本,而不是被“调用次数多了一次”误导。

5. 环境准备与最小工程结构

下面用 Python 实现一个最小可运行的两次抽取方案。所有代码都以常见的 OpenAI 兼容接口为例,你可以替换成任意国内大模型平台的兼容接口。

5.1 环境要求

  • Python 3.10+
  • openai Python SDK,版本请以实际项目为准
  • pydantic 用于定义输出结构
  • tenacity 用于重试
  • 一个可用的 LLM API key,环境变量名建议为LLM_API_KEYLLM_BASE_URL

安装依赖:

pip install openai pydantic tenacity

5.2 统一 LLM 客户端封装

# llm_client.py import os from openai import OpenAI client = OpenAI( api_key=os.getenv("LLM_API_KEY"), base_url=os.getenv("LLM_BASE_URL"), ) def chat_json(messages, model="gpt-4o-mini", temperature=0): resp = client.chat.completions.create( model=model, messages=messages, temperature=temperature, response_format={"type": "json_object"}, ) return resp.choices[0].message.content

这里注意两点:

  • temperature=0是抽取任务的基本要求,降低随机性。
  • response_format使用 JSON 模式时,需要在 prompt 中明确告诉模型输出 JSON 对象,并在示例中给出 JSON 格式,否则某些模型会忽略该限制。

6. 完整示例:两次调用实现信息抽取

假设要从一段企业公告中抽取“合同金额”“签约对象”“签约日期”三个字段。

6.1 定义输出数据结构

# schema.py from pydantic import BaseModel from typing import Optional class ContractInfo(BaseModel): amount: Optional[str] counterparty: Optional[str] sign_date: Optional[str]

用 Pydantic 而不是裸 dict,是为了后续做校验和统一输出。

6.2 第一次调用:定位关键片段

第一次调用不直接抽取字段,而是返回目标内容所在的行号或片段。这里先按行切分文本,让模型返回相关行的序号。

# first_pass.py import json from llm_client import chat_json def split_lines(text: str) -> list[str]: return [line.strip() for line in text.splitlines() if line.strip()] def locate_relevant_lines(text: str, fields: list[str]) -> list[int]: lines = split_lines(text) numbered = "\n".join( f"[{idx}] {line}" for idx, line in enumerate(lines) ) prompt = f""" 你是信息定位助手。 文本已经被按行编号,每行格式为 [行号] 内容。 请定位与以下字段相关的内容行: {", ".join(fields)} 规则: 1. 只返回可能包含字段信息的行号。 2. 不要输出任何抽取结果。 3. 如果某一行提到日期、金额、公司名称中的任意一个,也要返回。 4. 返回 JSON:{"target_lines": [行号列表]} 文本如下: {numbered} """ content = chat_json( [ {"role": "user", "content": prompt}, ] ) try: data = json.loads(content) return data.get("target_lines", []) except json.JSONDecodeError: return []

这段代码的关键在于:给文本编号之后,模型只需要返回数字,不需要复制原文。这样第一次调用的输出 token 非常少,也方便后续用脚本裁剪原文。

6.3 第二次调用:基于定位结果精确抽取

得到定位行号后,把对应行拼成裁剪文本,再执行结构化抽取。

# second_pass.py import json from llm_client import chat_json from schema import ContractInfo def extract_from_lines(text: str, target_lines: list[int], fields: list[str]) -> ContractInfo: lines = text.splitlines() selected = [] for idx, line in enumerate(lines): if idx in target_lines: stripped = line.strip() if stripped: selected.append(stripped) clipped_text = "\n".join(selected) prompt = f""" 你是信息抽取助手。 从下面的文本片段中抽取以下字段:{", ".join(fields)} 抽取规则: 1. 只根据给定文本,不要推断文本中没有的内容。 2. 如果字段在文本中不存在,返回 null。 3. 金额保留数字和单位,日期保留原格式。 4. 返回 JSON 对象。 文本片段: {clipped_text} """ content = chat_json( [ {"role": "user", "content": prompt}, ] ) data = json.loads(content) return ContractInfo(**data)

6.4 一次调用基线实现

为了对比,再写一个一次调用的版本:

# one_pass.py import json from llm_client import chat_json from schema import ContractInfo def extract_from_full_text(text: str, fields: list[str]) -> ContractInfo: prompt = f""" 你是信息抽取助手。 从下面的全部文本中抽取以下字段:{", ".join(fields)} 抽取规则: 1. 只根据给定文本,不要推断文本中没有的内容。 2. 如果字段在文本中不存在,返回 null。 3. 金额保留数字和单位,日期保留原格式。 4. 返回 JSON 对象。 文本: {text} """ content = chat_json( [ {"role": "user", "content": prompt}, ] ) data = json.loads(content) return ContractInfo(**data)

6.5 评测脚本:对比准确率与 token 消耗

这里用一个非常简单的评测框架:准备一组带标准答案的样本,分别跑一次调用和两次调用,统计字段准确率和 token 数。你只需要在samples列表里替换自己的测试数据。

# evaluate.py import time from collections import Counter from first_pass import locate_relevant_lines from second_pass import extract_from_lines from one_pass import extract_from_full_text # 示例数据,实际替换为你的测试集 samples = [ { "text": "某公司今日发布公告,与北京华信科技有限公司签署采购合同,合同金额为1200万元,签约日期为2024年5月18日。项目预计年底交付。", "expected": {"amount": "1200万元", "counterparty": "北京华信科技有限公司", "sign_date": "2024年5月18日"}, }, ] def exact_match(pred: dict, expected: dict) -> bool: return all(pred.get(k) == v for k, v in expected.items()) def run_eval(): fields = ["amount", "counterparty", "sign_date"] stats = Counter() total_tokens = {"one_pass": 0, "two_pass": 0} for sample in samples: text = sample["text"] expected = sample["expected"] t0 = time.time() one_result = extract_from_full_text(text, fields) stats["one_pass_time"] += time.time() - t0 stats["one_pass_correct"] += int(exact_match(one_result.model_dump(), expected)) t1 = time.time() target_lines = locate_relevant_lines(text, fields) two_result = extract_from_lines(text, target_lines, fields) stats["two_pass_time"] += time.time() - t1 stats["two_pass_correct"] += int(exact_match(two_result.model_dump(), expected)) print("one_pass accuracy:", stats["one_pass_correct"] / len(samples)) print("two_pass accuracy:", stats["two_pass_correct"] / len(samples))

运行:

export LLM_API_KEY="your-api-key" export LLM_BASE_URL="https://api.example.com/v1" python evaluate.py

注意,示例代码为了演示简化了 token 统计。实际评测时,可以通过 SDK 返回的usage.prompt_tokensusage.completion_tokens拿到精确 token 数,再把“一次调用所有样本的 token 总和”与“两次调用所有样本 token 总和”相除,就得到成本比例。

7. 运行效果与验证方法

7.1 预期输出

一次调用在简单样本上可能表现不错,但到了长文本样本上,容易出现以下问题:

  • 漏掉“签约对象”字段。
  • 把“付款日期”误当作“签约日期”。
  • 金额字段输出成“合同总价约1200万元人民币”,无法和标准答案精确匹配。

两次调用在这些场景下,由于裁剪后上下文短,输出通常更接近标准答案。

7.2 如何判断成功

用三个指标判断:

  1. 字段级准确率:每个字段独立对比,预测值与标准答案完全一致才计对。
  2. 样本级准确率:整条 JSON 所有字段完全匹配才算对。
  3. 精确匹配之外,还可以加入“可接受匹配”:比如金额允许四舍五入差异、日期允许“2024年5月”与“2024-05-18”的差异。更贴近真实业务。

7.3 失败时先看哪里

如果两次调用效果不如预期,大概率问题出现在第一次定位环节。建议先打印target_lines,检查定位是否准确。如果定位本身就漏了行,第二次抽取模型再强也救不回来。

诊断顺序:

  1. 第一次定位结果是否覆盖关键行。
  2. 裁剪后的文本是否完整保留了字段原文。
  3. 第二次调用的 prompt 是否要求了“不存在返回 null”。
  4. 模型是否真的启用了 JSON 输出模式。

8. 常见问题与排查思路

问题现象可能原因排查方式解决方案
第一次调用返回空行号模型没有理解行号格式打印 numbered 文本,检查编号是否溢出压缩文本长度,或改用句子切分而不是行切分
第二次调用抽出的字段值带前后缀裁剪片段包含过多上下文打印裁剪后文本缩小第一次定位范围,或增加字段正则后处理
JSON 解析失败模型未按 JSON 输出看原始响应统一走response_format,加字符串容错解析
成本高于预期第一次调用输入过长统计首次调用 prompt token先用正则或规则粗筛,减少进入 LLM 的文本量
长文本被截断超过模型上下文窗口检查输入 token 数按章节切分,多次第一次定位后合并结果
同一条文本结果不稳定temperature 过高检查参数强制 temperature=0,重试两次取多数结果

9. 最佳实践与工程建议

9.1 第一次定位不一定要用 LLM

如果你的文件结构相对固定,比如合同前几页固定是“合同主体”,中间固定是“价款条款”,可以先用正则、关键词、章节标题做粗定位,再把 LLM 用在真正需要语义理解的地方。这样能进一步降低成本。

9.2 二次调用时也可以做并发裁剪

如果字段分布在多个不相关区域,也可以把文档按章节切分后,并发调用第一次定位,再合并结果。这一步能把两次调用的延迟从串行变成并行,只是会稍增加成本。根据业务 SLA 决定。

9.3 输出建议增加“来源上下文”

第二次抽取时,除了要求返回字段值,还可以要求返回每个字段对应的原文片段或行号。这样:

  • 便于人工审核。
  • 便于定位模型抽错的原因。
  • 给下游系统提供可追溯依据。
class ContractInfoWithSource(BaseModel): amount: str amount_source: str counterparty: str counterparty_source: str sign_date: str sign_date_source: str

9.4 成本监控与兜底策略

生产环境建议对每次调用记录 model、prompt_tokens、completion_tokens、耗时、重试次数。当单个文档的 token 消耗超过阈值时,自动降级为规则抽取或人工处理,避免极端情况下的费用失控。

如果你的模型接口支持缓存,可以开启前缀缓存。由于第二次调用的 prompt 前缀是固定的指令部分,缓存命中后成本还能再降一截。

9.5 安全与合规

处理合同、病历等敏感文本时,只发送必要字段相关的文本片段,降低数据暴露面。调用外部模型前确认是否符合数据合规要求;如果不能出域,优先使用私有化部署模型。所有脚本和 API key 不要写入代码仓库,统一走环境变量或密钥管理服务。

10. 总结

这篇文章的核心判断是:做 LLM 信息抽取时,调用次数本身不是成本指标,context length 和指令复杂度才是。一次大而全的调用把定位、理解、格式化、反幻觉全部压给模型,效果不稳定;两次调用把任务降维成“先定位、再精抽”,准确率更高,成本反而可能更低。

建议你做的第一件事,不是改代码,而是先拿自己的数据跑一个分层对比:

  1. 选 50 到 100 条代表性文本,标注标准答案。
  2. 分别实现一次调用和两次调用。
  3. 统计字段准确率、样本准确率、平均 token、p50/p95 延迟。
  4. 再决定要不要在生产环境切换。

如果跳过评测直接改方案,大概率会因为“感觉贵了”或“感觉没差多少”而放弃一个实际更优的架构。先量化,再优化,这一步比任何 prompt 技巧都值钱。

下一步可以继续深入的方向:

  • 第一次定位用规则 + LLM 混合,减少模型调用。
  • 第二次抽取加入字段间依赖校验,比如金额必须为正数、日期必须符合格式。
  • 多次抽样投票,去除偶发的格式错误。
  • 把整个流程封装成可观测的内部服务,统一接入日志、监控和告警。

这套方法只是“用工程思维使用大模型”的一个例子。很多 LLM 场景的问题不在于模型不够聪明,而在于我们把太多任务塞给了同一次调用。拆开它,答案往往比想象中简单。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询