ARTICLE DETAIL

资讯详情

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

tsm-hub:为LLM统一Tools、MCP与Skills接入的网关架构与实战

tsm-hub:为LLM统一Tools、MCP与Skills接入的网关架构与实战 真正让我下决心写 tsm-hub是一次差点放弃的联调经历。当时我在做一个 LLM 驱动的自动化助手需要同时接上自研的 Tools、两个 MCP Server还想把 Claude Code 里那套 Skills 沿用过来。每个模块的接入方式完全不一样Tools 要走函数注册MCP 要走 JSON-RPC 协议握手Skills 又是一套独立的目录加载逻辑。搞到半夜三点我对着满屏的 protocol handshake 报错问自己就不能有一个统一网关让上层 Agent 只认一种接口吗tsm-hub 就是在这个背景下写的目标很直接——把 LLM、Tools、MCP、Skills 四类资源收进同一个入口对外提供统一的路由、鉴权和调用语义。这篇文章把设计思路、代码骨架和落地过程中踩过的坑完整交代一遍正在做或者准备做统一接入层的朋友可以直接拿去参考。1. 失控的不是模型是工具层为什么必须引入网关1.1 LLM 之外的外围资源正在爆炸LLM 本身其实很好接。OpenAI、Anthropic 或者本地模型本质都是发一个 completion 请求的事协议固定、格式统一文档也写得清楚。真正让人崩溃的是外围资源。Tools 要注册成 JSON Schema 才能喂给 function callingMCP Server 要遵循 initialize 握手、tools/list、tools/call 这一套流程Skills 又要按目录结构解析 SKILL.md 和配套脚本。这些资源数量的增长速度远超大多数人的预期——今天挂一个 playwright-mcp 操作浏览器明天挂一个 blender-mcp 处理 3D 建模后天从 GitHub 上手动装几个社区 Skills。每多一种资源接入层就要多写一套适配代码。这里有个容易被忽略的事实大多数 Agent 项目崩溃不是模型能力不行而是工具接入不统一导致的上层逻辑膨胀。我在上一个项目里统计过核心编排逻辑只写了 300 行适配各类工具和协议反而写了 900 多行。300 行是可控的900 行是不可控的因为里面有大量重复的连接管理、错误处理、参数格式转换而且每加一个新工具都要动这段代码。1.2 为什么不能直接用框架自带的 Tool 抽象很多人会问LangChain 有 Tool 抽象Semantic Kernel 也有 Tool 抽象为什么还要自己搞网关关键在于框架和网关解决的是两个层面的问题。框架的 Tool 抽象是纵向集成它要求你的工具和编排逻辑都在一个进程里MCP 要自己桥接、Skills 要自己写加载器。这个桥接代码写一次两次没问题写多了你会发现它跟业务逻辑缠在一起拆都拆不开。网关的做法是横向统一。它独立在 LLM 和工具之间不管背后是本地函数、MCP Server 还是 Skills 目录上层 Agent 只面对一份能力清单。这就是 API Gateway 在微服务架构里的思路服务多了以后你需要的不是再多一个服务而是一个统一的流量入口。工具接多了以后你需要的也不是再多一个适配器而是一个统一的能力入口。1.3 网关的边界不取代 LLM也不取代 MCPtsm 这个缩写我自己的解释是 Tool/Skill/Model Hub写全了就是工具、技能、模型的统一枢纽。它的定位非常清楚位于上层 Agent 与下层资源之间的连接层。它不参与 Agent 的推理决策不决定模型该调哪个工具也不替代 MCP Server 去实现具体业务功能。它只做四件事能力的注册与发现、请求的路由、协议的转换、权限与观测。这个边界很重要。很多人在做统一网关时容易犯的错是越做越大最后把 Agent 编排、记忆管理、工具执行全塞进去变成一个四不像。我坚持让 tsm-hub 只做网关该做的事上层怎么编排是上层的事下层怎么实现是下层的事网关只管让两边的对话变得顺畅。2. 统一 Capability 抽象把四类资源翻译成同一种语言2.1 Capability 长什么样一切皆能力网关存在的核心价值是提供一个中间抽象层。在 tsm-hub 里这个抽象叫 Capability。不管是本地函数、MCP 工具还是 Skills最终都被归一成一个 Capability 对象。这样上层 Agent 只需要理解一种模型不需要关心背后的协议细节。import type { JSONSchema7 } from json-schema; export interface Capability { id: string; name: string; description: string; inputSchema: JSONSchema7; source: local | mcp | skill; tags: string[]; endpoint?: string; transport?: stdio | sse | streamable-http; execute( args: Recordstring, unknown, ctx: CallContext ): Promiseunknown; } export interface CallContext { agentId: string; traceId: string; sessionId: string; signal?: AbortSignal; }为什么用 JSON Schema 作为中间语言因为它是当前所有主流 LLM 工具调用共同认可的标准。OpenAI 的 function calling 吃 JSON SchemaAnthropic 的 tool_use 也吃类似的描述结构MCP 的 tools/list 返回的 inputSchema 同样基于 JSON Schema。只要把能力描述统一成这种格式上层模型就能用同一种方式理解和调用。2.2 注册中心一份 YAML 配置接入所有资源tsm-hub 的能力注册分成两层静态配置和动态发现。静态配置是启动时读取的 config.yaml适合定义本地工具和固定地址的 MCP Server。动态发现是运行时通过管理 API 注册适合那些频繁变更的资源比如临时挂载的实验性 MCP 或刚 clone 下来的社区 Skills。gateway: port: 8787 agents: - id: my-agent apiKey: sk-local-demo capabilities: - id: browser_screenshot name: 浏览器截图 description: 打开指定 URL 并截图返回图片路径 source: mcp endpoint: http://127.0.0.1:3000/mcp transport: streamable-http auth: type: header name: Authorization value: token demo - id: web_search name: 网页搜索 description: 调用本地搜索服务获取关键词相关的网页摘要 source: local handler: ./handlers/web-search.js - id: code_review_skill name: 代码审查 description: 对指定目录或文件进行代码审查输出问题清单和修改建议 source: skill path: ./skills/code-review配置的语义很直观source 指明资源类型id 是上层 Agent 调用时使用的唯一标识transport 是 MCP 连接方式。这里最容易被忽视的是 auth 配置。很多 MCP Server 并不是裸跑的尤其是 streamable-http transport 的服务端会要求鉴权头。把这个配置进 Capability而不是让每个调用方自己处理。2.3 动态发现MCP 的 tools/list 与 Skills 的目录扫描注册中心需要主动去发现资源而不是等人来填。对接 MCP Server 时网关在启动阶段发起 initialize 握手然后调 tools/list 拉取工具列表再把每个 tool 转成 Capability。MCP Server 后续新增工具网关通过定时刷新或事件通知同步。这个机制天然支持热更新某个 Server 挂了只需要把对应的 Capability 标记为不可用其他资源不受影响。Skills 的发现方式不同它发生在文件系统里。Skilles 的加载依赖 SKILL.md 的解析我直接扫描配置的目录找到 SKILL.md 后读取 frontmatter 里的 name 和 description把目录下的可执行脚本包装成 execute 函数。比如从 GitHub 上 clone 一个 superpower skills 的仓库到 skills 目录重启网关就能自动识别里面定义的所有技能不需要手动写 Capability。2.4 路由策略精确匹配 语义兜底 标签分组能力多了以后路由就成了问题。上层 Agent 可能用任意措辞请求某个能力比如帮我截图和browser_screenshot明显不是同一个字符串。tsm-hub 的路由分三层处理。第一层是精确匹配如果模型调用时携带的 tool 名称正好是 Capability 的 id直接命中。第二层是别名与无意义词过滤把能力名称和 description 里的关键词做模糊匹配比如打开网站命中 browser_screenshot 里的打开。第三层是可选 embedding 语义匹配通过向量相似度找到最接近的能力。实际使用中前两层已经覆盖了九成场景第三层更适合能力列表特别庞大的情况。路由失败时有个细节必须做对返回能力不存在的同时给出几个候选建议。因为 LLM 在上下文里看到工具列表后有时会拼出错误的名字。把候选建议一起返回模型就能自我纠正而不是直接放弃。3. 网关的代码骨架协议转换、路由、权限与可观测性3.1 MCP 协议桥接比想象中多一个 initialized 通知MCP 的接入是网关里最繁琐的部分因为它不是简单的 HTTP 调用而是一套带生命周期状态的协议。核心流程是这样的先发 initialize 请求做握手服务端会返回协议版本和服务能力握手成功后客户端要发一个 notifications/initialized 通知告诉服务端初始化完成然后才能调 tools/list 获取工具列表。async function callMCP(cap: Capability, args: unknown) { const client await getMcpClient(cap.endpoint!, cap.transport!); // initialize 握手连接内部会缓存复用 await client.initialize({ protocolVersion: 2024-11-05 }); // 通知初始化完成这一步最容易漏 await client.sendNotification(notifications/initialized, {}); // 刷新工具列表找到对应的 tool 名称 const tools await client.listTools(); const tool tools.find(t t.name cap.id); if (!tool) { throw new GatewayError(mcp_tool_not_found, 404); } // 真正调用工具 const result await client.callTool({ name: tool.name, arguments: args }); return result; }这段逻辑看起来不多但有三处容易踩坑initialize 的 protocolVersion 要和服务端对齐否则直接握手失败initialized 通知不发部分严格实现的服务端会拒绝后续请求tools/list 返回的工具名称不一定与 Capability 的 id 一致需要做映射。我选择在注册时把 MCP 工具原名也存进 Capability避免每次调用都重新查找。3.2 本地工具与 Skills 的 execute 包装本地工具的包装很简单register 的时候传一个函数进来就行。Skills 的包装要稍微绕一点。Skills 本质上是一个目录里面有 SKILL.md 和若干脚本。网关需要把执行 Skill翻译成在对应目录下执行某个命令。async function executeSkill(cap: Capability, args: Recordstring, unknown) { const skillPath cap.path; const script resolveScript(skillPath, args.script || run.sh); const result await runInDirectory(script, skillPath, args); return { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode }; }这里有个设计取舍Skill 的输入参数怎么传。社区里 Skill 的写法五花八门有的是环境变量有的是命令行参数。tsm-hub 统一约定调用 Skill 时的参数先注入环境变量再追加为命令行参数。这样对 Skill 作者最友好不需要改脚本去读 JSON。3.3 权限控制白名单、审批模式与会话隔离网关暴露给上层的是一整套能力列表但真实场景里你通常不希望某个 Agent 能调用全部能力。权限控制我分了三个等级白名单模式、审批模式、全放行模式。白名单模式最常用config 里给每个 agent 配一个 allowedCapabilities 列表路由时直接过滤。审批模式适合高危操作比如删除文件、发布部署、修改数据库结构这类调用到达网关后进入 pending 状态通过管理接口审批后才真正执行。全放行模式只在本地开发时用千万不要带到生产环境。会话隔离容易被忽略。网关是共享的多个 agent 可能同时共用同一个 MCP Server。如果不在 CallContext 里透传会话身份下游服务会把不同用户的请求混在一起。我在每个下游调用里强制注入 agentId 和 sessionIdMCP Server 可以拿这些字段做自己的上下文管理。3.4 可观测性一个请求怎么从网关走到工具再回来工具链路比模型调用链路长所以必须做追踪。每次请求进来网关生成一个 traceId每个环节打 span。这样做的好处不只是排错更重要的是你能回答一次 Agent 调用到底消耗了多少资源这种问题。错误类型含义网关处理protocol_errorMCP 握手或协议失败自动重试一次失败后熔断缓存not_found能力不存在或工具名拼错返回候选能力列表供模型纠正validation_error参数不符合 inputSchema把校验错误转成 LLM 可读文本execution_error工具执行过程自身异常记录 traceId 后抛给上层auth_error下游服务鉴权失败触发凭证刷新流程最终落成日志的 span 是这样的结构{ traceId: trace-8f2a9c, spans: [ { node: gateway, duration: 8, status: ok }, { node: mcp:browser, duration: 735, status: ok }, { node: skill:code-review, duration: 3120, status: ok } ] }这里的价值在于一眼看出瓶颈是浏览器操作还是代码审查脚本而不是去猜。4. 从零部署与接入实测整合 Playwright MCP 和社区 Skills4.1 容器化部署与最小配置部署方式我推荐 Docker Compose因为网关要挂载 Skills 目录和配置文件容器化以后迁移很方便。Node.js 20 以上的环境也可以直接 npm 全局安装。启动方式按个人习惯选。version: 3.8 services: tsm-hub: image: tsm-hub/tsm-hub:latest ports: - 8787:8787 volumes: - ./config.yaml:/app/config.yaml - ./skills:/app/skills environment: - TSM_HUB_ENVproduction启动后本地的 8787 端口就暴露了三个端点/v1/chat/completions 是 OpenAI 兼容格式/admin/capabilities 是能力管理接口/mcp 是网关自己的 MCP 服务端点。第三个端点很重要它让 tsm-hub 本身也可以作为一个 MCP Server 被其他 Agent 客户端使用实现了自洽。4.2 接入 Playwright MCP一个完整的浏览器自动化链路Playwright MCP 是浏览器自动化的标准选择。先把服务端跑起来npx playwright/mcplatest --port 3000然后在 config.yaml 里注册。启动后上层 Agent 调用的不是 playwright 的原始方法而是浏览器截图这个语义化能力。调用链长这样LLM 决定调用 browser_screenshot → 网关收到请求 → 路由到 Capability → 网关与 Playwright MCP Server 握手 → 创建一个浏览器会话 → 导航到 URL → 截图 → 把图片路径返回给网关 → 网关包装后返回给 LLM。这一步跑通了意义很大你的 LLM 从此拥有操控真实浏览器的能力而且不用关心 playwright 的 API 细节。配合 llm wiki 这类知识库工具甚至可以做打开文档站、按页面内容回答问题的闭环。4.3 从 GitHub 安装社区 Skills 并挂载Skills 的安装过程比 MCP 简单本质是文件拷贝。以从 GitHub 上安装一组社区 Skills 为例在 skills 目录下执行 git clone把仓库拉下来。确认每个技能目录下都有 SKILL.md 和可执行脚本。重启 tsm-hub网关自动扫描并注册新能力。一个标准的 SKILL.md 长这样--- name: code-review description: 对指定目录或文件进行代码审查输出问题清单和修改建议 --- 执行仓库根目录下的 review.sh传入目标路径参数。这类前端开发 skills、opencode skills、codex skills 虽然在不同工具里的格式不完全一致但 SKILL.md 作为说明书 脚本包的思路基本被社区接受了。tsm-hub 只认 SKILL.md 的 frontmatter正文内容交给脚本执行时去理解所以兼容性很好。4.4 上层 Agent 怎么对接网关客户端对接有两种方式。一种是走 OpenAI 兼容接口上层 Agent 直接把网关当成一个 tool 列表的提供者curl -X POST http://localhost:8787/v1/chat/completions \ -H Authorization: Bearer sk-local-demo \ -H Content-Type: application/json \ -d { model: gpt-4o, messages: [ {role: user, content: 打开 https://example.com 并截图} ] }注意 tools 参数留空网关会自动把该 agent 所有有权限的 Capability 转换成 JSON Schema 注入请求。上层模型不需要感知工具背后是 MCP 还是 Skills。另一种方式是把 tsm-hub 作为 MCP Server 暴露出去让 Claude Code、opencode 这类原生支持 MCP 客户端的工具直接连接。在客户端配置 MCP 服务地址指向 http://127.0.0.1:8787/mcp 即可。这个方案的好处是那些只认 MCP 协议的 Agent 也能复用你所有的能力资源。5. 踩坑实录协议握手失败、上下文撑爆与工具静默失联5.1 MCP 协议版本不一致导致的握手失败第一次对接 streamable-http transport 的 MCP Server 时我遇到的报错是 initialize 请求返回 406。一开始以为是地址写错排查后发现是 protocolVersion 的问题。我发的是 2025-03-26服务端只支持 2024-11-05版本不匹配直接被拒。排查链路分享给大家。先手动发一个原始 initialize 请求看响应体里 service 返回的 protocolVersion 是什么curl -X POST http://127.0.0.1:3000/mcp \ -H Content-Type: application/json \ -H Authorization: Bearer demo \ -d {jsonrpc:2.0,id:1,method:initialize,params:{protocolVersion:2024-11-05,capabilities:{},clientInfo:{name:tsm-hub,version:0.1.0}}}看到服务端支持的版本后再把网关里的默认版本改成一致握手立刻通了。后来我在这块做了兼容initialize 失败时解析响应里的 protocolVersion 字段用服务端支持的版本自动重试一次。5.2 工具描述太长把上下文窗口撑爆MCP Server 的 tools/list 返回的描述信息通常非常冗长尤其是那些自动生成的工具文档动不动就是几百个 token。一次注入 30 个工具上下文窗口先被吃掉一大截留给真实对话的空间所剩无几。这个问题在接入多个 MCP Server 时被放大。解决办法有三层描述截断、按需加载、标签过滤。描述截断是硬性的description 超过 200 字符就截断保证注入的 token 可控。按需加载是懒加载收到调用请求后才去同步完整描述。标签过滤是让上层 Agent 声明自己需要的标签范围比如browser或design网关只注入对应标签的工具。蓝湖 MCP 这类设计协作工具描述通常很长标签过滤的效果尤其明显。5.3 Skills 目录加载失败大小写和权限的教训从 GitHub 上 clone Skills 仓库时我遇到过几次加载成功但调用失败的情况。最常见的原因是 SKILL.md 文件名大小写不对。有些仓库写的是 skill.md网关只认 SKILL.md结果整个技能静默跳过。另一个高频问题是脚本没有执行权限Linux 下默认文件权限是 644直接执行会报 permission denied。建议在 skills 目录初始化脚本里加两步find ./skills -name SKILL.md -o -name skill.md | while read f; do dir$(dirname $f) chmod x $dir/scripts/* 2/dev/null || true done这里的教训是网关加载 Skills 时必须给出明确的错误信息而不是把无法解析的目录静默忽略。我在 tsm-hub 里对每个 skill 目录都输出加载日志包含缺失的字段和文件路径避免排查问题时靠猜。5.4 LLM 静默拒绝调用工具的降级策略最头疼的问题不是工具报错而是模型压根不调用工具、直接给出一段文字答案。比如让模型打开 example.com 并截图它回答好的我来帮你打开然后就没有然后了。这是 tool-use 场景里很常见的静默拒绝。网关层面能做的降级有两个。第一是强制工具调用标记当请求里带 force_tool_calltrue 时网关在协议层要求模型必须返回 tool_use不允许直接答复。第二是意图-能力映射兜底LLM 返回纯文本时网关用轻量匹配判断文本是否对应某个已注册能力如果匹配度高返回建议调用某能力的提示把选择权交回给上层。这个兜底不能做得太重否则就变成网关替模型做决策了。但在实际开发中它能救回不少因为模型幻觉或上下文截断导致的失败请求。6. 写在最后实践中的收益与扩展方向tsm-hub 跑了几个月后我最直观的感受是接入效率上来了。以前加一个 MCP Server至少要写两百行适配代码现在只需要在 config.yaml 里加几行配置。以前想让 Claude Code 复用自研 Tools得折腾半天现在把网关当作一个 MCP Server 暴露出去就行。而 Skills 的加入让团队里不熟悉协议细节的同事也能贡献能力——他们只需要写清楚 SKILL.md剩下的交给网关。后续的扩展方向我目前在看三个技能市场也就是一个统一的 Skills 仓库管理机制让技能可以按版本发布和订阅策略引擎在网关层做一些更细的规则比如按时间、按调用次数做限流审计报表把所有能力调用的数据沉淀下来用来优化工具描述和路由策略。最后给想动手的朋友一个建议从最小闭环开始。先接一个 MCP Server把浏览器截图跑通再加一个本地 Search 工具最后尝试挂载 Skill。不要一开始就追求大而全工具接入的体验只有真实用起来才能感受到哪些设计是必要的哪些只是过度设计。网关的价值不在于它接了多少资源而在于当资源数量越来越多时你的上层 Agent 代码结构还能保持稳定不被不断膨胀的适配逻辑拖垮。
返回列表