评测平台的主要难点,在于两件不可控的事叠在一起:”不可控的 LLM 输出”遇上”复杂的工程场景”。传统软件开发的预期是输入确定、输出可断言,而评测平台每天面对的是概率采样出来的文本,同一句话可能给出完全不同的评价。一开始我们按老经验搭架构,结果线上连续踩了几个坑才意识到,这套业务需要的是另一种设计思维。下面按难度从高到低复盘,每一条都记录了问题本身、我们卡在哪、最后怎么解的。
一、最难:LLM 评分稳定性
评分稳定是评测平台的生死线,也是我们花时间最多的地方。如果评分本身不可信,平台存在的意义就没了,用户凭什么信你的结论。
问题
同一答案两次评分可能差 1-2 分,同一答案不同评委差 2-4 分,prompt 改一个字分数可能波动 10%。
难点
LLM 输出本质是概率采样,没有”绝对正确”。这与传统软件测试的”期望值断言”完全不同。
这个难点最折磨人的地方是:它不报错。传统软件如果逻辑有问题,会直接失败,很快暴露;LLM 评分漂移是悄悄发生的,用户报告”上次 8 分这次 7 分”,我们才发现问题。它不可复现、不好定位,只能靠体系化的约束去压住,这对习惯了”出 bug 就修”的团队是思维上的转变。
解决路径
我们分五步把漂移逐步压下去,每一步都有明确目的:
- 温度设 0(greedy)减少随机性,但仍有浮动;
- 多评委 + 中位数:3 个不同家族模型打分取中位数,方差 < 1.5 算一致;
- Prompt 锚定:每个分数档(9-10、7-8、5-6…)配详细描述,缩小主观空间;
- A/B 测试评分 prompt:每版跑 200 条历史答案,对比与人工评分相关性;
- 持续校准:每月抽样 100 条”高置信度”做人工校准,监控准确率。
这五步是逐步叠加的。一开始只设温度 0,漂移从 2-4 分压到 1-2 分,还不够;加上多评委取中位数后,方差明显收敛;再配上 prompt 锚定和月度校准,评分才真正稳下来。每一步的收益都能量化,这也是我们敢持续投入的原因——看到数字在变好,团队才愿意坚持下去。
经验
评分不是”一次到位”的工作,是”持续优化”的工作。永远不要相信一次评分结果。
二、并列最难:流式输出的完整性
流式输出看起来是”打字机效果”,真正落地才发现坑在尾巴上。用户看到的是逐字蹦出来的文字,我们盯的是最后一帧的收尾。
问题
LLM 流式响应是 SSE 协议,每个 chunk 是文本片段,最后一个 chunk 才返回 token 用量。要做:
- 实时显示打字机效果;
- 准确统计 token;
- 处理断流(网络中断、用户取消);
- 失败时部分输出不丢。
难点
- 前端要”流式展示 + 流式取消”;
- 后端要”实时推 chunk + 完整收尾 + 异常时部分保存”;
- 中断时已用 token 仍需计费(不能让用户白花)。
最难的是计费:用户看到一半关掉页面,前端直接断连,但服务端已经消耗了 token,这笔费用必须记上,否则平台会亏。这意味着”取消”这个用户动作,对后端来说不是简单的停止,而是”结算收尾”。一开始我们没想清楚这一点,后来对账时发现大量漏记的 token,才意识到取消也要算钱。
解决路径
后端用 Spring WebFlux 的 SSE 控制器,把每个 chunk 包装成 ServerSentEvent,并在 doOnCancel 里保存部分用量:
// 后端 SSE 控制器
@GetMapping(value = "/stream", produces = TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> stream(@RequestBody ChatRequest req) {
return chatProvider.stream(req)
.map(chunk -> ServerSentEvent.builder(chunk.text()).build())
.doOnCancel(() -> {
// 客户端断连也要保存已用 token
usageLogService.savePartial(req, partialUsage);
})
.concatWith(Flux.just(ServerSentEvent.builder("[DONE]").build()));
}
// 前端 fetch + AbortController
const controller = new AbortController()
fetch(url, { signal: controller.signal, body: ... })
.then(async res => {
const reader = res.body.getReader()
// 逐 chunk 处理
// AbortController.abort() 触发后端 doOnCancel
})
这里前后端契约要想清楚:前端 abort() 后,后端 doOnCancel 会被触发,但它并不保证一定执行成功,所以部分用量还要落一份兜底日志,由对账任务去补记。只依赖一处记费用,早晚会漏,这条经验是我们被对账数据教育之后补上的。
经验
流式是 LLM 应用的标配,但完整 + 可靠 + 可取消的流式远没那么简单。
三、跨实例实时推送
批量任务跑起来之后,用户要实时看进度,但任务可能在任何一台实例上完成,推送就成了分布式问题。
问题
4 个实例跑应用,用户的 WebSocket 连在实例 A,批量任务在实例 B 消费完成,要给用户推进度。
难点
实例之间不共享内存,需要分布式协调。
解决路径
我们把推送拆成两步:本实例有连接就直接推,同时通过 Redis Pub/Sub 广播给所有实例,由持有该会话的实例转发:
// WebSocket 推服务
public void broadcast(long batchId, String msg) {
// 1) 本实例直接推
localPush(batchId, msg);
// 2) 跨实例通过 Redis Pub/Sub
redis.convertAndSend("ws:batch:" + batchId, msg);
}
// 所有实例订阅
@EventListener
public void onMessage(Message msg) {
if (hasLocalSession(batchId)) {
localPush(batchId, msg.getBody());
}
}
这里要注意 Redis Pub/Sub 是”发后即焚”,没有持久化。如果订阅方短暂断连,消息就丢了。我们对进度推送的容忍度是”偶尔丢一条可以,状态以服务端为准”,所以重连时用 SNAPSHOT 全量同步兜底,两条机制配合才完整。如果你做的场景要求一条不丢,Pub/Sub 就不够用了,得换消息队列。
经验
分布式协调工具是最后才上的——本地消息队列能解决 80% 的问题,不要一上来就 Redis Pub/Sub。
四、复杂状态的任务调度
评测平台的批量任务是一个典型的多维状态机问题,复杂度不来自单点逻辑,来自状态的组合。用户看到的是进度条,背后是两层状态在联动。
问题
5000 个测试用例 × 5 个模型 = 25000 子任务,要:
- 并发执行(不能串行 5 小时);
- 进度可见(用户能看实时进度);
- 失败重试(个别任务失败不能拖死整批);
- 优先级(VIP 用户优先);
- 资源控制(不能把模型打爆)。
难点
多维度约束下的状态机——子任务有 4 个状态(pending/running/success/fail),父任务要根据子任务状态实时更新,批次要根据任务状态实时更新,UI 要根据所有状态实时显示。
解决路径
进度累加用原子 SQL,避免并发更新互相覆盖:
-- 原子 SQL 累加进度
UPDATE eval_task
SET done_count = done_count + 1,
status = IF(done_count + 1 = total_count, 'done', 'running')
WHERE id = ?;
- 用 RabbitMQ 做任务队列;
- 用 Semaphore 做并发控制(全局 + 模型双层);
- 用 WebSocket 推进度(500ms 聚合一次避免风暴);
- 用 SQL 原子累加避免竞态。
进度更新这块我们踩过坑:最初是”先查再更新”的读改写,高并发下进度会回退,用户看到 30% 跳到 25%,体验很差。改成 SQL 原子累加后问题消失。状态机的每一处更新都必须是原子的,这是调度系统最重要的工程约束,靠应用层代码保证不如靠 SQL 保证来得稳。
经验
任务调度系统复杂度是 O(任务数 × 状态数 × 推送频率)。先小流量验证,再放大。
五、成本控制
LLM 是外部付费服务,成本失控是评测平台特有的风险,普通互联网平台没有这个痛点——服务器贵是固定开销,token 贵是随用量爆发的开销。
问题
用户提交 1000 个评测,每个 5 模型 × 1000 token,每个 token 0.01 元/1k,单次成本 50 元。一个月 1000 次就是 5 万元。
难点
- 实时计数(不能月底算账);
- 预算告警(不能等到欠费);
- 公平(VIP 用户额度比普通多);
- 灵活(不同时段不同模型价格不同)。
解决路径
成本用 Redis 累加,配合日预算检查,超了就告警并拦截:
// Redis INCRBYFLOAT 累加
public BigDecimal accumulateAndCheck(long userId, BigDecimal fee) {
String key = "budget:daily:" + userId + ":" + LocalDate.now();
BigDecimal today = new BigDecimal(
redisTemplate.opsForValue().increment(key, fee.doubleValue())
);
redisTemplate.expire(key, Duration.ofDays(2));
if (today.compareTo(user.getBudgetDaily()) > 0) {
alertService.send(userId, "日预算已超", today);
throw new BusinessException("预算不足");
}
return today;
}
这段代码背后有个设计取舍:先扣费还是后扣费?我们选”预扣 + 结算”,评测发起时按预估费用预扣,实际用完再按真实用量结算,多余的退还。这样能防住”用户发起 1000 个评测却不看结果”的浪费场景。预算检查如果只做在业务层,很容易漏,最好下沉到统一网关,让所有 LLM 调用都过一道成本闸门。
经验
涉及外部付费服务的项目,成本计数器是基础设施,不是可选项。
六、前端流式 UI
后端流式做通了,前端渲染又是另一道坎。LLM 输出按 token 到达,如果每个 token 都触发一次 DOM 更新,页面直接卡死,我们第一版就是这么干的。
问题
LLM 输出要”打字机效果”,且要:
- 首字延迟 < 500ms;
- 长文本不卡(5000+ 字);
- 用户可”停止生成”;
- 代码高亮实时同步。
难点
- 直接
+=chunk 会触发上千次 ref 更新,Vue 卡死; - 直接渲染长文本 DOM 节点爆炸;
- 中断响应要快(< 100ms)。
解决路径
// 1. 50ms 节流
let pending = ''
let timer: number | null = null
function appendText(text: string) {
pending += text
if (!timer) {
timer = window.setInterval(() => {
if (pending) { output.value += pending; pending = '' }
}, 50)
}
}
// 2. 虚拟滚动
<RecycleScroller :items="lines" :item-size="20" />
// 3. AbortController
const controller = new AbortController()
function stop() { controller.abort() }
三招各有分工:节流把上千次更新压到每 50ms 一次,虚拟滚动解决超长文本的 DOM 数量,AbortController 让停止生成真正生效。代码高亮实时同步是最后加的,因为高亮是重活,我们对高亮做了防抖,等输入暂停 300ms 再执行,否则卡顿感很重。这套组合在 5000 字长回答下依然流畅,用户体感提升明显。
经验
长列表必用虚拟滚动,长输出必用节流。这是性能底线。
七、跨域 + 鉴权 + Cookie
前后端分离部署,跨域问题集中爆发,而且 CORS 这类问题报错信息非常误导人,浏览器就给你一句”被 CORS 策略阻止”,真正的原因全靠猜。
问题
前后端分离(Vercel + 自建后端),要:
- CORS 不能
* + credentials; - 跨域 Cookie 用 SameSite=None + Secure;
- 跨域 iframe 不共享 cookie;
- 多个 CORS 代理不能叠加(否则浏览器拒绝)。
难点
CORS 是浏览器安全机制,配置错一个 header 整个跨域就失败。
解决路径
CORS 白名单只在一处配置,前端固定精确来源:
# 单一来源配置(不要多源重复)
app:
cors:
allowed-origins:
- https://eval.example.com
- https://admin.example.com
CorsConfiguration cfg = new CorsConfiguration();
cfg.setAllowedOrigins(allowedOrigins); // 精确白名单
cfg.setAllowCredentials(true);
cfg.setAllowedHeaders(List.of("Authorization", "Content-Type", "token"));
我们在这一块踩过最典型的坑:Nginx 配了一层 CORS,Spring Boot 又配了一层,结果预检请求被重复处理,浏览器直接报跨域失败,排查了两天才发现是两层配置叠加导致的。后来定下规约,CORS 只允许一层配置,要么网关要么应用,杜绝叠加,这类问题再没出现过。
经验
CORS 配置只在一处写(要么 Nginx 要么 Spring Boot),不要两层都写。
八、技术债与重构
平台从 0 到 1 写得快,问题是欠下的技术债会复利,到后期每加一个功能都像在雷区上走路。
问题
平台从 0 到 1 写得快,到 100 后开始”动一处坏三处”:
- Controller 臃肿(一个 800 行 Controller);
- Service 互相调用像蜘蛛网;
- TypeScript any 蔓延;
- 测试覆盖率 30%。
难点
- 业务不能停(用户每天在用);
- 重构风险大(可能引入新 bug);
- 团队精力分散(新功能 vs 重构)。
解决路径
- 绞杀者模式:新功能用新代码写,老功能逐步迁移;
- 童子军规则:每次提交让代码比之前好一点点;
- 关键模块优先:先重构影响面广的(如 LLM 抽象层);
- 接口隔离:重构前先抽象接口,确保旧实现和新实现可并存。
我们最后选的是”边走边还”而不是”专门立项重构”。原因是业务压力不允许停摆,而绞杀者模式让新功能天然使用新结构,老功能慢慢被替换,风险可控。LLM 抽象层是第一个被重构的,因为它是全平台改动频率很高的地方,收益率也高。专门停一个迭代重构,反而容易因为需求积压而半途而废。
经验
重构不是”等有空了做”,是”持续做”。
九、按难度排名的清单
把上面的难点汇总成一张表,排名和耗时都是当时的真实记录,方便后来者对照自己的情况:
| 排名 | 难点 | 团队耗时 |
|---|---|---|
| 1 | LLM 评分稳定性 | 持续 12 个月 |
| 2 | 流式输出完整性 | 2 个月 |
| 3 | 跨实例实时推送 | 1 个月 |
| 4 | 任务调度 | 1.5 个月 |
| 5 | 成本控制 | 1 个月 |
| 6 | 前端流式 UI | 2 周 |
| 7 | 跨域 + 鉴权 | 1 周 |
| 8 | 技术债 | 持续 |
值得注意的一点:耗时和”难度”并不完全成正比。跨域这类问题一星期就解决,但排查过程极其折磨;评分稳定性则要持续投入一年,而且看不到明确的”完成”节点。排名的意义在于提醒自己,最难的不是一次性能解决的问题,而是需要长期维护的,要把资源往这类问题上倾斜。
十、跨难点的共性
回头看这八类难点,有几个共性规律值得记录,它们也是评测类平台和普通 Web 项目最不一样的地方:
- 都不是”写代码”难,是”想清楚”难;
- 都不是一次性解决,是持续优化;
- 都需要”业务”和”技术”双视角;
- 都需要”监控数据”驱动决策。
其中”监控数据驱动决策”是我们到后期才慢慢领悟的。早期我们靠感觉排优先级,结果把时间花在了不痛不痒的地方。后来把评分一致性、错误率、成本曲线都量化之后,该先修什么一目了然,争论也少了。数据不会骗人,感觉会。
常见问题(FAQ)
Q1:最值得复用的经验?
LLM 应用要”假设 LLM 是不可靠的”,一切按”会失败”设计。
Q2:最难的下一步?
从”工具平台”演化到”基础设施平台”,多租户 + 高可用。
Q3:团队成长明显的是什么?
从”实现功能”到”评估技术决策”的能力。