最开始的样子
面面通最开始接 AI 的方式,用一个字形容就是——糙。
每个 Service 各自 new 一个 RestTemplate,拼 JSON 请求体,解析返回的 JSON,如果抛异常了各自 try-catch。
代码大概长这样:
// 最初的写法:InterviewService 里RestTemplate restTemplate = new RestTemplate();HttpHeaders headers = new HttpHeaders();headers.set("Authorization", "Bearer " + apiKey);// ... 拼消息体、调 API、解析 response、处理异常这段代码在 InterviewService 里有,在 PolishService 里又写了一遍,在 ResumeAnalysisService 里再写一遍。
7 个 Service,7 份几乎一样的代码。
当需求只是”调 DeepSeek”的时候,勉强能跑。但当需求变成:
- 用户想用自己的 API Key 怎么办?
- 有人想用通义千问,有人想用其他模型怎么办?
- 怎么限制每天调用次数?
- 怎么统计每个用户花了多少 token?
每个需求都要去改 7 个 Service。改漏了就是 bug。
于是决定重构。
目标:再新增一个 Service 时,不需要关心 AI 怎么调
我的目标很简单:后面再写一个 Service,它只需要说「我要调 AI」,不需要关心用哪个模型、用谁的 Key、扣不扣配额。
最终落地的就是 AiGateway 网关层。它不是为了炫设计模式,而是为了把”选供应商、选 Key、选模型、扣配额”这些脏活集中到一个地方。

