
1. 为什么要把三个模型塞进同一个工作台我平时的工作流里模型切换这件事一直很烦。写代码的时候习惯用 DeepSeek 做推理和补全写文档、整理会议纪要的时候更偏向 Qwen 的中文语感遇到需要长上下文、结构化输出的任务又会切到 GLM。问题是每换一个模型就要换一个客户端、换一套 API Key、换一种对话历史管理方式一天下来光是在不同窗口之间复制粘贴就能耗掉不少精力。后来我干脆花了一个周末把这三个模型统一接进了一个自建的 AI 工作台。整个过程比想象中简单得多核心改动其实就集中在两行配置上——一行是模型路由的映射表一行是统一请求格式的适配层。做完之后的效果是同一个输入框下拉菜单里选模型历史记录统一管理Prompt 模板共享再也不用在三个客户端之间来回跳。这篇文章适合两类人看。一类是已经在自己电脑上跑过至少一个本地或云端大模型、想进一步做统一管理的开发者另一类是团队里负责搭内部工具的人想让同事用一个入口就能访问多个模型。如果你完全没接触过 API 调用建议先花半小时把最基础的 HTTP 请求和 JSON 格式搞清楚后面的内容会顺很多。需要提前说明的是我这里讲的“工作台”不是什么商业产品就是一个自己搭的轻量级 Web 界面加一层后端转发。技术栈用的是 Node.js 做网关、前端一个简单的聊天页面数据库用 SQLite 存对话历史。整套东西跑在一台普通开发机上完全够用不需要什么高端显卡——因为三个模型我都是走 API 调用的本地只负责转发和界面。提示本文所有配置示例都基于公开的 API 接口规范具体参数请以各平台最新文档为准。涉及密钥的部分请务必放在环境变量里不要硬编码进代码。2. 整体架构设计与选型思路2.1 为什么不做本地部署而是走 API一开始我也考虑过本地部署。Qwen 有开源版本可以下下来跑DeepSeek 也有本地部署方案GLM 同样有可获取的权重。但实际算了一笔账之后我放弃了这条路。本地部署三个模型光是显存需求就很吓人。Qwen 一个 7B 级别的模型量化之后大概需要 6 到 8GB 显存DeepSeek 的推理模型参数量更大即便量化后也要 10GB 以上GLM 系列同样不是省油的灯。三个模型如果都要常驻内存没有 24GB 以上的显存根本转不动。而且本地部署还涉及模型加载、推理框架选型、版本更新维护这一堆事我一个人的精力根本顾不过来。走 API 就简单多了。三个平台各自提供标准的 HTTP 接口我只需要在网关层做统一的请求转发和响应解析。成本方面日常使用量不大的话每个月的 API 费用比升级一张显卡便宜得多。响应速度也稳定不用担心本地机器跑满之后风扇狂转的问题。当然走 API 也有代价。网络延迟是客观存在的而且数据要经过第三方服务器。如果你的场景对数据隐私要求极高那本地部署是唯一选择。但对我这种个人开发者来说API 方案的性价比明显更高。2.2 网关层的核心职责整个工作台的架构可以拆成三层前端界面、网关层、模型 API。前端界面负责展示对话、管理历史记录、提供模型选择下拉框。这部分我用了一个开源的聊天 UI 模板改的没什么技术含量重点是把模型选择的状态传给后端。网关层是整个系统的核心。它要做的事情包括接收前端发来的统一格式请求、根据模型标识路由到对应的 API 端点、把统一格式转换成各平台要求的请求体、调用 API、再把各平台返回的响应转换成统一格式传回前端。此外还要处理错误重试、超时控制、Token 计数这些杂事。模型 API 层就是三个平台各自的服务端点。它们的接口风格有相似之处但细节差异不少。比如请求体的字段名、消息角色的定义、流式输出的格式每家都有自己的规矩。2.3 两行核心配置到底改了什么标题里说的“只改了两行配置”指的是网关层里的两个关键映射。第一行是模型路由表。它定义了前端传来的模型标识和实际 API 端点之间的对应关系。比如前端传deepseek-chat网关就知道要去调 DeepSeek 的对话接口传qwen-plus就路由到 Qwen 的接口传glm-4就转发到 GLM 的端点。这张表用 JSON 配置加新模型的时候只需要加一行。第二行是请求格式适配规则。三个平台的请求体结构大同小异但字段名不一样。有的用messages数组有的用prompt字符串有的把系统提示放在system角色里有的用单独的system字段。适配层的作用就是把统一的内部格式转换成各平台能识别的格式。这部分的配置也是一行一个模型声明字段映射关系即可。这两行配置之所以能撑起整个工作台是因为它们把“变化的部分”隔离出来了。模型可以随时增删接口格式可以随时调整但核心的转发逻辑和前端界面完全不用动。这就是配置驱动设计的好处。3. 核心细节解析与实操要点3.1 统一请求格式的设计要让三个模型共用一个前端第一步是定义一套内部统一的请求格式。我设计的格式是这样的{ model: deepseek-chat, messages: [ {role: system, content: 你是一个助手}, {role: user, content: 你好} ], stream: true, temperature: 0.7, max_tokens: 2048 }这个格式参考了主流对话接口的设计字段名尽量通用。model字段用来标识要调用哪个模型messages是对话历史数组stream控制是否流式输出后面两个是生成参数。前端只需要按这个格式发请求网关负责把它翻译成各平台能懂的格式。这样做的好处是前端逻辑极其简单加新模型的时候前端一行代码都不用改。注意max_tokens这个字段在不同平台上的含义可能略有差异。有的平台指的是“输入加输出的总长度”有的只算“输出长度”。配置的时候要仔细看文档否则容易出现请求被截断的情况。3.2 三个平台的请求格式差异DeepSeek 的接口格式和 OpenAI 的风格非常接近messages数组、role字段、content字段都一致。系统提示直接放在messages里用system角色即可。流式输出的格式也是标准的 SSE每个数据块里带一个delta对象。Qwen 的接口同样兼容 OpenAI 风格但在参数命名上有一些自己的习惯。比如它可能用top_p而不是topP用enable_search这样的扩展字段来控制联网搜索。系统提示的处理方式基本一致但部分版本对system角色的支持程度不同需要实测确认。GLM 的接口在消息格式上和前两家类似但它的认证方式用的是自己的 Token 生成机制不是简单的 Bearer Token。请求头里需要带一个经过签名的 JWT这个签名过程要用到 API Key 里的两部分信息。这是三个平台里认证最复杂的一个也是适配层需要特殊处理的地方。平台认证方式消息格式流式输出特殊字段DeepSeekBearer Tokenmessages 数组SSE无QwenBearer Tokenmessages 数组SSEenable_searchGLMJWT 签名messages 数组SSE需生成 Token3.3 适配层的实现要点适配层的核心是一个转换函数输入是统一格式输出是各平台格式。我用了一个配置对象来描述每个平台的转换规则const adapters { deepseek: { endpoint: https://api.deepseek.com/v1/chat/completions, buildBody: (req) ({ model: deepseek-chat, messages: req.messages, stream: req.stream, temperature: req.temperature, max_tokens: req.max_tokens }), buildHeaders: (key) ({ Authorization: Bearer ${key}, Content-Type: application/json }) }, qwen: { endpoint: https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions, buildBody: (req) ({ model: qwen-plus, messages: req.messages, stream: req.stream, temperature: req.temperature, max_tokens: req.max_tokens }), buildHeaders: (key) ({ Authorization: Bearer ${key}, Content-Type: application/json }) }, glm: { endpoint: https://open.bigmodel.cn/api/paas/v4/chat/completions, buildBody: (req) ({ model: glm-4, messages: req.messages, stream: req.stream, temperature: req.temperature, max_tokens: req.max_tokens }), buildHeaders: (key) ({ Authorization: Bearer ${generateGLMToken(key)}, Content-Type: application/json }) } };每个适配器负责三件事声明端点地址、构造请求体、构造请求头。GLM 的请求头需要动态生成 Token所以单独写了一个函数。这个结构的好处是加新模型只需要在adapters对象里加一个条目其他代码完全不用动。这就是我说的“一行配置”的实际含义——虽然严格来说不止一行但核心改动确实集中在这一个地方。3.4 流式输出的统一处理三个平台都支持流式输出但返回的数据块格式有细微差别。DeepSeek 和 Qwen 的格式基本一致每个 SSE 数据块里有一个choices数组数组元素的delta字段里带content。GLM 的格式类似但字段层级可能略有不同。网关层需要把这些差异抹平统一转换成前端能识别的格式。我的做法是在网关里定义一个标准的流式事件格式// 统一后的流式事件 { type: content, data: 生成的文本片段 }网关收到各平台的原始数据块后提取出文本内容包装成这个格式再发给前端。前端只需要监听content类型的事件把data追加到当前消息后面即可。这样做还有一个好处如果某个平台的流式格式变了只需要改网关里的解析逻辑前端完全不受影响。4. 实操过程与核心环节实现4.1 环境准备与依赖安装整套系统跑在 Node.js 环境上版本建议用 18 以上的 LTS 版本。如果你还没装 Node.js去官网下载安装包一路下一步就行。装完之后在终端里跑node -v确认版本号。项目初始化很简单mkdir ai-workbench cd ai-workbench npm init -y npm install express axios dotenv cors这里用到的几个依赖各有用途。express提供 HTTP 服务axios用来发 API 请求dotenv管理环境变量cors处理跨域。都是很成熟的库没什么坑。环境变量文件.env里放三个平台的密钥DEEPSEEK_API_KEY你的密钥 QWEN_API_KEY你的密钥 GLM_API_KEY你的密钥 PORT3000注意.env文件一定要加到.gitignore里千万别提交到代码仓库。我见过太多人因为把密钥推到公开仓库导致被盗刷的案例。4.2 网关服务的核心代码网关服务的主文件大概一百多行核心逻辑就是接收请求、查适配器、转发、返回。我把它拆成了几个模块主入口负责路由和中间件适配器单独放一个文件。主入口的关键代码const express require(express); const axios require(axios); const { adapters } require(./adapters); require(dotenv).config(); const app express(); app.use(express.json()); app.use(require(cors)()); app.post(/api/chat, async (req, res) { const { model } req.body; const adapter adapters[model]; if (!adapter) { return res.status(400).json({ error: 未知模型: model }); } const apiKey process.env[adapter.keyEnv]; const body adapter.buildBody(req.body); const headers adapter.buildHeaders(apiKey); try { if (req.body.stream) { res.setHeader(Content-Type, text/event-stream); res.setHeader(Cache-Control, no-cache); res.setHeader(Connection, keep-alive); const response await axios.post(adapter.endpoint, body, { headers, responseType: stream, timeout: 60000 }); response.data.on(data, (chunk) { const text adapter.parseChunk(chunk); if (text) { res.write(data: ${JSON.stringify({ type: content, data: text })}\n\n); } }); response.data.on(end, () { res.write(data: [DONE]\n\n); res.end(); }); } else { const response await axios.post(adapter.endpoint, body, { headers, timeout: 60000 }); const text adapter.parseResponse(response.data); res.json({ type: content, data: text }); } } catch (err) { console.error(请求失败:, err.message); res.status(500).json({ error: err.message }); } }); app.listen(process.env.PORT || 3000, () { console.log(工作台已启动); });这段代码里有两个关键点。一是流式和非流式走不同的分支流式用responseType: stream拿到原始数据流然后逐块解析。二是每个适配器都要实现parseChunk和parseResponse两个方法分别处理流式和非流式的响应解析。4.3 各平台适配器的完整实现适配器文件里每个平台一个对象。DeepSeek 和 Qwen 的实现比较直接GLM 需要额外处理 Token 生成。GLM 的 Token 生成逻辑是这样的把 API Key 按点号拆成两部分第一部分是 id第二部分是 secret。然后用 HMAC-SHA256 对一段包含时间戳的 payload 做签名最后把 id、时间戳、签名拼成一个 JWT。这个过程用 Node.js 内置的crypto模块就能完成不需要额外装库。const crypto require(crypto); function generateGLMToken(apiKey) { const [id, secret] apiKey.split(.); const now Date.now(); const payload { api_key: id, exp: now 3600 * 1000, timestamp: now }; const header { alg: HS256, sign_type: SIGN }; const encodedHeader Buffer.from(JSON.stringify(header)).toString(base64url); const encodedPayload Buffer.from(JSON.stringify(payload)).toString(base64url); const signature crypto .createHmac(sha256, secret) .update(${encodedHeader}.${encodedPayload}) .digest(base64url); return ${encodedHeader}.${encodedPayload}.${signature}; }这个函数每次请求前调用一次生成的 Token 有效期一小时。实际使用中可以在网关层做个缓存避免每次请求都重新计算。流式解析方面三个平台的 SSE 数据块格式略有不同。DeepSeek 和 Qwen 的数据块里文本内容在choices[0].delta.content。GLM 的格式类似但有时候会在choices[0].delta里直接放content字段。解析的时候要做兼容处理先尝试取delta.content取不到再尝试其他路径。4.4 前端界面的最小实现前端我没花太多心思就是一个简单的聊天页面。核心是一个下拉框选择模型一个消息列表展示对话一个输入框发送消息。发送消息的时候前端把模型标识和对话历史打包成统一格式POST 到网关的/api/chat接口。如果开启了流式就用fetch的ReadableStream逐块读取响应把文本追加到当前消息后面。async function sendMessage(model, messages) { const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ model, messages, stream: true, temperature: 0.7, max_tokens: 2048 }) }); const reader response.body.getReader(); const decoder new TextDecoder(); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n\n); buffer lines.pop(); for (const line of lines) { if (line.startsWith(data: )) { const data line.slice(6); if (data [DONE]) return; const event JSON.parse(data); if (event.type content) { appendToCurrentMessage(event.data); } } } } }这段代码处理了 SSE 的缓冲问题。因为网络传输是分块的一个完整的事件可能被拆到两个 chunk 里所以要用一个 buffer 来暂存不完整的数据等下一个 chunk 到了再拼接。4.5 对话历史的存储与管理对话历史我用 SQLite 存表结构很简单一个会话表一个消息表。会话表记录会话 ID、使用的模型、创建时间消息表记录消息 ID、所属会话、角色、内容、时间戳。每次用户发消息先把用户消息存进去等模型回复完成后再把助手消息存进去。加载会话的时候按时间顺序把消息查出来组装成messages数组传给模型。这里有个细节要注意不同模型的上下文窗口大小不一样。Qwen 的某些版本支持很长的上下文DeepSeek 和 GLM 各有各的限制。如果对话历史太长超出模型窗口的部分会被截断。我的做法是在网关层做一个简单的 Token 估算超过阈值就从最早的消息开始丢弃但保留系统提示。提示Token 估算不需要非常精确按字符数除以 2 粗略估计就够了。中文一个字大约对应 1 到 2 个 Token英文一个单词大约 1 到 1.5 个 Token。留出 20% 的余量比较稳妥。5. 常见问题与排查技巧实录5.1 认证失败的各种姿势三个平台里GLM 的认证是最容易出问题的。常见错误包括API Key 格式不对导致拆分失败、时间戳偏差太大导致签名过期、签名算法用错导致校验不通过。排查的时候先把生成的 Token 打印出来用在线的 JWT 解析工具看看结构对不对。然后检查时间戳是不是当前时间单位是毫秒还是秒。GLM 用的是毫秒如果你传了秒级时间戳签名会直接失效。DeepSeek 和 Qwen 的认证相对简单就是标准的 Bearer Token。如果报 401先检查 Key 有没有复制错前后有没有多余空格。然后确认 Key 有没有过期或者被禁用。错误码可能原因排查方向401认证失败检查 Key 格式、有效期、请求头字段名403权限不足确认账号是否开通了对应模型的权限429请求过频降低并发数加请求间隔500服务端错误稍后重试检查请求体格式超时网络或模型负载高增加超时时间检查网络连通性5.2 流式输出中断的处理流式输出最烦的问题是中途断掉。可能的原因有几个网络抖动导致连接断开、模型生成时间过长触发超时、网关层的缓冲区满了。我的处理策略是加一个心跳机制。网关每隔 15 秒往客户端发一个空注释行保持连接活跃。同时设置一个总超时时间比如 120 秒超过就主动断开并返回已生成的部分内容。前端也要做容错。如果流式读取过程中出错把已经收到的内容保留下来提示用户“生成中断已保留部分内容”而不是直接清空。5.3 模型响应格式不一致的兼容虽然三个平台都号称兼容 OpenAI 格式但实际用下来还是有不少差异。比如有的平台在流式输出的最后一个数据块里会带usage字段有的不会有的平台在非流式响应里把内容放在choices[0].message.content有的放在output.text。我的做法是在适配器的parseResponse方法里做兼容处理用可选链和默认值兜底parseResponse: (data) { return data?.choices?.[0]?.message?.content || data?.output?.text || data?.result || ; }这样即使某个平台改了字段名只要还有一条路径能取到内容就不会完全挂掉。当然最稳妥的办法还是定期跑一遍集成测试确认三个平台的响应格式没有变化。5.4 性能优化的几个实用技巧第一个技巧是连接复用。Node.js 的axios默认会为每个请求创建新连接改成用http.Agent并开启keepAlive可以显著降低延迟。配置方式是创建一个 Agent 实例设置keepAlive: true和maxSockets: 50然后传给 axios。第二个技巧是响应缓存。对于相同的输入和参数如果短时间内重复请求可以直接返回缓存结果。我用了一个简单的内存缓存key 是模型标识加消息内容的哈希过期时间设 5 分钟。对于调试和测试场景这个优化能省不少 API 调用。第三个技巧是并发控制。如果同时发多个请求要限制并发数避免触发平台的频率限制。我用了一个简单的信号量最多同时发 3 个请求超出的排队等待。5.5 密钥安全管理的注意事项密钥泄露是自建工作台最大的风险。除了前面说的不要提交到仓库还有几个细节要注意。日志里不要打印完整的密钥。我见过有人在调试的时候把请求头整个打出来结果密钥就留在日志文件里了。正确的做法是只打印密钥的前几位和后几位中间用星号代替。如果工作台要暴露到公网一定要加访问控制。最简单的做法是加一个固定的访问令牌前端请求的时候带上网关校验通过才转发。更严格的做法是接入 OAuth 或者自己搭一套用户体系但那就复杂了。定期轮换密钥也是个好习惯。三个平台都支持在控制台重新生成密钥建议每三个月换一次。换的时候先在网关的环境变量里更新重启服务确认没问题后再去控制台禁用旧密钥。6. 后续可以继续折腾的方向这套工作台跑通之后我又陆续加了一些小功能。比如在网关层加了一个简单的用量统计记录每个模型每天调用了多少次、消耗了多少 Token月底一看就知道钱花在哪了。还加了一个 Prompt 模板管理常用的系统提示存成模板切换模型的时候自动带上。再往后可以考虑的方向是加一个简单的路由策略。比如根据输入内容的长度自动选择模型短文本用响应快的长文本用上下文窗口大的。或者根据任务类型路由代码相关的问题走 DeepSeek中文写作走 Qwen结构化输出走 GLM。这个策略可以用规则实现也可以训练一个小分类器看个人需求。我在实际使用中最大的体会是配置驱动的设计真的能省很多事。一开始多花点时间把适配层抽象好后面加模型、改参数都是几分钟的事。反过来如果一开始图快把各平台的调用逻辑散落在代码各处后面维护起来就是噩梦。这个经验不光适用于 AI 工作台任何需要对接多个外部服务的项目都适用。