第1702篇:Java密封类在AI领域模型中的应用——用Sealed Classes建模LLM响应变体
第1702篇:Java密封类在AI领域模型中的应用——用Sealed Classes建模LLM响应变体
上一篇我们聊了用Records建模AI响应DTO,有读者在评论区问了一个很好的问题:如果LLM返回的结果有多种情况,比如成功、失败、内容被过滤、token超限,怎么用Records来处理?
这个问题直接把我问到了Java的另一个现代特性:密封类(Sealed Classes)。
说真的,这个特性我刚看到的时候没觉得有多特别,感觉就是给继承加了个门禁。但用在AI领域模型上之后,才意识到它解决的问题有多精准。今天把这块经验完整写出来。
一、从一个真实的业务场景说起
我们有个功能:用AI给用户的简历打分并给建议。调用LLM之后,可能出现以下情况:
- 正常返回:有评分、有详细建议
- 内容被安全过滤:简历里某些词触发了模型的内容政策
- Token超限:简历太长,超过了上下文窗口
- 模型超时:网络问题或模型负载高
- 解析失败:模型返回的JSON格式不对,解析不了
最开始我用了一个"万能"的响应类:
public class ResumeScoreResult {
private boolean success;
private String errorCode;
private String errorMessage;
private Integer score;
private List<String> suggestions;
private String filteredReason;
// ...各种字段
}然后调用方得写这样的代码:
ResumeScoreResult result = service.score(resume);
if (result.isSuccess()) {
// 处理正常结果
Integer score = result.getScore();
// 但你不确定score是否为null...
} else if ("CONTENT_FILTERED".equals(result.getErrorCode())) {
// 处理内容过滤
} else if ("TOKEN_LIMIT".equals(result.getErrorCode())) {
// 处理token超限
}
// ...这种写法有两个核心问题:
一是类型不安全。score在成功时应该有值,但编译器不知道,你每次用都得判断null,或者写注释告诉别人"这个字段只在success==true时有效"。
二是遗漏处理。如果加了新的错误类型,编译器不会提醒你。某天下午新加了"QUOTA_EXCEEDED",但有三个地方的switch忘了加对应分支,直到测试才发现。
密封类完美解决这两个问题。
二、密封类基础概念
Sealed Class(Java 17 GA)让你可以精确控制哪些类可以继承你的接口或类。
// 密封接口:只允许列出的子类型实现
public sealed interface LLMResult
permits LLMResult.Success, LLMResult.ContentFiltered,
LLMResult.TokenLimitExceeded, LLMResult.Timeout,
LLMResult.ParseFailure {
// Success:唯一有返回内容的情况
record Success(String content, int promptTokens, int completionTokens)
implements LLMResult {}
// 内容被过滤:有被过滤的原因
record ContentFiltered(String reason, String filterCategory)
implements LLMResult {}
// Token超限:记录是哪边超了
record TokenLimitExceeded(int requestedTokens, int maxAllowed, String side)
implements LLMResult {}
// 超时:记录等待了多久
record Timeout(long waitedMs, String endpoint)
implements LLMResult {}
// 解析失败:保留原始响应以便排查
record ParseFailure(String rawResponse, String parseError)
implements LLMResult {}
}这里我把所有变体都作为密封接口的内部类型(嵌套记录),这是我比较推荐的组织方式。代码更内聚,一眼就能看到所有可能的情况。
调用方的代码变成了这样:
LLMResult result = llmService.call(prompt);
// Java 21的模式匹配switch,编译器会检查穷举性
String display = switch (result) {
case LLMResult.Success s ->
"评分完成,消耗%d tokens".formatted(s.completionTokens());
case LLMResult.ContentFiltered f ->
"内容被过滤: " + f.reason();
case LLMResult.TokenLimitExceeded t ->
"内容太长,请缩短后重试(超出%d tokens)"
.formatted(t.requestedTokens() - t.maxAllowed());
case LLMResult.Timeout t ->
"服务繁忙,请稍后重试";
case LLMResult.ParseFailure p ->
"解析失败,技术团队已收到日志";
// 如果漏掉了某种情况,编译器会报错!
};漏掉任何一个分支,编译器直接报错。 这就是密封类的核心价值——让不完整的处理成为编译错误而不是运行时bug。
三、更复杂的AI领域模型建模
简历评分只是个小场景。在更复杂的AI应用里,响应变体会更多,层次也更深。来看一个对话管理系统的建模:
// 对话轮次结果
public sealed interface TurnResult
permits TurnResult.Completed, TurnResult.Interrupted, TurnResult.Failed {
// 对话完整结束
record Completed(
String assistantMessage,
List<ToolCall> toolCalls, // 工具调用(如果有)
FinishReason finishReason,
ConversationMetrics metrics
) implements TurnResult {
public boolean hasToolCalls() {
return toolCalls != null && !toolCalls.isEmpty();
}
}
// 对话中断(需要用户确认或提供额外信息)
sealed interface Interrupted extends TurnResult
permits Interrupted.NeedsUserConfirmation, Interrupted.NeedsMoreInfo {
record NeedsUserConfirmation(
String pendingAction,
String confirmationPrompt,
String actionId // 用来后续恢复
) implements Interrupted {}
record NeedsMoreInfo(
List<String> missingFields,
String clarificationPrompt
) implements Interrupted {}
}
// 对话失败
sealed interface Failed extends TurnResult
permits Failed.ModelError, Failed.GuardrailTriggered, Failed.QuotaExceeded {
record ModelError(String errorCode, String message, boolean retryable)
implements Failed {}
record GuardrailTriggered(
String guardrailName,
String violationType,
String safeAlternative // 可以给用户的安全回复
) implements Failed {}
record QuotaExceeded(
QuotaType quotaType,
Instant resetTime
) implements Failed {
public enum QuotaType { DAILY_TOKENS, RPM, MONTHLY_COST }
}
}
}
// 工具调用信息
public record ToolCall(
String toolId,
String toolName,
Map<String, Object> arguments,
ToolCallStatus status
) {
public enum ToolCallStatus { PENDING, COMPLETED, FAILED }
}
// 对话指标
public record ConversationMetrics(
int promptTokens,
int completionTokens,
long latencyMs,
String modelUsed,
double estimatedCostUSD
) {}
// 完成原因
public enum FinishReason {
STOP, // 正常结束
LENGTH, // 达到token限制
TOOL_CALLS, // 需要执行工具
CONTENT_FILTER // 内容过滤
}这个多层密封类的设计,准确地表达了AI对话可能发生的所有情况,而且层次很清晰:
TurnResult
├── Completed(完成)
├── Interrupted(中断)
│ ├── NeedsUserConfirmation
│ └── NeedsMoreInfo
└── Failed(失败)
├── ModelError
├── GuardrailTriggered
└── QuotaExceeded四、与Spring的整合:异常体系也能密封
密封类不只用于返回值,用来设计异常体系也很好用。我们AI服务的异常层次:
// 密封异常基类
public abstract sealed class AIException extends RuntimeException
permits AIException.AuthenticationException,
AIException.RateLimitException,
AIException.ContentPolicyException,
AIException.ModelUnavailableException,
AIException.InvalidRequestException {
protected AIException(String message) {
super(message);
}
protected AIException(String message, Throwable cause) {
super(message, cause);
}
// 是否值得重试
public abstract boolean isRetryable();
// 推荐的重试等待时间(秒),不重试返回0
public abstract int retryAfterSeconds();
// 认证失败
public static final class AuthenticationException extends AIException {
private final String apiKeyPrefix; // 脱敏后的key前缀,方便排查
public AuthenticationException(String apiKeyPrefix) {
super("API Key认证失败,Key前缀: " + apiKeyPrefix);
this.apiKeyPrefix = apiKeyPrefix;
}
@Override public boolean isRetryable() { return false; }
@Override public int retryAfterSeconds() { return 0; }
public String apiKeyPrefix() { return apiKeyPrefix; }
}
// 限流
public static final class RateLimitException extends AIException {
private final int retryAfter;
private final String limitType; // RPM, TPM, etc.
public RateLimitException(int retryAfter, String limitType) {
super("请求限流(%s),%d秒后重试".formatted(limitType, retryAfter));
this.retryAfter = retryAfter;
this.limitType = limitType;
}
@Override public boolean isRetryable() { return true; }
@Override public int retryAfterSeconds() { return retryAfter; }
public String limitType() { return limitType; }
}
// 内容政策违规
public static final class ContentPolicyException extends AIException {
private final String violationType;
public ContentPolicyException(String violationType) {
super("内容违反使用政策: " + violationType);
this.violationType = violationType;
}
@Override public boolean isRetryable() { return false; }
@Override public int retryAfterSeconds() { return 0; }
public String violationType() { return violationType; }
}
// 模型不可用
public static final class ModelUnavailableException extends AIException {
private final String modelId;
private final String fallbackModel; // 建议的备用模型
public ModelUnavailableException(String modelId, String fallbackModel) {
super("模型[%s]不可用,建议使用: %s".formatted(modelId, fallbackModel));
this.modelId = modelId;
this.fallbackModel = fallbackModel;
}
@Override public boolean isRetryable() { return true; }
@Override public int retryAfterSeconds() { return 30; }
public String modelId() { return modelId; }
public String fallbackModel() { return fallbackModel; }
}
// 请求参数错误
public static final class InvalidRequestException extends AIException {
private final String paramName;
private final String reason;
public InvalidRequestException(String paramName, String reason) {
super("请求参数[%s]无效: %s".formatted(paramName, reason));
this.paramName = paramName;
this.reason = reason;
}
@Override public boolean isRetryable() { return false; }
@Override public int retryAfterSeconds() { return 0; }
public String paramName() { return paramName; }
public String reason() { return reason; }
}
}在Spring的全局异常处理器里,配合模式匹配就非常干净:
@RestControllerAdvice
public class AIExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(AIExceptionHandler.class);
@ExceptionHandler(AIException.class)
public ResponseEntity<ErrorResponse> handleAIException(AIException ex) {
// 密封类 + 模式匹配,处理所有情况
ErrorResponse response = switch (ex) {
case AIException.AuthenticationException e -> {
log.error("API认证失败,Key前缀: {}", e.apiKeyPrefix());
yield new ErrorResponse("AUTH_FAILED", "服务认证配置异常,请联系管理员", 500);
}
case AIException.RateLimitException e -> {
log.warn("触发限流,类型: {},等待: {}s", e.limitType(), e.retryAfterSeconds());
yield new ErrorResponse("RATE_LIMITED",
"请求频率过高,请" + e.retryAfterSeconds() + "秒后重试", 429);
}
case AIException.ContentPolicyException e -> {
log.info("内容策略拦截,类型: {}", e.violationType());
yield new ErrorResponse("CONTENT_POLICY", "输入内容不符合使用规范", 400);
}
case AIException.ModelUnavailableException e -> {
log.error("模型不可用: {},备用: {}", e.modelId(), e.fallbackModel());
yield new ErrorResponse("MODEL_UNAVAILABLE", "AI服务暂时不可用,请稍后重试", 503);
}
case AIException.InvalidRequestException e -> {
log.warn("请求参数错误: {} - {}", e.paramName(), e.reason());
yield new ErrorResponse("INVALID_REQUEST", "请求参数有误: " + e.reason(), 400);
}
};
return ResponseEntity.status(response.httpStatus())
.body(response);
}
public record ErrorResponse(String code, String message, int httpStatus) {}
}五、密封类在状态机建模中的应用
AI对话本质上是个状态机,密封类用来表达状态也很贴切:
// AI对话会话状态
public sealed interface ConversationState
permits ConversationState.Idle, ConversationState.Processing,
ConversationState.WaitingForTool, ConversationState.Completed,
ConversationState.Error {
// 空闲:等待用户输入
record Idle(String sessionId, Instant lastActiveAt)
implements ConversationState {}
// 处理中:AI正在生成响应
record Processing(
String sessionId,
String currentMessage,
Instant startedAt,
int attemptNumber
) implements ConversationState {
public boolean isTimedOut() {
return Duration.between(startedAt, Instant.now()).getSeconds() > 30;
}
}
// 等待工具执行
record WaitingForTool(
String sessionId,
List<String> pendingToolCallIds,
String assistantMessageSoFar
) implements ConversationState {}
// 已完成
record Completed(
String sessionId,
String finalResponse,
ConversationMetrics metrics
) implements ConversationState {}
// 错误状态
record Error(
String sessionId,
String errorCode,
boolean recoverable,
String errorMessage
) implements ConversationState {}
}
// 状态转换服务
@Service
public class ConversationStateMachine {
public ConversationState transition(ConversationState current, ConversationEvent event) {
return switch (current) {
case ConversationState.Idle idle -> handleIdleTransition(idle, event);
case ConversationState.Processing processing -> handleProcessingTransition(processing, event);
case ConversationState.WaitingForTool waiting -> handleToolWaitTransition(waiting, event);
case ConversationState.Completed completed ->
// 完成状态只能重置到Idle
event instanceof ConversationEvent.Reset
? new ConversationState.Idle(completed.sessionId(), Instant.now())
: current;
case ConversationState.Error error ->
// 错误状态如果可恢复且收到Retry事件,可以转回Idle
(error.recoverable() && event instanceof ConversationEvent.Retry)
? new ConversationState.Idle(error.sessionId(), Instant.now())
: current;
};
}
private ConversationState handleIdleTransition(
ConversationState.Idle idle, ConversationEvent event) {
return switch (event) {
case ConversationEvent.MessageReceived msg ->
new ConversationState.Processing(
idle.sessionId(), msg.content(), Instant.now(), 1
);
default -> idle;
};
}
private ConversationState handleProcessingTransition(
ConversationState.Processing processing, ConversationEvent event) {
return switch (event) {
case ConversationEvent.ResponseGenerated resp ->
resp.hasToolCalls()
? new ConversationState.WaitingForTool(
processing.sessionId(),
resp.toolCallIds(),
resp.partialResponse()
)
: new ConversationState.Completed(
processing.sessionId(),
resp.fullResponse(),
resp.metrics()
);
case ConversationEvent.ErrorOccurred err ->
new ConversationState.Error(
processing.sessionId(),
err.errorCode(),
err.recoverable(),
err.message()
);
default -> processing;
};
}
// ... 其他转换处理省略
}
// 对话事件也用密封类建模
public sealed interface ConversationEvent
permits ConversationEvent.MessageReceived, ConversationEvent.ResponseGenerated,
ConversationEvent.ErrorOccurred, ConversationEvent.Reset, ConversationEvent.Retry {
record MessageReceived(String content, String userId) implements ConversationEvent {}
record ResponseGenerated(
String fullResponse,
String partialResponse,
List<String> toolCallIds,
ConversationMetrics metrics
) implements ConversationEvent {
public boolean hasToolCalls() {
return toolCallIds != null && !toolCallIds.isEmpty();
}
}
record ErrorOccurred(String errorCode, String message, boolean recoverable)
implements ConversationEvent {}
record Reset(String reason) implements ConversationEvent {}
record Retry() implements ConversationEvent {}
}六、密封类与Jackson序列化
把密封类用在API层,需要解决序列化问题。Jackson对密封类有一定支持,但需要配置多态类型处理:
@JsonTypeInfo(
use = JsonTypeInfo.Id.NAME,
include = JsonTypeInfo.As.PROPERTY,
property = "type"
)
@JsonSubTypes({
@JsonSubTypes.Type(value = LLMResult.Success.class, name = "success"),
@JsonSubTypes.Type(value = LLMResult.ContentFiltered.class, name = "content_filtered"),
@JsonSubTypes.Type(value = LLMResult.TokenLimitExceeded.class, name = "token_limit"),
@JsonSubTypes.Type(value = LLMResult.Timeout.class, name = "timeout"),
@JsonSubTypes.Type(value = LLMResult.ParseFailure.class, name = "parse_failure")
})
public sealed interface LLMResult
permits LLMResult.Success, LLMResult.ContentFiltered,
LLMResult.TokenLimitExceeded, LLMResult.Timeout,
LLMResult.ParseFailure {
// ...
}这样序列化出来的JSON会带上type字段:
{
"type": "success",
"content": "这是AI的回复",
"promptTokens": 150,
"completionTokens": 80
}{
"type": "content_filtered",
"reason": "包含违禁词汇",
"filterCategory": "hate_speech"
}反序列化时Jackson根据type字段选择正确的Record类型。
有一个坑:如果密封类的子类型是内部Record(嵌套在接口里面),Jackson的@JsonSubTypes需要配合完整类名或者用@JsonTypeName注解。
// 内部Record加上类型名注解
@JsonTypeName("success")
record Success(String content, int promptTokens, int completionTokens)
implements LLMResult {}七、测试:密封类让测试更容易穷举
密封类的另一个好处是测试时容易知道需要覆盖哪些场景:
@Test
class LLMResultHandlerTest {
private final ResultHandler handler = new ResultHandler();
@Test
void shouldHandleSuccess() {
var result = new LLMResult.Success("测试响应", 100, 50);
assertThat(handler.process(result)).isEqualTo(ProcessStatus.DONE);
}
@Test
void shouldHandleContentFiltered() {
var result = new LLMResult.ContentFiltered("违规内容", "hate_speech");
assertThat(handler.process(result)).isEqualTo(ProcessStatus.REJECTED);
}
@Test
void shouldHandleTokenLimit() {
var result = new LLMResult.TokenLimitExceeded(130000, 128000, "input");
assertThat(handler.process(result)).isEqualTo(ProcessStatus.NEED_TRUNCATE);
}
@Test
void shouldHandleTimeout() {
var result = new LLMResult.Timeout(30000L, "https://api.openai.com");
assertThat(handler.process(result)).isEqualTo(ProcessStatus.RETRY);
}
@Test
void shouldHandleParseFailure() {
var result = new LLMResult.ParseFailure("不是JSON", "Unexpected character");
assertThat(handler.process(result)).isEqualTo(ProcessStatus.ERROR);
}
// 密封类明确告诉你一共5种情况,测试用例不会漏
}对比之前用字符串errorCode的方式,那时候测试用例经常漏,因为你得靠文档或者阅读代码才能知道所有情况。密封类是自文档化的枚举集合。
八、架构视图
用密封类建模AI响应之后,整体结构是这样的:
九、和枚举的区别:什么时候用哪个
经常有人问我:密封类和枚举有什么本质区别?什么时候用哪个?
我的判断标准是:如果每种情况携带的数据结构不同,用密封类;如果只是类型标识、数据结构相同,用枚举。
// 适合用枚举:每种情况没有额外数据,或者数据结构相同
public enum FinishReason {
STOP, LENGTH, TOOL_CALLS, CONTENT_FILTER
}
// 适合用密封类:每种情况携带不同的数据
public sealed interface LLMResult permits LLMResult.Success, LLMResult.Failed {
record Success(String content, int tokens) implements LLMResult {}
// Failed携带的是error信息,和Success完全不同
record Failed(String errorCode, boolean retryable) implements LLMResult {}
}如果用枚举来处理后者,你会发现枚举里塞了一堆可空字段,然后调用方写代码时永远不知道哪个字段在什么情况下有值,这就是密封类要解决的问题。
小结
密封类在AI领域模型中的价值,不是炫技,而是实实在在解决了几个工程问题:
- 类型安全:不同情况携带不同的类型信息,编译器帮你检查
- 穷举检查:模式匹配switch漏掉情况会编译报错
- 自文档化:代码本身就说明了有哪些可能的情况
- 可扩展:加新的情况时,所有处理这个类型的地方都会被编译器标红,强迫你处理
配合下一篇要讲的模式匹配,密封类的表达力还能再上一个台阶。
