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。
Claude Code 的 BashTool 实现在安全上下了血本:tree-sitter WASM 做 AST 解析区分复合命令和参数、22 个独立验证器各检查一种危险模式、沙箱隔离限制文件系统和网络访问、sed 命令解析器处理编辑场景、React 组件渲染工具执行状态。总共 16 个文件。
V2 把这一套几乎全砍了。业务场景下 LLM 执行的 Shell 命令通常是对已有数据的分析操作:统计文件行数、查找特定记录、格式转换。不是随意系统命令。没有沙箱、没有 AST 解析、没有 sed 验证。安全策略简化为两层:BashTool 自身做精确匹配拦截危险命令(rm -rf、mkfs、shutdown、reboot 等 10 条),更细粒度的路径边界、命令阻断、复合命令拆分检查由下面的 pathGuard 负责。V2只有3个文件。
其他 Tool 也有类似程度的简化,只不过没有BashTool改动那么大。
Tool 的安全边界:pathGuard
砍掉 Claude Code 复杂的权限系统后,需要有东西补上安全缺口。pathGuard 就是干这个的,这是我原创设计的模块,没有参考 Claude Code,因为 Claude Code 的安全路径在 Permission 系统和沙箱层面,和 V2 的场景完全不搭。
设计思路很直接:以项目目录为边界,以路径检查代替命令分析。 Claude Code 的做法是分析命令本身是否危险(AST 解析、验证器),pathGuard 的做法是分析命令的操作目标是否在允许范围内。前者问"你在做什么",后者问"你在动哪里"。对 V2 的场景来说,后者更有效------LLM 执行分析命令本身不可怕,可怕的是它访问不该访问的路径。
分三层拦截:
第一层,路径边界。 所有文件读写操作先过 isPathWithinProject,检查目标路径是否在项目目录内。相对路径先解析为绝对路径再比较,~ 展开到 home 目录,.. 穿越被拒绝。uploaded 文件标记为只读,不能覆盖用户上传的原始数据。
第二层,技能白名单。 Skill 执行时 LLM 需要读技能目录下的脚本和模板文件,这些文件不在项目目录内。pathGuard 维护了一个白名单:data/skills/(全局技能)和 data/userSkill/{userId}/(用户自己的技能)。Read 操作命中白名单直接放行------但只放行 LLM 的读取,推给用户的 SSE 事件在 chat.ts 里会被拦截替换(下一篇会细讲)。
第三层,Bash 命令检查。 LLM 通过 Bash 执行命令时,不是直接放行。先过禁止命令列表(chmod、sudo、ssh、pip install 等 29 条),再过路径参数提取------把复合命令按 ;、&&、|| 分割,提取每段子命令的路径参数,逐个检查是否越界。输出重定向(>、>>)的目标路径同样检查。cd + 写操作的组合(cd /tmp && rm data.csv)直接拒绝,防止绕过目录检查。
三层之间是递进关系:路径边界是第一道门,白名单是墙上开的窗(只对特定目录放行读操作),Bash 检查是对最灵活也最危险的执行通道做额外加固。
pathGuard 替代了 Claude Code 的 tree-sitter + 22 个验证器 + 沙箱的一整套安全体系。不能说它比 Claude Code 的方案更安全------明显更粗糙,没有 AST 级别的命令理解,路径参数提取也用了简化匹配而非真正的 shell 解析。但它匹配了 V2 的威胁模型:用户上传文件有独立目录,LLM 的操作范围被限制在项目内,敏感路径通过白名单显式授权,够用了。
集群部署时的沙箱方案(未实现)
当前单实例没上沙箱,pathGuard 的路径边界加命令阻断把风险控制在可接受范围。但集群部署时安全模型要重新评估:多租户共用实例,一个用户的 LLM 执行了恶意命令可能影响其他用户。pathGuard 的路径检查挡不住同实例内的横向操作。
沙箱方案有三个候选方向:容器隔离、进程级沙箱、应用层限制加用户级隔离。
上了沙箱之后,pathGuard 的部分机制可以简化。比如禁止命令列表可以缩减------沙箱本身限制了文件系统访问,rm -rf 在沙箱里只能删除挂载目录内的文件,比 pathGuard 的字符串匹配更可靠。路径边界检查也可以从"拒绝越界"退到"警告越界"------沙箱挡住了,pathGuard 多一道审计日志就行。这是后续的优化方向,当前不急着动。
Tool 接口的精简
Claude Code 的 Tool 类型有 30 多个字段,将近一半是 React 渲染入口:renderToolUseMessage、renderToolResultMessage、renderToolUseProgressMessage 等。V2 是 Web 应用,前端有自己的 Zustand 状态管理和 React 组件树,不需要 Tool 层插手 UI。把这些全砍了,Tool 接口只保核心字段:name、inputSchema、call、description、prompt、isEnabled、isReadOnly、isConcurrencySafe、maxResultSizeChars。Tool 层不依赖任何 UI 框架,将来换前端方案不受影响。
卡住了回头看:持续学习和理解 Claude Code 源码
前面讲的都是结构性问题:架构怎么改、Tool 怎么裁、安全怎么补。这些问题开工前就能识别,不需要看源码也知道要做。
但跑起来之后,才暴露了一些实现层的问题。这些问题靠自己琢磨也能解决,但回头看 Claude Code 源码通常能省掉大量试错。
prompt caching 是最典型的例子。一开始系统提示词是一整段文本,每次请求原样发送。跑了几天后发现 token 消耗过高------同样的工具使用说明每次都原样传。翻 Claude Code 源码,发现它的系统提示词不是一段字符串,是 string[] 数组:每个元素独立标记 cache_control: { type: 'ephemeral' },静态段(工具使用说明、格式要求)可缓存,动态段(环境信息如工作目录、项目 ID)放在 breakpoint 后面。照着这个思路把系统提示词拆成 15 个 section,用一个边界标记 __SYSTEM_PROMPT_DYNAMIC_BOUNDARY__ 分开静态和动态。静态段的 token 消耗接近零。
工具结果预算也是类似。Claude Code 有多级压缩策略(microcompact、auto-compact、reactive-compact),评估后认为业务场景不需要这么重,只做了单级截断。
没做的
SubAgent 系统、流式工具执行(Claude Code 的 StreamingToolExecutor)、多级上下文压缩、交互式恢复。都评估过,都决定不做。理由一样:业务场景用不上。
Claude Code 是给程序员用的通用工具,V2 是给特定业务场景用的专用工具。Claude Code 有的功能不代表 V2 需要。判断什么不该做和判断什么该做一样重要。看起来是放弃了一些能力,实际上是拒绝了不必要的复杂度。
Agent 引擎和 Tool 体系讲完了。下一篇聊 Agent 的事件怎么推到浏览器、数据怎么持久化、多会话和中断怎么处理------就是上面架构图里 query() 下面那条 SSE 箭头牵引出的一整套 Web 交互机制。