☰
基于Dify零代码实现双模型分栏同步流式对比
2026/10/19 7:32:34 网站建设 项目流程

最近有朋友跟我抱怨,说每次模型一更新,做选型对比就成了一场折磨:同一个问题在两个聊天窗口里粘了又粘、复制来复制去,最后连哪段回答是哪家模型生成的都分不清楚。我一开始建议他用 OpenWebUI 来统一接模型,结果发现它更适合当“多模型聊天客户端”,而不是“双模型分栏同步流式输出”的对比台。后来我们换成 Dify,走零代码路线把两个模型各自拆成独立应用,再用一层极薄的页面把两条输出流并排渲染,整个过程顺了不少。这篇文章把思路、配置、代码和踩过的坑完整写下来,给正在做模型选型、提示词对比,或者想快速搭一个模型评测工具的人参考。

1. 双模型对比这件事,为什么越做越痛苦

1.1 手动双开的“伪同步”

做过模型对比的人,基本都经历过这样一套流程:打开模型 A 的对话页,粘贴问题;再打开模型 B 的对话页,再粘贴一遍;然后来回切换窗口,凭记忆比较两边回答。

这套流程的最大问题,是它看起来在对比,实际根本不具备对比的条件。

第一个坑是输入不一致。很多问题不是一句话,而是带着上下文、带着示例、带着约束条件的完整提示词。手动复制时很容易漏掉一两段,或者顺序颠倒。两边收到的 Prompt 都不一样,结果当然没有可比性。

第二个坑是节奏不同步。模型 A 可能两秒就开始输出了,模型 B 要思考很久才吐第一个字。等 B 开始输出时,A 早就写完一大段了。人的注意力天然会被先完成的内容吸引,后续再看 B 的回答时,潜意识里已经带着“A 先写完,应该更靠谱”的偏见了。模型本身的输出质量,和它的响应速度,被完全混在了一起。

第三个坑是格式不一致。有的模型喜欢列点,有的喜欢长篇,有的会在开头先自我介绍一段,有的直接给结论。一旦格式五花八门,对比起来就是灾难。你甚至需要自己先在脑子里做一遍“格式归一化”,才能把两段答案放到同一个维度上比较。

第四个坑是上下文隔离。如果你在模型 A 里追问了一句“展开说说第二点”,模型 B 里并没有这段对话历史。两边往后的对话会越来越分叉,最后你已经不是在对比同一个问题了。

这些痛点叠加在一起,导致“多模型对比”听起来很简单,做起来却极度耗时,且结论很不靠谱。

1.2 现有工具的局限与契机

OpenWebUI 这类产品解决了一部分问题:它可以把多个模型接入同一个界面,不用再开一堆浏览器标签页。但它的核心心智模型仍然是“一个人在聊天”,而不是“两个模型在同一题面上、同一时间轴上互相对比”。你确实可以在里面开多个会话分别绑定不同模型,但要做到左右分栏、同步流式、逐字渲染,它并没有提供这样的视野。

我后来转向 Dify,核心原因是它对“应用”的定义和聊天前端完全不同。Dify 里的每个应用都能独立绑定模型、独立配置系统提示词,甚至独立的开场白与变量;应用发布后还能拿到独立的流式 API 地址。这等于天然把一个模型封装成了一个可编程调用的“服务”,而不是锁在某个聊天窗口里的“会话”。

更关键的是,Dify 的聊天 API 原生支持 SSE 流式返回。这意味着前端可以同时向两个应用发起请求,拿到两路增量文本流,然后渲染在两栏里。模型接入、上下文管理、日志记录这些脏活,Dify 后台全包了。你需要自己动手的部分,只是把两路输出拼到同一个页面上而已。

这就是“零代码”三个字在这个场景里的准确含义:模型的逻辑编排、系统提示词、参数调整、发布管理都在 Dify 页面里点出来,不需要写一行后端代码。

2. Dify 的可视化编排,为什么能撑起这个玩法

2.1 一个模型一个应用,边界比想象中重要

在 Dify 里创建一个应用,有两种主要路径:一种是聊天助手,另一种是工作流。对于双模型分栏对比这个需求,我强烈建议用两个独立的聊天助手应用,而不是在一个工作流里塞两个 LLM 节点。

为什么?因为工作流虽然可以在一个 DAG 里并联多个 LLM 节点,但工作流的终点只有一个输出。无论你在中间并联几个模型,最终返回给用户的都是一个拼接好的完整字符串。你要想在浏览器里看到左边模型 A 在逐字输出、右边模型 B 同时在逐字输出,工作流默认的输出模式做不到这种双通道同步流。

