源码版本:
google-gemini/gemini-climain 分支(@google/gemini-cliv0.69.0-nightly,2026-11 快照),Apache-2.0 开源。 仓库地址:github.com/google-gemi...
一、开场故事:住在雪山机房里的"失明天才"
先讲个故事。
想象有一位天才,被关在瑞士雪山机房的一个纯文本房间里。他有几个特点:
- 聪明绝顶------读过的书比图书馆还多,什么都懂;
- 没有眼睛、没有手------既看不见你的截图,也敲不了你的键盘;
- 只会干一件事:收纸条、写纸条------窗口递进来一张纸条,他写一张递出去,仅此而已。
现在你让他帮忙:"帮我看看 cat.png 里是什么猫,然后把仓库里的测试跑一遍,失败了就修。"
他怎么办?他看不到图,跑不了测试,也改不了文件。
但他能写字。于是整个游戏变成了这样:
- 你提前塞给他一张工具菜单 ,上面写着:"本店提供
read_file、run_shell_command等服务,需要什么参数请写在纸条上。" - 他想看图,就在纸条上写:
{菜名: read_file, 参数: {路径: cat.png}} - 服务员(gemini-cli 程序本体)看到纸条,替他去读文件、跑命令;
- 干完后把结果写成一张新纸条递回窗口;
- 天才读完结果,再写下一张纸条......如此往复,直到他在纸条上写下最终答案,并注明"活干完了"。
这就是 Agent(Agent = 模型 + 工具 + 循环) 的全部秘密。听起来简单得离谱?对,大模型 Agent 的核心原理就是这么朴素:模型永远只输出纯文本(JSON 也是文本),所有的"能力"都是外围程序把文本翻译成动作,再把动作结果翻译回文本喂回去。
gemini-cli 是 Google 官方开源的终端 AI 编程助手,~10 万颗星。下面我们拿着它的源码,把这个故事的每一环拆开看。
二、一切的起点:模型看到的只是 JSON
先纠正一个常见误解:模型看不到你的终端、你的文件系统、你的屏幕。它每一轮看到的,是一份结构化的 JSON 请求,大概长这样:
json
{
"contents": [
{ "role": "user", "parts": [{ "text": "帮我看看 cat.png 里是什么猫" }] },
{ "role": "model", "parts": [{ "functionCall": { "name": "read_file", "args": { "path": "cat.png" } } }] },
{ "role": "user", "parts": [{ "functionResponse": { "name": "read_file", "response": { "output": "..." } } }] }
],
"tools": [{ "functionDeclarations": [ /* 工具菜单 */ ] }]
}
两个关键角色:
parts:纸条上的内容块。可以是text(文字),也可以是functionCall(模型点的菜),还可以是inlineData(图片/音频的 base64)。functionDeclarations:工具菜单。注意是 declaration(声明)------模型只拿到"菜单"和"参数说明书",拿不到任何可执行代码。
工具菜单是怎么发给模型的?GeminiClient 每轮请求前都会把工具注册表里的所有工具抽出来:
csharp
// packages/core/src/core/client.ts:329
async setTools(modelId?: string): Promise<void> {
const toolRegistry = this.context.toolRegistry;
const toolDeclarations = toolRegistry.getFunctionDeclarations(modelId);
const tools: Tool[] = [{ functionDeclarations: toolDeclarations }];
this.getChat().setTools(tools);
}
每个工具的本质,在接口定义里写得清清楚楚------一个名字 、一段说明书 、一个参数 schema ,外加一个由本地程序实现的 build/execute:
csharp
// packages/core/src/tools/tools.ts:405(节选)
name: string; // API 里模型要写的工具名,如 'read_file'
description: string; // 给模型看的"这工具是干嘛的"
readonly schema: FunctionDeclaration; // 参数说明书(JSON Schema)
build(params: TParams): ToolInvocation<TParams, TResult>;
// ^ 用模型给的原始参数构造一次"可执行的调用",验证参数合法性
💡 划重点:模型与真实世界的唯一接口,就是"写纸条点菜"。它永远不会执行代码,执行永远发生在外围程序里。这是安全设计的第一道墙------后面讲确认机制时会再提。
三、模型的回信:纯文本里的三种"意图"
请求发出去,响应流式返回。gemini-cli 把每一轮对话封装成 Turn 类,负责把响应流翻译成事件流。
核心逻辑在 packages/core/src/core/turn.ts:369:
csharp
const parts = resp.candidates?.[0]?.content?.parts ?? [];
for (const part of parts) {
if (part.thought) {
// ① 思考内容:解析成 Thought 事件,展示"它正在想什么"
const thought = parseThought(part.text ?? '');
yield { type: GeminiEventType.Thought, value: thought, traceId };
}
}
const text = getResponseText(resp);
if (text) {
// ② 正文文字:Content 事件,直接流式渲染到终端
yield { type: GeminiEventType.Content, value: text, traceId };
}
// ③ 点菜:模型输出的 functionCall
const functionCalls = resp.functionCalls ?? [];
for (const fnCall of functionCalls) {
const event = this.handlePendingFunctionCall(fnCall, traceId);
if (event) {
yield event; // GeminiEventType.ToolCallRequest
}
}
也就是说,模型的回复本质上是一叠文本块,gemini-cli 把它们分成三类:
| 模型输出的 part | 翻译成 | 去向 |
|---|---|---|
thought |
Thought 事件 |
终端上"✦ 思考中..."的灰色小字 |
text |
Content 事件 |
终端正文,流式打印 |
functionCall |
ToolCallRequest 事件 |
交给调度器,去干真实世界的活 |
handlePendingFunctionCall 还干了一件小事:给每次点菜生成唯一 ID(callId),并在本地工具注册表里查一下这道菜存不存在。注意------哪怕查不到,它也会把请求发给调度器,由调度器生成"工具不存在"的错误纸条回喂给模型,让模型自己纠正。
四、图像识别:模型没有眼睛,图是"写"出来的
这是全文最颠覆直觉的部分。你让 gemini-cli"看看这张图",它真的很"看"------但过程是把图片变成一坨 base64 文本 ,塞进请求的 parts 里。
4.1 图片怎么进请求?两条路
路一:@ 文件引用 。你在输入框敲 @cat.png 这是什么猫,atCommandProcessor 会解析 @ 语法,在底层调用 read_many_files 工具把文件读出来:
ini
// packages/cli/src/ui/hooks/atCommandProcessor.ts:571
invocation = readManyFilesTool.build(toolArgs);
const result = await invocation.execute({ abortSignal: signal });
// ...
for (const part of result.llmContent) {
if (typeof part === 'string') {
parts.push({ text: `\nContent from @${displayPath}:\n` });
parts.push({ text: fileActualContent });
} else {
parts.push(part); // ← 图片的 inlineData 部分原样塞进用户消息
}
}
路二:模型主动点菜 。模型看到你的描述后自己调用 read_file 工具读图片。真正的读取逻辑在 fileUtils.ts 里,按文件类型分流:
javascript
// packages/core/src/utils/fileUtils.ts:752
case 'image':
case 'pdf':
case 'video': {
const mimeType = getSpecificMimeType(filePath) ?? 'application/octet-stream';
const contentBuffer = await readSecureFileBuffer(filePath, stats);
const base64Data = contentBuffer.toString('base64'); // ← 图片 → base64 字符串
return {
llmContent: {
inlineData: {
data: base64Data, // 一坨几 MB 的 base64"文本"
mimeType, // 'image/png'
},
},
returnDisplay: `Read ${fileType} file: ${relativePathForDisplay}`,
};
}
看到没?所谓"看图",本质是把图片的每个像素编码成 base64 字符,作为 inlineData 块塞进 JSON 请求。Gemini 模型是多模态的,它的底层网络能消化这种结构------但从 gemini-cli 的视角看,进出全都是"结构化文本"。模型点菜要图片、秘书递图、模型"看"完写结论,全程没有一句"魔法"。
4.2 工具结果里的图片,怎么递回去?
工具结果要封装成 functionResponse 才能回喂,这里有个协议细节:函数响应默认是 output 字符串,图片塞哪儿?gemini-cli 的处理在 convertToFunctionResponse:
css
// packages/core/src/utils/generateContentResponseUtilities.ts:49(节选)
const parts = toParts(llmContent);
const textParts: string[] = [];
const inlineDataParts: Part[] = [];
// ...把 text 和 inlineData 分拣...
if (filteredInlineDataParts.length > 0) {
if (isMultimodalFRSupported) {
// 模型支持多模态函数响应:图片嵌进 functionResponse.parts
Object.assign(part.functionResponse!, { parts: filteredInlineDataParts });
} else {
// 不支持:图片作为"兄弟块"跟在 functionResponse 后面
siblingParts.push(...filteredInlineDataParts);
}
}
甚至还有兜底文案:如果工具返回的只有图片没有文字,系统会自动补一句 "Binary content provided (1 item(s)).",保证响应结构永远合法。工程的严谨都藏在这种小细节里。
五、CLI 命令:run_shell_command,从"点菜"到"真跑"
现在看工具系统里最刺激的一个:执行 shell 命令。模型点的菜长这样:
json
{ "functionCall": {
"name": "run_shell_command",
"args": { "command": "npm test" }
} }
5.1 第一道关卡:要不要问用户?
ShellToolInvocation.shouldConfirmExecute 是执行前的守门员(packages/core/src/tools/shell.ts:356):
kotlin
override async shouldConfirmExecute(abortSignal, forcedDecision) {
if (this.hasTaintedOrBuildFileRisk()) {
return this.getConfirmationDetails(abortSignal); // 高危文件相关 → 必须确认
}
if (this.context.config.getApprovalMode() === ApprovalMode.YOLO &&
forcedDecision !== 'ask_user') {
return super.shouldConfirmExecute(...); // YOLO 模式(全自动)→ 放行
}
// 其余情况:弹出确认框,让用户按 y / a / n
}
默认模式下,你会在终端看到:Allow? Execute "npm test" ... (y/n/a)。这个"人工在环"就是 Agent 安全性的核心------模型只有建议权,人类有审批权。
还有一道很硬核的安全检查:检测命令注入 。模型如果在参数里夹带 $() 或反引号做命令替换,直接拦下:
css
// packages/core/src/tools/shell.ts:638(节选)
if (detectCommandSubstitution(strippedCommand) && !(await this.isCommandSubstitutionAllowed(options))) {
return {
llmContent: 'Command injection detected: command substitution syntax ' +
'($(), backticks, <() or >()) found in command arguments. ...',
returnDisplay: 'Blocked: command substitution detected in shell command.',
};
}
注意:拦截信息也是写成 llmContent 回喂给模型的------让模型知道"这招被禁了",下次换个写法。
5.2 真正的执行:spawn 子进程
确认通过后,execute() 走到 shellExecutionService,本质就是 Node.js 的 child_process.spawn(packages/core/src/services/shellExecutionService.ts:9),macOS/Linux 上还优先用 PTY 伪终端 跑,这样能拿到带颜色的完整终端输出。输出会流式回传给 UI(updateOutput 回调),所以你能实时看到测试日志滚动。
5.3 结果打包成"纸条"
进程跑完,结果被拼装成给模型看的纯文本(packages/core/src/tools/shell.ts:1015 附近):
javascript
llmContentParts.push(`Error: ${finalError}`); // 有错吗?
llmContentParts.push(`Exit Code: ${result.exitCode}`); // 退出码非 0 必须写明
llmContentParts.push(`Signal: ${result.signal}`); // 被杀信号
llmContentParts.push(`Background PIDs: ${...}`); // 后台进程
llmContent = llmContentParts.join('\n');
也就是说,模型拿到的"命令输出",长这样:
css
<标准输出全文...>
Exit Code: 1
简单、朴素,但对模型来说是金矿------它会盯着 Exit Code: 1 和报错栈,决定下一步是修代码还是重跑命令。
六、结果回喂:一张 functionResponse 纸条的诞生
工具干完活,ToolResult(含 llmContent)会被转换成协议要求的格式。这是整个回路的"接缝",值得单独看:
csharp
// packages/core/src/utils/generateContentResponseUtilities.ts:49
export function convertToFunctionResponse(
toolName: string, callId: string, llmContent: PartListUnion, ...
): Part[] {
if (typeof llmContent === 'string') {
return [createFunctionResponsePart(callId, toolName, llmContent)];
}
// ...分拣 text / inlineData / fileData(见第四节)...
const part: Part = {
functionResponse: {
id: callId,
name: toolName,
response: { output: textParts.join('\n') },
},
};
生成出来的就是第二节 JSON 里那种结构:
swift
{ "role": "user", "parts": [
{ "functionResponse": {
"id": "run_shell_command__xxx",
"name": "run_shell_command",
"response": { "output": "...测试输出...\nExit Code: 1" }
} }
] }
协议铁律 :functionCall 是模型(role=model)写的,functionResponse 必须以 user 身份紧跟着写回去,一一配对。gemini-cli 甚至有专门的函数处理"配对失败"的情况------用户中途取消时,discardTrailingUnansweredToolCallTurn() 会把没等到响应的 functionCall 从历史里撤掉,防止历史"烂尾"污染后续所有请求。
七、循环引擎:Agent 的心脏
万事俱备,现在把齿轮串起来。完整链路是:
scss
submitQuery(用户输入)
→ geminiClient.sendMessageStream() // packages/core/src/core/client.ts:953
→ processTurn()
→ turn.run() // 一轮 API 调用,turn.ts:270
├─ 流式产出 Content / Thought / ToolCallRequest 事件
← 事件流回到 CLI 层
useGeminiStream.processGeminiStreamEvents() // packages/cli/src/ui/hooks/useGeminiStream.ts:1697
case ToolCallRequest:
toolCallRequests.push(event.value)
// 流结束后:
scheduleToolCalls(toolCallRequests, signal) // 调度执行(确认→执行)
→ handleCompletedTools(completedToolCalls) // useGeminiStream.ts:2135
→ submitQuery(responsesToSend, { isContinuation: true }) // ★ 闭环:再进 sendMessageStream
看 handleCompletedTools 的收尾(packages/cli/src/ui/hooks/useGeminiStream.ts:2306):
javascript
const responsesToSend: Part[] = geminiTools.flatMap(
(toolCall) => toolCall.response.responseParts, // 就是 convertToFunctionResponse 的产物
);
// ...
submitQuery(
responsesToSend,
{ isContinuation: true }, // ← 不是新问题,是同一个任务的续篇
prompt_ids[0],
);
这就是循环本体 :工具结果作为新的"用户消息"再次进入 sendMessageStream,模型看到纸条后继续思考、继续点菜,直到某轮它不再输出 functionCall、只输出最终文字(finishReason: STOP),循环自然终止。
一个任务"跑 20 轮工具"在协议上就是 20 次 sendMessageStream,每次请求都带着全量对话历史 (包括所有 functionCall/functionResponse 配对)。上下文越长,请求越大------这也是为什么 gemini-cli 内置了自动压缩(tryCompressChat,快爆上下文时把旧对话压成摘要)。
7.1 防呆三件套
裸循环会失控(模型陷入死循环无限点菜),gemini-cli 装了三个保险丝:
① 轮数上限 ------MAX_TURNS 兜底,超了直接发 MaxSessionTurns 事件终止;
② 死循环检测 ------LoopDetectionService 对每个事件做指纹哈希,同样内容的响应连续出现立即掐断:
arduino
// packages/core/src/core/client.ts:790
const loopResult = await this.loopDetector.turnStarted(signal);
if (loopResult.count > 1) {
yield { type: GeminiEventType.LoopDetected }; // "检测到循环,已停止"
return turn;
}
③ 续话检测 ------如果模型这轮既没点菜也没说结束语,checkNextSpeaker 会判断"该不该轮到模型继续说",必要时自动替你说一句"Please continue":
ini
// packages/core/src/core/client.ts:937
if (nextSpeakerCheck?.next_speaker === 'model') {
const nextRequest = [{ text: 'Please continue.' }];
turn = yield* this.sendMessageStream(nextRequest, ...);
}
甚至还有个彩蛋:工具全部执行成功但模型返回了空响应时,CLI 会自动发一条系统提示:[System: You successfully executed a tool but returned an empty response. Please analyze the tool output...],把模型从发呆里拽回来。
八、MCP:给天才的菜单,插上全世界的菜
前面所有工具都是 gemini-cli 内置 的。那第三方工具(数据库、浏览器、Figma......)怎么接进来?答案就是 MCP(Model Context Protocol) ------Anthropic 搞的开放协议,Google 全面拥抱。
你只需在 ~/.gemini/settings.json 里写:
perl
{
"mcpServers": {
"github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"] }
}
}
启动时,McpClientManager 会逐个连接服务器并发现工具:
ini
// packages/core/src/tools/mcp-client.ts:1325(discoverTools 节选)
const response = await mcpClient.listTools({}, options);
const discoveredTools: DiscoveredMCPTool[] = [];
for (const toolDef of response.tools) {
// 把 MCP 的 inputSchema 转成 Gemini 的 FunctionDeclaration 参数格式
toolDef.inputSchema = transform(toolDef.inputSchema);
discoveredTools.push(new DiscoveredMCPTool(...));
}
连接层支持两种传输(mcp-client.ts:19-22):
javascript
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { StreamableHTTPClientTransport, ... } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
// 本地子进程走 Stdio(JSON-RPC over stdin/stdout),远程服务走 HTTP
发现到的工具注册进 ToolRegistry,和内置工具同表混排 。之后 getFunctionDeclarations 一视同仁地把它们写进菜单------对模型来说,MCP 工具和 read_file 没有任何区别,它只是看到菜单上多了几道菜。
真正的执行,是"透传"给 MCP 服务器:
kotlin
// packages/core/src/tools/mcp-tool.ts:413(DiscoveredMCPToolInvocation.execute 节选)
const functionCalls: FunctionCall[] = [
{ name: this.serverToolName, args: this.params },
];
const rawResponseParts = await this.mcpTool.callTool(functionCalls);
// ↑ 内部走 this.client.callTool ------ 标准 MCP JSON-RPC 调用
MCP 服务器返回的结果,照样走 convertToFunctionResponse 打包成 functionResponse 纸条回喂。接入协议、退出协议、循环协议,全部复用同一套。 这就是 MCP 精妙的地方:它没有发明新的 Agent 架构,只是把"工具菜单"标准化了。
九、全链路时序图
把以上所有环节画成一张时序图(以"看图 + 跑测试"任务为例,跑两轮循环):

