当大模型从对话工具进化为能调用工具的智能体,Agent框架的工程实现就成了关键。Python生态有LangChain、LlamaIndex,但企业核心业务系统往往是Java的。把Python Agent嵌入Java系统意味着跨进程调用、序列化开销和运维复杂度翻倍。JavaManus的目标是用纯Java实现一个可扩展的ReAct Agent,基于Spring AI 1.1.0 + Spring Boot 3.4,默认接入火山引擎Ark(豆包),整体不到30个Java文件。

分层架构:组合优于继承

JavaManus的类层次是:ManusController(SSE接口)→ ManusAgent(装配工具)→ ToolCallAgent(工具编排)→ ReActAgent → BaseAgent(状态机/Memory/步数控制/卡死检测/事件分发)。

职责切分很清晰:BaseAgent是纯状态机,不关心LLM怎么调、工具怎么执行;ReActAgent定义step() = think() + act()的骨架;ToolCallAgent实现工具调用的think/act逻辑;ManusAgent只负责装配具体工具。替换LLM或工具集不需要改Agent核心逻辑。

ReAct循环:状态机与模板方法

BaseAgent.run()是循环入口:把用户请求写入memory,进入RUNNING状态,在currentStep < maxSteps且未FINISHED时反复调用step(),每步后做卡死检测,finally中重置状态、步数并清空memory防止记忆污染。

step()由ReActAgent实现:先think(),若判定无需行动则直接返回最后一条消息文本;否则执行act()。

think():禁用Spring AI自动执行

ToolCallAgent.think()把memory消息、system prompt和工具定义发给LLM,解析文本与工具调用。关键配置是internalToolExecutionEnabled(false)。Spring AI默认会在ChatModel.call()内部自动执行工具并递归请求LLM,但Agent框架需要手动控制执行时机——截断工具输出、触发事件、检测特殊工具——所以必须禁用自动执行,自己编排循环。

act():执行工具并截断输出

act()遍历toolCalls,解析参数、执行工具,然后通过maxObserve截断超长结果。工具输出可能极大(比如cat一个几万行文件),全部喂回LLM会迅速耗尽context window。结果以ToolResponseMessage写回memory;若命中特殊工具(如terminate),直接把状态置为FINISHED。

Memory:滑动窗口

Memory本质是带上限的消息列表,addMessage后检查是否超过maxMessages,超过就丢弃最早的消息。这个滑动窗口策略很朴素,但在长任务中能保证context不无限膨胀。更好的做法是摘要或向量检索做长期记忆,但对大部分工具调用场景,滑动窗口已经够用。

工具系统:统一抽象与路由

BaseTool实现Spring AI的ToolCallback接口,抽象execute(Map)返回纯文本,call()负责JSON解析和异常兜底。ToolCollection用HashMap做工具路由,未注册的工具抛ToolError。

两个核心工具:StrReplaceEditor支持view/create/str_replace/insert/undo_edit五种命令,是Agent修改代码的主要手段;PythonExecute通过ProcessBuilder启动独立Python进程,带超时保护,让Agent能做数学计算和数据处理。

Ark适配层:ChatModel与消息映射

Spring AI官方没有火山引擎starter,需要自己实现ChatModel。ArkChatModel.call()把Spring AI的Prompt转成Ark的ChatCompletionRequest,再把响应转回ChatResponse。最复杂的是tool call双向映射:Spring AI的AssistantMessage.ToolCall与Ark的ChatToolCall之间转换,其中arguments在两边都是JSON字符串,可以直接透传。

systemPrompt分两层:ArkProperties.systemPrompt是全局默认提示词,放在消息列表最前面;Agent.systemPrompt是Agent专属角色设定,通过SystemMessage传入。这样不同Agent可共享同一ChatModel但拥有不同角色。

五个真实工程坑

坑一:PythonExecute超时完全失效。 原代码先readAllBytes()再waitFor(timeout),但readAllBytes()会阻塞到进程关闭stdout,即进程退出。死循环时它永远不返回,waitFor根本没机会执行。修复是用CompletableFuture异步读取输出流,先waitFor,超时则destroyForcibly并取部分输出。这个坑单测很难发现,因为测试代码都能快速结束。

坑二:文件编辑工具无沙箱。 StrReplaceEditor只校验绝对路径,不限制范围,Agent可读写/etc/passwd等任意文件。修复是引入workspaceRoot,所有路径normalize后必须startsWith(workspaceRoot),否则抛ToolError。

坑三:System.out.println泄露prompt。 ArkChatModel.call()里直接打印完整prompt,包含用户输入和工具参数。修复是改用@Slf4j的log.debug,日志级别可控。

坑四:LLM失败后死循环。 think()中LLM抛异常时写入错误消息并返回false,循环继续,下一轮可能继续失败,直到maxSteps才退出,浪费API配额。修复是LLM失败时直接把state置为FINISHED并返回false。

坑五:卡死检测误判。 原isStuck()遍历历史所有assistant消息统计相同文本数量,导致两个问题:工具调用后最后一条是ToolResponseMessage被跳过,检测时机不对;历史偶发重复被累计导致误判。修复是只统计连续重复的assistant消息,从最后一条assistant向前比对,遇到不同即break。

SSE接口与虚拟线程

Agent执行可能几十秒,同步HTTP会超时。JavaManus用SSE推送thought/tool_call/tool_result/complete/error事件。几个要点:Agent执行放在Thread.ofVirtual()中,不阻塞Web容器线程;ManusAgent有状态,通过ObjectProvider.getObject()每次请求获取新实例(prototype作用域);SseEmitter超时由配置控制。

与Python生态的工程取舍

Python生态在LLM领域有压倒性优势,但JavaManus展示了一条不同路径:复用Spring AI的ToolCallback抽象、Spring Boot的依赖注入和配置体系,让Agent直接嵌入现有Java服务。代价是生态工具较少、需要自己实现LLM适配层;收益是无需跨进程调用、运维栈统一、类型安全。对于核心系统是Java的团队,这个取舍通常是划算的。

从JavaManus的实现看,一个可用的ReAct Agent核心并不复杂:状态机循环、工具注册表、滑动窗口记忆、事件分发。真正花时间的是边界处理——超时、沙箱、日志脱敏、失败终止、卡死检测。这些坑在单测中往往不暴露,只有生产环境的死循环和异常输入才会触发。