☰
tsm-hub:统一网关融合LLM、工具、MCP与Skills的架构实践
2026/9/28 15:02:13 网站建设 项目流程

从四套系统到一个网关:tsm-hub 如何把 LLM、Tools、MCP、Skills 收进同一个入口

如果你最近在做 Agent 相关的东西,大概率已经受够了这种状态:大模型 API 要单独对接,函数调用要自己实现,MCP server 这个仓库一个、那个仓库一个,Skills 脚本又是另一套加载逻辑。我也是被这几样东西来回折腾了几个月,最后忍无可忍,才动手写了 tsm-hub 这个统一网关。简单说,它就是把 LLM、Tools、MCP、Skills 四类组件收进同一个入口,让你面向一个网关编程,而不是同时维护四五套集成方式。这篇文章讲讲我为什么这么设计、核心模块怎么拆,以及你照着落地会碰到的那些坑。

这个内容适合谁?适合正在做 AI Agent、企业内部 AI 中台,或者单纯觉得“工具调用越来越乱”的开发者。不管你是刚接触 MCP 协议,还是已经写了十几个 Agent 服务,这套网关的思路都能直接拿过去用——就算你不打算引入 tsm-hub,光是把下面这些模块划分和配置规范看明白,也能帮你把现有项目理清楚。

1. 为什么需要 tsm-hub:四个孤岛问题

1.1 一个真实任务暴露出的四个孤岛

先看一个很普通的场景:你想做一个“帮我查会议室、预订空闲时段、顺便在群里发通知”的 Agent。就这么一个小任务,你至少要面对以下四套东西:

第一是 LLM。你选一个模型供应商,搞定它的鉴权、消息格式、流式响应,如果哪天想换成另一家模型,所有调用代码都要跟着改。第二是 Tools。你写好了查会议室、订会议室的业务函数,要把它们变成模型可以“看见”的工具描述,还要自己处理函数参数的 JSON Schema。第三是 MCP server。公司有个统一会议系统开放了 MCP 协议,你要在 Agent 里初始化 MCP client,管理 server 的进程生命周期、连接握手、协议版本。第四是 Skills。你可能之前已经在 Claude Code 或同类工具里攒了不少 skill 包,它们靠自然语言指令驱动,跟你现在写代码调函数的 Agent 完全是两套逻辑。

这些组件单独用都挺顺手,一旦组合起来就出问题。最常见的一种崩溃现场是:模型说“我要调 get_meeting_room”,你的 Tools 注册表里确实有这个函数,但 MCP server 那边也暴露了同名工具,两边优先级没定义,模型随机选择,结果一会儿走直连函数、一会儿走 MCP 通道,日志里行为全靠猜。更麻烦的是 Skills,它本质上是一套“教模型怎么做”的文本指令,跟普通的“工具函数调用”还不一样,没法用统一方式触发。这就催生了我的核心需求:能不能做一个网关,把四类组件注册到同一套路由表里,让模型、工具、MCP server、skill 都通过一个入口对外服务?

1.2 统一网关的三种解法思路

针对上面这个问题,按经验大体上有三条路可以走。

第一种是自以为省事的“协议翻译层”方案。在每个 Agent 里分别装 SDK,把 MCP 协议翻译成内部工具调用,把 Skills 翻译成 prompt。这种做法初期开发快,但每加一个新 Agent、每接一个新 MCP server,翻译逻辑就要复制一遍,后期维护成本翻着倍往上涨。

第二种是做“服务网关”,但只做透传。它把 MCP server 的地址统一管理起来,LLM 和 Tools 仍然走各自的路径。这种网关解决不了“一个 Agent 同时需要模型、工具、技能”的编排问题,治标不治本。

第三种才是 tsm-hub 走的路子:定义一中立的运行时内核,让 LLM、Tools、MCP、Skills 全部以“能力单元”的形式注册到网关里,由网关统一处理鉴权、路由、批量调度与重试。Agent 侧只有一个输入输出协议:你给网关发一条带目标意图的请求,网关自己决定是调模型、走工具、连 MCP 还是激活某个 skill。我最终选了第三种,因为它的长期维护成本最低,后续新增能力时,只是给网关增加一个注册项,不会污染业务代码。

2. tsm-hub 架构拆解:核心模块与统一协议

2.1 核心模块:模型网关、工具路由、MCP 适配器、技能库

tsm-hub 内部拆成四个核心模块,它们的职责边界非常清楚。

