基于端侧大模型的嵌入式 Skill 调度引擎实现智能业务生成的尝试

前言

近期在研究大模型(LLM)Skill(技能) 的结合应用,颇有感触。人类拥有绘画、编程等技能,依靠眼和手来完成;类比到嵌入式设备中,图像采集、数据传输同样是技能,只不过它们依靠板载硬件来完成。

通常,一块开发板的硬件资源是固定的,但上层应用是多变的。同一块板子,既可以在项目 A 中使用,也可以在项目 B 中使用,本质都是基于固定的硬件资源进行开发。因此,我们可以认为这块板子具备与生俱来的固有技能,而形形色色的应用,无非是对这些基础技能的组合与编排。

有人说过,电脑是人脑的延伸;如今,大模型则是人脑的近似。它能像人一样思考任务:先分析子目标,再进行规划,最后按部就班地执行。这正是我想实现的——基于大模型和 Skill 的智能业务流生成

本文旨在验证上述想法,核心目标是将用户的自然语言指令转换为可执行的硬件操作序列。把业务逻辑交给大模型,让它自行编排工作流,而我不再编写繁琐的业务代码,仅需定义好板子的技能和执行引擎,便可做到“换板子即换技能”。


准备工作

硬件环境

本次实验使用的是英飞凌 OK1126BJ-S 开发板,其核心板为 FET1126BJ-S

  • SoC:基于 Rockchip RV1126BJ 处理器

  • CPU:集成 4 个 ARM Cortex-A53 核心

  • NPU:内置独立 NPU,具备 3 TOPS @ INT8​ 算力

软件环境

使用瑞芯微官方提供的 rknn-llm 开发套件。

仓库地址:GitHub - airockchip/rknn-llm

模型获取

可直接下载预转换的 RKLLM 模型文件:

