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