Cloud Agent 开发笔记(2):Agent 引擎与 Tool 体系

另起炉灶

上一篇Cloud Agent 开发笔记(1):V1从跑通到放弃聊到决定重写,以及为什么选了参考 Claude Code 源码这条路。4 月 8 号动手,第一个要解决的问题是:Claude Code 是 CLI 工具,用户面对的是终端窗口。Cloud Agent V2 是 Web 应用,用户面对的是浏览器,都要做出哪些变化?这个答案只能在逐个推进模块的过程中解答,下面按实际的开发推进顺序讲。

先定个调:这不是另一篇 Claude Code 的源码解读——网上不缺这类文章。它是一个实际工程项目的决策记录:代码是 Vibe Coding 写的,但取舍是人做的,踩的坑是人踩的。写下来是为了以后回头看时,能想起当时为什么选了这条路而不是那条。

Agent Loop和首要原则

真正动工前,先要把基础方向定下来。上一篇提到技术栈向 Claude Code 靠拢:TypeScript + Bun + Zod,Hono 做 HTTP 层,React + Vite 做前端,SQLite 做持久化。这些是开工前就想清楚的,没什么悬念。

但有一件事是开始设计 agent loop 时才意识到的。

Claude Code 的 agent 循环有两层结构。QueryEngine 管会话生命周期:多轮状态、transcript 持久化、usage 累积、错误恢复。queryLoop() 管单轮执行:调用 LLM、执行工具、拼接结果。我一开始也照这个模式搭了框架,但越搭越不对劲:为什么要两层?

翻回去看 Claude Code 源码,才搞清楚它的上下文。Claude Code 同时服务四个消费端:CLI、IDE 插件、SDK 调用、MCP server 模式。每个消费端对事件的格式、粒度、生命周期管理的要求不一样。CLI 要把事件渲染到终端,IDE 插件要推给前端组件,SDK 调用要返回结构化数据。QueryEngine 的存在意义是统一适配这些差异,让 queryLoop 不用管上游是谁。

Claude Code 的两层结构:

  ┌─────────────────────────────────────────┐
  │              QueryEngine                │
  │   会话状态 · transcript 持久化 · usage   │
  │   错误恢复 · 多消费端事件适配            │
  ├──────────┬──────────┬────────┬──────────┤
  │   CLI    │ IDE 插件  │  SDK   │ MCP Server│
  │ (Ink+    │ (前端     │ (结构化  │ (工具     │
  │  React)  │  组件)    │  返回)  │  调用)    │
  ├──────────┴──────────┴────────┴──────────┤
  │              queryLoop()                │
  │    调用 LLM → 执行工具 → 拼接结果        │
  │    流式事件通过 AsyncGenerator 上抛      │
  └─────────────────────────────────────────┘

Cloud Agent V2 只有一个消费端:浏览器。没有 CLI、没有 IDE 插件、没有 SDK。多出来的那一层适配纯粹是负资产——多一层调用、多一层状态管理、多一层要维护的代码。

直接把 QueryEngine 拿掉,两层合并成一个 query() 函数:

V2 的简化结构:

  Hono Server  (POST /api/sessions/:id/chat)
    │
    ▼
  query()  AsyncGenerator<StreamEvent>
    │  调用 LLM → 执行工具 → 拼接结果
    │  每轮检查 abort 信号
    ▼
  SSE 事件流  ───────────────────►  浏览器
    text / tool_result / usage

Hono 收到请求后调用 query(),遍历它 yield 出来的事件,序列化成 SSE 发出去。中间没有适配层、没有事件格式转换,query 产什么 SSE 就推什么。

这个决策的底层逻辑后来贯穿了整个 V2 的设计:Claude Code 的复杂度对应它的多场景需求,V2 不需要为用不上的场景买单。 从工具删减到协议选型,本质上都是这句话的延伸。

上下文是怎么构建的

Agent 循环的入口是 query(),每次调用时它会组装一份完整的上下文发给 LLM。以下是 V2 每次 LLM 请求的上下文组装过程,以及和 Claude Code 的差异。