下载地址:[rkllm_model_zoo](https://console.box.lenovo.com/l/l0tXb8)
提取码:rkllm

image

兼容性说明

支持平台

  • RK3588 Series

  • RK3576 Series

  • RK3562 Series

  • RV1126B Series

支持模型

  • ✅ LLAMA / TinyLLAMA

  • ✅ Qwen2 / Qwen2.5 / Qwen3 / Qwen3.5

  • ✅ Phi2 / Phi3

  • ✅ ChatGLM3-6B

  • ✅ Gemma 系列

  • ✅ InternLM2

  • ✅ MiniCPM3 / MiniCPM4

  • ✅ TeleChat2

  • ✅ Qwen2-VL / Qwen3-VL

  • ✅ MiniCPM-V-2_6

  • ✅ DeepSeek-R1-Distill

  • ✅ Janus-Pro-1B

  • ✅ InternVL 系列

  • ✅ SmolVLM / SmolLM3

  • ✅ RWKV7

  • ✅ DeepSeekOCR


部署大模型 (LLM)

1. 确认板端基础信息

在部署前,需确认板子的架构、内存及 NPU 驱动版本,以便选择合适的模型。

# 查看架构,应为 aarch64
uname -m

# 查看内存容量
cat /proc/meminfo | head -2

# 检查 NPU 驱动版本 (RV1126B 需 v0.9.8,配合 toolkit2 v2.3.2)
dmesg | grep -i npu
cat /sys/kernel/debug/rknpu/version

# 查看 RKNN Runtime 版本
strings /usr/lib/librknnrt.so | grep "librknnrt version"

经验之谈:硬件资源越丰富,可承载的模型体量就越大。鉴于 RV1126B 的资源限制,我选择了 Qwen3.5-0.8B 量化版本。

对应的模型文件是Qwen3.5-0.8B_w4a16_g128_rv1126b.rkllm,当然如果需要使用其他的模型,也可以做转换部署

2. PC 端环境搭建 (Ubuntu 22.04)

为了方便管理依赖,我选择在 Docker 容器中构建环境。

# 克隆仓库
git clone https://github.com/airockchip/rknn-llm.git
cd rknn-llm/rkllm-toolkit/packages

# 安装 Python 依赖
pip install -r requirements.txt

# 安装 Toolkit (选择与 Python 版本匹配的 wheel 文件)
pip install rkllm_toolkit-*.whl

3. 编译 Demo

修改官方例程 rkllm_api_demo 中的 llm_demo.cpp,优化解码参数:

/* embed_flash: 0 表示将权重加载至 DRAM,资源充足时更稳定 */
param.extend_param.embed_flash = 0;

param.top_k = 10;       // 贪心解码,过小会导致输出呆板
param.top_p = 0.9;
param.temperature = 0.8;

修改交叉编译脚本 build-linux.sh,指定编译器路径:

C_COMPILER=/usr/bin/aarch64-linux-gnu-gcc
CXX_COMPILER=/usr/bin/aarch64-linux-gnu-g++
STRIP_COMPILER=/usr/bin/aarch64-linux-gnu-strip

执行编译:

./build-linux.sh

4. 板端部署与测试

将编译产物拷贝至开发板:

  1. llm_demo (可执行文件)

  2. Qwen3.5-0.8B_w4a16_g128_rv1126b.rkllm (模型文件)

  3. librkllmrt.so (Runtime 库,注意:Qwen3.5 需要 RKLLM v1.3.0 支持)

调整系统参数并运行:

# 增大文件句柄上限,防止 NPU 内存管理报错
ulimit -HSn 102400

# 启动 Demo (max_new_tokens + max_context)
./llm_demo Qwen3.5-0.8B_w4a16_g128_rv1126b.rkllm 1024 1024

实测日志与分析

rkllm init start
I rkllm: rkllm-runtime version: 1.3.0, rknpu driver version: 0.9.8
I rkllm: loading model... max_context_limit: 4096, npu_core_num: 1
rkllm init success

user: 你是一个严谨的数学助手... 鸡兔同笼问题...
robot: ### 1. 设定变量
设鸡的数量为 $x$,兔子的数量为 $y$。
...(省略推导过程)...
**答:鸡有 9 只,兔子有 5 只。**

踩坑记录

  1. 截断问题:初始参数设为 256+512 时,解题过程常被截断。需调整为 1024+1024(需满足 32 对齐)。

  2. 逻辑错误:小模型容易啰嗦且中途改答案。通过优化 Prompt(如要求“精简逻辑”)和增加输出长度解决。


端侧 LLM + Function Calling(Tool Use)

设计理念:Skill 调度与 LLM Agent

本项目的核心是将大模型作为“大脑”,Skill 作为“手脚”。通过组合多个 Skill 实现复杂的业务流。这在嵌入式领域常被称为 “Skill 调度”​或 “Edge Agent”。

由于 RV1126B 只能运行 0.8B 级别的小模型,其逻辑推理能力有限。为了保证系统的稳定性,我采用了“分阶段架构”,将复杂的自然语言理解任务拆解,结合代码的确定性逻辑,确保最终执行结果的可靠性。

之前尝试依赖大模型实现大部分的功能,包括意图识别技能映射以及任务编排。只有执行引擎是纯代码实现。可是效果很不理想,做了很多优化,调整prompt,堆示例,分解模型调用层级,减小每次模型任务难度等,但最终仍不达标

为了增加模型输出稳定性,同时保证通用性,经过调整,意图识别这一块一定是LLM占主导,所以怎么调用LLM也是一个可设计的点,同时在其他地方增加约束,这样才能达到一个良好的效果。

技术栈概览

层次 技术 版本 说明
语言 C/C++ C++11 主程序 C++,核心模块 C
JSON库 cJSON - 轻量级解析
LLM框架 rkllm v1.3.0 瑞芯微推理框架
构建工具 CMake 3.28+ 跨平台构建
编译目标 Linux aarch64 - RV1126B 平台

核心设计原则

  1. 数据驱动:Skill 定义、作用域规则全部通过 skills.json 配置,代码仅负责解析和执行。

  2. 输入制式:以逗号分割输入,每个切片对应一个动作序列,降低模型分割难度。

  3. LLM 做选择题:阶段一让 LLM 从给定索引列表中选择,而非自由生成文本,大幅降低输出难度,这些索引列表从skills.json中动态生成。

  4. 确定性操作:阶段二、三完全由代码实现,杜绝 LLM 幻觉。

  5. 栈式作用域:通过栈算法处理控制流,支持嵌套和切换。


整体架构:三阶段流水线

为了克服小模型的局限性,系统被拆分为三个严格的阶段:

用户输入 → 阶段一(意图识别) → 阶段二(技能映射) → 阶段三(结构解析) → 执行引擎

整体架构框图如下,展示各层次的组件和数据流:

image

阶段一:意图识别(LLM 主导)

目标:将用户的自然语言切片转换为 索引|文本切片,并提取参数。

实现逻辑

  1. 预处理:代码层按逗号(支持中英文)切分用户输入。

  2. 索引识别:针对每个切片,调用 LLM 识别对应的 Skill 索引(数字)。

  3. 参数提取:针对识别出的索引,调用 LLM 提取参数,采用“填空题”格式(key=value)。

关键代码逻辑

🔎索引识别流程:

// 1. 预处理:清理不可见字符
char *input_copy = strdup(user_input);
// ... 字符过滤 ...

// 2. 按逗号切片
while (remaining && *remaining) {
    char slice_buf[256];
    // ... 提取切片 ...

    // 3. 构造 Prompt 并调用 LLM
    std::string prompt = "<|im_start|>system\n" + system_prompt + ...;
    llm_run_with_prompt(handle, prompt.c_str(), params);
    
    // 4. 解析索引
    skill_index = atoi(strip_markdown(g_collected_text));
}

设计要点

  • 根据skills.json动态生成prompt

  • 不让模型直接输出技能名称,而是输出数字索引,极大降低了拼写错误的概率。

  • 输入切片,不仅保留了自然语言的输入,同时减少了大模型任务难度

🔎参数提取实现

const char *skill_registry_build_param_extract_prompt(int skill_index, const char *text_slice) {
    // 动态生成参数提取Prompt
    // 格式:填空题形式,如 "device=[ ]"
    
    skill = get_skill_by_index(skill_index);
    append "从输入切片中提取技能 " + skill.skill_id + " 的参数值。\n";
    
    if (skill.pattern) {
        append "该技能的模式是:" + skill.pattern + "\n";
    }
    
    // 列出参数选项
    for each param in skill.input_schema:
        if param.type is "enum":
            append "- " + param.name + ": [" + join(param.values, ", ") + "]\n";
        else:
            append "- " + param.name + ": 数字\n";
    
    // 生成填空题
    append "输入切片:" + text_slice + "\n";
    for each param in skill.input_schema:
        append "- " + param.name + "=[ ]\n";
}

设计要点

  • 针对单一技能的参数提取,复用性高,通用性强

  • 以技能的input_schema字段作为提取准则,字段包含了参数类型,可以时字符串,数字以及枚举

阶段二:技能映射(代码确定性执行)

目标:将阶段一得到的索引,100% 准确地映射为具体的技能名称和数据结构。

核心数据结构

typedef struct {
    char skill_id[64];     // 技能名
    int index;             // 索引号
    char category[32];     // action / structural
    // ... 作用域管理字段 (scope_action, scope_type等)
} skill_info_t;

映射逻辑

const char *skill_registry_find_by_index(int index) {
    for (int i = 0; i < g_skill_count; i++) {
        if (g_skill_table[i].index == index) {
            return g_skill_table[i].skill_id; // 精确查找
        }
    }
    return NULL;
}

设计要点

  • 解耦:技能定义(JSON)与技能实现(Handler 函数)分离。

  • 注册机制:支持动态注册新的技能 Handler,无需修改核心逻辑。

阶段三:结构解析(数据驱动的树构建)

目标:将扁平的技能列表转换为树状的 AST(抽象语法树),以支持 Loop 和 If-Else 等控制流。

核心算法:栈式作用域管理

初始化:栈顶为 ROOT
遍历技能列表:
  ├─ action技能 → 压入当前栈顶的任务数组
  └─ structural技能:
       ├─ open (如 loop, if) → 创建父节点,Push 新作用域
       ├─ switch (如 else) → 切换当前作用域 (IF_THEN -> IF_ELSE)
       └─ close (如 end) → Pop 作用域
结束:自动 Pop 所有未关闭的作用域

IF-ELSE 切换逻辑

// 采用 Pop-then-Push 策略
parser->stack_top--; // 退出 IF_THEN
// 创建 ELSE 分支数组
parser->stack_top++; // 进入 IF_ELSE

else分支的作用域切换采用"pop-then-push"策略,先退出IF_THEN作用域,再进入IF_ELSE作用域,共享同一个parent_node(condition对象)。

变量自动绑定

当 Action 技能(如 read)执行完毕后,系统会根据 output_schema 自动将其结果存入变量池。

例如:read 读取电压后,自动生成变量 $read_电压,供后续的 if 条件判断使用。

前三阶段流程示例

以输入"先关灯,每隔3秒,读取电压,若大于1V,再开灯,否则,关灯,若小于0.5V,退出任务"为例:

技能列表(阶段二输出):
1. close (action) → 添加到ROOT作用域
2. loop (structural, open) → 创建loop节点,push LOOP_BODY作用域
3. read (action) → 添加到LOOP_BODY作用域,自动绑定 read_电压
4. if (structural, open) → 创建condition节点,push IF_THEN作用域
5. open (action) → 添加到IF_THEN作用域
6. else (structural, switch) → pop IF_THEN,push IF_ELSE作用域
7. close (action) → 添加到IF_ELSE作用域
8. if (structural, open) → 创建condition节点,push IF_THEN作用域
9. exit_task (action) → 添加到IF_THEN作用域

遍历结束,自动关闭所有作用域:
- pop IF_THEN (深度3 → 2)
- pop IF_ELSE (深度2 → 1)
- pop LOOP_BODY (深度1 → 0)

最终任务结构:
{
  "tasks": [
    {"type":"action","skill_name":"close",...},
    {"type":"loop","interval":3,"tasks":[
      {"type":"action","skill_name":"read","store":"read_电压",...},
      {"type":"condition","condition":{"a":"$read_电压","op":">=","b":1},
        "then":[{"type":"action","skill_name":"open",...}],
        "else":[
          {"type":"action","skill_name":"close",...},
          {"type":"condition","condition":{"a":"$read_电压","op":"<","b":0.5},
            "then":[{"type":"action","skill_name":"exit_task",...}]}
        ]
      }
    ]}
  ]
}

执行引擎(递归下降执行)

目标:遍历 AST,执行具体的硬件操作。

执行上下文

typedef struct {
    cJSON *vars;           // 变量池 (键值对)
    int loop_depth;        // 循环深度
    int should_exit;       // 退出标志
} exec_context_t;

任务执行主流程

static int execute_task_internal(cJSON *tasks_array, exec_context_t *ctx, cJSON **results_array) {
    cJSON *task_item;
    cJSON_ArrayForEach(task_item, tasks_array) {
        cJSON *type_item = cJSON_GetObjectItemCaseSensitive(task_item, "type");
        const char *type = type_item ? type_item->valuestring : "";
        
        if (strcmp(type, "action") == 0) {
            execute_action(task_item, ctx, results_array);
        } else if (strcmp(type, "loop") == 0) {
            execute_loop(task_item, ctx, results_array);
        } else if (strcmp(type, "condition") == 0) {
            execute_condition(task_item, ctx, results_array);
        }
        
        // 检测退出标志
        if (ctx->should_exit) {
            break;
        }
    }
    return 0;
}

三大执行器

  1. Action Executor:调用具体的技能 Handler(如点亮 LED)。

  2. Loop Executor:循环执行子任务,支持 sleepexit_task 中断。

  3. Condition Executor:解析 $variable,比较数值,决定执行 Then 还是 Else 分支。

工作流程

主要流程伪代码如下:

// 阶段一:索引识别
prompt = skill_registry_build_phase_one_prompt();
rkllm_run(llmHandle, prompt, callback);
// 解析输出:索引|文本切片

// 阶段一:参数提取
for each intent:
    prompt = skill_registry_build_param_extract_prompt(index, text_slice);
    rkllm_run(llmHandle, prompt, callback);
    // 解析参数

// 阶段二:技能映射
for each intent:
    skill_name = skill_registry_find_by_index(index);
    category = skill_registry_get_category(skill_name);
    // 构建技能列表

// 阶段三:结构解析
struct_parser_execute(skills, &tasks);

// 执行引擎
execute_tasks(tasks);

下面是一张完整时序图:

image

这张时序图,描述从用户输入到硬件操作的完整执行流程


核心模块详解

文件 职责 语言
llm_demo.cpp 主流程控制,串联三阶段 C++
skill_registry.c/h 技能加载、Prompt 生成、索引映射 C
skill_struct_parser.c/h 栈式解析,AST 构建 C
skill_task.cpp/h 任务执行引擎 C++
skill_*.cpp 具体技能实现 (LED, ADC等) C++
skills.json 技能定义与配置 JSON

接下来挑几个重点内容介绍一下。

技能定义 (skills.json)

为了让 0.8B 模型更好地理解,Skill 定义需遵循“对 LLM 友好”的原则:动词化命名、枚举化参数、口语化描述

{
  "skill_id": "read",
  "index": 3,
  "category": "action",
  "trigger_keywords": ["读取", "获取"],
  "input_schema": {
    "target": { "type": "enum", "values": ["电压", "电流"] }
  },
  "output_schema": { "value": "float" }
}

技能分类

action技能(实际执行操作):

  • open/close:打开/关闭
  • delay:延时等待
  • read:读取
  • write:参数设置
  • compare:数值比较
  • exit_task:退出任务

structural技能(控制流结构,由结构解析器处理):

  • loop:循环控制
  • if:条件判断
  • else:条件分支
  • end:作用域结束

技能注册中心(skill_registry.c)

职责:加载技能定义、动态生成Prompt、索引到技能映射

核心函数

函数 说明
skill_registry_init() 加载skills.json,初始化技能表
skill_registry_build_phase_one_prompt() 动态生成阶段一索引识别Prompt
skill_registry_build_param_extract_prompt() 动态生成参数提取Prompt
skill_registry_find_by_index() 通过索引查找技能名称
skill_registry_dispatch() 分发技能执行,设置exit_task标志

设计要点

  • Prompt完全动态生成,基于skills.json配置
  • 支持category和scope规则字段的加载
  • skill_registry_dispatch新增exec_ctx参数,用于设置退出标志

下面给出skill_registry_dispatch函数的代码:

/**
 * @brief 技能分发器:根据技能名称查找并执行对应的注册函数
 * @param skill_name 技能名称(可能是别名,需经 map 转换)
 * @param params_json 技能参数字符串(JSON格式)
 * @param output_result 输出参数,用于接收技能执行结果(JSON对象)
 * @param exec_ctx 执行上下文,包含变量池、循环深度、退出标志等
 * @return 0表示成功,非0表示失败
 */
int skill_registry_dispatch(const char *skill_name, const char *params_json,
                            cJSON **output_result, exec_context_t *exec_ctx)
{
    // ....

    //  技能名称映射:支持使用别名调用,获取真实的技能ID
    //    例如:将 "led_on" 映射为标准的 "open"
    const char *mapped_name = skill_registry_map_id(skill_name);
    // 使用映射后的标准名称进行后续查找
    skill_name = mapped_name;

    // 4. 遍历全局技能表,查找匹配的技能
    for (int i = 0; i < g_skill_count; i++) {
        // 比较技能表中的 ID 与目标技能名
        if (strcmp(g_skill_table[i].skill_id, skill_name) == 0) {
            // 4.1 检查该技能是否已注册具体的处理函数(handler)
            if (g_skill_table[i].handler) {
                // 打印执行日志,便于调试
                printf("[执行技能: %s]\n", skill_name);
                printf("参数: %s\n", params_json);

                // 4.2 获取该技能的私有上下文(用于存储状态或硬件句柄)
                skill_context_t *ctx = skill_registry_get_context(skill_name);

                // 4.3 特殊处理:如果是 exit_task 技能,设置上下文的退出标志
                //     这是一个信号,通知外层循环(如 execute_loop)终止执行
                if (strcmp(skill_name, "exit_task") == 0 && exec_ctx) {
                    exec_ctx->should_exit = 1;
                }

                // 4.4 调用注册的处理函数,执行具体硬件操作
                return g_skill_table[i].handler(params_json, output_result, ctx);
            } else {
                // 技能存在但未绑定实现函数
                fprintf(stderr, "技能 [%s] 未注册处理器\n", skill_name);
                return -1;
            }
        }
    }

    // 5. 遍历完整个表仍未找到,返回错误
    fprintf(stderr, "未找到技能: %s\n", skill_name);
    return -1;
}

下面是阶段一意图识别需要用到的prompt片段:

"你是一个指令解析器,从输入切片中识别技能索引。\n\n"

"输出格式:只输出数字索引,不要输出其他任何内容\n"

"示例:\n"

"输入:开灯\n输出:0\n\n"

"输入:等待3秒\n输出:2\n\n"

"输入:若电压大于12V\n输出:7\n\n"

"输入:否则\n输出:8\n\n"

"输入:结束\n输出:9\n\n"

"规则:\n"

"1. 索引必须从下方技能索引列表中选择\n"

"2. 如果找不到匹配的触发词,输出-1\n"

"3. 只输出一个数字,不要输出其他文字\n\n");

