结构化输出

结构化输出

Berial Pwn

结构化输出的原理

Spring AI 实现结构化输出的思路:

  1. 分析你传入的 Java 类(Record 或 POJO),生成对应的 JSON Schema;
  2. 把 JSON Schema 附加到 Prompt 中,告诉模型“请按照这个格式输出”;
  3. 拿到模型返回的 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)

@JsonPropertyJsonPropertyDescription 加描述

​ 有的时候字段名不够直观,可以用注解加描述让模型知道我们想要什么。

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);

// System.out.println(converter.getFormat());

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)
  • 标题: 结构化输出
  • 作者: Berial
  • 创建于 : 2026-03-25 12:35:26
  • 更新于 : 2026-03-25 12:37:49
  • 链接: https://berial.cn/posts/结构化输出.html
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论