拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

LLM、Tools、MCP、Skills 统一网关:Agent 系统资源治理与工程实践

LLM、Tools、MCP、Skills 统一网关:Agent 系统资源治理与工程实践

1. 为什么要把 LLM、Tools、MCP、Skills 塞进同一个网关

第一次听到“tsm-hub”这个名字,很多人会以为又是一个套壳的模型转发服务。但真正上手搭过 Agent 系统的人会立刻意识到,它要解决的是一个非常具体的工程痛点:当你的系统里同时存在多个大模型供应商、一堆本地工具函数、若干 MCP Server,以及不断迭代的 Skills 提示词包时,调用链会迅速变成一团乱麻。

我最早做 Agent 项目时,采用的是最朴素的做法:业务代码里直接 import 各家 SDK,工具函数写在一个 utils 目录里,MCP 客户端单独起一个进程,Skills 则以 markdown 文件的形式散落在仓库各处。项目小的时候没问题,一旦要接入第二个模型供应商、或者要把某个工具同时暴露给三个不同的 Agent,代码就开始失控。改一处工具签名,要翻遍五六个调用点;想给某个模型单独配置超时和重试策略,发现配置项散落在三个配置文件里。

tsm-hub 的核心思路,就是把这四类东西抽象成统一的“资源”,通过一个网关层统一注册、统一路由、统一鉴权、统一观测。LLM 是推理资源,Tools 是本地可执行能力,MCP 是外部协议化的能力来源,Skills 是面向任务的能力编排单元。它们本质上都是 Agent 在完成任务时需要调用的东西,只是形态和调用方式不同。把它们收进一个网关,意味着上层业务只需要面对一套接口,底层的差异由网关消化。

这个设计带来的直接好处有三个。第一是可替换性:今天用 A 模型,明天想换成 B 模型做 A/B 测试,只需要在网关配置里改一行,业务代码零改动。第二是可观测性:所有调用都经过同一个入口,日志、耗时、token 消耗、错误率可以统一采集,不用在每个 SDK 外面包一层埋点。第三是权限收敛:哪些 Agent 能调用哪些工具、哪些 Skills 能访问哪些 MCP Server,全部在网关层做策略,而不是散落在业务逻辑里做 if-else。

适合读这篇内容的人,是那些已经过了“跑通 demo”阶段、开始认真考虑 Agent 系统可维护性的开发者。如果你还在纠结怎么让模型返回 JSON,这篇可能偏深;但如果你已经在为“工具越来越多、模型越接越杂、Skills 版本管理混乱”而头疼,那接下来的拆解应该能帮你省下不少重构时间。

2. 四类资源的抽象设计与选型考量

2.1 LLM 资源:为什么不做成简单的代理转发

很多人对“LLM 网关”的第一反应是反向代理:把请求转发给 OpenAI 或 Anthropic 的接口,顺便做个 key 管理。但 tsm-hub 里的 LLM 资源抽象要更深一层。它需要处理的不只是 HTTP 转发,还包括模型能力描述、参数归一化和降级策略。

模型能力描述指的是,网关需要知道每个注册的模型支持什么:是否支持 function calling、是否支持流式输出、上下文窗口多大、是否支持图片输入。这些信息在路由时至关重要。比如一个请求带了 tools 参数,网关就应该只路由到支持 function calling 的模型;如果所有候选模型都不支持,网关要能提前返回明确的错误,而不是把请求发出去再等一个 400。

参数归一化解决的是不同供应商 API 差异的问题。同样是“最大输出 token”,有的叫max_tokens,有的叫max_output_tokens,有的放在顶层,有的放在 generation config 里。如果让业务代码去适配这些差异,那网关就白做了。tsm-hub 的做法是定义一套内部标准参数,在适配层做双向映射。这样新增一个供应商,只需要写一个适配器,业务侧完全无感。

降级策略是实际生产里最容易被忽略、但最不能省的部分。我踩过的坑是:某个模型供应商在高峰期频繁超时,但业务代码里没有做任何 fallback,导致整个 Agent 链路卡死。在网关层做降级就优雅得多——配置一条规则:主模型连续失败 N 次或响应超过 T 毫秒,自动切到备用模型,同时打点告警。业务代码完全不知道背后换了模型,它只关心拿到结果。

注意:降级不是无脑切换。如果主模型和备用模型的能力差异较大(比如一个支持 tools 一个不支持),降级前必须做能力校验,否则会出现“切过去之后请求直接失败”的尴尬情况。

2.2 Tools 资源:本地能力的注册与生命周期管理