第一个模块是模型网关(Model Gateway)。它统一封装了各家 LLM API,对外只暴露 chat 和 stream 两类接口。你需要在这里做供应商适配器,比如 OpenAI 系列的 function calling 格式、Anthropic 的 tool use 格式、国产模型的 tool 格式——各家有各家的差异,但内部都翻译成统一的消息体。这个模块的另一个关键职责是超时管理,因为不同模型供应商的响应速度差异极大,必须有统一的超时策略,否则一个慢模型会把整个网关拖住。

第二个模块是工具路由(Tool Router)。它维护一张能力注册表,每一项工具或能力都有唯一标识、用途描述、输入输出 Schema,以及可调用的后端列表。这个模块解决的核心问题是“同名工具”冲突:当你同时接入了直连函数和 MCP server 暴露的同名工具时,可以按服务商优先级、健康状态、响应延迟来决定路由到谁。没有这个路由层,你就只能在业务代码里写死 if else。

第三个模块是 MCP 适配器(MCP Adapter)。它负责所有 MCP server 的生命周期管理。MCP 协议比较特别,它同时支持 HTTP + SSE 传输和 stdio 进程内传输两种方式,适配器要处理连接池、心跳检测、重连、以及 MCP 版本兼容。这一层还承担一个隐藏任务:把 MCP server 暴露的“工具列表”拉回来,动态注册进工具路由表。这样你不需要在网关里预先写死“某个 MCP server 有什么工具”,启动时自动发现即可。

第四个模块是技能库(Skills Registry)。它有点像插件管理器。一个 skill 包通常包含一个 SKILL.md 文件(自然语言说明书),以及可被调用的脚本、Prompt 模板、参考资源。技能库负责打包、索引、版本管理这些 skill 包,并且把它们从“prompt 文本的世界”映射成“可供路由的工具描述”。核心转化手段是:给每个 skill 生成一个“技能触发描述”,告诉模型什么场景下该激活这个 skill,然后网关去按描述匹配调用。

这四个模块之间的消息流是一条完整链路:Agent 发请求进来,模型网关先让 LLM 判断意图,如果模型决定需要外部能力,就会输出结构化工具调用;工具路由根据调用目标去查注册表,找到对应的 MCP server 或本地函数;MCP 适配器随后执行远程调用或进程内调用,把结果返回给模型网关,最终由模型生成面向用户的答案。

2.2 为什么选 MCP 做统一协议:基于各家工具的融合

我在设计 tsm-hub 时,最被反复问到的就是:“为什么统一协议选了 MCP?Tools 和 Skills 不是各自有各自的格式吗?”我讲讲我的取舍过程。

两点考虑。第一,MCP 已经事实上成为工具调用的开放标准之一,生态里能直接接入的现成服务越来越多,比如设计稿解析、浏览器操作、接口调试、测试平台这些方向都有可用的 MCP server。你不接入 MCP 生态,就等于要手动为每一个外部服务重写一套工具接口,那工作量可观。第二,MCP 协议本身定义得足够完整——它有 client/server 模型、工具发现机制、标准化的参数描述格式,这些都跟 tsm-hub 想要的“能力注册与自动发现”高度匹配。

那 Tools 和 Skills 怎么办?我的处理方式是“全部向 MCP 靠齐”:本地函数写一层薄薄的 adapter 包装成 MCP server,Skills 也通过技能库包装成一个“虚拟 MCP server”,暴露一个名为 invoke_skill 的统一工具。这样从路由层的视角看,所有能力都是 MCP tool,统一走同一套发现、调用、返回链路。你可能觉得这有点过度设计,但实际操作中你会发现这个好处特别明显:新增能力时不用改 Agent 的代码,只需要注册一个新的 MCP server,整个系统的可扩展性一下子打开了。

3. 实操:从零搭建 tsm-hub 的完整落地过程

3.1 技术选型与前置准备

这部分是实操,我就直接说我自己的选型,以及为什么这么选,方便你拿来参考或者按你的偏好替换。

后端主体我用的 Python 3.11 + FastAPI。原因有三:一是 LLM 生态里 Python 的库最齐全,各个模型供应商的 SDK 基本都是 Python 优先;二是 MCP 官方的 Python SDK 支持比较完善,能省掉不少协议层的手写工作;三是 FastAPI 的异步能力足够支撑流式响应和 SSE 推送。内部通信走 JSON,配置管理用 YAML 文件加环境变量覆盖。进程管理器我用的是内置的 asyncio 任务调度——因为 MCP server 里有不少是 stdio 模式,必须由主进程拉起子进程并通信,用 asyncio.create_subprocess_exec 去管理生命周期最方便。

前置准备其实不复杂,你需要在环境里安装这些依赖:

  • fastapi:提供 HTTP 服务框架
  • uvicorn:ASGI 服务器,用来跑服务
  • mcp:MCP 官方 Python SDK
  • httpx:异步调用 LLM API
  • pydantic:做数据校验和 Schema 定义