V2 上下文构建流程(每次 query() 调用时组装):

  系统提示词(15 个 section,启动时初始化,内存缓存)
  ┌─────────────────────────────────────────────┐
  │ 静态段(可缓存,每次都一样)                     │
  │  base → using_tools → actions → output_style  │
  │  → tone → session_guidance                   │
  │  每个段标记 cache_control: ephemeral          │
  ├─────────────────────────────────────────────┤
  │ __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__           │
  ├─────────────────────────────────────────────┤
  │ 动态段(不可缓存,随环境变化)                    │
  │  environment: 工作目录、项目 ID                │
  └─────────────────────────────────────────────┘
                        │
                        ▼
  消息列表(currentMessages[])
  ┌─────────────────────────────────────────────┐
  │ 技能注入(首次对话时 unshift)                   │
  │  system-reminder: "以下技能可用:..."          │
  ├─────────────────────────────────────────────┤
  │ 历史消息(JSONL 加载)                          │
  │  user / assistant / tool_result 交替          │
  ├─────────────────────────────────────────────┤
  │ 本次用户消息                                    │
  ├─────────────────────────────────────────────┤
  │ 工具结果预算(每轮开始前执行)                     │
  │  applyToolResultBudget: 超 100KB → 磁盘       │
  │  总预算: 单轮 20 万字符                         │
  ├─────────────────────────────────────────────┤
  │ 消息归一化                                     │
  │  normalizeMessagesForAPI: 内部格式 → API 格式  │
  │  最后一条消息附加 cache_control                 │
  └─────────────────────────────────────────────┘
                        │
                        ▼
  Tool 定义(tools[])
  ┌─────────────────────────────────────────────┐
  │ 内置 Tools: 11 个,直接加载                    │
  │ MCP Tools: 动态注入,mcp__{server}__{tool}    │
  │ Skill/MCP: deferred,由 ToolSearchTool 发现   │
  │ Schema 缓存: toolSchemaCache(LRU 100 条)     │
  └─────────────────────────────────────────────┘
                        │
                        ▼
                  发给 LLM API

对比 Claude Code,V2 的上下文构建骨架相同,但少了几层:

Claude Code 有而 V2 没有/未启用的:

  多级上下文压缩(cc 独有)
  ├── microcompact: 自动清理旧 tool_result
  ├── auto-compact:  输入 token 超阈值 → 裁剪历史消息
  └── reactive-compact: LLM 返回特殊信号 → 触发压缩

  V2 现状: contextManagement.ts 已从 cc 搬运(clear_tool_uses_20250919 策略),
  但未接入 query()。当前只靠工具结果截断 + maxTurns=20 硬限制。

  Thinking blocks(cc 独有)
  └── cc 有 extended thinking + clear_thinking_20251015 清理策略
      V2 直接移除 thinking,API 调用不带 thinking 参数。

  权限反馈(cc 独有)
  └── cc 的 Permission 系统把用户 allow/deny 决定回写上下文
      V2 简化为全 allow,不产生额外消息。

  技能发现(cc 更复杂)
  └── cc 支持 MCP skills + 远程技能(AKI/GCS) + 多级筛选
      V2 只从 skill_registry 表 + 文件系统查。

两者的架构模式一致——分段缓存、消息归一化、工具预算、deferred tools——但 cc 在上下文压缩上多了三级机制。V2 在裁剪、管理上下文机制上已经强过V1太多,够用所以没补,contextManagement.ts 留着是预留的,将来上下文压力大了直接启用。

Tool适配

方向定了,接下来面对的是 Tool 系统。

插一句背景。Tool calling 是这几年 LLM 应用最重要的演进之一。ChatGPT 2022 年底出来时只能纯文本对话,问它"今天天气怎么样"它只能告诉你它不知道。后来 OpenAI 在 2023 年中发布了 Function Calling,LLM 不再只输出文本,而是输出一个结构化的函数调用请求,由外部程序执行后再把结果传回给 LLM。从此 Agent 不再是一个 prompt 工程概念,变成了一个可实现的系统:LLM 负责决策(调用哪个函数、传什么参数),Tool 负责执行(真正去做事)。

