统一AI网关:融合LLM、Tools、MCP与Skills的实践指南

发布时间:2026/9/30 9:49:43
统一AI网关:融合LLM、Tools、MCP与Skills的实践指南 去年有段时间我的日常工作基本被三件事占满给不同模型写兼容层、帮同事把工具注册进函数调用、在好几个项目里重复搭同一套“检索→总结→输出”流程。模型 SDK 一个厂商一个风格工具定义散落在业务代码里好不容易把一个模型的 function calling 调通换另一个模型又要改半天——这些碎片化问题单独看都不大叠在一起就是灾难。后来我把 LLM、Tools、MCP、Skills 这些能力统一收敛到一个网关里内部代号就叫tsm-hub。这篇文章不堆架构概念只讲我为什么这样设计、实际接入时哪些地方容易踩坑以及把它推到生产环境前必须想清楚的几件事。如果你也在做 AI 应用或 Agent或者正在纠结“MCP 和 Tools 到底什么关系”这篇应该对你有用。1. 从三套 if/else 到一张网关我最初遇到的碎片化困局1.1 模型接口碎片化不是多写一个适配器的事最开始的痛苦来自模型接入。OpenAI 的接口、Anthropic 的接口、Google 的接口虽然大方向都是“输入 messages、输出 completion”但细节差异能把人磨疯。OpenAI 用tools字段传函数定义Anthropic 叫tools但内部结构不同Google 早期叫function_declarations。流式输出时OpenAI 的 chunk 里是delta.contentAnthropic 的流事件是content_block_delta解析逻辑完全没法复用。token 统计字段、停止原因、工具调用的返回结构每个厂商都有自己的命名。团队里一旦有两个项目同时接模型基本就是各写各的 adapter。最夸张的时候一个项目里躺着五套“把模型响应转成统一对象”的代码改需求时得同时改五个地方。这种碎片化不是“多写一个适配器”就能解决的因为适配器本身没有边界只会越写越乱。1.2 工具注册碎片化JSON Schema 散落一地第二个痛点比接口更隐蔽。支持 function calling 之后每个业务功能都要把“这个工具接受什么参数、参数是什么类型、哪些必填”描述清楚也就是工具的 JSON Schema。问题是这些 Schema 通常散落在各个业务模块里有的写在模型调用前有的写在工具函数旁边有的干脆在 Prompt 里用自然语言描述。结果就是同名工具在不同模块里参数定义不一致模型经常猜错。工具列表越加越长每个请求都把所有工具塞给模型上下文被大量无用定义撑爆。想统一加个“所有工具都带request_id参数”都没有地方下手。本质上缺的不是“工具函数”而是一个工具的注册、发现、编排中心。工具应该像插线板上的插孔一样被统一管理而不是散落在地板上。1.3 Skills 与人肉复制同样的流程每个项目写一遍第三个问题来自“技能”的复用。比如“联网搜索并总结成 Markdown”这件事本质上就是“调用搜索工具 → 把结果塞进上下文 → 让模型按模板总结”。逻辑很清晰但每个项目要做时都得重新写一遍 Prompt、重新接搜索 API、重新处理超时和异常。更难受的是这种技能往往是团队里某个聪明人调出来的效果很好但没沉淀下来。别人想复用只能靠“复制那哥们儿的代码”。Skills 在这时候就不只是“提示词模板”了它应该是一个可执行的单元带上工具依赖、带上编排逻辑、带上上下文约束谁都能调用。1.4 为什么是“网关”而不是“框架”针对这些问题团队里有过争论要不要直接引入一个 Agent 框架我的想法比较坚定——我们要的是一个网关不是框架。框架的问题在于侵入性。用框架意味着业务代码要跟着框架的写法走项目一旦用上就很难抽身而网关只做转发、归一、路由、治理业务系统还是“用自己的语言”来调用 AI 能力。网关在外面包一层内部模型怎么换、工具怎么加业务侧不用关心。而且网关天然适合多服务复用。我们当时有十来个服务都要接模型和工具如果每个服务都集一个框架维护成本翻十倍统一进网关后接入一次谁都能用。这也是 tsm-hub 定位的起点做公司内部的 AI 能力统一出入口。2. tsm-hub 的定位与四层结构先想清楚再动手2.1 接入层、路由层、归一化层、执行层设计 tsm-hub 时我把它拆成四层分层不是为了好看而是为了出事时知道去哪查。接入层Ingress负责接收外部请求。不管是来自 HTTP API、消息队列还是内部 RPC统一转换成网关内部的消息格式。外部系统不需要关心背后是哪个模型、哪个工具。路由层Routing负责决定“这个请求该去哪”。按模型优先级路由、按租户路由、按 skill 依赖的工具路由都在这一层做。比如“先用 Claude挂了自动切到别的模型”这种策略就在路由层实现。归一化层Normalization是核心。不管是 OpenAI 的 tools、Anthropic 的工具调用还是 MCP server 拉下来的工具到这里都变成同一种内部表示。模型 A 的响应结构、模型 B 的响应结构也在这里统一成一份。执行层Execution负责真正跑工具、跑 MCP 调用、跑技能编排。工具的超时、重试、并发控制都在这一层。2.2 统一请求模型四类能力映射到同一份协议把 LLM、Tools、MCP、Skills 收进一个网关最大的挑战不是代码而是“概念统一”。我花了不少时间才想明白这四样东西在网关内部完全可以看作两种能力——生成能力LLM和动作能力Tools/MCP/Skills。LLM 本质上是“根据上下文生成文本/决策”的能力Tools 和 MCP 是“执行动作”的能力Skills 则是“一段时间内按编排执行的一组动作生成”。所以网关内部的统一消息模型长这样{ type: chat.completion | tool.execute | skill.run, request_id: req_8f6d2e1a, model: claude-sonnet-4-0, messages: [], tools: [], skill: null, metadata: { tenant: team_a, timeout_ms: 30000 } }type决定走哪条执行链路tools永远是一份“归一化后的工具定义列表”不管这些工具是本地注册的还是从 MCP server 拉来的。归一化层要做的事就是把各家工具定义翻译成这一份格式。2.3 Provider 采用插拔式设计为什么不用硬编码接新模型、新 MCP server最忌讳的是在网关核心代码里硬编码分支。我用的方式是定义 Provider 接口interface Provider { id: string; chat(messages: Message[], options: ChatOptions): PromiseChatResponse; stream?(messages: Message[], options: ChatOptions): AsyncIterableStreamChunk; listTools?(): PromiseToolDefinition[]; }每个模型厂商、每个 MCP server都是一个独立的 Provider通过配置注册进网关。核心代码只跟Provider接口打交道新增一个模型就等于新增一个实现类。这样做的原因是模型和工具市场变化太快硬编码等于给自己埋雷。当时团队里有人提议直接封装一个“万能模型类”把所有厂商的差异用 if/else 处理掉。我反对这种写法因为一旦出现一个新厂商或一个新特性就得改这个类测试范围会越来越大。插拔式设计虽然初期多写几个文件但长期收益非常明显。2.4 一条请求的完整旅程拿一个带工具的实际请求举例。外部系统调用/v1/chat/completions网关做五件事接入层解析请求校验 key 和租户信息生成request_id。路由层根据模型配置选择 Provider比如“优先 Anthropic失败退到 OpenAI”。归一化层把请求里的消息、工具定义转成各 Provider 能理解的格式。执行层把响应里的工具调用解析出来按注册表找到对应工具执行后把结果作为新消息继续送回模型。最终在模型完成回复后把标准响应返回给调用方。整个链路里外部系统只看到一套 API网关内部是哪家模型、调了什么工具对外完全透明。这其实就是 API 网关的套路只不过管理的资源从“服务”变成了“模型和工具”。3. 一个周末的集成实录模型、工具、MCP Server 依次进网关3.1 先把网关跑起来路由与注册表我习惯先做最小的闭环一个 LLM Provider 一个本地工具 能转发请求的 HTTP 入口。路由配置用 YAML 维护因为运维和同事都能看懂不需要改代码routes: - name: primary match: model_group: flagship providers: - id: anthropic-claude weight: 100 - id: openai-gpt weight: 0 # 备胎主模型失败时启用 - name: local match: model_group: fast providers: - id: local-qwen路由配置单独放是因为模型厂商的可用性和价格变化很快我不想为了“换个主模型”发一次版。网关启动时加载这份配置调用方只需要传model_group网关自动做映射。3.2 接入第一个 LLM Provider适配器写法写 Provider 时有个小技巧不要直接对接厂商的官方 SDK而是对接它们的OpenAI 兼容接口。现在绝大多数模型厂商都提供 OpenAI 兼容的 HTTP 端点这意味着一个适配器能打通一大片。我写 OpenAI 兼容适配器时核心就两件事把统一消息转成它们的messages格式把它们的响应转回统一格式。关键字段就三个内容、工具调用、停止原因。只要能稳定映射这三个其它细节都可以慢慢补。流式处理是另一个容易翻车的地方——不要等到整个流结束才返回而要把 chunk 实时转成统一协议返回。这样上层可以边生成边展示体验差别很大。具体做法是适配器内部用AsyncIterable每个 chunk 都做一次字段映射。3.3 自动化工具 Schema从函数签名生成 JSON Schema工具注册最大的痛点是写 JSON Schema。手写不仅枯燥而且容易出错。我的解法非常简单用类型系统自动生成。后端用 Python 就上 Pydantic前端/Node 环境就用 Zod定义好入参模型后调用model_json_schema()或zodToJsonSchema()直接得到 Schemafrom pydantic import BaseModel class SearchInput(BaseModel): query: str Field(description搜索关键词) max_results: int Field(default5, ge1, le20) class Config: json_schema_extra {title: search_web}网关启动时扫描带装饰器的函数自动提取入参模型生成工具定义并注册到工具表。这样函数签名就是工具定义永远不可能“代码和 Schema 不一致”。这个习惯我强烈建议养成能省掉一大半工具接入的体力活。3.4 接一个 MCP Server拉取工具再注册现在很多现成能力都以 MCP Server 的形式提供比如数据库查询、浏览器自动化、设计工具控制等。接 MCP 比接本地工具多一步先通过 MCP 客户端拉取它的工具列表再注册进网关的统一工具表。用官方 Client SDK 时核心代码大致是这个思路const client new McpClient({ transport }); await client.connect(); const { tools } await client.listTools(); for (const tool of tools) { registry.register({ id: mcp:${serverName}:${tool.name}, schema: tool.inputSchema, execute: (args) client.callTool(tool.name, args), }); }这里的id用了mcp:serverName:toolName的命名空间避免和本地工具冲突。MCP 的好处是工具的能力定义和传输协议都是标准化的网关不用为每个 MCP Server 写适配器只需要一个通用的 MCP Provider。4. MCP 与 Tools 的功能重叠协议归一差点翻车的地方4.1 MCP 到底是什么它和 Tools 不在同一层做网关之前我也被“MCP 和 Tools 到底什么关系”这个问题卡过。后来我把它类比成 HTTP 和 API 的关系HTTP 是传输协议API 是接口能力MCP 是“模型上下文协议”Tools 是“函数调用能力”。MCP 规定的是“客户端怎么发现工具、怎么调用工具、工具结果怎么传回”的一套消息标准它不关心工具内部怎么实现。而 function calling 里说的 Tools本质是“让模型知道有哪些函数可用、并按约定输出调用指令”的一种机制。所以 MCP Server 只是工具的一个来源Tools 是网关内部的统一概念。在网关里本地函数可以注册成 ToolMCP Server 拉下来的能力也可以注册成 Tool。MCP 解决的是工具来源的标准化Tools 解决的是模型侧调用的统一。至于“MCP 是软件协议还是硬件协议”这个问题——它是纯软件协议运行在应用层走的是消息传递跟硬件毫无关系。4.2 命名空间冲突与工具爆炸把多个 MCP Server 接进来后问题马上出现不同 Server 可能有同名工具比如两个 Server 都提供search。如果直接注册后注册的会把先注册的顶掉模型调用时可能调用到完全不是预期的那个。我的方案就是前面提到的命名空间前缀。每个 MCP Server 一个前缀如mcp:github:search、mcp:db:query本地工具用local:前缀。模型看到的工具名虽然长了点但绝对不会混淆。还有一个坑是“工具爆炸”。接入的 MCP Server 多了工具列表可能有上百个全部塞给模型既浪费 token 又降低选择准确率。网关里必须做一层工具过滤根据当前会话的意图或 skill 声明只把相关的工具暴露给模型。我当时做法很土但有效——每个 skill 里手写tools_required字段运行时就按这个字段过滤。4.3 鉴权差异本地工具和远端 MCP 的凭证管理本地工具通常在网关进程内执行鉴权可以用网关自身的服务账号远端 MCP Server 则可能有自己的 token、自己的访问控制。刚开始我把所有凭证都塞在 Provider 配置里结果日志一打token 全漏出去了。后来统一改成凭证库管理网关内存里只保留凭证引用执行工具调用时动态注入日志系统统一对authorization、token、api_key字段做脱敏。这个细节放在后面“上生产”部分还会提但这里我要强调接 MCP 的那一刻就要考虑凭证审计而不是等出事了再补。4.4 接 Playwright MCP 时踩过的坑我们接浏览器自动化 MCP 时踩过一个特别典型的坑。早期测试阶段多个会话共用一个浏览器上下文结果一个会话的页面跳转把另一个会话的页面也带跑了工具执行结果全串。排查半天才发现MCP Server 自己维护游览器实例如果网关层面不做会话隔离所有请求会共享状态。最后强制每个request_id在 MCP 侧映射到一个新的浏览器上下文用完即焚才算稳定。接有状态 MCP Server 时隔离粒度必须提前设计好否则后面所有上层业务都会被它坑。5. Skills 的进阶玩法从“提示词模板”升级为“可复用执行单元”5.1 给 skill 一个明确的定义如果把 Skill 简单理解成“一段 Prompt”它永远只能在同一个项目里贴来贴去。我在 tsm-hub 里给了 Skill 更强的语义Skill 编排模板 工具依赖 上下文约束编排模板决定“先做什么、再做什么”工具依赖声明“这个技能需要哪些工具”上下文约束决定“哪些参数必须由调用方提供、哪些可以由网关自动补充”。三者缺一不可。这个设计想明白后一个 Skill 就不再是文本而是一个能在网关里被解析、执行、复用、版本化的实体。5.2 一个 Skill 的 DSL 示例我用的 Skill 定义是 YAML尽量让不懂代码的同事也能看懂id: web_search_summarize version: 1.2.0 description: 联网搜索并输出结构化总结 tools_required: - local:web_search - mcp:summary:extract_key_points context: required_inputs: - query auto_inject: - current_date - request_id steps: - call: local:web_search params: query: {query} max_results: 8 - call: mcp:summary:extract_key_points params: documents: {web_search.result} - complete: template: | 搜索主题{query} 关键结论{extract_key_points.summary}{query}、{web_search.result}这种写法是网关里的变量绑定机制前一步的输出自动成为下一步的输入。整个 Skill 可以在没有模型参与的情况下完成纯工具编排也可以在某一步显式调用模型生成中间结果。5.3 版本管理与沙箱执行Skill 是沉淀出来的经验必须有版本。我把 Skill 文件放在 Git 仓库里每次修改走 MR 评审网关加载时按版本号加载沙箱目录。这样线上跑的是已验证的版本新版本测试通过前不会影响线上。沙箱不仅指文件隔离更指资源隔离Skill 里跑的工具调用要有独立的超时预算、独立的并发限制、独立的临时目录。不然一个 Skill 里某个工具卡住可能拖垮整个网关的线程池。5.4 实测效果拆出两个 Skill 后团队怎么用我先把最通用的两个能力拆成 Skillweb_search_summarize联网搜索总结和code_review_basic代码审查。以前同事要做一个“资料整理机器人”得自己接搜索 API、调模型、写总结 Prompt一天起步。现在只要调网关的/v1/skills/web_search_summarize传一个query进去剩下的事网关全包了。团队里最让我意外的是这个 Skill 被产品同事拿去用在了内部知识库问答上——因为他不需要懂模型调用只需要知道“填一个关键词拿一份总结”。这就是把 Skills 实体化之后的价值它把专家经验变成了所有人可以调用的服务。6. 上生产之前必须盯住的三个细节6.1 限流、熔断、重试不要在每个适配器里各写一套网关层最忌讳的是“每个 Provider 自己管自己的容错”。我在最早的版本里每个模型适配器里都写了重试逻辑结果一个模型抽风时所有适配器同时重试直接把上游打挂。后来统一收敛到网关的流量治理层全局限流按租户和模型分组熔断按失败率自动断开异常 Provider重试逻辑由网关统一控制。模型层只负责执行单次请求不做任何重试。这样线上出问题时我们只需要在网关改配置不用动任何适配器代码。6.2 链路追踪request_id 贯穿所有调用排查问题最痛苦的时候就是模型侧说“我没收到请求”工具侧说“我执行了但没返回”业务侧说“我超时了”——三个服务各执一词对不上单。在 tsm-hub 里request_id从接入层生成后会一路透传到模型调用、工具执行、MCP 调用最终写进所有日志和指标。任何一个环节出问题拿request_id一查整条链路就出来了。成本和收益完全不成正比多写几行日志代码就能让排障时间从半天缩到十分钟这笔买卖太值了。6.3 密钥管理与租户隔离最后说一个容易在“小团队”阶段被忽视的问题密钥。网关是统一出入口天然汇聚了所有模型厂商的 key、所有 MCP Server 的 token。一旦泄漏等于所有上游凭证一次性全暴露。我踩过的具体坑是某个 Provider 报错时异常信息里带了完整请求 URL而那个 URL 上挂着 query 参数形式的 key直接进了日志系统。后来强制规定三件事凭证只从环境变量注入、异常信息统一脱敏、日志输出前跑一遍敏感字段过滤器。另外如果需要多租户隔离key 的管理还要跟路由绑定租户 A 只能用租户 A 的模型配额不能因为网关里配了别的 key 就意外串用。我在路由配置里每个租户都有独立的credential_binding宁可配置多点也不能让 key 混用。最后聊两句心里话tsm-hub 做出来后我最大的感受是网关只解决“接入”问题不解决“治理”问题。接入层再漂亮如果没有流量治理、链路追踪、凭证审计它就是一团漂亮的浆糊。真正让它变得可靠靠的是后面这一层又一层的“兜底”。另外一个小技巧想分享给你在网关里把知识库检索也包装成一个 Skill而不是一个普通工具。因为检索往往需要多轮“转换查询词→检索→筛选→再检索”的流程用 Skill 编排比让模型裸调工具稳定得多。这也是我最近在扩展的方向——把更多“流程型能力”从工具升级为 Skill。做统一网关这条路越走越觉得有意思。