文章目录
-
- [1. 代码全景](#1. 代码全景)
- [2. 功能 → 文件 → 函数调用链](#2. 功能 → 文件 → 函数调用链)
-
- [2.0 总表](#2.0 总表)
- [2.1 单次任务链路(核心路径,逐步展开)](#2.1 单次任务链路(核心路径,逐步展开))
- [2.2 工具注册链路(import 时的"魔法")](#2.2 工具注册链路(import 时的"魔法"))
- [3. 数据结构设计](#3. 数据结构设计)
-
- [3.1 messages 列表(整个系统的"共享缓冲区")](#3.1 messages 列表(整个系统的"共享缓冲区"))
- [3.2 工具注册表 `TOOLS`(registry.py)](#3.2 工具注册表
TOOLS(registry.py)) - [3.3 SQLite 记忆库(memory.py → 项目根目录 `memory.db`)](#3.3 SQLite 记忆库(memory.py → 项目根目录
memory.db)) - [3.4 配置项(.env → config.py)](#3.4 配置项(.env → config.py))
- [3.5 磁盘产物](#3.5 磁盘产物)
- [4. 全部源码](#4. 全部源码)
-
- [4.1 agent/config.py](#4.1 agent/config.py)
- [4.2 agent/llm.py](#4.2 agent/llm.py)
- [4.3 agent/tools/registry.py](#4.3 agent/tools/registry.py)
- [4.4 agent/tools/ha.py](#4.4 agent/tools/ha.py)
- [4.5 agent/tools/sysinfo.py](#4.5 agent/tools/sysinfo.py)
- [4.6 agent/memory.py](#4.6 agent/memory.py)
- [4.7 agent/loop.py](#4.7 agent/loop.py)
- [4.8 main.py](#4.8 main.py)
1. 代码全景
| 文件 | 职责 | 对外暴露 |
|---|---|---|
main.py |
CLI 入口:命令分发、自检、交互 | 可执行脚本 |
agent/config.py |
加载 .env,集中管理配置 |
6 个配置常量 |
agent/llm.py |
MiniMax M3 客户端(OpenAI 兼容) | get_client() / chat() |
agent/loop.py |
Agent 主循环(灵魂) | run() / strip_think() |
agent/tools/registry.py |
工具注册表 + 统一执行入口 | register() / dispatch() / openai_schemas() |
agent/tools/ha.py |
HA REST 三工具(感知/执行/视觉) | 注册后进入 TOOLS |
agent/tools/sysinfo.py |
板卡健康自查工具 | 注册后进入 TOOLS |
agent/memory.py |
SQLite 会话记忆 | new_session() / append() / history() 等 |

2. 功能 → 文件 → 函数调用链
2.0 总表
| 功能 | 入口 | 完整调用链 |
|---|---|---|
| 连通性自检 | main.py cmd_selfcheck() |
agent/llm.py chat()(LLM 连通)+ requests.get(HA_URL/api/)(HA 连通) |
| 单次任务 | main.py cmd_run() |
agent/loop.py run() ↔ agent/llm.py chat() ↔ agent/tools/registry.py dispatch() → 具体工具 |
| 多轮对话 | main.py cmd_chat() |
agent/memory.py new_session() → 循环 loop.run(session_id=...) |
| 恢复会话 | main.py cmd_chat(session_id) |
agent/memory.py history() 载入 → 同上循环 |
| 会话列表 | main.py cmd_sessions() |
agent/memory.py list_sessions() |
| 工具注册 | (import 时自动) | agent/tools/__init__.py import ha/sysinfo → 各文件模块级 @register 装饰器 → registry.TOOLS |
| 工具执行 | loop.py run() 内 |
registry.dispatch(name, args_json) → TOOLS[name]["run"](**args) |
| LLM 对话 | loop.py run() 内 |
llm.chat(messages, tools) → get_client() → OpenAI SDK → MiniMax 端点 |
2.1 单次任务链路(核心路径,逐步展开)
以 main.py run "列出家里所有的开关和灯" 为例:
text
main.py __main__
└─ cmd_run(task) # main.py:解析命令行
└─ loop.run(user_input) # agent/loop.py:主循环启动
├─ tools.openai_schemas() # registry.py:导出4个工具的Schema给LLM
├─ llm.chat(messages, tools) # agent/llm.py:POST /chat/completions
│ └─ OpenAI SDK → api.minimaxi.com/v1 [第1次网络请求]
├─ 返回 msg.tool_calls → registry.dispatch() # registry.py
│ ├─ json.loads(args_json) # 参数反序列化
│ └─ TOOLS["get_states"]["run"](domain=...) # agent/tools/ha.py
│ └─ requests.get(HA_URL/api/states) [第2次网络请求,本地HA]
├─ messages += tool结果 → 回到 llm.chat() [第3次网络请求]
└─ msg.content → strip_think() → 最终答复 # loop.py
实测轨迹(2026-08-30,HA_TOKEN 未填时的错误路径------同一链路,走的是错误分支):
text
[loop 1] → 工具调用 get_states({"domain": "switch"})
← 错误:HA_TOKEN 未配置,请在 .env 填写 Home Assistant 长期访问令牌
[loop 1] → 工具调用 get_states({"domain": "light"})
← 错误:同上
[loop 2] Agent:(向用户解释原因,并给出配置令牌的操作指引)
注意两点:模型连试了 switch、light 两个 domain(符合"先查再报"的行为);错误没有终止任务,最终答复是模型基于回填错误组织的------错误回填设计跑通了错误分支。
2.2 工具注册链路(import 时的"魔法")
text
main.py / loop.py 执行 from agent import tools
└─ agent/tools/__init__.py: from . import ha, sysinfo
├─ ha.py 模块级代码执行:@register({...}) 装饰 get_states 等 3 个函数
│ └─ registry.register(schema) 返回 deco → TOOLS[name] = {"schema":..., "run":fn}
└─ sysinfo.py 同理注册 get_system_status
结果:import 完成即注册完成,loop.py 无需感知有几个工具
这是典型的注册器模式 :新增一个工具 = 新建文件 + 写函数 + 挂装饰器 + 在 __init__.py 加一行 import,主循环零改动。类比 Linux 内核的 module_init()------驱动自己注册,框架不点名。
3. 数据结构设计
3.1 messages 列表(整个系统的"共享缓冲区")
OpenAI 协议的消息序列,四种 role 四种形态,全项目只有这一份核心数据结构:
python
messages = [
# ① system:规则与身份,只出现一次,永远在下标 0
{"role": "system", "content": "你是运行在 ROCK 5T 上的硬件 Agent......"},
# ② user:任务输入(会话恢复时,前面还有若干历史 user/assistant 对)
{"role": "user", "content": "列出家里所有的开关和灯......"},
# ③ assistant 带 tool_calls:模型的"动手决策"(SDK 对象,append 原样入列)
# msg.tool_calls = [ToolCall(id="call_abc", function=Function(
# name="get_states", arguments='{"domain": "switch"}'))]
# ④ tool:工具执行结果,靠 tool_call_id 与 ③ 配对(缺配对 → 400)
{"role": "tool", "tool_call_id": "call_abc", "content": "[{...实体状态...}]"},
# ⑤ assistant 纯文本:最终答复,循环退出条件
# msg.content = "书房灯......"
]
形态 ③→④→(回到 ①③ 之间循环)→⑤ 的推进完全由 loop.py 驱动。四个刻意约束 :③ 必须原样入列(协议配对)、④ 的 content 恒为字符串(dispatch 保证)、列表只增不删(单任务内)、跨任务只持久化 ②⑤ 两种形态(见 3.3)。

3.2 工具注册表 TOOLS(registry.py)
python
TOOLS: dict[str, dict] = {
"get_states": {
"schema": { # 给 LLM 看的"寄存器手册"
"type": "function",
"function": {
"name": "get_states",
"description": "获取 Home Assistant 中设备/传感器的当前状态列表......",
"parameters": { "type": "object", "properties": {...} },
},
},
"run": <function get_states>, # 真正干活的本地函数
},
"call_service": {...}, "camera_snapshot": {...}, "get_system_status": {...},
}
Schema 与实现绑定在同一 dict 里、经同一装饰器登记:一份定义,两个出口 ------openai_schemas() 出口面向 LLM(Chat Completions 的 tools 参数),dispatch() 出口面向本地执行。当前共 4 个工具。
3.3 SQLite 记忆库(memory.py → 项目根目录 memory.db)
sql
CREATE TABLE IF NOT EXISTS sessions(
id TEXT PRIMARY KEY, -- uuid4,会话句柄
title TEXT, -- 取首条输入前40字符,人读
created_at REAL, -- unix 时间戳
updated_at REAL -- 排序键:会话列表按最近活跃倒序
);
CREATE TABLE IF NOT EXISTS messages(
seq INTEGER PRIMARY KEY AUTOINCREMENT, -- 全局递增,天然时间序
session_id TEXT NOT NULL,
role TEXT NOT NULL, -- 只存 'user' / 'assistant' 两种
content TEXT NOT NULL,
created_at REAL
);
关键取舍:只存"对话级"消息(②⑤),不存中间工具帧(③④) 。三个理由:省 token(一次任务 6~10 条消息,多轮累积很快撑爆上下文);避开 ③④ 的 tool_call_id 配对坑(回放永远是合法的 user/assistant 交替序列);信息压缩(工具原始返回由模型总结进最终答复,相当于把总线抓包归档成会议纪要)。
读取侧 history(session_id, limit=20):滑动窗口取最近 20 条、时间正序返回。语义检索式长期记忆留给后续阶段。
3.4 配置项(.env → config.py)
| 配置 | 用途 | 缺省 |
|---|---|---|
MINIMAX_API_KEY |
LLM 身份凭证 | 必填 |
MINIMAX_BASE_URL |
API 端点 | https://api.minimaxi.com/v1 |
MINIMAX_MODEL |
模型名 | MiniMax-M3 |
HA_URL |
HA 地址 | http://localhost:8123 |
HA_TOKEN |
HA 长期访问令牌 | 控制设备前必填 |
3.5 磁盘产物
| 路径 | 产生者 | 内容 |
|---|---|---|
memory.db |
agent/memory.py |
会话与对话级消息 |
snapshots/snap_<unix时间>.jpg |
ha.py camera_snapshot() |
摄像头抓拍 |
4. 全部源码
4.1 agent/config.py
python
"""配置加载:从项目根目录 .env 读取密钥与地址。
嵌入式类比:相当于硬件的"引脚配置表"------所有随环境变化的参数
(密钥、地址、模型名)集中在 .env 一处,代码只引用符号名。
换模型、换板子时只改配置文件,代码零改动。
"""
import os
from pathlib import Path
from dotenv import load_dotenv
# 以本文件的上一级目录为项目根,保证从任意工作目录启动都能找到 .env
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
load_dotenv(_PROJECT_ROOT / ".env")
# ---- MiniMax M3(OpenAI 兼容协议)----
MINIMAX_API_KEY = os.getenv("MINIMAX_API_KEY", "")
MINIMAX_BASE_URL = os.getenv("MINIMAX_BASE_URL", "https://api.minimaxi.com/v1")
MINIMAX_MODEL = os.getenv("MINIMAX_MODEL", "MiniMax-M3")
# ---- Home Assistant(REST API,默认 8123 端口)----
HA_URL = os.getenv("HA_URL", "http://localhost:8123")
HA_TOKEN = os.getenv("HA_TOKEN", "")
4.2 agent/llm.py
python
"""LLM 客户端:统一封装对 MiniMax M3 的对话调用。
关键认知(为什么能用 openai SDK 调 MiniMax):
MiniMax 的对话接口兼容 OpenAI Chat Completions 协议,所以直接用
openai 官方 SDK,只改 base_url 和 api_key 两项。
嵌入式类比:OpenAI 协议之于 LLM 服务,就像标准 UART 帧格式之于
串口模块------大家说同一种"报文格式",换厂商只换地址和密钥,
上层代码(我们手写的 Agent Loop)完全不用动。
"""
from openai import OpenAI
from . import config
def get_client() -> OpenAI:
"""创建指向 MiniMax 端点的 OpenAI 兼容客户端。"""
if not config.MINIMAX_API_KEY:
raise RuntimeError("MINIMAX_API_KEY 未配置,请在项目根目录 .env 中填写")
return OpenAI(
api_key=config.MINIMAX_API_KEY,
base_url=config.MINIMAX_BASE_URL,
)
def chat(messages: list, tools: list | None = None, temperature: float = 0.3):
"""单次对话调用。
messages : 对话历史,role 取值 system / user / assistant / tool
tools : OpenAI function-calling 格式的工具描述(JSON Schema 列表)
返回 : assistant 消息对象------要么带 content(最终答复),
要么带 tool_calls(要求调用本地工具),二者必居其一
temperature 取较低值(0.3):硬件控制场景要的是"稳定执行指令",
不是"发散创意",类比:控制回路里的采样周期,宁可保守不要抖动。
"""
client = get_client()
kwargs = {
"model": config.MINIMAX_MODEL,
"messages": messages,
"temperature": temperature,
}
if tools:
kwargs["tools"] = tools
resp = client.chat.completions.create(**kwargs)
return resp.choices[0].message
4.3 agent/tools/registry.py
python
"""工具注册表:Agent 的"手",一切硬件交互的唯一入口。
设计要点(这是整个项目最重要的抽象):
1. 每个工具 = 一份 JSON Schema 描述(给 LLM 看)+ 一个 Python 函数(真正干活)
- Schema 是"芯片手册里的寄存器表":LLM 只能通过它认识工具
- 函数是"寄存器背后的硬件逻辑":LLM 永远不接触实现细节
- 这个隔离是刻意的:LLM 决定"调什么、传什么参数",执行权在本地代码
2. dispatch() 出错时返回错误字符串而不是向上抛异常------
把错误以 role=tool 消息回填给 LLM,它有机会自己纠正
(换个参数重试、改用别的工具)。类比:给 MCU 加了看门狗,
任务跑飞不等于系统重启,恢复现场继续跑。
"""
import json
TOOLS: dict = {}
def register(schema: dict):
"""装饰器:把工具函数连同它的 Schema 一起登记进注册表。"""
def deco(fn):
TOOLS[schema["function"]["name"]] = {"schema": schema, "run": fn}
return fn
return deco
def openai_schemas() -> list:
"""导出 OpenAI tools 参数格式(发给 LLM 的"能力清单")。"""
return [t["schema"] for t in TOOLS.values()]
def dispatch(name: str, args_json: str) -> str:
"""按名字执行工具,入参是 LLM 给出的 JSON 字符串。
返回值统一为字符串:无论成功失败都变成 role=tool 消息回填给 LLM。
"""
if name not in TOOLS:
return f"错误:未知工具 {name}"
try:
args = json.loads(args_json or "{}")
except json.JSONDecodeError as e:
return f"错误:参数不是合法 JSON:{e}"
try:
result = TOOLS[name]["run"](**args)
except TypeError as e:
# LLM 传错参数(少了/多了字段)------最常见的运行期错误
return f"错误:工具 {name} 参数不匹配:{e}。请对照工具定义修正后重试。"
except Exception as e:
return f"错误:工具 {name} 执行失败:{e}"
# 确保回填内容是字符串(工具内部可能返回 dict/list)
return result if isinstance(result, str) else json.dumps(result, ensure_ascii=False)
4.4 agent/tools/ha.py
python
"""Home Assistant REST API 工具:通过 HA 控制已接入的一切设备。
背景:HA 以 network_mode: host 运行在 8123 端口,REST API 默认开启,
鉴权方式是 HTTP Header 里带长期访问令牌(Bearer Token)。
协议模型(学习记录):
HA 的服务调用是"二维寻址":domain/service 定位一个动作,
entity_id 指定目标对象。对嵌入式工程师最贴切的类比是
"CAN ID + 报文"------domain.service 是功能码,entity_id 是节点地址。
我们只注册 3 个工具,却覆盖了 HA 全部设备能力:
- get_states 读所有实体状态 (感知)
- call_service 调任意服务 = 控一切设备 (执行)
- camera_snapshot 摄像头抓一张当前画面 (视觉)
不为一类设备写一个工具,而是暴露"总线协议"本身------工具数量少,
LLM 的选择空间小,出错概率就低。
"""
import json
import time
from pathlib import Path
import requests
from .registry import register
from .. import config
_SNAPSHOT_DIR = Path(__file__).resolve().parent.parent.parent / "snapshots"
def _ha_headers() -> dict:
if not config.HA_TOKEN:
raise RuntimeError("HA_TOKEN 未配置,请在 .env 填写 Home Assistant 长期访问令牌")
return {
"Authorization": f"Bearer {config.HA_TOKEN}",
"Content-Type": "application/json",
}
@register({
"type": "function",
"function": {
"name": "get_states",
"description": "获取 Home Assistant 中设备/传感器的当前状态列表。可按域名过滤,"
"常用 domain:switch(开关)、light(灯)、sensor(传感器)、"
"binary_sensor(二值传感器)、camera(摄像头)、climate(温控)。",
"parameters": {
"type": "object",
"properties": {
"domain": {
"type": "string",
"description": "要过滤的实体域名,如 switch、sensor;留空返回全部实体",
},
},
"required": [],
},
},
})
def get_states(domain: str = "") -> str:
"""拉取实体状态,裁剪成 LLM 需要的最小字段集。"""
r = requests.get(f"{config.HA_URL}/api/states", headers=_ha_headers(), timeout=10)
r.raise_for_status()
states = r.json()
if domain:
states = [s for s in states if s["entity_id"].startswith(domain + ".")]
# 上下文裁剪:每个实体只留 entity_id/state/friendly_name 三个字段。
# HA 全量状态有几十 KB,塞进 prompt 会浪费大量 token------
# 类似串口通信里只发有效字节,不做无谓的全帧转发。
slim = [
{
"entity_id": s["entity_id"],
"state": s["state"],
"name": s["attributes"].get("friendly_name", ""),
}
for s in states
]
return json.dumps(slim, ensure_ascii=False)
@register({
"type": "function",
"function": {
"name": "call_service",
"description": "调用 Home Assistant 服务以控制设备。典型用法:"
"开开关 call_service('switch', 'turn_on', 'switch.xxx');"
"关灯 call_service('light', 'turn_off', 'light.xxx')。"
"先调用 get_states 查到准确的 entity_id 再操作。",
"parameters": {
"type": "object",
"properties": {
"domain": {"type": "string", "description": "服务域,如 switch / light"},
"service": {"type": "string", "description": "服务名,如 turn_on / turn_off"},
"entity_id": {"type": "string", "description": "目标实体 ID,如 switch.study_desk"},
"data": {
"type": "object",
"description": "可选附加参数,如 {\"brightness\": 128}",
},
},
"required": ["domain", "service", "entity_id"],
},
},
})
def call_service(domain: str, service: str, entity_id: str, data: dict | None = None) -> str:
"""调用任意 HA 服务。这是 Agent 的"万能执行器"。"""
payload = {"entity_id": entity_id}
if data:
payload.update(data)
r = requests.post(
f"{config.HA_URL}/api/services/{domain}/{service}",
headers=_ha_headers(), json=payload, timeout=10,
)
r.raise_for_status()
return f"已执行 {domain}.{service} -> {entity_id}"
@register({
"type": "function",
"function": {
"name": "camera_snapshot",
"description": "让指定摄像头实体抓拍一张当前画面,保存为 jpg 并返回文件路径。",
"parameters": {
"type": "object",
"properties": {
"entity_id": {"type": "string", "description": "摄像头实体 ID,如 camera.usb_cam"},
},
"required": ["entity_id"],
},
},
})
def camera_snapshot(entity_id: str) -> str:
"""抓拍当前帧到 snapshots/ 目录,返回文件路径。"""
r = requests.post(
f"{config.HA_URL}/api/camera_proxy_snapshot/{entity_id}",
headers=_ha_headers(), timeout=15,
)
r.raise_for_status()
_SNAPSHOT_DIR.mkdir(exist_ok=True)
path = _SNAPSHOT_DIR / f"snap_{int(time.time())}.jpg"
path.write_bytes(r.content)
return f"快照已保存:{path}"
4.5 agent/tools/sysinfo.py
python
"""板卡自身状态工具:让 Agent 感知"自己的身体"。
硬件 Agent 与纯软件 Agent 的第一个区别:它跑在真实硬件上,
温度、内存、磁盘都是会出问题的物理量。给它一个自查工具,
它才能在"我变卡了"这类问题里区分是软件还是过热降频。
"""
import json
import shutil
from .registry import register
def _cpu_temp() -> float:
"""RK3588 的 SoC 温度从 thermal_zone 读出,单位毫摄氏度。"""
try:
with open("/sys/class/thermal/thermal_zone0/temp") as f:
return int(f.read().strip()) / 1000.0
except (OSError, ValueError):
return -1.0
@register({
"type": "function",
"function": {
"name": "get_system_status",
"description": "获取 ROCK 5T 板卡自身状态:SoC 温度、内存占用、根分区磁盘占用。",
"parameters": {"type": "object", "properties": {}},
},
})
def get_system_status() -> str:
mem = {}
with open("/proc/meminfo") as f:
for line in f:
key, val = line.split(":", 1)
if key in ("MemTotal", "MemAvailable"):
mem[key] = int(val.strip().split()[0]) # 单位 kB
mem_used_pct = round(
100 * (mem["MemTotal"] - mem["MemAvailable"]) / mem["MemTotal"], 1
)
# shutil.disk_usage 没有 percent 字段(那是 psutil 的 API),
# 首次运行实测踩坑:'usage' object has no attribute 'percent',手动算
disk = shutil.disk_usage("/")
disk_used_pct = round(100 * disk.used / disk.total, 1)
return json.dumps({
"soc_temp_c": _cpu_temp(),
"mem_used_pct": mem_used_pct,
"disk_used_pct": disk_used_pct,
}, ensure_ascii=False)
4.6 agent/memory.py
python
"""会话记忆:SQLite 持久化"对话级"历史(user / 最终答复)。
设计决策------为什么只存对话级消息,不存中间工具帧:
1. 省 token:一次任务动辄产生 6~10 条消息(阶段A 6.2 实例),
多轮累积很快挤爆上下文窗口;
2. 避免协议配对坑:回放的旧历史若含 tool_calls,必须与 tool 结果
严格配对(tool_call_id),缺对直接 400。只回放 user/assistant
交替序列,永远是合法报文;
3. 信息压缩:工具原始返回不进记忆,模型在最终答复里的"总结"进记忆
------相当于把总线抓包归档成会议纪要。
"""
import sqlite3
import time
import uuid
from pathlib import Path
# 数据库放项目根目录,路径基于本文件定位,与启动时的工作目录无关
# (MCP server 被外部客户端拉起时 cwd 不可控,一切路径都必须锚定 __file__)
_DB_PATH = Path(__file__).resolve().parent.parent / "memory.db"
def _conn() -> sqlite3.Connection:
conn = sqlite3.connect(_DB_PATH)
conn.executescript("""
CREATE TABLE IF NOT EXISTS sessions(
id TEXT PRIMARY KEY,
title TEXT,
created_at REAL,
updated_at REAL
);
CREATE TABLE IF NOT EXISTS messages(
seq INTEGER PRIMARY KEY AUTOINCREMENT,
session_id TEXT NOT NULL,
role TEXT NOT NULL, -- 只存 'user' / 'assistant'
content TEXT NOT NULL,
created_at REAL
);
CREATE INDEX IF NOT EXISTS idx_messages_session
ON messages(session_id, seq);
""")
return conn
def new_session(title: str = "") -> str:
"""新建会话,返回会话 ID。"""
sid = str(uuid.uuid4())
now = time.time()
with _conn() as conn:
conn.execute(
"INSERT INTO sessions(id, title, created_at, updated_at) VALUES(?,?,?,?)",
(sid, title or "(未命名)", now, now),
)
return sid
def set_title(session_id: str, title: str) -> None:
with _conn() as conn:
conn.execute("UPDATE sessions SET title=? WHERE id=?", (title[:40], session_id))
def append(session_id: str, role: str, content: str) -> None:
"""追加一条对话级消息(role 仅限 user / assistant)。"""
now = time.time()
with _conn() as conn:
conn.execute(
"INSERT INTO messages(session_id, role, content, created_at) VALUES(?,?,?,?)",
(session_id, role, content, now),
)
conn.execute("UPDATE sessions SET updated_at=? WHERE id=?", (now, session_id))
def history(session_id: str, limit: int = 20) -> list[dict]:
"""取该会话最近 limit 条对话级消息,按时间正序返回。
limit 从尾部截断:更早的对话对当前任务贡献最小------简单的滑动窗口,
可预期、零依赖;按语义检索的长期记忆留给阶段 D。
"""
with _conn() as conn:
rows = conn.execute(
"SELECT role, content FROM messages WHERE session_id=? "
"ORDER BY seq DESC LIMIT ?",
(session_id, limit),
).fetchall()
return [{"role": r, "content": c} for r, c in reversed(rows)]
def list_sessions() -> list[tuple]:
"""列出全部会话(id / 标题 / 最近活跃时间),供 CLI 恢复会话用。"""
with _conn() as conn:
return conn.execute(
"SELECT id, title, datetime(updated_at, 'unixepoch', 'localtime') "
"FROM sessions ORDER BY updated_at DESC"
).fetchall()
4.7 agent/loop.py
python
"""手写 Agent Loop ------ 本项目的核心学习目标。
用嵌入式思维理解这个循环,它就是"带外设决策的主循环":
while (未得到最终答复):
1. 把完整对话历史(含工具执行结果)发给 LLM
2. LLM 返回二选一:
a) content ------ 最终答复,循环结束
b) tool_calls ------ "我要调这些工具"
3. 逐个执行工具,结果以 role=tool 消息追加进历史
4. 回到 1,LLM 看到工具结果后再决策
对比裸聊天(一次请求一次回答)的本质区别:
LLM 的输出可以"触发本地函数",函数结果又"回流进上下文",
形成 感知 -> 决策 -> 执行 -> 反馈 的闭环。
这正是"中断驱动的控制系统":LLM 是决策器,工具是外设驱动,
消息历史是共享缓冲区,loop.py 是主循环。
"""
import json
import re
from .llm import chat
from . import tools as toolset
from . import memory
# MiniMax M3 是思考模型,最终答复里内联 <think>...</think> 推理过程。
# 给人看的最终答案把它剥掉;推理轨迹本身仍有学习价值,注释里保留出处。
_THINK_RE = re.compile(r"<think>.*?</think>", re.DOTALL)
def strip_think(text: str) -> str:
"""剥掉模型答复中的思考块,只留结论。"""
return _THINK_RE.sub("", text or "").strip()
SYSTEM_PROMPT = """你是运行在 Radxa ROCK 5T(RK3588)开发板上的硬件 Agent。
职责:
- 通过 get_states 感知家中设备状态,通过 call_service 控制设备,
通过 camera_snapshot 查看摄像头画面,通过 get_system_status 自查板卡健康。
规则:
1. 涉及具体设备操作时,必须先用 get_states 确认准确的 entity_id,
绝不凭猜测操作实体。
2. 用户意图模糊时先查询状态再向用户确认,不盲操作。
3. 回答用中文,简洁,给出你实际执行了什么动作和结果。
"""
def run(user_input: str, session_id: str | None = None,
max_iterations: int = 10, verbose: bool = True) -> str:
"""执行一次完整的 Agent 任务,返回最终答复文本。
session_id 给定时:从 SQLite 载入该会话的对话级历史作为上下文,
任务结束后把本轮 user 输入与最终答复写回------多轮记忆由此形成。
max_iterations 是安全阀:LLM 理论上可能无限"调工具而不回答",
类比主循环里的任务超时看门狗,防止死循环烧 token。
"""
history = memory.history(session_id) if session_id else []
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
if history:
messages += history
if verbose:
print(f"\033[2m[loop] 已载入会话记忆 {len(history)} 条\033[0m")
messages.append({"role": "user", "content": user_input})
for i in range(1, max_iterations + 1):
if verbose:
print(f"\033[2m[loop {i}] 请求 LLM(历史 {len(messages)} 条)...\033[0m")
msg = chat(messages, tools=toolset.openai_schemas())
# ---- 分支 b:LLM 要求调用工具 ----
if msg.tool_calls:
# 注意顺序:必须先把 assistant(带 tool_calls 的)这条消息
# 原样放进历史,工具结果才能通过 tool_call_id 与之配对。
# 这是 OpenAI 协议的硬性要求,漏掉会直接报 400。
messages.append(msg)
for tc in msg.tool_calls:
args_pretty = tc.function.arguments
if verbose:
print(f"\033[36m → 工具调用 {tc.function.name}({args_pretty})\033[0m")
result = toolset.dispatch(tc.function.name, tc.function.arguments)
if verbose:
brief = result if len(result) <= 200 else result[:200] + "...(截断)"
print(f"\033[33m ← 工具结果 {brief}\033[0m")
messages.append({
"role": "tool",
"tool_call_id": tc.id,
"content": result,
})
continue # 带着工具结果回到步骤 1
# ---- 分支 a:LLM 给出最终答复,循环退出 ----
answer = strip_think(msg.content)
if session_id:
memory.append(session_id, "user", user_input)
memory.append(session_id, "assistant", answer)
return answer
return "(达到最大迭代次数,任务未完成,已中止。)"
4.8 main.py
python
#!/usr/bin/env python3
"""硬件 Agent 命令行入口。
用法:
.venv/bin/python main.py selfcheck # 连通性自检(配好 .env 后先跑这个)
.venv/bin/python main.py run "把客厅灯打开"
.venv/bin/python main.py chat # 交互模式(自动新建会话,带记忆)
.venv/bin/python main.py chat --session <会话ID> # 恢复历史会话
.venv/bin/python main.py sessions # 列出历史会话
"""
import sys
from agent import config, memory
from agent import tools as toolset # noqa: F401 导入即完成工具注册
from agent.loop import run
def cmd_selfcheck() -> int:
"""三项自检:配置完整性 → MiniMax API 连通性 → HA API 连通性。
部署新板/新 key 时永远先跑它,把"环境问题"和"代码问题"分开排查------
和点亮一块新板先量电源轨是一个道理。
"""
ok = True
print("== 1. 配置检查 ==")
for name, val, need in [
("MINIMAX_API_KEY", config.MINIMAX_API_KEY, True),
("MINIMAX_BASE_URL", config.MINIMAX_BASE_URL, True),
("MINIMAX_MODEL", config.MINIMAX_MODEL, True),
("HA_TOKEN", config.HA_TOKEN, False), # 阶段一可以先不配 HA
]:
state = "已配置" if val else "未配置"
if need and not val:
ok = False
print(f" {name:18s}: {state}" + (f" ({val})" if val and "KEY" not in name and "TOKEN" not in name else ""))
print("\n== 2. MiniMax API 连通性 ==")
if not config.MINIMAX_API_KEY:
print(" 跳过(未填 key)")
else:
try:
from agent.llm import chat
msg = chat([{"role": "user", "content": "回复两个字:在线"}], temperature=0.0)
print(f" 模型回复:{msg.content}")
except Exception as e:
ok = False
print(f" 失败:{e}")
print("\n== 3. Home Assistant 连通性 ==")
if not config.HA_TOKEN:
print(" 跳过(未填 HA_TOKEN)")
else:
try:
import requests
r = requests.get(
f"{config.HA_URL}/api/",
headers={"Authorization": f"Bearer {config.HA_TOKEN}"},
timeout=5,
)
print(f" HTTP {r.status_code}: {r.json().get('message', r.text[:50])}")
except Exception as e:
ok = False
print(f" 失败:{e}")
print(f"\n结果:{'全部通过' if ok else '存在未通过项,见上方输出'}")
return 0 if ok else 1
def cmd_run(task: str) -> int:
print(f"任务:{task}")
answer = run(task)
print(f"\n\033[32mAgent:{answer}\033[0m")
return 0
def cmd_chat(session_id: str | None = None) -> int:
"""多轮交互模式:对话级历史经 SQLite 持久化,跨轮记忆成立。"""
if session_id:
print(f"恢复会话 {session_id}")
else:
session_id = memory.new_session()
print(f"新会话 {session_id}(首次输入将作为会话标题)")
print("硬件 Agent 交互模式(exit 退出)")
resumed = session_id is not None
first = True
while True:
try:
user = input("\n你 > ").strip()
except (EOFError, KeyboardInterrupt):
break
if not user or user.lower() in ("exit", "quit"):
break
if first and not resumed:
memory.set_title(session_id, user)
first = False
answer = run(user, session_id=session_id)
print(f"\n\033[32mAgent:{answer}\033[0m")
return 0
def cmd_sessions() -> int:
rows = memory.list_sessions()
if not rows:
print("(暂无会话)")
return 0
for sid, title, ts in rows:
print(f" {sid} {ts} {title}")
return 0
if __name__ == "__main__":
if len(sys.argv) < 2 or sys.argv[1] == "help":
print(__doc__)
sys.exit(0)
cmd = sys.argv[1]
if cmd == "selfcheck":
sys.exit(cmd_selfcheck())
elif cmd == "run":
if len(sys.argv) < 3:
print('用法:main.py run "任务描述"')
sys.exit(2)
sys.exit(cmd_run(sys.argv[2]))
elif cmd == "chat":
# chat [--session <会话ID>]
if "--session" in sys.argv:
i = sys.argv.index("--session")
sid = sys.argv[i + 1] if len(sys.argv) > i + 1 else None
if not sid:
print("用法:main.py chat --session <会话ID>(ID 用 sessions 命令查)")
sys.exit(2)
sys.exit(cmd_chat(session_id=sid))
sys.exit(cmd_chat())
elif cmd == "sessions":
sys.exit(cmd_sessions())
else:
print(f"未知命令:{cmd}")
print(__doc__)
sys.exit(2)