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

项目地址:GitHub - fuyuxiang/echo-agent: Echo Agent 是一个可自托管、长期运行、持续学习的 AI Agent,面向个人与团队的私有自动化场景。它可以部署在自有服务器上,统一连接模型、工具、记忆、权限与消息入口。内置四层认知记忆、遗忘曲线与矛盾检测机制,能够在跨会话任务中持续沉淀上下文,并保持长期记忆的质量。针对命令执行、文件操作等高风险行为,它提供基于 LLM 的审批与解释机制,为关键操作建立可审计、可追溯的安全边界。原生支持 MCP、A2A、多模型路由、任务调度、工具调用和多通道接入,覆盖 CLI、Gateway API、微信、Telegram 等入口。它让 Agent 带着长期记忆和可进化技能,持续、安全地为你工作。 · GitHub

13-cover

你让 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 的实现为例。它的工具系统由 ToolToolExecutionContextToolResultToolRegistrydiscover_toolsfilter_tools_by_policyApprovalGate 等模块共同组成。

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:
        ...

这段抽象传递了一个关键判断:工具不是一段可调用代码,而是带声明、约束和执行现场的能力入口。

13-能力边界

模型并不直接面对真实文件系统、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,对象、anyOfoneOfallOf 等结构也会递归检查。ToolRegistry.get_definitions() 转换工具定义时,如果某个工具 schema 不合法,会跳过该工具并记录错误。

这一步很硬,但必要。非法 schema 如果进入 provider 层,可能导致模型请求失败;即使 provider 接受了,也可能让模型生成系统无法解析的参数。

13-schema协议

参数校验发生在执行前。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_idtrace_id 用于可观测性;session_keyuser_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 和可选子系统组装工具。基础文件工具会注册,如 ReadFileToolWriteFileToolEditFileToolListDirTool;搜索、补丁、待办、消息、澄清和通知工具也会加入。

执行类工具依赖配置。只有 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()

它不是一个普通字典查找,而是工具执行内核。流程可以概括为:

  1. 解析工具别名,如 bash 映射到 exec

  2. 检查 allowed_tools,防止受限 worker 越权。

  3. 查找工具对象。

  4. 校验参数。

  5. 构造或使用 ToolExecutionContext

  6. 检查副作用工具 replay cache。

  7. 记录脱敏执行日志。

  8. 用超时和重试策略执行工具。

  9. 成功后记录 replay cache。

  10. 返回 ToolResult

13-执行治理

执行时用 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_idtrace_id、开始时间、成功状态和尝试次数。参数键名如果包含 keytokensecretpasswordapi_keycredentialauth 等敏感词,日志里会显示为 "***"

这类日志对调试 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_filesknowledge_search 应该从名字上就能区分;execprocess 应该分别表达一次性命令和后台进程。危险工具不应该用轻描淡写的名字。名称会影响模型选择、用户审批、日志审计和团队理解。

真正成熟的工具生态,不是把所有能力摊开给模型,而是让模型在正确场景看到正确能力,并让每次行动都能被解释、限制和复盘。

小结

Agent 能做事,不是因为模型“更聪明”,而是因为系统给了它一套受控的行动语言。

工具名和描述决定模型如何理解能力,schema 决定自然语言意图如何落成结构化参数,执行上下文决定本次调用属于谁、能做什么,幂等和审批限制副作用扩散,日志和结果反馈让行动可以进入下一轮推理和事后复盘。

工具系统的价值就在这里:它既让模型接触外部世界,又不把世界直接交给模型。

(全篇完)


本文为 echo-agent 设计笔记系列第 13 篇。项目源码已开源至 GitHub。如果你对工业级 Agent 的工程落地感兴趣,欢迎加入技术交流群参与日常讨论。下一篇我们将探讨 《Agent 执行器设计笔记:隔离命令、代码与进程》,敬请期待。

Logo

欢迎加入 MCP 技术社区!与志同道合者携手前行,一同解锁 MCP 技术的无限可能!

更多推荐