☰
小智AI的MCP交互全流程拆解:从ESP32到WebSocket工具调用
2026/9/27 19:30:45 网站建设 项目流程

1. 小智AI的MCP交互链路到底长什么样

小智AI在ESP32上跑MCP工具调用,本质是把设备本身当成一个"能力提供方":设备通过WebSocket连上小智AI服务器,把自己的工具清单报上去,用户说一句话,服务器侧的模型决定调哪个工具、传什么参数,再把调用请求顺着同一条WebSocket推回设备,设备本地执行完把结果回传,最后模型根据结果生成语音回复。整条链路里,ESP32既是音频终端,也是MCP Server。

这套东西适合谁?如果你手上有一块ESP32-S3开发板,跑过小智AI的固件,想让"把音量调到80""查一下设备状态"这类指令真正落到硬件动作上,而不是只停留在聊天层面,那MCP就是那条把语言变成操作的通道。它和外部MCP服务器的最大区别在于:工具执行发生在设备本地,省掉了"设备→服务器→外部MCP→服务器→设备"的额外网络往返,实测能省下150到300毫秒。

我试过在本地把这条链路完整跑通一遍,踩过的坑主要集中在工具注册的JSON-RPC格式和WebSocket消息分帧上。下面按"连接建立→工具注册→语音触发→工具调用→结果回传→语音播报"的顺序,把每一步的协议细节和可复制配置拆开讲,最后给一套能直接对着抓包日志排查的验证方法。

2. TaoToken 前置准备:拿到模型侧调用凭证

小智AI服务器侧的意图理解和工具决策依赖大模型能力,如果你要自建或替换这一层,需要先准备好模型调用的API Key。TaoToken在这里的角色是提供统一的模型接入入口,让你不用为每个模型单独对接一套鉴权。

操作路径很直接:打开官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,注册后在控制台创建API Key。控制台地址是 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,Key管理页在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。API的Base URL统一用 https://taotoken.net/api ,注意这个地址后面不要加UTM参数,否则部分SDK会把查询串拼进请求路径导致404。

拿到Key之后,建议先在模型对话页面 https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 里发一条测试消息,确认Key有效、额度正常。这一步别跳过,我见过太多人把Key填进ESP32固件后调不通,最后发现是Key本身没激活。

如果你打算长期跑编码类或Agent类任务,比如让模型持续生成工具调用决策,可以看下Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite ,按套餐走比按量计费更可控。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,里面有针对不同语言SDK的示例,ESP32侧用HTTP客户端调REST接口时可以直接参考。

需要说明的是,TaoToken只负责模型调用这一层,ESP32和小智AI服务器之间的WebSocket连接、MCP协议握手是设备固件和服务器各自实现的,两者不要混在一起理解。

3. 可复制的MCP配置骨架与WebSocket联调

3.1 连接建立:设备侧WebSocket初始化

ESP32启动后,先完成音频编解码、显示等外设初始化,然后注册MCP工具,最后发起WebSocket连接。连接地址形如:

// 设备侧连接小智AI服务器 void Application::Initialize() { // 外设初始化省略 #if CONFIG_IOT_PROTOCOL_MCP McpServer::GetInstance().AddCommonTools(); // 注册工具 #endif protocol_->Connect(); // 内部发起WebSocket连接 }

WebSocket的URL结构是wss://api.xiaozhi.me/mcp/device/{device_id},其中device_id是设备唯一标识。连接建立后,服务器会主动下发tools/list请求来拉取设备能力,这一步不需要设备主动发起。

3.2 工具注册:tools/list 的请求与响应

服务器发来的请求是标准JSON-RPC 2.0格式:

