Prompt Lab 后端实现详解(AI 大模型评测平台的提示词实验室架构)

Prompt Lab 的价值,最终落在”改词、换变量、对比版本”能不能秒级反馈上。平台上的用户在调 prompt 时有很强的试错需求——改一个词、换一个变量、同时对比两个版本,每次都希望立刻看到效果。我最初按普通 CRUD 的思路做了一版,结果交互又慢又笨,前端等着转圈,用户反馈很差,返工了两轮,才把”快响应、版本化、可对比、可复用”这四件事真正落地。这篇文章把后端的完整设计从头讲一遍,包括数据模型、运行链路和踩过的坑。

一、核心功能拆解

需求看起来简单:左边编辑 prompt,右边看输出。但真要落地,后端要承担的事情比表面多得多:

功能 后端职责
实时运行 同步调 LLM,1-3s 返回
版本快照 每次运行 immutable 存档
多 prompt 对比 同次跑多个 user_prompt
变量渲染 Mustache 模板引擎 + 校验
收藏/复用 标记”我的模板” / “公共模板”

这张表其实是我们踩坑踩出来的。比如”版本快照”一开始没做,用户改完 prompt 发现效果变差,想回退到上一版,结果找不回来,后来才补上每次运行都存档的机制。

二、数据模型

功能拆清楚之后,数据模型就顺理成章了。核心思路是”模板”和”运行结果”分开存,避免一张表既放结构又堆历史:

CREATE TABLE prompt_lab (
  id           BIGINT PRIMARY KEY,
  user_id      BIGINT NOT NULL,
  name         VARCHAR(128) NOT NULL,
  scene_code   VARCHAR(64),               -- 可选归属场景
  model_code   VARCHAR(64) NOT NULL,
  system_prompt TEXT,
  user_prompt  TEXT NOT NULL,
  variables    JSON,                       -- 变量定义
  parameters   JSON,                       -- temperature/max_tokens
  is_favorite  TINYINT DEFAULT 0,
  is_public    TINYINT DEFAULT 0,
  created_at   DATETIME,
  updated_at   DATETIME
);

CREATE TABLE prompt_lab_run (
  id            BIGINT PRIMARY KEY,
  lab_id        BIGINT NOT NULL,
  user_id       BIGINT NOT NULL,
  inputs        JSON,                       -- 实际输入的变量值
  output        LONGTEXT,
  input_tokens  INT,
  output_tokens INT,
  fee           DECIMAL(10,6),
  duration_ms   INT,
  status        VARCHAR(16),                -- ok/error
  error_msg     VARCHAR(512),
  created_at    DATETIME,
  INDEX idx_lab (lab_id, created_at)
);

prompt_lab 存”模板”,prompt_lab_run 存”每次运行结果”,两表分离让 Lab 本身轻量。运行记录是只增不减的流水,后面做历史回放、成本统计都靠它。

三、核心 API 设计

API 设计遵循”CRUD + 核心动作单独端点”的思路。运行和对比都是带副作用的高频操作,单独拆出来便于做权限校验和限流:

@RestController
@RequestMapping("/api/lab")
public class PromptLabController {

    // CRUD
    @PostMapping public BaseResponse<Long> create(@RequestBody LabDTO dto);
    @PutMapping("/{id}") public BaseResponse<Void> update(@PathVariable long id, @RequestBody LabDTO dto);
    @GetMapping("/{id}") public BaseResponse<LabVO> get(@PathVariable long id);
    @GetMapping public BaseResponse<Page<LabVO>> list(@RequestParam LabQuery query);
    @DeleteMapping("/{id}") public BaseResponse<Void> delete(@PathVariable long id);

    // 核心:运行
    @PostMapping("/{id}/run")
    public BaseResponse<RunResultVO> run(@PathVariable long id, 
                                          @RequestBody RunRequest req);

    // 运行历史
    @GetMapping("/{id}/runs")
    public BaseResponse<Page<RunResultVO>> listRuns(@PathVariable long id,
                                                      @RequestParam PageQuery pq);

    // 对比运行(一次跑多个 prompt 版本)
    @PostMapping("/compare")
    public BaseResponse<List<RunResultVO>> compare(@RequestBody CompareRequest req);
}

初版我把 run 也塞进 CRUD 里用 PUT 传参,被同事吐槽语义混乱,重构后才拆出 /run 和 /compare 两个动作端点,阅读和调用都清晰多了。

