1. 为什么要把三个大模型塞进同一个工作台
我平时写代码、查资料、做技术方案,手头同时开着 DeepSeek、Qwen、GLM 三个网页标签是常态。DeepSeek 推理强、写代码逻辑清晰,Qwen 中文理解细腻、长文本处理稳,GLM 在工具调用和结构化输出上有自己的优势。问题是每次切换都要重新登录、重新贴上下文,一天下来光在三个页面之间复制粘贴就能耗掉半小时。
后来我琢磨着搭一个统一的 AI 工作台,把这三个模型的 API 全部接进来,用一个界面调度。核心诉求很简单:同一个输入框,能自由切换模型,或者让多个模型同时回答同一个问题做对比。听起来像是要写不少代码,但实际动手之后发现,真正需要改的配置只有两行——一行是模型列表的注册,一行是路由分发的映射。剩下的工作,都是围绕这两行配置做的外围适配。
这篇文章适合两类人看:一类是手头有多个模型 API Key、想统一管理但不想折腾复杂框架的开发者;另一类是刚接触多模型调度、想找一个最小可行方案入门的同学。我会把整个搭建过程拆开讲清楚,包括为什么这么选、每一步在做什么、哪些地方容易踩坑。代码量不大,但背后的思路值得说透。
提示:本文涉及的所有 API 调用均基于各平台公开的标准接口规范,不涉及任何非官方渠道。请确保你的使用方式符合各平台的服务条款。
2. 工作台的整体设计与技术选型
2.1 核心架构:一个路由层加三个适配器
整个工作台的结构可以类比成一个"翻译中转站"。用户在前端输入一句话,路由层负责判断这句话该发给哪个模型,适配器负责把统一的请求格式翻译成各个模型 API 能听懂的格式,拿到响应之后再翻译回统一格式返回给前端。
为什么要在中间加一层适配器,而不是直接在前端调三个不同的 API?原因有三个。第一,前端不需要知道每个模型的 API 长什么样,切换模型只是换一个参数值,不用改调用逻辑。第二,后续加新模型只需要写一个新的适配器,前端代码一行不用动。第三,统一格式之后可以做很多有意思的事情,比如同时发给三个模型做结果对比、根据问题类型自动路由到最合适的模型、做失败重试和降级。
适配器模式在这里的价值,就像旅行时带了一个万能转换插头。你不用管每个国家的插座是什么形状,只需要把插头换成对应的头子就行。
2.2 技术栈选择:为什么不用现成的框架
市面上确实有一些多模型调度的开源框架,功能很全,但我最后选择了自己用 Node.js 写一个轻量级的服务。原因很实际:现成框架的抽象层太厚,出了问题排查起来很痛苦。我之前用过一个框架,一个简单的 API 调用报错,追了五层封装才找到真正的原因——是某个模型对temperature参数的取值范围要求不同。
自己写的好处是每一行代码都在掌控之中。整个服务核心代码不到 200 行,用 Express 做 HTTP 服务,用原生的fetch发请求,不引入任何额外的 HTTP 客户端库。Node.js 版本建议用 18 以上,因为 18 开始原生支持fetch,不需要再装node-fetch或者axios。
如果你更熟悉 Python,用 FastAPI 也是完全一样的效果,思路不变。我选 Node.js 纯粹是因为前端也是 JS,前后端可以共享一些类型定义和工具函数,省得两边维护两套。
2.3 配置文件的设计:两行核心配置长什么样
标题里说的"只改两行配置",具体是指这两个地方。
第一行是模型注册表,定义有哪些模型可用、各自的 API 地址和 Key 从哪里读:
const MODELS = { deepseek: { baseUrl: 'https://api.deepseek.com/v1', key: process.env.DEEPSEEK_KEY, model: 'deepseek-chat' }, qwen: { baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', key: process.env.QWEN_KEY, model: 'qwen-plus' }, glm: { baseUrl: 'https://open.bigmodel.cn/api/paas/v4', key: process.env.GLM_KEY, model: 'glm-4' }, };第二行是路由映射表,定义什么情况下用哪个模型:
const ROUTE_MAP = { code: 'deepseek', longtext: 'qwen', tool: 'glm', default: 'deepseek' };这两行配置之所以能撑起整个工作台,是因为它们把"有哪些模型"和"怎么选模型"这两个最常变的东西抽出来了。加一个新模型,在MODELS里加一行;调整路由策略,改ROUTE_MAP里的映射关系。其他代码都是通用的,不需要动。
注意:API Key 千万不要硬编码在代码里,也不要在前端暴露。用环境变量读取是最基本的做法,如果部署在服务器上,建议配合密钥管理服务使用。
3. 核心细节解析与实操要点
3.1 统一请求格式的设计
三个模型的 API 虽然都兼容 OpenAI 的接口规范,但细节上还是有差异。比如 DeepSeek 的deepseek-chat模型对temperature的默认值是 1.0,Qwen 的qwen-plus默认是 0.7,GLM 的glm-4默认是 0.95。如果不做统一,前端每次切换模型都要手动调整参数,体验很差。
我的做法是定义一个内部的统一请求格式,然后在适配器里做转换:
// 统一请求格式 { model: 'deepseek', // 目标模型标识 messages: [...], // 对话历史 temperature: 0.7, // 统一默认值 max_tokens: 2048, stream: false }适配器拿到这个格式之后,根据目标模型做三件事:替换model字段为各平台的实际模型名、补充各平台特有的必填参数、调整参数取值范围。比如 GLM 的max_tokens上限和其他两家不同,如果用户传了一个超限的值,适配器会自动截断到上限并记录一条警告日志。
这里有个容易忽略的点:消息角色的命名。OpenAI 规范里角色是system、user、assistant,但有些模型对system消息的位置有要求,必须放在最前面。适配器里加一个简单的排序逻辑就能解决,成本很低但能避免很多莫名其妙的报错。
3.2 流式输出的统一处理
流式输出是多模型工作台体验的关键。三个模型都支持 SSE 格式的流式返回,但事件格式有细微差别。DeepSeek 和 Qwen 的格式基本一致,GLM 在某些情况下会多返回一个usage字段。
处理流式响应的核心逻辑是:逐块读取、解析 JSON、提取增量内容、转发给前端。这里要注意的是,SSE 的数据块不一定按行完整到达,可能一个 JSON 对象被拆成两个 TCP 包。所以不能简单地按行分割,要维护一个缓冲区,遇到不完整的数据先存着,等下一个块到了再拼接。
let buffer = ''; for await (const chunk of response.body) { buffer += chunk.toString(); const lines = buffer.split('\n'); buffer = lines.pop(); // 最后一行可能不完整,留到下次 for (const line of lines) { if (line.startsWith('data: ')) { const data = line.slice(6); if (data === '[DONE]') continue; try { const parsed = JSON.parse(data); const content = parsed.choices?.[0]?.delta?.content; if (content) res.write(`data: ${JSON.stringify({ content })}\n\n`); } catch (e) { /* 忽略解析失败的不完整块 */ } } } }这段代码我调了大概两个小时才稳定下来。最开始没加缓冲区,偶尔会出现 JSON 解析失败导致整个流中断的情况。加了缓冲区之后,连续跑了几天没有再出现类似问题。
3.3 错误处理与降级策略
多模型工作台的一个隐藏价值是可以做自动降级。比如你默认用 DeepSeek,但它偶尔会因为负载高返回 503,这时候自动切换到 Qwen 重试,用户几乎无感知。
实现思路是在适配器外面包一层重试逻辑:
async function callWithFallback(modelId, request, retries = 2) { const fallbackChain = [modelId, ...ROUTE_MAP.fallback]; for (const id of fallbackChain) { try { return await adapters[id].call(request); } catch (err) { if (err.status === 429 || err.status >= 500) continue; throw err; // 参数错误之类的直接抛,不重试 } } throw new Error('所有模型均不可用'); }这里的关键判断是哪些错误值得重试。429(限流)和 5xx(服务端错误)值得换模型重试,400(参数错误)和 401(鉴权失败)换模型也没用,直接抛给用户更快。这个区分能避免无意义的等待。
提示:降级链不要设太长,两到三个就够了。链太长会导致用户等待时间过久,体验反而更差。
4. 完整实操流程与关键环节实现
4.1 环境准备与项目初始化
先把基础环境搭起来。Node.js 建议用 18 LTS 或更高版本,用nvm管理版本最省心。初始化项目:
mkdir ai-workbench && cd ai-workbench npm init -y npm install express dotenv只需要两个依赖:express做 HTTP 服务,dotenv读取环境变量。不需要装 CORS 中间件,因为前端我直接用一个静态 HTML 文件,同源部署,省去跨域配置的麻烦。
创建.env文件存放密钥:
DEEPSEEK_KEY=sk-xxxxxxxx QWEN_KEY=sk-xxxxxxxx GLM_KEY=xxxxxxxx.xxxxxxxx PORT=3000.env文件一定要加到.gitignore里,这个不用多说了。我见过不止一个项目因为把 Key 提交到仓库导致被盗刷的案例。
4.2 适配器层的具体实现
适配器层的核心是一个统一的接口定义,每个模型实现自己的call方法。以 DeepSeek 为例:
const deepseekAdapter = { async call(req) { const body = { model: MODELS.deepseek.model, messages: normalizeMessages(req.messages), temperature: clamp(req.temperature ?? 0.7, 0, 2), max_tokens: Math.min(req.max_tokens ?? 2048, 8192), stream: req.stream ?? false, }; const resp = await fetch(`${MODELS.deepseek.baseUrl}/chat/completions`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${MODELS.deepseek.key}`, }, body: JSON.stringify(body), }); if (!resp.ok) { const err = new Error(`DeepSeek API error: ${resp.status}`); err.status = resp.status; throw err; } return resp; } };Qwen 和 GLM 的适配器结构完全一样,区别只在baseUrl、model名称和参数范围。Qwen 的temperature范围是 0 到 2,GLM 是 0 到 1,所以clamp函数需要根据模型动态调整上限。这个细节如果不处理,传一个 1.5 给 GLM 会直接报参数错误。
normalizeMessages函数负责把消息数组标准化:确保system消息在最前面、过滤掉空内容的消息、把过长的历史消息做截断。截断策略我采用的是保留最近 10 轮对话加上第一条system消息,超出部分丢弃。这个策略对大多数场景够用了,如果做长文档问答需要另外处理。
4.3 路由层的分发逻辑
路由层要做的事情是:根据请求内容判断该用哪个模型。最简单的实现是让用户手动指定,但既然叫"工作台",自动路由才是加分项。
我的路由策略基于关键词匹配加规则判断:
function routeRequest(messages) { const lastUserMsg = messages.filter(m => m.role === 'user').pop()?.content || ''; if (/代码|函数|debug|报错|实现/.test(lastUserMsg)) return 'deepseek'; if (/总结|翻译|改写|长文/.test(lastUserMsg)) return 'qwen'; if (/调用|工具|结构化|JSON/.test(lastUserMsg)) return 'glm'; return ROUTE_MAP.default; }这个规则很粗糙,但实际用下来命中率还不错。因为大多数时候用户的问题类型是明确的——写代码就是写代码,做总结就是做总结。如果你想要更智能的路由,可以先用一个轻量模型做意图分类,再分发到对应的模型。不过那样会增加一次 API 调用,延迟会上去,看你怎么权衡。
路由结果会返回给前端显示,用户可以手动覆盖。这个设计很重要——自动路由是辅助,最终决定权在用户手里。我自己的习惯是默认自动,但遇到重要问题会手动切到最信任的模型再问一遍。
4.4 前端界面的最小实现
前端我用的就是一个单 HTML 文件,没有上框架。核心是一个输入框、一个模型选择下拉、一个对话展示区。模型选择下拉里除了三个具体模型,还有一个"自动"选项,对应路由层的自动判断。
发送请求的代码很直接:
async function send() { const model = document.getElementById('model-select').value; const input = document.getElementById('input').value; const resp = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, messages: [...history, { role: 'user', content: input }] }), }); // 处理流式响应... }流式响应的前端处理用ReadableStream逐块读取,每读到一个增量就追加到对话区。这里有个体验优化点:在等待第一个 token 到达之前显示一个加载指示器,因为有些模型的首 token 延迟能到两三秒,没有反馈用户会以为卡死了。
5. 常见问题与排查技巧实录
5.1 模型返回内容被截断怎么办
这是最常见的问题。三个模型的max_tokens默认值不同,如果不显式设置,DeepSeek 默认可能只返回 1024 个 token,长回答会被硬截断。
排查方法:在适配器里打印每次请求的实际max_tokens值和响应的finish_reason。如果finish_reason是length,说明被截断了,需要调大max_tokens。但注意不要无脑调到最大,因为max_tokens越大,计费越高,而且有些模型对超长输出有额外的限制。
我的做法是设置一个合理的默认值(2048),然后在系统提示里告诉模型"如果回答较长请分段输出"。这样即使单次被截断,用户也能通过"继续"指令拿到后续内容。
5.2 切换模型后上下文丢失
这个问题的根源在于不同模型的上下文窗口大小不同。Qwen 的qwen-plus支持 128K 上下文,DeepSeek 的deepseek-chat是 64K,GLM 的glm-4是 128K。如果你在 Qwen 里聊了很长一段,切到 DeepSeek 时历史消息可能超出窗口限制。
解决方案是在适配器里做上下文裁剪:根据目标模型的窗口大小,从最近的消息开始往前保留,直到接近窗口上限。裁剪时要注意保留第一条system消息,否则模型的角色设定会丢失。
function trimContext(messages, maxTokens) { const system = messages.find(m => m.role === 'system'); const rest = messages.filter(m => m.role !== 'system'); let total = estimateTokens(system?.content || ''); const kept = []; for (let i = rest.length - 1; i >= 0; i--) { const t = estimateTokens(rest[i].content); if (total + t > maxTokens * 0.8) break; // 留 20% 余量给输出 total += t; kept.unshift(rest[i]); } return system ? [system, ...kept] : kept; }estimateTokens用一个粗略的估算就行——中文大约 1 个字 1.5 个 token,英文大约 4 个字符 1 个 token。不需要精确,留足余量即可。
5.3 流式响应中途断开
流式响应断开的原因通常有三个:网络抖动、模型服务端超时、前端处理逻辑有 bug。排查顺序建议从后往前。
先看前端:打开浏览器开发者工具的 Network 面板,看 SSE 连接是什么时候断的。如果是前端主动断的,检查ReadableStream的读取循环有没有异常退出。再看服务端:在适配器的流处理循环里加日志,看是哪个环节停止的。最后才怀疑网络和服务端。
我遇到过一次是服务端的res.write在客户端断开后继续写入导致报错,加一个res.on('close')监听就能解决:
res.on('close', () => { abortController.abort(); // 取消上游请求 });这个细节不做的话,客户端断开后上游请求还在跑,白白消耗 token 额度。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 返回内容不完整 | max_tokens 设置过小 | 检查 finish_reason 是否为 length | 调大 max_tokens 或分段输出 |
| 切换模型后报错 | 上下文超出目标模型窗口 | 打印消息总 token 估算值 | 实现上下文裁剪逻辑 |
| 流式响应中断 | 网络抖动或前端处理异常 | 查看 Network 面板和错误日志 | 加断线重连和 abort 处理 |
| 429 限流 | 请求频率过高 | 查看响应头中的限流信息 | 加请求队列和指数退避重试 |
| 参数错误 400 | 参数超出模型取值范围 | 对比各模型参数文档 | 适配器里做参数 clamp |
| 鉴权失败 401 | Key 过期或格式错误 | 检查环境变量是否正确加载 | 重新生成 Key 并更新 .env |
注意:排查问题时建议先在适配器里加详细的请求和响应日志,定位到具体是哪个环节出的问题再动手改。盲目改代码只会引入新问题。
6. 几个提升体验的进阶技巧
6.1 多模型并行对比
工作台搭好之后,我加了一个"对比模式":同一个问题同时发给三个模型,结果并排展示。这个功能在选型阶段特别有用——同一个技术问题,看三个模型的回答,很快就能判断出哪个更适合你的场景。
实现上就是把单次调用改成Promise.allSettled并发调用三个适配器,然后分别渲染。用allSettled而不是all是因为要允许部分失败——某个模型挂了不影响其他两个的结果展示。
6.2 对话历史的持久化
默认情况下对话历史存在内存里,服务重启就没了。加一个简单的 JSON 文件持久化就能解决:每次对话结束后把消息数组写入文件,启动时读取。数据量不大,不需要上数据库。
文件按会话 ID 分目录存储,每个会话一个 JSON 文件。清理策略是保留最近 30 天的会话,超期的自动删除。这个用fs模块加一个定时任务就能实现,代码量很少。
6.3 成本监控
三个模型的计费方式不同,DeepSeek 按 token 计费,Qwen 有免费额度加超出计费,GLM 也是按 token。如果不做监控,月底账单出来可能会吓一跳。
我在适配器里记录每次调用的 token 消耗,按模型和日期聚合,提供一个简单的统计接口。前端加一个页面展示当日和当月的消耗趋势。这个功能不复杂,但能帮你及时发现异常调用——比如某个循环 bug 导致重复请求。
7. 我在实际使用中踩过的坑
第一个坑是环境变量加载顺序。dotenv必须在其他模块之前加载,否则适配器初始化时读到的 Key 是undefined。我一开始把require('dotenv').config()放在了路由定义之后,结果所有请求都返回 401,排查了半小时才发现是加载顺序问题。
第二个坑是GLM 的鉴权头格式。DeepSeek 和 Qwen 都是标准的Bearer格式,GLM 虽然也兼容,但它的 Key 格式是id.secret的形式,某些情况下需要先做一次 token 交换。我用的glm-4模型直接传 Key 就能用,但如果你用的是其他模型,可能需要额外处理。
第三个坑是流式响应的编码问题。Node.js 的fetch返回的response.body是ReadableStream,直接toString()在某些 Node 版本上会得到[object ReadableStream]。需要用for await...of迭代或者用TextDecoder手动解码。这个坑在 Node 18 和 20 上的表现还不一样,建议锁定 Node 版本。
最后一个经验是:不要追求一次把所有功能做完。我最开始想做一个功能完整的工作台,结果拖了两周还没上线。后来砍到最小可用版本——只有三个模型、一个输入框、一个切换下拉——半天就跑起来了。后面加对比模式、成本监控、历史持久化,都是在这个基础上逐步迭代的。先跑起来,再优化,这个顺序不能反。