如何读懂zclaw代码:agent.c工具循环驱动ESP32 AI助手多轮LLM调用的完整剖析
【免费下载链接】zclawYour personal AI assistant at all-in 888KiB (~35KB in app code). Running on an ESP32. GPIO, cron, custom tools, memory, and more.项目地址: https://gitcode.com/gh_mirrors/zc/zclaw
zclaw 是一个运行在 ESP32 上的个人 AI 助手,全部固件仅约 888 KiB。它的核心是 main/agent.c 里一段紧凑的工具循环:反复向大模型发起多轮 LLM 调用,模型要么直接回答,要么要求执行工具;设备执行完工具后把结果"回灌"给模型,继续下一轮,直到拿到最终回答。本文带你完整读懂这套循环的设计与实现。
吉祥物画面:一只龙虾用钳子"操作"着 Seeed XIAO ESP32-C3 开发板——zclaw 就住在这块小小的板子里。
整体架构:一个 FreeRTOS 任务,一条消息一个循环
🧠 zclaw 由多个 FreeRTOS 任务协作,但真正"思考"的只有一个:agent 任务。入口是 agent.c 中的agent_task:
- 启动时接收三个队列:用户输入队列、本地输出队列、Telegram 输出队列;
- 之后死循环地从输入队列取消息,逐条交给
process_message()处理。
这保证了同一时刻只有一条消息在走工具循环,天然避免了并发写历史。
进入 process_message:循环开始前的准备工作
每条消息进入 process_message() 后,先做四件事:
- 拦截斜杠命令(/start、/help、/stop 等)和 USB 本地管理命令,直接应答,不走 LLM,省流量;
- 取工具清单:
tools_get_all()拿到内置工具表和数量; - 暂停 Telegram 轮询,避免 LLM 长请求期间 TLS 握手互相挤占内存;
- 把用户消息写入会话历史,并记录起点
history_turn_start——这是出错时"回滚"用的存档点。
核心 while 循环:5 轮上限的 agent.c 工具循环
真正的核心是这段循环,位于 agent.c:
while (!done && rounds < MAX_TOOL_ROUNDS) { rounds++; // 1. 构建请求 JSON(系统提示 + 全部历史 + 工具定义) // 2. 限流检查 // 3. 调用 LLM(带重试) // 4. 解析响应:是工具调用?→ 执行并把结果写回历史,继续下一轮 // 是普通文本?→ 回复用户,done = true }MAX_TOOL_ROUNDS在 config.h 中定义为5:一条消息最多触发 5 轮 LLM 调用。这个"刹车"是防失控的关键(后面细说)。
构建请求:系统提示 + 滚动历史 + 工具定义
每轮都会重新组装一次请求 JSON:
- 系统提示词:由
agent_build_system_prompt()按当前人格(neutral / friendly / technical / witty)生成,告诉模型"你是一台 400 KB RAM 的 ESP32 上的 agent,回答要简短、只用纯文本"; - 会话历史:把
s_history里已有的全部消息原样带上——包括上一轮的工具调用和工具结果,这是"多轮"的记忆来源; - 工具清单:内置工具由 builtin_tools.def 宏展开进 tools.c 的注册表(名称、描述、参数 JSON Schema、执行函数),用户自定义工具一并附上。
限流检查与带预算的重试机制
发送前先过ratelimit_check()(默认100 次/小时、1000 次/天),超限直接回滚并告知用户。
每轮 LLM 调用自带重试机制,参数见 config.h:
| 参数 | 值 | 含义 |
|---|---|---|
LLM_MAX_RETRIES | 3 | 每轮最多尝试次数(含首次) |
LLM_RETRY_BASE_MS | 2000 | 首次失败后等 2 秒 |
LLM_RETRY_MAX_MS | 10000 | 退避间隔封顶 10 秒 |
LLM_RETRY_BUDGET_MS | 45000 | 单轮重试总时间预算 45 秒 |
失败后按指数退避(2s → 4s → 8s…),但总耗时不超过 45 秒预算,防止网络故障时卡死设备。
两种响应分支:工具调用 或 最终回答
json_parse_response()解析出响应后,循环走向两个分支之一:
分支一:模型要调工具(工具名非空且有参数)
- 把这条 tool_use 消息(工具名 + 参数 JSON)以 assistant 身份写入历史;
- 执行工具,结果写入 512 字节的
s_tool_result_buf; - 把工具结果以 user 身份的 tool_result 消息写回历史;
- 不回复用户,直接
continue进入下一轮 LLM 调用——让模型"看到"自己工具的结果。
工具执行分三类(见 agent.c):
- 🛠️内置工具(gpio_write、cron_set、memory_set 等):直接在芯片上执行;
- 🔧用户自定义工具:不直接执行,而是把它的 action 描述变成"Execute this action now: …"交还给模型,由模型再拆解成内置工具完成——这就是"自定义工具组合"的实现方式;
- 🚫特殊规则:定时任务触发的消息里禁止再调
cron_set,防止定时任务不断繁殖新的定时任务。
分支二:模型返回普通文本
写入历史 → 经队列发回 Telegram / 本地串口 →done = true,循环结束。
滚动历史:24 条消息槽位就是全部记忆
ESP32 内存紧张,zclaw 的历史就是一个固定数组s_history[24](MAX_HISTORY_TURNS = 12,见 config.h),每条消息最长 1024 字符。满了怎么办?history_add() 的做法是:丢弃最旧的一条,其余整体前移。
注意它刻意"不按一问一答成对删除"——因为一次工具交互会跨多条消息(user → assistant 工具调用 → user 工具结果),按对删除会把配对拆散,导致 API 报错。这个细节是嵌入式上做 LLM agent 很容易踩的坑。
防失控设计:回滚、上限与指标
🛡️ 工具循环的可靠性不靠运气,靠三道保险:
- 轮次上限:达到 5 轮仍没拿到文本回复,循环强制终止,回复用户"(Reached max tool iterations)",避免模型陷入无限调工具;
- 历史回滚:构建请求失败、被限流、LLM 重试耗尽、响应解析失败,任何一个环节出错都会调用
history_rollback_to()把历史恢复到本轮起点(agent.c),保证不会把半截的 tool_use 留在记忆里污染后续对话; - 指标统计:每次请求都有
request_metrics计时——LLM 总耗时、工具总耗时、轮数、调用次数,请求结束时打一行 METRIC 日志,方便你快速定位"为什么这次回答特别慢"。
关键常量与源码地图
| 常量 | 值 | 作用 | 位置 |
|---|---|---|---|
MAX_TOOL_ROUNDS | 5 | 每请求最多工具轮数 | config.h |
MAX_HISTORY_TURNS | 12 | 保留 12 轮(24 条)历史 | config.h |
LLM_MAX_RETRIES | 3 | 每轮 LLM 最多尝试次数 | config.h |
LLM_RETRY_BUDGET_MS | 45000 | 单轮重试时间预算(毫秒) | config.h |
TOOL_RESULT_BUF_SIZE | 512 | 工具结果缓冲区(字节) | config.h |
核心文件一览:
- main/agent.c:agent 任务、消息预处理与工具循环主体;
- main/config.h:循环轮数、重试、历史长度等全部预算参数;
- main/tools.c:内置工具注册表与执行分发;
- main/json_util.c:请求构建与响应解析;
- main/llm.c:多后端(Anthropic / OpenAI / OpenRouter / Ollama)HTTP 调用。
总结:小设备上的 Agent 循环设计范式
zclaw 把多轮 LLM 调用压缩成了一个固定缓冲 + 固定上限的循环:FreeRTOS 单任务串行处理消息,每轮"构建请求 → 调 LLM → 解析响应",工具结果回灌历史后进入下一轮,普通文本则直接收尾;滚动历史、限流、带时间预算的重试和历史回滚,保证了整个循环在 400 KB RAM 的芯片里稳定可靠。读懂这 200 行核心代码,你就掌握了在资源受限设备上构建 LLM agent 的完整范式。🦞
【免费下载链接】zclawYour personal AI assistant at all-in 888KiB (~35KB in app code). Running on an ESP32. GPIO, cron, custom tools, memory, and more.项目地址: https://gitcode.com/gh_mirrors/zc/zclaw
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考