【读Gemini CLI源码,品Agent架构设计】系列文章(一) —— Agent Loop设计与实现

0 阅读8分钟

「写给读者的话」本系列文章记录了笔者在学习 Gemini CLI 源码过程中的点点滴滴,更多从代码实现细节中学习如何设计一个优秀的代码 Agent,希望对大家有帮助。


阅读导读:带着这些问题来读,或许能更有收获——

  • 想了解 Agent 如何在多轮交互中完成任务
  • 是否还在困惑 Agent Loop 何时终止
  • 模型调用 是如何实现流式响应
  • 为什么 Agent 必须调用工具,不能只返回文本?complete_task 工具在其中扮演什么角色?
  • 工具调用 是如何被批量执行、排序,并保证模型收到的结果顺序正确的?

1. 执行机制 —— 核心循环解析

Agent Loop 是 Gemini CLI 的心脏。说白了,就是:接收用户输入 → 调模型 → 执行工具 → 处理结果 → 再来一轮,直到任务完成。

你可以把它理解成一个状态机,在几个状态之间来回跳:

初始化 → 执行轮次 → 检查终止条件 → [继续/完成]
         ↓
    调用模型
         ↓
    解析响应
         ↓
    执行工具
         ↓
    更新状态

熟悉 ReAct 的同学会觉得很眼熟——没错,这就是典型的 Reasoning + Acting 模式:

  • 推理callModel() 调模型,让它决定要调用哪些工具
  • 行动processFunctionCalls() 真正执行这些工具
  • 观察:把工具执行结果塞回对话,作为下一轮的输入
  • 循环:重复上述过程

源码在 LocalAgentExecutor 里,核心类结构大致长这样:

export class LocalAgentExecutor<TOutput extends z.ZodTypeAny> {
  readonly definition: LocalAgentDefinition<TOutput>;
  // ... 省略字段

  async run(inputs: AgentInputs, signal: AbortSignal): Promise<OutputObject>
  private async executeTurn(...): Promise<AgentTurnResult>
  private async callModel(...): Promise<{ functionCalls, textResponse }>
  private async processFunctionCalls(...): Promise<{ nextMessage, taskCompleted }>
}

外部怎么用?很简单:

const executor = await LocalAgentExecutor.create(agentDefinition, config);
const result = await executor.run({ task: "分析代码库结构" }, abortSignal);
// 返回 { result: "...", terminate_reason: "GOAL" }

2. 主要依赖 —— 谁在干活

Agent Loop 不是单打独斗,背后有一堆组件在撑腰。我整理了个表,方便你快速定位:

组件职责位置
Config全局配置,各种服务的统一入口config/config.ts
GeminiChat管聊天历史、发模型请求core/geminiChat.ts
ToolRegistry管工具注册和执行,每个 Agent 一份tools/tool-registry.ts
ChatCompressionService压缩历史,省 Tokenservices/chatCompressionService.ts
Scheduler批量调度工具调用scheduler/scheduler.ts
ModelRouterService模型路由,支持 auto 模式routing/modelRouterService.ts
ModelConfigService模型配置解析services/modelConfigService.ts
MessageBus事件总线,工具确认、状态同步confirmation-bus/message-bus.ts
promptIdContextAsyncLocalStorage 管 promptIdutils/promptIdContext.ts

3. 关键数据结构

3.1 执行结果

AgentTurnResult:单轮执行的结果,要么继续,要么停。

type AgentTurnResult =
  | { status: 'continue'; nextMessage: Content }
  | { status: 'stop'; terminateReason: AgentTerminateMode; finalResult: string | null };

OutputObject:整个 Agent 跑完后的最终输出。

interface OutputObject {
  result: string;
  terminate_reason: AgentTerminateMode;
}

3.2 终止模式

Agent 为啥停?有六种可能:

  • GOAL:任务完成
  • TIMEOUT:超时
  • MAX_TURNS:轮数用完了
  • ABORTED:用户手动取消
  • ERROR:出错了
  • ERROR_NO_COMPLETE_TASK_CALL:模型没调 complete_task 就停了——这在 Gemini CLI 里算协议违规

3.3 Agent 定义

LocalAgentDefinition 就是 Agent 的「配置单」:

interface LocalAgentDefinition<TOutput extends z.ZodTypeAny> {
  name: string;
  description: string;
  promptConfig: PromptConfig;
  modelConfig: ModelConfig;
  runConfig: RunConfig;           // 超时、最大轮次等
  toolConfig?: ToolConfig;
  outputConfig?: OutputConfig<TOutput>;  // 结构化输出
  processOutput?: (output: z.infer<TOutput>) => string;
}

3.4 消息与工具调用

ContentFunctionCall 这些来自 @google/genai,不赘述。

FunctionCall 示例(模型返回的工具调用):

{ id: 'call_001', name: 'list_files', args: { path: '/src', recursive: true } }
{ id: 'call_002', name: 'read_file', args: { path: '/src/app.ts' } }

ToolCallRequestInfo 是内部用的,多了一些追踪字段(callId、prompt_id、parentCallId 等),方便在嵌套调用里定位。


