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

发布时间:2026/6/29 21:04:36
Cloud Agent 开发笔记(2):Agent 引擎与 Tool 体系 gent Loop和首要原则真正动工前先要把基础方向定下来。上一篇提到技术栈向 Claude Code 靠拢TypeScript Bun ZodHono 做 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() AsyncGeneratorStreamEvent │ 调用 LLM → 执行工具 → 拼接结果 │ 每轮检查 abort 信号 ▼ SSE 事件流 ───────────────────► 浏览器 text / tool_result / usageHono 收到请求后调用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 缓存: toolSchemaCacheLRU 100 条 │ └─────────────────────────────────────────────┘ │ ▼ 发给 LLM API对比 Claude CodeV2 的上下文构建骨架相同但少了几层Claude Code 有而 V2 没有/未启用的 多级上下文压缩cc 独有 ├── microcompact: 自动清理旧 tool_result ├── auto-compact: 输入 token 超阈值 → 裁剪历史消息 └── reactive-compact: LLM 返回特殊信号 → 触发压缩 V2 现状: contextManagement.ts 已从 cc 搬运clear_tool_uses_20250919 策略 但未接入 query()。当前只靠工具结果截断 maxTurns20 硬限制。 Thinking blockscc 独有 └── cc 有 extended thinking clear_thinking_20251015 清理策略 V2 直接移除 thinkingAPI 调用不带 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 CallingLLM 不再只输出文本而是输出一个结构化的函数调用请求由外部程序执行后再把结果传回给 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 个基础 ToolsFileRead、FileWrite、FileEdit、Glob、Grep、Bash这6个Tools项目初始化时就直接搬了这是 Agent 的底线能力能读文件、能写文件、能搜索、能执行命令。没有这 6 个Agent 什么都做不了。Bash 最特殊下面单独讲。第二批2个 Web ToolsWebFetch移除了 Claude Code 特有的域名黑名单和 Haiku 二次摘要换了 turndown 做 HTML→Markdown 转换。WebSearch它实现方式值得单独说一下。市面上很多 Agent 的搜索能力是通过 MCP 接入的——启动一个 Brave Search 或 Tavily 的 MCP serverAgent 调用 MCP Tool 来完成搜索。本质上是应用→MCP server→搜索 API三段链路。V2 没用这个方案走的是 Anthropic API 原生的web_search_20250305。流程是Tool 把web_search_20250305作为 tool schema 传给 LLM APIAPI 自己完成搜索返回结构化的结果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个需要前端配合的 ToolsAskUserQuestion向用户提问确认一开始我没想过 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 依赖改成了 SSEuser_question事件 前端 UserQuestionDialog 弹窗。工具只负责触发事件和等待答案渲染全交给前端。SkillTool技能调用SkillTool 源码 1109 行精简和适配后 200 行。砍掉了三个重头forked sub-agent 执行模式V2 只保留 inline 模式远程技能加载AKI/GCSMCP 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 搭建过程中踩的坑。第五批ToolSearchToolMCP 上线后的连带需求MCP 和 Skill Tool 标记为 deferredshouldDefer: true不直接出现在 Tool 列表里由 ToolSearchTool 托管。LLM 需要时先调用 ToolSearchTool 按关键词搜索找到对应的 Tool 后再调用。这个设计减少了初始 prompt 的体积。总结一下最终内置 Tool 是 11 个加上 4 个 MCP 相关 ToolTool用途FileRead读文件FileWrite写文件FileEdit编辑文件Glob文件名搜索Grep文件内容搜索Bash执行 Shell 命令WebFetch获取网页WebSearch网络搜索AskUserQuestion向用户提问SkillTool技能调用ToolSearchTool延迟 Tool 发现MCPToolMCP 工具调用核心ListMcpResourcesTool列出 MCP 资源ReadMcpResourceTool读取 MCP 资源McpAuthToolMCP OAuth 认证占位后 4 个是 MCP 体系的骨架 Tool。除此之外运行时还会动态注入 MCP Tool以mcp__{server}__{tool}命名数量取决于连接了多少 MCP server。哪些没实现Tool名称原因LSPTool、NotebookEditTool、NotebookReadTool代码编辑器功能业务场景不存在TaskCreateTool、TodoWriteTool任务管理用不上Worktree 系列Git worktree 隔离无需求REPLTool、PowerShellToolBash 已覆盖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-com