1. 为什么我要用 Java 从头写一个 AI Agent 框架
1.1 一个被忽视的事实:AI Agent 不只有 Python
过去一年多,AI Agent 这个词几乎被 Python 生态垄断了。LangChain、AutoGPT、CrewAI、AutoGen,清一色 Python。你在技术社区里搜"Agent 框架",翻十页可能都翻不到一个像样的 Java 实现。但现实情况是,大量企业的核心业务系统跑在 Java 上——交易系统、风控引擎、订单中台、ERP、CRM,这些系统里有海量的业务逻辑、数据接口、权限体系,它们不可能为了接一个 Agent 就整体重写成 Python。
我所在的团队就遇到过这个尴尬。我们有一套跑了七八年的订单处理系统,用 Spring Boot 写的,里面沉淀了几十个内部服务接口。业务方希望在这套系统上叠加一个"智能调度助手",能根据自然语言指令自动查询订单、触发退款、生成对账报表。用 Python 另起一个服务?那就意味着要重新对接所有内部接口、重新做鉴权、重新处理事务一致性,成本高得离谱。更现实的做法是:直接在 Java 侧构建 Agent 能力,让 Agent 天然就能调用现有的 Service 层方法。
这就是 JavaManus 这个项目的起点。它不是要做一个"Java 版的 LangChain",而是想回答一个具体问题:如何用 Java 的技术栈,构建一个能真正嵌入企业现有系统的 AI Agent 运行时。核心目标有三个:第一,Agent 的推理循环(Reasoning Loop)要清晰可控,不能是个黑盒;第二,工具调用(Tool Calling)要能直接映射到 Java 方法,最好用注解就能注册;第三,整个框架要能塞进一个 Spring Boot 应用里,不引入额外的运行时依赖。
适合谁看这篇内容?如果你是有 Java 背景、想在自己的系统里落地 Agent 能力的后端工程师,或者你正在评估"到底该用 Python 还是 Java 做 Agent",那这篇从设计到实现的拆解应该能给你一些参考。我不会只讲概念,会把关键代码结构、参数选择、踩过的坑都摊开说。
1.2 JavaManus 的整体定位与能力边界
先把边界划清楚,免得读者产生不切实际的期待。JavaManus 是一个轻量级 Agent 运行时框架,它不提供大模型本身,不提供向量数据库,也不做复杂的多 Agent 编排。它做的事情是:接收一个用户目标,驱动大模型进行多轮推理,在推理过程中按需调用注册好的 Java 工具方法,最后把结果汇总返回。
用一句话概括它的工作流:用户输入 → 构造 Prompt → 调用 LLM → 解析输出 → 判断是否需要调用工具 → 执行 Java 方法 → 把结果回填 → 继续推理 → 直到得出最终答案。这个循环就是 Agent 的心脏,业界通常叫 ReAct(Reasoning + Acting)模式。JavaManus 在这个基础上做了几处针对 Java 场景的优化,后面会详细展开。
它的能力边界也很明确:单 Agent、单轮任务为主,支持多轮工具调用;工具必须是同步或可等待的 Java 方法;不内置记忆持久化,但预留了扩展接口。这些取舍都是刻意的——先把最核心的推理循环做扎实,比堆一堆花哨功能更重要。
2. 核心架构设计与技术选型背后的取舍
2.1 为什么是 ReAct 而不是 Plan-and-Execute
Agent 的推理模式有好几种主流范式。最简单的是单轮 Function Calling,模型直接输出要调用的函数和参数,执行完就结束。复杂一点的是 Plan-and-Execute,先让模型制定完整计划,再逐步执行。还有 ReAct,边想边做,每一步都根据上一步的结果决定下一步。
我最终选了 ReAct,理由很实际。Plan-and-Execute 看起来优雅,但它对模型的规划能力要求很高,一旦第一步计划错了,后面全盘皆输,而且中间出错很难纠正。Function Calling 单轮又太弱,处理不了"先查订单,根据订单状态决定是否退款,退款后再发通知"这种链式任务。ReAct 的"思考-行动-观察"循环天然适合这种场景:模型每一步都能看到上一步的真实结果,动态调整策略。
代价是 ReAct 的 token 消耗更高,因为每一轮都要把历史对话重新塞进上下文。实测下来,一个三到五步的任务,token 消耗大概是单轮调用的三到四倍。这个成本我认为可以接受,因为任务成功率提升带来的收益远大于 token 成本。如果你的场景对成本极度敏感,可以考虑加一个"简单任务走单轮、复杂任务走 ReAct"的路由层,这是后话。
2.2 工具注册:用注解把 Java 方法变成 Agent 能力
这是 JavaManus 我觉得最值得讲的设计。Python 生态里注册工具通常要写一个 JSON Schema 描述函数签名,或者用装饰器。Java 有更强的类型系统,完全可以做得更优雅。
我的方案是自定义一个@AgentTool注解,加在 Spring Bean 的方法上。框架启动时扫描这些注解,通过反射提取方法名、参数类型、参数描述,自动生成符合 OpenAI Function Calling 格式的 JSON Schema。开发者只需要写正常的业务方法,加个注解,这个方法就变成了 Agent 可调用的工具。
@Component public class OrderTools { @AgentTool(description = "根据订单号查询订单详情") public OrderDetail queryOrder( @ToolParam(description = "订单号,格式为 ORD 开头") String orderId) { return orderService.getById(orderId); } @AgentTool(description = "对指定订单发起退款") public RefundResult refundOrder( @ToolParam(description = "订单号") String orderId, @ToolParam(description = "退款金额,单位元") BigDecimal amount) { return refundService.refund(orderId, amount); } }这样做的好处是,工具的定义和实现完全在一起,不会出现"Schema 写的是 A,实际方法做的是 B"这种不一致。而且参数类型由 Java 编译器保证,BigDecimal就是BigDecimal,不会像动态语言那样传进来一个字符串还得手动转。
2.3 大模型接入层的抽象设计
JavaManus 不绑定任何一家模型厂商。我定义了一个LlmClient接口,核心方法就一个:接收消息列表和工具定义,返回模型的响应(包含文本内容或工具调用请求)。具体的实现类可以是 OpenAI 兼容协议的、可以是国内某厂商的、也可以是本地部署模型的。
public interface LlmClient { LlmResponse chat(List<Message> messages, List<ToolDefinition> tools); }这个抽象层的关键在于统一工具调用的返回格式。不同厂商的 API 在工具调用的字段命名上略有差异,有的叫tool_calls,有的叫function_call,参数有的是 JSON 字符串有的是对象。我在实现层做了归一化,上层推理循环只认一种内部格式。这样换模型只需要换一个实现类,推理逻辑一行不用改。
提示:接入层一定要做超时和重试。大模型 API 的响应时间波动很大,我遇到过正常 2 秒返回、偶尔卡到 30 秒的情况。建议单次调用超时设 60 秒,失败重试 2 次,重试时带上指数退避。
3. 推理循环的完整实现与关键细节
3.1 消息模型与上下文管理
Agent 的上下文本质上是一个消息列表,每条消息有角色(system、user、assistant、tool)和内容。这个模型看着简单,但有几个细节直接决定 Agent 能不能正常工作。
第一个细节是system prompt 的构造。它要告诉模型三件事:你是一个能调用工具的助手;你有哪几类工具可用;调用工具时必须严格按格式输出。我踩过的坑是,如果 system prompt 写得太啰嗦,模型反而容易忽略工具调用格式;写得太简略,模型又不知道该调工具。最后的经验是:工具列表由框架自动注入,system prompt 只保留角色定义和格式约束,保持精简。
第二个细节是工具结果的回填格式。模型发起工具调用后,执行结果要以tool角色的消息追加到上下文,并且必须带上对应的tool_call_id。如果这个 id 对不上,模型会认为工具没被调用,然后重复调用同一个工具,陷入死循环。我早期就因为这个 id 匹配问题,让 Agent 连续查了八次同一个订单。
第三个细节是上下文长度控制。ReAct 循环跑久了,消息列表会越来越长。我的做法是保留 system prompt、原始用户输入、以及最近 N 轮的消息,中间的历史做摘要压缩。N 默认设 10,这个值可以根据模型上下文窗口调整。
3.2 单步推理的执行流程拆解
把一次完整的推理循环拆开看,大概是这么几步。第一步,把当前消息列表和工具定义发给 LLM。第二步,拿到响应后判断类型:如果响应里有工具调用请求,进入工具执行分支;如果只有文本内容,说明模型认为任务完成,循环结束。
第三步是工具执行。这里有个容易忽略的点:模型可能一次请求调用多个工具。比如它可能同时想查订单和查用户信息。我的实现是支持并行执行的,用线程池把多个工具调用并发跑,然后按顺序把结果回填。但要注意,如果工具之间有依赖关系(比如第二个工具的参数依赖第一个工具的结果),模型通常不会一次请求两个,所以并行执行是安全的。
第四步是结果回填和循环判断。把工具执行结果追加到消息列表后,检查是否达到最大循环次数(默认 15 轮)。如果超了还没结束,强制中断并返回当前已有的信息,避免无限循环烧 token。
public AgentResult run(String userInput) { List<Message> messages = new ArrayList<>(); messages.add(systemMessage()); messages.add(userMessage(userInput)); for (int i = 0; i < maxIterations; i++) { LlmResponse response = llmClient.chat(messages, toolDefinitions); messages.add(response.toAssistantMessage()); if (!response.hasToolCalls()) { return AgentResult.success(response.getContent()); } List<ToolResult> results = executeTools(response.getToolCalls()); for (ToolResult r : results) { messages.add(toolMessage(r)); } } return AgentResult.maxIterationsReached(messages); }3.3 工具执行的安全护栏
让大模型直接调用你的 Java 方法,这件事本身就带着风险。模型可能传进来一个不存在的订单号,可能传一个负数金额,甚至可能因为幻觉调用一个不该调用的方法。所以工具执行层必须加护栏。
我的做法分三层。第一层是参数校验,在反射调用前,根据@ToolParam上声明的约束(非空、范围、正则)先校验一遍,不通过就直接返回错误信息给模型,让它重新决策。第二层是权限标记,给@AgentTool加一个requiresConfirmation属性,标记为 true 的工具(比如退款、删除)不会直接执行,而是挂起等待人工确认。第三层是异常兜底,工具方法抛出的任何异常都被捕获,转成结构化的错误消息回填给模型,绝不让异常穿透到推理循环外面。
注意:千万不要让 Agent 直接调用没有幂等保护的写操作。模型重试是常态,一个没有幂等的退款接口被调用两次,后果很严重。要么在工具方法内部做幂等,要么在框架层对相同参数的调用做去重。
4. 实操落地:从零跑通一个订单助手
4.1 环境准备与依赖引入
JavaManus 本身依赖很轻,核心只需要 JDK 17 以上、一个 HTTP 客户端(我用的是 Java 内置的 HttpClient)、以及 JSON 处理库(Jackson)。如果要用注解扫描和依赖注入,再加 Spring Context。下面是最小依赖配置。
<dependencies> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.0</version> </dependency> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context</artifactId> <version>6.1.0</version> </dependency> </dependencies>JDK 版本我强烈建议 17 起步。原因有两个:一是 record 类型让消息模型的定义简洁很多;二是 sealed interface 能优雅地表达"响应要么是文本要么是工具调用"这种互斥结构。用 JDK 8 也能写,但代码会啰嗦不少。
4.2 定义第一个工具并注册
假设我们要做一个订单助手,先定义查询订单的工具。注意方法上的注解和参数描述,这些描述会直接变成给模型看的 Schema,写得越清楚,模型调用越准确。
@Component public class OrderAgentTools { private final OrderRepository orderRepository; public OrderAgentTools(OrderRepository orderRepository) { this.orderRepository = orderRepository; } @AgentTool(description = "根据订单号查询订单的当前状态、金额和创建时间") public OrderInfo queryOrder( @ToolParam(description = "订单号,以 ORD 开头,例如 ORD20240101001") String orderId) { Order order = orderRepository.findByOrderId(orderId); if (order == null) { throw new ToolExecutionException("订单不存在: " + orderId); } return new OrderInfo(order.getId(), order.getStatus(), order.getAmount(), order.getCreatedAt()); } }这里有个实操心得:参数描述里给例子。我一开始只写"订单号",模型经常传进来一个纯数字或者带空格的字符串。后来改成"以 ORD 开头,例如 ORD20240101001",调用准确率明显提升。模型对示例的敏感度比抽象描述高得多。
4.3 组装 Agent 并跑通第一个任务
把工具注册进框架,配置好 LLM 客户端,就可以跑了。下面是一个完整的启动示例。
public class OrderAgentDemo { public static void main(String[] args) { LlmClient llmClient = new OpenAiCompatibleClient( System.getenv("LLM_API_KEY"), System.getenv("LLM_BASE_URL"), "your-model-name" ); AgentRuntime runtime = AgentRuntime.builder() .llmClient(llmClient) .toolScanner(new AnnotationToolScanner("com.example.tools")) .maxIterations(15) .build(); AgentResult result = runtime.run("帮我查一下订单 ORD20240101001 现在是什么状态"); System.out.println(result.getContent()); } }跑起来之后,你会看到 Agent 的执行日志大概是这样的:第一轮模型输出"我需要查询订单",发起工具调用;框架执行queryOrder方法,拿到订单信息;第二轮模型看到订单信息,输出"该订单当前状态为已发货"。整个过程两轮推理,一次工具调用,耗时取决于模型响应速度。
4.4 参数选择与性能调优记录
几个关键参数的实测经验。maxIterations设 15 是权衡的结果,设太小复杂任务跑不完,设太大出问题时烧 token 多。我统计过,绝大多数正常任务在 3 到 6 轮内结束,15 轮足够覆盖异常情况。工具执行的线程池大小设 4 到 8 就够,因为模型一次请求的工具调用很少超过 3 个。
超时设置上,LLM 调用超时 60 秒,工具执行超时 30 秒。工具超时这个值要根据你的实际业务调整,如果工具有数据库慢查询,得相应放宽。我建议给每个工具单独配置超时,而不是全局一个值,因为查询类工具和计算类工具的合理耗时差异很大。
5. 常见问题排查与避坑经验实录
5.1 模型不调用工具或反复调用同一个工具
这是最高频的问题。模型不调用工具,通常是 system prompt 里工具描述不够清晰,或者模型本身对 Function Calling 支持不好。解决办法是先确认模型支持工具调用,然后检查工具描述是否具体。反复调用同一个工具,九成是tool_call_id没对上,模型以为上次调用失败了。检查回填消息里的 id 是否和请求里的完全一致。
5.2 工具参数解析失败
模型返回的参数是 JSON 字符串,解析时可能遇到类型不匹配。比如模型把金额100传成了字符串"100"。我的处理是在参数绑定层做宽松转换:目标类型是数字就尝试 parse,是布尔就识别 "true"/"false"。但宽松转换要有边界,转换失败必须返回明确错误,不能静默吞掉。
5.3 上下文爆炸与 token 超限
长任务跑到后面,消息列表可能超出模型上下文窗口。除了前面说的摘要压缩,还有一个技巧是工具结果裁剪。如果工具返回的是一个巨大的 JSON,只保留模型决策需要的字段,其余截断。我遇到过查询接口返回几百行数据,直接把上下文撑爆的情况,裁剪后问题解决。
| 问题现象 | 最可能原因 | 排查方向 |
|---|---|---|
| 模型不调用工具 | 工具描述模糊或模型不支持 | 检查描述、确认模型能力 |
| 反复调用同一工具 | tool_call_id 不匹配 | 核对回填消息的 id |
| 参数解析失败 | 类型不匹配 | 加宽松转换与错误返回 |
| 上下文超限 | 历史消息过长 | 摘要压缩 + 结果裁剪 |
| 任务中途卡死 | 工具执行阻塞 | 加工具级超时 |
5.4 几个我踩过的坑
第一个坑是工具方法里用了事务。Agent 调用工具时,如果工具方法带@Transactional,而工具执行又是在独立线程池里跑的,事务上下文传不过去,导致写操作不生效。解决办法是把事务边界放在工具方法内部显式管理,或者让工具执行复用调用方线程。
第二个坑是日志打印了完整上下文。调试时图方便把整个消息列表打出来,结果日志里全是业务数据和模型输出,又长又难查。后来改成只打印每轮的工具调用摘要和耗时,清爽多了。
第三个坑是没做工具调用的去重。模型偶尔会因为网络抖动重发同一个请求,导致同一个查询工具被执行两次。虽然查询是幂等的没出问题,但如果是写操作就麻烦了。现在框架层对相同tool_call_id的调用做了去重。
6. 这套框架还能怎么扩展
JavaManus 目前的定位是单 Agent 运行时,但它的结构留了不少扩展口子。最直接的一个方向是多 Agent 协作:把每个 Agent 也封装成一个工具,让一个"调度 Agent"去调用其他专业 Agent。这样就能实现"订单 Agent + 风控 Agent + 通知 Agent"的协作模式,每个 Agent 各管一摊。
另一个方向是记忆持久化。现在上下文只存在内存里,进程重启就没了。可以接一个存储层,把每个会话的消息列表存下来,下次继续。如果要做得更智能,可以引入向量检索,让 Agent 能回忆相关的历史交互。
还有一个我觉得很有价值的方向是可观测性。Agent 的决策过程是个黑盒,出了问题很难定位。可以给每一轮推理打上 trace,记录输入、输出、工具调用、耗时,接入现有的链路追踪系统。这样线上出问题时,能清楚看到 Agent 是在哪一步走偏的。
我个人在实际操作中的体会是,Agent 框架的难点从来不在"调通模型",而在"让它在真实业务里稳定可靠地跑"。模型会幻觉、会重试、会超时,业务接口会有各种边界情况,这些才是真正花时间的地方。把护栏做扎实,比堆功能重要得多。最后分享一个小技巧:上线前一定要用一批"刁钻"的输入去压测你的 Agent,比如让它查一个不存在的订单、传一个格式错误的参数、连续发起多个任务,看看它会不会崩、会不会乱调工具。这些边界场景的处理质量,直接决定了这套东西能不能真正交付使用。