AI项目博客
记录 AI 工程实践与大模型应用

从零搭建 AI 在线考试系统(五):AI 能力接入与成本治理

系列最后一篇,实现 AI 能力的接入与成本控制。AI 调用是要真金白银计费的,而「点一下按钮」是免费的——这个不对称如果不处理,很快就会出问题。

一、模型调用层:只做三件事

package com.laijun.commons.ai;

/**

 * 大模型调用客户端(OpenAI 兼容协议)。

 *

 * <p>只做三件事:发请求、判错、把助手文本取出来。业务语义(提示词怎么写、

 * 返回的 JSON 怎么理解)不在这里 —— 换模型、换服务商、换调用方时这一层都不需要改动。</p>

 *

 * <p>用 hutool 而非声明式 HTTP 框架:为一次出站调用引入接口扫描与额外依赖,

 * 收益不抵复杂度。</p>

 */

@Slf4j

@RequiredArgsConstructor

public class AiClient {

    /** OpenAI 兼容的对话补全路径 */

    private static final String CHAT_PATH = "/v1/chat/completions";

    /** 错误提示里保留的响应体长度(模型侧的报错信息通常在开头) */

    private static final int ERROR_BODY_LIMIT = 300;

    private final AiProperties properties;

    /** 一次对话的结果 */

    public record ChatResult(String content, Integer totalTokens) {

    }

    /**

     * 发起一次对话补全。

     *

     * @param scene        业务场景标签(如「AI出题」「阅卷试评」),仅用于日志与错误提示

     * @param systemPrompt 系统提示词(角色与输出格式约束)

     * @param userPrompt   用户提示词(本次任务的具体要求)

     */

    public ChatResult chat(String scene, String systemPrompt, String userPrompt) {

        if (!properties.available()) {

            throw new PlatformException(properties.unavailableReason());

        }

        String payload = buildPayload(systemPrompt, userPrompt).toString();

        long startedAt = System.currentTimeMillis();

        String responseBody;

        try (HttpResponse response = HttpRequest.post(resolveUrl())

                .header(Header.AUTHORIZATION, "Bearer " + properties.getApiKey())

                .header(Header.CONTENT_TYPE, ContentType.JSON.getValue())

                .body(payload)

                .timeout(properties.getTimeoutSeconds() * 1000)

                .execute()) {

            responseBody = response.body();

            if (!response.isOk()) {

                // 必须带上响应体:模型侧的 400/401/402(模型名错、密钥无效、余额不足)

                // 光看状态码无法定位

                throw new PlatformException("调用大模型失败(HTTP " + response.getStatus()

                        + "):" + abbreviate(responseBody));

            }

        } catch (PlatformException ex) {

            throw ex;

        } catch (Exception ex) {

            throw new PlatformException("调用大模型失败:" + ex.getMessage());

        }

        ChatResult result = parseResponse(responseBody);

        log.info("大模型调用完成:scene={},model={},耗时={}ms,回复字符数={},tokens={}",

                scene, properties.getModel(), System.currentTimeMillis() - startedAt,

                result.content().length(), result.totalTokens());

        return result;

    }

    /**

     * 组装请求体。

     *

     * <p>刻意<b>不传</b> response_format: json_object:JSON 模式虽能提高格式稳定性,

     * 但并非所有兼容实现都支持,传了会直接 400。这里改为在提示词里强约束格式 +

     * 解析端做容错,换来「任何 OpenAI 兼容服务都能接」。</p>

     */

    private JSONObject buildPayload(String systemPrompt, String userPrompt) {

        JSONArray messages = new JSONArray();

        messages.add(new JSONObject().set("role", "system").set("content", systemPrompt));

        messages.add(new JSONObject().set("role", "user").set("content", userPrompt));

        return new JSONObject()

                .set("model", properties.getModel())

                .set("temperature", properties.getTemperature())

                .set("messages", messages);

    }

}

两个设计决定值得注意:

