ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

小智服务器MCP消息格式与工具调用实战:从初始化响应到完整链路

小智服务器MCP消息格式与工具调用实战:从初始化响应到完整链路 小智服务器的日志里如果你仔细看设备连上来的那几秒会看到一串MCP相关的消息刷过去——初始化响应、工具列表下发、工具调用请求、工具调用响应。对这些消息的理解程度直接决定了你做设备端功能时是半天摸不着头脑还是十分钟就能把“语音控制智能家居”这种场景跑通。这篇文章我不讲协议文档里那些抽象定义就围绕小智服务器上实际跑的MCP消息格式、初始化响应结构、工具列表怎么下发、工具调用响应怎么对齐这几件事把我调通这些消息时的完整过程和踩坑经验拆开讲。不管你是刚接触小智设备开发还是已经在接MCP Server但被异步消息匹配折磨过这篇内容都适合你照着排查。补充一句文中消息结构基于小智服务器的常见实现和我自己部署的版本整理不同版本字段名可能有细微出入但整体交互框架是一致的你可以按这个思路去对照自己手里的日志。1. 小智设备接入MCP到底在解决什么问题先别急着看消息格式得先把“为什么需要MCP”这件事想清楚。小智设备比如ESP32、手机端小智App本质上是一个语音交互终端用户说话、云端做识别和理解、设备负责播报和执行。但很早之前大家就发现如果只靠“理解意图”然后返回一段话语音助手的价值非常有限——用户真正想要的往往是“帮我把客厅灯打开”“查一下明天天气”“给门锁发个临时密码”这类动作。这就意味着设备需要一种标准化的方式去调用外部工具。1.1 传统做法为什么麻烦MCP出现之前要实现一个“设备能操作第三方服务”的功能通常得这么干先在服务器代码里写死某个服务的API调用逻辑再为每个意图维护一套参数映射。比如用户说“开灯”服务器要有一行if 开灯 in text: turn_on_light()用户说“调到最亮”又得加一个亮度解析。写多了以后技能之间互相耦合新增一个工具要动主流程代码而且设备端、服务器、第三方服务三方的消息格式各写各的非常难维护。我自己早期做一个语音控制台灯的功能时就陷在这种泥潭里意图识别、参数抽取、API调用、结果回填每一步都是手写胶水代码而且一旦第三方接口字段变化整条链路要跟着改。后来小智服务器引入MCP我本来以为又是一种“套壳协议”实际用下来才明白——它把你需要工具列表、调用工具、拿结果这一整套交互全部标准化了服务器不用再关心每个工具的差异化逻辑只需要把设备发来的消息按MCP规范转发给对应的MCP Server即可。1.2 三方角色和一条完整链路在小智的MCP架构里一共有三方角色设备端负责采集语音、播放回复、展示状态也是各种MCP消息的发起方或接收方。小智服务器消息中枢和调度器负责把设备消息路由到LLM大模型再把LLM要调用的工具请求转发给MCP Server最后把执行结果打包回传给设备。MCP Server能力提供方例如智能家居控制服务、天气查询服务、数据库查询服务等。通过MCP协议暴露工具让LLM和设备调用。一条完整的链路是这样的用户说“把客厅灯亮度调到50%”——设备采集音频上传——小智服务器经过语音识别ASR得到文本——文本交给大模型LLM——大模型判断“需要调用set_light_brightness这个工具”返回一条function call——小智服务器把这次工具调用包装成MCP消息发给设备确认/执行——设备或服务器把消息发给对应的MCP Server——MCP Server执行完返回结果——小智服务器把结果整理成“工具调用响应”消息同时生成一句自然语言回复“好的客厅灯亮度已调到50%”——设备播放并展示状态。你会注意到在整个链路里设备端需要处理的消息就是标题里那三类初始化响应建立会话后服务器告诉设备“我准备好了工具列表如下”、工具列表设备能做什么、工具调用响应工具执行完的结果。把这三种消息彻底吃透MCP这块基本就通了。2. 连接握手与初始化响应MCP会话的第一关设备连接小智服务器以后第一步不是直接发语音而是要完成WebSocket连接和初始化握手。我见过很多人在这一关卡住日志里报一堆连接错误但其实规则不复杂就是把对应的握手消息发对了。2.1 WebSocket连接与鉴权细节小智服务器的MCP连接是一个WebSocket长连接设备端通过wss协议连接到服务器地址。连接串的格式大致是wss://api.xiaozhi.me/mcp/?tokenYOUR_TOKEN注意这里的token参数是鉴权凭证。我见过有些示例代码里直接写死了token字符串这是非常危险的习惯——token一旦泄露等于任何人可以冒充你的设备连接服务器。正确的做法是从配置文件或安全存储里读取而且不同设备的token应该不同方便单独吊销。实际使用中token有时会过期。如果你发现设备连上去后服务器迟迟不回初始化响应先检查一下token状态这比排查代码更高效。2.2 初始化握手的消息序列WebSocket连接建立后设备要主动发一个hello消息告诉服务器自己的基本能力和音频参数。大概长这样{ type: hello, version: 1, transport: websocket, audio_params: { format: opus, sample_rate: 16000, channels: 1 } }服务器收到后会返回一个初始化响应也就是标题里的“初始化响应”。我这边实际抓到的响应主要字段如下{ type: hello, transport: websocket, audio_params: { format: opus, sample_rate: 16000, channels: 1 }, session_id: a1b2c3d4-xxxx-xxxx-xxxx-xxxxxxxxxxxx, message_id: msg_hello_001, server_time: 1700000000 }这里最关键的是session_id。后续所有MCP消息、工具调用响应都会挂在这个会话下。message_id则是本次响应的消息编号客户端可以用它确认“我收到的是哪条响应”。server_time用于设备端校准时间如果设备端日志时间总对不上记得用这个字段校准。2.3 MCP初始化与小智hello的关系容易搞混有一个非常容易混淆的点MCP协议本身也有一个initialize握手流程客户端发initialize服务端回initialize_response小智服务器的hello则是设备与服务器之间的自有协议握手。我在刚开始接触时一直以为两者是一回事结果按MCP标准改动装置发了一堆initialize消息服务器根本不鸟我。实际逻辑是设备先和小智服务器完成hello握手得到session_id之后设备、服务器、MCP Server之间才用小智封装好的MCP消息格式做工具相关通信。换句话说小智服务器对外是MCP Server对内是设备网关它把两边协议做了桥接。小智服务器通过标准MCP协议从MCP Server那边拉工具列表、调用工具但设备端看到的是一套小智自有类型的MCP消息封装。你需要分别理解这两层而不是混为一谈。2.4 握手失败常见的三个原因结合个人排查经验握手失败通常逃不出这三种情况token无效或过期服务器返回连接关闭或者一直不响应。把token在代码里打出来检查一遍确认没有空格、换行之类的隐藏字符。音频参数不匹配设备端上报的sample_rate或format与服务器不兼容。常见的是ESP32用16k采样率但日志里配置成了8k服务器直接拒绝后续音频流。心跳超时握手完成后如果设备端没有定期发心跳一般是ping/pong服务器会在一段时间后断开连接。有些外行实现只发了hello就不管了等到要发消息时才发现连接已经断了。3. 设备的消息信封看懂MCP消息是怎么被包装的小智服务器上的MCP消息不是裸的MCP协议帧而是包在一个小智自有消息信封里。掌握了信封结构后面看工具列表、工具调用响应的消息体就都是一样的套路了。3.1 信封的核心字段type 与 mcp小智服务器上设备端与服务器之间交互的消息最外层统一有一个type字段用来标识消息大类。MCP相关的消息type固定是mcp然后真正的MCP内容放在mcp字段下面。我在代码里解包时一般只关心mcp字段下的三层结构{ type: mcp, mcp: { to: device, content: { type: tools_list, data: {} } } }to标明这条消息是发给谁的。设备收到的大部分消息to是device设备主动上报给服务器的to是server。content.type表示这条MCP消息的具体动作。data该动作携带的实际数据。这套信封的好处是无论底层MCP协议怎么变化设备端只需要解析固定的外壳然后根据content.type分发处理即可。我自己的处理逻辑类似一个switch分发器content.type是tools_list就走工具列表处理函数是tool_call就走工具调用分发是tool_result就走结果回填。3.2 设备会遇到的MCP消息有哪几种把所有MCP消息类型列一张表你后面排查时对照着看会非常清楚| content.type | 方向 | 含义 | |--------------------|----------------|------------------------------------------| | hello/init_response | 服务器 - 设备 | 初始化响应会话建立/工具列表就绪 | | tools_list | 服务器 - 设备 | 下发当前可用的工具列表 | | tool_call | 设备 - 服务器 | 设备端请求调用某个工具 | | tool_result | 服务器 - 设备 | 工具执行结果的响应 | | tool_error | 服务器 - 设备 | 工具调用失败/异常的响应 | | iot | 双向 | 设备上下行事件常和工具调用配合使用 | | audio | 双向 | 音频流消息与工具调用异步并行 |我一般在日志里看到content.type是tools_list时就知道服务器已经完成了工具注册可以准备干活了看到tool_result时就知道工具执行链路已经闭环。3.3 同步与异步设备端如何等待工具执行设备端最容易写错的是把工具调用当成同步请求处理发一条tool_call消息然后傻等tool_result回来。实际上在小智服务器的设计里工具调用的执行是在MCP Server那边异步进行的耗时可能是几百毫秒也可能是几秒取决于第三方服务的响应速度。我在设备端做状态机时会给每个tool_call生成一个本地递增的请求编号request_id然后把“等待工具结果”的状态挂到该编号下等tool_result回来时按编号匹配再唤醒对应的等待逻辑。这个异步模型一开始不习惯但理清楚之后反而更好因为设备可以同时处理音频播报和工具调用不会卡死语音通道。4. 工具列表设备拿到的“能力说明书”工具列表是设备功能开放的核心。小智服务器在握手完成后会主动把当前可用的工具列表推给设备设备拿到这份列表后就知道自己“现在能帮用户干什么”。4.1 工具列表从哪来工具列表的来源有两个方向一是小智服务器内置的默认工具比如播放音乐、设置闹钟、查询天气等二是通过外部MCP Server注册进来的第三方工具。服务器启动时会根据配置文件加载MCP Server地址并通过标准MCP协议从每个Server拉取工具定义然后汇总、去重、整理成一份统一列表。我部署时在配置文件里注册了一个智能家居MCP Server和一个小智官方工具包启动日志里会看到类似register tools from mcp server ...的信息后面跟着拉取到的工具数量。如果拉取失败不要急着怀疑设备端先去检查MCP Server的地址和密钥是否配置正确。4.2 工具列表消息结构拆解设备收到content.type为tools_list的消息其数据结构大致如下{ type: mcp, mcp: { to: device, content: { type: tools_list, data: { request_id: list_1700000000, tools: [ { name: set_light_brightness, description: 设置指定灯光的亮度范围为1-100, parameters: { type: object, properties: { light_id: { type: string, description: 灯光的唯一标识 }, brightness: { type: integer, description: 亮度值1-100 } }, required: [light_id, brightness] } }, { name: get_weather, description: 获取指定城市的当前天气信息, parameters: { type: object, properties: { city: { type: string, description: 城市名称 } }, required: [city] } } ] } } } }核心部分在data.tools数组。每个工具由name、description、parameters三要素组成。name是设备/LLM调用时的唯一标识description是给大模型看的语义说明模型靠它判断“用户这句话应该调哪个工具”parameters用JSON Schema格式描述参数类型和要求。为什么description那么重要因为在语音场景下用户不会说“调用set_light_brightness”只会说“调亮点”。大模型必须通过工具描述里的文字泛化成“设置灯光亮度”这个意图才能正确映射到工具。描述写得越具体、覆盖的说法越多调用准确率就越高。4.3 设备端怎么处理工具列表拿到工具列表后设备端典型做法是把工具列表传给LLM作为候选函数列表这样大模型在理解用户请求时就知道该申请调用哪个工具。与此同时设备端本地也可以把工具列表缓存下来用于界面展示或校验——比如用户问“你能干什么”设备直接从缓存里列出工具名称和描述不依赖网络。这里必须提醒一个坑ESP32这类内存紧张的单片机设备工具列表太长时直接整包缓存会爆内存。我实测过工具数量超过几十个后JSON字符串可能达到十几KB对某些设备来说压力不小。对策是只缓存工具名称和简短描述把完整参数定义丢弃有屏幕的设备则只保留用于展示的摘要字段。5. 工具调用与响应从LLM决策到设备播报的完整闭环这一节是整篇文章的重头戏。工具调用和响应虽然看起来只是两条消息但其中的时序、编号、错误处理稍有疏忽就会导致“工具执行成功了设备却反馈超时”的诡异现象。5.1 一次完整的工具调用是怎么发起的工具调用的发起有两条路径一条是LLM决策后由服务器下发“需要调用工具”的意图另一条是设备端主动调用工具。不管哪条路径最终设备端都会发出/收到一个tool_call类型的消息。先讨论设备端主动发起的情况。比如设备上有一个物理按键按下后直接触发“查询当前位置天气”设备端构造并发出{ type: mcp, mcp: { to: server, content: { type: tool_call, data: { request_id: req_1700000001, tool_name: get_weather, arguments: { city: 深圳 } } } } }这里tool_name必须和工具列表里的name严格一致大小写都不能错。arguments则是按照工具parameters结构填充的实际参数。我在调试中经常遇到的一件事小智服务器端对参数做的是JSON Schema校验如果arguments里多传了未定义的字段或者类型不符合定义比如brightness定义是integer传了字符串50会被服务器拒绝。再看LLM决策后由服务器发起的路径。这种情况下服务器会以tool_call消息形式把LLM解析好的工具调用下发到设备端等设备确认后转发给MCP Server执行。这个消息的结构和上行类似区别只在于to字段是device且request_id由服务器生成。5.2 为什么 request_id 是整个消息匹配的灵魂我调试工具调用时最深刻的体会是request_id是整个异步消息流程的灵魂。服务器发出工具调用后执行结果可能几秒钟后才回来中间设备还可能在播报、在录音、在走其他流程。如果没有request_id设备端根本分不清刚收到的tool_result对应的是哪一次调用。你自己写设备端逻辑时务必为每个tool_call保存一个映射表request_id - 本地回调函数 / 状态机状态收到tool_result时从data.request_id查表找到对应的等待状态再触发后续逻辑。查不到request_id的响应直接丢弃或打日志不要盲目处理。这样即使响应乱序、延迟、重复也不会让设备状态错乱。5.3 工具调用响应的结构工具执行完成后服务器会把结果通过tool_result消息回传给设备。正常的响应大致长这样{ type: mcp, mcp: { to: device, content: { type: tool_result, data: { request_id: req_1700000001, ok: true, result: { temperature: 28, humidity: 60, weather: 晴 } } } } }request_id与调用时的编号一一对应ok标记是否成功result是工具返回的具体数据。设备拿到result后可以做两件事一是直接显示在屏幕上二是把它转成自然语言播报通常转发给LLM生成回复文本。工具执行失败时回传的可能是tool_result且ok: false也可能是专门的tool_error消息里面通常带code和message字段{ type: mcp, mcp: { to: device, content: { type: tool_error, data: { request_id: req_1700000001, code: TIMEOUT, message: tool execution timed out after 5000ms } } } }设备端拿到TIMEOUT这类错误时要根据场景向用户播报“操作超时请稍后再试”或“该设备暂无响应”。这里我有一个经验工具超时不一定代表工具没执行成功。有些第三方接口执行比较慢服务器等不起就返回超时但后台任务其实还在跑。所以涉及“开关类”操作时我在设备端会保守一点播报“已请求操作请确认效果”而不绝对说“操作失败”。5.4 工具调用过程中音频和状态怎么协调语音设备在工具调用期间往往还要继续播放音频。比如用户说“帮我打开空气净化器”设备先播报“好的正在打开”同时后台调工具等工具结果回来后追加播报“已打开”。这里有一个矛盾小智服务器的MCP消息和音频消息是并行走同一通道的如果不加处理工具结果可能被音频播放占住导致播报时机错乱。我的做法是设备端把工具调用分成“前置播报”和“结果播报”两个音频段落。发起工具调用时先播报前置段收到tool_result后暂停其他音频任务优先插入结果播报。如果结果一直没回来超过设置的阈值后播报“暂时没有收到设备响应”。这种“先铺垫、后确认”的节奏用户体感明显更好。6. 真机调试踩坑经验从抓包到消息对齐说了这么多消息结构最后必须分享一些真机调试的硬核经验。MCP消息这东西不跑真机永远不知道坑在哪。我前后调了几周踩过不少低级的坑挑几个影响最大的来说。6.1 把三方消息用日志串起来调MCP消息时设备端、小智服务器、MCP Server三方的日志各自独立出问题时很难定位到底哪一方出了问题。我建议你在设备端给每个MCP消息都打一条结构化日志至少包含direction、content.type、request_id、tool_name、ok这几个字段。比如[dev] mcp tool_call request_idreq_1 tool_nameget_weather [dev] mcp tool_result request_idreq_1 oktrue cost1200ms然后在小智服务器那边也开启MCP日志按request_id过滤。如果设备端发出了tool_call但服务器没有转发到MCP Server的日志说明消息没到服务器问题出在设备→服务器这条链路优先检查WebSocket状态和token。如果服务器转发了但MCP Server没有执行日志问题就出在服务器→MCP Server的注册/鉴权配置。我自己总结的排查顺序很简单先看设备日志里有没有tool_result没有就往上查服务器日志再看服务器日志里有没有调用MCP Server的记录没有就查注册配置最后看MCP Server日志有没有收到请求并返回结果。按这个链路逐段截断绝大多数问题半小时内能定位。6.2 参数类型不匹配是最隐蔽的坑工具列表里的parameters定义了参数类型但很多设备端代码习惯把参数全部拼成字符串再传比如areas整型参数传50。小智服务器侧如果做了严格校验这种调用会被直接拒绝而且报错日志可能只显示“参数校验失败”不给具体字段。我遇到过一次工具明明定义brightness是 integer我传了字符串80服务器反回tool_error但那段时间日志没打全排查了很久才发现是类型问题。规避方法是在设备端构造arguments时按工具列表里properties的type做一次类型转换。字段是integer就parseInt是boolean就转布尔是array就确保传合法的JSON数组。这个转换逻辑虽然枯燥但能帮你避开大量测试时反复确认类型的焦虑。6.3 某些工具执行耗时太长需要预判默认的工具执行超时时间通常不会太长我用过的环境里大概5秒左右。但有些MCP Server执行的操作本身很重比如查询数据库、控制外设、上传文件。一旦超时设备端会收到TIMEOUT错误但后台任务可能还在继续。如果你的工具列表里确实有这种“长耗时”操作我的处理经验是这样的先给设备端一个“已收到请求”的中间响应比如服务器先把tool_call的ACK发给设备而不是等最终结果延迟播报提示比如“正在处理需要一点时间”在应用层把超时阈值调大并做好request_id的防重复处理避免同一个工具调用被设备端重复发起。这一点在你接第三方MCP Server时尤其重要因为第三方服务的响应时间根本不归你管。6.4 可以用本地模拟Server把链路完整测通没有真实智能家居设备时我可以自建一个本地模拟MCP Server做测试。做法很简单写一个最小的WebSocket/HTTP服务按MCP协议暴露一个测试工具比如set_light_brightness不管发来什么参数都返回固定的成功结果。这样把设备端、小智服务器、MCP Server三方链路完整跑通等确认消息结构全部正确后再替换成真实的第三方服务。这一步帮我避开了大量“设备端写对了但真实服务不可用/没响应”造成的干扰。强烈建议你也先搭一个模拟服务把MCP消息链路的基本盘稳定住再上真实场景。在真机上把这些MCP消息彻底调通你再看小智服务器的日志会发现设备从连接到完成一次工具调用无非就是初始化响应确认会话、工具列表建立认知、工具调用闭环执行结果这几件事。我现在调试新的工具场景时固定会先抓一份tools_list对照工具名和参数再按request_id串联tool_call和tool_result基本上不会有意外。你如果也维护着设备端代码建议在现有代码里把MCP消息日志加全特别是request_id这个匹配键——它帮你省的排查时间绝对值得你多写几行日志。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进