Spring AI 实战:第三章、Spring AI结构化输出之告别杂乱无章

引言:当程序员遇上剧荒

“周末看什么?”

这个看似简单的问题,往往能让我们在各大影视平台间反复横跳半小时,最后无奈选择重刷《老友记》。本期让我们用技术解决这个"世纪难题":让大模型成为你的私人影视推荐官

一、输出结构化 - 轻松拿捏数据

1.1 原始输出的烦恼

简单示例:查一位idol主演的电影

  @GetMapping("/idol/movie")
    public String idolMovies(@RequestParam(value = "idol", defaultValue = "刘德华") String idol) {

        String content = chatClient.prompt().user(u -> {
            u.text("帮我找五部{idol}主演的电影").param("idol", idol);
        }).call().content();

        log.info(content);
        return content;
    }

结果输出:

以下是五部由刘德华主演的经典电影推荐,涵盖不同风格和年代的作品:

  1. 《无间道》(2002)

    • 类型:警匪/悬疑

    • 角色:刘建明(反派卧底警察)

    • 亮点:香港警匪片巅峰之作,刘德华与梁朝伟的双雄对决,剧情反转深刻。

  2. 《天若有情》(1990)

    • 类型:爱情/动作

    • 角色:华弟(街头浪子)

    • 亮点:经典悲情爱情片,刘德华骑摩托载吴倩莲的画面成为影史名场面。

  3. 《暗战》(1999)

    • 类型:犯罪/剧情

    • 角色:张彼得(高智商劫匪)

    • 亮点:刘德华凭此片首获香港金像奖影帝,与刘青云的斗智戏码精彩。

  4. 《桃姐》(2011)

    • 类型:家庭/温情

    • 角色:罗杰(少爷)

    • 亮点:现实主义题材,刘德华与叶德娴的细腻演出,斩获多项大奖。

  5. 《拆弹专家2》(2020)

    • 类型:动作/犯罪

    • 角色:潘乘风(残障拆弹专家)

    • 亮点:高燃爆炸场面与复杂角色塑造,近年港产动作片代表作。

其他备选

  • 《赌神》(1989)饰演陈刀仔(喜剧/赌片)

  • 《十面埋伏》(2004)饰演刘捕头(武侠/爱情)

如需特定类型或年代的推荐,可以进一步补充说明!

问题来了:大模型默认输出自然语言(如纯文本),需额外解析(正则表达式、NLP 处理)才能被程序使用,容易出错

  • 不同模型输出格式不一致
  • 返回格式不稳定,难以用程序解析
  • 无法直接映射到Java对象

1.2 结构化的优势

直接输出 JSON、XML、YAML 等格式,程序可无缝解析,无需复杂后处理,以结构化的输出才能更精准的控制整个流程以及和用户的交互形态,提升产品用户体验


{
  "name": "无间道",
  "releaseDate": "2002-12-12",
  "directorName": "刘伟强",
  "desc": "卧底警察深入黑帮调查的故事"
}

Spring AI通过在提示词中添加格式化指令要求大模型按特定格式做内容返回,在拿到大模型输出数据后通过内置转换器的能力做结构化输出。

二、Spring AI结构化输出实战

2.1 BeanOutputConverter:让AI输出Java对象

实体定义:定义一个对象 Film来标识电影信息实体

 record Film(@JsonPropertyDescription("电影名称") String name,
                @JsonPropertyDescription("上映时间") String releaseDate,
                @JsonPropertyDescription("导演名称") String directorName,
                @JsonPropertyDescription("电影简介") String desc) {
    }

结构转换:使用BeanOutputConverter转化模型返回数据

@GetMapping("/film")
public Film queryFilm(@RequestParam(value = "filmName", defaultValue = "无间道") String filmName) {

    BeanOutputConverter<Film> beanOutputConverter = new BeanOutputConverter<>(new ParameterizedTypeReference<Film>() {
    });

    String content = chatClient.prompt().user(u -> {
        u.text("""
               从豆瓣上查询影视作品{filmName}的信息 
               {format}
               """).params(Map.of("filmName", filmName, "format", beanOutputConverter.getFormat()));
    }).call().content();

    return beanOutputConverter.convert(content);
}

