从零搭建 AI Agent 调度中台(一):底座搭建——SpringCloud Alibaba + 9 个中间件串联

第 2 / 14 章
从零搭建 AI Agent 调度中台(一):底座搭建——SpringCloud Alibaba + 9 个中间件串联

一、先回答一个问题:为什么是 8 个微服务,不是单体?

这一章讲底座——从父 pom 到 Docker Compose,把 8 个微服务和 9 个中间件串起来。但在动手之前,必须先回答一个面试官必问的问题:

为什么是微服务,不是单体?

三个理由,每个都有代码证据:

理由一:部署隔离。 Agent-Core 和 RAG-Knowledge 可能需要分别部署在不同机房(合规场景下向量存储和业务逻辑物理隔离)。如果是单体,你要么全量部署,要么拆分模块做"伪微服务"——但那比真微服务还难维护。我们的拆分标准很简单:每个服务有独立数据库表、独立业务边界、能独立启动和独立发布。你看下面这张表就明白了:

服务端口独立表核心边界
aap-gateway8080无(纯网关)统一入口、鉴权、限流、路由
aap-sys-auth8081sys_tenant / sys_user / sys_role认证、权限、租户
aap-agent-core8082agent_info / agent_sessionAgent 配置、对话调度
aap-model-gateway8083model_route_config模型路由、Token 统计
aap-rag-knowledge8084knowledge_base / knowledge_file / knowledge_chunkRAG 知识库
aap-agent-workflow8085workflow_def / workflow_instanceDAG 工作流定义与执行
aap-agent-tool-plugin8086tool_info工具插件
aap-task-job8087task_record异步任务、定时任务

8 个服务,每个一个端口,每个一套独立表——这不是过度拆分。

理由二:AI 层用 Python 的灵活性。 Function Calling 深循环、LangGraph 图编排、A2A 协议——这些 AI 原生能力,Python 写起来比 Java 自然得多。Java 做它擅长的(鉴权、CRUD、事务、微服务治理),Python 做它擅长的(LLM 交互、Agent 编排),两边通过 Feign + HTTP 回调打通。ch05 会专门讲双栈架构。

Python 灵活性展示

理由三:AI 辅助生成的天然分层。 还记得上一章说的吗——AI 生成的骨架在"按模块填内容"这件事上做得很好。8 个子模块 pom.xml 每个都是 AI 生成、我微调版本号。如果是单体,AI 生成的代码结构会乱得一塌糊涂(所有 Controller 塞一个包),我还得手动拆。微服务架构反而让 AI 的输出更规整。

二、父 pom:版本号的那些坑

父 pom 就干一件事:统一版本管理。看关键片段:

<!-- 父 pom dependencyManagement 核心版本 -->
<spring-boot.version>3.2.5</spring-boot.version>
<spring-cloud.version>2023.0.1</spring-cloud.version>
<spring-cloud-alibaba.version>2023.0.1.2</spring-cloud-alibaba.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
<milvus-sdk.version>2.4.11</milvus-sdk.version>
<elasticsearch-java.version>8.15.3</elasticsearch-java.version>

版本号微调了 AI 生成的两个地方:

坑点一:Sentinel 从 starter-gateway 改成 adapter。

AI 生成的 pom 里写的是:

<!-- 这是 SCA 2022 时代的旧写法,2023 已废弃 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>

SCA 2023 版本做了重构——Sentinel 和 Spring Cloud Gateway 的适配从 starter 模式改成了原生 adapter 模式。正确写法:

<dependency>
    <groupId>com.alibaba.csp</groupId>
    <artifactId>sentinel-spring-cloud-gateway-adapter</artifactId>
    <version>${sentinel.version}</version>  <!-- 1.8.8 -->
</dependency>

为什么要改? starter 模式会自动把 Sentinel 的 FlowRuleManager 初始化、注册全局 Filter——但 Gateway 是 WebFlux 反应式框架,starter 里的 Filter 是 Servlet 版的,启动直接报 NoSuchMethodError。adapter 模式让你手动配置 WebFlux 版的限流处理器,这才是正确姿势。

坑点二:MyBatis-Plus 用 Spring Boot 3 专用 starter。

AI 一开始写的是老版本的 mybatis-plus-boot-starter,SB3 必须换:

<artifactId>mybatis-plus-spring-boot3-starter</artifactId>

MyBatis-Plus 3.5.3 起把 Jakarta 适配放进了 SB3 专用 starter,老 starter 还是 javax 命名空间,会和 SB3 的 jakarta.servlet 冲突。

三、Gateway:WebFlux vs Servlet 的坑