安装好之后,建议在项目根目录建好 config/ 和 adapters/ 两个目录。前者放全局配置和供应商账号信息,后者专门放各家 LLM 的适配器。目录清晰了,后面每一步都会省力很多。

3.2 第一步:定义统一消息格式

在接入任何东西之前,先把内部消息格式定好。这是整个网关的地基,后面所有模块都要围绕这个格式工作。

我定义的核心消息体由三层组成。第一层是请求头部 request,带上请求 ID、Agent 标识、会话上下文。第二层是意图结构 intent,记录模型判断出的目标代号、原始自然语言指令、可选的参数列表。第三层是能力路由结果 route,记录最终命中的工具类型、服务地址、请求超时时间。

这里有一个比较隐蔽的设计点:不要把某个模型供应商的执行结果直接塞进内部消息体。比如 OpenAI 返回的 tool_calls 数组和 Anthropic 返回的 tool_use 块结构差别很大,如果你直接透传,将来换模型时就得多写一层兼容逻辑。正确做法是在供应商适配器里把这些差异统一翻译成内部格式——统一的工具名、统一的参数 JSON、统一的执行状态码。这样后面无论对接哪家供应商,路由层看到的都是同一种结构。

统一消息结构定义完成后,需要一个注册中心来登记所有能力。我把注册表做成了 YAML 文件加一个自动发现机制:启动时扫描 rules/ 目录,把每个 MCP server 的 tools 列表拉回来,合并成一个全局能力表。这部分逻辑我用一个简化的伪代码表示:

async def load_tools(): all_tools = {} for server in config.mcp_servers: client = await MCPClient.connect(server) tools = await client.list_tools() for tool in tools: # 统一封装成 ToolSchema all_tools[tool.name] = { "description": tool.description, "input_schema": tool.input_schema, "server": server.name, } return all_tools

3.3 第二步:接入一个 LLM 供应商适配器

接口统一了,接着写适配器。我以最常用的 OpenAI 兼容接口为例来讲。

适配器要做什么?简单说,就是把网关的内部消息体——包括系统提示、历史对话、工具定义列表——翻译成某家供应商 API 能识别的格式。以 OpenAI 为例,你需要把工具定义列表映射成 functions 数组,每个函数包含名称、描述、参数 JSON Schema。当模型返回 tool_calls 时,你要把其中的 function name 和 arguments 抽取出来,转换成内部路由请求。

实操里比较容易被坑的点是流式响应。OpenAI 的流式返回里,工具调用的内容是分片到达的——有的 chunk 给函数名,有的 chunk 给参数片段,有的 chunk 追加内容。你要自己维护一个“正在流式构建的 tool call 缓冲区”,等所有分片到达后再组装完整参数。我建议把这个逻辑写死在适配器里,不要让上层业务感知到分片过程。我的实现里采用了异步生成器,配合一个简单的状态机管理工具调用的开始、进行、结束三个阶段。

不同供应商之间的差异主要体现在消息字段和鉴权方式上。OpenAI 用 Authorization Bearer token,Anthropic 除了 token 还要额外带 version 头,部分国产模型需要在请求体里加 extra_body 字段。我把这些差异全部收敛到 adapter 类的 get_request_headers 和 format_request_body 两个方法里,每加一家模型就是新增一个 adapter 文件,完全不影响其他模块。

这里有一个关于超时的经验:统一网关的超时不要只看“整体请求超时”,要拆成“首 token 等待时间”和“流式空闲超时”两段。前者用来暴露模型服务是否挂了,后者用来处理流式传输中断的情况。我在配置里默认设置首 token 等待 30 秒,流式空闲超时 60 秒——你根据模型供应商的实际情况去调。相关的参数发散我放到 4.3 节再细讲。

3.4 第三步:把普通 Tools 注册成 MCP Server

现在到了最关键的一步:把已有的普通 Python 函数变成可以通过 MCP 协议调用的工具。

理论上你可以给这些本地函数直接建一个内部 tool registry,不走 MCP,但为了统一协议,我更推荐把它们包成 MCP server,哪怕用 stdio 模式在本地起进程。好处是如果你以后想把这个工具开放给其他服务,直接把这个 MCP server 部署出去就行,不用改任何代码。

整个包装过程分三步。第一步,定义工具 Schema,明确函数名称、描述、参数结构,这一步会直接影响模型能不能正确调用。第二步,用 MCP SDK 注册 FastMCP 实例,把函数绑定进 server。第三步,启动 MCP server 并把它配置到网关的连接池里。

