文章目录
-
- [1. 目标与总体架构](#1. 目标与总体架构)
- [2. 环境搭建与问题实录](#2. 环境搭建与问题实录)
-
- [2.1 这一阶段要干什么](#2.1 这一阶段要干什么)
- [2.2 遇到的问题(如实记录)](#2.2 遇到的问题(如实记录))
- [2.3 认知沉淀:venv 到底是什么](#2.3 认知沉淀:venv 到底是什么)
- [2.4 项目分层设计(和固件分层对照)](#2.4 项目分层设计(和固件分层对照))
- [2.5 环境信息(本机实测)](#2.5 环境信息(本机实测))
- [3. MiniMax M3 接入](#3. MiniMax M3 接入)
-
- [3.1 为什么 openai 的 SDK 能调 MiniMax](#3.1 为什么 openai 的 SDK 能调 MiniMax)
- [3.2 Function Calling 报文格式(重点,逐帧剖析)](#3.2 Function Calling 报文格式(重点,逐帧剖析))
- [4. 连通性自检](#4. 连通性自检)
-
- [4.1 自检脚本的设计思想](#4.1 自检脚本的设计思想)
- [4.2 操作步骤](#4.2 操作步骤)
- [4.3 问题排查表(按现象查)](#4.3 问题排查表(按现象查))
- [4.4 实测运行记录(2026-08-30)](#4.4 实测运行记录(2026-08-30))
- [5. 工具层设计:把 Home Assistant 变成 Agent 的手脚](#5. 工具层设计:把 Home Assistant 变成 Agent 的手脚)
-
- [5.1 设计原则:暴露"总线",不为每类设备写驱动](#5.1 设计原则:暴露"总线",不为每类设备写驱动)
- [5.2 HA 的寻址模型(CAN 视角)](#5.2 HA 的寻址模型(CAN 视角))
- [5.3 两个工程细节](#5.3 两个工程细节)
- [5.4 sysinfo:Agent 感知"自己的身体"](#5.4 sysinfo:Agent 感知"自己的身体")
- [5.5 验收清单](#5.5 验收清单)
- [6. Agent Loop 手写:不到 100 行的主循环](#6. Agent Loop 手写:不到 100 行的主循环)
-
- [6.1 全景](#6.1 全景)
- [6.2 一次任务里 messages 的生长过程(实例)](#6.2 一次任务里 messages 的生长过程(实例))
- [6.3 四个刻意的设计](#6.3 四个刻意的设计)
- [6.4 流程可观测性](#6.4 流程可观测性)
- [7. 第一次运行实录(2026-08-30)](#7. 第一次运行实录(2026-08-30))
- [8. 遗留思考与下一步](#8. 遗留思考与下一步)
1. 目标与总体架构
一句话:让跑在板子上的 Agent 完成 感知 → 决策 → 执行 → 反馈 闭环。

| 层 | 组件 | 本项目实现 |
|---|---|---|
| 感知层 | HA 设备状态 / USB 摄像头 / 板卡温度内存 | get_states / camera_snapshot / get_system_status |
| 消息层 | Mosquitto(1883,已有) | 后续事件驱动接入 |
| 决策层 | MiniMax M3 + 手写 Agent Loop | agent/loop.py + agent/llm.py |
| 执行层 | HA 服务调用 | call_service(控一切已接入设备) |
| 反馈层 | 终端轨迹 / 后续 SQLite 与通知 | 当前阶段仅终端打印 |
2. 环境搭建与问题实录
2.1 这一阶段要干什么
先把"骨骼"立起来:项目目录、Python 环境、和 MiniMax M3 打通、把 Home Assistant 变成 Agent 可调用的工具。目标是跑通一次完整闭环,暂时不追求智能。
2.2 遇到的问题(如实记录)
问题 1:系统 Python 是"裸"的
text
$ python3 -c "import requests"
ModuleNotFoundError: No module named 'requests'
板子出厂的 Debian 12 精简干净,什么第三方包都没有。第一反应 pip install,但 Debian 12 会拦:
text
error: externally-managed-environment
我的理解:这是 PEP 668 机制。发行版担心 pip 装的包覆盖 apt 管理的系统 Python 包(apt 自身的工具就依赖 python3-xxx),把系统搞坏。两条路:
pip install --break-system-packages------粗暴,污染系统,pass;- venv 虚拟环境------标准做法,选这个。
问题 2:venv 创建也失败
text
$ python3 -m venv .venv
The virtual environment was not created successfully because ensurepip is not
available. ... You may need to install python3.11-venv
Debian 把 venv 支持拆成了单独的包(最小化安装的哲学,和 buildroot 只编必要的东西一个思路)。解决:
bash
sudo apt install python3.11-venv
2.3 认知沉淀:venv 到底是什么
看完目录结构就明白了:.venv/bin/python 是指向系统解释器的软链接 ,.venv/lib/python3.11/site-packages/ 是一块独立的包安装区 ,pip install 只写这块区域。
类比 :和交叉编译的 sysroot 一回事------独立的头文件/库搜索空间,随便折腾,不污染主机根文件系统。删掉 .venv/ 目录 = 整个环境消失,零残留。以后每个项目一个 venv,就像每个项目一套 sysroot。
2.4 项目分层设计(和固件分层对照)
text
hardware-agent/
├── main.py # 入口:selfcheck / run / chat 三个命令
├── .env # 配置(key、地址,不进 git)
├── agent/
│ ├── config.py # 配置加载 ≈ 引脚配置表/板级头文件
│ ├── llm.py # LLM 通信 ≈ 协议栈(对 MiniMax 的"串口驱动")
│ ├── loop.py # Agent 主循环 ≈ main loop,本项目的灵魂
│ └── tools/
│ ├── registry.py # 工具注册表 ≈ 驱动框架(ops 结构体)
│ ├── ha.py # HA REST 工具 ≈ 一个具体的"外设驱动"
│ └── sysinfo.py # 板卡自检 ≈ 传感器驱动(读板子健康)
├── docs/ # 学习文档
└── snapshots/ # 摄像头快照输出(运行时生成)
分层的意义和固件一样:换模型只动 .env,换执行器只动 tools/,主循环 loop.py 从第一版起就不该大改。
2.5 环境信息(本机实测)
| 项 | 值 |
|---|---|
| 板卡 | Radxa ROCK 5T,RK3588(4×A76+4×A55),16GB RAM |
| NPU | 6 TOPS,驱动 v0.9.8,rknpu2 服务运行中 |
| 系统 | Debian 12 bookworm,Python 3.11.2 |
| 已有服务 | HA 8123 端口(host 网络)/ Mosquitto 1883 / Z2M / Node-RED(Docker) |
| 依赖 | openai 3.6.0 / requests / python-dotenv(装在 .venv) |
3. MiniMax M3 接入
3.1 为什么 openai 的 SDK 能调 MiniMax
第一反应是怀疑:pip install openai 装的是 OpenAI 家的 SDK,凭什么能调别家模型?看代码(agent/llm.py)后明白了------SDK 里 base_url 是可配的。
OpenAI 的 Chat Completions HTTP 协议(POST /chat/completions,请求/响应 JSON 结构)已经成了行业事实标准,MiniMax 等各家都兼容它。所以对接任何一家,只需要三个参数:
text
base_url ------ 打给哪个服务(国内站 https://api.minimaxi.com/v1 / 国际站 https://api.minimax.io/v1)
api_key ------ 身份凭证
model ------ 指定模型名(以拿到的 API 文档为准,在 .env 里改)
类比:这和嵌入式里的协议复用一模一样------MQTT 客户端不关心 broker 是 Mosquitto 还是 EMQX;AT 指令集不关心模组是移远还是合宙。**只要报文格式约定一致,传输层换个对端,上层应用零改动。**手写的 Agent Loop 就是那个上层应用。
3.2 Function Calling 报文格式(重点,逐帧剖析)
Agent 区别于聊天机器人的核心机制。一次完整交互是三段式:
第 1 段:请求时声明工具清单(能力上报)
json
{
"model": "MiniMax-M3",
"messages": [{"role": "user", "content": "把书房灯打开"}],
"tools": [{
"type": "function",
"function": {
"name": "call_service",
"description": "调用 Home Assistant 服务以控制设备......",
"parameters": { "type": "object", "properties": { "...": "JSON Schema" } }
}
}]
}
parameters 是标准 JSON Schema------这就是给 LLM 看的"寄存器手册",它只能通过这段文字理解工具能干什么、参数怎么传。
第 2 段:模型不下结论,而是"下指令"
模型不执行任何东西(它只是一台推理服务器),而是返回:
json
{
"role": "assistant",
"tool_calls": [{
"id": "call_abc123",
"function": {
"name": "call_service",
"arguments": "{\"domain\":\"light\",\"service\":\"turn_on\",\"entity_id\":\"light.study\"}"
}
}]
}
两个细节,第一眼容易忽略:
arguments是JSON 字符串 ,不是 JSON 对象------本地要再json.loads一次;- 模型只给参数,不执行。执行权百分之百在本地代码手里,这条边界是安全的基石:模型永远只能"申请",不能"访问"。
第 3 段:本地执行,结果回填,再问一次模型
本地执行 call_service() 成功后,把两条消息追加进历史再发起请求:
json
{"role": "assistant", "tool_calls": [ ...第2段原样放回... ]},
{"role": "tool", "tool_call_id": "call_abc123",
"content": "已执行 light.turn_on -> light.study"}
坑(已经预感到会在这里摔跤) :assistant 那条 tool_calls 消息必须原样 放回历史,工具结果靠 tool_call_id 与之配对,漏放或改动会直接报 400。这类似 CAN 总线上"请求帧"和"应答帧"必须成对,缺帧就是总线错误。
模型收到工具结果后,要么继续申请调下一个工具(循环),要么输出最终答复(循环结束)。这个 while 循环就是 Agent Loop 的全部。
4. 连通性自检
4.1 自检脚本的设计思想
main.py selfcheck 按依赖顺序查三层,从物理层往上量:
text
1. 配置检查 ------ key/地址/模型名是否填了 (电源在不在)
2. LLM 连通 ------ 发一句话看模型是否回话 (串口能不能通)
3. HA 连通 ------ GET /api/ 应返回 "API running." (外设在不在)
以后每换环境(新板、新 key、断网恢复)第一步永远跑它。把"环境问题"和"代码问题"隔离排查,和点亮新板先量电源轨一个道理。
4.2 操作步骤
bash
cd ~/hardware-agent
cp .env.example .env
nano .env # 填 MINIMAX_API_KEY(HA_TOKEN 可留空,下一阶段再填)
.venv/bin/python main.py selfcheck
4.3 问题排查表(按现象查)
| 现象 | 可能原因 | 处理 |
|---|---|---|
| 401 Unauthorized | key 错/未生效 | 核对 key、看控制台是否需开通 |
| 404 / model not found | 模型名不匹配 | 按手头的 API 文档改 .env 的 MINIMAX_MODEL |
| 连接超时 | base_url 域名不对(国内/国际站)、网络 | 核对文档域名;本机有 Clash 代理,必要时设 https_proxy |
| selfcheck 第 3 项 401 | HA_TOKEN 未填/过期 | HA 网页 → 用户 → 安全 → 长期访问令牌 |
| 报 400 tool message 相关 | 协议配对问题(见 3.2 的坑) | 检查 assistant 消息是否原样入历史 |
4.4 实测运行记录(2026-08-30)
- selfcheck 三项全部通过
- 模型实际回复:
<think>用户只要求回复"在线"两个字......</think> 在线
------重要发现:M3 是思考模型,推理过程以<think>标签内联在 content 里 ,
不像某些厂商放在单独的reasoning_content字段。最终答案呈现时需要剥离
(已在 loop.py 加strip_think()处理)。 - key 是
sk-cp-前缀(不是官方 JWT 格式,疑似中转渠道),但在官方国内端点
api.minimaxi.com/v1+ 模型名MiniMax-M3下直接可用,无需改 base_url。 - HA 项留待填令牌后补测。
5. 工具层设计:把 Home Assistant 变成 Agent 的手脚
5.1 设计原则:暴露"总线",不为每类设备写驱动
第一版最容易犯的错:为每种设备写一个工具------open_light()、close_curtain()、set_temperature()......设备一多,工具清单爆炸,模型选择空间大,出错率跟着涨。
实际方案(agent/tools/ha.py)只注册 3 个工具:
| 工具 | 对应能力 | 类比 |
|---|---|---|
get_states |
读所有实体状态 | 总线抓包(看总线上所有节点状态) |
call_service |
调任意 HA 服务 | 总线写事务(对任意节点发命令) |
camera_snapshot |
抓一帧画面 | 视觉传感器采样 |
因为 HA 本身就是家里所有设备的抽象层------Zigbee 开关、USB 摄像头、温湿度传感器在 HA 里全都是统一的 entity。我们复用这层抽象,等于"已有一根现成的设备总线,Agent 只需要会说这根总线上的协议"。
5.2 HA 的寻址模型(CAN 视角)
HA 服务调用是二维寻址:
text
POST /api/services/{domain}/{service}
body: {"entity_id": "...", ...附加参数}
例:POST /api/services/light/turn_on body: {"entity_id": "light.study"}
domain.service(如light.turn_on)------功能码entity_id(如light.study)------节点地址
和 CAN 的"ID 含功能含义 + 报文指定目标"高度同构,理解成本几乎为零。一个推论:entity_id 必须先查再操作 (get_states 先行),凭记忆写 entity_id 就像凭记忆写 CAN ID------迟早打错节点。这条规则写进了 Agent 的 system prompt。
5.3 两个工程细节
状态裁剪(省 token = 省带宽) :HA /api/states 全量返回几十 KB,每个实体带几十个属性字段。get_states 只保留 entity_id / state / friendly_name 三个字段再回填。**LLM 上下文是昂贵资源,类似串口带宽------只发有效字节,不转发整帧。**设备数量上去之后,这一刀会越来越重要(后面要做分页/按需过滤)。
错误回填,不抛异常(registry.py 的看门狗设计) :dispatch() 对所有错误(未知工具、JSON 解析失败、参数不匹配、执行异常)统一返回错误字符串回填给模型,而不是让异常炸出主循环。为什么:模型收到"参数不匹配"的错误信息后,有能力对照工具定义自行修正重试。单次工具失败 ≠ 任务失败,把错误当作总线上的 NACK 帧处理,任务级韧性来自循环重试。当然 max_iterations(第 6 节)兜底防止无限重试烧 token。
5.4 sysinfo:Agent 感知"自己的身体"
get_system_status 读 /sys/class/thermal/thermal_zone0/temp(RK3588 SoC 温度)和 /proc/meminfo。这是硬件 Agent 和纯软件 Agent 的第一个区别:它跑在真实硅片上,过热降频、内存吃紧都会改变它的行为。让 Agent 能自查,排查"它今天变笨了/变卡了"时才有数据可看。
5.5 验收清单
- 不填 HA_TOKEN 时调用 ha 工具 → 应得到明确报错"HA_TOKEN 未配置"
- 填 HA_TOKEN 后
get_states返回家中设备列表(含 Zigbee 开关) -
call_service控制一个真实开关,人眼确认动作 -
camera_snapshot生成 jpg,打开看内容正确 -
get_system_status报出合理温度(实测 70.23°C,带 Docker 负载属正常偏高)
6. Agent Loop 手写:不到 100 行的主循环
不用任何框架(LangChain/LangGraph 之类),手写整个循环------先搞懂轮子,再决定要不要用别人的轮子。
6.1 全景

text
messages = [system, user] ← 任务下发
while True:
msg = LLM(messages, tools) ← 问决策器
if msg.tool_calls: ← 决策"动手"
历史 += assistant(tool_calls) ← 请求帧入队
for tc in tool_calls:
结果 = dispatch(tc) ← 执行外设操作
历史 += tool(结果) ← 应答帧入队
continue ← 带结果再问决策器
else: ← 决策"收工"
return msg.content ← 最终答复
对照固件:LLM 是决策器(跑在"云端的协处理器"),tools 是外设驱动,messages 历史是共享缓冲区,loop.py 是主循环。和聊天机器人的唯一区别:LLM 的输出能触发本地函数,函数结果又能回流进上下文------闭环形成,"智能"就出现在这个闭环里。
6.2 一次任务里 messages 的生长过程(实例)
用户输入:"看看书房灯开着没,开着的话帮我关掉"
text
[0] system 你是 ROCK 5T 上的硬件 Agent......(规则)
[1] user 看看书房灯开着没,开着的话帮我关掉
[2] assistant tool_calls: [get_states({"domain":"light"})] ← 第1轮决策
[3] tool [{"entity_id":"light.study","state":"on",...}] ← 执行结果
[4] assistant tool_calls: [call_service(light,turn_off,light.study)] ← 第2轮
[5] tool "已执行 light.turn_off -> light.study"
[6] assistant "书房灯原本开着,已关闭。" ← 最终答复,退出
三轮请求,6 条追加消息。"智能"不是一段代码,是这几条消息往返出来的轨迹------每多一轮,模型就多看一眼真实世界的反馈。
6.3 四个刻意的设计
| 设计 | 位置 | 理由 |
|---|---|---|
| temperature=0.3 | llm.py | 硬件控制要稳定复现,不要发散。类比:控制回路采样周期宁稳勿抖 |
| max_iterations=10 | loop.py | 看门狗:防模型无限调工具死循环烧 token |
| assistant 消息先入历史再回填 | loop.py | OpenAI 协议硬性要求,tool_call_id 配对,漏了报 400(3.2 的坑) |
| 工具错误回填不抛异常 | registry.py | 把失败当 NACK,模型可自纠重试(5.3) |
6.4 流程可观测性
loop.py 里所有 print 用了终端颜色:灰色=请求中、青色=工具调用、黄色=工具结果、绿色=最终答复。学习期把每轮决策打印出来至关重要------Agent 出错时需要像看逻辑分析仪波形一样回放这条轨迹。后面做常驻服务时这些 print 会升级成日志文件。
7. 第一次运行实录(2026-08-30)
任务:run "查一下板子的温度和内存占用,用一句话汇报"
第一遍:抓到真 bug,同时验证了错误回填设计。
text
[loop 1] → 工具调用 get_system_status({})
[loop 1] ← 错误:'usage' object has no attribute 'percent'
[loop 2] Agent:抱歉,get_system_status 调用失败(内部错误......)建议排查
两层教训:
- 我的错 :
shutil.disk_usage()返回的 usage 对象只有 total/used/free,percent是 psutil 的 API------写代码时把两个库的接口记串了。修复:手动used/total*100。 - 设计的胜利 :工具报错没有炸掉主循环,错误字符串按 role=tool 回填后,M3 自己理解了失败并向用户解释------
registry.py的"看门狗"设计第一次运行就体现了价值。
第二遍:绿色全通。
text
[loop 1] → get_system_status({})
← {"soc_temp_c": 70.23, "mem_used_pct": 35.1, "disk_used_pct": 30.3}
[loop 2] Agent:板子当前 SoC 温度 70.23°C,内存占用 35.1%,运行状态正常
模型行为观察:只给了"温度和内存"却主动多报了磁盘(工具返回了三个字段它就全用了);空参数工具传参准确;两轮收敛,无多余动作。
新增处理 :M3 的 <think> 推理块会出现在最终答复里,loop.py 增加 strip_think() 剥离(4.4 有记录)。
8. 遗留思考与下一步
遗留:
call_service是"万能执行器",也意味着模型可以调任何 服务(包括删自动化、改配置)。后续要加白名单:service 域级别准入 + 高危操作二次确认。get_states全量返回在设备 >50 个时会挤爆上下文,届时需要"按 domain 查 + 关键字搜索"两个变体。
下一步(阶段 B):
- 多轮记忆:现在是每次 run() 独立、无记忆。引入 SQLite 会话存储。
- MCP 化:把 tools/ 封装成 MCP server,让任何 MCP 客户端直接控制板卡硬件------体验"标准化外设总线"。
- 语音/QQ 入口:复用板上的 QQ bot 通道,Agent 获得远程对话入口。