输出如下, 很显然按照指定实体对象做结果的输出

{

“name”: “无间道”,

“releaseDate”: “2002-12-12”,

“directorName”: “刘伟强、麦兆辉”,

“desc”: “《无间道》是一部2002年上映的香港警匪电影,讲述了警方和黑帮互相派遣卧底的故事,展现了复杂的身份认同和道德困境。”

}

整体流程

  • 通过<font style="color:rgba(0, 0, 0, 0.9);">BeanOutputConverter<T></font> 的泛型约定返回结果的实体对象
  • 在提示词后面追加**<font style="color:rgba(0, 0, 0, 0.9);">{format}</font>**占位符,该占位符对应的值为<font style="color:rgba(0, 0, 0, 0.9);">beanOutputConverter.getFormat()</font>
  • 调用<font style="color:rgba(0, 0, 0, 0.9);">beanOutputConverter.convert(content)</font>处理大模型返回数据,得到返回实体

<font style="color:rgba(0, 0, 0, 0.9);">BeanOutputConverter</font>的泛型除了可以指定一个普通实体对象外,还可以指定List和Map。

List示例

 @GetMapping("/idol/film/bean/list/format")
    public List<Film> idolFilmsBeanListFormat(@RequestParam(value = "idol", defaultValue = "刘德华") String idol) {

        BeanOutputConverter<List<Film>> beanOutputConverter = new BeanOutputConverter<>(new ParameterizedTypeReference<List<Film>>() {
        });


        String content = chatClient.prompt().user(u -> {
            u.text("""
                    帮我找五部{idol}主演的电影
                    
                    {format}
                    """).params(Map.of("idol", idol, "format", beanOutputConverter.getFormat()));
        }).call().content();


        List<Film> filmList = beanOutputConverter.convert(content);

        log.info("BeanOutputConverter#filmList,size={},resp={}", filmList.size(), filmList);
        return filmList;


    }

输出:

[

{

"name": "无间道",

"releaseDate": "2002-12-12",

"directorName": "刘伟强",

"desc": "卧底警察深入黑帮调查的故事"

},

{

"name": "赌神",

"releaseDate": "1989-12-14",

"directorName": "王晶",

"desc": "赌神高进的传奇故事"

},

{

"name": "暗战",

"releaseDate": "1999-09-23",

"directorName": "陈木胜",

"desc": "警察与盗贼之间的斗智斗勇"

},

{

"name": "天若有情",

"releaseDate": "1990-06-14",

"directorName": "杜琪峰",

"desc": "关于时间与爱情的科幻电影"

},

{

"name": "拆弹专家",

"releaseDate": "2017-04-28",

"directorName": "邱礼涛",

"desc": "警察与罪犯之间的心理博弈"

}

]

Map示例: 目前在Map中嵌套复杂类型还不支持,因此Map返回用的是Object而不是Film

@GetMapping("/bean/map/format")
public Map<String, Object> beanMapFormat(@RequestParam(value = "style", defaultValue = "华语流行") String style) {

    BeanOutputConverter<Map<String, Object>> beanOutputConverter = new BeanOutputConverter<>(new ParameterizedTypeReference<Map<String,Object>>() {
    });


    String content = chatClient.prompt().user(u -> {
        u.text("""
               帮我找五部{style}的电影,以电影名为分组键,值为电影信息,电影信息需要包含电影名称、上映时间、导演名、电影简介等内容

               {format}
               """).params(Map.of("style", style, "format", beanOutputConverter.getFormat()));
    }).call().content();


    Map<String, Object> filmMap = beanOutputConverter.convert(content);

    log.info("BeanOutputConverter#filmMap,size={},resp={}", filmMap.size(), filmMap);
    return filmMap;
}

输出:

{

“霸王别姬”: {

"电影名称": "霸王别姬",

"上映时间": "1993年",

"导演名": "陈凯歌",

"电影简介": "影片围绕两位京剧伶人半个世纪的悲欢离合,展现了对传统文化、人的生存状态及人性的思考与领悟。"

},

“无间道”: {

"电影名称": "无间道",

"上映时间": "2002年",

"导演名": "刘伟强、麦兆辉",

"电影简介": "讲述的是两个身份混乱的男人分别为警方和黑社会的卧底,经过一场激烈的角斗,他们决心要寻回自己的故事。"

},

“卧虎藏龙”: {

"电影名称": "卧虎藏龙",

"上映时间": "2000年",

"导演名": "李安",

"电影简介": "一代大侠李慕白有退出江湖之意,托付红颜知己俞秀莲将自己的青冥剑带到京城,作为礼物送给贝勒爷收藏。"

},

“让子弹飞”: {

"电影名称": "让子弹飞",

"上映时间": "2010年",

"导演名": "姜文",

"电影简介": "讲述了悍匪张牧之摇身一变化名清官“马邦德”上任鹅城县长,并与镇守鹅城的恶霸黄四郎展开一场激烈争斗的故事。"

},

“大话西游之大圣娶亲”: {

"电影名称": "大话西游之大圣娶亲",

"上映时间": "1995年",

"导演名": "刘镇伟",

"电影简介": "至尊宝被月光宝盒带回到五百年前,遇见紫霞仙子,被对方打上烙印成为对方的人,并发觉自己已变成孙悟空。"

}

}

2.2 优雅的entity()写法

大模型输出转换为实体对象看起来还是比较复杂的,不过Spring AI还提供更简易的方式:直接在call()后面调用entity,把对应的class类型传递进去即可,并且在提示词中**{format}**占位符不需要再手动添加

@GetMapping("/film/entity")
public Film queryFilmEntity(@RequestParam(value = "filmName", defaultValue = "无间道") String filmName) {

    Film resp = chatClient.prompt().user(u -> {
        u.text("""
               从豆瓣上查询影视作品{filmName}的信息 
               """).param("filmName", filmName);
    }).call().entity(Film.class);

    return resp;
}

输出:

{

“name”: “无间道”,

“releaseDate”: “2002-12-12”,

“directorName”: “刘伟强、麦兆辉”,

“desc”: “《无间道》是2002年上映的一部香港警匪片,讲述了警方和黑帮互相派遣卧底的故事,展现了复杂的身份认同和道德困境。”

}

三、集合类输出处理

3.1 列表输出:ListOutputConverter

@GetMapping("/list/converter/films")
public List<String> toListFilms(@RequestParam(value = "idol", defaultValue = "刘德华") String idol) {

    ListOutputConverter listOutputConverter = new ListOutputConverter(new DefaultConversionService());

    String content = chatClient.prompt().user(u -> {
        u.text("""
               帮我找五部{idol}主演的电影
               {format}
               """).params(Map.of("idol", idol, "format", listOutputConverter.getFormat()));
    }).call().content();

    List<String> list = listOutputConverter.convert(content);
    log.info("ListOutputConverter#toListFilms,size={},resp={}", list.size(), list);
    return list;
}

输出:

[

“无间道”,

“暗战”,

“天下无贼”,

“十面埋伏”,

“拆弹专家”

]

简易写法

@GetMapping("/list/converter/films/entity")
public List<String> toListFilmsEntity(@RequestParam(value = "idol", defaultValue = "刘德华") String idol) {


    List<String> list = chatClient.prompt().user(u -> {
        u.text("""
               帮我找五部{idol}主演的电影
               """).params(Map.of("idol", idol));
    }).call().entity(new ListOutputConverter(new DefaultConversionService()));

    log.info("ListOutputConverter#toListFilms,size={},resp={}", list.size(), list);
    return list;
}

3.2 Map输出:MapOutputConverter

