资讯详情

资讯详情

Java实现轻量级Agent框架:状态机、工具调用与生产级工程实践

先说结论如果一个团队既有成熟的 Java 微服务底座又想快速接入 Agent 能力与其硬着头皮引入一套 Python 技术栈不如用 Java 自己写一个轻量 Agent 框架。这个判断不是我拍脑袋得出的JavaManus 就是在这类项目里被逼出来的产物。这篇文章不会讲怎么调用某个现成 SDK也不会堆概念而是把从零设计、实现、调优这个框架的完整过程拆开给你看包括执行循环怎么设计、工具怎么抽象、上下文怎么管理、模型怎么切换以及上线后靠什么兜底。1. 从“技术栈需求”说起为什么一定得在 JVM 体系里做 Agent很多团队聊到 Agent 的第一反应是“直接用现成的 Python 框架”。这话不假Python 生态在 AI 领域确实领先但落到实际工程里就未必了。我参与过的一个项目前后端全是 Java服务部署在自建的容器平台上运维规范、权限体系、日志采集全都是按 JVM 标准建立的。如果为了一个 Agent 功能单独引入 Python 运行时意味着要额外维护一套环境、一套依赖、一套部署流程认证和权限还得重新打通。这种成本在评估阶段很容易被低估到了线上出问题才真正疼。JavaManus 的目标很明确不改变团队现有的基础设施能力只用 Java 语言和 JVM 生态内已有的库把 Agent 的核心能力做出来。选 Java 还有几个现实理由。第一JVM 的线程模型在处理并发工具调用时有天然优势。Agent 执行过程中常常需要同时查多个系统、做多个工具调用Java 的线程池、CompletableFuture、虚拟线程都能让并行变得很自然而不用像某些运行时那样靠异步回调把代码逻辑拆得七零八落。第二类型系统是 Agent 框架最好的保护网。工具的参数、返回结果、状态流转如果都走强类型很多问题在编译期就能拦住而不是等到模型调用返回一堆乱结构之后再去做运行时防御。第三现成的可观测性体系可以直接复用。公司里已有的调用链追踪、日志平台、监控告警不用为了一个 Agent 重新搭一套因为在 Java 侧接入这些本来就是顺手的事。这里要澄清一个误区用 Java 写 Agent 框架并不意味着从零实现“大模型推理”推理仍然交给模型供应商或者本地推理服务。框架真正要做的是把“模型调用、工具调度、上下文管理、会话状态”这几件事编排起来形成一条可执行的链路。核心价值和语言无关因此用熟悉的技术栈做会容易得多。2. Agent 执行循环的核心骨架状态机、消息队列与工具调用的回合制2.1 先想清楚 Agent 到底是什么再去写代码要理解 Agent 框架可以先放下“智能”这个词回归到工程视角Agent 本质上一个循环系统接收用户请求把它们转换成对模型的一次调用模型的回答里可以携带工具调用指令然后执行这些指令并把结果返回给模型如此往复直到模型判断可以给出最终答案。这个模式在学术上有各种名字比如 ReAct、通过推理行动来解决复杂任务的一种技术路线、Function Calling 等但落到代码层面都大同小异。JavaManus 在执行循环上直接采用这种回合制模式因为它直观、可控、易于调试。如果设计成一个状态机核心状态其实只有四个状态含义进入条件WAITING_INPUT等待用户新输入会话开始或一轮结束时INVOKING_LLM正在调用模型有新的输入或工具结果需要继续推进EXECUTING_TOOL正在执行工具调用模型返回包含工具调用的响应FINISHED返回最终结果模型返回纯文本且无工具调用真正跑起来之后系统会在这几个状态之间反复流转直到结束。这个状态机是后面所有调试、追踪、异常处理的基础不建议省略即使初期看起来只是几个布尔值的事。2.2 消息模型的底层设计一切感情都只是消息状态机之外消息结构是最值得认真设计的部分。Agent 的对话本质是一串消息的累积而工具调用是消息的一种特殊类型。JavaManus 的消息设计如下public class Message { private Role role; // SYSTEM, USER, ASSISTANT, TOOL private String content; private ListToolCall toolCalls; // assistant 发起调用 private String toolCallId; // 工具执行后回填 private MapString, Object metadata; }这里的角色不是简单的 user/assistant而是必须承载工具调用的上下文关系ASSISTANT 消息可以包含 toolCalls表示模型想调用哪些工具TOOL 消息必须通过 toolCallId 关联到某一次调用并携带工具结果SYSTEM 消息放在消息序列最前面描述系统行为和约束。这套设计与主流模型服务商的消息协议对齐因此切换模型时可以少做很多转换工作。更重要的是这种角色关系的设计可以保留完整的调用链不会因工具调用的插入而打乱上下文的有序性。2.3 主循环实现不带脑子的代码最不容易出错主循环往往是最简单的代码因为它的逻辑就是“在没有结束信号之前不断推进”。参考实现如下public AgentResult run(String userInput) { Session session sessionStore.createSession(); session.append(Message.user(userInput)); while (true) { if (session.needInitialResponse()) { ChatResponse response llmClient.chat(session.messages(), session.tools()); if (response.hasToolCalls()) { session.append(Message.assistantWithToolCalls(response.toolCalls())); for (ToolCall call : response.toolCalls()) { ToolResult result; try { result toolRegistry.execute(call.name(), call.arguments()); } catch (Exception e) { result ToolResult.error(工具执行失败: e.getMessage()); } session.append(Message.tool(call.id(), result)); } } else { session.append(Message.assistant(response.content())); return new AgentResult(session); } } } }这里有两个小的设计决策值得展开。把工具执行的异常捕获放在循环内部而不是抛出去是为了让错误的工具结果以消息形式流回模型而不是直接中断整个 Agent 会话。模型可以读到错误信息然后自行决定是用备选工具还是调整参数重试这比让用户看到一个堆栈错误要友好得多。工具调用执行完之后不直接返回结果而是把结果作为新的 TOOL 消息追加进会话并回到循环起点。因为一个模型响应里可能包含多个工具调用这些调用可能并行执行但追加消息的顺序必须与工具调用顺序一致否则模型会无法对号入座。2.4 并行工具调用把“同时查多个系统”变成现实当模型返回多个工具调用时串行执行会让整体响应时间线性增长。JavaManus 的做法是把工具调用提交到线程池并行执行然后统一收集结果。ExecutorService executor Executors.newVirtualThreadPerTaskExecutor(); ListCompletableFutureToolResult futures toolCalls.stream() .map(call - CompletableFuture.supplyAsync( () - toolRegistry.execute(call.name(), call.arguments()), executor)) .toList(); CompletableFuture.allOf(futures.toArray(CompletableFuture[]::new)).join();注意并行执行仅适用于相互独立的工具调用。如果多个工具之间存在依赖比如第二个工具的入参来自第一个工具的返回值那还是需要分轮次串行完成。这一点需要在实际实现中作区分不能无脑并行。3. 工具层设计如何把外部系统变成 Agent 的“双手”3.1 工具注册中心提前声明的“能力清单”执行循环只是骨架真正让 Agent 有战斗力的是它能调用的工具集。JavaManus 把工具当成一等公民来设计。一个工具对应一个接口实现在框架启动时向注册中心注册。public interface AgentTool { String name(); // 全局唯一 String description(); // 描述该工具的适用场景 String jsonSchema(); // 参数声明通常是 JSON Schema ToolResult execute(JsonNode arguments); }描述信息看起来只是“一句说明”实际上对模型的行为影响极大。模型依据 description 来决定什么情况下调用这个工具以及怎么传递参数。描述写得含糊模型就会在不恰当的场景调用参数约束写得宽松模型就会传错格式。这里有一个经验描述里应该写清楚“什么时候用”和“什么时候不要用”而不是只写“这个工具用来查天气”。参数声明我直接使用 JSON Schema 格式。原因很简单主流的模型服务商都支持 JSON Schema 来表达工具参数这样工具定义不需要做额外转换可以直接被拼到模型的 tool 参数里。如果自己定义一套 DSL最终还是要翻译成模型可识别的格式纯属给自己增加工作量。3.2 返回结果设计让模型能“看懂”的执行结果工具执行结果也有讲究。我给 ToolResult 设计了三个字段public record ToolResult(boolean success, String summary, JsonNode data) {}summary一段给模型看的文本描述说明执行情况和结论data结构化数据可以留作后续渲染或展示success布尔值标记是否执行成功。为什么 summary 和 data 要分离因为模型读的是文本而下游应用消费的是结构化数据。如果一个工具只是简单地返回一个 Data 对象模型拿到 JSON 后还要自己解析语义准确率会明显下降。把 summary 写成人话告诉模型“查询到了三条记录分别是 XXX”模型的后续决策会更稳。比如一个查仓库库存的工具成功时 summary 可能是“仓库 A 的 A001 商品库存 120 件仓库 B 库存 85 件”而 data 是完整的库存明细。模型可以根据 summary 直接给出答案也可以在必要时引用 data 中的具体字段。3.3 动态工具注册“元工具”与“反射式”调用随着接入的业务系统变多靠一个一个手写工具类再一个个注册会变成维护灾难。JavaManus 里我引入了“元工具”机制如果系统内已经沉淀了大量 HTTP API只需配一张工具映射表描述每个 API 的路径、方法、参数映射和鉴权方式框架就能通过动态代理自动生成工具实例。ToolSpec spec ToolSpec.builder() .name(book_room) .description(预订会议室) .api(/api/v1/meeting/room/booking) .method(POST) .param(roomId, string) .build(); toolRegistry.registerDynamic(spec);这个设计把新工具接入成本从“写代码 发版”降低到“加配置 热加载”。不过它也带来一个代价运行时的类型安全变弱参数校验的负担从编译期转移到了运行时。所以动态工具必须配备一套参数校验器至少要做到入参类型、必填项、枚举值校验避免把脏数据直接透传到后端服务。4. 上下文与记忆管理撑住长对话的三种配置策略4.1 问题从哪来Token 窗口不是无限的模型有上下文窗口限制而 Agent 每轮交互都会向消息列表里追加新的内容。一次长会话可能连续执行十几次工具调用再加上系统提示词和工具定义很快就能把窗口填满。如果放任不管两个后果会同时出现早期关键指令被挤出窗口导致模型忘记目标超出窗口长度直接报错。JavaManus 采取了三层策略按成本递增配置。4.2 策略一截断最“不重要”的内容滑动窗口最简单的方式是固定保留最近 N 条消息旧的直接丢弃。适合短任务型 Agent比如“查一下结合部门绩效选一个最优方案”这种一次性需求。但坏处显而易见截断后模型会失去前文上下文一旦需要旧信息就无法找回。这个策略我只在工具调用链特别长、且历史消息对后续决策无实质影响的场景中使用。4.3 策略二自动摘要压缩记忆更优的做法是定期对旧消息做摘要。JavaManus 中有一个单独的记忆模块当消息数量超过阈值把最早的一批消息打包发送给模型要求生成一段 200 字以内的摘要然后用摘要消息替换掉原始消息。if (session.messageCount() 30) { String summary summarize(session.oldMessages()); session.compress(summary); }摘要的粒度也需要调优。太粗就丢失了细节太细则压缩比不高。我常用的做法是分层摘要先按“用户目标、已采取行动、未解决问题、关键数据”四个维度生成摘要中间如果遇见工具调用的返回结果会同时保留关键数字和结论不保留详细的中间日志。这个策略支撑了大多数中长会话场景是三种策略里的主力。4.4 策略三外部记忆存储长期记忆如果 Agent 要跨会话记住用户偏好、历史结论、项目背景那就要把记忆从“会话内”搬到“存储层”。JavaManus 抽象了一个 MemoryStore 接口public interface MemoryStore { void save(String sessionId, String key, String value); ListMemoryItem search(String sessionId, String query, int limit); }每次会话开始时把系统里跟用户上下文高度相关的记忆片段取出来注入系统提示词。会话过程中也会根据关键事件写入新的记忆。这种做法的优点是灵活但它必然依赖嵌入Embedding或关键词检索会话链路会变重。目前我只有在确实有跨会话需求的场景才启用不会默认开。4.5 上下文管理中最容易忽略的事工具定义也要占空间很多人在优化上下文时只盯着消息内容忘了工具定义本身会反复占用 Token。一次模型调用会把所有注册工具的 name、description、parameter schema 全部放进请求里。如果注册了 30 个工具定义文本可能超过 5000 Token比一条普通消息贵得多。JavaManus 增加了“按会话动态装载工具”的机制根据会话目标和用户消息先做一次轻量语义匹配只把相关的 5 到 8 个工具放入当前请求其余工具不参与这轮调用。这个机制把请求体积显著降了下来响应速度也快了不少。5. 多模型适配层用接口切换供应商用提示词治理成本5.1 定义一个足够抽象的模型客户端接口Agent 框架不能绑死在某一家模型供应商身上原因不只是“防止供应商锁定”更现实的是不同模型在推理能力、响应速度、成本上的差异可能接近一个数量级。JavaManus 里没有直接封装某个供应商的实际 SDK而是定义了自己的客户端接口。public interface ChatModelClient { ChatResponse chat(ListMessage messages, ListToolSpec tools); String modelName(); }各厂商的 SDK 实现成 adapter 注入框架。这样切换模型只需要改配置里的模型标识不需要改业务代码。在一次项目交付里同一个 Agent 能力既接入了某供应商的云端模型也接入了团队自建的私有化推理服务适配层的存在让迁移做到了“改一行配置”。5.2 参数治理温度、超时与重试要有一个默认策略集不同模型支持的参数不同但常见的有这几个temperature采样温度、max tokens、超时时间、最大重试次数。JavaManus 对模型请求参数做了统一管理并内置了一套默认值参数默认值说明temperature0.2工具调用场景尽量低保证动作稳定max tokens2048防止异常长输出拖垮响应时间connect timeout10s建立连接超时read timeout60s最长等待响应时间max retries3网络抖动时的重试上限工具调用与创作类任务的 parameter 需要区别对待。工具调用偏确定性温度调得很高模型可能会发明不存在的工具名或者参数值因此建议在 0~0.3 之间如果是写文案、做总结温度可以放宽到 0.7 以上。5.3 模型回答不要盲信解析工具调用的三种容错方式模型输出本质上是概率生成无论多强都可能在工具调用格式上出问题。JavaManus 在解析模型输出时做了三层容错第一层按标准工具调用字段解析拿到完整结构体第二层如果是纯文本但包含 JSON 片断尝试提取其中形似 JSON 的内容并修正格式第三层解析失败时直接把原始文本作为普通助手消息返回给用户而不是报错。这个设计非常关键。因为把解析异常当成“整个 Agent 会话失败”会大幅降低用户体验宁可让模型少做一步工具调用也不要让整体流程因为格式问题崩溃。5.4 提示词版本治理把 Prompt 当代码管理系统提示词、工具描述、摘要模板都属于“逻辑”的一部分直接决定 Agent 行为。JavaManus 把提示词全部外置成配置文件并按版本管理。这样线上如果出现 Agent 行为异常可以快速回滚到上一版提示词而不需要重新发布代码。提示词配置采取变量注入模式prompt templateEngine.render(agent_system_prompt, Map.of(date, LocalDate.now().toString(), memory, sessionMemoryText()));这样提示词内容变化不用改 Java 代码同一个 framework 可以用在不同业务场景只靠换 Prompt 集就能生成不同领域的 Agent。6. 稳定性兜底重试、超时、追踪以及 Agent 特有的“自愈”手段6.1 工具调用超时握住Agent的“手脚”Agent 一次任务可能涉及多个工具调用如果其中一个工具迟迟不返回整个会话会被拖住。JavaManus 为每次工具执行设了默认超时时间超时后立刻返回一个“工具执行超时”的 ToolResult让模型决定是换一个工具还是重试。这里“让模型决定”和“强制失败”是两个截然不同的策略。很多情况下模型接收到超时提示后会尝试换一个等价工具或换一个参数这比直接给用户抛异常要实用得多。6.2 重试策略要区分场景模型调用链路建议重试网络抖动、限流属于短时故障重试成功率较高。工具本身执行不建议简单重试如果是写操作下单、删除、修改无脑重试可能造成重复操作。因此 JavaManus 里定义了工具的两个属性幂等性和失败策略。幂等工具执行失败后可以自动重试最高 2 次非幂等工具执行失败后不自动重试只在 ToolResult 里带错误信息留给模型判断下一步。这个区分在连接外部系统时特别重要避免了一次网络抖动就重复下单的可怕场景。6.3 可观测性Agent 翻车时你要能看清发生了什么Agent 的日志与普通 API 日志有本质区别普通 API 一次请求一个结果Agent 却是多轮、多工具调用的串联。如果只在入口打日志问题出现时根本没法追溯是哪一轮的哪个环节出了错。JavaManus 为每个会话生成一个 traceId并在整条链路中输出的日志结构如下{ traceId: 0ab43f..., sessionId: s_0234, step: 3, event: tool_execution_start, tool: search_product, args: {\keyword\: \空调\} }有了这个结构化日志就能画出 Agent 每一轮的完整轨迹包括模型返回内容、工具执行时长、工具异常、最终回复。对线上问题定位帮助极大。6.4 一种 Agent 特有的兜底手段给模型设“逃生舱”Agent 框架里还有一个概念叫“终止条件”。如果模型在循环中持续调用工具每次都能拿到结果但就是不输出最终答案可能会陷入死循环。JavaManus 设置了三个硬性终止条件最大轮次限制默认 15 轮最大工具调用次数限制默认 30 次单轮总耗时上限默认 10 分钟。达到任一条件框架会强制中断循环并返回“任务未完成但已尽力”的消息。这个兜底不能少否则 Agent 可能会因为一次误解陷入无限循环烧掉大量 Token。7. 从 JavaManus 到生产环境我踩过的坑和三个关键经验7.1 坑一把所有工具一股脑全注册给模型初期为了方便我把所有业务工具一次性注册进去模型每轮都能看到全部工具。结果模型频繁挑选出错有时还会在无关场景调用某些高权限工具危害很大。后来改成按会话动态装载工具问题立刻缓解调用准确率也明显上升。经验工具暴露给模型要遵循“最小权限原则”不能让 Agent 直接看到所有能力有时也要按订阅等级控制工具可见性。7.2 坑二把工具参数设计得“太自由”初期设计工具时参数约束只写了类型没写枚举值、范围、格式。结果模型经常把日期传成“昨天”这样的相对描述或者把金额传成负数。后来把所有能够明确的枚举、范围、示例全部写进 JSON Schema 里模型调用工具的成功率提高了一个台阶。经验工具参数的 Schema 就是 API 契约越细越好别怕麻烦。7.3 坑三只盯着模型返回不关心工具执行细节Agent 框架里模型返回的是“意图”真正干事的是工具。早期调试时我总在模型侧找问题后来发现大量 bug 其实出在工具实现侧有的 HTTP 客户端没设超时有的请求参数把类型传错。把工具测试做成独立单测集以后整体稳定性才真正起来。经验工具层要像对待自己平台的接口一样做单测、冒烟和回归不要把 Agent 的稳定性寄托在模型推理能力上。能给工具调用加 mock 接口的一定要加这样开发调试时不依赖真实外部系统。7.4 后续演进的一些方向JavaManus 目前已经支持了单 Agent 的基本能力接下来我计划做这几个方向的扩展多 Agent 协作让不同角色 Agent 能互相传递消息引入流式输出让模型回复边生成边返回给前端同时把 RAG 检索能力内聚成框架的标准工具与现有工具注册体系无缝结合。写到这里回头看JavaManus 真正让我受益的地方在于它逼着我从工程化角度把 Agent 拆成了状态机、消息模型、工具调度、上下文管理、多模型适配、稳定性兜底这些清晰的部分而不是停留在“给模型写一段提示词”的层面。这套框架的设计不是为了炫技是为了让团队所有人都能用最小的心智负担把 Agent 能力接入到实际业务里。如果你们团队也正卡在“技术栈选型”的十字路口希望这篇文章能给你一个参考方向。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →