做文档类项目最烦的往往不是文档本身,而是模型接口。上个月我在搭一套合同审阅管线时,光是切换不同供应商的模型就折腾了整整两天:A 平台的模型要单独注册拿 key,B 平台的响应结构和 C 完全不一样,D 平台的文档里写的还是过期参数。想对比一下哪个模型抽取字段最稳,光是适配代码就写了几百行。后来把所有请求收敛到一个聚合层,才真正体会到"自由使用大模型"的舒服——这种自由不是网络意义上的,而是选择模型、切换供应商、调配预算的自由。OpenRouter 就是这类聚合网关里用得比较顺手的那个,尤其适合文档理解这种需要反复试模型的任务:合同要素抽取、发票结构化、表格还原、长文问答,都能在一个统一接口里完成。这篇文章就围绕 OpenRouter 讲透三件事:它解决了什么问题、文档理解到底难在哪、以及一个可以直接抄的合同抽取工具怎么实现。
1. 多模型时代的 API 碎片化:为什么我们需要一个统一网关
1.1 碎片化带来的真实成本:注册、计费、接口格式、限流
现在的模型生态和两三年前完全不同。早年基本只有一个选择,照着官方文档把 SDK 拉下来直接用就行。现在几乎每周都有新模型发布,各有各的强项:有的擅长长文本,有的对视觉表格敏感,有的数学推理特别稳。表面上看选择多了是好事,但落到工程上就变成灾难。
首先是注册和鉴权。每家供应商一套账号体系,一个独立 API Key,有的还要额外设置安全策略、白名单、支付宝扣款绑定。一次接入两个模型,等于把整个考证流程重复两遍。其次是接口格式。虽然大家都在往 OpenAI 兼容协议靠拢,但细节差异多得吓人:有的要求消息里必须带max_tokens,有的把参数叫max_completion_tokens,有的不支持temperature=0,有的对图片输入有自己的 base64 前缀规则。第三是计费单位不统一。有的按 token 计费,有的按字符计费,有的按图片张数计费,有的按分钟跑一个微调实例计费。做成本对比的时候,你会发现你连一个统一口径都算不出来。
最后是限流和容错。不同供应商的速率限制策略完全不同,有的按 RPM(每分钟请求数),有的按 TPM(每分钟 token 数),有的干脆不公开具体阈值。生产环境一跑,请求多了就随机 429,让你根本分不清到底是流量问题、代码问题还是配额问题。这些碎片化成本加起来,通常比模型本身的调用费还高。
1.2 统一网关到底做了什么:路由、鉴权、格式化、计费聚合
OpenRouter 这类统一网关解决的就是上述这些"脏活"。
它把几十个供应商的模型全部纳到一个统一的 API 入口后面。你只管拿一个 API Key、按统一协议发送请求,网关负责把请求转发给上游供应商,把上游返回的差异收敛成统一格式回给你。具体拆开看,它做了四件事:
第一,路由。你可以在请求体里指定具体模型,也可以让网关按你设定的规则自动挑选。比如按价格上限挑最便宜的可用模型,或者按供应商可用性做故障转移——A 挂了自动切 B,这个动作在你自己写代码时要几百行,在网关层面只是一个参数。
第二,鉴权和用量聚合。所有模型共用同一个 API Key,同一个账户统一结算。你不用再去 N 个后台查账单,所有模型的花费在同一个额度池里消耗。这对做批处理任务特别重要,因为你可以直接把预算当成一个全局变量来管理。
第三,协议统一和格式纠偏。不同模型对 JSON 输出、工具调用、视觉输入的支持程度不同,网关会尽量把这些差异折算成 OpenAI 兼容格式。你用的如果是openaiPython 库,只需要改base_url和api_key,业务代码完全不用动。
第四,中间层观测和拦截。很多网关还提供请求日志、令牌用量统计、内容缓存。一旦某个模型供应商出了事故,网关侧会直接标记不可用,你的重试逻辑不用把三十六家供应商全部打一遍。
1.3 说清楚:这里的"路由"是请求分发,不是网络代理
有一点我必须特意说明,因为太容易被想歪了。很多第一次接触这类技术的人都以为"路由"意味着某种隧道、中转或者绕过机制,其实完全不是。
OpenRouter 的 "Router" 是模型路由,意思是在多个大模型 API 之间做智能分发。你可以把它类比成一个外卖聚合平台:你下单(发一个标准化的请求),平台根据商家位置、价格、评分决定派单给哪家餐厅(某个模型供应商)。它不改变你的网络链路,不做任何流量加密或转发到特殊通道,只是换了一个 API 入口地址,把请求交给背后的模型服务商去执行。
本质上,它就是一个商业化的 API 聚合层,解决的是"接口标准不统一"和"模型切换成本高"这两个工程问题。所以这篇文章里说的"自由",指的是选模型和换供应商的自由,是预算可控的调度自由,跟任何网络访问手段没有关系。
2. OpenRouter 的使用模型与协议细节
2.1 OpenAI 兼容协议:一个 base_url 走天下
OpenRouter 对外暴露的 API 是 OpenAI 兼容格式,这意味着所有支持自定义base_url的 OpenAI SDK 都能直接用。我在项目里用的是 Python 的openai库,全局只需要配置一次:
from openai import OpenAI client = OpenAI( api_key="sk-or-这里填你自己的Key", base_url="https://openrouter.ai/api/v1", )配置完这个之后,client.chat.completions.create()、client.embeddings.create()这些方法全部照常工作。参数名完全沿用 OpenAI 的命名习惯,model、messages、temperature、max_tokens、response_format、stream都认识。
这个设计最大的好处是零迁移成本。如果你手上已经有用 OpenAI SDK 写好的业务代码,切换过来只需要改两行配置,不需要改任何 prompt 和逻辑。我在这套合同管线里先跑了三个模型的对比测试,从写代码到出结果只花了不到二十分钟,因为压根没有"接入"这个过程,只有"改 model 字段"这个动作。
2.2 模型标识符与路由策略:精确指定、自动回退、按价格排序
OpenRouter 对每个模型都维护一个唯一标识,格式通常是"供应商名/模型名"的 slug,例如某家的视觉模型写完大概是provider-xxx/vision-14b。具体列表在平台模型页面上能看到,调用的时候把这串标识直接放进model字段即可。
除了手动指定单个模型,网关还支持一种非常有用的高级玩法:模型路由。你可以传多个模型,让网关决定用哪个:
response = client.chat.completions.create( model="provider-xxx/vision-14b", # 首选 fallback_models=["provider-yyy/doc-8b"], # 备选,主模型挂了或限流时用 messages=messages, )在文本生成场景中,还可以用价格排序的策略:网关在满足你设置的max_price上限条件下,从可用模型列表里挑最便宜的来跑。这个对成本敏感的任务非常实用,因为很多简单任务根本不需要上旗舰模型,用便宜的小模型就能完成大部分工作,只有当它失败时才升级到更大的模型。
2.3 上下文窗口、采样参数与供应商差异
不同模型的能力边界差异很大,网关能做的是把请求转发过去并尽量把响应压缩成统一格式,但不会帮你变出不存在的上下文窗口。我在实际使用时会把关键差异列成一个表,贴在手边上,随时对照:
| 模型档次 | 典型上下文窗口 | 视觉输入 | JSON 模式 | 建议用途 |
|---|---|---|---|---|
| 入门小模型 | 8K~32K | 部分支持 | 部分支持 | 文本分类、简单抽取 |
| 中级通用模型 | 32K~128K | 大部分支持 | 普遍支持 | 文档问答、中等抽取 |
| 大型旗舰模型 | 128K~200K+ | 支持 | 支持 | 长文档推理、复杂表格 |
| 专用视觉模型 | 32K~128K | 强支持 | 视供应商而定 | 图表识别、版式还原 |
使用时要特别注意两点。第一,上下文窗口是"输入的文本 token + 图片视觉 token + 输出的 token 总和",不是你想发多长就发多长。第二,不同供应商对temperature的支持粒度不一样,一些模型只接受整数,一些模型支持 0.1 的浮点精度,写参数时建议统一用一个保守值(比如 0.2),避免某些上游搞出意外的解析错误。
2.4 计量与计费:统一额度背后的单位换算
计费是聚合网关最容易被骂、也最需要理解的地方。OpenRouter 把所有模型的费用统一折算成美元计费,底层逻辑是:网关先给每个模型定义每百万 token 的输入价和输出价,再把用户的消耗累加进一个统一账户。
这里有一个坑:不同模型按 token 计价,但一个中文 token 大概相当于 0.6~0.7 个汉字,英文 token 大致对应 4 个字符,所以同样是 1 美元额度,处理中文文档和处理英文文档的"页数"完全不一样。做预算时我一般按"页 × 平均每页 token 数 × 单价"估算:一页常规 A4 纯文字合同,大约 500~800 个 token;转成图片给视觉模型,则要按图片张数和每张图片固定的视觉 token 数计算,通常一张图会占用 1300 到 1600 个视觉 token,这个开销往往比纯文字大得多。
理解这一点之后,你就明白为什么文档理解任务里,单纯把整本 PDF 无脑发给模型是个非常奢侈的做法——后面我会讲怎么用"先抽页再分发"的思路控制成本。
3. 文档理解任务的技术拆解:比"OCR"多走三步
3.1 为什么文档理解不能用普通 OCR 替代
很多人以为文档理解就是把图片里的字识别出来,调一个 OCR 接口就完事。放到合同、发票、学术论文这类真实场景里,这个想法会很快破产。
OCR 只解决一个问题:把像素变成字符。但一份文档的含义藏在结构里。表格里的金额和日期是什么关系?合同条款里"甲方"和"乙方"分别出现在哪些段落?发票里的"合计金额"到底是哪个数字?页眉页脚有没有被当成正文?这些问题 OCR 一件都答不了。
要真正"理解"文档,至少需要三步:版面分析(区分标题、正文、表格、页眉页脚)、语义解析(把字符组织成字段和关系)、知识输出(把抽取结果整理成结构化 JSON 或自然语言答案)。传统做法是OCR + 规则表达式 + 版面模型拼起来,复杂且脆弱;现在大模型直接把这三步压缩进"看图回答"或"读全文回答"的过程里,这也是 OpenRouter 这类多模型网关在文档任务上特别有价值的原因。
3.2 从文档到模型输入:版面分析、视觉编码与分页
大模型理解文档有两种主流输入方式。第一种是把文档页渲染成图片,走视觉模型。这种方式对大表格、复杂版式、盖章签字区域特别好用,因为模型能看到全局布局。第二种是先做文本抽取,再把纯文本喂给语言模型。这种方式便宜、token 占用少,但遇到表格就会丢失结构。
我做的合同抽取工具走的是混合路线:先渲染成图片,用视觉模型抽每页的候选字段,再用语言模型对跨页信息做整合。为什么优先走视觉?因为合同里有大量带格式、带颜色、带上下标的局部信息,纯文本抽取会把这些格式信息全部抹掉,导致"亿"和"万"这种数量级的关键字错位。
实操上一个容易忽略的点是分页策略。不要把整份 30 页的合同一次性塞进上下文。我习惯每页渲染成一张 150 DPI 到 200 DPI 的 PNG,逐页抽取,然后把每页的结构化结果投给第二阶段的汇总模型。这样既绕开了上下文窗口限制,又方便定位字段在哪一页,后续人工复核也简单得多。
3.3 结构化抽取与文档问答的 Prompt 要点
在 OpenRouter 的统一接口上做文档抽取,Prompt 写得不好,再强的模型也会输出一堆模棱两可的东西。我总结了几条文档场景专属的 Prompt 规则:
第一,明确输出格式。不要只说"帮我抽取字段",要说"输出一个 JSON,包含以下键:甲方名称、乙方名称、合同金额(数字)、付款方式、违约金比例(百分数),若字段缺失填 null"。这一步直接决定你后处理的时候要不要跟模型吵架。
第二,限定取值边界。比如合同金额要注明"只提取大写金额和数字金额一致的那一个",违约金比例要注明"是百分比数值,不是文字描述"。模型不是不懂,而是你不限定它就容易发挥。
第三,提供示例。在 Prompt 里给一个"输入片段 → 期望输出"的小例子,比任何规则都管用。视觉模型尤其吃这一套。
第四,明确拒答策略。告诉模型"如果某字段在文档中没有明确出现,输出 null,不要猜测"。否则你会发现它特别擅长编造一个看起来非常合理的甲方公司名。
3.4 用 OpenRouter 搭建文档理解链路
把前面这些串起来,一条可复用的文档理解链路长这样:
- 文档解析层:用 PyMuPDF 把 PDF 按页渲染成图片,或抽取文字层。
- 预处理层:剔除全空白页、判定是否需要旋转、把 DPI 统一。
- 视觉抽取层:通过 OpenRouter 统一接口调用视觉模型,逐页输出结构化字段。
- 汇总修正层:把多页结果交给语言模型做冲突消解、跨页字段合并。
- 校验兜底层:用本地规则跑一遍空值检查、类型检查和金额单位换算,不合格的页自动标记为"待人工复核"。
这套链路不看具体供应商的接口差异,每一层都只依赖 OpenRouter 的统一协议,所以换个模型只是改一行配置的事。下一步我直接贴出这套链路的核心代码。
4. 实战:搭一个"合同要素抽取"小工具
4.1 选型:为什么这样选
项目名我随便起了个"合同要素抽取工具",任务定义是:输入一份 PDF 合同,输出甲方、乙方、合同金额、付款方式、违约金比例这几个字段的 JSON。
技术选型上我做了三个决定。一是 Python +openaiSDK,因为团队对 OpenAI 协议最熟,而且 OpenRouter 兼容这个协议,不需要引入自定义 SDK。二是图片渲染用 PyMuPDF,它比pdf2image少一层poppler的系统依赖,安装和部署都省心。三是视觉模型通过 OpenRouter 网关调,保证我在代码里不写死某个供应商的私有参数。
4.2 环境准备与鉴权配置
装依赖:
pip install openai pymupdf然后在代码里初始化客户端:
import os from openai import OpenAI client = OpenAI( api_key=os.environ.get("OPENROUTER_API_KEY"), base_url="https://openrouter.ai/api/v1", )强调一下:API Key 不要硬编码在代码里。从环境变量读取可能只是多打几个字,但能省掉后续一堆"谁把 Key 提交到仓库里"的社交尴尬。顺带一提,OpenRouter 的发现在模型列表页可以勾选"视觉"标签筛选支持图片输入的模型,标识符格式见 2.2 节。
4.3 把 PDF 转成多模态输入
核心是把每一页渲染成 PNG,并转成 base64。我一般控制在 150 DPI,再高清也不是不行,但是 token 成本和延迟都会涨。代码:
import base64 import fitz def pdf_page_to_base64(pdf_path, page_index, dpi=150): doc = fitz.open(pdf_path) page = doc.load_page(page_index) pix = page.get_pixmap(dpi=dpi) png_data = pix.tobytes("png") return base64.b64encode(png_data).decode() img_b64 = pdf_page_to_base64("sample_contract.pdf", 0)这一段代码我在很多项目里复制过,基本没踩过坑。唯一要提醒的是不要用一页生成一张超级大图再缩放,直接通过dpi参数控制输出分辨率是最省内存的。
4.4 核心代码:调用聚合网关的视觉接口
下一步组装消息体并调用 OpenRouter:
def extract_contract_fields(img_b64, page_label="第一页"): messages = [ { "role": "user", "content": [ { "type": "text", "text": ( "请从这份合同图片中抽取结构化字段。\n" "输出JSON,字段名为:甲方名称、乙方名称、合同金额、付款方式、违约金比例。\n" "规则:金额只取数字和币种,违约金比例输出数值百分比,没有出现的字段填null,不要猜测。\n" "示例:\n" '{"甲方名称":"某建设有限公司","乙方名称":"某材料供应部",' '"合同金额":1280000,"付款方式":"分三期","违约金比例":5}\n' "当前处理的页面是:" + page_label ), }, { "type": "image_url", "image_url": { "url": f"data:image/png;base64,{img_b64}" }, }, ], } ] response = client.chat.completions.create( model="provider-xxx/vision-14b", # 换成你在模型列表里看到的标识符 messages=messages, response_format={"type": "json_object"}, temperature=0.1, ) return response.choices[0].message.content这里有两个值得展开的细节。第一,response_format={"type": "json_object"}能强制大多数支持该参数的模型输出合法 JSON,比让模型自己"讲人话"再解析稳定得多。第二,temperature压到 0.1 是为了让抽取任务尽量可复现,不需要创造性波动。
4.5 运行效果与字段回填
单页跑完之后,返回的是一个 JSON 字符串,json.loads之后大概长这样:
{ "甲方名称": "某建设有限公司", "乙方名称": "某材料供应部", "合同金额": 1280000, "付款方式": "分三期", "违约金比例": 5, }多页合并不是简单合并即可。我的做法是:逐页抽取,存成page_results[i];最后把全部页码结果发给一个语言模型或字面合并,重字段取置信度最高的页(通常是谁第一次出现且格式完整的那个),金额字段要对比所有出现位置的值是否一致,不一致就标记为待人工复核。这一步听起来像凑活,但其实已经是很多自动化审阅工具的真实形态——机器先干 80% 的活,剩下的 20% 靠规则兜底和人工抽查。
5. 实测中的五次翻车与对应的补丁
5.1 图片 base64 导致请求体过大
第一次跑真实合同时,我直接按整页高清 PNG 发给模型,结果发现接口一直报请求体过大。排查之后才想起来,一张 300 DPI 的 A4 页面 PNG,base64 之后经常超过 10MB,而统一网关和很多上游供应商的单请求体上限通常只有 20MB 左右。你一发就是好几页串行加在一起,超限是必然的。
补丁方案有三个:一是把 DPI 降到 150,肉眼可读性完全够,请求体立刻缩到 2~3MB;二是用有损 JPEG 代替 PNG,体积还能再降一个量级,代价是表格细线可能模糊;三是控制并发时不要一次性把多页 base64 放进同一个消息数组,逐页请求。我现在默认就是用 150 DPI PNG 逐页处理,稳定没再翻车。
5.2 文档多页时上下文窗口爆炸
有一份 40 页的项目协议,我想省事点,一次性把 40 页图片都塞进 messages,结果直接触达上下文窗口上限,请求失败。教训是我在前面 3.2 节写的那个原理——上下文窗口是所有输入 token 加输出的总和,一张图按 1300 个视觉 token 算,40 张就是 5 万多个视觉 token,加上文本 prompt,大型模型的窗口也快满了。
修法是改成滑窗式抽取:每页单独跑一遍视觉模型,输出只保留字段 JSON;然后只把每页的 JSON 汇总文本(大概几百 token)发给第二阶段模型做合并。这样第一阶段永远只处理单页,第二阶段永远只处理紧凑文本,上下文压力从"页数 × 图片 token"变成"页数 + 少量文字",账一下子算得过来了。
5.3 JSON 输出不稳定:用 response_format 锁结构
在不加任何格式约束时,模型偶尔会在 JSON 外面裹一层 markdown 代码块,偶尔还会在字段名里加个奇怪的引号或者多出一个"、"。json.loads一失败,整条任务就断了。
后来我做了两件事。第一,在请求里带response_format={"type": "json_object"},OpenRouter 会把下游模型的输出尽量规范成纯 JSON。第二,在解析层写了一个软化函数:如果json.loads失败,先把字符串里被 markdown 包裹的部分剥掉,再尝试一次。如果还失败,就标记本页为解析失败,不阻塞整份文档。流程设计上永远要允许单个页面失败,而不是让一张坏图毁掉整个任务。
5.4 供应商限流与随机失败:重试与回退
中间有一次,网关首选的那个模型服务商在高峰期频繁 429,我整批任务跑着跑着就断掉。OpenRouter 天然支持故障转移——我在model之外设置了fallback_models,首选失败时自动切到备用模型,这个我在 2.2 节的示例里写过。
但光有 fallback 还不够,我还在代码里加了指数退避重试:遇到 429 或 5xx,等待 1 秒、2 秒、4 秒重试,最多三次。成熟的 HTTP 客户端库一般自带这个功能,比如httpx的transport.retries,或者自己写一个装饰器。配合 fallback 之后,整条管线的可用性从"单供应商的可用性"上升到"一堆供应商里至少一个有货"的高度,实测跑几百页文档没再中断。
5.5 幻觉与字段缺失:用规则校验兜底
最大的坑永远出在幻觉上。有一次一份合同中完全没有约定违约金比例,模型却自信地输出了 3.5%。这个值连参考文献都找不到,完全编出来的。从那之后我就在流水线后端加了一层本地校验:
import re from datetime import datetime def validate_fields(data): issues = [] if data.get("合同金额") is not None and data["合同金额"] <= 0: issues.append("合同金额不能为负数或零") if data.get("违约金比例") is not None: if not (0 < data["违约金比例"] < 100): issues.append("违约金比例超出合理范围") if data.get("甲方名称") == data.get("乙方名称"): issues.append("甲方乙方名称相同,需人工复核") return issues这种校验不依赖模型,纯粹是业务规则,非常稳定。凡是触发规则的字段,我都自动标成"reviewed=False",推给人工作台。永远记住一个原则:模型输出的不是事实,是一种合理的推测;规则校验是最后一道把推测变成可交付结果的门卫。
6. 成本控制与模型选择策略
6.1 按任务复杂度分档:别让旗舰模型干杂活
接入统一网关之后,最大的成本陷阱是"因为切换方便,所以全部都上旗舰模型"。但实际上文档理解任务里,有大量工作是文本分类、重复抽取、字段合并这种低难度任务。
我现在的做法是给每个子任务设置一个档位:
| 子任务 | 模型档位 | 为什么 |
|---|---|---|
| 页面方向检测、空白剔除 | 规则或入门小模型 | 不需要理解语义 |
| 单页字段抽取 | 中级视觉模型 | 需要视觉 + 结构化输出 |
| 跨页字段合并、矛盾修正 | 高级语言模型 | 需要长上下文推理 |
| 最终格式化和校验 | 本地代码 | 完全不需要模型 |
这样下来,只有"单页抽取"和"跨页合并"两步真正付出模型费用,其他环节要么是本地规则,要么是小模型的几毫 token 开销。同样一份 30 页合同,成本可以控制在无脑全用旗舰模型方案的三分之一到五分之一。
6.2 预算保护:上限、配额、缓存
OpenRouter 统一账户可以设置额度上限,我强烈建议在开始批量任务之前就把它调成项目预算的 120% 左右。哪怕代码逻辑写得再严谨,也保不齐某个循环条件写错导致几万次请求出去——这种事故我在别人那里见过不止一次。
另外要充分利用缓存。文本类的同 Prompt 重复请求,如果能用网关侧缓存或自己做一层内容去重,省下来的成本非常可观。文档理解任务里,同一份合同被重复提交的场景其实很多:业务员上传一次、审阅复核再传一次、驳回后重新签发起一次。在管线上游做文件哈希(比如 SHA-256),重复文件直接返回上次抽取结果,既省时间又省钱。
6.3 最终管线长什么样
把前面所有内容合起来,一套跑得通的完整管线是这样的:
- 上传 PDF → 计算 SHA-256,查缓存,命中直接返回。
- 未命中 → PyMuPDF 按页渲染 150 DPI PNG。
- 单页视觉抽取 → OpenRouter 统一接口,视觉模型,JSON 输出,失败自动 fallback 到备用模型。
- 跨页合并 → 语言模型读取每页 JSON,处理冲突。
- 本地规则校验 → 标记待复核字段。
- 写回缓存,返回结构化结果。
我现在所有文档类项目都优先按这个框架搭,OpenRouter 只充当最前面那个"一入口多出口"的调度层。换模型、加新供应商、做对比实验,都只是改配置的事。
最后再分享一个小技巧。如果你在跑长文档批处理,一定要把stream=True打开,配合逐页回调输出进度。这样一方面用户能看到进度条,心里有底;另一方面流式模式下,单条请求的响应是边生成边返回的,遇到网络抖动时,已经收到的内容不会丢。从用户体验到工程健壮性都有实打实的改善。做这套管线反复调优之后,我最大的体会是:工具的意义不在于让你多了一种调用方式,而在于把"切换模型"这件事从人力成本变成一行配置,让你能把精力重新放回文档本身——毕竟问题从来不是在哪个模型更聪明,而是你愿不愿意把整个流程拆到足够细,让每个环节都有清晰的退出条件。