大模型只会输出token,是怎么“调用工具“的?

一个只会输出 token 的模型,凭什么能调用外部程序?

这篇文章只讲两件事:

① 外部程序是怎么"检测到"模型要调工具的

② MCP 到底封装了什么、怎么封装的


一句话结论

模型从来没有"调用"过任何工具。

它只是输出了特定格式的文本,外部程序检测到这段文本,替它执行。

而 MCP 封装的不是模型,是工具。

下面拆开讲。


一、先钉死前提:模型只会输出 token

大模型的推理过程,抽象到最底层只有一个动作:

复制代码
输入 token 序列  →  输出下一个 token 的概率分布  →  采样一个  →  拼回去  →  再算下一个

它没有 socket、没有文件句柄、没有 syscall。

它能做 它做不到
矩阵乘法、注意力计算 读文件
输出 token 概率分布 发 HTTP 请求
采样下一个 token 查数据库

它能影响外部世界的唯一途径,就是它输出的那些 token。

所以"调用工具"这件事,只能建立在**"约定一种文本格式"**之上。


二、检测机制:怎么知道模型要调工具?

2.1 模型输出的原始形态

模型决定调工具时,吐出来的 token 序列长这样(各家标记不同):

text 复制代码
<tool_call>{"name": "get_weather", "arguments": {"city": "武汉"}}</tool_call>

注意:包括 <tool_call> 这种看起来像标记的东西,也是模型一个 token 一个 token 预测出来的。

各家模型的"标点符号"不一样:

模型 工具调用标记
Qwen <tool_call>...</tool_call>
Llama 3.1 `<
DeepSeek <|tool▁calls▁begin|>...<|tool▁calls▁end|>
Mistral [TOOL_CALLS][...]

2.2 第一次检测:服务端把文本解析成字段

如果你用的是 OpenAI 兼容 API,那么这一次检测在服务端就完成了。

推理服务端(vLLM、SGLang,或者厂商的 API 网关)在解码循环里,一边吐 token 一边盯着特殊标记:

python 复制代码
# 伪代码:推理引擎内部
while 还没结束:
    token = 模型预测下一个 token()
    输出给客户端(token 流)

    if 累积文本里出现 "<tool_call>":
        开始收集后面的内容

    if 累积文本里出现 "</tool_call>":
        raw = 收集到的原始文本        # '{"name":"get_weather","arguments":{"city":"武汉"}}'
        组装成结构化对象 = {
            "id": "call_" + 随机串,     # ← 引擎生成的,不是模型生成的
            "type": "function",
            "function": {
                "name": 解析(raw)["name"],
                "arguments": raw        # ← 原样塞进去,还是字符串
            }
        }

所以你在客户端拿到的响应是这样的:

json 复制代码
{
  "choices": [{
    "finish_reason": "tool_calls",
    "message": {
      "role": "assistant",
      "content": null,
      "tool_calls": [{
        "id": "call_abc123",
        "type": "function",
        "function": {
          "name": "get_weather",
          "arguments": "{\"city\": \"武汉\"}"
        }
      }]
    }
  }]
}

两个关键细节:

① id 是引擎生成的。 模型从没输出过 call_abc123 这种东西。这是引擎为了让"调用"和"结果"能配对,自己编的随机串。

② arguments 是字符串,不是对象。 因为引擎只做了"提取",没做"再解析" ------ 它把模型写的那段 JSON 文本原样放进去了。

这个字符串,就是"模型输出的是文本"最硬的证据。

2.3 第二次检测:客户端逐 chunk 判断

流式模式下,arguments 是一片一片到达的。真实的 SSE chunk 长这样:

json 复制代码
{"choices":[{"delta":{"tool_calls":[{"index":0,"id":"call_abc123","type":"function","function":{"name":"get_weather","arguments":""}}]},"finish_reason":null}]}
{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\"ci"}}]},"finish_reason":null}]}
{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"ty\": \"武"}}]},"finish_reason":null}]}
{"choices":[{"delta":{"tool_calls":[{"index":0,"function":{"arguments":"汉\"}"}}]},"finish_reason":null}]}
{"choices":[{"delta":{},"finish_reason":"tool_calls"}]}

看 arguments 字段:

复制代码
第 1 片:  (空)
第 2 片:  {"ci
第 3 片:  ty": "武
第 4 片:  汉"}
拼起来:   {"city": "武汉"}

它是碎片,一个字符一个字符流过来的。 这就是模型逐 token 生成的直接投影。

客户端要做的判断,就两个:

Spring AI 的 OpenAiChatModel.ChunkMerger:

java 复制代码
static final class ChunkMerger {

    /** 这个 chunk 里有没有工具调用? */
    static boolean hasToolCall(ChatCompletionChunk chunk) {
        return !chunk.choices().isEmpty()
                && chunk.choices().get(0).delta().toolCalls()
                     .filter(toolCalls -> !toolCalls.isEmpty()).isPresent();
    }

    /** 这一轮工具调用说完了吗? */
    static boolean toolCallsDone(ChatCompletionChunk chunk) {
        return !chunk.choices().isEmpty()
                && FinishReason.TOOL_CALLS == chunk.choices().get(0).finishReason().orElse(null);
    }
}

这两段代码直接回答了"怎么检测"这个问题:

疑问 答案
是轮询吗?每秒一次? 不是轮询。 事件驱动 ------ 每来一个 chunk,这两个方法被调用一次
每个 token 都要过一遍? 不是 token,是 chunk。 一个 chunk 可能含多个 token
靠扫描文本找 <tool_call> 吗? 不是。 判断的是结构化字段(delta.tool_calls、finish_reason)
什么时候算"说完了"? finish_reason == TOOL_CALLS ------ 不是找到了结束标记

2.4 为什么必须攒够才执行

因为碎片没拼完就是非法 JSON,根本解析不了。

所以框架用 bufferUntil 攒着,攒够了才一次性交出去:

java 复制代码
/**
 * Buffers the chunks that stream a tool call until the tool calls are done,
 * so that each tool call reaches the caller merged into a single chat completion.
 */
private static Flux<ChatCompletion> mergeToolCallChunks(Flux<ChatCompletionChunk> chunks) {
    AtomicBoolean isInsideTool = new AtomicBoolean(false);
    return chunks
        .doOnNext(chunk -> {
            if (ChunkMerger.hasToolCall(chunk)) {
                isInsideTool.set(true);          // 打标记:进入工具调用区
            }
        })
        .bufferUntil(chunk -> {
            if (isInsideTool.get() && ChunkMerger.toolCallsDone(chunk)) {
                isInsideTool.set(false);
                return true;                      // ← 攒够了,一次性放行
            }
            return !isInsideTool.get();
        })
        .map(ChunkMerger::mergeChunks)            // 合并所有碎片
        .map(ChunkMerger::chunkToChatCompletion);
}

而合并逻辑的本质,就是字符串拼接:

java 复制代码
private static ToolCall mergeToolCalls(ToolCall previous, ToolCall current) {
    String arguments = Stream.of(
            previous.function().flatMap(Function::arguments),
            current.function().flatMap(Function::arguments))
        .flatMap(Optional::stream)
        .collect(Collectors.joining());       // ← 就是字符串拼接
    ...
}

框架里处理这个参数的代码,本质上是一次 String.joining()。 没有比这更直白的证据了。

2.5 什么时候才真的执行?------ 必须等流结束

ToolCallingAdvisor 的流式路径:

java 复制代码
return CHAT_CLIENT_MESSAGE_AGGREGATOR
    .aggregateChatClientResponse(responseFlux, ...)     // ① 先把流聚合完
    .concatWith(Flux.defer(() -> this.handleToolCallRecursion(...)));   // ② 流结束后才处理工具

而 handleToolCallRecursion 的源码注释,原文写着:

Handles tool call detection and recursion after streaming completes.

"after streaming completes" ------ 这七个单词就是答案。

为什么不能"边流边执行"? 因为要等 finish_reason 到达,才能确定:

  1. 这一轮到底有几个 tool_call? 模型可能一次返回多个(并行调用),要全部到齐
  2. 每个调用的参数拼完了没有? 碎片没拼完就是非法 JSON

所以:"检测"的准确说法是"等到说完",不是"实时监控"。


三、执行:谁在真的调用?

检测完了,轮到真正干活的人。DefaultToolCallingManager.executeToolCall:

java 复制代码
for (AssistantMessage.ToolCall toolCall : assistantMessage.getToolCalls()) {

    String toolName = toolCall.name();
    String toolInputArguments = toolCall.arguments();       // ← 还是字符串

    // Handle the possible null parameter situation in streaming mode.
    final String finalArgs = StringUtils.hasText(toolInputArguments) ? toolInputArguments : "{}";

    ToolCallback toolCallback = toolCallbacks.stream()
        .filter(tool -> toolName.equals(tool.getToolDefinition().name()))
        .findFirst()                                        // ← 按名字查表
        .orElseThrow(() -> new IllegalStateException("No ToolCallback found: " + toolName));

    String toolResult = ...observe(() -> {
        try {
            return toolCallback.call(finalArgs, toolContext);       // ★ 就是这一行
        }
        catch (ToolExecutionException ex) {
            return this.toolExecutionExceptionProcessor.process(ex); // 异常当结果回传
        }
    });

    toolResponses.add(new ToolResponseMessage.ToolResponse(toolCall.id(), toolName, toolResult));
}

"调用工具"的全部实现,就是一个 for 循环 + 一次 toolCallback.call(...)。

一次普通的 Java 方法调用。没有 RPC,没有事件总线,没有守护线程。

然后 do-while 决定要不要再来一轮:

java 复制代码
boolean isToolCall = false;

do {
    chatClientResponse = callAdvisorChain.copy(this).nextCall(processedRequest);   // 发请求
    isToolCall = this.toolExecutionEligibilityChecker.isToolCallResponse(chatResponse);

    if (isToolCall) {
        ToolExecutionResult result = this.toolCallingManager
            .executeToolCalls(new Prompt(fullTurnHistory, options), chatResponse);  // 执行
        fullTurnHistory = result.conversationHistory();                             // 结果拼回历史
    }
}
while (isToolCall);   // ← 源码注释:"loop until no tool calls are present"

整个 Agent Loop,就是一个 do-while。


四、MCP 封装原理

前面讲的是 Function Calling 的检测与执行。现在说 MCP。

4.1 MCP 封装了什么?

它封装的是"工具"这件事的两端:

封装对象 MCP 提供的方法
工具怎么描述自己 tools/list
工具怎么被调用 tools/call

把"工具在哪台机器、用什么语言写、怎么部署"全部隐藏掉。

为什么值得封装? 解决的是工程规模问题:

  • M 个 AI 应用(Claude Desktop、Cursor、你写的 Agent......)
  • N 个工具系统(GitHub、数据库、Jira......)

不用 MCP:每个应用为每个工具写适配 → M × N

用了 MCP:每个工具实现一次 Server,每个应用实现一次 Client → M + N

和 USB、LSP 是同一个思路:接口标准化。

4.2 怎么封装的?三样东西

① 协议:JSON-RPC 2.0

一行一个 JSON,没有别的。

json 复制代码
{"jsonrpc":"2.0","id":2,"method":"tools/list"}
{"jsonrpc":"2.0","id":2,"result":{"tools":[{"name":"get_weather","description":"查询天气","inputSchema":{...}}]}}
json 复制代码
{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_weather","arguments":{"city":"武汉"}}}
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"武汉:晴,18~26℃"}],"isError":false}}

规则就三条:

  1. stdout 只走协议报文,一行一个 JSON;日志走 stderr
  2. 带 id 的是请求 ,必须回一条同 id 的响应
  3. 不带 id 的是通知,不许回复

② 原语:三个

原语 语义 谁触发
Tools 可执行的能力 模型主动调用
Resources 可读取的数据 应用/用户选择
Prompts 预设提示词模板 用户主动选择

日常只用 Tools。

③ 传输:可替换

transport 场景
stdio 本地子进程(最常用)
Streamable HTTP 远程服务

协议不变,换传输就行。 这就是"封装"的意义。

4.3 封装完之后,怎么接到模型上?

这是最关键的一步。看 Spring AI 的 SyncMcpToolCallback:

java 复制代码
public class SyncMcpToolCallback implements ToolCallback {      // ← 实现框架的接口

    private final McpSyncClient mcpClient;                      // ← 持有一个 MCP 客户端
    private final Tool tool;                                    // ← MCP 的工具定义

    @Override
    public ToolDefinition getToolDefinition() {
        return McpToolUtils.createToolDefinition(this.prefixedToolName, this.tool);
    }

    @Override
    public String call(String toolCallInput, @Nullable ToolContext toolContext) {

        if (!StringUtils.hasText(toolCallInput)) {
            toolCallInput = "{}";
        }

        Map<String, Object> arguments = jsonHelper.fromJsonToMap(toolCallInput);  // 字符串 → Map

        var request = CallToolRequest.builder(this.tool.name())
                .arguments(arguments)
                .build();

        response = this.mcpClient.callTool(request);            // ★ JSON-RPC 发出去

        return jsonHelper.toJson(response.content());
    }
}

把 MCP 工具和本地函数并排放:

本地函数 MCP 工具
类 MethodToolCallback SyncMcpToolCallback
实现的接口 ToolCallback ToolCallback
call(String) 内部 反射调 Java 方法 mcpClient.callTool(request)
入参 / 返回 String / String String / String

接口一样,入参返回一样。

所以第三节那行代码:

java 复制代码
toolResult = toolCallback.call(finalArgs, toolContext);

它根本不知道、也不需要知道,背后是本地反射还是跨网络的 MCP 调用。

这就是 MCP 能无缝接入的原因 ------ 它把自己藏在了同一个接口后面。

4.4 工具是怎么被发现的?

SyncMcpToolCallbackProvider.getToolCallbacks():

java 复制代码
this.cachedToolCallbacks = this.mcpClients.stream()
    .flatMap(mcpClient -> mcpClient.listTools()                    // ① 向每个 MCP Server 要清单
        .tools()
        .stream()
        .filter(tool -> this.toolFilter.test(...))                  // ② 过滤
        .<ToolCallback>map(tool -> SyncMcpToolCallback.builder()    // ③ 包装成 ToolCallback
            .mcpClient(mcpClient)
            .tool(tool)
            .prefixedToolName(...)                                  // ④ 加前缀防重名
            .build()))
    .toList();

三步:拉清单 → 过滤 → 包装成 ToolCallback。

包装完之后,这些工具和你的本地函数混在同一个列表里 ,一起交给 DefaultToolCallingManager。

框架从此不再区分它们。

4.5 MCP 没封装什么?

没封装模型。

MCP 的两端是「应用 」和「工具」,模型不在里面。

注意是 AI applications ,不是 AI models。官方支持者名单里全是客户端应用(Claude、ChatGPT、VS Code、Cursor),没有一个是"模型"。

三个直接推论:

① 换模型,MCP Server 一行都不用改。

因为 MCP Server 压根不知道对面用的是哪个模型。

② 模型不需要为 MCP 重新训练。

initialize、tools/list、tools/call 的报文,一个 token 都进不了 prompt。模型看到的永远只是:

text 复制代码
<tools>{"name":"get_weather","description":"...","parameters":{...}}</tools>

工具是本地写的还是从 MCP Server 拉的,对它来说毫无区别。

③ "统一各家模型格式"是框架干的,不是 MCP。

Spring AI 里有 OpenAiChatModel、AnthropicChatModel、OllamaChatModel、VertexAiGeminiChatModel ------ 每个模型一个实现,这才是抹平各家格式差异的地方。

4.6 那各家格式到底怎么统一的?------ 靠中间对象,不是格式对转

假设:

  • MCP 要的格式(记作 A):JSON-RPC
  • Qwen 输出的格式(记作 B) :<tool_call>{...}</tool_call>
  • ChatGPT 输出的格式(记作 C) :{"tool_calls":[{...}]}

关键:不存在「B 转 A」这一步。 真实过程是三步:

java 复制代码
// ===== 第 1 步:框架把 B 格式解析成对象 =====
String name      = "get_weather";
String arguments = "{\"city\":\"武汉\"}";        // ← 还是字符串

// ===== 第 2 步:字符串 → 结构化对象 =====
Map<String, Object> args = jsonHelper.fromJsonToMap(arguments);
// → {city: "武汉"}

// ===== 第 3 步:用对象构造 A 格式 =====
var request = CallToolRequest.builder("get_weather").arguments(args).build();
mcpClient.callTool(request);

ChatGPT 的 C 格式走完全相同的第 2、3 步 ------ 因为解析完之后,拿到的都是 name + arguments,一模一样的东西。

复制代码
Qwen 输出 B      <tool_call>{...}</tool_call>       ┐
ChatGPT 输出 C   {"tool_calls":[{...}]}              ├── 框架的 N 个解析器
DeepSeek 输出 D  <|tool▁calls▁begin|>{...}         ┘
                                                     │
                                                     ▼
                                    统一的对象:name + arguments
                                                     │
                                                     ▼
                                    1 个构造器 → MCP 的 A 格式

N 种输入格式 → 1 个中间对象 → 1 种输出格式。

这个模式你天天在用:JDBC。

各家协议 统一的中间表示
JDBC MySQL / PostgreSQL / Oracle 协议 ResultSet
Spring AI Qwen / GPT / DeepSeek 格式 ToolDefinition + ToolCall

MySQL 驱动不会去"转换成 PostgreSQL 协议",它把 MySQL 协议解析成 ResultSet。

同理:框架不会把 B 格式"转成" A 格式。它把 B 解析成对象,再用对象构造 A。

因为中间那个对象把 N 收敛成了 1,所以 MCP 只需要定义一种格式。

4.7 分工表

问题 谁解决 在哪一层
工具来源分散(M×N) MCP 应用 ↔ 工具
各家模型格式不同 框架的 ChatModel 适配层 应用 ↔ 模型
需要写几个 谁写
解析器(模型格式 → 对象) N 个(每个模型一个) 框架
构造器(对象 → MCP 格式) 1 个 MCP

一句话:MCP 让「工具」通用;框架让「模型」通用。两件事。


五、完整链路

MCP Server 本地函数 ToolCallback 客户端框架 推理服务端 模型 MCP Server 本地函数 ToolCallback 客户端框架 推理服务端 模型 #mermaid-svg-Xd1vqqCziwXJg0IS{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Xd1vqqCziwXJg0IS .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Xd1vqqCziwXJg0IS .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Xd1vqqCziwXJg0IS .error-icon{fill:#552222;}#mermaid-svg-Xd1vqqCziwXJg0IS .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Xd1vqqCziwXJg0IS .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Xd1vqqCziwXJg0IS .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Xd1vqqCziwXJg0IS .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Xd1vqqCziwXJg0IS .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Xd1vqqCziwXJg0IS .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Xd1vqqCziwXJg0IS .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Xd1vqqCziwXJg0IS .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Xd1vqqCziwXJg0IS .marker.cross{stroke:#333333;}#mermaid-svg-Xd1vqqCziwXJg0IS svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Xd1vqqCziwXJg0IS p{margin:0;}#mermaid-svg-Xd1vqqCziwXJg0IS .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Xd1vqqCziwXJg0IS text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-Xd1vqqCziwXJg0IS .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Xd1vqqCziwXJg0IS .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-Xd1vqqCziwXJg0IS .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-Xd1vqqCziwXJg0IS .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-Xd1vqqCziwXJg0IS #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-Xd1vqqCziwXJg0IS .sequenceNumber{fill:white;}#mermaid-svg-Xd1vqqCziwXJg0IS #sequencenumber{fill:#333;}#mermaid-svg-Xd1vqqCziwXJg0IS #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-Xd1vqqCziwXJg0IS .messageText{fill:#333;stroke:none;}#mermaid-svg-Xd1vqqCziwXJg0IS .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Xd1vqqCziwXJg0IS .labelText,#mermaid-svg-Xd1vqqCziwXJg0IS .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-Xd1vqqCziwXJg0IS .loopText,#mermaid-svg-Xd1vqqCziwXJg0IS .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-Xd1vqqCziwXJg0IS .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-Xd1vqqCziwXJg0IS .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-Xd1vqqCziwXJg0IS .noteText,#mermaid-svg-Xd1vqqCziwXJg0IS .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-Xd1vqqCziwXJg0IS .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Xd1vqqCziwXJg0IS .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Xd1vqqCziwXJg0IS .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-Xd1vqqCziwXJg0IS .actorPopupMenu{position:absolute;}#mermaid-svg-Xd1vqqCziwXJg0IS .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-Xd1vqqCziwXJg0IS .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-Xd1vqqCziwXJg0IS .actor-man circle,#mermaid-svg-Xd1vqqCziwXJg0IS line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-Xd1vqqCziwXJg0IS :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 每来一片检查一次不是轮询 攒够,合并成完整对象 等流结束才执行 alt本地函数MCP 工具 吐 token 流SSE chunk,arguments 分片到达bufferUntil 攒碎片最后一片,finish_reason 到达for 循环调用 call(arguments)反射调用 Java 方法返回值JSON-RPC callToolcontentString 结果结果拼进历史do-while 判断再来一轮

看图看两个地方:

  1. alt 那块 ------ 唯一的分叉点就是 ToolCallback。往左是本地函数,往右是 MCP。分叉之上的一切,两者完全共用。
  2. 模型在最上面 ------ 它到 MCP Server 之间隔了四层。它这辈子都不会知道 MCP 的存在。

六、常见误解

误解 事实
"模型把调用发给了工具" 不是。模型只会往输出流写 token,它不知道有工具存在
"有工具在监控模型输出" 没有"工具监控"这回事。是客户端框架在逐 chunk 拼接
"检测靠轮询,每秒一次" 不是轮询,是事件驱动 ------ 每个 chunk 到达时同步判断一次
"每个 token 都要过一遍" 不是 token,是 chunk;判断的是结构化字段,不是扫描文本
"检测到就立刻执行" 不是。必须等 finish_reason 到达、流结束,才执行
"tool_calls 字段是模型生成的" 不准确。模型生成的是文本,这个结构是推理引擎解析出来的
"id 是模型给的" 不是。是推理引擎为了让宿主配对结果,自己生成的随机串
"arguments 是个 JSON 对象" 不是。它是字符串 ,必须自己 json.loads()
"MCP 需要模型重新训练" 不用。MCP 报文一个 token 都进不了 prompt
"MCP 是统一各家模型格式的" 统一模型格式 的是框架的适配层;MCP 统一的是工具
"模型记得上一轮调用了什么" 不是。模型无状态,每轮都是把完整上下文重发一遍

七、小结

四句话:

  1. 模型只会输出 token。 它没有执行能力,所以"调用工具"只能被编码成文本。
  2. 检测不是"监控",是"等待"。 客户端逐 chunk 收、逐 chunk 拼,等到 finish_reason 到达才动手。
  3. 触发是一次普通的方法调用。 for 循环遍历 tool_calls,逐个 call() ------ 就这么简单。
  4. MCP 封装的是工具,不是模型。 它把"工具在哪、怎么调"藏在一个统一接口后面,让本地函数和远程工具在框架眼里长得一模一样。

你以为的"AI 在调用工具",实际发生的是:一段代码在 while 循环里,等模型说完话,然后替你按下了按钮。


相关推荐
枫叶丹41 小时前
语音 AI 怎样边听边答:实时对话系统的工作原理
开发语言·人工智能·chatgpt·开源·php·agent·codex
MicrosoftReactor1 小时前
技术速递|从 Jev 到你的笔记本电脑:使用 Mobius 在 ONNX 中构建“System One”决策模型
人工智能·ai·大模型·onnx
启雀AI2 小时前
全球化培训平台多语言多时区引擎技术实现:自动语言探测、语种动态管理与本地化渲染方案
ai·系统架构·软件需求·培训系统·培训平台
七牛云行业应用2 小时前
Dots 完整教程:从安装入口、跑第一个任务到给 Codex 派活(2026 年 10 月)
人工智能·大模型·agent
养肥胖虎11 小时前
CodeGraph学习笔记:给代码建索引,节省Token和时间
ai·codegraph·代码索引
hpoenixf11 小时前
从工具调用到模型输入:把 Agent 的观测链路接起来
agent
10年前端老司机12 小时前
你的RAG检索正在“高效地重复废话”:一文彻底搞懂MMR算法
langchain·llm·agent
Csvn13 小时前
线上出问题怎么查?一套可复现的排障 SOP(O04)
人工智能·aigc·agent
Raas10013 小时前
MAI Gateway(魔芋企业级AI网关)能力解析:AI网关能做故障转移吗?AI网关核心功能详解
大数据·人工智能·网关·ai·gateway·mai gateway