四、运行接口核心实现

运行接口是整个 Lab 的心脏,一条链路上要串起模板校验、变量渲染、LLM 调用、结果入库四步,执行顺序是固定的:

  1. 取模板并做归属校验,防止越权操作;
  2. 校验并渲染变量,把 {{var}} 替换成真实值;
  3. 组装 ChatRequest 并同步调用 LLM;
  4. 保存运行结果(成功或失败都要存)并返回给前端。

核心代码如下:

@PostMapping("/{id}/run")
public BaseResponse<RunResultVO> run(@PathVariable long id, 
                                      @RequestBody RunRequest req) {
    long t0 = System.currentTimeMillis();
    
    // 1) 取模板
    PromptLab lab = labService.getById(id);
    if (!lab.getUserId().equals(currentUser.getId())) {
        throw new BusinessException(ErrorCode.NO_AUTH_ERROR);
    }
    
    // 2) 校验变量
    Map<String, Object> vars = req.getVariables();
    variableValidator.validate(lab.getVariables(), vars);
    
    // 3) 渲染 prompt
    String userPrompt = templateEngine.render(lab.getUserPrompt(), vars);
    String systemPrompt = StringUtils.isNotBlank(lab.getSystemPrompt()) 
        ? templateEngine.render(lab.getSystemPrompt(), vars) : null;
    
    // 4) 调 LLM(同步)
    ChatRequest cr = ChatRequest.builder()
        .modelCode(lab.getModelCode())
        .systemPrompt(systemPrompt)
        .userPrompt(userPrompt)
        .parameters(lab.getParameters())
        .build();
    
    ChatResult res;
    try {
        res = llmClient.call(cr);
    } catch (Exception e) {
        // 失败也要存 run 记录,方便用户看错误
        runService.save(RunResult.error(lab, vars, e.getMessage(), 
                                         System.currentTimeMillis() - t0));
        throw new BusinessException(ErrorCode.LLM_ERROR, e.getMessage());
    }
    
    // 5) 存运行结果
    long cost = System.currentTimeMillis() - t0;
    RunResult run = RunResult.success(lab, vars, res, cost);
    runService.save(run);
    
    // 6) 返回(带 usage 给前端展示 token)
    return ResultUtils.success(RunResultVO.from(run));
}

注意第 4 步的 try/catch:调用失败时也把 run 记录存下来,用户能看到错误信息而不是干等超时。这个细节是线上事故教会的,早期失败直接抛异常,用户连”哪里错了”都不知道。

五、对比运行(Compare)

用户调 prompt 时最常干的事就是”两个版本比一比”。对比接口让用户选 N 个 prompt 版本 + 1 个模型,平台同次并行调:

@PostMapping("/compare")
public BaseResponse<List<RunResultVO>> compare(@RequestBody CompareRequest req) {
    List<PromptLab> labs = labService.listByIds(req.getLabIds());
    
    // 并行调用
    List<RunResult> results = labs.parallelStream()
        .map(lab -> {
            Map<String, Object> vars = req.getVariables();
            String userPrompt = templateEngine.render(lab.getUserPrompt(), vars);
            try {
                ChatResult res = llmClient.call(ChatRequest.builder()
                    .modelCode(lab.getModelCode())
                    .userPrompt(userPrompt)
                    .build());
                return RunResult.success(lab, vars, res, ...);
            } catch (Exception e) {
                return RunResult.error(lab, vars, e.getMessage(), ...);
            }
        })
        .toList();
    
    results.forEach(runService::save);
    return ResultUtils.success(results.stream().map(RunResultVO::from).toList());
}

这里有个容易忽略的坑:一个 Lab 调用失败不能拖垮整个对比,所以每个 Lab 独立 try/catch,失败的那个返回 error 记录,其他照常返回。前端用左右两栏展示”prompt v1 输出 vs prompt v2 输出”。

六、变量校验

模板里用 {{var}} 占位符,变量校验是运行前的一道关卡。用户传错类型、漏传必填项,都要在这里拦下来:

public void validate(JSONArray defs, Map<String, Object> actual) {
    for (var def : defs) {
        String name = def.getStr("name");
        boolean required = def.getBool("required", false);
        if (required && !actual.containsKey(name)) {
            throw new BusinessException("变量必填: " + name);
        }
        // 类型校验
        String type = def.getStr("type", "string");
        Object v = actual.get(name);
        if (v != null && !typeCheck(v, type)) {
            throw new BusinessException("变量类型错误: " + name);
        }
    }
}

