1. 项目概述:从零构建一个Java版AI智能体框架
最近在折腾AI应用开发,发现市面上的Agent框架要么是Python的天下,要么就是封装得太“黑盒”,想深入理解其内部运作机制得扒好几层皮。作为一个有十多年经验的Java开发者,我决定自己动手,用Java从零开始完整实现一个基于Claude风格API的AI智能体框架——这就是HoppinZQ Agent项目的由来。
这个项目不是一个简单的API封装,而是一个完整的、演进式的教学项目。它通过11个递进式的模块,带你从最基础的Bash命令执行器开始,一步步构建出具备任务管理、上下文压缩、多工具协同、甚至Web服务能力的完整Agent系统。每个模块都是独立的Maven子项目,你可以像搭积木一样,从s01开始,逐个模块学习和实践,亲眼见证一个AI智能体是如何“长大”的。
如果你是一名Java开发者,对AI应用开发感兴趣,但又觉得现有的框架过于复杂或不够透明,那么这个项目就是为你准备的。它不依赖任何“魔法”,所有代码都是清晰可读的Java,核心逻辑不过几百行。通过它,你不仅能学会如何使用大模型API,更能深刻理解Agent背后的设计哲学:工具调用、上下文管理、任务编排这些核心概念到底是怎么落地的。
2. 核心架构与设计理念拆解
2.1 为什么选择Java和Claude风格API?
在开始动手之前,得先想清楚技术选型。为什么用Java?又为什么瞄准Claude的API风格?
首先,Java的生态和工程化能力是毋庸置疑的。我们构建的是一个可能用于生产环境的Agent框架,需要健壮的错误处理、清晰的模块划分、以及易于集成的能力。Spring Boot、MyBatis-Plus这些成熟的Java生态组件,能让我们快速搭建起Web服务和数据持久层,而不用从零发明轮子。更重要的是,这个项目的目标是“教学”,Java严谨的面向对象特性和丰富的调试工具,能让学习路径上的每一步都清晰可见。
其次,选择Claude风格的API,背后有很实际的考量。目前,在代码生成和推理能力上,Claude系列模型(尤其是Claude 3.5 Sonnet)是公认的佼佼者。它的API设计非常优雅,与OpenAI的接口高度相似,这意味着我们的框架在底层上具备很好的兼容性。一个更关键的点是,国产的顶尖模型如智谱GLM-5和DeepSeek,都已经提供了对Anthropic(Claude出品方)API协议的兼容。这意味着,你写好一套基于Claude API的调用逻辑,只需要更换BASE_URL和API_KEY,就能无缝切换到这些国产大模型上,彻底解决了“卡脖子”的访问问题。
实操心得:在项目初期,我就把API调用的抽象层做得足够薄。核心的
ZQAgent基类只依赖一个非常简单的HTTP客户端和JSON解析器。这样设计的好处是,未来如果出现更优秀的API协议或模型,我们可以用最小的代价进行适配,而不是被某一家供应商绑定。
2.2 演进式模块设计:像拼图一样学习
整个项目最精妙的设计,莫过于它的模块化演进路线。它不是一上来就给你一个庞然大物,而是设计了11个独立的模块(s01到s08,外加s13、s14和web),每个模块在前一个的基础上只增加一个核心功能。
你可以把s01模块看作是这个智能体的“胚胎期”。它只有一个能力:执行Bash命令。对应的,代码里也只有一个核心工具类BashTool。这时,Agent的循环逻辑(ZQAgent.runLoop)已经成型:接收用户输入,构造包含工具描述的System Prompt发给大模型,解析模型的返回(可能是纯文本,也可能是工具调用请求),执行工具,将结果追加到对话历史,然后继续循环。
到了s02模块,这个“胚胎”长出了“手”和“眼睛”——我们增加了文件读写、编辑和内容搜索(基于ripgrep)等5个新工具。这时,Agent已经能帮你操作本地文件了。s03模块增加了“记事本”功能,即Todo待办事项管理。你会发现,每个新功能的加入,并不是对原有代码的颠覆性修改,而是在ZQAgent这个稳固的基类之上,通过注册新的ToolDefinition来扩展能力。
这种设计的教学意义巨大。你不需要一次性理解Agent所有的复杂概念。你可以先运行s01,看看最简单的工具调用是怎么工作的;理解透彻后,再进入s02,学习如何设计和集成更复杂的工具。这种循序渐进的方式,极大地降低了学习曲线,也让调试和问题定位变得异常简单——如果新加的功能出了问题,你几乎可以肯定问题就出在新写的那个工具类里。
2.3 统一的核心循环与工具系统
无论模块如何演进,所有Agent都共享同一个心脏——ZQAgent基类中定义的核心运行循环。这个循环的逻辑非常经典,也是理解任何Agent框架的钥匙:
- 初始化:根据当前注册的工具列表,动态生成System Prompt。这个Prompt会告诉大模型:“你现在是一个助手,可以调用以下工具:A、B、C...,调用时请遵循JSON格式。”
- 对话循环:将用户输入和累积的对话历史(Context)一起发送给大模型。
- 解析与分发:解析大模型的返回。如果是普通文本,直接输出给用户,本轮结束。如果是一个工具调用请求(一个结构化的JSON),则进入下一步。
- 工具执行:根据工具名找到对应的Java方法,传入参数,执行真正的业务逻辑(比如执行一条Shell命令、读取一个文件)。
- 结果反馈:将工具执行的结果(成功或失败)格式化成一段自然语言描述,追加到对话历史中。
- 继续或终止:带着包含了工具执行结果的、更丰富的上下文,再次跳回第2步,请求大模型进行下一步推理。循环会一直持续,直到大模型返回纯文本结论,或者达到预设的最大交互轮次。
工具系统是另一个设计亮点。每个工具都是一个普通的Java方法,但需要用@Tool注解进行标记,并定义一个描述其输入参数的Schema类。框架在启动时会扫描这些注解,自动将它们包装成LLM能理解的ToolDefinition对象。这种基于注解和反射的机制,让增加一个新工具变得和写一个Service方法一样简单,开发者只需要关注业务逻辑本身。
3. 核心模块深度解析与实操要点
3.1 基石模块:s01 Bash执行器与Agent循环实现
s01模块是整个项目的起点,代码量最少,但包含了Agent最核心的骨架。我们来看关键部分。
ZQAgent基类的核心循环伪代码:
public void runLoop() { List<Message> conversationHistory = new ArrayList<>(); // 1. 构建系统提示,包含所有工具的描述 String systemPrompt = buildSystemPromptWithTools(); conversationHistory.add(Message.system(systemPrompt)); while (true) { // 2. 获取用户输入 String userInput = getUserInput(); conversationHistory.add(Message.user(userInput)); // 3. 调用LLM,获取响应 LLMResponse response = callLLM(conversationHistory); if (response.isText()) { // 4. 如果是文本,输出并结束本轮 printToUser(response.getText()); break; } else if (response.isToolCall()) { // 5. 如果是工具调用,执行工具 ToolCall toolCall = response.getToolCall(); String toolResult = executeTool(toolCall); // 6. 将工具执行结果作为“系统”或“助手”消息追加到历史 conversationHistory.add(Message.assistant("工具执行结果: " + toolResult)); // 循环继续,让LLM基于结果进行下一步思考 } } }BashTool工具的实现:
@Slf4j public class BashTool { @Tool(name = "execute_bash", description = "在本地执行一条bash命令并返回结果") public String executeBash(@ToolParam(description = "要执行的bash命令") String command) { try { Process process = Runtime.getRuntime().exec(new String[]{"bash", "-c", command}); String stdout = IOUtils.toString(process.getInputStream(), StandardCharsets.UTF_8); String stderr = IOUtils.toString(process.getErrorStream(), StandardCharsets.UTF_8); process.waitFor(); int exitCode = process.exitValue(); return String.format("Exit Code: %d\nStdout:\n%s\nStderr:\n%s", exitCode, stdout, stderr); } catch (Exception e) { log.error("执行命令失败: {}", command, e); return "命令执行失败: " + e.getMessage(); } } }注意事项:在
s01中,一个容易被忽略但至关重要的细节是工具描述的编写质量。@Tool注解里的description和@ToolParam里的description,会直接作为Schema的一部分发送给大模型。这些描述必须清晰、无歧义,并且说明白工具的“边界”。例如,execute_bash的描述明确了是“在本地执行”,这能防止模型产生不切实际的期望(比如让它去操作远程服务器)。模糊的描述会导致模型错误地调用工具或传递错误的参数。
3.2 能力扩展:s02文件操作与s03 Todo管理
s02模块引入了文件操作工具集,这是Agent从“对话机”迈向“执行者”的关键一步。我们新增了read_file,write_file,edit_file,list_files,search_content等工具。其中,search_content工具基于ripgrep(rg命令),提供了强大的代码库内容检索能力,这对于编程助手类的Agent至关重要。
这里以edit_file工具为例,展示如何设计一个“有状态”的复杂工具。它不仅要修改文件,还要能向LLM清晰地展示修改前后的差异(diff),以便LLM能判断修改是否正确,或进行下一步调整。
EditFileTool的核心逻辑:
public class EditFileTool { @Tool(name = "edit_file", description = "编辑文件中的特定内容。需要提供文件路径、要替换的旧文本和新文本。") public String editFile( @ToolParam(description = "文件的绝对路径") String filePath, @ToolParam(description = "文件中需要被替换的旧文本") String oldText, @ToolParam(description = "替换旧文本的新文本") String newText) { try { String originalContent = Files.readString(Paths.get(filePath)); if (!originalContent.contains(oldText)) { return "错误:在文件中未找到指定的旧文本。"; } String newContent = originalContent.replace(oldText, newText); Files.write(Paths.get(filePath), newContent.getBytes()); // 构造一个清晰的diff结果,帮助LLM理解 return String.format("文件编辑成功。\n原内容片段:[...]%s[...]\n新内容片段:[...]%s[...]", abbreviate(oldText), abbreviate(newText)); } catch (Exception e) { return "编辑文件时出错: " + e.getMessage(); } } }s03模块引入了TodoManager,这是一个更高级别的抽象。它不再是简单的原子操作,而是维护了一个在内存中的待办事项列表(List<TodoItem>)。工具包括add_todo,list_todos,mark_todo_done,delete_todo等。这个模块演示了如何让Agent操作和管理一个内部状态。这个状态在Agent的单次会话生命周期内是持久的,使得Agent可以像一个真正的个人助理一样,记住你交代的事情。
实操心得:在实现
s02和s03时,错误处理的友好性变得特别重要。文件可能不存在,文本可能找不到,参数可能格式错误。工具方法必须捕获所有异常,并返回一段LLM能理解的、自然语言描述的错误信息。例如,返回“错误:文件/tmp/test.txt不存在”,而不是一个FileNotFoundException的堆栈信息。这能帮助LLM进行后续的推理和决策(比如提示用户先创建文件)。
3.3 架构进阶:s04子Agent与s05技能系统
从s04开始,项目进入了架构设计的深水区。s04引入了SubAgent的概念。有时候,一个复杂的任务需要专注的、上下文隔离的“子对话”来完成。比如,用户要求“写一个Java单例模式,并解释其线程安全性”,主Agent可以创建一个专注于“代码生成与解释”的子Agent去处理这个子任务。子Agent拥有独立的对话历史,不会污染主对话的上下文。任务完成后,子Agent将最终结果返回给主Agent。
SubAgent的关键设计:
public class SubAgent { private ZQAgent innerAgent; // 内部运行一个独立的Agent实例 private String objective; // 子任务目标 public String run(String objective, String initialInput) { this.objective = objective; // 为子Agent构建一个专属的系统提示,强调其专注范围 String subSystemPrompt = String.format("你是一个专注于解决以下任务的专家:%s。请专注于此任务,完成后给出最终答案。", objective); // 内部Agent运行,拥有独立的历史记录 return innerAgent.runWithPrompt(subSystemPrompt, initialInput); } }s05模块的技能(Skill)系统是另一个精妙设计。它的核心问题是:如何让Agent掌握大量、复杂的知识(比如一个项目的API文档、一套复杂的操作流程),而又不一次性耗尽宝贵的上下文窗口?
项目采用了两层注入的策略:
- 第一层(轻量索引):在System Prompt中,只注入所有技能的名称和简短描述(约100 tokens/技能)。例如:“可用技能:
spring_boot_startup_guide- Spring Boot项目启动指南”。 - 第二层(按需加载):当LLM认为需要某个技能时,它会调用
load_skill工具。此时,框架才会从磁盘或数据库加载该技能的完整内容(可能是几千字的Markdown文档),并将其作为工具执行结果返回给LLM。这样,上下文窗口只在实际需要时才被大量占用。
3.4 性能与工程化:s06上下文压缩与s07/s08任务管理
随着对话轮次增加,上下文会越来越长,最终会触及模型的最大Token限制。s06模块的上下文压缩(Context Compaction)就是为了解决这个问题。它实现了三层策略:
- 微压缩:每次工具调用后,自动将冗长的“工具调用请求”和“原始结果”替换为一句简短的总结,如“已使用bash工具查看了当前目录”。
- 自动压缩:当上下文长度接近阈值时,自动触发。调用LLM对除最近几轮外的历史对话进行总结,用一段摘要替换掉大量旧消息。
- 手动压缩:提供
compact工具,LLM可以在认为对话历史冗长时主动调用,手动触发压缩。
s07和s08模块则展现了Agent的工程化与自动化能力。s07引入了基于有向无环图(DAG)的任务管理系统。你可以描述一个包含多个步骤且步骤间有依赖关系的复杂任务,比如“1. 解析需求,2. 根据需求生成代码,3. 运行单元测试”。TaskManager会解析依赖,并按正确顺序调度任务节点执行。
s08的后台任务更进一步。某些任务(如“监控日志文件中的错误”)是长期运行的。BackgroundManager允许Agent启动一个守护线程在后台执行任务,并通过一个通知队列将后台产生的重要事件(如“发现错误日志”)注入回主Agent的上下文中,从而实现了异步的事件驱动交互。
避坑指南:实现上下文压缩时,最大的坑在于信息丢失。过于激进的压缩会导致LLM失忆。我们的策略是“保新舍旧”和“保留关键指令”。永远保留最新的用户指令和最近几轮交互的完整内容。压缩的目标是那些遥远的、细节性的中间过程。在自动压缩的Prompt中,要明确指示LLM:“请总结对话的目标和目前已完成的进展”,而不是总结所有细节。
4. 高级特性与集成方案实战
4.1 s13 MCP协议集成:连接外部工具宇宙
MCP(Model Context Protocol)是一个新兴的开放协议,旨在标准化LLM与外部工具、数据源之间的连接。你可以把它想象成LLM世界的“USB标准”。s13模块实现了MCP客户端,让我们的Agent能动态发现并调用任何符合MCP标准的服务器提供的工具。
例如,你可以运行一个MCP服务器,它提供了“查询数据库”、“发送邮件”、“控制智能家居”等工具。我们的Agent在启动时,会通过MCP协议连接到这个服务器,自动获取这些工具的Schema并注册到自己的工具列表中。此后,Agent就能像调用内置的bash工具一样,调用“发送邮件”工具。
MCP集成的核心流程:
- 连接:Agent启动时,根据配置(STDIO/SSE/HTTP)连接到MCP服务器。
- 发现:通过MCP定义的
tools/list等接口,获取服务器暴露的所有工具列表及其详细Schema。 - 注册:将这些远程工具动态地包装成本地
ToolDefinition,注册到ZQAgent的工具系统中。 - 调用:当LLM请求调用某个MCP工具时,框架将参数通过MCP协议转发给服务器执行,并将结果返回。
这使得Agent的能力边界得到了无限扩展,而且不需要修改Agent本身的代码。这是构建通用型、可插拔Agent系统的关键。
4.2 s14 ReAct推理模式:让思考过程可见
ReAct(Reasoning + Acting)是一种让LLM将思考过程显式化的提示模式。标准的工具调用是“黑盒”的:用户输入 -> LLM直接返回工具调用。而在ReAct模式下,LLM会先输出一个Thought:(思考),阐述它接下来要做什么以及为什么,然后才是Action:(工具调用)。执行工具得到Observation:(观察结果)后,再进入下一轮Thought。
s14模块实现了这个模式。这不仅让Agent的决策过程更透明、更易于调试,而且对于复杂任务,显式的推理链能显著提高任务完成的准确率。框架通过一个特殊的ReActInput处理器,在System Prompt中植入ReAct的格式要求,并在解析LLM输出时,区分Thought和Action部分。
4.3 agent-web:Spring Boot一体化Web服务
agent-web模块是前面所有能力的集大成者。它基于s08的全部功能,并集成了s14的ReAct模式,然后用Spring Boot包装成一个完整的Web服务。这提供了一个生产级Agent应用的原型,包含以下关键部分:
- RESTful API:提供
/chat接口处理用户对话,支持多轮会话。 - 会话管理:利用Spring Session或数据库,为每个用户或对话线程维护独立的对话上下文。
- 持久化层:使用MyBatis-Plus将对话历史、任务状态、技能库等数据存入MySQL。
- 可配置的技能与工具库:通过数据库管理技能和工具配置,实现动态加载。
一个典型的Web请求处理流程:
- 用户通过HTTP POST发送消息到
/api/chat。 AgentChatController根据会话ID从数据库加载历史上下文。- 创建或获取一个
WebZQAgent实例(它继承了所有核心能力)。 - 将用户消息和历史上下文传入Agent循环。
- Agent与LLM交互,可能调用工具、访问数据库。
- 将最终回复和更新的上下文保存回数据库,并通过HTTP响应返回给用户。
这个模块展示了如何将一个实验性的、命令行驱动的Agent,工程化为一个可扩展、可维护、支持多用户并发访问的在线服务。
5. 实战部署与常见问题排查
5.1 环境配置与快速启动
要让项目跑起来,你需要准备好三样东西:Java 17+、Maven 3.8+、以及一个可用的AI模型API密钥。
第一步:克隆与编译
git clone https://github.com/HOPPINZQ/hoppinai-agent.git cd hoppinai-agent # 编译所有模块 mvn clean compile第二步:关键配置所有模块的API配置都集中在hoppinzq-core模块下的AIConstants.java文件中。你需要根据自己使用的模型服务商来修改它。
// 示例:使用DeepSeek的Anthropic兼容端点 public class AIConstants { // 必填:API的基础地址 public static final String BASE_URL = "https://api.deepseek.com/anthropic"; // 必填:你的API Key public static final String API_KEY = "sk-your-deepseek-api-key-here"; // 必填:模型名称 public static final String MODEL = "deepseek-chat"; // 可选:设置超时、代理等 public static final Duration TIMEOUT = Duration.ofSeconds(60); }如果你使用智谱GLM,只需将BASE_URL和MODEL替换为智谱对应的值即可,代码无需任何改动。
第三步:运行体验
# 运行最基础的s01模块,体验Bash工具调用 mvn exec:java -pl hoppinzq-module-agent-01 -Dexec.mainClass="com.hoppinzq.agent.Agent01" # 运行完整的Web Demo mvn spring-boot:run -pl hoppinzq-module-agent-web运行后,访问http://localhost:8080即可与Web版的Agent进行交互。
5.2 常见问题与解决方案速查表
在实际搭建和运行过程中,你可能会遇到以下典型问题。这里我把自己踩过的坑和解决方案整理出来。
| 问题现象 | 可能原因 | 排查步骤与解决方案 |
|---|---|---|
| 运行后无反应,或立即退出 | 1. API Key或BASE_URL配置错误。 2. 网络问题导致无法连接API端点。 | 1.检查配置:确认AIConstants.java中的API_KEY和BASE_URL无误。对于国内用户,确保BASE_URL是可访问的国内代理或兼容端点。2.测试连接:使用 curl命令或Postman手动调用一次配置的API地址和Key,看是否能返回正确响应或认证错误。 |
控制台报错ToolExecutionException | 工具方法执行时抛出未处理的异常。 | 1.查看详细日志:日志会打印出具体是哪个工具调用失败以及异常堆栈。 2.检查工具逻辑:重点检查文件操作工具的路径权限、Bash命令的环境依赖等。确保所有工具方法都有完善的 try-catch,并返回友好错误信息。 |
| Agent陷入无限循环,不断调用同一个工具 | 1. 工具执行结果未能让LLM理解任务已完成。 2. 工具描述不清,导致LLM误解。 | 1.优化工具输出:工具执行成功后,返回的信息应具有“终结性”。例如,搜索文件后返回“未找到匹配内容”比返回空字符串更好,这能明确告知LLM搜索动作已结束且无结果。 2.审查工具描述:检查 @Tool注解中的description,确保它清晰指明了工具的用途和边界。 |
| 上下文很快耗尽,提示Token超限 | 对话历史过长,未有效压缩。 | 1.启用压缩:确保在s06及之后的模块中,上下文压缩功能是开启的。2.调整压缩策略:在 ContextCompactor中调整autoCompactionThreshold(自动压缩阈值),使其在上下文达到模型限制的70%-80%时触发。3.简化System Prompt:检查是否在System Prompt中注入了过多不必要的指令或技能描述。 |
| Web模块启动失败,数据库连接错误 | application.yml中数据库配置不正确,或MySQL服务未启动。 | 1.检查配置:打开agent-web模块的src/main/resources/application.yml,检查datasource.url,username,password是否正确。2.初始化数据库:项目可能需要执行特定的SQL脚本来建表。查看模块的README或 resources/db目录下的SQL文件。3.简化启动:初次体验可先注释掉数据库相关配置,将对话历史存储在内存中,快速验证Web服务是否正常。 |
| MCP模块连接失败 | MCP服务器未启动,或传输方式(STDIO/SSE/HTTP)配置错误。 | 1.先启动服务器:确保你已经按照MCP服务器(如sqlite-mcp-server)的文档正确启动它。2.核对配置:检查 McpSetting类中的连接配置(如进程命令、SSE URL等)是否与服务器匹配。3.查看日志:MCP客户端在初始化时会尝试连接并列出工具,查看此阶段的日志是定位连接问题的关键。 |
5.3 性能调优与扩展建议
当项目跑通后,你可能会考虑如何让它更快、更稳定、功能更强。
1. 异步化工具调用:目前工具调用是同步的,如果某个工具(如一个慢速的网络请求)执行时间很长,会阻塞整个Agent线程。可以考虑将工具执行器改为异步模式,使用CompletableFuture。当LLM请求调用工具时,立即返回一个“任务已接收”的中间状态,然后通过WebSocket或轮询的方式将最终结果推送给用户。
2. 上下文管理的持久化与向量化:对于agent-web,目前的对话历史是存在数据库里的纯文本。当历史很长时,每次检索和加载效率低。可以考虑:
- 向量化存储:将每一轮对话的文本通过Embedding模型转换为向量,存入如Milvus、Chroma等向量数据库。当需要压缩或总结时,可以进行语义相似度检索,只加载最相关的历史片段,而不是全部。
- 分级存储:将最近的对话放在内存或Redis中,将较久的历史归档到对象存储(如S3)或冷数据库中。
3. 工具的动态热加载:目前工具是在Agent启动时通过扫描注解静态注册的。在生产环境中,你可能希望在不重启服务的情况下增加新工具。可以实现一个ToolRegistry中心,支持通过HTTP API或配置文件动态注册/注销工具定义,并由ZQAgent在运行时刷新其工具列表。
4. 多模型路由与降级:不要只依赖一个模型服务。可以封装一个ModelRouter,根据请求的类型(代码生成、文案创作、逻辑推理)、预算、当前各服务的延迟和可用性,智能地将请求路由到最合适的模型(如Claude、GLM、DeepSeek)。当主服务不可用时,自动降级到备用服务。
这个项目提供了一个坚实而透明的起点。它的价值不在于提供了多少开箱即用的功能,而在于清晰地揭示了一个现代AI Agent框架的每一块骨骼和肌肉是如何生长和协同工作的。你可以把它当作一个超级模板,在此基础上,根据你自己的业务需求,去构建那个独一无二的、智能的“数字员工”。