Cloudflare AI Agent 客户端工具与自动续聊(Client-Side Tools and Auto-Continuation)实战指南
【免费下载链接】agentsBuild and deploy AI Agents on Cloudflare项目地址: https://gitcode.com/GitHub_Trending/agents1/agents
导读
在AIChatAgent(@cloudflare/ai-chat)中,工具分为"服务端工具"与"客户端工具"两类:服务端工具由 Worker 上的execute函数自动执行;而客户端工具没有execute,工具调用会通过onToolCall发送到浏览器等客户端执行。本文围绕docs/agents/client-tools-continuation.md讲解如何借助autoContinueAfterToolResult让客户端工具获得与服务端工具一致的无缝体验——LLM 调用工具 → 客户端执行 → 服务端自动续聊,最终将续聊内容合并进同一条 assistant 消息。读完本文,你将掌握服务端/客户端两端的完整配置、关闭自动续聊的显式续聊模式,以及将客户端工具与needsApproval审批流程组合使用的完整方案,并结合仓库源码理解其底层协议与自动续聊屏障(barrier)的实现原理。
工具的两大分类:服务端工具与客户端工具
在AIChatAgent中,工具根据是否拥有服务端execute函数被分为两类:
- 服务端工具(Server tools):在服务端定义了
execute函数。AI SDK 会自动执行该函数,并在同一轮(turn)内让 LLM 继续回复。例如查询天气、调用内部 API 等不需要用户参与的工具。 - 客户端工具(Client tools):在服务端没有
execute函数。模型发出工具调用后,服务端暂停该轮回复,通过tool-input-available事件将调用发送到客户端,由客户端的onToolCall回调执行并返回结果。默认情况下(无自动续聊)这需要一次新的请求才能让对话继续。
从源码实现来看,客户端工具的形态定义在 packages/agents/src/chat/client-tools.ts:
export type ClientToolSchema = { /** 工具的唯一名称 */ name: string; /** 人类可读的工具描述 */ description?: Tool["description"]; /** 使用 JSON Schema 定义输入参数 */ parameters?: JSONSchema7; };这里值得注意的一个实现细节是:客户端工具的参数使用parameters(JSON Schema7)而非 AI SDK 的inputSchema(Zod),因为Zod schema 无法在网络上序列化,而客户端工具 schema 需要从浏览器通过 WebSocket 传输到 Worker。服务端最终通过createToolsFromClientSchemas将这些 schema 转换为 AI SDK 的tool()定义(默认不带execute),模型调用它们时工具调用会被送回客户端。
启用autoContinueAfterToolResult(默认开启)后,客户端工具可以表现得像服务端工具:LLM 调用工具 → 客户端执行 → 服务端收到工具结果后自动续聊,整个过程仍然在同一轮对话中完成,用户看到的是无缝的单一回复。
服务端配置:定义无execute的客户端工具
在服务端,只需定义一个不带execute函数的工具。AI SDK 检测到没有执行器时,会暂停当前流,并向客户端发送tool-input-available,等待客户端回传结果:
import { AIChatAgent } from "@cloudflare/ai-chat"; import { createWorkersAI } from "workers-ai-provider"; import { streamText, tool, convertToModelMessages, stepCountIs } from "ai"; import { z } from "zod"; export class MyAgent extends AIChatAgent { async onChatMessage() { const workersai = createWorkersAI({ binding: this.env.AI }); const result = streamText({ model: workersai("@cf/moonshotai/kimi-k2.7-code"), messages: await convertToModelMessages(this.messages), tools: { // 客户端工具:没有 execute 函数 getUserLocation: tool({ description: "Get the user's location from their browser", inputSchema: z.object({}) }), // 服务端工具:有 execute,自动执行 getWeather: tool({ description: "Get weather for a city", inputSchema: z.object({ city: z.string() }), execute: async ({ city }) => fetchWeather(city) }) }, // 允许多步执行,这样 LLM 拿到工具结果后还能继续回复 stopWhen: stepCountIs(5) }); return result.toUIMessageStreamResponse(); } }配置要点:
getUserLocation只有description与inputSchema,没有execute,因此被识别为客户端工具。本例它的输入是空对象z.object({})(位置由浏览器直接获取);若需要参数,客户端 schema 会通过 JSON Schema 传输。getWeather拥有execute,属于服务端工具,由 AI SDK 自动执行、自动续步,不需要客户端参与。stopWhen: stepCountIs(5)很关键:自动续聊本质上是多步执行,必须为streamText设置步数上限,否则模型在工具结果之后可能无法继续生成最终回复。
客户端配置:onToolCall执行 + 默认自动续聊
客户端使用@cloudflare/ai-chat/react的useAgentChathook。通过onToolCall处理客户端工具的执行;autoContinueAfterToolResult默认即为true,所以通常无需显式设置——服务端收到工具结果后会自动再次调用onChatMessage(),让 LLM 在同一条 assistant 消息中继续回复:
import { useAgent } from "agents/react"; import { useAgentChat } from "@cloudflare/ai-chat/react"; function Chat() { const agent = useAgent({ agent: "MyAgent" }); const { messages, sendMessage } = useAgentChat({ agent, // 自动续聊默认开启 —— 无需显式设置 // autoContinueAfterToolResult: true, onToolCall: async ({ toolCall, addToolOutput }) => { if (toolCall.toolName === "getUserLocation") { const pos = await new Promise((resolve, reject) => { navigator.geolocation.getCurrentPosition(resolve, reject); }); addToolOutput({ toolCallId: toolCall.toolCallId, output: { lat: pos.coords.latitude, lng: pos.coords.longitude } }); } } }); // Render messages... }onToolCall回调中拿到的toolCall携带toolName与toolCallId,执行完成后调用addToolOutput将结果回传给服务端。从仓库的 React 实现看(packages/agents/src/chat/react.tsx):
autoContinueAfterToolResult?: boolean— 收到客户端工具结果或审批后是否自动续聊,默认true,续聊内容合并进同一条 assistant 消息;为false时客户端必须调用sendMessage()才能继续,此时会创建新的 assistant 消息。
对应的测试也验证了这一默认行为(packages/ai-chat/src/react-tests/use-agent-chat.test.tsx):测试构造一条state: "input-available"的工具调用消息,在未显式传入autoContinueAfterToolResult的情况下调用addToolOutput,随后断言发出的cf_agent_tool_result消息携带autoContinue: true。
底层协议:CF_AGENT_TOOL_RESULT与自动续聊标志
客户端回传结果本质上是向服务端发送一条CF_AGENT_TOOL_RESULT消息。协议定义位于 packages/agents/src/chat/wire-types.ts:
{ /** 客户端向服务端发送工具结果(用于客户端工具) */ type: MessageType.CF_AGENT_TOOL_RESULT; /** 本次工具调用对应的 toolCallId */ toolCallId: string; /** 工具名称 */ toolName: string; /** 工具执行输出 */ output: unknown; /** 覆盖工具 part 的状态(例如自定义拒绝时用 "output-error") */ state?: "output-available" | "output-error"; /** 当 state 为 "output-error" 时的错误信息 */ errorText?: string; /** 服务端应用结果后是否自动续聊 */ autoContinue?: boolean; /** 用于续聊的客户端工具 schema(客户端是权威来源) */ clientTools?: Array<{ name: string; description?: string; parameters?: JSONSchema7; }>; }从协议字段可以读出几个关键设计:
autoContinue是"是否续聊"的开关,客户端在发送工具结果时根据autoContinueAfterToolResult设置该值(见 react.tsx)。clientTools字段让客户端在续聊时重新上报工具 schema,因为客户端是工具定义的权威来源——服务端续聊的下一轮onChatMessage()需要知道当前有哪些客户端工具可用。state: "output-error"与errorText允许客户端表达"执行失败"或"用户拒绝",而不是只能用通用错误。
自动续聊的完整工作流程
文档给出了一个完整的端到端流程示例,用户问题为 "What's the weather near me?":
1. 客户端发送消息 → 服务端调用 LLM 2. LLM 决定调用 getUserLocation(无服务端 execute) 3. 流式输出向客户端发送 tool-input-available 4. onToolCall 触发 → 客户端获取地理位置 → 发送 CF_AGENT_TOOL_RESULT 5. 服务端收到结果,其中 autoContinue: true 6. 服务端等待原始流完整结束 7. 服务端再次调用 onChatMessage()(续聊) 8. LLM 看到位置结果,调用 getWeather(服务端 execute) 9. LLM 回复:"It's sunny and 72°F near you!" 10. 续聊产生的 parts 合并进同一条 assistant 消息尽管中间发生了客户端工具调用,用户最终看到的仍然是一条无缝的完整回复。
源码层面的自动续聊屏障
这个"等待原始流结束 → 触发续聊"的协调逻辑,在仓库中由AutoContinuationController(packages/agents/src/chat/auto-continuation-controller.ts)统一实现。它是@cloudflare/ai-chat与@cloudflare/think共享的"事件驱动屏障",核心机制包括:
- 合并去抖(coalesce/debounce):
COALESCE_MS = 50ms的定时器。模型可能并行发出多个客户端工具调用,多个工具结果先后到达时,去抖会把这些"相邻结果"合并为一次服务端续聊检查(对应仓库 issue #1650),避免对每个结果各续聊一次。 - 双触发防护(
_barrierActive):确保同一时刻只有一个"应用排空(apply-drain)"在运行,防止重复触发续聊。 - 稳定性门槛(
fireWhenStable):真正的续聊只有在以下条件全部满足时才会触发:- 模型并行工具批次已全部收到结果(无未答复的兄弟工具调用,对应 #1649);
- 当前没有正在流式输出的 assistant 轮次(
isStreamActive为false)——否则无法确认并行批次是否完整,提前触发会把未到达的结果错误地标记为失败; - 没有正在进行的工具结果/审批应用操作(
hasPendingInteraction)。
- 无孤儿超时:如果某个兄弟工具结果永远不回来,屏障不会无限期占用 isolate,而是保持
pending状态,等待后续用户轮次或聊天恢复机制修复转录。
客户端续聊的启动逻辑
在客户端,startToolContinuation(react.tsx)会在autoContinueAfterToolResult为true且当前没有其他续聊进行时,通过resumeStream()恢复服务端流,将续聊的增量 parts 合并进现有 assistant 消息;若续聊期间用户stop,stopWithToolContinuationAbort会同时取消服务端轮次并中止续聊流(react.tsx)。
关闭自动续聊:显式控制的续聊模式
将autoContinueAfterToolResult设置为false时,客户端必须在提供工具结果后显式发送一条后续消息,对话才会继续:
const { messages, sendMessage, addToolOutput } = useAgentChat({ agent, onToolCall: async ({ toolCall, addToolOutput: provide }) => { if (toolCall.toolName === "getUserLocation") { const pos = await getPosition(); provide({ toolCallId: toolCall.toolCallId, output: { lat: pos.coords.latitude, lng: pos.coords.longitude } }); } } autoContinueAfterToolResult: false, // 关闭自动续聊 }); // 提供工具结果后,发送后续消息以继续对话 // 这会创建一条新的 assistant 消息,而不是续写现有那条注意此时sendMessage()创建的是新的 assistant 消息,而非合并进原消息。适合使用显式续聊模式的场景:
- 希望精确控制对话何时继续,例如工具结果需要经过用户审阅、编辑或确认后再进入下一步;
- 工具结果是中间数据,用户可能想先看到结果再决定是否让 LLM 继续推理。
文档还提示:与autoContinueAfterToolResult相关的autoSendAfterAllConfirmationsResolved选项已被弃用,建议改用 AI SDK 的sendAutomaticallyWhen。同时autoContinueAfterToolResult: false模式下还有一个细节——如果并行批次中某个工具结果没有携带autoContinue,但它的兄弟结果选择继续,服务端仍会通过rearmForBatch(auto-continuation-controller.ts)重新运行屏障检查,确保批次完整后再触发续聊。
组合使用needsApproval:审批 + 客户端执行
客户端工具可以与needsApproval审批流程组合。例如一个需要用户授权 + 浏览器执行才能完成的"分享位置"工具:
// 服务端:需要审批但没有 execute 的工具 const shareLocation = tool({ description: "Share the user's location with a third party", inputSchema: z.object({ service: z.string() }), needsApproval: true // 没有 execute —— 审批通过后由客户端执行 });// 客户端:处理审批,然后执行 const { addToolApprovalResponse } = useAgentChat({ agent, autoContinueAfterToolResult: true, onToolCall: async ({ toolCall, addToolOutput }) => { if (toolCall.toolName === "shareLocation") { const pos = await getPosition(); addToolOutput({ toolCallId: toolCall.toolCallId, output: { lat: pos.coords.latitude, lng: pos.coords.longitude } }); } } });此时的完整流程变为:LLM 调用工具 → 用户审批 → 客户端执行 → 服务端自动续聊。协议层面,审批由CF_AGENT_TOOL_APPROVAL消息承载,同样支持autoContinue字段(见 wire-types.ts),因此审批通过后也能无缝衔接自动续聊。
如果用户拒绝了工具,可以不用泛化的错误,而是通过addToolOutput传入state: "output-error"提供自定义的拒绝理由:
// 带理由地拒绝,而不是泛化的错误 addToolOutput({ toolCallId: toolCall.toolCallId, state: "output-error", errorText: "User declined to share location" });这个errorText会作为工具调用的错误输出进入模型上下文,让 LLM 理解"用户拒绝了"以及原因,从而给出得体的后续回复(例如礼貌地不再追问、或询问其他可选方案)。
实践建议与注意事项
stepCountIs步数上限:启用自动续聊后,一次对话可能包含"客户端工具 → 续聊 → 服务端工具 → 再续聊"等多步执行,务必为streamText设置合理的stopWhen步数上限,防止模型陷入无限工具循环。- 客户端 schema 上报:客户端工具定义以客户端为准,续聊时客户端会通过
CF_AGENT_TOOL_RESULT的clientTools字段重新上报 schema(client-tools.ts 中的createToolsFromClientSchemas负责将其转换为 AI SDK 工具)。若服务端onChatMessage依赖固定工具集,需要确保两种来源一致;重复的工具名会触发告警且后者覆盖前者。 - 并行工具调用:模型可以并行发出多个客户端工具调用,自动续聊屏障会合并去抖并等待整个批次完整后才续聊,开发者无需自己处理并发结果。
- 执行失败处理:客户端执行失败时优先使用
state: "output-error"+errorText回传结构化错误,而不是抛异常中断整个流。
关联文档
- Chat Agents —
AIChatAgent与useAgentChat的完整参考 - Human in the Loop — 包含
needsApproval的审批模式详解
【免费下载链接】agentsBuild and deploy AI Agents on Cloudflare项目地址: https://gitcode.com/GitHub_Trending/agents1/agents
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考