下面是一个实际例子。假设你有一个查询订单状态的函数,那么注册成一个 MCP server 的代码大致是:

from mcp.server.fastmcp import FastMCP mcp = FastMCP("order-service") @mcp.tool() async def get_order_status(order_id: str) -> dict: """根据订单号查询订单当前状态。""" return {"order_id": order_id, "status": "已发货"} if __name__ == "__main__": mcp.run(transport="stdio")

然后你在网关的 config 里加一行 MCP server 地址配置,把这个 server 挂进来。就这么简单。我踩过的一个小坑是工具描述写得太随意。模型对工具的理解完全依赖这段描述,你不能写“查询订单”,而要写“根据订单号查询订单当前物流状态及签收时间,适用于用户追问物流进度时调用”。描述越具体,选工具的正确率越高。这个点怎么强调都不过分,很多人在 MCP server 跑通之后抱怨“模型老选错工具”,八成就是描述写得太模糊。

3.5 第四步:把 Skills 纳入网关

Skills 的处理思路跟普通 Tools 不太一样。一个 skill 包一般不是单纯的“输入参数->返回结果”的函数,它更像是一份操作指南加一组可执行脚本。比如“用 Playwright 做表格数据抓取”这个 skill,里面的 SKILL.md 会告诉你抓取步骤、翻页策略、反爬注意事项,配套脚本才是真正执行动作的代码。

tsm-hub 处理 Skills 的策略是这样的:技能库在启动时扫描 skills 目录,读取每个 skill 的 SKILL.md,提取出标题、用途、触发条件和主入口。然后把这些信息映射成一个虚拟工具,工具名按 skill 包名生成,比如 skill_web_scraper。当模型决定调用这个虚拟工具时,网关就把“目标自然语言指令+skill 的说明文档”一起交给底层的 driver——通常是 Claude Code 或 Codex 这类具备 agent 能力的执行器——去实际执行。

这个设计的核心是把 skill 调用变成一种“懒执行”。模型不需要理解 skill 内部的复杂步骤,只需要识别“这个场景适合触发某个 skill”,执行细节全部交给 executor。这条路走通之后,我发现一个新玩家入场特别快:比如想接入“前端开发”相关的新 skill,只需准备一个标准格式的 skill 包,把它丢进 skills 目录,网关重启后自动注册,业务代码一行都不用动。对团队的扩展效率来说这种体验提升是非常明显的。

4. 常见问题与排查技巧实录

4.1 从 MCP 连接超时到设计原理的排查

第一批问题集中在 MCP 连接的建立阶段,最典型的是两种。

一种是 stdio 模式的 MCP server 启动失败。特征是日志里出现类似“子进程退出,退出码 1”的报错。排查思路按下面这几步走:先手动在同目录下跑一遍启动命令,看是不是缺依赖;再确认子进程的 cwd 设置是否正确;最后检查 stdout/stderr 重定向——stdio 模式的 MCP server 会把协议数据都打在主进程创建的管道上,但有些 server 会把正常的 print 日志混进 stdout,导致协议解析错乱。解决方案是让 server 端的日志全部走 stderr,或者在网关的 stdio 适配逻辑里把 stdout 和 stderr 分开处理,我在 tsm-hub 里就是分别建立了两个管道流,下面的代码片段展示了一个可用配置:

proc = await asyncio.create_subprocess_exec( cmd, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, cwd=workdir, )

另一个高频问题是 HTTP+SSE 模式连接上了,但 tools 列表返回为空。这通常不是网关的错,而是 server 端工具的注册目录跟你预期的不一致。远程 server 经历过多次重新部署后,工具列表可能发生变化。解决方法是把“工具发现结果”加一层持久化缓存,并在启动日志里打清楚工具总数和明细——否则出了问题你连问谁都说不清。建议在配置里加一个 auto_sync_tools 开关,启动时从 server 拉取工具列表,运行中每 60 秒做一次增量同步,确保 tools 表与真实暴露接口一致。

4.2 从 model 误调用到 Schema 兼容性:日常报错排查

等链路跑通之后,最常见的报错集中在模型与工具的 Schema 兼容上。一个小数问题在 AI 工程里其实非常常见,但好多人第一次碰到会慌。

首先是“模型返回了不存在的工具名”。这种情况十有八九是工具描述与工具名之间的关联做错了,比如 Agent 的 system prompt 里出现了一个旧版工具名称,或者你把两个不同 MCP server 的同名工具合并时没有做好路由。建议在工具注册阶段就对工具名做命名空间隔离,比如 order_service_get_status 和 legacy_order_get_status,能极大减少误调用。

