☰
Java实现轻量级Agent框架:状态机、工具调用与生产级工程实践
2026/10/22 15:24:11 网站建设 项目流程

先说结论:如果一个团队既有成熟的 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 List<ToolCall> toolCalls; // assistant 发起调用 private String toolCallId; // 工具执行后回填 private Map<String, 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(); List<CompletableFuture<ToolResult>> 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); List<MemoryItem> 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(List<Message> messages, List<ToolSpec> 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 能力接入到实际业务里。如果你们团队也正卡在“技术栈选型”的十字路口,希望这篇文章能给你一个参考方向。

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

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

立即咨询