处理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),置信度下降,路由就会切换到api或human。
实际部署中的关键决策
源文章在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。