两个独立应用的好处在于边界清晰:应用 A 只负责模型 A 的系统提示词、参数和 API 端点;应用 B 只负责模型 B 的一切。两边互不干扰。前端只需要分别调用两个应用的 API,就能拿到两条完全独立的增量流。以后想换模型,去应用设置里改一下默认模型就行,前端代码完全不用动。

如果你后续还想让某个裁判模型来给 A、B 的回答打分,那再新建一个工作流应用,把两边的最终文本传进去做最终评估——相当于把“对比”和“评审”两层流程拆开了,逻辑非常干净。

2.2 流式 API 是整套方案的地基

Dify 的聊天消息接口是/v1/chat-messages,请求体里有个response_mode字段,设为streaming时,服务端返回的就是text/event-stream格式的 SSE 流。

这条流里会按顺序出现几种事件:message事件携带增量回答内容;message_end表示本轮回答正常结束;如果中间出错,会有error事件。前端拿到message事件里的answer字段,不断追加到界面上,就能实现类似打字机效果的逐字输出。

这也是“双模型分栏同步流式输出”在地基层面的可行性来源:两个独立的 Dify 应用 = 两条独立的 SSE 流。前端同时发两个 HTTP 请求,互不阻塞,各自渲染。模型响应快的那边就快出来,响应慢的那边就慢慢出,画面始终是同步推进的。

这种体验和“等两个模型都跑完再一起展示”完全不一样。等全部跑完再展示,你看到的是一堆静态文字,还得自己比对;同步流式输出时,你的眼睛从第一个字出现就开始参与判断了,能明显感知到模型是“先思考后输出”还是“边想边写”,这个动态过程本身就是一种评估维度。

3. 先搭好两个应用:模型接入与提示词隔离策略

3.1 两个应用怎么创建

实操路径如下。

第一步,进入 Dify 工作台,点击创建空白应用,选择聊天助手类型。命名时务必带上清晰标识,比如“模型A对比器”“模型B对比器”。名字起得越清楚,后面维护的时候越省心,尤其是当你同时跑三四组对比时。

第二步,在应用编排页面找到模型选择下拉框。如果 Dify 里还没接入任何模型提供商,需要先到设置里的模型供应商页面,按提示填写对应的 API 密钥。Dify 的模型供应商接入是可视化的,每个厂商要填的字段不一样,但基本就是 API Key、Base URL 这类的信息,照着页面填就行。

第三步,把模型 A 的型号选好,然后再复制创建出一个模型 B 应用,把默认模型换成另一家的型号。注意,这里我特意用了“复制创建”这个思路,为的是让两个应用的系统提示词保持完全一致,避免人工重写时引入细微差异。

第四步,粘贴系统提示词模板。我常用的模板长这样:

你是模型评测场景下的候选模型,即将和另一个模型回答同一个用户问题。 请遵守以下规则: 1. 直接回答用户问题,不要自我介绍,不要说“作为AI模型”这类话。 2. 回答开头用一句话概括你的核心思路,不超过20个字。 3. 回答控制在800字以内,分点输出,方便对照。 4. 如果问题涉及事实信息,末尾用一行注明“此信息可能存在时效性”。

这套模板的核心思路是“格式对齐”。模型对比最怕的就是一个长篇大论、一个惜字如金。通过系统提示词把输出格式、长度、结构都约束住,才能保证后续比较的是模型的内容质量,而不是格式偏好。

第五步,点击发布。两个应用都发布后,就能拿到各自的 API 凭证了。

3.2 保持公平性:参数一致性比模型更重要

这一步是我踩过最深的一个坑,必须单独拿出来说。

刚开始做第一批对比实验时,我把模型 A 的温度调成了 0.9,希望它更有创造力;模型 B 保持默认 0.3,希望它更稳定。结果两边输出风格天差地别,A 写了一堆发散思路,B 规规矩矩列点回答。当时我还以为是模型能力差距,后来把参数拉平才发现,模型自身的真实差异根本没有那么大。

在做双模型对比时,必须把以下参数统一:

参数统一值建议原因
temperature0.2 或 0.7,两边一致控制随机性,避免发散程度不同
top_p1与 temperature 配合,保持采样策略一致
max_tokens相同数值,如 800避免一边写满了、一边被截断
联网搜索同时开启或同时关闭避免一边有实时信息、一边纯靠记忆
记忆/上下文两边都开新会话避免历史会话影响当前回答

