很多人第一次接触大模型工具调用时,容易把几个角色混在一起:大模型是不是直接读取了本地文件?Skill 是不是一个可执行插件?MCP Server 是不是直接连到了模型?

实际链路更清晰,也更安全:模型只看见客户端发给它的上下文和工具定义;真正访问本地文件、数据库、命令行或服务的是客户端;MCP 是客户端调用本地能力的一种标准接口;调用结果再被客户端作为新消息发回模型。

客户端、Skill 与 MCP 的交互闭环

总览:先看完整链路#

一次完整交互可以拆成四步:

  1. 客户端读取用户问题,并加载本地 Skill、系统提示词、可用工具列表。
  2. 客户端把这些信息整理成一次模型请求,发给大模型。
  3. 大模型判断需要调用工具时,不直接执行本地动作,而是返回一个 tool_call 请求。
  4. 客户端根据 tool_call 调用本地 MCP Server,把结果作为 tool 消息回填给模型,模型再基于结果继续回答。

这里最重要的边界是:模型提出“我要调用什么”,客户端决定“能不能调用、怎么调用、结果如何回传”。

Skill 是什么:给模型看的操作说明#

Skill 可以理解成一份本地能力说明书。它通常不是让模型直接执行的程序,而是一组面向模型的规则、流程、约定和示例。

例如,一个写博客的 Skill 可能告诉模型:

# Blog Writing Skill

- 写中文技术文章。
- 文件名必须是英文小写 slug。
- 创建文章前先读取已有标签。
- 图片必须通过 blog MCP 上传。
- 创建后再次读取文章确认 frontmatter 和图片引用。

客户端在发起模型请求前,会把匹配到的 Skill 内容放进上下文。于是模型知道:当前任务不是随便写一段 Markdown,而是要按照这个博客系统的规范完成“查标签、写正文、上传图片、创建文章、验证结果”这些步骤。

注意,Skill 本身不等于工具。它更像操作手册:告诉模型什么时候应该做什么、输出应该长什么样、有哪些约束不能违反

MCP 是什么:给客户端调用的本地能力接口#

MCP Server 则更接近真正的本地工具提供者。它把本地能力包装成结构化接口,比如:

{
  "name": "blog.create_post",
  "description": "创建一篇 Hugo 博客文章",
  "input_schema": {
    "type": "object",
    "properties": {
      "title": { "type": "string" },
      "content": { "type": "string" },
      "tags": {
        "type": "array",
        "items": { "type": "string" }
      },
      "draft": { "type": "boolean" }
    },
    "required": ["title", "content"]
  }
}

客户端启动或连接 MCP Server 后,会拿到这些工具定义。随后客户端可以把工具名、描述和参数结构告诉模型,让模型知道有哪些工具可用。

但模型并不会直接连到 MCP Server。模型只能输出类似这样的意图:

{
  "type": "tool_call",
  "name": "blog.create_post",
  "arguments": {
    "title": "客户端、Skill 与 MCP:大模型如何调用本地能力",
    "content": "...文章正文...",
    "tags": ["MCP", "AI", "大模型", "Agent"],
    "draft": false
  }
}

真正发起本地调用的是客户端。

客户端如何把本地 Skill 告知大模型#

客户端一般会在请求模型之前做一次上下文组装。一个简化后的请求可能长这样:

{
  "model": "some-llm",
  "messages": [
    {
      "role": "system",
      "content": "你是一个严谨的软件工程助手。"
    },
    {
      "role": "system",
      "content": "当前可用 Skill:Blog Writing Skill。规则:写中文;先查标签;图片必须通过 MCP 上传;创建后验证。"
    },
    {
      "role": "user",
      "content": "写一篇关于客户端如何与大模型交互的博客,搭配配图。"
    }
  ],
  "tools": [
    {
      "name": "blog.list_tags",
      "description": "列出博客已有标签",
      "input_schema": { "type": "object", "properties": {} }
    },
    {
      "name": "blog.upload_image",
      "description": "上传图片到博客仓库",
      "input_schema": { "type": "object", "properties": { "filename": { "type": "string" } } }
    },
    {
      "name": "blog.create_post",
      "description": "创建博客文章",
      "input_schema": { "type": "object" }
    }
  ]
}

这个请求里有两类信息:

  • messages:告诉模型当前任务、角色、规则、Skill 内容。
  • tools:告诉模型当前客户端愿意暴露哪些可调用能力。

模型看到这些内容后,才知道“写博客”不是单纯生成文本,而是可以借助本地工具完成真实发布。

大模型如何让客户端调用本地 MCP#

当模型判断需要获取现有标签时,它不会说“我已经查过标签”。它应该返回一个工具调用请求:

{
  "type": "tool_call",
  "id": "call_001",
  "name": "blog.list_tags",
  "arguments": {}
}

客户端收到后,会做几件事:

  1. 检查工具名是否存在。
  2. 校验参数是否符合 schema。
  3. 判断是否需要用户授权。
  4. 调用对应 MCP Server 的 blog.list_tags
  5. 拿到 MCP 返回值。

