找回密码
立即注册
搜索
发回帖 发新帖

5521

积分

0

好友

710

主题
发表于 5 小时前 | 查看: 1| 回复: 0

本章深入 esp-claw 的心脏 components/claw_modules/claw_core,逐文件剖析 Agent 核心源码:对象模型与生命周期、请求队列的串行化设计、上下文构建管线、OpenAI 与 Anthropic 双协议的 LLM 后端抽象、工具调用循环、用户中断机制、上下文持久化与响应交付。每个机制都从嵌入式场景的真实约束出发,对照真实源码回答“为什么这样设计”,并评价其得失。claw_core 是能力、记忆、技能、事件路由各章汇入的枢纽,读懂它,esp-claw 的运行时便不再有黑盒。

目录

  1. claw_core 的定位与设计约束
  2. 对象模型与生命周期
  3. 请求队列:串行化的第一道闸门
  4. 上下文构建管线
  5. LLM 后端抽象
  6. 工具调用循环(Agent Loop)
  7. 中断机制
  8. 上下文持久化与会话切换
  9. 响应交付
  10. 设计模式总结与嵌入式约束映射

1. claw_core 的定位与设计约束

本章先给 claw_core 立坐标:它必须同时满足的三个需求,以及它在依赖图里刻意保持的“小”。

1.1 一个组件要回答的三个问题

esp-claw 的推理触发源远不止 IM 消息一条:事件路由器的规则命中、CLI 命令、定时调度器、子代理完成后的自动汇报,都可能同时要求“让 LLM 想一想”。而单颗 ESP32 的现实是:内部 RAM 只有几百 KB 可用,一次 LLM 请求要跑几十秒、随时可能失败,对话上下文又必须跨越重启存活。claw_core 作为 Agent 核心,用一个组件回答三个问题:并发触发如何串行化、上下文从哪里拼出来、结果往哪里交付。

应用外壳通过 claw_manager 的 claw_agent_mgr 创建一个根代理和最多 3 个子代理(CONFIG_CLAW_AGENT_MGR_MAX_SUBAGENTS 默认 3),每个代理就是一个独立的 claw_core 实例,拥有自己的请求队列与工作任务。本章聚焦 claw_core 本身;能力分发见第5章《能力系统》,事件路由见第6章《事件路由系统》,记忆与技能分别见第7章《记忆系统》与第8章《技能系统》。

1.2 模块边界与可裁剪性

claw_core 的 idf_component.yml 只声明两个依赖:claw_utils(字符串与时间工具)与 espressif/cjson ^1.7(public)。事件路由器只出现在 CMakeLists.txt 的 PRIV_REQUIRES claw_event_router 里——claw_core 会向路由器发布事件(第9.1节),但不把该依赖暴露给上层。

Kconfig 里唯一的可裁剪项是 CLAW_CORE_STAGE_VERBOSITY(Simple/Verbose),控制 Agent 执行期间推送到 IM 的阶段消息详略。

组件采用 ESP-IDF C 风格面向对象:对外只暴露不透明句柄 claw_core_handle_t(即 struct claw_core_state *),内部状态全部私有。源码按职责切成八个文件:claw_core.c 管生命周期,claw_core_messages.c 管入队与响应队列,claw_core_agent_loop.c 是主循环,claw_core_context.c 拼上下文,claw_core_control.c 管中断与阶段,claw_core_events.c 发事件,claw_core_context_persist.c 管持久化,src/llm/ 子树是 LLM 运行时与后端。这种按职责切文件的做法让每个文件都能一次通读。

2. 对象模型与生命周期

先看骨架:不透明句柄背后是什么状态、哪些 FreeRTOS 原语,以及如何创建与销毁。

2.1 句柄与内部状态的真实结构

claw_core_internal.h 中的 claw_core_state 是全部共享状态的唯一归宿,其并发相关部分如下:

struct claw_core_state {
    bool initialized;
    bool started;
    volatile bool stop_requested;
    ...
    /* Waiting queue: entries start new agent loops when the core task is idle. */
    QueueHandle_t request_queue;      /* 等待队列:新请求在此排队 */
    QueueHandle_t response_queue;     /* 响应队列:供消费者按 request_id 拉取 */
    TaskHandle_t task_handle;         /* 唯一执行 Agent Loop 的工作任务 */
    SemaphoreHandle_t response_lock;  /* 保护响应队列与 pending 链表 */
    SemaphoreHandle_t inflight_lock;  /* 保护在途请求的全部状态 */
    uint32_t inflight_request_id;
    char inflight_session_id[CLAW_CORE_INFLIGHT_SESSION_ID_SIZE];
    claw_core_agent_loop_phase_t agent_loop_phase;
    volatile bool inflight_abort;
    claw_core_request_item_t insert_queue[CLAW_CORE_INSERT_QUEUE_LEN];
};

围绕它的 FreeRTOS 原语各司其职:

原语 类型 职责
request_queue 队列,深度默认 4 等待队列,新请求串行进入 Agent Loop
response_queue 队列,深度默认 4 拉取通道,CLI 等消费者按 request_id 取结果
insert_queue[4] 环形数组,受 inflight_lock 保护 同会话插话缓冲,用于中断在途推理
inflight_lock 互斥锁 保护 inflight_*、agent_loop_phase、inflight_abort
llm_lock 互斥锁 保护 LLM 配置与运行时,串行化所有 LLM HTTP 调用
response_lock 互斥锁 串行化响应队列的多消费者竞争
task_handle 工作任务 唯一的 Agent Loop 执行者

