Agent 核心循环
Gemini CLI — Agent 核心循环
学习目标
- 理解 GeminiClient 的
sendMessageStream如何构建 Agent 循环 - 掌握 AsyncGenerator 事件流驱动的 turn 执行模型
- 分析模型粘滞性和防循环检测机制
前置知识
本章涉及 Agent 核心循环的通用原理,建议先阅读:
下文假设你已理解 Session/Turn 两层循环模型和 SQ/EQ 事件驱动架构,直接聚焦 Gemini CLI 的具体实现。
项目实践
核心循环入口
Gemini CLI 的 Agent 循环入口是 GeminiClient.sendMessageStream()(packages/core/src/core/client.ts),它返回一个 AsyncGenerator<ServerGeminiStreamEvent, Turn>。调用方通过 for await...of 消费事件流,而非传统的回调或 Promise 链。
sendMessageStream(request, signal, prompt_id, turns=MAX_TURNS) ├── 1. BeforeAgent Hook(可选拦截/注入上下文) ├── 2. processTurn(request, signal, prompt_id, boundedTurns) │ ├── history 管理 / 压缩检查 │ ├── 模型路由 / 模型粘滞性 │ ├── turn.run() → 调用 Gemini API,流式返回 │ ├── 工具调用 → Scheduler 调度执行 │ └── 循环判断:有工具调用则继续,否则返回 └── 3. AfterAgent Hook(可选停止/继续)MAX_TURNS 硬限值为 100,防止无限循环。
AsyncGenerator 事件流模型
与传统的 while-loop 不同,Gemini CLI 使用 AsyncGenerator 作为事件流载体:
async *processTurn(...) { const turn = new Turn(this.getChat(), prompt_id);
// 调用模型,获取流式结果 const resultStream = turn.run(modelConfigKey, request, signal, {...});
for await (const event of resultStream) { // 检测循环 const loopResult = this.loopDetector.addAndCheck(event); if (loopResult.detected) { ... }
// 检测工具调用 if (event.hasToolCalls) { yield event; // 通过 Scheduler 执行工具 const results = await scheduler.schedule(toolCalls, signal); // 将工具结果回传给模型,继续循环 turn.addToolResults(results); continue; // 模型会再次响应 }
yield event; }
return turn;}关键设计:
- 事件流中同时包含内容块和工具调用请求,调用方无需区分处理
continue驱动循环:当有工具调用时,工具结果作为新消息回传,模型再次响应- 当模型不再调用工具时,循环自然结束
模型粘滞性(Sequence Stickiness)
Gemini CLI 维护一个 currentSequenceModel 字段:
if (this.currentSequenceModel) { // 保持使用已选中的模型 modelToUse = this.currentSequenceModel;} else { // 首次 turn,通过 Router 选择模型 const decision = await router.route(routingContext); modelToUse = decision.model;}this.currentSequenceModel = modelToUse;为什么需要粘滞性:
- 避免同一对话中模型频繁切换导致的行为不一致
- 保持提供商缓存温暖(同一模型连续调用命中率更高)
- 减少路由开销(每次路由需要运行分类器)
粘滞性何时解除:
- 用户显式切换模型(
-m参数或/model命令) - 会话重置(
/new或/reset) - 模型不可用时(fallback 触发)
防循环检测
LoopDetectionService 在两个阶段检测循环:
- turnStarted:Turn 开始前检查当前 prompt 的历史是否已有相似状态
- addAndCheck:Turn 执行中,逐个事件检查是否进入重复模式
检测结果分级处理:
| 检测级别 | 处理方式 |
|---|---|
| count > 1 | 直接终止当前 turn,报告 LoopDetected 事件 |
| count == 1 且 boundedTurns <= 1 | 报告 MaxSessionTurns,无剩余 turn 可用 |
| count == 1 且 boundedTurns > 1 | 调用 _recoverFromLoop,注入引导文本让模型改变行为 |
_recoverFromLoop 的具体做法是压缩最近的历史(保留关键工具调用和结果),然后添加引导文本(如 “你似乎陷入了循环。请尝试不同的方法。”),让模型在新上下文中重新思考。
BeforeAgent / AfterAgent 钩子生命周期
sendMessageStream 前后分别触发 BeforeAgent 和 AfterAgent 钩子:
BeforeAgent Hook:
- 可以阻止执行(返回
AgentExecutionStopped) - 可以注入额外上下文(返回
additionalContext,包装在<hook_context>标签中)
AfterAgent Hook:
- 可以停止执行(
shouldStopExecution()→ 返回AgentExecutionStopped) - 可以请求继续(
isBlockingDecision()→ 自动递归调用sendMessageStream,触发新一轮 BeforeAgent) - 请求继续时,会重置
hasFiredBeforeAgent状态,确保钩子不会重复触发
hookStateMap 管理每个 prompt_id 的钩子状态:
hasFiredBeforeAgent:防止 BeforeAgent 钩子重复触发activeCalls:追踪当前活跃的钩子调用数
问题与规避
AsyncGenerator 的取消处理
问题:AsyncGenerator 本身不支持取消,AbortSignal 需要在循环内部显式检查。
对策:Gemini CLI 在所有关键点检查 signal.aborted:
- turn 开始前
- 模型调用中(通过
abortSignal传给 SDK) - 工具调度中(通过
signal传给 Scheduler) - 循环判断后
模型粘滞性与回退冲突
问题:当粘滞的模型不可用时,fallback 会切换模型,但 currentSequenceModel 仍指向旧模型。
对策:handleModelChanged 事件监听器在模型切换时重置 currentSequenceModel = null,强制下一次 turn 重新路由。
Hook 递归深度
问题:AfterAgent 的 isBlockingDecision() 会递归调用 sendMessageStream,理论上可形成深度嵌套。
对策:boundedTurns 逐层递减(boundedTurns - 1),防止无限递归。同时 stopHookActive 标志位告知 AfterAgent 钩子这是重试调用,避免重复决策。
设计取舍
AsyncGenerator vs SQ/EQ 队列
| 方案 | 优势 | 代价 |
|---|---|---|
| AsyncGenerator | 消费方代码简洁(for await...of),天然支持流式 UI | 生成方需要手动 yield 事件,取消和错误传播需要小心处理 |
| SQ/EQ 队列 | 双向通信更灵活,支持并发操作 | 消费方需要管理队列状态,代码复杂度高 |
Gemini CLI 的选择:使用 AsyncGenerator 作为事件输出通道,但内部通过 MessageBus 实现双向通信(确认、取消等),结合了两种模式的优点。
100 Turn 硬限值的合理性
| 方案 | 优势 | 代价 |
|---|---|---|
| 固定上限(如 100) | 简单、可预测 | 可能在复杂任务中过早终止 |
| 动态上限 | 根据任务复杂度调整 | 难以估计复杂度,可能设置过高 |
| 无上限 | 理论上可以完成任意复杂任务 | 实际中几乎总是导致无限循环或 token 耗尽 |
Gemini CLI 的选择:100 是一个经验值——足够完成大多数编程任务,又不会让失控的 Agent 消耗过多资源。用户可通过 --max-session-turns 自定义。