MCP设计讲解

Posted by 汤键|兔子队列|Lewis on December 9, 2025 禁止转载
本文总共 4433 字 · 阅读全文大约需要 18 分钟

Prompts 的机制:动态逻辑执行

普通的模板(如 Jinja2, Mustache)是静态的,只有字符串替换

但 MCP Prompt 是代码

当你调用一个 Prompt 时,服务端可以执行任意 Go/Python 代码来决定最终返回给 LLM 什么内容

场景:Code Review 助手

  • 普通模板"请帮我 Review 一下这段代码:"
    • 缺点:用户必须手动复制粘贴代码到输入框里
  • MCP Prompt
    • 客户端参数:filename (用户只输入文件名 “main.go”)
    • 服务端逻辑:
      1. 接收 main.go
      2. 代码执行:去文件系统读取 main.go 的最新内容
      3. 代码执行:读取 git blame 信息,看谁最后修改的
      4. 代码执行:读取相关的 linter 报错日志
      5. 组装:把以上所有信息组合成一段 2000 字的 Prompt
    • 返回给 LLM:包含代码、Git 信息、报错日志的完整上下文

结论:填空填进去的不仅仅是“参数”,而是触发了一系列后端逻辑

Prompts 的机制:服务端控制的对话编排

普通的 Prompt 模板通常只能生成一段纯文本(Text)

而 MCP Prompts 允许服务端返回一个完整的 消息对象列表(List of Messages)

这赋予了服务端直接控制 LLM “对话历史”和”思考路径”的权力

核心价值不是”能发 JSON”

而是服务端能通过协议做以下三件事:

A. 权限隔离(System/User 物理分层)

服务端可以强制指定

哪些信息是”系统指令”(System Prompt),哪些是”用户数据”(User Content)

普通模板:把指令和数据拼成一个大字符串

风险:用户数据里如果包含恶意指令(Prompt Injection),LLM 容易搞混

MCP 编排:服务端明确返回 role: system 和 role: user 的独立对象

优势:LLM 会赋予 System 更高的权重,且从结构上隔离了数据污染风险

B. 预设思维链(Assistant Prefill / Thinking Injection)

服务端可以伪造或预填充一条 role: assistant 的消息作为对话的”最新回复”

场景:你希望 LLM 在审查代码前,必须先列出所有引用的库

MCP 编排:服务端在返回的列表中,最后加一条:

1
2
JSON:
{ "role": "assistant", "content": "好的,我已经阅读了代码。首先,我将列出该文件引入的所有第三方库:\n1." }

效果:当 LLM 接收到这个列表时,它会被迫顺着这个”开头”继续写下去

这比在 User Prompt 里写“请你先列出库”要强效得多

这种方法叫做 Prefilling(预填充)

C. 少样本学习注入

服务端可以根据当前的上下文,动态插入几组”一问一答”的历史消息作为示例

场景:用户在写 SQL,服务端检测到是用 MySQL

MCP 编排:

服务端在 System 和 User 之间,动态插入 3 对 user + assistant 的历史消息,展示 MySQL 的特定语法

效果:无需改变 System Prompt,通过伪造历史对话,让 LLM 瞬间学会当前场景的特定风格

Prompts 的机制:Resource 内容的结构化嵌入

MCP Prompts 不仅仅是返回提示词文本

它能在协议层面将服务端实时读取的资源内容(如代码文件、数据库记录)包装成结构化对象

直接注入到对话消息中

为什么需要这个机制?

在没有 MCP 之前,如果你想把一个文件的内容发给 LLM,通常的做法是”字符串拼接”:

1
2
// ❌ 旧做法(非结构化):粗暴拼接
prompt := "请分析下面的代码:\n" + string(fileContent) + "\n代码结束"

这种做法有两个弊端:

  1. 语义模糊:LLM 看到的只是一大坨文本,很难区分哪里是指令,哪里是数据
  2. 元数据丢失:文件是什么类型(MIME Type)?来自哪里(URI)?这些上下文信息都丢失了

MCP Prompts 引入了 Embedded Resource 机制,解决了这个问题

技术实现:服务端 I/O + 结构化注入 当客户端调用 GetPrompt 时

服务端(MCP Server)会立即执行代码(例如 os.ReadFile/数据库查询)将读取到的实际内容封装进 JSON 返回

协议层面的 JSON 结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
// JSON-RPC Response
{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    // 1. 描述信息 (Optional)
    "description": "Code review context for main.go",

    // 2. 消息列表 (Required) —— 这就是 Prompt 的核心本体
    "messages": [
      
      // 第一条消息:System Prompt (纯文本)
      {
        "role": "system",
        "content": {
          "type": "text",
          "text": "你是一个资深的 Go 语言专家,请专注于并发安全问题的审查。"
        }
      },

      // 第二条消息:User Message (混合了 文本 + 资源)
      {
        "role": "user",
        "content": {
          "type": "resource",  // 这里是资源嵌入
          "resource": {
            "uri": "file:///project/main.go", // 标识符
            "mimeType": "text/x-go", // 类型提示
            "text": "package main\n\nimport \"fmt\"..." // 实际文件内容
          }
        }
      },

      // 第三条消息:User Message (补充指令)
      {
        "role": "user",
        "content": {
          "type": "text",      // 这里是普通文本
          "text": "请检查上面代码中是否存在 Goroutine 泄露的风险。"
        }
      }

	  // 其余消息:
	  // type: text (指令、话术)、type: resource (文件内容、数据)、type: image (图片内容)
    ]
  }
}

