大模型是怎么"看"图、跑命令、调 MCP 的?我把 gemini-cli 源码扒了个底朝天

源码版本:google-gemini/gemini-cli main 分支(@google/gemini-cli v0.69.0-nightly,2026-11 快照),Apache-2.0 开源。 仓库地址:github.com/google-gemi...

一、开场故事:住在雪山机房里的"失明天才"

先讲个故事。

想象有一位天才,被关在瑞士雪山机房的一个纯文本房间里。他有几个特点:

  1. 聪明绝顶------读过的书比图书馆还多,什么都懂;
  2. 没有眼睛、没有手------既看不见你的截图,也敲不了你的键盘;
  3. 只会干一件事:收纸条、写纸条------窗口递进来一张纸条,他写一张递出去,仅此而已。

现在你让他帮忙:"帮我看看 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 记忆),感兴趣的关注一波~

相关推荐
Flynt15 分钟前
一夜涨近2000星的"开源Grok Bot",我在1核2GB小机上装了一遍:壳是真的,脑子得自备
开源·agent·claude
用户9432547583736 分钟前
你的 Agent 会重复扣钱吗?我给 LangGraph 加了一层"步骤日志"
agent
小田学Python2 小时前
DeepSeek Harness 完整上手指南:安装部署 + 必装插件 + 用一周就回不去的功能
大数据·大模型·agent·ai工具·deepseek
程序员阿晟2 小时前
10 分钟搭一套自己的智能客服系统:果唯云客服 + Hermes Agent 实战
agent
去伪存真3 小时前
AI Agent Harness 深度测评与技术选型实战
前端·agent
七夜zippoe3 小时前
Agent 超时与幂等性:重复请求、副作用控制与状态一致性
ai·agent·超时·控制·幂等性
用户460381291943 小时前
ChatMemory 要被弃用了?Spring AI 2.1 用事件溯源重做 Agent 记忆
llm·agent
泯泷4 小时前
别再死磕提示词了:Anthropic 说,AI Agent 真正的胜负手是“上下文工程”
llm·aigc·agent
后端小肥肠4 小时前
漫画工坊上线用户破百:公众号漫画从需求到变现全复盘
人工智能·aigc·agent