Spring AI 接入 Spring Boot 实战:从提示词模板到结构化输出

Feng 6 阅读 AI技术

Spring AI 已经进入 Spring 生态的正式序列,但很多团队还停留在”能调通接口”的阶段。这篇用一个可运行的最小验证,把从依赖引入到结构化返回的整条链路走一遍。

先想清楚为什么要用 GPT

动手之前先回答一个问题:引入大模型到底解决什么。除了质量提升,无非是省时间、降成本。

生成式 AI 擅长处理耗时的大批量任务,但结果最好再经过有经验的人复核,这样拿到有效产出的概率才高。

别急着写代码。先花点时间把几个核心概念过一遍,后面调试会省很多事。

  • 输入:喂给模型的素材
  • 输出:模型算出来的结果
  • LLM:根据输入生成输出的微调逻辑
  • 提示词:把输入送进模型的接口
  • 提示词模板:构造结构化、参数化提示的组件
  • 令牌:模型内部把输入切成 token,再据此拼出输出
  • 上下文窗口:单次请求允许的 token 上限,用得越多通常越贵

项目基线

这套验证跑在下面的版本组合上:

  • Java 21
  • Maven 3.9.2
  • Spring Boot 3.2.2
  • Spring AI 0.8.0-SNAPSHOT

注意最后一行。0.8.0 还是快照版,属于实验性质,不建议直接上生产。快照依赖需要额外声明仓库,否则 Maven 会直接报找不到构件,这是新手最容易卡住的一步。

配图

仓库配好之后,加入 starter:

配图

Open AI Chat Client 由此成为应用上下文里的一个 Bean,负责把输入发给 OpenAI 并拿回输出。

密钥写在 application.properties:

spring.ai.openai.api-key = api-key-value

这个值就是你的有效 API key,去 OpenAI 官网注册或登录后生成。

客户端与应用之间怎么通信

第一段链路是浏览器、curl 这类客户端和你的 Spring Boot 应用之间的通信,用一个 REST 控制器暴露 HTTP GET 接口。

路径是 /job-reasons,带上提示词里定义的三个参数,形如:

/job-reasons?count={count}&job={job}&locatinotallow={location}

控制器长这样:

@Rest Controller
public class Open Ai Controller {
@Get Mapping(“/job-reasons”)
public Response Entity job Reasons(@Request Param(value = “count”, required = false, default Value = “3”) int count,
@Request Param(“job”) String job,
@Request Param(“location”) String location) {
return Response Entity.ok().build();
}
}

返回值是包了字符串的 ResponseEntity,因为 OpenAI 回来的就是字符串。此时启动应用发请求,响应体是空的,正常,因为还没接上模型。

应用与 OpenAI 之间怎么通信

Spring AI 目前主要覆盖语言类模型,OpenAI 这边对应 GPT-3.5、GPT-4 这些。它提供了一套统一 API,屏蔽掉各家模型的差异。

ChatClient 的契约很简洁,输入输出都是文本:

@Functional Interface
public interface Chat Client extends Model Client {
default String call(String message) {
Prompt prompt = new Prompt(new User Message(message));
return call(prompt).get Result().get Output().get Content();
}

Chat Response call(Prompt prompt);
}

函数式 API 是实际开发里最常用的调用方式。

我们要的就是它:把参数化的 Prompt 发出去,拿回结果。下面这个 Service 注入了 ChatClient 实例:

@Service
public class Open Ai Service {
private final Chat Client client;

public Open Ai Service(Open Ai Chat Client ai Client) {
this.client = ai Client;
}

public String job Reasons(int count, String domain, String location) {
final String prompt Text = “””
Write {count} reasons why people in {location} should consider a {job} job.
These reasons need to be short, so they fit on a poster.
For instance, “{job} jobs are rewarding.”
“””;
final Prompt Template prompt Template = new Prompt Template(prompt Text);
prompt Template.add(“count”, count);
prompt Template.add(“job”, domain);
prompt Template.add(“location”, location);
Chat Response response = client.call(prompt Template.create());
return response.get Result().get Output().get Content();
}
}

