在这里插入图片描述
P.S. 无意间发现了一个巨牛的人工智能教程,非常通俗易懂,对AI感兴趣的朋友强烈推荐去看看, 传送门https://blog.csdn.net/qq_34419312

前言

对着一台小智 ESP32 说"声音调小一点",服务端那边回手就是一个 text: "true"。这个瞬间,你要不要给用户回一句"好的,音量已经调小了"?

建议先把手从回车键上挪开。

这个 true 的完整意思是:我,一个回调函数,成功把 SetOutputVolume() 叫过来,人家干完活回去了,我顺利走到 return true。至于扬声器到底小没小——老实说,没人问过我。

设备接进 Agent 这条链路,最容易被压缩掉的就是这一段:工具出现在列表里、请求被接受、回调执行完、物理效果真的发生,这是四件事,不是一件事的四种说法。就像"他下班了"“他到家了”“他洗完澡了”“他发朋友圈了”,中间任何一环断了,你都只能说清自己亲眼看到的那一环。

底下所有结论都钉在 78/xiaozhi-esp32 固定提交 6240b777aaa2bc0cad43a4ce25b30de23f36ad00 上,核验日期 2026-09-11。证据来自官方客户端源码,示例请求是照实现写的,不是抓包,更不是我拿设备实测——我先招了,免得你看到后面热血沸腾,然后找我索赔。

1. 先搞清楚:谁在给谁递工具

设备没有"听懂"你那句话。它收到的是结构化调用,然后去执行一个注册好的 C++ 回调。换个说法:你不是在跟设备聊天,你是在给设备下订单,设备照着订单执行。执行到哪一步,看订单内容,也看门店实力。

这条链路里,McpServer 是工具提供方,后端是 MCP 客户端。这个角色划分值得画个重点:ESP32 不是听懂人话、再自己决定改哪个寄存器的大聪明。它是接到一张写着"音量设为 40"的工单、转身就干的打工仔。

1.1 工具不是出厂自带

Application 启动时调用 AddCommonTools() 和 AddUserOnlyTools(),一个工具注册同时绑定名称、描述、参数和回调。音量工具叫 self.audio_speaker.set_volume,参数 volume 是整数,范围 0 到 100。

注意,这个范围说的是音量参数范围,不是分贝数。它没承诺"调到 40 就真的小了一半"。分贝是声学的事,不是工单的事。至于 101 想干嘛,别急,2.1 有它好看的。

1.2 同一个工具名,不同的灵魂

固定版本里,bread-compact-wifi 这块板子根据编译配置选 NoAudioCodecSimplex 或 NoAudioCodecDuplex。工具名是同一个,底层实现可能完全不同。就像"老张"这个称呼,你在物业喊和在派出所喊,走出来的人大概率不是同一个。

1.3 列表里没有工具,先别骂模型

AddCommonTools() 只有拿到非空 backlight 时才注册亮度工具;主题和摄像头工具还缩在 HAVE_LVGL 编译条件里。接入后发现少一个工具,第一反应应该是"这固件到底注册了什么",而不是"模型是不是没听懂"。

模型:你礼貌吗?我连工具都看不见,你让我调用空气?

1.4 只看第一页,不算看完了

tools/list 带着 nextCursor 就说明还有下一页。这个分页来自固件构造返回消息的大小控制,不是"设备最多只能有 N 个工具"的硬上限。分页只是消息放不下了,不是设备看不起你。只看第一页就宣布"工具不存在",跟只看了第一集就宣布"这剧烂尾了"一样草率。

2. 描述里写"先查状态",执行路径可没查

音量工具的注册描述长这样:

"Set the volume of the audio speaker. If the current volume is unknown, you must call `self.get_device_status` tool first and then call this tool."

翻译:当前音量未知时,得先查 self.get_device_status,再调这个工具。对"调小一点"这种需求这很有意义——工具收的是目标值,不是"再小一点"这种相对量。假设查到 50,决定设成 40,这是调用方可以做的推理。50 和 40 是我随手举的例子,不是固件内置的固定步长。

但回调长这样:

[&board](const PropertyList& properties) -> ReturnValue {
    auto codec = board.GetAudioCodec();
    codec->SetOutputVolume(properties["volume"].value<int>());
    return true;
}

它没检查"你前面查过状态了吗",也没顺手帮你查一次。描述是建议,回调是执行。如果后端真需要这个顺序,得在编排和调用记录里自己验证——不能因为描述里有个 must,就以为设备内置了一个纪律委员。

就像外卖平台的"请确认收货地址",它只是温馨提示,不会替你在下单前检查地址。你填错了它照送,送到哪算哪。

2.1 参数检查倒是真的在

DoToolCall() 按注册参数逐个读请求,必需参数缺了就报错,整数值走上下界检查。你传 101,会走进超过最大值的异常分支,回调根本不会执行。说明一下,这是源码分支推演,我没真跑这个请求——问就是怕烧板子,板子贵。

2.2 “Schema 写了 integer"不等于"严格拒绝小数”