第一步:抽接口
不管 DeepSeek 还是通义千问,做的事情都是同一个:你发一段文本,它返回一段文本。有的支持流式,有的不支持。
所以接口只有 3 个方法:
public interface ProviderAdapter { String name(); // 供应商名称 AiResponse chat(AiRequest request, String apiKey); // 同步 void streamChat(AiRequest request, String apiKey, AiStreamHandler handler); // 流式}18 行。不多不少。
然后写一个 OpenAI 兼容适配器——因为 DeepSeek、通义千问、豆包、智谱这类供应商都可以按类似的 chat/completions 结构接进来:
public class OpenAiCompatibleAdapter implements ProviderAdapter { private final ProviderConfig config;
public AiResponse chat(AiRequest request, String apiKey) { // 拼 Authorization: Bearer + apiKey // 拼 {"model":"...", "messages":[...]} // 调 config.endpoint() // 解析 {"choices":[{"message":{"content":"..."}}]} // 返回 AiResponse }}以后想加一个新供应商,只要它也兼容这套格式,基本就是加一份配置、一个 Adapter Bean、注册到 Map 里。
比如现在的 AiProviderConfig 里已经有 DeepSeek、Qwen、豆包和智谱。再加一个兼容供应商,核心变化大概就是这样:
ProviderConfig kimi = new ProviderConfig("kimi", endpoint, model, List.of("moonshot-v1-8k"));OpenAiCompatibleAdapter kimiAdapter = new OpenAiCompatibleAdapter(kimi);adapters.put("kimi", kimiAdapter);当然,真实项目里会写成 Spring Bean,不会直接塞在方法里。但意思就是这个:只要接口长得像,业务 Service 完全不用知道多了一个供应商。
不兼容的?自己实现 ProviderAdapter,代价也不大。
第二步:注册表管理
供应商统一注册到一个注册表里:
public class ProviderRegistry { private final Map<String, ProviderAdapter> adapters; // 预设供应商 private final Map<String, ProviderAdapter> customAdapters; // 用户自定义端点
public ProviderAdapter getAdapter(String name) { return adapters.get(name); }
public ProviderAdapter getOrCreateCustomAdapter(String endpoint) { // 同端点的适配器缓存复用 return customAdapters.computeIfAbsent(endpoint, ep -> new OpenAiCompatibleAdapter(new ProviderConfig("custom", ep, "", ...)) ); }
public ProviderAdapter getDefault() { return adapters.get(defaultProvider); }}customAdapters 这块有意思——面面通允许用户配置自定义的 API 端点。比如你有自己的模型代理服务,填个地址就行。系统会帮你创建一个对应的适配器并缓存起来,下次再用同一个端点就直接命中缓存。
第三步:门面
门面是所有消费者的统一入口,也是整个网关层最核心的类:
@Servicepublic class AiGateway {
public AiResponse chat(AiRequest request, Long userId) { // 1. 选供应商 ProviderAdapter adapter = resolveAdapter(userId); // 2. 决定用什么 Key String apiKey = keyResolver.resolve(userId); // 3. 决定用什么模型 String model = modelResolver.resolve(userId, request.model(), null); // 4. 扣配额 quotaPolicy.consume(userId, request.taskType(), model); // 5. 真正调 AI return adapter.chat(resolvedRequest, apiKey); }
private ProviderAdapter resolveAdapter(Long userId) { // 用户配置了自定义端点 → 用自定义 // 用户选了预设供应商 → 用对应的 // 都没选 → 回退到默认 }}chat 和 streamChat 两个方法,加起来 100 行。
核心的 Resolve 逻辑是一个优先级链:
供应商选择:自定义端点 > 用户预设 > 默认Key 选择: 用户自己的 Key > 系统 Key > 配额用户模型选择: 请求指定 > 用户偏好 > 任务默认 > 供应商默认每一层都用单独的 Resolver 类封装,互不依赖。想改 Key 的分配逻辑?改 KeyResolver 一个文件。
迁移过程
重构最怕的是”改完了跑不起来”。
重构前后的差别其实很直观:以前每个 Service 都自己拉线去调 AI;抽出网关层之后,业务 Service 只把请求交给同一个入口。

面面通的做法是:不改现有代码,直接新增网关层。
- 先写好 AiGateway + ProviderAdapter + Registry
- 把 7 个 Service 从”自己调 API”改为”注入 AiGateway,调它的方法”
- 逐个 Service 验证——面试能跑通就把 InterviewService 切过去,简历能跑通就切 ResumeAnalysisService
- 验证完所有 Service 后,删除旧的 API 调用代码
这个过程花了一周。工具链熟悉的话可能两三天就行,但面面通的每个 Service 对错误处理、超时、重试的策略都不太一样,迁移时要逐个适配。
当时最麻烦的不是”怎么抽象”,而是每迁一个 Service 都得确认三件事:
- 原来的错误提示有没有丢
- 流式输出有没有提前断
- 配额扣减有没有重复或漏掉
最终效果是:新增消费者时只需要注入 AiGateway,调用 chat 或 streamChat 方法。不用关心用户用的是 DeepSeek 还是自定义端点,不用关心配额扣没扣。
// 重构后:InterviewServiceprivate final AiGateway aiGateway;
public void startInterview() { AiResponse response = aiGateway.chat(request, userId); // 处理 response ...}
// 重构后:PolishServiceprivate final AiGateway aiGateway;
public void runPolish() { aiGateway.streamChat(request, userId, token -> { // 接收流式 token ... });}两个 Service 的调用代码几乎一模一样——这就是我要的效果。
额外收获
重构完网关层之后,有几个意料之外的好处:
1. 测试变简单了
以前测试一个 Service 要 mock RestTemplate,现在 mock AiGateway 就行了。一个接口两个方法,mock 成本很低。
后端测试基础设施就是在这个阶段加的——383368c0 test: 添加后端测试基础设施和消费者冒烟测试。
2. 配额统计统一了
所有 AI 调用都经过 AiGateway,配额消耗在网关层统一记录,不会漏。
后来加的 QuotaPolicy 也是在网关层切进去的,不需要改动任何消费者代码。
3. 多供应商切换对前端透明
用户在 ProfileView 里配置想用的供应商和模型,后端 AiGateway 自动路由。前端不用知道后端接了多少个 AI 供应商——它只调接口,网关层决定怎么路由。
一点感想
面面通最开始也是那种”先跑起来再说”的代码。7 个 Service 各自调 API,跑是能跑,但每加一个需求就要改 7 遍。
重构网关层之后,后面加论文润色服务时,代码量少了很多。新 Service 只需要关注业务逻辑——关键的 AI 调用直接丢给 AiGateway。
这次重构让我学到的不是设计模式有多厉害,而是:当你要改第 3 遍的时候,就该抽层了。 不要等第 7 遍。
面面通的完整代码在 GitHub 上,AiGateway 的源码在 service/ai/gateway/ 包下,总代码不到 500 行。
下一篇会讲面面通里最复杂的模块——AI 面试引擎,包括怎么用 Prompt 控制 AI 扮演面试官、SSE 流式对话怎么保证不丢数据、编程环节怎么跟面试流程整合。
部分信息可能已经过时





