Side-by-Side 是横向比模型、Prompt Lab 是纵向调 prompt,二者的技术实现完全是两套。当时前端设计稿出来,两个页面长得都像”左边输入、右边对比”,产品同学一度以为后端复用一套逻辑就行。结果等我真正动手做技术方案时才发现,这两个场景对实时性、数据存储、调用方式的要求完全相反,硬塞进一套通用实现,两头都会别扭。后来我按各自的业务特性拆成两套独立后端,把踩过的坑也沉淀了下来。下面把两者的本质差异、实现细节和取舍理由完整讲清楚。
一、先说本质差异
要理解两套后端为什么长不一样,先抓住一个根本区别:Side-by-Side 回答的是”哪个模型或哪份答案更好”,Prompt Lab 回答的是”同一个模型的这个 prompt 怎么改更好”。前者一次要调多个模型横向对比,后者是同模型反复试同一个 prompt 的纵向调优。这个区别直接决定了调用次数、数据模型和交互方式。
下面从核心问题、自变量、因变量等五个维度做对比:
| 维度 | Side-by-Side | Prompt Lab |
|---|---|---|
| 核心问题 | “哪个模型/答案更好” | “这个 prompt 怎么改更好” |
| 自变量 | 模型、参考答案 | 提示词、参数 |
| 因变量 | 人类/AI 评分 | 答案质量指标 |
| 调用次数 | 通常 2-N 个模型各 1 次 | 同模型多次(不同 prompt) |
| 数据存储 | 一组答案 + 评分 | 多版本 prompt + 各次输出 |
看完表格就明白:Side-by-Side 是一次性把多个模型拉来”同题竞技”,数据落库后还要评分;Prompt Lab 是单模型的反复试错,数据量小但迭代频率高。这两者的本质差异,决定了后面所有技术选型。
二、Side-by-Side 后端实现
数据模型
Side-by-Side 的核心是”一次评测会话包含多个模型输出”。所以数据模型拆成两张表:eval_session 存会话主体,eval_session_item 存每个模型的单条输出和评分。我当时设计时特意把评分字段放在 item 上而不是 session 上,因为不同模型答案质量独立,后续换评分员重评也只需要改 item:
CREATE TABLE eval_session (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
question TEXT NOT NULL,
reference TEXT,
status VARCHAR(16) NOT NULL, -- pending/running/done/error
created_at DATETIME
);
CREATE TABLE eval_session_item (
id BIGINT PRIMARY KEY,
session_id BIGINT NOT NULL,
model_code VARCHAR(64) NOT NULL,
prompt TEXT,
output LONGTEXT,
score DECIMAL(4,2),
duration_ms INT,
INDEX idx_session (session_id)
);
核心流程
会话的提交链路是异步的。用户提交 question 和 N 个模型后,后端先建会话,再把每个模型拆成一个任务丢进消息队列,Worker 并发去调各家模型接口,每完成一个更新一次进度。这个流程拆成六个步骤:
- 用户提交 question + 选定 N 个模型;
- 后端创建
eval_session,给每个模型创建一行eval_session_item; - 通过 RabbitMQ 异步并行调用各模型;
- 每完成一个 item 更新进度,session status 转 done;
- 调用 AI 评分 prompt 打分(可选多评委交叉);
- 前端 WebSocket 推送实时进度。
入口 Controller 只负责建会话和投递任务,真正的调用逻辑在 MQ 消费端。这里我踩过一个坑:最初把”调用模型”直接写在 Controller 里,一次请求要等全部模型返回,5 个模型串行跑要一两分钟,前端一直转圈。改成异步后才把耗时从”所有模型之和”降成”最长那个模型”:
@PostMapping("/session/submit")
public BaseResponse<Long> submit(@RequestBody EvalSubmitRequest req) {
long sid = evalService.createSession(req);
for (String model : req.getModelCodes()) {
mqSender.sendEvalTask(new EvalTask(sid, model, req.getQuestion()));
}
return ResultUtils.success(sid);
}
关键点
异步化之后还有三个细节不能省,否则线上会出各种幺蛾子。
- 并行调用而非串行:用 Flux.merge 或 CompletableFuture,多模型同时请求,5 个模型 20 秒完成(串行要 100 秒)。
- WebSocket 实时进度:用 STOMP 或原生 WebSocket,每个 item 完成就 push 一次,前端不轮询。
- 存储原始输出:所有模型输出完整保存,支持”事后人工复核”和”换评分员重评”。
存储原始输出这个点,是我做”评分漂移复查”时意识到的。AI 评分结果偶尔会波动,没有原始输出做底账,根本没法复盘到底哪个模型答了什么。现在每次评分变更都能追溯到当时的模型输出,运营同学处理申诉也快得多。
三、Prompt Lab 后端实现
数据模型
Prompt Lab 的核心是”版本”和”运行记录”。prompt_lab 存当前 prompt 配置,prompt_lab_run 存每一次运行的输入输出和费用。为什么每次运行都要单独存一行?因为用户调 prompt 靠的就是对比”上一版这么写、这一版那么写,输出差在哪”,没有运行快照,这个对比就无从谈起:
CREATE TABLE prompt_lab (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
name VARCHAR(128) NOT NULL,
model_code VARCHAR(64) NOT NULL,
system_prompt TEXT,
user_prompt TEXT,
temperature DECIMAL(3,2),
max_tokens INT,
created_at DATETIME
);
CREATE TABLE prompt_lab_run (
id BIGINT PRIMARY KEY,
lab_id BIGINT NOT NULL,
version INT NOT NULL, -- 第 N 次运行
output LONGTEXT,
input_tokens INT,
output_tokens INT,
fee DECIMAL(10,6),
created_at DATETIME,
INDEX idx_lab (lab_id, version)
);
核心流程
Prompt Lab 的交互链路和 Side-by-Side 完全相反,它是同步的、立即返回的:
- 用户编辑 prompt,点击”运行” → 调用 LLM,返回结果展示;
- 每次运行生成一行
prompt_lab_run,记录完整输入输出; - 用户对比多次运行结果(左侧历史版本,右侧最新),调 prompt;
- 支持”对比运行”:固定 system prompt,user prompt 写两个版本同次跑。
关键点
Prompt Lab 有三个技术选型,都是围绕”用户要快速试错”这个核心诉求展开的。
- 响应要快:用户调 prompt 是高频操作,单次调用必须 < 3s,所以走同步 API 不进 MQ。
- Token 计数是核心 UI:每次运行都展示本次 token、累计费用,用户据此决定是否继续调。
- 版本快照:每次 prompt 内容 + 参数完整快照到
prompt_lab_run,避免”覆盖写”导致历史回不去。
Token 计数这条在实现时很折腾:不同模型返回的 token 字段名不一样,有的叫 usage.prompt_tokens,有的藏在别处,我封装了一个统一的解析层把各家 usage 归一化,前端才拿到一致的展示格式。
四、后端架构差异
两个场景的数据流走的是两条完全不同的路径。Side-by-Side 是”提交 → 异步 → 推送”,Prompt Lab 是”提交 → 同步 → 返回”,用图表示最直观:
Side-by-Side 路径:
Client → Controller → MQ (异步) → Worker 调用 LLM → DB 写 result → WebSocket 推
Prompt Lab 路径:
Client → Controller → 同步调用 LLM → DB 写 run → 立即返回
两条路径在工程上的差异,用一张表能说清楚:
| 路径 | Side-by-Side | Prompt Lab |
|---|---|---|
| 入口 | Controller (REST) | Controller (REST) |
| 异步队列 | RabbitMQ | 无 |
| WebSocket | 是(实时进度) | 否 |
| 评分服务 | LLM-as-Judge | 不评分 |
| 限流策略 | 5 次/分钟 | 30 次/分钟 |
| 单次耗时 | 10-60s | 1-3s |
| 状态机 | pending/running/done | 无状态 |
限流策略的差异值得多说一句:Side-by-Side 一次调用多个模型、成本高,限得严;Prompt Lab 是高频试错,限得太严用户没法干活,就放宽到 30 次/分钟。这个数字是压测加观察用户行为后调出来的。
五、为什么不能合并
既然两个功能都涉及”调大模型 + 存输出”,早期我确实动过合并的心思,想用一个通用 eval_task 表兼容两个场景。结果真做起来,两边都难用:
- SxS 需要”多模型并行 + 实时进度”,强行复用单条 task 的同步返回会让前端等 30s;
- Lab 需要”快速试错 + 版本回滚”,合并后表字段 30 个,90% 字段在某一场景下是冗余的;
- 评分流程只在 SxS 有,Lab 用不到;
- 限流档位不同,合一个接口粒度不好做。
合并方案最致命的问题是”状态机”没法统一。SxS 有 pending/running/done 三态,还要支撑进度推送;Lab 根本不需要状态,每次运行都是独立快照。为了兼容而引入的状态字段,在两个场景里互相添乱。最终拆成 eval_session 与 prompt_lab 两套表加两套服务,互不污染,各自演进也快。这个教训我记了很久:相似的需求表面,后端设计别被”看起来像”带偏。
六、踩过的坑
两套系统上线前后踩过的坑,都列在这里,每个都对应一次线上事故或返工。
- SxS 进度推送丢消息:WebSocket 断线后用户重连,需要补发”已完成的 item”,实现”快照比对+增量推送”双通道。
- Lab 高频调用被风控:用户连续点 50 次运行,平台限流 30 次/分钟。给前端加按钮 loading + 倒计时引导。
- SxS 评分员和选手撞了:评分员要选”非选手”模型,否则自家模型评自家答案全 9 分。
- Lab 历史版本泄漏:早期覆盖写 user_prompt,用户改回旧版找不到之前那次的输出。改成 immutable 追加 +
latest_version标记。
WebSocket 丢消息这个坑影响最大:用户提交评测后页面进度卡在 2/5,刷新也回不去。后来定位是断线重连没有补发机制。改成重连时先下发会话快照、再推送增量后,进度就再没丢过。Lab 覆盖写那个坑则属于设计缺陷,不是 bug,immutable 追加之后,用户每次运行都有独立版本,彻底告别”找不到上次输出”的投诉。到这一步,双场景的后端架构就稳定运行了,后续再扩展新评测玩法,直接照这两套模板加即可。
常见问题(FAQ)
Q1:SxS 和 Lab 数据能互相转换吗?
能。SxS 的每个 item 可以”提取 prompt 存到 Lab”,Lab 的 prompt 也可以”跑多模型转成 SxS session”。平台提供转换按钮。
Q2:为什么 SxS 走 MQ 异步而 Lab 不走?
SxS 一次提交多个模型任务、要实时进度,异步队列天然适合;Lab 用户期待”立等可取”,异步反而让交互变卡。
Q3:两者能否共享模型价目/token 计数?
能。这部分下沉到 usage_record 公共表,任意调用都写一行,平台统一查询、统计、限价。