导读:周五下午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自动化流水线

大模型不是万能药,但在"理解非结构化数据"这件事上,它确实让以前不可能的任务变成了"一个周末就能搞定"。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