RK3588硬件 Agent 代码详解

文章目录

    • [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 工具注册表 TOOLSregistry.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:(向用户解释原因,并给出配置令牌的操作指引)

注意两点:模型连试了 switchlight 两个 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 工具注册表 TOOLSregistry.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)
相关推荐
禅口魔心3 小时前
RK3588(Rock 5T)硬件 Agent 搭建实录:从环境到跑通第一个闭环
rk3588·嵌入式开发
派勤电子3 天前
工控主板串口数量上限是多少?X86与ARM不同板型原生串口数量详情
嵌入式开发·工业控制·工业自动化·工控主板·arm主板·工控主板串口·x86主板串口
BW.SU3 天前
RUI Studio--嵌入式 UI 开发新范式
单片机·ui·嵌入式开发
dozenyaoyida3 天前
无外网 Linux 服务器离线安装 VS Code Remote-SSH 指南(新版双包机制 + commit 一致)
ssh·vs code·嵌入式开发·clangd·离线安装·remote-ssh
Tronlong创龙4 天前
告别系统崩溃!ARM工控机 + OverlayFS,系统一键还原
嵌入式开发·硬件开发·工业控制·工业开发板
俊基科技4 天前
AU-48 双麦多功能语音处理模组让每一句话都清晰抵达 —— 一颗 23×20mm 小芯片,重新定义“听得清“
嵌入式开发·硬件开发·ai降噪·回声消除·拾音降噪
捷瑞电子工坊7 天前
FreeRTOS 内存管理详解:从 heap_1 到 heap_5 的选择与实践
freertos·内存管理·嵌入式开发·内存碎片·嵌入式实时操作系统·动态内存分配
Lee_jerome11 天前
从 PyTorch 权重到 RK3588 板端推理:ResNet18 二分类模型完整部署教程
pytorch·边缘计算·rk3588·模型部署·onnx·resnet18·int8量化
QXWZ_IA11 天前
【深度】打破IoT“米级”魔咒:当RTK SDK遇上具身智能与工业互联,厘米级定位如何重塑边缘计算?
物联网·自动驾驶·嵌入式开发·rtk·北斗导航