你对小智说“声音调小一点”,服务端调用了音量工具,拿到一段 text: "true"。这时可以回答“音量已经调小了”吗?

先看这个 true 从哪里来。在本文核验的小智固件里,音量工具取出板卡的 AudioCodec,调用 SetOutputVolume(),随后直接返回 true。它没有在这个回调里测量扬声器声压,也没有比较调整前后的实际声音。工具调用可以把语言意图送到硬件接口,但返回值能证明到哪一步,仍由具体回调决定。

这也是设备接入 Agent 最容易被压缩掉的一段:工具名字出现在列表里、请求被接受、回调执行结束、物理效果发生,是不同的事实。下面沿小智的一次音量调用把它们接起来。

本文基于 78/xiaozhi-esp32 固定提交 6240b777aaa2bc0cad43a4ce25b30de23f36ad00,核验日期为 2026-09-11。证据来自官方客户端源码;示例请求是依据实现编写的说明,并非抓包或设备测试。这里不评测当前 MCP 规范兼容性,也不推断未公开的云端权限和编排实现。

先让服务端知道,这块板子有什么工具

在小智这条链路里,ESP32 固件里的 McpServer 是工具提供方,后端是发现、调用工具的 MCP 客户端。这个角色划分很关键:设备不是拿到一句自然语言后,自行运行大模型决定该调哪个寄存器;设备收到的是结构化调用,随后执行注册好的 C++ 回调。

Application 启动时调用 AddCommonTools() 和 AddUserOnlyTools()。一个工具注册同时绑定名称、描述、参数和回调。音量工具的名字是 self.audio_speaker.set_volume;它的 volume 参数声明为整数,范围 0 到 100;回调通过 Board::GetInstance() 对应的板卡对象取得音频接口。

这个范围描述的是该工具的音量参数,不是分贝数。换一块板,底层 codec 和驱动可能不同。例如固定版本的 bread-compact-wifi 板卡依据编译配置选择 NoAudioCodecSimplex 或 NoAudioCodecDuplex。一个共同的工具名覆盖了不同实现,但没有让它们获得相同的声学输出特性。

工具目录也不是所有开发板完全相同。AddCommonTools() 只有取得非空 backlight 时才注册亮度工具;主题和摄像头相关工具还处于 HAVE_LVGL 编译条件及各自对象判断之内。接入后看不到一个工具,首先应该检查实际固件注册了什么,不能直接归因于模型没理解指令。

后端发起 initialize 时,这份实现返回 protocolVersion: "2024-11-05"、tools 能力以及板卡名称、固件版本。随后用 tools/list 获取工具定义。列表带有 nextCursor 时,需要继续请求下一页;仅检查第一页不足以证明工具不存在。这个分页限制来自固件构造返回消息的大小控制,并不是“设备最多只能有多少个工具”的固定数量上限。

因此,一次可追溯的接入,应先留下板卡、固件版本和完整工具列表。后面的调用结果才有明确的实现对象。

“先查状态”写在描述里,参数范围检查写在执行路径里

音量注册里有一个容易误读的细节。以下两段分别是官方源码中的描述和回调摘录:

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

第一段告诉调用方:当前音量未知时,应先查设备状态。这对于“调小一点”有实际意义,因为工具接收的是目标值,不是相对变化量。假设状态读取为 50,再决定设为 40,是一种调用方可以实施的解释过程;50 和 40 只是本文示例,不是固件固定步长。

但第二段没有验证“前面是否查过状态”,也没有自动执行那次查询。描述提供调用指导,回调负责执行;如果后端需要确保这个顺序,应在实际编排和调用记录中验证,不能只凭描述中的 must 就认为设备强制保证了。

参数范围则不同。DoToolCall() 按注册的参数逐个读取请求;必需参数缺少有效值时返回错误,整数值随后经 Property::set_value<int>() 检查上下界。音量请求为 101 时,会走超过最大值的异常路径,回调尚未执行。这里说的是源码分支推演,没有运行这次请求。

也不要把这套检查泛称为完整 JSON Schema 校验器。这个版本对整数参数先使用 cJSON_IsNumber(),然后读取 valueint;因此,“Schema 写了 integer”不能直接推出“实现严格拒绝所有小数”。评估自定义工具时,应该同时读参数声明和实际解析代码。

图01 图02

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

假设后端已经完成发现,并决定把音量设为 40。发送给设备的 MCP 内层请求可以写成:

{"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 是数字。进入 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());
    }
});

这段代码把调用安排到主任务执行,没有把每个工具放到独立的后台工作线程。应用主循环取出排队的任务并执行。读自定义工具时,要继续检查回调实际做了什么:同步做完,还是又安排了一项稍后才执行的工作。

