ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Cloud Agent 开发笔记(2):Agent 引擎与 Tool 体系——从 Agent Loop 到 Claude Code 的工程化拆解

Cloud Agent 开发笔记(2):Agent 引擎与 Tool 体系——从 Agent Loop 到 Claude Code 的工程化拆解 1. 从 Agent Loop 说起Cloud Agent 引擎到底在循环什么Cloud Agent 这个词最近被用得有点泛但落到工程上它其实就回答一个问题一个跑在云端的进程怎么把「大模型决策」和「工具执行」这两件事串成一个能自己往下走的闭环。这个闭环就是 Agent Loop。你可以把它理解成一个不知疲倦的调度员把当前上下文喂给模型模型说「我要读这个文件」调度员就去读把结果塞回上下文再问模型「下一步呢」如此往复直到模型说「我做完了」。我试过用最朴素的方式手写这个循环几十行代码就能跑起来但一旦要接入真实业务——多轮会话、工具结果超长、用户中途打断、SSE 推流——裸循环立刻撑不住。所以这一篇不聊概念聊工程化Agent 引擎怎么分层、Tool 体系怎么注册和编排、状态怎么管最后给出一份可复制的配置和目录结构让你能本地把 Agent Loop 闭环跑通。适合谁看已经用过大模型 API、想自己搭一个 Cloud Agent 骨架的后端或全栈同学正在纠结「要不要照抄 Claude Code 那套两层结构」的架构决策者以及被工具调用报错折磨过、想搞清楚 Tool 注册与调用编排细节的人。核心检索词先摆在这Cloud Agent 引擎设计、Agent Loop 实现、Tool 体系注册与调用编排、Claude Code 工程化拆解。下面所有内容都围绕这几个词展开不跑题。先说结论省得你看到一半才发现方向不对Claude Code 的 Agent 引擎是两层结构QueryEngine 管会话生命周期queryLoop 管单轮执行但这个两层是为它同时服务 CLI、IDE 插件、SDK、MCP Server 四个消费端而存在的。如果你的 Cloud Agent 只有一个消费端——比如浏览器——那多出来的适配层就是负资产。合并成一层query()函数反而更清爽。这个判断贯穿全文也是我踩过坑之后最想先告诉你的一句话。2. TaoToken 前置把模型调用这层先铺平在动手写 Agent Loop 之前有个绕不开的前置问题模型从哪来。Agent 引擎再漂亮模型调用这层不稳整个循环就是空中楼阁。我自己的做法是把模型接入统一走 TaoToken官网在 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 端点是 https://taotoken.net/api 它兼容 Anthropic 风格的接口这对后面要讲的 Tool calling 和 WebSearch 原生能力很关键。为什么强调「兼容 Anthropic 风格」因为 Claude Code 的 Tool 体系里WebSearch 走的是 API 原生的web_search_20250305工具 schema 直接传给模型 API由 API 侧完成搜索再返回结构化结果。如果你的模型提供商不支持这个特性这个 Tool 就得回退到 MCP 方案多一个进程多一个维护点。所以选接入层的时候先确认它支持哪些原生工具能力比事后补救省事得多。拿 Key 的流程不复杂但有几个细节值得说。进控制台 https://taotoken.net/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 创建 API Key然后在 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 管理你的密钥。这里有个坑Key 一旦创建就只显示一次务必当场复制到环境变量里别指望回头再找。我见过太多人把 Key 硬编码进代码然后提交到仓库这是安全事故的高发区。环境变量建议这样组织把 Base URL、Key、Model ID 三件套分开管理# .env.local —— 不要提交到 git TAOTOKEN_BASE_URLhttps://taotoken.net/api TAOTOKEN_API_KEYsk-你的密钥 TAOTOKEN_MODEL_IDclaude-sonnet-4-20250514三件套里 Model ID 最容易被忽略。Agent 引擎在组装 Tool 定义时需要知道当前模型支持哪些工具特性比如是否支持并行工具调用、是否支持原生 WebSearch。这些能力差异直接决定 Tool 注册表里哪些工具该启用。所以 Model ID 不是一个随便填的字符串它是引擎做能力协商的依据。如果你打算长期跑编码类或 Agent 类任务可以了解下 Coding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 它在高频调用场景下比按量计费更可控。接入文档在 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 遇到接口细节问题先翻文档比在群里问快。想先验证模型通不通直接用模型对话页面 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 发一条消息能返回就说明 Key 和网络都没问题再往下写代码。这里要提醒一句接入层只是把「模型调用」这件事标准化它不负责你的 Agent 逻辑。别指望接上就自动有了 Agent Loop循环、工具注册、状态管理这些还是得自己写。前置工作做到这一步就够了剩下的精力留给引擎本身。3. 可复制配置Agent 引擎与 Tool 体系的目录结构这一节给可直接复制的配置片段和目录结构。技术栈沿用 Claude Code 的路子TypeScript Bun Zod 做类型与校验Hono 做 HTTP 层SQLite 做持久化。这套组合的好处是启动快、类型安全、依赖少适合 Cloud Agent 这种需要快速迭代的场景。先看 Agent 引擎的核心配置。我把它放在src/agent/config.ts用 Zod 定义 schema启动时校验避免运行时才发现配置缺字段// src/agent/config.ts import { z } from zod; export const AgentConfigSchema z.object({ model: z.object({ baseUrl: z.string().url(), apiKey: z.string().min(1), modelId: z.string().min(1), maxTokens: z.number().int().positive().default(8192), temperature: z.number().min(0).max(1).default(0.7), }), loop: z.object({ maxTurns: z.number().int().positive().default(20), toolResultBudgetChars: z.number().int().positive().default(200_000), singleToolResultLimitChars: z.number().int().positive().default(100_000), abortOnError: z.boolean().default(false), }), tools: z.object({ enabled: z.array(z.string()).default([]), deferred: z.array(z.string()).default([]), schemaCacheSize: z.number().int().positive().default(100), }), }); export type AgentConfig z.infertypeof AgentConfigSchema; export function loadAgentConfig(): AgentConfig { return AgentConfigSchema.parse({ model: { baseUrl: process.env.TAOTOKEN_BASE_URL!, apiKey: process.env.TAOTOKEN_API_KEY!, modelId: process.env.TAOTOKEN_MODEL_ID!, }, loop: {}, tools: { enabled: [ FileRead, FileWrite, FileEdit, Glob, Grep, Bash, WebFetch, WebSearch, AskUserQuestion, SkillTool, ToolSearchTool, ], deferred: [MCPTool, ListMcpResourcesTool, ReadMcpResourceTool], }, }); }注意loop.maxTurns默认 20这是硬限制防止 Agent 陷入死循环烧光 token。toolResultBudgetChars是单轮工具结果总预算超了就截断或落盘。这两个参数是 Agent Loop 的刹车别省。再看 Tool 体系的目录结构。我按「注册、执行、安全」三层来组织每个 Tool 一个目录互不干扰src/ ├── agent/ │ ├── config.ts # 引擎配置上面那段 │ ├── query.ts # Agent Loop 主函数AsyncGenerator │ ├── context.ts # 上下文组装系统提示词 消息列表 工具定义 │ └── types.ts # StreamEvent 等类型定义 ├── tools/ │ ├── registry.ts # Tool 注册表注册、查找、启用/禁用 │ ├── base.ts # Tool 接口定义name/inputSchema/call/... │ ├── FileRead/ │ │ ├── index.ts │ │ └── schema.ts │ ├── FileWrite/ │ ├── FileEdit/ │ ├── Glob/ │ ├── Grep/ │ ├── Bash/ │ │ ├── index.ts │ │ └── blockedCommands.ts # 禁止命令列表 │ ├── WebFetch/ │ ├── WebSearch/ │ ├── AskUserQuestion/ │ ├── SkillTool/ │ └── ToolSearchTool/ ├── security/ │ └── pathGuard.ts # 路径边界 白名单 Bash 命令检查 ├── server/ │ └── chat.ts # Hono 路由SSE 推流 └── db/ └── schema.sql # SQLite 表结构Tool 接口只保核心字段砍掉所有 UI 渲染入口。Claude Code 的 Tool 类型有 30 多个字段近一半是 React 渲染函数Web 场景下这些全不需要// src/tools/base.ts import { z } from zod; export interface ToolDefinitionTInput unknown { name: string; description: string; inputSchema: z.ZodTypeTInput; prompt?: string; isEnabled: () boolean; isReadOnly: boolean; isConcurrencySafe: boolean; maxResultSizeChars: number; shouldDefer?: boolean; call: (input: TInput, ctx: ToolContext) PromiseToolResult; } export interface ToolContext { sessionId: string; projectDir: string; abortSignal: AbortSignal; emit: (event: StreamEvent) void; } export interface ToolResult { content: string; isError?: boolean; metadata?: Recordstring, unknown; }shouldDefer这个字段是给 ToolSearchTool 用的。标记为 deferred 的工具不直接出现在初始工具列表里模型需要时先调 ToolSearchTool 按关键词搜索找到再调。这样能显著减小初始 prompt 体积MCP 工具多的时候尤其明显。注册表本身很简单一个 Map 加几个方法// src/tools/registry.ts import type { ToolDefinition } from ./base; const registry new Mapstring, ToolDefinition(); export function registerTool(tool: ToolDefinition): void { if (registry.has(tool.name)) { throw new Error(Tool already registered: ${tool.name}); } registry.set(tool.name, tool); } export function getTool(name: string): ToolDefinition | undefined { return registry.get(name); } export function listActiveTools(config: AgentConfig): ToolDefinition[] { return [...registry.values()].filter( (t) t.isEnabled() !t.shouldDefer config.tools.enabled.includes(t.name) ); } export function listDeferredTools(): ToolDefinition[] { return [...registry.values()].filter((t) t.shouldDefer); }这套结构的关键在于Tool 层不依赖任何 UI 框架将来换前端方案不受影响。Claude Code 把渲染逻辑塞进 Tool 里是因为它的消费端是终端渲染和工具执行耦合在一起。Web 场景下前端有自己的状态管理Tool 只管执行和返回结果渲染交给前端组件。4. 验证请求本地跑通 Agent Loop 闭环配置和目录都齐了现在验证 Agent Loop 能不能闭环。核心是query()函数它是一个 AsyncGenerator每 yield 一个事件Hono 就序列化成 SSE 推给浏览器。先看主循环// src/agent/query.ts import type { AgentConfig } from ./config; import { listActiveTools, getTool } from ../tools/registry; import { buildContext } from ./context; export async function* query( sessionId: string, userMessage: string, config: AgentConfig, abortSignal: AbortSignal ): AsyncGeneratorStreamEvent { const messages await loadMessages(sessionId); messages.push({ role: user, content: userMessage }); let turn 0; while (turn config.loop.maxTurns) { if (abortSignal.aborted) { yield { type: aborted }; return; } turn; const context buildContext(messages, config); const response await callLLM(context, config, abortSignal); yield { type: text, content: response.text }; if (response.toolCalls.length 0) { yield { type: done, turns: turn }; return; } for (const call of response.toolCalls) { const tool getTool(call.name); if (!tool) { yield { type: tool_error, name: call.name, error: Tool not found }; continue; } yield { type: tool_start, name: call.name, input: call.input }; const result await tool.call(call.input, { sessionId, projectDir: config.projectDir, abortSignal, emit: () {}, }); yield { type: tool_result, name: call.name, result: result.content }; messages.push({ role: tool, toolCallId: call.id, content: result.content }); } } yield { type: max_turns_reached, turns: turn }; }这个循环的逻辑很直白调模型 → 有工具调用就执行 → 结果塞回消息列表 → 再调模型。maxTurns是刹车abortSignal是中断开关。每轮开始前检查 abort用户点了停止就立刻退出。Hono 侧把 AsyncGenerator 转成 SSE// src/server/chat.ts import { Hono } from hono; import { streamSSE } from hono/streaming; import { query } from ../agent/query; import { loadAgentConfig } from ../agent/config; const app new Hono(); const config loadAgentConfig(); app.post(/api/sessions/:id/chat, async (c) { const sessionId c.req.param(id); const { message } await c.req.json(); const abortController new AbortController(); return streamSSE(c, async (stream) { stream.onAbort(() abortController.abort()); for await (const event of query(sessionId, message, config, abortController.signal)) { await stream.writeSSE({ event: event.type, data: JSON.stringify(event) }); } }); }); export default app;启动服务发一条测试请求bun run src/server/index.ts # 另开终端 curl -N -X POST http://localhost:3000/api/sessions/test-1/chat \ -H Content-Type: application/json \ -d {message:读一下 package.json 告诉我项目名}预期看到的事件流先是text事件模型说要读文件然后tool_startFileRead 开始接着tool_result文件内容再一个text模型总结项目名最后done。如果这条链路走通了Agent Loop 闭环就成立了。验证成功的关键标志有三个一是tool_start和tool_result成对出现说明工具注册和调用编排没问题二是done事件带turns字段说明循环正常退出三是整个过程中 SSE 连接不断说明流式推送稳定。我实测下来最容易出问题的是工具结果太大导致 SSE 卡顿这时候singleToolResultLimitChars就该起作用了超限的结果落盘只把摘要塞回上下文。5. 本篇常见错排查401、local proxy failed、reading choices、OAuth跑 Agent Loop 的过程中报错基本集中在几个地方。这一节按真实报错对照排查每个都给定位思路。401 Unauthorized。最常见八成是 Key 没配对。先确认.env.local里的TAOTOKEN_API_KEY有没有被正确加载——Bun 默认读.env如果你写的是.env.local得显式指定。再确认 Base URL 结尾有没有多余的斜杠https://taotoken.net/api和https://taotoken.net/api/在某些客户端里行为不一致。最后确认 Key 有没有过期或被删。三件套Base URL Key Model ID里任何一个错都可能表现为 401 或 403。local proxy failed。这个报错通常出现在你本地配了代理但代理没起来或者环境变量HTTP_PROXY/HTTPS_PROXY指向了一个不存在的端口。排查方法先unset HTTP_PROXY HTTPS_PROXY再跑一次如果好了就是代理配置问题。注意这里说的是本地开发环境的网络配置不是让你去搞什么特殊网络手段纯粹是环境变量清理。reading choices of undefined。这是典型的响应结构解析错误。OpenAI 风格的响应有choices数组Anthropic 风格的是content数组。如果你用 Anthropic 兼容接口却按 OpenAI 的结构去解析就会读到 undefined。检查你的callLLM函数确认解析路径和实际返回结构一致。TaoToken 走的是 Anthropic 风格所以应该读response.content不是response.choices。OAuth 相关报错。如果你在接 MCP 工具可能会遇到 OAuth 认证失败。stdio 类型的 MCP server 不需要 OAuth只有 HTTP/SSE 类型的才需要。McpAuthTool 在骨架阶段是占位实现如果你现在就调它会报未实现。正确做法是先把 MCP 连接管理搭起来OAuth 单独处理。这里涉及的三件套配置要写全{ mcpServers: { example: { baseUrl: https://taotoken.net/api, apiKey: sk-你的密钥, modelId: claude-sonnet-4-20250514, transport: stdio, command: bun, args: [run, mcp-server.ts] } } }Base URL、Key、Model ID 三件套一个都不能少transport 决定要不要 OAuth。stdio 走本地进程不需要认证HTTP 走远程才需要 OAuth 流程。工具调用死循环。模型反复调同一个工具maxTurns到了才停。这通常是工具返回的结果让模型误以为任务没完成。检查工具返回的content是不是清晰表达了「已完成」必要时在结果里加明确的完成标记。另一个原因是工具描述写得太模糊模型不知道该用哪个就反复试。SSE 连接中断。长任务跑到一半断了多半是中间有反向代理的超时设置。本地开发一般不会遇到部署到云上要注意。解决思路是加心跳事件定期推一个空注释保持连接活跃。排错的时候建议把query()里每个 yield 的事件都打日志这样能清楚看到循环走到哪一步卡住。日志级别用 debug生产环境关掉。遇到 401 或 local proxy failed 这类接入层问题先去接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 对照接口规范再去 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 确认密钥状态基本能定位。6. 继续往下走从骨架到可用 Agent骨架跑通只是起点。接下来要补的东西不少上下文压缩现在只有单级截断上下文压力大了要上多级策略、MCP 连接管理骨架有了实际连接还没接、权限反馈现在全 allow真实场景需要 allow/deny 回写上下文、以及集群部署时的沙箱方案。但别急着一次全上。我的经验是先把 Agent Loop 闭环跑稳再逐个加工具每加一个就验证一次。工具迁移分五批推进是有道理的基础文件工具先上Web 工具跟上需要前端配合的AskUserQuestion、SkillTool等前端就绪MCP 骨架占位最后 ToolSearchTool 收尾。这个顺序不是拍脑袋是按依赖关系排的。如果你现在就想动手建议从最小闭环开始只注册 FileRead、FileWrite、Bash 三个工具把query()跑通确认 SSE 能推事件再逐步加。想验证模型能力用模型对话页面 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_contentmodel-chatutm_campaignrewrite 快速试想长期跑编码 AgentCoding Plan https://taotoken.net/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding-planutm_campaignrewrite 更划算接入细节卡住了接入文档 https://taotoken.net/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 和 API Keys 页面 https://taotoken.net/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi-keysutm_campaignrewrite 是两个最常回看的地方。最后留一个我踩过的坑pathGuard 的路径检查一定要在工具执行前做别等工具跑完再校验。我一开始把检查放在结果返回后结果 LLM 已经读到了不该读的文件虽然最后拦截了输出但数据已经进了上下文。安全边界要在入口处不在出口处。这个教训值一篇单独的文章下一篇讲 MCP 搭建时再展开。
返回列表