MCP 返回值可能是:

{
  "tags": [
    { "tag": "AI", "count": 3 },
    { "tag": "MCP", "count": 2 },
    { "tag": "大模型", "count": 1 }
  ]
}

这一步的本质是:模型发出意图,客户端执行意图。

如果工具会产生副作用,比如写文件、发邮件、删除数据、提交代码,客户端还应该做权限控制和确认流程。模型不能绕过客户端直接操作本地环境。

调用结果如何告知大模型#

客户端拿到 MCP 结果后,会把它包装成一条工具结果消息,再发回模型。简化后类似:

{
  "role": "tool",
  "tool_call_id": "call_001",
  "name": "blog.list_tags",
  "content": "[{\"tag\":\"AI\",\"count\":3},{\"tag\":\"MCP\",\"count\":2},{\"tag\":\"大模型\",\"count\":1}]"
}

模型在下一轮推理时会看到这条 tool 消息。于是它可以继续决定:

  • 使用已有标签 AIMCP大模型
  • 不创建重复标签;
  • 继续调用 blog.upload_image 上传配图;
  • 最后调用 blog.create_post 创建文章。

所以工具结果不是“展示给用户看的最终答案”,而是模型继续工作的事实输入。

一个完整例子:让 AI 通过 MCP 发布博客#

假设用户说:

使用写博客的技能,写一篇关于客户端如何与大模型交互、如何告知大模型本地 Skill、大模型如何让客户端调用本地 MCP、调用结果如何告知大模型的文章,搭配适合的配图。

客户端会先匹配到“写博客” Skill,然后把 Skill 内容和可用 MCP 工具一起发给模型。模型可能规划出这条链路:

1. 调用 blog.list_tags,复用已有标签。
2. 生成一张流程图,并调用 blog.upload_image 上传。
3. 写文章正文。
4. 调用 blog.create_post 创建文章。
5. 调用 blog.read_post 或 blog.list_images 验证文章和图片。

其中第 1、2、4、5 步都不是模型自己执行的。模型只是发出工具调用请求:

{
  "type": "tool_call",
  "name": "blog.upload_image",
  "arguments": {
    "filename": "llm-client-skill-mcp-loop-diagram.svg",
    "base64_content": "..."
  }
}

客户端收到后调用本地 blog MCP。MCP 把图片写入博客仓库并返回:

{
  "path": "static/images/llm-client-skill-mcp-loop-diagram.svg",
  "markdownRef": "![llm-client-skill-mcp-loop-diagram](/images/llm-client-skill-mcp-loop-diagram.svg)"
}

客户端再把这个结果作为 tool 消息回填给模型。模型获得图片地址后,才能在正文中正确引用:

![客户端、Skill 与 MCP 的交互闭环](/images/llm-client-skill-mcp-loop-diagram.svg)

同理,创建文章也是这样:模型输出 blog.create_post 的调用参数,客户端执行 MCP 调用,MCP 返回文章文件名、路径或创建结果,客户端再把结果告诉模型。

为什么要把这几个角色分开#

这种设计有几个直接好处。

第一,安全边界清晰。模型不能直接访问本地文件系统,也不能绕过客户端执行危险操作。客户端可以做权限控制、参数校验、日志记录和人工确认。

第二,能力可发现。客户端可以动态发现有哪些 MCP Server、每个 Server 暴露了哪些工具,然后把当前允许使用的工具告诉模型。

第三,行为可复现。Skill 把“怎么做事”的规范写下来,模型每次执行同类任务时都能遵循同一套流程。

第四,结果可追踪。每次工具调用都有 tool_call_id、参数和返回值,后续排查时能知道模型为什么这么做、客户端实际执行了什么、MCP 返回了什么。

常见误区#

误区一:模型直接调用了本地 MCP。

不是。模型只生成工具调用请求。真正连接 MCP Server 的是客户端。

误区二:Skill 是可执行插件。

通常不是。Skill 主要是给模型看的上下文和流程说明。可执行能力来自工具或 MCP Server。

误区三:工具返回结果后任务就结束了。

不一定。工具结果通常还要回填给模型,让模型继续判断下一步,或者基于真实结果生成最终回答。

误区四:把所有本地能力都暴露给模型就更强。

不应该。客户端应该只暴露当前任务需要的工具,并对高风险工具做授权控制。

总结#

客户端和大模型的交互不是“模型控制一切”,而是一个由客户端主导的闭环:

用户请求
  -> 客户端加载 Skill 和工具定义
  -> 大模型阅读上下文并提出 tool_call
  -> 客户端调用本地 MCP
  -> MCP 返回结构化结果
  -> 客户端把结果回填给大模型
  -> 大模型继续推理或生成最终回答

一句话记住:Skill 负责告诉模型怎么做,MCP 负责提供本地能做什么,客户端负责把两者接起来并控制执行边界,工具结果负责把真实世界的反馈带回模型。