同一个模型同一份输入,几个 prompt 版本到底哪个效果更好——被反复追问的问题,最初只能靠手动翻记录来回答。后来我主导做了”对比模式”——固定一个模型,2 到 5 个 prompt 版本同一批跑,输出横向排列。看着简单,落地时踩了不少坑:变量一致性、并发隔离、结果对齐、视觉对比,每个都藏着细节。这篇把这套功能的实现完整拆开,从数据结构到并发控制到投票机制。
一、什么是”单模型多提示词对比”
先明确边界:对比模式对比的是 prompt,不是模型。模型固定,输入变量共享,只有 userPrompt 是变量,这样跑出来的差异才能归因到 prompt 本身,而不是被其他因素搅浑。
固定:model_code = "gpt-4o"
变量:5 个不同版本的 user_prompt
操作:5 个 prompt 同次并发调用
输出:横向 5 列展示结果
用户场景:调 prompt 不知道哪个好,让平台帮”同条件”对比。这个功能上线后成了 Prompt Lab 使用率位居前列的功能,调 prompt 的用户几乎都靠它做版本筛选,而不是靠肉眼来回切换标签页。
二、对比请求的数据结构
请求设计决定后端处理顺不顺畅。我们定了三条原则:变量尽量共享、每个 prompt 保留独立覆盖能力、生成参数全局统一。下面就是最终的请求结构。
{
"modelCode": "gpt-4o",
"prompts": [
{ "name": "v1 基础版", "userPrompt": "翻译:{{text}}", "variables": { "text": "Hello" } },
{ "name": "v2 加风格", "userPrompt": "用文艺风格翻译:{{text}}", "variables": { "text": "Hello" } },
{ "name": "v3 加解释", "userPrompt": "翻译并解释:{{text}}", "variables": { "text": "Hello" } }
],
"sharedVariables": { "text": "Hello" },
"temperature": 0.7,
"maxTokens": 500
}
设计要点:
sharedVariables复用同一份输入变量,避免每条 prompt 重复传;- 每个 prompt 独立
userPrompt字符串,可含独立变量; temperature/maxTokens共享保证”同一条件下”对比。
这套结构的好处是前后端一一对应:前端一个表单就能驱动,用户改共享变量时所有 prompt 同步更新;后端解析也直接,sharedVariables 和 variables 两层合并就行,不需要额外约定。
三、后端对比接口
后端核心是并行调用和顺序保证。这里我们特意选了 CompletableFuture 而不是响应式 Flux,原因很朴素:返回结果必须跟请求里的 prompt 顺序一一对应,futures 按索引 join 天然有序,不会出现顺序错乱。
@PostMapping("/api/lab/compare")
public BaseResponse<List<RunResultVO>> compare(@RequestBody CompareRequest req) {
// 1) 校验所有 prompt 变量齐全
req.getPrompts().forEach(p ->
variableValidator.validate(p.getVariables(), p.getUserPrompt()));
// 2) 并行调用(用 CompletableFuture 而非 Flux,保证顺序与请求一致)
List<CompletableFuture<RunResult>> futures = req.getPrompts().stream()
.map(p -> CompletableFuture.supplyAsync(() -> {
try {
String rendered = templateEngine.render(
p.getUserPrompt(), mergeVariables(p, req.getSharedVariables()));
ChatResult res = llmClient.call(ChatRequest.builder()
.modelCode(req.getModelCode())
.userPrompt(rendered)
.temperature(req.getTemperature())
.maxTokens(req.getMaxTokens())
.build());
return RunResult.success(p, res, calculateFee(res));
} catch (Exception e) {
return RunResult.error(p, e.getMessage());
}
}, executor))
.toList();
// 3) 等所有完成(任一失败不影响其他)
List<RunResult> results = futures.stream()
.map(CompletableFuture::join)
.toList();
// 4) 持久化
results.forEach(runService::save);
return ResultUtils.success(results.stream()
.map(RunResultVO::from)
.toList());
}
每个 prompt 的失败在 lambda 里被 catch 住,转成 RunResult.error,一个失败不会拖垮整批,前端能把失败列标红展示。calculateFee 在返回前就算好费用,前端直接展示,避免前后端各算一遍对不上账。
四、关键技术难点
对比模式的核心难点集中在四个词:并发隔离、单点失败、变量一致性、结果对齐。逐个拆开说,顺序正好是后端到前端。
1. 并发隔离
对比的语义是”同时开始”。串行跑 5 个 prompt,最后一个要等前面 4 个都结束才能开始,用户白白多等几十秒。我们用 CountDownLatch 做一个”起跑线”:所有 future 都创建好,一声令下同时发。
// 用 CountDownLatch 等所有 prompt 都准备好再一起发
CountDownLatch startLatch = new CountDownLatch(1);
CountDownLatch doneLatch = new CountDownLatch(req.getPrompts().size());
List<RunResult> results = new ArrayList<>();
List<CompletableFuture<RunResult>> futures = req.getPrompts().stream()
.map(p -> CompletableFuture.supplyAsync(() -> {
try {
startLatch.await(); // 等所有 future 都创建好
ChatResult res = llmClient.call(...);
return RunResult.success(p, res, ...);
} catch (Exception e) {
return RunResult.error(p, e.getMessage());
} finally {
doneLatch.countDown();
}
}, executor))
.toList();
// 一次性释放所有任务
startLatch.countDown();
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
实测数据很直观:5 个 prompt 顺序执行 25 秒,并发只要 5 秒,差距全在等待上。doneLatch 的作用是保证主流程在所有任务真正结束后才返回,不会出现只等完一半就响应的情况。
2. 单点失败不拖整体
5 个 prompt 里 1 个调用失败(超时、限流、模型返回 5xx),不能影响另外 4 个的结果展示。catch 住异常并返回错误标记。
CompletableFuture.supplyAsync(() -> {
try {
ChatResult res = llmClient.call(...);
return RunResult.success(p, res, ...);
} catch (Exception e) {
return RunResult.error(p, e.getMessage()); // 失败也返回
}
}, executor);
前端渲染时根据错误标记降级展示,失败列显示错误信息,其余列正常出结果。
<div v-for="r in results" :key="r.id" class="compare-col">
<h4>{{ r.name }} <el-tag v-if="r.error" type="danger">失败</el-tag></h4>
<div v-if="!r.error" class="output">{{ r.output }}</div>
<div v-else class="error">错误: {{ r.error }}</div>
<footer>用时: {{ r.durationMs }}ms · 费用: ¥{{ r.fee }}</footer>
</div>
模板里用 v-if/v-else 分流正常输出和错误态,footer 统一展示耗时和费用。用户能一眼看出哪列失败、为什么失败,而不是整批结果都拿不到。
3. 变量一致性
共享变量的坑在于:用户在 v1 里写了 {{text}},复制到 v2 时不小心改成 {{content}},那改共享变量时 v1 变了、v2 没变,对比的前提就没了。我们前后端用同一套合并逻辑。
// 前端
const sharedVars = ref({ text: 'Hello' })
const prompts = ref([
{ name: 'v1', userPrompt: '翻译:{{text}}' },
{ name: 'v2', userPrompt: '用文艺风格翻译:{{text}}' }
])
// 渲染时合并
const renderedPrompts = computed(() => prompts.value.map(p => ({
...p,
rendered: renderTemplate(p.userPrompt, sharedVars.value)
})))
后端同样合并:
private Map<String, Object> mergeVariables(
CompareRequest.PromptItem p, Map<String, Object> shared) {
Map<String, Object> all = new HashMap<>(shared);
if (p.getVariables() != null) all.putAll(p.getVariables());
return all;
}
合并规则是 shared 打底、prompt 独立变量覆盖。前端预览和后端实际渲染用同一套合并逻辑,保证所见即所得——用户看到的预览结果,就是后端真正发给模型的内容。
4. 结果对齐
5 列结果要等高排列,Markdown 和高亮要同步生效,否则视觉上参差不齐,横向对比就没有意义。
.compare-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 16px;
}
.compare-col {
min-height: 200px;
max-height: 600px;
overflow-y: auto;
}
grid 布局固定五列,每列独立滚动,内容多时撑到 600px 上限,不破布局。代码高亮、Markdown 渲染要同步生效:
// 用 v-html + 高亮库
import { marked } from 'marked'
import hljs from 'highlight.js'
const rendered = computed(() => marked.parse(r.output, {
highlight: (code) => hljs.highlightAuto(code).value
}))
统一用一个 marked 实例和同一份高亮配置,避免不同列渲染效果不一致——这个坑我们真踩过,v1 列的高亮配置和 v3 列不一致,同一段代码两列颜色完全不同,排查了半天才发现是两个实例配置漂移了。
5. 评分接入
对比完只是第一步,用户得能表达”哪个更好”。我们加了投票机制,每个对比组支持投票,胜率累计到对应 prompt 上。
@PostMapping("/api/lab/compare/{groupId}/vote")
public BaseResponse<Void> vote(@PathVariable long groupId,
@RequestBody VoteRequest req) {
// 1) 找出 groupId 对应的 N 个 run
List<RunResult> results = runService.listByGroup(groupId);
// 2) 记录用户投票
voteService.save(new Vote(groupId, req.getWinnerRunId(),
currentUser.getId()));
// 3) 累加每个 prompt 的胜率
results.forEach(r -> runService.incVote(r.getId(),
r.getId() == req.getWinnerRunId() ? 1 : 0));
return ResultUtils.success();
}
投票数据沉淀下来,长期能看出哪种风格的 prompt 更容易胜出,等于平台白赚了一套 prompt 优化数据,后续还能反哺推荐。
五、典型用户工作流
把用户的实际操作串起来看,功能是否好用一目了然。核心是”改 prompt – 对比 – 投票 – 再改”的闭环。
1. 选模型(gpt-4o)
2. 写 prompt v1
3. 复制 v1 改 v2
4. 复制 v1 改 v3
5. 设置变量 text=Hello
6. 点「对比」
7. 看 3 列结果
8. 投票给最优的那个
9. 改 prompt 继续对比
用户一般循环两三轮就能锁定一个版本,整个流程不需要离开页面,这是我们设计时最在意的体验点。
六、与单次运行的差异
对比模式并不是简单地把单次运行复制 N 份,它在并发、失败处理、变量管理上都有本质区别。下面这张表把两种模式并排比较。
| 维度 | 单次运行 | 多 prompt 对比 |
|---|---|---|
| 调用次数 | 1 | N(2-5) |
| 并发 | 串行 | 并行 |
| 失败处理 | 直接报错 | 降级展示 |
| 变量 | 独立 | 共享 + 独立 |
| 成本 | 1x | Nx |
| UI | 1 个输出卡 | N 列对比 |
| 用途 | 调单个 prompt | 横向筛选 |
单次运行面向”调试单个 prompt”,对比模式面向”批量筛选最优版本”。前者要的是速度和准确性,后者要的是可比性和容错,两者的设计目标完全不同。
七、性能优化
对比模式天然并发,对上游模型服务的压力需要控制住。我们做了两层保护:共享 HTTP 连接池和并发信号量。
// 共享 HTTP 连接(如果用同一模型)
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.build();
// 并发限流:5 个 prompt 不至于打爆上游
Semaphore sem = new Semaphore(5);
prompts.forEach(p -> CompletableFuture.runAsync(() -> {
sem.acquire();
try { llmClient.call(...); }
finally { sem.release(); }
}));
信号量上限设成和单次对比的 prompt 数一致,防止极端情况下多个用户同时点对比,瞬时请求把上游模型网关打爆,触发 429 限流。
八、踩过的坑
这个功能迭代了大半年,踩过的坑都在下面。有几条是上线后用户反馈才暴露的,有几条是压测发现的。
- 变量共享冲突:用户把
{{text}}写到 prompt 1 又写到 prompt 2,共享变量改名后两边都变。要在 UI 提示”变量名应保持一致”。 - 并发超时雪崩:5 个 prompt 同时打超时点。评委侧做”分散超时”(10s、12s、14s、16s、18s 错开)。
- Markdown 渲染不一致:每个输出用不同 marked 配置。统一 import 一个 marked 实例。
- 结果错位:futures.join() 顺序与原始 prompt 列表不一致。
f.thenApply时记录原索引,最终按索引排序。 - 单模型速率限制:5 个 prompt 同一秒打过去触发 429。错开启动时间(50ms 间隔)。
- 费用展示精度:5 次调用的 token 累加展示,避免用户看单个不准。
这些坑大多集中在”并发带来的副作用”上:超时一起到、限流一起撞、顺序被打乱。处理思路就一条——把并发尽量错开、把顺序显式管理,剩下的交给兜底逻辑。
九、保存对比结果
对比结果要能存下来,方便用户回溯,也方便沉淀数据集。我们用一张对比组表加 run 表的关联字段解决。
CREATE TABLE prompt_lab_compare_group (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
name VARCHAR(128),
model_code VARCHAR(64),
shared_vars JSON,
created_at DATETIME
);
prompt_lab_run 加 group_id 字段关联。一个对比组就是一次完整的对比快照——模型、共享变量、参与运行的 prompt 全部存下来,前端历史列表按组展示,用户随时能回到三个月前的某次对比。
十、AB 测试进阶
有数据沉淀之后,高级用户会拿对比组做统计。对同一个 prompt 的不同版本跑固定次数,再比成功率。
v1 提示词 100 次
v2 提示词 100 次
→ 统计成功率、平均分
→ 显著性检验
平台提供自动 A/B 框架(实验性功能)。样本量不够时只展示描述统计,不做显著性结论,避免小样本下的偶然差异误导用户。
常见问题(FAQ)
Q1:对比模式能用不同模型吗?
不能。多 prompt 对比固定单一模型。多模型对比用 SxS 模式(Side-by-Side)。
Q2:最多几个 prompt 对比?
5 个。再多视觉上不好对比,且费用高。
Q3:对比能保存吗?
能,保存为”对比组”,可在历史里反复查看。