4. 执行流程

4.1 初始化

run() 之前,会先做几件事:创建执行器、注册工具、准备上下文、创建 GeminiChat、解析任务生成首条用户消息。

首条用户消息的生成逻辑:

const query = this.definition.promptConfig.query
  ? templateString(this.definition.promptConfig.query, augmentedInputs)
  : DEFAULT_QUERY_STRING;
let currentMessage: Content = { role: 'user', parts: [{ text: query }] };

combinedSignal 的合并方式——把外部取消和超时定时器绑在一起:

const combinedSignal = AbortSignal.any([signal, timeoutController.signal]);

4.2 主循环:一个 while (true)

主循环的骨架很直白:

while (true) {
  // 1. 先看能不能停
  const reason = this.checkTermination(startTime, turnCounter);
  if (reason) { terminateReason = reason; break; }
  if (combinedSignal.aborted) { /* 超时或用户取消 */ break; }

  // 2. 执行这一轮
  const turnResult = await this.executeTurn(chat, currentMessage, turnCounter++, ...);

  // 3. 处理结果
  if (turnResult.status === 'stop') {
    terminateReason = turnResult.terminateReason;
    finalResult = turnResult.finalResult;
    break;
  }

  // 4. 准备下一轮
  currentMessage = turnResult.nextMessage;
}

combinedSignal 值得提一嘴:它把「外部取消」和「超时定时器」合并成一个 AbortSignal,这样无论是用户按 Ctrl+C 还是跑超了,都能统一处理。个人觉得这种设计挺干净。

4.2.1 终止条件

三种情况会跳出循环:

  1. 轮次超限turnCounter 超过 maxTurns
  2. 超时timeoutController.signal.aborted
  3. 用户取消:外部传入的 signal.aborted

终止条件的判断逻辑:

const reason = this.checkTermination(startTime, turnCounter);
if (reason) { terminateReason = reason; break; }
if (combinedSignal.aborted) {
  terminateReason = timeoutController.signal.aborted
    ? AgentTerminateMode.TIMEOUT
    : AgentTerminateMode.ABORTED;
  break;
}

4.2.2 Recovery —— 最后一搏

这是我觉得设计得比较巧妙的地方。当 Agent 因为超时达到最大轮次、或者没调 complete_task 就停了而退出时,不会立刻放弃,而是给一次「恢复机会」:

  • 发一条警告消息给模型:「你快要超时了,赶紧调用 complete_task」
  • 启动一个 60 秒宽限期
  • 再执行一轮,看模型能不能完成任务

不可恢复的情况只有三种:已经 GOAL、用户 ABORTED、或者 ERROR。这种设计能明显提高任务完成率,尤其是网络慢、模型「磨叽」的时候。

Recovery 的触发条件和执行逻辑:

// 可恢复:TIMEOUT / MAX_TURNS / ERROR_NO_COMPLETE_TASK_CALL
if (
  terminateReason !== AgentTerminateMode.ERROR &&
  terminateReason !== AgentTerminateMode.ABORTED &&
  terminateReason !== AgentTerminateMode.GOAL
) {
  const recoveryResult = await this.executeFinalWarningTurn(chat, turnCounter, terminateReason, signal);
  if (recoveryResult !== null) {
    terminateReason = AgentTerminateMode.GOAL;
    finalResult = recoveryResult;
  }
}

executeFinalWarningTurn 内部会发警告消息、启动 60 秒宽限期、再执行一轮,成功则返回 complete_task 的结果。

4.3 每轮执行 —— executeTurn

executeTurn 才是真正干活的。流程大致是:

  1. 压缩历史tryCompressChat(),省 Token
  2. 调模型callModel(),拿 functionCallstextResponse
  3. 检查协议:如果 functionCalls 为空,说明模型没调工具就停了 → 直接返回 ERROR_NO_COMPLETE_TASK_CALL
  4. 执行工具processFunctionCalls()
  5. 判断是否完成:如果调了 complete_task,返回 GOAL;否则返回 nextMessage 继续下一轮

关键代码片段:

// 生成 promptId,压缩历史
const promptId = `${this.agentId}#${turnCounter}`;
await this.tryCompressChat(chat, promptId);

// AsyncLocalStorage 传递 promptId,回调内可通过 getStore() 获取
const { functionCalls } = await promptIdContext.run(promptId, async () =>
  this.callModel(chat, currentMessage, combinedSignal, promptId),
);

// 协议强制:没调工具就停 = 违规
if (functionCalls.length === 0) {
  return { status: 'stop', terminateReason: AgentTerminateMode.ERROR_NO_COMPLETE_TASK_CALL, finalResult: null };
}

const { nextMessage, submittedOutput, taskCompleted } =
  await this.processFunctionCalls(functionCalls, combinedSignal, promptId);

promptIdContext 用的是 Node 的 AsyncLocalStorage,在异步调用链里传递 promptId,不用一层层传参。如果你写过需要「请求 ID 贯穿全链路」的代码,应该能体会这种写法的爽感。

4.4 模型调用:callModel

callModel 做三件事:选模型发流式请求解析响应

4.4.1 模型路由

