本地优先推理:让80%的文档零API成本完成提取

2026-05-11 19 预计阅读时间:1 分钟
来源:infoq.com AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:12 分钟

处理4700份工程图纸PDF,API费用砍掉75%,处理时间缩短55%——这不是靠更便宜的模型,而是靠一个更聪明的路由策略:先把能确定性提取的文档留在本地处理,只把边缘情况交给云端大模型。Obinna Iheanachor 把这个实践总结为"Local-First AI Inference"架构模式,核心思路简单但效果显著。

为什么需要本地优先

文档处理场景里,大量内容是结构化或半结构化的——发票有固定字段位置,工程图纸的标题栏格式统一,合同的关键条款在特定段落。这些内容用正则、模板匹配、规则引擎就能提取,不需要调用GPT-4。

但现实中的架构往往是:所有文档一股脑送进大模型API。结果是什么?80%的请求在做大模型本不需要做的工作,每一条都在烧钱、加延迟、引入不确定性。Local-First 模式反过来:先问"我能不能本地搞定",只有回答"不能"时才调用API

三层路由架构

整个模式的核心是一个三层分流机制:

层级 处理方式 覆盖比例 成本
第一层:确定性本地提取 正则、模板、规则引擎 70–80% 0
第二层:云端大模型 Azure OpenAI 等LLM API 15–20% 按token计费
第三层:人工审核 标记低置信度结果,人工复核 5–10% 人力成本

关键不是三层本身,而是层间的置信度阈值——本地提取完成后,给每个字段打一个置信度分数。分数够高,直接输出;分数在中间地带,送云端重试;分数太低或云端也拿不准的,标记给人看。

路由逻辑与置信度评分

本地提取的置信度怎么算?最实用的方式是组合多个信号:

  • 字段是否命中模板:模板定义了"图号在第3行第2列",命中则置信度高
  • 正则匹配的严格程度:严格正则(如ISO-\d{4}-\d{2})比宽松正则(如\d+)置信度高
  • 提取值是否在预期范围内:日期在合理区间、数值不为负、字符串长度正常
  • 多规则交叉验证:两个独立规则提取到相同值,置信度叠加

下面是一个可运行的最小化实现,展示整个路由流程:

import re
import json
from dataclasses import dataclass, field
from typing import Optional

# ── 置信度配置 ──
LOCAL_THRESHOLD = 0.85      # ≥ 此值直接输出
API_THRESHOLD = 0.50        # ≥ 此值送云端,< 此值送人工
LOW_CONFIDENCE_PENALTY = 0.3  # 云端返回仍低置信度时的惩罚

# ── 模板定义:工程图纸标题栏 ──
DRAWING_TEMPLATE = {
    "drawing_number": {
        "regex": r"DWG[-\s]?(\d{4,12})",
        "position": {"row": 3, "col": 2},
        "expected_len": (4, 12),
    },
    "revision": {
        "regex": r"REV[-\s]?([A-Z])",
        "position": {"row": 3, "col": 4},
        "expected_len": (1, 1),
    },
    "date": {
        "regex": r"(\d{4}[-/]\d{2}[-/]\d{2})",
        "position": {"row": 4, "col": 2},
        "expected_range": ("2000-01-01", "2030-12-31"),
    },
    "title": {
        "regex": r"TITLE[:\s]+(.+?)(?:\n|$)",
        "position": {"row": 1, "col": 1},
        "expected_len": (5, 200),
    },
}


@dataclass
class ExtractedField:
    name: str
    value: Optional[str] = None
    confidence: float = 0.0
    source: str = "local"  # local | api | human


@dataclass
class DocumentResult:
    doc_id: str
    fields: list[ExtractedField] = field(default_factory=list)
    route: str = "local"  # local | api | human


def compute_field_confidence(field_name: str, value: str, text: str) -> float:
    """组合多个信号计算单字段置信度"""
    template = DRAWING_TEMPLATE.get(field_name, {})
    conf = 0.0
    signals = 0

    # 信号1:正则是否匹配
    regex = template.get("regex")
    if regex and re.search(regex, text):
        match = re.search(regex, text)
        if match.group(1).strip() == value.strip():
            conf += 0.40
            signals += 1

    # 信号2:值长度是否在预期范围
    expected_len = template.get("expected_len")
    if expected_len and expected_len[0] <= len(value) <= expected_len[1]:
        conf += 0.20
        signals += 1

    # 信号3:值是否在预期值域(日期/数值类)
    expected_range = template.get("expected_range")
    if expected_range and value >= expected_range[0] and value <= expected_range[1]:
        conf += 0.25
        signals += 1

    # 信号4:位置是否命中(简化:检查值是否出现在文本对应行)
    position = template.get("position")
    if position:
        lines = text.split("\n")
        row_idx = position["row"] - 1
        if row_idx < len(lines) and value in lines[row_idx]:
            conf += 0.15
            signals += 1

    # 无信号则给低底分
    return conf if signals > 0 else 0.10


def local_extract(doc_id: str, text: str) -> DocumentResult:
    """第一层:确定性本地提取"""
    result = DocumentResult(doc_id=doc_id)
    for field_name, template in DRAWING_TEMPLATE.items():
        regex = template.get("regex", r"(.+)")
        match = re.search(regex, text)
        if match:
            value = match.group(1).strip()
            confidence = compute_field_confidence(field_name, value, text)
            result.fields.append(ExtractedField(
                name=field_name, value=value,
                confidence=confidence, source="local"
            ))
        else:
            result.fields.append(ExtractedField(
                name=field_name, confidence=0.0, source="local"
            ))
    return result


