
本文是「Spring Boot + AI 全栈后端」系列第 03 篇。第 02 篇跑通了 AI 对话接口,这一篇解决上线后最现实的问题:账单。示例基于 Spring AI 2.0 / Boot 4.1。
让财务皱眉的场景
真是巧了,给做客服系统的团队做方案评审,几乎每家都踩过同一个坑:接口接上了查物流、问营业时间、退换货政策,本以为省了人力,月底账单却傻了眼------占比 80% 以上的「几点下班」「快递到哪了」这类简单问题,和真正的投诉、理赔走的是同一张账单,因为代码里写死了一个最贵的旗舰模型。它按 token 计费、单价是轻量档好几倍,简单问题「高射炮打蚊子」,钱就这么悄悄烧没了。
问题很具体:不是模型不行,是没分档------问题全喂给最贵的。
解决思路就一条:Spring AI 把模型抽象成 ChatModel,一套接口挂多个实例(便宜轻量 + 强旗舰),再写路由策略挑档。业务只认抽象,简单问题走轻量档、复杂风险才上旗舰,成本立降。V哥 的建议是:这一步别等账单爆了才做,接口一上线就把路由留出来。
先算一笔账,看看能省多少
别凭感觉,先拿数字说话。假设日均 1 万条客服问答,八成是简单问题,单条输入输出合计约 800 token,旗舰档单价约为轻量档的 6 倍(各家的公开单价不一样,你按自己的签约价代入即可):
| 方案 | 旗舰调用 | 轻量调用 | 相对成本 |
|---|---|---|---|
| 全部走旗舰 | 10 000 条 | 0 条 | 100% |
| 全部走轻量 | 0 条 | 10 000 条 | 约 17% |
| 按复杂度路由 | 2 000 条 | 8 000 条 | 约 33% |
结论很清楚:路由方案用两成的旗舰调用,拿到了接近全轻量的成本,还保住了复杂问题的回答质量。全切轻量倒是便宜,但客户来投诉理赔的时候给它一个敷衍的答案,省下的钱还不够赔一次客诉。
这张表还有个隐藏价值:它是你和领导谈预算时的抓手------省钱要能被算出来,方案才立得住。
第一步:把"分档"抽象成策略
别把 if (question.contains("退款")) 散落在业务代码里。先抽象出"档位"和"策略"两个概念:
java
public enum ModelTier {
LITE,
FLAGSHIP
}
public interface RoutingStrategy {
ModelTier route(String question);
}
最朴素的关键词命中:高风险词走旗舰,其余轻量:
java
@Component
public class KeywordRoutingStrategy implements RoutingStrategy {
private static final Set<String> COMPLEX_MARKERS = Set.of(
"退款", "投诉", "纠纷", "赔偿", "违约金", "起诉", "仲裁");
@Override
public ModelTier route(String question) {
if (question == null || question.isBlank()) {
return ModelTier.LITE;
}
for (String marker : COMPLEX_MARKERS) {
if (question.contains(marker)) {
return ModelTier.FLAGSHIP;
}
}
return ModelTier.LITE;
}
}
三种路由策略怎么选
关键词只是起点。V哥 把常见做法列成一张表,你按团队阶段挑:
| 策略 | 怎么判断 | 优点 | 代价 | 适合阶段 |
|---|---|---|---|---|
| 关键词/规则 | 命中高风险词、超长文本、带附件就上旗舰 | 零成本、可解释、出问题能立刻定位 | 覆盖不全,新词得人工补 | 刚上线,先把八成的钱省下来 |
| 规则 + 打分 | 关键词、长度、用户等级各占权重,过阈值上旗舰 | 比纯规则平滑,误判少 | 阈值要调,得有人盯数据 | 跑了一两个月,有历史数据 |
| 小模型分类器 | 先让轻量模型判断"这个问题难不难",再决定档位 | 覆盖率高,能识别没见过的问题 | 多一次调用,多一份延迟和成本 | 量大、且前两种已榨干收益 |
无论选哪种,RoutingStrategy 这个接口都不用改------换策略是换实现类,不是改业务代码 。这就是为什么第一步要先抽象:今天用关键词,明天换分类器,CustomerRouterService 一个字都不用动。
第二步:按档选模型,顺手把降级和计数做了
服务用 @Qualifier 注入两档 ChatModel,先选档再调用。这里多了两件上线必备的事------降级 和计数:
java
@Service
public class CustomerRouterService {
private final ChatModel flagship;
private final ChatModel lite;
private final RoutingStrategy strategy;
/** 分档调用计数:最小可观测实现,生产里换成 Micrometer 埋点或落库报表。 */
private final Map<ModelTier, AtomicLong> usage = new EnumMap<>(ModelTier.class);
public CustomerRouterService(
@Qualifier("flagship") ChatModel flagship,
@Qualifier("lite") ChatModel lite,
RoutingStrategy strategy) {
this.flagship = flagship;
this.lite = lite;
this.strategy = strategy;
}
public CsReply answer(String question) {
ModelTier tier = strategy.route(question);
boolean degraded = false;
String text;
try {
text = call(tier == ModelTier.FLAGSHIP ? flagship : lite, question);
} catch (RuntimeException ex) {
if (tier != ModelTier.FLAGSHIP) {
// 轻量档都挂了,说明模型服务整体不可用,交给全局异常返回 503
throw ex;
}
tier = ModelTier.LITE;
degraded = true;
text = call(lite, question);
}
usage.computeIfAbsent(tier, k -> new AtomicLong()).incrementAndGet();
return new CsReply(question, text, tier.name(), degraded);
}
/** 某个档位累计被调用了多少次:省没省钱,得有数才算数。 */
public long usageOf(ModelTier tier) {
AtomicLong counter = usage.get(tier);
return counter == null ? 0L : counter.get();
}
private String call(ChatModel model, String question) {
return model.call(new Prompt(new UserMessage(question)))
.getResult()
.getOutput()
.getText();
}
}
为什么要降级
路由之后有个新问题:旗舰档比轻量档慢、也更容易被限流。一个来投诉的用户,本来就憋着火,再让他等三十秒最后弹个 503,这单客诉基本就定了。
上面的 catch 干的事很朴素------旗舰档失败就退到轻量档,宁可答得简单点,也别让用户干等。同时把 degraded 标成 true 带回响应,方便你单独统计和告警:降级次数突然涨了,说明旗舰档或它的上游出问题了,这是要人盯的信号,不是可以默默吞掉的异常。
注意那个 if (tier != ModelTier.FLAGSHIP) throw ex;:轻量档自己都挂了,说明是模型服务整体不可用,这时降级没有意义,直接抛出去让全局异常处理返回 503 才是正解。
为什么要计数
tier 和 usageOf() 加起来,就是成本可观测的最小闭环。有了它,你能回答三个此前答不上来的问题:
- 这个月旗舰档被调用了多少次?占比多少?
- 昨天调了关键词表之后,旗舰占比降了没有?
- 降级发生了几次?集中在哪个时段?
V哥 见过太多团队"上了路由就以为省钱了",问省了多少,答不上来。没有埋点的优化,等于没优化------因为你既证明不了收益,也发现不了回退。
第三步:接上接口
控制器只收请求做校验:
java
@RestController
@RequestMapping("/api/cs")
public class CsController {
private final CustomerRouterService routerService;
public CsController(CustomerRouterService routerService) {
this.routerService = routerService;
}
@PostMapping
public CsReply ask(@RequestBody @Valid CsAskRequest request) {
return routerService.answer(request.question());
}
}
public record CsAskRequest(@NotBlank(message = "问题不能为空") String question) {}
public record CsReply(String question, String answer, String tier, boolean degraded) {}
CsReply 里的两个字段各有分工:tier 告诉前端"这次是轻量档答的"(前端可选择性标注),degraded 是给运维看的告警信号。V哥 建议这两个字段不要藏着,直接进日志和监控------它们是这套路由系统唯一的自我证明。

