这几周连续有朋友来问同一个需求:想做一个“两个AI同时回答同一个问题、并排看效果”的小工具。有的是为了挑模型,有的是为了对比提示词,还有的纯粹是觉得好玩。我一开始以为就是个双栏页面,后端各调一次接口就行,真正动手才发现,这玩意儿踩坑的点比想象的深得多——流式输出的并发控制、两路状态的管理、哪边先到哪边后到、用户中途切换问题时旧请求怎么取消,全是细节。
所以我把完整的实现方案拆开写成这篇东西,从架构设计到核心代码再到实测坑点,适合正在做类似工具、或者想给团队搭一个模型评测台的开发者参考。不需要你有多深的前端功底,后端会一点Python就能跟上,关键是把“两条流并行、互不干扰、体验顺滑”这套逻辑理清楚。
1. 双AI对话对比到底解决什么问题:从模型选型的真实痛点说起
1.1 模型输出的“感觉”,只有并排看才明显
我在日常开发里经常遇到一个场景:要给某个功能挑模型,A厂商说自己的模型多强,B厂商说自己的更牛。跑分、榜单、评测集都看了,分数差得不大,可真拿同一个Prompt去问,两个模型的回答风格、信息密度、逻辑组织方式差异非常大。比如同样是“帮我梳理一下微服务拆分的基本原则”,一个模型会给你列10条条目,每条配解释;另一个模型会用一整段文章讲一个完整的故事线。你说哪个好?分场景。
问题在于,这种差异没法通过跑分看出来,你只能把两个答案并排放在一起,同一个问题、同一套系统提示词、同一个温度参数,肉眼扫过去一眼就能判断谁更贴合你的场景。这个需求听起来简单,但手动操作非常痛苦:先复制问题,去A平台问一遍,复制结果,再去B平台问一遍,复制结果,再打开文档对比。来回几次你就烦了。做双AI对话对比工具,本质上就是把“手动复制粘贴对比”变成“一个Enter键两边同时出结果”。
1.2 对话对比的适用场景与边界
我实际用下来,有四个场景最需要它:
- 模型选型:团队要引入新模型,拿典型业务问题做盲测,两个模型并排输出,评审人员直接打分。
- 提示词调优:同一个模型,两版不同的系统提示词放左右两侧对比,能快速看出哪版提示词把模型带向了更想要的方向。
- 翻译质量校对:源语言问题发到两个模型,左右对照译文,选更准确的那个。
- 教育场景/写作辅助:两路回答互为补充,帮使用者看到不同角度的理解。
边界也说明白:这个工具做的是“人工横向评测”,不是“自动化批量评测”。你要的是一个直观的展示面,而不是大规模的离线评估任务。想算BLEU、ROUGE那些分数,是另外一整套事了。明白这个边界,后续的功能设计和架构能少走很多弯路。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体架构设计:两条流如何并行推送而不串流
2.1 模块划分与数据流
我先给整体画个框架,不用图表,用文字描述就够清晰了。
工具分三块:前端双栏展示区、后端统一适配层、模型API层。
前端不用多说,左右两个面板,中间可以放一个输入框或者顶部放一个公用的输入框。后端统一适配层是核心,它负责接收前端一次请求,把这个请求转发给两个不同的模型API,并把两个模型的流式输出合并成一条通道推回前端。模型API层就是真实对接的各个模型服务。
数据流是这样的:用户在输入框敲入问题并点击发送,前端发起一个请求;后端在内部同时创建两条异步任务,分别去请求模型A和模型B;两个模型开始流式返回,后端边收边转,把每一段增量数据按“模型标识+文本增量”的格式封装成SSE帧推给前端;前端拿到帧,根据模型标识把增量追加到对应的面板里。
一句话概括:前端只管渲染,后端负责“同问、并行、分流”。
2.2 为什么采用SSE流式推送而不是轮询
这个方案里最关键的技术选型不是前端框架,而是“怎么把两路数据同时送到前端”。我对比过两条路线:轮询和SSE。
轮询太蠢了——前端开个定时器每秒去拉一次两个模型的进度。问题是,流式输出的高频增量数据用轮询来拉,既浪费大量HTTP请求,还会造成输出一卡一卡的感觉,完全没有“流式打字机”的体验。
SSE(Server-Sent Events)几乎是天生的更适合这件事。它是单向长连接,服务端可以持续推数据给客户端,前端用EventSource或者fetch的流式读取就能接收。双向通信需要客户端不断发消息给服务端的时候才上WebSocket,这里客户端只发一次请求,剩下都是服务端往客户端推,用SSE轻量又稳定。而且SSE协议本身支持自定义事件类型,天然适合我们这种“两路数据混在同一条连接里”的需求。
所以架构最终定为:前端POST /api/compare发起一次请求,后端返回一个text/event-stream的流,流里的每条帧要么属于left模型,要么属于right模型,前端按帧分流即可。
3. 核心实现细节:适配层、流式合并与前端渲染
3.1 后端统一适配层设计
后端我用FastAPI写的,异步支持和流式响应都很顺手。先定义一个模型适配层,把不同厂商的API统一成相同的接口。现在主流模型厂商的接口基本都是OpenAI兼容格式,但参数细节还是有差异,比如某些模型不支持seed参数,某些模型对max_tokens的语义不同。适配层做的就是把差异消化在内部。
python复制import os
import httpx
MODEL_CONFIGS = {
"left-model": {
"api_base": os.environ.get("LEFT_MODEL_API_BASE"),
"api_key": os.environ.get("LEFT_MODEL_API_KEY"),
"model": os.environ.get("LEFT_MODEL_NAME", "gpt-4o"),
},
"right-model": {
"api_base": os.environ.get("RIGHT_MODEL_API_BASE"),
"api_key": os.environ.get("RIGHT_MODEL_API_KEY"),
"model": os.environ.get("RIGHT_MODEL_NAME", "claude-sonnet"),
},
}
async def stream_chat(model_key: str, messages: list, temperature: float):
config = MODEL_CONFIGS[model_key]
url = f"{config['api_base'].rstrip('/')}/chat/completions"
headers = {"Authorization": f"Bearer {config['api_key']}"}
payload = {
"model": config["model"],
"messages": messages,
"temperature": temperature,
"stream": True,
}
# 如果模型支持 seed,可以加进来,但需要判断
async with httpx.AsyncClient(timeout=60) as client:
async with client.stream("POST", url, headers=headers, json=payload) as response:
if response.status_code != 200:
error_body = await response.aread()
raise RuntimeError(f"模型[{model_key}]请求失败: {response.status_code} {error_body.decode()}")
async for line in response.aiter_lines():
if not line.startswith("data: "):
continue
data = line[6:].strip()
if data == "[DONE]":
break
yield data
注意这里要处理几个事:第一,timeout必须设,不同模型响应速度差异很大,流式长连接尤其容易触达超时阈值;第二,HTTP状态码异常时要读取响应体给出明确错误,而不是让前端黑屏;第三,每个模型的api_base、api_key、model都走环境变量,方便切换厂商,千万别写死。
有的模型接口不是OpenAI格式,比如部分模型用anthropic的/v1/messages接口,字段名完全不同。我的建议是不要硬塞进同一个stream_chat,而是为这种非OpenAI格式的模型单独写一个适配函数,返回同样的增量文本格式,对外接口一致即可。
3.2 流式合并:让两条流交错推送不堵塞
核心来了:怎么把两个异步生成器合并成一条SSE流。
最容易写错的方案是“先等A流走完再走B流”,那样左边输出完了右边才开始,对比效果大打折扣。正确写法是启动两个并发任务,谁先有数据就先推谁,两条流交错着走。
python复制import asyncio
import json
from fastapi import FastAPI, Request
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
app = FastAPI()
class CompareRequest(BaseModel):
question: str
temperature: float = 0.4
@app.post("/api/compare")
async def compare(request: CompareRequest):
messages = [
{"role": "system", "content": "你是一个专业的助手,请根据用户问题给出清晰、结构化的回答。"},
{"role": "user", "content": request.question},
]
async def event_generator():
left_stream = stream_chat("left-model", messages, request.temperature)
right_stream = stream_chat("right-model", messages, request.temperature)
queue: asyncio.Queue = asyncio.Queue()
remaining = 2
async def pump(agen, model_tag: str):
nonlocal remaining
try:
async for chunk in agen:
await queue.put({"model": model_tag, "delta": chunk})
except Exception as exc:
await queue.put({"model": model_tag, "error": str(exc)})
finally:
remaining -= 1
if remaining == 0:
await queue.put(None)
task_left = asyncio.create_task(pump(left_stream, "left"))
task_right = asyncio.create_task(pump(right_stream, "right"))
while True:
item = await queue.get()
if item is None:
break
yield f"data: {json.dumps(item)}\n\n"
await asyncio.gather(task_left, task_right, return_exceptions=True)
return StreamingResponse(event_generator(), media_type="text/event-stream")
这里有个关键陷阱,我必须展开说。asyncio.create_task创建的两个pump任务是并发执行的,主循环用queue.get()从队列里取数据。任何一个pump拿到数据就往queue里放,所以左右两路是交错出现的。当remaining减到0时,最后一个完成的pump往队列放一个None,主循环收到None就退出,SSE流结束。
如果你一开始用asyncio.gather直接等待两个pump完成再消费队列,那就错了——两个任务没完成前,队列里的数据永远不会被消费,形成死锁。正确顺序是先消费队列,再gather收尾。
另一个细节:pump内部捕获了所有异常,把错误信息当作一个error帧推给前端。这样即使一个模型挂了,另一个模型的输出还能正常展示,前端能提示用户“左边模型出错了”并且保留右边的结果。双路并行的容错性,就体现在这里。
3.3 前端双面板与流式渲染
前端我用React写,左右两栏的布局用CSS Grid或者Flexbox都行,两个面板各自维护自己的内容。
流式接收用fetch原生API读取响应体,因为EventSource不支持POST请求,而我们还需要把问题的内容通过POST传给后端。所以用fetch + ReadableStream读流。
javascript复制async function compare(question, temperature) {
const id = ++roundIdRef.current;
setLeftStatus("streaming");
setRightStatus("streaming");
setLeftOutput("");
setRightOutput("");
const response = await fetch("/api/compare", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ question, temperature }),
});
if (!response.ok) {
setLeftStatus("error");
setRightStatus("error");
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split("\n\n");
buffer = frames.pop();
for (const frame of frames) {
const line = frame.trim();
if (!line.startsWith("data: ")) continue;
const payload = JSON.parse(line.slice(6));
if (payload.error) {
if (payload.model === "left") {
setLeftStatus("error");
} else {
setRightStatus("error");
}
continue;
}
if (payload.model === "left") {
setLeftOutput(prev => prev + payload.delta);
} else {
setRightOutput(prev => prev + payload.delta);
}
}
}
if (id === roundIdRef.current) {
setLeftStatus("done");
setRightStatus("done");
}
}
我在这个函数里加了一个roundIdRef,这是第4章要重点讲的竞态控制,先留个伏笔。
SSE帧解析这里有个容易错的地方:每个SSE帧以空行\n\n分隔,但数据到达前端时不一定正好按帧边界拆开,一段TCP包可能包含多个帧,也可能只包含半个帧。所以必须用buffer缓存拼接,遇到完整帧再解析。这个坑我在第一版实现中踩过,不加缓冲直接按行解析,输出会随机丢字。
4. 实测中踩过的坑:竞态、超时与成本控制
4.1 流式中途切换的竞态问题
第一版做完,我拿来玩了两分钟,立刻碰上一个令人抓狂的bug:我在左边模型还在输出的时候,快速输入了一个新问题发送,结果左面板的内容被截断了,但右面板还残留着上一个问题的尾部输出,然后新问题的内容又开始追上来,两边内容完全串了。
原因很清楚:前一个请求的SSE流还没关闭,新请求的响应又到了,两路UI更新互相覆盖。
解决方案就是我给compare函数加的那个roundIdRef。前端每次发送新请求都让这个自增ID加1,同时在compare内部保存本次请求的id。只有id === roundIdRef.current时才更新状态。旧请求即使还在流式返回,它的id已经过期了,所有状态更新都会被丢弃。
javascript复制const roundIdRef = useRef(0);
async function compare(question, temperature) {
const id = ++roundIdRef.current;
// ...后续更新面板状态前都检查 id === roundIdRef.current
}
这里还有增强方案:在发送新请求前,主动调用AbortController中断上一次的fetch连接,让浏览器真正断开旧请求,而不是默默丢弃数据。注意fetch的signal需要传入AbortController的实例,同一个controller在旧请求和新请求之间切换时,要保证每个请求有独立的controller。
真实项目中我两个机制都用了:AbortController负责物理断开,roundIdRef负责逻辑兜底。双保险,实测稳了。
4.2 输出不一致与兜底策略
两路模型输出的速度完全不可控,有时左边已经输出完1000字,右边才刚开始。这不算bug,但也需要处理一下展示层,不然用户会误以为右边卡死了。
我在两个面板底部各加了一个状态指示:等待中、输出中、已完成、出错了。只要收到该模型的第一帧数据,状态就从“等待中”切换到“输出中”;收到流结束标记(前端读流读到done)就切换到“已完成”。这样用户一眼就能判断是模型没响应还是正在输出。
还有一个兜底:如果某个模型超过一定时间(比如30秒)没有返回任何数据,前端应该主动提示“该模型响应超时”。这个我在后端httpx层也加了超时控制,双端拦截,避免用户对着一个永久转圈的界面干等。
4.3 成本与速率控制
双路并发意味着一次对比就要消耗两个模型的Token,预算翻倍。我做了两个限制:
第一,让temperature、max_tokens作为前端可调参数,但max_tokens默认给一个保守值(比如1024),用户可以根据需要调大。流式输出如果对端设置了max_tokens,模型生成到上限就会自动截断,只要面板提示完整,用户心里有数就行。
第二,在系统提示词里要求模型“回答精炼,优先给出结论,不要展开过长”,这能显著减少不必要的Token消耗。别小看这步,同一次对比,A模型默认能输出1200字,B模型输出400字,你那边的Token消耗量和等待时间都会差一大截,而这个差距有一半可以通过提示词拉平。
再加上Redis或者内存限流器,按用户维度限制每秒最多发起N次对比请求,防止有人恶意刷接口把预算烧穿。我在内网工具里设的是每用户每分钟不超过10次,完全够用。
5. 进阶方向:让对比结果真正具备参考价值
5.1 控制变量:温度、提示词、上下文对齐
对比的前提是控制变量。如果左右两边用的提示词、温度参数、上下文历史都不一样,那对比出来的差异没有参考价值。所以我在请求后端时,把temperature作为对比请求的统一参数传给两边模型,系统提示词也由后端统一拼接,用户不需要手动对齐。
还得注意对话历史的隔离。做多轮对话对比时,左边模型的多轮历史不能泄漏给右边模型,两个模型的messages数组是彼此独立的,只在第一轮共享同一个用户问题。我在后端把messages构建工作放到前端传参之后、构造两路stream之前完成,保证两边拿到的是同一份原始输入,但各自维护自己的历史。
如果模型支持seed参数,可以尝试固定seed以降低随机性。但要注意,不同模型对seed的支持和语义不同,OpenAI系支持得比较好,部分模型却不支持这个参数。我的适配层写法是:模型配置里加一个supports_seed标记,支持就带seed=42,不支持就忽略,避免报错。
5.2 从双AI对比到多模型评测台
双路只是起点,架构实际上支持扩展到N路。队列合并逻辑里,remaining变量从2改成N,每个pump给一个唯一的model_tag即可,前端可以动态渲染N个面板。
这个方向再往前走一步,就成了一个轻量级的模型评测台。每个模型配一个独立的“温度”“系统提示词”配置项,同一组问题发给所有模型,旁边再给一个评分栏,人工打分后把结果落库。后续想统计哪个模型更适合当前场景,直接查表看分数分布就完事了。
我自己的下一步计划是把这个工具接到内部工作流里:使用者选定几个候选模型,输入典型的业务问题,并行输出后直接截图贴到需求评审文档里,作为模型选型的依据。比以往“把AI生成的结果给领导描述半天”高效得多。
最后再分享一个小技巧:面板的输出建议用pre-wrap样式渲染,保留换行符和空格,否则Markdown格式的代码块、表格在面板里全挤成一团,完全没法对比。这个细节不补上,整个工具的使用体验会大打折扣。