值得注意的是,inflight_* 一族字段(在途请求号、会话号、阶段、中止标志、插入队列)只在工作任务正在处理某个请求时有意义,被压缩到一把 inflight_lock 之下;而 LLM 配置(llm_config 与 llm_runtime)用独立的 llm_lock,因为配置热更新路径(第5.5节)与推理路径竞争的是它。锁的粒度按“谁和谁真的并发”划分,而不是一律一把大锁。

2.2 create/start/stop/destroy 的设计取舍

claw_core_create 强制要求 system_prompt 非空(人格提示词是 Agent 的身份,不是可选项),随后创建三条队列和三把锁,最后用 claw_core_llm_config_copy 把 LLM 配置逐字段 strdup 深拷贝进核心——任何一次分配失败就整体回滚返回 ESP_ERR_NO_MEM。在嵌入式上内存分配随时可能失败,“先拷贝、后指针替换”的写法保证失败路径不留半成品。

同样刻意的是惰性初始化:create 阶段不创建 LLM 运行时,首次推理时才由 claw_core_llm_ensure_runtime_locked 按需建立,因此 API token 可以在设备运行后配置,热更新配置无需重启。

claw_core_start 通过 claw_task_create 创建工作任务,栈策略为 CLAW_TASK_STACK_PREFER_PSRAM——底层是 xTaskCreatePinnedToCoreWithCaps,16 KiB 的任务栈优先放进 PSRAM(应用外壳在 app_claw.c 中覆盖为 16 KiB,组件默认 8 KiB),把宝贵的内部 RAM 留给 DMA 与中断。

claw_core_stop 的做法是:置 stop_requested,调用 claw_core_cancel_request(core, 0) 取消任意在途请求,再投递一个全零的“唤醒包”让阻塞在 xQueueReceive 上的任务醒来,然后限时轮询等待任务退出。

claw_core_destroy 在 stop 失败时直接拒绝销毁——工作任务还在跑就释放内存是典型的 use-after-free,宁可不删也不能删错。这套生命周期的可证明性是用“stop 可能超时失败、调用方必须处理”换来的。

3. 请求队列:串行化的第一道闸门

一切并发问题的答案都藏在第一个队列里:谁可以触发推理、按什么顺序、排队失败怎么办。

3.1 为什么所有触发源都要排队

如果没有队列,IM 消息、路由规则、CLI、调度器可以并发触发推理:两份完整 prompt 同时驻留堆上(每份可达数十 KB 的 cJSON 树),两个 HTTP 响应缓冲同时增长,同一会话文件的写入互相交错。任何一条都足以让 RAM 预算失控或会话历史损坏。

claw_core 的回答是经典的主动对象(Active Object)结构:所有触发源只做一件事——调用 claw_core_submit 把请求放进 request_queue,唯一的串行工作任务逐个消费。收益有三层:

其一,任何时刻每个核心只有一份 prompt、一次在途 HTTP,内存峰值可以精确预算;
其二,单会话一致性天然成立,同一会话的写入者永远只有一个;
其三,队列深度本身就是背压——默认 4,满了 xQueueSend 超时返回,压力被推回调用方。

同一会话的后续输入不该傻等,走的是第7章的中断通道;不同会话的请求在队列里按序等待。代价是吞吐上限被串行化锁死,但 ESP32 上“正确性优先于吞吐”,需要并行时用多实例解决——根代理与子代理各是一个 claw_core。

3.2 请求结构与 flags 语义

claw_core_request_t 携带一次请求的全部语境:request_id、flags、session_id、user_text,以及来源五元组(source_channel、source_chat_id、source_sender_id、source_message_id、source_cap)与去向二元组(target_channel、target_chat_id,缺省回落到来源)。

行为差异全部编码在四个标志位里:

#define CLAW_CORE_REQUEST_FLAG_PUBLISH_OUT_MESSAGE (1U << 0)
#define CLAW_CORE_REQUEST_FLAG_SKIP_RESPONSE_QUEUE (1U << 1)
#define CLAW_CORE_REQUEST_FLAG_USER_INTERRUPT      (1U << 2)
#define CLAW_CORE_REQUEST_FLAG_PUBLISH_STAGE_MESSAGE  (1U << 3)
标志位 语义
PUBLISH_OUT_MESSAGE 结果以 out_message 事件推给事件路由器
SKIP_RESPONSE_QUEUE 不入响应队列,交付完全走推送通道
USER_INTERRUPT 同会话插话,尝试中断在途推理
PUBLISH_STAGE_MESSAGE 推送工具调用的阶段进展消息

真实用法见分野:事件路由器把 IM 消息投给根代理时四个标志全开(claw_event_router.c 中组合赋值)——结果推回 IM、不入响应队列、同聊天的新消息视为插话;CLI 提交则一个都不开,用响应队列阻塞收结果。一个 struct 加一个位域,把“谁触发、怎么回”的通道策略说完了。

3.3 深拷贝入队与背压

入队前 clone_request_item 把所有字符串 strdup 成 owned_* 字段,view 里的指针全部改指向自己拥有的副本,FreeRTOS 队列按值传递这组指针,所有权随之转移。为什么必须深拷贝:调用方(例如路由器的事件处理栈)传来的字符串生命周期到 submit 返回即止,而请求可能在队列里躺很久;入队就是所有权分界线。

出队方在 finish_request 收口统一 claw_core_free_request_item,配合 goto 单一出口,泄漏路径可枚举。

