
我本來以為只要把 KV cache 的最後幾格砍掉重來就好,結果 RKLLM 回我這一行:
E rkllm: Start_pos and end_pos are only valid when keep_history == 0 and the generation has been paused by returning 1 in the callback!
rkllm_clear_kv_cache 回傳 -1,靠北,我要的範圍回捲就這樣被擋在門外。偏偏 NVIDIA 的 LocateAnything-3B 整個解碼流程,每一步都得把「視窗」丟掉再重來。
這篇是我把這個模型搬到 RK3588 NPU 的完整紀錄:怎麼轉、卡在哪、怎麼繞過去,還有最重要的,轉完到底掉了多少精度。結論先講:轉換成功,在 Rock 5C 上能端到端跑,平均每張圖約 5.8 秒;但在我的 100 張 COCO 子集上,F1@0.5 從 GPU 的 0.774 掉到 0.606。成品我已經放到 Hugging Face ,授權限非商業研究,下面會講細節。
環境與前置條件
- 板子:Radxa Rock 5C(RK3588,8GB),RKNPU 驅動,librkllmrt.so1.3.0
- 轉檔機:x86 Linux,RTX 3090,Python 3.10;rkllm-toolkit1.3.0、rknn-toolkit22.5.0(兩個必須分開 venv)
- 板上相依:rknn-toolkit-lite22.5.0、numpy、Pillow、tokenizers
- 前置知識:知道 VLM 的基本結構,用過 RKLLM 或 RKNN 其中一個
- 記憶體:LLM 約 3.8GB + 視覺約 0.9GB,8GB 板子要先停掉其他吃 NPU 的服務
LocateAnything 到底快在哪
視覺定位模型通常把一個框寫成四個座標 token,一個一個吐。慢是一回事,更麻煩的是框的四個數字其實彼此相依,卻被硬拆成獨立的預測。NVIDIA 在 論文 裡的做法是 Parallel Box Decoding(PBD):把一個框當成一個單位,一次預測完。
模型本身是 MoonViT 視覺編碼器、一個 MLP 投影層,加上 Qwen2.5-3B-Instruct 當語言模型,見 NVIDIA 的模型卡 。官方 專案頁 給的數字是 H100 上 hybrid 模式 12.7 boxes/s,對比 Qwen3-VL 的 1.1 boxes/s,號稱超過 10 倍。注意這是 H100 的數字,跟 RK3588 沒有半毛關係。而且這是官方自報的數字:專案頁另有一處 ablation 寫 13.2 BPS,條件不同,我也沒看到 Qwen3-VL 是否在同一台機器上測的說明,所以只當參考,後面我會用自己的實測說話。

