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

一、先回答一个问题:为什么是 8 个微服务,不是单体?
这一章讲底座——从父 pom 到 Docker Compose,把 8 个微服务和 9 个中间件串起来。但在动手之前,必须先回答一个面试官必问的问题:
为什么是微服务,不是单体?
三个理由,每个都有代码证据:
理由一:部署隔离。 Agent-Core 和 RAG-Knowledge 可能需要分别部署在不同机房(合规场景下向量存储和业务逻辑物理隔离)。如果是单体,你要么全量部署,要么拆分模块做"伪微服务"——但那比真微服务还难维护。我们的拆分标准很简单:每个服务有独立数据库表、独立业务边界、能独立启动和独立发布。你看下面这张表就明白了:
| 服务 | 端口 | 独立表 | 核心边界 |
|---|---|---|---|
| aap-gateway | 8080 | 无(纯网关) | 统一入口、鉴权、限流、路由 |
| aap-sys-auth | 8081 | sys_tenant / sys_user / sys_role | 认证、权限、租户 |
| aap-agent-core | 8082 | agent_info / agent_session | Agent 配置、对话调度 |
| aap-model-gateway | 8083 | model_route_config | 模型路由、Token 统计 |
| aap-rag-knowledge | 8084 | knowledge_base / knowledge_file / knowledge_chunk | RAG 知识库 |
| aap-agent-workflow | 8085 | workflow_def / workflow_instance | DAG 工作流定义与执行 |
| aap-agent-tool-plugin | 8086 | tool_info | 工具插件 |
| aap-task-job | 8087 | task_record | 异步任务、定时任务 |
8 个服务,每个一个端口,每个一套独立表——这不是过度拆分。
理由二:AI 层用 Python 的灵活性。 Function Calling 深循环、LangGraph 图编排、A2A 协议——这些 AI 原生能力,Python 写起来比 Java 自然得多。Java 做它擅长的(鉴权、CRUD、事务、微服务治理),Python 做它擅长的(LLM 交互、Agent 编排),两边通过 Feign + HTTP 回调打通。ch05 会专门讲双栈架构。

理由三: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/*? 因为:
- 前端只知道 Gateway 一个地址,不需要关心后端有多少个服务;
- Gateway 可以统一做鉴权、限流、跨域,每个微服务不用自己配;
- 如果以后某个服务内部路径变了,改 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 | 超时阈值 |
|---|---|---|
| MySQL | mysqladmin ping | 10s × 10 次 |
| Nacos | curl /nacos/v1/console/health/readiness | 15s × 10 次 |
| Redis | redis-cli ping | 5s × 5 次 |
| Milvus | curl :9091/healthz | 30s × 10 次(含 60s start_period) |
| ES | curl /_cluster/health | 15s × 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 闭环,这是整个平台的心脏。
