做 LLM 与工业设备对接时,我踩过最深的坑不是数据量,也不是模型选型,而是让它去理解 Modbus 寄存器。寄存器说到底是一堆 16 位整数,本身没有单位、没有小数位、没有物理含义,语义全写在设备手册里。大语言模型擅长的是语言理解与意图推理,让它去猜寄存器怎么拼、字节序怎么排、缩放系数是多少,等于让会计去修机床,不是不行,是错位。这篇文章就围绕一个核心思路展开:LLM 不擅长解码 Modbus 寄存器,那就不让它解码,把解码交给确定性代码,把语义查询交给 LLM,各干各的活。
1. 为什么要让 LLM 绕开 Modbus 寄存器解码
1.1 问题场景:当大模型被拉进工业数据链路
近几年 LLM 在工业场景里被寄予厚望,常见做法是把设备数据喂给大模型,让它做设备状态问答、异常原因分析、运维报告生成。Modbus 作为 PLC、传感器、仪表、网关之间最常见的通讯协议,自然成了第一站。
很多人一开始的方案很简单:用 pymodbus 读寄存器,把寄存器数组原样拼进 prompt,然后问 LLM“温度是多少”。第一次跑通确实有新鲜感,但很快会发现输出不可控。它可能会把[0x00, 0x64]解析成 100,也可能解析成 25.6,还可能告诉你“根据上下文,这应该是 100”,然后加一句“但我不确定”。这种不确定性在工业场景里是不能接受的。
问题的本质不是模型不够聪明,而是任务性质错了。Modbus 寄存器的解码是确定性计算,每一步都明确,没有歧义;而 LLM 是概率模型,它的每一次输出都带有采样随机性。用概率模型去做确定性计算,等于把工程问题变成了猜谜问题。
1.2 LLM 为什么不擅长寄存器解析
可以从几个角度理解这件事。
第一,寄存器本身没有语义。Modbus 保持寄存器是 16 位存储单元,读出来就是 0 到 65535 的整数。它们可能代表温度、压力、状态字、累计流量,也可能是一个 32 位浮点数的高半段和低半段。没有点位表和设备手册,任何人都无法从寄存器值本身判断物理含义。
第二,跨字节序和跨寄存器组合超出了 LLM 的可靠范围。一个 32 位浮点数需要两个寄存器,先读哪个寄存器、每个寄存器内是高字节在前还是低字节在前,不同厂家可能完全不同。大端模式、小端模式、混合字节序,LLM 经常出错,而且出错之后还可能自信地给出错误解释。
第三,LLM 有幻觉风险。遇到没把握的数据,模型不会像代码一样抛异常,它会根据上下文“补全”一个答案。这种补全在自然语言场景里是能力,在数值解析场景里是事故。工厂里一个温度位号读错了,可能导致连锁反应。
第四,性能和成本问题。寄存器解析通常是高频操作,一次数据采集可能涉及几十上百个点位。如果用 LLM 逐点位解析,延迟不可控,成本也会快速上升。而用代码解析只需要几毫秒,且结果可复现。
1.3 正确的分工:确定性计算在前,大模型在后
项目标题里说“LLMs are bad at decoding Modbus registers, so I made sure they never have to”,这句话的核心就是职责分离。
正确的数据链路应该是:
Modbus 设备 ↓ 确定性解析层(pymodbus + 点位表 + 解码器) ↓ 结构化语义 JSON(带名称、单位、时间戳) ↓ LLM 工具调用层(查询语义数据,不直接接触寄存器) ↓ 自然语言回答LLM 只需要看到“temperature: 25.6 °C,时间:2025-01-15 10:30:00”,它不需要知道这个温度在寄存器 0 还是寄存器 2,也不需要知道它是 int16 还是 float32。这些底层细节全部由确定性代码管理。
2. Modbus 寄存器基础:动手之前需要知道的事
2.1 寄存器的数据模型
Modbus 协议定义了四种数据对象:
| 对象类型 | 读写属性 | 常见用途 | 对应功能码示例 |
|---|---|---|---|
| 线圈(Coil) | 可读写 | 开关、启停控制 | 0x01 读线圈,0x05 写单个线圈 |
| 离散输入(Discrete Input) | 只读 | 开关状态输入 | 0x02 读离散输入 |
| 输入寄存器(Input Register) | 只读 | 只读模拟量 | 0x04 读输入寄存器 |
| 保持寄存器(Holding Register) | 可读写 | 参数、设定值、可读模拟量 | 0x03 读保持寄存器,0x06 写单个寄存器 |
每种对象内部是一组 16 位寄存器。Modbus TCP 和 Modbus RTU 在寄存器层面的逻辑一致,区别主要在于传输层封装:
- Modbus TCP:基于 TCP/IP,端口通常是 502,报文带 MBAP 头。
- Modbus RTU:基于串口(RS-232/RS-485),报文带 CRC 校验。
- Modbus ASCII:基于串口,报文用 ASCII 字符表示,实际中使用较少。
在本文场景里,我们只需要关心如何从保持寄存器或输入寄存器中读出原始 16 位值。
2.2 从寄存器值到真实物理量
假设设备手册写着:
地址 0x0000:温度,int16,分辨率 0.1,单位 °C 地址 0x0002:压力,float32,分辨率 1.0,单位 kPa那么读出的寄存器数组[0x00FA]表示温度原始值为 250,实际温度是 250 × 0.1 = 25.0 °C。如果直接把 250 丢给 LLM,它无法知道这是 25.0 而不是 250,更无法知道单位是 °C。
这里的关键在于点位表(Point Table)。点位表本质上是一个映射文件,把“寄存器地址 + 数据类型 + 字节序 + 缩放系数 + 单位”组合成业务字段。解析寄存器时不靠模型猜,而是查表算。
2.3 理解字节序与数据类型
字节序是 Modbus 解析最容易出错的地方。常见情况有两种:
- 大端模式(Big Endian,Modbus 默认风格):寄存器内高位字节在前,跨寄存器时高字在前。
- 小端模式(Little Endian):寄存器内低位字节在前,跨寄存器时低字在前。
一个 float32 占两个寄存器。假设两个寄存器原始值为0x4148和0xCCCD:
- 大端组合:
41 48 CC CD,对应浮点数约 12.55。 - 小端组合:
CD CC 48 41,对应浮点数约 12.55,但需要的寄存器顺序和字节顺序处理不同。
实际设备还有可能使用混合字节序,例如“寄存器内部大端,跨寄存器小端”。遇到这种情况,必须在点位表里显式配置,不能指望 LLM 靠常识判断。
3. 架构设计:三层结构隔离 LLM 与寄存器
3.1 总体架构
我建议把系统拆成三个层次:
第一层是通讯层,负责与 Modbus 从站建立连接、发请求、收响应、处理超时与重试。这一层只输出原始寄存器数组,不关心业务含义。
第二层是解析层,负责结合点位表,把寄存器数组解码成带物理意义的数值。这一层是确定性逻辑的核心,输出结构化 JSON。
第三层是 LLM 接入层,把解析层暴露为工具或 API。LLM 只负责理解用户意图,比如“现在温度多少”,然后把意图转为对语义 API 的调用。
+------------------+ +------------------+ +------------------+ | Modbus 设备 | -> | 通讯层 | -> | 解析层 | +------------------+ +------------------+ +------------------+ | v +------------------+ +------------------+ +------------------+ | 用户问答 | <- | LLM 对话层 | <- | 语义 API | +------------------+ +------------------+ +------------------+3.2 为什么需要点位表
点位表的价值在于把设备手册里的信息结构化。它让解析逻辑与具体设备解耦,换一个设备型号时,只需要更新点位表,不需要改代码。
点位表至少需要包含以下字段:
| 字段 | 含义 | 示例 |
|---|---|---|
| name | 点位唯一名称 | temperature |
| description | 可读描述 | 1号反应釜温度 |
| register_type | 寄存器类型 | holding |
| address | 寄存器起始地址 | 0 |
| count | 寄存器数量 | 1 |
| data_type | 数据类型 | int16 |
| byte_order | 字节序 | big |
| scale | 缩放系数 | 0.1 |
| unit | 物理单位 | °C |
| aliases | 别名,便于 LLM 匹配 | [温度, temp] |
在 LLM 场景里,别名尤其重要。用户可能会说“炉子温度”“反应釜温度”“temperature”,如果点位表没有别名,LLM 很难把自然语言和点位名对上。
3.3 数据流向与接口约定
解析层对外暴露的接口应该简单稳定。例如:
GET /api/points/temperature返回:
{ "name": "temperature", "description": "1号反应釜温度", "value": 25.6, "unit": "°C", "timestamp": "2025-01-15T10:30:00" }这个 JSON 是 LLM 可以直接使用的格式,字段名清晰,单位明确,时间戳完整。LLM 回答用户问题时,直接复述这个结果即可,不需要做任何额外计算。
4. 实战:构建 Modbus 语义读取服务
4.1 项目结构与依赖
下面用一个最小可运行项目演示完整思路。项目结构如下:
modbus-llm-bridge/ ├── config/ │ └── points.yaml # 点位表 ├── modbus_client.py # Modbus 通讯层 ├── decoder.py # 寄存器解码器 ├── point_table.py # 点位表加载与解析 ├── api.py # 语义查询 API └── llm_bridge.py # LLM 工具调用示例运行环境以 Python 3.9+ 为例,需要安装以下依赖:
pip install pymodbus pyyaml fastapi uvicorn说明:pymodbus 不同版本的导入路径略有差异。本文示例以 pymodbus 3.x 的ModbusTcpClient写法为准,如果使用 4.x,请把导入语句调整为from pymodbus.client import ModbusTcpClient。
4.2 点位表配置
新建config/points.yaml:
connection: host: 192.168.1.10 port: 502 timeout: 3 points: - name: temperature description: 1号反应釜温度 register_type: holding address: 0 count: 1 data_type: int16 byte_order: big scale: 0.1 unit: "°C" aliases: [温度, temp, reactor temperature] - name: humidity description: 1号反应釜湿度 register_type: holding address: 1 count: 1 data_type: uint16 byte_order: big scale: 0.1 unit: "%RH" aliases: [湿度, hum] - name: pressure description: 1号反应釜压力 register_type: holding address: 2 count: 2 data_type: float32 byte_order: big scale: 1.0 unit: "kPa" aliases: [压力, press]这里的点位表只是示例,实际设备可能只有部分点位,或者数据类型与地址不同,务必以设备手册为准。
4.3 寄存器读取与解码器
新建modbus_client.py:
from pymodbus.client.sync import ModbusTcpClient def read_holding_registers(host, port, address, count, unit=1, timeout=3): """ 读取保持寄存器原始值。 注意:pymodbus 4.x 中 unit 参数已改名为 slave,请按版本调整。 """ client = ModbusTcpClient(host, port=port, timeout=timeout) client.connect() try: response = client.read_holding_registers(address, count=count, unit=unit) if response.isError(): raise RuntimeError(f"Modbus read error: {response}") return response.registers finally: client.close()新建decoder.py:
import struct def _pack_registers(registers, byte_order): """ 将寄存器列表转换为字节串。 仅处理两种常见模式: - big: 寄存器内大端,跨寄存器高字在前 - little:寄存器内小端,跨寄存器低字在前 混合字节序请自行扩展或在点位表中增加 word_order 字段。 """ if byte_order == "big": return b"".join(r.to_bytes(2, "big") for r in registers) elif byte_order == "little": # 寄存器顺序翻转后按小端组包,用于模拟 32 位小端数据 return b"".join(r.to_bytes(2, "little") for r in registers[::-1]) raise ValueError(f"Unsupported byte_order: {byte_order}") def decode_registers(registers, data_type, byte_order="big", scale=1.0): """ 将原始寄存器数组解码为真实物理量。 registers: 寄存器整数列表,例如 [0x00FA] data_type: int16 / uint16 / int32 / uint32 / float32 byte_order: big / little scale: 缩放系数,解码后乘以 scale """ if data_type == "int16": value = registers[0] if value >= 0x8000: value -= 0x10000 elif data_type == "uint16": value = registers[0] else: raw = _pack_registers(registers, byte_order) fmt = { "int32": "i", "uint32": "I", "float32": "f", }.get(data_type) if fmt is None: raise ValueError(f"Unsupported data_type: {data_type}") code = ">" if byte_order == "big" else "<" value = struct.unpack(code + fmt, raw)[0] return value * scale这里的解码逻辑已经覆盖了最常见的情况。实际项目中如果碰到混合字节序,建议在点位表里增加word_order和byte_order两个独立字段,分别控制跨寄存器顺序和寄存器内字节顺序。
4.4 点位表加载与语义查询 API
新建point_table.py:
import yaml class PointTable: def __init__(self, path): with open(path, "r", encoding="utf-8") as f: self.config = yaml.safe_load(f) self.by_name = {} self.alias_map = {} for point in self.config["points"]: name = point["name"] self.by_name[name] = point self.alias_map[name] = name for alias in point.get("aliases", []): self.alias_map[alias.lower()] = name def list_points(self): return list(self.by_name.keys()) def resolve(self, key): name = self.alias_map.get(key.lower()) if name is None: raise KeyError(f"Unknown point: {key}") return self.by_name[name]新建api.py:
from datetime import datetime from fastapi import FastAPI, HTTPException from decoder import decode_registers from modbus_client import read_holding_registers from point_table import PointTable app = FastAPI(title="Modbus Semantic API") points = PointTable("config/points.yaml") def read_point_value(point): raw = read_holding_registers( host="192.168.1.10", port=502, address=point["address"], count=point["count"], unit=1, ) value = decode_registers( registers=raw, data_type=point["data_type"], byte_order=point.get("byte_order", "big"), scale=point.get("scale", 1.0), ) return { "name": point["name"], "description": point.get("description", ""), "value": round(value, 4), "unit": point.get("unit", ""), "timestamp": datetime.now().isoformat(), } @app.get("/api/points") def list_points(): return {"points": points.list_points()} @app.get("/api/points/{name}") def get_point(name: str): try: point = points.resolve(name) except KeyError: raise HTTPException(status_code=404, detail=f"unknown point: {name}") return read_point_value(point) @app.get("/api/points/{name}/raw") def get_raw_point(name: str): """ 调试接口:返回原始寄存器值,便于核对点表配置。 线上环境建议关闭该接口。 """ try: point = points.resolve(name) except KeyError: raise HTTPException(status_code=404, detail=f"unknown point: {name}") raw = read_holding_registers( host="192.168.1.10", port=502, address=point["address"], count=point["count"], unit=1, ) return { "name": point["name"], "raw_registers": raw, "message": "该接口仅供调试,生产环境请勿开放", } if __name__ == "__main__": import uvicorn uvicorn.run(app, host="0.0.0.0", port=8000)启动服务:
python api.py然后访问:
curl http://127.0.0.1:8000/api/points/temperature预期响应格式:
{ "name": "temperature", "description": "1号反应釜温度", "value": 25.6, "unit": "°C", "timestamp": "2025-01-15T10:30:00" }到这里,LLM 已经不需要接触寄存器了。它只需要调用这个语义 API。
4.5 LLM 接入:只让它看到 JSON
新建llm_bridge.py,演示工具调用结构:
# 这里是给 LLM 的工具描述 # 实际调用时,模型会返回一个 JSON 化的函数调用参数 tools = [ { "type": "function", "function": { "name": "get_sensor_value", "description": "查询某个传感器的实时数值,返回结构化 JSON", "parameters": { "type": "object", "properties": { "point_name": { "type": "string", "description": "点位名称或别名,例如 temperature、temp、温度" } }, "required": ["point_name"] } } } ] system_prompt = ( "你是工厂运维助手。" "查询设备数据时,必须调用 get_sensor_value 工具," "工具会返回准确的结构化数值,你只需要用自然语言复述。" "不要猜测寄存器地址,不要自行计算原始值。" ) # 伪代码示例: # user: 现在车间温度是多少? # model output: # { # "function_call": { # "name": "get_sensor_value", # "arguments": "{\"point_name\": \"temperature\"}" # } # } # # 程序收到 function_call 后,调用 /api/points/temperature # 得到 {"value": 25.6, "unit": "°C", ...} # 再交给 LLM 组织回答。这段代码的核心思路是:LLM 只负责把自然语言映射到工具调用参数,最终数值来源是语义 API,而不是模型推理。
5. 常见问题与排查清单
5.1 寄存器数据明显不对
现象:API 返回数值和实际设备显示不一致,例如温度应为 25.0 °C,实际返回 6400。
可能原因:
- 地址配置错误,读到了相邻寄存器。
- 数据类型错误,比如实际是 int32,点位表配成了 int16。
- 字节序错误,float32 的字节顺序反了。
- 缩放系数错误,漏乘或重复乘。
排查步骤:
- 先调用
/api/points/{name}/raw查看原始寄存器值。 - 对照设备手册的寄存器地址和类型定义。
- 用十六进制转换工具把寄存器值拼成字节串,手动解算。
- 确认字节序和缩放系数后,再更新点位表。
- 点位表修改后重启服务,重新读取。
5.2 请求超时和从站无响应
现象:read_holding_registers抛超时异常,或者返回ConnectionException。
可能原因:
- 设备 IP/端口不对,或者设备没有上电。
- 网关没有转发对应从站请求。
- 串口参数(波特率、数据位、校验位)错误。
- 同一时刻请求过于频繁,从站来不及响应。
- 防火墙拦截了 TCP 502 端口。
解决思路:
- 先用 Modbus Poll、Modbus Slave 等工具验证链路是否正常。
- 减小请求频率,增加超时时间和重试机制。
- 如果从站下有多个单元,确认 unit/slave 参数正确。
- 排查网络是否跨了不安全的外网,Modbus TCP 建议只在可信内网使用。
5.3 LLM 回答出现幻觉
现象:LLM 没有调用工具,而是直接根据用户提示编了一个数值。
可能原因:
- system prompt 没有强制约束工具调用。
- 工具描述不够明确。
- 模型低温度采样的同时,还是选择了“直接回答”。
解决思路:
- 在 system prompt 中明确:不允许猜测数值,必须调用工具。
- 对工具返回结果做二次校验,数值异常时拒绝输出。
- 如果模型支持严格工具调用模式,优先开启。
- 在应用层判断:如果用户问题涉及数据,但没有产生工具调用,直接提示“无法获取实时数据”。
5.4 一个通用排查表
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
| 寄存器读出来全是 65535 | 地址越界或设备未支持该地址 | 对照手册确认地址范围 |
| float32 数值巨大或极小 | 字节序或寄存器顺序错误 | 调整 byte_order,重新解析 |
| 温度偶尔跳变 | 缩放系数精度不足或读取竞态 | 增加去抖与合理缓存 |
| LLM 答非所问 | 点位别名缺失 | 在点位表中补充 aliases |
| API 直接报错 | 依赖版本不一致 | 检查 pymodbus 导入路径和参数名 |
6. 最佳实践与工程建议
6.1 职责边界是第一位
做 LLM + 工业协议集成时,最重要的原则是:能用代码算的,绝不让模型猜。
- 寄存器解码、字节序转换、缩放计算,全部走确定性代码。
- LLM 只做自然语言意图识别、工具调用、结果复述。
- 不要给 LLM 提供原始寄存器数组,也不要让它输出 Modbus 读写脚本并在生产环境自动执行。
如果确实需要让 LLM 辅助生成设备对接代码,必须走人工 review + 测试环境验证,不能直接把模型生成的脚本放到生产环境。
6.2 点位表纳入版本管理
点位表是设备语义的核心资产,应当提交到 Git 仓库,和代码一起评审。点位表的变更可能影响监控告警和历史数据一致性,必须走变更流程。
同时建议给点位表增加如下辅助信息:
- name: pressure description: 1号反应釜压力 address: 2 count: 2 data_type: float32 byte_order: big scale: 1.0 unit: "kPa" limits: min: 0 max: 100 source: 设备手册第 3 章 updated_by: engineer_zhang updated_at: 2025-01-10这些字段可以用于异常检测,也可以帮助后续维护人员快速定位问题。
6.3 安全与访问控制
Modbus TCP 本身没有认证机制,直接在网络中暴露非常危险。建议:
- 将 Modbus 设备、网关、解析服务部署在同一可信内网。
- 语义 API 增加身份认证,例如 API Key 或 OAuth。
- 写操作接口单独隔离,不要和查询接口混在一起。
- 生产环境关闭
/raw这类调试接口,防止寄存器细节泄露给无关人员。 - 如果 LLM 应用本身部署在公网,解析服务不要直接监听公网端口,应由网关统一转发。
如果需要支持写操作,例如写线圈或写保持寄存器,务必增加二次确认、白名单、审计日志,并且遵循最小权限原则,只允许 LLM 在受控场景下触发写指令。
6.4 可观测性与审计
引入 LLM 之后,数据链路的可观测性比传统系统更重要。建议记录以下日志:
- 每次 Modbus 读取的原始寄存器值。
- 解码后的语义 JSON。
- LLM 收到的工具描述和实际工具调用参数。
- LLM 最终回答内容。
- 请求耗时和错误异常。
这样可以回答一个关键问题:“这个温度值到底是从设备来的,还是模型编的?” 答案都应该能在日志里找到。
6.5 缓存与批量读取
Modbus 设备通常有扫描周期限制,频繁轮询会增加从站负载。建议:
- 对低频变化的模拟量点位做短 TTL 缓存,例如 1 到 5 秒。
- 一次读取连续的寄存器区间,减少通讯次数。
- 将解析结果写入时序数据库,方便后续告警和历史分析。
在 LLM 调用场景中,5 秒内的缓存对问答体验几乎没有影响,但对设备压力改善明显。
7. 总结
这篇文章的核心思路是:LLM 不擅长解码 Modbus 寄存器,那就设计一套架构让它永远不需要解码。底层用 Modbus 通讯层读取原始寄存器,用点位表和确定性解码器把寄存器翻译成语义 JSON,最后通过工具调用把语义 JSON 提供给 LLM。三者各司其职,数据可靠性由代码保证,语言理解由模型负责。
如果接下来你想继续深入,可以关注几个方向:第一,在点位表中增加阈值告警,让 LLM 在回答时带上异常状态;第二,把语义 API 封装成 MCP 或通用工具服务,方便多个 Agent 复用;第三,把解析结果接入时序数据库,做历史数据问答。每一次扩展都应该遵守同样的原则:寄存器永远留在确定性计算层,LLM 只和语义化结果对话。