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 调用、结果入库四步,执行顺序是固定的:
- 取模板并做归属校验,防止越权操作;
- 校验并渲染变量,把
{{var}}替换成真实值; - 组装 ChatRequest 并同步调用 LLM;
- 保存运行结果(成功或失败都要存)并返回给前端。
核心代码如下:
@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 只能本人访问。