Files
agcore/docs/5-tool-system.md
T
徐涛 5d6bb5e983 feat(tools): 完善工具系统设计方案并添加扩展性分析
更新工具系统方案文档,补充 Token 消耗分析、错误分类策略、`submit_request()` 重构说明及流式事件职责划分,新增第9节未来扩展性分析,明确 `ToolContext` 注入、`ToolEntry` 元数据、
`ToolOutput` 枚举等扩展路径,更新实现计划以保持与设计一致
2026-06-07 10:13:19 +08:00

37 KiB
Raw Blame History

Phase 2: Tool System — 方案设计

定稿日期:2026-06-03

背景与目标

AG Core Phase 0Foundation)已完成 LLM 调用周期基础设施,Phase 1Prompt Engineering)已完成提示词组合与模板化。Phase 2 的目标是补齐工具系统能力,实现 LLM 驱动的工具定义、注册、调用、权限控制,以及 MCP 协议集成。

核心目标:让 LLM 能通过 FinishReason::ToolCalls 触发工具自动执行,并将结果回传至对话上下文,形成完整的"思考 → 调用 → 反馈"闭环。


需求分析

功能需求

模块 需求 验收条件
BaseTool trait 工具抽象接口:名称、描述、参数、执行、权限声明 实现 trait 后可注册到 Registry
ToolRegistry 工具注册、发现、按名称调用 注册 3 个工具后能按名称查找到并执行
McpClient MCP 协议客户端(stdio transport 能启动 MCP 服务器子进程、列出工具、调用工具
PermissionChecker 工具执行前权限校验 禁止无权限的工具执行,返回结构化错误
自动 Tool 循环 LlmCycle 收到 ToolCalls 后自动执行工具并回传 一个包含工具调用的对话能完整执行 2+ 轮
流式 Tool 事件 流式模式下发射 ToolExecutionCompleted 事件 流式调用中工具执行完成后触发对应事件
工具调用历史持久化 自动工具循环产生的 Tool/Assistant 消息正确追加到 messages 查看 cycle.messages() 能获取完整工具交互轨迹

非功能需求

  • 所有公开 API 必须带 /// 文档注释
  • 无新增 unwrap() 调用
  • BaseToolexecute() 必须为 async
  • 工具执行错误必须结构化为 ToolError,不允许 panic
  • MCP 客户端超时默认 30 秒,可配置
  • 自定义工具与 MCP 工具通过同一 ToolRegistry 管理,对 LlmCycle 透明
  • 权限检查在工具执行之前,阻断后返回错误而非静默跳过
  • BaseTool::execute() 签名必须预留扩展点(ToolContext 注入),确保未来 Skill/Agent 层可在不修改 trait 签名的情况下注入 session_id、cancellation_token 等上下文信息
  • 自动 tool 循环应考虑 token 消耗——工具定义随每轮请求重复发送,工具结果直接追加到对话历史,需提供结果大小限制和截断策略

方案设计

模块结构

src/
  tools.rs                # tools 模块根:声明子模块 + 重导出公共 API
  tools/
    base.rs               # BaseTool trait — 工具抽象接口
    registry.rs           # ToolRegistry — 工具注册表
    permission.rs         # PermissionChecker — 权限校验器
    mcp.rs                # McpClient — MCP 协议客户端
    error.rs              # ToolError — 工具系统错误类型

tools.rs 根模块声明:

// tools.rs
pub mod base;
pub mod error;
pub mod mcp;
pub mod permission;
pub mod registry;

pub use base::{BaseTool, ToolContext};
pub use error::ToolError;
pub use mcp::McpClient;
pub use permission::{Permission, PermissionChecker, PermissionConfig};
pub use registry::{ToolEntry, ToolInvocation, ToolRegistry};

lib.rs 添加:

 pub mod llm;
 pub mod prompt;
+pub mod tools;

1. BaseTool trait — 工具抽象接口

// tools/base.rs

use async_trait::async_trait;
use serde_json::Value;

use crate::tools::error::ToolError;
use crate::tools::permission::Permission;

/// 工具抽象接口 —— 所有工具(自定义或 MCP)最终都实现此 trait。
#[async_trait]
pub trait BaseTool: Send + Sync {
    /// 工具名称(唯一标识,用于 LLM 的 tool_calls.name 匹配)。
    fn name(&self) -> &str;

    /// 工具描述(LLM 据此决定是否调用此工具)。
    fn description(&self) -> &str;

    /// 工具参数定义(JSON Schema 格式,传递给 LLM 的 tool.parameters)。
    fn parameters(&self) -> Value;

    /// 声明工具所需的权限列表。
    fn required_permissions(&self) -> Vec<Permission> {
        Vec::new()
    }

    /// 执行工具调用。
    async fn execute(&self, args: Value) -> Result<Value, ToolError>;
}

设计说明

  • name() 返回 &str 而非 String,避免每次调用克隆
  • parameters() 返回 serde_json::Value,与现有 OpenaiToolDefinition.parameters 类型一致
  • required_permissions() 提供默认空实现,简化无敏感操作的工具定义
  • execute() 接收 ValueJSON 对象)作为参数,返回 Value 作为结果,与 OpenAI API 的 arguments/output 格式一致

