ESP32接入Agent必看:工具回调成功≠设备执行生效
文章目录
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
更多推荐



所有评论(0)