跳转至

Spring Boot 与自动装配

🧩 1. Spring Boot 自动装配的原理是什么?@SpringBootApplication 做了什么?

自动装配的本质是“根据类路径中的依赖和配置,自动推断并创建 Bean,而无需你手动编写繁琐的配置类。” 它像一位经验丰富的管家,看到你书架上有书(依赖),就会把对应的书桌、台灯(Bean)都准备好。

@SpringBootApplication 是这三者的合体:

image.png

真正的魔术师是 @EnableAutoConfiguration。它通过 @Import(AutoConfigurationImportSelector.class) 导入了一个选择器,这个选择器会去读取所有依赖的 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 文件(Spring Boot 2.7+,更早版本是 spring.factories)。

加载流程:
1. 启动类 → @EnableAutoConfiguration
2. AutoConfigurationImportSelector 扫描所有 AutoConfiguration.imports 文件
3. 收集到上百个候选配置类(如 DataSourceAutoConfiguration)
4. 逐个评估 @ConditionalOnClass / @ConditionalOnMissingBean 等条件注解
5. 条件满足 → 加载该配置类 → 创建对应的 Bean 并放入容器

为什么加上 @Conditional 注解? 如果没有这些条件注解,所有候选配置类都会无条件加载,那你的应用就会因为缺少某些类而崩溃。例如,DataSourceAutoConfiguration 只有在类路径中存在 DataSource 类、且容器中没有你手动定义的 DataSource Bean 时才生效。这种 “按需激活” 是自动装配的精髓。

示例代码:验证自动装配 你可以在 application.yml 中通过 debug: true 查看哪些自动配置类被激活了:

debug: true

然后在启动日志中搜索 AutoConfiguration,或者用 spring-boot-actuator/actuator/conditions 端点查看正匹配和负匹配的条件。

用一张图概括 @SpringBootApplication 的结构:

image.png

🛠️ 2. 如何自定义一个 Spring Boot Starter?

自定义 Starter 就是把“一组可复用的自动配置 + 依赖”打包成一个独立的模块。它通常包含两个子模块:autoconfigure(自动配置逻辑)和 starter(空壳,只引入 autoconfigure 和所需依赖)。

以创建一个 agent-tool-starter 为例,它会在启动时自动创建一个 ToolService Bean,并根据配置项 agent.tool.name 来设置工具名称。

步骤一:创建 autoconfigure 模块

// 1. 属性类,绑定 application.yml 中的前缀
@ConfigurationProperties(prefix = "agent.tool")
public class ToolProperties {
    private String name = "default-tool";
    // getter/setter...
}

// 2. 核心服务
public class ToolService {
    private final String toolName;
    public ToolService(String toolName) { this.toolName = toolName; }
    public String call() { return "Calling " + toolName; }
}

// 3. 自动配置类,负责创建 Bean
@Configuration
@EnableConfigurationProperties(ToolProperties.class)
@ConditionalOnClass(ToolService.class)
public class ToolAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public ToolService toolService(ToolProperties properties) {
        return new ToolService(properties.getName());
    }
}

步骤二:在 resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports 中注册自动配置类:

com.example.agent.tool.ToolAutoConfiguration

步骤三:创建 starter 模块 它是一个空的 Maven 模块,只包含一个 pom.xml,依赖 autoconfigure 模块和 spring-boot-starter。用户只需在自己的项目中引入这个 starter,就会自动获得 ToolService Bean。

用户使用时的效果:

@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        var ctx = SpringApplication.run(Application.class, args);
        ToolService tool = ctx.getBean(ToolService.class);
        System.out.println(tool.call());
    }
}

application.yml 中定制:

agent:
  tool:
    name: "web-search"

打包方式与约定:

  • autoconfigure 模块:包含配置类、属性类、服务类;pom.xml 中将 spring-boot-autoconfigure 作为可选依赖。

  • starter 模块:pom.xml 中引入 autoconfigure 模块及 spring-boot-starter。用户只需在 pom.xml 中添加 starter 依赖。

结构一览:

agent-tool-spring-boot-starter/
├── pom.xml
└── src/main/resources/META-INF/spring/
    └── org.springframework.boot.autoconfigure.AutoConfiguration.imports

agent-tool-spring-boot-autoconfigure/
├── pom.xml
└── src/main/java/com/example/agent/tool/
    ├── ToolProperties.java
    ├── ToolService.java
    └── ToolAutoConfiguration.java

为什么分成两个模块? 为了遵循“关注点分离”。autoconfigure 包含所有配置类和逻辑,可以独立测试和版本管理;starter 只是一个引入入口,方便用户。这种模式也是 Spring 官方所有 Starter 的通用做法。


⚙️ 3. Spring Boot 的配置加载优先级?多环境配置如何管理?

Spring Boot 支持多达 17 个配置来源,它们按优先级从高到低排列。高优先级的配置会覆盖低优先级的相同键。这对于部署时灵活调整参数至关重要。

