事件驱动的工具调度
Gemini CLI — 事件驱动的工具调度
学习目标
- 理解 Scheduler 的事件驱动状态机架构
- 掌握并行工具识别和批量执行机制
- 分析工具生命周期(Validating → Scheduled → Executing → Terminal)
前置知识
本章涉及工具调用的通用原理,建议先阅读:
下文假设你已理解工具调用的声明式定义模式和两阶段(build → execute)流程,直接聚焦 Gemini CLI 的调度实现。
项目实践
Scheduler 的角色定位
Scheduler 是 Gemini CLI 中连接 LLM 响应和工具执行的桥梁。当模型返回 functionCall 后,GeminiClient 将这些调用交给 Scheduler 处理:
模型响应: [functionCall: read_file, functionCall: grep] ↓GeminiClient.scheduler.schedule(calls, signal) ↓Scheduler: 验证 → 策略检查 → 执行 → 返回结果 ↓GeminiClient: 将结果回传给模型工具状态机
每个工具调用经历以下状态流转:
状态说明:
| 状态 | 职责 | 关键操作 |
|---|---|---|
| Validating | 参数验证 + Policy 检查 + 用户确认 | 构建 ToolInvocation,检查 Policy,等待用户确认(如需) |
| Scheduled | 等待执行 | 策略通过,等待队列调度 |
| Executing | 正在执行 | 调用工具的 execute() 方法 |
| Success/Error/Cancelled | 终态 | 记录结果,从活跃集合移除 |
三阶段处理循环
Scheduler 的 _processQueue() 方法实现核心处理循环:
_processQueue(signal): while queueLength > 0 || hasActiveCalls: _processNextItem(signal) ├── Phase 1: 处理 Validating 状态 │ └── Policy 检查 + 用户确认 ├── Phase 2: 执行 Scheduled 状态 │ └── 并行执行所有已调度的工具 └── Phase 3: 终结终态调用 └── finalize 并记录结果关键特征:每次循环迭代处理所有可推进的调用,而非一次只处理一个。这使得:
- 多个工具可以在同一次迭代中并行执行
- 状态变化在循环内立即可见,无需等待下一轮
并行工具识别
当队列中第一个工具可并行时,Scheduler 会批量取出所有连续的可并行工具:
_isParallelizable(request): return request.parallelizable && !request.requiresSerialExecution
if _isParallelizable(firstRequest): while peekQueue() && _isParallelizable(peeked): dequeue() // 批量取出// 现在所有取出的工具将并行执行并行条件:
- 工具的
parallelizable标记为 true - 不要求串行执行(
requiresSerialExecution为 false)
典型并行工具:read_file、glob、grep、ls —— 纯读取操作,无副作用。
典型串行工具:write_file、edit、shell —— 修改文件系统或执行命令,有副作用。
请求队列机制
当 Scheduler 正在处理一批工具时,新的工具调用会被入队:
schedule(requests, signal): if isProcessing || isActive: return _enqueueRequest(requests, signal) // 排队等待 else: return _startBatch(requests, signal) // 立即开始队内请求在 _processNextInRequestQueue() 中处理:当前批次完成后,自动启动下一批。这保证了工具调用不会交错执行(不会出现批次 A 执行到一半、批次 B 的工具也开始执行的情况)。
Topic 变更优先排序
同一批次中,UPDATE_TOPIC_TOOL_NAME(主题变更工具)会优先处理:
sortedRequests.sort((a, b) => { if (a.name === UPDATE_TOPIC_TOOL_NAME) return -1; if (b.name === UPDATE_TOPIC_TOOL_NAME) return 1; return 0;});为什么:主题变更可能影响后续工具的上下文或策略,先处理确保后续工具使用正确的主题。
MCP 进度追踪
Scheduler 通过 CoreEvent.McpProgress 事件接收 MCP 工具的进度更新,实时更新执行状态:
handleMcpProgress(payload): call = state.getToolCall(payload.callId) if call && call.status === Executing: state.updateStatus(callId, Executing, { progressMessage: payload.message, progressPercent: (payload.progress / payload.total) * 100, })这使得长时间运行的 MCP 操作(如远程 API 调用)可以向用户展示进度。
问题与规避
工具状态机的死锁
问题:如果 Validating 状态的工具等待用户确认,但确认请求未能传递到 UI,工具将永久挂起。
对策:MessageBus 发布 TOOL_CONFIRMATION_REQUEST 事件,UI 层监听并展示确认对话框。超时或取消后,Scheduler 的 cancelAll() 将所有等待中的工具标记为 Cancelled。
并行工具的副作用冲突
问题:两个并行工具同时操作同一文件可能导致竞争条件。
对策:有副作用的工具(write、edit、shell)标记为 parallelizable: false,不会被批量取出。但如果模型同时请求两个写操作(理论上不应发生),Scheduler 不会检测文件级冲突——这依赖于模型正确排序工具调用。
取消的延迟传播
问题:signal.aborted 后,正在执行的工具不会立即停止。
对策:cancelAll() 将所有非终态工具标记为 Cancelled,但实际执行中的工具需要自行检查 AbortSignal。部分工具(如 shell 执行)在收到 abort 后会发送 SIGKILL 终止子进程。
设计取舍
事件驱动状态机 vs 顺序执行
| 方案 | 优势 | 代价 |
|---|---|---|
| 事件驱动状态机 | 支持并行、可扩展到多 Agent、状态可追踪 | 实现复杂,状态流转需要精细管理 |
| 顺序执行 | 简单、易于调试 | 不支持并行、无法利用无依赖工具的并行性 |
Gemini CLI 的选择:使用状态机是因为工具间存在复杂的依赖关系(确认、策略、并行),顺序执行无法满足性能需求。
批量并行 vs 逐个执行
| 方案 | 优势 | 代价 |
|---|---|---|
| 批量并行 | 无依赖工具同时执行,减少延迟 | 需要正确识别依赖关系,错误分类会导致竞争 |
| 逐个执行 | 永远不会出现竞争 | 延迟累加,多工具调用场景下体验差 |
Gemini CLI 的选择:连续的可并行工具批量执行,串行工具(有副作用的)逐个执行。这在安全和性能之间取得了平衡。