AI 应用的 Schema-First 开发——先定义数据结构,再写 Prompt
AI 应用的 Schema-First 开发——先定义数据结构,再写 Prompt
上个季度我们接了一个数据提取的需求:从用户上传的各种简历 PDF 里,自动提取候选人的结构化信息(姓名、工作经历、技术栈、教育背景等),然后写入数据库,供招聘系统使用。
最开始的做法是先写 Prompt——"你是一个简历解析专家,请从以下简历中提取关键信息",然后看看模型输出什么,再去适配代码。结果走了不少弯路:模型输出的格式每次都不一样,"工作经历"有时候是数组,有时候是带序号的字符串,有时候日期是"2021年3月",有时候是"2021-03",后端解析代码写得一团糟。
后来换了思路:先定义好数据结构(Schema),再写 Prompt 让模型填充这个 Schema。
这个改变不只是换了开发顺序,它从根本上改变了 AI 功能的可靠性——你先想清楚"我要什么",然后才让 AI 给你"你要什么"。
Schema-First 的核心思想
传统的 Prompt 开发流程是这样的:
写 Prompt -> 看模型输出 -> 写解析代码 -> 发现输出不稳定 -> 改 Prompt -> 改解析代码 -> ...Schema-First 的流程是这样的:
定义输出 Schema -> 基于 Schema 写 Prompt -> 用 Schema 做类型约束 -> 部署关键区别在于:Schema 是合约,它同时约束了 Prompt 和解析代码。当你先把 Schema 定义好,Prompt 的写法就很清晰了——你只需要告诉模型"按这个 Schema 填充数据"。
这和 API 开发里的"契约优先"(Contract-First)是同一个思想:先定义接口,再实现。
和 Structured Output 的区别
可能有人会问:这和我之前写的 Structured Output 有什么不同?
Structured Output(结构化输出)主要解决的是"怎么让模型输出 JSON 而不是自然语言"——它是一个技术实现层面的问题,核心是用 JSON Schema 约束模型的输出格式。
Schema-First 是一个设计模式,它强调的是开发流程:先设计数据结构,再设计 Prompt。Schema-First 包含但不限于 Structured Output。
Schema-First 还包含:
- Schema 驱动的 Prompt 自动生成
- Schema 驱动的校验逻辑
- Schema 版本管理和演化
- Schema 和业务实体的映射
简单说:Structured Output 告诉你"怎么做",Schema-First 告诉你"先做什么"。
Schema 定义的最佳实践
用 Java Record 或注解驱动的 POJO
Spring AI 支持直接把 Java 类作为 Schema 来约束输出:
/**
* 候选人简历的结构化表示
* 使用 Jackson 注解来控制 JSON 序列化
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
public record CandidateResume(
@JsonProperty("name")
@Schema(description = "候选人姓名")
String name,
@JsonProperty("email")
@Schema(description = "电子邮箱地址")
String email,
@JsonProperty("phone")
@Schema(description = "联系电话")
String phone,
@JsonProperty("summary")
@Schema(description = "个人简介(如有)")
String summary,
@JsonProperty("work_experiences")
@Schema(description = "工作经历列表,按时间倒序排列")
List<WorkExperience> workExperiences,
@JsonProperty("education")
@Schema(description = "教育经历列表")
List<Education> education,
@JsonProperty("skills")
@Schema(description = "技术技能列表")
List<String> skills,
@JsonProperty("certifications")
@Schema(description = "证书和资质列表")
List<String> certifications
) {
@JsonInclude(JsonInclude.Include.NON_NULL)
public record WorkExperience(
@JsonProperty("company") String company,
@JsonProperty("title") String title,
@JsonProperty("start_date") String startDate, // 统一格式:YYYY-MM
@JsonProperty("end_date") String endDate, // "至今" 或 YYYY-MM
@JsonProperty("description") String description,
@JsonProperty("tech_stack") List<String> techStack
) {}
@JsonInclude(JsonInclude.Include.NON_NULL)
public record Education(
@JsonProperty("school") String school,
@JsonProperty("degree") String degree,
@JsonProperty("major") String major,
@JsonProperty("graduation_year") Integer graduationYear
) {}
}Schema 文档化:给模型看的字段说明
Schema 定义不只是给代码用的,还要给 Prompt 用。我写了一个 Schema 文档化工具,自动把 Java Schema 转成人类可读的字段说明:
@Component
public class SchemaDocumentationGenerator {
private final ObjectMapper objectMapper;
/**
* 生成 Schema 文档,用于注入 Prompt
* 输出类似于:
* - name (字符串): 候选人姓名
* - work_experiences (数组): 工作经历列表
* - company (字符串): 公司名称
* ...
*/
public String generateSchemaDoc(Class<?> schemaClass) {
StringBuilder sb = new StringBuilder();
sb.append("输出的 JSON 结构说明:\n");
generateClassDoc(schemaClass, sb, 0);
return sb.toString();
}
private void generateClassDoc(Class<?> clazz, StringBuilder sb, int depth) {
String indent = " ".repeat(depth);
for (Field field : getAllFields(clazz)) {
JsonProperty jsonProp = field.getAnnotation(JsonProperty.class);
Schema schema = field.getAnnotation(Schema.class);
String fieldName = jsonProp != null ? jsonProp.value() : field.getName();
String description = schema != null ? schema.description() : "无描述";
String typeName = getTypeName(field.getGenericType());
sb.append(indent).append("- ").append(fieldName)
.append(" (").append(typeName).append("): ")
.append(description).append("\n");
// 递归处理嵌套类型
if (isNestedRecord(field.getType())) {
generateClassDoc(field.getType(), sb, depth + 1);
}
}
}
// ... 辅助方法省略
/**
* 生成 JSON Schema(OpenAI 格式),直接传给 API 的 response_format
*/
public JsonNode generateJsonSchema(Class<?> schemaClass) {
JsonSchemaGenerator schemaGen = new JsonSchemaGenerator(objectMapper);
try {
JsonSchema schema = schemaGen.generateSchema(schemaClass);
return objectMapper.valueToTree(schema);
} catch (JsonMappingException e) {
throw new RuntimeException("Schema 生成失败", e);
}
}
}Schema-First 的 Prompt 构建模式
有了 Schema,Prompt 的结构就非常清晰了:
@Service
@Slf4j
public class SchemaFirstResumeParser {
private final ChatClient chatClient;
private final SchemaDocumentationGenerator schemaDoc;
private final ObjectMapper objectMapper;
public SchemaFirstResumeParser(ChatClient.Builder builder,
SchemaDocumentationGenerator schemaDoc,
ObjectMapper objectMapper) {
// 配置 chatClient 强制 JSON 输出
this.chatClient = builder
.defaultOptions(OpenAiChatOptions.builder()
.withResponseFormat(new ResponseFormat(ResponseFormat.Type.JSON_OBJECT))
.build())
.build();
this.schemaDoc = schemaDoc;
this.objectMapper = objectMapper;
}
public CandidateResume parseResume(String resumeText) {
// 1. 生成 Schema 文档
String schemaDescription = schemaDoc.generateSchemaDoc(CandidateResume.class);
// 2. 构建 Schema-First 的 Prompt
String systemPrompt = buildSystemPrompt(schemaDescription);
String userPrompt = buildUserPrompt(resumeText);
log.debug("Schema-First Prompt 长度:{} + {} Token(估算)",
systemPrompt.length() / 2, userPrompt.length() / 2);
// 3. 调用模型
String jsonResponse = chatClient.prompt()
.system(systemPrompt)
.user(userPrompt)
.call()
.content();
// 4. 反序列化到强类型对象
try {
return objectMapper.readValue(jsonResponse, CandidateResume.class);
} catch (JsonProcessingException e) {
log.error("JSON 反序列化失败,原始响应:{}", jsonResponse);
throw new ResumeParseException("简历解析失败:JSON 格式错误", e);
}
}
private String buildSystemPrompt(String schemaDescription) {
return """
你是一名专业的简历信息提取助手。
你的任务是从简历文本中提取结构化信息,严格按照以下 JSON 结构输出。
%s
重要规则:
1. 只输出 JSON,不要有任何额外文字、解释或 markdown 代码块
2. 日期格式统一为 "YYYY-MM"(如 "2021-03")
3. 如果某字段信息不存在,使用 null
4. 工作经历按时间倒序排列(最新的在前)
5. 技术栈只列具体的技术名称(如 "Java"、"Spring Boot"),不要写完整句子
6. "至今" 用字符串 "present" 表示
""".formatted(schemaDescription);
}
private String buildUserPrompt(String resumeText) {
return "请解析以下简历:\n\n" + resumeText;
}
}Schema 版本管理
Schema 会随着业务需求演化。比如我们一开始没有 certifications 字段,后来加上了。如何管理 Schema 的版本?
/**
* Schema 注册表,管理不同版本的 Schema
*/
@Component
public class SchemaRegistry {
private final Map<String, Map<Integer, Class<?>>> schemas = new HashMap<>();
@PostConstruct
public void registerSchemas() {
// 简历 Schema
registerSchema("resume", 1, CandidateResumeV1.class);
registerSchema("resume", 2, CandidateResume.class); // 当前版本
// 合同分析 Schema
registerSchema("contract_info", 1, ContractInfo.class);
}
public void registerSchema(String schemaId, int version, Class<?> schemaClass) {
schemas.computeIfAbsent(schemaId, k -> new TreeMap<>())
.put(version, schemaClass);
}
public Class<?> getLatestSchema(String schemaId) {
Map<Integer, Class<?>> versions = schemas.get(schemaId);
if (versions == null || versions.isEmpty()) {
throw new IllegalArgumentException("Schema 不存在: " + schemaId);
}
// TreeMap 的最后一个键就是最大版本号
return ((TreeMap<Integer, Class<?>>) versions).lastEntry().getValue();
}
public Class<?> getSchema(String schemaId, int version) {
Map<Integer, Class<?>> versions = schemas.get(schemaId);
if (versions == null) {
throw new IllegalArgumentException("Schema 不存在: " + schemaId);
}
Class<?> schema = versions.get(version);
if (schema == null) {
throw new IllegalArgumentException("Schema 版本不存在: " + schemaId + " v" + version);
}
return schema;
}
}Schema 驱动的校验
Schema-First 的另一个好处是校验逻辑可以和 Schema 耦合,而不是分散在各处:
@Component
public class SchemaValidator {
/**
* 基于 Schema 注解做业务规则校验
* (区别于 JSON 格式校验,这里是业务层面的校验)
*/
public ValidationResult validate(CandidateResume resume) {
List<String> errors = new ArrayList<>();
List<String> warnings = new ArrayList<>();
// 必填字段校验
if (resume.name() == null || resume.name().isBlank()) {
errors.add("姓名不能为空");
}
// 日期格式校验
if (resume.workExperiences() != null) {
for (CandidateResume.WorkExperience exp : resume.workExperiences()) {
if (!isValidDateFormat(exp.startDate())) {
errors.add("工作经历日期格式不正确: " + exp.startDate() +
",应为 YYYY-MM 格式");
}
// 日期逻辑校验
if (!"present".equals(exp.endDate()) &&
exp.endDate() != null &&
exp.startDate() != null &&
exp.endDate().compareTo(exp.startDate()) < 0) {
errors.add("工作经历结束日期早于开始日期: " + exp.company());
}
}
}
// 合理性警告(不是错误,但值得关注)
if (resume.workExperiences() != null && resume.workExperiences().size() > 15) {
warnings.add("工作经历超过 15 条,请确认是否提取完整");
}
if (resume.skills() != null && resume.skills().size() > 50) {
warnings.add("技能列表超过 50 项,可能包含非技术技能");
}
return new ValidationResult(errors, warnings, errors.isEmpty());
}
private boolean isValidDateFormat(String date) {
if (date == null || "present".equals(date)) return true;
return date.matches("\\d{4}-\\d{2}");
}
}带重试的完整解析流程
@Service
@Slf4j
public class RobustResumeParser {
private final SchemaFirstResumeParser parser;
private final SchemaValidator validator;
private final ChatClient repairClient;
public ParseResult parseWithRetry(String resumeText) {
int maxAttempts = 3;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
CandidateResume resume = parser.parseResume(resumeText);
ValidationResult validation = validator.validate(resume);
if (validation.hasErrors()) {
log.warn("第 {} 次解析校验失败:{}", attempt, validation.getErrors());
if (attempt < maxAttempts) {
// 把校验错误信息反馈给模型,让它修复
resumeText = repairWithFeedback(resumeText, validation.getErrors(), resume);
}
} else {
if (!validation.getWarnings().isEmpty()) {
log.info("解析成功(有警告):{}", validation.getWarnings());
}
return ParseResult.success(resume, validation);
}
} catch (ResumeParseException e) {
log.warn("第 {} 次解析失败:{}", attempt, e.getMessage());
}
}
return ParseResult.failed("解析失败:超过最大重试次数");
}
/**
* 把校验错误反馈给模型,让模型修复输出
*/
private String repairWithFeedback(String originalResume,
List<String> errors,
CandidateResume previousOutput) {
// 这里不是修改原始简历,而是把错误信息加到 Prompt 里让模型重新输出
// 通过修改 resumeText 的方式传递额外信息(简化实现)
String errorFeedback = "\n\n[上次解析中发现以下问题,请在重新解析时注意修正:\n" +
String.join("\n", errors.stream().map(e -> "- " + e).collect(Collectors.toList())) +
"]";
return originalResume + errorFeedback;
}
}Schema 在复杂业务场景的应用
简历解析是一个比较简单的例子。再看一个更复杂的场景:从用户的自然语言输入中提取「旅行计划」。
/**
* 旅行计划 Schema
* 用于从自然语言提取结构化的旅行信息
*/
public record TravelPlan(
@JsonProperty("destination")
@Schema(description = "目的地城市或地区")
String destination,
@JsonProperty("departure_city")
@Schema(description = "出发城市")
String departureCity,
@JsonProperty("travel_dates")
TravelDates travelDates,
@JsonProperty("travelers")
TravelerInfo travelers,
@JsonProperty("preferences")
TravelPreferences preferences,
@JsonProperty("budget")
Budget budget,
@JsonProperty("special_requirements")
@Schema(description = "特殊需求,如无障碍设施、素食、宠物携带等")
List<String> specialRequirements
) {
public record TravelDates(
@JsonProperty("departure_date") String departureDate,
@JsonProperty("return_date") String returnDate,
@JsonProperty("is_flexible") Boolean isFlexible,
@JsonProperty("flexibility_days") Integer flexibilityDays
) {}
public record TravelerInfo(
@JsonProperty("adults") Integer adults,
@JsonProperty("children") Integer children,
@JsonProperty("children_ages") List<Integer> childrenAges
) {}
public record TravelPreferences(
@JsonProperty("accommodation_type") String accommodationType,
@JsonProperty("travel_style") String travelStyle,
@JsonProperty("interests") List<String> interests
) {}
public record Budget(
@JsonProperty("total") Double total,
@JsonProperty("currency") String currency,
@JsonProperty("per_person") Boolean perPerson
) {}
}Schema 定义好之后,Prompt 几乎是自动生成的:
String userInput = "我想下个月带老婆孩子去日本玩一周,预算大概 3 万,喜欢美食和历史文化," +
"小孩 5 岁,最好住好一点的酒店";
TravelPlan plan = schemaFirstService.extract(userInput, TravelPlan.class);
// plan.destination() = "日本"
// plan.travelDates().departureDate() = null("下个月"需要进一步确认)
// plan.travelDates().isFlexible() = true
// plan.travelers().adults() = 2
// plan.travelers().children() = 1
// plan.travelers().childrenAges() = [5]
// plan.budget().total() = 30000.0
// plan.budget().currency() = "CNY"
// plan.preferences().travelStyle() = "文化休闲"
// plan.preferences().interests() = ["美食", "历史文化"]
// plan.preferences().accommodationType() = "高档酒店"Schema 设计的常见陷阱
陷阱一:Schema 字段太细,模型填不上
比如你要求模型提取"职位薪资范围的最小值和最大值(数字)",但简历上写的是"薪资面议",模型不知道该填什么,可能填 0 或者胡乱填一个数字。
解法:关键数值字段加 Optional 标记(null 是合法值),并在 Prompt 里明确说明"如果信息不存在填 null"。
陷阱二:Schema 设计了模型不理解的抽象
比如你定义了一个字段 experience_level: enum(JUNIOR/MID/SENIOR/PRINCIPAL),并期望模型根据工作经历自动推断级别。但这个判断标准因公司而异,模型的推断结果会很不稳定。
解法:不要让 Schema 里包含需要业务判断的派生字段,这类字段应该在代码层面根据原始数据计算。
陷阱三:嵌套太深
Schema 嵌套超过 3 层之后,模型的正确率会下降,而且 Prompt 中的 Schema 说明也会变得很长。
解法:拆分成多个 Schema,用 Prompt Chain 分步提取。
总结
Schema-First 的价值在于:
- 让意图可见:先定义 Schema,所有人(包括 AI)都知道期望输出是什么
- 减少解析代码复杂度:Schema 约束了输出格式,解析代码从"处理任意格式"变成"反序列化到已知类型"
- 校验逻辑可以和 Schema 内聚:Schema 驱动的校验,代码更内聚
- 更容易迭代:修改 Schema 时,影响范围清晰(Prompt 重新生成,解析代码跟着改)
下次开始 AI 功能开发,先别急着写 Prompt,先把 Java 类画出来。
