rk3588大模型上手体验

过尽千帆皆不是,斜晖脉脉水悠悠,肠断白频洲

继上个月到手国产树毒派后,刷官方系统把玩了一番。参考了一些论坛和博客后,决定换其它系统玩玩。然后发现大模型在rk系列的NPU上可以跑一些蒸馏版本,于是便开始体验一下。


rknn大模型部署

官方的大模型主要是c和c++的一些库和demo组成,参考官方文档安装RKNN环境。通过下面命令可以查看目前npu驱动版本:

    sudo dmesg | grep "Initialized rknpu"

根据板端系统的 python 版本将对应的 rknn_toolkit_lite2-2.3.0-cp3X-cp3X-manylinux_2_17_aarch64.manylinux2014_aarch64.whl 复制到板端,进入虚拟环境后使用 pip3 安装

pip3 install ./rknn_toolkit_lite2-2.3.0-cp3X-cp3X-manylinux_2_17_aarch64.manylinux2014_aarch64.whl

然后安装rknn-toolkit2:

pip install -U rknn-toolkit2

安装rknn-toolkit2 lib:

wget https://raw.githubusercontent.com/airockchip/rknn-toolkit2/refs/heads/master/rknpu2/runtime/Linux/librknn_api/aarch64/librknnrt.so
sudo cp librknnrt.so /usr/lib/

Yolo系列

目前最新的yolo11版本已经可以通过ultralytics库来调用和运行,相关大模型权重文件在官方文档中可以查看下载,也可以通过ultralytics库来自己转换权重为rknn文件。注意调用时要带上metadata.yaml文件,权重文件路径不要加后缀.rknn,下面是测试脚本:

#!/bin/bash

yolo predict model='./yolo11n_3588_rknn_model' source='https://ultralytics.com/images/bus.jpg'

python测试脚本如下:

from ultralytics import YOLO

# Load the exported RKNN model
rknn_model = YOLO("./yolo11n_3588_rknn_model")

# Run inference
results = rknn_model("https://ultralytics.com/images/bus.jpg")

Deepseek蒸馏模型

参考官方的文件,可以用c++来部署,但是还是更习惯用python来部署。RKNN‑LLM(又名 RKLLM)目前没有官方 Python 接口用于直接在脚本中调用推理功能。理解它的运行机制如下: 🧠 当前 SDK 架构

  1. RKLLM‑Toolkit(PC 端模型转换工具) – 提供 Python API,用于将 HuggingFace 格式模型导出为 .rkllm 文件,可进行量化与平台优化。
  2. RKLLM Runtime(板端推理库) – 运行于 RK3588/RK3576 等设备上的 C/C++ 动态库,生成 .rkllm 后需通过调用 C 接口进行推理。
  3. 示例项目(C++ demo、shell 脚本等) – GitHub 上提供的示例多为 C/C++ 形式,而非 Python。

所以这里我利用 ctypes 或 cffi 封装 C 接口(如 rkllm_init、rkllm_infer、rkllm_release),为 Python 提供绑定接口。✅ ctypes 是最轻量级、最常用的做法。


✅ 使用前提

已有 RKNN-LLM 推理库,比如:

  • 动态库封装的头文件,官方在github仓库中给出
  • 动态库路径:/usr/lib/librkllm_runtime.so
  • 模型文件:chatglm3.rkllm
  • 你了解推理输入输出格式(例如是字符串,还是 token ID)

📦 示例目录结构
DeepSeek-R1-Distill-Qwen-1.5B_RKLLM/
├── python/
│   ├── rkllm_wrapper.py
│   └── run.py
├── demo_Linux_aarch64/
│   └── lib/
│       └── librkllmrt.so
├── DeepSeek-R1-Distill-Qwen-1.5B_W8A8_RK3588.rkllm

⚠️ 可选:设置 LD_LIBRARY_PATH

如果遇到 .so 加载问题,建议在运行前:

export LD_LIBRARY_PATH=../demo_Linux_aarch64/lib:$LD_LIBRARY_PATH

🧰步骤详解:用 ctypes 封装 .so 和头文件
  • 1️⃣ 准备头文件(例如 rkllm.h

从 GitHub 上 rkllm.h 看,你有类似下面的结构:

typedef struct {
    char* model_path;
    int context_len;
    float temperature;
} rkllm_param;

int rkllm_init(rkllm_param* param);
char* rkllm_run(const char* input);

  • 2️⃣ 加载 .so 文件(如 librkllmrt.so
import ctypes
from ctypes import *

# 加载 so 文件
rkllm = ctypes.CDLL("/path/to/librkllmrt.so")  # 注意路径

  • 3️⃣ 定义结构体 rkllm_param(对应 C 的结构)
class rkllm_param(Structure):
    _fields_ = [
        ("model_path", c_char_p),
        ("context_len", c_int),
        ("temperature", c_float)
    ]

  • 4️⃣ 定义函数签名
# int rkllm_init(rkllm_param* param);
rkllm.rkllm_init.argtypes = [POINTER(rkllm_param)]
rkllm.rkllm_init.restype = c_int

# const char* rkllm_run(const char* input);
rkllm.rkllm_run.argtypes = [c_char_p]
rkllm.rkllm_run.restype = c_char_p

  • 5️⃣ 调用函数
# 准备参数结构体
param = rkllm_param(
    model_path=b"/path/to/model.bin",   # 注意要是 bytes 类型
    context_len=512,
    temperature=0.8
)

# 初始化
ret = rkllm.rkllm_init(byref(param))
print("init returned", ret)

# 推理
output = rkllm.rkllm_run(b"你好,请问你是谁?")
print("output:", output.decode())

Python 调用 .so 的本质流程
  1. .so 本质是 ELF 格式共享库,导出符号函数如 rkllm_run
  2. ctypes.CDLLcdll.LoadLibrary 会读取动态库符号表;
  3. 你需要设置 .argtypes.restype 告诉 Python 参数类型;
  4. ctypes.Structure 映射 C struct
  5. Python 调用时,底层通过 FFI(Foreign Function Interface)传参、执行并返回结果。

常见问题提示
问题类型 原因或解决方式
Segmentation fault 参数传错(如结构体没初始化正确、内存越界)
Python crash restypeargtypes 没定义或错误
字符串乱码 应使用 c_char_p 且传入 bytes 类型
返回结构体 POINTER(MyStruct) + .contents 提取

是否需要自动解析头文件?

你也可以用工具自动将 .h 头文件转换为 Python 结构,比如:

  • ctypesgen:自动读取 .h.so 生成 .py
  • CFFI:适用于更复杂的绑定(需要手动或 C 编译)
  • pybind11:C++ 层包装,非常优雅但需重新编译
🔍 补充说明

下面是Python ctypes 封装代码,用于调用 rkllm.h 中定义的所有结构体、枚举和函数:

import ctypes
from ctypes import c_int, c_float, c_char_p, c_bool, c_void_p, POINTER, Structure, Union, c_size_t, c_int8, c_uint32, c_ubyte

# —— 常量定义 —— 
CPU0 = 1 << 0
CPU1 = 1 << 1
# … 同理 CPU2–CPU7

# —— 枚举值 —— 
RKLLM_RUN_NORMAL = 0
RKLLM_RUN_WAITING = 1
RKLLM_RUN_FINISH = 2
RKLLM_RUN_ERROR = 3

...

# —— 结构体定义 —— 
class RKLLMExtendParam(Structure):
    _fields_ = [
        ("base_domain_id", c_int),
        ("embed_flash", c_int8),
        ("enabled_cpus_num", c_int8),
        ("enabled_cpus_mask", c_uint32),
        ("reserved", c_ubyte * 106),
    ]

class RKLLMParam(Structure):
    _fields_ = [
        ("model_path", c_char_p),
        ("max_context_len", c_int),
        ("max_new_tokens", c_int),
        ("top_k", c_int),
        ("n_keep", c_int),
        ("top_p", c_float),
        ("temperature", c_float),
        ("repeat_penalty", c_float),
        ("frequency_penalty", c_float),
        ("presence_penalty", c_float),
        ("mirostat", c_int),
        ("mirostat_tau", c_float),
        ("mirostat_eta", c_float),
        ("skip_special_token", c_bool),
        ("is_async", c_bool),
        ("img_start", c_char_p),
        ("img_end", c_char_p),
        ("img_content", c_char_p),
        ("extend_param", RKLLMExtendParam),
    ]

class RKLLMLoraAdapter(Structure):
    _fields_ = [
        ("lora_adapter_path", c_char_p),
        ("lora_adapter_name", c_char_p),
        ("scale", c_float),
    ]

...

class RKLLMInputUnion(Union):
    _fields_ = [
        ("prompt_input", c_char_p),
        ("embed_input", RKLLMEmbedInput),
        ("token_input", RKLLMTokenInput),
        ("multimodal_input", RKLLMMultiModelInput),
    ]

class RKLLMInput(Structure):
    _anonymous_ = ("input",)
    _fields_ = [
        ("input_type", c_int),
        ("input", RKLLMInputUnion),
    ]

...

class RKLLMInferParam(Structure):
    _fields_ = [
        ("mode", c_int),
        ("lora_params", POINTER(RKLLMLoraParam)),
        ("prompt_cache_params", POINTER(RKLLMPromptCacheParam)),
        ("keep_history", c_int),
    ]

...

# —— 类型定义 —— 
LLMHandle = c_void_p
LLMResultCallback = ctypes.CFUNCTYPE(None, POINTER(RKLLMResult), c_void_p, c_int)

# —— 加载 SO 库并设置函数签名 —— 
rkllm = ctypes.cdll.LoadLibrary("librkllmrt.so")

rkllm.rkllm_createDefaultParam.restype = RKLLMParam

...


callback 函数是一个 C 回调,用于处理模型推理的结果(如文本、隐藏层、错误状态等)。我们可以将它完整转换为 Python 的 ctypes 回调函数,保留所有逻辑,包括保存隐藏层输出。

✅ Python 回调转换如下:

import ctypes
from ctypes import POINTER, c_void_p, c_int, cast
import numpy as np

# 假设这些枚举常量已定义
RKLLM_RUN_NORMAL = 0
RKLLM_RUN_WAITING = 1
RKLLM_RUN_FINISH = 2
RKLLM_RUN_ERROR = 3

# —— 类型定义 —— 
'''
 * @typedef LLMHandle
 * @brief A handle used to manage and interact with the large language model.
'''
LLMHandle = c_void_p
'''
 * @typedef LLMResultCallback
 * @brief Callback function to handle LLM results.
 * @param result Pointer to the LLM result.
 * @param userdata Pointer to user data for the callback.
 * @param state State of the LLM call (e.g., finished, error).
'''
LLMResultCallback = ctypes.CFUNCTYPE(None, POINTER(RKLLMResult), c_void_p, c_int)

# 假设 RKLLMResult、RKLLMResultLastHiddenLayer 等结构体已正确定义
# LLMResultCallback 类型也已定义为:
# LLMResultCallback = ctypes.CFUNCTYPE(None, POINTER(RKLLMResult), c_void_p, c_int)

def python_callback(result_ptr, userdata, state):
    if not result_ptr:
        result = None
    else:
        result = result_ptr.contents
        
    if state == RKLLM_RUN_FINISH:
        print('\n')
    elif state == RKLLM_RUN_ERROR:
        print('\\run error\n')
    elif state == RKLLM_RUN_NORMAL:
        ''' ================================================================================================================
        若使用GET_LAST_HIDDEN_LAYER功能,callback接口会回传内存指针:last_hidden_layer,token数量:num_tokens与隐藏层大小:embd_size
        通过这三个参数可以取得last_hidden_layer中的数据
        注:需要在当前callback中获取,若未及时获取,下一次callback会将该指针释放
        =============================================================================================================== '''
        if result != None:
            print(result.text.decode('utf-8'), end='', flush=True)
        else:
            return

        # 如果有隐藏层数据
        embd_size = result.last_hidden_layer.embd_size
        num_tokens = result.last_hidden_layer.num_tokens
        hidden_ptr = result.last_hidden_layer.hidden_states

        if embd_size != 0 and num_tokens != 0 and hidden_ptr:
            data_size = embd_size * num_tokens
            print(f"\n[隐藏层] embd_size: {embd_size}, num_tokens: {num_tokens}, 总大小: {data_size * 4} 字节")

            # 将 C 指针转换为 numpy 数组
            array_type = ctypes.c_float * data_size
            np_array = np.ctypeslib.as_array(cast(hidden_ptr, POINTER(array_type)).contents)
            np_array = np_array.reshape((num_tokens, embd_size))

            # 保存为二进制文件
            try:
                np_array.astype(np.float32).tofile("last_hidden_layer.bin")
                print("隐藏层数据已保存到 last_hidden_layer.bin")
            except Exception as e:
                print(f"保存文件失败: {e}")


# 将 python_callback 包装成 ctypes 函数指针
cb = LLMResultCallback(python_callback)
  1. result.textc_char_p,需要 .decode('utf-8')
  2. 隐藏层是一个二维数组 [num_tokens][embd_size],我们用 numpy 把它还原出来。
  3. 数据写入 last_hidden_layer.bin 与 C++ 等效。
  4. 你需要提前在 Python 中定义好 RKLLMResult 和其内部结构体。
  5. 若不使用 numpy,也可以用 Python 的 bytearray 手动复制数据,但 numpy 更高效且方便调试。
  6. llmHandlectypesc_void_p 或实际结构体指针。
  7. rkllm_destroy() 是你绑定的原生 C API,用 ctypes 加载即可。
  8. sys.exit(signal) 会让程序以指定信号码退出。
  9. ctypes.Structure 中,字段未赋值时默认是 0,但为了完全对应 C 的 memset(&x, 0, sizeof(x)),我们调用了 ctypes.memset
  10. 如果你不确定结构体字段,只声明结构体时 memset 也可清零所有未初始化内存。
  11. ctypes.byref 获取结构体引用,适用于类似 memset(&obj, ...) 的调用场景。

armbian系统相关

最后说一说armbian Debian 12 (Bookworm),蓝牙体验非常好,果然比官方系统好用!很多软件和依赖都可以直接sudo apt install装完即用。先来说说迁移系统后的一些问题:

  1. 更改语言后,在~/.xsessionrc~/.bashrc里也要更改对应的locales。

  2. nmcli创建的热点需要安装dnsmasq后才能成功开启

  3. 无屏模式
     sudo systemctl set-default multi-user.target
    
  4. 安装Cockpit,使用armbian-config选择Software-Management-Cockpit安装

  5. docker安装出错,按照上面官方镜像一样安装会出错,需要安装
     sudo apt update
     sudo apt install apparmor-utils
     sudo systemctl restart docker
    
  6. 网络部分,参考debian官方文档

  7. RDP部分安装:
     sudo apt update
     sudo apt install -y xfce4 xfce4-goodies
     sudo apt install -y xrdp xorgxrdp xserver-xorg-core xserver-xorg-video-fbdev
     echo startxfce4 > ~/.xsession
     chmod +x ~/.xsession
    
  8. docker容器连wifi
     docker run -d --name openwrt --network none --restart unless-stopped -v /home/lyc/Workspace/easytier-linux-aarch64:/data2 --privileged kiddin9/openwrt /sbin/init
     sudo ip link add veth-host type veth peer name veth-openwrt
     sudo ip addr add 192.168.166.1/24 dev veth-host
     sudo ip link set veth-host up
     PID=$(docker inspect -f '\{\{.State.Pid\}\}' openwrt)
     sudo ip link set veth-openwrt netns $PID
     sudo nsenter -t $PID -n ip addr add 192.168.166.2/24 dev veth-openwrt
     sudo nsenter -t $PID -n ip link set veth-openwrt up
     sudo nsenter -t $PID -n ip route add default via 192.168.166.1
    

    在容器里面先用passwd改密码,接下来编辑/etc/config/network文件,加入下面的部分:

     config device
         option name 'veth-openwrt'
         option type 'ethernet'
        
     config interface 'wan'
         option device 'veth-openwrt'
         option proto 'static'
         option ipaddr '192.168.166.2'
         option netmask '255.255.255.0'
         option gateway '192.168.166.1'
         option ip6assign '60'
    

    。然后运行/etc/init.d/network restart重启网络。关闭防火墙etc/init.d/firewall stop后就可以访问网页https://192.168.166.2来打开Luci网页了。为了联网需要开启NAT转发

     echo 1 | sudo tee /proc/sys/net/ipv4/ip_forward
     sudo iptables -t nat -A POSTROUTING -s 192.168.166.0/24 -o wlx0c82680ba357 -j MASQUERADE
    

    禁用 docker 的默认 FORWARD 限制,查看是否被 FORWARD 策略阻挡:

     sudo iptables -L FORWARD
    

    如果默认是 DROP,请设置为 ACCEPT

     sudo iptables -P FORWARD ACCEPT
    

    为了转发局域网的端口,接下来可行的操作就很多了,一个个介绍一下吧。

    • Cloudflared

      首先安装一下

        opkg update
        opkg install cloudflared
        cloudflared update
      

      然后开始登录

        cloudflared tunnel login
      

      这个命令会在终端中显示一个 URL,你需要在浏览器中打开并授权登录 Cloudflare 账号。接下来通过以下命令创建一个新的 tunnel:

        cloudflared tunnel create <your-tunnel-name>
      

      这个命令会生成一个 tunnel_id,后续需要用到。配置文件一般保存在 /etc/cloudflared/config.yml 中,若没有此文件,需要手动创建。

        nano /etc/cloudflared/config.yml
      

      在文件中,配置你的 tunnel_id 和要代理的服务(例如 OpenWrt 的 Web 管理端口)。下面是一个例子:

        tunnel: <your-tunnel-id>
        credentials-file: /etc/cloudflared/<your-tunnel-id>.json
              
        ingress:
        - hostname: <your-subdomain>.example.com
            service: ssh://10.12.0.3:22  # 这里是你的 OpenWrt 地址和端口
        - service: http_status:404
      

      your-tunnel-id 是你通过 cloudflared tunnel create 命令生成的 Tunnel ID。 hostname 是你想在 Cloudflare 上配置的域名,注意你需要提前将该子域的 DNS 解析设置为 Cloudflare 代理。 service 是你想通过 Tunnel 代理的服务地址,这里是内网某主机ssh服务 10.12.0.3:22。 配置好之后,你可以通过以下命令启动 Tunnel:

        cloudflared tunnel run <your-tunnel-name>
      
    • frp服务 本服务需要有公网IP,将本地内网的某个服务绑定到公网IP的某一端口。包括frpc和frps两个部分。一般frps部署在具有公网IP的机器上,s即是server。先下载文件

        wget https://github.com/fatedier/frp/releases/download/v0.62.1/frp_0.62.1_linux_arm64.tar.gz
        tar -zxvf frp_0.62.1_linux_arm64.tar.gz 
      

      然后在上面进行开启frp服务,先写入配置文件frp.ini下面的内容:

        [common]
        bind_port = 7000
        dashboard_port = 7500
        dashboard_user = admin
        dashboard_pwd = admin
        token = set_your_token
      

      之后开启服务:

        nohup ./frps -c ./frp.ini > log.txt 2>&1 &
      

      接下来配置frp客户端。直接在网页上System-SOftware里下载luci-frpc-app然后在网页进行配置即可。也可以将frps配置为systemd系统服务,就不用每次使用命令 自己启动了,具体步骤如下。先创建文件/etc/systemd/system/frps.service,然后填入下面的内容,路径自行修改:

        [Unit]
        Description=Frp Server Service
        After=network.target
      
        [Service]
        Type=simple
        User=nobody
        Restart=on-failure
        RestartSec=5s
        ExecStart=/path/to/frps -c /path/to/frps/config/file.ini
      
        [Install]
        WantedBy=multi-user.target
      

      然后使用sudo systemctl daemon-reload来加载新的服务单元,使用sudo systemctl start frps来启动服务,使用sudo systemctl enable frps来开机启动。 注意User=nobody时,使用sudo -u nobody /path/to/frps -h来验证nobody的运行权限,否则会出现systemd 203错误。可以放到/usr/local/bin目录下,另外 将配置文件的读取权限更改,运行命令:setfacl -m u:nobody:r /path/to/frp.ini

    • socat服务

      相比之下socat服务更简单,只需要来到iStoreOS来安装一下Socat。但是容器本身需要配置端口映射,将一些端口映射到容器外。此时如果宿主机本身就有公网IP的话,就可以将端口直接映射到域名+端口了。最后去网页iStore安装socat,通过网页接口设置端口转发功能。另外可以在网页的System-Software里面安装lua-app-easytireeasytire来开启组网。

  9. docker容器安装ssh,启用gpu支持

     docker run -it --gpus all  --name pydev  -v /data/yanchaoliu:/data2 --privileged -p 65530:65530 -p 65531:65531 -p 65532:65532 ubuntu:latest /bin/bash
    

    进去就可以使用命令nvidia-smi查看gpu情况。然后安装adduser和sudo,

     apt install -y adduser sudo nano
    

    创建用户lyc,并加入sudo权限

     adduser lyc
     usermod -aG sudo lyc
    

    加入公钥

     su lyc
     mkdir ~/.ssh
     cd ~/.ssh
     touch authorized_keys
     nano authorized_keys # 加入公钥到文件
     chmod 600 authorized_keys
    

    然后安装ssh服务,编辑配置文件

     sudo apt install openssh-server
     sudo nano /etc/ssh/sshd_config
     sudo mkdir /var/run/sshd
     sudo /etc/init.d/ssh start
    
  10. docker event监听事件触发 如果你需要长期在宿主机里实现“事件驱动”效果:写一个监听脚本如下
    #!/bin/bash
    docker events --filter 'event=start' --filter 'container=my_container' |
    while read event
    do
        echo "容器启动事件触发: $event"
        /opt/scripts/my_script.sh
    done
    

    用 systemd service 或 supervisord 管理它,让它常驻并随宿主机启动。例如 /etc/systemd/system/docker-event-listener.service

    [Unit]
    Description=Run script when my_container starts
    After=docker.service
    
    [Service]
    ExecStart=/bin/bash /opt/scripts/docker_event_listener.sh
    Restart=always
    
    [Install]
    WantedBy=multi-user.target
    

    这样就真的和“中断”一样了,事件发生时触发,不发生时零开销地挂在那里。

如果您觉得该文章对您有用,欢迎打赏作者,激励创作!
Welcome to tip the author!

微信(WeChat Pay) 支付宝(AliPay)
比特币(Bitcoin) 以太坊(Ethereum)
以太坊(Base) 索拉纳(Solana)