① 不传 response_format: json_object。这个参数能提高 JSON 格式稳定性,但并非所有兼容实现都支持——传了会直接 400。改成「提示词里强约束格式 + 解析端容错」,代价是偶尔要处理格式异常,换来的是任何 OpenAI 兼容服务都能接(DeepSeek / 通义兼容模式 / 智谱 / vLLM / Ollama)。

② 错误信息必须带响应体。模型侧的 400/401/402 分别对应模型名错、密钥无效、余额不足,光看状态码没法定位。但也要截断(300 字符),避免把整个响应体灌进日志。

二、配置分层:连接与上限分开

# 连接配置:怎么连模型

spring:

  ai:

    openai:

      base-url: https://api.your-provider.com/v1

      api-key: ${AI_API_KEY}

      timeout: 120s

      chat:

        options:

          model: your-chat-model

          temperature: 0.7

# 业务上限:允许多少

exam:

  ai:

    limit:

      daily-limit-per-user: 0   # 每人每日调用上限,0=不限

      max-count: 20             # 单次出题题量上限

      batch-size: 10            # 每次调用模型的批量

对应的配置类:

/**

 * 考试侧 AI 业务上限(exam.ai.limit.*)。

 *

 * <p>与 spring.ai.openai.*(连接配置)分开:那边是「怎么连模型」,这边是「允许多少」。

 * 两类配置混在一起,会出现「为了调额度和人吵架,却顺手动了模型地址」这种事。</p>

 */

@Data

@Component

@ConfigurationProperties(prefix = "exam.ai.limit")

public class ExamAiLimitProperties {

    /**

     * 每人每日 AI 调用上限(0 或负数 = 不限)。

     *

     * <p>AI 调用是真实花钱的,而「点击」是免费的 —— 没有这道闸,

     * 一个人连点 20 次就是 20 次计费,且没人会发现,直到账单出来。</p>

     */

    private int dailyLimitPerUser = 0;

    /** 单次出题题量上限(一次要太多,模型会偷工减料,也更容易超时) */

    private int maxCount = 20;

    /** 每次调用模型的批量(超过该值时拆批,避免单次响应过长被判超时) */

    private int batchSize = 10;

}

分别配置的理由很实际:连接配置是基础设施,业务上限是运营参数。两者的变更频率和审批级别完全不同,混在一起迟早出问题。

三、AI 出题:生成与入库分离

@Override

public QuestionAiGenerateVO generate(QuestionAiGenerateBO bo) {

    if (!aiClient.available()) {

        throw new PlatformException(aiClient.unavailableReason());

    }

    ExamCategoryEntity category = examCategoryMapper.selectById(bo.getCategoryId());

    if (category == null) {

        throw new PlatformException("所属知识点不存在,ID=" + bo.getCategoryId());

    }

    List<QuestionTypeEnum> types = parseTypes(bo.getQuestionTypes());

    int count = Math.min(bo.getCount(), limitProperties.getMaxCount());

    int batchSize = Math.max(1, Math.min(limitProperties.getBatchSize(), count));

    // 配额闸门:模型调用一旦发出就是计费动作,放在最靠近调用处才能真省钱

    examAiUsageService.checkQuota();

    long startedAt = System.currentTimeMillis();

    List<QuestionAiCandidateVO> candidates = new ArrayList<>(count);

    int totalTokens = 0;

    boolean tokensReported = false;

    // 按批拆分:批内再按题型均匀分配,保证「要 5 单选 5 多选」这类请求不会退化成全是单选

    int remaining = count;

    while (remaining > 0) {

        int current = Math.min(batchSize, remaining);

        BatchResult batch = invokeBatch(category, types, current, bo);

        candidates.addAll(batch.candidates());

        if (batch.totalTokens() != null) {

            totalTokens += batch.totalTokens();

            tokensReported = true;

        }

        remaining -= current;

    }

    // 两个后置清理:模型偶尔会超量返回,也偶尔会把同一道题写两遍

    List<QuestionAiCandidateVO> normalized = distinctByStem(candidates);

    if (normalized.size() > count) {

        normalized = new ArrayList<>(normalized.subList(0, count));

    }

    normalized.forEach(candidate -> candidate.setValidationError(validateCandidate(candidate)));

    int invalid = (int) normalized.stream()

            .filter(item -> StringUtils.hasText(item.getValidationError()))

            .count();

    // ... 组装返回 VO(候选题目、有效/待修正数量、耗时、token)

}

