Java 程序员第 44 阶段03:大模型微服务拆分,独立服务解耦便于扩容维护,AI推理服务独立化
[推理服务独立化的目标与职责边界](#1-推理服务独立化的目标与职责边界)- [服务接口设计:同步与流式双模式](#2-服务接口设计同步与流式双模式)
- [多模型路由:统一抽象屏蔽底层差异](#3-多模型路由统一抽象屏蔽底层差异)
- [流式输出工程实现:SSE 全链路透传](#4-流式输出工程实现sse-全链路透传)
- [推理容错:熔断、降级、重试与超时策略](#5-推理容错熔断降级重试与超时策略)
- [Token 计量与用量统计](#6-token-计量与用量统计)
- [请求排队与背压控制](#7-请求排队与背压控制)
- [模型预热与冷启动优化](#8-模型预热与冷启动优化)
- [安全与合规:内容审核与脱敏](#9-安全与合规内容审核与脱敏)
- [本章小结与上线检查清单](#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 上线检查清单
- **接口契约**:OpenAPI 文档已定义,同步和流式双模式已实现。
- **多模型路由**:至少配置了主模型 + 1 个 fallback,故障转移已验证。
- **超时分层**:连接 5s / 读取 60s / 总超时 90s,均已配置。
- **熔断降级**:熔断器参数已按大模型特性调优,降级兜底文案已准备。
- **SSE 透传**:网关、编排、推理三层流式透传已端到端验证。
- **背压控制**:有界队列已实现,队列深度指标已暴露。
- **模型预热**:启动预热已实现,readiness probe 检查模型就绪状态。
- **Token 计量**:用量统计已实现,异步上报计量服务已联通。
- **内容审核**:输入审核和输出脱敏已实现。
- **API Key 安全**:密钥已加密存储,未硬编码。
- **指标监控**:延迟、错误率、队列深度、token 用量指标均已暴露。
- **压测验证**:已通过压测验证扩缩容效果和降级表现。
10.3 后续预告
下一篇将聚焦 Prompt 管理服务的设计与实现,包括模板版本管理、变量渲染引擎、AB 测试等核心能力。
> 本文是"Java 程序员第 44 阶段"系列的第 03 篇,聚焦 AI 推理服务的独立化设计。建议结合前两篇方法论阅读。
更多推荐

所有评论(0)