{ "jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {} }

设备侧在McpServer::HandleRequest里解析method字段,命中tools/list就构造工具数组返回。每个工具必须包含name、description、inputSchema三个字段,其中inputSchema是JSON Schema,用来告诉模型参数类型和取值范围:

if (strcmp(method->valuestring, "tools/list") == 0) { cJSON* response = cJSON_CreateObject(); cJSON* result = cJSON_CreateObject(); cJSON* tools = cJSON_CreateArray(); cJSON* volume_tool = cJSON_CreateObject(); cJSON_AddStringToObject(volume_tool, "name", "self.audio_speaker.set_volume"); cJSON_AddStringToObject(volume_tool, "description", "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."); cJSON* input_schema = cJSON_CreateObject(); cJSON_AddStringToObject(input_schema, "type", "object"); cJSON* properties = cJSON_CreateObject(); cJSON* volume_prop = cJSON_CreateObject(); cJSON_AddStringToObject(volume_prop, "type", "integer"); cJSON_AddNumberToObject(volume_prop, "minimum", 0); cJSON_AddNumberToObject(volume_prop, "maximum", 100); cJSON_AddItemToObject(properties, "volume", volume_prop); cJSON_AddItemToObject(input_schema, "properties", properties); cJSON_AddItemToObject(volume_tool, "inputSchema", input_schema); cJSON_AddItemToArray(tools, volume_tool); cJSON_AddItemToObject(result, "tools", tools); cJSON_AddItemToObject(response, "result", result); char* response_str = cJSON_Print(response); protocol_->SendMCPResponse(response_str); free(response_str); cJSON_Delete(response); }

设备返回的工具列表长这样,注意required字段要显式声明,否则模型可能生成缺参数的调用:

{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [ { "name": "self.get_device_status", "description": "Provides the real-time information of the device...", "inputSchema": {"type": "object", "properties": {}} }, { "name": "self.audio_speaker.set_volume", "description": "Set the volume of the audio speaker...", "inputSchema": { "type": "object", "properties": { "volume": {"type": "integer", "minimum": 0, "maximum": 100} }, "required": ["volume"] } } ] } }

3.3 工具调用:tools/call 的完整往返

用户说"把音量调到80",ESP32采集音频、Opus编码后发给服务器,服务器做ASR得到文本,模型分析意图后生成工具调用请求:

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

设备侧命中tools/call分支,取出name和arguments,执行本地动作:

if (strcmp(method->valuestring, "tools/call") == 0) { auto params = cJSON_GetObjectItem(json, "params"); auto tool_name = cJSON_GetObjectItem(params, "name"); auto arguments = cJSON_GetObjectItem(params, "arguments"); if (strcmp(tool_name->valuestring, "self.audio_speaker.set_volume") == 0) { auto volume = cJSON_GetObjectItem(arguments, "volume"); int volume_value = volume->valueint; auto& board = Board::GetInstance(); auto codec = board.GetAudioCodec(); codec->SetOutputVolume(volume_value); auto display = board.GetDisplay(); if (display) { display->ShowNotification("音量: " + std::to_string(volume_value)); } cJSON* response = cJSON_CreateObject(); cJSON* result = cJSON_CreateObject(); cJSON_AddBoolToObject(result, "success", true); cJSON_AddNumberToObject(result, "volume", volume_value); cJSON_AddStringToObject(result, "message", "音量设置成功"); cJSON_AddItemToObject(response, "result", result); char* response_str = cJSON_Print(response); protocol_->SendMCPResponse(response_str); free(response_str); cJSON_Delete(response); } }

回传结果:

{ "jsonrpc": "2.0", "id": 2, "result": { "success": true, "volume": 80, "message": "音量设置成功" } }

服务器拿到结果后生成回复文本"好的,已将音量调整到80",TTS合成音频推回设备,设备在OnIncomingAudio里入队,OnAudioOutput里Opus解码后写I2S播放。

3.4 协议层与异步处理

MCP消息的收发统一走Protocol类,避免在业务代码里直接操作WebSocket:

class Protocol { public: void SendMCPResponse(const std::string& response) { websocket_->send(response); } void OnMCPRequest(const std::string& request) { McpServer::GetInstance().HandleRequest(request); } };

工具执行可能涉及I2C、I2S等阻塞操作,建议放到后台任务里,别卡住WebSocket接收线程:

void McpServer::HandleRequest(const std::string& request) { background_task_->Schedule([this, request]() { ProcessMCPRequest(request); }); }