结构解析器(skill_struct_parser.c)

作用域规则(在skills.json中配置):

技能 scope_action scope_type child_scope_type 说明
loop open LOOP LOOP_BODY 打开循环作用域
if open IF IF_THEN 打开条件作用域(then分支)
else switch IF - 切换到else分支
end close - - 关闭作用域

作用域栈数据结构

typedef struct {
    cJSON *tasks_array;      // 当前作用域的任务数组(action技能添加到这里)
    char scope_type[32];     // 作用域类型:ROOT, LOOP_BODY, IF_THEN, IF_ELSE
    cJSON *parent_node;      // 父节点(loop/condition对象)
} scope_stack_item_t;

typedef struct {
    scope_stack_item_t stack[MAX_SCOPE_DEPTH];  // 作用域栈(最大深度8)
    int stack_top;                               // 栈顶指针
    cJSON *root_tasks;                           // 根任务数组
} struct_parser_t;

举例:IF作用域打开实现代码:

static void handle_scope_open(struct_parser_t *parser, const char *scope_type, 
                              const char *child_scope_type, const char *skill_name, cJSON *params) {
    cJSON *parent_node = cJSON_CreateObject();
    
    if (strcmp(scope_type, "IF") == 0) {
        // 创建condition任务节点
        cJSON_AddStringToObject(parent_node, "type", "condition");
        cJSON_AddItemToObject(parent_node, "condition", build_condition_json(params));
        
        // 创建then分支任务数组
        cJSON *then_tasks = cJSON_CreateArray();
        cJSON_AddItemToObject(parent_node, "then", then_tasks);
        
        // 将父节点添加到当前作用域
        cJSON_AddItemToArray(parser->stack[parser->stack_top].tasks_array, parent_node);
        
        // push新作用域(IF_THEN)
        parser->stack_top++;
        parser->stack[parser->stack_top].tasks_array = then_tasks;
        strncpy(parser->stack[parser->stack_top].scope_type, "IF_THEN", ...);
        parser->stack[parser->stack_top].parent_node = parent_node;
    }
    // ... LOOP类型类似
}