那为什么resource放在了”role”: “user”下面,而不是”role”: “system”

答:技术上可以放在 System 里,但工程上强烈建议放在 User 里

背后的逻辑:

  1. 语义定义的区别:身份 vs 素材

System 中的参数:定义”我是谁”

当你在 System Prompt 里填参数时,通常是在配置模型的人设、风格或边界

数据属于 “规则的一部分”,所以放在 System 里

User 中的 Resource:定义”我要处理什么”

当你把 Resource 放在 User Prompt 时,这是在提供待处理的原材料

数据属于 “任务的素材”,所以放在 User 里

  1. 安全边界的区别:可控 vs 不可控

System 参数:通常是”配置”

System Prompt 里的参数,通常来自你的系统配置,或者用户从下拉菜单里选的选项

所以它是可信的,可以放在 System 这个”最高权限区”

Resource 数据:通常是”素材”

Resource 里的数据,通常是文件内容、网页抓取结果或用户输入的长文本

所以它是不可信的

如果你把它放在 System 里,它的权重太高,容易覆盖掉你的安全指令

放在 User 里,模型会把它当做”用户说的话”来处理,防御力更强

放在 System 里:模型可能把它当成一条”最高指令”去执行,因为 System 代表权威

放在 User 里:模型更容易将其识别为”用户提供的一段包含恶意文本的数据”,从而保持 System 设定的人设不崩塌

将不可信的外部数据(Resource)与可信的内部指令(System Prompt)物理隔离

是防御 Prompt注入 的第一道防线

  1. 有没有 Resource 必须放在 System 的情况?有

有时候,你会检索出几条公司内部的高可信文档(比如《员工手册》)

你希望模型严格按照这些文档回答,绝对不能自由发挥

这时,为了提高权重,我们会把这些检索到的 Resource 放进 System里

为什么这里敢放?

数据源可信:这是公司内部审核过的文档,没有恶意注入

目的不同:这里的 Resource 是作为”法律”存在的,而不是”案件”

  1. 缓存策略

现在很多大模型(如:Claude, Gemini)都支持 Context Caching(上下文缓存)以降低长文本的成本和延迟

System Prompt 通常是静态的:你的助手人设(”你是一个专家…“)几周都不变

这部分非常适合被缓存

Resource 通常是动态的:用户一会传 main.go,一会传 user.go

如果混在一起:

如果你把 main.go 的内容塞进了 System 字段

那么每次文件变了,整个 System Prompt 的指纹(Hash)就变了,缓存就失效了

最佳实践:

System: 静态指令(长期缓存)

User: 动态指令 + Resource(短期缓存或不缓存)

需要注意的是:MCP 不能直接操作 Context Caching(Prompt Caching)

Context Caching(Prompt Caching) 是底层模型 API 的特性

只能由 客户端client 在调用模型 API 时自动管理

缓存基于提示前缀(prefix)的 hash

如果保持不变,就能命中缓存,省成本/延迟

MCP 只是一个协议层(基于JSON-RPC)

用于客户端 ↔ MCP server 的通信(暴露 tools、resources、prompts 等)

不包含控制底层 API 缓存的原语或方法

但 MCP 的使用可以间接优化或兼容 caching,也就是上面说的缓存策略

它通过 “决定数据放在哪(System 还是 User)”

实际上就是在 隐性操控 客户端的缓存命中率

Prompts 性能:会增加 LLM 推理耗时吗?

答案:不会。 MCP Prompts 和 MCP Tools 的运行机制完全不同

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
❌ 误区(类似 Tools 的流程)
━━━━━━━━━━━━━━━━━━━━━━━━━
用户提问
  ↓
Dify 把问题发给 LLM
  ↓
LLM 思考:"我需要调用工具"(消耗 Token,耗时)❌
  ↓
LLM 拿到模板,再次思考(消耗 Token,耗时)❌

✅ 真实流程(预处理/拼接)
━━━━━━━━━━━━━━━━━━━━━━━━━
Step 1:Client 填好参数(极快,几毫秒,纯用户侧)
Step 2:Dify 向 MCP Server 发送极小请求(网络开销 10-50ms)
Step 3:Server 进行字符串替换(代码逻辑,不涉及 AI,极快)
Step 4:Dify 把已拼装好的完整文本发给 LLM
Step 5:LLM 看到的就是一段完整指令,直接开始生成

结论:LLM 根本不知道这个提示词来自 MCP,还是手写的
      所以 AI 推理时间(延迟)= 零增加
MCP 原语 机制 LLM 是否需要额外思考? 耗时增加
Prompts 文本预处理/拼接 ❌ No(发送前已完成) 🟢 极低(仅网络传输)
Tools 函数调用 (Function Calling) ✅ Yes(需判断何时调用) 🟡 中(多一轮交互)
Resources 上下文挂载 (RAG) ❌ No(作为背景知识预加载) 🟢 极低(仅数据加载)