鸡西市网站建设_网站建设公司_响应式开发_seo优化
2026/1/16 15:40:52 网站建设 项目流程

写在前面

参考历史博客,成功把Qwen3-vl2b部署在RK3588上,但是对代码处理流程模糊,C++底子差,遂做笔记于CSDN。难免出错,欢迎大家指出,交流。

项目结构

  1. build 通常是编译构建产物的临时目录,存放编译过程中生成的中间文件、可执行程序等,也就是编译之后的可执行文件地址。
  2. data是数据相关目录:
    /datasets:应该存放训练 / 测试用的数据集(datasets.json可能是数据集的配置文件);
    /inputs_embeds:存放模型输入的 “嵌入向量” 文件(比如图片、文本转成的特征向量);
    make_input_embeds_for_quantize.py:用来生成量化模型所需的输入嵌入的工具。
  3. deploy 是部署相关目录,负责把模型 / 代码打包成可部署的形式:
    /3rdparty:存放依赖的第三方库(比如编译需要的外部代码);
    /src:部署用的源码(比如image_enc是图片编码器的代码;
    main.cpp是部署程序的入口);
    build-android.sh/build-linux.sh:对应安卓、Linux 系统的编译脚本;
    CMakeLists.txt:CMake 的构建配置文件,用来管理 C++ 代码的编译流程。
  4. export是模型导出相关目录:
    export_llm.py/export_vision.py:负责把训练好的大语言模型(LLM)、视觉模型导出成通用格式(比如 ONNX);
    onnx子目录应该存放导出后的 ONNX 模型文件;
    check_ops.py/degrade_onnx.py是用来检查、处理 ONNX 模型的工具脚本。

详解/deploy/src下的main.cpp

代码分4个阶段:
准备阶段:解析参数 → 加载 LLM 和图像编码器模型;
预处理阶段:读取图片 → 格式转换 / 正方形填充 / 缩放 → 生成图像特征向量;
推理阶段:接收用户输入 → 组装文本 / 图像输入 → 调用模型推理 → 实时输出结果;
收尾阶段:释放模型资源,正常退出。

准备阶段:解析参数

if(argc<7){std::cerr<<"Usage: "<<argv[0]<<" image_path encoder_model_path llm_model_path max_new_tokens max_context_len rknn_core_num "<<"[img_start] [img_end] [img_content]\n";return-1;}constchar*image_path=argv[1];constchar*encoder_model_path=argv[2];// 1. 创建默认的LLM参数结构体(相当于拿一张“参数表”,先填默认值)RKLLMParam param=rkllm_createDefaultParam();// 2. 填写核心参数(覆盖默认值)// LLM模型文件的路径(对应命令行第4个参数,argv[3])param.model_path=argv[3];// top_k=1:生成回答时,只选概率最高的1个Token(保证回答最“确定”,新手不用改)param.top_k=1;// max_new_tokens:最多生成多少个Token(命令行第5个参数,argv[4])param.max_new_tokens=std::atoi(argv[4]);// max_context_len:模型的上下文窗口大小(命令行第6个参数,argv[5])param.max_context_len=std::atoi(argv[5]);// skip_special_token=true:忽略模型输出中的特殊标记(比如<|end|>,只显示人类能看懂的文本)param.skip_special_token=true;// 基础域ID(瑞芯微框架的底层参数,新手不用关注)param.extend_param.base_domain_id=1;// 3. 配置图片相关的特殊标记(多模态模型的关键)// 图片开始标记:告诉模型“接下来是图片特征”param.img_start="<|vision_start|>";// 图片结束标记:告诉模型“图片特征结束了”param.img_end="<|vision_end|>";// 图片填充标记:用这个标记占位图片特征的位置(模型内部需要)param.img_content="<|image_pad|>";// 注释里的DeepSeekOCR:适配其他模型时的标记,新手暂时忽略// param.img_start = "";// param.img_end = "";// param.img_content = "<|▁pad▁|>";// 4. 参数校验提示(如果用户只传了7个必选参数,提示自定义标记)if(argc==7){std::cerr<<"[Warning] Using default img_start/img_end/img_content: "<<param.img_start<<" , "<<param.img_end<<" , "<<param.img_content<<". Please customize these values according to your model, "<<"otherwise the output may be incorrect.\n";}// 5. 如果用户传了自定义的图片标记,覆盖默认值if(argc>7)param.img_start=argv[7];if(argc>8)param.img_end=argv[8];if(argc>9)param.img_content=argv[9];