2. ToolRegistry — 工具注册表

// tools/registry.rs

use std::collections::HashMap;
use std::sync::Arc;

use async_trait::async_trait;
use serde_json::Value;

use crate::llm::types::{OpenaiToolDefinition, ToolDefinition};
use crate::tools::base::BaseTool;
use crate::tools::error::ToolError;
use crate::tools::permission::{Permission, PermissionChecker};

/// 工具调用记录 —— 用于追踪和调试。
pub struct ToolInvocation {
    pub tool_name: String,
    pub input: Value,
    pub output: Result<Value, ToolError>,
}

/// 工具注册表 —— 管理工具注册、发现、调用。
pub struct ToolRegistry {
    tools: HashMap<String, Arc<dyn BaseTool>>,
    permission_checker: Option<Arc<PermissionChecker>>,
}

impl ToolRegistry {
    pub fn new() -> Self;

    /// 设置权限检查器(可选,不设置则不检查权限)。
    pub fn with_permission_checker(mut self, checker: PermissionChecker) -> Self;

    /// 注册一个工具。
    pub fn register(&mut self, tool: Arc<dyn BaseTool>) -> Result<(), ToolError>;

    /// 批量注册工具。
    pub fn register_all(&mut self, tools: Vec<Arc<dyn BaseTool>>) -> Result<(), ToolError>;

    /// 注销一个工具。
    pub fn unregister(&mut self, name: &str) -> Option<Arc<dyn BaseTool>>;

    /// 按名称查找工具。
    pub fn get(&self, name: &str) -> Option<Arc<dyn BaseTool>>;

    /// 获取所有已注册工具的名称列表。
    pub fn list_tools(&self) -> Vec<String>;

    /// 获取所有工具的 ToolDefinition 列表(用于传递给 LLM)。
    pub fn definitions(&self) -> Vec<ToolDefinition>;

    /// 调用一个工具(含权限检查)。
    pub async fn invoke(&self, name: &str, args: Value) -> Result<ToolInvocation, ToolError>;

    /// 批量执行工具调用(并行执行互不依赖的工具)。
    pub async fn invoke_all(
        &self,
        calls: Vec<(String, Value)>,
    ) -> Vec<ToolInvocation>;
}

核心逻辑

  • invoke():查找工具 → 权限检查 → 执行 → 返回 ToolInvocation
  • invoke_all():对多个工具调用并行执行(使用 tokio::join!futures::join_all),适用于 LLM 同时发出多个 tool_calls 的场景
  • invoke_all() 应对每个工具执行添加超时控制(通过 tokio::time::timeout),超时时间由 CycleConfig::tool_timeout_secs 配置,默认 60 秒,防止单个工具长时间阻塞整个循环
  • definitions():将注册的工具批量转换为 Vec<ToolDefinition>,供 LlmCycle 传递 LLM
  • ToolRegistry 不持有 PermissionChecker 的生命周期(使用 Arc),允许多个 Registry 共享同一个 Checker

使用示例

let mut registry = ToolRegistry::new()
    .with_permission_checker(checker);

registry.register(Arc::new(WeatherTool))?;
registry.register(Arc::new(FileReadTool))?;

// 获取 ToolDefinitions 传递给 LLM
let tools = registry.definitions();

// 收到 LLM 的 tool_calls 后执行
let calls = vec![
    ("get_weather".into(), json!({"city": "Beijing"})),
    ("read_file".into(), json!({"path": "/tmp/data.txt"})),
];
let results = registry.invoke_all(calls).await;

3. PermissionChecker — 权限校验器

// tools/permission.rs

/// 权限级别枚举。
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Permission {
    /// 只读(读取文件、查询数据库等)。
    Read,
    /// 写入(创建/修改文件、插入数据等)。
    Write,
    /// 删除(删除文件、记录等)。
    Delete,
    /// 网络访问(HTTP 请求等)。
    Network,
    /// Shell 命令执行。
    Shell,
    /// 文件系统操作(除读/写/删之外的 FS 操作)。
    FileSystem,
    /// 自定义权限(可通过 namespaced 字符串扩展)。
    Custom(String),
}

/// 权限配置。
pub struct PermissionConfig {
    /// 允许的权限列表(空 = 全部允许)。
    pub allowed: Vec<Permission>,
    /// 拒绝的权限列表(优先级高于 allowed)。
    pub denied: Vec<Permission>,
    /// 是否允许未声明权限的工具执行(默认为 true)。
    pub allow_unspecified: bool,
}