核心优先级顺序(从高到低):

  1. 命令行参数(--server.port=9090

  2. Java 系统属性(System.getProperties()

  3. 操作系统环境变量(export SERVER_PORT=9090

  4. 当前 profile 特定的 application-{profile}.yml(外部化,如 config/ 目录下)

  5. 当前 profile 特定的 application-{profile}.yml(内部 classpath)

  6. 通用 application.yml(外部化)

  7. 通用 application.yml(内部 classpath)

  8. @PropertySource 注解引入的配置

  9. 默认属性(SpringApplication.setDefaultProperties

多环境管理 就是利用“profile 特定文件”来实现隔离。在 application.yml 中通过 spring.profiles.active 指定激活哪个环境,然后该环境对应的 application-{profile}.yml 会与主配置合并(相同键被 profile 文件覆盖)。

示例:

主配置 application.yml

spring:
  application:
    name: agent-hub
server:
  port: 8080

开发环境 application-dev.yml

server:
  port: 9090
logging:
  level:
    com.agent: DEBUG

生产环境 application-prod.yml

server:
  port: 443
logging:
  level:
    root: WARN

激活方式: 可以在主配置中设置 spring.profiles.active: dev,也可以通过环境变量或命令行参数覆盖:

java -jar agent-hub.jar --spring.profiles.active=prod

更精细的配置管理:

  • spring.config.import 可以导入外部配置,比如 Consul、Vault 或额外的配置文件。

  • spring.config.additional-location 可指定额外的外部配置目录,优先级高于默认位置。

  • Kubernetes 部署:通常将非敏感配置放在 ConfigMap 中挂载为外部文件,敏感信息通过 Secrets 注入环境变量,再配合 spring.profiles.active 实现不同部署环境(dev/staging/prod)的切换。

何时会用到外部化配置? 比如 Agent 服务的 LLM API Key,绝不能硬编码在 jar 里,而是通过环境变量 LLM_API_KEY 注入,由 Spring Boot 自动映射到 spring.ai.openai.api-key 或自定义属性。对于经常变的参数(如模型名称、超时时间),也建议外部化,避免重新打包。

优先级在 Agent 场景的应用: 假设你的 Agent 服务默认连接本地 LLM(application.yml 中设置 llm.url=``http://localhost:11434),但在 Docker 部署时想切换到远程服务,只需在 docker-compose.yml 中设置环境变量 LLM_URL=``http://gpu-server:8080,它就会覆盖默认值,而无需修改镜像。

收束:

自动装配让 Spring Boot 成为智能管家,Starter 让你把经验打包成可复用的乐高块,而配置优先级与环境分离则给了你在不同舞台上灵活演绎的剧本。这三者共同构成了 Spring Boot “约定大于配置”的哲学核心,也是它能在微服务和 AI 工程中遍地开花的根本原因。


Spring Boot 内嵌容器与启动流程

🔍 1. Spring Boot 的启动流程是什么?SpringApplication.run() 内部做了哪些事?

很多人以为 run() 只是“启动一个 Web 容器”,实际上它背后是一整套完备的启动生命周期,我们可以用一张“全景图”拆解成两个阶段:构造阶段 和 运行阶段。

1.1 构造阶段 —— new SpringApplication()

  • 推断应用类型:根据 classpath 下是否存在 DispatcherServletReactive 相关类,决定是 SERVLETREACTIVE 还是 NONE

  • 加载 ApplicationContextInitializer:通过 SpringFactoriesLoaderspring.factories 中加载所有初始化器,用于在 Context 刷新前进行自定义配置。

  • 加载 ApplicationListener:同样从 spring.factories 中加载事件监听器,后续各个启动阶段都会广播对应事件。

1.2 运行阶段 —— run(String... args) 的 10 个关键步骤

public ConfigurableApplicationContext run(String... args) {
    // 1. 启动计时器
    StopWatch stopWatch = new StopWatch();

    // 2. 准备监听器并发布 ApplicationStartingEvent
    SpringApplicationRunListeners listeners = getRunListeners(args);
    listeners.starting();

    // 3. 准备 Environment
    ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);

    // 4. 打印 Banner
    Banner printedBanner = printBanner(environment);

    // 5. 创建 ApplicationContext(如 AnnotationConfigServletWebServerApplicationContext)
    context = createApplicationContext();

    // 6. 准备上下文:设置环境、执行 Initializer、加载启动类
    prepareContext(context, environment, listeners, applicationArguments, printedBanner);

    // 7. 刷新上下文(核心)—— 执行 BeanFactoryPostProcessor、注册 Bean、启动内嵌容器
    refreshContext(context);

    // 8. afterRefresh(扩展点)
    afterRefresh(context, applicationArguments);

    // 9. 回调 ApplicationRunner / CommandLineRunner
    callRunners(context, applicationArguments);

    // 10. 发布 ApplicationReadyEvent
    listeners.started(context);
}

👉 细节强调:

  • 第 6 步 prepareContext 会把启动类(@SpringBootApplication 标注的类)注册为配置源。

  • 第 7 步 refreshContext 真正触发了 Spring 的核心 refresh(),其中 onRefresh() 会调用 createWebServer(),嵌入式 Tomcat 就在这里被创建并启动。

  • 任何异常都会触发 ApplicationFailedEvent,确保监听器能感知失败。

🧩 一句话总结:SpringApplication.run() 是 Spring Boot 的“启动调度器”,它通过事件驱动的方式,把环境准备、上下文构建、容器刷新、扩展回调全部串联起来。

理解了这张启动全景图,你就能自信地掌控应用启动顺序,自定义初始化逻辑或排查启动故障。


🐱 2. Spring Boot 内嵌 Tomcat 的启动原理?如何切换为 Undertow/Jetty?

2.1 内嵌 Tomcat 启动原理

内嵌 Tomcat 的启动巧妙地融合在 Spring 容器刷新流程 中,依赖两个核心要素:

  1. 自动配置注册工厂 ServletWebServerFactoryAutoConfiguration 发现 classpath 下有 tomcat-embed-core,就会自动注册一个 TomcatServletWebServerFactory Bean,它是创建嵌入式 Tomcat 的工厂。

  2. 上下文触发的 onRefresh() ServletWebServerApplicationContext 重写了 onRefresh(),大致逻辑:

protected void onRefresh() {
    createWebServer(); // 获取工厂 → 创建并初始化 WebServer
}

private void createWebServer() {
    ServletWebServerFactory factory = getWebServerFactory(); // 拿到 TomcatServletWebServerFactory
    this.webServer = factory.getWebServer(getSelfInitializer()); // 创建 TomcatWebServer
    // TomcatWebServer 内部完成 Tomcat 实例化、Connector 配置、端口绑定、start()
}
  • Tomcat 实例会被创建,设置 HostContext,并将 DispatcherServlet 等注册进去。

  • Tomcat.start() 真正把连接器线程拉起来,开始监听端口。

  • 整个流程在 refresh()finishRefresh() 前完成,确保 WebServer 就绪后,容器才宣告启动完毕。

2.2 切换为 Undertow 或 Jetty

Spring Boot 的可插拔设计让这一步几乎 零代码。以 Maven 为例:

  • 排除默认的 Tomcat:
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <exclusions>
        <exclusion>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-tomcat</artifactId>
        </exclusion>
    </exclusions>
</dependency>

引入 Undertow(高性能、内存占用低):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-undertow</artifactId>
</dependency>

或 引入 Jetty(广泛用于嵌入式场景):

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jetty</artifactId>
</dependency>

原理很简单:@ConditionalOnClass 检测到对应类(如 io.undertow.Undertow)存在时,自动配置就会注册 UndertowServletWebServerFactory 替代 Tomcat 工厂。后续 createWebServer() 链路不变,只是工厂实现不同。

✈️ 一句话总结:内嵌容器的核心是“Spring 容器驱动 Web 容器”——由自动配置提供工厂,由上下文刷新触发启动。而切换容器只需更换依赖,这是“习惯优于配置”的绝佳示范,也是自动装配价值最大化的体现。


⚡ 3. Agent 服务需要支持高并发 SSE 长连接,如何调优内嵌容器参数?

Agent 服务基于 SSE(Server-Sent Events)推送消息,典型特点是 HTTP 长连接 + 单向数据流。高并发下,如果仍用传统的“一个请求一个线程”模式,Tomcat 线程池会瞬间被耗光。调优需要从 连接器、异步线程模型、操作系统 三个维度进行组合拳。

3.1 启用并优化异步处理(核心中的核心)

Spring MVC 中返回 SseEmitter 实际上已经利用了 Servlet 3.1 异步特性:Tomcat 线程在提交异步任务后就释放回池,真正阻塞的是 异步线程。所以必须显式配置异步线程池:

spring:
  task:
    execution:
      pool:
        core-size: 20          # 常驻异步线程
        max-size: 100          # 峰值线程数
        queue-capacity: 500    # 等待队列
      thread-name-prefix: sse-task-
  mvc:
    async:
      request-timeout: -1     # 异步请求永不超时(SSE 连接需长期保持)

同时,创建 SseEmitter 时可以指定超时:

SseEmitter emitter = new SseEmitter(0L); // 0 表示永不超时

3.2 容器连接器参数调优(Tomcat 示例)

server:
  tomcat:
    # 最大连接数:操作系统能处理的连接上限,默认 8192,SSE 场景必须调高
    max-connections: 20000
    # 当所有工作线程都在忙时,新的连接进入等待队列
    accept-count: 1000
    # 工作线程数:异步模式下无需太大,因为线程很快释放
    threads:
      max: 200
      min-spare: 20
    # 连接超时:SSE 需要保持连接,建议关闭或设大
    connection-timeout: -1
    # keep-alive 保持长连接
    keep-alive-timeout: 60000
  • max-connections 决定了同时能建立的 TCP 连接数,直接制约并发 SSE 连接量。

  • threads.max 在异步模式下不需要开到几千,200 左右足够,因为每个请求只占用极短时间。

  • connection-timeout=-1 避免 Tomcat 因“无数据”断开空闲连接,但记得真正的超时控制应在业务层(心跳机制)实现。

3.3 操作系统层面放行

SSE 长连接本质是占用文件描述符和内存。Linux 环境需确保:

# 查看当前限制
ulimit -n
# 临时增大(生产建议写入 limits.conf)
ulimit -n 65535

如果使用 Docker,要记得 --ulimit nofile=65535:65535。否则连接数到达 1024 就会报 Too many open files

3.4 考虑切换容器(进阶选择)

对于极高并发的 SSE,Undertow 在内存占用和长连接吞吐上通常优于 Tomcat(其基于 XNIO,线程模型更轻量)。如果想彻底解放并发能力,甚至可以用 WebFlux + Netty 完全非阻塞实现 SSE,一个 Netty EventLoop 线程就能管理上千条连接。

💡 一句话总结:高并发 SSE 的内核是“异步化 + 连接上限放开”,别让 Tomcat 工作线程成为瓶颈。参数调优只是“术”,理解异步执行模型才是“道”,双管齐下,Agent 服务才能稳健承载海量长连接,真正做到推送不延迟、不断流。


优雅停机

⚙️ 1. Spring Boot 优雅停机如何配置?

抛开版本不谈,从 Spring Boot 2.3.0 开始,优雅停机才真正变得开箱即用。核心配置只有两行,但背后要考虑的点不少。

基础配置(application.yml)

server:
  shutdown: graceful  # 开启优雅停机,默认 immediate(立即)

spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s  # 每个生命周期阶段的最大等待时间

配置说明:

  • server.shutdown=graceful 让内嵌容器(Tomcat/Jetty/Undertow)停止接收新请求,并等待正在处理的请求完成。

  • timeout-per-shutdown-phase 是 Spring 容器销毁 Bean 时,每个 Lifecycle 阶段(如 SmartLifecycle.stop())的超时时间。这并不意味着请求一定能执行 30 秒,而是给 smartLifecycle 组件一个优雅退出的窗口。

容器层微调(以 Tomcat 为例)

为了让优雅停机在连接器层面更丝滑,可以配合调整 Tomcat 参数:

server:
  tomcat:
    # 连接器暂停后,等待多久再真正关闭。需配合 shutdown=graceful 使用
    # 默认无此配置,但可通过自定义 TomcatConnectorCustomizer 实现
    # 一般在 Spring Boot 2.3+ 内部已自动处理

实际上 Spring Boot 会自动调用 TomcatWebServer 的优雅关闭方法,内部会暂停连接器,然后等待请求处理完毕。你如果不放心,可以写一个监听器精细化控制。

我一般还会在 k8s 里把 terminationGracePeriodSeconds 设得比 Spring 的超时大一些,避免容器提前被 kill 掉。这一环扣一环的配置,才算是完整的优雅停机。


🧩 2. 底层原理:SmartLifecycle、ContextClosedEvent、ShutdownHook 各自的角色

我习惯把优雅停机的底层协作比作一场交响乐,有三个主角:

2.1 触发源头:ShutdownHook(JVM 关门前的最后哨兵)

当进程收到 SIGTERM 或正常结束时,JVM 会启动 ShutdownHook 线程。Spring Boot 启动时,通过 ApplicationContext 注册了一个钩子,里面只做一件事: 关闭 Spring 容器,即调用 context.close()

  • 角色:操作系统和 Spring 之间的桥梁,把 JVM 退出事件转化成 Spring 容器关闭动作。

  • 注意:kill -9 或系统崩溃不会触发它,所以优雅停机只对“礼貌退出”有效。

2.2 事件广播:ContextClosedEvent(内部信号塔)

context.close() 执行时,Spring 会按顺序:

  1. 发布 ContextClosedEvent 事件 —— 这让所有监听该事件的 Bean 能执行清理逻辑。

  2. 销毁所有单例 Bean(先执行 DisposableBean.destroy())。

  3. 触发 LifecycleProcessor.onClose(),开始执行 SmartLifecyclestop 方法。

角色:生命周期事件的发布中心,让开发者可以解耦地感知关闭动作,比如记录日志、释放连接池、停止定时任务。

2.3 有序关闭的执行官:SmartLifecycle(优雅停机的骨架)

SmartLifecycle 继承自 Lifecycle,最关键的是增加了 阶段(phase)和是否自动启动/停止 的控制。在关闭时,所有 SmartLifecyclestop() 方法会按照 phase 从大到小的逆序 调用。

举个例子:

  • 内嵌 Web 服务器的生命周期实现,phase 一般设为 Integer.MAX_VALUE - ... 很高的值,这样它会在 最后才停止,保证在它停止前,其他业务 Bean(如处理请求的控制器)还在。

  • 而我们自定义的 SmartLifecycle,可以设置 phase 值,确保 先于 Web 服务器停止执行。

角色:提供了可控的关闭顺序。比如你的 Agent 服务可以在 stop() 中:

  • 向所有活跃 SSE 客户端发送“服务即将重启”的最后一条消息,并关闭连接。

  • 等待队列中的 LLM 调用结果返回或超时。

💡 这三者配合的精妙之处在于:ShutdownHook 负责“喊停”,ContextClosedEvent 负责“广播”,SmartLifecycle 负责“有序撤军”。 理解了这层关系,你就能任意定制任何复杂关闭逻辑了。


🔄 3. Agent 服务发布时,有正在进行的 SSE 长连接和 LLM 调用,如何实现无损下线?

这才是今天的重头戏。无损下线不是简单地调个配置,而是一套 “流量摘除 → 存量排空 → 进程退出” 的组合拳。

3.1 无损下线的前置工作:注册中心摘除(最关键的第一步)

服务实例准备下线时,必须先通知注册中心(Nacos/Eureka/Consul)把自己标记为不健康或直接摘除,让负载均衡不再分配新流量。这一步通常由 k8s preStop hook 或发布系统回调完成。

# k8s 示例
lifecycle:
  preStop:
    exec:
      command:
        - curl
        - -X POST
        - http://localhost:8080/actuator/service-registry?status=DOWN

这样,在 Spring 容器关闭之前,新请求已经进不来了,为存量请求的处理争取了时间窗口。

3.2 优雅停机配置 + 主动资源清理

在前面基础配置之上,我们要专门处理 SSE 和 LLM 调用的持久性逻辑。

针对 SSE 长连接 SSE 本质是一个永远不会结束的 HTTP 请求,返回 SseEmitter。如果服务器直接关闭,客户端会报错。我们需要主动完成它。

  • 做法:监听 ContextClosedEvent 或实现 SmartLifecycle,在容器关闭时,遍历你维护的所有活跃的 SseEmitter 集合。

  • 向每个 SseEmitter 发送一个自定义的“服务即将停止”事件(如 {event: "shutdown", data: "bye"}),然后调用 emitter.complete()

  • 同时,你可以将 SseEmitter 超时设置为和 timeout-per-shutdown-phase 接近的值,保证整体不卡死。

@Component
public class SseSessionManager implements ApplicationListener<ContextClosedEvent> {
    private final List<SseEmitter> emitters = new CopyOnWriteArrayList<>();

    @Override
    public void onApplicationEvent(ContextClosedEvent event) {
        for (SseEmitter emitter : emitters) {
            try {
                emitter.send(SseEmitter.event().name("shutdown").data("服务即将重启"));
                emitter.complete();
            } catch (Exception e) {
                emitter.completeWithError(e);
            }
        }
        emitters.clear();
    }
}

针对 LLM 调用

LLM 调用通常是异步或阻塞调用,必须等待它返回或达到超时。

  • 如果你的 LLM 调用使用线程池(AsyncTaskExecutor),需要配合 SmartLifecycle 停止线程池。

  • 必须确保线程池关闭前,会尝试让现有任务执行完毕(调用 shutdown()awaitTermination)。

  • 同时,利用 OpenFeign 或 RestTemplate 的超时设置,避免无限期挂起。设置请求连接超时和读取超时,比如 10 秒,那么停机时最多等 10 秒。

3.3 给下线操作留出时间缓冲

即使配置了优雅停机,也要在容器编排层面预留时间。k8s 的 terminationGracePeriodSeconds 建议大于 spring.lifecycle.timeout-per-shutdown-phase 加上一点缓冲,例如设成 45 秒,避免被强制 SIGKILL。

整体下线时序图(简单文字描述)

  1. preStop: 标记 /actuator/health 返回 OUT_OF_SERVICE。

  2. 等待约 5 秒(视流量情况而定),让注册中心同步,不再有新请求进入。

  3. 发送 SIGTERM,触发 ShutdownHook,Spring 开始优雅关闭。

  4. ContextClosedEvent 发布 → 自定义监听器主动完成 SSE 连接并通知客户端。

  5. SmartLifecycle.stop() 按顺序关闭线程池、等待 LLM 任务完成或超时。

  6. 内嵌 Web 服务器 phase 最高,最后才 stop,此时所有存量请求已处理完毕。

  7. 进程结束,容器退出。

真正做到无损,不是保证每个请求都能等到结果,而是让用户感知不到中断——即便断了,也能用最友好的方式提示重连。这才是面对 Agent 服务,最务实的下线哲学。


Actuator 可观测性

🔌 1. Spring Boot Actuator 有哪些常用的 Endpoint?如何保护敏感 Endpoint?

Actuator 自带几十个端点,我先按功能类型帮你筛一筛真正能用上的:

类型 端点 用途
健康与状态 health、info 存活/就绪探针、自定义状态展示
指标监控 metrics、prometheus 为 Micrometer 和 Prometheus 提供数据源
运行时调优 loggers、env、configprops 在线修改日志级别、查看环境变量和配置绑定
诊断排错 heapdump、threaddump、mappings 内存快照、线程栈、URL 映射排查
生命周期管理 startup、restart(需额外依赖) 分析启动耗时、重启上下文

✅ 保护敏感端点的 4 层防线

  1. 最小暴露原则
management:
  endpoints:
    web:
      exposure:
        include: health,info,metrics,prometheus  # 只暴露需要的
        exclude: env,heapdump
  1. 线上环境用白名单比黑名单安全得多。

  2. 端口隔离 把 Actuator 端口和应用端口分开,如 management.server.port=8081,然后在防火墙层只允许监控系统访问。

  3. 与 Spring Security 深度集成

http.requestMatchers(EndpointRequest.to("health", "info")).permitAll()
    .requestMatchers(EndpointRequest.toAnyEndpoint()).hasRole("ACTUATOR");
  1. 敏感操作如 loggersenv 必须权限控制。

  2. 健康信息脱敏

management:
  endpoint:
    health:
      show-details: when-authorized
      roles: ACTUATOR
  1. 这样只有授权用户才能看到磁盘空间、数据库状态等细节,避免信息泄露。

📊 2. 如何自定义 HealthIndicator 和 Micrometer 指标?指标如何接入 Prometheus?

自定义 HealthIndicator:把业务健康状况暴露出来

比如 Agent 依赖的 LLM 服务是否可达、向量数据库是否连接正常,都可以自己定义:

@Component
public class LLMHealthIndicator implements HealthIndicator {
    @Override
    public Health health() {
        // 模拟一次检测请求
        boolean llmReachable = checkLLMConnection();
        if (llmReachable) {
            return Health.up().withDetail("model", "gpt-4").build();
        }
        return Health.down().withDetail("error", "Connection timeout").build();
    }
}

/actuator/health 就能看到聚合结果,Kubernetes 的 liveness/readiness 探针可以直接用。

自定义 Micrometer 指标:把业务变成时序数据

核心就三种类型,覆盖大部分场景:

  • Counter:只增不减,计数用(成功次数、错误次数、Token 消耗量)

  • Timer:记录耗时和计数,适合看延迟分布

  • Gauge:瞬时值,如当前正在处理的请求数

在代码里通过 MeterRegistry 注册:

@RestController
public class LLMController {
    private final MeterRegistry registry;

    public LLMController(MeterRegistry registry) {
        this.registry = registry;
    }

    public String callLLM() {
        Timer.Sample sample = Timer.start(registry);
        try {
            String result = llmService.invoke();
            registry.counter("llm.calls", "status", "success").increment();
            return result;
        } catch (Exception e) {
            registry.counter("llm.calls", "status", "failure").increment();
            throw e;
        } finally {
            sample.stop(registry.timer("llm.call.duration"));
        }
    }
}

也可以用 @Timed 注解,但需要 AOP 支持,上面这种显式埋点更灵活,不丢上下文。

接入 Prometheus

  • 依赖:micrometer-registry-prometheus

  • 暴露端点:/actuator/prometheus 就绪

  • Prometheus 抓取配置:

scrape_configs:
  - job_name: 'agent-service'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['agent-svc:8081']

随后在 Grafana 里就能直接用 rate(llm_calls_total[1m])histogram_quantile(0.99, rate(llm_call_duration_seconds_bucket[5m])) 出图。


🤖 3. 如何用 Actuator 监控 Agent 服务的 LLM 调用成功率、Token 消耗量、工具调用延迟等核心指标?

这是整个回答的落地关键。一个 Agent 服务在推理、工具调用、回复生成等环节都有瓶颈点,我们要把核心交互变成可监控的黄金指标。

需要监控的指标清单

  • llm.calls(Counter,标签 status=success|failure) → 调用成功率

  • llm.tokens(Counter,标签 type=prompt|completion) → Token 消耗量及趋势

  • llm.call.duration(Timer) → LLM 响应延迟 P95/P99

  • tool.calls(Counter,标签 tool_namestatus) → 每个工具调用次数和成功率

  • tool.call.duration(Timer,标签 tool_name) → 工具调用延迟

实现方式(以 AOP + Micrometer 为例)

定义一个注解 @MonitoredLLM,切面里统一埋点:

@Around("@annotation(monitoredLLM)")
public Object monitorLLM(ProceedingJoinPoint pjp, MonitoredLLM monitoredLLM) {
    Timer.Sample sample = Timer.start(meterRegistry);
    try {
        Object result = pjp.proceed();
        meterRegistry.counter("llm.calls", "status", "success").increment();
        // 假设能从返回值中获取 token 用量
        long tokens = extractTokens(result);
        meterRegistry.counter("llm.tokens", "type", "completion").increment(tokens);
        return result;
    } catch (Exception e) {
        meterRegistry.counter("llm.calls", "status", "failure").increment();
        throw e;
    } finally {
        sample.stop(Timer.builder("llm.call.duration")
                .publishPercentiles(0.95, 0.99)
                .register(meterRegistry));
    }
}

工具调用同理,用 tool.calls Counter 和 tool.call.duration Timer,标签区分工具名称。

在 Actuator 里验证 启动后访问 /actuator/metrics/llm.calls,可以看到带标签的成功失败次数。在 Grafana 中配面板:

  • 成功率:sum(rate(llm_calls_total{status="success"}[1m])) / sum(rate(llm_calls_total[1m]))

  • Token 消耗趋势:sum(rate(llm_tokens_total[5m]))

  • LLM 调用 P99 延迟:histogram_quantile(0.99, rate(llm_call_duration_seconds_bucket[5m]))

  • 告警规则:比如连续 3 分钟成功率低于 99%,就触发 PagerDuty。

进阶:结合健康检查做熔断 如果 LLM 的错误率超过阈值,可以在 LLMHealthIndicator 中读取 Micrometer 指标,动态将健康状态置为 DOWN,配合 Kubernetes 的 readinessProbe 自动摘除该实例,避免扩散雪崩。

把这些指标搭好之后,你甚至能画出一张 Agent 全景驾驶舱:左侧是对话请求 QPS,中间是 LLM 延迟和 Token 消耗趋势,右侧是各工具调用热力图。用数据驱动迭代,才是 Agent 服务从“能用”走向“可靠”的正确姿态。


Spring Boot 测试

1、基础题:@SpringBootTest 和 @WebMvcTest 有什么区别?各自适用什么场景?

难度级别:⭐⭐(集成测试、Slice 测试、Mock 依赖)

1️⃣ Common Answer

@SpringBootTest 是启动整个 Spring 容器的集成测试,@WebMvcTest 只测试 Web 层。@WebMvcTest 更快,因为它只加载 Controller 相关的 Bean。@SpringBootTest 适合测试完整流程,@WebMvcTest 适合测试 API。

2️⃣ Impressive Answer

  1. @SpringBootTest
  2. 启动完整容器:加载整个应用上下文(包括 Controller、Service、Repository 等)。
  3. 适用场景:端到端测试、集成测试、需要测试多个组件协作的场景。
  4. 缺点:启动慢,依赖真实的外部服务(数据库、Redis 等)。
@SpringBootTest
@AutoConfigureMockMvc
public class AgentIntegrationTest {
    @Autowired
    private MockMvc mockMvc;

    @Test
    public void testExecuteTool() throws Exception {
        mockMvc.perform(post("/api/agent/execute")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"tool\":\"weather\",\"params\":{\"city\":\"杭州\"}}"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.result").exists());
    }
}
  1. @WebMvcTest
  2. Slice 测试:只加载 Web 层(Controller、ControllerAdvice、Filter 等),不加载 Service 和 Repository。
  3. 自动 Mock:Service 层的 Bean 自动被 Mock,需要用 @MockBean 模拟行为。
  4. 适用场景:单元测试 Controller、测试 API 输入输出、验证异常处理。
@WebMvcTest(AgentController.class)
public class AgentControllerTest {
    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private AgentService agentService;

    @Test
    public void testExecuteTool() throws Exception {
        when(agentService.executeTool(any()))
            .thenReturn(ToolResult.success("result"));

        mockMvc.perform(post("/api/agent/execute")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"tool\":\"weather\",\"params\":{\"city\":\"杭州\"}}"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.result").value("result"));
    }
}
  1. 其他 Slice 测试
  2. @DataJpaTest:只测试 JPA 层,自动配置嵌入式数据库(H2)。
  3. @JsonTest:只测试 JSON 序列化/反序列化。
  4. @WebFluxTest:测试 WebFlux Controller。

  5. 选择原则

  6. 单元测试:优先用 Slice 测试(@WebMvcTest、@DataJpaTest)。
  7. 集成测试:用 @SpringBootTest,但尽量减少数量。
  8. 测试金字塔:70% 单元测试 → 20% 集成测试 → 10% 端到端测试。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了区别和适用场景 包含代码示例、Slice 测试类型、测试金字塔
技术深度 不知道 Slice 测试的自动 Mock 清楚 @MockBean 的工作机制
实践经验 没有测试金字塔概念 有测试策略和分层测试经验
面试官印象 知道测试注解 有完整的测试架构设计能力

2、进阶题:Spring Boot 测试的 Slice 测试(@DataJpaTest、@WebMvcTest 等)是如何实现的?如何 Mock 外部依赖?

1️⃣ Common Answer

Slice 测试就是只加载一部分 Bean,不加载整个容器。Mock 外部依赖用 @MockBean 注解,然后用 when().thenReturn() 模拟返回值。

2️⃣ Impressive Answer

  1. Slice 测试实现原理
  2. 通过 @BootstrapWith 指定自定义的 TestContextBootstrapper
  3. TestContextBootstrapper 根据 @TestConfiguration@Import 过滤 BeanDefinition,只加载特定层的 Bean。
  4. 例如 @WebMvcTest 只加载 @Controller@ControllerAdvice@Filter 等注解的 Bean。

  5. @MockBean 工作机制

  6. @MockBean 通过 MockitoPostProcessor 在测试上下文中注册 Mockito Mock 对象。
  7. Mock 对象会替换容器中同类型的真实 Bean(包括 @Primary 和 @Qualifier)。
  8. 支持按类型、按名称、按限定符匹配要替换的 Bean。

  9. Mock 外部依赖示例

@WebMvcTest(AgentController.class)
public class AgentControllerTest {
    @MockBean
    private AgentService agentService;

    @MockBean
    private LLMProvider llmProvider;  // Mock 外部 LLM 服务

    @Test
    public void testExecuteTool() {
        // Mock Service 层
        when(agentService.executeTool(any()))
            .thenReturn(ToolResult.success("result"));

        // Mock 外部 LLM 服务
        when(llmProvider.chat(anyString(), anyString()))
            .thenReturn("AI response");
    }
}
  1. @SpyBean vs @MockBean
  2. @MockBean:完全 Mock,所有方法默认返回默认值(null、0、false)。
  3. @SpyBean:部分 Mock,真实方法会被调用,可以 when(spy.method()).thenCallRealMethod()

  4. Mock 静态方法(Mockito 3.4+):

@Test
public void testStaticMethod() {
    try (MockedStatic<UUID> mocked = mockStatic(UUID.class)) {
        mocked.when(UUID::randomUUID).thenReturn(uuid);
        // 测试代码
    }
}

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了 MockBean 包含实现原理、SpyBean、静态方法 Mock
技术深度 不知道 TestContextBootstrapper 清楚 Slice 测试的过滤机制
实践经验 没有静态方法 Mock 有完整的 Mock 技术栈经验
面试官印象 会用 Mock 理解测试框架的底层机制

3、场景题:Agent 服务的工具调用逻辑需要单元测试,但工具调用依赖外部 LLM API,如何用 Mockito + @MockBean 实现隔离测试?

难度级别:⭐⭐⭐(隔离测试、Mock 链式调用、参数匹配、异常测试)

1️⃣ Common Answer

可以用 @MockBean 把 LLM Provider Mock 掉,然后用 when().thenReturn() 模拟返回值。测试时调用工具执行方法,验证返回结果是否符合预期。

2️⃣ Impressive Answer

  1. 工具调用服务设计
@Service
public class ToolExecutor {
    private final LLMProvider llmProvider;

    public ToolExecutor(LLLMProvider llmProvider) {
        this.llmProvider = llmProvider;
    }

    public ToolResult execute(ToolCall call) {
        try {
            String prompt = buildPrompt(call);
            String response = llmProvider.chat(prompt, call.getModel());
            return parseResponse(response);
        } catch (LLMException e) {
            return ToolResult.error(e.getMessage());
        }
    }
}
  1. 隔离测试实现
@ExtendWith(MockitoExtension.class)
public class ToolExecutorTest {

    @Mock
    private LLMProvider llmProvider;

    @InjectMocks
    private ToolExecutor toolExecutor;

    @Test
    public void testExecuteSuccess() {
        // 准备测试数据
        ToolCall call = ToolCall.builder()
            .tool("weather")
            .params(Map.of("city", "杭州"))
            .build();

        // Mock 外部依赖
        when(llmProvider.chat(
            startsWith("查询杭州的天气"),
            eq("gpt-4")
        )).thenReturn("{\"temperature\": 25, \"weather\": \"晴\"}");

        // 执行测试
        ToolResult result = toolExecutor.execute(call);

        // 验证结果
        assertThat(result.isSuccess()).isTrue();
        assertThat(result.getData()).containsEntry("temperature", 25);

        // 验证 Mock 被调用
        verify(llmProvider, times(1))
            .chat(anyString(), eq("gpt-4"));
    }

    @Test
    public void testExecuteFailure() {
        ToolCall call = ToolCall.builder()
            .tool("weather")
            .params(Map.of("city", "杭州"))
            .build();

        // Mock 异常情况
        when(llmProvider.chat(anyString(), anyString()))
            .thenThrow(new LLMException("API rate limit exceeded"));

        ToolResult result = toolExecutor.execute(call);

        assertThat(result.isError()).isTrue();
        assertThat(result.getError()).contains("rate limit");
    }

    @Test
    public void testExecuteWithMultipleCalls() {
        // Mock 多次调用返回不同结果
        when(llmProvider.chat(anyString(), anyString()))
            .thenReturn("{\"result\": \"first\"}")
            .thenReturn("{\"result\": \"second\"}");

        ToolResult result1 = toolExecutor.execute(call1);
        ToolResult result2 = toolExecutor.execute(call2);

        assertThat(result1.getData()).containsEntry("result", "first");
        assertThat(result2.getData()).containsEntry("result", "second");
    }
}
  1. 参数匹配器
// 精确匹配
when(llmProvider.chat("exact prompt", "gpt-4")).thenReturn(...);

// 任意参数
when(llmProvider.chat(anyString(), anyString())).thenReturn(...);

// 部分匹配
when(llmProvider.chat(
    startsWith("查询"),
    endsWith("-4")
)).thenReturn(...);

// 自定义匹配器
when(llmProvider.chat(argThat(prompt -> prompt.length() > 10), anyString()))
    .thenReturn(...);
  1. 验证调用次数和顺序
// 验证调用次数
verify(llmProvider, times(2)).chat(anyString(), anyString());
verify(llmProvider, never()).chat(eq("forbidden"), anyString());
verify(llmProvider, atLeastOnce()).chat(anyString(), anyString());

// 验证调用顺序
InOrder inOrder = inOrder(llmProvider);
inOrder.verify(llmProvider).chat(eq("first"), anyString());
inOrder.verify(llmProvider).chat(eq("second"), anyString());
  1. 集成测试(@SpringBootTest)
@SpringBootTest
@AutoConfigureMockMvc
public class ToolExecutionIntegrationTest {
    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private LLMProvider llmProvider;

    @Test
    public void testToolExecutionApi() throws Exception {
        when(llmProvider.chat(anyString(), anyString()))
            .thenReturn("{\"result\": \"test\"}");

        mockMvc.perform(post("/api/tools/execute")
                .contentType(MediaType.APPLICATION_JSON)
                .content("{\"tool\":\"weather\",\"params\":{\"city\":\"杭州\"}}"))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.data.result").value("test"));
    }
}

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了基本的 Mock 包含参数匹配、验证、异常测试、集成测试
技术深度 不知道参数匹配器 清楚 Mockito 的完整 API
实践经验 没有验证调用次数 有完整的测试覆盖经验
面试官印象 会写单元测试 有测试驱动开发的工程化能力

4、容易一起考的题

关联题 和本题的关系
JUnit 5 的新特性有哪些? 单元测试框架的演进
如何测试私有方法? 测试边界情况的讨论
测试覆盖率如何计算? 测试质量的评估指标

条件注解与自动装配扩展

1、基础题:@Conditional 家族注解有哪些?各自的使用场景是什么?

难度级别:⭐⭐(条件注解、自动装配条件控制)

1️⃣ Common Answer

@ConditionalOnClass 是类路径上有这个类才生效,@ConditionalOnMissingBean 是没有这个 Bean 才生效。这些注解用在自动配置类上,控制 Bean 是否创建。

2️⃣ Impressive Answer

  1. 类路径条件
  2. @ConditionalOnClass:类路径上存在指定类时生效(如 @ConditionalOnClass(Tomcat.class)
  3. @ConditionalOnMissingClass:类路径上不存在指定类时生效

  4. Bean 条件

  5. @ConditionalOnBean:容器中存在指定 Bean 时生效
  6. @ConditionalOnMissingBean:容器中不存在指定 Bean 时生效(用户自定义优先的核心机制)
  7. @ConditionalOnSingleCandidate:容器中只有一个候选 Bean 时生效

  8. 配置条件

  9. @ConditionalOnProperty:配置文件中存在指定属性且值匹配时生效
// 通过配置开关控制功能启停
@ConditionalOnProperty(
    prefix = "agent.tool",
    name = "enabled",
    havingValue = "true",
    matchIfMissing = true  // 没有配置时默认生效
)
@Bean
public WeatherTool weatherTool() {
    return new WeatherTool();
}
  1. 环境条件
  2. @ConditionalOnExpression:SpEL 表达式为 true 时生效
  3. @Profile:激活指定 Profile 时生效(本质是 @Conditional(ProfileCondition.class)

  4. Web 条件

  5. @ConditionalOnWebApplication:是 Web 应用时生效
  6. @ConditionalOnNotWebApplication:不是 Web 应用时生效

  7. 自定义 Condition

public class OnLLMProviderCondition implements Condition {

    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        Environment env = context.getEnvironment();
        String provider = env.getProperty("agent.llm.provider");
        // 只有配置了 provider 才加载对应的 Bean
        return provider != null && !provider.isEmpty();
    }
}

@Conditional(OnLLMProviderCondition.class)
@Bean
public LLMClient llmClient() {
    return new LLMClient();
}

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了两个注解 6 大类条件注解,覆盖所有场景
技术深度 不知道 matchIfMissing 清楚各注解的细节参数
实践经验 没有自定义 Condition 有完整的自定义条件实现
面试官印象 背过注解名 理解条件注解的设计意图

2、进阶题:@ConditionalOnMissingBean 的判断时机是什么?为什么用户自定义的 Bean 能覆盖自动配置?

难度级别:⭐⭐⭐(Bean 注册顺序、DeferredImportSelector、自动配置优先级)

1️⃣ Common Answer

因为用户的 Bean 先注册,自动配置的 Bean 后注册,@ConditionalOnMissingBean 检查到已经有了就不创建了。

2️⃣ Impressive Answer

  1. 核心机制:DeferredImportSelector
  2. AutoConfigurationImportSelector 实现了 DeferredImportSelector延迟到所有 @Configuration 类处理完之后才执行。
  3. 用户的 @Configuration 类(通过 @ComponentScan 扫描)先于自动配置类处理。
  4. 因此用户的 Bean 先注册到容器,自动配置类执行时 @ConditionalOnMissingBean 检查到已有 Bean,跳过创建。

  5. 判断时机

  6. @ConditionalOnMissingBean 的判断发生在 BeanDefinition 注册阶段,而非 Bean 实例化阶段。
  7. ConditionEvaluator 在处理每个 @Bean 方法时调用 Condition.matches(),此时已注册的 BeanDefinition 都可以被检测到。

  8. 正确覆盖姿势

// 用户自定义配置,覆盖自动配置的 DataSource
@Configuration
public class MyDataSourceConfig {

    @Bean
    // 不需要加任何条件注解,因为自动配置的 DataSource 有 @ConditionalOnMissingBean
    public DataSource dataSource() {
        return new HikariDataSource(myConfig());
    }
}
  1. 常见陷阱@ConditionalOnMissingBean 只检查当前已注册的 BeanDefinition,如果两个自动配置类都没有 @ConditionalOnMissingBean 保护,会导致 Bean 重复注册冲突。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了"先后顺序" DeferredImportSelector 机制 + 判断时机 + 陷阱
技术深度 不知道 DeferredImportSelector 清楚延迟导入的设计意图
实践经验 不知道判断发生在哪个阶段 清楚 BeanDefinition 注册阶段的判断逻辑
面试官印象 知道结论但不知道原因 理解 Spring Boot 自动配置的核心设计

3、容易一起考的题

关联题 和本题的关系
Spring Boot 自动装配的完整链路? 条件注解是自动装配的核心控制机制
BeanDefinition 的注册流程? 条件注解判断发生在 BeanDefinition 注册阶段
如何自定义一个 Spring Boot Starter? Starter 的核心就是条件注解的合理使用

配置绑定机制

1、基础题:@ConfigurationProperties 和 @Value 有什么区别?各自适用什么场景?

难度级别:⭐⭐(配置绑定、类型安全、松散绑定)

1️⃣ Common Answer

@Value 是注入单个配置项,@ConfigurationProperties 是批量绑定一组配置。@ConfigurationProperties 支持 POJO 绑定,@Value 支持 SpEL 表达式。

2️⃣ Impressive Answer

  1. 核心区别对比
维度 @Value @ConfigurationProperties
绑定粒度 单个属性 整个前缀下的属性组
类型安全 不支持(运行时失败) 支持(启动时校验)
松散绑定 不支持 支持(server-port = serverPort)
SpEL 表达式 支持 不支持
元数据提示 不支持 支持(IDE 自动补全)
适用场景 少量、简单配置 模块化配置、复杂对象
  1. @ConfigurationProperties 最佳实践
@ConfigurationProperties(prefix = "agent.llm")
@Validated  // 开启 JSR-303 校验
public class LLMProperties {

    @NotBlank
    private String apiKey;

    @Min(1)
    @Max(4096)
    private int maxTokens = 2048;

    private double temperature = 0.7;

    private List<String> models = new ArrayList<>();

    // getter/setter 省略
}

// 注册到容器(二选一)
@EnableConfigurationProperties(LLMProperties.class)
// 或者在类上加 @Component
  1. 松散绑定规则agent.llm.api-keyagent.llm.apiKeyAGENT_LLM_API_KEY(环境变量)都能绑定到 apiKey 字段。

  2. @Value 的 SpEL 能力

// 支持默认值
@Value("${agent.timeout:30}")
private int timeout;

// 支持 SpEL 表达式
@Value("#{${agent.weights}}")
private Map<String, Double> weights;
  1. Agent 场景:LLM 配置(API Key、模型参数、超时)用 @ConfigurationProperties 统一管理,支持多环境切换和配置校验,避免启动后才发现配置错误。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了"批量 vs 单个" 完整对比表 + 代码示例 + 松散绑定规则
技术深度 不知道松散绑定 清楚三种命名格式都能绑定
实践经验 没有 @Validated 校验 有启动时配置校验的最佳实践
面试官印象 知道区别 理解配置绑定的工程化最佳实践

2、进阶题:Spring Boot 的 Environment 抽象是什么?PropertySource 的优先级如何工作?

难度级别:⭐⭐⭐(Environment、PropertySource、配置覆盖机制)

1️⃣ Common Answer

Environment 是 Spring 的环境抽象,可以获取配置属性。PropertySource 是配置源,有多个配置源时按优先级取值,命令行参数优先级最高。

2️⃣ Impressive Answer

  1. Environment 抽象层次
  2. Environment 接口:提供 getProperty()containsProperty()getActiveProfiles() 等方法。
  3. ConfigurableEnvironment:可以添加/删除 PropertySource,获取 MutablePropertySources
  4. StandardEnvironment:非 Web 环境,包含系统属性和环境变量两个 PropertySource。
  5. StandardServletEnvironment:Web 环境,额外包含 Servlet 配置和上下文参数。

  6. PropertySource 优先级链(从高到低)

命令行参数(CommandLinePropertySource)
Servlet 配置参数(ServletConfig/ServletContext)
JNDI 属性
Java 系统属性(System.getProperties())
系统环境变量(System.getenv())
application-{profile}.yml(激活的 Profile)
application.yml(默认配置)
@PropertySource 注解引入的配置
默认属性(SpringApplication.setDefaultProperties())
  1. 自定义 PropertySource
@Component
public class NacosPropertySourceLoader implements EnvironmentPostProcessor {

    @Override
    public void postProcessEnvironment(ConfigurableEnvironment environment,
                                       SpringApplication application) {
        // 从 Nacos 加载配置,插入到高优先级位置
        Map<String, Object> nacosProperties = loadFromNacos();
        MapPropertySource nacosSource =
            new MapPropertySource("nacos", nacosProperties);

        // addFirst 保证 Nacos 配置优先级最高(仅次于命令行)
        environment.getPropertySources().addFirst(nacosSource);
    }
}
  1. EnvironmentPostProcessor 注册:在 META-INF/spring.factories 中注册(Boot 2.x)或 META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor.imports(Boot 3.x)。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了"命令行最高" 完整优先级链 + Environment 层次 + 自定义扩展
技术深度 不知道 EnvironmentPostProcessor 清楚配置中心集成的扩展点
实践经验 没有自定义 PropertySource 有 Nacos 集成的完整实现思路
面试官印象 知道优先级 理解 Environment 抽象的扩展机制

3、容易一起考的题

关联题 和本题的关系
Spring Boot 配置加载优先级? PropertySource 优先级的具体体现
Nacos/Apollo 配置中心如何集成? 自定义 PropertySource 的典型应用
@RefreshScope 动态刷新原理? 配置变更后如何让 Bean 感知到

Spring Boot 事件机制

1、基础题:Spring Boot 有哪些内置的应用事件?发布顺序是什么?

难度级别:⭐⭐(ApplicationEvent、事件发布顺序、监听器)

1️⃣ Common Answer

Spring Boot 有 ApplicationStartedEvent、ApplicationReadyEvent 等事件,可以用 @EventListener 监听。启动完成后会发布 ApplicationReadyEvent。

2️⃣ Impressive Answer

  1. Spring Boot 启动事件完整顺序
ApplicationStartingEvent        → SpringApplication.run() 刚开始,容器还未创建
ApplicationEnvironmentPreparedEvent → Environment 准备完成,配置已加载
ApplicationContextInitializedEvent  → ApplicationContext 创建完成,Initializer 执行完
ApplicationPreparedEvent        → BeanDefinition 加载完成,Bean 还未实例化
ApplicationStartedEvent         → refreshContext 完成,Bean 已实例化
ApplicationReadyEvent           → Runner 执行完成,应用完全就绪
ApplicationFailedEvent          → 启动失败时发布
  1. 监听方式
// 方式一:@EventListener 注解(容器刷新后的事件)
@Component
public class AgentStartupListener {

    @EventListener(ApplicationReadyEvent.class)
    public void onApplicationReady(ApplicationReadyEvent event) {
        // 应用完全就绪后,预热 LLM 连接池
        llmClient.warmUp();
    }
}

// 方式二:实现 ApplicationListener 接口(可监听容器刷新前的事件)
public class EnvironmentPreparedListener
        implements ApplicationListener<ApplicationEnvironmentPreparedEvent> {

    @Override
    public void onApplicationEvent(ApplicationEnvironmentPreparedEvent event) {
        // 容器刷新前,可以修改 Environment
        ConfigurableEnvironment env = event.getEnvironment();
        // 动态注入配置
    }
}
  1. 注意事项ApplicationStartingEventApplicationEnvironmentPreparedEvent 发布时容器还未创建,不能用 @EventListener,必须通过 SpringApplication.addListeners()spring.factories 注册。

  2. 自定义事件

// 定义事件
public class LLMCallCompletedEvent extends ApplicationEvent {
    private final String model;
    private final int tokensUsed;

    public LLMCallCompletedEvent(Object source, String model, int tokensUsed) {
        super(source);
        this.model = model;
        this.tokensUsed = tokensUsed;
    }
}

// 发布事件
@Autowired
private ApplicationEventPublisher eventPublisher;

eventPublisher.publishEvent(new LLMCallCompletedEvent(this, "gpt-4", 512));

// 监听事件
@EventListener
public void onLLMCallCompleted(LLMCallCompletedEvent event) {
    metricsService.recordTokenUsage(event.getModel(), event.getTokensUsed());
}

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了几个事件名 完整顺序 + 两种监听方式 + 注意事项
技术深度 不知道容器刷新前的事件限制 清楚哪些事件不能用 @EventListener
实践经验 没有自定义事件 有完整的自定义事件发布/监听实现
面试官印象 背过事件名 理解事件机制的完整链路和限制

2、进阶题:ApplicationRunner 和 CommandLineRunner 有什么区别?执行时机是什么?

难度级别:⭐⭐(Runner 接口、启动后执行、执行顺序)

1️⃣ Common Answer

ApplicationRunner 和 CommandLineRunner 都是启动完成后执行的,区别是参数类型不同。ApplicationRunner 的参数是 ApplicationArguments,CommandLineRunner 是 String[]。

2️⃣ Impressive Answer

  1. 核心区别
维度 CommandLineRunner ApplicationRunner
参数类型 String[](原始命令行参数) ApplicationArguments(结构化参数)
参数解析 需要手动解析 --key=value 自动解析,支持 getOptionValues("key")
适用场景 简单脚本、参数不复杂 需要解析命名参数的场景
  1. 执行时机:在 ApplicationStartedEvent 之后、ApplicationReadyEvent 之前执行,此时容器已完全刷新,所有 Bean 已就绪。

  2. 执行顺序控制:通过 @Order 注解控制多个 Runner 的执行顺序(值越小越先执行)。

@Component
@Order(1)
public class DatabaseInitRunner implements ApplicationRunner {

    @Override
    public void run(ApplicationArguments args) throws Exception {
        // 先初始化数据库
        if (args.containsOption("init-db")) {
            databaseService.initialize();
        }
    }
}

@Component
@Order(2)
public class LLMWarmUpRunner implements CommandLineRunner {

    @Override
    public void run(String... args) throws Exception {
        // 后预热 LLM 连接
        llmClient.warmUp();
    }
}
  1. Agent 场景:启动时预加载 Embedding 模型、预热向量数据库连接、初始化工具注册表,避免第一次请求时的冷启动延迟。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了参数类型区别 对比表 + 执行时机 + 顺序控制 + 场景
技术深度 不知道执行时机 清楚在哪两个事件之间执行
实践经验 没有 @Order 控制 有多 Runner 顺序控制的实践
面试官印象 知道区别 理解 Runner 在启动流程中的定位

3、容易一起考的题

关联题 和本题的关系
Spring Boot 启动流程? 事件和 Runner 都是启动流程的组成部分
@PostConstruct 和 ApplicationRunner 的区别? 启动后执行逻辑的不同方式对比
Spring 的事件驱动模型(观察者模式)? 事件机制的设计模式基础

3.8 Spring Boot 3.x 新特性

1、基础题:Spring Boot 3.x 相比 2.x 有哪些重要变化?

难度级别:⭐⭐(版本迁移、Jakarta EE、Java 17 基线)

1️⃣ Common Answer

Spring Boot 3.x 要求 Java 17,把 javax 包改成了 jakarta 包。自动配置的注册文件也变了,从 spring.factories 迁移到新的文件。

2️⃣ Impressive Answer

  1. 基础要求升级
  2. Java 17 基线:不再支持 Java 8/11,可以使用 Record、Sealed Class、Pattern Matching 等新特性。
  3. Jakarta EE 10:所有 javax.* 包迁移到 jakarta.*(如 javax.servletjakarta.servlet),这是最大的迁移成本。
  4. Spring Framework 6.x:底层框架同步升级。

  5. 自动配置机制变化

  6. 废弃 spring.factoriesEnableAutoConfiguration 入口。
  7. 迁移到 META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
  8. 新增 @AutoConfiguration 注解替代 @Configuration,语义更清晰。

  9. AOT(Ahead-of-Time)编译支持

  10. Spring Boot 3.x 内置 AOT 处理器,在构建时分析 Bean 定义,生成静态代码。
  11. 为 GraalVM 原生镜像编译提供支持,大幅减少反射使用。
  12. mvn spring-boot:build-image 可直接构建原生镜像。

  13. 可观测性增强

  14. 内置 Micrometer Tracing(替代 Spring Cloud Sleuth),支持分布式链路追踪。
  15. 自动集成 OpenTelemetry,无需额外配置。

  16. 其他重要变化

  17. HttpExchange 接口替代 Feign,声明式 HTTP 客户端内置支持。
  18. 问题详情(RFC 7807)支持,ProblemDetail 标准化错误响应。
  19. @ControllerAdvice 支持函数式异常处理。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了 Java 17 和 jakarta 5 大变化:基础要求+自动配置+AOT+可观测性+其他
技术深度 不知道 AOT 编译 清楚 AOT 对 GraalVM 原生镜像的意义
实践经验 不知道迁移成本 知道 javax→jakarta 是最大迁移成本
面试官印象 知道版本号 理解版本升级的技术演进方向

2、进阶题:Spring Boot 3.x 的 AOT 编译和 GraalVM 原生镜像是什么?有什么优缺点?

难度级别:⭐⭐⭐(AOT、GraalVM、原生镜像、冷启动优化)

1️⃣ Common Answer

GraalVM 可以把 Java 程序编译成原生可执行文件,启动速度很快,内存占用少。但是编译时间很长,而且有些反射代码会有问题。

2️⃣ Impressive Answer

  1. AOT vs JIT 对比
  2. JIT(Just-In-Time):运行时编译,启动慢(需要 JVM 预热),但长期运行性能好。
  3. AOT(Ahead-of-Time):构建时编译,启动极快(毫秒级),内存占用低,但失去 JIT 优化。

  4. Spring Boot AOT 处理流程

mvn spring-boot:process-aot
AOT 处理器分析 BeanDefinition
生成静态 Bean 注册代码(替代反射)
生成 GraalVM 反射配置(reflect-config.json)
GraalVM native-image 编译
原生可执行文件(无需 JVM)
  1. 性能对比
指标 JVM 模式 GraalVM 原生镜像
启动时间 2-10 秒 50-200 毫秒
内存占用 200-500 MB 50-100 MB
峰值吞吐量 高(JIT 优化) 较低(无 JIT)
编译时间 秒级 分钟级
动态特性 完全支持 受限(反射需配置)
  1. Agent 场景适用性
  2. 适合:Serverless 函数、CLI 工具、冷启动敏感的场景(如 K8s 快速扩容)。
  3. 不适合:需要动态加载类、大量反射的 Agent 框架(如 LangChain4J 的动态代理)。

  4. 常见问题:反射、动态代理、序列化需要额外配置 reflect-config.json;第三方库兼容性问题(需要 GraalVM 支持)。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了优缺点 AOT 流程 + 性能对比表 + 场景适用性
技术深度 不知道 AOT 处理流程 清楚 Spring Boot AOT 的完整编译链路
实践经验 不知道 Agent 场景的适用性 有 Serverless vs 长期运行的选型判断
面试官印象 知道原生镜像 理解 AOT 的工程权衡

3、场景题:Agent 服务需要快速冷启动(K8s 弹性扩容),如何用 Spring Boot 3.x 优化启动时间?

难度级别:⭐⭐⭐(启动优化、懒加载、AOT、类路径扫描优化)

1️⃣ Common Answer

可以用 GraalVM 原生镜像,启动很快。也可以开启懒加载,减少启动时加载的 Bean 数量。

2️⃣ Impressive Answer

  1. 启动优化分层策略
# 第一层:懒加载(最简单,效果明显)
spring:
  main:
    lazy-initialization: true  # 所有 Bean 延迟到第一次使用时初始化
  1. 第二层:减少类路径扫描范围
@SpringBootApplication(scanBasePackages = "com.example.agent")
// 精确指定扫描包,避免扫描整个类路径
  1. 第三层:排除不需要的自动配置
@SpringBootApplication(exclude = {
    DataSourceAutoConfiguration.class,    // 不用数据库
    SecurityAutoConfiguration.class,      // 不用 Security
    FlywayAutoConfiguration.class         // 不用数据库迁移
})
  1. 第四层:AOT + 原生镜像(终极方案)
<!-- pom.xml -->
<plugin>
    <groupId>org.graalvm.buildtools</groupId>
    <artifactId>native-maven-plugin</artifactId>
</plugin>
# 构建原生镜像
mvn -Pnative spring-boot:build-image

# 启动时间从 5s → 100ms
  1. 启动时间基准测试
优化手段 启动时间 适用场景
无优化 5-10s 开发环境
懒加载 2-4s 生产环境(简单)
排除自动配置 1-3s 生产环境(精细化)
AOT 编译(JVM) 1-2s 生产环境(推荐)
GraalVM 原生镜像 50-200ms Serverless/弹性扩容
  1. 懒加载的风险:懒加载会把配置错误推迟到运行时才暴露,建议在测试环境关闭懒加载,生产环境开启。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了两个方案 4 层递进优化策略 + 基准测试数据
技术深度 不知道懒加载的风险 清楚懒加载推迟错误暴露的问题
实践经验 没有量化数据 有各方案的启动时间对比
面试官印象 知道方向 有系统性的启动优化方案

4、容易一起考的题

关联题 和本题的关系
Spring Boot 2.x 升级到 3.x 的迁移注意事项? 版本迁移的工程实践
GraalVM 和 JVM 的区别? AOT 编译的底层原理
K8s 的 HPA(水平自动扩缩容)如何配置? 弹性扩容场景下的启动时间要求

3.9 Spring Boot 启动优化与性能调优

1、基础题:Spring Boot 应用启动慢的常见原因有哪些?如何排查?

难度级别:⭐⭐(启动性能、Bean 扫描、自动配置)

1️⃣ Common Answer

启动慢可能是 Bean 太多,或者扫描范围太大。可以用 --debug 参数看自动配置报告,找出哪些配置类生效了。

2️⃣ Impressive Answer

  1. 常见原因分析
  2. 类路径扫描范围过大@ComponentScan 扫描了不必要的包,包含大量第三方 jar。
  3. 自动配置过多:加载了不需要的自动配置类(如引入了 spring-boot-starter-data-jpa 但不用数据库)。
  4. Bean 初始化耗时:某些 Bean 的 @PostConstruct 或构造函数有耗时操作(如建立连接、加载模型)。
  5. AOP 代理创建:大量 @Transactional@Async 注解导致代理对象创建耗时。

  6. 排查工具

# 开启启动时间详细日志
java -jar app.jar --debug

# 使用 Spring Boot Actuator 的 startup 端点
management.endpoint.startup.enabled=true
# GET /actuator/startup 查看每个步骤的耗时
  1. Spring Boot 启动耗时分析
// 自定义 ApplicationStartup 记录启动步骤耗时
@SpringBootApplication
public class AgentApplication {

    public static void main(String[] args) {
        SpringApplication app = new SpringApplication(AgentApplication.class);
        // 记录详细的启动步骤
        app.setApplicationStartup(new BufferingApplicationStartup(2048));
        app.run(args);
    }
}
  1. 快速定位方法
  2. refreshContext 耗时(Bean 实例化阶段)
  3. @PostConstruct 打时间戳,找出耗时的 Bean 初始化
  4. 检查是否有同步的网络调用(如启动时连接外部服务)

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了"Bean 太多" 4 大原因 + 排查工具 + 定位方法
技术深度 不知道 ApplicationStartup 清楚启动耗时分析的完整工具链
实践经验 没有具体排查步骤 有系统性的排查方法论
面试官印象 知道方向 有生产环境性能排查经验

2、进阶题:Spring Boot DevTools 的热部署原理是什么?为什么生产环境要禁用?

难度级别:⭐⭐⭐(类加载器、热部署、双亲委派)

1️⃣ Common Answer

DevTools 可以在代码改变后自动重启应用,不用手动重启。生产环境要禁用是因为会影响性能,而且有安全风险。

2️⃣ Impressive Answer

  1. 热部署核心原理——双类加载器机制
  2. Base ClassLoader:加载不会变化的类(第三方 jar、Spring 框架本身)。
  3. Restart ClassLoader:加载开发者自己的类(classpath 下的 class 文件)。
  4. 文件变化时,只丢弃并重建 Restart ClassLoaderBase ClassLoader 保留,因此比完整重启快得多。

  5. 触发机制

  6. 监听 classpath 下的文件变化(IDE 编译后触发)。
  7. 默认排除静态资源(/static/public/templates)的变化,避免频繁重启。

  8. LiveReload 功能:DevTools 内置 LiveReload 服务器(端口 35729),浏览器安装插件后,静态资源变化时自动刷新页面(不重启 JVM)。

  9. 生产环境禁用的原因

  10. 性能开销:文件监听器持续消耗 CPU 和 IO。
  11. 类加载器隔离问题:双类加载器可能导致 ClassCastException(同一个类被两个 ClassLoader 加载,类型不兼容)。
  12. 安全风险:LiveReload 服务器暴露额外端口。
  13. 自动禁用机制:DevTools 检测到 java -jar 启动时自动禁用(通过检查 jar 包中是否有 BOOT-INF 目录判断)。

  14. 配置排除

spring:
  devtools:
    restart:
      exclude: static/**,public/**,templates/**
      additional-paths: src/main/java  # 额外监听路径
    livereload:
      enabled: false  # 禁用 LiveReload

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了"自动重启" 双类加载器原理 + LiveReload + 禁用原因
技术深度 不知道双类加载器 清楚 Restart ClassLoader 的设计
实践经验 不知道 ClassCastException 风险 知道类加载器隔离导致的类型不兼容问题
面试官印象 会用 DevTools 理解热部署的底层机制和风险

3、容易一起考的题

关联题 和本题的关系
JVM 类加载器的双亲委派模型? DevTools 热部署打破双亲委派的原理
Spring Boot 懒加载如何配置? 启动优化的另一种手段
GraalVM 原生镜像 vs JVM 启动优化? 不同场景下的启动优化选型

3.10 @Import 机制原理

1、基础题:@Import 注解有哪几种用法?

难度级别:⭐⭐(@Import、ImportSelector、ImportBeanDefinitionRegistrar)

1️⃣ Common Answer

@Import 可以导入一个配置类,相当于把那个类里的 Bean 都注册进来。Spring Boot 的自动装配就是用 @Import 实现的。

2️⃣ Impressive Answer

@Import 有三种用法,复杂度递增:

  1. 直接导入配置类:最简单,等价于在当前配置类中声明那个类。
@Configuration
@Import(DataSourceConfig.class)  // 直接导入另一个配置类
public class AppConfig {}
  1. ImportSelector:动态决定导入哪些类,返回类名数组。@EnableAutoConfiguration 就是这种方式。
public class LLMProviderSelector implements ImportSelector {

    @Override
    public String[] selectImports(AnnotationMetadata importingClassMetadata) {
        // 根据注解元数据动态决定导入哪个实现类
        Map<String, Object> attrs = importingClassMetadata
            .getAnnotationAttributes(EnableLLM.class.getName());
        String provider = (String) attrs.get("provider");

        return switch (provider) {
            case "openai" -> new String[]{OpenAIConfig.class.getName()};
            case "claude" -> new String[]{ClaudeConfig.class.getName()};
            default -> new String[]{DefaultLLMConfig.class.getName()};
        };
    }
}

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(LLMProviderSelector.class)
public @interface EnableLLM {
    String provider() default "openai";
}
  1. ImportBeanDefinitionRegistrar:最灵活,可以直接操作 BeanDefinitionRegistry,动态注册任意 BeanDefinition(MyBatis 的 @MapperScan 就是这种方式)。
public class ToolRegistrar implements ImportBeanDefinitionRegistrar {

    @Override
    public void registerBeanDefinitions(AnnotationMetadata metadata,
                                        BeanDefinitionRegistry registry) {
        // 扫描所有 @Tool 注解的类,动态注册为 Bean
        ClassPathScanningCandidateComponentProvider scanner =
            new ClassPathScanningCandidateComponentProvider(false);
        scanner.addIncludeFilter(new AnnotationTypeFilter(Tool.class));

        for (BeanDefinition bd : scanner.findCandidateComponents("com.example.tools")) {
            registry.registerBeanDefinition(bd.getBeanClassName(), bd);
        }
    }
}

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了"导入配置类" 三种用法递进,覆盖静态/动态/注册三个层次
技术深度 不知道 ImportSelector 清楚 @EnableAutoConfiguration 的实现原理
实践经验 没有自定义 Selector 有 LLM Provider 动态选择的完整实现
面试官印象 知道 @Import 能导入 理解 Spring 扩展机制的完整层次

2、进阶题:DeferredImportSelector 和 ImportSelector 有什么区别?为什么自动装配要用 DeferredImportSelector?

难度级别:⭐⭐⭐(延迟导入、处理顺序、用户配置优先)

1️⃣ Common Answer

DeferredImportSelector 是延迟执行的 ImportSelector,会在所有配置类处理完之后才执行。这样可以保证用户自己的配置先生效。

2️⃣ Impressive Answer

  1. 执行时机对比
特性 ImportSelector DeferredImportSelector
执行时机 当前 @Configuration 类处理时立即执行 所有 @Configuration 类处理完后延迟执行
分组支持 不支持 支持 getImportGroup(),同组的 Selector 合并处理
典型用途 @Enable* 注解的简单导入 Spring Boot 自动装配
  1. 为什么自动装配必须用 DeferredImportSelector
用户配置类(@ComponentScan 扫描)
    ↓ 先处理
用户的 @Bean DataSource 注册到容器
DeferredImportSelector 延迟执行
DataSourceAutoConfiguration 处理
@ConditionalOnMissingBean(DataSource.class) → 检测到已有 DataSource → 跳过

如果用 ImportSelector(立即执行),自动配置类会在用户配置类之前处理,@ConditionalOnMissingBean 检测不到用户的 Bean,导致自动配置的 Bean 和用户的 Bean 冲突。

  1. 分组机制(Group)DeferredImportSelector.Group 允许多个 Selector 的结果合并排序,AutoConfigurationImportSelector 实现了 AutoConfigurationGroup,负责对所有自动配置类按 @AutoConfigureOrder@AutoConfigureBefore/After 排序。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了"延迟执行" 执行时机对比 + 原因分析 + 分组机制
技术深度 不知道为什么要延迟 清楚延迟是保证用户配置优先的核心手段
实践经验 不知道 Group 机制 知道自动配置类的排序是通过 Group 实现的
面试官印象 知道结论 理解设计背后的工程权衡

3、容易一起考的题

关联题 和本题的关系
Spring Boot 自动装配的完整链路? @Import + DeferredImportSelector 是自动装配的核心
@Enable* 注解的设计模式? 模块化开关的设计范式,底层都是 @Import
MyBatis 的 @MapperScan 原理? ImportBeanDefinitionRegistrar 的典型应用

3.11 Spring Boot SPI 机制原理

1、基础题:Spring Boot 的 SPI 机制是什么?spring.factories 是如何被加载的?

难度级别:⭐⭐⭐(SPI、SpringFactoriesLoader、类加载机制)

1️⃣ Common Answer

spring.factories 是一个配置文件,里面写了要自动加载的类名。Spring Boot 启动时会读取这个文件,然后把里面的类加载进来。这是一种 SPI 机制。

2️⃣ Impressive Answer

  1. SPI 机制本质:SPI(Service Provider Interface)是一种"接口在框架侧定义,实现在使用方提供"的扩展机制。Spring Boot 的 SPI 比 Java 原生 SPI(ServiceLoader)更强大,支持按接口类型分组加载。

  2. SpringFactoriesLoader 加载流程:

SpringFactoriesLoader.loadFactoryNames(EnableAutoConfiguration.class, classLoader)
扫描所有 jar 包中的 META-INF/spring.factories
解析 Properties 文件,按 key(接口全限定名)分组
返回对应 key 下的所有实现类名列表
通过反射实例化(loadInstantiatedFactories)
  1. 缓存机制SpringFactoriesLoader 内部有 cacheMap<ClassLoader, MultiValueMap<String, String>>),同一个 ClassLoader 只解析一次,后续从缓存读取,避免重复 IO。

  2. Boot 3.x 的改进

# Boot 2.x:spring.factories(所有 key 都在一个文件,全量加载)
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.example.FooAutoConfiguration,\
  com.example.BarAutoConfiguration

# Boot 3.x:独立文件(按需加载,只加载 AutoConfiguration)
# META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
com.example.FooAutoConfiguration
com.example.BarAutoConfiguration

Boot 3.x 的改进:不再把所有扩展点放在一个文件,避免加载不需要的扩展点,启动更快。

  1. spring.factories 支持的扩展点类型
扩展点接口 作用
EnableAutoConfiguration 自动配置类注册
ApplicationContextInitializer 容器初始化扩展
ApplicationListener 应用事件监听
SpringApplicationRunListener 启动流程监听
EnvironmentPostProcessor 环境后处理
FailureAnalyzer 启动失败分析

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了"读取文件加载类" 加载流程 + 缓存机制 + Boot 3.x 改进 + 扩展点类型
技术深度 不知道缓存机制 清楚 ClassLoader 级别的缓存设计
实践经验 不知道 Boot 3.x 的变化 知道独立文件的性能优化原因
面试官印象 知道 spring.factories 理解 SPI 机制的完整设计

2、进阶题:如何利用 spring.factories 实现自己的扩展点?EnvironmentPostProcessor 的使用场景?

难度级别:⭐⭐⭐(EnvironmentPostProcessor、自定义扩展点、配置加密)

1️⃣ Common Answer

在 spring.factories 里注册自己的类,实现对应的接口就可以了。EnvironmentPostProcessor 可以在 Environment 准备好之后修改配置。

2️⃣ Impressive Answer

  1. EnvironmentPostProcessor 典型场景——配置解密
public class EncryptedPropertyDecryptor implements EnvironmentPostProcessor {

    private static final String ENCRYPTED_PREFIX = "ENC(";

    @Override
    public void postProcessEnvironment(ConfigurableEnvironment environment,
                                       SpringApplication application) {
        MutablePropertySources sources = environment.getPropertySources();

        // 遍历所有 PropertySource,解密 ENC(...) 格式的配置值
        Map<String, Object> decryptedProperties = new HashMap<>();
        for (PropertySource<?> source : sources) {
            if (source instanceof MapPropertySource mapSource) {
                mapSource.getSource().forEach((key, value) -> {
                    if (value instanceof String strValue
                            && strValue.startsWith(ENCRYPTED_PREFIX)) {
                        String encrypted = strValue.substring(4, strValue.length() - 1);
                        decryptedProperties.put(key, decrypt(encrypted));
                    }
                });
            }
        }

        if (!decryptedProperties.isEmpty()) {
            // 插入到最高优先级,覆盖原始加密值
            sources.addFirst(new MapPropertySource("decrypted", decryptedProperties));
        }
    }

    private String decrypt(String encrypted) {
        // 调用 KMS 或本地密钥解密
        return AESUtil.decrypt(encrypted, getSecretKey());
    }
}
  1. 注册方式
# META-INF/spring.factories(Boot 2.x)
org.springframework.boot.env.EnvironmentPostProcessor=\
  com.example.EncryptedPropertyDecryptor

# META-INF/spring/org.springframework.boot.env.EnvironmentPostProcessor.imports(Boot 3.x)
com.example.EncryptedPropertyDecryptor
  1. 执行时机EnvironmentPostProcessorApplicationEnvironmentPreparedEvent 发布后执行,此时 application.yml 已加载,但 Spring 容器还未创建,不能注入 Bean

  2. Agent 场景:LLM API Key 在配置文件中加密存储(ENC(xxx)),通过 EnvironmentPostProcessor 在启动时解密,避免明文存储敏感信息。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了"实现接口注册" 完整实现 + 注册方式 + 执行时机限制
技术深度 不知道不能注入 Bean 清楚容器未创建时的限制
实践经验 没有具体场景 有配置解密的生产级实现
面试官印象 知道扩展点存在 有安全配置的工程实践

3、容易一起考的题

关联题 和本题的关系
Java 原生 SPI(ServiceLoader)和 Spring SPI 的区别? SPI 机制的对比,Spring SPI 更灵活
Spring Boot 自动装配的完整链路? SPI 是自动装配的基础加载机制
配置中心(Nacos)如何集成到 Spring Boot? EnvironmentPostProcessor 的典型应用场景

3.12 refreshContext 核心原理

1、基础题:Spring 的 refresh() 方法做了哪些事?Bean 的生命周期是什么?

难度级别:⭐⭐⭐(refresh 流程、BeanPostProcessor、Bean 生命周期)

1️⃣ Common Answer

refresh() 是 Spring 容器的核心方法,会扫描 Bean、实例化 Bean、注入依赖。Bean 的生命周期包括实例化、属性注入、初始化、使用、销毁几个阶段。

2️⃣ Impressive Answer

  1. refresh() 12 个核心步骤:
prepareRefresh()              → 设置启动时间、激活标志、初始化属性源
obtainFreshBeanFactory()      → 创建/刷新 BeanFactory,加载 BeanDefinition
prepareBeanFactory()          → 配置 BeanFactory(ClassLoader、BeanPostProcessor、内置 Bean)
postProcessBeanFactory()      → 子类扩展点(Web 容器注册 Servlet 相关 Bean)
invokeBeanFactoryPostProcessors() → 执行 BeanFactoryPostProcessor(@Configuration 解析、@ComponentScan 扫描)
registerBeanPostProcessors()  → 注册 BeanPostProcessor(AOP、@Autowired 注入等)
initMessageSource()           → 初始化国际化
initApplicationEventMulticaster() → 初始化事件广播器
onRefresh()                   → 子类扩展(Web 容器:创建并启动内嵌 Tomcat)
registerListeners()           → 注册 ApplicationListener
finishBeanFactoryInitialization() → 实例化所有非懒加载的单例 Bean(最耗时)
finishRefresh()               → 发布 ContextRefreshedEvent,启动 Lifecycle Bean
  1. Bean 完整生命周期
实例化(Constructor)
属性注入(@Autowired、@Value)
Aware 接口回调(BeanNameAware、ApplicationContextAware 等)
BeanPostProcessor.postProcessBeforeInitialization()  ← AOP 代理在这里创建
@PostConstruct / InitializingBean.afterPropertiesSet() / init-method
BeanPostProcessor.postProcessAfterInitialization()
Bean 就绪,放入单例缓存(singletonObjects)
使用阶段
@PreDestroy / DisposableBean.destroy() / destroy-method
  1. 最关键的两个步骤
  2. invokeBeanFactoryPostProcessorsConfigurationClassPostProcessor 在这里解析 @Configuration@ComponentScan@Import@Bean,注册所有 BeanDefinition。
  3. finishBeanFactoryInitialization:遍历所有非懒加载单例 BeanDefinition,依次实例化,这是启动最耗时的阶段。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了"扫描、实例化、注入" 12 步完整流程 + Bean 生命周期完整链路
技术深度 不知道 BeanFactoryPostProcessor 清楚 ConfigurationClassPostProcessor 的核心作用
实践经验 不知道哪步最耗时 知道 finishBeanFactoryInitialization 是性能瓶颈
面试官印象 背过生命周期 理解 refresh 流程的完整机制

2、进阶题:BeanPostProcessor 和 BeanFactoryPostProcessor 有什么区别?各自的典型应用是什么?

难度级别:⭐⭐⭐(后处理器、AOP 代理、@Autowired 注入原理)

1️⃣ Common Answer

BeanPostProcessor 是 Bean 初始化前后的扩展点,BeanFactoryPostProcessor 是 BeanFactory 级别的扩展点。AOP 就是通过 BeanPostProcessor 实现的。

2️⃣ Impressive Answer

  1. 核心区别
维度 BeanFactoryPostProcessor BeanPostProcessor
作用阶段 BeanDefinition 注册完成后,Bean 实例化之前 Bean 实例化之后,初始化前后
操作对象 BeanDefinition(元数据) Bean 实例
典型实现 ConfigurationClassPostProcessor、PropertySourcesPlaceholderConfigurer AutowiredAnnotationBeanPostProcessor、AbstractAutoProxyCreator
执行时机 invokeBeanFactoryPostProcessors() registerBeanPostProcessors() 注册,Bean 初始化时调用
  1. 典型应用分析
BeanFactoryPostProcessor 典型应用:
├── ConfigurationClassPostProcessor
│   └── 解析 @Configuration、@ComponentScan、@Import、@Bean
│       注册所有 BeanDefinition(这是 Spring 最核心的后处理器)
└── PropertySourcesPlaceholderConfigurer
    └── 替换 BeanDefinition 中的 ${...} 占位符

BeanPostProcessor 典型应用:
├── AutowiredAnnotationBeanPostProcessor
│   └── 处理 @Autowired、@Value 注入(postProcessBeforeInitialization)
├── CommonAnnotationBeanPostProcessor
│   └── 处理 @PostConstruct、@PreDestroy、@Resource
└── AbstractAutoProxyCreator(AOP 核心)
    └── postProcessAfterInitialization 中判断是否需要代理
        如果需要,返回 CGLIB/JDK 动态代理对象替换原始 Bean
  1. AOP 代理创建时机AbstractAutoProxyCreator.postProcessAfterInitialization() 中,检查 Bean 是否匹配任何 Advisor(切面),如果匹配则创建代理对象,替换容器中的原始 Bean。这就是为什么 @Transactional 方法在同一个类内部调用不生效——内部调用绕过了代理。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
结构性 只说了"Bean 级别 vs BeanFactory 级别" 完整对比表 + 典型实现树 + AOP 代理时机
技术深度 不知道 ConfigurationClassPostProcessor 清楚 Spring 最核心的后处理器是什么
实践经验 不知道 @Transactional 内部调用失效原因 能从代理创建时机解释这个经典问题
面试官印象 知道扩展点存在 理解 Spring 扩展机制的完整体系

3、容易一起考的题

关联题 和本题的关系
Spring 的三级缓存解决循环依赖? finishBeanFactoryInitialization 阶段的核心问题
@Transactional 为什么同类内部调用失效? BeanPostProcessor 创建代理的机制决定的
Spring AOP 的 CGLIB 和 JDK 动态代理区别? AbstractAutoProxyCreator 选择代理方式的逻辑

3.13 外部化配置加载原理(ConfigData API)

1、基础题:Spring Boot 的配置文件是如何被加载进来的?Boot 2.x 和 Boot 3.x 的加载机制有什么变化?

⭐⭐(ConfigFileApplicationListener、ConfigDataEnvironmentPostProcessor、PropertySource 加载时机)

Answer:

Spring Boot 配置加载的核心机制经历了从 Boot 2.x 到 Boot 3.x 的重大重构,主要体现在从 ConfigFileApplicationListener 到 ConfigData API 的演进。

Boot 2.x 机制:

使用 ConfigFileApplicationListener 监听器,在 ApplicationContextInitializer 阶段加载配置:

  1. 监听器触发:监听 ApplicationEnvironmentPreparedEvent 事件

  2. 搜索配置文件:按优先级搜索 application.properties/yml,支持 file:./config/file:./classpath:/config/classpath:/ 四个位置

  3. PropertySource 加载:将解析后的配置添加到 EnvironmentPropertySource 链中

  4. Profile 激活:根据 spring.profiles.active 激活特定 profile 的配置文件

Boot 3.x 机制:

引入全新的 ConfigData API,使用 ConfigDataEnvironmentPostProcessor 替代监听器模式:

  1. EnvironmentPostProcessor 触发:在 Spring Boot 启动早期执行

  2. ConfigData 接口:定义统一的配置数据抽象,支持多种配置源(文件、环境变量、云配置中心等)

  3. ConfigDataLocationResolver:解析配置位置,返回 ConfigDataResource

  4. ConfigDataLoader:加载具体的配置数据,转换为 PropertySource

  5. Import 链:支持配置文件的嵌套导入(spring.config.import

核心差异对比:

维度 Boot 2.x Boot 3.x
触发方式 事件监听器 EnvironmentPostProcessor
配置抽象 PropertySource ConfigData + PropertySource
扩展性 继承监听器 实现 ConfigDataLocationResolver
导入支持 不支持配置导入 支持 spring.config.import

2、进阶题:Spring Boot 的配置加载优先级完整链路是什么?命令行参数、环境变量、application.yml、application-{profile}.yml 的优先级顺序?

⭐⭐⭐(PropertySource 优先级、Profile 激活时机、配置覆盖规则)

1️⃣ Common Answer

Spring Boot 配置加载优先级从高到低依次为:命令行参数、环境变量、application-{profile}.ymlapplication.yml、默认配置。高优先级的配置会覆盖低优先级的同名配置。Profile 激活后,特定 profile 的配置会覆盖默认配置。

2️⃣ Impressive Answer

Spring Boot 配置加载的优先级链路是一个多层 PropertySource 栈,理解其加载顺序对多环境部署和配置调试至关重要。我从加载时机、优先级规则和实战应用三个维度展开。

一、完整的加载优先级链路(从高到低)

优先级 1:命令行参数(Command Line Args)
  格式:--server.port=8080,PropertySource 名称:commandLineArgs

优先级 2:环境变量(Environment Variables)
  格式:SERVER_PORT=8080(自动转换),支持绑定到嵌套属性

优先级 3:JVM 系统属性(System Properties)
  格式:-Dserver.port=8080

优先级 4:随机数配置(random.*)
  示例:app.id=${random.value}

优先级 5:外部配置文件(Profile 特定)
  file:./config/application-{profile}.yml
  file:./application-{profile}.yml
  classpath:/config/application-{profile}.yml
  classpath:/application-{profile}.yml

优先级 6:外部配置文件(默认)
  file:./config/application.yml → classpath:/application.yml

优先级 7:@PropertySource 注解

优先级 8:默认属性(Default Properties)
  SpringApplication.setDefaultProperties()

二、Profile 激活和配置覆盖规则

Profile 的激活方式(优先级从高到低):

  1. 命令行参数--spring.profiles.active=prod

  2. 环境变量SPRING_PROFILES_ACTIVE=prod

  3. 配置文件spring.profiles.active=prod

覆盖规则:Profile 特定配置覆盖默认配置的同名属性,未定义的属性继承默认值,命令行参数始终最高优先级。

三、Agent 服务场景实战

# application.yml(基础配置)
agent:
  model: "gpt-4"
  temperature: 0.7

# application-prod.yml(生产环境覆盖)
agent:
  timeout: 30000
  maxConcurrent: 10

# 启动命令(最高优先级,紧急调整)
java -jar agent-service.jar --spring.profiles.active=prod --agent.timeout=60000

调试技巧:通过 --debug 启动参数,Spring Boot 会打印完整的 ConditionEvaluationReport 和 PropertySource 栈。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
优先级链路深度 仅列出 4-5 个层级 完整列出 8 个层级,包括随机数、@PropertySource
加载时机 未提及或模糊 明确说明在 SpringApplication.run() 极早期完成
Profile 激活方式 仅提及配置文件 列出 3 种激活方式及其优先级
实战场景 缺少具体案例 结合 Agent 服务场景,给出多环境配置示例
调试技巧 提供 --debug 启动参数调试方法

3.14 Profiles 机制

1、基础题:Spring Boot 的 @Profile 和 spring.profiles.active 是如何工作的?Profile Groups 是什么?

⭐⭐(@Profile 注解、spring.profiles.active、spring.profiles.include、Profile Groups)

Answer:

  1. @Profile 注解的工作原理

@Profile 可以标注在 @Configuration@Component@Bean 方法上,只有当指定的 Profile 处于激活状态时,对应的 Bean 才会被注册到容器中。底层通过 @Conditional(ProfileCondition.class) 实现。

@Configuration
@Profile("dev")
public class DevConfig {
    @Bean
    public LlmClient devLlmClient() {
        return new OpenAiLlmClient("gpt-3.5-turbo"); // 开发环境用低成本模型
    }
}

@Configuration
@Profile("prod")
public class ProdConfig {
    @Bean
    public LlmClient prodLlmClient() {
        return new OpenAiLlmClient("gpt-4"); // 生产环境用高质量模型
    }
}
  1. Profile 激活方式(优先级从高到低)
# 命令行参数(最高优先级)
java -jar app.jar --spring.profiles.active=prod

# 环境变量
export SPRING_PROFILES_ACTIVE=prod

# 配置文件
spring.profiles.active: dev

# 代码编程式激活
SpringApplication app = new SpringApplication(MyApplication.class);
app.setAdditionalProfiles("dev");
  1. spring.profiles.include

spring.profiles.include 用于无条件激活指定的 Profile,适用于所有环境都需要的基础配置:

spring:
  profiles:
    active: dev
    include: common,logging  # 无论激活哪个环境,都会加载这两个 Profile
  1. Profile Groups(Boot 2.4+)

Profile Groups 允许将多个 Profile 组合成一个逻辑组,简化配置管理:

spring:
  profiles:
    group:
      prod: "prod,db-prod,cache-prod"
      dev: "dev,db-dev,cache-dev"

激活 prod 时,会同时激活 proddb-prodcache-prod 三个 Profile,避免手动激活多个 Profile。


2、进阶题:多环境配置管理的最佳实践是什么?如何在 Agent 服务中实现 dev/test/prod 三套环境的配置隔离?

⭐⭐⭐(application-{profile}.yml 加载机制、Profile 激活方式、配置中心集成)

1️⃣ Common Answer

多环境配置管理主要通过 application-{profile}.yml 文件实现。创建 application-dev.ymlapplication-test.ymlapplication-prod.yml,通过 spring.profiles.active 激活对应环境的配置文件。在 Agent 服务中,dev 环境使用低成本 LLM 模型,test 环境使用测试模型,prod 环境使用生产模型。

2️⃣ Impressive Answer

多环境配置管理的最佳实践需要从配置分层、环境隔离、配置中心集成、敏感信息保护四个维度系统设计。

第一,采用配置分层架构,避免重复配置。

基础配置放在 application.yml,所有环境共用;环境特定配置放在 application-{profile}.yml,只覆盖差异化部分。结合 Profile Groups 管理多组件配置:

# application.yml - 基础配置
spring:
  application:
    name: agent-service
  profiles:
    group:
      dev: "dev,llm-dev,vector-dev"
      prod: "prod,llm-prod,vector-prod"

agent:
  max-concurrent-tasks: 100
  timeout-seconds: 300
# application-dev.yml - 开发环境差异配置
agent:
  llm:
    model: gpt-4o-mini    # 降低成本
    api-key: ${LLM_API_KEY_DEV}
  vector:
    host: localhost
    collection: agent_dev
logging:
  level:
    com.example.agent: DEBUG
# application-prod.yml - 生产环境差异配置
agent:
  llm:
    model: qwen-max       # 高质量模型
    api-key: ${LLM_API_KEY_PROD}
  vector:
    host: milvus-prod.internal
    collection: agent_prod

第二,容器化部署使用环境变量激活 Profile。

# Kubernetes ConfigMap
apiVersion: v1
kind: ConfigMap
data:
  SPRING_PROFILES_ACTIVE: "prod"

第三,集成配置中心实现动态配置。

对于 Agent 服务这类需要频繁调整参数的场景(如 LLM 温度、最大 Token 数),集成 Nacos 配置中心,通过 @RefreshScope 实现热更新,无需重启服务:

@RestController
@RefreshScope
public class AgentController {
    @Value("${agent.llm.temperature}")
    private double temperature;
}

第四,敏感信息使用密钥管理服务。

LLM API Key、向量数据库密码等敏感信息绝不写入配置文件,通过环境变量或 Kubernetes Secret 注入:

agent:
  llm:
    api-key: ${LLM_API_KEY}  # 从环境变量读取,不硬编码

3️⃣ Key Differences

维度 Common Answer Impressive Answer
配置分层 简单的文件分离 基础配置 + 环境特定 + Profile Groups 三层架构
激活方式 仅提到配置文件激活 环境变量优先,适配容器化部署场景
动态配置 未提及 集成 Nacos,通过 @RefreshScope 实现热更新
敏感信息 直接写在配置文件中 使用密钥管理服务,通过环境变量注入
Agent 场景 简单的环境区分 针对多组件(LLM、向量库)的 Profile Groups 设计

3.15 多模块 Starter 工程实践

1、基础题:自定义 Spring Boot Starter 的标准目录结构和命名规范是什么?spring-boot-autoconfigure 和 spring-boot-starter 模块如何分工?

⭐⭐(Starter 命名规范、autoconfigure 模块职责、AutoConfiguration.imports 注册)

Answer:

Spring Boot Starter 的标准命名规范为 xxx-spring-boot-starter(如 llm-client-spring-boot-starter),官方 Starter 则以 spring-boot-starter-xxx 命名。

标准多模块结构:

llm-client-spring-boot-starter/
├── llm-client-spring-boot-autoconfigure/   # 自动配置模块
│   ├── src/main/java/
│   │   └── com/example/llm/autoconfigure/
│   │       ├── LlmClientAutoConfiguration.java
│   │       └── LlmClientProperties.java
│   └── src/main/resources/META-INF/spring/
│       └── org.springframework.boot.autoconfigure.AutoConfiguration.imports
└── llm-client-spring-boot-starter/         # 启动器模块(仅 pom.xml)
    └── pom.xml

模块分工:

  • autoconfigure 模块:包含所有自动配置逻辑(@Configuration 类)、配置属性类(@ConfigurationProperties)、核心功能组件,通过 AutoConfiguration.imports(Boot 2.7+)或 spring.factories(旧版本)注册

  • starter 模块:仅包含 pom.xml,声明对 autoconfigure 模块和实际依赖库的依赖,提供"一个依赖引入所有功能"的体验

注册方式:

# META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports(Boot 2.7+)
com.example.llm.autoconfigure.LlmClientAutoConfiguration

2、进阶题:如何设计一个生产级的 LLM Client Starter?需要考虑哪些工程实践问题?

⭐⭐⭐(条件注解、@ConfigurationProperties、HealthIndicator、MeterRegistry、优雅降级)

1️⃣ Common Answer

设计 LLM Client Starter 主要考虑:使用 @ConditionalOnMissingBean 实现自动配置、@ConfigurationProperties 绑定 API Key 和模型名称、实现 HealthIndicator 检查连通性、用 MeterRegistry 记录请求耗时。

@Configuration
@ConditionalOnClass(LlmClient.class)
@EnableConfigurationProperties(LlmClientProperties.class)
public class LlmClientAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    public LlmClient llmClient(LlmClientProperties properties) {
        return new OpenAiLlmClient(properties);
    }
}

2️⃣ Impressive Answer

设计生产级 LLM Client Starter 需要从稳定性、可观测性、可扩展性三个维度系统考虑,我从以下四个方面展开。

第一,智能化的条件装配与配置管理。

使用多级条件注解实现灵活装配,配置属性类加 JSR-303 校验:

@Configuration
@ConditionalOnClass({LlmClient.class, OpenAiApi.class})
@EnableConfigurationProperties(LlmClientProperties.class)
@AutoConfigureBefore(WebMvcAutoConfiguration.class)
public class LlmClientAutoConfiguration {

    @Bean
    @ConditionalOnMissingBean
    @ConditionalOnProperty(prefix = "llm.client", name = "enabled", havingValue = "true", matchIfMissing = true)
    public LlmClient llmClient(LlmClientProperties properties,
                               ObjectProvider<List<LlmClientInterceptor>> interceptorsProvider) {
        OpenAiLlmClient client = new OpenAiLlmClient(properties);
        client.setInterceptors(interceptorsProvider.getIfAvailable());
        return client;
    }
}
@ConfigurationProperties(prefix = "llm.client")
@Validated
public class LlmClientProperties {

    @NotBlank
    private String apiKey;

    @NotBlank
    private String model = "gpt-3.5-turbo";

    @Min(1000) @Max(120000)
    private Duration timeout = Duration.ofSeconds(30);

    @Min(1) @Max(10)
    private Integer maxRetries = 3;
}

第二,可观测性建设(健康检查 + 指标)。

实现深度健康检查,不仅检测连通性,还检测配额和限流状态:

public class LlmClientHealthIndicator implements HealthIndicator {

    @Override
    public Health health() {
        try {
            LlmModelsResponse response = client.listModels();
            return Health.up()
                .withDetail("model", properties.getModel())
                .withDetail("quota", response.getQuota())
                .build();
        } catch (RateLimitException e) {
            return Health.down()
                .withDetail("error", "Rate limit exceeded")
                .withDetail("retryAfter", e.getRetryAfter())
                .build();
        }
    }
}

指标暴露涵盖请求耗时、Token 消耗、成功率:

Timer.builder("llm.client.request.duration")
    .tag("model", client.getModel())
    .register(registry);

Counter.builder("llm.client.tokens.used")
    .tag("type", "prompt")
    .register(registry);

第三,优雅降级与容错机制。

使用 Resilience4j 实现熔断、重试,当熔断器打开时返回缓存结果:

@Bean
public LlmClient resilientLlmClient(LlmClient delegate, LlmClientProperties properties) {
    CircuitBreakerConfig config = CircuitBreakerConfig.custom()
        .failureRateThreshold(50)
        .waitDurationInOpenState(Duration.ofSeconds(30))
        .build();

    RetryConfig retryConfig = RetryConfig.custom()
        .maxAttempts(properties.getMaxRetries())
        .retryOnException(e -> e instanceof TimeoutException || e instanceof RateLimitException)
        .build();

    return new ResilientLlmClient(delegate,
        CircuitBreaker.of("llmClient", config),
        Retry.of("llmClient", retryConfig));
}

第四,扩展性设计(拦截器机制)。

提供拦截器 SPI,支持自定义请求/响应处理(日志、签名、脱敏等):

public interface LlmClientInterceptor {
    default LlmRequest beforeRequest(LlmRequest request) { return request; }
    default LlmResponse afterResponse(LlmRequest request, LlmResponse response) { return response; }
    default void onError(LlmRequest request, Exception e) {}
}

总结: 生产级 Starter 设计的核心是开箱即用生产就绪的平衡。通过条件装配实现按需加载,通过健康检查和指标实现可观测性,通过熔断降级保障稳定性,通过拦截器机制提供扩展性。

3️⃣ Key Differences

维度 Common Answer Impressive Answer
条件装配 仅用 @ConditionalOnMissingBean 多级条件注解,避免不必要的 Bean 创建
配置管理 简单的属性绑定 JSR-303 校验 + spring-configuration-metadata.json IDE 提示
健康检查 仅检测连通性 深度检查:连通性 + 配额 + 限流状态
指标暴露 仅记录请求耗时 多维度:耗时、Token 消耗、成功率,支持 Prometheus 集成
容错机制 简单异常处理 Resilience4j 熔断、降级、重试,提供缓存降级策略
扩展性 无扩展机制 拦截器 SPI,支持日志、签名、脱敏等自定义处理