必要参数如下:
image_path:要分析的图片路径(比如./test.jpg);
encoder_model_path:图像编码器模型路径(把图片转成模型能理解的特征向量);
llm_model_path:LLM 模型路径(核心,负责理解文本 + 图片特征,生成回答);
max_new_tokens:模型最多生成多少个 Token(比如 50,就是回答最多 50 个字符左右);
max_context_len:模型能记住的上下文长度(比如 1024,模型最多记住 1024 个 Token 的对话);
rknn_core_num:用 RK 芯片的几个核心跑模型(比如 4,用 4 核加速);

准备阶段:模型初始化

// 1. 定义变量:ret存函数返回值(判断是否成功),t_start_us记录开始时间(统计加载耗时)intret;std::chrono::high_resolution_clock::time_point t_start_us=std::chrono::high_resolution_clock::now();// 2. 初始化LLM模型(核心函数!)// 参数说明:// &llmHandle:输出模型句柄(加载成功后,llmHandle就指向加载好的模型);// &param:上面配置的运行参数;// callback:回调函数(模型生成回答时,会通过这个函数实时打印结果);ret=rkllm_init(&llmHandle,&param,callback);// 3. 判断是否加载成功if(ret==0){printf("rkllm init success\n");// 加载成功,打印提示}else{printf("rkllm init failed\n");// 加载失败,打印提示exit_handler(-1);// 调用退出函数,释放资源并退出}// 4. 统计加载耗时并打印(新手了解即可)std::chrono::high_resolution_clock::time_point t_load_end_us=std::chrono::high_resolution_clock::now();autoload_time=std::chrono::duration_cast<std::chrono::microseconds>(t_load_end_us-t_start_us);printf("%s: LLM Model loaded in %8.2f ms\n",__func__,load_time.count()/1000.0);

rkllm_init:瑞芯微提供的 LLM 初始化函数,作用是 “把模型文件从硬盘读到内存,按参数配置好,准备运行”;
llmHandle:模型加载成功后,这个变量就代表 “加载好的 LLM 模型”,后续推理、释放都要用到它;
callback:回调函数(后面推理阶段会详细说,这里只需要知道:模型生成回答时,会调用这个函数把结果打印出来);
加载耗时:统计模型从硬盘加载到内存的时间(比如 1000ms,就是 1 秒加载完成),方便调试模型加载速度。

准备阶段:初始化图像编码器模型

LLM 不能直接 “看懂” 图片,需要先通过图像编码器把图片转成 “特征向量”(一串数字,代表图片的内容),这部分是加载这个编码器:

// 1. 创建图像编码器的上下文(相当于编码器的“工作空间”)rknn_app_context_trknn_app_ctx;memset(&rknn_app_ctx,0,sizeof(rknn_app_context_t));// 初始化上下文,全部设为0// 2. 记录开始时间(统计加载耗时)t_start_us=std::chrono::high_resolution_clock::now();// 3. 读取RKNN核心数(命令行第7个参数,argv[6])constintcore_num=atoi(argv[6]);// 4. 初始化图像编码器(核心函数!)// 参数说明:// encoder_model_path:编码器模型路径;// &rknn_app_ctx:编码器的上下文(输出,加载成功后存编码器的状态);// core_num:用几个核心跑编码器;ret=init_imgenc(encoder_model_path,&rknn_app_ctx,core_num);// 5. 判断是否加载成功if(ret!=0){printf("init_imgenc fail! ret=%d model_path=%s\n",ret,encoder_model_path);return-1;}// 6. 统计加载耗时并打印t_load_end_us=std::chrono::high_resolution_clock::now();load_time=std::chrono::duration_cast<std::chrono::microseconds>(t_load_end_us-t_start_us);printf("%s: ImgEnc Model loaded in %8.2f ms\n",__func__,load_time.count()/1000.0);

