资讯动态

三模型同台流式PK:GPT/Claude/Gemini对比工具开发全解析

发布时间:2026/9/16 18:00:35 来源:尧图企业网站定制
最近在折腾大模型相关的小工具手头同时开了 GPT、Claude、Gemini 几个模型的 API 账号需求很直接同一个问题我想同时看到三家模型的回答而且要一个字一个字地“流出来”而不是等半天出一整段。这样一来模型之间的风格差异、响应速度、结构化能力都能在同一起跑线上直观对比。于是就有了这个「多模型流式对比」界面——一个让 GPT、Claude、Gemini 同台 PK 的 Web 应用。这篇文章我会把完整思路、API 差异、关键代码、踩坑记录都整理出来。适合两类人看一是想做类似 AI 聚合工具、需要把多个模型接到自己项目里的开发者二是想弄明白流式响应到底怎么回事、SSE 怎么解析、多路并发怎么管理的进阶玩家。文章以实操为主我会把每个步骤为什么这么设计也讲清楚尽量做到你看完能直接照着搭一个。1. 整体设计思路为什么需要多模型流式对比先说说这个项目要解决的核心问题。平时我们比较模型多数时候是打开三个网页、复制粘贴三次问题、再人工对比三份回答。这种方式的痛点很明显问题被拆成了串行操作模型之间的回答节奏差异感知不到而且一旦问题带上下文反复复制粘贴很容易出错。我想要的体验是输入一个问题点击发送三个模型开始“同时打字”左边 GPT、中间 Claude、右边 Gemini谁的输出风格更直接、谁的思考路径更清晰、谁的首字延迟更低一目了然。这个场景其实隐藏着两个关键技术点一个是“流式”另一个是“对比”。流式Streaming意味着后端不能等模型全部生成完再返回而是通过 SSE 或类似机制把生成内容按 token 切碎边生成边推送。前端收到后不断追加渲染形成打字机效果。多模型对比则要求三个请求必须并发发出而不是串行等待不然就失去了“同台”的意义。方案选型上我一开始考虑过纯前端方案也就是浏览器直接调三个厂商的 API。但很快否掉了原因有两个API Key 放前端等于公开泄露GPT 和 Claude 的 Key 都是按 token 计费谁拿到都能刷你的额度。浏览器有跨域限制Gemini 的接口虽然支持 CORS但 Anthropic 和 OpenAI 的接口在纯前端调用会遇到很多跨域策略问题排查成本远高于自己架一层代理。所以最终架构是前端一个网页React 写的后端一个轻量代理服务Node.js Express前端统一请求自己的后端后端再分发给三家模型服务商把差异消化在代理层。整个链路是这样浏览器输入问题 ↓ React 前端三个面板并发 fetch ↓ Node.js 代理层分别转发给 OpenAI / Anthropic / Gemini ↓ 三个模型各自的流式响应回传 ↓ 代理层解析各家格式统一成一种 SSE 事件 ↓ 前端逐字渲染到对应面板这个设计的优势很明显前端只认一种数据格式哪怕以后接入新的模型只要代理层适配一下就行API Key 只存在后端环境变量里所有限流、重试、日志统计都集中在代理层做调试起来很方便。2. 三模型接入API 协议差异与统一适配方案接入三个模型之前我先把它们的 API 文档翻了一遍。这三家的流式协议可以说“同一个爹不同的写法”表面看都是 SSEServer-Sent Events但事件名称、数据格式、鉴权方式、结束标记各不相同。我整理了一张对比表这算是我踩坑最多的部分。对比项OpenAI (GPT)Anthropic (Claude)Google (Gemini)请求端点/v1/chat/completions/v1/messages/v1beta/models/{model}:streamGenerateContent鉴权 HeaderAuthorization: Bearerx-api-keyanthropic-versionx-goog-api-key流式启用参数stream: truestream: true端点带?altsse数据事件类型data:单事件event: message_start / content_block_delta / message_delta等data:单事件增量字段位置choices[0].delta.contentdelta.textcandidates[0].content.parts[0].text结束标记data: [DONE]event: message_stop无特标记流结束即结束用量统计位置末尾usage字段message_delta中的usageusageMetadata2.1 协议差异里最容易踩的三个坑坑一Anthropic 的事件机制不是纯 data 流。OpenAI 和 Gemini 的流式响应基本就是一行行data: {...}解析时只要按行读取、找data:前缀就行。但 Claude 的流里有大量事件类型比如content_block_delta才是真正的文字增量message_start、content_block_start这种事件如果当成正文解析会拿到一堆无法渲染的 JSON 框架。所以代理层必须针对 Anthropic 单独写一段状态解析逻辑看到event: content_block_delta后再去读下一行的data。坑二Gemini 不加?altsse返回的不是标准 SSE。Gemini 的streamGenerateContent端点默认返回的是 multipart 格式一行是一大段 JSON 数组虽然也能解析但跟前端的统一格式不搭。加上altsse参数后它才会输出标准的data:前缀事件这样代理层就能统一按 SSE 逐行切分。坑三OpenAI 流式默认不带 usage 统计。如果不在请求体里加一个stream_options: { include_usage: true }流式响应末尾是没有 token 统计信息的。我一开始没加导致界面上的 token 消耗一直显示不出来后来翻文档才发现这个参数。Anthropic 则是在message_delta里自带 usageGemini 在最后的usageMetadata里三家处理方式完全不同。2.2 代理层怎么设计才省事代理层我不打算做成三个独立函数飞线的散装逻辑而是统一抽一个createUpstream(provider, messages)工厂返回每家请求所需的 URL、Headers、Body再用一个extractDelta(provider, data)函数把不同结构的增量统一成{ delta: xxxx }。这样前端看到的永远是这样的 SSE 事件data: {delta:你好} data: {delta:我是} data: {delta:GPT}前端只要认这一种格式解析逻辑写一套就够。新增模型时只需要在工厂里加一个分支前端几乎不用动。2.3 模型选择与请求参数模型名我直接写成了前端可配置项后端从请求参数里读。目前我用的配置是GPTgpt-4o-minitemperature 0.7Claudeclaude-3-5-haiku-latesttemperature 0.7Geminigemini-2.0-flashtemperature 0.7三家都支持流式都是各自价格与速度比较均衡的档位。如果你有更高额度把模型名换掉即可代码逻辑不用改。temperature 保持一致是为了让对比更公平排除随机性带来的参数干扰。3. 核心实现多路流式请求与逐字渲染的关键代码这一章是正餐。我按照后端代理、前端并发、状态管理三个层次来写每一步都会解释设计意图避免你照抄了却不知道哪里能改。3.1 后端代理串起三家流式响应后端我用的是 Node.js Express先装依赖npm init -y npm install express cors dotenv核心代理接口代码如下注意几个关键点// server.js require(dotenv).config(); const express require(express); const cors require(cors); const app express(); app.use(cors()); app.use(express.json()); // 各家上游配置工厂 function createUpstream(provider, messages) { switch (provider) { case gpt: return { url: https://api.openai.com/v1/chat/completions, headers: { Content-Type: application/json, Authorization: Bearer ${process.env.OPENAI_API_KEY}, }, body: { model: gpt-4o-mini, messages, stream: true, stream_options: { include_usage: true }, }, }; case claude: return { url: https://api.anthropic.com/v1/messages, headers: { Content-Type: application/json, x-api-key: process.env.ANTHROPIC_API_KEY, anthropic-version: 2023-06-01, }, body: { model: claude-3-5-haiku-latest, messages, max_tokens: 2048, stream: true, }, }; case gemini: return { url: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:streamGenerateContent?altssekey${process.env.GEMINI_API_KEY}, headers: { Content-Type: application/json }, body: { contents: toGeminiMessages(messages) }, }; default: throw new Error(Unknown provider: provider); } } // 把统一的 messages 格式转成 Gemini 的 contents 格式 function toGeminiMessages(messages) { return messages.map((m) ({ role: m.role assistant ? model : user, parts: [{ text: m.content }], })); } // 从各家数据的 JSON 里提取真正的文本增量 function extractDelta(provider, json) { try { if (provider gpt) { return json.choices?.[0]?.delta?.content || ; } if (provider claude) { // Claude 流里的 content_block_delta 事件才有 delta.text 字段 return json.delta?.text || ; } if (provider gemini) { return json.candidates?.[0]?.content?.parts?.[0]?.text || ; } } catch (e) { return ; } return ; } app.post(/api/chat/:provider, async (req, res) { const { provider } req.params; const { messages } req.body; res.setHeader(Content-Type, text/event-stream; charsetutf-8); res.setHeader(Cache-Control, no-cache); res.setHeader(Connection, keep-alive); res.flushHeaders(); const upstream createUpstream(provider, messages); try { const upstreamRes await fetch(upstream.url, { method: POST, headers: upstream.headers, body: JSON.stringify(upstream.body), }); if (!upstreamRes.ok) { const errText await upstreamRes.text(); res.write(data: ${JSON.stringify({ error: errText })}\n\n); res.end(); return; } const reader upstreamRes.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; let eventType ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // 按行切分 SSE const lines buffer.split(\n); buffer lines.pop(); for (const line of lines) { const trimmed line.trim(); if (trimmed.startsWith(event:)) { eventType trimmed.slice(6).trim(); continue; } if (!trimmed.startsWith(data:)) continue; const data trimmed.slice(5).trim(); if (data [DONE]) continue; let json; try { json JSON.parse(data); } catch (e) { continue; } // Claude 只在 content_block_delta 事件中提取正文增量 if (provider claude eventType ! content_block_delta) { continue; } const delta extractDelta(provider, json); if (delta) { res.write(data: ${JSON.stringify({ delta })}\n\n); } } } res.end(); } catch (err) { console.error([${provider}] stream error:, err); res.write(data: ${JSON.stringify({ error: err.message })}\n\n); res.end(); } }); const PORT process.env.PORT || 3001; app.listen(PORT, () { console.log(Proxy server running at http://localhost:${PORT}); });这段代码里最容易被忽略的是 Claude 的事件类型判断。如果不做eventType ! content_block_delta这个过滤你会发现 Claude 的回答里夹杂着大量message_start和content_block_start的初始元数据面板上会多出一堆 JSON 框架。另一个细节是res.flushHeaders()它确保 SSE 响应头立即下发前端能第一时间拿到响应对象否则首字延迟会偏高。流式转发除了逐行读取之外还需要处理一个边界情况一个 chunk 可能在半行处截断。所以代码里用了一个buffer变量缓存未完成的部分等下一个 chunk 到达时续接。这是流式解析常见的坑没做缓存的话有时候最后一个字会突然消失。3.2 前端并发三个面板同时发起流式请求前端我用了 React但核心逻辑跟框架关系不大你用 Vue 或者原生 JS 也能照搬。页面布局上桌面端是三列并排移动端可以用 Tab 切换这一点不做强制要求。关键代码在并发请求的管理上。三个问题要同时发但任一个失败不能影响另外两个所以必须用Promise.allSettled而不是Promise.all。import { useRef, useState } from react; const PROVIDERS [ { id: gpt, name: GPT }, { id: claude, name: Claude }, { id: gemini, name: Gemini }, ]; function App() { const [question, setQuestion] useState(); const [outputs, setOutputs] useState({ gpt: , claude: , gemini: , }); const [statuses, setStatuses] useState({ gpt: idle, claude: idle, gemini: idle, }); const [timers, setTimers] useState({ gpt: { start: 0, firstToken: 0, end: 0 }, claude: { start: 0, firstToken: 0, end: 0 }, gemini: { start: 0, firstToken: 0, end: 0 }, }); const abortRefs useRef({ gpt: null, claude: null, gemini: null, }); function resetPanels() { setOutputs({ gpt: , claude: , gemini: }); setStatuses({ gpt: loading, claude: loading, gemini: loading }); setTimers({ gpt: { start: Date.now(), firstToken: 0, end: 0 }, claude: { start: Date.now(), firstToken: 0, end: 0 }, gemini: { start: Date.now(), firstToken: 0, end: 0 }, }); } async function streamOne(provider, question) { // 中止上一次该模型的未完成请求 abortRefs.current[provider]?.abort(); const controller new AbortController(); abortRefs.current[provider] controller; try { const res await fetch(/api/chat/${provider}, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages: [{ role: user, content: question }], }), signal: controller.signal, }); if (!res.ok) throw new Error(HTTP ${res.status}); const reader res.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; let localFirstToken false; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const chunks buffer.split(\n\n); buffer chunks.pop(); for (const chunk of chunks) { const line chunk.split(\n).find((l) l.startsWith(data:)); if (!line) continue; const payload JSON.parse(line.slice(5)); if (payload.error) { setStatuses((prev) ({ ...prev, [provider]: error })); return; } if (payload.delta !localFirstToken) { localFirstToken true; setTimers((prev) ({ ...prev, [provider]: { ...prev[provider], firstToken: Date.now() }, })); } if (payload.delta) { setOutputs((prev) ({ ...prev, [provider]: prev[provider] payload.delta, })); } } } setTimers((prev) ({ ...prev, [provider]: { ...prev[provider], end: Date.now() }, })); setStatuses((prev) ({ ...prev, [provider]: done })); } catch (err) { if (err.name AbortError) return; setStatuses((prev) ({ ...prev, [provider]: error })); } } async function runComparison() { if (!question.trim()) return; resetPanels(); await Promise.allSettled( PROVIDERS.map((p) streamOne(p.id, question)) ); } // 渲染部分 return ( div classNamecontainer div classNameinput-row textarea value{question} onChange{(e) setQuestion(e.target.value)} placeholder输入一个问题三家模型同台PK / button onClick{runComparison}发送/button /div div classNamepanels {PROVIDERS.map((p) ( div classNamepanel key{p.id} h3{p.name}/h3 pre{outputs[p.id]}/pre /div ))} /div /div ); } export default App;这里有个容易被忽略的设计点每个模型都要单独设置AbortController。当用户连续发送两个问题时旧请求必须被中断否则会把上一次的回答内容追加到当前面板里。如果不做这个处理快速连续提问时会出现面板内容错乱的现象。另外我用了setOutputs((prev) ({ ...prev, [provider]: prev[provider] payload.delta }))这种函数式更新而不是直接读闭包里的outputs。原因很简单多个 SSE 事件触发非常频繁如果直接读状态变量React 的状态更新是异步的你读到的可能还是旧值导致丢字。3.3 首字延迟与耗时统计这个功能算是流式对比界面的一个隐藏彩蛋。既然模型都在同台输出用户自然会关心谁“先开口”所以我在前端加了三个时间点start点击发送的时间戳全局统一firstToken某个模型第一次收到增量内容的时间戳end某个模型流式结束的时间戳首字延迟的计算公式是firstToken - start总耗时的计算是end - start。这个数据能很直观地反映一家模型服务的首包响应速度而且放进对比界面里会让工具的实用性提升一个档次。实测下来Gemini 的首字延迟通常最低很多情况下点击发送后不到 500ms 就开始出字了GPT 和 Claude 通常需要 1 到 2 秒的“思考”时间尤其是问题比较长的时候Claude 有时候会先发一段元数据再过一两秒才进入正文。当然这个跟网络环境、模型负载都有关不能一概而论但值得作为一个参考维度展示出来。4. 常见问题排查与多模型实战对比感受接口通了、页面能跑了剩下的就是各种异常场景的打磨。这一章我把实际使用中遇到的高频问题整理成表格然后分享几组真实的对比体验。4.1 高频异常与解决方案现象原因解决方案Claude 面板输出大量 JSON未过滤content_block_delta事件代理层维护eventType非增量事件直接跳过Gemini 面板不出字但请求 200未加altsse返回 multipart 格式URL 添加?altssekey...GPT 请求报 401API Key 前缀或环境变量读取错误确认Authorization: Bearer格式环境变量不要带空格连续快速提问后内容错乱旧请求未中止SSE 事件仍追加每次发送前对每个 provider 调用AbortController.abort()首字延迟很高5 秒以上代理层fetch未流式读取或缺少flushHeaders确认使用res.flushHeaders()确认未做整段缓冲某个模型报 429API rate limit 超限增加指数退避重试或降低并发请求频率代理层内存持续增长SSE 事件未正常结束连接未释放检查reader.read()循环退出条件增加超时兜底这里我想专门讲一下 429 限流。三家对免费额度的限制不同免费 Key 的 RPM每分钟请求数通常非常有限。对比界面一次并发三个请求如果同一时间还有别人在用你的代理很容易触发限流。稳妥的做法是在代理层做一次简单的队列或令牌桶至少保证同一 Key 的并发请求数不超过 2。我的方案比较简单前端每次发送前做 1 秒节流按钮在请求期间置灰实际体验下来足够用了。另一个容易忽略的问题是超时。SSE 连接长时间没有新数据时前端不要无限等下去。理想做法是设置一个 60 秒的兜底定时器超出后自动标记该面板为timeout。我实测 GPT 偶尔会在长回答的中途停顿 10 秒以上这种情况如果贸然超时可能把正在生成的内容给丢了所以超时时间设得比较宽容。4.2 同一个问题三个模型的回答风格差异工具做完之后我拿一批问题反复测了很多轮覆盖技术解释、文案改写、代码生成、数学题等场景。这里选一个比较典型的例子让三个模型用大白话解释“什么是数据库索引”。GPT 的回答是典型的“结构化输出”风格先给一句话结论然后分点展开最后补一个比喻。结构感很强适合快速阅读。Claude 的开头喜欢用类比引入会先说“你可以把数据库索引想象成书的目录”然后再逐步展开。整体语气更像一个老师在循循善诱行文比较舒展。Gemini 的回答相对“平铺直叙”信息密度高但段落之间的衔接没有前两者那么讲究。逻辑上没毛病但读起来更像技术文档摘要。这三个模型的差异在技术类问题上体现得最明显。如果问题换成文案类比如“帮我想一句咖啡店促销文案”GPT 喜欢给多个选项Claude 会先分析目标人群再给文案Gemini 则偏向给出比较务实的口号。这类对比结果让我确信不同模型并不是“谁比谁聪明”的线性关系而是各自有擅长的输出模式。做这个工具的价值就在于你可以在十几秒内看到三套不同的思维框架而不是盲目相信某一个模型的输出。4.3 界面体验上的几个加分项基础功能跑通之后我又加了一些提升体验的小功能单独重新生成某个模型输出不满意时可以只重新请求这一个 provider不用整套重跑。实现方式是把streamOne(provider, question)暴露成按钮的点击事件。复制单列内容每个面板右上角加复制按钮只复制该模型的输出。这个在多轮测试时特别方便不用全选再删。Token 估算代理层不做精确统计但在前端用字符数除以 4 做一个粗略的 token 估算够用来横向比较三个模型的“啰嗦程度”。Dark 模式对比工具经常要长时间盯着屏幕我直接默认用了深色背景白字展示降低视觉疲劳。这些功能都不复杂但对实际使用体验的提升很明显。尤其是“单独重新生成”因为实际测试中经常出现某个模型跑偏、另两个正常的情况有了这个按钮就不必整页重来。4.4 给想扩展的读者几条方向这个工具做出来后我身边不少朋友问我能不能再加几个模型。其实架构上预留了扩展位在代理层的createUpstream里加一个 case前端PROVIDERS数组里加一个对象面板就会自动多一列。国内的开源模型比如 DeepSeek、通义千问接口风格通常兼容 OpenAI 格式接入成本更低只需要改 base URL 和 Model 名称。还可以做的方向包括把对比历史保存到本地方便回看支持多轮对话上下文加入 Prompt 预设库一键测试固定 Prompt 在不同模型下的表现。对于经常做 prompt 工程的人来说最后一个功能其实非常实用等于把对比工具变成一个持续迭代的实验台。5. 核心代码的工程化改进空间上面给的是能跑通的最小可用版本但真要长期用代码还得再打磨。这里补充几个工程化层面的改进思路。5.1 把代理层拆成独立模块当server.js越来越长时建议把上游配置、流式解析、路由处理拆到不同文件里server/ ├── index.js # 入口路由注册 ├── providers/ │ ├── gpt.js │ ├── claude.js │ └── gemini.js ├── streamParser.js # SSE 按行切分 事件类型追踪 └── utils.js # 内存估算、日志每家模型一个独立文件各自导出buildRequest(messages)和parseDelta(json, eventType)这样新增模型时完全不会影响已有代码可维护性会好很多。5.2 错误兜底与重试机制实际使用中三家 API 都可能因为各种原因中途断流。断流后前端面板会一直停留在 loading 状态体验很差。一个实用的方案是增加一个定时器某面板超过预期时间没有新数据就自动尝试重新连接一次如果重连失败再标记为错误。重试需要注意幂等性。流式生成如果已经消费了一部分 token重新发起请求会浪费额度。我的经验是如果已经拿到了超过 50 个字符的增量就不重试直接把当前内容保留如果刚开始几秒就断了才值得重试。5.3 前端状态管理升级当前代码用useState管理每个面板的状态在三个模型并发输出时主线程频繁执行setOutputsReact 的渲染压力其实不小。如果问题很长、输出内容很大可以考虑把输出内容移到useRef中只在特定时机比如每 100ms强制刷新一次减少不必要的重渲染。还有一个细节setOutputs里每次更新都是复制整个 outputs 对象再改其中一个字段高频执行时会产生一定性能开销。数据量小的时候无所谓但测过一段几千字的长答案后我能感受到输入框和按钮的响应略有卡顿。优化的方式是每个面板单独一个 state互不干扰。5.4 安全与费用控制API Key 虽然放在后端但仍然建议在代理层做两个限制请求频率限制比如每个 IP 每分钟最多请求 10 次单次请求最大 token 数限制防止误传超长文本把额度烧光我曾在测试时不小心把一个几万字的文件内容直接粘贴到输入框三个模型分别请求了一次额度消耗居然比一整天正常测试还多。从那以后我在后端对所有请求做了内容长度校验超过 2000 字直接拒绝。注意这类的多模型聚合工具一旦上线一定要做费用报警。可以在代理层记录每次请求的 cost 估算累计到一定金额就发告警。免费额度用完之前该停就停。6. 一些更细的实操心得最后分享几个做这个项目过程中比较深的主观体会。第一流式对比工具最加分的地方不是“漂亮”而是“同步感”。如果三个模型不是同时开始打字用户心理上会觉得有前有后、对比不公。所以我很早就把串行请求改成了并发并且确保三个面板的初始状态在同一时刻开始计时。这个小细节带来的体验提升比任何 UI 特效都明显。第二SSE 解析的通用性比想象中重要。把三家模型的解析逻辑统一成标准事件之后我接第五个模型时只花了半小时。前端的streamOne函数不改一行代码只加了一个 provider 的配置项。所以如果你打算长期做模型聚合类工具格式统一这件事越早做越好。第三多模型对比的结果要谨慎解读。模型输出受 temperature、版本、上下文长度等多个变量影响同一问题换个参数结果可能完全不同。我的经验是任何一次对比得出的“结论”都应该用至少三轮同样的测试去验证否则很容易被偶然性误导。工具只能缩短测试周期不能替代人工判断。这个项目从想法到跑通前后花了大概两个晚上。第一晚上把后端代理和三个模型的流式接入调通第二晚上做了前端界面和并发控制。如果你只是想验证“多模型流式对比”这个思路照着上面的代码搭一套半天时间应该足够。真正花时间的地方在协议差异的处理和异常场景的打磨这也是我觉得最有价值的一部分。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价