支持指定模型和 auto 模式。auto 时:

const routingContext: RoutingContext = {
  history: chat.getHistory(/*curated=*/ true),
  request: message.parts || [],
  signal,
  requestedModel,
};
const decision = await router.route(routingContext);
modelToUse = decision.model;

失败就回退到 DEFAULT_GEMINI_MODEL。路由的细节后续再深入分析。

4.4.2 流式请求

const responseStream = await chat.sendMessageStream(
  { model: modelToUse, overrideScope: this.definition.name },
  message.parts || [],
  promptId,
  signal,
);
for await (const resp of responseStream) {
  if (signal.aborted) break;
  // 处理 CHUNK...
}

流式的好处不用多说:首字节快、能中途取消、体验好。

4.4.3 分块解析

每个 chunk 里可能有三类内容,核心解析逻辑:

if (resp.type === StreamEventType.CHUNK) {
  const chunk = resp.value;
  const parts = chunk.candidates?.[0]?.content?.parts;
  const { subject } = parseThought(parts?.find((p) => p.thought)?.text || '');
  if (subject) this.emitActivity('THOUGHT_CHUNK', { text: subject });
  if (chunk.functionCalls) functionCalls.push(...chunk.functionCalls);
  const text = parts?.filter((p) => !p.thought && p.text).map((p) => p.text).join('') || '';
  if (text) textResponse += text;
}
类型处理方式
thought解析 subject,emitActivity('THOUGHT_CHUNK') 实时展示
functionCalls直接 push 到数组,最后一起返回
text拼接非 thought 的文本

最终返回 { functionCalls, textResponse },供后续 processFunctionCalls 使用。

4.5 工具调用 —— processFunctionCalls

4.5.1 输入输出

输入是 functionCalls 数组,输出是 nextMessage(工具结果拼成的 Content)、submittedOutput(complete_task 的返回值)、taskCompleted(是否完成)。

返回值结构示例(任务未完成时):

{
  nextMessage: { role: 'user', parts: [{ functionResponse: { name: 'list_files', response: {...}, id: 'call_001' } }, ...] },
  submittedOutput: null,
  taskCompleted: false
}

工具可以并行执行,但返回给模型时必须按原始顺序。实现方式是用 syncResults(Map)存结果,最后按 functionCalls 的顺序重建 toolResponseParts。这个细节很容易被忽略,但很重要——模型依赖顺序做推理。

4.5.2 outputConfig 与 complete_task

如果 Agent 配置了 outputConfigcomplete_task 的返回值会先走 Zod 校验:

const validationResult = outputConfig.schema.safeParse(outputValue);
if (!validationResult.success) {
  // 校验失败,返回错误给模型,不标记完成
  return { /* error response */ };
}
const validatedOutput = validationResult.data;
submittedOutput = this.definition.processOutput?.(validatedOutput) ?? JSON.stringify(validatedOutput);

校验不过,任务不算完成,会返回错误给模型。校验过了,还可以通过 processOutput 做二次处理。这样既保证了结构化输出的类型安全,又留了扩展空间。

4.5.3 顺序保证

const toolResponseParts: Part[] = [];
for (const [index, functionCall] of functionCalls.entries()) {
  const callId = functionCall.id ?? `${promptId}-${index}`;
  const part = syncResults.get(callId);
  if (part) toolResponseParts.push(part);
}

并行执行,顺序重建。简单有效。


5. 结语

最后用一张表收个尾,把 Agent Loop 里的设计亮点捋一捋:

设计亮点核心机制价值
outputConfigZod Schema 校验输出类型安全,校验失败不认完成
Recovery超时/轮次限制后给 60 秒宽限期提高完成率,减少假失败
历史压缩每轮前自动压缩省 Token,防上下文溢出
promptIdContextAsyncLocalStorage免传参,支持嵌套追踪
双重 AbortSignal合并外部 + 超时灵活的中断控制
工具隔离每 Agent 独立 ToolRegistry防冲突、防权限泄露
协议强制必须调工具,不能只说话确保 Agent 可执行
批量工具执行Scheduler并行 + 状态管理
顺序保证Map 存结果 + 按序重建模型收到的顺序正确
模型路由auto 模式按上下文选模型

如果你在啃 Agent Loop 的源码,希望这篇能帮你少走点弯路。有问题欢迎一起讨论。


6. 参考文献

  1. Gemini CLI 官方文档:geminicli.com/docs/
  2. Gemini CLI GitHub 仓库:github.com/google-gemi…
  3. Gemini CLI 架构文档:github.com/google-gemi…
  4. Gemini API 文档:ai.google.dev/gemini-api/…
  5. Vertex AI 文档:docs.cloud.google.com/vertex-ai/g…
  6. MCP (Model Context Protocol):modelcontextprotocol.io/
  7. Zod Schema 验证库:zod.dev/
  8. AsyncLocalStorage API:nodejs.org/api/async_c…
  9. AbortSignal API:developer.mozilla.org/en-US/docs/…
  10. Node.js Streams:nodejs.org/api/stream.…
  11. ReAct Agent Design:www.ibm.com/think/topic…