直接让大模型去操作真实设备这件事,我第一次试就栽了跟头。模型确实听懂了我的话,也确实按正确顺序调用了接口,可它把温度单位搞混了——一个设备返回的是毫摄氏度,另一个返回的是摄氏度,结果一次“调低空调温度”的动作,直接把实验室从一个稳态推到了另一个极端。那次事故让我意识到,AI 要进入物理世界,缺的其实不是更聪明的模型,而是一层足够笨、足够确定性的中间系统。
M-Language Core 就是冲着这个缺口做的。它是一套极简指令语言,外加一个确定性虚拟机,专门夹在 AI 模型和真实设备之间,负责把模型的“意图”翻译成有边界的、可验证的、可审计的物理动作。语言本身只有少量根指令,虚拟机则保证:同样的指令序列,在任何时候执行,产生完全一致的调度顺序、状态变化和事件记录。
这篇文章不讲复杂理论,只讲实际方案:为什么需要这层 VM、指令集怎么定、确定性怎么实现、设备接入怎么做,以及我自己的真机联调踩坑记录。适合正在做“大模型控制真实设备”“语音助手接 IoT”“智能体操控机器人”这类项目的开发者,也适合对可解释执行系统感兴趣的工程师。
1. 为什么 AI 指令需要一个独立的虚拟机层
1.1 大模型输出与物理动作之间隔着一层“语义缝隙”
如果让模型直接返回设备控制的 JSON 或者函数调用,本质上是在赌两件事:第一,模型的语义理解足够稳定;第二,设备接口的语义恰好和模型想象中的一致。实际做下来,这两个赌注大概率会输掉一个。
设备接口的语义差异是常态。同样是“设置温度”,有的接口用 0 到 100 的百分比表示功率,有的用浮点摄氏度,有的还要区分制热和制冷模式。同样是一条“打开开关”的命令,有的设备要求保持两秒的高电平,有的设备只是翻转一个寄存器位。模型并不知道这些细节,它只会按照训练语料里的惯用方式生成调用,而惯用方式在真实硬件面前往往不够精确。
更麻烦的是,模型的输出是概率性的。同一个意图,连续问十次,可能得到十种不同的调用顺序;同样的调用,今天能成功,明天因为上下文干扰就多加了一个不该有的参数。物理世界不接受概率——灯要么亮了要么没亮,阀门要么开了要么没开,不会因为模型“比较确定”就让结果变得可靠。
所以我们需要一个中间表示层。这个层要做的事很简单:把模型的自由文本意图,翻译成一种表达能力被故意压缩过的指令序列。翻译之后的指令集是固定的、有限的、有明确语义的。模型只能在边界内做选择,而不能自由发挥。
1.2 确定性是物理控制系统的基础属性
我见过不少系统把“确定性”理解为“结果稳定”,这是个误区。结果稳定只是统计学意义上的现象,物理控制要的是过程可重放。
什么叫过程可重放?就是给定同样的初始状态、同样的指令序列、同样的外部事件序列,系统的每一步决策和每一个输出都是完全一致的。没有隐藏的随机种子,没有依赖系统时钟的调度抖动,没有“看着差不多就继续”的模糊判断。
为什么要追求这么严格?因为出问题的时候,你需要能回答问题:这个动作是谁发起的?上一条指令是什么?为什么这一步执行了而下一步没有?如果系统内部充满随机性和隐式分支,这些问题根本无从查起。
确定性还有一个直接好处:可以仿真。把指令序列放进确定性虚拟机,配合设备的物理模型,就能推演出预期的状态变化轨迹;再把真实世界的观测值拉回来对比,差距一目了然。没有确定性的执行内核,仿真和真实之间就没有可对应的锚点,对比无从谈起。
1.3 M-Language Core 的真实定位
这套系统不是要替代操作系统,也不是要重写设备 SDK,它是“翻译—执行—验核”三层结构里的执行核。
模型层负责把用户的自然语言变成结构化意图;编译层负责把意图编译成 M-Language 指令流;虚拟机层负责装载指令流并执行物理动作。层与层之间严格隔离,模型拿不到设备原始接口,只能看到编译层给它的受限动作图;虚拟机拿不到模型推理细节,只按照指令逐条执行。
这个分层带来一个关键能力:责任隔离。如果物理设备出了问题,我们不需要审问模型“你当时为什么要那样推理”,只需要查虚拟机里留下了哪条指令、由哪个编译任务签发。调试边界一下子变得非常清晰。
2. 极简指令集是怎么定出来的
2.1 从物理世界倒推指令边界
设计指令集时,我没有先画编程语言的语法树,而是先列了一张表:一台设备上,人类可能希望 AI 替自己做的所有动作类别。
列完之后发现,动作类别其实很少。无非是打开、关闭、调节、等待、校验、采样、停止、恢复。那些看起来高级的“复杂行为”,比如“把房间调到舒适温度”,本质上是“读取温度—计算目标—设置风量—等待温度到达—再次确认”的组合。
于是我把根指令压缩到了十个以内。压缩的原则很简单:每个指令必须对应物理世界中的一次可观测状态变更或一个明确的外部事件等待。凡是不能直接映射到状态变更的指令,都不进入根集合。
2.2 根原语与它们的状态语义
下面是 M-Language Core 的根原语集合,每个原语都绑定明确的状态写操作或阻塞条件。
| 原语 | 参数 | 物理语义 | 执行后的可观测效果 |
|---|---|---|---|
| OPEN | device, channel | 打开设备的某个通道 | 设备通道状态变为 OPEN,可能伴随电流、信号量变化 |
| CLOSE | device, channel | 关闭设备的某个通道 | 设备通道状态变为 CLOSE |
| SET | device, property, value | 设置设备某个属性的目标值 | 属性目标值更新,设备开始逼近该值 |
| WAIT | condition, timeout | 阻塞执行直到条件满足或超时 | 没有直接状态变化,产生事件记录 |
| CHECK | device, property, operator, expected | 读取设备属性并与期望值比较 | 产生布尔观测结果,不修改物理状态 |
| EMIT | label, payload | 向外发出一条结构化事件 | 消息队列中出现一条记录 |
| GUARD | condition, action_id | 安装一个保护条件,触发时跳转 | 增加一条动态约束规则 |
| REVERT | action_id | 对已执行动作进行补偿 | 执行该动作的逆操作 |
| HALT | reason | 无条件停止后续指令 | 虚拟机进入停机状态 |
| OBSERVE | device, property | 将设备属性的当前值追加到事实库 | 事实库新增一条时间点观测值 |
这里有一个要点:SET 不保证物理状态立刻到达目标值。它只保证“目标值被写入设备控制面”。真正的到达确认,必须由后续的 WAIT 和 CHECK 完成。把“写目标”和“确认到达”拆成两步,是为了让每条指令的职责单一,也方便在故障时精确定位是哪一步出了问题。
2.3 为什么砍掉循环和条件分支
最初的设计稿里,我留了 LOOP 和 IF。后来在实际模拟中把它们砍掉了。
理由很直接:任何形式的无条件循环,放在 AI 生成逻辑里都是危险品。一次循环里读温度、开阀门、再等待,如果外部条件持续不满足,循环就会变成无限往复的物理动作,轻则设备磨损,重则系统磨损。虽然可以加循环上限,但上限本身又是一个需要模型理解的复杂语义,不如直接在语言层面禁止。
条件分支也被砍了。M-Language 里没有 IF-THEN-ELSE,只有 GUARD。GUARD 是一等公民的例外处理机制:当一个保护条件被触发时,系统跳转到指定的处理动作,然后停机或转入人工接管。这里没有“分支回去继续执行”的路径,只有“停止或走安全通道”。
这个设计决策的本质是:AI 控制物理世界时,表达能力的优先级远低于安全性。图灵完备的语言把更多选择权交给了模型,也把更多失控半径交给了模型。极简语言牺牲了灵活性,换来了“AI 无论如何都跑不出边界”的底线性保证。
2.4 WAIT 和 CHECK 必须是内建语义
很多设备控制框架把“等待”和“校验”当作普通库函数,需要开发者手动调。这对人是一回事,对 AI 是另一回事。模型天然倾向于省略校验步骤:它在训练数据里见过太多“调用接口—直接成功—然后继续”的示例,很少见到“调用接口—验证反馈—不一致时先纠正”的完整路径。
所以我把 WAIT 和 CHECK 提升为一等原语,每个动作原语执行后自动进入观测窗口。具体来说,每次 OPEN、CLOSE、SET 执行后,虚拟机默认注入一条隐式 CHECK,比对设备反馈与预期状态。观测结果与预期不一致时,自动转入错误处理流程,而不是让 AI 继续发下一条指令。
这一步极其关键。它把“AI 可能忘了确认”变成“系统根本不允许不确认”。错误处理不再依赖模型的自觉,而是由 VM 的结构强制完成。
3. 确定性虚拟机:从“同一输入同一输出”到“同一事件同一痕迹”
3.1 单线程调度与逻辑 Tick 时间模型
确定性的第一个敌人是并发。并发引入调度不确定性,调度不确定性引入执行顺序差异。所以 M-Language Core 的虚拟机内部拒绝任何形式的无界并行。
所有指令在一个 FIFO 单线程队列里逐个执行。指令栈之外没有后台协程,没有隐式线程池,没有“稍后自动执行”的定时任务。所有定时能力都收敛到 WAIT 原语,而 WAIT 的触发条件由事件循环统一判定。
时间模型上,我采用了逻辑 Tick。每执行一条原子指令,Tick 计数加一。外部真实时钟只作为两个用途:绑定设备事件的时间戳,以及给 WAIT 提供物理超时判断。调度顺序本身只依赖 Tick 和显式状态,不依赖墙钟时间。
为什么不能直接用系统时钟做调度?因为系统时钟会被 NTP 修正、会被用户手动调整、会在跨设备时出现偏移。逻辑 Tick 则永远是单调递增的本地计数器,不受外界干扰。重放执行时,只要初始 Tick 一致,无论真实时间走得多快或多慢,指令序列的推进顺序完全一样。
3.2 资源版本化与状态快照
物理设备的真实状态有时和系统记录状态存在偏差。为了消除这种偏差,虚拟机对每个资源维护三个东西:ResourceID、当前认知状态、状态版本号。
每次 SET 或 OPEN 写入控制面时,指令会携带一个期望版本号。设备端回传的确认消息必须匹配这个版本号,写入才算成功。如果回传版本号不匹配,说明设备状态已经被外部因素改变,虚拟机不会盲目覆盖,而是重新执行一次 OBSERVE 拉取最新状态,再重新评估后续指令是否还成立。
你可以把它理解成数据库里的乐观锁,只不过锁的对象是物理设备的状态。这个机制解决了一个常见问题:AI 规划的动作基于过时的环境假设。设备状态改了,但指令序列里的前提条件还是旧的,容易造成二次伤害。版本化确保每次写入都能察觉到“前提已经失效”。
3.3 不可逆动作与回滚边界
物理世界里没有真正的撤销。关灯后可以再开灯,但切割材料之后不会自动粘回去,锁被安全复位之后不会因为 REVERT 就回到原状。
所以 VM 里只存在两种处理路径:回滚补偿和不可逆停机。可逆动作(开关、亮度调节、阀门微调)记录一个补偿动作栈;一旦后续校验失败,按逆序执行 REVERT。不可逆动作走完全不同的通道:执行前必须经过额外审批链,执行时进入“单指令锁”状态——这条指令提交之后,VM 不再自动发送任何后续动作指令,只允许人工接管。
| 动作类型 | 由 VM 自动补偿? | 故障后的处理通道 | 示例 |
|---|---|---|---|
| 可逆动作 | 是,执行逆操作 | 自动回退后告警 | 开关灯、设定亮度、调节风扇档位 |
| 可补偿动作 | 是,根据补偿表执行 | 自动补偿后仍异常则停机 | 温度控制、功率输出、进给速度 |
| 不可逆动作 | 否 | 单指令锁 + 人工接管 | 固件复位、安全锁释放、电机强停 |
这个分类表是接入每个新设备时必填的元数据。设备接入时如果没定义动作类型,虚拟机直接拒绝装载该设备的控制指令。宁可速度慢,也不做没有分类的动作。
4. 从 LLM 意图到指令流:编译、白名单与审批链
4.1 意图编译器:自由文本到动作图
模型不直接输出 VM 字节码,而是输出一个结构化的“意图 JSON”动作图。原因很现实:字节码的语法规则相对严格,模型很难百分百遵守;而 JSON 这类结构数据,模型生成的成功率高得多,也方便在编译器里做静态校验。
动作图是一个有向无环图,节点是动作原语,边是执行顺序和依赖关系。编译器完成四件事:把节点映射到根原语,检查每个节点的参数是否在合法范围内,检查边的时序是否与物理约束冲突,最后把动作图线性化为指令序列。
4.2 能力白名单与最小权限会话
每个设备接入 M-Language 时,都要登记一张能力表。能力表包含三类信息:设备拥有的可执行动作、每个动作允许的参数范围、单位时间内的最大调用次数。
AI 会话拿到的是一个受限资源句柄,上面只挂载了这张能力表的子集。也就是说,即使模型“知道”某台设备的底层命令,只要该命令不在授权能力表里,虚拟机层面的调度器就会直接拒绝。具体配置结构如下:
| 字段 | 示例 | 说明 |
|---|---|---|
| device_id | light_hub_02 | 设备唯一标识 |
| actions | OPEN, CLOSE, SET, CHECK | 允许执行的动作原语 |
| properties | power, brightness, color_temp | 允许读写的属性名 |
| ranges | brightness: 0-100 | 参数上下限 |
| rate_limit | 5 次/10 秒 | 同一动作的频率上限 |
| movement_class | reversible | 动作可逆性分类 |
这里最容易被忽略的是参数范围校验。模型很容易生成“把功率设置为 -10”或者“把亮度设置为 5000”这类荒谬值。语言模型没有物理直觉,它只是在做 token 续写。能力表里的 ranges 字段,就是为了在编译阶段就拦截这些无效参数。
4.3 高风险动作的多人审批链
不可逆动作清单由每个项目的安全管理员维护。清单中的动作,无论模型规划得多么合理,都不能由 AI 自动执行。执行链被硬编码为四段状态机:AI_STAGING 表示意图已生成,等待人工确认;HUMAN_APPROVED 表示有人明确批准;EXECUTING 表示进入执行,此时自动锁定其他所有动作通道;CONFIRMED 表示设备端回传成功,系统解锁。
这个状态机的好处是,无论模型如何重复触发或者绕路,都无法跳过人工审批节点。审批记录、审批时间和审批人信息全部写入审计日志,和指令执行记录关联。
我实际部署时发现,这个审批链对研发环境的效率影响并不大,因为大多数普通动作走不到审批链;但它对系统安全信心的提升是巨大的。你知道那些会带来不可逆后果的动作,永远不会被一段概率性生成的文本直接触发。
5. 搭一个最小可运行系统的实操路径
5.1 运行时核心实现思路
虚拟机核心可以压缩成一个非常小的解释循环。初始化时拿到指令序列、初始事实库和一组已注册设备句柄,然后循环执行指令。每执行完一条,更新 Tick 并写入审计日志。遇到 WAIT 时挂起当前指令,等待设备事件或超时;遇到 CHECK 时同步读取设备属性,写入事实库。
核心调度逻辑大致如下:
def run(self, program, context): device_registry = context.device_registry fact_store = context.fact_store tick = 0 while self.pc < len(program): instr = program[self.pc] # 状态版本校验 if instr.requires_state_match and not device_registry.version_match( instr.device_id, instr.expected_version ): self.handle_state_conflict(instr) break # 执行原子动作 result = device_registry.execute(instr.atomic_action()) if not result.ack: self.handle_action_failure(instr, result) break # 强制观测窗口 observation = device_registry.observe(instr.device_id, instr.observe_property) fact_store.record(tick=tick, instruction=instr, observation=observation) self.write_audit(instr, result, observation, tick=tick) self.pc += 1 tick += 1有一点要注意:这个循环里没有重试逻辑,所有动作只执行一次。重试逻辑统一放在更高层的策略模块里,并且对不可逆动作使用单独的重试确认通道。这个设计的理由,后面踩坑部分会详细说明。
5.2 一段真实的 M-Lang 脚本剖析
下面是一段典型的设备控制脚本,场景是实验室的自动温度巡检:目标设备是环境舱,包含温度传感器、加热器、循环风机和指示灯。
program: auto_temp_check steps: - id: s1 op: OPEN device: heater channel: power tag: infra - id: s2 op: SET device: thermostat property: target value: 24.0 - id: s3 op: WAIT condition: property: temp_reading device: sensor_a operator: GE value: 23.5 timeout_ms: 30000 - id: s4 op: CHECK device: thermostat property: heater_status operator: EQ expected: RUNNING - id: s5 op: OBSERVE device: sensor_b property: temp_reading - id: s6 op: EMIT label: temp_observation payload: source: sensor_b property: temp_reading - id: s7 op: CLOSE device: heater channel: power这段脚本的执行逻辑是:打开加热器电源、把目标温度设为 24 摄氏度、等待温度传感器读数达到 23.5 以上(超时 30 秒)、确认加热器状态是 RUNNING、读取传感器 B 的温度、输出结构化事件、最后关闭加热器。
有一点值得注意:脚本里 WAIT 的条件用的是测试舱自己的传感器 A,而最终观测用的是传感器 B。这是故意设计的——AI 不能只依赖单一信息源做最终判断。主传感器可能坏,可能被遮挡,可能探针位置不对,双点校验才能提供冗余。
5.3 可观测性:审计日志与事件溯源
可观测性对物理控制系统的重要性不亚于执行正确性。M-Language Core 的每一条指令都会产生一条结构化日志,字段如下:
| 字段 | 含义 | 示例值 |
|---|---|---|
| tick | 逻辑执行序号 | 42 |
| instr_id | 指令标识 | s4_check_heater_status |
| op | 原语名 | CHECK |
| device_id | 目标设备 | thermostat |
| property | 目标属性 | heater_status |
| expected | 预期值 | RUNNING |
| observed | 实际观测值 | IDLE |
| result | 指令结论 | FAIL_MISMATCH |
| action_type | 可逆性分类 | reversible |
日志以 JSONL 格式落盘,每行一条。这个格式有两个额外好处:一是可以导入到文本分析工具里做模式排查,二是可以直接作为事件溯源的消息体传给监控面板。
我特别推荐把审计日志设计成不可变文件,只追加,不修改。执行过程中的任何策略调整、参数变更,都不应该影响已经写下的记录。这样当出现争议时,日志就是唯一的仲裁依据。
6. 真机联调踩过的三个坑
6.1 逻辑时钟正常,但观测时间戳漂移导致版本误判
第一次把 VM 接到真机上时,我发现状态版本判断偶尔出错。检查了内部逻辑,发现 Tick 推进完全正常,问题出在外部传感器回传的时间戳上。
有几台传感器上报时间戳依赖设备自身的晶振,另一部分传感器上报的是网络同步时间,两者存在几百毫秒到秒级的偏差。虚拟机拿这些时间戳做版本比较时,会得出“某个状态比实际更旧”的错误结论,从而触发不必要的状态重拉。
解决方案是:外部时间戳一律只作为事件排序参考,不参与状态版本判断。状态版本只使用设备自身的单调计数器,每次设备回传状态时带上计数器值,虚拟机对比计数器值判断新老,不再依赖墙钟。
这个教训的通用版本是:物理系统中,任何外部的时间来源都不可信,要么用本地单调时钟,要么用设备自己的版本计数器。
6.2 WAIT 超时参数拍脑袋,导致反复自我干扰
环境温度在目标点附近波动时,会引发一个隐蔽的故障循环。我的初始脚本里 WAIT 超时设成了固定的 15 秒,条件是“温度达到 23.5 度以上”。现实情况是温度在 23.4 和 23.6 之间来回震荡,经常在接近超时时才达到阈值,于是下一步 CHECK 读到“尚未稳定”的状态,触发补偿动作,系统反复开关加热器。
根因不是逻辑有问题,而是超时参数没有和设备物理响应曲线对齐。不同体积的环境舱、不同功率的加热器、不同位置的传感器,温度到达速率差别很大。固定的 15 秒不可能适配所有设备组合。
后来我在接入阶段加了标定流程:首次接入时执行一段“空转时长标定”,施加一个固定功率,记录从触发到状态稳定的时间分布,然后取分布的高分位数作为 WAIT 超时。这套流程跑完之后,同样的脚本在大部分设备上都能稳定执行,不再自我干扰。
6.3 不可逆动作被“统一重试”包装坑了一次
这是我在早期版本上踩过最严重的一个坑。当时为了提升可靠性,把所有设备调用都包了一层统一的自动重试逻辑。正常情况下没问题,直到遇到一个安全阀复位动作。
安全阀复位的协议要求是:发送复位指令—确认阀体位置—记录完成。在某种极端情况下,阀体实际已经复位,但确认消息在传输中丢了,于是重试逻辑重新发送复位指令。结果就是安全阀被连续执行了两次复位操作,第二次复位导致机械结构处于一个不受控的中间状态。
从那以后,我把动作执行和动作确认彻底拆成两个独立原语。自动重试只允许发生在“确认未收到”的情形,而且重试的是 CONFIRM 这个过程,不是重发动作本身。所有不可逆动作都登记为“重试语义:仅确认重试,不重放执行”,这是接入清单里必填的硬性字段。
这个教训用一句话总结就是:对物理世界而言,重复一个已成功的动作,和第一次执行一个动作,具有完全不同的风险等级。自动重试机制如果不区分这一点,就是给自己埋雷。
7. 后续还能往哪些方向扩展
当前这套系统的确定性保证是单机范围的。如果要把 M-Lang 脚本扩展到多设备、多节点协同场景,需要一个全局的逻辑时钟协议来对齐不同 VM 实例的 Tick 序列,否则跨设备的动作顺序又会变成不确定的。我正在用序列共识的方式做这部分尝试,但还没有达到可以生产化的可靠程度。
另一个实用的方向是物理仿真预演。在 VM 前面接一个设备仿真器,把编译后的指令流先跑一遍仿真,生成预期的状态变化曲线,再把这组曲线作为真实执行的对照基线。真实执行时,VM 把观测到的状态变化实时和基线做差值比较,偏差超过阈值就告警。这等于给物理控制加了一层“影子模式”,对验证 AI 规划的长期行为特别有用。
还有一点我还没有做得很好:把 LLM 决策所需的“事实库”同步给模型的机制。目前事实库由 VM 维护,模型每次规划前需要主动读取。未来可以考虑做成事件驱动订阅模式——设备状态变化时主动推送给模型侧的上下文,让模型在每个决策点拿到的都是最新事实,而不是基于过期的假设。
如果看完这篇,你打算自己动手做一个类似的 AI 物理中间层,我唯一想强调的建议是:从禁令开始,而不是从能力开始。先把绝对不能做的事列清楚,把不可逆动作清单建好,把重试边界定义好,再考虑模型能做什么。物理世界对错误的容忍度很低,前面这些防守工作,占比应该超过整个项目工作量的一半。这听起来不酷,但真出事故的时候,你会庆幸自己当初按这个顺序行事。