Prompt Lab 单模型多提示词对比实操方法(AI 大模型评测平台的核心功能实现)

同一个模型同一份输入,几个 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:对比能保存吗?

能,保存为”对比组”,可在历史里反复查看。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部