def route_document(result: DocumentResult) -> DocumentResult:
    """根据置信度决定路由"""
    avg_conf = sum(f.confidence for f in result.fields) / len(result.fields) if result.fields else 0.0

    if avg_conf >= LOCAL_THRESHOLD:
        result.route = "local"
    elif avg_conf >= API_THRESHOLD:
        result.route = "api"
    else:
        result.route = "human"
    return result


# ── 模拟云端提取(实际中调用 Azure OpenAI)──
def api_extract(result: DocumentResult, text: str) -> DocumentResult:
    """第二层:云端大模型提取,仅处理低置信度字段"""
    # 实际实现中,这里构造 prompt 调用 Azure OpenAI
    # 示例仅模拟:对低置信度字段重新赋值
    prompt = f"""Extract the following fields from this engineering drawing text.
Return JSON with keys: {', '.join(f.name for f in result.fields if f.confidence < LOCAL_THRESHOLD)}.

Text:
{text[:2000]}
"""
    # 模拟API返回(实际代码用 openai.ChatCompletion.create)
    mock_api_response = {
        "drawing_number": "DWG-20240101",
        "revision": "C",
        "date": "2024-06-15",
        "title": "Pump Assembly Detail"
    }

    for field in result.fields:
        if field.confidence < LOCAL_THRESHOLD:
            api_value = mock_api_response.get(field.name)
            if api_value:
                field.value = api_value
                field.confidence = 0.75  # API 返回给中等置信度
                field.source = "api"

    # API 返回后仍有低置信度字段 → 标记人工
    if any(f.confidence < API_THRESHOLD for f in result.fields):
        result.route = "human"
    else:
        result.route = "api"
    return result


# ── 运行示例 ──
sample_text = """TITLE: Hydraulic Pump Assembly - Section A
DWG-20240101
Material: Stainless Steel 316
REV-C
Date: 2024-06-15
Scale: 1:2
Approved by: J. Smith
"""

# Step 1: 本地提取
result = local_extract("ENG-001", sample_text)
print("=== 本地提取结果 ===")
for f in result.fields:
    print(f"  {f.name}: value={f.value}, conf={f.confidence:.2f}")

# Step 2: 路由决策
result = route_document(result)
print(f"\n=== 路由决策: {result.route} (avg_conf={sum(f.confidence for f in result.fields)/len(result.fields):.2f}) ===")

# Step 3: 如果路由到API层
if result.route == "api":
    result = api_extract(result, sample_text)
    print("\n=== API提取后 ===")
    for f in result.fields:
        print(f"  {f.name}: value={f.value}, conf={f.confidence:.2f}, source={f.source}")
    print(f"  最终路由: {result.route}")

运行这段代码,你会看到本地提取对格式规范的字段给出高置信度,路由决策直接走local通道——零API调用。如果把sample_text里的字段格式改乱(比如图号写成模糊的No.24),置信度下降,路由就会切换到apihuman

实际部署中的关键决策

源文章在4700份工程图纸PDF上的部署结果:API成本降75%,处理时间降55%。要拿到这个结果,有几个决策点值得注意:

模板维护成本。 本地提取依赖模板和规则,工程图纸的标题栏格式相对稳定,模板维护成本低。但如果你的文档格式高度多变(比如自由格式的邮件、非结构化的会议记录),本地层覆盖率会大幅下降,70–80%的比例就很难达到。这种场景下,本地层可以退化为"预处理"——做PDF解析、OCR、分页,把清洗后的文本送API,至少省掉重复的预处理token开销。

置信度阈值调优。 三个阈值(直接输出、送API、送人工)需要根据业务容忍度调整。金融合规场景,LOCAL_THRESHOLD可能要设到0.95以上;内部工具场景,0.80就够了。建议先跑一批标注数据,统计各阈值下的分流比例和准确率,再定最终值。

人工审核的吞吐量瓶颈。 5–10%的文档进人工层,4700份就是235–470份需要人看。如果日处理量是10万份,人工层就是5000–10000份/天——这需要配套的审核界面和团队。模式本身不消除人工,只是把人工限制在可控边界内。

采用前的检查清单

在把 Local-First 模式引入自己的系统之前,逐项确认:

  • 文档结构化程度:你的文档有多少比例有可识别的固定格式?>60%才值得建本地层
  • 字段提取精度要求:哪些字段允许95%准确率,哪些必须99.9%?分别设阈值
  • 模板/规则的维护节奏:文档格式多久变一次?谁来更新模板?有没有变更检测机制
  • API调用的当前成本基线:先量一下每月token消耗,再算本地层能砍多少
  • 人工审核的容量:按5–10%比例算,日均审核量是多少?有没有现成的审核流程和工具
  • 延迟目标:本地提取毫秒级,API调用秒级,混合路由的P99延迟是否满足SLA
  • 监控与回退:当本地层模板失效(文档格式大变)时,能否快速切换到全API模式兜底

Local-First 不是万能方案——它最适合"大量重复格式+少量边缘情况"的文档场景。但这个思路可以推广:任何有确定性处理路径和不确定性处理路径混合的场景,都值得先问一句"哪些我能本地搞定",再决定要不要调用那个昂贵的API。


相关推荐