impl Default for PermissionConfig {
    fn default() -> Self {
        Self {
            allowed: vec![Permission::Read, Permission::Network],
            denied: vec![Permission::Delete, Permission::Shell],
            allow_unspecified: true,
        }
    }
}

/// 权限检查器。
pub struct PermissionChecker {
    config: PermissionConfig,
}

impl PermissionChecker {
    pub fn new(config: PermissionConfig) -> Self;

    /// 检查指定权限是否允许执行。
    pub fn check(&self, tool_name: &str, permissions: &[Permission]) -> Result<(), ToolError>;
}

权限判定规则

  1. 如果权限在 denied 中 → 拒绝
  2. 如果权限在 allowed 中 → 允许
  3. 如果 allowed 非空且权限不在其中 → 拒绝(白名单模式)
  4. 如果 allowed 为空 → 按 allow_unspecified 判定

4. McpClient — MCP 协议客户端

MCPModel Context Protocol)是一种基于 JSON-RPC 的协议,用于 LLM 与外部工具系统通信。Phase 2 实现其 最小可行子集,优先实现 stdio transport。

传输方式说明MCP 协议版本 2025-03-26 定义了两种标准传输——stdioStreamable HTTP。原有的 HTTP+SSE 传输(2024-11-05)已被官方废弃,新实现不应采用。Streamable HTTP 通过单一 HTTP 端点同时支持 JSON 响应和 SSE 流式升级,是 HTTP 场景的推荐方案。

// tools/mcp.rs

use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};

use serde_json::Value;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::{ChildStdin, ChildStdout, Command as TokioCommand};
use tokio::sync::Mutex;

use crate::tools::base::BaseTool;
use crate::tools::error::ToolError;

/// MCP 协议版本。
const MCP_VERSION: &str = "2025-03-26";

/// MCP 传输方式。
pub enum McpTransport {
    /// 通过子进程 stdin/stdout 通信。
    Stdio {
        command: String,
        args: Vec<String>,
    },
    /// Streamable HTTP 传输(MCP 2025-03-26 引入,替代已废弃的 HTTP+SSE)。
    /// 客户端通过单一 HTTP 端点与 MCP Server 通信,支持 JSON 和 SSE 流式响应。
    StreamableHttp {
        url: String,
        headers: Option<Vec<(String, String)>>,
    },
}

/// MCP 客户端 —— 与 MCP 服务器通信。
pub struct McpClient {
    transport: McpTransport,
    server_name: String,
    /// 已初始化的工具列表(缓存)。
    tools: Vec<McpTool>,
    /// 是否已初始化。
    initialized: AtomicBool,
    /// 超时时间(秒)。
    timeout_secs: u64,
}

/// MCP 服务器暴露的工具(缓存结构)。
struct McpTool {
    name: String,
    description: Option<String>,
    input_schema: Value,
}

impl McpClient {
    /// 创建一个 MCP 客户端。
    pub fn new(server_name: impl Into<String>, transport: McpTransport) -> Self;

    /// 设置超时时间。
    pub fn with_timeout(mut self, secs: u64) -> Self;

    /// 连接并初始化(发送 initialize 请求,获取服务器能力声明)。
    pub async fn connect(&mut self) -> Result<(), ToolError>;

    /// 列出服务器支持的工具(调用 tools/list)。
    pub async fn list_tools(&mut self) -> Result<Vec<ToolDefinition>, ToolError>;

    /// 调用一个工具(调用 tools/call)。
    pub async fn call_tool(&self, name: &str, args: Value) -> Result<Value, ToolError>;

    /// 关闭连接(终止子进程)。
    pub async fn close(&mut self) -> Result<(), ToolError>;

    /// 将 MCP 客户端转换为 BaseTool 适配器列表(用于注册到 ToolRegistry)。
    pub fn into_tools(self) -> Vec<Arc<dyn BaseTool>>;
}

MCP 协议交互流程

客户端                          MCP 服务器
  │                                │
  ├── initialize request ──────►   │
  │   { protocolVersion, capabilities }
  │◄──── initialize response ──   │
  │   { protocolVersion, serverInfo, capabilities }
  │                                │
  ├── initialized notification ──► │
  │                                │
  ├── tools/list ──────────────►   │
  │◄── tools/list result ────────  │
  │   { tools: [{ name, description, inputSchema }] }
  │                                │
  ├── tools/call ─────────────►   │
  │   { name, arguments }
  │◄── tools/call result ────────  │
  │   { content: [{ type, text }] }
  │                                │
  ├── shutdown ───────────────►    │
  │◄── shutdown ─────────────       │

