1. 这个项目到底在解决什么问题
Java 生态里做 AI Agent,一直有个尴尬的断层。Python 那边 LangChain、LlamaIndex 玩得飞起,各种 Agent 框架层出不穷,但 Java 开发者想搞一个能上生产的智能体,要么硬着头皮跨语言调用,要么自己从零撸一套 HTTP 客户端加状态机。前者运维复杂度翻倍,后者代码质量参差不齐,最后往往变成一堆 if-else 堆出来的“伪 Agent”。
这个项目就是冲着这个断层来的。它用 Spring Boot 做工程底座,Spring AI 做模型交互层,把 Personal Agent 需要的能力——对话管理、工具调用、记忆存储、任务编排——全部用 Java 原生实现,而且直接摊开了生产级细节。什么叫生产级细节?不是 demo 里跑通一次就完事,而是考虑了并发、超时、重试、可观测性、配置外置这些真正上线才会遇到的问题。
适合谁来参考?如果你是有 Spring Boot 经验的 Java 后端,想在自己的系统里嵌入一个可控的智能体能力,这个项目的架构思路和实现细节值得逐行研究。如果你刚接触 AI 应用开发,但 Java 基础扎实,它也能帮你绕过 Python 生态的认知负担,直接用熟悉的注解和依赖注入来理解 Agent 的运作机制。甚至如果你是技术负责人,正在评估“自研 Agent 还是接第三方 API”,这篇文章里的取舍分析也能帮你省掉不少调研时间。
我花了几天时间把这个项目的核心模块拆了一遍,下面从设计思路、关键实现、实操落地到踩坑记录,完整地摊开讲。
2. 整体架构设计与技术选型逻辑
2.1 为什么是 Spring Boot + Spring AI 这个组合
Java 生态做 AI 应用,可选的技术栈其实不少。可以用 OkHttp 直接调模型 API,可以用 LangChain4j,也可以用 Spring AI。这个项目选了 Spring AI,背后的考量很实在。
Spring AI 的核心价值在于它把模型交互抽象成了ChatClient和EmbeddingClient这类接口,底层换模型提供商的时候,上层业务代码几乎不用动。今天用某家的模型,明天想换另一家,改个配置就行。这种抽象在快速迭代阶段特别重要,因为模型选型本身就是一个需要反复试错的过程。
另一个原因是 Spring AI 和 Spring Boot 的自动配置机制天然契合。spring-ai-openai-spring-boot-starter这类 starter 引入后,application.yml里配好 api-key 和 base-url,一个ChatClientBean 就直接注入到你的 Service 里了。这种开发体验对 Java 后端来说几乎零学习成本。
但 Spring AI 也不是没有短板。它的工具调用(Function Calling)支持相对早期,某些高级特性比如流式响应中的工具调用、多轮工具编排,需要自己做一些封装。这个项目在 Spring AI 的基础上补了一层ToolRegistry和AgentExecutor,把工具注册、参数解析、结果回填的流程标准化了。这是典型的“框架不够,业务层来凑”的做法,但凑得比较优雅。
2.2 Personal Agent 的核心模块拆解
整个项目可以拆成五个核心模块,每个模块的职责边界很清晰:
- 对话管理层:维护会话上下文,处理多轮对话的状态。这里没有用简单的
List<Message>存内存,而是抽象了一个ConversationStore接口,默认实现基于 Caffeine 做本地缓存,同时预留了 Redis 实现的扩展点。 - 工具注册与调用层:所有可被 Agent 调用的工具都实现统一的
AgentTool接口,通过ToolRegistry注册。调用时根据模型返回的 function call 信息,反射找到对应工具并执行。 - 记忆存储层:短期记忆走对话历史,长期记忆走向量库。项目里默认集成了 SimpleVectorStore,但接口设计上支持切换到其他向量数据库。
- 任务编排层:这是 Agent 和普通 ChatBot 的分水岭。
AgentExecutor负责解析模型的意图,决定是直接回复还是调用工具,工具调用后把结果回填给模型继续推理,直到模型给出最终答案。 - 可观测层:每个环节都有 Micrometer 指标埋点,对话轮次、工具调用次数、模型响应耗时、Token 消耗量都能监控到。
这种分层的好处是,每一层都可以独立替换。比如你觉得 Caffeine 不够用,换个 Redis 实现就行;觉得 SimpleVectorStore 性能不行,接个 Milvus 或 PGVector 也就是实现一个接口的事。
2.3 生产级细节体现在哪些地方
很多 Agent 项目在 demo 阶段跑得挺好,一上生产就各种问题。这个项目在几个关键点上做了处理,我挑几个重要的说。
超时与重试:模型 API 调用设置了连接超时和读取超时,默认分别是 10 秒和 60 秒。重试策略用的是 Spring Retry,对可恢复的异常(如网络抖动、限流)做指数退避重试,最大重试 3 次。这里有个细节,重试的时候会判断请求是否幂等,非幂等的工具调用不会盲目重试。
并发控制:AgentExecutor内部用了Semaphore限制同时处理的对话请求数,防止大量请求打满模型 API 的配额。信号量的许可数通过配置项agent.max-concurrent-sessions控制,默认 50。
配置外置:所有关键参数——模型名称、温度、最大 Token 数、系统提示词、工具超时时间——全部放在application.yml里,支持通过环境变量覆盖。这意味着同一份代码可以在开发、测试、生产环境用不同的配置运行,不需要改代码重新打包。
优雅降级:当模型 API 不可用时,AgentExecutor会返回一个兜底回复,而不是直接抛异常给前端。兜底回复的内容可以通过配置指定,比如“当前服务繁忙,请稍后重试”。
这些细节单独看都不复杂,但组合在一起,就是一个能扛住真实流量的 Agent 服务该有的样子。
3. 核心模块的代码级实现细节
3.1 对话上下文的存储与裁剪策略
对话历史是 Agent 的记忆基础,但模型有上下文窗口限制,不可能无限往里面塞历史消息。这个项目的做法是:存储全量历史,但发送给模型前做裁剪。
存储层用了一个Conversation对象,包含sessionId、messages列表、createdAt、lastUpdatedAt。messages是一个CopyOnWriteArrayList,保证并发读写的安全性。每个Message包含角色(user/assistant/system/tool)、内容、时间戳、Token 数估算。
裁剪策略是核心。项目实现了两种:
- 滑动窗口:保留最近 N 轮对话,N 默认是 20。简单粗暴,但可能丢失早期的重要信息。
- Token 预算裁剪:根据模型的上下文窗口大小,从最新消息往前累加 Token 数,直到接近预算上限。系统提示词和最近一轮用户消息始终保留。
实际用的是第二种,因为更精准。Token 估算用的是jtokkit这个库,它支持多种模型的 Tokenizer,估算误差在 5% 以内。裁剪时会预留 20% 的预算给模型的回复,避免请求超限。
注意:Token 估算不要用简单的字符数除以 4,不同模型的分词器差异很大。中文场景下,一个汉字可能对应 1 到 2 个 Token,用 jtokkit 这类专业库更靠谱。
3.2 工具调用的注册、解析与执行链路
工具调用是 Agent 的核心能力。这个项目的工具定义方式很 Java:定义一个接口,实现它,加个注解,就注册好了。
public interface AgentTool { String getName(); String getDescription(); Map<String, Object> getParameterSchema(); Object execute(Map<String, Object> parameters); }getParameterSchema()返回的是 JSON Schema 格式的参数描述,这个描述会随请求一起发给模型,模型根据它来决定怎么填参数。这里有个经验:参数描述要写得像给新人看的 API 文档,越详细,模型填错的概率越低。
工具注册通过ToolRegistry完成,它在应用启动时扫描所有AgentTool的实现类,按名称建立索引。调用时,AgentExecutor从模型的响应中提取 function call 信息,拿到工具名和参数,从注册表里找到工具并执行。
执行链路有几个关键处理:
- 参数校验:执行前用 JSON Schema 校验参数类型和必填项,不合法直接返回错误信息给模型,让它重新生成。
- 超时控制:每个工具调用有独立的超时时间,默认 30 秒,通过
CompletableFuture实现。超时后中断执行,返回超时提示。 - 异常封装:工具执行抛出的异常会被捕获,转换成模型能理解的错误消息,比如“工具 X 执行失败:参数 Y 格式不正确”。这样模型有机会自我修正。
public Object executeWithTimeout(AgentTool tool, Map<String, Object> params, Duration timeout) { CompletableFuture<Object> future = CompletableFuture.supplyAsync( () -> tool.execute(params), toolExecutor ); try { return future.get(timeout.toMillis(), TimeUnit.MILLISECONDS); } catch (TimeoutException e) { future.cancel(true); throw new ToolExecutionException("工具执行超时: " + tool.getName()); } }3.3 记忆模块的向量化与检索实现
长期记忆靠向量检索。项目默认用 Spring AI 的SimpleVectorStore,它底层是一个内存中的向量索引,适合开发和中小规模场景。生产环境建议换成 PGVector 或 Milvus,项目里预留了VectorStoreFactory来支持切换。
记忆的写入时机有两个:一是每轮对话结束后,把用户消息和助手回复拼接后向量化存入;二是工具调用产生重要结果时,单独存一条记忆。检索时,用当前用户输入去向量库做相似度搜索,取 Top-K 条相关记忆,拼接到系统提示词里。
这里有个容易忽略的细节:记忆的去重。如果用户反复问类似的问题,向量库里会堆积大量相似记忆,检索时返回一堆重复内容,浪费 Token。项目里加了一个简单的去重逻辑:检索到结果后,计算两两之间的余弦相似度,超过 0.95 的只保留一条。
另一个细节是记忆的时效性。每条记忆存的时候带一个时间戳,检索时可以按时间衰减加权,让近期记忆的得分更高。这个权重通过memory.recency-weight配置,默认 0.3。
3.4 流式响应与前端交互的处理
Agent 的响应时间通常比普通接口长,因为可能涉及多轮模型调用和工具执行。用流式响应能显著提升用户体验。项目用的是 Spring WebFlux 的Flux<ServerSentEvent>来实现 SSE 推送。
流式响应的难点在于:工具调用过程中,模型可能先输出一段文本,然后触发工具调用,工具执行完再继续输出。这要求流式处理逻辑能区分“文本增量”和“工具调用事件”。
项目的做法是定义一个AgentEvent类型,包含eventType(TEXT、TOOL_CALL、TOOL_RESULT、DONE)和data。AgentExecutor在处理过程中不断发出AgentEvent,前端根据事件类型决定是追加文本还是显示工具调用状态。
public Flux<AgentEvent> stream(String sessionId, String userInput) { return Flux.create(sink -> { // 发送用户消息确认事件 sink.next(AgentEvent.text("正在思考...")); // 执行 Agent 逻辑,过程中不断 sink.next() // ... sink.complete(); }); }提示:SSE 连接要设置合理的超时时间,建议 5 分钟。同时前端要做好断线重连,因为 Agent 处理时间可能较长,网络抖动导致连接中断的情况并不少见。
4. 从零搭建一个可运行的 Agent 服务
4.1 环境准备与依赖引入
先确认基础环境:JDK 17 或以上,Maven 3.8+,一个可用的模型 API 密钥。项目本身不绑定特定模型提供商,但默认配置走的是 OpenAI 兼容接口,所以任何提供兼容 API 的服务都能用。
pom.xml里需要引入的核心依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-webflux</artifactId> </dependency> <dependency> <groupId>org.springframework.ai</groupId> <artifactId>spring-ai-openai-spring-boot-starter</artifactId> <version>1.0.0-M4</version> </dependency> <dependency> <groupId>com.github.ben-manes.caffeine</groupId> <artifactId>caffeine</artifactId> </dependency> <dependency> <groupId>com.knuddels</groupId> <artifactId>jtokkit</artifactId> <version>1.1.0</version> </dependency>Spring AI 的版本要注意,M 版本之间的 API 变动比较大,建议锁定一个稳定版本,不要频繁升级。项目里用的是 1.0.0-M4,这个版本的工具调用 API 相对稳定。
4.2 配置文件的关键参数说明
application.yml里的配置项不少,我挑几个影响最大的解释:
spring: ai: openai: api-key: ${AI_API_KEY} base-url: ${AI_BASE_URL} chat: options: model: gpt-4o-mini temperature: 0.7 max-tokens: 2048 agent: max-concurrent-sessions: 50 tool-timeout-seconds: 30 memory: max-messages: 100 recency-weight: 0.3 top-k: 5 system-prompt: | 你是一个个人助理,可以调用工具帮助用户解决问题。 回答要简洁准确,不确定的信息不要编造。temperature设 0.7 是一个平衡值。太低(如 0.2)回答太死板,太高(如 1.2)容易胡说。工具调用场景下,建议不要超过 0.8,否则模型可能生成不合法的参数。
max-tokens控制单次回复的最大长度。设太大浪费钱,设太小可能截断。2048 对大多数对话场景够用,如果涉及长文生成,可以调到 4096。
system-prompt是 Agent 的“人设”。这里有个经验:系统提示词里要明确告诉模型“可以调用工具”,否则有些模型会倾向于直接回答而不是调用工具。另外要加上“不确定的信息不要编造”,减少幻觉。
4.3 定义你的第一个工具
工具的定义分三步:实现接口、加注解、注册。
@Component public class WeatherTool implements AgentTool { @Override public String getName() { return "get_weather"; } @Override public String getDescription() { return "查询指定城市的当前天气。当用户询问天气时调用此工具。"; } @Override public Map<String, Object> getParameterSchema() { return Map.of( "type", "object", "properties", Map.of( "city", Map.of( "type", "string", "description", "城市名称,如北京、上海" ) ), "required", List.of("city") ); } @Override public Object execute(Map<String, Object> parameters) { String city = (String) parameters.get("city"); // 实际调用天气 API return Map.of("city", city, "temperature", "25°C", "condition", "晴"); } }getDescription()的写法很关键。不要写“天气查询工具”这种模糊描述,要写清楚“什么时候调用”。模型是根据描述来决定是否调用工具的,描述越明确,误调用越少。
参数 Schema 里的description同样重要。city参数如果不写描述,模型可能传“北京市朝阳区”这种带行政区的全称,而你的 API 可能只接受“北京”。写清楚示例能减少很多参数清洗的工作。
4.4 启动类与核心 Bean 的装配
启动类很标准,关键是几个核心 Bean 的装配:
@SpringBootApplication public class AgentApplication { public static void main(String[] args) { SpringApplication.run(AgentApplication.class, args); } @Bean public ToolRegistry toolRegistry(List<AgentTool> tools) { return new ToolRegistry(tools); } @Bean public ConversationStore conversationStore() { return new CaffeineConversationStore(1000, Duration.ofHours(2)); } @Bean public AgentExecutor agentExecutor( ChatClient chatClient, ToolRegistry toolRegistry, ConversationStore conversationStore, VectorStore vectorStore) { return new DefaultAgentExecutor( chatClient, toolRegistry, conversationStore, vectorStore ); } }ToolRegistry的构造参数是List<AgentTool>,Spring 会自动把所有实现了AgentTool的 Bean 注入进来。这种“约定优于配置”的做法,新增工具时只需要加一个@Component类,不需要改任何注册代码。
ConversationStore用的是 Caffeine,设置了最大 1000 个会话和 2 小时过期。这两个参数要根据实际用户量调整。如果日活用户多,会话数要相应放大,否则频繁的会话淘汰会导致上下文丢失。
4.5 写一个 Controller 暴露 Agent 能力
对外接口分两个:一个同步的,一个流式的。
@RestController @RequestMapping("/api/agent") public class AgentController { private final AgentExecutor agentExecutor; @PostMapping("/chat") public ResponseEntity<AgentResponse> chat(@RequestBody ChatRequest request) { String reply = agentExecutor.execute(request.sessionId(), request.message()); return ResponseEntity.ok(new AgentResponse(reply)); } @PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) public Flux<ServerSentEvent<AgentEvent>> stream(@RequestBody ChatRequest request) { return agentExecutor.stream(request.sessionId(), request.message()) .map(event -> ServerSentEvent.builder(event).build()); } }同步接口适合简单场景,但要注意设置合理的超时时间。Agent 处理可能涉及多轮模型调用,耗时可能到十几秒,网关的超时配置要相应调整。
流式接口用Flux返回 SSE,前端用EventSource接收。这里有个细节:SSE 的Content-Type必须是text/event-stream,Spring 的produces属性要写对,否则前端收不到事件。
5. 实际运行中会遇到的问题与排查
5.1 模型不调用工具怎么办
这是最常见的问题。用户问“北京天气怎么样”,模型直接编了一个“北京今天晴,25度”,而不是调用get_weather工具。
原因通常有三个:一是系统提示词里没有强调可以使用工具;二是工具描述不够明确,模型没意识到该调用;三是模型本身的能力问题,某些小模型对 function calling 的支持不好。
排查步骤:先看请求日志里有没有把工具定义发给模型。Spring AI 的ChatClient在开启工具调用后,会在请求的tools字段里带上工具定义。如果日志里没有,说明工具注册没生效,检查ToolRegistry是否被正确注入。
如果工具定义发了但模型还是不调用,试着在系统提示词里加一句“当用户询问实时信息时,必须调用相应工具获取,不要依赖你的训练数据”。这句话对大多数模型都有效。
5.2 工具调用参数格式错误
模型返回的参数格式不对,比如该传字符串的传了数字,该传数组的传了单个值。这种情况在参数 Schema 描述不清晰时特别常见。
解决办法是在execute方法里做参数容错。比如期望List<String>但收到String,自动包装成单元素列表。期望数字但收到字符串,尝试解析。这种“宽进严出”的策略能显著降低工具调用的失败率。
另一个办法是在参数 Schema 里加examples字段,给模型更具体的参考。比如:
{ "city": { "type": "string", "description": "城市名称", "examples": ["北京", "上海", "广州"] } }5.3 多轮工具调用导致死循环
Agent 调用工具后,模型可能觉得结果不完整,又调用同一个工具,反复几次后陷入死循环。项目里加了一个最大工具调用轮次限制,默认 5 轮。超过后强制让模型基于已有信息给出最终回复。
这个限制通过agent.max-tool-rounds配置。设太小可能导致复杂任务完不成,设太大浪费 Token。5 轮对大多数场景够用,如果业务涉及复杂的数据查询链路,可以调到 8 到 10。
注意:死循环的另一个原因是工具返回的结果格式模型无法理解。比如工具返回了一个嵌套很深的 JSON,模型解析不了,就反复调用。工具返回结果尽量扁平化,用自然语言描述关键信息,模型更容易处理。
5.4 内存泄漏与连接池耗尽
长时间运行后,如果发现内存持续增长或数据库连接池被占满,大概率是会话存储或向量存储没有正确释放。
Caffeine 的ConversationStore设置了过期时间,正常情况下不会泄漏。但如果sessionId生成逻辑有问题,比如每次请求都生成新的sessionId,会导致会话数无限增长。检查sessionId的生成策略,确保同一用户的多次请求复用同一个 ID。
向量存储方面,SimpleVectorStore是内存存储,数据量大了会吃内存。生产环境建议换成外部向量库,或者定期清理旧数据。项目里预留了一个MemoryCleanupTask,可以配置定时清理超过 N 天的记忆。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决措施 |
|---|---|---|---|
| 模型不调用工具 | 提示词未强调、工具描述模糊 | 检查请求日志中的 tools 字段 | 优化系统提示词和工具描述 |
| 参数格式错误 | Schema 描述不清晰 | 查看模型返回的 function call | 加 examples,execute 里做容错 |
| 工具调用死循环 | 结果格式模型无法理解 | 检查工具返回内容 | 扁平化返回结果,设最大轮次 |
| 响应超时 | 模型 API 慢或工具执行慢 | 看各环节耗时指标 | 调超时时间,优化慢工具 |
| 内存持续增长 | 会话未过期或向量数据堆积 | 监控会话数和向量数 | 调过期时间,加清理任务 |
| 流式响应中断 | SSE 超时或网络抖动 | 看连接时长和错误日志 | 调超时,前端加重连 |
6. 几个容易被忽略的实操心得
6.1 系统提示词的写法比模型选型更重要
我试过同一个模型,换了一版系统提示词,工具调用的准确率从 60% 提到 90% 以上。提示词里要明确三件事:你是谁、你能做什么、什么时候做什么。不要写“你是一个有用的助手”这种空话,要写“你是一个个人助理,可以查询天气、管理日程、搜索信息。当用户询问实时信息时,必须调用工具,不要依赖训练数据”。
另外,提示词里的指令要具体。比如“回答要简洁”不如“回答控制在 3 句话以内”。“不确定的信息不要编造”不如“如果工具返回的结果不包含用户询问的信息,直接说‘我没有查到相关信息’”。
6.2 工具的数量不是越多越好
工具太多会导致两个问题:一是请求体变大,每次都要把全部工具定义发给模型,浪费 Token;二是模型选择困难,工具之间的功能边界模糊时,容易调错。
建议把工具控制在 10 个以内。如果确实需要很多工具,可以按场景分组,不同场景加载不同的工具集。项目里可以通过ToolRegistry的getToolsForScenario(String scenario)方法来实现按场景过滤。
6.3 日志要打全,但要注意脱敏
排查 Agent 问题时,最有用的是完整的请求和响应日志。但模型请求里可能包含用户隐私信息,日志脱敏不能忘。项目里用了一个LoggingSanitizer,对手机号、身份证号、邮箱等敏感信息做掩码处理。
日志级别建议:开发环境 DEBUG,打印完整的请求和响应;生产环境 INFO,只打印关键节点和耗时,异常时打 ERROR 并附带上下文。
6.4 成本控制要从第一天就做
模型调用是按 Token 计费的,Agent 场景下 Token 消耗比普通对话大得多,因为每次都要带工具定义和历史上下文。项目里加了 Token 消耗统计,每次请求结束后记录输入 Token 数和输出 Token 数,按会话和用户维度聚合。
如果发现某个用户的 Token 消耗异常高,可能是陷入了工具调用循环,或者历史上下文没有正确裁剪。这两个问题在成本报表里都能看出来。
6.5 测试要用真实模型,但也要有 Mock 方案
单元测试里调真实模型 API 又慢又贵,但不调又测不出工具调用的逻辑。折中方案是:核心逻辑用 Mock 的ChatClient测试,确保流程正确;集成测试用真实模型,但只跑少量关键用例。
Mock 的时候要注意,模型的响应格式要模拟真实情况,包括 function call 的 JSON 结构。项目里提供了一个MockChatClient,可以配置返回预设的响应序列,方便测试多轮工具调用的场景。
这个项目后续还可以扩展的方向不少,比如接入更多模型提供商、支持多 Agent 协作、增加更细粒度的权限控制。但核心的架构和实现思路已经比较完整了,拿过来改改就能用在自己的业务里。我在实际搭建过程中最大的体会是:Java 做 Agent 不是能不能做的问题,而是怎么把工程规范带进来,让 AI 能力真正变成系统里一个可维护、可观测、可控制的模块。这个项目在这方面给了一个不错的参考答案。