scss
用户CLI (useGeminiStream)GeminiClientTurnGemini API (雪山机房)ToolSchedulerShellToolMCP 服务器"看看 cat.png 是什么猫, 跑下测试"1submitQuery(prompt)2setTools() 收集 functionDeclarations(内置工具 + MCP 工具)3sendMessageStream(history + parts)4generateContent{contents, tools:[functionDeclarations]}5流式 parts: text / thought / functionCall6Content 事件(流式打印正文)7ToolCallRequest 事件read_file {path: cat.png}8scheduleToolCalls([请求])9shouldConfirmExecute()低危只读 → 直接放行10invocation.execute()11读文件 → base64 inlineData12ToolResult {llmContent}13完成 (convertToFunctionResponse→ functionResponse part)14submitQuery(functionResponse, isContinuation)15第二轮 sendMessageStream16history += [functionCall, functionResponse(含图)]17text:"这是只橘猫" + functionCall:run_shell_command {command:"npm test"}18ToolCallRequest(run_shell_command)19scheduleToolCalls20❓ 确认执行 "npm test"? (y/n)21y22spawn 子进程执行23stdout + Exit Code: 124functionResponse25submitQuery(functionResponse)26第三轮27模型看到测试失败,可能继续点菜修文件...28text: 最终总结 (无 functionCall, finishReason=STOP)29最终答案 ✅ 循环结束30loop[Agent 循环 (直到无 functionCall)]用户CLI (useGeminiStream)GeminiClientTurnGemini API (雪山机房)ToolSchedulerShellToolMCP 服务器
十、总结:Agent = 纸条 + 后厨 + 循环
把整篇文章压成一张表:
| 环节 | 谁干的 | 本质 |
|---|---|---|
| 理解任务 / 决定点什么菜 | 大模型 | 输出 functionCall 纯文本 |
| 提供菜单 | gemini-cli | 把工具名+描述+参数 schema 塞进请求 |
| 执行工具(读图/跑命令/MCP) | gemini-cli 本地代码 | spawn 子进程、读文件、JSON-RPC |
| 图像识别 | 多模态模型 | 图片 → base64 → inlineData 块 |
| 结果回喂 | convertToFunctionResponse |
functionResponse part,紧跟原 functionCall |
| 循环 | submitQuery(isContinuation) |
结果当新消息再进 sendMessageStream |
| 终止 | 模型不再点菜 + STOP |
外加 MAX_TURNS / LoopDetection 保险丝 |
| 安全 | 确认机制 + 命令注入检测 + 沙箱 | 模型只有建议权,人类有审批权 |
最后说点感悟。看完 gemini-cli 的源码,你会发现 Agent 没有任何神秘感------它不比大模型"聪明",它只是给一个只会写字的天才配了一套"后厨 + 传菜员 + 安全员"。真正让 Agent 可用的,恰恰是这些朴素到容易被忽视的工程细节:
- 图片转 base64 时的 MIME 类型分拣、音频不支持的兜底文案;
- 用户中途取消后,把"没等到响应的 functionCall"从历史里撤掉,防止上下文烂尾;
- 空响应时自动发一条 "Please analyze the tool output" 把模型拽回来;
- 循环检测的指纹哈希。
大模型负责"想",工程负责"把想变成可执行的、安全的、可恢复的动作"。 下次你再看到某个 Agent 框架吹得天花乱坠,可以淡定地问一句:你的 functionCall 和 functionResponse,配对了吗?😄
如果这篇文章对你有帮助,欢迎点赞收藏。下一篇计划拆 gemini-cli 的上下文管理(自动压缩 + GEMINI.md 记忆),感兴趣的关注一波~