☰
JavaManus:纯Java实现AI Agent框架,从ReAct循环到生产治理
2026/10/26 6:32:37 网站建设 项目流程

1. 为什么是 Java:Agent 框架成熟之前,生态才是壁垒

1.1 Python 是试验田,Java 是生产农场

Java 里的 Agent 开发,一直有个很尴尬的局面。生态里最有想象力的 AI Agent 框架,几乎全是 Python 写的;企业里跑着几十万行 Java 代码的团队,想给系统接一个能自己分析数据、调用工具、拆解任务的 Agent,要么被迫引入异构微服务,要么花两周时间把大模型 API 包成聊天机器人,然后被业务同事一句话问住:"它能自己干活吗?"

我写 JavaManus,就是想解决这个局面。它是一个用纯 Java 从零实现的 AI Agent 框架,覆盖多模型接入、动态工具调用、任务规划、记忆管理和生产治理,不依赖任何第三方 Agent SDK。这篇文章会把整体设计和实现细节拆开讲,包括关键代码、参数取舍和踩坑经验,适合想给现有 Java 系统接入 Agent 能力的团队,也适合想弄明白 Agent 内部到底在发生什么的同学。

Python 生态确实适合做快速实验,但把它搬进企业核心链路就很难受。一个 Java 团队想要接入 LangChain 这类框架,意味着要额外拉起 Python 服务,单独部署、单独运维、单独审计,还要解决跨语言调用时数据结构不一致的问题。相比之下,Java 团队已经有 Spring Boot、MyBatis、配置中心、链路追踪、权限体系这一整套成熟设施。JavaManus 的思路很直接:让 Agent 成为 Java 应用里的一个普通组件,注解、依赖注入、Spring 容器管理,和写 CRUD 一样自然。这样才能让 Agent 真正长在业务系统里,而不是游离在系统之外。

1.2 JavaManus 的目标不是造一个 SDK,而是造一套运行时

市面上很多"Java Agent SDK"其实只做了模型 API 封装,给一个大模型客户端加几个工具方法,就算 Agent 了。这种方案最大的问题是:没有循环控制,没有工具注册机制,没有记忆管理,没有预算限制,跑几次线上任务就会暴露一堆工程问题。

JavaManus 的目标从一开始就定得很清楚:不做一个聊天 API 的包装器,而是做一套 Agent 运行时。它需要满足五个硬指标:

  • 能和 Spring 无缝集成,工具注册走注解扫描,生命周期交给容器管理。
  • 支持多厂商模型切换,应用层不绑死任何一家大模型 SDK。
  • 工具方法只需一个注解就能暴露给模型,参数 Schema 自动生成。
  • 循环有上限,成本有控制,防止模型在任务上无限自旋。
  • 日志和审计完整,每一步思考、每一次工具调用都可以追溯。

这套目标和做业务接口完全不同。写业务接口时,你假设调用方是可靠的,参数是约定好的;但 Agent 的调用方是一个概率模型,它会漏参数、传错类型、重复调用同样的工具,甚至会在工具返回错误后继续瞎试。所以 Agent 框架本质上是一套"在不可靠调用者之上建立可靠约束"的工程设施。

常见封装方式 JavaManus 聊天机器人(无工具调用) 真正的 ReAct 循环:思考-行动-观察 硬编码 if-else 分支路由工具 注解注册 + 动态 JSON Schema 单厂商 SDK 直接绑定 统一模型网关,可切换任意兼容接口 无记忆或简单拼上下文 滑动窗口 + 摘要压缩 + 长短期记忆 黑盒运行,出问题没法查 结构化日志 + 审计事件 + 预算控制

表格里的每一行,都是我在实际开发中被真实问题逼出来的。没有这一步一步的取舍,JavaManus 最后大概率也会变成一个只会返回文本的"高级聊天接口"。

2. 架构设计:ReAct 循环如何变成一个可维护的 Java 状态机

2.1 Agent 的本质:一个在"思考-行动-观察"之间循环的调度器

