【SpringAIAlibaba新手村系列】(7)结构化输出与对象映射

0 阅读7分钟

第七章 结构化输出与对象映射

版本标注

  • Spring AI: 1.1.2
  • Spring AI Alibaba: 1.1.2.0

章节定位

  • 结构化输出在 1.1.2.x 中的价值更高,除了对象映射,还常用于 Agent 路由决策、参数解析、工作流分类与节点控制。

s01 > s02 > s03 > s04 > s05 > s06 > [ s07 ] s08 > s09 > s10 > s11 > s12 > s13 > s14 > s15 > s16 > s17 > s18

"让模型返回对象, 比让人手动拆字符串靠谱得多" -- 结构化输出的核心价值是稳定和可编程。


一、为什么需要结构化输出?

1.1 自然输出的问题

默认情况下,AI 的输出是纯文本的,比如你问"帮我返回一个学生信息",AI 会返回:

学生信息如下:
姓名:张三
学号:1001
专业:计算机科学与技术
邮箱:zzyybs@126.com

但是在实际开发中,我们往往需要:

  • 把返回值存到数据库
  • 在前端展示成表格
  • 传递给其他接口

如果让 AI 返回"一堆文字",我们需要手动写解析代码,非常麻烦。

1.2 结构化输出的价值

让 AI 直接返回一个结构化的对象(JSON、Java对象):

{
  "name": "张三",
  "studentId": "1001", 
  "major": "计算机科学与技术",
  "email": "zzyybs@126.com"
}

这样我们可以:

  • 直接用 Jackson 解析成 Java 对象
  • 在前端用 Vue/React 的表格组件展示
  • 传递给其他微服务接口
  • 存入数据库

二、核心概念:Record 类型

2.1 什么是 Record?

Java 14 引入了 Record(记录类),它是一种特殊的类,专门用于存储数据。

// 普通类
public class Student {
    private String name;
    private String studentId;
    private String major;
    private String email;
    // 需要 getter/setter/构造函数...
}

// Record(简洁多了!)
public record StudentRecord(
    String name,          // 自动生成 final 字段
    String studentId,    // 自动生成构造函数
    String major,        // 自动生成 getter 方法(但叫 getName() 而是 name())
    String email         // 自动生成 equals(), hashCode(), toString()
) {}

Record 的特点

  • 所有字段默认 public final
  • 自动生成构造函数
  • 自动生成 equals()hashCode()toString()
  • 代码超级简洁

2.2 Spring AI 中的结构化输出

Spring AI 提供了 .entity(Class) 方法,可以直接将 AI 的输出映射到 Java 对象:

// 调用 AI,并指定返回类型为 StudentRecord
StudentRecord student = chatClient.prompt()
    .user("学号1001,我叫张三,专业计算机,邮箱zzyybs@126.com")
    .call()
    .entity(StudentRecord.class);  // 直接得到 Java 对象!

三、项目代码详解

3.1 定义 Record 类

首先创建两个 Record 类来接收 AI 返回的数据:

// 文件位置:src/main/java/com/atguigu/study/records/StudentRecord.java
package com.atguigu.study.records;

public record StudentRecord(
    String name,          // 姓名
    String studentId,    // 学号  
    String major,        // 专业
    String email         // 邮箱
) {}

// 文件位置:src/main/java/com/atguigu/study/records/Book.java
package com.atguigu.study.records;

public record Book(
    String title,        // 书名
    String author,       // 作者
    double price,        // 价格
    String publishDate   // 出版日期
) {}

3.2 控制器代码

package com.atguigu.study.controller;

import com.atguigu.study.records.StudentRecord;
import jakarta.annotation.Resource;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.function.Consumer;

/**
 * 结构化输出控制器
 * 展示如何让 AI 返回结构化的 Java 对象(Record)
 */
@RestController
public class StructuredOutputController
{
    // 注入 Qwen 的 ChatClient
    @Resource(name = "qwenChatClient")
    private ChatClient qwenChatClient;

    /**
     * 方式一:使用 Consumer 风格的参数设置(推荐)
     * 
     * 核心方法:.entity(RecordClass.class)
     *         自动把 AI 返回的 JSON 解析成指定的 Java Record 对象
     * 
     * 接口:http://localhost:8007/structuredoutput/chat?sname=李四&email=zzyybs@126.com
     */
    @GetMapping("/structuredoutput/chat")
    public StudentRecord chat(@RequestParam(name = "sname") String sname,
                              @RequestParam(name = "email") String email)
    {
        // 使用 Consumer 风格的 API 设置用户消息
        // promptUserSpec.text() 定义模板,.param() 替换变量
        return qwenChatClient.prompt().user(new Consumer<ChatClient.PromptUserSpec>() {
            @Override
            public void accept(ChatClient.PromptUserSpec promptUserSpec)
            {
                // text() 方法中使用 {sname} {email} 作为占位符
                // .param() 方法将实际参数值填充进去
                promptUserSpec.text("学号1001,我叫{sname},大学专业计算机科学与技术,邮箱{email}")
                        .param("sname", sname)      // 替换 {sname}
                        .param("email", email);     // 替换 {email}
            }
        })
        // .entity(StudentRecord.class) 是关键!
        // 告诉 AI 返回 JSON 格式,然后自动映射成 StudentRecord 对象
        .call()
        .entity(StudentRecord.class);
    }