我花了點時間讀原始碼,PBD 的一步其實是這樣:
- 輸入尾端接上「重複的最後一個 token」加 5 個
<text_mask>,湊成 6 格視窗。 - 視窗內部用雙向 attention,視窗外只看前面的 KV cache。
- 6 個位置的 logits 剛好對應一個框:
<box> x1 y1 x2 y2 </box>,座標是 0 到 1000 的 token。 - 視窗的 KV 每一步都丟掉,只保留已接受 token 的 cache。
- 解碼出問題(格式不合法、座標候選太分歧)就退回逐 token,等遇到
</box>再切回來,這就是 hybrid 模式。
所以一個框只要一次 forward,不是六次。聽起來很美,直到你想把它塞進 RKLLM。
RK3588 能給什麼、不能給什麼
RK3588 的 NPU 標稱 6 TOPS,見 CNX Software 的規格整理 (這是二手來源,我沒找到官方 datasheet)。RKLLM 是 Rockchip 的 LLM 部署工具,官方 repo 列的支援平台(2026-11-02 查證)有 RK3588、RK3576、RK3562、RV1126B,模型清單裡有 Qwen 家族、Gemma、InternVL、Qwen-VL 等,我看了一遍,沒有 LocateAnything。CNX 當年的報導 介紹過這套 toolkit 的發布;官方 README 則說明 toolkit 是在電腦上做模型轉換、板子上用 runtime 的 C/C++ 介面部署,所以流程就是在 x86 上轉成 .rkllm,再放到板子上跑。
我把需要的功能對著 rkllm.h 逐項查:
| PBD 需要的能力 | RKLLM 有沒有 | 實際狀況 |
|---|---|---|
| 餵 token 與圖像 embedding | 有 | INPUT_TOKEN、INPUT_MULTIMODAL |
| 一次取多個位置的 logits | 有 | RKLLM_INFER_GET_LOGITS,回傳 num_tokens × vocab |
| KV cache 範圍回捲 | 有但極受限 | 註解寫只在 keep_history == 0 且回呼暫停時有效,我實測一般呼叫直接 -1 |
| 視窗內雙向 attention | 沒有 | RKLLMInferParam 沒有 mask 或 position 欄位 |
最後一列是硬傷。官方標頭裡唯一帶 mask 的是 cross-attention 的 encoder 參數,跟 decoder 視窗無關。所以我預期這件事做不到原樣,只能近似。
轉換:兩個模型,兩個 venv
LLM 這半邊
LocateAnything 的權重是一個大檔,語言模型是標準 Qwen2,只是詞表擴到 152,681(多了框和座標 token)。第一步是把它拆成一個乾淨的 Qwen2 checkpoint:
# Python 3.10, transformers 4.57.1, safetensors
import json
from safetensors import safe_open
from safetensors.torch import save_file
idx = json.load(open("model/model.safetensors.index.json"))["weight_map"]
llm = {}
for fname in sorted(set(idx.values())):
with safe_open(f"model/{fname}", "pt") as sf:
for k in sf.keys():
if k.startswith("language_model."):
llm[k[len("language_model."):]] = sf.get_tensor(k)
# lm_head 與 embed_tokens 實際上是共用的,只存一份
llm.pop("lm_head.weight")
save_file({k: v.contiguous() for k, v in llm.items()}, "qwen25_3b_la/model.safetensors")
再配一份 Qwen2Config(tie_word_embeddings=True、vocab_size=152681),用普通的 Qwen2ForCausalLM.from_pretrained 載回來確認沒爛。接著用 rkllm-toolkit 轉 w8a8,在 3090 上一兩分鐘就出爐,產物 3.76GB。
這裡有個小缺口要老實講:官方 README 寫最新是 v1.3.1(我 2026-11-02 查的),但我手上只找得到 1.3.0 的 wheel(從板子上的 SDK 包拿的),所以這次用 1.3.0 轉。Qwen2 架構在 1.3.0 就支援,runtime 也剛好是板子上現成的 1.3.0。
視覺這半邊
MoonViT 原本支援動態解析度,用複數 RoPE、雙三次插值的位置 embedding、一堆 cu_seqlens。這些在 RKNN 上都是地雷。我的做法是直接固定輸入 448×448:網格 32×32 共 1024 個 patch,經過 2×2 合併剩 256 個 image token。
位置 embedding 和 2D RoPE 都改成預先算好的常數,複數乘法改寫成實數的 cos/sin 旋轉,attention 用最樸素的 matmul + softmax。最後接上 mlp1,直接輸出 256×2048,整個圖丟給 RKNN。我拿官方模型對同一張圖算參考輸出,對照結果:
shape (256, 2048) cos 1.000011 max|diff| 0.00054 ref std 1.2129
# cos 略大於 1 是 fp32 浮點誤差,實質上就是 1;兩邊都是 fp32,這段還沒碰到 NPU 的 fp16
這個結果讓我放心不少,至少「圖變 embedding」這段,數值上跟官方是一致的。然後用 rknn-toolkit2 2.5.0 轉成 fp16 的 .rknn(不量化),0.92GB。
兩個工具包不要裝同一個 venv
rkllm-toolkit1.3.0 與rknn-toolkit22.5.0 放在同一個環境,pip 會直接報ResolutionImpossible。分開兩個 venv,各裝各的,一個都不委屈。