这个版本对整数参数先 cJSON_IsNumber(),再读 valueint。所以别把"声明里写了 integer"当成"实现会严格拒绝所有小数"。评估自定义工具,参数声明和实际解析代码要一起看——声明是 PPT,代码是现实。

3. 一次音量调用,真正走过哪些地方

假设后端完成了发现,决定把音量设为 40。发给设备的内层请求长这样:

{"jsonrpc":"2.0","id":17,"method":"tools/call","params":{"name":"self.audio_speaker.set_volume","arguments":{"volume":40}}}

这是内层 payload,不是完整的 WebSocket 或 MQTT 消息。小智协议外面还套了一层 type: "mcp",Application 收到后把 payload 交给 McpServer::ParseMessage()。解析器检查 JSON-RPC 版本、方法、参数,这个版本还要求请求 id 必须是数字——id 不是数字,连门都进不去。

进 DoToolCall():先按名字找工具,再构造校验参数,最后把调用安排到应用主任务:

app.Schedule([this, id, tool_iter, arguments = std::move(arguments)]() {
    try {
        ReplyResult(id, (*tool_iter)->Call(arguments));
    } catch (const std::exception& e) {
        ESP_LOGE(TAG, "tools/call: %s", e.what());
        ReplyError(id, e.what());
    }
});

注意,这段是把调用丢进主任务队列,没给每个工具开独立线程。主循环取出排队的任务再执行。所以读自定义工具时,得继续往下看回调到底干了啥:是同步干完,还是又安排了一件"稍后再说"的事。这个区别,后面重启工具会教做人。

3.1 true 是文本,不是布尔

McpTool::Call() 拿到回调返回的布尔值后,把它编码成文本内容,所以结果里是字符串 "true",不是顶层 JSON 布尔,还带着 isError: false。布尔 false 也会被编成 "false",同时保留 isError: false。

结论:业务返回值是业务的事,包装层错误标记是包装层的事。收到 "false" 别急着喊"工具出错了",先看 isError——它可能只是平静地告诉你"这次业务结果是假的"。

3.2 构造结果 ≠ 远端收到

ReplyResult() 接着调 Application::SendMcpMessage(),发送本身也是安排到主任务的。“结果构造好了"和"远端收到了"之间,隔着一条队列。就像隔着一条河,你喊"听到了吗”,回声还没回来。联调时,要把 id=17 的请求和远端实际收到的同 id 响应配对。这种网络实测我没有,你要测了记得告诉我结果。

4. true 到底证明什么

音量回调里的 true,至多说明它调完 SetOutputVolume() 后正常走到了 return。而 SetOutputVolume() 是虚函数——虚函数翻译成人话就是:基类说"具体怎么干,子类自己看着办"。你只看基类,就等于只听部门经理说"放心,我们很专业",具体干活的到底是谁,你还没见着。

官方基类实现更新 output_volume_ 并写入设置。相应地,WifiBoard::GetDeviceStatusJson() 里,音量状态来自 codec->output_volume()。这给联调提供一个便宜的回读:调用完再查一次设备状态,看看软件记录值符不符合预期。

但软件回读不是声学测量。它证明不了扬声器接线、功放状态、声学增益,更证明不了你的耳朵。软件回读回答"我设成了 40",声学测量才回答"你的耳朵觉得小没小"——前者是考勤打卡,后者是绩效面谈,别混。

4.1 重启工具:true 只是"我安排了"

self.reboot 的回调先 app.Schedule() 安排任务:等一秒,再 app.Reboot(),然后回调自己返回 true。这个 true 的意思是"我已安排重启工作",不是"设备已经重启完了"。

这相当于领导跟你说"这事我安排好了"——你该等的不是这句话,是事办完的证据。重启的完成证据是设备重新上线、跑到预期状态,而不是一个成功文本。等一个 true 就想宣布"重启完成",跟等一句"好的收到"就宣布"快递已经送到你手里了",是一回事。

而且别急着推断"调用方一定先收到 true 再看设备重启"。响应发送也要排队,重启工作和发送工作谁先谁后、连接还送不送得出结果,都得沿调度路径验证。

5. user_only:看不见,不等于禁止

有些硬件动作不该让模型随便碰。固定版本把重启、升级固件注册为 user-only,工具定义里加 audience: ["user"]。默认工具列表会跳过:

if (!list_user_only_tools && (*it)->user_only()) {
    ++it;
    continue;
}

tools/list 带 withUserTools: true 时会包含它们。但再看 DoToolCall(),查找条件是工具名相等,方法内部没有依据 user_only() 再拦一次。

注意把结论收紧:这只能说明"到达这个分发函数的调用,没在这儿被拦截"。它不能证明外头谁都能连上设备,也不能证明云端没有身份校验、界面确认或调用限制——那些要各自的服务端和接入证据说了算。

对想加电机、继电器的开发者,直接结论:别把"默认不给模型列出"写成"设备强制禁止模型调用"。动作需要操作者确认或硬件互锁,就指出检查在哪执行、什么条件会拒绝,再验证请求确实经过那里。本文源码里没有新增设备的实现,更没验证过电机继电器动作安全——你要真上继电器,先想想家里还有没有别的家具。