Claude Code 就是在这个范式上搭起来的。虽然它名字里带"Code",但它的能力范围远超编程。它的 Tool 体系——文件读写、数据搜索、Shell 执行、Web 访问——本质上是一套通用的信息处理能力。一个财务分析师让它处理 Excel 对账单,一个法务让它对比合同条款,一个运维让它检查日志异常,它都能应对。我自己用它处理财务业务文件时就感受到这一点:它不像一个只懂代码的工具,更像一个会读文件、会分析、会动手的通用 Agent。这是它值得作为参考的第一个原因。

第二个原因和模型无关,和 Agent 设计有关。实际用下来我的感受是,Agent + LLM 的实际表现四六开:闭源模型(GPT、Claude)本身当然强,但一个设计良好的 Agent 同样关键。坏的 Agent 能把好模型的上下文搞乱、Token 烧光、输出失控。Claude Code 除了模型好,它的 Agent 设计——Tool调度、上下文管理、缓存策略、中断恢复——才是让它持续可靠工作的根基。这种设计带来的效果不是"一次性输出完美的结果",而是更务实的:不强制要求一步做对,能合理规划流程,先读文件、再分析、中间可能绕点弯路,但只要步数不是特别离谱,最终能拿到正确结果。这是我在用它写代码时反复观察到的模式,Tool的适配,就是此时的重点。

引入哪些,按什么顺序

Tool 迁移不是一次性全引入的,是分了五批,按依赖关系和复杂度推进。

第一批:6 个基础 Tools

FileRead、FileWrite、FileEdit、Glob、Grep、Bash,这6个Tools项目初始化时就直接搬了,这是 Agent 的底线能力:能读文件、能写文件、能搜索、能执行命令。没有这 6 个,Agent 什么都做不了。Bash 最特殊,下面单独讲。

第二批:2个 Web Tools
WebFetch

移除了 Claude Code 特有的域名黑名单和 Haiku 二次摘要,换了 turndown 做 HTML→Markdown 转换。

WebSearch

它实现方式值得单独说一下。市面上很多 Agent 的搜索能力是通过 MCP 接入的——启动一个 Brave Search 或 Tavily 的 MCP server,Agent 调用 MCP Tool 来完成搜索。本质上是"应用→MCP server→搜索 API"三段链路。

V2 没用这个方案,走的是 Anthropic API 原生的 web_search_20250305。流程是:Tool 把 web_search_20250305 作为 tool schema 传给 LLM API,API 自己完成搜索,返回结构化的结果(web_search_tool_result,含 title/url/文本摘要),Tool 只负责解析和格式化。搜索动作发生在 API 侧,不经过 V2 的服务器,也不经过任何 MCP 中间层。

选这个方案的理由很直接:一是少一个进程就少一个维护点,MCP server 也有自己的版本和配置要管;二是 API 原生的搜索质量受模型厂商持续优化,不需要我来操心搜索引擎的选型和升级。这个方案有一个前提:LLM API 需要支持 WebSearch 能力。Anthropic 原生的 web_search_20250305 是目前的事实标准,兼容 Anthropic API 的模型提供商(GLM、百炼等)一般都支持,当前用的模型跑起来没问题。代价是绑定了 API 厂商——如果换了一个不支持此特性的提供商,这个 Tool 就得回退到 MCP 方案。

第三批:2个需要前端配合的 Tools
AskUserQuestion(向用户提问确认)

一开始我没想过 Tool 还需要前端配合。Claude Code 的 Tool 全是服务端执行,结果通过终端渲染,不涉及"前端"这个概念。但 Web 场景下,终端就是浏览器。Tool 执行过程中如果卡住了需要用户选择——比如 AskUserQuestion 弹出一个多选表单——服务端没法自己完成,必须把问题推到浏览器、等用户操作完再拿结果回来。反过来想,这其实和 Claude Code 的 Permission 弹窗是一回事,只是弹窗从 TUI 换成了 Web UI。Tool 需要交互层,交互层在 Web 架构下天然属于前端。想通这一点后,第二批的依赖关系就清楚了。

AskUserQuestion 源码约 250 行(含 Ink React 组件),精简和适配后约 100 行。原版的交互方式是 Permission 系统触发 TUI 弹窗,Claude Code 的终端 UI 框架全在这一层。V2 砍掉了整个 Ink React 依赖,改成了 SSE user_question 事件 + 前端 UserQuestionDialog 弹窗。工具只负责触发事件和等待答案,渲染全交给前端。

