OpenRouter聚合网关实战:统一大模型API,高效实现文档理解与合同抽取

发布时间:2026/10/17 4:41:46
OpenRouter聚合网关实战:统一大模型API,高效实现文档理解与合同抽取
做文档类项目最烦的往往不是文档本身而是模型接口。上个月我在搭一套合同审阅管线时光是切换不同供应商的模型就折腾了整整两天A 平台的模型要单独注册拿 keyB 平台的响应结构和 C 完全不一样D 平台的文档里写的还是过期参数。想对比一下哪个模型抽取字段最稳光是适配代码就写了几百行。后来把所有请求收敛到一个聚合层才真正体会到自由使用大模型的舒服——这种自由不是网络意义上的而是选择模型、切换供应商、调配预算的自由。OpenRouter 就是这类聚合网关里用得比较顺手的那个尤其适合文档理解这种需要反复试模型的任务合同要素抽取、发票结构化、表格还原、长文问答都能在一个统一接口里完成。这篇文章就围绕 OpenRouter 讲透三件事它解决了什么问题、文档理解到底难在哪、以及一个可以直接抄的合同抽取工具怎么实现。1. 多模型时代的 API 碎片化为什么我们需要一个统一网关1.1 碎片化带来的真实成本注册、计费、接口格式、限流现在的模型生态和两三年前完全不同。早年基本只有一个选择照着官方文档把 SDK 拉下来直接用就行。现在几乎每周都有新模型发布各有各的强项有的擅长长文本有的对视觉表格敏感有的数学推理特别稳。表面上看选择多了是好事但落到工程上就变成灾难。首先是注册和鉴权。每家供应商一套账号体系一个独立 API Key有的还要额外设置安全策略、白名单、支付宝扣款绑定。一次接入两个模型等于把整个考证流程重复两遍。其次是接口格式。虽然大家都在往 OpenAI 兼容协议靠拢但细节差异多得吓人有的要求消息里必须带max_tokens有的把参数叫max_completion_tokens有的不支持temperature0有的对图片输入有自己的 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_keysk-or-这里填你自己的Key, base_urlhttps://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( modelprovider-xxx/vision-14b, # 首选 fallback_models[provider-yyy/doc-8b], # 备选主模型挂了或限流时用 messagesmessages, )在文本生成场景中还可以用价格排序的策略网关在满足你设置的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_keyos.environ.get(OPENROUTER_API_KEY), base_urlhttps://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, dpi150): doc fitz.open(pdf_path) page doc.load_page(page_index) pix page.get_pixmap(dpidpi) png_data pix.tobytes(png) return base64.b64encode(png_data).decode() img_b64 pdf_page_to_base64(sample_contract.pdf, 0)这一段代码我在很多项目里复制过基本没踩过坑。唯一要提醒的是不要用一页生成一张超级大图再缩放直接通过dpi参数控制输出分辨率是最省内存的。4.4 核心代码调用聚合网关的视觉接口下一步组装消息体并调用 OpenRouterdef 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: fdata:image/png;base64,{img_b64} }, }, ], } ] response client.chat.completions.create( modelprovider-xxx/vision-14b, # 换成你在模型列表里看到的标识符 messagesmessages, response_format{type: json_object}, temperature0.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 页面 PNGbase64 之后经常超过 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这种校验不依赖模型纯粹是业务规则非常稳定。凡是触发规则的字段我都自动标成reviewedFalse推给人工作台。永远记住一个原则模型输出的不是事实是一种合理的推测规则校验是最后一道把推测变成可交付结果的门卫。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 只充当最前面那个一入口多出口的调度层。换模型、加新供应商、做对比实验都只是改配置的事。最后再分享一个小技巧。如果你在跑长文档批处理一定要把streamTrue打开配合逐页回调输出进度。这样一方面用户能看到进度条心里有底另一方面流式模式下单条请求的响应是边生成边返回的遇到网络抖动时已经收到的内容不会丢。从用户体验到工程健壮性都有实打实的改善。做这套管线反复调优之后我最大的体会是工具的意义不在于让你多了一种调用方式而在于把切换模型这件事从人力成本变成一行配置让你能把精力重新放回文档本身——毕竟问题从来不是在哪个模型更聪明而是你愿不愿意把整个流程拆到足够细让每个环节都有清晰的退出条件。