@GetMapping("/map/converter/films")
public Map<String, Object> toMapFilms(@RequestParam(value = "style", defaultValue = "华语流行") String style) {

    MapOutputConverter mapOutputConverter = new MapOutputConverter();

    String content = chatClient.prompt().user(u -> {
        u.text("""
               帮我找五部{style}的电影,以电影名为分组键,值为电影信息,电影信息需要包含电影名称、上映时间、导演名、电影简介等内容
               {format}
               """).params(Map.of("style", style, "format", mapOutputConverter.getFormat()));
    }).call().content();

    Map<String, Object> resp = mapOutputConverter.convert(content);
    log.info("MapOutputConverter#toListFilms,size={},resp={}", resp.size(), resp);
    return resp;
}

输出:

{

“卧虎藏龙”: {

"电影名称": "卧虎藏龙",

"上映时间": "2000年",

"导演名": "李安",

"电影简介": "讲述一代大侠李慕白有退出江湖之意,托付红颜知己俞秀莲将自己的青冥剑带到京城,作为礼物送给贝勒爷收藏的故事。"

},

“让子弹飞”: {

"电影名称": "让子弹飞",

"上映时间": "2010年",

"导演名": "姜文",

"电影简介": "讲述了悍匪张牧之摇身一变化名清官“马邦德”上任鹅城县长,并与镇守鹅城的恶霸黄四郎展开一场激烈争斗的故事。"

},

“霸王别姬”: {

"电影名称": "霸王别姬",

"上映时间": "1993年",

"导演名": "陈凯歌",

"电影简介": "影片围绕两位京剧伶人半个世纪的悲欢离合,展现了对传统文化、人的生存状态及人性的思考与领悟。"

},

“大话西游之大圣娶亲”: {

"电影名称": "大话西游之大圣娶亲",

"上映时间": "1995年",

"导演名": "刘镇伟",

"电影简介": "讲述了至尊宝为了救白晶晶而穿越回到五百年前,遇见紫霞仙子之后发生一段感情并最终成长为孙悟空的故事。"

},

“无间道”: {

"电影名称": "无间道",

"上映时间": "2002年",

"导演名": "刘伟强、麦兆辉",

"电影简介": "讲述了两个身份混乱的男人分别为警方和黑社会的卧底,经过一场激烈的角斗,他们决心要寻回自己的故事。"

}

}

简易写法,要传递的entity是new ParameterizedTypeReference类型, 和list还是有些细微的区别的

@GetMapping("/map/converter/films/entity")
public Map<String, Object> toMapFilmsEntity(@RequestParam(value = "style", defaultValue = "华语流行") String style) {


    Map<String, Object> resp = chatClient.prompt().user(u -> {
        u.text("""
               帮我找五部{style}的电影,以电影名为分组键,值为电影信息,电影信息需要包含电影名称、上映时间、导演名、电影简介等内容
               """).params(Map.of("style", style));
    }).call().entity(new ParameterizedTypeReference<Map<String, Object>>() {
    });

    log.info("MapOutputConverter#toListFilms,size={},resp={}", resp.size(), resp);
    return resp;
}

四、实体关系

Spring AI结构化输出转换器为开发者提供一种强大而灵活的方式来控制和解析LLM的输出。无论是需要简单的列表、复杂的映射还是完整的Java对象,这些转换器都能帮助我们轻松实现目标。通过合理使用这些工具,可以构建更可靠、更易维护的AI集成应用。

结语:让技术为生活服务

通过Spring AI的结构化输出转换器,成功:

  • 将杂乱不稳定的模型输出转为整洁的Java对象
  • 用几行代码打造私人影视推荐API
  • 让周末选片时间从30分钟缩短到3秒

下次剧荒时,不妨试试这段代码:


@GetMapping("/weekend-recommendation")
public List<Film> getWeekendRecommendation() {
    return chatClient.prompt()
    .user("推荐五部适合周末放松的优质电影")
    .call().entity(new ParameterizedTypeReference<List<Film>>() {});
}

好的技术不仅应该解决工作问题,更应该让生活更美好——包括解决"看什么"这个重大生活课题!🎬

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

liaokailin

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值