变量自动绑定

static const char *auto_bind_variable(const char *skill_name, cJSON *params) {
    // 获取技能的output_schema
    cJSON *output_schema = skill_registry_get_output_schema(skill_name);
    if (!output_schema) return NULL;
    
    // 检查是否包含value字段(表示有数值输出)
    cJSON *value_field = cJSON_GetObjectItemCaseSensitive(output_schema, "value");
    if (!value_field) return NULL;
    
    // 生成变量名:skill_name_target
    cJSON *target_item = cJSON_GetObjectItemCaseSensitive(params, "target");
    const char *target = target_item ? target_item->valuestring : "";
    
    static char var_name[64];
    snprintf(var_name, sizeof(var_name), "%s_%s", skill_name, target);
    return var_name;  // 如:read_电压
}

当 Action 技能(如 read)执行完毕后,系统会根据 output_schema 自动将其结果存入变量池。

例如:read 读取电压后,自动生成变量 $read_电压,供后续的 if 条件判断使用。

任务执行引擎(skill_task.cpp)

职责:执行任务流,管理执行上下文

执行器类型

执行器 处理任务类型 说明
execute_action() action 调用技能处理器,存储结果
execute_loop() loop 循环执行子任务,检测退出标志
execute_condition() condition 条件判断,执行then/else分支

