更新工具系统方案文档,补充 Token 消耗分析、错误分类策略、`submit_request()` 重构说明及流式事件职责划分,新增第9节未来扩展性分析,明确 `ToolContext` 注入、`ToolEntry` 元数据、 `ToolOutput` 枚举等扩展路径,更新实现计划以保持与设计一致
37 KiB
Phase 2: Tool System — 方案设计
定稿日期:2026-06-03
背景与目标
AG Core Phase 0(Foundation)已完成 LLM 调用周期基础设施,Phase 1(Prompt 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()调用 BaseTool的execute()必须为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()接收Value(JSON 对象)作为参数,返回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():查找工具 → 权限检查 → 执行 → 返回ToolInvocationinvoke_all():对多个工具调用并行执行(使用tokio::join!或futures::join_all),适用于 LLM 同时发出多个 tool_calls 的场景invoke_all()应对每个工具执行添加超时控制(通过tokio::time::timeout),超时时间由CycleConfig::tool_timeout_secs配置,默认 60 秒,防止单个工具长时间阻塞整个循环definitions():将注册的工具批量转换为Vec<ToolDefinition>,供 LlmCycle 传递 LLMToolRegistry不持有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>;
}
权限判定规则:
- 如果权限在
denied中 → 拒绝 - 如果权限在
allowed中 → 允许 - 如果
allowed非空且权限不在其中 → 拒绝(白名单模式) - 如果
allowed为空 → 按allow_unspecified判定
4. McpClient — MCP 协议客户端
MCP(Model Context Protocol)是一种基于 JSON-RPC 的协议,用于 LLM 与外部工具系统通信。Phase 2 实现其 最小可行子集,优先实现 stdio transport。
传输方式说明:MCP 协议版本 2025-03-26 定义了两种标准传输——
stdio和Streamable 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 的启动命令和参数
工具缓存说明:
McpClient在list_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_calls(parallel_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% | 工具结果包含页面内容 |
| 多工具数据 pipeline(5-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 统一暴露给 LLM,ToolRegistry 作为唯一的工具发现和调用入口,对 LlmCycle 透明。
各场景支持度评估
| 场景 | 当前支持度 | 关键瓶颈 |
|---|---|---|
| 常规工具(天气/计算器) | ✅ 直接可行 | 无 |
| MCP 工具(McpClient→BaseTool 适配器) | ✅ 可行 | 适配器模式优雅,MCP 流式/进度能力被 Value→Value 约束 |
| Memory CRUD(store/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. 工具返回模式——从单一 Value 到 ToolOutput 枚举
当前返回类型 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 3(Memory 工具化)
├─ MemoryStore trait(扩展 BaseTool)
├─ memory_store / memory_recall / memory_search 等作为工具注册
└─ ToolContext.progress 支持(分批返回检索结果)
Phase 4(Agent + 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 - 定义
BaseTooltrait(name / 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: McpClient(MCP 协议客户端)
- 创建
src/tools/mcp.rs - 实现 JSON-RPC 消息结构(Request / Response / Error / Notification)
- 实现 stdio transport:
connect():启动子进程,发送 initialize 请求list_tools():调用 tools/list,缓存结果call_tool():调用 tools/call,解析响应close():发送 shutdown 请求,终止子进程
StreamableHttptransport 预留枚举变体,当前返回 "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 返回结构化错误 |
验收标准
cargo check编译通过cargo clippy无警告- 模块文件路径正确:
src/tools.rs+src/tools/{base,registry,permission,mcp,error}.rs BaseTooltrait 可被自定义工具实现,name()/description()/parameters()/execute()四个方法正常工作ToolRegistry支持注册、查找、列出、注销操作ToolRegistry::definitions()返回正确的Vec<ToolDefinition>ToolRegistry::invoke()执行工具前进行权限检查ToolRegistry::invoke_all()并行执行多个工具调用PermissionChecker根据配置正确判定权限(白名单/黑名单/默认策略)LlmCycle::submit_with_tools()收到FinishReason::ToolCalls后自动执行工具并回传结果LlmCycle::submit_with_tools()达到max_turns上限时终止并返回错误LlmCycle::submit_stream_with_tools()在流式模式下发射ToolExecutionCompleted事件- 自动 tool 循环产生的 Tool 消息正确追加到
cycle.messages() McpClient::connect()能完成 MCP 协议握手(initialize)McpClient::list_tools()能获取 MCP Server 暴露的工具列表McpClient::call_tool()能调用 MCP Server 的工具McpClient::into_tools()能生成可供ToolRegistry注册的适配器- 所有新公开 API 有文档注释
- 测试覆盖率:
cargo test全部通过 BaseTool::execute()签名通过ToolContext参数预留了扩展点(session_id、cancellation_token),未来 Skill/Agent 层可在不修改 trait 签名的情况下注入上下文