结构化输出的原理
Spring AI 实现结构化输出的思路:
- 分析你传入的 Java 类(Record 或 POJO),生成对应的 JSON Schema;
- 把 JSON Schema 附加到 Prompt 中,告诉模型“请按照这个格式输出”;
- 拿到模型返回的 JSON 后,自动反序列化成 Java 对象。
只需要定义目标烈性,调一个方法。
最简单的用法*.entity()*
com/berial/springai/controller/structured/MovieController.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| package com.berial.springai.controller.structured;
import org.springframework.ai.chat.client.ChatClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController;
@RestController @RequestMapping("/movie") public class MovieController {
record Moviecommendation( String title, String director, int year, String genre, String reason ) {}
private final ChatClient chatClient;
public MovieController(ChatClient.Builder builder) { this.chatClient = builder.build(); }
@GetMapping("/recommend") public Moviecommendation recommend() { return chatClient.prompt() .user("推荐一部经典的科幻电影") .call() .entity(Moviecommendation.class); }
}
|
调用示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| GET http://localhost:8081/movie/recommend
HTTP/1.1 200 Content-Type: application/json Transfer-Encoding: chunked Date: Wed, 25 Mar 2026 03:11:08 GMT
{ "title": "2001太空漫游", "director": "斯坦利·库布里克", "year": 1968, "genre": "科幻", "reason": "《2001太空漫游》以深邃的哲学思考、开创性的视觉效果和宏大的宇宙想象力,被公认为最经典的科幻电影之一。" } 响应文件已保存。 > 2026-03-25T111108.200.json
Response code: 200; Time: 3820ms (3 s 820 ms); Content length: 131 bytes (131 B)
|
Spring AI 会自动处理 Prompt 注入和 JSON 解析。
返回 List
com/berial/springai/controller/structured/BookController.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| package com.berial.springai.controller.structured;
import org.springframework.ai.chat.client.ChatClient; import org.springframework.core.ParameterizedTypeReference; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController @RequestMapping("/book") public class BookController {
record BookSummary( String title, String author, String oneLinerSummary ) {}
private final ChatClient chatClient;
public BookController(ChatClient.Builder builder) { this.chatClient = builder.build(); }
@GetMapping("/list") public List<BookSummary> list() { return chatClient.prompt() .user("列出 5 本经典的 CTF 书籍") .call() .entity(new ParameterizedTypeReference<List<BookSummary>>() {}); }
}
|
调用示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| GET http://localhost:8081/book/list
HTTP/1.1 200 Content-Type: application/json Transfer-Encoding: chunked Date: Wed, 25 Mar 2026 03:17:55 GMT
[ { "title": "The Art of Deception", "author": "Kevin Mitnick and William L. Simon", "oneLinerSummary": "从社会工程与攻击思维角度帮助读者建立接近 CTF 与安全竞赛所需的安全意识。" }, { "title": "Hacking: The Art of Exploitation", "author": "Jon Erickson", "oneLinerSummary": "经典入门书,涵盖汇编、调试、缓冲区溢出与利用开发,是很多 CTF 选手的启蒙读物。" }, { "title": "Penetration Testing: A Hands-On Introduction to Hacking", "author": "Georgia Weidman", "oneLinerSummary": "系统介绍渗透测试方法与常见漏洞利用,适合作为 Web 与综合类 CTF 的基础参考。" }, { "title": "The Shellcoder's Handbook", "author": "Chris Anley, John Heasman, Felix Lindner and Gerardo Richarte", "oneLinerSummary": "专注漏洞利用与高级二进制攻击技术,适合学习 Pwn 与逆向相关 CTF 题型。" }, { "title": "Practical Binary Analysis", "author": "Dennis Andriesse", "oneLinerSummary": "深入讲解二进制分析、漏洞挖掘与 exploit 开发,是现代 CTF 二进制方向的重要参考书。" } ] 响应文件已保存。 > 2026-03-25T111755.200.json
Response code: 200; Time: 8685ms (8 s 685 ms); Content length: 733 bytes (733 B)
|
复杂嵌套对象
com/berial/springai/controller/structured/ResumeController.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40
| package com.berial.springai.controller.structured;
import org.springframework.ai.chat.client.ChatClient; import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController @RequestMapping("/resume") public class ResumeController {
record SkillLevel(String name, String level) {}
record ResumeAnalysis( String name, String email, String summary, List<SkillLevel> technicalSkills, List<String> workHistory, String overallAssessment ) {}
record ResumeRequest(String content) {}
private final ChatClient chatClient;
public ResumeController(ChatClient.Builder builder) { this.chatClient = builder.build(); }
@PostMapping("/analyze") public ResumeAnalysis analyze(@RequestBody ResumeRequest request) { return chatClient.prompt() .system("你是一个专业的 HR,帮助分析候选人简历。字段为空时填 null,技能等级只能是:入门/熟练/精通。") .user("分析这份简历:\n" + request.content()) .call() .entity(ResumeAnalysis.class); }
}
|
调用示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| POST http://localhost:8081/resume/analyze Content-Type: application/json
{ "content": "Berial,邮箱12345678@qq.com,熟练使用IDA、GDB,一年漏洞挖掘经验" }
POST http://localhost:8081/resume/analyze
HTTP/1.1 200 Content-Type: application/json Transfer-Encoding: chunked Date: Wed, 25 Mar 2026 03:35:44 GMT
{ "name": "Berial", "email": "12345678@qq.com", "summary": "具备一年漏洞挖掘相关经验,熟练使用IDA、GDB进行逆向分析与调试,适合初级安全研究、漏洞挖掘与二进制分析相关岗位。", "technicalSkills": [ { "name": "IDA", "level": "熟练" }, { "name": "GDB", "level": "熟练" }, { "name": "漏洞挖掘", "level": "熟练" } ], "workHistory": [ "一年漏洞挖掘经验" ], "overallAssessment": "候选人具备一年漏洞挖掘经验,掌握IDA和GDB等逆向分析与调试工具,具备一定的安全研究与漏洞分析基础。" } 响应文件已保存。 > 2026-03-25T113544.200.json
Response code: 200; Time: 7239ms (7 s 239 ms); Content length: 320 bytes (320 B)
|
用 @JsonProperty 和 JsonPropertyDescription 加描述
有的时候字段名不够直观,可以用注解加描述让模型知道我们想要什么。
com/berial/springai/controller/structured/ProductReviewController.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| package com.berial.springai.controller.structured;
import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; import org.springframework.ai.chat.client.ChatClient; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController @RequestMapping("/review") public class ProductReviewController {
record ProductReview( @JsonProperty("product_name") @JsonPropertyDescription("商品名,从评论中提取") String productName,
@JsonProperty("sentiment") @JsonPropertyDescription("情感倾向:POSITIVE(正面)、NEGATIVE(负面)、NEUTRAL(中性)") String sentiment,
@JsonProperty("score") @JsonPropertyDescription("评分,1-5分,根据评论语气推断") int score,
@JsonProperty("key_points") @JsonPropertyDescription("评论中提到的关键点,最多3条") List<String> keyPoints,
@JsonProperty("imporvement_suggestions") @JsonPropertyDescription("改进建议,如果没有则为空列表") List<String> improvementSuggestions ) {}
record ReviewRequest(String content) {}
private final ChatClient chatClient;
public ProductReviewController(ChatClient.Builder builder) { this.chatClient = builder.build(); }
@PostMapping("/analyze") public ProductReview analyze(@RequestBody ReviewRequest request) { return chatClient.prompt() .user("分析这条评论:" + request.content()) .call().entity(ProductReview.class); }
}
|
调用示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| POST http://localhost:8081/review/analyze Content-Type: application/json
{ "content": "这款键盘不错,声音好听,磁轴键盘,打游戏急停很好用" }
POST http://localhost:8081/review/analyze
HTTP/1.1 200 Content-Type: application/json Transfer-Encoding: chunked Date: Wed, 25 Mar 2026 03:49:13 GMT
{ "product_name": "键盘", "sentiment": "POSITIVE", "score": 5, "key_points": [ "声音好听", "磁轴键盘", "打游戏急停很好用" ], "imporvement_suggestions": [] } 响应文件已保存。 > 2026-03-25T114913.200.json
Response code: 200; Time: 6168ms (6 s 168 ms); Content length: 123 bytes (123 B)
|
BeanOutputConverter:手动控制(进阶)
.entity() 底层是用 BeanOutputConverter 实现的。如果需要更细粒度的控制,可以直接使用这个。
com/berial/springai/controller/structured/ConverterDemoController.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| package com.berial.springai.controller.structured;
import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.converter.BeanOutputConverter; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController @RequestMapping("/converter") public class ConverterDemoController {
record SkillLevel(String name, String level) {}
record ResumeAnalysis( String name, String email, String summary, List<SkillLevel> technicalSkills, List<String> workHistory, String overallAssessment ) {}
record ResumeRequest(String content) {}
private final ChatClient chatClient;
public ConverterDemoController(ChatClient.Builder builder) { this.chatClient = builder.build(); }
@PostMapping("/analyze") public ResumeAnalysis analyze(@RequestBody ResumeRequest request) { BeanOutputConverter<ResumeAnalysis> converter = new BeanOutputConverter<>(ResumeAnalysis.class);
String prompt = """ 分析这份简历,按照以下 Json 格式输出: %s 简历内容:%s """.formatted(converter.getFormat(), request.content);
String jsonResponse = chatClient.prompt() .user(prompt) .call().content();
return converter.convert(jsonResponse); }
}
|
调用示例和前面的简历功能代码一样,打印 converter.getFormat() 的结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47
| Your response should be in JSON format. Do not include any explanations, only provide a RFC8259 compliant JSON response following this format without deviation. Do not include markdown code blocks in your response. Remove the ```json markdown from the output. Here is the JSON Schema instance your output must adhere to: ```{ "$schema" : "https://json-schema.org/draft/2020-12/schema", "type" : "object", "properties" : { "email" : { "type" : "string" }, "name" : { "type" : "string" }, "overallAssessment" : { "type" : "string" }, "summary" : { "type" : "string" }, "technicalSkills" : { "type" : "array", "items" : { "type" : "object", "properties" : { "level" : { "type" : "string" }, "name" : { "type" : "string" } }, "required" : [ "level", "name" ], "additionalProperties" : false } }, "workHistory" : { "type" : "array", "items" : { "type" : "string" } } }, "required" : [ "email", "name", "overallAssessment", "summary", "technicalSkills", "workHistory" ], "additionalProperties" : false }```
|
枚举类型
如果让某个字段只有有限个值,可以用枚举让模型进行单选:
com/berial/springai/controller/structured/IssueController.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42
| package com.berial.springai.controller.structured;
import org.springframework.ai.chat.client.ChatClient; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController;
@RestController @RequestMapping("/issue") public class IssueController {
enum Priority { HIGH, MEDIUM, LOW, CRITICAL }
enum Category {BUG, FRETURE, IMPROVEMENT, DOCUMENTATION}
record IssueClassification( String title, Category category, Priority priority, String assignTo, String reason ) {}
record IssueRequest(String description) {}
private final ChatClient chatClient;
public IssueController(ChatClient.Builder builder) { this.chatClient = builder.build(); }
@PostMapping("/classify") public IssueClassification classify(@RequestBody IssueRequest request) { return chatClient.prompt() .system("你是项目经理,负责对 Issue 进行分类和优先级评估。") .user(("请你对这个 Issue 进行分类:" + request.description())) .call().entity(IssueClassification.class); }
}
|
调用示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| POST http://localhost:8081/issue/classify Content-Type: application/json
{ "description": "该软件有栈溢出漏洞" }
POST http://localhost:8081/issue/classify
HTTP/1.1 200 Content-Type: application/json Transfer-Encoding: chunked Date: Wed, 25 Mar 2026 04:14:43 GMT
{ "title": "软件存在栈溢出安全漏洞", "category": "BUG", "priority": "CRITICAL", "assignTo": "安全工程师", "reason": "该问题属于安全缺陷,栈溢出漏洞可能导致程序崩溃、任意代码执行或系统被攻陷,影响严重且需立即处理。" } 响应文件已保存。 > 2026-03-25T121443.200.json
Response code: 200; Time: 8656ms (8 s 656 ms); Content length: 141 bytes (141 B)
|
实战:文章分析API
com/berial/springai/controller/structured/ArticleAnalysisController.java:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64
| package com.berial.springai.controller.structured;
import com.fasterxml.jackson.annotation.JsonPropertyDescription; import org.springframework.ai.chat.client.ChatClient; import org.springframework.core.ParameterizedTypeReference; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController @RequestMapping("/api/article") public class ArticleAnalysisController {
private final ChatClient chatClient;
public ArticleAnalysisController(ChatClient.Builder builder) { this.chatClient = builder.build(); }
enum ArticleType {NEWS, OPINION, TUTORIAL, RESEARCH, OTHER}
enum ArticleSentimentType {POSITIVE, NEGATIVE, NEUTRAL}
record ArticleAnalysis( @JsonPropertyDescription("文章标题,如果没有则根据内容生成") String title, @JsonPropertyDescription("文章类型") ArticleType type, @JsonPropertyDescription("100字以内的摘要") String summary, @JsonPropertyDescription("关键词列表,最多5个") List<String> keywords, @JsonPropertyDescription("文章的主要观点,最多3条") List<String> mainPoints, @JsonPropertyDescription("情感倾向") ArticleSentimentType sentiment, @JsonPropertyDescription("可读性评分,1-10分,10分最易读") int readabilityScore ) {}
@PostMapping("/analyze") public ArticleAnalysis analysis(@RequestBody ArticleRequest request){ return chatClient.prompt() .user("请分析以下文章:\n\n" + request.content()) .call() .entity(ArticleAnalysis.class); }
@PostMapping("/keywords") public List<String> extractKeywords(@RequestBody ArticleRequest request){ return chatClient.prompt() .user("从以下文章中提取5个最重要的关键词:\n\n" + request.content()) .call() .entity(new ParameterizedTypeReference<List<String>>() {}); }
record ArticleRequest(String content) {} }
|
调用示例:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37
| POST http://localhost:8081/api/article/analyze Content-Type: application/json
{ "content": "雨是在傍晚时分停的。西边的云层裂开一道缝,夕阳趁机将最后的余晖洒向湿漉漉的街道。空气里满是泥土和青草的清冽味道,每一片叶尖上都挂着将落未落的水珠,像缀着无数细碎的钻石。远处的屋檐还在滴着水,滴答、滴答,节奏慵懒而散漫,仿佛时间也跟着慢了下来。行人渐渐多了起来,收起了伞,脚步也不再急促。整个世界像是被这场雨彻底洗过一遍,干净、透亮,连人心里的褶皱,似乎也被抚平了些许。" }
POST http://localhost:8081/api/article/analyze
HTTP/1.1 200 Content-Type: application/json Transfer-Encoding: chunked Date: Wed, 25 Mar 2026 04:32:21 GMT
{ "title": "雨后傍晚的宁静街景", "type": "OTHER", "summary": "文章细腻描写了雨后傍晚的街景与空气、声响变化,营造出清新、宁静、透亮的氛围,并借景表达内心被抚平的舒适感。", "keywords": [ "雨后傍晚", "夕阳余晖", "街道景象", "清新空气", "宁静氛围" ], "mainPoints": [ "文章描绘了雨后傍晚天空放晴、夕阳映照街道的清新景象。", "通过水珠、滴水声和行人变化,营造出慵懒宁静的氛围。", "结尾借景抒情,表达雨后世界洁净明亮、内心也得到抚慰的感受。" ], "sentiment": "POSITIVE", "readabilityScore": 9 } 响应文件已保存。 > 2026-03-25T123221.200.json
Response code: 200; Time: 5828ms (5 s 828 ms); Content length: 298 bytes (298 B)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25
| POST http://localhost:8081/api/article/keywords Content-Type: application/json
{ "content": "雨是在傍晚时分停的。西边的云层裂开一道缝,夕阳趁机将最后的余晖洒向湿漉漉的街道。空气里满是泥土和青草的清冽味道,每一片叶尖上都挂着将落未落的水珠,像缀着无数细碎的钻石。远处的屋檐还在滴着水,滴答、滴答,节奏慵懒而散漫,仿佛时间也跟着慢了下来。行人渐渐多了起来,收起了伞,脚步也不再急促。整个世界像是被这场雨彻底洗过一遍,干净、透亮,连人心里的褶皱,似乎也被抚平了些许。" }
POST http://localhost:8081/api/article/keywords
HTTP/1.1 200 Content-Type: application/json Transfer-Encoding: chunked Date: Wed, 25 Mar 2026 04:33:08 GMT
[ "雨后", "夕阳", "街道", "水珠", "宁静" ] 响应文件已保存。 > 2026-03-25T123308.200.json
Response code: 200; Time: 2579ms (2 s 579 ms); Content length: 26 bytes (26 B)
|