举例:循环执行实现

static int execute_loop(cJSON *loop_node, exec_context_t *ctx, cJSON **results_array) {
    cJSON *interval_item = cJSON_GetObjectItemCaseSensitive(loop_node, "interval");
    cJSON *body_tasks = cJSON_GetObjectItemCaseSensitive(loop_node, "tasks");
    
    int interval = interval_item && cJSON_IsNumber(interval_item) 
                   ? cJSON_GetNumberValue(interval_item) : 3;
    
    ctx->loop_depth++;
    
    int loop_count = 0;
    while (1) {
        // 检测退出标志
        if (ctx->should_exit) {
            printf("[循环执行] 收到退出信号,退出循环\n");
            break;
        }
        
        loop_count++;
        printf("[循环执行] 第 %d 轮\n", loop_count);
        
        // 执行循环体任务
        cJSON *round_results = cJSON_CreateArray();
        int ret = execute_task_internal(body_tasks, ctx, &round_results);
        
        // 再次检测退出标志(循环体内部可能调用exit_task)
        if (ctx->should_exit) {
            cJSON_Delete(round_results);
            break;
        }
        
        // 等待interval秒
        if (interval > 0) {
            printf("[循环执行] 等待 %d 秒...\n", interval);
            sleep(interval);
        }
    }
    
    ctx->loop_depth--;
    return 0;
}