Gateway 是 Spring Cloud Gateway,它基于 WebFlux 反应式栈。这带来一个致命坑:不能扫 aap-common 包。

看启动类:

@SpringBootApplication(scanBasePackages = "com.asiainfo.aap.gateway")
@EnableDiscoveryClient
public class AapGatewayApplication { ... }

为什么只扫 gateway 包?因为 aap-common 里有:

// SentinelConfig.java —— Servlet 版限流处理器
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
@ConditionalOnClass(BlockExceptionHandler.class)
public class SentinelConfig { ... }

虽然有 @ConditionalOnWebApplication 条件守卫,但 AI 生成的时候没加这个守卫——最初 AI 写的 SentinelConfig 没有 @ConditionalOnWebApplication,Gateway 启动时把它当成普通 Bean 扫进来,然后因为 WebFlux 没有 javax.servlet.http.HttpServletRequest 直接 NoClassDefFoundError。

修复方案:两层防护。

第一层,SentinelConfig 加条件守卫:

@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)

第二层,Gateway 启动类只扫自己包:

@SpringBootApplication(scanBasePackages = "com.asiainfo.aap.gateway")

Gateway 自己的 Sentinel 配置用 WebFlux 专属版本(GatewaySentinelConfig.java):

@Bean
public BlockRequestHandler blockRequestHandler() {
    return (exchange, throwable) -> ServerResponse.ok()
            .contentType(MediaType.APPLICATION_JSON)
            .bodyValue(JSON.toJSONString(Result.failed(ResultCode.REQUEST_TOO_MANY)));
}

Sentinel 1.8.8 的 adapter 包提供了 com.alibaba.csp.sentinel.adapter.gateway.sc.callback.BlockRequestHandler——这是 WebFlux 版的,返回 Mono<ServerResponse>,和 Servlet 版返回 void 并直接写 HttpServletResponse 的 BlockExceptionHandler 完全不同。

四、Nacos:namespace + group 的隔离艺术

所有微服务的 application.yml 里都有这三行:

spring:
  cloud:
    nacos:
      discovery:
        server-addr: ${NACOS_ADDR:127.0.0.1:8848}
        namespace: ${NACOS_NAMESPACE:aap}
        group: AAP_GROUP
      config:
        server-addr: ${NACOS_ADDR:127.0.0.1:8848}
        namespace: ${NACOS_NAMESPACE:aap}
        group: AAP_GROUP

namespace 是租户级隔离,group 是项目级隔离。 我们用 namespace=aap 把整个 AAP 项目的注册/配置和 Nacos 里可能存在的其他项目隔开;用 group=AAP_GROUP 把本项目的配置归到一个组里,方便 Nacos 控制台批量管理。

公共配置的加载: 每个服务的 yml 最后都有一行:

spring:
  config:
    import:
      - "optional:nacos:aap-common.yaml?group=AAP_GROUP"

这是 Spring Boot 3 引入的配置导入机制——所有服务统一从 Nacos 拉 aap-common.yaml,里面放跨服务共享的配置:Redis 地址、ES 地址、Milvus 地址、RabbitMQ 地址、XXL-Job admin 地址。如果某个中间件地址改了,只改 Nacos 上这一个文件,所有服务热更新。

optional: 前缀的意思是——Nacos 没启的时候不报错,走本地默认值。这对开发调试特别友好:你不想起 Nacos,直接本地跑也行。

五、Gateway 路由:StripPrefix + /api 前缀的约定

Gateway 的 routes 配置:

spring:
  cloud:
    gateway:
      routes:
        - id: aap-agent-core
          uri: lb://aap-agent-core
          predicates: [ Path=/api/agent/** ]
          filters: [ StripPrefix=1 ]

StripPrefix=1 是什么意思?用户请求 /api/agent/chat → Gateway 转发到 aap-agent-core 服务的 /agent/chat 路径。

为什么前端调 /api/agent/* 而不是直接调 lb://aap-agent-core/agent/*? 因为:

  1. 前端只知道 Gateway 一个地址,不需要关心后端有多少个服务;
  2. Gateway 可以统一做鉴权、限流、跨域,每个微服务不用自己配;
  3. 如果以后某个服务内部路径变了,改 Gateway 路由就行,前端不用动。

六、Feign:为什么内部调用不走 Gateway?

看 AgentCoreFeignClient:

@FeignClient(name = "aap-agent-core", fallbackFactory = AgentCoreFeignFallback.class)
public interface AgentCoreFeignClient {

    @PostMapping("/agent/internal/chat")
    Result<ChatResponse> chat(@RequestBody ChatRequest request);

    @GetMapping("/agent/internal/info/{agentId}")
    Result<Map<String, Object>> getAgentInfo(@PathVariable("agentId") Long agentId);
}

注意两个关键点:

第一,路径不带 /api 前缀。 Feign 用 name = "aap-agent-core" + 路径 /agent/internal/chat,Nacos 服务发现直连目标服务,不经过 Gateway。所以 Feign 调的是 lb://aap-agent-core/agent/internal/chat,而不是 lb://aap-gateway/api/agent/internal/chat。

第二,内部端点用 /internal/* 路径。 Agent-Core 暴露了 /agent/internal/chat 这种端点,它信任调用方在 body 里透传的 tenantId/userId,不走 Gateway 鉴权逻辑——因为调用方都是微服务自己,租户上下文在 Feign 调用链里已经透传了。

七、InternalPathAuthFilter:防谁?

既然 Feign 直连不走 Gateway,那有人会问:如果我作为外部攻击者,从 Gateway 直接访问 /api/agent/internal/chat 怎么办?

好问题,这就是 InternalPathAuthFilter 存在的理由。

@Component
public class InternalPathAuthFilter implements GlobalFilter, Ordered {

    private static final String INTERNAL_PATH_PATTERN = "/**/internal/**";

    @Override
    public int getOrder() {
        return -200;  // 放在鉴权 Filter 之前
    }

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
        String path = exchange.getRequest().getURI().getRawPath();
        if (!PATH_MATCHER.match(INTERNAL_PATH_PATTERN, path)) {
            return chain.filter(exchange);  // 不是内部路径,放行
        }
        // 是内部路径 → 检查白名单 → 默认 403
        return writeForbidden(exchange);
    }
}

这是一个 Gateway GlobalFilter,order=-200 确保它在所有鉴权 Filter 之前执行。逻辑很简单:任何从 Gateway 进来的路径包含 /internal/ 的请求,一律返回 403。 Feign 直连不走 Gateway,所以不受影响——只有外部流量才到这里。

AI 生成的代码里没有这个 Filter——它只在 Gateway yml 里写了路径路由,但没考虑"如果攻击者绕过直接访问内部端点"的情况。这就是 AI 生成骨架的典型盲区:它不知道安全层面的防御策略。

八、Feign Fallback:降级不是返回 null

AgentCoreFeignFallback:

@Component
public class AgentCoreFeignFallback implements FallbackFactory<AgentCoreFeignClient> {

    @Override
    public AgentCoreFeignClient create(Throwable cause) {
        log.error("[AgentCoreFeignFallback] Agent 服务降级, cause={}", cause.getMessage());
        return new AgentCoreFeignClient() {
            @Override
            public Result<ChatResponse> chat(ChatRequest request) {
                return Result.failed(ResultCode.MODEL_CALL_FAILED,
                        "Agent 服务降级: " + cause.getMessage());
            }
            @Override
            public Result<Map<String, Object>> getAgentInfo(Long agentId) {
                return Result.failed(ResultCode.AGENT_NOT_FOUND,
                        "Agent 详情降级(agentId=" + agentId + "): " + cause.getMessage());
            }
        };
    }
}

注意:降级返回的是 Result.failed(...),不是返回 null 或空对象。 调用方拿到的统一是 Result<T>,Result.failed 里的 code/msg 会告诉上层"这是降级,不是正常响应"。上层不需要判 null,直接看 Result.isSuccess() 就行。

AI 生成的 Fallback 最初是返回 null 的——

// AI 写的反模式
@Override
public Result<ChatResponse> chat(ChatRequest request) {
    return null;  // 上层拿到 null,直接 NPE
}

改成 Result.failed() 之后,上层业务代码里永远不会因为 Feign 降级而 NPE。这是一个防御性编码原则:Fallback 的返回值必须和正常返回值类型一致、语义一致(都是 Result),只是 success=false。

九、Docker Compose:9 个中间件一键起

所有中间件在 docker/docker-compose.yml 里,9 个服务 + 2 个辅助(etcd 是 Milvus 依赖,也算中间件)。关键设计:

依赖关系用 healthcheck 表达

milvus:
  depends_on:
    etcd:
      condition: service_healthy
    minio:
      condition: service_healthy

Milvus 启动需要 etcd(元数据存储)和 minio(向量块存储)先健康。AI 生成的 compose 最初用的是 service_started——etcd 容器"启动了"不等于"能用了",Milvus 启动会连不上 etcd 端口直接报 ConnectionRefusedError。改成 service_healthy 之后,Milvus 会等 etcd 自己的 healthcheck(etcdctl endpoint health)返回 healthy 才启动。

每个中间件都配了 healthcheck,这不是摆设——

服务healthcheck超时阈值
MySQLmysqladmin ping10s × 10 次
Nacoscurl /nacos/v1/console/health/readiness15s × 10 次
Redisredis-cli ping5s × 5 次
Milvuscurl :9091/healthz30s × 10 次(含 60s start_period)
EScurl /_cluster/health15s × 10 次

ES 必须先调 sysctl

# compose 文件注释里提醒了
# 前置:WSL 执行 sudo sysctl -w vm.max_map_count=262144

ES 8.x 在 Linux 上要求 vm.max_map_count >= 262144,默认值是 65536。WSL2 里 docker compose up 会因为这个直接启动失败,报 max virtual memory areas vm.max_map_count [65530] is too low。每次起 compose 之前先调一下:

sudo sysctl -w vm.max_map_count=262144

AI 生成的 compose 里没有这个注释——它不知道宿主机内核参数的坑。

容器间用服务名互访

所有容器在同一个 aap-net bridge 网络里:

networks:
  aap-net:
    driver: bridge

容器之间用服务名当 hostname:

milvus:
  environment:
    ETCD_ENDPOINTS: etcd:2379      # 不是 127.0.0.1
    MINIO_ADDRESS: minio:9000       # 不是 localhost
xxl-job-admin:
  environment:
    PARAMS: >-
      --spring.datasource.url=jdbc:mysql://mysql:3306/xxl_job...

本地开发时,Java 代码里用 ${NACOS_ADDR:127.0.0.1:8848} 走本机端口。但如果把微服务也放进 Docker Compose,地址改成服务名——所有中间件的地址配置都通过 Nacos 的 aap-common.yaml 统一管理,改一处即可。

十、起一个试试:启动顺序

# 1. 起中间件(大约 2-3 分钟全部 healthy)
cd docker && docker compose up -d

# 2. WSL ES 别忘了先调参数(或者等它自己 healthcheck 失败后再调)
# sudo sysctl -w vm.max_map_count=262144

# 3. 启动业务服务(任意顺序,Nacos 会自动发现)
cd aap-sys-auth && mvn spring-boot:run
cd aap-agent-core && mvn spring-boot:run
cd aap-model-gateway && mvn spring-boot:run
cd aap-rag-knowledge && mvn spring-boot:run
cd aap-agent-workflow && mvn spring-boot:run
cd aap-agent-tool-plugin && mvn spring-boot:run
cd aap-task-job && mvn spring-boot:run
cd aap-a2a-service && mvn spring-boot:run
cd aap-gateway && mvn spring-boot:run

# 4. Gateway 起好了就能调了
curl http://127.0.0.1:8080/api/system/tenants
# → {"code":200,"data":[...],"msg":"ok"}

验证 Nacos 注册成功:打开 http://127.0.0.1:8848/nacos → 服务列表 → namespace 选 aap → 应该看到 8 个服务(aap-* 开头的)。

十一、这一章的 AI 生成 vs 人工硬焊总结

内容AI 生成质量我做了什么
父 pom 版本号★★★☆☆Sentinel 改 adapter 包、MyBatis-Plus 换 SB3 starter
Gateway 启动类★★☆☆☆加 scanBasePackages 限制只扫 gateway 包
Sentinel 配置★★☆☆☆Servlet 版加 @ConditionalOnWebApplication、Gateway 版单独写 WebFlux 的
Gateway 路由 yml★★★☆☆补 A2A 的 /.well-known/agent*.json 路由
InternalPathAuthFilter☆☆☆☆☆AI 完全没写,自己 150 行
Feign Fallback★★☆☆☆从返回 null 改成 Result.failed()
Nacos 配置导入★★★★☆基本正确,加了 optional: 前缀
Docker Compose★★☆☆☆Milvus/XXL-Job 的 depends_on 从 service_started 改成 service_healthy、补 ES sysctl 注释

这一章的核心教训:AI 能帮你把 Spring Cloud Alibaba 的"配置骨架"(pom 依赖、yml 模板、启动类)搭出来,但框架版本升级带来的 API 废弃、WebFlux vs Servlet 的栈冲突、安全层面的防御——这些只有"知道框架内部怎么运作"的人才会注意到。

下一章,我们进入业务核心:Agent 对话主链路——从用户提问到 LLM 回答的完整调度流程。Redis 会话存储 + Token 滑动窗口裁剪 + Agent 配置加载 + Function Calling 闭环,这是整个平台的心脏。