Agent 工具系统:把模型意图变成可控能力

echo-agent 前身为 2025 年 11 月启动的个人助理项目 fubot,最初面向长期陪伴型个人智能体,围绕认知记忆、上下文延续、用户偏好沉淀、任务闭环与持续自我优化展开。随着真实场景迭代,项目逐步形成多入口接入、统一事件模型、消息总线、Agent Loop、多模型抽象、工具调用、MCP 接入、任务调度、权限审批、运行轨迹、长期记忆和受控自演进等能力。目前已支持微信、QQ、CLI、Gateway、Webhook、Cron 等入口,服务用户超过 20 万、累计下载超过 50 万,是面向长期运行、记忆增强和可持续成长智能体的开源 Agent Runtime。

你让 Agent “帮我修复测试失败”。模型很快判断:应该读日志、搜索相关文件、运行测试、修改代码,再重新验证。
如果这只是一次聊天,模型说出这套步骤就结束了。但对 Agent 来说,真正的问题从这里才开始:它能不能读文件?能不能执行命令?能不能写代码?写到哪里?失败后能不能重试?每一步有没有记录?
工具系统要解决的不是“让模型会调用函数”,而是把模型生成的行动意图,转换成受约束、可审批、可追踪、可恢复的真实能力。
问题入口
最小化的工具调用 demo 通常长这样:
response = llm.chat(messages, tools=tool_defs) if response.tool_calls: result = execute(response.tool_calls[0]) messages.append(result)
这段代码能说明 tool calling 的基本形式,但不能构成生产级工具系统。
因为模型生成的 tool_call 不是系统命令,而是行动提案。模型可能漏传参数、传错类型、调用不该暴露的工具、重复执行副作用动作,或者把一段含糊自然语言塞进参数里。
如果系统把这些提案直接执行,Agent 的安全边界就变成了模型自觉。工具越强,风险越大。
会调用工具只说明模型有行动接口;工具调用是否可控,要看 schema、权限、幂等、审批、超时、重试和审计是否进入执行链路。
工具边界
工具不是普通函数。
普通函数由开发者调用,调用者理解代码上下文,知道哪些参数合法、哪些副作用危险、失败后应该怎么处理。Agent 工具由模型选择,调用者是一个概率系统。它只能通过工具名、描述和 schema 推断怎样行动。
所以生产级工具至少要声明这些信息:
| 维度 | 要回答的问题 |
|---|---|
| 名称与描述 | 模型什么时候应该使用它 |
| 参数 schema | 模型必须提供哪些结构化输入 |
| 参数校验 | 错误参数能否在执行前拦住 |
| readiness | 当前环境里工具是否可用 |
| 风险级别 | 只读、写入、执行还是危险动作 |
| 副作用 | 是否会改变外部状态 |
| 超时与重试 | 执行失败时系统如何收敛 |
| 执行上下文 | 属于哪个 session、用户和 trace |
| 返回结果 | 如何反馈给模型进入下一轮决策 |
为了不停留在抽象层面,下面以 echo-agent 的实现为例。它的工具系统由 Tool、ToolExecutionContext、ToolResult、ToolRegistry、discover_tools、filter_tools_by_policy 和 ApprovalGate 等模块共同组成。
Tool 是所有工具的抽象基类。它不仅定义 execute,也定义工具名、描述、参数、超时、重试、能力标签和风险等级:
class Tool(ABC):
name: str = ""
description: str = ""
parameters: dict[str, Any] = {}
timeout_seconds: int = 30
max_retries: int = 0
stream_capable: bool = False
capabilities: tuple[str, ...] = ()
risk_level: str = "write"
@abstractmethod
async def execute(
self,
params: dict[str, Any],
ctx: ToolExecutionContext | None = None,
) -> ToolResult:
...
这段抽象传递了一个关键判断:工具不是一段可调用代码,而是带声明、约束和执行现场的能力入口。

