教学 LLM 按需拉取 MCP 资源与提示词以优化上下文
- 作者

- 姓名
- Nino
- 职业
- Senior Tech Editor
在构建面向生产环境的 AI 智能体(Agent)时,管理大语言模型(LLM)的上下文窗口是最关键的挑战之一。随着 Claude 3.5 Sonnet 和 DeepSeek-V3 等先进模型被深度集成到开发者工作流中,它们需要频繁访问外部数据库、API 文档和代码库结构。由 Anthropic 开源的模型上下文协议(Model Context Protocol,简称 MCP)已成为向 LLM 暴露这些能力的标准协议。
然而,标准的 MCP 集成方案通常存在一个根本性的设计缺陷:它们倾向于将海量信息塞满 LLM 的上下文窗口。通过使用领先的 LLM API 聚合器 n1n.ai,开发者能够接入具有极高并发和极低延迟的顶级模型。尽管这些模型支持多达 200k 甚至更长的上下文,但这并不意味着我们应该无节制地消耗它们。将冗余信息直接堆积到上下文窗口中,不仅会显著增加 API 响应延迟,还会大幅提升调用成本,并因为“大海捞针”(Needle in a Haystack)效应而降低模型的推理准确率。
本技术指南将深入探讨如何将 MCP 的“应用控制”(Application-controlled)原语无缝接入模型控制的工具调用循环中,从而将上下文检索的主动权从应用端转移给模型自身。
MCP 的控制权困境:应用控制与模型控制
模型上下文协议(MCP)定义了服务端向客户端提供能力的三种核心原语:
- 工具 (Tools):允许模型自主选择调用的可执行函数。
- 资源 (Resources):只读的数据源(如文件、API 响应或数据库 Schema)。
- 提示词 (Prompts):预定义的模板或斜杠命令,用于辅助用户格式化输入。
这些原语在自主 Agent 的执行流中有着截然不同的控制逻辑:
| 原语 | 决定使用时机的角色 | 是否天然适合工具调用? |
|---|---|---|
| 工具 (Tools) | 大语言模型 (LLM 自身通过 function-calling 触发) | ✅ 是 — 天然嵌入在推理循环中 |
| 资源 (Resources) | 应用程序或终端用户 (启动时预加载) | ❌ 否 — 传统做法是直接塞入系统提示词 |
| 提示词 (Prompts) | 终端用户 (通常通过 UI 的斜杠命令触发) | ❌ 否 — 在执行循环开始前即已加载完毕 |
由于资源和提示词在模型的自主推理循环中缺乏原生的触发机制,大多数开发者选择走阻力最小的通路:在应用启动时拉取所有资源,并将它们的全部文本内容拼接到系统提示词(System Prompt)中。
这种暴力填充(Context Stuffing)的方式在实际生产中会迅速暴露以下四个瓶颈:
- 上下文膨胀 (Context Bloat):每一次 API 请求都必须为所有资源支付 Token 成本,无论模型当前是否需要这些资源。如果挂载了 10 个每个 5,000 Token 的 Markdown 文件,意味着每一次对话交互都会平白无故多出 50,000 Token 的开销。
- 内容被动截断 (Truncation):为了防止 Token 费用失控,开发者通常会强制截断资源内容(例如限制
content[:2000])。这会导致大文件末尾的关键信息被无情丢弃,导致模型产生幻觉。 - 二进制文件处理失败:PDF、PNG 或 ZIP 等资源没有直接的纯文本表达。简单的文本读取器在处理这些文件时经常抛出
UnicodeDecodeError,或者只输出无用的[No content available]占位符。 - 模型缺乏自主权:模型无法主动声明“我现在不需要这些数据”或“我需要完整读取那个特定文件”。它只能被动接受被灌输的所有信息。
破局方案:构建客户端合成工具
为了解决上述问题,我们可以改变控制流的方向。我们不再将资源塞入系统提示词,而是将应用控制的资源和提示词转化为模型控制的合成工具(Synthetic Tools)。通过在客户端注册两个合成工具——read_resource(uri) 和 invoke_prompt(name),我们能够让模型在确有需要时自主拉取数据。
在此架构下,系统提示词不再包含任何具体的资源内容,而仅仅包含一个轻量级的资源目录(Resource Catalog),列出所有可用资源的 URI 及其简要描述。
改造后的系统提示词示例如下:
## Available Resources
The following resources can be read on demand. To read one, call the
`read_resource` tool with its exact URI. Do not assume a resource's
contents until you have read it.
### Resource: SUM ABAP Test Matrix
URI: sap-btp://sum-abap-v1
Description: SUM (Software Update Manager) test matrix specification for ABAP products
当模型接收到用户请求并进行分析时,它会首先扫描资源目录。如果它判断解答该问题需要用到 ABAP 测试矩阵,它就会输出一个工具调用请求:read_resource(uri="sap-btp://sum-abap-v1")。我们的编排图(Graph)会拦截该调用,直接从本地缓存中提取对应的内容,并作为工具响应消息返回给模型。这样,资源内容就只会在需要它的那一轮对话中进入上下文。
Python 逐步实现指南
我们将基于 LangGraph 和 langchain-mcp-adapters 的设计模式来演示具体实现。这一模式是框架无关的,你可以轻松将其移植到其他 Agent 框架中。
步骤 1:定义合成工具
我们首先定义 read_resource 作为一个 StructuredTool,其 Schema 接收一个 URI 字符串。其执行函数(func)设为一个占位 Lambda 表达式,因为真正的执行逻辑将在执行图的节点中被拦截和处理。
from pydantic import BaseModel, Field
from langchain_core.tools import StructuredTool
class ReadResourceArgs(BaseModel):
uri: str = Field(
description="The exact URI of the MCP resource to read, e.g. 'sap-btp://sum-abap-v1'."
)
read_resource_tool = StructuredTool.from_function(
func=lambda uri: "", # 占位 Lambda,在图的执行流中被拦截
name="read_resource",
description=(
"Read the full contents of an MCP resource by its URI. "
"Call this when the user asks to read, open, or summarize a resource, "
"or when you need a resource's contents to answer. "
"Only resources listed under 'Available Resources' can be read."
),
args_schema=ReadResourceArgs,
)
# 注册工具并将其加入免审白名单
tools = [read_resource_tool]
allowed_tools_without_review = ["read_resource"]
专家提示:工具的描述(Description)本质上就是模型的路由提示词。务必清晰、严谨地编写描述,以便 LLM 准确判断何时调用该工具,何时使用其自身的参数化知识。
步骤 2:缓存并索引 MCP 资源
为了避免在工具调用循环中产生额外的网络延迟,我们在客户端初始化时一次性拉取资源的元数据,并将其缓存在以 URI 为键的内存字典中。
# 内存资源缓存
resources_content = {}
def initialize_resource_cache(mcp_resources):
for res in (mcp_resources or []):
uri = getattr(res, "uri", None)
name = getattr(res, "name", "")
description = getattr(res, "description", "")
content = getattr(res, "content", "")
# 避坑指南:MCP 协议中的 URI 可能会以 Pydantic 的 AnyUrl 对象形式传入。
# 必须将其显式转换为 plain string,否则在字典查询中会因为类型不匹配而静默失败。
uri_str = str(uri) if uri is not None else ""
if uri_str and uri_str != "unknown":
resources_content[uri_str] = {
"name": name,
"description": description,
"content": content
}
步骤 3:优雅处理二进制和多媒体资源
如果资源是 PDF、图像或二进制包,直接将原始字节塞进工具的输出消息会使模型崩溃,或产生大量乱码。我们需要解析 MIME 类型,对非文本资源返回结构化的描述符:
def is_text_mime(mime: str) -> bool:
mime = (mime or "").lower().split(";")[0].strip()
return (
mime.startswith("text/")
or mime.endswith(("+json", "+xml", "+yaml"))
or mime in {"application/json", "application/xml", "application/yaml"}
)
def extract_resource_payload(uri_str, resource_data, mime_type):
if isinstance(resource_data, bytes):
if is_text_mime(mime_type):
return resource_data.decode("utf-8")
else:
# 返回一个清晰的二进制描述符,而不是传输破坏上下文的原始字节
size_kb = len(resource_data) / 1024
return (
f"[Binary resource: {mime_type or 'application/octet-stream'}, "
f"{size_kb:.2f} KB. This is not text and cannot be inlined; "
f"open it with a client that handles its media type.]"
)
return str(resource_data)
步骤 4:在执行图中拦截工具调用
在 LangGraph 的 Agent 执行节点中,我们在工具分发逻辑前对 read_resource 进行拦截。这样可以省去网络往返,直接利用本地缓存快速返回结果。
def handle_tool_calls(state):
messages = state["messages"]
last_message = messages[-1]
new_messages = []
if not last_message.tool_calls:
return {"messages": []}
for tool_call in last_message.tool_calls:
if tool_call["name"] == "read_resource":
uri = str(tool_call["args"].get("uri", ""))
resource = resources_content.get(uri)
if resource:
# 根据 MIME 类型安全地解析资源内容
raw_content = resource["content"]
mime = resource.get("mime_type", "text/plain")
resolved_text = extract_resource_payload(uri, raw_content, mime)
new_messages.append({
"role": "tool",
"name": "read_resource",
"content": resolved_text,
"tool_call_id": tool_call["id"],
})
else: # 优雅处理未找到资源的情况
available_uris = list(resources_content.keys())
new_messages.append({
"role": "tool",
"name": "read_resource",
"content": f"Resource '{uri}' not found. Available URIs: {available_uris}",
"tool_call_id": tool_call["id"],
})
return {"messages": new_messages}
架构执行流程图
以下是当用户发起请求时,该架构在内部的完整数据流向:
┌─────────────────┐
│ 用户输入消息 │
└────────┬────────┘
│
▼
┌──────────────────────────────────────────┐
│ 系统提示词 = 仅包含资源目录 (Catalog) │
│ (仅包含 URI 与描述,不包含内容) │
└────────┬─────────────────────────────────┘
│
▼
┌───────────────┐
│ LLM 自主决策 │
└──┬─────────┬──┘
│ │
需要读取│ │ 不需要读取
资源内容│ └──────────────► 直接输出回答
▼
┌──────────────────────────┐
│ tool_use: read_resource │
│ (uri) │
└────────────┬─────────────┘
│
▼
┌──────────────────────────────┐
│ 运行图 (Graph) 拦截该工具调用 │
│ (自动批准,无需人工介入审核) │
└────────────┬─────────────────┘
│
▼
┌──────────────────────────────┐
│ 在本地缓存 Map 中检索 URI │
└───────┬───────────────┬──────┘
│ 文本类型 │ 二进制类型
▼ ▼
┌────────────────┐ ┌──────────────────────┐
│ 填充完整文本 │ │ 填充描述符: │
│ 作为工具消息返回│ │ MIME 类型与文件大小 │
└───────┬────────┘ └───────────┬──────────┘
│ │
└───────────┬───────────┘
│
▼
(返回给 LLM ──► 生成最终回答)
生产环境避坑指南与最佳实践
在通过 n1n.ai 部署大规模生产级 Agent 时,建议严格遵循以下五个工程最佳实践:
- 类型标准化 (Type Normalization):确保将缓存 Key 统一强制转换为
str类型。某些 MCP 客户端库会将 URI 解析为 Pydantic 的AnyUrl对象,若直接用作字典的 Key,在进行字符串匹配查询时会静默失败。 - 免审自动批准 (Auto-Approval):如果你的 Agent 架构中引入了人工审核(Human-in-the-Loop)机制,务必将
read_resource等只读操作加入免审白名单。要求用户手动确认每一次“读取文件”的操作会极大地损害交互体验。 - MIME 字段定位:在
langchain-mcp-adapters中,媒体类型信息是直接挂在 Blob 对象的.mimetype属性上的,而不是在metadata字典里。读取错误的字段会导致所有文件都被识别为通用的二进制流。 - 提示词缓存复用:当使用 n1n.ai 提供的模型 API 时,尽量保持系统提示词中的资源目录结构稳定。静态的资源列表能够极大提高大模型的 Prompt Cache 命中率,从而大幅削减首字延迟(TTFT)与调用成本。
- 动态目录剪枝:如果你的 MCP 服务端暴露了成百上千个资源,切忌将它们全部塞入目录。应当在生成系统提示词前加入一步轻量级的向量检索(RAG),仅将与当前问题最相关的 5-10 个资源的描述和 URI 写入系统提示词中。
总结
通过将模型上下文协议(MCP)的资源转化为由模型控制的合成工具,我们成功地将上下文管理模式从粗暴的“推送(Push)”转变为精准的“拉取(Pull)”。LLM 获得了自主检索数据的能力,Token 消耗显著降低,大文件被迫截断导致的幻觉问题也迎刃而解。
在 n1n.ai 获取免费 API 密钥