繞過限制:前綴 KV 重用
回到開頭那個 -1。範圍回捲走不通,我就去問:RKLLM 自己會不會偷偷幫我重用前綴?我寫了一支小探針,每次用 GET_LOGITS 送一串 token,觀察「這次實際重算了幾列」:
| 呼叫 | 送入內容 | 重算列數 | KV 長度 | 耗時 |
|---|---|---|---|---|
| S1 | 47 個 token | 47 | 47 | 355 ms |
| S2 | 前 40 個相同,後 7 個換掉 | 6 | 47 | 145 ms |
| S1 再送一次 | 同 S1 | 6 | 47 | 139 ms |
| S3 | 前 40 個 + 5 個 <text_mask> | 5 | 45 | 143 ms |
重點在 S2:只重算了 6 列,不是 7。我回頭看 token,是因為被換掉的尾巴頭尾剛好都是 </box>,所以共同前綴多了一格。這證明 runtime 會自動找最長共同前綴、截斷其後的 KV 再接著算。S1 再送一次,logits 跟 fp32 的 PyTorch 參考比,cosine 平均 0.9993。
換句話說,我根本不需要手動回捲,每一步直接重送「已接受的全部 token + 5 個 <text_mask>」就好,runtime 會幫我重用前綴。連圖像輸入也一樣:
| 圖像呼叫 | 回傳的 logits 列數 | 耗時 |
|---|---|---|
| 第一次(256 個圖像 token + 約 34 個文字 token,KV 共 290) | 19 | 約 1,981 ms |
| 同樣的提示詞再送 | 1 | 140 ms |
後面接 <ref>person</ref><box> | 4 | 162 ms |
再接 <97><515> | 2 | 158 ms |
這裡的 <97> 這種座標 token 和 <box> 都會被切成單一 token,剛好對得上。於是引擎的一步長這樣:
# 簡化自 rk3588/locate3b.py
text = tok.decode(gen, skip_special_tokens=False) # 已接受的 token 轉回文字
prompt = head + text + "<text_mask>" * 5 # 視窗:5 個 mask
logits = run_logits(prompt, image_embeds)[-6:] # 取最後 6 列 = 一個框
# 之後走官方 decode_bbox_avg / decode_ref / handle_pattern 的 numpy 移植
代價呢?視窗內部變成因果,不是原本的雙向。因為視窗的位置排布跟官方一樣(最後一個真實 token 在 L-1,mask 在 L 到 L+4),差別只剩 mask token 之間能不能互相看見。這是個近似,我後面用數字量化它。
圖片 448x448
│
▼
RKNN 視覺 fp16(256 個 image token)──┐
▼
提示詞 + 已接受 token + 5 個 text_mask ──► RKLLM Qwen2.5-3B w8a8(GET_LOGITS)
│
▼
最後 6 列 logits
│
▼
CPU numpy:取樣與 PBD 解碼
│ │
合法框 │ 格式有誤 │
▼ ▼
接受 6 個 token 退回逐 token
└──────► 回到下一步 ◄───┘
踩坑紀錄
Q1:模型一直吐框、永遠不結束(greedy 的陷阱)
現象:我最早用 greedy 加repetition_penalty=1.1,bus.jpg 找到四個人之後,後面接了一大串座標微幅遞增的重複框。
量化:100 張 COCO 子集,GPU 官方模型 greedy 的 F1 只有 0.37 到 0.45,100 張裡只有 1 到 2 張正常結束。
原因:官方batch_infer.py的預設是temperature 0.7、top_p 0.9、repetition_penalty 1.1的抽樣,不是 greedy。我偏離了官方設定。
解法:改用官方抽樣後,GPU 上 F1 升到 0.73 到 0.77,100 張全部正常結束。板上引擎也補上同樣的抽樣。
Q2:
rkllm_clear_kv_cache一直回 -1
現象:終端機印出開頭那行Start_pos and end_pos are only valid when keep_history == 0 ...。
原因:標頭檔寫範圍清除只在keep_history == 0且回呼暫停時有效。我在GET_LOGITS模式下,連讓回呼回傳 1 之後再清,也一樣回-1。為什麼,我只能推測這個流程是為 generate 的暫停情境設計的,沒有深入驗證。
解法:放棄手動回捲,靠前綴重用。
Q3:venv 建不起來,
ensurepip is not available
原因:Ubuntu 的python3.10-venv沒裝,而且我不想動 sudo。
解法:python3 -m venv --without-pip venv,再下載get-pip.py在 venv 裡裝 pip。
Q4:
ImportError: cannot import name 'AutoProcessor'
原因:transformers 的延遲載入吃掉了真正的錯誤,底層是系統的 Pillow 9.0.1 太舊,缺PIL.Image.Resampling。
解法:在 venv 裡pip install --ignore-installed "pillow==11.1.0"。用import transformers.models.auto.processing_auto直接匯入才看得到真正的錯誤。
Q5:板子記憶體放不下兩個東西
現象:LLM 3.8GB、視覺 0.9GB,加起來約 4.7GB,8GB 板子上原本的rkllm-api已經載著別的模型。
解法:實驗時暫停rkllm-api,結束再重啟。我自己有一隻叫 DORO 的 Discord 聊天 bot,它的模型就掛在這個服務上,測試期間它連不上模型,夭壽,是我自己害的。另外板子磁碟只剩 1.1GB,這也是個隱憂。
精度與速度:我量到的數字
評測方式:從 COCO val2017 抽 100 張(固定種子,每張取最多的那個類別,1 到 8 個目標,框都不小於 32×32 像素),用 Locate all the instances that matches the following description: <類別>.,算 box F1@0.5(依輸出順序貪婪匹配)加上匹配框的平均 IoU。所有設定都套用同一套後處理:遇到與先前框 IoU 大於 0.85 的重複框就截斷,最多取 30 個。抽樣參數同官方,單一種子。
| 設定 | Precision | Recall | F1@0.5 | F1@0.75 | 匹配框 mIoU |
|---|---|---|---|---|---|
| 原模型,GPU bf16,雙向視窗,448 | 0.842 | 0.716 | 0.774 | 0.660 | 0.874 |
| 原模型,GPU bf16,因果視窗,448 | 0.760 | 0.716 | 0.738 | 0.624 | 0.871 |
| 原模型,GPU bf16,雙向,原生解析度 | 0.747 | 0.712 | 0.729 | 0.645 | 0.885 |
| 本轉換,Rock 5C,w8a8 | 0.710 | 0.529 | 0.606 | 0.408 | 0.795 |