变量引用机制

static double get_value(cJSON *item, exec_context_t *ctx) {
    if (!item) return 0;
    
    if (cJSON_IsString(item) && item->valuestring) {
        const char *str_val = item->valuestring;
        
        // 变量引用格式:$var_name
        if (str_val[0] == '$') {
            const char *var_name = str_val + 1;
            cJSON *var_item = exec_context_get_var(ctx, var_name);
            if (var_item) {
                if (cJSON_IsNumber(var_item)) {
                    return cJSON_GetNumberValue(var_item);
                } else if (var_item->valuestring) {
                    return atof(var_item->valuestring);
                }
            }
            return 0;
        }
        
        // 字符串形式的数字
        return atof(str_val);
    } else if (cJSON_IsNumber(item)) {
        return cJSON_GetNumberValue(item);
    }
    
    return 0;
}

整个变量机制分为三个阶段: 命名 → 赋值 → 使用

  • 通过auto_bind_variable自动命名变量
    变量名格式: {技能名}_{目标参数},只有当技能的 output_schema 包含 value 字段时才会自动绑定变量,这个变量名会写入任务结构的 store 字段

  • 多种提取类型(voltage、result、value)

voltage技能输出 ( skill_voltage.cpp ):

cJSON_AddNumberToObject(*output_result, "voltage", 
voltage);  // 输出 voltage 字段