第二类是“参数校验失败的报错”。模型生成的参数有时候会不符合 JSON Schema——比如该传整数却传了字符串。处理这类问题的工具化方案是双保险:网关做一层预校验把不合法参数拦截下来,再添加一个 repair 机制,将错误信息连同原参数回传给模型,让模型自己纠正输出。实测下来修复成功率很高,比直接报错强太多。下面是可供参考的实现:

def validate_args(schema: dict, args: dict) -> list[str]: # 此处用 jsonschema 库做严格校验 errors = [] validator = Draft202012Validator(schema) for err in validator.iter_errors(args): errors.append(f"{err.json_path}: {err.message}") return errors

第三类是流式上下文被截断的问题。某些模型在流式返回工具调用过程中,会中途插话或吐出与工具调用无关的文本。这时你要在适配器里把工具调用数据与模型的自然语言回复分开缓存,不能混在一起交付给路由层。我自己曾因为这个问题排查整晚,最后发现原因荒谬:某供应商的流式输出中文本与 tool_call 是无序的,必须做完整的重排。如果你对接的供应商比较多,这类“各家有各家的毛病”的场景会不断出现,写适配器时不要假设所有格式都规范。

4.3 资源隔离与性能问题:并发、超时、查找优化

网关跑起来后,性能与隔离的问题是我后续才注意到的,以下三件事比预想的更重要。

一是并发度限制。MCP stdio server 本质上是本地子进程,它的并发能力很有限。你在网关里如果同时喂给单个 stdio server 几十个请求,它会彻底卡死或者出错。我建议给每个 server 配置独立的并发池,比如 max_concurrent_tasks = 4,超出部分的请求走排队策略,而不是无限并发。对于 HTTP 型 SSE server,并发上限可以放宽到 10 到 20,实测这个配置可以让集群的吞吐发挥到较优水平。

二是超时参数必须要分层。最早我统一设了一个 60 秒超时,结果有的工具调用量大、耗时天然就长,而有些模型供应商的流式响应原本只需 2 秒,偶尔的慢请求把整个链路拖到用户不可接受。后来我拆成三层:模型供应商请求超时 30 秒,MCP server 单次调用超时 45 秒,整个 Agent 任务的最长执行时间 120 秒。这三层超时用配置变量独立管理,比单一大超时要准确得多。

三是工具数量变大之后的查找链路优化。开始只有十几个工具时,线性遍历完全没问题,当工具规模上升到几百个甚至上千个时,每次模型请求都把所有工具描述塞给模型是不现实的——上下文长度不够、token 成本也太高。我当时设计并测试后的方案是给工具描述做“索引标签”,比如按领域打上“meeting”“email”“order”等标签,在模型调用之前先做一次粗粒度的标签过滤,只把命中的工具子集——通常不超过 30 个——放进模型请求里,这样既快又省 token。实测下来,工具调用准确率反而比全量塞进去更好,因为模型在大量无关工具干扰下容易选错。

4.4 避坑清单:我踩过的 7 个坑

最后整理一份自己反复踩过的坑清单,希望能帮你少走点弯路。

第一,定义工具 Schema 时一定要写“何时用、何时不用”的边界,而不是只写一句话。第二,stdio 模式的 MCP server 千万不要在代码里往 stdout 打印业务日志,一旦混入协议数据,整个调用链路直接废了。第三,网关里的工具注册表一定要有版本和来源字段,不然等工具来自多个 MCP server 的时候,你根本没法回溯是谁定义的。第四,统一网关里不要直接透传底层异常细节给上游,否则用户会看到一堆内部错误堆栈,安全性和体验都差,包装成一个标准错误码结构更合适。第五,所有连接到网关的 Agent 要有独立的 API Key,不然无法做审计和权限隔离。第六,技能包的版本更新一定要走完整的注册与审核流程,不要直接在线上覆盖目录,否则会出现 SKILL.md 更新了但配套脚本文件还是旧的这种诡异状态。第七,监控不能只看调用成功率和延迟,还要专门看“工具调用重试率”和“工具误选率”——这两个指标才是网关质量的核心,直接反映了你工具定义和描述写得好不好。

我自己的体会是:做一个 Agent 中台,最难的从来不是写代码,而是把各种不同形态的能力收敛到一致的抽象层上。tsm-hub 这个项目的一次次迭代让我确认了一件事——统一网关的价值 80% 来自“路由规划与 Schema 治理”的设计,只有 20% 来自 API 代理本身的代码实现。如果你正苦恼于模型、工具、MCP 与技能各管各的、改一处牵连一片,试试先把它们收进同一个注册表里。这比任何单点优化都值得你花时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询