要理解 Agent 框架,先别去看那些花哨的概念。Agent 的本质就是一个循环,和客服接线员很像:接到需求,先看手头有哪些工具(知识库、ERP、CRM),想一下要查什么,点击系统,看到结果,再决定下一步。直到拿到足够信息,才给出最终答案。

这个模式在论文里叫 ReAct:Reasoning(思考)和 Acting(行动)交替进行。模型先生成一段思考过程,决定调用哪个工具,框架执行工具并把结果返回给模型,模型看到结果后再生成下一轮思考。和传统的链式调用不一样,这是一个带反馈的闭环。

JavaManus 把循环抽象成了几个语义状态:THINK -> ACT -> OBSERVE -> THINK ... -> FINISH。每个状态有明确入口和出口,这样调度器可以用状态机的方式管理循环。好处是后续无论加人工审核、加多 Agent 协作,都只是往状态机里增加状态转换,不用动摇底层的循环骨架。

2.2 几个核心抽象接口,先把边界定死

写框架第一件事是定接口,接口定得好,后面换模型、换工具、换记忆策略都不伤筋动骨。JavaManus 的核心抽象只有四个:

public interface LLMProvider { LLMResponse chat(List<ChatMessage> history, List<FunctionSchema> tools); CompletableFuture<StreamEvent> chatStream(List<ChatMessage> history, List<FunctionSchema> tools); } public interface AgentTool { String name(); String description(); JsonSchema inputSchema(); ToolResult execute(String jsonArgs); } public interface Memory { void append(ChatMessage message); List<ChatMessage> buildMessages(String task, List<FunctionSchema> tools); } public interface AgentNode { AgentState evaluate(AgentContext ctx); }

LLMProvider 是为了屏蔽厂商差异。无论接 OpenAI 兼容接口、通义、DeepSeek 还是智谱,只要实现这个接口,AgentRunner 完全不感知底层换了谁。AgentTool 是业务方法与模型之间的桥梁,一个工具就是一个 Java 方法,有名字、有描述、有入参 Schema。Memory 管理上下文,是实现滑动窗口和摘要压缩的隔离层。AgentNode 更灵活,预告阶段、规划阶段、最终回答审核,都可以作为节点挂进循环。

接口定得简单是有意的。太复杂的接口会让实现者望而却步,太简单的接口又表达不了 Agent 的复杂度。现在这套边界,已经足够支撑我在生产环境里跑数据分析、报表生成、定时巡检这些真实场景。

2.3 循环必须有限度,不计代价的 Agent 一定会烧钱

Agent 循环是 token 消耗的放大器。模型每多思考一轮,就要多付一次输入费用,如果工具返回结果很大,这个费用还会翻倍涨。我见过不止一次,模型因为工具报错不肯放弃,反复用同一个参数重试同一个工具,几分钟烧掉几十块。

所以 AgentRunner 的默认策略很严格:maxSteps 默认 12,每次循环前检查剩余步骤和累计 token,超过预算直接中断,把已生成的部分结果和审计摘要返回给调用方。同时支持外部锁:调用方可以在 Agent 进入某个工具前挂起循环,等人审批通过再 continue。比如删除文件、发起支付这类高风险动作,必须加人工确认。

经验值是:简单任务 maxSteps 设 6 就够,数据分析类任务设 12,超过 15 步还在转的,多半不是模型不聪明,而是工具设计有问题——要么工具描述不清,要么入参让模型猜不中,要么工具本身一步能完成的事被拆碎成十步。

3. 关键模块实现:每一个细节都值得写出来

3.1 模型网关:让应用与厂商 SDK 解耦的接入层

我先说结论:Agent 框架的模型接入层一定要自己做,不要在业务代码里直接 new 厂商客户端。这不是为了秀技术,而是为了统一三件事:流式与非流式调用、错误码与重试策略、工具调用参数格式。