关于 stdio transport 实现

  • 使用 tokio::process::Command 启动子进程
  • stdin 写入 JSON-RPC 请求(每行一个 JSON 对象)
  • stdout 读取 JSON-RPC 响应(使用 BufReader 逐行读取)
  • 每个请求关联一个 id(递增整数),通过 id 匹配请求和响应
  • 进程退出时自动关闭

关于 MCP Server 的管理

  • Phase 2 实现 MCP Server 框架,只实现 Client
  • MCP Server 由外部提供(如 npx @anthropic/mcp-server-filesystem
  • 用户需要提供 MCP Server 的启动命令和参数

工具缓存说明

  • McpClientlist_tools() 时缓存工具列表,避免每次调用都重新请求
  • 缓存假设:MCP Server 的工具列表在运行时不会频繁变更(如插件式加载场景除外)
  • 如需刷新,可通过新增 refresh_tools() 方法或基于 TTL(如 60 秒)自动失效

5. ToolError — 错误类型

// tools/error.rs

use thiserror::Error;

#[derive(Error, Debug)]
pub enum ToolError {
    #[error("工具 '{0}' 未注册")]
    NotFound(String),

    #[error("工具 '{0}' 执行失败: {1}")]
    ExecutionFailed(String, String),

    #[error("工具 '{0}' 参数无效: {1}")]
    InvalidArguments(String, String),

    #[error("权限被拒绝: 工具 '{0}' 需要 {1:?} 权限")]
    PermissionDenied(String, String),

    #[error("MCP 协议错误: {0}")]
    McpError(String),

    #[error("MCP 未初始化: {0}")]
    McpNotInitialized(String),

    #[error("MCP 超时: {0}")]
    McpTimeout(String),

    #[error("IO 错误: {0}")]
    Io(#[from] std::io::Error),

    #[error("其他错误: {0}")]
    Other(String),
}

6. LlmCycle 扩展 — 自动 Tool 循环

核心设计:在 LlmCycle 中新增 submit_with_tools() 方法,自动处理 tool 执行循环。

// llm/cycle.rs 新增

use crate::tools::registry::ToolRegistry;
use crate::tools::error::ToolError;

impl LlmCycle {
    /// 提交消息并自动处理工具调用循环。
    ///
    /// 流程:
    /// 1. 发送请求(含工具定义)
    /// 2. 检查响应中的 finish_reason
    /// 3. 如果是 ToolCalls → 执行工具 → 回传结果 → 重复 1
    /// 4. 如果是 Stop/Length → 返回最终响应
    pub async fn submit_with_tools(
        &mut self,
        prompt: String,
        registry: &ToolRegistry,
    ) -> Result<ChatResponse, LlmError> {
        let tools = registry.definitions();
        let max_turns = self.config.max_turns.unwrap_or(10); // 注:CycleConfig.max_turns 默认值为 None,实现时需修改 Default 为 Some(10)
        let mut turn = 0;

        self.messages.push(OpenaiChatMessage::user_text(prompt));

        loop {
            turn += 1;
            if turn > max_turns {
                return Err(LlmError::Other(format!(
                    "达到最大工具循环轮次 ({})",
                    max_turns
                )));
            }

            // 发送请求
            let response = self.submit_request(&tools).await?;

            // 检查是否需要执行工具
            let should_execute = matches!(
                response.stop_reason,
                Some(FinishReason::ToolCalls) | None
            ) && has_tool_calls(&response.message);

            if !should_execute {
                return Ok(response);
            }

            // 解析 tool_calls 并执行
            let tool_calls = extract_tool_calls(&response.message);
            let results = registry.invoke_all(tool_calls).await;

            // 回传工具结果
            for result in results {
                let content = match &result.output {
                    Ok(value) => serde_json::to_string(value)
                        .unwrap_or_else(|_| "{}".to_string()),
                    Err(e) => format!("错误: {}", e),
                };

                self.messages.push(
                    OpenaiChatMessage::tool_result(result.tool_name.clone(), content)
                );
            }
        }
    }

    /// 内部请求方法(与 submit 共享重试逻辑,但不 push user message)。
    async fn submit_request(
        &mut self,
        tools: &[ToolDefinition],
    ) -> Result<ChatResponse, LlmError> {
        // ... 提取 submit() 中的 request → response 逻辑(不含 user prompt push
    }
}

关键设计决策

决策 选择 理由
循环方式 同步循环(单线程串行) 工具执行依赖前一轮结果,串行更安全
最大轮次 CycleConfig.max_turns,默认 Some(10) 防止无限循环(LLM 反复调用工具)。注意:当前 CycleConfig 默认值为 None,实现时需将 Default 改为 Some(10)
工具并行 invoke_all() 互不依赖的工具并行 LLM 可能一次发出多个 tool_callsparallel_tool_calls
工具超时 CycleConfig::tool_timeout_secs,默认 60 防止单个工具长时间阻塞循环。invoke_all() 使用 tokio::time::timeout 包装
错误处理 工具执行错误以文本回传 LLM,而非终止循环 LLM 可自行从错误中恢复
消息追踪 所有工具交互通过 self.messages 持久化 调用方能通过 cycle.messages() 查看完整轨迹

Token 消耗分析

自动 tool 循环的 token 消耗主要来自三个来源:

来源 说明 影响程度
工具定义重复发送 definitions() 在每轮请求中携带全部工具的 JSON Schema 注册工具数 × 平均定义大小 × 轮数。20 个工具 × 500B × 5 轮 ≈ 50KB 输入 token
工具结果追加历史 每次工具执行结果完整追加到 messages,后续请求重发全部历史 最显著的 token 泄漏源。大结果(如向量搜索 Top-50)单次可能 ~15KB,多轮累加
Value→String 序列化 工具结果 serde_json::to_string() 后 JSON 字符串膨胀 ~20-30% 线性的常量损耗

影响估算

场景 工具相关 token 占比 说明
单次简单查询 <5% 可忽略
文件读取+分析(3-4 轮) ~30% 工具结果逐步累积
网页搜索+总结(3-5 轮) ~40% 工具结果包含页面内容
多工具数据 pipeline5-10 轮) ~60%+ 需关注压缩和限制策略

缓解方向(Phase 2 不强制实现,但设计需可扩展):

  • 结果大小限制:工具执行结果超过阈值时自动截断(如 CycleConfig::max_tool_result_bytes
  • 自动压缩:现有的 Auto-compaction 需感知工具消息,避免压缩掉 LLM 后续依赖的数据
  • 工具定义缓存:基础工具定义变化极少,未来可考虑客户端侧缓存(需等 provider 支持)

错误分类与处理策略

工具执行错误需要区分"可恢复"和"不可恢复"两类,不可恢复的错误应终止循环而非回传 LLM:

错误类型 处理策略 理由
ToolError::ExecutionFailed 回传 LLM(文本) LLM 可能下次换参数或换方式重试
ToolError::InvalidArguments 回传 LLM(文本) LLM 可自动修正参数
ToolError::NotFound 终止循环,返回 LlmError LLM 无法注册工具,重试无意义
ToolError::PermissionDenied 终止循环,返回 LlmError 安全敏感,不应允许重试
ToolError::McpError 终止循环,返回 LlmError MCP 链路故障,重试大概率失败
ToolError::McpTimeout 终止循环,返回 LlmError 或可考虑重试 1 次后终止
ToolError::Io 终止循环,返回 LlmError IO 错误通常是环境问题
ToolError::Other 回传 LLM(文本) 兜底,保守回传

实现上可在 ToolError 上添加 is_recoverable() 方法,或在 submit_with_tools() 中通过 match 分支判断。

submit_request() 重构说明

提取 submit_request() 作为 submit_with_tools() 的内部方法时,需确保不影响现有方法的行为。重构后的方法职责矩阵:

方法 Push user msg Compaction Retry Call provider Handle response
submit() submit_request()
submit_messages() submit_request()
submit_with_tools() submit_request() *
submit_request()

*submit_with_tools()submit_request() 返回后额外检查 ToolCalls,执行工具后递归调用自身。

流式模式支持

submit_stream() 的增强方案:新增 submit_stream_with_tools(),在流式事件层面支持自动 tool 循环。

impl LlmCycle {
    pub async fn submit_stream_with_tools(
        &mut self,
        prompt: String,
        registry: &ToolRegistry,
    ) -> Result<Pin<Box<dyn Stream<Item = StreamEvent> + Send>>, LlmError> {
        // 1. 使用 submit_stream() 获取初始事件流
        // 2. 监听 TurnComplete { reason: ToolCalls }
        // 3. 触发时:通过 ToolRegistry 执行工具
        // 4. 发射 ToolExecutionCompleted 事件(由 submit_stream_with_tools 负责,非底层 stream parser
        // 5. 将工具结果注入 messages
        // 6. 自动发起下一轮请求(递归)
        // 7. 直到 finish_reason 为 Stop
    }
}

事件发射时序

submit_stream_with_tools("查天气")
  │
  ├─ AssistantTextDelta "我来查一下北京的天气..."      ← 底层 stream parser 发射
  ├─ ToolExecutionStarted { tool_name, input, id }     ← submit_stream_with_tools 发射
  ├─ TurnComplete { reason: ToolCalls }                 ← 底层 stream parser 发射
  │
  ├── [自动] 执行工具 get_weather({city:"北京"})
  │
  ├─ ToolExecutionCompleted { tool_name, output, ... }  ← submit_stream_with_tools 发射
  │
  ├─ AssistantTextDelta "北京今天 22°C"                ← 底层 stream parser 发射
  ├─ TurnComplete { reason: Stop }                     ← 底层 stream parser 发射
  │
  └─ (流结束)

**事件发射职责划分**:底层 `parse_chunk_stream()` 负责 LLM 原生事件(`AssistantTextDelta`、`TurnComplete`);`submit_stream_with_tools()` 负责工具层事件(`ToolExecutionStarted`、`ToolExecutionCompleted`),在工具执行前/后手动 `yield` 事件。

7. 自定义工具示例

use agcore::tools::{BaseTool, ToolError};
use async_trait::async_trait;
use serde_json::Value;

struct WeatherTool;

#[async_trait]
impl BaseTool for WeatherTool {
    fn name(&self) -> &str { "get_weather" }
    fn description(&self) -> &str { "获取指定城市的当前天气" }
    fn parameters(&self) -> Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "city": { "type": "string", "description": "城市名称" }
            },
            "required": ["city"]
        })
    }

    async fn execute(&self, args: Value) -> Result<Value, ToolError> {
        let city = args["city"].as_str()
            .ok_or_else(|| ToolError::InvalidArguments(
                "get_weather".into(), "缺少 city 参数".into()
            ))?;
        // 模拟天气查询
        Ok(serde_json::json!({ "city": city, "temperature": 22, "unit": "°C" }))
    }
}

