你让 LLM 直接读一个 Modbus 寄存器地址 40001 对应的是电压还是温度如果它是 32 位浮点数那高字在前还是低字在前要不要乘系数如果设备手册写的是数据地址而实际工具用的是协议地址又差了几格这些问题抛给通用大模型它通常会给你一个结构完整、自信满满、但完全不能用于现场调参的答案。最近我在做工业设备数据接入时试过几款主流 LLM。结论很明确它们可以解释 Modbus RTU 报文可以写出 Modbus Poll 这类调试工具的基本用法可以告诉你 03 功能码读的是保持寄存器。但如果你把一份真实的寄存器原始值贴在对话里让它还原成“电压 220.5V、状态位 bit01”这样的物理量它给出的结果经常是错的。这种错还很难发现因为输出看起来像模像样可能只有小数点后面的数字不对或者在某个状态位上差一位。所以我做了一个不太一样的调整与其想办法让 LLM 变聪明去理解寄存器不如把解码这件事从模型那里整个拿掉。让确定性代码去解析协议让 LLM 只通过工具接口消费已经语义化后的 JSON。LLM 负责理解用户意图和生成回答设备通信和数值解码交给一个中间层。这正是标题里那句话的落地方式LLMs are bad at decoding Modbus registers, so I made sure they never have to。1. Modbus 寄存器解析根本不是 LLM 该干的活1.1 寄存器解码表面简单本质是协议约定Modbus 是一个非常“老”但极其常见的工业通信协议。它的数据模型分为线圈、离散输入、输入寄存器、保持寄存器等几类。对于普通开发者来说最常用到的是保持寄存器因为很多智能电表、传感器、PLC 都通过它对外提供数据。单个保持寄存器是 16 bit可以表示一个无符号整数、有符号整数也可以表达状态位。但实际设备里的“电压”“温度”往往不是 16 bit 能装下的。于是设备厂商会把两个甚至更多寄存器拼在一起组成 32 位整数、32 位浮点数或者更复杂的结构。这时问题就来了两个寄存器谁在高位谁在低位寄存器内部字节是大端还是小端32 位浮点是否还有字交换、字节交换的变体原始值是 0 到 65535还是带符号最终要不要乘 0.1、0.01 之类的缩放系数设备手册写的是“寄存器地址 40001”但协议报文里的地址可能是 0x0000。映射关系到底差多少某些值不是一个数字而是打包在一个 16 位字里的多个状态位每个 bit 代表一个告警信号。这些规则从设备手册到实际代码转换每一步都需要严格按照约定执行。中途只要有一个约定理解错结果就是错的。你可能会说这不就是查手册、写代码吗对但对 LLM 来说这恰恰是最容易出问题的环节。因为它不是“查手册”和“写代码”两种能力的简单叠加而是要求模型在概率生成的过程中始终严格遵循一个不常见的、由设备厂商自定义的编码规则。1.2 让模型直接解码为什么错得如此隐蔽LLM 的本质是概率语言模型。它根据上下文预测下一个 token。处理“根据 Modbus 协议把寄存器值还原成物理量”这种任务它需要的是确定性计算字节拼接、符号扩展、字节序调整、浮点解析。这些步骤每一步都是精确的但 LLM 并不天然具备这种精确性。我见过很多真实案例模型把0x1234和0x5678两个寄存器拼成0x12345678结果应该是两百多万它算出来是“12345678”对应的十进制但因为符号位处理错误得到负数。模型搞错字节序把0x12 0x34按小端读成0x3412数值差了好几倍。模型看到data_type: float32不知道应该先读两个寄存器再解析直接把第一个寄存器的整数值当成浮点数。模型在结果里“编造”了一个合理的电压值比如 220.5V但原始寄存器值根本对应不到 220.5。更麻烦的是这类错误不会直接报错。LLM 不会说“这里我不确定”它通常会给出一个看起来正常的答案。在工业现场一个“看起来正常但实际错误”的数值比一个明显的报错更可怕。因为你会下意识相信它然后带着错误的判断去操作设备。所以我的判断很明确不要让 LLM 参与寄存器解码无论它能不能在单次测试里碰巧答对。因为概率模型的成功不可控而工业数据要求确定性。2. 把解码从模型里拆出去一个三层语义接口2.1 核心判断LLM 不应该懂字节序我们通常会习惯性地认为一个“智能”系统应该什么都能做。于是让 LLM 直接解析 Modbus 寄存器听起来像是一个很自然的“智能化”需求。但工程上的正确答案往往是反过来的越是需要确定性、一致性、可审计的步骤越应该交给普通代码越是需要理解模糊意图、组织语言、综合上下文的任务越应该交给 LLM。字节序、缩放、地址映射、状态位解析全部属于“确定性计算”。这些计算不需要“智能”需要的是“不犯错”。而 LLM 恰恰会在这种地方犯错。所以我做的方案里LLM 根本不需要知道字节序是什么。它只需要知道这个设备有一个工具函数叫做read_device_values调用它之后会返回一个已经变成人能看懂的 JSON里面有电压、电流、温度、告警状态。2.2 三层结构通信层、解析层、语义层我采用的架构可以拆成三层层级职责关键点通信层负责 Modbus TCP/RTU 的建立连接、读取原始寄存器、写入线圈或寄存器、超时重试需要处理设备离线、连接超时、从站无响应、网络抖动解析层读取设备描述文件将原始寄存器数组转换为语义字段字节序、数据类型、缩放、状态位全部在这里处理语义层把解析后的 JSON 暴露给 LLM 工具调用让模型基于结构数据回答问题工具设计、参数校验、返回结构化错误信息通信层可以基于常见的 Modbus 库实现比如 Python 生态里的 pymodbus或者其他语言的对应实现。这一层不应该有任何“智能”业务只管收发协议帧。解析层是核心。它根据设备手册定义好的映射表把[0x1234, 0x5678, 0x0003]这样的原始数组转换成{ line_voltage: 220.5, alarm_status: { over_voltage: false, under_voltage: true } }LLM 只消费这个 JSON。它不需要知道line_voltage是存在 40001 还是 40002也不需要知道它是怎么从两个寄存器里拼出来的。2.3 为什么这样做比微调模型更可持续有些人可能会想与其搞这么一层中间解析不如拿一批“寄存器原始值-正确结果”数据去微调模型让它学会解码。这个思路表面上可行但落地时有几个问题设备型号一变寄存器地址、字节序、缩放系数都变了微调的样本就失效了。你要重新整理数据集、重新训练。微调后的模型仍然是一个概率模型。它即使见过类似样本也可能在边缘 case 上出错。Modbus 设备的“点表”通常不算复杂但差异化极强。用微调去记忆这些细节成本高、收益低。调试时你还需要能定位错误是模型理解错还是数据源错如果模型直接输出结果中间没有解析层你很难区分。解析层方案把“设备差异”变成了“配置文件差异”。新增设备时不需要改模型只需要新增一个设备描述文件然后在工具注册表里增加一个实例。这才是可持续的设计。3. 最小可用方案映射表、解码器、工具调用3.1 先做设备描述文件把寄存器表变成配置落地第一步是把设备手册里的寄存器表整理成一个机器可读的描述文件。常见做法是 YAML 或 JSON。下面是一个简化的示例结构重点看流程不是完整实现{ device: example_energy_meter, endpoint: 192.168.1.10:502, unit_id: 1, registers: [ { name: line_voltage, register: 0x0000, length: 2, data_type: float32, byte_order: big_endian, scale: 1.0, unit: V }, { name: alarm_status, register: 0x0002, length: 1, data_type: uint16, bits: { over_voltage: 0, under_voltage: 1 } } ] }注意几个容易踩坑的地方register字段要明确是协议地址还是数据地址。如果用的是设备手册里的 PLC 地址 40001需要映射成协议地址 0x0000。byte_order的取值必须在自己系统里标准化。不同库可能叫big_endian、little_endian有的还要区分word_order和byte_order。如果你不做内部约定后面一定乱。length是寄存器个数。float32通常占 2 个寄存器不是 1 个。对于状态位不要只用 int 返回建议拆成具名字段方便 LLM 直接理解。3.2 写一个确定性解码器把原始值变成语义字段通信层拿到原始寄存器数组后解析层根据配置转换成 JSON。这里的关键是无论什么情况解码逻辑必须可预期、可单测、可审计。下面是一个简化版的 Python 示意def decode_registers(raw_values, mapping): result {} offset 0 for reg_conf in mapping[registers]: length reg_conf[length] raw raw_values[offset:offset length] offset length value decode_by_type( raw, data_typereg_conf[data_type], byte_orderreg_conf[byte_order] ) if reg_conf.get(scale): value value * reg_conf[scale] if bits in reg_conf: result[reg_conf[name]] { bit_name: bool(value (1 bit_index)) for bit_name, bit_index in reg_conf[bits].items() } else: result[reg_conf[name]] value return result这段代码只是流程示意。实际项目中decode_by_type需要对每种数据类型、字节序组合做完整实现并且用已知的寄存器值做单元测试。有一点必须提醒不要在没有验证的情况下直接套用某个库的默认字节序。不同库对big_endian的定义可能不同尤其涉及 32 位浮点时有的设备是“寄存器顺序交换但字节不交换”有的则是“完全大端”。配置字段里最好显式提供而不是依赖库默认值。3.3 给 LLM 的工具只暴露语义函数不暴露寄存器解析层完成后需要把能力封装成 LLM 可以调用的工具函数。无论你用的是 OpenAI Function Calling、开源模型的 Tool Use还是自建的 Agent 框架思路都一样只暴露语义函数不要暴露底层寄存器函数。比如可以暴露def read_device_values(device_id: str, group: str default) - dict: 读取指定设备的当前测量值返回已转换为工程单位的数据 raw_values read_modbus_registers(device_id) device_mapping load_device_mapping(device_id) return decode_registers(raw_values, device_mapping)对应的工具声明通常会包含一个 JSON Schema{ name: read_device_values, description: 读取指定设备的当前测量值返回已转换为工程单位的数据, parameters: { type: object, properties: { device_id: { type: string, description: 设备标识比如 example_energy_meter }, group: { type: string, description: 寄存器分组默认 default } }, required: [device_id] } }为什么不要暴露read_raw_register(register_address: int)因为只要暴露了原始寄存器地址LLM 就多了一个自己拼地址、猜字节序的机会。哪怕它只是偶尔拼错也会破坏整体确定性。工具层应该把“设备内部细节”屏蔽掉给模型的不是“可能性”而是“结果”。如果某些场景确实需要 LLM 读原始寄存器也应该是在后端函数里明确写清楚映射规则而不是让模型自己决定传什么地址。3.4 最小链路验证的三个步骤第一次搭建时不要急着直接问 LLM 复杂问题。按照下面的顺序走一遍先用 Modbus Poll 或自己的调试脚本读取设备原始寄存器值记录下一组真实数据。把这一组原始值喂给解码器对比输出是否与设备手册里的预期一致。再通过 LLM 工具调用入口问一句“现在的电压是多少”观察模型是否只调用了read_device_values而没有任何自行计算或猜测。如果第 2 步就错了不要继续去调 prompt先修解析层。我见过太多人跳过前面两步直接拿 LLM 的错乱输出去调试 prompt最后发现根因在寄存器映射表白白浪费了大量时间。4. 工程化落地时要补上的细节和排查顺序4.1 错误信息要按 LLM 能理解的结构返回设备通信不是永远稳定的。Modbus TCP 可能会超时RTU 可能收不到响应寄存器可能越界。这些错误不能简单返回一个字符串 “error”。LLM 接收到这样的错误信息后无法判断是设备离线、地址错误还是权限问题它只能基于训练数据编一个“可能的解释”。更好的做法是让工具统一返回结构化错误对象{ status: timeout, reason: device_not_reachable, suggestion: check network or device power, read_at: 2025-04-01T10:30:00Z }这样 LLM 至少能告诉用户“当前设备连接超时建议检查网络或设备电源。”而不是“我无法读取数据可能电压有问题。”4.2 寄存器映射文件的长期维护策略设备点表不是一成不变的。相同型号的设备固件版本不同寄存器偏移可能变化。新增设备时最怕的不是写代码而是 mapping 文件里出现重复地址、长度越界、类型不匹配。建议在系统启动时做一次配置校验检查每个 register 地址和 length 是否越界。检查不同字段是否指向同一个寄存器区域。检查 data_type 和 length 是否匹配比如 float32 必须 length2。检查 byte_order 是否在支持列表内。把这个校验做成独立模块而不是在读取时才报错。配置错误越早发现影响越小。4.3 写操作必须白名单化读操作相对安全写操作要格外谨慎。不要让 LLM 通过“写保持寄存器地址 0x0001 50”这种方式去控制设备。应该封装成具名操作def write_setpoint(device_id: str, setpoint: float) - dict: # 内部检查 setpoint 范围 # 再映射到 Modbus 寄存器 ...这样 LLM 只需要理解“setpoint 是目标温度”而不需要理解它应该写到哪个寄存器。后端函数负责校验范围、权限、限幅和写入后的确认读取。在工业控制场景中写操作一定要有额外保护操作确认、数值范围检查、硬件互锁、操作日志。这些不能依赖 LLM 自觉。4.4 数据新鲜度缓存、刷新与时间戳LLM Agent 在回答多轮问题时可能会多次调用同一个工具。如果每次都去设备重新读一次既慢又可能给设备增加负担。所以工具层可以做短时间缓存。但缓存会带来新问题用户问“现在电流多少”如果拿到的是 10 秒前的数据可能不够新鲜。建议在返回结果里带上read_at时间戳同时给工具增加一个force_refresh参数。这样模型可以根据用户语气判断是否需要强制刷新。如果设备数据变化很快比如毫秒级那这种工具调用模式本身就不适合。你应该考虑更直接的实时推送链路而不是让 LLM 在中间做问答。4.5 排查链路问题到底出在哪一层当最终回答不对时必须按层级定位而不是一上来就改 prompt。我的排查顺序是先看 LLM 有没有调用工具。如果没有调用说明模型没有理解意图这是 prompt 或工具描述的问题。如果调用了工具看工具返回的 JSON 是什么。返回本身是否合理如果合理那就是模型在生成回答时曲解了数据。如果 JSON 不合理看解码器输出。拿同一组原始寄存器值直接跑解码函数看是否和预期一致。如果解码器输出不对看映射表和原始值。用 Modbus Poll 等工具读一下原始寄存器确认数据源。如果原始值本身就不对再看通信层地址、从站号、Byte order、功能码。这个顺序的核心是从模型往底层逐层排除。不要一上来怀疑模型能力不够很多时候问题在底层数据而不是模型智商。5. 这套方案对 LLM 应用开发的通用启发5.1 不只是“智能问答”而是“设备即 API”把 Modbus 设备包装成“语义 API”之后你会发现它的价值不只是给 LLM 用。同一个解析层可以同时服务于传统 REST API 接口前端看板告警规则引擎定时报表LLM 问答也就是说LLM 只是这套设备数据接口的其中一个消费方。当设备模型被结构化之后所有上层应用都能受益。这也是我认为这个方案比“调 prompt 让模型懂 Modbus”更有价值的原因。5.2 适用边界这套方案适合谁不适合谁适合的情况需要让非技术人员用自然语言查询设备状态。设备型号多点表差异大需要快速接入。希望把设备数据统一成 JSON供多个系统复用。已有的 Modbus 通信代码稳定只需要在上层加语义层。不适合的情况对实时性要求极高的控制闭环不允许中间再嵌入 LLM 调用。点表非常少只有几个寄存器直接写死即可。没有足够的工程资源维护映射表和解码器只想临时问一次。设备涉及严格的安全规范且不允许外部 Agent 操作。所以在动手之前先判断你的场景到底需不需要“自然语言问答”这一层。如果不需要就老老实实直接写定时读取。5.3 沉淀一个通用框架意图靠模型数据靠代码决策靠人最后我想把这件事提炼成一个更通用的原则理解模糊意图交给 LLM。执行确定性转换交给代码。做出高风险决策交给人。Modbus 寄存器解码属于“确定性转换”所以它不应该出现在 LLM 应该负责的范畴里。不只是 Modbus其他很多和硬件、协议、数据格式相关的任务也是如此解析二进制文件、计算 CRC、做数据校验、权限判断、交易金额计算这些都应该由确定性模块来处理。所谓让 LLM 不擅长的事情永远不发生不是因为它能力不够而是因为它本来就不该出现在那个位置。Modbus 只是其中一个例子但它把这个道理揭示得很清楚当模型无法稳定做好某件事时最好的解决方案不是逼模型做得更好而是重新设计系统让模型根本不需要去做它。