这一话我们先用 30 行代码把 Agent 的"嘴"接上,然后立刻拆开自动装配:
ChatClient.Builder 是谁注册的、为什么它是 prototype、ChatResponse
里到底装了什么、多模型共存怎么玩。
# application.yml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
base-url: https://api.openai.com # 换成任意 OpenAI 兼容网关即可
chat:
options:
model: gpt-4o-mini //!hl
temperature: 0.2 //!hl
retry:
max-attempts: 3 # 框架内置重试(版本不同可能是 spring.ai.retry.*)
backoff:
initial-interval: 500ms
multiplier: 2
国内同学可换 spring-ai-starter-model-* 的其他厂商 Starter,或把 base-url 指向兼容网关,代码零改动 —— 这就是 Port/Adapter 的红利。
@RestController
public class ChatController {
private final ChatClient chatClient;
public ChatController(ChatClient.Builder builder) { //!hl
this.chatClient = builder //!hl
.defaultSystem("你是资深 Java 架构师,回答简洁、有代码、有取舍分析。") //!hl
.build(); //!hl
}
@GetMapping("/chat")
public String chat(@RequestParam String q) {
return chatClient.prompt()
.user(q)
.call()
.content();
}
// 流式:把 .call().content() 换成 .stream().content()
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream(@RequestParam String q) {
return chatClient.prompt().user(q).stream().content(); //!hl
}
}
ChatClient 是不可变(immutable)的构建产物,线程安全,可放心作为单例字段。ChatClientAutoConfiguration(随任意 model starter 一起生效)大致做了这件事:
@AutoConfiguration
@ConditionalOnClass(ChatModel.class)
public class ChatClientAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@Scope(BeanDefinition.SCOPE_PROTOTYPE) //!hl
ChatClient.Builder chatClientBuilder(ChatModel chatModel) { //!hl
return ChatClient.builder(chatModel); //!hl
}
}
为什么是 prototype?因为 ChatClient.Builder 持有大量可变状态
(defaultSystem、defaultAdvisors、defaultOptions、defaultToolCallbacks……)。
如果它是单例,两个 Service 各自注入后调用 builder.defaultSystem(...) 就会互相污染 ——
这是教科书级的有状态 Bean 并发陷阱。prototype 让"每次注入点拿到全新 Builder",配合 @ConditionalOnMissingBean,
你随时可以自己定义一个全公司统一的 ChatClient.Builder Bean 覆盖它。
ChatModel Bean,自动配置的 Builder 会因无法抉择而失效/报错。
解决办法见本节末尾"多模型共存"。
.content() 只是给你一个字符串的糖。真正的信息全在 ChatResponse 里:
ChatResponse response = chatClient.prompt().user(q).call().chatResponse(); //!hl
AssistantMessage out = response.getResult().getOutput(); //!hl
String text = out.getText();
// 1) 用量与计费(做成本核算必拿)
Usage usage = response.getMetadata().getUsage(); //!hl
long promptTokens = usage.getPromptTokens();
long completionTokens = usage.getCompletionTokens();
// 2) 停止原因:STOP / LENGTH(被截断!) / TOOL_CALLS / CONTENT_FILTER
String finishReason = response.getResult().getMetadata().getFinishReason(); //!hl
// 3) 厂商原始信息、速率限制
String model = response.getMetadata().getModel();
RateLimit rl = response.getMetadata().getRateLimit();
| 对比项 | ChatClient(推荐) | ChatModel(底层) |
|---|---|---|
| 抽象层级 | 流式 API + Advisor 链 + 结构化输出 | 裸接口:call(Prompt) |
| 是否支持 Advisor | 支持(记忆/RAG/日志/护栏) | 不支持,全靠手写 |
| 返回值 | String / T / ChatResponse | ChatResponse |
| 适用场景 | 90% 业务代码 | 写框架 / 自己造轮子 / 需要精确控制 Prompt 对象时 |
// 退回底层:手动拼 Prompt,精确控制每一条 Message
Prompt prompt = new Prompt(List.of(
new SystemMessage("你是 SQL 审核专家"),
new UserMessage("审核这段 SQL:" + sql)
), OpenAiChatOptions.builder().withTemperature(0.0).build());
ChatResponse r = chatModel.call(prompt); //!hl
@Configuration
class ModelConfig {
// 廉价模型:做分类、摘要、路由
@Bean @Qualifier("fast")
ChatModel fastModel(OpenAiChatModel m) { return m; }
// 你自己的公司网关 / 另一家厂商
@Bean @Qualifier("strong")
ChatModel strongModel(/* 另一套 properties 绑定的 ChatModel */ ...) { ... }
@Bean
ChatClient router(@Qualifier("fast") ChatModel fast,
@Qualifier("strong") ChatModel strong) {
var fastClient = ChatClient.builder(fast).build();
var strongClient = ChatClient.builder(strong).build();
// 由业务代码决定用哪个(第 09 话的 Routing 模式)
return strongClient;
}
}
把"模型选择"当成一个可配置的策略:廉价模型负责分类/摘要/路由,昂贵模型负责最终生成。 这是控制成本最立竿见影的一招,通常能把账单砍掉一半以上,而体验几乎无损。
spring.ai.retry.*(或各 Starter 自己的 retry 属性),处理 429/5xx 与网络抖动。TimeLimiter。ChatClientAutoConfiguration 以 prototype 注册;response.getMetadata().getUsage()。RestClient.Builder 一个路子。有点意思了。第 03 话 —— 提示词不是字符串,是契约。我们把 Prompt 模板化、外置化,并让模型输出严格落到 Java 对象上(BeanOutputConverter 原理 + JSON Schema 注入 + 校验重试)。