    /**
     * 方式二:更简洁的 Lambda 写法
     * 
     * 接口:http://localhost:8007/structuredoutput/chat2?sname=孙伟&email=zzyybs@126.com
     */
    @GetMapping("/structuredoutput/chat2")
    public StudentRecord chat2(@RequestParam(name = "sname") String sname,
                               @RequestParam(name = "email") String email)
    {
        // 定义模板字符串(使用占位符)
        String stringTemplate = """
                学号1002,我叫{sname},大学专业软件工程,邮箱{email}            
                """;

        // 使用 Lambda 简化版的 param 设置
        return qwenChatClient.prompt()
                // text() 设置模板,.param() 替换变量
                .user(promptUserSpec -> promptUserSpec.text(stringTemplate)
                .param("sname", sname)
                .param("email", email))
                .call()
                // 关键:将 AI 返回的数据映射成 StudentRecord 对象
                .entity(StudentRecord.class);
    }

    // ========== 下面是 Book 的示例(类似)==========

    // @GetMapping("/structuredoutput/book")
    // public Book getBook(...) { ... }
}

3.3 Consumer 匿名类 vs Lambda 表达式详解

在上面的代码中,方式一和方式二在功能上完全等价,只是语法形式不同。理解它们的区别,有助于你更好地掌握 Java 8 引入的 Lambda 特性。

3.3.1. 核心区别
维度方式一方式二
语法匿名内部类(传统 Java 写法)Lambda 表达式(Java 8+ 写法)
代码量多,包含 new@Overrideaccept 等样板代码极少,一行搞定
底层对象都是 Consumer<PromptUserSpec> 的实现实例同上
3.3.2. 为什么方式二不用写方法名就能自动识别?

原理在于 函数式接口(Functional Interface)

在方式一中,user() 方法要求的参数类型是 Consumer<PromptUserSpec>
Consumer 接口的定义长这样:

@FunctionalInterface
public interface Consumer<T> {
    void accept(T t); // 只有一个抽象方法
}

Java 规定:只要一个接口只有一个抽象方法(Single Abstract Method, SAM),它就可以用 Lambda 表达式代替。

编译器在解析方式二时,会自动做以下推断:

  1. 这个位置需要一个 Consumer 类型的参数
  2. Consumer 接口只有一个抽象方法叫 accept
  3. 所以 Lambda 的参数 promptUserSpec 就是 accept 方法的参数
  4. Lambda 的箭头右边 -> ... 就是 accept 的方法体

因此你不需要写方法名,编译器会自动把 Lambda 映射到 accept 方法上。底层通常会通过 invokedynamic 指令在运行时生成对应的实现类。

3.3.3. 一句话总结

方式二是方式一的 Lambda 语法糖。因为 Consumer 是函数式接口,Java 允许你省略接口名和方法名,直接写“参数 -> 逻辑”,编译器会自动补全成 accept 方法。日常开发强烈推荐方式二。


四、底层原理分析

4.1 AI 返回 JSON 的原理

当你调用 .entity(StudentRecord.class) 时,Spring AI 内部会:

  1. 发送请求时:在 Prompt 中自动加上"请以JSON格式返回"这样的指令
  2. 接收响应时:AI 返回的文本(假设是 JSON 格式)
  3. 解析时:使用 Jackson 或其他 JSON 库,把 JSON 解析成指定的 Record 对象

整个过程对你来说是透明的,你只需要关心:

  • 输入:给 AI 描述清楚需要哪些字段
  • 输出:直接拿到 Java 对象

4.2 为什么用 Record 而不用普通类?

// 普通类
public class Student {
    private String name;
    private String studentId;
    // 需要手动写:
    // private 字段
    // public getter/setter
    // 无参构造函数
    // 全参构造函数
    // equals/hashCode/toString
    // ... 一大坨代码
}

// Record(自动帮你生成)
public record StudentRecord(
    String name,
    String studentId
) {}  // 一行搞定!

Spring AI 的结构化输出功能会自动把 JSON 映射到字段名称匹配的 Record 上,特别方便!


五、结构化输出的最佳实践

5.1 Prompt 中明确字段要求

// ❌ 模糊的描述(AI可能返回不完整的结构)
prompt: "返回一个学生信息"

// ✅ 明确的描述(AI会按照要求的字段返回)
prompt: """
    返回一个学生信息,JSON格式,包含以下字段:
    - name: 姓名(字符串)
    - studentId: 学号(字符串)
    - major: 专业(字符串)
    - email: 邮箱(字符串)
    """

5.2 字段命名建议

// AI 返回 JSON 通常是 camelCase
{"name": "张三", "studentId": "1001"}

// 使用 Record 时,字段名要和 JSON 的 key 对应
public record StudentRecord(
    String name,        // 对应 "name"
    String studentId,   // 对应 "studentId"
    String major,
    String email
) {}

// 如果 JSON 用 snake_case,需要加 @JsonProperty
public record StudentRecord(
    @JsonProperty("student_id") String studentId  // 对应 "student_id"
) {}

六、本章小结

6.1 核心技能

技能说明
RecordJava 的简洁数据类,自动生成 equals/toString
.entity(Class)Spring AI 的核心方法,将 AI 输出映射为 Java 对象
@JsonPropertyJackson 注解,处理 JSON 和 Java 字段名不一致问题

6.2 使用流程

定义Record类 ──> 构建Prompt ──> 调用.entity(RecordClass) ──> 获得Java对象
    ↓
   AI智能理解需求,返回JSON ──> 自动解析 ──> 注入到Record的字段中

本章重点

  1. Java Record 的基本使用
  2. 如何让 AI 返回结构化数据(JSON)
  3. 使用 .entity() 方法实现自动映射

下章剧透(s08):

学会了让 AI 返回结构化数据后,下一章我们将学习持久化会话——如何用 Redis 保存对话历史,让 AI 记住之前的上下文。


📝 编辑者:Flittly
📅 更新时间:2026年4月
🔗 相关资源Spring AI Structured Output