4. 验证请求与成功结果

联调时不要一上来就对着麦克风喊,先用WebSocket客户端手动发JSON-RPC,把协议层单独验证通过。

第一步,用wscat或Postman的WebSocket功能连上wss://api.xiaozhi.me/mcp/device/{device_id},连接成功后服务器会自动发tools/list请求。你手动回一条工具列表,观察服务器是否继续下发其他消息。

第二步,手动构造tools/call请求发过去,看设备串口日志里是否打印出工具名和参数。如果设备侧有显示屏,应该能看到"音量: 80"的通知。

第三步,检查设备回传的JSON里id是否和请求一致。JSON-RPC靠id做请求响应配对,id对不上服务器会一直等,表现为"设备执行了但AI没反应"。

成功的结果是:串口日志依次出现tools/list handled、tools/call: self.audio_speaker.set_volume、volume set to 80,同时扬声器音量实际变化,服务器侧返回TTS音频,设备播放"好的,已将音量调整到80"。整条链路延迟实测在500到1250毫秒之间,其中ASR占200到500毫秒,模型决策100到300毫秒,本地工具执行只要10到50毫秒。

5. 本篇常见错误排查

5.1 tools/list 返回后服务器无响应

最常见的原因是JSON里少了jsonrpc字段或值不是"2.0"。有些cJSON示例代码只加了result,忘了顶层协议版本,服务器解析直接丢弃。检查cJSON_AddStringToObject(response, "jsonrpc", "2.0")是否在构造response时第一时间加上。

另一个原因是inputSchema写成了input_schema或parameters。MCP规范里字段名是驼峰inputSchema,大小写敏感,写错模型就看不到参数定义,自然不会发起调用。

5.2 工具调用请求到了但设备不执行

先看method字段比对是不是用了strcmp且字符串完全一致。tools/call中间是斜杠不是点,写成tools.call就永远命中不了。

再看arguments的解析。如果模型传的是{"volume": "80"}字符串而不是数字,valueint会取到0。稳妥做法是用cJSON_IsNumber判断类型,字符串则用atoi转换,并在工具描述里明确写"type": "integer"。

5.3 回传结果后AI不生成语音

检查回传JSON的id是否和请求的id一致。服务器用id做异步配对,id不一致会认为调用超时。另外result必须是对象,不能直接是布尔值或字符串,否则服务器解析失败。

如果设备侧用了后台任务异步处理,注意request字符串的生命周期。lambda捕获的是引用还是拷贝?捕获引用的话,函数返回后request已析构,后台任务拿到的是野指针。改成按值捕获[this, request]。

5.4 WebSocket频繁断连

ESP32的WebSocket心跳间隔要小于服务器超时时间。如果服务器30秒无数据就断,设备侧心跳设成20秒。另外注意发送大块TTS音频时不要和MCP消息共用同一个发送缓冲,容易互相阻塞。建议音频走独立通道,MCP消息走控制通道。

6. 继续深入的方向

把上面这套跑通之后,你可以往两个方向扩展。一是增加工具数量,比如self.get_device_status返回电量、WiFi信号、固件版本,让模型在调音量前先查状态。二是把工具执行结果结构化,除了success和message,把实际生效的值也带上,方便模型做二次判断。

模型侧如果要做更复杂的多轮工具编排,比如"先查状态再调音量最后播报",建议用支持function calling的模型,并在系统提示里写清楚工具调用顺序约束。TaoToken的模型对话入口 https://taotoken.net/model-chat?utm_source=taotoken_aicg_blog_end&utm_content=model-chat&utm_campaign=rewrite 可以直接测这类多轮决策,接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 里有function calling的请求示例。长期跑Agent类任务的话,Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 的额度模型更适合持续调用。

最后提醒一句:MCP工具描述里的文字会直接影响模型的调用决策,description写得越具体,模型选错工具的概率越低。我踩过的坑是description写得太笼统,模型把"调音量"理解成"调亮度",后来在描述里加了"audio speaker"限定词才稳定。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询