小智说"我已经把滑台移到 150 毫米的位置了",我拿卡尺过去一看,滑台停在大概 70 毫米的地方,驱动器的 FAULT 灯亮得发红。翻日志,MCP 工具返回的是{"success": true}。那一刻我就知道,跟 AI 联调硬件,这坑算是正式踩上了。
小智是我这边负责硬件动作调度的 AI Agent,跑在自研的 Agent 框架里,通过 MCP Server 连接嵌入式控制层,用自然语言操作继电器、步进电机和传感器。这个组合听起来很爽,实际联调起来,第一个绕不开的问题就是:工具返回 true,硬件动作就真的完成了吗?
答案是否定的,而且否定得相当干脆。这篇文章把我踩过的坑、排查链路和最终的方案完整写出来,给正在做 AI 控制硬件、或者准备把大模型接入嵌入式设备的朋友做个参考。
1. 先搞清楚:MCP 工具的那个 true,是从哪一层回来的
1.1 一次完整调用的链路
MCP 全称 Model Context Protocol(模型上下文协议),核心作用是让大模型应用能够以标准化的方式调用外部工具、读取外部数据。调用链路通常是这样:
用户提需求 → LLM 分析意图并决定调用某个工具 → MCP Host 把调用请求发给 MCP Server → Server 执行对应的工具函数 → 把返回值传回给 Host → Host 把结果交回给 LLM → LLM 根据结果继续决策。
这整个链路里,MCP 本身只负责"把工具函数的返回值安全地传回给模型"。它不是硬件协议,不关心你的继电器有没有吸合、电机有没有转动、传感器是不是真的读到了有效数据。
所以在 MCP 工具里写这样一段代码,是非常常见的:
@mcp.tool() def move_motor(steps: int) -> dict: # 把脉冲序列发出去 pulse_sender.send(steps) return {"success": True}当小智收到{"success": True}时,它实际收到的信息是:工具函数 move_motor 在服务端被成功执行了,并且函数体内部主动写了个 True 作为返回值的一部分。
注意这里的关键措辞——是"函数被成功执行了",不是"电机成功移动了"。这个区别不点破,第一次做 AI 硬件联调的人几乎都会掉进去。因为从接口上看,MCP 调用没有报错、没有超时、返回值格式合法,一切都"正常"。但正常的是代码路径,不是物理世界。
1.2 "返回成功"和"动作成功"是两个世界的事
我习惯把"成功"拆成几个层次来看,尤其是涉及硬件的时候:
| 层面 | "成功"的含义 | 你验证到了什么 |
|---|---|---|
| 函数执行层面 | 没抛异常,函数跑完了 | 函数内的代码都执行了 |
| 总线传输层面 | I2C/SPI/串口写入返回"写成功" | 数据被放进了总线缓冲 |
| 设备接收层面 | 设备收到了指令并开始执行 | 命令到达了设备,甚至只是驱动层认为到达了 |
| 动作完成层面 | 设备执行完毕并反馈结果 | 电机转到位、继电器吸合、传感器返回有效值 |
MCP 工具返回 true,绝大多数情况下只证明前两层,后面两层有没有发生,完全取决于工具实现者有没有主动去查。
还有更隐蔽的情况:某条 I2C 写入因为设备无应答报了异常,但工具实现里用了 try...except 兜底,except 分支里不管三七二十一return {"success": True}。这种代码我见过不止一次。它比"没确认状态"更危险,因为底层的故障信息被直接过滤掉了,LLM 得到的不是"未确认但真实"的结果,而是一个"虚假的成功"。
1.3 LLM 为什么天然相信 true
有朋友问我:"既然小智是 AI,它为啥不怀疑一下返回结果?"
这就要说到 LLM 的工具使用逻辑。在目前的模型工具调用机制里,模型对于工具返回值基本是当"已知信息"处理的,它没有一条独立的链路去复检硬件状态。模型看到success: true,结合工具名move_motor,很自然地就推理出"电机移动成功"。
这不是模型的错,而是它只能基于工具实现者给的数据做判断。你没给它状态确认字段,它就没有办法知道自己缺少信息。这就像你微信问朋友"到公司了吗",对方回一个"收到"——你大概率会理解成"到了",实际上对方只是在地铁上瞥了一眼手机。
所以,想让 LLM 不把 true 当完成,唯一的办法是升级返回值设计,让返回结构本身具备区分"命令已接收"和"动作已完成"的能力。
2. 从 "true" 到 "动作完成",中间隔着三道坎
2.1 第一道坎:时间差,命令发出去了但动作还在路上
硬件动作不是瞬时的。下面几个数据是实际项目里的常见量级:
- 继电器吸合时间:5~20ms
- 舵机从 0° 转到 90°:300~800ms
- 步进电机走 200 步:视转速而定,通常 1~3 秒
- 加热器从 25°C 升到 100°C:几十秒到几分钟
如果工具实现是"发送命令后立即返回",那 true 对应的物理时刻,动作才刚刚开始。
@mcp.tool() def set_relay(relay_id: int, on: bool) -> dict: driver.set_relay(relay_id, on) return {"success": True}这条函数走完可能只需要 100 微秒,但继电器触点真正吸合、稳定导通需要 10ms 以上。你返回 true 的时候,物理世界的动作还在路上。
更麻烦的是连续动作场景。小智需要"打开继电器 A → 等系统上电 → 5 秒后读取传感器",如果第一步的 true 被当成"完成",它就会立刻去读传感器,结果读到的是系统尚未上电时的无效数据。这种由 true 导致的时序错乱,在自动化流程里比手动操作危险得多,因为错误的动作会被当作正确状态缓存下来,持续影响后面的决策。
2.2 第二道坎:底层库吞掉了故障
很多嵌入式驱动库的 API 设计得很粗糙,写寄存器的方法返回 bool,但这个 bool 往往只代表"总线写入操作本身是否成功",不代表设备确认"我执行了这个动作"。
举一个典型的 I2C 场景。在 Linux 上用 ioctl 发 I2C 写操作,返回 0 表示"写入成功",但你写进去之后传感器内部发生了什么,驱动层完全不知道。如果传感器因为配置非法根本没有完成转换,它会在状态寄存器里置一个错误位,但工具函数不去读这个寄存器,就永远看不到。
还有一个更典型的情况:有些串口设备收到命令后会回 NAK(否定应答),但工具实现者只发送、不读取应答,直接 return true。于是那个 NAK 就一直躺在串口接收缓冲区里,而小智已经拿着 true 走了。
这种"吞掉故障"的问题,根源在于工具实现者把"发出去了"等同于"完成了"。
2.3 第三道坎:true 这个布尔值本身装不下真实状态
硬件动作的真实状态,至少是四态的:
- pending(未执行/排队中)
- running(执行中)
- completed(已完成)
- failed(已失败)
布尔值只有两态:true / false。用布尔值描述四态场景,信息必然损失。
哪怕你把 false 理解为"失败","执行中"也无处安放。异步硬件动作执行到一半的时候,工具函数要向下走,该返回什么?返回 false,LLM 会认为动作失败了,触发异常处理,但实际上动作还在正常跑。
true / false 也表达不了失败原因。真实项目里的"失败"五花八门:设备无响应、总线上无 ACK、寄存器配置非法、驱动芯片过流保护、电源电压不稳、执行机构堵转。全部压缩成一个 false,LLM 没有任何线索判断如何恢复。它只会尝试重复调用,再失败,再卡死。
顺带说一句,在 C 语言的世界里,true 不过是 stdbool.h 里定义的宏,本质是整数 1;不同 SDK 对它的定义还可能不一样。这个语义漂移从 C 层传到 Python 层,再传到 MCP 返回的 JSON 里,最后进入大模型的上下文,每一层都会丢失一点信息。等到了 LLM 手里,它已经是一个被层层简化过的信号,再拿它当"硬件确认"来用,风险极高。
2.4 一个真实的翻车现场:步进电机堵转
我们当时有个需求:让小智通过 MCP 控制步进电机带动丝杆,把滑台移动到指定位置。电机驱动器带堵转检测,FAULT 引脚在堵转时拉低。
最初的工具实现很直接:
@mcp.tool() def move_slider(target_mm: float) -> dict: steps = int(target_mm * STEPS_PER_MM) pulse_ctrl.emit_steps(steps) return {"success": True}一切正常的时候没事,但有一次滑台到了行程末端,丝杆顶住机械限位,电机堵转了。驱动器 FAULT 引脚已经拉低,可是工具函数从头到尾没有去读 FAULT 引脚的状态,小智收到的仍然是{"success": True}。
最要命的是后续动作。小智拿着这个 true 继续执行判断,把夹具气缸给触发了。等我们听到异响赶过去,滑台已经发出刺耳的摩擦声,驱动器过流报警灯闪个不停。
排查日志时我们发现,从堵转发生那一刻起,驱动器的状态位就变了,FAULT 信号也稳定输出。整个系统里除了工具函数,没有人读到这个信号。硬件层面不是没有反馈,是软件层根本没把反馈纳入返回链路。
这个案例让我彻底确定了一条设计原则:工具返回的内容,不是"我执行了什么",而是"硬件确认了什么"。
3. 让 true 真正可信:把"下发"和"确认"拆成两件事
3.1 核心设计:command_id + 状态查询
最直接的方法,是不要试图用一个工具函数干完所有事。把"一条命令做完动作并返回 true"重构成两个工具。
第一个是动作下发工具:
@mcp.tool() def move_slider(target_mm: float) -> dict: command_id = uuid.uuid4().hex if not device_online(): return {"command_id": command_id, "status": "rejected", "error": "device offline"} _action_store[command_id] = { "status": "running", "target_mm": target_mm, "started_at": time.time(), } # 后台线程执行真实动作,避免阻塞 MCP 调用 threading.Thread( target=_do_move, args=(command_id, target_mm), daemon=True ).start() return {"command_id": command_id, "status": "accepted"}第二个是状态查询工具:
@mcp.tool() def get_action_status(command_id: str) -> dict: action = _action_store.get(command_id) if not action: return {"status": "not_found", "error": "unknown command_id"} return action配合这个方案,LLM 的调用方式变成:
- 小智调用
move_slider(150.0),拿到一个command_id - 小智调用
get_action_status(command_id),得到running - 小智继续查询,得到
completed,附带最终位置150.1mm - 小智确认"到位",汇报完成
这样,true 这个词不再是空洞的表态,而是一个可追踪、可回查的流程节点。我在_do_move里使用后台线程写状态,保证 MCP 调用不被长动作卡死,这个设计在后面的实操中也反复验证是必要的。
3.2 给每个硬件动作定义"完成"的判据
每种硬件动作的"完成"标准不一样,工具实现者必须替硬件定义清楚,并把对应的可观测信号读回来填进返回字段。我们项目里的判据参考如下:
| 硬件动作 | 完成判据 | 返回字段 |
|---|---|---|
| 继电器吸合 | 读取反馈触点状态或线圈电流达阈值 | confirmed: true, type: "feedback" |
| 步进电机走位 | 编码器计数值与目标一致,驱动器无 FAULT | position: 150.1, error: null |
| 舵机转到角度 | PWM 波形发送完毕,电流无异常 | angle_set: 90, current: 0.4A |
| 固件烧录 | 写入完成且校验和一致 | crc_ok: true, bytes: 8192 |
| 传感器采集 | 数值落在合理区间,状态寄存器无错误位 | value: 25.3, valid: true |
例如步进电机场景中,_do_move的完整逻辑大致是:
def _do_move(command_id: str, target_mm: float): try: pulse_ctrl.emit_steps(int(target_mm * STEPS_PER_MM)) time.sleep(0.5) # 等动作相对稳定,再读取反馈 if driver.fault_pin.is_low(): _action_store[command_id].update({ "status": "failed", "error_code": "MOTOR_STALL", "detail": "步进电机堵转,驱动器 FAULT 引脚拉低,请检查丝杆与机械限位", }) else: _action_store[command_id].update({ "status": "completed", "position": encoder.read_mm(), }) except Exception as e: _action_store[command_id].update({"status": "failed", "error": str(e)})对于没有反馈信号的简单硬件,比如只有一个 GPIO 控制的 LED,完成判据就是"GPIO 写入后的电平与目标一致",同样要读回来确认。写高电平之后读引脚电平确实为高,才算 completed。能把"读回来"这一步做出来的工程,基本就不会再犯早期的低级误判。
3.3 工具描述怎么写,LLM 才不误解
MCP 工具的 schema 里有 description 字段,会作为工具使用说明的一部分提供给 LLM。很多开发者写得很随意,比如 "Move the slider",然后就没有了。这种描述完全无法让 LLM 预判返回值里的坑。
我的建议是在 description 里写清楚四点:
- 这个工具是"下发命令"还是"查询状态"
- 返回的 status 字段取哪些值,每个值什么含义
- 下发工具只负责命令受理,不负责执行结果
- 查询执行结果应调用哪个工具
例如:
{ "description": "下发滑台移动指令。返回值 status 为 accepted 仅表示命令已受理,不代表滑台已到位。调用后应通过 get_action_status 查询执行结果。", "inputSchema": { "type": "object", "properties": { "target_mm": {"type": "number", "description": "目标位置,单位毫米"} }, "required": ["target_mm"] } }这段描述写完之后,小智在调用时就会明白:拿到 accepted 不能直接汇报成功,需要接着查状态。这一步能极大减少 LLM 误判。
3.4 硬件没有状态反馈怎么办
不是所有硬件都有状态反馈。低成本场景里,可能就是一个 GPIO 控制继电器,没有辅助触点;一个 PWM 控制舵机,没有角度传感器。这种情况下做不到物理确认,但至少应该把"未确认"这件事诚实返回。
做法是返回结构里增加verified: false,同时给出提醒:
return { "status": "sent", "verified": False, "hint": "该继电器无反馈触点,无法确认实际吸合状态,请通过电流/温度等间接指标观察" }这样设计之后,LLM 至少不会拿着一个没有闭环的信号,自信满满地汇报"动作已完成"。它知道自己处于"不可验证"的状态,就会用更谨慎的措辞跟用户沟通。
4. 落地实操:轮询、超时、错误码和日志,一个都不能少
4.1 状态查询工具要尽量避免反复轮询
前面说过,LLM 进行工具调用是有心智成本的。如果一次查询拿不到结果,让它机械地反复查询 10 次,不仅慢,而且容易出错。
一个对 LLM 友好的做法是:让get_action_status内部做一次短时间的阻塞等待。例如,最多等 3 秒,每 100ms 查一次状态,一旦完成立即返回;超过 3 秒没完成,就返回当前状态 running。
@mcp.tool() def get_action_status(command_id: str) -> dict: deadline = time.time() + 3.0 while time.time() < deadline: action = _action_store.get(command_id) if action and action["status"] in ("completed", "failed"): return action time.sleep(0.1) action = _action_store.get(command_id) return action if action else {"status": "not_found"}注意,MCP 调用默认是同步请求,工具内部阻塞太久会卡住整个 Agent 的响应。3 秒是一个相对安全的区间。超过 3 秒的长动作,我会在_do_move里继续用后台线程跑,同时让查询工具先返回 running,附上预估剩余时间。
4.2 超时与重试:不是每个动作都能无脑重试
超时时间不能一刀切。继电器 100ms 内没反馈基本可以判定异常,加热器 5 秒没完成还在正常范围内。我在工具实现里给每个动作类型配了一个estimated_seconds字段,查询接口返回 running 时同时返回这个字段,LLM 看到它就知道大概什么时候再查。
比如:
{ "status": "running", "estimated_remaining_seconds": 2.5 }这样避免了模型盲猜,也减少了无效轮询。
重试要特别小心,不是所有动作都适合重试。步进电机移动这类带位置的动作,重试前必须重新读取当前位置、重新计算剩余步数,否则会重复执行导致过冲。我吃过"重试后重复发指令,机械限位被硬撞"的亏。
所以重试逻辑最好写在工具函数内部,而不是让 LLM 自己决定重试。工具函数内部重试时,要判断动作的幂等性:
- 继电器设置"开/关",幂等,可安全重试
- 步进电机"移动 N 步",非幂等,重试前必须重新计算
- 固件烧录,非幂等,重试前要重新擦除
4.3 结构化错误码,让 LLM 能读懂并自主恢复
错误码不是给机器看的,是给 LLM 看的。它决定模型下一步能做什么。我们项目里维护了一张错误码表:
| 错误码 | 含义 | LLM 应采取的典型动作 |
|---|---|---|
| DEVICE_OFFLINE | 设备离线,无法通信 | 提示用户检查电源/连接 |
| BUS_NAK | 总线无应答,可能未上电或地址错误 | 检查设备地址与上电状态 |
| REGISTER_INVALID | 寄存器配置非法,参数越界 | 修正参数后重试 |
| MOTOR_STALL | 电机堵转,检测到驱动器 FAULT | 提示机械卡滞,请求人工介入 |
| TIMEOUT | 动作执行超时 | 查询状态,必要时重置设备 |
| CRC_MISMATCH | 校验和错误,多用于固件烧录 | 重新下发固件 |
返回格式统一为:
{ "status": "failed", "error_code": "MOTOR_STALL", "detail": "步进电机堵转,驱动器 FAULT 引脚拉低,请检查机械限位和滑台是否卡死" }这样的 detail 字段让 LLM 能理解并回复用户"滑台卡住了,需要先松开过流保护",而不是机械地再说一遍"失败"。对于普通读者来说,可以这样理解:错误码是给 AI 看的精确诊断码,detail 是给人看的可读信息,两者缺一不可。
4.4 日志与链路追踪:出问题时才不会抓瞎
MCP 工具调用日志要与硬件调试日志对齐。我在实际项目里用统一的毫秒时间戳打点,所有 MCP 调用和底层硬件日志都归到同一个时区。出问题时,先按 command_id 过滤动作链路,再按时间轴对齐硬件日志,通常能快速定位是硬件问题、驱动问题还是工具逻辑问题。
工具入口要把入参完整打出来。比如"target_mm: 150.0",不能只打成"args..."。浮点数转步数时可能因为精度问题多发几个脉冲,这类问题只能靠入参日志发现。
还有线程安全。多个工具并发调用同一个_action_store时,会出现脏读。我第一版用普通 dict,并发一高,状态互相覆盖,日志乱成一团。后来改成 dict + threading.Lock,或者直接用 Redis 做状态中心,问题才解决。
5. 怎么验证你的 MCP 硬件工具是可信的
5.1 故障注入测试:把硬件往死里整
验证工具是否可信,不能只测正常流程,还要故意让硬件失效,看工具返回什么。
我使用的故障注入手段包括:
- 挡住机械限位,发一个越程指令,验证工具是否返回 MOTOR_STALL
- 拔掉设备电源,发指令,验证是否返回 DEVICE_OFFLINE
- 断开传感器连接,发读取指令,验证是否返回 BUS_NAK 而不是 true
- 在动作执行到一半时强制断电再上电,验证命令状态是否变成 failed,而不是永远 running
- 连续快速发送两个动作,验证 command_id 互不干扰
每次故障注入后要看小智的最终回复。如果它仍然自信满满地说"已完成",说明工具层传给 LLM 的信息不够充分,需要继续补状态字段和错误码。
5.2 一张验证清单
| 测试项 | 操作 | 期望的工具返回 | 期望的 LLM 反馈 |
|---|---|---|---|
| 正常移动 | 移动滑台到合法位置 | completed + position | 汇报到位 |
| 堵转 | 挡住滑台后发移动指令 | failed + MOTOR_STALL | 提示机械卡滞 |
| 离线 | 关闭设备电源后发指令 | rejected + DEVICE_OFFLINE | 提示设备离线 |
| 半途断电 | 移动过程中断电 | failed + TIMEOUT 或对应错误码 | 提示执行中断 |
| 无反馈设备 | 控制无反馈继电器 | sent + verified=False | 提示无法确认 |
5.3 实测中发现的两个坑
一开始我用固定 2 秒超时判断所有动作,结果一个需要 5 秒的加热动作反复被标记成 failed,小智每次都在中途报错。后来改成"按动作类型设置预期时长 + 超过预期时长数倍才判定超时",误报率才降下来。
第二个坑是关于 estimated_remaining_seconds。这个字段最初没做,小智不知道动作还要多久,就频繁查询。加了之后,它学会了等待。这说明工具返回的元数据越丰富,LLM 对工具的使用越从容。
这套方案改完之后,小智再也没干过"拿着 true 硬刚硬件"的事。我个人的体会是,做 AI 加硬件联调,最忌讳的就是把 AI 当全知全能的操盘手。工具层必须把硬件的不确定性如实暴露给模型:给它一个带状态语义的接口,它就能做出负责任的操作;给它一个空泛的 true,它就只能给你一个空泛的承诺。