SkillTool(技能调用)

SkillTool 源码 1109 行,精简和适配后 200 行。砍掉了三个重头:

  • forked sub-agent 执行模式(V2 只保留 inline 模式)

  • 远程技能加载(AKI/GCS)

  • MCP skills 支持。

技能发现也从 getAllCommands 简化为数据库加文件系统目录。SkillTool 需要前端的理由和 AskUserQuestion 不一样。AskUserQuestion 是运行时强依赖:Tool 执行到一半必须等前端弹窗返回结果。SkillTool 走 inline 模式,执行时不依赖前端。但它依赖 skill_registry 表里的配置数据——哪些技能启用了、scope 是什么、SKILL.md 放在哪——这些配置只能通过前端的 Skills 管理页面录入。没有前端管理后台,表就是空的,SkillTool 无技能可调。所以不是运行时依赖,是配置链路依赖:前端填数据→数据库→SkillTool 读取。

所以这一批的节奏是:前端先做 UserQuestionDialog 和 SkillsPage,同时后端扩展 SSE 协议加 user_question 事件类型,然后才接上两个 Tool 的实现。

第四批:4 个 MCP 骨架

MCPTool、ListMcpResourcesTool、ReadMcpResourceTool、McpAuthTool。这批只搬了骨架(类型定义和占位逻辑),实际的 MCP 连接管理到 4 月 16 号才接上。McpAuthTool 至今仍是占位,因为 stdio 不需要 OAuth。

先骨架后完整是刻意的开发策略。4 月 13 号这个时间点,Agent 的核心循环刚跑通,Skill 系统还在搭,需要尽快验证"Tool 能正常注册、Agent 能正常调用、SSE 能正常推送"这条链路。MCP 的完整实现涉及子进程管理、连接状态机、错误分类、重连策略,这些堆在一起调试的成本太高。先把骨架放进去占住位置,让架构能跑通,MCP 单独拉出来慢慢搭。后面 04 篇会详细讲 MCP 搭建过程中踩的坑。

第五批:ToolSearchTool

MCP 上线后的连带需求:MCP 和 Skill Tool 标记为 deferred(shouldDefer: true),不直接出现在 Tool 列表里,由 ToolSearchTool 托管。LLM 需要时先调用 ToolSearchTool 按关键词搜索,找到对应的 Tool 后再调用。这个设计减少了初始 prompt 的体积。

总结一下

最终内置 Tool 是 11 个,加上 4 个 MCP 相关 Tool:

Tool 用途
FileRead 读文件
FileWrite 写文件
FileEdit 编辑文件
Glob 文件名搜索
Grep 文件内容搜索
Bash 执行 Shell 命令
WebFetch 获取网页
WebSearch 网络搜索
AskUserQuestion 向用户提问
SkillTool 技能调用
ToolSearchTool 延迟 Tool 发现
MCPTool MCP 工具调用核心
ListMcpResourcesTool 列出 MCP 资源
ReadMcpResourceTool 读取 MCP 资源
McpAuthTool MCP OAuth 认证(占位)

后 4 个是 MCP 体系的骨架 Tool。除此之外,运行时还会动态注入 MCP Tool(以 mcp__{server}__{tool} 命名),数量取决于连接了多少 MCP server。

哪些没实现

Tool名称 原因
LSPTool、NotebookEditTool、NotebookReadTool 代码编辑器功能,业务场景不存在
TaskCreateTool、TodoWriteTool 任务管理,用不上
Worktree 系列 Git worktree 隔离,无需求
REPLTool、PowerShellTool Bash 已覆盖
AgentTool 子 Agent 系统,复杂度高,评估后暂时不需要
Chrome 浏览器工具 无浏览器自动化需求
SleepTool、ScheduleCronTool 无定时需求
TeamCreate/Delete 无团队协作需求
ConfigTool 配置通过 Web UI 管理,不需要 LLM 操作

接入不是照抄:BashTool 的极端简化

用得上的 Tool,也不是原样接入。最典型的例子是 BashTool。

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