6. 用一张表,把"接通"和"效果"连起来

拿音量工具当首个接入样例,可以留这么一张记录。它是照前面源码写的验收模板,还没执行,每行回答一个具体问题:

检查位置需要留下什么能回答什么
实际固件与发现板卡、版本、完整 tools/list、音量参数定义当前设备是否注册并公开了目标工具
调用方决策用户要求、此前状态、目标音量、请求 id"调小一点"如何被解释成一个目标值
设备参数与回调合法值和越界值的处理记录、实际 codec 类型请求是否到达预期实现,越界是否在执行前被拒绝
响应与软件回读同 id 的实际响应、后续状态中的音量值回调报告了什么,设备软件状态是否符合预期
产品要求的效果在指定板卡和播放条件下观察实际输出;未验证时明确留空软件设置是否达到了这次需求的物理效果

排查逻辑也顺了:工具没出现在完整列表,回注册和板卡条件;越界参数还执行,回解析与检查;true 返回了但软件状态不符,查实际 codec 实现和状态读取路径;软件值正确但听感不对,去看硬件输出条件。每一步失败都有明确的下一站。

以后换硬件工具,先把"回调返回时已经完成了什么"和"什么证据算动作完成"这两格重写一遍。这两格写清楚,Agent 才知道什么时候能说"办完了",什么时候只能说"我交给设备了"。

7. 错误速查卡

症状根因定位修复
工具返回 text: "true" 就向用户回复"音量已调小"true 只到 SetOutputVolume() 之后的 return 语句看 mcp_server.cc L60-64 注册回调区分"回调完成"与"硬件动作完成",按需追加软件回读 / 声学验证
看到完整列表里没有目标工具就归因于模型没理解工具受编译条件与板卡对象状态影响看 mcp_server.cc L33-78 注册条件先查完整 tools/list(含 nextCursor 分页),再核对实际固件
只看第一页 tools/list 就宣布工具不存在列表分页由消息大小控制看 mcp_server.cc L33-78 nextCursor 返回收到 nextCursor 继续请求下一页
bread-compact-wifi 板卡声学输出符合预期同一工具名覆盖不同 codec 实现看 compact_wifi_board.cc L172-183 codec 选择按实际 board 与编译配置核对;同工具名 ≠ 相同声学输出
描述里写 must call ... first 就当设备强制顺序描述是建议,强制由外部编排/调用记录验证看 mcp_server.cc L128-169 回调实际行为描述里 must 不构成设备强制;按需求另加检查
整数 101 直接执行了回调DoToolCall 按参数上下界提前拒看 mcp_server.cc L361-571 解析路径越界走异常分支,回调未执行;先记录请求 id + 参数值
"Schema 写 integer"就严格拒绝所有小数当前实现先 cJSON_IsNumber() 再读 valueint看 mcp_server.cc L361-571 解析代码自定义工具评估要同时读参数声明和解析代码
业务返回 false 推出"工具出错"业务返回值与包装层错误标记不同看 mcp_server.h L290-291 / L303-304 包装检查 isError 字段;false 也可 isError: false
text: "true" 被当作 JSON 布尔布尔被编码为文本字符串看 mcp_server.h L290-291 编码解析时把 text 当字符串读,不要用 JSON 布尔解码
写完响应就以为远端已收到ReplyResult 也走 app.Schedule 队列看 application.cc L676-680 / L1316-1326 发送路径用同 id 请求-响应配对验证网络
回调返回值就认定动作完成回调可能只安排了后续工作看 mcp_server.cc L128-169 回调实现区分同步做完与"安排了一项稍后工作";看后续状态
self.reboot 返回 true 就报告"设备已重启"回调只安排 1 秒后的 app.Reboot() 任务看 reboot 工具注册回调完成证据应是设备重新上线 + 预期运行状态
user_only 工具默认列表里看不到就视为"已禁止"DoToolCall 内部不再做拦截看 mcp_server.cc L361-571 分发函数仅说明到达该函数的调用没被拦截;强制需在编排/服务端
默认列表里不出现 = “云端已做限制”列表过滤只控制模型可见性看 mcp_server.cc L33-78 列表构造不能用列表可见性代替云端身份校验 / 操作者确认
加电机/继电器工具直接复用 user_only 就行硬件互锁应在另一层执行看 mcp_server.cc L361-571 内部指出互锁具体在哪里、条件如何,再验证请求经过
仅看"回调成功"就宣布 Agent 完成缺软件回读与效果验证看 audio_codec.cc L40-46 / wifi_board.cc L303-308 状态读取增加 5 行验收表中的"响应与软件回读"和"产品要求的效果"两行

以上,来自一个被 true 骗过的过来人。别问我是怎么知道的——问就是,我在等设备重新上线。

P.S. 无意间发现了一个巨牛的人工智能教程,非常通俗易懂,对AI感兴趣的朋友强烈推荐去看看,传送门https://blog.csdn.net/qq_34419312

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