这两年做LLM应用,最让我头疼的已经不是"模型不够聪明"了,而是整个工具链越来越散。模型要接OpenAI、Anthropic、国产几家的API,各自的消息格式、参数语义还不一样;工具调用这边,一会儿要写Function Calling的JSON Schema,一会儿又要对接外部MCP Server,还得维护一套Skills技能包。每个模块单独拎出来都不复杂,但堆在一起就是一团乱麻。tsm-hub就是我在这个背景下做的一个统一网关:把LLM、Tools、MCP、Skills这四类能力收敛到同一个内核里,通过一个入口暴露出去,让上层应用不用关心"模型是谁、工具在哪、协议是什么"。
这篇文章不是理论概念稿,我会从为什么需要网关、核心架构怎么拆、具体怎么搭、真实踩坑记录这几个维度展开。如果你正在做Agent类应用、多模型接入、或者准备把MCP服务纳入自己的系统,这篇应该能给你省不少时间。
1. 为什么需要"统一网关":碎片化才是真痛点
1.1 我现在手上的系统有多乱
我先还原一个场景。一个典型的AI应用,往往同时依赖这些能力:
- LLM层:对话、推理、文本生成,可能同时用了两到三个厂商的模型,每个模型的能力边界、成本、延迟都不一样。
- Tools层:内部业务接口,比如查订单、查库存、根据权限操作CRM,这些要封装成模型能识别的函数。
- MCP层:外部标准化工具服务,按Model Context Protocol暴露能力,比如联网搜索、读知识库、访问某个业务系统。
- Skills层:一段经过验证的提示词模板、参数预设、工具白名单、输出约束,相当于"专业技能包"。
如果每个模块各自对接,应用侧代码会变成这样:到处是if-else分支判断用哪个模型,工具函数的schema散落在各处,MCP连接逻辑各有各的写法,技能包基本靠复制粘贴system prompt。换一个模型或者加一个新工具,至少要动三四个文件。
我试过最痛苦的一次:团队接一个第三方MCP服务,对方升级了协议版本,把tools/list的响应结构改了,结果我方所有调用了这个服务的功能全部报错。原因很简单——每个功能模块都自己维护了一份MCP调用代码,升级时根本不知道哪些地方需要跟着改。这个教训直接推动我去做一个统一网关。
1.2 tsm-hub的设计目标
tsm-hub要解决的,就是把上面那些能力变成"可插拔"的。它的核心设计目标可以概括成四句话:
- 统一入口:对外只暴露一套兼容OpenAI格式的Chat接口,内部再怎么切换模型,上层调用方无感知。
- 统一Tool模型:不管是本地函数、HTTP接口还是MCP Server,全部归一成"工具描述+可执行调用"两种形态。
- 统一Skills机制:技能包不再是散落的prompt文档,而是有格式定义、可加载、可组合的配置资产。
- 统一运维视角:模型调用次数、工具成功率、MCP连接状态、技能命中情况,都能在网关层看到,而不是分散在日志里靠grep。
名字里的tsm,我习惯理解成Tool / Skill / Model三个名字的缩写,Hub就是集线器。你可以把它理解成:现实里的插线板——电器(模型、工具、MCP服务、技能包)各自有各自的插头(协议、格式),插线板负责把它们统一成标准插座(统一网关接口),电器之间互不关心对方长什么样。
1.3 谁适合用这套方案
如果你是这几种情况之一,tsm-hub的思路应该对你有用:
- 项目里同时接了两家以上大模型API,想统一切换、降级、做成本控制;
- 正在从Function Calling转向MCP,或者两类工具并存,需要一层统一的调度;
- 想把团队里的提示词资产、工具调用经验沉淀成标准化的"技能包",而不是各自存文档;
- 想给上层多个应用共享一套LLM能力,避免每个应用重复对接底层API。
如果只是单模型、单工具、一次性脚本,那确实没必要上网关,直接写代码最利索。但只要是长期迭代的产品,统一层带来的收益会随着接入方数量线性增长。
2. 核心架构拆解:四个能力域如何收敛到一个入口
2.1 网关的整体分层设计
tsm-hub的内部大致分成四层:
- 接入层:接收HTTP/WebSocket请求,兼容OpenAI Chat Completions格式,做鉴权、限流、参数校验。
- 路由层:根据配置和请求内容,决定这次调用走哪个模型、挂载哪些工具、启用哪个技能包。
- 适配层:把统一内部消息格式翻译成各家模型的请求格式,把模型返回翻译回内部格式。这是"多模型兼容"的关键。
- 执行层:负责工具的注册、查找、调用,MCP会话管理,以及Skills的加载和渲染。
为什么要把"适配层"单独拆出来?因为不同模型的API差异真的不小。OpenAI的Function Calling用functions数组描述工具,Anthropic要用tools数组加tool_choice控制模式;消息角色命名也不一样,OpenAI叫assistant,Anthropic叫assistant还算一致,但工具结果回传时,OpenAI用role: "tool"加tool_call_id,Anthropic要用user角色包一层tool_result。如果不做统一抽象,上层业务就得每次适配两种协议,时间长了必然出问题。
2.2 LLM Provider Adapter:多模型接入的关键
这一层我见过两种设计方式,各有取舍:
| 设计方式 | 实现思路 | 优点 | 缺点 |
|---|---|---|---|
| 直连适配 | 每个Provider写一个Adapter类,直接调用官方SDK | 结构简单,便于利用厂商特有参数 | 新增模型要写新代码,维护量大 |
| 抽象适配 | 统一走HTTP调用,自己拼JSON,兼容OpenAI格式 | 一套代码接所有兼容服务,加模型基本只改配置 | 厂商特有功能需要额外参数透传机制 |
我最终采用的是"抽象适配为主,直连为辅"的混合方案。对于OpenAI兼容的API,全部走同一个HTTP客户端,只改base_url和api_key;对于Anthropic这种格式差异大的,单独写一个Adapter做格式转换。这样既保证了覆盖面,又不至于为每个小厂商重复造轮子。
Adapter内部其实就做三件事:
- 请求转换:把内部统一消息数组(
[role, content, tool_calls, tool_results])转成目标模型的请求体。 - 响应解析:把模型返回的文本、工具调用指令、用量统计统一解析成内部结构。
- 错误归一:把各家API的报错信息转成统一错误码,比如
timeout、rate_limit、invalid_request、context_length_exceeded。
2.3 工具注册中心:一切皆可被调用
工具注册中心解决的核心问题是"模型如何知道有哪些工具可以用,以及怎么调用它们"。所有工具在网关内都以一份JSON Schema描述:
{ "name": "query_order", "description": "根据订单号查询订单状态", "parameters": { "type": "object", "properties": { "order_id": { "type": "string", "description": "订单号" } }, "required": ["order_id"] } }工具的执行入口可以是本地Python函数、一个HTTP接口callable、或者一个MCP工具。注册中心做的事就是:把Schema交给模型、收到模型生成的调用请求后、找到对应执行器、执行并返回结果。
这里我踩过一个坑:刚开始工具描述写得非常随意,"这个函数查询订单信息,参数是订单号"。结果模型经常传错参数名,比如把order_id写成orderId,或者明明参数必填却漏掉。后来我把每个参数描述都写得更明确,比如"订单号,格式为ORD开头加8位数字",甚至给出一个示例值"ORD12345678"。模型调用工具的准确率一下就上来了。
2.4 MCP Client:连接外部能力的高速通道
MCP(Model Context Protocol)本质上是一个让模型工具与外部服务建立连接的标准化协议。你不需要知道对方内部怎么实现,只需要按协议去握手、拿工具列表、调用工具。MCP Server可以是本地进程(stdio)、HTTP服务,也可以是WebSocket服务。
tsm-hub内置了一个MCP Client模块,负责管理多个外部MCP连接。每个连接的生命周期大概是:
- initialize:向Server发送协议版本协商、能力协商,拿到Server支持的特性。
- tools/list:获取这个Server对外提供哪些工具,每个工具一样是JSON Schema格式。
- tools/call:模型发起工具调用后,网关以Client身份请求MCP Server执行对应工具。
- 保活与重连:对长连接类型,定期发心跳;断了要做指数退避重连。
MCP接入到统一网关后,最大好处是:上层应用完全不知道背后连了哪些MCP服务。它们只知道"我有一个工具叫web_search",至于这个工具来自哪个MCP Server,或者其实是一个本地HTTP封装,对调用方完全透明。
值得说明的是,MCP的鉴权方式比较多。有的服务走HTTP Header的Bearer Token,有的走URL上的token参数,还有的需要完整的OAuth2流程。我在网关里内置了几种常用的认证方式,配置里声明一下就行:
mcp_servers: search_service: transport: sse # 或 streamable_http / ws url: https://mcp.example.com/mcp auth: type: bearer token_env: SEARCH_SERVICE_TOKEN internal_wiki: transport: streamable_http url: https://wiki.example.com/mcp auth: type: header headers: X-Api-Key: ${WIKI_API_KEY}2.5 Skills编排引擎:把经验沉淀成可复用资产
Skills是我在这个项目里投入最多心力的部分。它的作用不只是存一段prompt,而是把"一个完整能力"需要的所有要素包在一起:
- 一段system prompt模板(可以带变量插值);
- 一批可选的工具白名单;
- 模型选择偏好和推理参数(temperature、max_tokens);
- 可选的输出格式约束或后处理规则。
一个客服场景的Skill定义大概长这样:
name: order_customer_service description: 处理用户关于订单的咨询 model: provider: claude name: claude-3-5-sonnet temperature: 0.2 system_prompt: | 你是订单客服助手。 只回答与订单、物流、售后相关的问题。 如果用户问价、问库存,引导到对应入口。 答复前必须调用 query_order 工具核实订单状态。 tools: [query_order, logistics_tracking] max_tool_calls: 5调用Skill时,网关会渲染模板、装配工具列表、按照模型偏好创建会话。这样做的价值是:技能升级不用改应用代码。客服话术要调整?改这个YAML文件就行。想让客服逻辑换到一个更便宜的小模型?改配置就行。如果手动管理,这些都是散落在各处的代码和prompt。
社区里那些"前端开发skills""数学建模skills"其实也是同一种思路——把专业领域的提示词、工具组合、最佳实践封装好。tsm-hub只是给了它们一个更标准的落点。
3. 从零搭建:核心模块的实操要点
3.1 基础骨架与依赖选型
技术栈上,我选的是Python 3.11 + FastAPI + Pydantic。理由很简单:LLM生态的工具链Python最全,FastAPI天然支持异步,Pydantic做JSON Schema校验非常顺手。如果你更熟Node.js,用NestJS或Express也能做同样的事,但下面这些思路是通用的。
一个最小的网关入口文件结构是这样的:
from fastapi import FastAPI, Request from pydantic import BaseModel app = FastAPI() class ChatRequest(BaseModel): model: str | None = None messages: list[dict] tools: list[dict] | None = None skill: str | None = None @app.post("/v1/chat/completions") async def chat_completion(req: ChatRequest): # 1. 路由:确定模型、工具、技能 # 2. 适配:转换内部格式 # 3. 推理:调用模型 # 4. 工具循环:如果模型发起了tool_call,执行并回填 return await gateway.run(req)我不建议在一开始就引入特别重的框架。网关的价值在于"统一"和"可配置",不是在于代码有多少抽象层级。先跑通请求到模型的完整链路,再加工具,再加MCP,最后加Skills。顺序反了,你会在排查问题时非常痛苦。
3.2 模型路由与降级策略
模型路由是网关的核心功能之一。我实现了一个简单的评分路由:
async def route_model(request, skills_config): # 规则优先级依次是: # 1) 请求里显式指定了model -> 直接用 # 2) 挂载了skill -> 用skill.yaml里配置的model # 3) 按路由规则匹配 # 4) 默认模型路由规则支持两种常见模式:
- 按能力匹配:比如embedding请求走embedding模型,代码生成走代码模型,对话走对话模型。
- 按成本/负载分配:比如80%流量走
gpt-4o-mini这类便宜模型,20%走gpt-4o做高质量兜底。
降级策略我采用"连续失败降级":某个模型连续3次请求失败,就把流量切到备用模型,并标记这个模型为"冷却"状态,5分钟后再试探恢复。这个逻辑看着简单,但在多模型场景下能避免很多"模型供应商服务抖动导致业务全挂"的窘境。
3.3 工具注册与执行调度
工具注册我提供了两种方式:装饰器和配置文件。
装饰器方式适合本地函数:
from tsm_hub import gateway @gateway.tool( name="query_order", description="根据订单号查询订单状态,订单号格式为ORD开头加8位数字", ) async def query_order(order_id: str) -> dict: result = await order_api.get_status(order_id) return result配置文件方式适合外部HTTP接口或MCP工具:
tools: - name: web_search type: mcp mcp_server: search_service native_tool: web_search - name: internal_http_tool type: http url: https://internal.example.com/api/tool method: POST schema_file: schemas/internal_http_tool.json工具执行调度器要处理的几个关键点:
- 超时控制:每个工具调用必须设置单独超时,比如HTTP工具默认10秒,MCP工具默认30秒,避免某个卡住拖垮整个会话。
- 错误回传:工具执行失败后,不能直接抛异常让整个请求挂掉,而是要把错误信息作为一个特殊的结果消息返回给模型。模型会看到类似"工具执行失败:上游服务超时"的内容,它可以自行决定是否换一种方式处理。
- 调用次数上限:必须设置
max_tool_calls。没有这个限制,模型在循环里反复调用工具,费用会失控。我在生产环境默认设5次,复杂任务可适当放宽但不超过10次。
3.4 MCP连接池的工程细节
MCP的接入在工程上比普通HTTP工具复杂一些,因为涉及会话管理。我做了一个简单的连接管理器:
class MCPConnectionManager: def __init__(self): self.clients = {} # key: server_name, value: MCPClient async def get_client(self, server_name): if server_name not in self.clients: self.clients[server_name] = await self.connect(server_name) return self.clients[server_name] async def connect(self, server_name): config = load_mcp_config(server_name) client = MCPClient(config) await client.initialize() await client.list_tools() return client生产环境要注意的点:
- 懒连接:不要服务一启动就连所有MCP Server,按需连接。很多外部MCP服务对连接数有限制,空闲连接太多容易被踢。
- 心跳保活:WebSocket/SSE连接要定时发心跳,我用的是每30秒一次,60秒没收到响应就标记断连。
- 断线重连:指数退避,第一次1秒、第二次2秒、第三次4秒,上限30秒。重连后需要重新
initialize和tools/list,因为服务端的工具列表可能有更新。
3.5 Skills的加载与缓存
Skills的加载我做了两层:启动时加载基础技能包到内存;运行时通过管理接口热更新。每个Skill加载时做一次模板渲染预检,确保变量插值不会出错。
技能调用时的内部流程大概是:
- 用户请求里带上
skill: order_customer_service; - 网关读取对应YAML配置,渲染system prompt;
- 根据技能的工具白名单,从工具注册中心拉取对应工具Schema;
- 组装成请求发给模型;
- 进入工具调度循环;
- 返回最终结果。
缓存方面,因为没有特别频繁变更,我用的是一份全局字典加版本号,更新接口会递增版本号,下次请求重新装载。实测一万并发下也无压力。
4. 一个完整的落地示例:多模型智能助手接入真实工具链
4.1 场景定义
为了把前面讲的串起来,我做一个具体的例子:一个"订单智能客服助手"。需求是:
- 用户咨询订单状态、物流信息,助手需要先查订单库;
- 如果用户问的是"最近有什么优惠",需要联网搜索或查内部促销活动;
- 对话语气要符合客服规范,不能信口开河;
- 遇到复杂问题,切换到高能力模型处理;日常问答用低成本模型。
这里涉及到的能力正好覆盖了tsm-hub的四大块:LLM(两个模型)、Tools(查订单接口)、MCP(一个促销活动的MCP服务)、Skills(客服技能包)。
4.2 配置与代码实现
先看完整的配置文件:
# gateway.yaml gateway: default_model: gpt-4o-mini max_tool_calls: 5 providers: openai: type: openai_compatible base_url: https://api.openai.com/v1 api_key: ${OPENAI_API_KEY} models: - name: gpt-4o-mini cost_per_1k_input: 0.00015 cost_per_1k_output: 0.0006 - name: gpt-4o cost_per_1k_input: 0.0025 cost_per_1k_output: 0.01 anthropic: type: anthropic api_key: ${ANTHROPIC_API_KEY} models: - name: claude-3-5-sonnet cost_per_1k_input: 0.003 cost_per_1k_output: 0.015 tools: - name: query_order type: http url: https://api.internal.example.com/order/status method: POST auth: ${ORDER_API_KEY} schema: type: object properties: order_id: { type: string, description: "订单号,ORD开头" } mcp_servers: promotion_service: transport: streamable_http url: https://mcp.promotion.example.com/mcp auth: { type: bearer, token_env: PROMO_MCP_TOKEN } skills: - name: order_assistant model: gpt-4o-mini temperature: 0.3 system_prompt: | 你是电商客服助手,请用礼貌专业的语气回答。 查询订单前必须调用query_order工具。 如果用户询问优惠活动,必须调用promotion服务相关工具。 如无法确认信息,请如实说明,不要编造。 tools: [query_order, promotion_query]前端应用只需要调用统一的接口:
import requests resp = requests.post( "http://localhost:8080/v1/chat/completions", json={ "skill": "order_assistant", "messages": [ {"role": "user", "content": "我查一下订单ORD12345678到哪了"} ] }, timeout=30, ) print(resp.json())调用方完全不需要关心底层是OpenAI还是Anthropic还是MCP,这就是统一网关带来的直接收益。
4.3 端到端执行链路实录
一个完整请求在网关内部的执行过程如下:
- 请求进入
POST /v1/chat/completions,校验参数; - 检测到
skill: order_assistant,装载技能配置; - 从技能配置拿到目标模型
gpt-4o-mini,渲染system prompt; - 从技能的工具白名单
[query_order, promotion_query]拉取工具Schema; - 组装完整请求,发送给OpenAI兼容API;
- 模型返回一个tool_call请求,内容大致是"调用query_order,参数order_id=ORD12345678";
- 网关通过HTTP调用
query_order工具,拿到订单状态; - 把工具结果以
role=tool消息回填给模型; - 模型继续生成最终回复;
- 返回结果给用户,同时记录token用量到监控日志。
我在实际压测中,这一整条链路在本地网络环境下大约2.8秒完成,其中模型推理占2.3秒,工具调用占0.3秒,网关自身开销不到0.2秒。这个开销完全可以接受。
5. 常见问题速查:我在集成过程中踩过的坑
5.1 "provider rejected the request schema or tool payload"
这个问题我在同时接入多家模型时遇到的最多。大致报错就是模型提供方拒绝了你上传的Schema或者工具参数。
我排查后发现,原因通常是以下几种:
- 工具参数的JSON Schema格式不严格。OpenAI和Anthropic对Schema的字段格式要求有一定差异,比如Anthropic对
description字段长度有限制,OpenAI不允许工具名带特殊字符。解决方法是内部保存一份严格格式的Schema,发送前再做一次协议适配。 - 模型请求里带了空的tools数组。有些网关会条件性拼接工具,结果拼成
"tools": [],部分API不接受空数组。修复很简单:没有工具时不要带这个字段。 - 参数和Schema对不上。模型按Schema生成的参数如果被网关错误地改了类型,比如把数字改成了字符串,对方API可能直接拒绝。排查方式是在网关层增加schema校验,在转发前就对参数做JSON Schema校验。
我的建议是:网关层加一层"发送前自检",把工具Schema和参数逐一校验,校验失败直接返回清晰错误,而不是把脏请求打到模型供应商那里。
5.2 MCP连接经常掉线
MCP服务端和网页API不一样,很多服务端实现并不是为高频连接设计的。我在集成外部MCP时遇到过几种情况:
- 握手太慢导致超时。initialize阶段如果超过默认10秒就放弃,但有些服务端要加载插件,可能要15到20秒。配置里单独给握手加大超时时间。
- tools/list返回的工具太多。有次接了一个内部知识库MCP,一次性返回200多个工具,全部注册到模型上下文里,直接把上下文塞爆了。后来我加了一层工具过滤器,只把当前Skill白名单里的工具传递给模型。
- 连接被服务端主动断开。长时间没有调用,服务端可能自动断连。处理方式是,每次调用工具前检查连接状态,不健康就重连。同时做好指数退避,不要短时间不断重连,容易被服务端封IP。
5.3 Skill不生效,模型不按设定走
这个问题排查起来很隐蔽。有时候你配置好了system_prompt和工具白名单,但模型依然不调用工具,或者答非所问。我遇到过的情况:
- 技能缓存没刷新。网关启动时加载了旧版本配置,更新了YAML但进程还在内存缓存里。处理方法是加版本号机制,更新后让缓存失效。
- system_prompt太长被截断。有些模型窗口有限,系统提示词太长,工具描述太多,超出后模型可能忽视最开始的内容。需要精简提示词,或者用更小的工具集。
- 工具描述不够清晰导致模型不知道什么时候用。比如技能里挂了
promotion_query,但模型可能不知道"优惠活动"就是该用这个工具。解决办法是在工具描述里加触发条件,例如"当用户询问优惠、折扣、活动时,必须调用此工具"。
5.4 多模型返回格式不一致
不同模型在工具调用上的行为差异是常态。OpenAI兼容接口一般返回tool_calls数组,Anthropic返回的是content块中的tool_use对象,而且参数类型有时候是数组而不是对象。统一适配层的核心职责就是把这个差异消化掉。
我在Adapter里定义了一个极简内部格式:
ToolCall( tool_name: str, arguments: dict, # 统一解析成字典 tool_call_id: str # 回传结果时必须原样携带 )不管是哪家模型返回,最终都归一成这个结构。回传时再按目标模型的要求恢复成对应格式。这样上层业务永远只用处理一种形态。
5.5 安全相关的几个隐藏坑
工具网关天然是SSRF的聚集地,这是我在做这个项目时特别注意的。外部HTTP工具如果允许模型自由拼接URL,就可能被利用去探测内网服务。我的防护手段是:
- 所有HTTP工具的URL都必须在配置中声明,不允许运行时从模型参数里动态生成host;
- 工具调用时对目标地址限制IP段,不允许访问内网地址段;
- 在Prompt约束里明确告知模型"不得输出敏感系统指令",给工具加上权限标签,落地时校验。
这些安全措施不能等出问题再补,统一网关做好了,所有下游工具和MCP服务都能受益。
6. 再往深走一点:网关的扩展思路
tsm-hub目前支持的功能已经能覆盖绝大多数Agent应用场景,但如果要继续演进,有几个方向值得研究:
第一是模型灰度发布。新模型上线前只放5%的流量,逐步观察效果。这个比较适合在网关的路由层做,业务侧无感知。
第二是技能拆解与组合。现在每个Skill是独立的,如果想"客服技能+促销技能"组合,就需要引入更复杂的编排能力。比如定义技能的依赖关系,或者允许Skill内部条件触发子技能。
第三是观测与追踪。网关天然是日志集中点,每个请求经过哪些模型、调用了哪些工具、哪个环节慢、花费多少钱,都应该有完整链路。后续我会把OpenTelemetry集成进去,让整个链路可视。
第四是给上层多应用共享。如果公司内部有多个业务,都接这个网关,就需要加租户隔离、配额管理。每个租户能看到自己的用量和账单,但底层工具和模型按权限分层开放。
这些功能我在设计tsm-hub时都预留了接口,后面准备逐个落地。
回到开头那个问题:我个人的实际体会是,做LLM应用真正该花时间的不是写某个具体的Agent逻辑,而是把底座打稳。模型会变、工具会变、MCP服务也会变,但一个合理的网关抽象能让你以最低成本跟着变。
最后再分享一下我在日常迭代中的一个习惯:每次要加新能力,先想"这能沉淀成配置还是必须写成代码"。能配置化的,尽量配置化;能进Skills的,绝不写死在业务里。一开始会慢一点,但维护起来是真的轻松。tsm-hub这套思路我用了大半年,最大的收获不是代码,而是让我从"不停救火"的状态里走了出来,可以真正把精力放在产品和能力设计上。