ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

从return true到硬件确认:AI控制硬件的状态闭环设计

从return true到硬件确认:AI控制硬件的状态闭环设计 小智说我已经把滑台移到 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 理解为失败执行中也无处安放。异步硬件动作执行到一半的时候工具函数要向下走该返回什么返回 falseLLM 会认为动作失败了触发异常处理但实际上动作还在正常跑。true / false 也表达不了失败原因。真实项目里的失败五花八门设备无响应、总线上无 ACK、寄存器配置非法、驱动芯片过流保护、电源电压不稳、执行机构堵转。全部压缩成一个 falseLLM 没有任何线索判断如何恢复。它只会尝试重复调用再失败再卡死。顺带说一句在 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), daemonTrue ).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步进电机走位编码器计数值与目标一致驱动器无 FAULTposition: 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 verifiedFalse提示无法确认5.3 实测中发现的两个坑一开始我用固定 2 秒超时判断所有动作结果一个需要 5 秒的加热动作反复被标记成 failed小智每次都在中途报错。后来改成按动作类型设置预期时长 超过预期时长数倍才判定超时误报率才降下来。第二个坑是关于 estimated_remaining_seconds。这个字段最初没做小智不知道动作还要多久就频繁查询。加了之后它学会了等待。这说明工具返回的元数据越丰富LLM 对工具的使用越从容。这套方案改完之后小智再也没干过拿着 true 硬刚硬件的事。我个人的体会是做 AI 加硬件联调最忌讳的就是把 AI 当全知全能的操盘手。工具层必须把硬件的不确定性如实暴露给模型给它一个带状态语义的接口它就能做出负责任的操作给它一个空泛的 true它就只能给你一个空泛的承诺。
返回列表