第四步:配置里挂两档模型
真正的「分档」在配置:用同一 OpenAI 客户端包出两档 ChatModel,靠 app.chat.provider=openai 开关,测试默认不加载:
java
@Configuration
@ConditionalOnProperty(name = "app.chat.provider", havingValue = "openai")
public class ChatModelsConfig {
@Bean
@Qualifier("flagship")
public ChatModel flagshipModel(OpenAIClient openAIClient) {
OpenAiChatOptions options = OpenAiChatOptions.builder().model("gpt-4o").build();
return OpenAiChatModel.builder().openAiClient(openAIClient).options(options).build();
}
@Bean
@Qualifier("lite")
public ChatModel liteModel(OpenAIClient openAIClient) {
OpenAiChatOptions options = OpenAiChatOptions.builder().model("gpt-4o-mini").build();
return OpenAiChatModel.builder().openAiClient(openAIClient).options(options).build();
}
}
配置与代码分离,正是「配置切换不动业务」:轻量档明天换国产模型,只改这个类。 你的 CustomerRouterService、CsController、RoutingStrategy 全都不知情,也不需要知情。
怎么验证
路由逻辑不需要真去调模型,用两个桩模型(轻量/旗舰,回复里带不同标记)就能验证:问「几点下班」→ 命中 tier = LITE;问带退款、投诉、赔偿的问题 → 命中 tier = FLAGSHIP;问题为空 → 400 校验拦截。
再给旗舰桩加一点"脾气"------让它在遇到特定问题时抛异常,就能验证降级链路:原本该走旗舰的投诉问题,在旗舰失败后应该返回 tier = LITE、degraded = true,且答案来自轻量档。最后连问几次简单问题,断言 usageOf(LITE) 正好递增了对应次数。整个过程不需要真实密钥,也不依赖网络。
落地要点
- 策略可替换:关键词只是入门,线上用小模型做复杂度分类或按意图、长度加权即可,接口不变。
- 默认便宜档 :拿不准就默认
LITE,别让每句话都烧旗舰的钱。 - 降级要留痕 :
degraded进日志、进监控,别让降级悄悄发生。 - 成本要出报表 :
usageOf()的计数定期落到报表里,不然"省了多少"永远是一句口头承诺。 - 关键词表外置 :把
COMPLEX_MARKERS挪到配置中心,运营同学发现新类型的客诉时能自己加词,不用走一次发版。 - V哥 的提醒:别一上来就搞「智能路由」,先用关键词把八成的省钱拿到手,再用数据决定要不要上分类器------省钱这件事,见效快的方案先落地。

最后一句:别再让每句「在吗」都烧旗舰模型的钱------接上多模型路由,简单问题交给便宜档,复杂问题留给旗舰,失败还能自动降级,成本这道坎几行配置就跨过去。下一篇(04)V哥 带你解决另一种「看得见却用不了」的尴尬:让模型按你定义的字段输出,把简历解析结果直接落库。