@Override

@Transactional(rollbackFor = Exception.class)

public int save(QuestionAiSaveBO bo) {

    if (examCategoryMapper.selectById(bo.getCategoryId()) == null) {

        throw new PlatformException("所属知识点不存在,ID=" + bo.getCategoryId());

    }

    List<ExamQuestionSaveBO> payloads = new ArrayList<>(bo.getCandidates().size());

    List<String> errors = new ArrayList<>();

    // 查重预先做一遍:与 save 内的闸门同一套判据,但能明确指出「第几道撞了」,

    // 避免入库到一半才失败。整批只查一次(原实现每道候选题各查一次)。

    Map<String, Long> duplicateIds = Boolean.TRUE.equals(bo.getAllowDuplicate())

            ? Map.of()

            : examQuestionService.duplicateIdsByStems(

                    bo.getCandidates().stream().map(QuestionAiCandidateVO::getStem).toList());

    int index = 0;

    for (QuestionAiCandidateVO candidate : bo.getCandidates()) {

        index++;

        ExamQuestionSaveBO saveBo = toSaveBO(bo.getCategoryId(), candidate, bo.getAllowDuplicate());

        // 复用题库自身的校验:AI 出的题和人出的题过同一道关

        String error = examQuestionService.validateForSave(saveBo);

        if (error != null) {

            errors.add("第 " + index + " 道:" + error);

            continue;

        }

        Long duplicateId = duplicateIds.get(candidate.getStem());

        if (duplicateId != null) {

            errors.add("第 " + index + " 道:题干与已有题目重复(ID=" + duplicateId + ")");

            continue;

        }

        payloads.add(saveBo);

    }

    if (!errors.isEmpty()) {

        // 整批拒绝而不是跳过错误项:半批入库会让「哪些进来了」变成需要翻列表才能回答的问题

        throw new PlatformException("以下题目不合法,已取消整批入库:" + String.join(";", errors));

    }

    payloads.forEach(examQuestionService::save);

    return payloads.size();

}

几个关键设计:

① 生成不入库。AI 输出的不确定性会影响所有考生的成绩,所以必须有人工确认环节,不能让 AI 成为绕过校验灌题库的通道。

② 候选题校验复用题库自身的保存链路(validateForSave / save)。这意味着「AI 出的题」和「人出的题」过同一道关——选项格式、答案归一化、必填校验完全一致。如果 AI 走特殊通道,早晚会出现”AI 出的题存得进去、但答题时判分出问题”。

③ 整批拒绝而不是跳过错误项。半批入库会让「哪些进来了」变成需要翻列表才能回答的问题。宁可让操作者看到完整的错误清单,也不要留下一个来源不明的半成品。

④ 拆批调用。一次要 20 道题,模型可能偷工减料、也更容易超时。按 batch-size 拆成 2 批,每批内按题型均匀分配——保证「要 5 单选 5 多选」不会退化成全是单选。

AI 出题

图:AI 出题参数与本月 token 用量,单次最多 20 道。

四、成本治理

4.1 调用流水

@Override