提示词模板里三个参数:count 是想要的条数,job 是目标职位,location 是所在地区。模板本身主题清晰、任务明确,还带了示例,这是写好提示词的基本要求。

应用跑起来后从浏览器发请求,能拿到类似这样的结果:

  • 高薪职业:软件架构师岗位有竞争力的薪酬和良好的晋升空间
  • 热门方向:技术需求持续增长,本地和全球都缺人
  • 创造性解题:参与构建创新软件方案,能发挥创造力并影响多个行业

到这里,一个最简 API 就通了。

调参与观察

默认配置能跑,但调一调效果差别明显。

spring.ai.openai.chat.options.model 指定模型,默认是 gpt-3.5-turbo,也可以换成 gpt-4 或 gpt-4-32k。这里有个坑:按量付费的计划不一定能访问高版本模型,具体以 OpenAI 官网说明为准。

另一个值得动的参数是 spring.ai.openai.chat.options.temperature,默认 0.8。它控制输出的”创造性”,值越高越随机,越低越聚焦、越确定。

把它降到 0.3,重启应用,用同样的参数再问一次,输出会明显更收敛、更描述性。

让输出直接变成 Java 对象

文本结果不好用。能把响应载荷直接映射成 Java 类或 record 会方便很多,思路类似 Spring JDBC 的映射机制,Spring AI 用 OutputParser 实现。

定义一个 record:

public record Job Reasons(String job,
String location,
List reasons) {
}

把它的格式说明作为 {format} 塞进提示词,Service 方法改成:

public Job Reasons formatted Job Reasons(int count, String job, String location) {
final String prompt Text = “””
Write {count} reasons why people in {location} should consider a {job} job.
These reasons need to be short, so they fit on a poster.
For instance, “{job} jobs are rewarding.”
{format}
“””;
Bean Output Parser output Parser = new Bean Output Parser<>(Job Reasons.class);
final Prompt Template prompt Template = new Prompt Template(prompt Text);
prompt Template.add(“count”, count);
prompt Template.add(“job”, job);
prompt Template.add(“location”, location);
prompt Template.add(“format”, output Parser.get Format());
prompt Template.set Output Parser(output Parser);
final Prompt prompt = prompt Template.create();
Chat Response response = client.call(prompt);
return output Parser.parse(response.get Result().get Output().get Content());
}

再次调用,返回:

{
“job”:”software architect”,
“location”:”Romania”,
“reasons”:[
“High demand”,
“Competitive salary”,
“Opportunities for growth”
]
}

格式对了,但条数偏少,说明还需要继续调提示词或参数。从概念验证的角度看,这已经够用。

验证与收尾

想确认结构化输出是否稳定,可以写一个简单的集成测试:固定 count、job、location,断言返回的 JobReasons 对象字段非空、reasons 列表长度等于 count。跑上几轮,如果条数经常对不上,说明提示词约束还不够强。

提示词构建是整件事的核心。提示越清晰,输入越好,输出质量越高。

class
BertPooler(nn.Module):
def __init__(
self
, config):
super
().__init__()
self
.dense
=
nn.Linear(config.hidden_
size
, config.hidden_
size
)
self
.activation
=
nn.Tanh()
def forward(
self
, hidden_states):
# We
"pool"
the model
by
simply taking the hidden state
corresponding
#
to
the
first
token.
first
_token_tensor
=
hidden_states[:,
0
]
pooled_
output
=
self
.dense(
first
_token_tensor)
pooled_
output
=
self
.activation(pooled_
output
)
return
pooled_
output
from
transformers.models.bert.
configuration
_bert import
*
import torch
config
=
BertConfig.
from
_pretrained(
"bert-base-uncased"
)
bert_pooler
=
BertPooler(config
=
config)
print(
"input to bert pooler size: {}"
.
format
(config.hidden_
size
))
batch_
size
=
1
seq_len
=
2
hidden_
size
=
768
x
=
torch.rand(batch_
size
, seq_len, hidden_
size
)
y
=
bert_pooler(x)
print(y.
size
())

Feng
这位作者很神秘,还没有填写简介。