工具调用协议与执行模型
工具调用协议与执行模型
学习目标
读完本章后,你将能够:
- 理解 OpenAI Function Calling / Responses API 工具调用协议
- 设计工具系统的分层架构(规格层 → 路由层 → 执行层 → 编排层)
- 掌握并行与串行工具执行的选择策略
- 实现工具调用的审批、沙箱和重试机制
前置知识
- JSON Schema 基础
- 异步任务调度
核心概念
1. 工具调用协议
现代大语言模型(LLM)支持工具调用(Tool Calling),允许模型在生成回复时请求调用外部函数。典型的交互流程:
OpenAI Responses API 工具格式:
每个工具由 type、name、description 和 parameters(JSON Schema)定义:
{ "type": "function", "name": "read_file", "description": "读取文件内容", "parameters": { "type": "object", "properties": { "path": { "type": "string", "description": "文件路径" } }, "required": ["path"] }}模型返回的调用请求:
{ "type": "function_call", "call_id": "call_123", "name": "read_file", "arguments": "{\"path\":\"/tmp/test.txt\"}"}关键设计:call_id 用于将工具调用请求与执行结果一一对应,支持并行调用时不会混淆。
2. 工具系统四层架构
生产级 Agent 的工具系统通常分为四层:
分层的好处:
- 新增工具只需实现 Handler,无需修改路由或编排逻辑
- 编排策略可以统一调整(如改变审批阈值),不影响具体工具
- 规格层与执行层解耦,便于测试和 mock
3. 并行与串行执行
当模型在一次响应中请求调用多个工具时,Agent 需要决定是并行还是串行执行:
并行执行的条件:
- 工具之间没有资源依赖(不读写同一文件、不操作同一数据库记录)
- 工具之间没有逻辑依赖(后一个工具的参数不依赖前一个工具的输出)
- 所有工具都在同一个安全沙箱内
串行执行的条件:
- 工具之间存在逻辑依赖(如先搜索再读取)
- 工具之间存在资源竞争(如同时编辑同一文件)
- 需要按顺序获取用户审批
实现模式(读写锁模型):
- 并行工具获取读锁(
RwLock::read()),多个读锁可共存 - 串行工具获取写锁(
RwLock::write()),独占访问 - 这种模型天然支持”读并行、写串行”的语义
设计模式详解
工具审批模型
工具执行前的审批是安全的关键环节。常见的审批策略:
| 策略 | 说明 | 适用场景 |
|---|---|---|
| 自动允许 | 低风险工具(如 read_file)直接执行 | 只读操作、已知安全路径 |
| 自动拒绝 | 高风险工具(如 rm -rf /)直接拒绝 | 明确危险的命令 |
| 提示用户 | 中风险工具等待用户确认 | 写操作、网络请求、命令执行 |
动态审批:当用户批准一次操作后,可以将其持久化为规则(如”允许该目录下的所有 git 命令”),减少后续重复提示。
工具超时与取消
工具执行可能因外部依赖(如网络请求、长时间编译)而挂起:
- 超时机制:为每个工具调用设置最大执行时间,超时后强制终止
- 取消传播:使用
CancellationToken将取消信号从上层传递到子进程 - 优雅终止:先发送 SIGTERM,等待宽限期后再发送 SIGKILL
工具结果格式
工具执行结果需要标准化,以便模型理解:
{ "type": "function_call_output", "call_id": "call_123", "output": "文件内容...", "status": "success"}对于错误情况:
{ "type": "function_call_output", "call_id": "call_123", "output": "错误:文件不存在 /tmp/test.txt", "status": "error"}设计原则:错误信息应该对人类和模型都有用,包含足够的上下文(如文件路径、错误类型)。
问题与规避
工具名称冲突
问题:多个 MCP 服务器可能注册同名工具,导致路由歧义。
对策:
- 命名空间前缀:
server_name::tool_name - 哈希去重:当名称过长时截断并附加哈希后缀
- 白名单控制:per-server 启用/禁用特定工具
工具结果过大
问题:工具输出(如日志文件、大 JSON)可能超出上下文窗口限制。
对策:
- 结果截断:保留头部和尾部,中间用省略号替代
- 结果摘要:对长文本自动提取关键信息
- 分页读取:大文件分块读取,每次只加载必要部分
工具调用的无限循环
问题:模型反复调用同一工具(如 read_file 同一个文件),陷入循环。
对策:
- 重复调用检测:记录最近 N 次工具调用,检测到重复模式时提示模型
- 最大工具调用次数:单次 Turn 内限制工具调用总数
- 人工介入:当检测到异常模式时暂停并请求用户确认
悬空工具调用恢复
问题:当 Provider 或用户中断导致工具调用未完成时,后续模型调用可能因 tool_call_id 序列不匹配而失败(malformed history errors)。这种情况在 OpenAI 兼容的推理模型上尤为严重,因为它们严格验证工具调用序列的完整性。
对策:
- 检测 AIMessage 中存在
tool_calls但没有对应 ToolMessage 的悬空调用 - 剥离 provider 级原始工具调用元数据(
additional_kwargs.tool_calls),防止下一轮模型调用看到双重调用请求 - 注入占位符 ToolMessage(如”调用被中断”),确保 tool_call_id 序列完整
- 此策略在中间件链中作为
DanglingToolCallMiddleware运行,位于 LLM 错误处理之后
工具调用循环检测与降级
问题:Agent 可能陷入重复调用同一工具的循环(如反复搜索同一关键词、反复读取同一文件),消耗 Token 且无进展。
对策:
- 记录最近 N 轮的工具调用模式(工具名 + 参数哈希)
- 当检测到重复模式时,触发硬停止
- 清除 structured
tool_calls和原始 provider 元数据 - 强制模型输出文本答案,结束循环
- 此策略在中间件链中作为
LoopDetectionMiddleware运行
设计取舍:循环检测的阈值需要平衡——太敏感会打断合理的重试(如网络超时后的重连),太宽松则无法及时终止真正的循环。典型值是连续 3 次相同工具调用 + 相同参数视为循环。
设计取舍
内置工具 vs 外部 MCP 工具
| 维度 | 内置工具 | MCP 工具 |
|---|---|---|
| 集成深度 | 与 Agent 深度耦合,可访问内部状态 | 通过标准协议通信,松耦合 |
| 可扩展性 | 需修改 Agent 代码 | 动态加载,无需重启 |
| 安全性 | 信任度高(同进程) | 需要额外验证(跨进程/网络) |
| 生态 | 有限 | 丰富的 MCP 生态 |
推荐策略:核心工具(文件操作、命令执行)内置以保证性能和安全;领域特定工具(数据库查询、API 调用)通过 MCP 扩展。
即时执行 vs 审批后执行
| 方案 | 优势 | 代价 |
|---|---|---|
| 即时执行 | 流畅、无中断感 | 安全风险高、意外操作难挽回 |
| 审批后执行 | 安全可控、可审计 | 打断流、用户体验差 |
| 混合策略 | 平衡安全与流畅 | 策略配置复杂 |
现代 Agent 普遍采用混合策略:低风险操作即时执行,高风险操作等待审批。
动态工具模型 — Discriminated Union 模式
当工具集合是动态的(运行时注册/注销),需要用动态生成的 discriminated union 模型来让 LLM 输出结构化的工具调用:
# Python Pydantic v2 示例from typing import Annotated, Unionfrom pydantic import Field, BaseModel
# 每个工具对应一个 Action 模型ClickAction = Annotated[ClickParams, Field("click")]TypeAction = Annotated[TypeParams, Field("type")]NavigateAction = Annotated[NavigateParams, Field("navigate")]
# 动态生成联合模型AllActions = Annotated[ Union[ClickAction, TypeAction, NavigateAction, ...], Field(discriminator=True)]
class AgentOutput(BaseModel): thinking: str action: list[AllActions]关键设计:
- 使用 Pydantic v2 的
discriminator字段实现快速反序列化路由,无需尝试每个子模型 - 当工具集合变化时(如进入新页面后新增/移除可用工具),重新生成联合模型
- LLM 输出必须是合法的 discriminated union 格式,否则 Pydantic 校验失败
陷阱:当工具数量过多(>50)时,JSON Schema 会变得巨大,增加 Token 消耗。此时可按上下文过滤,只发送当前页面可用的工具。
上下文感知的工具可用性
在浏览器自动化等场景中,工具的可用性取决于当前上下文(页面 URL、DOM 状态):
- URL 级过滤:根据当前页面 URL 动态启用/禁用工具(如仅在电商页面显示”添加到购物车”动作)
- 能力感知:对支持视觉的模型(Claude Sonnet、Gemini Pro)启用坐标点击,对纯文本模型仅使用元素索引点击
- 状态感知:当页面无表单时隐藏”type”工具,当无下拉框时隐藏”select_option”工具
实现策略:在每次 turn 开始前,根据当前上下文重新计算可用工具子集,仅将子集的 schema 发送给 LLM。
多模态工具适配
当 Agent 同时使用文本和视觉输入时,工具的行为应该适配模型能力:
- 视觉模型:返回元素的像素坐标
(x, y),支持截图上的精确点击 - 纯文本模型:返回元素索引
[1]、[2]等,通过 DOM 树定位 - 自适应策略:同一套工具接口,根据模型能力自动选择最优的交互方式
设计权衡:坐标点击在复杂页面上更可靠(不受 DOM 变化影响),但依赖模型的视觉理解能力;索引点击更通用但不处理动态 DOM 变化时容易失效。
动作级超时保护
为每个工具调用设置独立超时,而非依赖全局超时:
- 默认超时:180s 动作级超时(覆盖 LLM 提取、慢速网络请求等场景)
- 环境变量覆盖:
BROWSER_USE_ACTION_TIMEOUT_S可按需调整 - 超时后行为:返回
ActionResult(error="timeout")而非挂起,让 Agent 在下一轮处理失败
为什么需要独立超时:事件总线中 await event 和 event_result() 调用没有内置超时,如果 watchdog 处理器在死掉的 WebSocket 上阻塞,动作会无限挂起。
补充:LangChain 的工具依赖注入模式
LangChain 通过 InjectedState、InjectedStore 和 ToolRuntime 实现工具调用时的依赖注入:
# 伪代码:工具函数接收 Agent 状态和存储def search_knowledge(query: str, state: InjectedState, store: InjectedStore) -> str: # 从 Agent 状态中获取上下文 context = state.get("user_context") # 从持久化存储中检索数据 results = store.search(namespace="docs", query=query) return format_results(results)InjectedState:将当前 Agent 状态(消息、变量、结构化响应)注入工具函数,使工具能够访问对话上下文。
InjectedStore:将跨会话的持久化存储注入工具,支持知识检索、用户记忆等场景。
ToolRuntime:提供运行时上下文,包括 stream_writer(自定义流事件)、context(运行时配置)。
设计优势:工具不需要手动接收状态参数,框架自动注入,简化了工具签名。同时工具与 Agent 状态解耦,同一工具可以在不同 Agent 间复用。
Component-as-Tool 模式
在可视化 AI 构建平台中,任意组件都可以被标记为 Agent 工具,无需编写额外的工具注册代码。
核心机制:
- 组件开发者在 Input 上标记
tool_mode=True - 框架通过
ComponentToolkit将组件包装为 LangChainStructuredTool - 工具的
name、description、parameters自动从组件的display_name、description、inputs推导
执行隔离策略:
当 Agent 并发调用同一组件工具时,必须防止状态污染:
def build_tool_function(component, output_method): def output_function(*args, **kwargs): # 深拷贝组件实例,防止并发调用时的状态竞争 comp = deepcopy(component) local_method = getattr(comp, output_method.__name__) return local_method(*args, **kwargs) return output_function消息隔离:工具执行期间,组件的 send_message 被替换为 no-op,防止中间状态泄露到 UI。执行完成后恢复原始方法。
参数 Schema 推导:
Input(name="query", tool_mode=True, field_type="str") → 工具参数: { "query": { "type": "string" } }
Input(name="top_k", tool_mode=True, field_type="int", default=5) → 工具参数: { "top_k": { "type": "integer", "default": 5 } }设计建议:仅将面向 Agent 的输入标记为 tool_mode=True,内部配置参数(如连接字符串、超时设置)不应暴露为工具参数。
参考来源
补充:Cline 的 XML/原生双轨与 Coordinator 路由
来源:Cline(cline/cline)apps/vscode/src/shared/tools.ts、apps/vscode/src/core/task/ToolExecutor.ts,commit 791d238
XML vs 原生工具调用双轨
Cline 同时支持两种工具传递协议:
| 协议 | 适用场景 | 实现方式 |
|---|---|---|
| XML 标签 | 通用(Anthropic、Ollama 等大多数提供商) | 工具调用以 <tool_name> 标签形式嵌入系统提示词 |
| 原生 tool calling | OpenAI Responses API 等支持原生工具调用的提供商 | 通过 useNativeToolCalls 标志切换,使用 JSON Schema 定义工具 |
为什么需要双轨? 不同模型提供商的工具调用协议不兼容。XML 标签是一种通用方案——将工具调用指令作为自然语言的一部分嵌入提示词,对模型没有格式要求。但对 OpenAI Responses API 等原生支持工具调用的提供商,使用原生协议可以获得更好的结构化输出和 call_id 追踪能力。
切换机制:在创建 API Handler 时,根据 ModelInfo.apiFormat 决定是否启用 useNativeToolCalls。系统提示词构建器和流式解析器根据该标志选择不同的工具定义和解析路径。
ToolExecutorCoordinator 路由模式
Cline 的工具执行采用 Coordinator 路由模式:
# 伪代码:Coordinator 路由class ToolExecutorCoordinator { private handlers: Map<string, ToolHandler>
register(name: string, handler: ToolHandler) { this.handlers.set(name, handler) }
async execute(toolName: string, params: any): Promise<ToolResult> { const handler = this.handlers.get(toolName) if (!handler) throw new UnknownToolError(toolName) return handler.execute(params) }}路由流程:
- 流式解析器从 LLM 响应中提取工具调用(XML 标签或 JSON tool_call)
ToolExecutor.execute()按工具名称查找已注册的 handler- Handler 在
src/core/task/tools/handlers/目录下按文件独立实现 - 执行前经过自动审批检查(
autoApprove.ts)
设计优势:
- 新增工具只需添加 handler 文件并注册,无需修改路由逻辑
- Plan 模式通过
PLAN_MODE_RESTRICTED_TOOLS列表限制可用工具集 - 每个 handler 可独立配置超时、重试和审批策略
审批分级与工具执行的联动
Cline 的三级自动审批直接嵌入工具执行流程:
| 级别 | 行为 | 适用场景 |
|---|---|---|
| Yolo Mode | 所有工具自动执行,无需确认 | 本地开发、信任度高 |
| Auto-approve All | 除浏览器/MCP 外的工具自动执行 | 日常开发,保留高风险工具确认 |
| 细粒度控制 | 每个工具独立配置是否自动批准 | 生产环境、敏感项目 |
关键设计:审批检查发生在 Coordinator 路由到具体 handler 之前。这意味着即使 handler 已经注册,审批不通过也不会执行。这种前置检查避免了工具执行的副作用。
命令权限控制器:CommandPermissionController 对 execute_command 工具进行额外验证:
- 通配符模式匹配(如
["git", "*"]允许所有 git 子命令) - 子 shell 递归验证(防止
$(rm -rf /)等注入) - 重定向操作符检测(防止
echo "malicious" > ~/.ssh/authorized_keys) - 链式命令分段验证(
&&、;、|分隔后逐段检查)
潜在陷阱:XML 协议下,如果 LLM 生成的标签格式不正确(如缺少闭合标签),解析器可能漏掉工具调用。Cline 的 parseAssistantMessageV2 使用健壮的解析策略,但仍然需要在提示词中明确工具调用的格式要求。
补充:LlamaIndex 的 FunctionTool 与 Pydantic 自动推断
来源:LlamaIndex llama-index-core v0.14.22,commit f027669
FunctionTool.from_defaults() 签名推断
LlamaIndex 的 FunctionTool.from_defaults() 通过 Pydantic 自动从函数签名推断参数 schema:
def search(query: str, top_k: int = 5) -> str: """搜索知识库并返回结果。""" return do_search(query, top_k)
tool = FunctionTool.from_defaults(fn=search)# tool.metadata 自动包含:# name: "search"# description: "搜索知识库并返回结果。"# fn_schema: Pydantic 模型 (query: str, top_k: int = 5)函数名作为工具名、docstring 作为工具描述、参数类型签名转换为 JSON Schema——一行代码完成工具注册。
requires_context:Context 自动注入
LlamaIndex 的工具支持一种特殊的参数类型识别:当函数签名中包含 Context 类型的参数时,Agent 在调用工具时会自动注入当前工作流上下文:
from llama_index.core.workflow import Context
def search_with_state(ctx: Context, query: str) -> str: # ctx 由 Agent 自动注入,无需 LLM 提供 current_state = ctx.get("state") return do_search(query, state=current_state)设计优势:将”工具需要的运行时信息”与”LLM 提供的参数”分离,避免 LLM 被要求输出它无法提供的内部状态。
BaseToolSpec 工具集合模式
一个类可以定义多个相关工具方法,通过 spec_functions 列表声明哪些方法应作为工具暴露,然后 to_tool_list() 批量转换为 FunctionTool 列表:
class SearchSpec(BaseToolSpec): spec_functions = ["search_by_keyword", "search_by_date", "search_by_author"]
def search_by_keyword(self, keyword: str) -> str: ... def search_by_date(self, date: str) -> str: ... def search_by_author(self, author: str) -> str: ...
tools = SearchSpec().to_tool_list() # 返回 3 个 FunctionToolBaseToolAsyncAdapter 同步适配
当 Agent 需要异步调用同步工具时,BaseToolAsyncAdapter 通过 asyncio.to_thread() 将同步工具包装为异步工具,无需阻塞事件循环。
补充:Warp 的客户端 Action/Result 类型对模式
来源:Warp crates/ai/src/agent/action/mod.rs,commit 94d29fe
在终端集成的 AI Agent 场景中,工具调用的请求和结果需要与终端 UI(block、网格、渲染)深度耦合。Warp 采用客户端 Action/Result 类型对模式:
// 伪代码:每种工具调用对应一个请求类型和结果类型pub enum AIAgentActionType { RequestCommandOutput { command: String, is_read_only: Option<bool>, ... }, ReadFiles(ReadFilesRequest), RequestFileEdits { file_edits: Vec<FileEdit>, title: Option<String> }, CallMCPTool { server_id: Option<Uuid>, name: String, input: serde_json::Value }, // 30+ 种工具调用类型...}
// 每种调用类型有独立的结果类型pub enum AIAgentActionResultType { RequestCommandOutput(RequestCommandOutputResult), ReadFiles(ReadFilesResult), RequestFileEdits(RequestFileEditsResult), CallMCPTool(CallMCPToolResult), // ...}关键设计:
cancelled_result()方法:每种工具调用都有统一的取消处理,返回对应结果类型的取消变体。这确保了无论在哪种状态下取消,都能产生结构化的结果。- 用户友好名称:
user_friendly_name()将内部操作转换为人类可读的描述(如"Run command: git status"),用于 UI 展示。 - 显示与调试格式:
Displaytrait 实现提供详细的调试输出,包含操作的所有关键参数。 EnumDiscriminants派生:自动生成判别枚举,便于模式匹配和序列化。
与通用工具调用协议的差异:通用工具调用(如 OpenAI Function Calling)关注的是模型 ↔ 执行器之间的协议。Warp 的模式关注的是客户端内部如何表示、路由、执行和展示工具调用结果。这两种协议在不同层级运行,需要桥接。
陷阱:AIAgentActionType 中的 Display 实现和 user_friendly_name 需要同步更新——当添加新的工具调用类型时,忘记更新其中之一会导致 UI 显示不完整。Warp 使用 exhaustive matching 避免通配符 __ 来强制处理所有变体。