别再用GPT-4处理PDF了!本地7B模型+RPA,500份合同3小时抽完,零API成本
导读:周五下午5点,部门群响了。老板@我:"这周末加个班,把供应商合同压缩包处理一下,下周一法务要用。"我看着那堆歪七扭八的扫描PDF,心里只有一个念头:这他妈是人干的活? 500份合同,要提取甲方、金额、工期、违约责任等20+字段。没有API权限、数据不能出内网、扫描件质量惨不忍睹。最后我用本地7B小模型+RPA,周六下午就交了差,周日打了一天游戏——零API成本,数据全程没出过内网。
一、周五下午5点:血压上来的瞬间
消息来自部门群,老板@我:
"这周末加个班,把供应商合同压缩包处理一下。下周一法务要用。"
我点开压缩包,心里咯噔一下:
-
500份PDF,全部是扫描件,没有电子文本层
-
角度歪斜、光线不均,有些还盖着红章、手写批注
-
合同模板不统一,有的20页,有的200页
-
最关键的:这些合同涉及采购金额和商业条款,绝对不能走公有云API
第一反应:人工干?500份 × 15分钟/份 = 125小时,周末两天48小时不吃不喝也干不完。
第二反应:上GPT-4 API写脚本批量跑?数据安全红线直接卡死。
第三反应:传统RPA硬上?之前用过,纯规则逻辑根本搞不定"这份合同的付款条款在第几页第几段"这种非结构化数据的语义理解问题。
周五晚上10点,我决定换条路:本地部署LLM(大语言模型)+ RPA,搞一套完全离线的文档抽取流水线。
二、第一版方案:GPT-4 API,翻得彻彻底底
先说翻车经历,避免后人踩坑。
我最初的想法很简单:把PDF转成图片,调GPT-4o的API,让它看图提取信息。代码半小时写完了:
import fitz # PyMuPDF
import base64
import openai
def pdf_to_images(pdf_path):
doc = fitz.open(pdf_path)
images = []
for page in doc:
pix = page.get_pixmap(dpi=200)
images.append(pix.tobytes("png"))
return images
def extract_with_gpt4(image_bytes):
b64 = base64.b64encode(image_bytes).decode()
resp = openai.ChatCompletion.create(
model="gpt-4o",
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "提取合同中的甲方、乙方、金额、工期"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
]
}]
)
return resp.choices[0].message.content
跑了50份,三个致命问题暴露:
问题1:扫描件识别率血崩
扫描件PDF没有文本层,fitz直接渲染成图片,文字模糊、噪点多。GPT-4o对这种低质量图片的理解能力远低于预期,金额识别错误率超过30%——"¥500,000.00"识别成"¥50,000.00",差一个零就是几十万。
问题2:幻觉(Hallucination)防不胜防
合同里有红章、手写批注、划线修改,GPT-4o会"脑补"内容。一份合同明明没有"质保金"条款,它硬是给编了一条"质保金为合同金额的5%"。这种错误比漏提更危险,因为漏了还能发现,编了你根本不知道怎么核对。
问题3:数据安全红线
跑了50份我才反应过来——这些合同的采购金额、供应商信息、违约责任,全是商业机密。走OpenAI API,数据要经过公网、经过美国服务器。就算公司没明说,真出了事,锅全在我头上。
周五晚上11点半,我把API脚本删了。这条路,死胡同。
三、第二版方案:本地7B小模型 + RPA,柳暗花明
周六早上7点,我重新梳理需求:
| 约束 | 解法 |
|---|---|
| 数据不能出内网 | 私有化部署大模型,物理断网 |
| 扫描件质量差 | OCR初筛 + 多模态模型二次校正 |
| 合同模板不统一 | 大模型零样本抽取(Zero-shot IE),不训练、不微调 |
| 500份批量处理 | RPA自动化流水线,无人值守 |
| 周一早上交差 | 周六必须跑通,留周日容错 |
3.1 模型选型:7B够用了,别被忽悠买A100
我先做了个小实验:用同一份扫描合同,测试不同模型的提取准确率。
| 模型 | 参数量 | 显存需求 | 金额准确率 | 工期准确率 | 违约责任准确率 |
|---|---|---|---|---|---|
| Qwen2-VL-2B | 2B | 6GB | 62% | 55% | 48% |
| Qwen2-VL-7B-int4 | 7B | 8GB | 89% | 85% | 82% |
| Qwen2-VL-72B | 72B | 48GB | 94% | 91% | 88% |
| GPT-4o (API) | - | - | 91% | 88% | 85% |
结论:7B-int4的准确率已经接近GPT-4o,显存只要8GB。我工位上的RTX 4060 Ti 16G完全够跑,根本不需要A100。
部署用的是vLLM + Ollama,一行命令启动:
# 下载模型
ollama pull qwen2-vl:7b
# 启动服务(绑定内网IP,只接受本地请求)
vllm serve qwen2-vl:7b \
--quantization awq \
--max-model-len 8192 \
--tensor-parallel-size 1 \
--host 192.168.10.100 \
--port 8000
踩坑记录:启动时我加了--max-model-len 16384,结果直接CUDA out of memory。查了半天文档,发现7B模型上下文根本不需要这么长,改成8192立刻正常。这种参数调优的坑,不亲自踩一遍根本想不到。
3.2 Prompt工程:不是"写一段提示词",是"迭代一上午"
这是整个方案最核心的部分。我花了整整一个上午,迭代了7版Prompt。
第一版( naive 版,翻车):
请提取合同中的甲方、乙方、金额、工期、违约责任。
结果:模型输出一大段自然语言,没有结构化格式,RPA无法解析。
第二版( 加格式约束,能用但不稳):
请以JSON格式输出以下字段:
- 甲方
- 乙方
- 金额
- 工期
- 违约责任
结果:JSON格式对了,但金额格式混乱——有的输出"伍万元整",有的输出"50000",有的输出"5万"。RPA填表时直接报错。
第三版( 加类型约束,接近生产级):
你是一名合同解析专家。请严格按以下JSON Schema输出,禁止添加任何解释性文字:
{
"甲方": "string, 甲方全称,保留原文",
"乙方": "string, 乙方全称,保留原文",
"金额": "number, 合同总金额,统一转换为元,纯数字,禁止保留单位",
"工期": "string, 统一格式为'XX天'或'XX个月'",
"违约责任": ["string, 列出所有违约责任条款,保留原文引用"]
}
约束:
1. 金额必须转换为纯数字。例如"伍万元整"→50000,"5.5万"→55000
2. 如果某字段在合同中未明确提及,填null,禁止编造
3. 违约责任逐条列出,不要合并成一段
结果:准确率提升到85%,但遇到红章遮挡文字时,模型会"猜"内容,幻觉还在。
第四版( 终极生产版,加入"不确定则null"策略):
你是一名合同解析专家。你的任务是:从提供的合同图片中提取结构化信息。
重要原则:如果你看不清某处文字,或不确定某个字段的值,必须填null,禁止猜测。
输出格式(严格JSON):
{
"甲方": {"value": "string|null", "confidence": "high|medium|low"},
"乙方": {"value": "string|null", "confidence": "high|medium|low"},
"金额": {"value": "number|null", "unit": "元", "confidence": "high|medium|low"},
"工期": {"value": "string|null", "confidence": "high|medium|low"},
"违约责任": {"items": ["string"], "confidence": "high|medium|low"}
}
字段定义:
- 甲方/乙方:合同首页的"甲方(买方)"和"乙方(卖方)"全称
- 金额:合同中的"合同总价"或"成交金额",含数字和中文大写的都要识别
- 工期:从"开工日期"到"竣工日期"的跨度,或"XX个日历天"
- 违约责任:所有包含"违约"、"赔偿"、"滞纳金"、"违约金"的条款
处理规则:
1. 红章遮挡区域:如果文字被红章完全覆盖无法辨认,该字段confidence标为low,value填null
2. 手写批注:以打印文字为准,手写内容仅作为补充
3. 多页合同:优先看"合同协议书"或"合同条款"章节,忽略"通用条款"中的模板内容
4. 金额转换:"壹佰贰拾叁万肆仟伍佰陆拾柒元捌角玖分" → 1234567.89
危险行为(绝对禁止):
- 不要编造不存在的条款
- 不要把"质保期"当成"工期"
- 不要把"预付款"当成"总金额"
这版Prompt跑下来,准确率飙到91%,且 confidence 为 low 的字段会自动标红,方便人工复核。
3.3 RPA流水线:从"下载PDF"到"Excel输出"全自动
Prompt搞定后,下午开始搭RPA流程。这里有个现实约束:甲方内网环境连Python都没装,我只能生成一个双击就能运行的包。
我对比了几个RPA工具的离线能力:
| 工具 | 离线运行 | 导出EXE | 内网部署难度 | 选择理由 |
|---|---|---|---|---|
| UiPath | 需Orchestrator授权 | 企业版功能 | 高 | 授权贵,社区版受限 |
| 影刀RPA | 支持 | 企业版功能 | 中 | 社区版不能导出EXE |
| 蓝印RPA | 支持 | 社区版支持 | 低 | 双击EXE就能跑,无需安装环境 |
蓝印RPA的离线导出功能在这里救了我——流程设计完后,直接导出独立EXE,拷贝到内网机器上双击运行,不需要装任何依赖。当然,如果你用影刀企业版或UiPath的离线包,也能达到同样效果,只是我当时手头正好有蓝印RPA的社区版。
RPA流程设计(核心步骤):
[开始]
↓
[读取合同文件夹] → 遍历所有PDF
↓
[PDF转图片] → 用PyMuPDF渲染为PNG(200dpi)
↓
[调用本地vLLM] → POST请求到192.168.10.100:8000
↓
[解析JSON结果] → 提取各字段值
↓
[置信度检查] → confidence=low? → 是 → [标记待复核]
↓ 否
[写入Excel] → 按模板格式填充
↓
[循环下一份]
↓
[结束] → 输出:result.xlsx + review_list.txt
调用本地模型的关键节点代码:
# RPA自定义脚本节点
import requests
import json
import base64
def call_local_llm(image_path):
# 读取图片转base64
with open(image_path, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
# 构造多模态请求
payload = {
"model": "qwen2-vl:7b",
"messages": [{
"role": "user",
"content": [
{"type": "text", "text": SYSTEM_PROMPT}, # 上面那版终极Prompt
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
]
}],
"temperature": 0.1, # 低温度,减少幻觉
"max_tokens": 2048
}
# 调用本地服务
resp = requests.post(
"http://192.168.10.100:8000/v1/chat/completions",
json=payload,
timeout=60
)
result = resp.json()["choices"][0]["message"]["content"]
# 清洗:有时候模型会包裹在markdown代码块里
if "```json" in result:
result = result.split("```json")[1].split("```")[0]
return json.loads(result)
3.4 混合策略:该用规则的地方别浪费模型
整个下午我都在跑测试,发现一个规律:不是所有合同都需要上大模型。
| 合同类型 | 特征 | 处理方式 | 耗时 |
|---|---|---|---|
| 标准模板合同 | 格式固定,字段位置固定 | OCR + 正则表达式 | 2秒/份 |
| 非标合同 | 格式混乱,手写批注多 | 大模型全量解析 | 15秒/份 |
| 红章遮挡严重 | 关键字段被覆盖 | 大模型 + 人工复核 | 30秒/份 |
我加了一个预处理分类器:
def classify_contract(text):
"""基于OCR文本判断合同类型"""
if "合同编号" in text and "签约地点" in text and text.count("元") == 1:
return "standard" # 标准模板,走正则
elif "补充协议" in text or "变更" in text:
return "non_standard" # 非标,走大模型
else:
return "non_standard" # 默认走大模型
500份合同里,约180份是标准模板,走正则2秒搞定;剩下320份走大模型。总耗时从"全部大模型的2小时"压缩到"3小时跑完全部+1小时复核"。
四、周六下午4点:交差
RPA流水线跑完500份,输出两个文件:
-
result.xlsx:482条完整记录,字段齐全 -
review_list.txt:18条 confidence=low 的记录,需要人工复核
我花了一小时复核那18条,大多是红章遮挡了金额或日期,翻一下纸质原件就能确认。
周六下午5点,Excel发给了老板。
周日,我打了整整一天游戏。周一早上,同事问我:"周末加班到几点?"
我说:"挺晚的。"(内心:周六下午5点就交了,周日睡了一天)
五、方案总结:成本、效果、踩坑清单
5.1 成本对比
| 方案 | 单份耗时 | 500份总耗时 | 成本 | 数据安全 |
|---|---|---|---|---|
| 人工处理 | 15分钟 | 125小时(约16人天) | 8000元/人天 × 2人 = 16000元 | ✅ |
| GPT-4o API | 30秒 | 4小时 | ~5000元(API费用) | ❌ |
| 本地7B + RPA | 5秒(平均) | 3小时 | 0元(自有显卡) | ✅ |
| 我们的方案(混合策略) | 2-15秒 | 3小时+1小时复核 | 0元 | ✅ |
5.2 准确率复盘
| 字段 | 准确率 | 主要错误原因 |
|---|---|---|
| 甲方/乙方 | 96% | 公司名太长换行,OCR断句错误 |
| 金额 | 93% | 中文大写金额转数字,"壹仟万"误为"壹千万" |
| 工期 | 89% | "日历天" vs "工作日"区分不清 |
| 违约责任 | 82% | 条款分散在多页,模型漏提 |
违约责任准确率最低,因为合同里的违约条款往往分散在"通用条款"、"专用条款"、"补充协议"多个位置。后续优化方向是:先让模型做章节定位(找出哪些页面包含违约相关内容),再针对性抽取。
5.3 三个血泪教训
教训1:温度参数别设高了
我第一次用 temperature=0.7,模型创造力爆棚,硬是给一份没有违约条款的合同编了3条。改成 temperature=0.1 后,幻觉基本消失。
教训2:超时处理必须做
vLLM服务偶尔会卡住,RPA流程里一定要设重试机制 + 超时兜底。我设了3次重试,每次60秒超时,超时就跳过该文件记日志,不能让整个流水线崩掉。
教训3:Excel模板提前锁死
RPA填表时,如果Excel模板有合并单元格、数据验证规则,很容易填错位置。建议用纯数据表(无格式)输出,最后再用VBA或手动套格式。
六、资源分享
本地模型部署的硬件建议:
-
个人学习:RTX 4060 8G 跑 4bit量化版完全够用
-
小团队生产:RTX 4090 24G 或 2×RTX 3090,可以跑更大模型或并发
-
企业级:A100/L40S + vLLM/tensor-parallel,支撑10+并发
七、最后
这套方案的核心不是技术多炫,而是"在约束条件下找到最优解":
-
数据不能出内网 → 私有化部署
-
没有运维能力 → 导出EXE双击运行
-
成本要可控 → 7B小模型 + 混合策略
-
时间要赶 → RPA自动化流水线
大模型不是万能药,但在"理解非结构化数据"这件事上,它确实让以前不可能的任务变成了"一个周末就能搞定"。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)