這張表我這樣解讀。
因果視窗的代價不大。 雙向到因果,F1 從 0.774 掉到 0.738,約 3.6 個百分點,匹配框的 IoU 幾乎沒變(0.874 對 0.871)。這個近似比我預期的溫和。
真正的落差在量化與 NPU 數值。 從 GPU 因果的 0.738 到板子的 0.606,又掉了約 13 個點,召回率從 0.72 掉到 0.53,定位精度(mIoU 0.795)也下滑。這一段我沒有把 w8a8、fp16 視覺和其他數值差異拆開,所以只能說「量化加 NPU」是主因的推測,並沒有證據分解。
樣本很小。 n = 100、單一種子,幾個百分點內的差距都在雜訊範圍。我也沒有跑完整的 COCO,不要把這張表當 benchmark 引用。
速度方面,100 張的平均是每張 5.78 秒(中位數 4.90 秒),引擎有逐張記錄分段時間,拆開來看:
| 階段 | 平均耗時 | 說明 |
|---|---|---|
| 圖片載入 + 視覺編碼(RKNN) | 約 2.67 秒 | 幾乎是固定值,100 張的最小值也是 2.66 秒 |
| LLM(RKLLM,含第一次 prefill) | 約 2.92 秒 | 平均約 7 個解碼步;第一次 prefill 約 2 秒,之後每步約 140 到 160 ms |
| CPU 取樣與 PBD 解碼 | 約 0.19 秒 | numpy 實作 |
三項加起來剛好就是 5.78 秒。也就是說,一半的時間花在把圖變成 256 個 embedding,真正「吐框」的部分反而是小頭,這也是我之後最想優化的地方。對照 GPU 約 0.14 秒一張,板子大約慢了 40 倍,所以別期待它有 H100 那種「10 倍」的體感。
授權:能發布,但要守規矩
這個部分我花的時間比想像中多。LocateAnything-3B 用 NVIDIA License,條款 裡有兩個重點:再散布必須附完整授權並保留版權聲明;衍生作品只能用於「非商業」,也就是研究或評估用途。轉換後的權重屬於衍生作品,所以我的 repo 沿用同一個授權,不能自己放寬。