8. 模块依赖关系

tools/ 模块内部依赖:
  base.rs     → 无内部依赖(Permission 枚举 + ToolError
  permission  → 无内部依赖
  registry    → base, error, permission
  mcp         → base, error(需通过 registry 注册)
  error       → 无内部依赖

跨模块依赖:
  tools/  → llm/types (ToolDefinition 类型)
  llm/cycle → tools/registry (自动 tool 循环)
  llm/cycle → tools/error (ToolError 转换)

9. 未来工具化路线扩展性分析

本节回答"当前设计是否足以支撑未来常规工具、MCP、Skill、记忆等统一走工具调用路线"。

设计目标

未来所有 Agent 可调用的能力(常规工具、MCP 工具、Skill、记忆操作)都应通过 BaseTool trait 统一暴露给 LLMToolRegistry 作为唯一的工具发现和调用入口,对 LlmCycle 透明。

各场景支持度评估

场景 当前支持度 关键瓶颈
常规工具(天气/计算器) 直接可行
MCP 工具(McpClient→BaseTool 适配器) 可行 适配器模式优雅,MCP 流式/进度能力被 Value→Value 约束
Memory CRUDstore/recall/forget/update ⚠️ 基本可行 检索分页、大量结果返回需额外处理
长时运行工具(数据集查询、文件上传) 不可行 无进度汇报、无 cancellation 机制
多轮确认工具("是否冻结账户?"审批流程) 不可行 单次调用→单次返回,无法表达"反问→确认"模式
Skill 编排(多步骤组合、嵌套执行) 不可行 无上下文传播(跨步骤传递中间结果)、无工具组合原语
Agent 按场景筛选工具子集 ⚠️ 部分可行 无 tag/category 筛选机制

关键扩展点

A. BaseTool::execute() 签名——预留 ToolContext 注入

BaseTool 是公开 trait,一旦用户实现并发布 crate,后续 breaking change 成本极高。当前签名:

async fn execute(&self, args: Value) -> Result<Value, ToolError>;

未来扩展路径——新增 ToolContext 参数,携带执行上下文:

async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<Value, ToolError>;

ToolContext 初始应包含的字段(Phase 2 实现时不必全部实现,但签名需预留参数位置):

字段 用途 引入阶段
session_id: &str 追踪一次对话中所有工具调用的关联性 Phase 2
trace_id: &str 链路追踪,跨工具调用的耗时分布 Phase 2
cancellation_token: CancellationToken 优雅取消正在执行的工具 Phase 2
progress: Option<UnboundedSender<ProgressEvent>> 进度汇报(数据处理到 50% Phase 3
shared_state: Option<&HashMap<String, Value>> Skill 跨步骤传递中间结果 Phase 4

这样 Skill/Agent 层在 Phase 4 引入时,execute 签名不必改,只需在 ToolContext 中增加字段。

B. ToolRegistry 内部结构——引入 ToolEntry 元数据

当前内部是 HashMap<String, Arc<dyn BaseTool>>,未来扩展为:

pub struct ToolEntry {
    pub tool: Arc<dyn BaseTool>,
    pub tags: Vec<String>,
    pub category: String,        // "memory", "data", "communication" 等
    pub version: Option<String>,
    pub stats: ToolStats,        // 调用次数、平均耗时
}

对应的筛选 API

pub fn find_by_tag(&self, tag: &str) -> Vec<&ToolEntry>;
pub fn find_by_category(&self, category: &str) -> Vec<&ToolEntry>;
pub fn groups(&self) -> HashMap<&str, Vec<&ToolEntry>>;

C. 工具返回模式——从单一 ValueToolOutput 枚举

当前返回类型 Result<Value, ToolError> 只能表达"一次性完整返回"。未来根据需要引入多模式输出:

pub enum ToolOutput {
    /// 一次性返回完整结果
    Final(Value),
    /// 通过 channel 逐步流式输出结果
    Streamed { initial: Value, rx: Receiver<Value> },
    /// 需要 LLM 进一步确认后再继续
    AwaitingInput { context: Value, prompt: String },
}
返回模式 场景示例
Final(Value) 天气查询、文件读取
Streamed { initial, rx } 向量搜索 Top-100 逐批返回
AwaitingInput { context, prompt } "检测到可疑交易,是否冻结?"

各能力的引入时序

Phase 2(当前实现)
  ├─ BaseTool trait (Value→Value, 但签名预留 Context 参数位)
  ├─ ToolRegistry (HashMap<String, ToolEntry> + tag/category 筛选)
  ├─ PermissionChecker / McpClient / ToolError
  ├─ submit_with_tools() / submit_stream_with_tools()
  └─ ToolContext { session_id, trace_id, cancellation_token }

Phase 3Memory 工具化)
  ├─ MemoryStore trait(扩展 BaseTool
  ├─ memory_store / memory_recall / memory_search 等作为工具注册
  └─ ToolContext.progress 支持(分批返回检索结果)

Phase 4Agent + Skill + 编排)
  ├─ ToolContext.shared_state 支持(跨步骤传递中间结果)
  ├─ ToolOutput 枚举支持(如需要流式/确认模式)
  ├─ ToolChain / ToolSelector 工具组合原语
  └─ Skill 机制(多步骤编排 + 内部状态)

已识别但推迟的设计决策

决策 推迟原因 何时需要
ToolOutput 枚举 Phase 2 的所有场景(常规工具/MCP)用 Value 足够 Phase 4 Agent 编排或长时工具
工具 DAG 调度 Agent 场景后才需要复杂编排 Phase 4
Skill 机制 需要先有 Agent 使用工具的实践经验 Phase 4
工具调用审计持久化 可先通过 Hook 点实现简单日志 Phase 4
用户授权(运行时弹窗确认) PermissionChecker 只做静态策略判定,不处理运行时交互。用户授权属于交互流程,应作为 ToolOutput::AwaitingInput 由上层 UI/Agent 层实现 Phase 4

实现计划

Step 1: 创建方案文档

创建 docs/5-tool-system.md(即本文档)。

Step 2: ToolError

  • 创建 src/tools/error.rs
  • 定义 ToolError 枚举(NotFound / ExecutionFailed / InvalidArguments / PermissionDenied / McpError / McpTimeout / Io / Other
  • 运行 cargo check 验证

Step 3: Permission

  • 创建 src/tools/permission.rs
  • 定义 Permission 枚举 + PermissionConfig + PermissionChecker
  • 编写权限判定逻辑(白名单/黑名单/未指定策略)
  • 编写 5+ 边界测试覆盖:白名单模式、黑名单模式、空列表、自定义权限冲突
  • 运行 cargo test 验证

Step 4: BaseTool trait

  • 创建 src/tools/base.rs
  • 定义 BaseTool traitname / description / parameters / required_permissions / execute
  • 定义 ToolContext 结构体(session_id / trace_id / cancellation_token),注入 execute() 作为第二个参数
  • 创建 src/tools.rs 模块根,声明子模块,重导出公共 API
  • lib.rs 添加 pub mod tools;
  • 编写 1 个 MockTool 测试工具并验证 trait 实现
  • 运行 cargo check 验证

Step 5: ToolRegistry

  • 创建 src/tools/registry.rs
  • 定义 ToolInvocation 结构体 + ToolEntry 元数据包装(tool + tags + category + stats+ ToolRegistry
  • 实现核心方法:register / get / list / definitions / invoke / invoke_all / find_by_tag / find_by_category
  • invoke_all() 使用 futures::future::join_all 并行执行互不依赖的工具
  • definitions()HashMap 中的工具转换为 Vec<ToolDefinition>
  • 编写 8+ 测试覆盖:注册冲突、空注册表查找、单次调用、批量并行调用、工具执行失败
  • 运行 cargo test 验证

Step 6: LlmCycle 扩展(自动 Tool 循环)

  • 新增 cycle_submit.rs 子模块(或直接在 cycle.rs 中扩增,取决于代码量)
  • 提取 submit_request() 内部方法(将 submit() 中的 request→response 逻辑独立)
  • 实现 submit_with_tools() 方法:
    • 循环:submit_request → 检查 finish_reason → 调用 registry.invoke_all → 回传结果
    • max_turns 控制,达到上限返回错误
    • 工具执行错误以文本回传(LLM 可恢复)
  • 实现 submit_stream_with_tools() 方法:
    • 组合流式事件流和自动 tool 循环
    • 在 TurnComplete(ToolCalls) 后发射 ToolExecutionCompleted
  • 更新 CycleConfig 文档注释,新增 tool_timeout_secs 字段,默认值 60
  • CycleConfig::max_turns 默认值由 None 改为 Some(10)
  • 编写 3+ 集成测试:单轮 tool 调用、多轮 tool 调用、达到 max_turns 终止
  • 运行 cargo test 验证

Step 7: McpClientMCP 协议客户端)

  • 创建 src/tools/mcp.rs
  • 实现 JSON-RPC 消息结构(Request / Response / Error / Notification
  • 实现 stdio transport
    • connect():启动子进程,发送 initialize 请求
    • list_tools():调用 tools/list,缓存结果
    • call_tool():调用 tools/call,解析响应
    • close():发送 shutdown 请求,终止子进程
  • StreamableHttp transport 预留枚举变体,当前返回 "not implemented" 错误,不在 Phase 2 实现
  • 实现 into_tools():将 MCP 工具转换为 Vec<Arc<dyn BaseTool>> 适配器
  • 设置 30 秒默认超时
  • 编写 MCP 协议消息序列化/反序列化测试 + 模拟子进程集成测试
  • 运行 cargo test 验证

Step 8: 收尾

  • 更新 docs/roadmap.md 标记 Phase 2 完成
  • cargo clippy — 无警告
  • cargo build — 完整构建
  • 检查所有新公开 API 有 /// 文档注释
  • cargo test — 所有测试通过

术语表

术语 说明
BaseTool 工具抽象接口,所有工具需实现此 trait
ToolRegistry 工具注册表,管理工具注册、发现、调用
ToolInvocation 工具调用记录,包含输入、输出和执行结果
Permission 权限级别枚举(Read/Write/Delete/Network/Shell 等)
PermissionChecker 权限校验器,执行前判定是否允许
McpClient MCP 协议客户端,通过 stdio 与 MCP Server 通信
ToolDefinition 传递给 LLM 的工具定义(同 OpenaiToolDefinition
自动 Tool 循环 LlmCycle 自动执行 LLM 请求的工具调用并回传结果

风险评估

风险 概率 缓解措施
MCP 协议规范变化 只实现最小子集(initialize/list_tools/call_tool),封装在 mcp.rs 中便于适配
MCP 子进程异常退出 实现超时机制 + 错误恢复;进程退出时自动标记为不可用
工具执行死循环(LLM 反复调用工具) max_turns 硬限制,达到上限后终止循环
JSON-RPC 消息竞争(stdio 双工) 请求和响应通过 id 字段匹配,使用 Mutex 保护写操作 + HashMap<u64, OneshotSender> 等待响应,实现复杂度高于接口示意
权限配置过于复杂 PermissionConfig 提供合理默认值(允许 Read/Network,拒绝 Delete/Shell),简单场景无需自定义
工具调用参数类型不匹配 execute() 接收 Value,由实现方自行校验;通过 ToolError::InvalidArguments 返回结构化错误

验收标准

  1. cargo check 编译通过
  2. cargo clippy 无警告
  3. 模块文件路径正确:src/tools.rs + src/tools/{base,registry,permission,mcp,error}.rs
  4. BaseTool trait 可被自定义工具实现,name() / description() / parameters() / execute() 四个方法正常工作
  5. ToolRegistry 支持注册、查找、列出、注销操作
  6. ToolRegistry::definitions() 返回正确的 Vec<ToolDefinition>
  7. ToolRegistry::invoke() 执行工具前进行权限检查
  8. ToolRegistry::invoke_all() 并行执行多个工具调用
  9. PermissionChecker 根据配置正确判定权限(白名单/黑名单/默认策略)
  10. LlmCycle::submit_with_tools() 收到 FinishReason::ToolCalls 后自动执行工具并回传结果
  11. LlmCycle::submit_with_tools() 达到 max_turns 上限时终止并返回错误
  12. LlmCycle::submit_stream_with_tools() 在流式模式下发射 ToolExecutionCompleted 事件
  13. 自动 tool 循环产生的 Tool 消息正确追加到 cycle.messages()
  14. McpClient::connect() 能完成 MCP 协议握手(initialize
  15. McpClient::list_tools() 能获取 MCP Server 暴露的工具列表
  16. McpClient::call_tool() 能调用 MCP Server 的工具
  17. McpClient::into_tools() 能生成可供 ToolRegistry 注册的适配器
  18. 所有新公开 API 有文档注释
  19. 测试覆盖率:cargo test 全部通过
  20. BaseTool::execute() 签名通过 ToolContext 参数预留了扩展点(session_id、cancellation_token),未来 Skill/Agent 层可在不修改 trait 签名的情况下注入上下文