定义 · 调用 · 安全 · 扩展 — 从 6 个 Agent 项目源码提炼的工具系统设计方法论
Agent 的「工具」本质是将一段代码暴露给 LLM 调用。但定义方式差异巨大——从简单的字典描述到完整的类型安全验证,这决定了工具系统的可靠性天花板。
Smolagents 的 Tool 基类要求开发者声明 name、description、inputs、output_type 四个类属性,并在 __init_subclass__ 时自动触发验证:
class Tool(BaseTool):
name: str
description: str
inputs: dict[str, dict[str, str | type | bool]]
output_type: str
output_schema: dict[str, Any] | None = None
def __init_subclass__(cls, **kwargs):
super().__init_subclass__(**kwargs)
validate_after_init(cls) # 自动验证
def validate_arguments(self):
# 1. 验证必填属性存在且类型正确
# 2. name 必须是合法 Python 标识符
# 3. inputs 中每个字段必须有 type + description
# 4. type 必须在 AUTHORIZED_TYPES 白名单内
# 5. forward() 签名必须与 inputs 一致
# 6. nullable 在 inputs 和签名中必须双向一致
validate_arguments() 在类定义时就做 6 层验证——类型白名单、签名一致性、nullable 双向检查。错误在定义时而非调用时暴露。
Smolagents 不允许任意类型,只接受预定义白名单:
AUTHORIZED_TYPES = [
"string", "boolean", "integer", "number",
"image", "audio", "array", "object", "any", "null"
]
这确保生成的 Schema 对 LLM 友好——不会出现 Python 特有的 Path、UUID 等类型导致模型困惑。
PydanticAI 走了完全不同的路:不需要声明式类,直接把普通 Python 函数变成工具,用 Pydantic 的内部 API 自动生成验证器:
def function_schema(
function: Callable[..., Any],
schema_generator: type[GenerateJsonSchema],
*,
takes_ctx: bool | None = None,
docstring_format: DocstringFormat = 'auto',
require_parameter_descriptions: bool = False,
) -> FunctionSchema:
核心流程:
FunctionSchema 数据结构包含运行时所需的一切:
@dataclass(kw_only=True)
class FunctionSchema:
function: Callable[..., Any]
name: str
description: str | None
validator: SchemaValidator # Pydantic 核心验证器
json_schema: ObjectJsonSchema # 给 LLM 的 JSON Schema
takes_ctx: bool # 是否接受 RunContext
is_async: bool
single_arg_name: str | None # 单参数优化
return_schema: ObjectJsonSchema # 返回值 Schema
当函数只有一个 model-like 参数时,PydanticAI 做了一个巧妙的优化——JSON Schema 直接暴露参数的字段,而不是包一层 {arg_name: {...}}:
# 普通多参数: {"query": ..., "limit": ...}
# 单参数 unwrap: 直接暴露 data 的字段,而非 {"data": {...}}
def _build_schema(fields, var_kwargs_schema, core_config):
if len(fields) == 1 and var_kwargs_schema is None:
name = next(iter(fields))
td_field = fields[name]
if td_field['metadata']['is_model_like']:
# unwrap: JSON Schema 去掉外层包装
# 但 validator 用 wrap 保留 {name: value} 格式
return (
core_schema.no_info_wrap_validator_function(
partial(_validate_single_arg, name=name),
td_field['schema']
),
name, # single_arg_name
)
# 多参数: 标准 TypedDict
return core_schema.typed_dict_schema(fields, ...), None
{name: value} 统一格式——对开发者透明,对模型友好。
| 维度 | Smolagents | PydanticAI |
|---|---|---|
| 定义方式 | 声明式类 (name/inputs/output_type) | 普通函数 + 自动推断 |
| 验证时机 | 类定义时 (__init_subclass__) | 注册时 (function_schema()) |
| Schema 生成 | 手动声明 → _convert_type_hints_to_json_schema | Pydantic 内部 API → GenerateJsonSchema |
| 类型约束 | 白名单 (AUTHORIZED_TYPES) | Pydantic 全类型支持 |
| 上下文注入 | 无 | RunContext[T] 自动识别 |
| 返回值 Schema | 可选 output_schema | 自动从返回类型注解生成 |
| 序列化/反序列化 | to_dict() / from_dict() | 依赖 Pydantic 序列化 |
将 Python 函数签名转为 LLM 可用的 JSON Schema 是工具系统的核心能力。6 个项目中有 3 种实现路径:
直接调用 Pydantic 的内部 API _generate_schema.GenerateSchema,绕过公开的高层 API:
# 步骤 1: 用 Pydantic 内部 API 构建核心 schema
config_wrapper = ConfigWrapper(ConfigDict(title=function.__name__))
gen_schema = _generate_schema.GenerateSchema(config_wrapper)
# 步骤 2: 遍历函数参数,为每个参数生成 TypedDictField
for name, p in sig.parameters.items():
field_info = FieldInfo.from_annotation(annotation) # 或 from_annotated_attribute
fields[name] = gen_schema._generate_td_field_schema(
field_name, field_info, decorators, required=required
)
# 步骤 3: 构建整体验证 schema
schema, single_arg_name = _build_schema(fields, var_kwargs_schema, core_config)
# 步骤 4: 创建验证器
schema_validator = create_schema_validator(
schema, function, function.__module__,
function.__qualname__, 'validate_call', core_config,
config_wrapper.plugin_settings
)
# 步骤 5: 生成 JSON Schema
json_schema = schema_generator().generate(schema)
Literal、Union、嵌套 model);代价是依赖内部实现,Pydantic 大版本升级时可能 breaking。
Smolagents 用 _function_type_hints_utils.py 做自己的类型提示解析:
from ._function_type_hints_utils import (
_convert_type_hints_to_json_schema,
_get_json_schema_type,
get_imports,
get_json_schema,
)
同时提供 @tool 装饰器,从普通函数快速创建工具:
@tool
def search_web(query: str, max_results: int = 5) -> str:
"""Search the web for information.
Args:
query: The search query string
max_results: Maximum number of results to return
"""
# ... implementation
关键区别:Smolagents 的 MethodChecker 做 AST 级别的静态验证——检查方法体中是否引用了未定义名称、是否有本地 import:
class MethodChecker(ast.NodeVisitor):
"""Checks that a method:
- only uses defined names
- contains no local imports
"""
def visit_Name(self, node):
if isinstance(node.ctx, ast.Load):
if not (
node.id in _BUILTIN_NAMES
or node.id in self.arg_names
or node.id in self.imports
or node.id in self.assigned_names
# ... 更多来源检查
):
self.errors.append(f"Name '{node.id}' is undefined.")
validate_tool_attributes() 会在序列化时检查工具类是否可重建——禁止 from_space、from_langchain、from_gradio 创建的工具调用 to_dict(),因为它们的代码不是自包含的。
SWE-agent 走了完全不同的路——工具不写 Python 代码,而是用 YAML 声明 + Shell 脚本实现:
# Bundle: config.yaml
tools:
search_file:
docstring: |
Search for a pattern in files.
Usage: search_file <pattern> [dir]
signature: "search_file <pattern> [<dir>]"
end_name: "EOF"
usage: |
Search for a regex pattern in a directory.
If no directory is provided, searches in the current directory.
edit:
docstring: |
Edit a file with a search/replace block.
signature: "edit <file>"
end_name: "end_of_edit"
state_command: "cat /root/state.json"
Bundle 在容器内安装和执行:
class Bundle(BaseModel):
path: Path
hidden_tools: list[str] = Field(default_factory=list)
_config: BundleConfig = PrivateAttr(default=None)
@property
def commands(self) -> list[Command]:
return [
Command(name=tool, **tool_config)
for tool, tool_config in self.config.tools.items()
if tool not in self.hidden_tools # 支持隐藏工具
]
OpenClaw 的工具安全系统是所有项目中最精细的——8 层 Policy Pipeline 依次过滤可用工具,每一层都可以独立配置 allow/deny 规则:
Pipeline 的核心实现:
export function applyToolPolicyPipeline(params: {
tools: AnyAgentTool[];
toolMeta: (tool: AnyAgentTool) => { pluginId: string } | undefined;
warn: (message: string) => void;
steps: ToolPolicyPipelineStep[];
}): AnyAgentTool[] {
// 1. 识别核心工具 vs 插件工具
const coreToolNames = new Set(
params.tools
.filter((tool) => !params.toolMeta(tool))
.map((tool) => normalizeToolName(tool.name))
);
// 2. 构建插件工具分组
const pluginGroups = buildPluginToolGroups({ tools, toolMeta });
// 3. 逐层过滤
let filtered = params.tools;
for (const step of params.steps) {
if (!step.policy) continue;
// 3a. 如果启用 stripPluginOnlyAllowlist,分离插件/核心条目
let policy = step.policy;
if (step.stripPluginOnlyAllowlist) {
const resolved = analyzeAllowlistByToolType(policy, pluginGroups, coreToolNames);
// 对 unknown allowlist 条目发出警告
if (resolved.unknownAllowlist.length > 0) { /* warn */ }
policy = resolved.policy;
}
// 3b. 展开插件组 (group:plugins → 实际工具名)
const expanded = expandPolicyWithPluginGroups(policy, pluginGroups);
filtered = expanded ? filterToolsByPolicy(filtered, expanded) : filtered;
}
return filtered;
}
除了 Pipeline,OpenClaw 还有独立的 Owner-Only 机制——某些工具只有 owner 发送者才能调用:
const OWNER_ONLY_TOOL_APPROVAL_CLASS_FALLBACKS = new Map([
["cron", "control_plane"], // cron 管理 → 控制面
["gateway", "control_plane"], // 网关管理 → 控制面
["nodes", "exec_capable"], // 节点管理 → 执行面
]);
function wrapOwnerOnlyToolExecution(tool, authorized) {
if (tool.ownerOnly !== true || authorized || !tool.execute) return tool;
return {
...tool,
execute: async () => {
throw new Error("Tool restricted to owner senders.");
}
};
}
OpenClaw 的 PluginToolGroups 支持用 group:plugins 统一引用所有插件工具:
export function expandPluginGroups(list, groups) {
const expanded = [];
for (const entry of list) {
if (normalizeToolName(entry) === "group:plugins") {
expanded.push(...groups.all); // 展开为所有插件工具
} else {
const tools = groups.byPlugin.get(normalized);
if (tools?.length > 0) {
expanded.push(...tools); // 展开为特定插件的工具
} else {
expanded.push(normalized);
}
}
}
return Array.from(new Set(expanded));
}
工具审批是 Agent 安全的最后一道防线。这里出现两种截然不同的设计:
Codex CLI 用 Rust 实现了一个完整的 Hook 事件系统,在工具调用前后各有一个拦截点:
PreToolUse 的三种决策:
pub(crate) enum PreToolUseDecisionWire {
#[serde(rename = "approve")]
Approve, // 明确批准
#[serde(rename = "block")]
Block, // 明确阻塞
}
pub(crate) enum PreToolUsePermissionDecisionWire {
#[serde(rename = "allow")]
Allow, // 自动允许
#[serde(rename = "deny")]
Deny, // 自动拒绝
#[serde(rename = "ask")]
Ask, // 交给用户决定
}
PermissionRequest 的保守折叠策略——任何 deny 都覆盖 allow:
fn resolve_permission_request_decision<'a>(
decisions: impl IntoIterator- ,
) -> Option
{
let mut resolved_allow = None;
for decision in decisions {
match decision {
PermissionRequestDecision::Allow => {
resolved_allow = Some(PermissionRequestDecision::Allow);
}
PermissionRequestDecision::Deny { message } => {
// deny 立即生效,覆盖之前的 allow
return Some(PermissionRequestDecision::Deny {
message: message.clone(),
});
}
}
}
resolved_allow
}
OpenClaw 的 pi-tools.before-tool-call.ts 实现了类似的 before-call 拦截,但加入了信任工具策略和插件审批解析:
// 关键逻辑伪代码
async function beforeToolCall(tool, args, context) {
// 1. 检查 before-tool-call 钩子
const hookResults = await runBeforeToolCallHooks(tool, args);
if (hookResults.shouldBlock) return { blocked: true, reason };
// 2. 插件审批解析
const pluginApproval = resolvePluginApproval(tool, context);
if (pluginApproval === "deny") return { blocked: true };
// 3. 信任工具策略——某些工具自动信任,跳过审批
if (isTrustedTool(tool, context)) return { approved: true };
// 4. 工具结果观察——某些工具的输出需要后处理
const observer = getToolOutcomeObserver(tool);
return { approved: true, observer };
}
| 级别 | Codex CLI | OpenClaw | 触发条件 |
|---|---|---|---|
| 自动允许 | PreToolUse → approve | Trusted tool 策略 | 低风险工具(read, search 等) |
| 需审批 | PermissionRequest → ask | Before-tool-call → plugin approval | 写操作、外部通信 |
| 硬拒绝 | PreToolUse → block / exit 2 | Policy Pipeline deny | 安全策略明确禁止 |
当工具数量从几个增长到几十个,如何组织和组合成为核心问题。
Hermes 拥有所有项目中最大的工具集——76 个工具,通过 Toolset 系统组织为 40+ 个命名工具集:
Toolset 的核心能力是递归组合:
def resolve_toolset(name: str, visited: Set[str] = None) -> List[str]:
if visited is None:
visited = set()
# 特殊别名: "all" / "*" → 所有工具集的并集
if name in {"all", "*"}:
all_tools = set()
for toolset_name in get_toolset_names():
resolved = resolve_toolset(toolset_name, visited.copy())
all_tools.update(resolved)
return sorted(all_tools)
# 环检测 (diamond 依赖安全)
if name in visited:
return []
visited.add(name)
toolset = get_toolset(name)
tools = set(toolset.get("tools", []))
# 递归解析 includes
for included_name in toolset.get("includes", []):
included_tools = resolve_toolset(included_name, visited)
tools.update(included_tools)
return sorted(tools)
Hermes 为 20+ 消息平台各定义了一个 hermes-{platform} 工具集,大部分共享 _HERMES_CORE_TOOLS,特定平台追加专属工具:
"hermes-discord": {
"tools": _HERMES_CORE_TOOLS + ["discord", "discord_admin"],
},
"hermes-feishu": {
"tools": _HERMES_CORE_TOOLS + [
"feishu_doc_read", "feishu_drive_list_comments", ...
],
},
"hermes-gateway": {
"tools": [],
"includes": ["hermes-telegram", "hermes-discord",
"hermes-whatsapp", ...], # 组合所有平台
}
SWE-agent 的 Bundle 是另一种组合模式——文件系统级打包:
class Bundle(BaseModel):
path: Path # Bundle 所在目录
hidden_tools: list[str] = [] # 隐藏的工具名
@property
def commands(self) -> list[Command]:
return [
Command(name=tool, **tool_config)
for tool, tool_config in self.config.tools.items()
if tool not in self.hidden_tools
]
Bundle 的安装流程:
OpenClaw 支持两种工具扩展机制:
PluginToolGroups 管理,支持 group:plugins 统一引用和按 pluginId 分组Plugin 工具在 Policy Pipeline 中的特殊处理:
export function analyzeAllowlistByToolType(
policy, groups, coreTools
): AllowlistResolution {
const normalized = normalizeToolList(policy.allow);
const pluginIds = new Set(groups.byPlugin.keys());
const pluginTools = new Set(groups.all);
let hasOnlyPluginEntries = true;
for (const entry of normalized) {
const isPluginEntry =
entry === "group:plugins" ||
pluginIds.has(entry) ||
pluginTools.has(entry);
if (!isPluginEntry) hasOnlyPluginEntries = false;
// 未知条目(既非核心也非插件)→ 发出警告
if (!isCoreEntry && !isPluginEntry) {
unknownAllowlist.push(entry);
}
}
return { policy, unknownAllowlist, pluginOnlyAllowlist: hasOnlyPluginEntries };
}
工具安全不仅是「谁能调用」,还有「什么命令不该执行」。
SWE-agent 的 ToolFilterConfig 实现了最精细的命令过滤:
class ToolFilterConfig(BaseModel):
# 级别 1: 前缀黑名单 — 阻止以这些开头的命令
blocklist: list[str] = [
"vim", "vi", "emacs", "nano", # 交互式编辑器
"nohup", "gdb", # 后台/调试
"less", "tail -f", # 交互式查看
"python -m venv", "make", # 长时间运行
]
# 级别 2: 精确黑名单 — 阻止完全匹配的命令
blocklist_standalone: list[str] = [
"python", "python3", "ipython", # 裸 Python shell
"bash", "sh", "/bin/bash", # 裸 shell
"nohup", "vi", "vim", "emacs",
"nano", "su",
]
# 级别 3: 条件黑名单 — 除非满足正则才允许
block_unless_regex: dict[str, str] = {
"radare2": r"\b(?:radare2)\b.*\s+-c\s+.*", # 只有 -c 模式允许
"r2": r"\b(?:radare2)\b.*\s+-c\s+.*",
}
过滤逻辑:
def should_block_action(self, action: str) -> bool:
action = action.strip()
if not action:
return False
# 前缀匹配
if any(action.startswith(f) for f in self.config.filter.blocklist):
return True
# 精确匹配
if action in self.config.filter.blocklist_standalone:
return True
# 条件正则: 命令名匹配但参数不满足正则 → 阻止
name = action.split()[0]
if name in self.config.filter.block_unless_regex:
if not re.search(self.config.filter.block_unless_regex[name], action):
return True
return False
OpenClaw 的 tool-allowlist-guard.ts 处理一种边界情况——当策略过滤后没有可用工具时:
// 核心逻辑:显式白名单验证
// 当所有工具都被策略拒绝时,给出有意义的错误信息
// 而非让 LLM 幻觉调用不存在的工具
| 维度 | PydanticAI | OpenClaw | Codex CLI | Hermes | SWE-agent | Smolagents |
|---|---|---|---|---|---|---|
| 定义方式 | 函数 + 自动推断 | TS 对象 + 插件 | 内置固定 | Python 函数 + Toolset | YAML + Shell | 声明式类 |
| Schema 生成 | Pydantic 内部 API | 手动 JSON Schema | JSON Schema (Rust) | 手动声明 | YAML → JSON | AST + 类型映射 |
| 类型安全 | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | ⭐⭐ | ⭐ | ⭐⭐⭐⭐ |
| 安全层数 | 1 (Pydantic 验证) | 8 (Policy Pipeline) | 3 (Hook 三层) | 2 (check_fn + Toolset) | 3 (三级 Blocklist) | 2 (验证 + 白名单) |
| 审批流 | 无 | Before-call + 信任策略 | PreToolUse + PermissionRequest | dangerous command 检查 | 无 (容器隔离) | 无 |
| 组合能力 | 无 | Plugin + MCP | Hook matcher | Toolset 递归组合 | Bundle 打包 | 从 LangChain/Gradio 转换 |
| 工具数量 | 用户定义 | 30+ 核心 + 插件 | 3 (Bash/Read/Write) | 76 | 5-15 (Bundle) | 用户定义 + Hub |
| 扩展机制 | Python 函数 | Plugin API + MCP | Hook 脚本 | Toolset + check_fn | Bundle 目录 | @tool 装饰器 + Hub |
| 运行时验证 | Pydantic validator | Schema 验证 | JSON Schema (Rust) | check_fn 门控 | which 命令检查 | AUTHORIZED_TYPES |
问题:LLM 只能理解 JSON Schema,如何确保 Python/TS 函数与 Schema 一致?
方案:从函数签名自动生成 Schema,而非手写。PydanticAI 是最佳实践——用 Pydantic 内部 API 保证 Schema 与验证器同源。
function_schema() 是当前最完整的实现。
问题:不同场景需要不同安全级别——个人 CLI 最宽松,群聊最严格。
方案:OpenClaw 的 8 层 Policy Pipeline——每一层独立配置,层层收紧。越具体的环境(sender > group > agent > global)越后执行,确保细粒度策略覆盖粗粒度。
问题:多个审批钩子可能给出冲突决策,如何合并?
方案:Codex CLI 的 resolve_permission_request_decision——deny 总是胜出。这是安全系统的黄金法则:宁可误拒,不可误放。
问题:工具数量增长后,如何避免 N² 的配置爆炸?
方案:Hermes 的 Toolset 系统——定义原子工具集,用 includes 组合,resolve_toolset() 递归展开。Diamond 依赖用 visited set 安全处理。
visited: Set[str] 是处理 diamond 依赖的最简方案——重复访问直接返回空列表。
问题:某些工具只在特定条件下可用(如 Home Assistant 需要 token,Kanban 需要 kanban worker 上下文)。
方案:Hermes 的 check_fn 模式——工具定义时附带检查函数,运行时按条件决定是否暴露:
# 工具注册时附带 check_fn
"send_message": {
"check_fn": lambda: gateway_is_running(), # 只在网关运行时暴露
},
"kanban_show": {
"check_fn": lambda: os.getenv("HERMES_KANBAN_TASK"), # 只在 kanban worker 中暴露
},
"computer_use": {
"check_fn": lambda: is_cua_driver_installed(), # 只在驱动安装后暴露
}
问题:Agent 执行交互式命令(vim、python shell)会导致会话卡死。
方案:SWE-agent 的三级过滤——前缀黑名单、精确黑名单、条件正则。核心洞察:不是过滤"危险命令",而是过滤"交互模式"。
安全需求低,扩展性需求高
安全需求高,隔离需求高
隔离需求极高,工具集固定
平台特化需求高,工具数量多
源码版本:PydanticAI (main) · OpenClaw (2026.5) · Codex CLI (codex-rs main) · Hermes Agent (main) · SWE-agent (main) · Smolagents (main)
生成时间:2026-05-17 · ← 返回目录