語言模型 Qwen2.5-3B-Instruct 另有 Qwen Research License ,要附授權全文、標示檔案被修改過、NOTICE 放它指定的那句版權文字,文件裡還要顯著標示「Built with Qwen」。視覺編碼器 MoonViT-SO-400M 的模型卡宣告 MIT,不過上游沒有附 LICENSE 檔,也沒寫版權持有人,所以我只引用它宣告的授權並連結來源,沒有替它編一行版權。
另外 Hugging Face 上已經有一個 RKNNAI 的版本 ,但它針對的是 RK1828(RK182X 系列),格式是 .rknn 加 .weight,跟 RK3588 的 .rkllm 不一樣,不能直接拿來用。我搜過 LocateAnything rknn 和 rkllm,目前沒看到別人發布 RK3588 的版本,不過我只搜了標題與關聯,漏看是可能的。
值不值得用
| 方案 | 優點 | 缺點 | 適合 |
|---|---|---|---|
| 這份轉換版(RK3588) | 離線、省電、不吃 GPU | F1 掉到 0.606、慢、固定 448、非商業 | 研究、邊緣實驗、看 NPU 極限 |
| 原模型 + GPU | 精度最高、最快 | 要 GPU | 正式驗證與基準 |
| YOLO 這類固定類別偵測器 | 結構簡單,轉成 RKNN 的路徑成熟 | 只能偵測訓練過的類別,沒辦法用一句話描述目標 | 類別固定的產線 |
我自己的結論是:如果你要的是「任意文字描述找物件」,而且願意接受精度折損,這個轉換版是目前 RK3588 上少數能做到的。如果你只需要固定類別,YOLO 更實際。
這個版本還有幾件沒做:沒有保長寬比的多解析度版本、沒有跑完整 COCO、沒有把量化誤差跟 fp16 視覺的貢獻拆開、也還沒接進 rkllm-api 當成一個正式的服務。有興趣的話,歡迎直接到 Hugging Face 下載來玩,轉檔與評測腳本都在 tools/,發現精度或授權上的問題也請告訴我。
參考資料
- LocateAnything: Fast and High-Quality Vision-Language Grounding with Parallel Box Decoding(arXiv 2605.27365)
- NVIDIA LocateAnything 專案頁
- nvidia/LocateAnything-3B 模型卡
- batch_infer.py(官方推論預設參數)
- airockchip/rknn-llm
- rkllm.h(RKLLM runtime 標頭檔)
- CNX Software:Rockchip RKLLM Toolkit 報導
- CNX Software:RK3588 規格整理
- RKNNAI/RK182X-VLM-LocateAnything-3B
- Qwen2.5-3B-Instruct 授權
- moonshotai/MoonViT-SO-400M
- oliver0804/LocateAnything-3B-RK3588(本文的轉換版)
沒有留言:
張貼留言