背压语义直接而诚实:xQueueSend 带调用方给定的超时,队满返回 ESP_ERR_TIMEOUT 并立即释放克隆;克隆阶段内存不足返回 ESP_ERR_NO_MEM。claw_core 不做排队重试——把失败交还调用方(路由器会向用户回复错误),避免在核心里堆积无主内存。这是嵌入式队列设计的常识:队列是限流器,不是无底洞。

4. 上下文构建管线

prompt 不是写死的字符串,而是若干 provider 在请求现场拼出来的——本节拆开这条管线。

4.1 provider 模型:collect 回调与三种上下文

prompt 从哪里来?claw_core 不回答这个问题,它只定义接口:

typedef esp_err_t (*claw_core_context_provider_collect_fn)(
    const claw_core_request_t *request,
    claw_core_context_t *out_context,
    void *user_ctx);

typedef struct {
    const char *name;                              /* provider 名,兼作提示词分节标题 */
    claw_core_context_provider_collect_fn collect; /* 采集回调 */
    void *user_ctx;                                /* 回调上下文 */
    uint32_t flags;                                /* 如 REQUEST_START_ONLY */
} claw_core_context_provider_t;

每个 provider 通过 claw_core_add_context_provider 注册(须在 start 之前,容量由 max_context_providers 限定),返回三种 claw_core_context_kind_t 之一:SYSTEM_PROMPT(追加到系统提示词的一个 ## <name> 分节)、MESSAGES(合并进消息数组的 JSON 数组)、TOOLS(合并进工具数组)。

错误契约值得细看:返回 ESP_ERR_NOT_FOUND 表示“这次没有内容”,静默跳过;返回其他错误则整个请求失败;返回成功但内容为空被视为代码缺陷(ESP_FAIL)。

应用外壳在 app_claw.c 里装配的 provider 链是:claw_memory_profile_provider、claw_memory_long_term_provider(轻量模式换 claw_memory_long_term_lightweight_provider)、claw_memory_session_history_provider、claw_skill_skills_list_provider,再由 claw_manager 附加 claw_cap_root_agent_tools_provider。

为什么用 provider 组合而非硬编码:claw_core 的依赖图里没有 memory、skill、cap 任何一方,功能裁剪(如关闭记忆)只是少注册一个 provider,核心零改动;这同时也是第5、7、8章各系统接入 Agent 循环的唯一缝隙。

4.2 请求开始一次性上下文与用户消息去重

带 CLAW_CORE_CONTEXT_PROVIDER_FLAG_REQUEST_START_ONLY 标志的 provider(会话历史就是一例)只在请求开始时采集一次,结果缓存在 claw_core_cached_context_t 数组里;工具循环的每一轮重建迭代上下文时直接复用缓存(claw_core_build_iteration_context 的 request_start_contexts 参数)。

两层用意:其一,避免每轮迭代都从 DATA 分区重读会话文件——一轮工具循环可能十几轮迭代,读盘代价乘以迭代次数不可接受;其二,语义正确性——请求中途持久化的工具轮记录不会反过来污染本轮正在进行的推理,本轮看到的历史是请求开始那一刻的快照。

另一个精巧的细节在 claw_core_agent_loop_task 开头:用户消息先被持久化(第8.1节),若持久化成功且缓存的会话历史里已经有 MESSAGES(说明历史 provider 会把这条用户消息读回来),则 inject_active_user 置 false,构建上下文时不再手工注入当前用户消息——否则 prompt 里会出现两条一模一样的用户输入。时序换正确性,一行布尔值解决。

4.3 build_iteration_context 的拼装顺序

每轮迭代的上下文由 claw_core_build_iteration_context 从零拼装,顺序固定:以核心自持的 system_prompt 为底 → 依注册序应用每个 provider 的内容(系统提示词分节、消息数组、工具数组三类分流)→ 追加 ## Core Request 分节,写入 source_channel、source_chat_id,让 LLM 知道自己在跟哪个通道的哪个聊天说话 → 按需注入当前用户消息 → 最后把 runtime_messages(本轮工具循环累积的 assistant 工具消息、工具结果与插话消息)追加到消息数组末尾。

三类分流的代码只有十几行:

switch (kind) {
case CLAW_CORE_CONTEXT_KIND_SYSTEM_PROMPT:
    return append_prompt_section(system_prompt, section_name, content);
case CLAW_CORE_CONTEXT_KIND_MESSAGES:
    return append_message_array_json(messages, content);
case CLAW_CORE_CONTEXT_KIND_TOOLS:
    return append_tool_array_json(tools, content);
default:
    return ESP_ERR_INVALID_ARG;
}

工具数组非空才序列化为 tools_json,为空则传 NULL(后端据此省略 tools 字段)。注入了非空内容的 provider 名被记进 obs_providers_csv,供完成观察者(第9.2节)汇总。

评:每轮从零拼装而非增量修改,逻辑简单、不怕中间状态漂移,代价是每轮重复 cJSON 的追加与序列化——在 ESP32 上可接受,因为消息树本来就在内存里,真正的开销大户是 HTTP 与推理本身。

5. LLM 后端抽象

从 vtable 到 HTTP 事件回调,LLM 接入被切成四层,本节自上而下过一遍,直到两种真实协议的适配细节。

5.1 vtable 后端注册表

LLM 接入分三层:claw_core_llm.c 是核心视角(持 llm_lock、惰性建运行时);claw_llm_runtime.c 是运行时与注册表;src/llm/backends/ 下是具体协议。后端接口是一张纯 C 的 vtable:

typedef struct {
    const char *id;
    esp_err_t (*init)(const claw_llm_runtime_config_t *config,
                      const claw_llm_model_profile_t *profile,
                      void **out_backend_ctx, char **out_error_message);
    esp_err_t (*chat)(void *backend_ctx, const claw_llm_model_profile_t *profile,
                      const claw_llm_chat_request_t *request,
                      claw_llm_response_t *out_response, char **out_error_message);
    /* infer_media 与 chat 同型签名,用于一次性媒体推理 */
    void (*deinit)(void *backend_ctx);
} claw_llm_backend_vtable_t;

注册项 claw_llm_backend_registration_t 在 vtable 之外附赠三项缺省值:auth_type、chat_path、max_tokens_field。内置两个后端:openai_compatible(/chat/completions、bearer)与 anthropic_compatible(/messages、none);第三方可经 claw_core_llm_register_custom_backend 挂到链表上,重名 ID 拒绝注册。

claw_llm_runtime_init 负责归一:超时缺省 120 秒、max_tokens 缺省 8192、图片上限缺省 512 KiB,配置里留空的字段回落到后端缺省值,最终合成 claw_llm_model_profile_t 供 chat 与 infer_media 使用。core 对后端的全部认知就是这张 vtable——替换后端不改一行核心代码。

5.2 OpenAI 兼容后端

claw_llm_backend_openai_compatible.c 的 OpenAI 兼容后端 覆盖绝大多数云端与本地模型(OpenAI、DeepSeek、vLLM 等)。请求方向:URL 由 join_url 拼 base_url + /chat/completions;system 消息置于 messages[0];工具由 cap 侧 claw_cap_build_llm_tools_json(ctx, true) 生成的 {"type":"function","function":{...}} 数组解析后放入 body。

若 profile 声明 supports_tools=false 却收到了工具,直接 ESP_ERR_NOT_SUPPORTED 拒绝——比让服务端报 400 更早暴露配置错误。鉴权按 auth_type 分流:bearer(默认)、api-key(X-API-Key 头)或 none。

响应方向 parse_chat_response 要求 choices[0].message.role 必须是 assistant,然后把整棵 message 存为 raw_message_json——后续的工具循环与持久化用的都是这份原始 JSON,而不是事后重建。content 变 text,DeepSeek 风格的 reasoning_content 单独保留,tool_calls[].function 的 name 与 arguments(字符串原样)展平为内部 claw_llm_tool_call_t 数组。

5.3 Anthropic 后端与双向消息转换

本组件最见功力的设计决策是:内部规范消息格式取 OpenAI 风格,Anthropic 后端是一个双向适配器。请求方向的 convert_messages_to_anthropic 处理两种协议的工具语义鸿沟,源码注释把约束说得很清楚:

/*
 * Merge consecutive "tool"-role messages into a single "user"
 * message.  Anthropic requires every tool_use in an assistant
 * message to have a corresponding tool_result in the immediately
 * following user message — all in that ONE user message.
 */
if (strcmp(role, "tool") == 0) {
    cJSON *tool_blocks = cJSON_CreateArray();
    ...
}

连续的 tool 角色消息被合并成一条 user 消息内的多个 tool_result 块;assistant 的 reasoning_content 转成 thinking 块插到内容最前;tool_calls 转成 tool_use 块(arguments 字符串 parse 回 input 对象);不认识的块直接丢弃。工具定义从 function.parameters 改写为 input_schema,并附 tool_choice: auto;system 不占消息位而是提升为顶层字段。鉴权关闭 Authorization(auth_type="none"),改发 x-api-key 与 anthropic-version: 2023-06-01 两个自定义头。

响应方向扫描 content 块:text 拼接、thinking 归入 reasoning_content、tool_use 还原成 tool_calls(input 序列化回字符串)。两套协议所有的差异被隔离在这一个文件里,Agent Loop 对协议完全无感知。

5.4 HTTP 传输层:可中止的请求

claw_llm_http_transport.c 是唯一的网络出口。请求体先过 sanitize_utf8_body_copy:逐字节校验 UTF-8 序列,非法序列替换为空格——cJSON 与服务端对无效字节零容忍,而设备上的字符串拼接并不总能保证这一点。响应缓冲从 4 KiB 起步按需倍增。

最关键的是中止机制,挂在 HTTP 事件回调里:

static esp_err_t http_event_handler(esp_http_client_event_t *evt)
{
    http_request_context_t *ctx = (http_request_context_t *)evt->user_data;

    if (abort_requested(ctx)) {
        return ESP_FAIL;  /* 每个数据事件都检查中止标志,立即断流 */
    }
    if (evt->event_id == HTTP_EVENT_ON_DATA) {
        return response_buffer_append(ctx->buffer, (const char *)evt->data, evt->data_len);
    }
    return ESP_OK;
}

abort_flag 是核心的 inflight_abort 指针,第7章的中断靠它在毫秒级掐断传输,而不是干等 120 秒超时;esp_http_client_perform 返回后还会再查一次标志兜底竞态。

非 200 响应经 parse_error_message_body 提取 error.message 或 message 字段,拼成“HTTP 429: ...”这样的可读错误,兜底截取正文前 160 字节——用户在 IM 里看到的是服务端的原话而不是 ESP_FAIL。TLS 用 esp_crt_bundle_attach 支持任意证书链,CONFIG_HTTP_REUSE_ENABLE 时开启 keep-alive。

取舍:这里是非流式整包接收,放弃 SSE 打字机效果,换来“响应就是一棵 cJSON 树”的极简解析路径;打字机体验由阶段消息(第9.2节)在工具粒度上补偿。

5.5 配置来源与在线更新

后端选择的配置链走一遍:cap_llm_config 能力(第12章《板级适配与配置服务》)在设备设置页暴露 api_key、backend_type、model、base_url、auth_type、timeout_ms、max_tokens 及 supports_vision、image_remote_url_only 等字段 → 落入 app_claw_config_t 的 llm_* 成员 → app_claw_fill_core_config 填出 claw_core_config_t(16 KiB 栈、优先级 5、不绑核、队列深度 4、provider 容量 8)→ claw_agent_mgr_init 持有这份模板,每个代理创建时拷贝。

在线更新走 claw_agent_mgr_update_core_config → claw_core_update_llm_config:在 llm_lock 下销毁旧运行时、整体替换配置副本,下一次推理时惰性重建——换模型、换密钥不打断系统。如果你正在为设备选择稳定的模型 API 通道,可参考 RouteFast.ai 这类 API 中转服务,覆盖 Claude、GPT、Gemini 等主流模型。

另一个体贴的设计在 claw_core_llm_config_ready:token 或模型未配置时,它不返回错误码,而是生成“请打开设备设置页配置 LLM API token”这样的引导文本,让请求以 OK 状态把指引当作回答返回——用户在聊天窗口里直接知道下一步该做什么。

6. 工具调用循环(Agent Loop)

Agent Loop 是 claw_core 的主动脉:LLM 决定调工具、工具结果回填、再推理,循环往复直到给出最终回复。

6.1 主循环骨架与阶段状态机

claw_core_agent_loop_task 是全组件的主动脉:外层 while (!stop_requested) 从请求队列串行取请求;每请求先过预检链——claw_core_llm_config_ready 配置检查、request_gate 门禁(如记忆模块的会话封锁检查)、on_request_start 通知(触发记忆的异步提取)、用户消息持久化——然后采集 request-start 上下文,进入内层 while (true) 迭代:处理插话 → 构建迭代上下文 → 再处理插话 → LLM HTTP 调用 → 若失败且是用户中断则转为插话处理 → 若无 tool_calls 即终局退出;否则处理插话 → 执行工具 → 持久化工具轮 → 迭代计数与上限检查。

迭代的每个阶段都写入 claw_core_agent_loop_phase_t 状态机(IDLE、BEFORE/BUILDING_ITERATION_CONTEXT、BEFORE/IN_LLM_HTTP、AFTER_LLM_BEFORE_TOOL、RUNNING_TOOL、FINALIZING 共 8 态),外部可通过 claw_core_get_agent_loop_phase 查询——子代理管理工具用它向根代理汇报内部进展。一次完整请求的全景如下。

一次请求的Agent Loop完整时序图

流程执行说明:

  • 入队与预检阶段(步骤 1—5):触发源调用 claw_core_submit(),请求被深拷贝进 request_queue;工作任务串行出队后依次执行配置检查、request_gate 门禁与 on_request_start 回调;用户消息经 persist_context 批次先行写入会话文件(CLAW_CORE_CONTEXT_RECORD_USER),保证掉电不丢输入。

  • 上下文采集阶段(步骤 6—7):带 REQUEST_START_ONLY 标志的会话历史 provider 被采集一次并缓存,其余 provider(长期记忆、技能清单、cap 工具表)在每轮迭代的 build_iteration_context() 中实时采集,分流拼装为系统提示词分节、messages 数组与 tools 数组。

  • 推理与分支阶段(步骤 8—17,loop 块):claw_llm_runtime_chat() 经 vtable 进入具体后端;alt 分支判定——无 tool_calls 时走左侧终局路径,最终回复以 turn_completed=true 持久化并退出循环;有 tool_calls 时走右侧工具路径,call_cap 经核心的 call_cap 回调进入 claw_cap 分发(第5章),输出 JSON 以 tool 角色消息回填 runtime_messages,随后进入下一轮迭代重新推理,直到 LLM 不再要求调用工具。

  • 交付阶段(步骤 18—21):终局后若请求带 PUBLISH_OUT_MESSAGE 标志,claw_core_events.c 构造 out_message 事件交给事件路由器(第6章),路由器按目标通道解析出站绑定(如 Telegram 对应 tg_send_message),由 IM 能力送达聊天;未带该标志的请求(如 CLI)则进入响应队列等待 claw_core_receive_for 拉取。

6.2 工具分发与结果回填

核心自己不知道怎么执行工具:claw_core_config_t.call_cap 回调在应用侧被装配为 claw_cap_call_from_core(第5章《能力系统》)。claw_core_append_tool_results_messages 逐个调用它,传入工具名、arguments_json 与当前请求(工具因此能看到会话与通道语境)。

错误处理体现 Agent 思维:工具失败时若无输出,就用 esp_err_to_name 生成一句错误文本作为输出,并给 tool 消息打上 is_error=true——请求不终止,让 LLM 看见失败、自行决定重试还是换路。嵌入式工具失败是家常便饭(传感器没接、文件不存在),“失败也是信息”比“失败即崩溃”合理得多。

每条工具消息 {"role":"tool","tool_call_id":...,"content":...,"is_error":...} 同时进两处:runtime_messages(本轮继续推理用)与汇总出的 tool_results_json(持久化用);assistant 的原始消息 raw_message_json 被整棵追加进 runtime_messages,保证回填给 LLM 的历史与服务端返回的逐字一致。tool_summary 缓冲(768 字节上限)累积 “- name: ok/failed” 行,最终进入失败轨迹(第8.1节)。

6.3 终止条件与保护

循环有三种出口。正常出口是 LLM 返回不含 tool_calls,claw_core_finish_from_plain_text 把文本定为最终回复。保护出口之一是迭代上限:

iteration++;
if (iteration >= core->max_tool_iterations) {
    response.view.error_message = claw_utils_string_dup("cap tool iteration limit reached");
    err = ESP_ERR_INVALID_STATE;
    goto finish_request;
}

max_tool_iterations 默认 10(CLAW_CORE_DEFAULT_TOOL_ITERATIONS)。保护出口之二是 LLM 或传输层错误直接终局。

两个方向的失控都被钉死:模型无限循环调工具被迭代上限截住,网络挂死被 HTTP 层 timeout_ms(默认 120 秒)截住。失败也不是静默的——claw_core_build_context_failure_trace 会把错误原因连同工具摘要写成一条会话注释持久化,下一轮对话里 LLM 知道“上一次请求为什么没有答案”,不会对着缺失的上下文胡编。

代价与收益对等:上限可能在长任务中途砍掉合法推理,但 10 轮内完不成的任务在 ESP32 上大概率也不该由它完成。

6.4 媒体输入如何进入推理

图片等媒体不走主聊天循环,而是独立的一次性推理接口 claw_core_llm_infer_media(claw_llm_media_request_t 携带 system_prompt、user_prompt 与 media 数组)。

媒体预处理在 claw_media_pipeline.c 的 claw_media_prepare_asset:本地文件先过体积检查(image_max_bytes 默认 512 KiB),按扩展名判定 MIME(jpg/png/gif/webp),mbedtls_base64_encode 编成 data URL;远程 URL 直接透传;image_remote_url_only 的模型(自己会拉 URL)拒绝本地文件;supports_vision=false 的模型直接 ESP_ERR_NOT_SUPPORTED。

两个后端各自组块:OpenAI 用 image_url.url 装 data URL,Anthropic 要求 base64 源块(parse_data_url 把 data URL 拆开重组)。当前每次推理只取 media[0],即单图。

对 Agent 循环而言,“看图”是一种工具:cap_llm_inspect 能力暴露 inspect_image 可调用工具,LLM 在循环中调用它,工具内部再调 claw_core_llm_infer_media,分析结果以文本回填循环——媒体推理因此复用了完整的工具分发与错误回传机制,没有为视觉单开一条循环。注意 infer_media 与 chat 共享 llm_lock:同一时刻只有一路 LLM HTTP,内存峰值依然可控。

7. 中断机制

长推理期间用户插话怎么办?claw_core 的答案是“打断传输、保留成果、合并重推”。

7.1 两种中断:取消与用户插话

中断分两种,共用 inflight_abort 标志但语义不同。

claw_core_cancel_request(request_id 传 0 表示取消任意在途请求)是管理性取消:置 inflight_abort=true、inflight_abort_reason=CANCEL,HTTP 传输随即失败,请求在 finish_request 处把传输错误替换为“request cancelled”后正常收尾——claw_core_stop 就靠它终止在途请求。

用户插话(USER_INTERRUPT)则温和得多:它的 HTTP abort 会被 claw_core_control_take_user_interrupt_http_abort “取走”并清除,随后按插入消息处理,请求继续活着。

区分二者靠 claw_core_control_abort_reason_t 的三值枚举(NONE/CANCEL/USER_INTERRUPT),全部读写都在 inflight_lock 之下。

7.2 插入队列与四个检查点

带 USER_INTERRUPT 标志的请求进入 claw_core_ingress_submit 后先尝试 try_queue_insert_request:条件是在途请求存在、session_id 与在途会话一致、阶段可插入(非 IDLE/FINALIZING)且插入队列未满(4 条)。

若此刻正处于 IN_LLM_HTTP 阶段,立即武装 HTTP 中断:

if (core->agent_loop_phase == CLAW_CORE_AGENT_LOOP_PHASE_IN_LLM_HTTP &&
        core->inflight_abort_reason != CLAW_CORE_CONTROL_ABORT_REASON_CANCEL) {
    core->inflight_abort = true;
    core->inflight_abort_reason = CLAW_CORE_CONTROL_ABORT_REASON_USER_INTERRUPT;
}

插入失败分两类处理:队列满或锁超时直接向调用方报错;会话不同(ESP_ERR_NOT_FOUND)或无可插时机(ESP_ERR_INVALID_STATE)则回落为普通排队请求——另一个会话的用户消息不会被丢掉。

工作任务一侧在四个检查点调用 handle_pending_user_interrupts:构建迭代上下文前、发起 HTTP 前、HTTP 因中断失败后、工具执行前。该函数取出全部同会话插话文本,清除中断标志,先持久化(persist_context 的 USER 批次),再逐条追加到 runtime_messages,然后 continue 重建上下文重新推理。

与“杀掉请求重来”相比,插话保留了已完成的部分推理——已执行的工具结果不清空,新输入像追加消息一样生效,省时间也省 token。

新请求中断进行中推理的时序图

流程执行说明:

  • 插话入队阶段(步骤 1—6):用户在同一会话追加消息,IM/事件路由以 USER_INTERRUPT 标志调用 claw_core_submit();入口在 inflight_lock 保护下执行 try_queue_insert_request(),会话匹配且阶段可插入时写入 insert_queue[4],并因当前处于 IN_LLM_HTTP 阶段而置位 inflight_abort 与 USER_INTERRUPT 原因。

  • 传输中止阶段(步骤 7—10):正在进行的 esp_http_client_perform() 期间,HTTP 事件回调检测到 abort_flag 返回 ESP_FAIL 立即断流,后端把错误规范化为 ESP_ERR_INVALID_STATE(HTTP request aborted)返回给 claw_core_llm_chat_messages——中断在毫秒级生效,无需等待 120 秒超时。

  • 中断接管阶段(步骤 11—13):主循环发现调用失败,先经 claw_core_control_take_user_interrupt_http_abort() 确认这是用户中断(取走并清除标志,与普通网络错误区分开),再由 handle_pending_user_interrupts() 取出插入文本,先持久化再追加到 runtime_messages。

  • 续推阶段(步骤 14—16):循环 continue 回到迭代起点,重建包含新输入的完整上下文,重新调用 LLM——推理带着插话继续,而不是从零开始。

8. 上下文持久化与会话切换

会话历史的落盘时机、存储形态与淘汰策略,决定了设备掉电重启后 Agent 还“记得”多少。

8.1 持久化回调与批次记录

持久化同样被倒置为回调 persist_context(应用装配为 claw_memory_persist_context_callback,存储细节见第7章《记忆系统》),core 只定义批次结构:

typedef struct {
    const char *session_id;
    const claw_core_request_t *request;
    const claw_core_context_record_t *records;  /* 一批消息记录 */
    size_t record_count;
    bool turn_completed;                        /* 本轮对话是否收尾 */
} claw_core_context_persist_batch_t;

写入时机有三:请求开始先存 USER 记录(掉电时用户输入绝不丢);每轮工具结束立即存 ASSISTANT_TOOL(原始消息 JSON)与 TOOL_RESULT(结果 JSON)——消息级持久化而非回合级,中途断电后历史停在最后一个完整的工具对上;终局存 ASSISTANT_FINAL 且 turn_completed=true。

请求失败时再补一条 failure trace(含错误原因与工具摘要)。无 session_id 的请求(如部分 CLI 单发)完全跳过持久化。持久化失败只记警告日志、不失败请求——可用性优先于完整性的取舍,代价是会话可能缺轮,但下一条 failure note 机制让缺失可解释。

8.2 存储布局、压缩与会话封锁

claw_memory 侧(此处概述,第7章详析):所有路径挂在 claw_paths 的 CLAW_PATH_DATA 逻辑根下——启动时一次性解析为 flash 存储分区或 SD 卡挂载点,其余固件无需关心介质。每个会话一对文件:追加写的 data 文件加定长索引 idx 文件(10 字节/条,magic 0x58444843),索引让“读最后 N 条”不必扫全文件。

内存受限下的淘汰策略分两级:turn_completed 时检查 data 文件,超过 CLAW_MEMORY_SESSION_SIZE_LIMIT(150 KiB)触发压缩——重写文件时每个历史工具轮只保留最近 1 轮(CLAW_MEMORY_SESSION_COMPACT_TOOL_TURNS=1),丢弃的是中间的工具细节、保留对话首尾与结论,ftruncate 原地收缩两个文件;若压缩后仍超限,则写入 blocked 标记,此后 claw_memory_request_gate_callback 在 request_gate 处直接拒绝新请求,并回复引导用户执行 /session new、/session delete 的提示文本。

评:这套策略把最坏情况的 prompt 尺寸钉死(150 KiB 历史 + 4096 字节/条消息上限 + 8192 max_tokens),RAM 峰值可预算;被封锁时用自然语言引导而非静默失败,体验完整。

会话切换在 core 侧零成本:session_id 由上层 claw_session_mgr 按策略生成,claw_core 不缓存任何跨请求状态,换会话就是换一个 ID。

9. 响应交付

结果如何回到发起者手中:本地拉取与 IM 推送两条通道并存,中间还夹着过程性的阶段消息。

9.1 响应队列与 out_message 双通道

结果交付有两条并存的通道。拉模式:响应进 response_queue,消费者用 claw_core_receive_for 按 request_id 精确匹配——response_lock 串行化多消费者,取到不匹配的响应先挂进 pending_head 链表等主人来取;CLI 就是这么做的(app_claw_cli.c 以 130 秒超时阻塞收结果)。

推模式:带 PUBLISH_OUT_MESSAGE 标志的请求在终局由 claw_core_events.c 构造 claw_event_t(source_cap="claw_core",事件类型 out_message,通道取 target_channel 缺省回落 source_channel,correlation_id 记来源消息 ID)交给 claw_event_router_publish。

路由器按通道名解析出站绑定——app_claw 注册了 qq、feishu、telegram、wechat、web 五条(分别对应 qq_send_message、feishu_send_message、tg_send_message、wechat_send_message、local_send_message 能力),由第9章《IM通信集成》的各能力送达聊天。

SKIP_RESPONSE_QUEUE 标志的存在有一层防死锁的考虑:推模式请求的交付已由事件表达,若再入响应队列而无人消费,工作任务会在 claw_core_response_push 的 portMAX_DELAY 上永久阻塞——所以路由器与子代理通知都不入队,直接释放。

评:拉模式给本地消费者精确配对与超时控制;推模式让 core 与 IM 彻底解耦——claw_core 根本不知道 qq 或 telegram 的存在。

9.2 阶段消息与完成观察者

长推理的体验问题(用户盯着“正在输入”几十秒)由阶段消息解决:请求带 PUBLISH_STAGE_MESSAGE 时,每轮工具调用前后都会推送工具调用的阶段进展消息,claw_publish_stage_tool_calls 发出 agent_stage 事件;Verbose 档(CONFIG_CLAW_CORE_STAGE_VERBOSITY_VERBOSE)会带上轮次编号、150 字符的推理摘要与 40 字符的参数预览,Simple 档只报工具名——这正是 Kconfig 里那个唯一裁剪项的意义。

终局前 collect_stage_note 回调让记忆模块附上异步提取到的摘要(第7章)。另一个扩展点是完成观察者:claw_core_add_completion_observer 最多注册 4 个,完成时收到 claw_core_completion_summary_t(含最终文本、注入过内容的 provider 清单 CSV、全部工具调用 CSV)。

子代理协作完全建立在其上:claw_agent_mgr 给子代理注册观察者,完成时把最终文本包进 <subagent_completed agent_id="..."> XML 再以 USER_INTERRUPT 标志提交回根代理的会话——多代理编排没有侵入 core 一行代码,这是观察者模式在 C 里最实用的形态。

10. 设计模式总结与嵌入式约束映射

最后把全章机制收拢为三个设计模式,并逐条对回嵌入式的物理约束。

10.1 主动对象:队列加单工作任务

claw_core 的骨架是教科书式的主动对象(Active Object):对象状态、一个工作任务、两条消息队列合为一体,外部接口只有 claw_core_submit 与 claw_core_receive。

除 inflight_* 一族(因为中断路径在工作任务之外写入,用一把小锁收口)与 LLM 配置(热更新路径)外,核心状态只在工作任务单线程内访问,几乎不存在锁竞争与数据竞争。

对比“每请求一个任务”或协程方案:ESP32 的任务栈是稀缺资源,即便放 PSRAM,16 KiB 乘以并发数也不可扩展;主动对象把并发度固定为 1,RAM 峰值变成常量。这是嵌入式 Agent 与服务器 Agent 最本质的分野:服务器用并发换吞吐,ESP32 用串行换可预算。

10.2 策略组合与适配器

回顾全章出现过的模式清单:上下文 provider 是策略组合,五种来源按注册序拼装,插拔即裁剪;LLM 后端是适配器加注册表,OpenAI/Anthropic 双协议乃至自定义协议统一到一张 vtable 之后;持久化、门禁、能力调用、阶段注释全是函数指针加 user_ctx 的回调注入;完成观察者支撑多代理协作。

这些“模式”在 C 里没有继承与虚函数,靠的是函数指针与依赖倒置——claw_core 的依赖图因此保持单向无环:它不依赖 memory、skill、cap、IM 中的任何一个,全部反过来注入。idf_component.yml 里那两个依赖(utils 与 cJSON)就是这句话的证明:Agent 核心小到可以被任何应用装配。

10.3 约束映射与得失评价

把全章机制收回约束本身,对应关系如下表:

嵌入式约束 claw_core 的回答
RAM 有限 单工作任务串行推理、prompt 每轮重建后即释放、队列深度 4、响应缓冲倍增有起点、图片 512 KiB 上限
网络不稳定 120 秒超时、事件回调级中止、错误体解析为可读文本、失败轨迹入会话史
掉电安全 用户消息先持久化、消息级追加写加定长索引、压缩用 ftruncate 原地收缩
单会话一致性 等待队列串行不同会话、insert 队列承接同会话插话
成本失控 max_tool_iterations 默认 10、max_tokens 默认 8192、会话 150 KiB 封顶

得失总评:串行化换来低内存与可证明的正确性,多会话并行靠多实例缓解,代价是单会话内长工具链会阻塞同核心的后续请求(插话通道部分缓解);非流式 HTTP 牺牲打字机体验,换来一棵 cJSON 树走天下的简单解析,阶段消息在工具粒度补偿;provider 与回调让核心极小,但装配知识全部上移到 app_claw.c 与 claw_manager——这是“框架小、应用担责”的自觉取舍。

理解了这些取舍的边界,也就知道二次开发时哪里该顺势扩展(新 provider、新后端、新观察者),哪里不该硬拗(在 core 内做并发推理)。如果你对嵌入式 Agent 开发有更多实践心得,欢迎到 云栈社区 交流。

参考文献

[1] esp-claw GitHub 仓库:https://github.com/espressif/esp-claw

[2] OpenAI Chat Completions API 文档:https://platform.openai.com/docs/api-reference/chat

[3] Anthropic Messages API 文档:https://docs.anthropic.com/en/api/messages

[4] ESP-IDF FreeRTOS 文档:https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/system/freertos.html

[5] ESP-IDF FreeRTOS 扩展(xTaskCreatePinnedToCoreWithCaps 等):https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/system/freertos_idf.html

[6] ESP-IDF esp_http_client 文档:https://docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/protocols/esp_http_client.html




上一篇:ESP-Claw启动流程剖析:从NVS到Agent就绪的完整链路
下一篇:ESP-Claw 能力系统源码剖析:LLM 工具调用与固件框架
您需要登录后才可以回帖 登录 | 立即注册

关闭

站长推荐上一条 /1 下一条

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-30 06:24 , Processed in 0.096360 second(s), 40 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表