Tools 在 tsm-hub 里指的是本地可执行的能力单元,通常是一个函数,接收结构化参数,返回结构化结果。听起来简单,但实际管理起来有几个绕不开的问题。

第一个问题是注册方式。最直接的做法是装饰器注册,在函数定义处打标记,启动时扫描收集。这种方式的好处是工具和实现在一起,不容易漏;坏处是工具的定义散落在代码各处,想统一查看或批量修改很麻烦。另一种做法是配置文件注册,工具实现和注册分离,网关启动时根据配置去加载对应的模块。tsm-hub 采用的是混合模式:装饰器负责标记元信息(名称、描述、参数 schema),配置文件负责控制启用状态和权限策略。这样既保留了开发时的便利,又给了运维时的灵活。

第二个问题是参数校验。LLM 生成的工具调用参数经常是“看起来对但实际有问题”的。比如一个查询天气的工具需要city和date两个参数,模型可能返回{"city": "北京", "date": "明天"},而你的实现期望的是 ISO 日期格式。如果不在网关层做校验,错误会一直传到工具函数内部才爆出来,排查成本很高。tsm-hub 在工具注册时要求提供 JSON Schema,调用前先做 schema 校验,不通过直接返回结构化错误给模型,让模型有机会自我修正。

第三个问题是超时与隔离。本地工具函数如果执行时间过长,会阻塞整个调用链。更严重的是,如果工具函数里有死循环或者内存泄漏,可能拖垮整个网关进程。我的做法是给每个工具调用设置独立的超时,并且对于不可信的工具(比如用户自定义的 Skills 里引用的工具),放到独立的 worker 里执行,避免主进程被拖死。

2.3 MCP 资源:协议适配与连接池管理

MCP 是这几年的热词,但很多人对它的理解停留在“又一个工具调用协议”。实际上 MCP 的价值在于它把能力的提供方和消费方解耦了:工具的实现可以是一个独立进程、一个远程服务,甚至是一个完全不同的语言写的程序,只要它说 MCP,就能被 Agent 调用。

tsm-hub 把 MCP 作为一种独立的资源类型来管理,而不是简单地当成 Tools 的一种。原因是 MCP 的连接是有状态的。一个 MCP Server 可能维护着会话上下文、缓存、甚至长连接。如果每次调用都新建连接,性能会很差;但如果复用连接,就要处理连接失效、重连、并发控制等问题。

我在实际项目里遇到过 MCP Server 在空闲一段时间后连接被对端关闭的情况,而客户端没有感知,下一次调用直接报错。后来在网关层加了心跳检测和连接池健康检查,才把这个坑填上。具体做法是:每个 MCP Server 维护一个最小连接数和最大连接数,空闲连接超过一定时间就主动探活,探活失败则重建。同时,对于同一个 Server 的并发调用,网关要控制并发度,避免把对端打挂。

另一个容易被忽略的点是MCP 工具的动态发现。MCP Server 启动后,它的工具列表可能不是固定的,可能根据配置或运行时状态变化。网关需要在连接建立后拉取工具列表并缓存,同时提供刷新机制。如果工具列表变了但网关没更新,就会出现“模型调用了不存在的工具”这种低级错误。

2.4 Skills 资源:从提示词包到可编排单元

Skills 是最容易被低估的一类资源。很多人把它等同于“一段系统提示词”,但实际上一个成熟的 Skill 应该包含:触发条件、所需工具、执行步骤、输出格式约束、以及失败处理逻辑。

tsm-hub 把 Skills 设计成一种可编排单元,它本身不直接执行,而是描述“完成某类任务需要哪些资源、按什么顺序调用”。比如一个“查天气并生成出行建议”的 Skill,会声明它需要天气查询工具和 LLM 推理能力,执行时先调工具拿数据,再把数据喂给 LLM 生成建议。

这种设计的好处是 Skills 可以复用底层资源,而不是每个 Skill 都自己实现一套工具调用逻辑。同时,Skills 的版本管理也变得清晰:每个 Skill 有独立的版本号,网关可以根据请求上下文路由到不同版本的 Skill,方便做灰度发布和回滚。

提示:Skills 的粒度控制很关键。太粗会导致复用性差,太细会导致编排复杂。我的经验是按“用户可感知的完整任务”来划分,比如“订机票”是一个 Skill,“查询航班”是它内部调用的工具,而不是另一个 Skill。

3. 网关核心层的实现细节与关键代码

3.1 统一资源注册表的设计