早期这里只做了必填校验,类型校验是后面补的——用户把 {{n}} 传成字符串 “3.14”,跑出来的结果完全不可信,排查半天才发现是类型问题。

七、性能优化

Lab 调 prompt 是高频操作,用户可能几分钟内连点十几次,性能直接决定体验。平台做了几处优化:

优化 实现 效果
模板缓存 Caffeine 存渲染结果,变量组合做 key 重复调用 80% 命中
客户端流式 改用 SSE 而不是等完整结果 首字延迟 < 500ms
历史分页 LIMIT 20 OFFSET + 索引 翻页 < 100ms
模型预热 用户切换模型时预 ping 切换感知 < 1s

这里面收益感受明显的当属模板缓存。同一份 prompt 只是变量值不同,缓存命中后渲染几乎零开销,用户反复调试时的响应速度提升了一大截。

八、与场景市场的衔接

Lab 不只是个人工具,平台上有大量”场景市场”里沉淀的成熟 prompt,我们希望用户能一键拿来用:

@PostMapping("/lab/fromScene/{sceneCode}")
public BaseResponse<Long> cloneFromScene(@PathVariable String sceneCode,
                                          @RequestParam(required=false) Long version) {
    // 1) 查场景的 active prompt
    Prompt p = promptService.getActiveBySceneCode(sceneCode, version);
    // 2) 复制为我的 Lab
    PromptLab lab = new PromptLab();
    BeanUtils.copyProperties(p, lab);
    lab.setId(null);
    lab.setUserId(currentUser.getId());
    lab.setIsPublic(0);
    labService.save(lab);
    return ResultUtils.success(lab.getId());
}

clone 的时候一定记得把 userId 换成当前用户、isPublic 置 0,否则用户改自己的 Lab 会把场景市场的原始模板改坏。这个坑在需求评审时就被拦下来了,写下来提醒各位注意。

九、踩过的坑

上线半年,Lab 这块踩的坑整理成清单,基本覆盖了常见的线上问题:

  • 流式输出导致 token 计数失败:SSE 流最后一个 chunk 才有 usage 字段,必须等流结束才统计。
  • 变量类型校验不严格:用户传 {{n}} 期望整数,传了字符串 “3.14” 也通过校验。改用 JSON Schema 严格校验。
  • 对比运行单点失败拖死整体:一个 Lab 调用失败时,整体接口要降级为”返回已成功的部分”+”失败原因”,不要让 5 个全失败。
  • 历史记录膨胀:Lab 用 6 个月后 prompt_lab_run 表上亿行。按 lab_id 分表 + 冷数据归档到 OSS。
  • 公共 Lab 被改坏:用户改了 clone 来的 Lab 改坏了源。clone 时 copy 完整 snapshot,独立编辑。

十、与 SxS 评测的转换

Lab 的终点是评测。用户在 Lab 里调好一个 prompt,下一步就是拿它去跑多个模型对比效果,一键转换的接口把两条链路打通:

@PostMapping("/{id}/toEval")
public BaseResponse<Long> toSxS(@PathVariable long id, 
                                  @RequestBody ToSxSRequest req) {
    PromptLab lab = labService.getById(id);
    // 选 N 个模型同跑这个 prompt
    EvalCreateRequest eval = new EvalCreateRequest();
    eval.setQuestion(req.getQuestion());
    eval.setPrompt(lab.getUserPrompt());
    eval.setModelCodes(req.getModelCodes());
    return ResultUtils.success(evalService.create(eval));
}

到这里,Prompt Lab 从创建模板、变量渲染、实时运行、版本对比到一键送评的完整链路就闭环了。

常见问题(FAQ)

Q1:为什么 Lab 用同步 API 不用 MQ?

用户期待”立等可取”,异步会让交互变卡。Lab 调通后会转 SxS 异步跑多模型。

Q2:Lab 运行结果要保留多久?

默认永久保留(用户工作资产),提供”批量删除 30 天前”工具。

Q3:能跨用户复用 Lab 吗?

能,公共 Lab 所有人可见,clone 后可独立编辑。私有 Lab 只能本人访问。

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

相关推荐

返回顶部