public void record(ExamAiInvokeLogBO bo) {

    Long userId = UserUtils.getCurrentUserId();

    if (userId == null) {

        // 不阻断调用,但必须留下痕迹:无归属的用量会在对账时变成「对不上的数字」

        log.warn("AI 调用流水缺少调用人上下文,将按无归属记录:scene={}", bo.getScene());

    }

    try {

        ExamAiInvokeLogEntity entity = new ExamAiInvokeLogEntity();

        entity.setScene(bo.getScene() == null ? null : bo.getScene().getCode());

        entity.setUserId(userId);

        entity.setUserName(currentUserName());

        entity.setModel(bo.getModel());

        entity.setTargetId(bo.getTargetId());

        entity.setTotalTokens(bo.getTotalTokens());

        entity.setSuccessFlag(bo.isSuccess() ? 1 : 0);

        entity.setErrorMsg(truncateError(bo.getErrorMsg()));

        entity.setElapsedMs(bo.getElapsedMs());

        save(entity);

    } catch (Exception ex) {

        // 刻意吞掉:流水写不进去,不能反过来让「已经发生并已计费」的调用失败。

        // 宁可少一条流水,也不能让使用者白付一次钱还拿不到结果。

        log.warn("AI 调用流水写入失败:scene={}", bo.getScene(), ex);

    }

}

两个细节:

① 失败也记(successFlag = 0)。超时和报错在多数供应商那里一样计费,那些”花了钱没拿到东西”的调用最需要被看见。

② 流水写入失败只告警不抛错。不能因为日志表写不进去,让一次已经计费的调用失败——那样使用者白付一次钱还拿不到结果。

4.2 配额校验

@Override

public void checkQuota() {

    int limit = limitProperties.getDailyLimitPerUser();

    if (limit <= 0) {

        // 0 或负数 = 不限。默认就是不限,把「不设上限」写成显式开关而不是隐性行为

        return;

    }

    int used = usedToday(requireCurrentUserId());

    if (used >= limit) {

        throw new PlatformException("今日 AI 调用次数已达上限(" + limit

                + " 次),请明日再试;如需调整请联系管理员修改 exam.ai.limit.daily-limit-per-user");

    }

}

private int usedToday(Long userId) {

    Integer count = baseMapper.countSinceByUser(userId, LocalDate.now().atStartOfDay());

    return count == null ? 0 : count;

}

配额统计包含失败的调用——注释里写得很清楚:只数成功次数的话,「连点 20 次全部超时」恰好绕过配额,而这种情况恰恰是最费钱的。

调用处的使用方式是「前 check、后 record」:

// 调用前

examAiUsageService.checkQuota();

long startedAt = System.currentTimeMillis();

try {

    ChatResult result = aiClient.chat(scene, systemPrompt, userPrompt);

    // 调用后

    examAiUsageService.record(ExamAiInvokeLogBO.success(scene, model, tokens, elapsed));

    return result;

} catch (Exception ex) {

    examAiUsageService.record(ExamAiInvokeLogBO.failure(scene, model, ex.getMessage(), elapsed));

    throw ex;

}

4.3 用量与场景归因

@Override

public ExamAiUsageVO usage() {

    Long userId = UserUtils.getCurrentUserId();

    int limit = limitProperties.getDailyLimitPerUser();

    ExamAiUsageVO vo = new ExamAiUsageVO();

    vo.setDailyLimit(limit);

    vo.setDailyUsed(userId == null ? 0 : usedToday(userId));

    // 不限时剩余次数没有意义,返回 null 让前端不渲染这一项(而不是显示一个巨大的假数字)

    vo.setDailyRemaining(limit <= 0 ? null : Math.max(limit - vo.getDailyUsed(), 0));

    Long tokens = userId == null ? null : baseMapper.sumTokensSinceByUser(userId, monthStart());

    vo.setMonthlyTokens(tokens == null ? 0L : tokens);

    return vo;

}

@Override