预处理阶段

读取图片:

// The image is read in BGR formatcv::Mat img=cv::imread(image_path);cv::cvtColor(img,img,cv::COLOR_BGR2RGB);

扩展图片为正方形:

// Expand the image into a square and fill it with the specified background color (According the modeling_minicpmv.py)cv::Scalarbackground_color(127.5,127.5,127.5);cv::Mat square_img=expand2square(img,background_color);

expand2square函数的实现如下:

cv::Matexpand2square(constcv::Mat&img,constcv::Scalar&background_color){intwidth=img.cols;// 获取图片宽度(列数)intheight=img.rows;// 获取图片高度(行数)// 如果已经是正方形,直接返回副本(避免修改原图片)if(width==height){returnimg.clone();}// 计算新尺寸:取宽/高中的最大值(保证是正方形)intsize=std::max(width,height);// 创建新的正方形图片,背景色为指定颜色cv::Matresult(size,size,img.type(),background_color);// 计算原图粘贴的位置(居中)intx_offset=(size-width)/2;// 水平方向偏移(左右填充的空白)inty_offset=(size-height)/2;// 垂直方向偏移(上下填充的空白)// 定义粘贴区域(ROI:感兴趣区域)cv::Rectroi(x_offset,y_offset,width,height);// 把原图粘贴到正方形图片的中心img.copyTo(result(roi));returnresult;}

为什么要做正方形填充?
很多视觉模型(包括这里的图像编码器)的输入层是正方形的(比如 224×224、448×448),如果直接拉伸非正方形图片,会导致图片变形(比如把圆形拉成椭圆),模型识别错误;而居中填充能保留图片的原始比例和内容。

缩放到模型要求的尺寸(适配编码器输入)

把正方形图片缩放到编码器要求的尺寸(比如 224×224),让图片刚好匹配编码器的输入层大小。

// Resize the imagesize_timage_width=rknn_app_ctx.model_width;size_timage_height=rknn_app_ctx.model_height;cv::Mat resized_img;cv::Sizenew_size(image_width,image_height);cv::resize(square_img,resized_img,new_size,0,0,cv::INTER_LINEAR);

准备特征向量存储容器(为生成图片特征做准备)

// 读取编码器上下文的关键参数,计算特征向量长度size_tn_image_tokens=rknn_app_ctx.model_image_token;// 图片对应的Token数(比如256)size_timage_embed_len=rknn_app_ctx.model_embed_size;// 每个Token的嵌入维度(比如768)size_tn_embed_output=rknn_app_ctx.io_num.n_output;// 编码器输出的数量(比如1)// 计算特征向量总长度:Token数 × 嵌入维度 × 输出数intrkllm_image_embed_len=n_image_tokens*image_embed_len*n_embed_output;// 创建浮点数组,存储特征向量(初始化为0)floatimg_vec[rkllm_image_embed_len];memset(img_vec,0,rkllm_image_embed_len*sizeof(float));

预处理阶段的代码逻辑可以简化为:
原始图片(BGR格式)→ 转换为RGB格式 → 填充为正方形(避免变形)→ 缩放到模型要求尺寸 → 准备特征向量存储数组

推理阶段

运行图像编码器,生成图片特征向量

// 记录推理开始时间(统计耗时)t_start_us=std::chrono::high_resolution_clock::now();// 运行图像编码器,生成特征向量并填充到img_vec中ret=run_imgenc(&rknn_app_ctx,resized_img.data,img_vec);if(ret!=0){printf("run_imgenc fail! ret=%d\n",ret);}// 统计推理耗时并打印t_load_end_us=std::chrono::high_resolution_clock::now();load_time=std::chrono::duration_cast<std::chrono::microseconds>(t_load_end_us-t_start_us);printf("%s: ImgEnc Model inference took %8.2f ms\n",__func__,load_time.count()/1000.0);

初始化 LLM 输入结构体

// 初始化LLM输入结构体(全部设为0,避免脏数据)RKLLMInput rkllm_input;memset(&rkllm_input,0,sizeof(RKLLMInput));// 初始化LLM推理参数结构体(全部设为0)RKLLMInferParam rkllm_infer_params;memset(&rkllm_infer_params,0,sizeof(RKLLMInferParam));// 配置推理模式:生成式推理(LLM生成回答)rkllm_infer_params.mode=RKLLM_INFER_GENERATE;// 不保留历史对话(每次提问都是独立的,不依赖上一次的回答)rkllm_infer_params.keep_history=0;// 注释掉的聊天模板:自定义对话格式(新手暂时忽略)// rkllm_set_chat_template(llmHandle, "<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n", "<|im_start|>user\n", "<|im_end|>\n<|im_start|>assistant\n");

定义预设提问模板

vector<string>pre_input;pre_input.push_back("<image>What is in the image?");pre_input.push_back("<image>这张图片中有什么?");cout<<"\n**********************可输入以下问题对应序号获取回答/或自定义输入********************\n"<<endl;for(inti=0;i<(int)pre_input.size();i++){cout<<"["<<i<<"] "<<pre_input[i]<<endl;}cout<<"\n*************************************************************************\n"<<endl;

进入交互式推理循环

while(true){// 定义变量存储用户输入std::string input_str;printf("\n");printf("user: ");// 读取用户输入的一行文本(支持空格)std::getline(std::cin,input_str);// 1. 退出指令:输入exit,跳出循环if(input_str=="exit"){break;}// 2. 清空缓存指令:输入clear,清空KV缓存if(input_str=="clear"){ret=rkllm_clear_kv_cache(llmHandle,1,nullptr,nullptr);if(ret!=0){printf("clear kv cache failed!\n");}continue;// 跳过本次循环,等待下一次输入}// 3. 处理预设问题:输入序号(0/1),替换为对应的预设问题for(inti=0;i<(int)pre_input.size();i++){if(input_str==to_string(i)){input_str=pre_input[i];cout<<input_str<<endl;}}// 4. 判断输入类型:纯文本 vs 多模态(图片+文本)if(input_str.find("<image>")==std::string::npos){// 纯文本输入:不含<image>标记,仅用文本推理rkllm_input.input_type=RKLLM_INPUT_PROMPT;rkllm_input.role="user";// 输入角色为“用户”rkllm_input.prompt_input=(char*)input_str.c_str();// 传入用户输入的文本}else{// 多模态输入:包含<image>标记,结合图片特征推理rkllm_input.input_type=RKLLM_INPUT_MULTIMODAL;rkllm_input.role="user";rkllm_input.multimodal_input.prompt=(char*)input_str.c_str();// 文本指令rkllm_input.multimodal_input.image_embed=img_vec;// 图片特征向量rkllm_input.multimodal_input.n_image_tokens=n_image_tokens;// 图片Token数rkllm_input.multimodal_input.n_image=1;// 图片数量(单张)rkllm_input.multimodal_input.image_height=image_height;// 图片高度rkllm_input.multimodal_input.image_width=image_width;// 图片宽度}// 5. 调用LLM推理,生成回答printf("robot: ");rkllm_run(llmHandle,&rkllm_input,&rkllm_infer_params,NULL);}

模块 4.1:退出指令(exit)
用户输入exit,break跳出while(true)循环,程序进入资源释放阶段;
这是程序的 “正常退出入口”。
模块 4.2:清空缓存指令(clear)
rkllm_clear_kv_cache:清空 LLM 的 KV 缓存,重置模型的 “记忆”;
为什么需要清空?如果开启了keep_history=1(多轮对话),模型会记住之前的对话,清空缓存可以让模型 “忘记” 历史,重新开始;
continue:执行完清空缓存后,跳过本次循环的剩余代码,直接等待用户下一次输入。
模块 4.3:预设问题处理
to_string(i):把数字 i 转成字符串(比如 i=0→"0");
如果用户输入 “0”,就把input_str替换为What is in the image?;输入 “1” 则替换为中文预设问题;
替换后打印出来,让用户看到实际传给模型的问题。
模块 4.4:输入类型判断(核心!区分纯文本 / 多模态)
这是多模态 LLM 的关键逻辑,新手重点理解:
input_str.find(“”) == std::string::npos:find函数查找标记,npos表示没找到;
纯文本输入(比如用户输入 “今天天气怎么样?”):
input_type=RKLLM_INPUT_PROMPT:告诉 LLM“这是纯文本输入,不用结合图片”;
role=“user”:LLM 的对话角色定义(区分用户 / 助手 / 系统);
prompt_input:传入用户输入的文本;
多模态输入(比如用户输入这是什么?):
input_type=RKLLM_INPUT_MULTIMODAL:告诉 LLM“这是多模态输入,需要结合图片特征”;
multimodal_input:多模态输入结构体,包含:
prompt:文本指令(比如 “这是什么?”);
image_embed:预处理好的图片特征向量(img_vec);
n_image_tokens:图片 Token 数(从编码器上下文读取);
n_image=1:本次推理用 1 张图片;
image_height/width:图片的尺寸(模型需要这个信息辅助理解)。
模块 4.5:调用 LLM 推理(rkllm_run)
rkllm_run:瑞芯微提供的 LLM 推理核心函数,参数说明:
llmHandle:准备阶段加载的 LLM 模型句柄;
&rkllm_input:封装好的输入信息(纯文本 / 多模态);
&rkllm_infer_params:推理规则(生成模式、是否保留历史);
NULL:用户自定义数据(这里没用);
关键:rkllm_run本身不直接打印回答,而是通过准备阶段注册的callback函数实时打印(下面解析callback函数)。

回调函数(callback):实时接收并打印 LLM 的回答

intcallback(RKLLMResult*result,void*userdata,LLMCallState state){if(state==RKLLM_RUN_FINISH){printf("\n");// 推理完成,换行}elseif(state==RKLLM_RUN_ERROR){printf("\\run error\n");// 推理错误,打印提示}elseif(state==RKLLM_RUN_NORMAL){printf("%s",result->text);// 正常生成,打印当前Token的文本// 注释掉的代码:打印Token的ID和概率(调试用,新手不用关注)// for(int i=0; i<result->num; i++)// {// printf("%d token_id: %d logprob: %f\n", i, result->tokens[i].id, result->tokens[i].logprob);// }}return0;}

###############################################################################
推理阶段的逻辑可以简化为:
生成图片特征向量 → 初始化输入结构体 → 预设提问模板 → 进入交互循环 →
接收用户输入 → 处理特殊指令(exit/clear)→ 替换预设问题 →
判断输入类型(纯文本/多模态)→ 封装输入信息 → 调用LLM推理 →
回调函数实时打印回答 → 循环直到exit → 释放资源

资源释放阶段

// 释放图像编码器资源ret=release_imgenc(&rknn_app_ctx);if(ret!=0){printf("release_imgenc fail! ret=%d\n",ret);}// 释放LLM模型资源rkllm_destroy(llmHandle);return0;// 程序正常退出

release_imgenc:释放图像编码器的上下文、内存等资源;
rkllm_destroy:释放 LLM 模型句柄,把加载到内存的模型释放掉;
这一步是 “收尾工作”,必须执行,否则会导致内存泄漏(尤其是嵌入式设备,内存有限)。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询