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 里的两部分信息。这是三个平台里认证最复杂的一个,也是适配层需要特殊处理的地方。
| 平台 | 认证方式 | 消息格式 | 流式输出 | 特殊字段 |
|---|---|---|---|---|
| DeepSeek | Bearer Token | messages 数组 | SSE | 无 |
| Qwen | Bearer Token | messages 数组 | SSE | enable_search |
| GLM | JWT 签名 | messages 数组 | SSE | 需生成 Token |
3.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=你的密钥 PORT=3000注意:
.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 工作台,任何需要对接多个外部服务的项目都适用。