如果某个模型不支持其中某个参数,那就取两边都支持的配置,以“最小公倍数”为准。比如其中一个模型不支持 top_p,那 top_p 就统一不动,保持默认值 1。

只有控制变量唯一,对比出来的差异才真正代表模型能力差异,否则你量化的是参数差异、上下文差异甚至运气差异。

3.3 获取 API 凭证

应用发布后,进入应用概览页面,左侧菜单里能找到 API 访问入口。在这里可以创建一个 API 密钥,格式通常是app-开头的一串字符。

同时记录两个关键信息:

  • API 端点地址,一般是https://你的域名/v1/chat-messages
  • 对应的密钥,一个应用一个,不要混用

Dify 后台还可以配置“允许跨域来源”。如果你的前端页面是本地打开的,或者部署在某个独立域名下,这里需要按实际情况添加允许的域名。临时测试可以填*,但要注意,这意味着任何域名都能发请求到这个 API,生产环境千万别这么干。

4. 把两个模型按时间线灌进同一张页面

4.1 为什么还需要一层薄薄的呈现层

严格讲,“分栏同步流式输出”这个最终效果,需要一层面向前端的渲染逻辑。毕竟 Dify 只负责把两路令牌流吐给调用方,谁负责把它们并排画出来,Dify 不管。

这里我得说句实在话:如果你对“零代码”的理解是“一个现成页面,点两下配置就出来”,那这个需求做不到。真正的零代码部分,是 Dify 侧的应用编排、模型接入、参数管理、发布和 API 交付;而把两条 SSE 流并排渲染到同一页面的工作,需要一个小得不能再小的前端壳子。

为了不辜负“零代码”三个字,我尽量只保留最关键的一段页面代码,不需要任何构建工具、不需要安装依赖,保存成.html文件双击打开就能跑。

4.2 可直接复制的最小化页面

先用 curl 演示单应用流式调用的格式,方便你自查接口是否正常:

curl -X POST 'https://your-dify.example.com/v1/chat-messages' \ -H 'Authorization: Bearer app-xxxxxxx' \ -H 'Content-Type: application/json' \ -d '{ "inputs": {}, "query": "请用一句话解释什么是模型蒸馏", "response_mode": "streaming", "conversation_id": "", "user": "model-compare" }'

如果返回的是text/event-stream类型的内容,说明接口正常。下面是可以直接上手的完整页面:

<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="UTF-8"> <title>双模型分栏流式对比</title> <style> body { font-family: system-ui, sans-serif; margin: 20px; background: #fafafa; } .bar { display: flex; gap: 12px; margin-bottom: 12px; } .bar textarea { flex: 1; height: 64px; padding: 8px; font-size: 14px; } .bar button { padding: 8px 16px; cursor: pointer; } .columns { display: flex; gap: 12px; } .col { flex: 1; border: 1px solid #ddd; border-radius: 8px; padding: 12px; min-height: 320px; background: #fff; white-space: pre-wrap; } .meta { color: #888; font-size: 12px; margin-top: 8px; } .done { border-color: #4caf50; } </style> </head> <body> <h4>双模型分栏同步流式输出</h4> <div class="bar"> <textarea id="prompt" placeholder="输入问题,同时发给两个 Dify 应用"></textarea> <button id="send">开始对比</button> </div> <div class="columns"> <div class="col"> <strong>模型 A</strong> <div id="colA"></div> <div class="meta" id="metaA"></div> </div> <div class="col"> <strong>模型 B</strong> <div id="colB"></div> <div class="meta" id="metaB"></div> </div> </div> <script> const apps = [ { name: '模型A', url: 'https://your-dify.example.com/v1/chat-messages', token: 'app-xxxx-a' }, { name: '模型B', url: 'https://your-dify.example.com/v1/chat-messages', token: 'app-xxxx-b' } ]; const decoder = new TextDecoder(); let compareId = 0; async function streamChat(app, query, outputEl, metaEl, sessionId) { const start = Date.now(); const resp = await fetch(app.url, { method: 'POST', headers: { 'Authorization': `Bearer ${app.token}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ inputs: {}, query: query, response_mode: 'streaming', conversation_id: '', user: `user-${sessionId}` }) }); if (!resp.ok || !resp.body) { throw new Error(`${app.name} 请求失败: ${resp.status}`); } const reader = resp.body.getReader(); let answer = ''; let buffer = ''; let firstTokenTime = null; 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 raw of lines) { const line = raw.trim(); if (!line.startsWith('data:')) continue; const data = line.slice(5).trim(); if (!data) continue; let payload; try { payload = JSON.parse(data); } catch (e) { continue; } if (payload.event === 'message') { if (!firstTokenTime) firstTokenTime = Date.now(); answer += payload.answer || ''; outputEl.textContent = answer; } if (payload.event === 'message_end') { const cost = ((Date.now() - start) / 1000).toFixed(2); const chars = answer.length; metaEl.textContent = `首字 ${((firstTokenTime - start) / 1000).toFixed(2)}s | 总耗时 ${cost}s | ${chars} 字`; outputEl.parentElement.classList.add('done'); return { app: app.name, cost, chars }; } if (payload.event === 'error') { metaEl.textContent = `错误: ${payload.message || '未知错误'}`; throw new Error(payload.message); } } } const cost = ((Date.now() - start) / 1000).toFixed(2); metaEl.textContent = `流中断 | 总耗时 ${cost}s | ${answer.length} 字`; return { app: app.name, cost, chars: answer.length }; } document.getElementById('send').addEventListener('click', async () => { const promptEl = document.getElementById('prompt'); const query = promptEl.value.trim(); if (!query) return; compareId++; const sessionId = compareId; const colA = document.getElementById('colA'); const colB = document.getElementById('colB'); const metaA = document.getElementById('metaA'); const metaB = document.getElementById('metaB'); colA.textContent = ''; colB.textContent = ''; metaA.textContent = ''; metaB.textContent = ''; colA.parentElement.classList.remove('done'); colB.parentElement.classList.remove('done'); // 同时发起两个流式请求,互不等待 const results = await Promise.allSettled([ streamChat(apps[0], query, colA, metaA, sessionId), streamChat(apps[1], query, colB, metaB, sessionId) ]); console.log(results); }); </script> </body> </html>

这段代码做的事情很清晰:点击按钮后,前端同时向两个 Dify 应用的 API 发请求,各起一个读取流;每收到一个message事件,就把增量内容追加到对应的栏里。两个请求完全独立,模型 A 卡住了,模型 B 照常渲染,反过来也一样。左下角和右下角会显示首字耗时、总耗时和总字数,用来辅助判断响应速度的差异。

这里要解释一下“同步”的标准。我理解的同步不是“两个模型必须同时吐完最后一个字”,而是“两个请求同时启动、各自边收边画”。模型 A 快就快出,模型 B 慢就慢出,你看到的是两条真实时间线并排推进。如果非要等慢的那方结束再同时渲染,那就成了静态对比,失去了流式输出的意义。

5. 踩坑层:流式输出里那些不为人知的细节

5.1 并不是逐 token 返回

很多人第一次接触 SSE 时,会认为每条data就是一个 token。实际用 Dify 跑下来,message事件的粒度并不完全固定,有时一次事件里包含几十个字的完整句子,有时一次只有两三个字。解析时绝不能按“一个事件 = 一个字符”来设计前端逻辑。

另一个容易踩的坑是 SSE 的行结构。标准 SSE 格式里,数据行以data:开头,事件类型以event:开头。Dify 返回的内容基本都能解析成event和data两个字段,但也存在一些空行、心跳包、甚至结束标志[DONE]混在里面。我的代码里用line.startsWith('data:')过滤,再将不可解析的行直接忽略,就是为了提高容错性。

还有一个更隐蔽的问题:网络传输中,一个数据块可能被拆成两半到达浏览器。如果前端每次都把buffer.split('\n')后的最后一段直接丢弃,那一半的 JSON 数据就丢了。正确的做法是把最后一个不完整的数据行暂存到 buffer 里,等下次数据块到达后拼接起来再解析。上面代码里buffer = lines.pop()就是这个用途。这个细节不注意,你会看到输出内容偶尔缺字、偶尔出现解析报错,非常难排查。

5.2 跨域、密钥暴露与限流

浏览器直接请求 Dify 的 API 域名时,如果两个域名不一致,就会触发跨域限制。Dify 后台虽然可以配置允许跨域来源,但本地文件方式打开时,Origin是null,很多配置不一定能覆盖到。

我的建议是不要试图去改浏览器安全策略,更不要装什么“关闭跨域”的插件。最省事的方案是在 Nginx 里加一个反向代理,把 Dify 的/v1路径代理到同域名下的/dify-proxy/v1,前端请求地址改成同域名,跨域问题自然消失。

密钥暴露是另一个必须提醒的点。前端代码里的app-token对浏览器用户是可见的,任何人打开控制台都能看到。如果是内部测评工具问题不大;如果是要给外部团队用的,一定要加一层代理服务,让 token 留在服务器端,前端只请求自己的代理接口。别把 API 密钥裸奔在公网页面上,这点不需要多说。

还有一个实际体验相关的问题:同时开两个模型请求,很容易触发模型提供方的速率限制。两边各自的429错误会交替出现。前端最好做简单的退避重试,比如出现429时等待Math.min(1000 * 2 ** attempt, 5000)毫秒后重试。如果频繁触发限流,也需要考虑错开并发时间,或者在 Dify 后台开启限流配置、设置应用的调用配额。

5.3 响应一快一慢造成的视觉失真

两个模型速度差异大的时候,左边已经输出完整篇,右边才刚开始。人的注意力天然倾向先完成的那个模型,容易把它视为“更好的选择”。这块不能怪模型,只能靠工具层面做补偿。

我的处理方式是在两个栏下方都显示计时信息:首字耗时、总耗时、字数。有了这些指标,模型输出的“快”和“好”就能分开了。评测的人看到左侧明显更快,右侧更慢但内容更完整,会下意识知道两条维度要分开考量。

另外,如果想让视觉效果更严苛一点,还可以在“消息第一次到达前端”的时候记录统一的时间零点,两栏的每个输出都打上相对时间戳。这个时间戳不展示给普通人看,而是导出对比报告时使用,用来精确计算每个段落是在第几秒出现的。这样就能量化“思考型模型”和“输出型模型”的行为差异了。

6. 对比 OpenWebUI:这场“秒杀”到底杀了什么

6.1 两者不在同一条跑道上

把 OpenWebUI 和 Dify拉在一起比较,有点类似比较“聊天室”和“开发平台”。两者都能接模型、都能聊天,但使用目标完全不一样。

维度OpenWebUIDify 双应用方案
定位多模型通用聊天前端应用编排与模型管理平台
模型接入接入多个模型,界面统一可视化管理模型,按应用绑定
提示词管理会话级,比较随意应用级,可版本化、可复用
流式接口自带聊天流,不方便外部二次聚合开放 SSE API,支持多路并行聚合
分栏同步流式需多窗口手动操作,无原生功能双 API + 聚合页,天然可做
日志与评测简单对话记录结构化日志、标注、工作流联动
部署成本较低略高,但功能和灵活性更强

OpenWebUI 的强项是轻量、直观、部署快,适合个人日常使用或小团队内部统一入口。如果你只是想把多个模型接到一个好看的聊天界面里,OpenWebUI 是很快的选择。但如果你是想要“双模型分栏同步流式输出”这种带着明确评测目的的功能,OpenWebUI 就不是合适答案了。

6.2 真正拉开差距的,是结果能否沉淀成资产

Dify 方案最让我觉得重要的地方,是每个应用天然带完整日志。一次双模型对比跑完,Dify 后台记录了两个模型分别的请求参数、提示词、输出 token 数和请求耗时。这些数据能导出、能分析、能进一步喂养给下游。

OpenWebUI 虽然能记录会话,但从“模型对比”到“模型评测”再到“模型优化”的闭环,它没有直接打通。这就导致对比完一轮之后,结论往往留在人的脑子里,很难固化成可复用的流程。

我在实际使用中还有一个体会:对比结果沉淀下来之后,可以慢慢形成一个提示词测试集。把几百条测试问题整理成固定列表,每次新模型接入时就跑一遍,直接把两栏输出结果截屏归档。几个月下来,模型表现的变化趋势非常清楚,比任何参数分数都直观。这个工作流用 OpenWebUI 也能勉强做,但要自己搭一堆外部工具;Dify 的好处在于应用、日志、API 都在同一个体系里,从发起到记录到归档,链路短了不止一半。

如果你现在也在做模型选型或者在搭一个评测环境,我的建议很简单:两个模型各占一个 Dify 聊天应用,参数统一,提示词固定,再把上面那个 HTML 页面跑起来。先跑几天真实问题,别急着下结论。很多你以为是模型能力差距的东西,最后会发现是参数设置、提示词写法甚至首屏渲染速度造成的错觉。把变量控制住了,结论才真正站得住。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询