模型并不直接面对真实文件系统、Shell、网络和任务系统。它面对的是一组被系统选择后暴露出来的工具 schema。工具设计得清楚,模型更容易正确行动;工具设计得含糊,模型就会把不确定性带进执行层。
Schema 协议
工具暴露给模型时,不是把 Python 对象传给模型,而是转换成 schema。
schema 会进入模型 API 的 tools 参数。模型看到工具名、描述和参数结构,然后决定是否发起调用。系统也通过 schema 限制模型能传什么。
这意味着 schema 不是文档,而是模型和系统之间的协议。
如果写文件工具只有一个 instruction: string,模型可能传入“帮我把配置修好,如果需要就覆盖旧文件”。这对模型很自然,但对系统很难治理:路径在哪里、是否覆盖、内容是什么、风险多大,都混在一句话里。
更合理的 schema 应该把最小可执行意图拆出来:
{
"type": "object",
"required": ["path", "content"],
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
"mode": {"type": "string", "enum": ["create", "overwrite", "append"]}
}
}
这样系统才能分别对路径应用 path_policy,对 mode 做枚举检查,对高风险覆盖动作触发审批。
echo-agent 在 Tool.to_schema() 中会先验证参数 schema。数组 schema 必须有 items,对象、anyOf、oneOf、allOf 等结构也会递归检查。ToolRegistry.get_definitions() 转换工具定义时,如果某个工具 schema 不合法,会跳过该工具并记录错误。
这一步很硬,但必要。非法 schema 如果进入 provider 层,可能导致模型请求失败;即使 provider 接受了,也可能让模型生成系统无法解析的参数。