网关的核心是一个资源注册表,它需要同时管理 LLM、Tools、MCP、Skills 四类资源,并且支持按名称、类型、标签等多种维度查询。我采用的是“类型 + 名称”作为唯一键,每个资源注册时生成一个内部 ID,外部调用通过名称或 ID 引用。

注册表的数据结构大致如下:

class ResourceRegistry: def __init__(self): self._resources = {} # {type: {name: ResourceMeta}} self._lock = threading.RLock() def register(self, resource_type, name, meta): with self._lock: if resource_type not in self._resources: self._resources[resource_type] = {} if name in self._resources[resource_type]: raise DuplicateResourceError(f"{resource_type}/{name} already registered") self._resources[resource_type][name] = meta def get(self, resource_type, name): with self._lock: return self._resources.get(resource_type, {}).get(name)

这里用读写锁而不是普通锁,是因为查询频率远高于注册频率。注册通常只在启动时发生,而查询在每次请求都会触发。用RLock虽然简单,但在高并发下会有性能问题。实际生产里我换成了读写锁,读操作可以并发,写操作互斥。

资源元信息里除了基本的名称、描述、参数 schema,还要包含健康状态和统计信息。健康状态用于路由时过滤不可用资源,统计信息用于监控和告警。这些信息需要定期更新,但不能每次查询都去探测,所以采用“缓存 + 后台刷新”的模式。

3.2 请求路由与能力匹配算法

当一个请求进来时,网关需要决定用哪个 LLM、哪些 Tools、哪个 Skill 版本。这个决策过程我称之为“能力匹配”。

以 LLM 路由为例,请求可能带有以下约束:需要支持 function calling、上下文窗口至少 8k、首选供应商是 A、如果 A 不可用则用 B。网关的匹配逻辑是:

  1. 从注册表取出所有 LLM 资源
  2. 按硬性约束过滤(能力、窗口大小)
  3. 按优先级排序(首选供应商优先)
  4. 检查健康状态,剔除不可用资源
  5. 返回第一个可用资源,如果没有则返回明确错误

这个过程看起来简单,但实际实现时要考虑权重和负载。比如两个同等优先级的模型,应该按当前负载做均衡,而不是永远选第一个。我加了一个简单的加权轮询,权重可以配置,也可以根据实时延迟动态调整。

Tools 的路由相对简单,因为工具通常是按名称精确调用的。但有一种情况需要处理:模型返回的工具名称可能带有命名空间前缀,比如weather.get_current,而注册表里存的是get_current。网关需要做名称归一化,支持多种命名风格。

Skills 的路由最复杂,因为一个请求可能匹配多个 Skill。我的做法是给每个 Skill 定义触发条件(关键词、正则、或者语义匹配),请求进来后先做粗筛,再用 LLM 做精排。粗筛用规则引擎,快但不够准;精排用模型,准但慢。两者结合,在延迟和准确率之间取平衡。

3.3 鉴权、限流与可观测性埋点

网关作为统一入口,天然适合做鉴权和限流。tsm-hub 的鉴权分两层:调用方鉴权和资源访问鉴权。

调用方鉴权解决“谁在调用网关”的问题,通常用 API Key 或 JWT。资源访问鉴权解决“这个调用方能不能用这个资源”的问题,用策略表配置。比如 Agent A 只能调用工具 X 和 Y,不能调用 Z;Skill B 只能访问 MCP Server C。

限流我采用的是令牌桶算法,按调用方和资源两个维度分别限流。按调用方限流防止单个客户端打爆网关,按资源限流防止某个热门工具被过度调用。令牌桶的参数(速率、桶大小)可以通过配置热更新,不用重启网关。

可观测性方面,每个请求都会生成一个 trace ID,贯穿 LLM 调用、工具执行、MCP 通信全过程。日志里记录 trace ID、资源名称、耗时、token 消耗、错误码。这些数据汇总到监控系统后,可以画出调用链路图,快速定位瓶颈。

def handle_request(request): trace_id = generate_trace_id() start = time.time() try: resource = route(request) result = resource.invoke(request, trace_id=trace_id) log_success(trace_id, resource.name, time.time() - start) return result except Exception as e: log_error(trace_id, request.resource_name, time.time() - start, e) raise

这段代码看起来简单,但实际生产里要考虑异步、超时、取消等复杂情况。我的建议是初期先用同步模型跑通,等稳定后再逐步引入异步。

4. 实操:从零搭一个最小可用的 tsm-hub

4.1 环境准备与依赖安装

