ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

发票处理选多模态视觉还是文本解析?TaoToken 统一 API 通道下的 LLM 策略基准测试

发票处理选多模态视觉还是文本解析?TaoToken 统一 API 通道下的 LLM 策略基准测试 1. 发票字段提取到底该走视觉还是走文本发票处理这个场景做过批量字段提取的开发者应该都有体会一张发票进来你要的是发票号、开票日期、金额、税额、买卖双方名称、行项目明细这些结构化字段。传统做法是 OCR 加模板模板一多维护成本就上来了版式一变就得重新调。这两年多模态大模型起来了很多人第一反应是「直接把发票图片丢给模型让它输出 JSON」另一派则坚持「先用文档解析工具把图片转成 Markdown再让纯文本模型抽字段」。这两条路线到底哪条更稳我在自己的批量发票流水线上把两条路都跑了一遍结论和一篇基准测试论文的结论基本一致本地图像处理多模态视觉直读在大多数真实票据上明显优于先转 Markdown 再解析的结构化路线。论文里扫描收据数据集上视觉直读最高到 87.46%而结构化解析最高只有 47.00%扫描发票上视觉 92.71%解析 64.03%。差距不是几个点是几十个点。但这里有个前提你得能方便地同时接入两类模型不然光切换供应商、管理多套 Key 就够折腾了。这篇就围绕「用 TaoToken 统一 API 通道把多模态视觉和文本解析两条策略放在同一套代码里做基准对比」来写交付可复制的 config.toml 和 settings.json 骨架、对比测试脚本以及字段准确率的验证动作。适合需要批量提取发票字段、正在纠结选哪条技术路线的开发者。2. 为什么用 TaoToken 做统一通道做基准对比最怕的就是变量不干净。如果视觉模型走 A 家、文本模型走 B 家那最后准确率差异里混进了供应商差异、SDK 差异、鉴权差异根本说不清是策略的功劳还是平台的功劳。所以我需要一个统一入口同一套 Key、同一套 API 协议、同一套调用方式只换模型名和输入形态。TaoToken 在这里的角色就是统一 API 通道。它提供 OpenAI 兼容的接口形态多模态视觉模型和文本模型都能通过同一个 base_url 调用鉴权用同一个 Key。这样我的对比脚本里唯一变化的变量就是「传图片」还是「传 Markdown 文本」其余全部锁死。具体来说接入信息是这样官网入口https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_contentAPI 基址https://taotoken.net/api 注意这个不带 UTM 参数直接作为 base_url 用模型对话调试页https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewriteAPI Keys 管理https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite接入文档https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite注意base_url 用https://taotoken.net/api不要在后面拼/v1之外的路径OpenAI 兼容客户端会自动补/chat/completions。如果你用的是官方 OpenAI SDK把base_url设成这个值即可。先把 Key 拿到手后面所有脚本都靠它。进 API Keys 页面创建一个复制出来存到环境变量里别硬编码进代码。export TAOTOKEN_API_KEYsk-你的key3. 可复制的配置骨架我习惯把配置拆成两层config.toml放模型清单和策略开关settings.json放运行时参数超时、重试、并发、字段定义。这样换模型不用改代码调参不用动配置结构。3.1 config.toml# config.toml —— 模型与策略清单 [api] base_url https://taotoken.net/api api_key_env TAOTOKEN_API_KEY timeout_seconds 120 max_retries 3 # 策略一多模态视觉直读 [strategies.vision] name native_image input_type image_url models [ gpt-5-chat, gpt-5-mini, gemini-2.5-pro, gemini-2.5-flash, gemma-3-12b-it, ] # 策略二先转 Markdown 再文本解析 [strategies.text] name markdown_parse input_type text models [ gpt-5-chat, gpt-5-mini, gemini-2.5-pro, gemini-2.5-flash, gemma-3-12b-it, ] [dataset] image_dir ./invoices/images markdown_dir ./invoices/markdown ground_truth ./invoices/labels.jsonl3.2 settings.json{ run: { concurrency: 4, temperature: 0, max_tokens: 2048, save_raw_response: true, output_dir: ./results }, fields: [ invoice_number, invoice_date, due_date, vendor_name, buyer_name, subtotal, tax_amount, total_amount, iban ], normalization: { date_format: %Y-%m-%d, strip_whitespace: true, number_keep_separators: true }, prompt: { system: 你是一个发票字段抽取引擎。只输出 JSON不要解释。缺失字段填 null。, user_template: 从以下内容中抽取字段{fields}。内容如下\n{content} } }temperature设 0 是为了让对比可复现同一张发票跑两次结果应该一致。number_keep_separators保持 true 是因为金额里的千分位逗号和小数点对订单处理很关键归一化时不能随手抹掉——论文里也专门提到没对数值里的逗号和点做纠正。4. 两条策略的调用代码核心思路把「取内容」和「调模型」解耦。视觉策略取的是图片的 base64 或 URL文本策略取的是 Markdown 文件内容之后走同一个请求函数。4.1 统一请求函数import os, json, base64, time from openai import OpenAI client OpenAI( api_keyos.environ[TAOTOKEN_API_KEY], base_urlhttps://taotoken.net/api, ) def build_content(strategy: str, payload: str): if strategy vision: # payload 是图片路径 with open(payload, rb) as f: b64 base64.b64encode(f.read()).decode() return [{ type: image_url, image_url: {url: fdata:image/png;base64,{b64}} }] else: # payload 是 markdown 文本 return [{type: text, text: payload}] def extract_fields(model: str, strategy: str, payload: str, fields: list, system: str, user_tpl: str): content build_content(strategy, payload) user_text user_tpl.format(fields, .join(fields), content) messages [ {role: system, content: system}, {role: user, content: [{type: text, text: user_text}] content}, ] for attempt in range(3): try: resp client.chat.completions.create( modelmodel, messagesmessages, temperature0, max_tokens2048, ) return resp.choices[0].message.content except Exception as e: if attempt 2: raise time.sleep(2 ** attempt)4.2 文本策略的 Markdown 预处理文本策略的关键在预处理。我用文档解析工具把发票图片转成保留表格结构的 Markdown再喂给模型。这一步是整条链路的瓶颈——论文里说得很直白在干净发票数据集上大多数模型准确率挤在 84-85% 的窄区间说明限制因素不是大模型的推理能力而是前面的 OCR 和 Markdown 转换。def image_to_markdown(image_path: str) - str: # 这里调用你本地的文档解析工具输出保留表格的 markdown # 示例docling 或同类工具的调用封装 from docling.document_converter import DocumentConverter conv DocumentConverter() result conv.convert(image_path) return result.document.export_to_markdown()4.3 跑对比import json, itertools with open(config.toml, rb) as f: import tomllib cfg tomllib.load(f) settings json.load(open(settings.json)) fields settings[fields] system settings[prompt][system] user_tpl settings[prompt][user_template] labels [json.loads(l) for l in open(cfg[dataset][ground_truth])] results [] for strategy in [vision, text]: for model in cfg[strategies][strategy][models]: for item in labels: if strategy vision: payload f{cfg[dataset][image_dir]}/{item[file]} else: payload image_to_markdown( f{cfg[dataset][image_dir]}/{item[file]}) raw extract_fields(model, strategy, payload, fields, system, user_tpl) results.append({ strategy: strategy, model: model, file: item[file], raw: raw, gt: item[fields], }) json.dump(results, open(results/raw.json, w), ensure_asciiFalse, indent2)5. 验证请求与字段准确率跑完原始结果下一步是算准确率。这里要特别注意归一化规则日期统一格式、空格规范化但金额里的分隔符不动。字段完全匹配才算对。import re from datetime import datetime def normalize(field, value, rules): if value is None: return None v str(value).strip() if rules.get(strip_whitespace): v re.sub(r\s, , v) if field.endswith(_date) and v: for fmt in (%Y-%m-%d, %d/%m/%Y, %m/%d/%Y, %Y/%m/%d): try: v datetime.strptime(v, fmt).strftime(rules[date_format]) break except ValueError: continue return v def score(results, rules): from collections import defaultdict stat defaultdict(lambda: {correct: 0, total: 0}) for r in results: try: pred json.loads(r[raw]) except json.JSONDecodeError: pred {} for f in r[gt]: key (r[strategy], r[model], f) stat[key][total] 1 pv normalize(f, pred.get(f), rules) gv normalize(f, r[gt][f], rules) if pv gv: stat[key][correct] 1 return stat stat score(results, settings[normalization]) for (strategy, model, field), s in sorted(stat.items()): acc s[correct] / s[total] if s[total] else 0 print(f{strategy:8s} {model:20s} {field:16s} {acc:.2%})跑完之后你会看到一张按策略、模型、字段三个维度切开的准确率表。我实测下来几个规律第一视觉策略在扫描件上优势最大。扫描收据、带邮戳和手写批注的发票先转 Markdown 会丢布局信息表格错位、字段串行模型再强也救不回来。第二干净的数字发票上两条策略差距缩小。论文里干净发票数据集上视觉和解析都能到 84-96%这时候选哪条更多看成本和吞吐。第三IBAN 这类非结构化字母数字字段是重灾区。常见错误是把数字 0 和字母 O、U 混淆视觉和文本策略都会踩但视觉策略因为能看到原始字形错得少一些。第四小模型在视觉任务上有能力阈值。gemma-3-4b-it 在干净发票上视觉直读只有 45.69%说明模型太小的时候视觉理解能力还没起来这时候反而不如走文本解析。6. 本篇常见错排查报错一401 Unauthorized。九成是 Key 没读到。检查TAOTOKEN_API_KEY环境变量是否在当前 shell 生效echo $TAOTOKEN_API_KEY确认一下。如果是 CI 环境注意 secret 注入的时机。报错二base_url 拼错导致 404。常见错误是写成https://taotoken.net/api/v1或漏了/api。正确值是https://taotoken.net/apiOpenAI SDK 会自己补路径。如果用的是 requests 手搓请求完整地址是https://taotoken.net/api/chat/completions。报错三图片 base64 太大被拒。扫描件分辨率高的时候 base64 字符串能到几 MB。建议先压缩到长边 2000px 以内或者用图片 URL 而不是 base64。压缩后视觉准确率基本不掉但请求体积小很多。报错四模型返回带 Markdown 代码块的 JSON。有些模型会把 JSON 包在json里。解析前先剥一层def clean_json(raw: str) - str: raw raw.strip() if raw.startswith(): raw re.sub(r^(?:json)?\s*, , raw) raw re.sub(r\s*$, , raw) return raw报错五文本策略准确率异常低。先别怀疑模型去检查 Markdown 转换结果。打开转换后的文件看表格有没有错位、字段有没有串行。论文里那个 47% 的上限就是转换瓶颈造成的不是模型不行。报错六并发跑满被限流。settings.json里 concurrency 设 4 起步观察 429 响应再调。TaoToken 通道下不同模型限流策略可能不同视觉模型因为请求体大建议并发比文本模型低一档。报错七日期字段全错。检查归一化里的日期格式列表是否覆盖了你数据集的格式。欧洲发票常见%d/%m/%Y美国常见%m/%d/%Y两者不加区分会互相误判。7. 选型建议与下一步把上面的脚本跑完你手里就有了一份属于自己业务数据的对比表。基于我的实测和论文结论给几条选型建议批量处理扫描件、版式杂、质量参差——优先视觉直读模型从 gemini-2.5-pro 或 gpt-5-chat 起步预算紧可以试 gemma-3-12b-it。处理电子发票、版式规整、吞吐要求高——两条路差距不大文本解析的 token 成本通常更低适合大规模跑量。字段里有 IBAN、税号这类字母数字混合标识——视觉策略更稳但别指望 100%关键字段建议加一道校验规则比如 IBAN 的 mod-97 校验。想长期做编码和 Agent 集成——可以看 Coding Plan 页面把发票抽取封装成可复用的工具函数https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite想先在网页上手动试几个模型对同一张发票的抽取效果——用模型对话页快速验证https://taotoken.net/models?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite接入细节和参数说明都在文档里https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite最后提醒一句基准测试的价值在于用你自己的数据跑。论文里的数据集是开源的可能已经被用作训练数据真实业务数据上的表现才是最终依据。把config.toml里的模型清单换成你实际能用的把labels.jsonl换成你标注好的样本跑一遍答案自然就出来了。
返回列表