Day49-AI微服务化-将大模型能力封装为标准微服务

把大模型当作一个普通的HTTP微服务来对待,是这一代Java工程师必须建立的工程思维。

今天这篇,我会用我们项目组的真实改造过程为例,从架构分层、代码实现到模型路由,一步步把AI能力封装成「别的业务系统愿意调用」的微服务。


一、为什么大模型必须微服务化?

很多人第一次接触AI能力,第一反应就是:「业务代码里直接调一下ChatClient.call()不就完了?」

行,但只在你这个项目就一个AI调用点的阶段。一旦出现下面任意一种情况,你就该把它服务化了:

|---------------|---------------------------------|
| 触发场景 | 直接调用的痛点 |
| 多个业务团队都要用AI | 每个团队都要接一遍API、申请一遍Key、重写一遍Prompt |
| 公司用了多家模型 | 不同团队锁死在某个供应商,议价权丢失 |
| 要做AI用量计费 | 用量分散在各业务系统表里,月底对账对到哭 |
| 出了幻觉/Prompt注入 | 没有统一的输入净化层,没有审计日志 |
| 老板要看AI大屏 | 没有统一的调用指标采集点 |

核心原则:AI能力就像数据库一样,是企业级基础设施------你不会让每个业务系统直接连生产DB,为什么要让它们直接调大模型?


二、AI微服务的四层架构

把大模型封装成标准微服务,我们采用四层架构。我把各层的职责划清楚:

复制代码

关键原则 :业务团队只看到第②层,他们不需要知道底下是GPT-4还是Qwen。这就是服务化的意义------对调用方隐藏复杂性


三、代码实现:搭建一个AI能力微服务

下面我用一个真实的「文档问答」能力为例,把这套架构跑通。所有代码基于 Spring Boot 3.3 + Spring AI 1.0 + JDK 17。

3.1 Maven依赖

java 复制代码
xml
<dependencies>
    
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>

    
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-openai</artifactId>
    </dependency>

    
    <dependency>
        <groupId>org.springframework.ai</groupId>
        <artifactId>spring-ai-starter-model-zhipuai</artifactId>
    </dependency>

    
    <dependency>
        <groupId>com.alibaba.cloud</groupId>
        <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
    </dependency>

    
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-actuator</artifactId>
    </dependency>

    
    <dependency>
        <groupId>io.micrometer</groupId>
        <artifactId>micrometer-registry-prometheus</artifactId>
    </dependency>
</dependencies>

3.2 业务能力服务:对外的稳定接口

这是给业务团队调的入口。我把所有业务字段、错误码、SLA都封装在这里,下游完全不感知模型变更。

java 复制代码
/**
 * AI能力微服务的对外Controller
 * 业务团队只依赖这个接口,与模型解耦
 */
@RestController
@RequestMapping("/api/v1/ai")
@RequiredArgsConstructor
public class AiCapabilityController {

    private final DocumentQaService documentQaService;
    private final UsageRecorder usageRecorder;

    /**
     * 文档问答接口
     * 业务系统只需要传:文档ID + 问题 + 用户ID
     */
    @PostMapping("/doc-qa")
    public ResponseEntity<AiResponse<DocAnswer>> docQa(@Valid @RequestBody DocQaRequest request,
                                                      @RequestHeader("X-Biz-Source") String bizSource) {
        long start = System.currentTimeMillis();

        try {
            // 1. 参数基础校验(鉴权在外层网关,这里只做业务校验)
            Document doc = documentService.getById(request.getDocId());
            if (doc == null) {
                return AiResponse.fail("DOC_NOT_FOUND", "文档不存在");
            }

            // 2. 调用业务能力(内部实现对调用方完全透明)
            DocAnswer answer = documentQaService.answer(
                request.getDocId(),
                request.getQuestion(),
                request.getUserId()
            );

            // 3. 记录用量(这里是最关键的环节,后面看)
            long cost = System.currentTimeMillis() - start;
            usageRecorder.record(bizSource, "doc-qa", answer.getPromptTokens(),
                              answer.getCompletionTokens(), cost);

            return AiResponse.ok(answer);
        } catch (BusinessException e) {
            log.warn("业务异常 biz={} code={}", bizSource, e.getCode());
            return AiResponse.fail(e.getCode(), e.getMessage());
        } catch (Exception e) {
            log.error("AI调用异常", e);
            // 兜底降级:返回预先准备好的标准答案或转人工
            return AiResponse.fail("AI_UNAVAILABLE", "AI服务暂时不可用,请稍后重试");
        }
    }

    /**
     * 流式问答(SSE)------ 长对话场景必备
     */
    @GetMapping(value = "/doc-qa/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<String>> streamDocQa(@RequestParam String docId,
                                                     @RequestParam String question,
                                                     @RequestParam String userId) {
        return documentQaService.streamAnswer(docId, question, userId)
            .map(chunk -> ServerSentEvent.builder(chunk).build())
            // 异常也要返回给前端,不能让连接挂死
            .onErrorResume(e -> {
                log.error("SSE流式异常", e);
                return Flux.just(ServerSentEvent.builder("[ERROR]AI服务异常").build());
            });
    }
}