参数校验发生在执行前。Tool.validate_params() 会检查 required 字段、基础类型和枚举值。校验失败不会让工具内部抛出不可控异常,而是返回失败的 ToolResult:
errors = tool.validate_params(params)
if errors:
return ToolResult(
success=False,
error=f"Invalid parameters: {'; '.join(errors)}",
)
这点很重要。参数错误也是一种观察,应该进入模型上下文。模型下一轮看到“缺少 path”或“mode 不在允许枚举内”,就有机会修正调用,而不是让整个 Agent Loop 崩掉。
执行现场
一次工具执行不是孤立动作。它属于某次请求、某个会话、某个用户和某条 trace。
ToolExecutionContext 负责把这些现场信息带进工具层:
@dataclass(frozen=True) class ToolExecutionContext: execution_id: str = "" trace_id: str = "" session_key: str = "" user_id: str = "" agent_id: str = "" attempt_index: int = 0 idempotency_key: str = "" is_replay: bool = False parent_execution_id: str | None = None credentials: dict[str, str] = field(default_factory=dict) approved_actions: frozenset[str] = field(default_factory=frozenset) allowed_tools: frozenset[str] = field(default_factory=frozenset)
这里的字段不是摆设。execution_id 和 trace_id 用于可观测性;session_key 和 user_id 用于权限、审计和隔离;credentials 让工具按需读取凭证,而不是到全局环境乱取密钥;approved_actions 承接审批结果;allowed_tools 限制委派 worker 能调用哪些工具。
最容易被忽略的是 idempotency_key。
副作用工具不能被无意重复执行。读文件失败了可以重试,发送消息、写文件、创建任务、执行命令重复一次,就可能产生真实影响。
echo-agent 用 trace_id、工具名、工具序号和排序后的参数生成幂等键:
def build_idempotency_key(trace_id, tool_name, index, params) -> str:
payload = json.dumps(
params,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
digest = hashlib.sha256(
f"{trace_id}:{tool_name}:{index}:{payload}".encode()
).hexdigest()
return digest[:24]
ToolRegistry.execute() 在副作用工具执行前检查 replay cache。若发现同一执行范围内的重复副作用,会返回失败结果并阻止执行;执行成功后,再把幂等键写入缓存。缓存超过上限后按 LRU 淘汰。
这不能替代业务系统自己的幂等设计,但能防住同一 Agent 推理过程里的重复副作用。
只读工具主要改变模型认知;副作用工具会改变外部世界。工具系统的分水岭,正是副作用是否已经发生。
注册与暴露
工具多了以后,问题不再是“有没有这个工具”,而是“本轮模型应该看到哪些工具”。
echo-agent 通过 discover_tools() 按配置、workspace、消息总线、provider 和可选子系统组装工具。基础文件工具会注册,如 ReadFileTool、WriteFileTool、EditFileTool、ListDirTool;搜索、补丁、待办、消息、澄清和通知工具也会加入。
执行类工具依赖配置。只有 config.tools.exec.enabled 打开时,才会创建执行器并注册 Shell 工具;代码执行和进程工具也依赖执行配置。网络工具还要看 web 配置和 network_policy。
如果系统提供了 task manager、workflow engine、session manager、scheduler、skill store、memory store 或 knowledge index,工具发现阶段会注册对应能力。图像生成和 TTS 工具也会根据配置尝试注册。
这说明工具发现不是固定目录扫描,而是运行时能力组装。
发现之后,还要经过 filter_tools_by_policy()。配置入口包括:
class ToolsConfig(_Base): profile: Literal["minimal", "messaging", "coding", "full"] = "coding" allow: list[str] = Field(default_factory=list) also_allow: list[str] = Field(default_factory=list) deny: list[str] = Field(default_factory=list)
这层策略决定哪些工具可以暴露给模型。它会结合 profile、allow、also_allow、deny、安全 profile、capabilities 和网络策略做过滤。
但暴露策略不是最终安全边界。即使工具被模型看到,执行前仍会经过 ApprovalGate、路径策略、执行器策略和工具内部校验。生产系统应该采用多层防御:少暴露、严审批、控执行、留审计。
| 层次 | 作用 |
|---|---|
| 工具发现 | 系统当前具备哪些能力 |
| readiness | 这些能力当前是否可用 |
| 暴露策略 | 本轮哪些工具给模型看 |
| 执行上下文 | 本次调用属于谁、能做什么 |
| 审批与策略 | 本次行动是否允许 |
| 执行日志 | 发生过什么、能否复盘 |
执行治理
当 InferenceStage 真正执行工具调用时,最终进入 ToolRegistry.execute()。
它不是一个普通字典查找,而是工具执行内核。流程可以概括为:
-
解析工具别名,如
bash映射到exec。 -
检查
allowed_tools,防止受限 worker 越权。 -
查找工具对象。
-
校验参数。
-
构造或使用
ToolExecutionContext。 -
检查副作用工具 replay cache。
-
记录脱敏执行日志。
-
用超时和重试策略执行工具。
-
成功后记录 replay cache。
-
返回
ToolResult。

执行时用 asyncio.wait_for 控制超时:
result = await asyncio.wait_for( tool.execute(params, exec_ctx), timeout=tool.timeout_seconds, )
失败会按 max_retries 重试。最终失败时,系统返回 ToolResult(success=False, error=...),而不是把未处理异常继续向上抛。
执行日志保存在 _execution_log 中,会记录工具名、脱敏参数、execution_id、trace_id、开始时间、成功状态和尝试次数。参数键名如果包含 key、token、secret、password、api_key、credential、auth 等敏感词,日志里会显示为 "***"。
这类日志对调试 Agent 行动很关键。模型说“我已经执行了某工具”不够,系统必须能查到:它何时执行、参数是什么、结果是否成功、重试了几次、有没有被策略拒绝。
结果反馈
工具输出不是最终回答,而是下一轮推理的观察。
echo-agent 用统一的 ToolResult 表达成功和失败:
@dataclass
class ToolResult:
success: bool = True
output: str = ""
error: str = ""
metadata: dict[str, Any] = field(default_factory=dict)
@property
def text(self) -> str:
return self.output if self.success else f"Error: {self.error}"
InferenceStage 不需要理解每个工具的内部异常,只要把 result.text 写入 tool 消息。metadata 则可以携带审批 ID、执行路径、结果数量、风险标记、输出文件位置等结构化信息,供审计和后续处理使用。
好的工具结果应服务下一轮决策。它至少应该说明是否成功、关键结果、错误原因、可采取的下一步、必要元数据和引用路径。
如果工具失败只返回“失败了”,模型无法恢复;如果返回几十万字符日志,模型会被噪声淹没。完整记录可以进入日志或文件,进入模型上下文的结果应该是可行动摘要。
对于副作用工具,结果还应说明影响范围:写入了哪个文件、执行器是什么、返回码是什么、是否被截断、是否触发审批、是否产生 artifact。
工具结果不是回答素材的散装文本,而是带成功失败语义、来源信息和行动后果的环境反馈。
生产可用性
判断一个工具系统是否接近生产可用,不能只看“内置了多少工具”。工具数量越多,越需要治理。
更可检验的标准是:
| 检查项 | 可检验标准 |
|---|---|
| 工具声明 | 每个工具有清晰名称、描述、schema、风险等级和能力标签 |
| Schema 质量 | 非法 schema 在暴露前被跳过并记录错误 |
| 参数校验 | required、类型和 enum 错误能变成可恢复的工具失败 |
| Readiness | 外部依赖缺失时启动阶段能报告不可用原因 |
| 分层暴露 | 按 profile、allow、deny、capabilities 和网络策略过滤工具 |
| 权限审批 | 副作用、高风险和执行类工具进入审批与策略判断 |
| 幂等保护 | 写入、命令、消息、任务等副作用工具有 replay 防护 |
| 超时重试 | 工具有 timeout 和有限重试,失败后返回结构化错误 |
| 审计日志 | 记录工具名、脱敏参数、trace、结果和尝试次数 |
| 结果治理 | 工具输出可行动、可引用、可截断,完整结果另行保存 |
| 委派收缩 | 多 Agent worker 使用 allowed_tools 控制最小能力范围 |
| 回归评估 | 有工具调用 trace、权限拒绝、重复副作用和参数错误样例 |
这里的核心判断很简单:工具系统不是给模型装上一堆外接能力,而是定义模型可以怎样接触世界。
如果工具过粗,模型会把大量隐含意图塞进自由文本,系统难以校验和审计;如果工具过细,模型需要承担过多编排工作,容易漏步骤、顺序错误或陷入循环。合适的粒度应该围绕语义动作:读文件、写文件、搜索知识、执行命令、创建任务、委派 worker、发送通知。
工具命名也不是美学问题。search_files 和 knowledge_search 应该从名字上就能区分;exec 和 process 应该分别表达一次性命令和后台进程。危险工具不应该用轻描淡写的名字。名称会影响模型选择、用户审批、日志审计和团队理解。
真正成熟的工具生态,不是把所有能力摊开给模型,而是让模型在正确场景看到正确能力,并让每次行动都能被解释、限制和复盘。
小结
Agent 能做事,不是因为模型“更聪明”,而是因为系统给了它一套受控的行动语言。
工具名和描述决定模型如何理解能力,schema 决定自然语言意图如何落成结构化参数,执行上下文决定本次调用属于谁、能做什么,幂等和审批限制副作用扩散,日志和结果反馈让行动可以进入下一轮推理和事后复盘。
工具系统的价值就在这里:它既让模型接触外部世界,又不把世界直接交给模型。
(全篇完)
本文为 echo-agent 设计笔记系列第 13 篇。项目源码已开源至 GitHub。如果你对工业级 Agent 的工程落地感兴趣,欢迎加入技术交流群参与日常讨论。下一篇我们将探讨 《Agent 执行器设计笔记:隔离命令、代码与进程》,敬请期待。
更多推荐


所有评论(0)