Spring AI Agent 漫画实战
第 02 话 HANDS ON

三十行跑通:ChatClient 与自动装配解剖 能跑起来不叫会了 —— 知道"哪个 Bean 替你干了什么"才算

这一话我们先用 30 行代码把 Agent 的"嘴"接上,然后立刻拆开自动装配: ChatClient.Builder 是谁注册的、为什么它是 prototype、ChatResponse 里到底装了什么、多模型共存怎么玩。

老 K
给我个能跑的。别整那些"Hello World"式的心灵鸡汤。
小 S
给。但跑完你要回答我三个问题:① Builder 哪来的?② 为什么要 build()?③ 这次调用花了多少 token?
答不上来 = 还没入门。

一、最小可运行:30 行

1. 配置

# 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 的红利。

2. 代码

@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.Builder 到底是谁注册的?

源码解剖

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 会因无法抉择而失效/报错。 解决办法见本节末尾"多模型共存"。
Model Starter spring-ai-starter-model-openai ChatModelAutoConfiguration 注册 OpenAiChatModel Bean ChatClientAutoConfiguration 注册 ChatClient.Builder(prototype) 你的 @Service 注入 Builder 并 build() ChatClient 不可变、线程安全 注入 ChatModel 口诀:Starter 提供 ChatModel → 自动装配提供 Builder(prototype)→ 你 build() 出 ChatClient(单例)

三、ChatResponse 里到底装了什么

.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();
ChatResponse getResult() → Generation getResults() → List(n>1) getMetadata() → 响应元数据 getOutput() → AssistantMessage getText() / getToolCalls() Usage(prompt/completion/total) model / rateLimit / id finishReason 很关键 LENGTH = 输出被截断 TOOL_CALLS = 要去调工具 工程提示:所有成本告警、限流判断、截断重试,都应该建立在这棵树的数据之上,而不是靠猜。
踩坑

四、两种 API 风格:什么时候退回 ChatModel

对比项ChatClient(推荐)ChatModel(底层)
抽象层级流式 API + Advisor 链 + 结构化输出裸接口:call(Prompt)
是否支持 Advisor支持(记忆/RAG/日志/护栏)不支持,全靠手写
返回值String / T / ChatResponseChatResponse
适用场景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;
    }
}
设计建议

把"模型选择"当成一个可配置的策略:廉价模型负责分类/摘要/路由,昂贵模型负责最终生成。 这是控制成本最立竿见影的一招,通常能把账单砍掉一半以上,而体验几乎无损。

六、超时、重试与降级(上线前必配)

小 S
三个问题会答了吗?
① Builder 由 ChatClientAutoConfiguration 以 prototype 注册;
② build() 产生不可变的 ChatClient,故只 build 一次;
③ token 在 response.getMetadata().getUsage()。
老 K
prototype 这个设计……跟 RestClient.Builder 一个路子。有点意思了。
← 上一话:范式迁移 目录 下一话:Prompt 即契约 →