平台开发最大难点定义解析(AI 大模型评测平台的硬骨头复盘)

评测平台的主要难点,在于两件不可控的事叠在一起:”不可控的 LLM 输出”遇上”复杂的工程场景”。传统软件开发的预期是输入确定、输出可断言,而评测平台每天面对的是概率采样出来的文本,同一句话可能给出完全不同的评价。一开始我们按老经验搭架构,结果线上连续踩了几个坑才意识到,这套业务需要的是另一种设计思维。下面按难度从高到低复盘,每一条都记录了问题本身、我们卡在哪、最后怎么解的。

一、最难:LLM 评分稳定性

评分稳定是评测平台的生死线,也是我们花时间最多的地方。如果评分本身不可信,平台存在的意义就没了,用户凭什么信你的结论。

问题

同一答案两次评分可能差 1-2 分,同一答案不同评委差 2-4 分,prompt 改一个字分数可能波动 10%。

难点

LLM 输出本质是概率采样,没有”绝对正确”。这与传统软件测试的”期望值断言”完全不同。

这个难点最折磨人的地方是:它不报错。传统软件如果逻辑有问题,会直接失败,很快暴露;LLM 评分漂移是悄悄发生的,用户报告”上次 8 分这次 7 分”,我们才发现问题。它不可复现、不好定位,只能靠体系化的约束去压住,这对习惯了”出 bug 就修”的团队是思维上的转变。

解决路径

我们分五步把漂移逐步压下去,每一步都有明确目的:

  1. 温度设 0(greedy)减少随机性,但仍有浮动;
  2. 多评委 + 中位数:3 个不同家族模型打分取中位数,方差 < 1.5 算一致;
  3. Prompt 锚定:每个分数档(9-10、7-8、5-6…)配详细描述,缩小主观空间;
  4. A/B 测试评分 prompt:每版跑 200 条历史答案,对比与人工评分相关性;
  5. 持续校准:每月抽样 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:团队成长明显的是什么?

从”实现功能”到”评估技术决策”的能力。

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

相关推荐

返回顶部