
清空记录
历史记录
取消
清空记录
历史记录

基于触觉智能RK3576开发板实测,从终端单次运行改造成多客户端可调用常驻服务,拆解边缘端大模型工程踩坑全细节。通过RKLLM运行时端侧部署量化后的DeepSeek-R1-Distill-1.5B模型。实测视频如下:
单纯把模型跑通其实不难,但想要做到工程可用、适配产品场景,中间藏了大量边缘设备独有的工程难题。
最开始模型只是前台终端程序:加载模型后独占整个终端,所有人想用都得SSH登录敲命令,会话结束进程直接退出,完全达不到服务化标准。
于是我对主程序 [main.cc](main.cc) 做改造,封装出一套 TCP 常驻服务端:模型仅加载一次、常驻内存,任意终端都能随时接入提问,同时支持查看硬件资源、Token 推理数据统计。本文逐段拆解完整源码逻辑,分享内存受限边缘设备部署大模型的实战工程经验。
整体架构
整套程序核心逻辑高度统一:模型常驻内存 + TCP 端口监听 + 单客户端独立线程 + 全局互斥锁串行推理 + Token 流式回传结果。
架构流程图解:
┌──────────────┐ nc 连接 ┌───────────────────────────────┐│ 任意终端 │ ──────────→│ accept → 新建线程 ││ nc/telnet 等 │ 每客户端 │ ├─ 循环读取客户端输入行 │└──────────────┘ 一线程 │ ├─ 分发逻辑:对话问答 / !运维命令 / 帮助提示 │ └─ 获取全局锁 → 调用rkllm_run执行推理 │ │ │ RKLLM回调函数(透传当前连接fd) │ └─ 每生成一个Token,实时流式写回客户端 └───────────────────────────────┘
这里有一个关键硬件约束:RKLLM 提供的rkllm_run是同步阻塞接口,同一时间仅能执行一次推理。
因此多客户端并发不能让每个连接独立推理,正确方案是「连接相互独立,推理排队串行」:通过一把全局互斥锁将推理逻辑串行执行,抢到锁的客户端执行推理,其余客户端阻塞等待。
这套方案既能避免多推理并行导致内存溢出,也能防止不同对话输出内容交错混乱。
源码逐段完整拆解
LLMHandle llmHandle = nullptr; // 全局唯一模型句柄static int generated_token_count = 0; // 本轮推理已生成Token总数static int prompt_token_count = 0; // 本轮输入Prompt预估Token数static steady_clock::time_point run_start_time; // 单次推理计时起点static std::mutex g_infer_mutex; // 推理串行互斥锁
llmHandle为模型加载后的唯一句柄,全局全程复用不重复创建。
三个静态变量负责单次推理的 Token 数据统计,g_infer_mutex管控推理串行执行。全部使用 static 修饰,是典型单例服务写法:全局仅加载一份模型,同一时间只执行一条推理任务。
#define PROMPT_TEXT_PREFIX "<|begin▁of▁sentence|><|User|>"#define PROMPT_TEXT_POSTFIX "<|Assistant|>"
这类特殊符号是 DeepSeek 系列模型专属对话模板定界符。用户提问内容需要包裹在两段标识中间,模型才能精准区分用户输入与模型回复。
划重点:更换其他大模型时,必须同步替换对应对话模板,这是接入开源大模型最高频踩坑点。
void exit_handler(int signal) { rkllm_destroy(llmHandle); // 主动释放模型、NPU硬件资源 exit(signal);}signal(SIGINT, exit_handler); // 捕获Ctrl+C终止信号signal(SIGTERM, exit_handler); // 捕获kill进程终止信号signal(SIGPIPE, SIG_IGN); // 忽略管道断开信号三类信号各司其职:
1.SIGINT/SIGTERM:收到终止信号时主动销毁模型,完整释放 NPU 与内存资源,实现优雅退出;
2.SIGPIPE 必须忽略:若客户端提前断开连接,程序向 Socket 写入数据会触发该信号,不做处理会直接导致整个服务进程崩溃,是网络服务开发基础知识点。
// 中文等CJK字符约1个Token/字,英文4个字符折合1个Tokenstatic int estimate_prompt_tokens(const std::string &s) { ... }当前版本 RKLLM SDK 存在功能缺失:未开放 Prompt 精确 Token 计数接口。
只能基于 UTF-8 字符做启发式估算,通过识别中日韩字符单独统计,英文多字符合并折算。数值仅作量级参考,会和真实 Token 数量存在小幅偏差,因此后台统计会标注「估算值」。
整套服务最核心的代码逻辑:
void callback(RKLLMResult *result, void *userdata, LLMCallState state) { int sockfd = *(int*)userdata; // 获取当前客户端对应的Socket文件描述符 if (state == RKLLM_RUN_NORMAL) { generated_token_count++; send_text(sockfd, result->text); // 生成单个Token立刻推送给客户端 } else if (state == RKLLM_RUN_FINISH) { // 打印本轮完整统计:输入Token/生成Token/推理耗时/生成速度tokens/s }}两处关键精巧设计:
1.userdata透传Socket fd:RKLLM 的 rkllm_run 支持透传自定义指针至回调函数。服务同时维护多条客户端连接,回调函数必须区分当前推理结果归属哪个客户端,将每条连接的 fd 存入 userdata 完美解决消息分发匹配问题;
2.每一次 RKLLM_RUN_NORMAL 回调对应单个生成 Token。统计 Token 无需额外工具,直接在回调内累加计数,结合推理耗时即可算出实时生成速度,零成本实现性能监控。
// 输入内容以!开头,则在开发板本地执行对应系统命令if (input_str[0] == '!') send_text(sockfd, run_shell_command(input_str.substr(1)));
除常规对话问答外,程序内置运维快捷指令,输入!free -h、!cat /proc/rknpu/load即可直接查询板子内存、NPU负载。无需新开SSH终端,一边调用大模型一边监控硬件资源,运维效率大幅提升。
这里存在一处实测踩坑点:原始run_shell_command采用 popen实现,底层依赖fork创建子进程。fork会完整复制父进程 2GB 左右的模型与 KV 缓存地址空间,在仅4GB LPDDR5内存的开发板上,执行瞬间内存占用暴涨,极易触发系统 OOM杀死服务进程。
后续Agent工具调用版本中替换为posix_spawn(glibc底层基于clone实现,不复制完整内存),彻底根治OOM问题。
while (true) { int conn = accept(listen_fd, ...); // 持续阻塞等待新客户端连接 std::thread(handle_client, conn).detach(); // 每个连接创建独立分离线程}主函数完成端口绑定、TCP监听后进入无限循环:每接入一个客户端,创建分离线程单独处理交互逻辑。
线程间共享资源仅为推理锁、Token统计变量,推理逻辑被锁包裹,统计变量仅在锁保护下读写,不存在线程安全问题。
边缘落地关键细节
方案设计 | 对应解决的线上坑点 |
userdata 透传 Socket fd | 多客户端场景下,回调无法区分结果该推送至哪个连接 |
全局互斥锁串行推理 | 同步推理接口支撑多并发连接的唯一可行方案,避免多对话输出内容错乱 |
忽略 SIGPIPE 信号 | 客户端提前断线时,防止整套服务直接崩溃 |
输出内容限制最大 64KB | 避免执行!cat /dev/mem这类指令,海量数据打满客户端缓冲区、占满带宽 |
使用 posix_spawn 替代 fork | fork复制完整LLM 进程地址空间,4GB小内存设备直接触发OOM |
方案延伸调用Agent
这份单文件 TCP 服务只是基础底座,基于同一套架构,我们已迭代出增强版 Agent 程序,实现模型自主调用系统命令(ReAct 工具调用流程):
用户提问「当前板子内存占用多少」,模型自动输出工具调用标识
依托这套底层骨架,还能持续拓展更多功能:
1.多类型工具接入:文件读写、网络请求、外设传感器控制、硬件驱动操作;
2.Prompt 缓存机制:缓存固定系统提示词,减少每轮推理重复预处理开销;
3.多模型动态切换:简单轻量任务加载小模型提速,复杂逻辑切换高精度大模型;
4.定时巡检任务:模型周期性读取板子硬件状态,自动识别异常并输出告警。
总结
整套单文件服务端代码体量并不大,但完整覆盖了边缘受限设备将大模型服务化的全部核心知识点:同步推理运行时如何支撑多客户端接入、流式输出的消息分发实现、零成本 Token 性能统计,以及只有真机跑在 4GB 小内存开发板上才会遇到的 OOM、管道信号崩溃等独特问题。