JavaManus 里最常用的实现是 OpenAICompatibleProvider,走标准 HTTP 接口。市面上大部分主流模型都兼容这个协议,所以一个实现类就能接多家模型,配置中心里改一个 model 名和 base url 就切换完成。

public class OpenAICompatibleProvider implements LLMProvider { private final HttpClient http; private final ObjectMapper mapper; private final ModelConfig config; @Override public LLMResponse chat(List<ChatMessage> history, List<FunctionSchema> tools) { Map<String, Object> body = new HashMap<>(); body.put("model", config.getModel()); body.put("messages", history.stream().map(ChatMessage::toMap).toList()); if (tools != null && !tools.isEmpty()) { body.put("tools", tools.stream().map(FunctionSchema::toMap).toList()); } body.put("temperature", config.getTemperature()); // 发送 HTTP 请求,解析 choices[0].message,识别 content 与 tool_calls } }

这里有两个容易被新人忽略的细节。第一是 token 的理解:token 是模型计费基本单位,也是上下文窗口的单位。中文场景下,1 个汉字通常约等于 1 到 2 个 token,字节少说也要 4 到 5 个 token。所以做上下文管理时,不要用字符数估算,要用 token 估算器。第二是 temperature 的选择:工具调用场景必须调低,0 到 0.3 最合适;只有让模型写文案时才调到 0.7 以上。如果开了 JSON mode 还习惯性用高温度,模型很容易输出不合法 JSON,或者在 JSON 里塞进大量废话。

3.2 工具注册中心:把 Java 方法暴露给大模型,且只用注解

模型要知道"有哪些工具、每个工具参数长什么样",才能决定调用哪个。JavaManus 使用 @AgentTool 注解标记方法,Spring 启动时扫描注册到 ToolRegistry。这个设计的好处是,业务团队完全不需要懂 Agent 内部机制,在 Service 层写一个普通方法,加一行注解,方法就变成了可被模型调用的资源。

@AgentTool(name = "query_sales", description = "查询销售统计信息,可根据日期范围和最小金额过滤,返回总金额与订单数", paramClass = SalesQuery.class) public SalesSummary querySales(SalesQuery query) { // 业务逻辑与普通 Service 方法完全一致 } public record SalesQuery(String startDate, String endDate, Double minAmount) {}

工具注册后,框架会用 Jackson 和反射扫描参数类型,自动生成 JSON Schema。SalesQuery 会生成类似这样的结构:

{ "type": "object", "properties": { "startDate": {"type": "string", "description": "开始日期,格式 yyyy-MM-dd"}, "endDate": {"type": "string", "description": "结束日期,格式 yyyy-MM-dd"}, "minAmount": {"type": "number", "description": "最低金额"} }, "required": ["startDate", "endDate"] }

如果你以前只写过普通 Java 方法,可能觉得这一步可有可无。但实际跑过 Agent 后会发现,工具 description 写得细不细,直接决定模型会不会用。拿"查询销售"和"查询指定日期范围内、可按最小金额过滤的销售记录,用于月度报表分析,其中 startDate 必填"相比,后者的工具被正确调用的概率高一倍以上。模型是靠 description 理解工具语义的,这里偷懒,后面就是无穷无尽的参数错误。

3.3 记忆管理:上下文长度永远是 Agent 的第一敌人

把历史消息无限往上下文里堆,是 Agent 开发最容易犯的错误。堆到一定程度,模型会出现两个问题:一是超出上下文窗口直接被拒,二是即便没超,注意力也会被早期对话稀释,忘了最初的任务目标。

JavaManus 的记忆分两层:短期滑动窗口和摘要压缩。滑动窗口保留最近 N 轮完整消息,窗口之前的内容会被压缩成一段摘要。压缩动作由 LLM 执行,摘要要保留任务目标、已经完成的动作、当前卡点,比如"用户已确认数据范围为 2024 年 1 月到 6 月,当前正在筛选异常订单"。这个摘要成本不高,但能把长期记忆压缩在几百个 token 内。

压缩时刻的选择有讲究。JavaManus 会在一次工具调用完成之后、下一次模型思考之前触发压缩,因为这时候历史里的原始工具结果往往又长又杂,压缩后模型反而更专注。工具返回结果本身也要做截断:CSV 文件不能整个塞进上下文,读取工具只返回列名、总行数和前 10 行样本,真正的明细数据通过另一个查询接口按条件获取。这套组合拳打下来,一个 50 步的长任务也能把输入 token 控制在一个可控范围内。

3.4 任务规划器:让 Agent 自己决定要不要拆解任务

不是所有任务都需要规划。简单的"翻译一句话"直接让模型回答就行,但"读取销售文件、按月汇总、绘制折线图、输出报告"这种任务,模型如果直接开干,经常会做完第一步就不知道怎么衔接下一步。

JavaManus 内置一个 Planner Prompt:把用户任务、可用工具列表传给模型,要求它先输出一份 JSON 形式的计划,包含步骤、每个步骤用的工具和预期产物。模型如果认为任务一步可完成,就输出单步计划,相当于没有额外开销。只有在复杂任务里,规划层才真正发挥价值。

规划结果不是神圣不可改的。执行过程中如果某一步失败,后续计划要允许重新生成。所以 Planner 产出的东西,我更愿意叫"任务上下文",而不是硬编码的流水线。它让 Agent 在宏观上保持方向感,又不会因为中间步骤偏差导致整条链路崩溃。

3.5 执行器与异常恢复:最核心的循环代码也就 40 行

所有前面提到的抽象,最终都要汇到 AgentRunner 这个执行器里。它的核心逻辑很简单,一个 while 循环,但边界条件非常关键。

public AgentOutput run(AgentContext ctx) { int usedSteps = 0; while (usedSteps < ctx.getMaxSteps()) { if (ctx.getBudget().isExhausted()) { throw new BudgetExceededException(ctx.getBudget().snapshot()); } List<ChatMessage> history = memory.buildMessages(ctx.getTask(), ctx.getTools()); LLMResponse response = provider.chat(history, ctx.getTools()); ctx.getAuditor().trace("step", usedSteps, response); if (response.hasToolCalls()) { for (ToolCall call : response.getToolCalls()) { ToolResult result = executeSafely(call); memory.append(ToolMessage.of(call.getId(), result.toJson())); } } else { memory.append(AssistantMessage.of(response.getContent())); return AgentOutput.finish(response.getContent(), ctx.getAuditor().summary()); } usedSteps++; } throw new MaxStepsExceededException(usedSteps); }

executeSafely 这个细节很值得说。工具执行异常不能直接让循环崩溃,要把异常信息转换成 ToolResult 返回给模型,让模型看到错误后有机会修正参数或者换一个工具。但也要防重试炸弹:同一个工具、同样的参数连续失败两次,就必须终止循环,否则模型会在一个无解的路上反复烧钱。这个启发式规则是我在真实任务里被折磨出来的,现在写在了框架默认行为里。

4. 完整示例:让 JavaManus 分析 CSV 销售报告并自动生成图表

4.1 项目依赖准备

先给一个最小可运行的工程结构,JDK 17 + Spring Boot 3.2,核心依赖只需要 web 和 jackson。JavaManus 本身不需要 Spring Boot,但生产环境里配合 Spring 用最省事,工具扫描直接走注解。

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> </dependency>

然后注册两个业务工具。第一个负责读 CSV,返回列名、行数和样本数据;第二个负责把图表配置转成 PNG 图片并保存到指定目录。

@AgentTool(name = "read_csv", description = "读取CSV文件并返回列名、总行数、前10行样本,用于数据分析", paramClass = CsvPath.class) public CsvSummary readCsv(CsvPath path) { // 用 Commons CSV 解析文件,返回摘要信息 } public record CsvPath(String path) {} @AgentTool(name = "render_chart", description = "根据 JSON 图表配置生成PNG图片,并保存到指定目录,返回图片路径", paramClass = ChartConfig.class) public String renderChart(ChartConfig cfg) { // 按配置绘制折线图或柱状图 }

工具就位后,启动类里扫描 @AgentTool 注册,一切准备就绪。

4.2 跑起来:一个自然语言任务

给 Agent 的任务是:"读取 /data/sales.csv,分析每个月的销售额,绘制折线图并存到 /output。"

运行时日志看起来是这样:

[step0] 思考:用户要求先读取CSV,然后按月汇总销售额,最后绘图 [step0] 调用 read_csv {"path": "/data/sales.csv"} [step0] 观察:文件包含 1200 行,列:date, amount, region [step1] 思考:数据包含日期和金额,需要按月聚合,但我没有现成的月度聚合工具 [step1] 分析:先汇总每月的 amount 总和,再构造折线图配置 [step1] 回答:已用摘要信息完成月度汇总 [step2] 调用 render_chart {"config": "{折线图配置...}"} [step2] 观察:图片已生成到 /output/sales_trend.png [step3] 回答:图表已生成,路径为 /output/sales_trend.png

这个例子看起来平淡,但内部发生了几件很核心的事:模型没有一跳到底直接画图,而是先读取数据确认列名;发现没有聚合工具时,没有报错退出,而是用已有数据自己完成计算;最终调画图工具时,参数是一次成功的。这就是一个完整的 ReAct 循环,思考、行动、观察、再行动,最后收敛到答案。

4.3 可控性:Agent 操作文件必须加安全边界

上面例子中 render_chart 会把文件写到 /output,生产环境里这种写文件动作不能完全放权。JavaManus 支持工具权限标记,比如 render_chart 标记为"需要确认",那么 Agent 调用它时,循环会先进入 WAIT_USER_CONFIRM 状态,把工具参数发给审批方,审批通过后才真正执行。日常场景我会建议:只读类工具全自动放行,写文件、发消息、删数据、调支付这些工具全部走人工确认,宁慢勿错。

5. 生产环境踩坑记录:这些问题我不会让你再踩一遍

5.1 JSON 响应被模型截断

模型输出半截 JSON 导致工具参数解析失败,这个坑我遇到不下十次。根因基本是两种:max_tokens 开太小,模型生成到一半被截断;或者 temperature 太高,模型开始自由发挥。解法是三层:解析失败时把原始片段作为错误信息返回给模型,请它补全或重新生成;maxTokens 给到 2048 以上,别让模型挤牙膏;要求模型开启 JSON mode,并且在 System Prompt 里写明"只输出 JSON 对象,不要输出解释文字"。

5.2 工具并发调用的线程安全

模型一次响应里可能带多个 tool_calls,并行执行确实能提速,但工具内部不一定是线程安全的。我踩过的一个典型事故是:同一个 CSV 读取工具被并发调用时,多个线程同时初始化解析器,直接撑爆了内存。解法是给工具注册中心加信号量,默认并发度 4,同时对共享资源加锁,工具实现尽量设计成无状态。

private final Semaphore semaphore = new Semaphore(4); public ToolResult execute(String args) { semaphore.acquire(); try { // 业务逻辑 } finally { semaphore.release(); } }

在调试期并发度可以设 1,先保证正确,再谈性能。

5.3 循环爆炸:一个死循环让我亏掉 5 美元

这个教训印象太深了。早期版本没设最大步骤限制,模型接到一个工具持续报错的任务后,用同一个参数反复调用同一个工具十几次,我眼睁睁看着费用往上涨。后来在 AgentRunner 里加了 maxSteps 默认值 12,又加了"同参数连续失败两次立即终止"的规则,才把这种事故堵死。强烈建议任何 Agent 框架都把循环终止当成基础设施,而不是可选配置。

5.4 函数调用参数类型不匹配

模型传参经常不按常理出牌,比如把金额写成 "1,000" 而不是 1000,日期写成 "7月15日" 而不是 "2024-07-15"。参数反序列化失败以后,我的做法是把异常信息、期望的 JSON 格式、一个正确示例一并返回给模型,让它自己修正。大部分情况下模型看到示例后能很快纠正。另外,工具 description 里必须写清楚格式约定,比如"金额单位元,日期格式 yyyy-MM-dd",这能从根本上减少参数错误。

5.5 流式输出与工具调用同时出现的撕裂感

有的厂商流式接口会同时推送文本片段和工具调用事件。如果前端一股脑把事件平铺渲染,用户会看到 "好的,我来查一下..." 后面突然没有任何下文,或者冒出 JSON 片段,体验很割裂。解决思路是把 AgentRunner 输出的事件分成四类:text_delta、tool_call、tool_result、final_answer,UI 层按事件类型分别渲染。text_delta 进对话气泡,tool_call 显示成一个"正在调用某工具"的缩略卡,tool_result 默认折叠,只显示最终结果。这个设计对后端同学来说多花了一点功夫,但对用户感知的提升是决定性的。

5.6 日志是 Agent 的显微镜

调试 Agent 没有日志就是盲人摸象。JavaManus 每个循环步骤都会输出结构化日志,包含步骤号、输入 token、输出 token、调用的工具名、参数摘要、结果摘要、耗时和成本估算。生产环境用 traceId 串起一次完整 Agent 会话,出了问题按 traceId 一查到底。工具参数和结果要脱敏,尤其是涉及用户数据或财务数据时,原始值不能直接进日志,否则审计那一关就过不了。

6. 生产落地:从 Demo 到可管控运行的最后一公里

6.1 观测与审计:让 Agent 的每一步都有脚印

Demo 阶段的 Agent 跑通一次任务不难,难的是上线之后你还能讲清楚它每一步做了什么。JavaManus 预留了 Auditor 接口,每次工具调用、每轮模型请求都会产生审计事件。对于涉及资金、隐私、生产变更的场景,还强制要求 human in the loop:Agent 进入 WAIT_CONFIRM 状态后挂起,管理员审核通过才恢复。从外面看,Agent 就是一个有状态机的任务执行引擎,每一步都沉淀为一条审计记录,出了事故能复盘,监管来了也能交代。

6.2 限流、成本控制与权限隔离

模型调用是花钱的,不设预算就是给业务埋雷。JavaManus 的 Budget 支持三种限制:单次任务最大 token 数、最大估算费用、最大执行时长。任何一个超限,AgentRunner 立刻中断并返回 CONTEXT 摘要。工具本身也要做权限隔离,我给 @AgentTool 增加了 allowRoles 属性,比如普通用户角色不允许触碰 delete_file、send_message 这类敏感工具。模型调用工具的权限边界,应该和用户操作权限边界完全一致,否则 Agent 就成了绕过权限系统的后门。

6.3 后续扩展:多 Agent 协作与 MCP 接入

AgentRunner 本身也能作为一个工具被另一个 Agent 调用,这样就实现了多 Agent 嵌套。主管 Agent 拆解任务后,把子任务连同参数发给数据分析 Agent,然后等待结果,再决定下一步。这种拆法比把所有逻辑压进一个提示词要清晰得多。工具模型再往前走一步,还能对接 MCP 协议:把 MCP Server 暴露的 tool 列表映射成 FunctionSchema,把 execute 转发给 MCP 客户端,外部能力生态就能直接装进 JavaManus。

最后说点个人体会。JavaManus 从立项到跑通第一个完整任务,前后花了两周,后续生产化又打磨了很久。踩坑最多的地方不是模型调用,而是工程治理:循环要不要停、工具能不能并发、日志够不够审计。Agent 框架真正难的地方不是让模型变聪明,而是在模型行为不可控的前提下,让系统整体可控。如果你也在用 Java 搞 Agent,我的建议是别一上来就追全家桶框架,先把 ReAct 循环、工具注册和预算控制这三个底盘做扎实,后面的所有高阶功能都是在这个底盘上长出来的。框架本身不复杂,复杂的是你愿不愿意把边界一点一点钉死。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询