这个 App 是我一个人做的,端侧 OCR 加 0.6B 的小模型,把支付截图变成账目。
先说一个我觉得最反直觉的地方。
这 60 倍里,没有一倍是花在模型上的。
结果
| 轮次 | 手段 | 单张耗时 |
|---|---|---|
| 基线 | llama.cpp,Q8_0,ARM 默认编译 | 596 秒 |
| 一 | Q4_K_M 加 NEON dotprod,关 OpenMP | 208 秒 |
| 二 | 后端换 LiteRT-LM,dynamic INT8 | 41.9 秒 |
| 三 | 切 OpenCL GPU,补采样器 | 17.4 秒 |
| 四 | 输出契约砍到 4 字段,引擎常驻 | 8.4 到 11 秒 |
质量闸门全程 30/30 通过,金额识别 96.7%,日期 100%。
596 秒是什么感觉,用户拍完照泡了杯茶回来,还在转圈。
这不是慢一点,是根本不能用。所以我后面所有的工作,都不是性能调优,是让这个东西能存在。
为什么非要端侧
自动记账这个需求早就被验证了。
钱迹一开始公开说因为隐私不做截图识别,后来被用户催着还是做了,2025 年底陆续上线。
同类的做法无非两条路。
一条是无障碍服务实时监听屏幕,识别能力最强,但等于把用户手机上的一切交给一个第三方进程。
另一条是云 OCR,实现最简单,但截图必须上传。
支付截图里有什么,有余额,有商户,有订单号,有时间,有时候还有卡号后四位。
我选的是第三条,整条链路留在设备上,不监听屏幕,不授权支付账号,不上传图片。
这个决定做完,技术路线就被锁死了,没有退路。
为什么最后没用视觉大模型
一开始当然是直接上 VLM,截图进去,JSON 出来。
我先在桌面用 Qwen3-VL-4B 跑了一张 1080×2400 的真机截图,Q4_K_M 量化,2.8GB。
vision prefill 单张 3 到 5 分钟,跑在一台 10 核的 CPU 上。
解码那一段在约束下只要 100 来个 token,占比很小,瓶颈全在 vision。
关键是 vision token 的数量是跟着像素面积涨的,这是个结构性成本,不会自己变好。
手机 SoC 的持续多核吞吐大概是那台桌面的五分之一到十分之一,推算下来单张 15 到 40 分钟。
降分辨率、GPU offload 都能改善,但都有不确定性,而且 2.8GB 的体积本身就是存储和加载的负担。
所以就换成了两段,先 OCR 出文本行,再让 0.6B 的文本模型把文本行转成 JSON。
对了,这里有个只有落地才会知道的细节。
ADR 里我写的是 PP-OCRv5,实际落地用的是 v4。
原因是桌面质量闸门跑的就是 RapidOCR 内嵌的 v4 三件套,模型层面零偏移,在调试阶段这个价值远大于版本号好看。
字典也不用带文件,中文 6623 个字直接内嵌在 rec ONNX 的 metadata character 键里,运行时读出来就行。
一个反直觉的选型:0.6B 打赢 1.7B
| 模型 | 硬字段准确率 | 全量耗时 |
|---|---|---|
| Qwen3-0.6B Q8_0 | 100% | 5 分 0 秒 |
| Qwen3-1.7B Q8_0 | 96.7%(金额错 1 例) | 17 分 0 秒 |
同一份数据集,30 张真实订单截图,同一套 harness。
更大的模型更慢,而且更差。
三倍体积,三倍多时间,还多错一笔。
个人觉得,端侧做"文本转 JSON"这种纯结构化任务,0.5B 到 2B 加语法约束就够了。模型再大,能力溢出到用不上的地方,付出的却是实打实的延迟和内存。
所以模型选型必须跟着闸门数据走,不能跟着"参数量越大越好"的直觉走。
四轮优化,每轮的瓶颈都不一样
第一轮:596 秒到 208 秒,改的是两个环境变量
这一跳踩的是编译参数,不是算法。
llama.cpp 的 ARM 分支默认是不加 NEON flags 的,交叉编译下 GGML_NATIVE 也不生效。
也就是说,你按文档老老实实编出来的 so,跑的是这颗芯片最保守的指令集。
必须显式指定:
bash
GGML_CPU_ARM_ARCH=armv8.2-a+dotprod
同时关掉 OpenMP:
bash
GGML_OPENMP=OFF
因为 NDK 27 自带 libomp,find_package(OpenMP) 会意外成功,然后真机在 prefill 段的 mul_mat memcpy 上 SIGSEGV,那种偶发的、难定位的崩溃。
顺手把 Q8_0 换成 Q4_K_M,397MB,内存峰值从 1.32GB 降到 1.06GB。
这里得说清楚一点,dotprod 主要加速的是 prefill,371 秒降到 123 秒,decode 还在 0.5 到 1.0 tok/s。
而且热节流影响很大,同一个配置在 208 秒到 389 秒之间波动。
最终我取的口径是连续第二次运行、cache 热、没有后台负载。这个必须写清楚,不然别人复现不出来会以为我在吹。
还有一个纯体力活,Q8_0 的 GGUF 没法在本地 requantize 到 Q4_K_M,得去 unsloth 的源仓库下载,国内还得从 ModelScope 镜像拉,98KB/s 也能用,就是慢。
第二轮:208 秒到 41.9 秒,换后端
从 llama.cpp 换到 LiteRT-LM,CPU dynamic INT8,五倍。
这里有个我自己的判断失误。
最初选 llama.cpp 的理由之一,是我以为 LiteRT-LM 的约束解码只在 C++ 层暴露,Kotlin 用不了。这个判断还写进了 ADR。
后来实测发现 Kotlin 也能用,enableResponseFormat=true 加一个 JSON Schema 就行,是我当时没查到底。
绕了一圈还是回到它,代价是要写一层编译器,把 GBNF 文法转译成 LLGuidance 用的 JSON Schema。
转译不完美,有个 quirk,enum 值会双重编码,输出长这样:
swift
"currency": "\"CNY\""
第一版我是在出口用正则把多出来的引号剥掉,治标。
真正根因后来才定位,stripGbnfQuotes 只剥了一层引号,schema 里的枚举值一直带着双层引号。
把这个剥壳逻辑上移到引擎层、改成迭代剥,才算彻底解决。
这个 bug 的可见症状挺滑稽的,没有日期的截图会固定失败。
不是模型的问题,是枚举值里多了一层引号,拼出来的 JSON 就是非法的。
第三轮:41.9 秒到 17.4 秒,开 GPU
这一跳只改了两行相关配置,但找这两行花了两天,因为两个坑都是静默失败,不报错,只是慢。
轮末实测,加载 3.8 到 4.1 秒,OCR 1.3 秒,LLM 15.7 到 16.8 秒。
第四轮:17.4 秒到 10 秒,让它少说话
到这一轮,瓶颈只剩一个,decode 大概 6 tok/s。
全程 GPU 67 度,已经在热节流了。
既然解码是唯一瓶颈,那最快的优化就是让模型少生成。
我把输出契约从 7 字段砍到 4 字段。
模型只输出用户确认时真正要看的 merchant、amountPaid、datePaid、category。
currency=CNY、dateSource=PAYMENT_TIME 这些改由引擎默认值承接。
日期只到天,grammar 里把时分秒的规则直接删掉。
输出 token 从 95 降到 55,LLM 段 16 秒变成 8 到 10 秒。
第二个动作更土,队列引擎常驻。
原来每张图都新建再销毁 transport,白白付 4 秒加载,现在批内复用,第 2 张起零加载。
实测三张批,加载 4.7 秒只算首张,OCR 1.1 到 1.3 秒,LLM 7.3 到 9.7 秒,稳态 8.4 到 11 秒一张。
个人觉得,优化到最后做的不是让模型更快,是让它少干点活。
三个坑,都是静默失败
坑一:<uses-native-library> 漏一行,会让你误判成"厂商封闭"。
GPU 跑不起来,第一反应是查机型支持,日志看起来就像这台机器不支持 OpenCL,我一度在笔记里写下"vivo 封闭"。
真相是 API 24 起 Android 有 linker namespace 隔离,非 NDK 公共库的 native 库默认不可访问。
必须在 manifest 里显式声明:
xml
<uses-native-library android:name="libOpenCL.so" android:required="false" />
漏了这行,链接器静默拒绝,运行时优雅回退到 CPU,没有任何错误,只有慢。
这个坑的教训比坑本身重要,"不支持"和"没声明"在日志里长得一模一样。
还有个更蠢的。
我一度写成 <uses-native-library android:name="libOpenCL-pixel.so" required="false"/>。
required 前面漏了 android: 前缀,被当成未知属性取了默认值 true,安装直接失败,报 INSTALL_FAILED_MISSING_SHARED_LIBRARY。
而且那个 Pixel 专用库本来就不需要。
坑二:AAR 少打一个 so,输出稳定截断在 145 字符。
开 GPU 之后速度上去了,但输出开始被截断在 145 字符左右。
看着像 token 上限,像 prompt 问题,其实都不是。
真实链条是这样。
官方 AAR 没有打包 libLiteRtTopKOpenClSampler.so,缺了 GPU 采样器运行时回退到 CPU 采样器,而 CPU 采样器和 LLGuidance 的约束解码不兼容,约束被提前放弃,输出就截止了。
修复很土,从 LiteRT-LM prebuilt 的 v0.17.0 tag 里把那个 so 取出来放进 jniLibs。那个 so 有 11.8MB,用 NDK 的 llvm-strip 压到 5.0MB 再打包。
验证方式更土但更可靠,GPU 采样器和 CPU 采样器的输出逐字节对比,2/2 等价就算过。
而且两次运行的输出字节级完全一致,说明不是随机截断,是确定性的约束失效。
坑三:INT4 是死路,而且是两条独立的死法。
4bit 看起来应该是下一个大杀器,wi4b32 只有 328MB,LLM 段只要 5.5 秒,INT8 的三分之一。
但两档 INT4 全部失败,失败方式还不一样。
wi4b32 速度惊人,但所有后端输出固定截断,上游同版本同现象。
mixed_int4 是另一条完全独立的转换管线,475MB,桌面闸门 30/30 全挂,报:
vbnet
Parser Error: token "Ġ" doesn't satisfy the grammar
模型提交了语法非法 token,LLGuidance 的掩码失效了。
同一 schema、同一分词器,INT8 是 30/30 通过。
两条独立管线死法相同,所以病灶在 runtime 0.17.0 的掩码,不只是 quantizer 的 scale 损坏。
而 GitHub 上最新的 release 就是 0.17.0,没有升级路径。
就算修好,收益也存疑,官方基准里 mixed_int4 在 GPU 上 decode 22.3 tok/s,跟 INT8 的 21 差不多。wi4b32 那个三倍来自 GPU 图优化,正好被语法机挡死。
所以判定死路,留观。重启条件只有一条,上游发新 runtime。
闸门:为什么 prompt 不能随便瘦身
优化到 17 秒之后,最自然的下一步就是把 prompt 再压一压。
我试了,被闸门否决。
字段说明从 8 行压到 1 行,prompt 从 410 降到 255 token,端到端确实降到 169 秒。
然后 30 张复验抓到了 order_12。
金额标注 ¥14.5,提取成了 ¥10.0。
商户"如意馄饨"变成了"绿宝广"。
硬字段准确率从 100% 掉到 96.7%。
被删掉的那句是,"取『实付款/实付』后紧跟的数字,不要商品单价"。
这句话在多金额的截图上是有实际作用的。
所以 prompt 里那些看似冗长的口径反例,是质量资产,不是噪音。
从此我定了个规矩,动 prompt 必须跑闸门,30 张全过才算数。
这条规矩后来拦下了很多"我觉得这样更好"的改动。
小模型会被界面带偏
0.6B 模型的真实失效模式,不是看不懂,是看错了地方。
事故一,¥8.00 被抄成 184。
原因是状态栏右侧一小块文字被 OCR 合并误读了。
网速「18.4 KB/s」丢了小数点读成 184,信号「5G 5G」和电量「67」并读成 5667,最后成了一行「184 5667」。
这行置信度只有 0.79,但它进了金额候选池,取首位数字段就是 184。
更麻烦的是,这个 bug 在桌面复现不了。
同一张图,桌面读成连写的「1845667」,七位纯整数,正好被既有的「超过六位就剪掉」规则拦住。真机读成带空格的两段,绕过去了。
修的时候要两道过滤,一道识别裸整数碎片,一道识别数字加量词的计数行。
还有一层保险。碎片出局之后,锚定兜底要能接住模型剩下的抄错,把 800 换成唯一带货币符号的 8.0。
这两层是分开失效的,只修一层还是错。
事故二,饿了么订单页进度条上的「100/100元」被当成实付款。
这一行没有货币符号,没有促销词,打分是 0,照样进了候选清单,被 0.6B 抄走了。
修复是按形态踢掉「数字/数字」的行。
这里有个我觉得很值得记的结论:prompt 里的排序提示只是软引导,小模型在长页面下不守规矩。凡是不可信的地方,必须用确定性规则兜底。
事故三,一图两单,商户被顶部的标签栏带偏。
真实情况比我一开始以为的复杂,是三层一起吃出来的错。
第一层,首块的起点是图的顶部,于是状态栏、搜索行、标签栏(全部订单 | 购物 | 闪购外卖 | 飞猪旅行)、筛选 chips 全被并进了第一块。
第二层,商户匹配是对块内全部行做包含扫描,「飞猪旅行」里含「飞猪」,命中。
第三层,同长度命中是先到先得,种子表里「飞猪」排在用户学过的「闪购」前面,于是飞猪赢了。
修完之后,两张单的商户从 [飞猪, 闪购] 变成 [闪购, 闪购]。
所以端侧小模型的准确率,一半靠模型,一半靠喂给它的那八行字。
OCR 给你的是全屏所有文字,包括状态栏、广告、按钮,把它们筛干净,模型才有机会对。
顺带做了个规则快路径。
如果唯一实付强锚、唯一日期、商户命中品牌库这三件事同时成立,就毫秒级出草稿,根本不调模型。
只有模糊的才降级到 LLM,比如多商户、多金额、一图多单。
这一条让闸门 30 张的总耗时从 7 分 07 秒降到 2 分 44 秒。
瓶颈最后在哪
10 秒之后,硬瓶颈还是 decode,大概 6 tok/s。
这台机器是天玑 9000+ 加 Mali-G710。
官方基准那个 21 tok/s 是 vivo X300 Pro,天玑 9400,差一代。
加上实测时 GPU 已经 67 度,在热节流。
NPU 也不通,专版只支持天玑 9400 以上。
剩下的空间,是等上游 runtime 升级,等 INT4 修复,还有 OCR 小字识别。
闪购列表卡上那个"07.28"的小角标,OCR 经常乱码或者漏识,现在靠"没日期就自动填导入当天"兜底。
10 秒就是这台硬件上的水位,我觉得这个数字挺好,比"我们做了大量优化"诚实。
最后
做完这四轮,我最大的感受不是"端侧很厉害"。
是你以为的瓶颈,和你实际撞上的瓶颈,经常不是一个东西。
我一开始以为难点是模型够不够小,跑不跑得动。
结果 596 秒里,一个字节都不是模型的错,全在构建参数、linker namespace、采样器打包、输出契约这些地方。
所以如果有人问我,手机能不能跑得动大模型。
我可能会反问一句,在你的场景里,把数据传出去这件事,到底能不能接受。
不能接受,那就没得选,剩下的全是工程问题,一个个抠就完了。
这个 App 是 MIT 开源的,四份 ADR、完整的耗时文档、30 张回归集的闸门机制都在里面。
如果你也在做端侧 AI 落地,或者你的团队正卡在"这套代码怎么用上 AI"这件事上,欢迎聊两句。
我把踩过的坑摊开讲,比你自己踩一遍便宜。
实测环境:vivo V2183A,天玑 9000+ 加 Mali-G710,Qwen3-0.6B,LiteRT-LM 0.17.0,dynamic INT8,OpenCL GPU,6 线程,PP-OCRv4 mobile 加 ONNX Runtime Android 1.20.0。
数据口径:单张截图端到端耗时,含模型加载(队列常驻后只算首张)、OCR、后处理、LLM 推理。30 张回归集是真实支付截图,硬字段 ≥95% 为通过线。