compare技能输出 ( skill_compare.cpp ):

cJSON_AddBoolToObject(*output_result, "result", result);  // 
输出 result 字段

赋值阶段 -- execute_action 函数

if (store_item && cJSON_IsString(store_item) && step_result) {
    // 按优先级查找:voltage → result → value
    cJSON *result_val = cJSON_GetObjectItemCaseSensitive(step_result, "voltage");
    if (!result_val) {
        result_val = cJSON_GetObjectItemCaseSensitive(step_result, "result");
    }
    if (!result_val) {
        result_val = cJSON_GetObjectItemCaseSensitive(step_result, "value");
    }
    
    // 将值存储到exec_context
    if (result_val && cJSON_IsNumber(result_val)) {
        exec_context_set_var(ctx, store_item->valuestring, result_val);
        printf("  - 存储变量: %s = %.2f\n", store_item->valuestring, cJSON_GetNumberValue(result_val));
    }
}

执行流程 :

  1. 获取任务结构中的 store 字段值(如 read_电压 )
  2. 调用技能处理器,获取 step_result (如 {"status":"success","voltage":0.47} )
  3. 按优先级查找字段:先找 voltage ,找不到找 result ,再找不到找 value
  4. 将找到的值存入 ctx->vars["read_电压"]

退出机制