这段代码的几个关键设计

  1. X-Biz-Source头部:强制业务方标识来源,用量统计、计费、限流的依据。
  1. AiResponse统一包装:所有AI接口必须返回这个结构,前端对接成本最低。
  1. 业务校验与AI调用分离:业务校验失败绝不打模型,节省Token。
  1. 兜底降级:AI挂掉不能影响业务------这是微服务的基本素养。

3.3 模型路由层:按场景选择模型与降级

业务能力层下面,就是模型路由层。这里是AI微服务最体现工程能力的地方。

java 复制代码
/**
 * 模型路由:根据场景动态选择模型
 * 核心思想:把"调哪个模型"的决策从业务代码里剥离出来
 */
@Service
@Slf4j
public class ModelRouter {

    private final ChatModel openAiModel;        // GPT-4o
    private final ChatModel qwenModel;          // 通义千问
    private final ChatModel deepseekModel;      // DeepSeek
    private final TokenBucketLimiter limiter;   // 限流器

    /**
     * 路由策略封装
     */
    public ChatModel route(RouteContext ctx) {
        // 1. 限流检查(最重要的护栏)
        if (!limiter.tryAcquire(ctx.getBizSource())) {
            throw new BusinessException("RATE_LIMIT", "调用频率超限");
        }

        // 2. 按业务来源路由
        return switch (ctx.getBizSource()) {
            case "customer-service" -> {
                // 客服场景:中文友好、成本敏感 → 通义
                log.debug("路由到通义千问");
                yield qwenModel;
            }
            case "code-assistant" -> {
                // 代码场景:英文能力强、JSON格式稳 → DeepSeek
                log.debug("路由到DeepSeek");
                yield deepseekModel;
            }
            case "image-desc" -> {
                // 多模态 → GPT-4o
                yield openAiModel;
            }
            default -> {
                // 兜底用主力模型
                yield qwenModel;
            }
        };
    }
}

/**
 * 路由上下文:把决策依据集中管理
 */
@Data
@Builder
public class RouteContext {
    private String bizSource;        // 业务来源
    private String userId;           // 用户ID
    private int priority;            // 优先级 0-9,9最高
    private boolean isVip;           // 是否VIP
    private int estimatedTokens;     // 预估Token
}

为什么要做路由?

  • 成本优化:简单问题用便宜模型,复杂问题用强模型;
  • 风险分散:一家模型API挂掉,自动切到备胎;
  • 差异化SLA:VIP用户走专用通道。

3.4 用量统计:大模型版「拦路虎」

这是把AI「真正当微服务」的关键一步------没有计费的微服务,老板没法给你批预算。

java 复制代码
/**
 * 用量记录器
 * 高频调用场景,必须异步,不能阻塞AI调用主链路
 */
@Component
@Slf4j
public class UsageRecorder {

    private final UsageMapper usageMapper;
    private final ThreadPoolExecutor asyncPool;

    /**
     * 记录一次AI调用
     * 用线程池异步落库,不阻塞调用链路
     */
    public void record(String bizSource, String capability,
                       int promptTokens, int completionTokens, long costMs) {
        asyncPool.execute(() -> {
            try {
                UsageRecord record = UsageRecord.builder()
                    .bizSource(bizSource)
                    .capability(capability)
                    .promptTokens(promptTokens)
                    .completionTokens(completionTokens)
                    .totalTokens(promptTokens + completionTokens)
                    .costMs(costMs)
                    .estimatedCost(calcCost(bizSource, promptTokens, completionTokens))
                    .timestamp(LocalDateTime.now())
                    .build();
                usageMapper.insert(record);
            } catch (Exception e) {
                // 用量记录失败不能影响主流程,记录到日志兜底
                log.error("用量记录失败 biz={} capability={}", bizSource, capability, e);
            }
        });
    }

    /**
     * 成本计算
     * 真实生产场景这里应该读配置中心,模型价格随时会变
     */
    private BigDecimal calcCost(String bizSource, int pt, int ct) {
        // 单位:元/千Token
        BigDecimal ptRate = switch (bizSource) {
            case "customer-service" -> new BigDecimal("0.0008");  // 通义
            case "code-assistant"   -> new BigDecimal("0.001");   // DeepSeek
            default                 -> new BigDecimal("0.005");   // GPT-4o
        };
        return ptRate.multiply(new BigDecimal(pt + ct))
                     .divide(new BigDecimal(1000), 6, RoundingMode.HALF_UP);
    }
}

到这里,一个完整的AI微服务就出来了。再用 Spring Cloud Gateway 给它加个网关限流,一个企业级的AI能力中台就成形了。


四、AI调用链追踪:把日志串起来

微服务架构下,AI调用会跨越多个内部服务。没有TraceId,出问题排查能让你薅秃头发。

下面是个轻量级的TraceId方案,不依赖SkyWalking这种重武器,但能解决80%的问题:

java 复制代码
/**
 * AI调用上下文,贯穿整个调用链
 * 用ThreadLocal传,跨服务通过Header传递
 */
