1. [推理服务独立化的目标与职责边界](#1-推理服务独立化的目标与职责边界)
  2. [服务接口设计:同步与流式双模式](#2-服务接口设计同步与流式双模式)
  3. [多模型路由:统一抽象屏蔽底层差异](#3-多模型路由统一抽象屏蔽底层差异)
  4. [流式输出工程实现:SSE 全链路透传](#4-流式输出工程实现sse-全链路透传)
  5. [推理容错:熔断、降级、重试与超时策略](#5-推理容错熔断降级重试与超时策略)
  6. [Token 计量与用量统计](#6-token-计量与用量统计)
  7. [请求排队与背压控制](#7-请求排队与背压控制)
  8. [模型预热与冷启动优化](#8-模型预热与冷启动优化)
  9. [安全与合规:内容审核与脱敏](#9-安全与合规内容审核与脱敏)
  10. [本章小结与上线检查清单](#10-本章小结与上线检查清单)

1.1 职责定义

AI 推理服务是大模型微服务拆分中最核心的服务,它的职责用一句话概括:**接收已组装好的 Prompt,调用大模型,返回生成结果**。这个定义看似简单,但必须严格守住边界。推理服务不负责组装 Prompt(那是编排服务的事),不负责知识检索(那是向量检索服务的事),不负责会话历史管理(那是编排服务的事)。它只做一件事:把 Prompt 送进模型,把输出取出来。

这种纯粹的职责定义带来两个好处。第一,推理服务的接口极简、极稳定,只要"输入 Prompt、输出文本"的范式不变,接口就不需要变,内部换模型、换推理框架都不影响调用方。第二,推理服务可以专注优化推理本身的性能和稳定性——批处理、量化、KV 缓存、模型预热——这些优化不需要和其他业务逻辑纠缠。

1.2 非职责清单

明确"不做什么"和明确"做什么"同样重要。推理服务的非职责包括:

  • **不组装 Prompt**:传入的 messages 数组已经是最终要送给模型的内容,推理服务不做变量替换、不做知识注入。
  • **不管理会话历史**:多轮对话的历史拼接由编排服务完成,推理服务是无状态的。
  • **不做内容审核的最终决策**:推理服务可以做基础的内容过滤,但最终的业务级审核策略由编排服务或专门的审核服务决定。
  • **不做计费**:推理服务只上报 token 用量,计费逻辑在计量服务。

1.3 依赖关系

推理服务对外依赖三类组件:大模型本身(自建 vLLM 或云厂商 API)、配置中心(Nacos,获取模型路由配置)、监控(Prometheus,上报指标)。它不依赖任何业务数据库,这是它能保持轻量和独立扩容的关键。

2.1 两种响应模式

大模型推理有两种响应模式:同步模式和流式模式。同步模式下,客户端发送请求后等待模型生成完毕,一次性收到完整响应;流式模式下,模型每生成一个 token 就推送给客户端,实现"打字机效果"。两种模式必须同时支持,因为适用场景不同:同步模式适合后台批处理任务(如文档摘要生成),流式模式适合面向用户的实时对话。

2.2 同步接口实现

先看同步接口的实现。推理服务接收一个标准化的请求,内部路由到具体模型,返回标准化响应。

@RestController
@RequestMapping("/v1/chat")
public class InferenceController {
    @Autowired
    private ModelRouter modelRouter;
    @Autowired
    private TokenMeter tokenMeter;
    @Autowired
    private InferenceMetrics metrics;
    @PostMapping("/completions")
    public ApiResult<InferenceResponse> complete(
            @RequestBody InferenceRequest request,
            @RequestHeader(value = "X-Trace-Id", required = false) String traceId) {
        MDC.put("traceId", traceId != null ? traceId : generateTraceId());
        long start = System.currentTimeMillis();
        try {
            // 路由到具体模型提供者
            ModelProvider provider = modelRouter.route(request.getModel());
            InferenceResponse response = provider.complete(request);
            // 记录用量
            tokenMeter.record(request, response);
            metrics.recordLatency(System.currentTimeMillis() - start, "success");
            return ApiResult.success(response);
        } catch (Exception e) {
            metrics.recordLatency(System.currentTimeMillis() - start, "error");
            log.error("推理失败, model={}, traceId={}", request.getModel(), traceId, e);
            return ApiResult.error(500, "推理服务异常: " + e.getMessage());
        } finally {
            MDC.clear();
        }
    }
}

2.3 请求与响应模型

请求和响应模型需要精心设计,既要覆盖大模型的通用参数,又要保持简洁。关键设计点是用枚举约束 role 字段、用 builder 模式方便构造、用 tokenUsage 透出计量信息。

@Data
@Builder
public class InferenceRequest {
    private String model;              // 模型标识,如 "gpt-4o" / "qwen-max"
    private List<Message> messages;     // 消息列表
    private Double temperature;         // 温度,默认0.7
    private Integer maxTokens;          // 最大生成token数
    private Double topP;               // 核采样
    private List<String> stop;          // 停止词
    private Boolean stream;             // 是否流式
    private Map<String, Object> extra;  // 模型特有参数
}
@Data
@AllArgsConstructor
public class Message {
    private String role;     // system / user / assistant
    private String content;
}
@Data
@Builder
public class InferenceResponse {
    private String content;
    private String finishReason;    // stop / length / content_filter
    private TokenUsage tokenUsage;
    private String model;           // 实际使用的模型
    private long latencyMs;
}
@Data
@Builder
public class TokenUsage {
    private int promptTokens;
    private int completionTokens;
    private int totalTokens;
}

2.4 流式接口设计

流式接口用 SSE(Server-Sent Events)实现。Spring Boot 对 SSE 有原生支持,返回 `SseEmitter` 即可。流式接口的核心挑战不是返回,而是异常处理和超时管理——流式连接可能持续数十秒,期间网络断开、模型超时都需要妥善处理。

@PostMapping(value = "/completions/stream",
             produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter completeStream(@RequestBody InferenceRequest request) {
    // 超时设为60秒,大模型流式最长持续
    SseEmitter emitter = new SseEmitter(60_000L);
    String traceId = MDC.get("traceId");
    modelRouter.route(request.getModel())
        .completeStream(request)
        .subscribe(
            chunk -> {
                try {
                    emitter.send(SseEmitter.event()
                        .data(chunk)
                        .id(traceId));
                } catch (IOException e) {
                    emitter.completeWithError(e);
                }
            },
            error -> {
                log.error("流式推理失败, traceId={}", traceId, error);
                emitter.completeWithError(error);
            },
            emitter::complete
        );
    // 超时和错误兜底
    emitter.onTimeout(() -> log.warn("SSE超时, traceId={}", traceId));
    emitter.onError(e -> log.warn("SSE异常, traceId={}", traceId, e));
    return emitter;
}

3.1 为什么需要多模型路由

生产环境中,一个推理服务通常要对接多个大模型:OpenAI GPT-4o 用于复杂推理、通义千问用于中文场景、本地 vLLM 部署的开源模型用于降本。不同模型的 API 格式、认证方式、参数命名各不相同,如果让调用方直接对接,会被某个厂商绑定。多模型路由的作用就是在推理服务内部做一层抽象,统一接口,屏蔽底层差异。

3.2 ModelProvider 抽象

定义一个统一的 ModelProvider 接口,每个具体模型实现这个接口。路由器根据请求中的 model 字段选择对应的 provider。

public interface ModelProvider {
    /**
     * 模型标识,如 "gpt-4o" / "qwen-max" / "local-vllm"
     */
    String modelId();
    /**
     * 同步推理
     */
    InferenceResponse complete(InferenceRequest request);
    /**
     * 流式推理
     */
    Flux<InferenceChunk> completeStream(InferenceRequest request);
    /**
     * 健康检查
     */
    boolean isAvailable();
}

3.3 OpenAI 兼容实现

大多数云厂商(通义、智谱、月之暗面)都提供 OpenAI 兼容接口,可以复用一个实现。下面是 OpenAI 兼容 provider 的核心实现,使用 WebClient 调用远程 API。

@Component
@Slf4j
public class OpenAICompatibleProvider implements ModelProvider {
    private final String modelId;
    private final String apiUrl;
    private final String apiKey;
    private final WebClient webClient;
    public OpenAICompatibleProvider(ModelConfig config) {
        this.modelId = config.getModelId();
        this.apiUrl = config.getApiUrl();
        this.apiKey = config.getApiKey();
        this.webClient = WebClient.builder()
            .baseUrl(apiUrl)
            .defaultHeader("Authorization", "Bearer " + apiKey)
            .defaultHeader("Content-Type", "application/json")
            .codecs(c -> c.defaultCodecs().maxInMemorySize(16 * 1024 * 1024))
            .build();
    }
    @Override
    public InferenceResponse complete(InferenceRequest request) {
        Map<String, Object> body = buildRequestBody(request, false);
        Map<String, Object> resp = webClient.post()
            .uri("/v1/chat/completions")
            .bodyValue(body)
            .retrieve()
            .bodyToMono(Map.class)
            .timeout(Duration.ofSeconds(30))
            .block();
        return parseResponse(resp);
    }
    @Override
    public Flux<InferenceChunk> completeStream(InferenceRequest request) {
        Map<String, Object> body = buildRequestBody(request, true);
        return webClient.post()
            .uri("/v1/chat/completions")
            .bodyValue(body)
            .retrieve()
            .bodyToFlux(String.class)
            .filter(line -> !line.equals("[DONE]"))
            .map(this::parseChunk)
            .onErrorMap(e -> new InferenceException("流式调用失败: " + e.getMessage(), e));
    }
    @Override
    public boolean isAvailable() {
        try {
            webClient.get().uri("/v1/models").retrieve().toBodilessEntity().block();
            return true;
        } catch (Exception e) {
            return false;
        }
    }
    private Map<String, Object> buildRequestBody(InferenceRequest req, boolean stream) {
        Map<String, Object> body = new LinkedHashMap<>();
        body.put("model", modelId);
        body.put("messages", req.getMessages());
        body.put("temperature", req.getTemperature() != null ? req.getTemperature() : 0.7);
        body.put("max_tokens", req.getMaxTokens() != null ? req.getMaxTokens() : 2048);
        if (req.getTopP() != null) body.put("top_p", req.getTopP());
        if (req.getStop() != null) body.put("stop", req.getStop());
        body.put("stream", stream);
        if (req.getExtra() != null) body.putAll(req.getExtra());
        return body;
    }
}

3.4 本地 vLLM 实现

本地部署的 vLLM 同样兼容 OpenAI 接口,但需要额外的 GPU 资源管理。它的 provider 实现可以复用 OpenAICompatibleProvider,只需要配置不同的 apiUrl(指向本地 vLLM 地址)。区别在于健康检查更严格——除了 API 可达,还要检查 GPU 利用率是否过载。

3.5 路由器实现

路由器维护 modelId 到 provider 的映射,支持动态配置(从 Nacos 读取)和故障转移。

@Component
public class ModelRouter {
    private final Map<String, ModelProvider> providers = new ConcurrentHashMap<>();
    private final Map<String, List<String>> fallbackChains = new ConcurrentHashMap<>();
    @NacosConfigListener(dataId = "model-router-config.json", timeout = 5000)
    public void onConfigUpdate(String config) {
        RouterConfig rc = JsonUtils.parse(config, RouterConfig.class);
        // 重建 provider 映射
        providers.clear();
        fallbackChains.clear();
        for (ModelConfig mc : rc.getModels()) {
            ModelProvider provider = createProvider(mc);
            providers.put(mc.getModelId(), provider);
            if (mc.getFallback() != null) {
                fallbackChains.put(mc.getModelId(), mc.getFallback());
            }
        }
        log.info("模型路由配置已更新, 共{}个模型", providers.size());
    }
    public ModelProvider route(String modelId) {
        ModelProvider provider = providers.get(modelId);
        if (provider != null && provider.isAvailable()) {
            return provider;
        }
        // 故障转移:按 fallback 链查找可用 provider
        List<String> chain = fallbackChains.getOrDefault(modelId, Collections.emptyList());
        for (String fallbackId : chain) {
            ModelProvider fallback = providers.get(fallbackId);
            if (fallback != null && fallback.isAvailable()) {
                log.warn("模型{}不可用,降级到{}", modelId, fallbackId);
                return fallback;
            }
        }
        throw new InferenceException("无可用模型: " + modelId);
    }
    private ModelProvider createProvider(ModelConfig mc) {
        switch (mc.getType()) {
            case "openai-compatible":
                return new OpenAICompatibleProvider(mc);
            case "local-vllm":
                return new VllmProvider(mc);
            default:
                throw new IllegalArgumentException("未知provider类型: " + mc.getType());
        }
    }
}

fallback 链的设计很重要。例如主模型 gpt-4o 的 fallback 是 [qwen-max, local-vllm],当 GPT-4o 的 API 不可用时,自动切换到通义千问,再不行切到本地模型,保证服务可用性。

4.1 全链路透传的挑战

流式输出要经过网关、编排服务、推理服务三层,每一层都必须做透传处理,任何一层缓冲都会破坏打字机效果。网关层需要配置为不缓冲 SSE 响应;编排层需要用响应式编程把推理服务的 Flux 透传给客户端;推理层需要正确解析模型返回的流式数据。

4.2 网关层 SSE 配置

Spring Cloud Gateway 默认支持 SSE 透传,但需要确保不被缓冲。关键配置如下。

spring:
  cloud:
    gateway:
      httpclient:
        response-timeout: 60s
      # SSE 流式响应不缓冲
      routes:
        - id: inference-stream
          uri: lb://ai-inference-service
          predicates:
            - Path=/api/chat/stream/**
          filters:
            - RemoveResponseHeader=Transfer-Encoding
          metadata:
            response-timeout: 60000

4.3 编排层透传

编排层用 WebFlux 的 Flux 把推理服务的流式响应透传给客户端,不做任何缓冲。

@RestController
@RequestMapping("/api/chat")
public class ChatController {
    @Autowired
    private InferenceClient inferenceClient;
    @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<ServerSentEvent<InferenceChunk>> streamChat(@RequestBody ChatRequest req) {
        // 组装 Prompt(编排服务的职责)
        InferenceRequest inferenceReq = assembleInferenceRequest(req);
        return inferenceClient.chatStream(inferenceReq)
            .map(chunk -> ServerSentEvent.<InferenceChunk>builder()
                .data(chunk)
                .event("message")
                .build())
            .onErrorResume(e -> {
                log.error("流式对话失败", e);
                return Flux.just(ServerSentEvent.<InferenceChunk>builder()
                    .event("error")
                    .data(InferenceChunk.error("服务暂时不可用"))
                    .build());
            });
    }
    private InferenceRequest assembleInferenceRequest(ChatRequest req) {
        // 拼接会话历史 + 知识片段 + 用户问题
        List<Message> messages = new ArrayList<>();
        messages.add(Message.of("system", buildSystemPrompt(req)));
        messages.addAll(loadHistory(req.getConversationId()));
        messages.add(Message.of("user", req.getQuestion()));
        return InferenceRequest.builder()
            .model(req.getModel())
            .messages(messages)
            .stream(true)
            .temperature(0.7)
            .build();
    }
}

4.4 背压处理

流式输出可能出现生产者(模型)比消费者(客户端)快的情况,如果不处理背压,内存中会堆积大量未发送的 chunk。Reactor 的 Flux 自带背压支持,但需要选择合适的策略。对于大模型场景,推荐用 BUFFER 策略但限制缓冲区大小,超过则丢弃最旧的 chunk(对对话场景影响小,因为用户在看最新的内容)。

return inferenceClient.chatStream(inferenceReq)
    .onBackpressureBuffer(64, dropped ->
        log.warn("背压丢弃chunk, conversationId={}", req.getConversationId()),
        BufferOverflowStrategy.DROP_OLDEST)
    .map(chunk -> ServerSentEvent.<InferenceChunk>builder()
        .data(chunk)
        .build());

5.1 大模型容错的特殊性

大模型推理的容错策略和普通 HTTP 调用不同。普通 HTTP 调用超时通常设 3-5 秒,重试 3 次很正常;但大模型推理动辄 10-30 秒,重试 3 次意味着用户可能等 90 秒,这是不可接受的。因此大模型容错的核心思路是:**快速失败优于漫长等待,降级优于重试**。

5.2 超时分层设置

超时不能只设一个全局值,需要分层设置。第一层是连接超时(5 秒),控制建立连接的时间;第二层是读取超时(60 秒),控制等待响应的时间;第三层是总超时(90 秒),硬性上限。连接超时短、读取超时长,因为大模型响应慢是正常的,但连接不上说明服务有问题。

@Configuration
public class WebClientConfig {
    @Bean
    @Qualifier("inferenceWebClient")
    public WebClient inferenceWebClient() {
        HttpClient httpClient = HttpClient.create()
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000)  // 连接超时5秒
            .responseTimeout(Duration.ofSeconds(60))               // 读取超时60秒
            .doOnConnected(conn -> conn
                .addHandlerLast(new ReadTimeoutHandler(60))
                .addHandlerLast(new WriteTimeoutHandler(10)));
        return WebClient.builder()
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .build();
    }
}

5.3 熔断器配置

熔断器的核心参数需要针对大模型特性调整。窗口大小要够大(至少 20 次调用),因为推理调用慢,小窗口统计不稳定;失败率阈值建议 50%,比普通服务略低,因为大模型失败成本高(用户等了很久才发现失败,体验差);半开状态的试探调用数建议 3-5 次,不要太多。

@Configuration
public class CircuitBreakerConfig {
    @Bean
    public Customizer<Resilience4JCircuitBreakerFactory> defaultCustomizer() {
        return factory -> factory.configureDefault(id -> {
            CircuitBreakerConfig config = CircuitBreakerConfig.custom()
                .slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
                .slidingWindowSize(20)
                .minimumNumberOfCalls(10)
                .failureRateThreshold(50)
                .slowCallRateThreshold(80)            // 80%调用慢则熔断
                .slowCallDurationThreshold(Duration.ofSeconds(45)) // 超过45秒算慢调用
                .waitDurationInOpenState(Duration.ofSeconds(15))
                .permittedNumberOfCallsInHalfOpenState(3)
                .automaticTransitionFromOpenToHalfOpenEnabled(true)
                .build();
            TimeLimiterConfig timeLimiter = TimeLimiterConfig.custom()
                .timeoutDuration(Duration.ofSeconds(90))
                .build();
            return new Resilience4JConfigBuilder(id)
                .circuitBreakerConfig(config)
                .timeLimiterConfig(timeLimiter)
                .build();
        });
    }
}

5.4 降级策略

降级是容错的最后一道防线。当推理服务完全不可用时,应该返回有意义的兜底内容,而不是裸的错误信息。降级策略按优先级包括:返回缓存的历史回答(如果相同问题之前问过)、返回简化模型的回答(从 GPT-4o 降级到本地小模型)、返回标准兜底文案。

@Component
public class InferenceFallback implements InferenceClient {
    @Autowired
    private RedisTemplate<String, String> redis;
    @Autowired
    private CacheProvider cacheProvider;
    @Override
    public InferenceResponse chat(InferenceRequest request) {
        String cacheKey = buildCacheKey(request);
        // 优先返回缓存
        String cached = redis.opsForValue().get("inference:cache:" + cacheKey);
        if (cached != null) {
            return InferenceResponse.builder()
                .content(cached)
                .finishReason("cache_hit")
                .tokenUsage(TokenUsage.builder().totalTokens(0).build())
                .build();
        }
        // 兜底文案
        return InferenceResponse.builder()
            .content("抱歉,当前服务繁忙,请稍后重试。")
            .finishReason("fallback")
            .build();
    }
    private String buildCacheKey(InferenceRequest request) {
        // 对请求内容做哈希,相同请求返回相同缓存
        String content = request.getMessages().stream()
            .map(m -> m.getRole() + ":" + m.getContent())
            .collect(Collectors.joining("|"));
        return DigestUtils.md5DigestAsHex(content.getBytes());
    }
}

5.5 重试策略

重试只对幂等且瞬时故障有效。大模型推理不是幂等的(同样的输入可能返回不同结果),但"返回不同结果"不影响业务正确性,因此可以重试。关键限制是:只重试瞬时故障(连接超时、5xx),不重试业务错误(4xx);重试次数不超过 1 次;重试间隔用指数退避。

Retry inferenceRetry = Retry.of("inference", RetryConfig.custom()
    .maxAttempts(2)                                    // 最多2次(1次重试)
    .intervalFunction(IntervalFunction.ofExponentialBackoff(500, 2))
    .retryOnException(e -> isTransient(e))             // 只重试瞬时故障
    .retryOnResult(result -> false)                     // 不按结果重试
    .build());
private boolean isTransient(Throwable e) {
    if (e instanceof WebClientResponseException wre) {
        return wre.getStatusCode().is5xxServerError();
    }
    return e instanceof java.net.ConnectException
        || e instanceof java.util.concurrent.TimeoutException;
}

6.1 为什么推理服务负责计量

Token 用量是大模型应用的核心成本指标。计量逻辑放在推理服务有两个原因:第一,推理服务是唯一知道实际使用了多少 token 的地方(模型返回的 usage 字段最准确);第二,把计量逻辑内聚在推理服务,避免计量逻辑散落各处。

6.2 计量实现

计量分两步:先从模型响应中提取 token 用量,再异步上报到计量服务。异步上报是为了不阻塞主流程。

@Component
public class TokenMeter {
    @Autowired
    private MeterRegistry meterRegistry;
    @Autowired
    private MeteringClient meteringClient;  // 远程计量服务
    @Async
    public void record(InferenceRequest request, InferenceResponse response) {
        TokenUsage usage = response.getTokenUsage();
        if (usage == null) return;
        // Prometheus 本地指标
        meterRegistry.counter("inference.tokens",
            "model", request.getModel(),
            "type", "prompt").increment(usage.getPromptTokens());
        meterRegistry.counter("inference.tokens",
            "model", request.getModel(),
            "type", "completion").increment(usage.getCompletionTokens());
        // 上报计量服务(用于按业务线计费)
        TokenRecord record = TokenRecord.builder()
            .model(request.getModel())
            .promptTokens(usage.getPromptTokens())
            .completionTokens(usage.getCompletionTokens())
            .totalTokens(usage.getTotalTokens())
            .timestamp(LocalDateTime.now())
            .build();
        try {
            meteringClient.report(record);
        } catch (Exception e) {
            log.warn("计量上报失败, 不影响主流程", e);
        }
    }
}

6.3 token 估算

有时候模型不返回 token 用量(某些流式接口不返回 usage),需要本地估算。可以用tiktoken4j库做近似估算。

@Component
public class TokenEstimator {
    private final Encoding encoding;
    public TokenEstimator() {
        this.encoding = Encodings.newDefaultEncoding();
    }
    public int estimate(String text) {
        return encoding.countTokens(text);
    }
    public int estimate(List<Message> messages) {
        return messages.stream()
            .mapToInt(m -> estimate(m.getContent()) + 4)  // 每条消息约4 token开销
            .sum();
    }
}

7.1 为什么需要排队

推理服务的处理能力是有限的。一个 GPU 实例同时处理的请求数取决于显存和批处理窗口,通常在 8-32 之间。超过这个并发数,请求要么排队要么被拒绝。直接拒绝会让用户体验差(看到 429 错误),而合理排队能在峰值时削峰、提升整体吞吐。

7.2 有界队列实现

用 Semaphore 控制并发数,用有界队列控制排队长度。超过队列长度则快速拒绝。

@Component
public class InferenceQueueManager {
    private final Semaphore inferenceSlots;
    private final BlockingQueue<InferenceTask> waitQueue;
    private final int maxQueueSize;
    public InferenceQueueManager(InferenceProperties props) {
        this.inferenceSlots = new Semaphore(props.getMaxConcurrency());
        this.maxQueueSize = props.getMaxQueueSize();
        this.waitQueue = new LinkedBlockingQueue<>(maxQueueSize);
    }
    public <T> CompletableFuture<T> submit(Supplier<CompletableFuture<T>> task,
                                            Duration timeout) {
        CompletableFuture<T> future = new CompletableFuture<>();
        InferenceTask inferenceTask = new InferenceTask(task, future, timeout);
        // 入队失败则快速拒绝
        if (!waitQueue.offer(inferenceTask)) {
            future.completeExceptionally(
                new InferenceRejectedException("推理队列已满, 请稍后重试"));
            return future;
        }
        tryDispatch();
        return future;
    }
    private void tryDispatch() {
        while (inferenceSlots.tryAcquire()) {
            InferenceTask task = waitQueue.poll();
            if (task == null) {
                inferenceSlots.release();
                break;
            }
            task.execute().whenComplete((result, error) -> {
                inferenceSlots.release();
                tryDispatch();  // 处理下一个排队任务
            });
        }
    }
}

7.3 队列深度监控

队列深度是推理服务最重要的运行时指标,扩容决策依赖它。通过 Micrometer 暴露队列深度。

@Scheduled(fixedRate = 1000)
public void reportQueueMetrics() {
    meterRegistry.gauge("inference.queue.depth", waitQueue.size());
    meterRegistry.gauge("inference.queue.available_slots", inferenceSlots.availablePermits());
}

8.1 冷启动问题

推理服务的新实例冷启动需要加载模型权重到显存,这个过程可能耗时 30-120 秒(取决于模型大小)。如果新实例一启动就接流量,首批请求会超时。解决方案是 readiness probe 检查模型加载状态,加载完成才标记就绪。

8.2 预热实现

在服务启动后,主动发起一次预热推理请求,确保模型已加载到显存且推理框架已就绪。

@Component
public class ModelWarmupRunner implements ApplicationRunner {
    @Autowired
    private ModelRouter modelRouter;
    @Override
    public void run(ApplicationArguments args) {
        List<String> modelsToWarm = List.of("gpt-4o", "qwen-max", "local-vllm");
        for (String modelId : modelsToWarm) {
            try {
                ModelProvider provider = modelRouter.route(modelId);
                InferenceRequest warmupReq = InferenceRequest.builder()
                    .model(modelId)
                    .messages(List.of(Message.of("user", "hello")))
                    .maxTokens(5)
                    .build();
                provider.complete(warmupReq);
                log.info("模型预热完成: {}", modelId);
            } catch (Exception e) {
                log.warn("模型预热失败: {}, 启动后按需加载", modelId, e);
            }
        }
    }
}

8.3 健康检查区分 liveness 和 readiness

Kubernetes 的 liveness probe 和 readiness probe 要区分设置。liveness 检查 JVM 是否存活(简单),readiness 检查模型是否加载完成(严格)。这样模型加载期间不会被杀掉,但也不会接流量。

@RestController
@RequestMapping("/actuator")
public class HealthController {
    @Autowired
    private ModelRouter modelRouter;
    @GetMapping("/liveness")
    public ResponseEntity<Void> liveness() {
        return ResponseEntity.ok().build();
    }
    @GetMapping("/readiness")
    public ResponseEntity<Void> readiness() {
        // 至少有一个模型可用才算就绪
        boolean anyAvailable = modelRouter.getAllProviders()
            .stream().anyMatch(ModelProvider::isAvailable);
        return anyAvailable ? ResponseEntity.ok().build()
                            : ResponseEntity.status(503).build();
    }
}

9.1 输入审核

推理服务应该在调用模型前做基础的内容审核,过滤明显的违规内容。审核可以基于关键词匹配或调用专门的审核 API。关键词匹配速度快,作为第一道防线;审核 API 准确但慢,作为补充。

@Component
public class InputGuard {
    private static final Set<String> BLOCKED_PATTERNS = Set.of(
        // 简化示例,实际应从配置加载
    );
    @Autowired
    private Content moderationClient;
    public void validate(InferenceRequest request) {
        for (Message msg : request.getMessages()) {
            if (containsBlockedContent(msg.getContent())) {
                throw new ContentViolationException("输入内容包含违规信息");
            }
        }
        // 异步调用审核API(不阻塞主流程)
        moderationClient.checkAsync(request.getMessages());
    }
    private boolean containsBlockedContent(String content) {
        String lower = content.toLowerCase();
        return BLOCKED_PATTERNS.stream().anyMatch(lower::contains);
    }
}

9.2 输出脱敏

模型生成的输出可能包含敏感信息(如身份证号、手机号),需要在返回前做脱敏处理。

@Component
public class OutputSanitizer {
    private static final Pattern PHONE = Pattern.compile("1[3-9]\\d{9}");
    private static final Pattern ID_CARD = Pattern.compile("\\d{17}[0-9Xx]");
    private static final Pattern EMAIL = Pattern.compile("[\\w.-]+@[\\w.-]+\\.\\w+");
    public String sanitize(String content) {
        content = PHONE.matcher(content).replaceAll(m -> mask(m.group(), 3, 4));
        content = ID_CARD.matcher(content).replaceAll(m -> mask(m.group(), 6, 4));
        content = EMAIL.matcher(content).replaceAll(m -> maskEmail(m.group()));
        return content;
    }
    private String mask(String s, int prefix, int suffix) {
        if (s.length() <= prefix + suffix) return "***";
        return s.substring(0, prefix) + "***" + s.substring(s.length() - suffix);
    }
    private String maskEmail(String email) {
        int at = email.indexOf('@');
        if (at <= 1) return email;
        return email.substring(0, 1) + "***" + email.substring(at);
    }
}

9.3 API Key 安全管理

推理服务持有的模型 API Key 是高敏感信息,不能硬编码或明文配置。建议用 Vault 或 Nacos 的加密配置功能存储,运行时解密加载。

10.1 核心要点回顾

AI 推理服务独立化的核心是守住"只做推理"的职责边界,通过多模型路由抽象屏蔽底层差异,通过 SSE 全链路透传实现流式输出,通过分层超时和熔断降级保障可用性,通过 token 计量实现成本可追溯,通过请求排队和模型预热优化性能。

10.2 上线检查清单

  1. **接口契约**:OpenAPI 文档已定义,同步和流式双模式已实现。
  2. **多模型路由**:至少配置了主模型 + 1 个 fallback,故障转移已验证。
  3. **超时分层**:连接 5s / 读取 60s / 总超时 90s,均已配置。
  4. **熔断降级**:熔断器参数已按大模型特性调优,降级兜底文案已准备。
  5. **SSE 透传**:网关、编排、推理三层流式透传已端到端验证。
  6. **背压控制**:有界队列已实现,队列深度指标已暴露。
  7. **模型预热**:启动预热已实现,readiness probe 检查模型就绪状态。
  8. **Token 计量**:用量统计已实现,异步上报计量服务已联通。
  9. **内容审核**:输入审核和输出脱敏已实现。
  10. **API Key 安全**:密钥已加密存储,未硬编码。
  11. **指标监控**:延迟、错误率、队列深度、token 用量指标均已暴露。
  12. **压测验证**:已通过压测验证扩缩容效果和降级表现。

10.3 后续预告

下一篇将聚焦 Prompt 管理服务的设计与实现,包括模板版本管理、变量渲染引擎、AB 测试等核心能力。

> 本文是"Java 程序员第 44 阶段"系列的第 03 篇,聚焦 AI 推理服务的独立化设计。建议结合前两篇方法论阅读。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