先说明一下,这里演示的是最小可用版本,目的是让你理解核心机制,而不是直接上生产。生产环境还需要考虑持久化、集群、高可用等,那些后面再说。

基础环境需要 Python 3.10 以上,因为用到了match语法和一些新的类型标注特性。依赖方面,核心的只有几个:

pip install fastapi uvicorn pydantic httpx

FastAPI 用来做 HTTP 层,Pydantic 用来做参数校验和 schema 定义,httpx 用来做异步 HTTP 调用。如果你要接 MCP,还需要安装对应的 MCP 客户端库,具体看你的 MCP Server 实现。

目录结构建议这样组织:

tsm-hub/ config/ resources.yaml policies.yaml src/ registry/ router/ adapters/ llm/ tools/ mcp/ skills/ server.py tests/

配置和代码分离,方便不同环境用不同配置。resources.yaml里声明有哪些资源,policies.yaml里声明访问策略。

4.2 注册第一个 LLM 资源

假设我们要接入两个模型:一个本地的 Ollama 模型,一个远程的兼容 OpenAI 接口的服务。先定义适配器接口:

class LLMAdapter(ABC): @abstractmethod async def chat(self, messages, tools=None, **kwargs): pass @abstractmethod def capabilities(self): pass

然后实现 Ollama 适配器:

class OllamaAdapter(LLMAdapter): def __init__(self, base_url, model_name): self.base_url = base_url self.model_name = model_name async def chat(self, messages, tools=None, **kwargs): async with httpx.AsyncClient() as client: resp = await client.post( f"{self.base_url}/api/chat", json={"model": self.model_name, "messages": messages, "stream": False} ) return resp.json() def capabilities(self): return {"function_calling": False, "streaming": True, "context_window": 8192}

注册到网关:

registry.register("llm", "local-ollama", { "adapter": OllamaAdapter("http://localhost:11434", "qwen2.5"), "capabilities": {"function_calling": False, "context_window": 8192}, "priority": 10, "tags": ["local", "free"] })

这里priority数字越小优先级越高,tags用于策略匹配。注册完成后,网关就知道有这个模型可用。

4.3 接入一个 MCP Server 并暴露为工具

MCP Server 的接入分三步:建立连接、拉取工具列表、注册为网关工具。

class MCPClient: def __init__(self, server_url): self.server_url = server_url self.session = None async def connect(self): # 实际实现取决于 MCP 传输方式(stdio / SSE / WebSocket) self.session = await create_mcp_session(self.server_url) tools = await self.session.list_tools() return tools async def call_tool(self, name, arguments): return await self.session.call_tool(name, arguments)

拉取到工具列表后,为每个工具生成一个网关侧的代理:

async def register_mcp_tools(registry, mcp_client, server_name): tools = await mcp_client.connect() for tool in tools: registry.register("tool", f"{server_name}.{tool.name}", { "handler": lambda args, t=tool: mcp_client.call_tool(t.name, args), "schema": tool.inputSchema, "source": "mcp", "server": server_name })

这样 MCP 工具就和本地工具一样,可以通过统一接口调用了。上层业务不需要知道这个工具是本地实现的还是远程 MCP 提供的。

4.4 定义一个可复用的 Skill

Skill 的定义我用 YAML 描述,因为它比代码更直观,也更容易做版本管理。

name: weather_advice version: 1.0.0 description: 查询天气并生成出行建议 triggers: - keywords: ["天气", "出行", "穿什么"] steps: - id: get_weather type: tool tool: weather.get_current params: city: "{{ input.city }}" - id: generate_advice type: llm model: local-ollama prompt: | 根据以下天气数据,给出一段简短的出行建议: {{ steps.get_weather.output }} output: "{{ steps.generate_advice.output }}"

网关加载这个 Skill 后,当用户输入包含“天气”关键词时,就会触发这个 Skill。执行时按步骤调用工具和模型,最后返回结果。

注意:Skill 里的{{ }}模板语法要小心注入问题。如果用户输入直接拼进 prompt,可能被恶意利用。我的做法是对所有插值做转义,并且限制插值长度。

5. 踩坑记录与常见问题排查

5.1 模型返回的工具调用参数格式不对怎么办

这是最高频的问题。模型可能返回字符串形式的 JSON、可能多包了一层、可能字段名大小写不对。我的处理策略是三级容错:

第一级,在 prompt 里明确要求 JSON 格式,并给出示例。第二级,解析时先尝试标准 JSON 解析,失败则尝试提取代码块、修复常见错误(比如单引号转双引号)。第三级,如果还是失败,把错误信息返回给模型,让它重新生成,最多重试两次。

