第1701篇:用Java Records重构AI响应DTO——不可变数据与结构化输出的最佳实践
第1701篇:用Java Records重构AI响应DTO——不可变数据与结构化输出的最佳实践
说实话,我在第一个AI项目里,DTO写得一塌糊涂。
那是2023年底,公司要做一个智能客服系统,接入了OpenAI的API。那时候我对AI还挺新鲜的,主要精力都放在prompt怎么写、怎么让模型不乱说话上,对Java代码质量这块没太上心。结果DTO层写了一堆getters和setters,各种构造器,代码量虚高,几个月后来维护的时候,自己都看不下去了。
后来Java 14正式引入Records(Java 16 GA),我才意识到——这玩意儿简直是为AI响应DTO量身定制的。今天就来聊聊这个话题,从为什么Records适合AI场景,到怎么处理复杂的LLM响应结构,再到和Spring AI的实战整合,一步步来。
一、先聊问题:AI响应DTO为什么容易写烂
先看看我之前的老代码长什么样:
public class ChatResponse {
private String id;
private String model;
private String content;
private Integer promptTokens;
private Integer completionTokens;
private Double temperature;
private Boolean cached;
private Long createdAt;
// 无参构造
public ChatResponse() {}
// 全参构造
public ChatResponse(String id, String model, String content,
Integer promptTokens, Integer completionTokens,
Double temperature, Boolean cached, Long createdAt) {
this.id = id;
this.model = model;
this.content = content;
// ... 省略剩余赋值
}
// 一堆getters setters...
public String getId() { return id; }
public void setId(String id) { this.id = id; }
// ... 继续省略
@Override
public boolean equals(Object o) { /* 手写或IDE生成 */ }
@Override
public int hashCode() { /* 手写或IDE生成 */ }
@Override
public String toString() { /* 手写或IDE生成 */ }
}这一个类就写了80多行。问题不是代码多,问题是:
1. 可变性带来的隐患。 AI响应是"已发生的事实",不应该被修改。但这种写法让任何人都能随意 response.setContent("被篡改了"),完全没有保护。
2. equals/hashCode 容易出bug。 团队里有人用Lombok,有人手写,还有人忘了写,导致缓存命中率计算不准确——我们的响应缓存有段时间就是因为这个坏掉的。
3. 嵌套结构越来越复杂。 GPT-4的响应有choices、message、finish_reason等多层结构,老式DTO嵌套起来维护成本指数级增长。
Record出来之后,我把整个DTO层重构了一遍,代码量砍掉了60%,而且更安全了。
二、Record基础:先把语法吃透
Java Record的本质是一个不可变的数据载体。编译器自动生成:构造器、所有字段的访问器(accessor)、equals、hashCode、toString。
// 就这一行顶80行的老代码
public record ChatResponse(
String id,
String model,
String content,
int promptTokens,
int completionTokens,
double temperature,
boolean cached,
long createdAt
) {}使用:
var response = new ChatResponse(
"chatcmpl-abc123",
"gpt-4o",
"Java Records非常适合AI场景",
150, 80, 0.7, false,
System.currentTimeMillis()
);
// 访问器,不是getter
String content = response.content(); // 注意:不是 getContent()
System.out.println(response); // 自动toString这里有个坑我得提一下:accessor方法名和字段名一样,不是getXxx()。如果你用Jackson做JSON序列化,默认配置可能识别不了,需要加配置:
// 方式一:在ObjectMapper上配置
ObjectMapper mapper = new ObjectMapper();
mapper.configure(MapperFeature.USE_GETTERS_AS_SETTERS, false);
// 同时需要添加模块
mapper.registerModule(new ParameterNamesModule());
// 方式二:直接加注解(更推荐)
@JsonAutoDetect(fieldVisibility = JsonAutoDetect.Visibility.ANY)
public record ChatResponse(...) {}或者更简单,用 @JsonProperty 明确标注:
public record ChatResponse(
@JsonProperty("id") String id,
@JsonProperty("model") String model,
@JsonProperty("content") String content
) {}实际上我在Spring Boot 3.x项目里,加了 jackson-module-parameter-names 依赖后基本不需要额外配置,Records和Jackson配合得挺好的。
三、为AI响应建模:从OpenAI响应结构说起
OpenAI的Chat Completion响应是个典型的嵌套结构:
{
"id": "chatcmpl-abc123",
"object": "chat.completion",
"created": 1677858242,
"model": "gpt-4o",
"choices": [
{
"message": {
"role": "assistant",
"content": "这是AI的回复"
},
"finish_reason": "stop",
"index": 0
}
],
"usage": {
"prompt_tokens": 13,
"completion_tokens": 7,
"total_tokens": 20
}
}用Records来建模这个结构,代码会非常优雅:
// 顶层响应
public record OpenAIChatResponse(
String id,
String object,
long created,
String model,
List<Choice> choices,
Usage usage
) {
// 便捷方法:获取第一个choice的内容
public String firstContent() {
if (choices == null || choices.isEmpty()) {
return "";
}
return choices.get(0).message().content();
}
// 判断是否正常结束
public boolean isNormalStop() {
return !choices.isEmpty() &&
"stop".equals(choices.get(0).finishReason());
}
}
// Choice嵌套Record
public record Choice(
Message message,
@JsonProperty("finish_reason") String finishReason,
int index
) {}
// Message嵌套Record
public record Message(
String role,
String content
) {}
// 用量统计
public record Usage(
@JsonProperty("prompt_tokens") int promptTokens,
@JsonProperty("completion_tokens") int completionTokens,
@JsonProperty("total_tokens") int totalTokens
) {
// 计算成本的便捷方法(以GPT-4o定价为例)
public double estimateCostUSD() {
double inputCost = promptTokens * 0.000005; // $5/1M tokens
double outputCost = completionTokens * 0.000015; // $15/1M tokens
return inputCost + outputCost;
}
}注意我在 OpenAIChatResponse 里加了实例方法 firstContent() 和 isNormalStop()。Record是可以有实例方法的,这一点很多人不知道或者没想到用。这让Record不仅仅是数据容器,还能携带领域逻辑。
四、紧凑构造器:数据验证的好时机
Record的紧凑构造器(Compact Constructor)是个宝藏特性,专门用来做验证和规范化:
public record AIRequestConfig(
String model,
double temperature,
int maxTokens,
String systemPrompt
) {
// 紧凑构造器 - 注意没有参数列表,也没有赋值语句
public AIRequestConfig {
// 验证模型名称
Objects.requireNonNull(model, "model不能为null");
if (model.isBlank()) {
throw new IllegalArgumentException("model不能为空字符串");
}
// 验证temperature范围
if (temperature < 0.0 || temperature > 2.0) {
throw new IllegalArgumentException(
"temperature必须在0.0到2.0之间,当前值:" + temperature);
}
// 验证maxTokens
if (maxTokens <= 0 || maxTokens > 128000) {
throw new IllegalArgumentException(
"maxTokens必须在1到128000之间,当前值:" + maxTokens);
}
// 规范化处理:去除systemPrompt两端空白
systemPrompt = systemPrompt != null ? systemPrompt.strip() : "";
// 规范化模型名称
model = model.toLowerCase().strip();
}
// 工厂方法:创建默认配置
public static AIRequestConfig defaults() {
return new AIRequestConfig("gpt-4o", 0.7, 4096, "你是一个有帮助的AI助手");
}
// 工厂方法:创建代码生成配置
public static AIRequestConfig forCodeGeneration() {
return new AIRequestConfig(
"gpt-4o", 0.2, 8192,
"你是一个专业的Java开发工程师,代码风格简洁规范"
);
}
// with系列方法:返回修改了某个字段的新实例
public AIRequestConfig withTemperature(double newTemperature) {
return new AIRequestConfig(model, newTemperature, maxTokens, systemPrompt);
}
public AIRequestConfig withModel(String newModel) {
return new AIRequestConfig(newModel, temperature, maxTokens, systemPrompt);
}
}这个模式我用得很多。with方法配合Record的不可变性,实现了一种类似Builder但更简洁的链式修改方式。不会修改原对象,每次都返回新实例,在并发场景下完全安全。
使用起来:
// 基础配置
AIRequestConfig base = AIRequestConfig.defaults();
// 需要更高创造性?返回新实例,原base不变
AIRequestConfig creative = base.withTemperature(1.2);
// 切换模型
AIRequestConfig withClaude = creative.withModel("claude-3-5-sonnet-20241022");
// 原来的base完全没有被修改
System.out.println(base.temperature()); // 0.7
System.out.println(creative.temperature()); // 1.2五、Record与Spring AI的整合实战
Spring AI(我用的1.x版本)对Record有很好的支持。来看一个完整的AI服务封装:
@Service
public class AIConversationService {
private final ChatClient chatClient;
// 对话结果Record
public record ConversationResult(
String sessionId,
String userMessage,
String assistantResponse,
int totalTokens,
long latencyMs,
boolean fromCache,
Instant timestamp
) {
// 自定义构造校验
public ConversationResult {
Objects.requireNonNull(sessionId, "sessionId required");
Objects.requireNonNull(userMessage, "userMessage required");
Objects.requireNonNull(assistantResponse, "assistantResponse required");
if (latencyMs < 0) throw new IllegalArgumentException("latencyMs不能为负数");
}
// 判断是否高延迟
public boolean isHighLatency() {
return latencyMs > 3000;
}
// 格式化摘要
public String summary() {
return String.format("[%s] 用时%dms, %d tokens, %s",
sessionId, latencyMs, totalTokens,
fromCache ? "命中缓存" : "实时响应");
}
}
// 流式响应的中间状态
public record StreamChunk(
String sessionId,
String delta, // 本次增量内容
boolean isLast, // 是否最后一块
int chunkIndex
) {}
public AIConversationService(ChatClient.Builder builder) {
this.chatClient = builder.build();
}
public ConversationResult chat(String sessionId, String userMessage) {
long startTime = System.currentTimeMillis();
try {
ChatResponse response = chatClient.prompt()
.user(userMessage)
.call()
.chatResponse();
long latencyMs = System.currentTimeMillis() - startTime;
String content = response.getResult().getOutput().getContent();
int totalTokens = response.getMetadata().getUsage().getTotalTokens().intValue();
return new ConversationResult(
sessionId,
userMessage,
content,
totalTokens,
latencyMs,
false,
Instant.now()
);
} catch (Exception e) {
throw new AIServiceException("AI对话失败: " + e.getMessage(), e);
}
}
// 流式版本,返回ConversationResult流
public Flux<StreamChunk> streamChat(String sessionId, String userMessage) {
AtomicInteger chunkIndex = new AtomicInteger(0);
return chatClient.prompt()
.user(userMessage)
.stream()
.chatResponse()
.map(response -> {
String delta = response.getResult().getOutput().getContent();
boolean isLast = "stop".equals(
response.getResult().getMetadata().getFinishReason());
return new StreamChunk(
sessionId,
delta != null ? delta : "",
isLast,
chunkIndex.getAndIncrement()
);
});
}
}六、结构化输出:Record配合JSON Schema
这是我最近比较兴奋的一个用法。GPT-4o支持Structured Output,可以强制模型按JSON Schema输出。用Record来定义这个Schema,比手写JSON Schema优雅太多了。
// 定义AI需要返回的结构
public record ProductAnalysis(
String productName,
String category,
List<String> keyFeatures,
int sentimentScore, // 1-10
String recommendation,
List<String> concerns
) {
public ProductAnalysis {
Objects.requireNonNull(productName, "productName required");
if (sentimentScore < 1 || sentimentScore > 10) {
throw new IllegalArgumentException("sentimentScore必须在1-10之间");
}
}
public boolean isPositive() {
return sentimentScore >= 7;
}
public String toMarkdownSummary() {
StringBuilder sb = new StringBuilder();
sb.append("## ").append(productName).append("\n\n");
sb.append("**分类**: ").append(category).append("\n");
sb.append("**情感评分**: ").append(sentimentScore).append("/10\n");
sb.append("**推荐**: ").append(recommendation).append("\n\n");
if (!keyFeatures.isEmpty()) {
sb.append("**主要特点**:\n");
keyFeatures.forEach(f -> sb.append("- ").append(f).append("\n"));
}
if (!concerns.isEmpty()) {
sb.append("\n**注意事项**:\n");
concerns.forEach(c -> sb.append("- ").append(c).append("\n"));
}
return sb.toString();
}
}
// 服务层使用
@Service
public class ProductReviewService {
private final ChatClient chatClient;
private final ObjectMapper objectMapper;
public ProductAnalysis analyzeReview(String reviewText) {
String prompt = """
分析以下产品评论,以JSON格式返回分析结果:
评论内容:
%s
请返回以下结构的JSON:
{
"productName": "产品名称",
"category": "产品类别",
"keyFeatures": ["特点1", "特点2"],
"sentimentScore": 8,
"recommendation": "推荐建议",
"concerns": ["注意事项1", "注意事项2"]
}
只返回JSON,不要其他内容。
""".formatted(reviewText);
String jsonResponse = chatClient.prompt()
.user(prompt)
.call()
.content();
try {
// 清理模型可能加的markdown代码块标记
String cleanJson = jsonResponse
.replaceAll("```json\\s*", "")
.replaceAll("```\\s*", "")
.strip();
return objectMapper.readValue(cleanJson, ProductAnalysis.class);
} catch (JsonProcessingException e) {
throw new AIParseException("无法解析AI返回的JSON: " + jsonResponse, e);
}
}
}这里有个实际踩坑:模型经常会在JSON外面加 json 这种markdown代码块标记,即使你明确说"只返回JSON"。所以必须做清理。我一般会写一个通用的JSON提取工具:
public final class JsonExtractor {
private static final Pattern JSON_BLOCK =
Pattern.compile("```(?:json)?\\s*([\\s\\S]*?)```");
private static final Pattern JSON_OBJECT =
Pattern.compile("\\{[\\s\\S]*\\}");
private JsonExtractor() {}
public static String extractJson(String text) {
// 先尝试匹配代码块
Matcher blockMatcher = JSON_BLOCK.matcher(text);
if (blockMatcher.find()) {
return blockMatcher.group(1).strip();
}
// 再尝试直接匹配JSON对象
Matcher objectMatcher = JSON_OBJECT.matcher(text);
if (objectMatcher.find()) {
return objectMatcher.group().strip();
}
// 实在找不到,返回原文本,让调用方处理解析错误
return text.strip();
}
public static <T> T parse(String text, Class<T> targetClass,
ObjectMapper mapper) {
String json = extractJson(text);
try {
return mapper.readValue(json, targetClass);
} catch (JsonProcessingException e) {
throw new AIParseException(
"JSON解析失败,原始文本: " + text + ",提取后: " + json, e);
}
}
}七、Record的局限性:不要硬用
用了一年多Records,我也总结了一些不适合用Record的场景:
场景1:需要懒加载的字段
Record的所有字段都在构造时确定,不能有懒加载逻辑。如果某个字段计算比较重,你不得不在构造时就算好,或者用Optional配合方法来模拟:
// 不好的做法:Record不支持懒加载
public record ExpensiveRecord(String rawData) {
// 想在第一次访问时才计算parsed,但Record做不到
// private ParsedData parsed; // 这不合法,Record字段必须在组件列表里
}
// 妥协方案:改用普通类,或者接受预先计算
public record ProcessedData(
String rawData,
ParsedData parsed // 构造时就传入,由调用方决定何时计算
) {
public static ProcessedData of(String rawData) {
// 在工厂方法里做计算
return new ProcessedData(rawData, ParsedData.parse(rawData));
}
}场景2:需要继承的层级结构
Record默认继承 java.lang.Record,不能再继承其他类。这对AI响应的多态处理是个限制,后面讲Sealed Classes的文章会专门解决这个问题。
场景3:JPA/Hibernate实体
绝对不要用Record做JPA实体。JPA需要无参构造器、可变的setter、代理类支持,和Record的设计完全冲突。我见过有人硬用,结果各种诡异的LazyInitializationException,最后还是改回来了。
// 错误示范
@Entity
public record UserEntity( // 千万别这样
@Id Long id,
String username
) {}
// 正确做法:实体用普通类,DTO用Record
@Entity
@Table(name = "users")
public class UserEntity {
@Id
private Long id;
private String username;
// getters setters...
}
// DTO转换用Record
public record UserDTO(Long id, String username) {
public static UserDTO from(UserEntity entity) {
return new UserDTO(entity.getId(), entity.getUsername());
}
}八、性能:Record和普通POJO有多大差别
这个问题我实际测过,结论是:几乎没有差别。
Record在JVM层面就是普通类,编译器生成的字节码和手写POJO基本一样。内存占用完全相同,因为字段类型和数量决定内存,不是类的形式。
唯一值得注意的是:Record的equals比较是所有字段的值比较,对于包含大List的Record,equals可能比你预期的慢。但这不是Record特有的问题,普通类用IDE生成的equals也一样。
// 如果作为Map键使用,注意这一点
public record CacheKey(
String model,
String promptHash, // 用hash而不是原始prompt
double temperature
) {
// 使用promptHash而不是完整prompt作为equals依据
// 避免大字符串比较
}九、一个完整的实战案例
最后来个完整的例子,把上面的内容串起来。这是一个AI辅助代码审查的DTO层设计:
// 代码审查请求
public record CodeReviewRequest(
String language,
String code,
ReviewType reviewType,
AIRequestConfig aiConfig
) {
public enum ReviewType {
SECURITY, PERFORMANCE, STYLE, COMPREHENSIVE
}
public CodeReviewRequest {
Objects.requireNonNull(language, "language required");
Objects.requireNonNull(code, "code required");
Objects.requireNonNull(reviewType, "reviewType required");
if (code.isBlank()) throw new IllegalArgumentException("code不能为空");
if (code.length() > 50000) {
throw new IllegalArgumentException("代码长度超过50000字符限制");
}
if (aiConfig == null) {
aiConfig = AIRequestConfig.defaults();
}
}
public static CodeReviewRequest securityReview(String language, String code) {
return new CodeReviewRequest(
language, code, ReviewType.SECURITY,
AIRequestConfig.defaults().withTemperature(0.1) // 安全审查用低temperature
);
}
}
// 单个问题
public record ReviewIssue(
String severity, // HIGH, MEDIUM, LOW, INFO
String category,
String description,
int lineNumber,
String suggestion,
String codeSnippet
) {
public boolean isCritical() {
return "HIGH".equals(severity);
}
}
// 审查结果
public record CodeReviewResult(
String language,
int overallScore, // 0-100
List<ReviewIssue> issues,
String summary,
List<String> positives,
long reviewDurationMs
) {
public CodeReviewResult {
if (overallScore < 0 || overallScore > 100) {
throw new IllegalArgumentException("overallScore必须在0-100之间");
}
issues = issues != null ? List.copyOf(issues) : List.of();
positives = positives != null ? List.copyOf(positives) : List.of();
}
public List<ReviewIssue> criticalIssues() {
return issues.stream()
.filter(ReviewIssue::isCritical)
.toList();
}
public boolean hasCriticalIssues() {
return issues.stream().anyMatch(ReviewIssue::isCritical);
}
public Map<String, Long> issuesBySeverity() {
return issues.stream()
.collect(Collectors.groupingBy(
ReviewIssue::severity,
Collectors.counting()
));
}
public String toReport() {
StringBuilder sb = new StringBuilder();
sb.append("# 代码审查报告\n\n");
sb.append("**语言**: ").append(language).append("\n");
sb.append("**综合评分**: ").append(overallScore).append("/100\n");
sb.append("**审查耗时**: ").append(reviewDurationMs).append("ms\n\n");
sb.append("## 概述\n").append(summary).append("\n\n");
if (!positives.isEmpty()) {
sb.append("## 优点\n");
positives.forEach(p -> sb.append("- ").append(p).append("\n"));
sb.append("\n");
}
if (!issues.isEmpty()) {
sb.append("## 问题列表\n");
issues.stream()
.sorted(Comparator.comparing(i -> switch(i.severity()) {
case "HIGH" -> 0;
case "MEDIUM" -> 1;
case "LOW" -> 2;
default -> 3;
}))
.forEach(issue -> {
sb.append("### [").append(issue.severity()).append("] ")
.append(issue.category()).append("\n");
sb.append("**位置**: 第").append(issue.lineNumber()).append("行\n");
sb.append("**描述**: ").append(issue.description()).append("\n");
sb.append("**建议**: ").append(issue.suggestion()).append("\n\n");
});
}
return sb.toString();
}
}这套设计下来,DTO层代码简洁、安全、自文档化,而且携带了足够的领域逻辑,避免了"贫血模型"的问题。
小结
回头看,Records这个特性真的很适合AI工程这个领域:
- AI的请求和响应天然是值对象,创建之后不应该被修改
- 嵌套结构用Record表达比POJO简洁得多
- 紧凑构造器做验证,让数据质量问题在入口就被拦住
- with方法配合不可变性,在配置变体场景下很好用
- 实例方法让Record有了领域逻辑,不只是数据袋
下一篇我们聊密封类(Sealed Classes),解决Records不能继承的问题,用它来建模LLM响应的多种变体——成功、失败、截断、被过滤,各种情况都能优雅处理。