public List<ExamAiSceneStatVO> sceneStats(int days) {

    int window = Math.max(days, 1);

    LocalDate from = LocalDate.now().minusDays(window - 1L);

    QueryWrapper<ExamAiInvokeLogEntity> wrapper = new QueryWrapper<>();

    // SUM(success_flag = 1):一条 SQL 同时拿到总次数与成功数,省掉第二次查询。

    // 走 idx_scene_time(scene, created_time):分组建的索引恰好覆盖这个形状。

    wrapper.select("scene", "COUNT(*) AS total", "SUM(success_flag = 1) AS success")

            .ge("created_time", from.atStartOfDay())

            .groupBy("scene");

    List<ExamAiSceneStatVO> stats = new ArrayList<>();

    for (Map<String, Object> row : baseMapper.selectMaps(wrapper)) {

        // ... 组装每个场景的调用次数、成功次数、场景名

    }

    // 次数多的排前面:环形图图例顺序直接沿用,前端不必再排一次

    stats.sort(Comparator.comparing(ExamAiSceneStatVO::getCount, Comparator.reverseOrder()));

    return stats;

}

场景字段区分 6 类:1 出题 / 2 试评 / 3 解析生成 / 4 错题讲解 / 5 语义查重 / 6 智能组卷。分开记录才能回答「哪个功能在花钱」。如果全部混在一起,只能看到总额,没法判断该优化哪个功能。

示意图

五、超时链:四层必须递增

AI 调用的超时配置牵扯四层,容易顾此失彼:

单批模型调用               120s      spring.ai.openai.timeout

后端整批最坏               240s      ⌈max-count / batch-size⌉ = 2 批

Nginx proxy_read_timeout   300s      /etc/nginx/conf.d/xxx.conf

前端 axios                 360s      LONG_REQUEST_TIMEOUT
// src/api/http.ts

const http = axios.create({

  timeout: 10000,   // 全局默认 10 秒

})

// 只有 AI 生成 / 试评这类长任务放开到 360 秒

export const LONG_REQUEST_TIMEOUT = 360000

export function postLong(url, data) {

  return http.post(url, data, { timeout: LONG_REQUEST_TIMEOUT })

}

四层必须由内到外递增。如果某一层比内层更早放弃,就会出现「后端成功、前端超时」——钱已经花了,但用户拿不到结果,只能重试,再花一次。

另外注意:不能为了省事把全局超时调大。全局 10 秒能让绝大多数接口的异常快速暴露;为了 AI 接口把全局调到 360 秒,等于让所有接口的故障都要等 6 分钟才被发现。

六、小结

这一篇的核心是两类约束:

能力的边界:

  1. AI 只给建议,不直接落库——生成与入库分离,人工确认后才进正式数据
  2. AI 出的题必须过和人一样的校验关——走特殊通道迟早出问题
  3. 整批拒绝优于半批入库——来源明确的失败好过来源不明的半成品

成本的边界:

  1. 连接配置与业务上限分开——避免”为了调额度动了模型地址”
  2. 失败调用照样记流水、照样计配额——失败那一次同样在花钱
  3. 四层超时由内到外递增——否则会出现”钱花了、结果拿不到”

七、系列总结

五篇下来,这套系统里最值得带走的经验是三类:

一是口径单一化。答案归一化口径、判分口径、练习与考试共用判分器、阅卷两条路径共用重算逻辑——凡是”同一个概念在多处实现”的地方,都是 bug 的温床。

二是取舍要写进代码注释。草稿只落 Redis 的数据风险、全屏不做强制检测、锁定 MyBatis-Plus 3.5.16——这些是主动选择,不是遗漏。写在注释里,后来者才不会误以为是缺陷而去”修复”。

三是成本要能归因。花了多少钱、花在哪个功能上、失败的是否也计费——这些问题如果答不上来,就没法优化。

配套的另外两条业务线(AI 知识库系统、统一认证中心)也有对应文章。

赞(0)
未经允许不得转载:AI项目博客 » 从零搭建 AI 在线考试系统(五):AI 能力接入与成本治理

评论 抢沙发

AI项目博客

专注 AI 工程化落地 —— 覆盖智能体、大模型、Java AI 与 Python 的学习笔记,以及 AI 平台的项目实战记录。

关于本站文章归档

登录

找回密码

注册