一个只会输出 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 到达,才能确定:
- 这一轮到底有几个 tool_call? 模型可能一次返回多个(并行调用),要全部到齐
- 每个调用的参数拼完了没有? 碎片没拼完就是非法 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}}
规则就三条:
stdout只走协议报文,一行一个 JSON;日志走stderr- 带
id的是请求 ,必须回一条同id的响应 - 不带
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 判断再来一轮
看图看两个地方:
alt那块 ------ 唯一的分叉点就是ToolCallback。往左是本地函数,往右是 MCP。分叉之上的一切,两者完全共用。- 模型在最上面 ------ 它到 MCP Server 之间隔了四层。它这辈子都不会知道 MCP 的存在。
六、常见误解
| 误解 | 事实 |
|---|---|
| "模型把调用发给了工具" | 不是。模型只会往输出流写 token,它不知道有工具存在 |
| "有工具在监控模型输出" | 没有"工具监控"这回事。是客户端框架在逐 chunk 拼接 |
| "检测靠轮询,每秒一次" | 不是轮询,是事件驱动 ------ 每个 chunk 到达时同步判断一次 |
| "每个 token 都要过一遍" | 不是 token,是 chunk;判断的是结构化字段,不是扫描文本 |
| "检测到就立刻执行" | 不是。必须等 finish_reason 到达、流结束,才执行 |
"tool_calls 字段是模型生成的" |
不准确。模型生成的是文本,这个结构是推理引擎解析出来的 |
"id 是模型给的" |
不是。是推理引擎为了让宿主配对结果,自己生成的随机串 |
"arguments 是个 JSON 对象" |
不是。它是字符串 ,必须自己 json.loads() |
| "MCP 需要模型重新训练" | 不用。MCP 报文一个 token 都进不了 prompt |
| "MCP 是统一各家模型格式的" | 统一模型格式 的是框架的适配层;MCP 统一的是工具 |
| "模型记得上一轮调用了什么" | 不是。模型无状态,每轮都是把完整上下文重发一遍 |
七、小结
四句话:
- 模型只会输出 token。 它没有执行能力,所以"调用工具"只能被编码成文本。
- 检测不是"监控",是"等待"。 客户端逐 chunk 收、逐 chunk 拼,等到
finish_reason到达才动手。 - 触发是一次普通的方法调用。
for循环遍历tool_calls,逐个call()------ 就这么简单。 - MCP 封装的是工具,不是模型。 它把"工具在哪、怎么调"藏在一个统一接口后面,让本地函数和远程工具在框架眼里长得一模一样。
你以为的"AI 在调用工具",实际发生的是:一段代码在 while 循环里,等模型说完话,然后替你按下了按钮。