// skill_registry_dispatch中设置退出标志
int skill_registry_dispatch(const char *skill_name, const char *params_json, 
                            cJSON **output_result, exec_context_t *exec_ctx) {
    // ... 查找技能handler
    
    if (strcmp(skill_name, "exit_task") == 0 && exec_ctx) {
        exec_ctx->should_exit = 1;  // 设置退出标志
    }
    
    return g_skill_table[i].handler(params_json, output_result, ctx);
}

exit_task 技能会设置 ctx->should_exit = 1,各执行器在循环中检测该标志,实现优雅退出。

新增技能流程

  1. 定义:在 skills.json 中添加技能元数据。

  2. 实现:编写 skill_xxx.cpp,实现具体的硬件操作逻辑。

  3. 注册:在 Registry 中注册 Handler。


实战演示

启动应用,加载模型和技能库:

./llm_demo ../Qwen3.5-0.8B_w4a16_g128_rv1126b.rkllm 1024 1024 ./skills.json

输入指令

先关灯,每隔3秒,读取电压,若大于1V,再开灯,否则,关灯,若小于0.5V,退出任务

系统执行流程

[阶段一] 识别到 9 个意图
  - 索引: 1, 技能: close, 参数: device=灯
  - 索引: 6, 技能: loop, 参数: interval=3
  - ... (省略中间过程)

[阶段二] 映射到 9 个技能

[阶段三] 结构解析完成,生成 AST
{
  "tasks": [
    {"type":"action","skill_name":"close",...},
    {"type":"loop","interval":3,"tasks":[
      {"type":"action","skill_name":"read","store":"read_电压",...},
      {"type":"condition","condition":{"a":"$read_电压","op":">=","b":1},
        "then":[{"type":"action","skill_name":"open",...}],
        "else":[...]}
    ]}
  ]
}

[执行引擎] 开始运行
------------------------
[任务执行] 子任务: close -> LED 0 已关闭
[循环执行] 第 1 轮
[任务执行] 子任务: read -> [电压] ADC原始值: 4882, 电压: 1.07V
  - 存储变量: read_电压 = 1.07
[条件判断] 条件成立,执行then分支
[任务执行] 子任务: open -> LED 0 已打开
...
[循环执行] 第 12 轮
[任务执行] 子任务: read -> [电压] ADC原始值: 2115, 电压: 0.46V
  - 存储变量: read_电压 = 0.46
[条件判断] 条件成立,执行then分支
[任务执行] 子任务: exit_task -> [退出任务] 任务已退出
------------------------
[任务执行] 执行完成

总结

受限于 0.8B 小模型的推理能力,本次实践不得不牺牲部分灵活性,转而依赖代码层面的确定性逻辑来增强系统的鲁棒性。这是一种权衡:放弃纯粹的大模型调度,换取可控的执行结果。

然而,这也验证了工程落地的核心逻辑:同样的模型,不同的工程架构,结果天差地别。从自然语言理解到硬件执行,中间经历了意图识别技能映射结构解析执行管控等多个环节。这种长链路的自动化,考验的不仅是模型的智商,更是工程的智慧。

随着端侧模型体量的不断增大(如更大的 1.5B、3B),有理由相信,模型的抽象推理能力将进一步解放代码逻辑,实现真正的“嵌入式AIGC”。

首发于21ic


微信公众号:软趴趴的工程师(一个乐于助人的工程师)
image

posted @ 2026-07-31 09:06  pie_thn  阅读(110)  评论(0)    收藏  举报