public class AiCallContext {

    private static final ThreadLocal<CallContext> CONTEXT = new ThreadLocal<>();

    public static void set(String traceId, String userId, String bizSource) {
        CONTEXT.set(new CallContext(traceId, userId, bizSource, System.currentTimeMillis()));
    }

    public static CallContext get() {
        return CONTEXT.get();
    }

    public static void clear() {
        CONTEXT.remove();
    }

    @Data
    @AllArgsConstructor
    public static class CallContext {
        private String traceId;
        private String userId;
        private String bizSource;
        private long startTime;
    }
}

/**
 * 在请求入口设置TraceId
 */
@Component
public class AiCallContextFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest req,
                                    HttpServletResponse resp,
                                    FilterChain chain) {
        String traceId = req.getHeader("X-Trace-Id");
        if (StringUtils.isEmpty(traceId)) {
            traceId = UUID.randomUUID().toString().replace("-", "");
        }
        AiCallContext.set(traceId,
                          req.getHeader("X-User-Id"),
                          req.getHeader("X-Biz-Source"));
        resp.setHeader("X-Trace-Id", traceId);
        try {
            chain.doFilter(req, resp);
        } finally {
            AiCallContext.clear();   // 必须清理,否则线程复用会污染
        }
    }
}

日志里带上traceId,出问题时grep一下,一个调用从进入到AI返回的全部日志就齐了:

java 复制代码
{
  "traceId": "a3f8c91b9e2d4f5c",
  "bizSource": "customer-service",
  "capability": "doc-qa",
  "promptTokens": 425,
  "completionTokens": 188,
  "costMs": 1245,
  "level": "INFO",
  "msg": "AI调用完成"
}

五、生产环境的几个坑

最后说几个我们踩过的坑,给大家提个醒:

1. 输入净化是AI微服务的标配 别以为你接的是「自己公司的模型」就不做Prompt注入防御。我见过业务方把用户原始输入直接拼到Prompt里,被攻击后模型输出违规内容被监管处罚的案例。所有用户输入必须过一道 InputSanitizer,至少做长度限制 + 敏感词过滤 + 角色锁定。

2. 超时一定要短,熔断一定要狠 大模型调用动辄5~30秒,你如果不在网关层设个2秒超时把请求cut掉,整个调用链都会被拖死。我们生产上AI网关默认超时1.5秒,熔断阈值是20%错误率就开跳。

3. Token限流比QPS限流更有意义 普通微服务按QPS限流,AI微服务要按Token限流。因为同样是「1次调用」,问"你好"和问"翻译一篇5000字文档"消耗的资源差100倍。我们用 Redis 滑动窗口 + Token 计数器,1分钟窗口最多消耗100万Token,超了就降级到便宜模型或直接拒绝。

4. 用量计费一定要做实时看板 别等月底拉Excel对账。我们搞了个Grafana看板,按业务方/能力/时间段三维展示用量、成功率、平均Token、花费。老板每周看一次,预算批得飞快。


写到这里,你会发现,把大模型当一个普通微服务对待,本质就是抽象和分层

这两件事,是软件工程40年不变的底层能力。不会因为你把数据库换成了大模型,就变得更高深了。它仍然是:把变化的部分隔离出来,把不变的部分稳定暴露出去。

下篇预告:第50篇《分布式一致性:Raft协议动画式讲解》------ 微服务之后必须啃的分布式一致性硬骨头,从Leader选举到日志复制一次讲透。


系列文章回顾

版权声明:本文为「老梁」原创出品,90天Java后端+AI系列第48篇,转载请注明出处。

相关推荐
Nebula_g1 小时前
JavaSE基础语法:特殊类(特殊情景下的设计模式)
java·开发语言·设计模式
使用小功能大师1 小时前
从零搭建高可用Web应用:全栈架构实战与成本优化完全指南
前端·阿里云·架构·服务搭建
安逸sgr1 小时前
激活函数有什么用?Sigmoid、Tanh、ReLU 到底怎么选?
人工智能·ai·大模型·agent·智能体
2401_894915531 小时前
Geo 优化源码部署避坑指南:解决访问异常、定位失效、收录卡顿问题
java·服务器·后端·缓存·开源
Dovis(誓平步青云)1 小时前
《 固井工程软件 Cemsol 的数据管理与国产化适配实践》
android·java·开发语言·人工智能
大模型码小白1 小时前
AI安全前沿:AI大模型安全防护的前沿技术
java·网络·人工智能·python·深度学习·学习·安全
Elastic 中国社区官方博客1 小时前
用两行 JSON 替换你的 ILM 策略:数据流生命周期新增冻结层支持
大数据·运维·elasticsearch·搜索引擎·架构·全文检索
meilindehuzi_a1 小时前
从 Vite 到 Axios 与 Mock:React Todos 全栈项目架构及请求链路详解
前端·react.js·架构
evans在进步1 小时前
LeetCode 34:在排序数组中查找元素的首尾位置——Java 两次二分查找详解
java·python·leetcode