实测下来,加了三级容错后,工具调用成功率从 85% 左右提升到 97% 以上。剩下 3% 通常是模型能力问题,换更强的模型或者简化工具 schema 可以解决。

5.2 MCP 连接频繁断开怎么排查

先确认是网络问题还是对端问题。在网关侧加日志,记录每次连接建立和断开的时间、原因。如果是对端主动断开,看对端的日志和配置;如果是网络问题,检查是否有中间设备做了空闲超时。

我遇到过一次是 MCP Server 部署在容器里,容器有 60 秒空闲超时,而网关的心跳间隔是 90 秒,导致每次心跳前连接就被杀了。把心跳间隔改成 30 秒后问题消失。所以心跳间隔一定要小于对端的空闲超时,最好留一半的余量。

5.3 Skills 版本升级后行为不一致

这通常是缓存导致的。网关为了性能会缓存 Skill 的定义,如果升级后没有清缓存,就会用旧版本。我的做法是给每个 Skill 定义加一个内容哈希,请求时带上哈希,哈希不匹配就重新加载。同时提供手动刷新接口,升级后可以主动触发。

另一个可能的原因是 Skill 依赖的工具或模型变了。比如 Skill 里引用了weather.get_current,但这个工具升级后参数 schema 变了,Skill 没同步更新。所以 Skill 升级时要检查依赖的资源是否兼容,最好在 CI 里加一个依赖校验步骤。

5.4 常见问题速查表

问题现象可能原因排查方向解决方式
请求超时模型响应慢 / 工具阻塞看 trace 各阶段耗时加超时、降级、异步化
工具调用失败参数 schema 不匹配对比模型输出和 schema加校验、优化 prompt
MCP 连接断开心跳间隔大于对端超时看连接日志时间戳缩短心跳间隔
Skill 不触发触发条件不匹配看粗筛和精排日志调整关键词或阈值
鉴权失败策略配置错误看策略匹配日志修正策略表
限流误伤令牌桶参数太小看限流统计调大速率或桶大小

6. 生产化之前还需要补的几块

最小可用版本跑通后,离生产还有距离。我列几个必须补的模块,按优先级排序。

持久化。注册表目前是内存态,重启就丢。生产环境需要把资源定义、策略、Skill 版本持久化到数据库或配置中心。但注意,运行时状态(健康状态、统计信息)不需要持久化,重启后重新探测即可。

集群与一致性。单机网关有单点问题。多机部署时,资源注册和策略变更需要同步。简单的做法是用配置中心推送,复杂的做法是引入协调服务。我的建议是初期用配置中心 + 定期拉取,够用且简单。

灰度与回滚。Skills 和模型的变更要支持灰度。按调用方、按流量比例、按请求特征都可以做灰度维度。回滚要快,最好能做到秒级。这要求版本管理足够清晰,每个版本可独立寻址。

成本控制。LLM 调用是花钱的,网关要能统计每个调用方、每个 Skill、每个模型的 token 消耗和费用。更进一步,可以设置预算和告警,超预算自动降级到便宜模型或拒绝服务。

安全审计。所有资源调用要有审计日志,记录谁在什么时候调用了什么、传了什么参数、返回了什么。敏感参数要脱敏。审计日志保留时间根据合规要求定,通常至少 90 天。

这几块里,我认为持久化和成本控制是最优先的。前者影响可用性,后者影响钱包。其他的可以按业务节奏逐步补。

7. 一些个人体会

搭这套网关的过程中,我最大的体会是:抽象要适度,不要为了统一而统一。LLM、Tools、MCP、Skills 确实有共性,但它们的差异也很大。如果强行用一套接口抹平所有差异,最后会得到一个又大又难用的抽象层。tsm-hub 的做法是统一注册和路由,但保留各自的调用语义,这个平衡点我觉得找得不错。

另一个体会是可观测性要前置。我一开始觉得日志和监控是后期的事,结果排查问题时两眼一抹黑。后来把 trace ID 贯穿全链路,每个环节都打点,排查效率提升了一个数量级。建议从第一天就把埋点加上,成本很低,收益很高。

最后说一个具体的技巧:给每个资源加一个“熔断开关”。当某个模型或工具出问题时,能一键禁用,而不是改配置重启。这个开关在事故处理时特别有用,能快速止血,争取排查时间。实现上就是一个布尔标志,路由时检查一下,成本几乎为零,但关键时刻能救命。

返回列表