「写给读者的话」本系列文章记录了笔者在学习 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 | 压缩历史,省 Token | services/chatCompressionService.ts |
| Scheduler | 批量调度工具调用 | scheduler/scheduler.ts |
| ModelRouterService | 模型路由,支持 auto 模式 | routing/modelRouterService.ts |
| ModelConfigService | 模型配置解析 | services/modelConfigService.ts |
| MessageBus | 事件总线,工具确认、状态同步 | confirmation-bus/message-bus.ts |
| promptIdContext | AsyncLocalStorage 管 promptId | utils/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 消息与工具调用
Content、FunctionCall 这些来自 @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 终止条件
三种情况会跳出循环:
- 轮次超限:
turnCounter超过maxTurns - 超时:
timeoutController.signal.aborted - 用户取消:外部传入的
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 才是真正干活的。流程大致是:
- 压缩历史:
tryCompressChat(),省 Token - 调模型:
callModel(),拿functionCalls和textResponse - 检查协议:如果
functionCalls为空,说明模型没调工具就停了 → 直接返回ERROR_NO_COMPLETE_TASK_CALL - 执行工具:
processFunctionCalls() - 判断是否完成:如果调了
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 配置了 outputConfig,complete_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 里的设计亮点捋一捋:
| 设计亮点 | 核心机制 | 价值 |
|---|---|---|
| outputConfig | Zod Schema 校验输出 | 类型安全,校验失败不认完成 |
| Recovery | 超时/轮次限制后给 60 秒宽限期 | 提高完成率,减少假失败 |
| 历史压缩 | 每轮前自动压缩 | 省 Token,防上下文溢出 |
| promptIdContext | AsyncLocalStorage | 免传参,支持嵌套追踪 |
| 双重 AbortSignal | 合并外部 + 超时 | 灵活的中断控制 |
| 工具隔离 | 每 Agent 独立 ToolRegistry | 防冲突、防权限泄露 |
| 协议强制 | 必须调工具,不能只说话 | 确保 Agent 可执行 |
| 批量工具执行 | Scheduler | 并行 + 状态管理 |
| 顺序保证 | Map 存结果 + 按序重建 | 模型收到的顺序正确 |
| 模型路由 | auto 模式 | 按上下文选模型 |
如果你在啃 Agent Loop 的源码,希望这篇能帮你少走点弯路。有问题欢迎一起讨论。
6. 参考文献
- Gemini CLI 官方文档:geminicli.com/docs/
- Gemini CLI GitHub 仓库:github.com/google-gemi…
- Gemini CLI 架构文档:github.com/google-gemi…
- Gemini API 文档:ai.google.dev/gemini-api/…
- Vertex AI 文档:docs.cloud.google.com/vertex-ai/g…
- MCP (Model Context Protocol):modelcontextprotocol.io/
- Zod Schema 验证库:zod.dev/
- AsyncLocalStorage API:nodejs.org/api/async_c…
- AbortSignal API:developer.mozilla.org/en-US/docs/…
- Node.js Streams:nodejs.org/api/stream.…
- ReAct Agent Design:www.ibm.com/think/topic…