对音量工具而言,McpTool::Call() 取得回调返回的布尔值后,将它编码成文本内容,所以结果里是字符串 "true",不是顶层 JSON 布尔值。结果还带有 isError: false。同一包装函数也会把布尔 false 编成文本 "false",同时保留 isError: false;业务返回值与包装层错误标记不能混为一谈。

ReplyResult() 随后调用 Application::SendMcpMessage(),发送本身也被安排到主任务里。源码中“已经构造结果”不等于远端已经收到结果。对一次真实联调,应把 id 为 17 的请求与远端实际收到的同 id 响应配对;本文没有这样的网络实测记录。

这一条链路能具体说明硬件如何成为 Agent 工具:名称和参数让调用方能表达动作,注册回调把动作接到板卡接口,响应把该回调提供的结果送回来。是否达到了用户想要的效果,还要继续读最后两端。

图03 图04

true 到底证明什么,要从这个工具的回调往下看

音量回调里的 true,至多说明它调用 SetOutputVolume() 后正常走到了返回语句。SetOutputVolume() 是虚函数,具体效果由实际板卡使用的实现决定,不能只看一个基类就概括所有设备。

官方 AudioCodec 基类的实现更新 output_volume_ 并写入设置。相应地,本文核验的 WifiBoard::GetDeviceStatusJson() 中,音量状态来自 codec->output_volume()。这给联调提供了一项便宜的回读:调用后再查询设备状态,检查软件记录值是否符合预期。

但软件回读仍不是声学测量。它没有证明扬声器接线、功放状态、声学增益或用户实际听到的音量。如果需求只是“设置设备音量值”,回读已经比一个裸 true 更有区分力;如果验收目标是“用户能够听到更小的声音”,还需要在明确硬件和播放条件下验证实际输出。两种需求需要的证据不同。

重启工具让这种区别更明显。self.reboot 的回调先通过 app.Schedule() 安排一项任务:等待一秒,再调用 app.Reboot();回调本身随后返回 true。这个 true 表达的是回调已安排重启工作,不能证明设备完成了重启。

这里还不能继续推成“调用方一定先收到 true,再看到设备重启”。前面已经看到,响应发送也需要排队;重启工作和发送工作的实际先后、连接是否还能送出结果,都要沿调度路径或用设备记录验证。对这个动作,更有意义的完成证据是设备重新上线及预期运行状态,而不是只等一个成功文本。

图05

user_only 控制目录可见性,调用边界还要单独核对

有些硬件动作不应该由模型自由选择。固定版本把重启、升级固件等工具注册为 user-only,并在工具定义中添加 audience: ["user"] 注解。默认工具列表会跳过这些工具:

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

当 tools/list 请求带 withUserTools: true 时,列表会包含它们。不过,继续查看 DoToolCall(),其查找条件是工具名相等;在这条方法内部,没有依据 user_only() 再做一次拒绝检查。

这个观察的范围必须收紧:它只说明,到达这份设备端分发函数的调用,没有在此处被该标记拦截。它不能证明外部任意人能连上设备,也不能证明云端没有身份校验、界面确认或调用限制。那些控制需要各自的服务端和接入证据。

对准备增加电机、继电器等工具的开发者,直接可用的结论是:不要把“默认不给模型列出”写成“设备已经强制禁止模型调用”。如果动作需要操作者确认或硬件互锁,应该指出具体在哪里执行检查、什么条件会拒绝,再验证请求确实经过那里。本文源码中没有新增设备的实现,更没有验证电机或继电器动作安全。

用一张记录,把工具接通与效果验收连起来

拿音量工具做首个接入样例,可以留下下面这张记录。它是依据前面源码编写的验收模板,尚未执行;每一行都回答一个具体问题。

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

如果工具没有出现在完整列表里,回到注册和板卡条件;如果越界参数仍然执行,回到解析与检查;如果 true 已返回但软件状态不符,检查实际 codec 实现和状态读取路径;如果软件值正确而听感不符,再去看硬件输出条件。这样,每一步失败都有具体的下一站。

以后换成其他硬件工具,先重写“回调返回时已经完成了什么”和“什么证据算动作完成”这两格。把它们写清楚,Agent 才知道什么时候可以向用户报告完成,什么时候只能报告请求已经交给设备。

图06

参考源码

以下链接均固定到本文核验提交;本地保存了原始文件和 SHA-256,未使用在线 main 代替版本证据。

  1. McpServer:注册、发现、调用、返回与 user-only 过滤:L33–78、L128–169、L361–571。
  2. 工具参数检查与返回值包装:L109–120、L232–310。
  3. Application:工具初始化、消息接收与主任务发送:L104–106、L264–271、L676–680、L1316–1326。
  4. AudioCodec 基类 与 虚函数声明:音量设置与实现边界。
  5. WifiBoard 状态读取 与 bread-compact-wifi 的 codec 选择。
  6. 项目 MCP 交互文档。该文档注明 AI 辅助并要求对照代码;本文涉及行为的结论均以对应实现为准。