LangChain系列之MCP 与 Skills 接入:工具怎么接,章法怎么给
MCP 与 Skills 接入:工具怎么接,章法怎么给
上一篇我们把记忆装好了。跨会话记得住人,也记得住事。
可 Agent 手里还是那几个硬编码的函数,能做的事就那几件。
这一篇解决「能做什么」。
一、5 个 MCP Server,6 万 token 的入场费
先看一组公开数据,出自 Anthropic 自己的工程团队。
GitHub 的 MCP server 带 35 个工具,工具定义约 26000 token。Slack 只有 11 个工具,却占了约 21000 token。Jira 再加约 17000。
三个加起来,6 万 4 千 token。Anthropic 内部环境优化之前,光是工具定义就到过 134000 token。
这些 token 在用户说第一句话之前就已经付掉了,而且每一次请求都要重付一遍。
比账单更贵的是准确率。Claude API 的文档里写得很直白:工具数量超过 30 到 50 个之后,模型选错的概率显著上升。
Anthropic 给出一组对照。58 个工具全量摆在模型面前时,Opus 4 选对的概率是 49%。
改成按需检索之后,同样的模型,准确率到了 74%。窗口里少掉的是选项,涨上来的是判断力。
网上有一类工具名,放进同一个上下文里就是在为难模型。
notification-send-user 和 notification-send-channel。两个都是发通知,只有目的地不同。模型靠文字匹配做决定,名字像到一定程度,它就开始抓瞎。
更麻烦的是抓瞎的时候不报错。模型自信地从错的地方拿了数据,自信地总结给你。
我也拿自己这套工程算了一遍账。业务风格的工具,带中文描述与参数说明,单工具均值 220 token 上下。
按这个均值外推,200K 窗口下:
| 工具数 | token 总量 | 占 200K 窗口 |
|---|---|---|
| 10 | 约 2.2K | 1.1% |
| 50 | 约 11.0K | 5.5% |
| 200 | 约 43.9K | 22.0% |
| 1000 | 约 219.6K | 109.8% |
| 7000 | 约 154 万 | 768.6% |
7000 个工具,超窗口 7 倍多。
这里有个容易忽略的事实:上一篇文章的三招救不了这笔账。
裁剪、删除、摘要,全都在 messages 里做文章。工具定义走的是另一个字段,压根不在消息历史里。你裁得再干净,请求开头那几十 K 的工具说明一个 token 都不会少。
MCP 官方在客户端实践指南里给了一条判据:工具定义占到窗口的 1% 到 5%,就该换成渐进式发现。
于是两个问题浮上来:工具怎么接进来?接进来之后,怎么让模型选得动?
还有个更少被提起的问题。工具再多,也只是「能做什么」。至于「这件事该怎么做」,工具本身不会告诉你。
这一篇把两件事一起说清。MCP 管能力,Skills 管章法。
二、MCP 是什么:一个协议,替掉 N 套适配
一句话说清:MCP 把「工具」从代码里的一个函数,变成一个可以被发现的协议对象。
客户端连上 server,问一句你能干什么,server 回一份清单。清单里的每个工具都带名字、说明和参数结构。
协议本身给了三样东西:
| 原语 | 是什么 | 谁发起 |
|---|---|---|
| tools | 能调用的动作 | 模型决定何时调用 |
| resources | 能读的数据 | 客户端按需拉取 |
| prompts | 预设好的指令模板 | 用户或客户端选用 |
这一篇主要用 tools,resources 会在合体那一章顺带看一眼。
它为什么能在两三年里变成事实标准?因为省掉的适配工作是可量化的。
server 那边加一个工具,所有客户端零改动。Java 写的服务、Python 写的脚本、Node 写的网关,只要包一层 MCP,模型侧看到的形态完全一样。
那什么时候不用它,直接写 @tool 更省事?
| 场景 | 选择 | 原因 |
|---|---|---|
| 同进程内的几个纯函数 | 直接 @tool |
少一层进程通信,调试也简单 |
| 跨团队、跨语言的服务 | MCP | 对方不用装你的 Python 环境 |
| 已经有 REST 接口的服务 | MCP | 把接口包一层,比让每个客户端各接一遍划算 |
| 本地开发调试用的小工具 | @tool |
起 server 的开销不值当 |
| 要给多个 Agent 共用 | MCP | 一份 server,多个客户端 |
分界线在「复用范围」。只服务一个 Agent 的一个场景,函数就够了。要被两个以上的消费方用到,包 MCP 才开始划算。
三、接入姿势一:三行拿到工具
LangChain 侧走 langchain-mcp-adapters。最省事的写法是这样:
from langchain_mcp_adapters.client import MultiServerMCPClient
client = MultiServerMCPClient({
"order": {
"command": "python",
"args": ["servers/order_server.py"],
"transport": "stdio",
},
})
tools = await client.get_tools()
get_tools() 一回来,server 那边的 3 个函数就变成了 3 个 StructuredTool,可以直接交给 create_agent。
网上教程里的第一句话,现在大概已经跑不起来了。
with MultiServerMCPClient({...}) as client: # 这条路径已经不通
...
实测会直接抛:
TypeError: 'MultiServerMCPClient' object does not support the context manager protocol
大量 2025 年的教程还停在 with 写法上。遇到这个报错不用怀疑自己,是写法变了。
拿到工具之后,有三处细节值得先看一眼,后面处理错误时会用到。
第一,args_schema 是 dict 形式的 JSON Schema,不是 Pydantic 模型。
schema 类型 : dict
schema 内容 : {"properties": {"order_no": {"title": "Order No", "type": "string"}, ...
写 @tool 的时候参数结构是 Pydantic 模型,从 MCP 拿回来的是一份 JSON Schema 字典。要做参数校验或改写,得按 dict 处理。
第二,description 就是 server 端那个函数的 docstring 原文。
我那个查单工具的 docstring 第一行是「按订单号查询订单详情。」,客户端侧读到的就是这一句,一个字都没变。
写 docstring 就是在写提示词。 这句提醒看着朴素,实际是把 MCP server 做好的关键。文档写得含糊,模型就选不准。
第三,工具调用返回的是 content blocks 列表,不是字符串。
raw type : list
raw value: [{'type': 'text', 'text': '{\n "found": true, ...', 'id': 'lc_...'}]
想拿文本得自己拼一遍,从每个 block 里取 text 字段。
传输方式三选一,判断口径也简单:
| transport | 连接方式 | 适合场景 | 鉴权 |
|---|---|---|---|
| stdio | 客户端把 server 当子进程拉起 | 本地工具、单机脚本、开发期 | 没有 header 概念,走环境变量 |
| streamable-http | 标准 HTTP 请求 | 生产环境、跨网络、多客户端共享 | 支持 headers |
| sse | 长连接服务端推送 | 早期远程方案,正被前一个替代 | 支持 headers |
本地起步,stdio 最省事。要上生产、要给多个客户端共用,换成 streamable-http。
四、接入姿势二:会话复用,与上线前要处理的四件事
姿势一有个隐含代价,官方文档写得很清楚:每一次工具调用都会新建一个会话。
查一次订单,起一个进程。再查一次,再起一个。
无状态的服务这么干没问题。碰到有状态的 server 就麻烦了。浏览器会话、数据库连接、登录态,这些都得活在同一个会话里。
这时候换姿势二:
async with client.session("order") as session:
tools = await load_mcp_tools(session)
result = await tools[0].ainvoke({"order_no": "SO20260912001", "tenant_id": "t-acme"})
进到会话内部,工具在这个会话里加载,调用也在这个会话里发生。
代价是生命周期得自己管。什么时候开、什么时候关、断了怎么重连,全归你。
姿势定了,接下来四件事是上线前绕不过去的。
第一件,鉴权。
headers 只对 http 和 sse 有效。stdio 走的是一条管道,没有 header 这个概念。
本地 stdio server 要拿凭据,走环境变量:
{
"command": "python",
"args": ["servers/order_server.py"],
"transport": "stdio",
"env": {"ORDER_API_TOKEN": os.environ["ORDER_API_TOKEN"]},
}
第二件,工具重名。
接了 5 个 server,其中 3 个都有个叫 search 的工具。后加载的会覆盖先加载的,而且不留任何提示。
官方参数叫 tool_name_prefix,给每个 server 的工具加前缀。这件事建议默认打开,别等出事再补。
第三件,错误。
MCP 把两类错误分得很清楚。工具执行失败是一种,会以 isError=True 的结果返回,作为 ToolMessage 回到模型手里,模型看到错误可以自己调整再来一次。协议层失败是另一种,连接断了、初始化失败,这类该崩就崩,硬扛没有意义。
客户端上有 handle_tool_errors 开关,用来决定执行错误怎么落地。
第四件,超时与重试。
第三方 server 的响应时间不归你管。LangChain 侧给了两个中间件兜底,ToolRetryMiddleware 管重试,ToolErrorMiddleware 管错误转译。
有时候还要防模型自己打转,ToolCallLimitMiddleware 可以直接卡住调用次数。
接入是十行代码,上线是这十件事。这句话我建议贴在工位上。
五、工具规模化:10 个靠人管,7000 个靠检索
前面那张账单表到这里要变成真问题了。
10 个工具靠人管,谁都能说清每个工具是干什么的。100 个工具就得上规范。几千个工具的时候,靠人管这件事本身就不成立了。
官方给了三条路。
第一条,把发现这件事交给 provider。
ProviderToolSearchMiddleware 会把指定的工具标记成延迟加载,由 provider 那边按需把 schema 取回来。
from langchain.agents.middleware import ProviderToolSearchMiddleware
ProviderToolSearchMiddleware(searchable_tools=["query_order", "calc_refund"])
这条路成本最低,但有一条硬约束:它只支持 Anthropic 与 OpenAI。
被标记为延迟加载、而当前 provider 不认识这套约定时,会直接抛 ValueError。用国产模型或本地模型的朋友,这条路走不通,得看后两条。
第二条,用一个便宜模型先挑工具。
LLMToolSelectorMiddleware 的逻辑是:每次调用之前,先把用户的问题和工具清单交给一个便宜模型,让它挑出相关的那几个,再把挑好的清单交给主模型。
from langchain.agents.middleware import LLMToolSelectorMiddleware
LLMToolSelectorMiddleware(model="gpt-4o-mini", max_tools=8, always_include=["read_skill"])
要留意它的解析失败策略。默认是 'error',挑工具的模型如果吐不出合法结构,整条请求就失败了。可以改成 'all' 兜底成「全给」,宁可多花点 token 也别把请求打断。
第三条,自己搭一个工具网关。
这是我在产线上用的路子,也是本篇 demo 里实现的那条。
思路一句话:货架全量注册,模型只看清单。 工具注册进一张表,每轮按当前的检索意图捞出 top-N,用 request.override(tools=...) 注入。
class ToolGatewayMiddleware(AgentMiddleware):
def wrap_model_call(self, request, handler):
query = last_human_text(request.messages)
on_shelf = self.shelf.names()
# 只接管货架上的工具,别的中间件注入的工具原样保留
keep = [t for t in (request.tools or []) if t.name not in on_shelf]
visible = keep + self.shelf.search(query, top_k=self.top_k)
return handler(request.override(tools=visible))
整条链路是这样一个形状:
这段代码里有一行特别容易踩空,我单独拎出来说。
request.override(tools=...) 是整份替换,不是增量合并。你只挑了 8 个工具注入,另外那些中间件自己加的工具也会被一起抹掉。
我自己写 Skills 中间件时撞上过这个。技能清单进上下文了,模型却调不到读技能正文的工具,报 Error invoking tool,查了半天才发现是网关把工具吃掉了。
正确做法就是上面那样,先把不在货架上的工具捞出来,原样带着走。
网关真正难的地方不在代码,在三个工程约定。
命名规范。 工具名按 {域}_{对象}_{动作} 组织,例如 order_refund_submit。前缀天然就是分组,检索和权限都能直接按前缀切。
危险分级。 只读、写、危险三档分开。危险操作不进自动检索结果,就算关键词命中也得显式放行。删库这个动作,不该因为用户说了句「清理一下」就出现在模型面前。
货架与清单分离。 注册是全量注册,可见是按需可见。这两件事分开之后,你能同时得到完整的能力覆盖和可控的上下文成本。
跑一遍这套网关,账是这样的:
货架共 63 个工具|分组:dom00 3,dom01 3,dom02 3|分级:read 33,write 22,danger 8
全量 63 个工具:13,799 token = 200K 窗口的 6.9%
命中 8 个(上限 8),模型侧账单从 13,799 降到 1,669 token
Agent 注册的工具总数 : 63
模型这一轮实际看到 : 9 个
账单降到原来的 1/8。第 9 个是 read_skill,来自 Skills 中间件,被网关原样保留了下来。
把货架换成 7000 个工具,模型侧依然是 9 个。这就是规模化之后还能管住的原因。
六、Skills 是什么:把工序写成一个文件夹
工具解决的是「能做什么」,接下来是「该怎么做」。
拿退款这件事举例。你有查订单的工具、有试算的工具、有提交的工具。模型手里攥着这三个工具,它会怎么做?
它可能先提交再试算。可能不问原因就自己挑一个退款理由编码。可能会凭印象告诉你「7 天内可以无理由退」,而这句话不在任何工具返回值里。
工具给了它能做的事,没给做事的方法。
Skills 的思路是:把方法写成一个文件夹。
refund-flow/
├── SKILL.md # 主文件,写工序
├── references/policy.md # 细则,需要时再读
└── scripts/ # 脚本,需要时再跑
SKILL.md 的结构很轻。开头一段 frontmatter,必填的只有两个字段:
---
name: refund-flow
description: 处理客户退款申请的标准工序。当用户提出退款、退货、退钱、订单取消、售后补偿等请求时使用,覆盖查单、试算、审批判断、提交与回执全流程。
---
下面是正文,用大白话把步骤写清楚。我那份写的是 5 步:查订单、查政策、试算金额、判断是否需要审批、提交并出回执。
关键在于它怎么进上下文,这一层叫做渐进披露:
启动的时候,模型只看到 skill 的名字和那句 description。任务命中哪条,它自己去读那条的正文。正文里提到的细则文件,要用了再读。
成本对照很直观。
同样一件事,用 MCP 的方式给 GitHub 能力,工具定义是 23K 到 50K token。写成一个 skill,名字加描述 200 到 500 token 就够。
有个观念得掰一下:Skills 不是「更弱的 MCP」。
流程、判据、输出格式这几类东西,本来就没法用函数签名表达。你没法定义一个函数叫「按公司规定判断这笔退款该不该批」,因为它的判断依据是一段话。
还有一条实操提醒,我认为是 Skills 最容易翻车的地方。
description 是唯一的触发入口。 模型只凭这一句话决定要不要用你这条技能。
写得含糊,例如「帮助处理售后相关事项」,条件相近的场景全都命中不了,这条技能等于白写。写得具体,把触发场景一个个列出来,命中率立刻不一样。
七、MCP 与 Skills,到底什么关系
讲到这儿,两个东西容易混。它们确实容易混,因为都在解决「模型能力不够」这件事。
先把分层讲清楚。
| 维度 | MCP(能力层) | Skills(过程层) |
|---|---|---|
| 给什么 | 能调用的动作 | 事情该怎么做 |
| 单元形态 | 函数签名加描述 | 文件夹加段落文字 |
| 谁定义 | server 作者,跨团队跨语言 | 业务方,跟着流程变 |
| 怎么用上 | 模型直接调用 | 模型先读正文,再照正文办事 |
| 触发方式 | 工具名与描述匹配意图 | description 匹配意图,再读正文 |
| 上下文成本 | 全量常驻,单工具约 220 token | 清单常驻约 100 token,正文按需 |
| 失败的样子 | 调错工具、参数填错 | 压根不触发,或触发了不照做 |
| 典型场景 | 查订单、发通知、提工单 | 退款流程、周报格式、评审清单 |
右边这一列有个共同点:它们的答案都是一段话。左边这一列也有个共同点:它们的答案都能写成一行签名。
于是判断口径就出来了:能用函数签名说清的事交给 MCP,必须用一段话才能说清的事交给 Skills。
遇到具体需求时,按这张表归位:
| 需求 | 落在哪一层 | 理由 |
|---|---|---|
| 让 Agent 能查订单 | MCP | 输入输出明确,两个参数 |
| 让 Agent 按退款流程办事 | Skills | 步骤顺序与审批判据要说清 |
| 让 Agent 出周报 | Skills | 四段结构、统计口径、字数限制 |
| 让 Agent 调内部 API | MCP | 接口定义现成 |
| 让 Agent 处理一笔退款 | 两层都要 | 工序来自 Skills,动作来自 MCP |
最后一行是常态。真实任务几乎都要两层叠起来。
有两个常见误判,两头都有人踩。
一种是把 Skills 当轻量版 MCP 用,把接口文档整段抄进 SKILL.md。结果最该省的正文长度没省下来,还得自己实现读正文的工具,成本优势一点没剩。
另一种是反过来,把 20 步的业务流程硬写成一个工具函数。参数会多到模型填不准,中间任何一步的判断逻辑也没地方放。
八、框架自带的 Skills,与它的一条边界
先看现成的怎么用。deepagents 里一行接入:
from deepagents import create_deep_agent
from deepagents.backends import FilesystemBackend
agent = create_deep_agent(
model=model,
skills=["./skills"],
backend=FilesystemBackend(),
)
我把它的 SkillsMiddleware 拆开看过,实现很干净,值得照着写。
before_agent 阶段扫各来源目录,解析 frontmatter,把元数据写进 state["skills_metadata"]。state 里已经有了就直接返回 None,所以一次会话只扫一遍。
wrap_model_call 阶段把技能清单追加进 system message,走 request.override 写回。
多来源按顺序加载,同名后者覆盖前者,提示词里会标出哪条优先级更高。base → user → project 这种分层就是这么实现的。
加载失败的情况处理得也讲究。它会以 <skill_load_warnings> 的形式注入,并明确标注这是诊断信息、不得当作指令执行。这条写法的价值在防提示注入,值得抄。
但这里有一条边界,是我读源码时最想写进文章的一条。
SkillsMiddleware 自己不注册任何工具。它的 tools 是空的。
技能正文靠谁读?靠 read_file。而 read_file 这个工具来自 deepagents 的另一个中间件,文件系统那一个。
把 SkillsMiddleware 单独拿到纯 create_agent 里用,会发生什么?
模型看得到技能清单,知道有这么两条技能,却没有工具去读它们的正文。
清单上写着路径,模型照着路径伸手,抓了个空。
这不是抱怨官方实现。它在 deepagents 那套组合里工作得很好。
我想说的是:「读正文」这件事得有工具来承担,而这件事没人替你兜底。
顺手把几个校验规则列一下,都是容易踩的:
| 规则 | 触发时的行为 |
|---|---|
| name 必须与目录名一致 | 只警告,照样加载 |
| name 只允许小写字母、数字、连字符 | 不规范会警告 |
| name 不超过 64 字符 | 超了会警告 |
| description 不超过 1024 字符 | 静默截断,只保留前 1024 |
| SKILL.md 不超过 10MB | 超了跳过,防的是 DoS |
allowed_tools 里列的工具 |
只渲染进提示词,不做任何强制检查 |
最后一条要特别说。allowed_tools 写的是「这条技能允许用哪些工具」,但它在实现里只是被拼进提示词里给模型看,没有任何运行时检查。
它是一句建议,不是一道闸门。想要真正的隔离,得上子 Agent 或者自己拦。
description 那条静默截断也很隐蔽。前端展示的时候看着很长很详细,实际进上下文的只有前面 1024 个字符。触发词要是写在第 1025 个字符上,这条技能就永远不会被触发。
九、自己写一个 Skills 中间件
知道边界之后,自己写一个就不难了。核心逻辑 4 步,代码 100 行上下。
class SkillLibraryMiddleware(AgentMiddleware):
state_schema = SkillsState
def __init__(self, skill_dirs, tool_name="read_skill"):
super().__init__()
self.library = SkillLibrary(skill_dirs)
self.tools = [self._build_read_tool()] # 关键:自己提供读正文的工具
def before_agent(self, state, runtime): # 扫目录、解析元数据
if state.get("skills_metadata"):
return None
metas, warnings = self.library.scan()
return {"skills_metadata": [m.to_state() for m in metas],
"skills_warnings": warnings}
def wrap_model_call(self, request, handler): # 清单进 system message
return handler(self._inject(request))
四个设计要点,前三个是常识,第四个是我实测踩出来的。
第一,路径白名单。
技能正文是磁盘上的文件,路径是模型给的。模型的路径来自它读到的清单,但你不能假设它一定守规矩。
../ 一个目录穿越,就能把服务器上任意一个文件读进上下文。这一条是安全底线,不是可选项。
root = Path(meta.root).resolve()
target = (root / file).resolve()
try:
target.relative_to(root)
except ValueError:
return f"[read_skill] 路径越界,已拒绝:{file}"
先 resolve 再比对,别用字符串前缀判断。.. 和各种软链接的变体能绕过简单的字符串检查。
第二,清单进 system message,别塞成一条消息。
技能清单是每轮都要用的内容,放进 system message 才能吃到 prompt 缓存。塞成一条普通消息,每轮都在变,缓存直接失效。
第三,正文绝不预加载。
wrap_model_call 里只注入 name 和 description。正文等模型自己调 read_skill 去读。这一条守住了,Skills 的成本优势才成立。
第四,同步与异步钩子都要实现。
这条我撞得比较实在。写完 wrap_model_call 用 ainvoke 跑,直接报错:
NotImplementedError: Asynchronous implementation of awrap_model_call is not available.
You are likely encountering this error because you defined only the sync version
(wrap_model_call) and invoked your agent in an asynchronous context.
官方报错信息写得很清楚,两套都要给。写成 abc 层面强制可能更好,现在这个设计得靠报错提醒。
自己写和用官方的差异,一张表说清:
| 对比项 | 官方 SkillsMiddleware | 自己实现 |
|---|---|---|
| 读正文的工具谁提供 | 依赖文件系统中间件 | 自己注册 read_skill |
| 能否脱离 deepagents | 不能,缺了文件系统就没法用 | 能,核心逻辑自包含 |
allowed_tools 是否强制 |
只进提示词 | 可按需做成运行时校验 |
| 多源优先级 | 支持,后者覆盖前者 | 需要自己加 |
| 路径白名单 | 由文件系统中间件负责 | 自己负责,必须做 |
选哪边,用的还是上一篇那套判据:能力能不能自己说了算。
要接进 deepagents 那套现成体系,用它自带的。要嵌进自己的平台、要控制读文件这条路径、要做工具级强制,自己写。
十、合体:一条完整工序串起两层
两层都齐了,走一遍真实场景。
用户说:客户说键盘有质量问题,要退款,订单号 SO20260912002,已经开票了。
这个任务里,工序来自 Skills,动作来自 MCP。两个 server 各走一种传输,正好一起验。
connections = {
"order": stdio_conn("order_server.py"), # 本地进程,stdio
"kb": http_conn(port), # 常驻服务,streamable-http
}
tools = await pose_get_tools(connections)
built = build_agent(model, tools=tools) # 注册 2 个中间件:技能库 + 工具
result = await built.agent.ainvoke({"messages": [{"role": "user", "content": QUESTION}]})
跑起来是这样:
实际输出:
stdio : order_server -> ['query_order', 'calc_refund', 'submit_refund']
http : kb_server -> ['search_policy']
system message 里出现技能清单 : True
system message 里出现技能正文 : False
system message 长度 : 600 字符
第 1 轮|模型看到 5 个工具|动作:read_skill
第 2 轮|模型看到 5 个工具|动作:search_policy
第 3 轮|模型看到 5 个工具|动作:query_order
第 4 轮|模型看到 5 个工具|动作:calc_refund
第 5 轮|模型看到 5 个工具|动作:submit_refund
第 6 轮|模型看到 5 个工具|动作:最终回答
第 1 轮先读工序,第 2 到第 5 轮照着工序办事,第 6 轮出结果。
看看第 1 轮读到的正文,这是我那份 skill 的开头:
# 退款处理工序
按下面 5 步走,不要跳步。每一步都要拿到上一步的返回值再继续。
## 第 1 步:查订单
调用 query_order(order_no, tenant_id)。
拿不到订单时直接回复客户「未查到该订单」,不要继续往下走。
正文 674 个字符,只在命中时才进上下文。system message 里的清单是 600 个字符,常驻。
两件事同时发生了。技能清单不预加载正文,工具定义也不全量上桌。
最后出结果,跑通的输出是这样:
已受理。工单号 RF912002,退款金额 458.00 元,走质量问题通道,运费与税点全免。
到账时间 1 到 3 个工作日。您需要把发票原件寄回,地址我发到您手机上。
依据:refund.window 质量问题 30 天内可退;refund.invoice 已开票需财务先冲红。
回执里四个要素都齐了。金额来自试算工具的返回值,两条依据来自政策检索工具,我那份 skill 里明确写了「金额、时间、政策条款三项全部来自工具返回值,不要自己编」。
这就是工序的价值。 它把「该拿哪些数据」「该按什么顺序」「哪几个字段不能自己编」这三件事,从模型的自由发挥变成了明确要求。
顺手把货架模式叠上去,同一个 Agent 多挂一个中间件:
货架 404 个工具,模型看到 9 个
['read_skill', 'calc_refund', 'query_order', 'search_policy',
'dom00_order_query_0000', 'dom00_order_query_0080', ...]
工序、动作、成本控制,三样都在位了。
十一、把这两层合起来看
MCP 让 Agent 有能力,Skills 让 Agent 有章法。
只给能力,它会乱试。手里攥着退款工具,却不问原因直接提交,把该走的审批跳过去。
只给章法,它无从下手。工序写得再清楚,没有工具去查订单,它只能干看着那 5 个步骤。
两张落地清单给你。
MCP 接入五步:
先探协议,确认 server 支持哪几种传输,本地还是远程。
再拿工具,get_tools 起手,有状态的场景换成会话复用。
然后处理鉴权、重名、错误、重试这四件事。
接着算一遍工具定义的账,看占到窗口的百分之几。
最后决定要不要上货架。
Skills 接入四步:
先把 description 写具体,把触发场景一条条列出来。
再控制正文长度,细则推到 references 里去。
然后决定读正文的工具由谁提供,是借现成的还是自己注册一个。
最后给路径白名单兜底。
回到这篇开头那笔账。
工具定义那 6 万 token,MCP 自己解决不了,它只负责把工具送进来。真正把它按住的,是「别一次全给」这个思路。货架解决工具那一半,任务命中才加载解决章法那一半。
两半加起来,一个 Agent 才可能在几百上千个工具面前保持清醒。
工序和工具都齐了,下一个问题自然会冒出来:这些能力该不该塞进同一个 Agent。
查订单的、写文案的、审合同的,全堆在一个 system prompt 里,上下文又开始膨胀,权责也开始模糊。下一步该聊的,是把一个 Agent 拆成几个。
本篇完整工程化 demo(2 个 MCP server 分别走 stdio 与 streamable-http、货架式工具网关、自实现的 Skills 中间件、16 项冒烟测试,核心零依赖、无需 API key 即可跑通)已打包。在公众号回复 mcp 领取。