系列最后一篇,实现 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 出题参数与本月 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 分钟才被发现。
六、小结
这一篇的核心是两类约束:
能力的边界:
- AI 只给建议,不直接落库——生成与入库分离,人工确认后才进正式数据
- AI 出的题必须过和人一样的校验关——走特殊通道迟早出问题
- 整批拒绝优于半批入库——来源明确的失败好过来源不明的半成品
成本的边界:
- 连接配置与业务上限分开——避免”为了调额度动了模型地址”
- 失败调用照样记流水、照样计配额——失败那一次同样在花钱
- 四层超时由内到外递增——否则会出现”钱花了、结果拿不到”
七、系列总结
五篇下来,这套系统里最值得带走的经验是三类:
一是口径单一化。答案归一化口径、判分口径、练习与考试共用判分器、阅卷两条路径共用重算逻辑——凡是”同一个概念在多处实现”的地方,都是 bug 的温床。
二是取舍要写进代码注释。草稿只落 Redis 的数据风险、全屏不做强制检测、锁定 MyBatis-Plus 3.5.16——这些是主动选择,不是遗漏。写在注释里,后来者才不会误以为是缺陷而去”修复”。
三是成本要能归因。花了多少钱、花在哪个功能上、失败的是否也计费——这些问题如果答不上来,就没法优化。
配套的另外两条业务线(AI 知识库系统、统一认证中心)也有对应文章。

AI项目博客






