最近在折腾 AI Agent 基础设施,翻了一圈开源方案,对 OpenClaw 这类 Agent Gateway 的模块化思路印象很深。它的核心价值很直接:把模型接入、工具调用、通道适配、会话管理这些事统一收敛到一个入口,上层应用不用关心底层具体接的是哪个模型、哪个渠道。我决定用 Java 全栈把这一套重新实现一遍,既是为了吃透 Agent 网关的完整链路,也是想验证 Java 在 AI 工程化场景下的落地体验。
这篇博文会从整体架构设计、核心模块拆解、实际编码实现到问题排查,完整复盘这个项目。适合三类人看:一是想入门 AI Agent 开发的 Java 工程师,二是准备全栈方向、想找一个完整项目充实简历的开发者,三是已经在做 Agent 应用、想了解网关层该怎么设计的同学。
1. 整体设计与思路拆解
1.1 为什么选 Java 而不是 Python/Node
先说结论:在这个项目里,Java 不是最优解,但绝对是最稳的选择。
AI Agent 生态里 Python 占主导,Node.js 在快速原型阶段也很好用。但我要做的不是单个 Agent,而是一个网关。网关是什么?是流量入口、路由分发、协议转换、限流鉴权、状态管理这一堆基础能力的集合体。这些恰好是 Java 服务端最常见的场景。
Spring Boot 的生态让我不用重复造轮子。安全认证有 Spring Security,限流可以基于 Redis 做计数器,消息通信有 Spring AMQP 或者直接上 WebSocket,监控有 Actuator + Prometheus。这些能力在 Python 里要一个个拼,在 Java 里基本是开箱即用。
另外还有一个现实考量:未来接手这个项目的人大概率是 Java 工程师。用团队熟悉的语言做基础设施,后续维护成本会低很多。Agent 网关不是一次性的 Demo,它要长期演进,可维护性比开发速度更重要。
1.2 网关要解决的核心问题
我自己用下来的体会是,Agent Gateway 要解决三件事:
第一,接入统一。上层应用不应该关心模型是 OpenAI 的还是 Anthropic 的,也不应该关心是云端 API 还是本地部署的模型。网关层把所有模型封装成统一的调用接口,上层只认一种协议。
第二,能力沉淀。Agent 不是只有一个大模型在跑,它要调工具、查知识库、写文件、发请求。这些能力如果散落在各个业务代码里,每做一个新 Agent 都要重写一遍。网关层把工具调用、会话管理、Prompt 模板这些通用能力做沉淀,新 Agent 只需要做业务编排。
第三,流量控制。真实场景里,多个应用共享同一批模型 API Key 时,必须要有配额管理、限流、熔断、审计。这一层不进网关,后续一定会出事故。
OpenClaw 给我的启发在于它的模块边界很清晰:模型供应商、Agent 运行时、通道适配器、控制 UI 各自独立。我照着这个思路做 Java 版的时候,把模块边界映射到了 Maven 的多模块结构里。
1.3 整体技术选型
整个项目的技术栈是这样定的:
- 核心框架:Spring Boot 3.2 + Java 17
- 通信层:Spring WebFlux 处理高并发网关流量,部分管理接口用 Spring MVC
- 状态存储:Redis 存会话状态和短期记忆,PostgreSQL 存 Agent 配置和调用日志
- 消息队列:RabbitMQ 处理异步任务和事件通知
- 向量检索:为了做知识库工具,接了 Chroma 作为向量数据库
- 前端:Vue 3 + Element Plus,做控制台界面
- 部署:Docker Compose 一键编排
选 WebFlux 而不是传统 MVC,是因为网关层的流量模型是 IO 密集型的,大量时间花在等待模型响应上。WebFlux 的响应式模型能用一个线程处理大量并发请求,资源占用比线程池模型低不少。但我也只在代理转发和流式响应的部分用了 WebFlux,复杂的业务逻辑还是用 MVC 写,避免响应式编程带来的心智负担。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与实操要点
2.1 模型供应商抽象层:让上层应用无感切换
这是整个网关的基石。我定义了一个统一的模型调用接口,所有供应商适配器都实现这个接口。
接口设计上我尽可能贴近大模型 API 的通用形态:支持文本生成、流式输出、工具调用声明。核心方法就三个:普通对话、流式对话、带工具调用的对话。
模型路由是关键。我设计了 ModelRoute 的概念,每个路由绑定一个供应商和一个具体模型名,比如 openai/gpt-4o、anthropic/claude-3.5-sonnet、nvidia-nim/local-llama3。上层应用发起请求时,只要指定路由 ID,网关自动解析出供应商、模型名和对应的认证凭据。
这里踩过一个小坑:不同供应商的工具调用格式差异很大。OpenAI 用 tools 数组 + tool_calls,Anthropic 用 tools + tool_use,本地模型又可能走 OpenAI 兼容格式。我在适配层做了请求转换,不管是哪种供应商,内部统一用厂商的原生格式,这样能保证每个模型的能力完全释放,不会因为统一抽象而丢掉某个模型的特殊参数。
实际编码时每个适配器都要处理两类异常:一类是网络超时,需要做重试;另一类是模型返回格式异常,要能识别出来并返回友好的错误信息。这块属于”防御性编程“,虽然啰嗦,但线上稳定全靠它。
2.2 会话状态与记忆管理
Agent 应用和普通 API 最大的区别在于有状态。多轮对话里,上下文就是 Agent 的短期记忆。但把整个历史对话每次都发给模型,成本太高,而且超过上下文窗口就会报错。
我的方案是三层记忆结构:
第一层是短期记忆,存在 Redis 里,会话维度的消息列表,设置了 TTL 自动过期。每次请求时取最近 N 轮对话,加上 token 预算控制,超过了就走裁剪策略。
第二层是摘要记忆,当会话太长、超出上下窗口预算时,调用一次模型把早期对话压缩成摘要,之后带着摘要继续对话。这个做法牺牲了一点精度,但能保底让长对话不崩。
第三层是长期记忆,存在 PostgreSQL 里,用户维度的偏好、关键事实、历史结论。这层是应用层按需写入的,比如用户在对话里说 ”我喜欢简洁的回答“,Agent 可以把它抽出来存到长期记忆表里。
会话超时处理和并发控制也在这里做。同一个会话的并发请求通过 Redis 分布式锁串行化,避免多轮上下文错乱。这是我在实际使用中发现必须处理的问题——用户在网页上连续点提交,后端如果并发处理同一个会话,Message 顺序会乱,模型拿到的上下文就是错的。
2.3 Agent 工具调用机制的实现
工具调用是 Agent 的灵魂。所谓工具,就是给模型提供的一组函数声明,模型根据用户意图决定调哪个、传什么参数,然后由网关真实执行并把结果返回给模型。
我定义了一个 Tool 接口,核心方法包括获取工具声明、执行工具、校验参数。工具注册表用 Spring 容器自动收集所有实现类,按名称索引。
工具调用的循环流程是这样的:
- 用户提问进入 Agent
- Agent 带着会话历史和工具声明请求模型
- 模型返回两种可能:直接回答,或者返回工具调用请求
- 如果是工具调用,网关执行对应工具,把结果拼到消息里,再次请求模型
- 循环执行,直到模型直接回答或者达到最大轮数
代码上要特别注意循环次数限制。我之前遇到过模型在几个工具之间反复横跳的 bug,如果不加最大轮数限制,一次请求能跑几十次工具调用,既费钱又慢。默认设置 8 轮,超过就强制返回当前结果。
工具注册表里我预设了几个常用工具:查天气、计算器、获取当前时间、调用内部 HTTP API、查 PostgreSQL 数据库、向量检索知识库。每个工具都配了详细的参数描述,因为模型是靠描述来判断什么时候该调用工具的,描述写得烂,工具就废了。
2.4 多通道接入:微信、网页、飞书消息怎么统一
通道接入是网关的另一个关键维度。我开发的 Agent 最终要在多个入口提供服务:用户在网页控制台直接聊天、在微信里跟 Agent 对话、在飞书群里 @Agent。
通道适配器和模型适配器类似,也是抽象一层。我定义了一个 InboundChannel 接口,负责把不同渠道的消息规范化成统一的 AgentRequest,然后把 Agent 返回的结果适配回渠道的消息格式。
微信接入用的方案是企业微信机器人 Webhook,飞书用的飞书开放平台的机器人 API,网页端是 WebSocket 长连接。每个通道独立实现,互不影响。
这块真正麻烦的是异步推送。比如用户在微信里发一条消息,Agent 处理可能要 5 秒甚至更长,但微信的接口要求 5 秒内响应。我的方案是先把请求接收下来,立刻返回 “正在思考中”,然后用回调地址把结果推回去。不同平台的回调机制差异很大,飞书支持事件订阅回调,企业微信用被动回复消息的机制也能实现,每个渠道都要单独调协议。
注意:通道适配层很容易写成一堆 if-else。建议用策略模式加 Spring 注入的方式,每个通道一个 Bean,按渠道类型路由,后续加新渠道只写新类不动旧逻辑。
3. 实操过程与核心环节实现
3.1 工程初始化与模块划分
我使用 Spring Initializr 生成的骨架,然后手工调整成多模块结构。整个工程分成了这几个 Maven 模块:
gateway-core:核心模型、工具接口、Agent 编排引擎gateway-model:模型供应商适配层gateway-channel:通道适配层gateway-server:Spring Boot 启动模块,提供 REST API 和 WebSocket 服务gateway-console:前端工程,Vue 3 项目
多模块的好处是依赖边界清晰。比如后续如果只想跑模型适配层做本地调试,不需要启动整个网关。这在实际开发里非常有用。
依赖管理上,所有模块的版本号统一放到父 POM 的 dependencyManagement 里。Spring Boot 的 BOM 处理大部分依赖版本,额外加的几个库(比如 OkHttp、Chroma 客户端)自己指定版本。
3.2 网关路由与限流:从配置到注解
路由层是网关的入口。我实现了一个动态路由表,存在 PostgreSQL 里,每个路由配置包括路径前缀、目标服务、限流策略、认证方式。网关根据请求路径匹配路由,做转发。
java复制@Service
public class GatewayRouter {
private final RedisTemplate<String, String> redisTemplate;
public Mono<ServerResponse> route(ServerRequest request) {
String routeKey = request.path();
RouteConfig route = routeService.match(routeKey);
if (route == null) {
return ServerResponse.notFound().build();
}
// 限流校验
if (!allowRequest(routeKey, route.getRateLimit())) {
return ServerResponse.status(429).bodyValue("Too Many Requests");
}
return forward(request, route);
}
private boolean allowRequest(String routeKey, int limit) {
Long count = redisTemplate.opsForValue().increment(routeKey);
if (count != null && count == 1) {
redisTemplate.expire(routeKey, Duration.ofSeconds(1));
}
return count != null && count <= limit;
}
}
限流我用的是最简单的 Redis 计数器模式:每个路由每秒最多放行 N 个请求。线上如果用得更严,可以换成令牌桶算法,Redis 的 Lua 脚本实现也不算复杂。不过对 Agent 网关来说,真正要限的是模型 API 的调用频次,而不是网关入口的请求频次。所以我还加了一层模型调用层的限流,按 API Key 维度做配额控制。
3.3 Agent 编排引擎的实现
Agent 编排引擎是整个项目的核心。它的职责是管理一次完整任务的执行生命周期:从接收用户输入开始,到调用模型、循环执行工具、生成最终回答。
这个引擎我用了状态机的思路来实现。每个 AgentRequest 都有一个状态,枚举值如下:
java复制public enum AgentState {
CREATED, // 已创建,等待执行
CALLING_MODEL, // 正在调用模型
EXECUTING_TOOL, // 正在执行工具
FINISHED, // 已完成
FAILED // 失败
}
引擎主循环的代码非常简洁,核心逻辑就是 while 循环加状态切换:
java复制public AgentResponse execute(AgentRequest request) {
AgentRuntime runtime = new AgentRuntime(request);
int maxTurns = 8;
while (maxTurns > 0) {
ModelResponse response = modelRouter.chat(runtime.getMessages(), runtime.getToolDeclarations());
if (response.hasToolCalls()) {
List<ToolResult> results = runTools(response.getToolCalls());
runtime.addToolResults(results);
maxTurns--;
} else {
runtime.setAnswer(response.getContent());
break;
}
}
return runtime.buildResponse();
}
代码看着简单,但工程化要解决的问题都在外围。比如工具执行的结果要截断长度,避免把超长日志塞回上下文窗口;比如模型返回 JSON 格式解析失败时要有回退策略;再比如超时控制,整个 Agent 循环必须在 60 秒内结束,否则就取消。
我在这里用了虚拟线程。Java 21 的虚拟线程特别适合这种场景——Agent 循环里大量时间在等模型 API 返回,虚拟线程被阻塞时几乎不占资源,一批请求可以创建大量虚拟线程而不会把线程池打满。我在代码里用 Executors.newVirtualThreadPerTaskExecutor() 跑工具调用任务,实测并发能力比传统线程池高了一个量级。
3.4 前端控制台与后端 API 打通
全栈项目必须有前端。我的前端控制台实现了三个页面:登录页、对话控制台、Agent 管理页。
对话控制台的核心是 WebSocket 通信。用户发消息、Agent 流式返回、工具执行状态实时推送,全部走 WebSocket。后端用 Spring WebFlux 的 WebSocket 实现,前端 Vue 3 用原生 WebSocket API 封装了一个 composable。
js复制// useChat.js
export function useChat(agentId) {
const ws = new WebSocket(`/ws/chat/${agentId}`)
const messages = ref([])
function send(content) {
ws.send(JSON.stringify({ content }))
}
ws.onmessage = (event) => {
const data = JSON.parse(event.data)
if (data.type === 'token') {
// 追加流式 token
} else if (data.type === 'tool_start') {
// 显示工具执行状态
} else if (data.type === 'done') {
// 结束状态
}
}
return { messages, send }
}
后端对接的 REST API 和 WebSocket 共有同一套认证逻辑,我用 JWT 做认证。前端在登录后拿到 token,REST 请求放在 Authorization 头里,WebSocket 握手时通过 query 参数传 token,网关层先校验再建立连接。
前端这块我花了不少时间调流式输出。模型返回是一段一段的,要通过 WebSocket 实时推给前端,前端按 token 渲染。真正做起来才会发现,流式传输要处理的不只是 “把字打印出来” 这么简单,还有断线重连、流式内容缓存、渲染性能这些细节。
4. 常见问题与排查技巧实录
4.1 502 Bad Gateway 报错排查
项目刚跑起来的时候,我频繁遇到 unexpected status 502 bad gateway: unknown error,后面对应的 URL 是 http://127.0.0.1:1572。这类报错单独看很容易让人蒙圈,但实际上 502 的含义是网关拿到了上游的无效响应,排查方向要锁定在 “上游服务到底有没有正常工作”。
第一类原因是后端服务没启动。很多时候我改了代码重启,端口还没监听,前端就已经发请求过来了。Spring Boot 应用启动需要几秒,如果部署脚本里没做健康检查就直接切流量,必然 502。
第二类原因是服务的监听地址不对。本地调试时服务绑定了 127.0.0.1,但网关请求发的是容器 IP,导致连接被拒。解决方式是服务监听地址改为 0.0.0.0,端口映射要显式声明。
第三类原因是请求转发配置写错。我在自定义路由里把请求转发到上游服务时,目标 URL 拼错了路径,导致上游返回 404,网关就包装成 502 抛给客户端。
排查 502 的通用步骤可以按下面这个顺序来:
- 先确认目标服务进程是否存活:
curl http://127.0.0.1:1572/health,如果是 Spring Boot 应用,可以访问/actuator/health看状态。 - 确认端口监听情况:
netstat -tunlp | grep 1572,看端口是否真的在监听。 - 把网关日志级别调到 DEBUG,看它实际请求的上游地址是什么。
- 如果所有检查都正常,但依然 502,检查超时配置。模型推理时间一旦超过代理层的超时时间,网关也会返回 502。
4.2 Java OOM 内存不足处理
另一个高频问题是 java: outofmemoryerror: insufficient memory。Agent 网关是内存密集型应用,因为会话上下文、工具执行结果、临时数据都要驻留在内存里。
最常见的触发点是流式响应缓冲。WebFlux 的流式响应里,如果代码把完整的响应 body 先缓冲到内存再转发,大模型的输出可能几个 MB,并发一多内存就爆了。解决方式是真正的流式转发,用 DataBuffer 直接读取并写入下游,不做整体缓冲。
另一个内存大户是会话消息列表。如果不给每个会话设置上限,长期跑下来 Redis 里的消息数据会持续膨胀,JVM 堆里缓存的会话对象也越来越多。我后来给每个会话的消息数量设了上限:最多保留 50 条消息,超过就触发摘要压缩。
JVM 参数上我踩过坑之后做了这样的配置:
bash复制java -Xms2g -Xmx4g \
-XX:MaxMetaspaceSize=512m \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=100 \
-jar gateway-server.jar
G1 在这个场景下表现还不错。模型调用时产生大量短期对象,G1 能控制暂停时间,不至于让响应出现明显卡顿。
4.3 全栈开发的学习路线与面试要点
这个项目做下来,我对 “全栈工程师” 有了新的理解。全栈不是 “前端我也会一点、后端我也懂一点” 的拼接,而是能理解一条完整请求链路从浏览器到数据库的每一个环节。我做这个项目时补了很多前端知识,比如 Vue 的生命周期、WebSocket 的状态管理、前端构建工具链。
如果你也是 Java 后端背景想转全栈,我的建议是按照这个顺序学习:
- 先把 Java 基础打牢。多线程、集合、JVM 内存模型是 Java 面试的高频考点,也是排查线上问题的底层能力。
- 学 Spring Boot 的自动装配、AOP、事务管理,这是 Java 全栈的立身之本。
- 掌握前端三件套后直接上 Vue 或 React,不要纠结框架选型,关键是理解组件化开发思维和状态管理。
- 数据库必学索引原理和 SQL 优化。Agent 网关里的会话查询,索引加不加的差距能到几十倍。
- Redis 和消息队列是加分项,做全栈项目绕不开缓存和异步化。
面试的时候,一份完整的 AI Agent 项目经历比背几十道八股文有用得多。面试官大概率会追问这几个点:Agent 循环怎么设计的?上下文窗口超出怎么办?工具调用怎么保证安全性?并发问题怎么处理?每个问题都是你在实际开发中真正会遇到、真正解决过的问题,答案自然能对答如流。
4.4 前端转后端的避坑建议
项目里还有一段代码是前端同事写的,我在 review 过程中发现了一些典型问题,这里也记录一下,给转后端的同学做个参考。
第一个坑是接口设计没有考虑幂等性。前端提交 Agent 任务时,如果用户双击按钮,同一个任务被提交了两次,Agent 就会跑两遍,产生两条重复记录。后来我在后端接口加了幂等校验:请求头带一个 requestId,后端 Redis 里存一份,重复请求直接返回第一次的结果。
第二个坑是数据库操作没用事务。前端同学写的批量更新操作,中间一步失败了前面已经成功的数据就回不去了。这在我这个项目里是要命的——Agent 调用日志和会话状态必须保持一致。后来所有写操作都加了 @Transactional,并做了详细的自测。
第三个坑是异常处理太粗糙。前端代码里请求失败后直接弹个 “网络错误”,根本不看后端返回的错误码。我后来规范了统一错误响应体,把错误码、错误消息、traceId 都带上,前端根据不同的错误码做不同的提示逻辑。
5. 项目复盘与后续扩展方向
做这个 Java 版 Agent Gateway 项目,前后花了大概三周时间,最大的收获不是把功能跑通,而是对整个 AI Agent 工程的链路有了完整的感知。模型调用只是最后一公里,真正的工程量都在网关的基础设施上:接口抽象、状态管理、工具编排、通道适配、监控告警,每一层都有大量细节要处理。
现在这个项目的完整能力已经能支撑内部多个 Agent 应用接入。我们把不同业务的 Agent 路由到同一个模型供应商,通过网关统一管理 API Key 配额和调用审计,新 Agent 只需要注册路由配置和工具注册表,不用重复建模型连接,迭代速度比以前快了不少。
我个人在实际操作中体会到,做技术选型时不要盲目追新,Agent 领域每天都有新框架出来,但练好底层基本功永远不会过时。精通 Java 并发、熟悉 Spring 生态、理解全链路分布式系统,这些能力在 AI 时代依然坚挺,因为 AI 应用落地后依然是软件工程问题。
最后分享一个小技巧:这类全栈项目写博客复盘时,建议把每一步踩过的坑都记录到项目的 README 里。写博客的过程其实是重新梳理技术决策的过程,很多当时想当然的 “why”,写下来才发现自己也解释不清楚。把这些都搞清楚,才是做项目最大的收获。
