我最早接触 Agent 这个概念,还是通过 Python 生态里那一堆基于动态语言特性构建的开源项目。彼时就在想,Java 这种强类型、重编译期的语言,到底适不适合做 Agent 框架?毕竟 Agent 的核心就是“动态”两个字——动态地感知环境、动态地调用工具、动态地决策下一步动作。直到我真正动手用 Java 从零开始写了一个名叫 JavaManus 的 AI Agent 框架之后,才彻底想明白:动态是一种能力,不是一种语言特权。Java 不仅能做,而且用好了,反而比动态语言多出编译期保障和清晰的边界约束。
这篇文章就想把 JavaManus 的设计和实现过程做一个深度拆解。它到底是什么,能解决什么问题?一句话说清楚:它是一个完全基于 Java 生态构建的通用 AI Agent 运行时,支持工具调用回环(Tool Calling Loop)、多轮对话管理、上下文裁剪、并行工具执行,以及和主流大模型 API 的对接。适合谁看?有 Java Web 开发经验、想进入 Agent 开发领域但从没碰过 Python 框架的同学,以及已经用过某些 Java 版 Agent 框架,但想搞清楚底层原理、自己也能写一个的开发者。这中间涉及的核心问题和取舍,我都会摊开来讲。
1. 核心架构:为什么这么拆,边界画在哪
1.1 从“智能体”到底层的三层建模
Agent 这个词被用烂了,但落到工程实现上,其实就三个东西:模型(Model)、工具(Tool)、循环(Loop)。模型负责“思考”,工具负责“行动”,而循环把所有思考和行动串起来的胶水层。
JavaManus 的核心思路,就是把这三件事拆成三个互相独立的模块,然后通过统一的消息协议串起来。
很多人一开始做 Agent 都容易犯一个毛病——把“智能”写死在一个类里。比如写一个Agent类,里边既处理 LLM 请求,又判断该调用什么工具,还要管理对话历史,最后还把日志输出绑死控制台。一开始跑 Demo 确实飞快,可一旦要加新的工具类型、换模型供应商、或者调整 Agent 的决策策略,整个类就成了一坨改不动的大泥球。
我在设计 JavaManus 时参考了主流 Agent 框架的成熟思路,把整个系统画成三层:
- 基础层:负责和外部世界通信。包括 LLM 客户端适配、工具定义与发现、消息模型。
- 决策层:即 Agent 的核心循环,负责解析模型输出、维护上下文状态、分发工具调用、处理执行结果。
- 交互层:面向开发者的门面,提供简单的编程接口(就说“直接让你的 Java 应用跑起 Agent 能力”)。
这三层之间用消息对象解耦。LLM 返回的内容被解析成结构化的AgentMessage,决策层看到的是消息、而不是字符串。这样做的好处是,无论底层的模型是返回纯文本还是返回工具调用请求,决策层都可以基于同一套消息协议运转。
1.2 关键技术选型:为什么这么别扭但又这么对
先交代一下技术栈,我用了 Java 17、Jackson、虚拟线程(Project Loom 在 Spring Boot 3.x 里的封装)、以及 JBoss 的TypeReference来做泛型反序列化。这些选型看起来平平无奇,但每一处都有自己的理由。
第一,用 Jackson 来处理所有 JSON 序列化和反序列化,不用别的。LLM 通信的本质就是 JSON 进、JSON 出,Jackson 在这块的生态成熟度和性能都经过了长期验证。关键是它的ObjectMapper可以被精细配置,能处理大模型中经常出现的各种“非标”JSON——比如模型返回了一段 Markdown 代码块包裹的 JSON,或者 JSON 里混了注释,甚至有的模型偶尔多输出一个逗号。我在工具层封装了一个专门的JsonParser,它基于 Jackson 提供的容错配置做了二次加固,实测能把市面上主流大模型的输出解析成功率从不到七成拉到九成以上。后来我意识到,模型输出的健壮性,是 Agent 框架最重要的基础能力之一。
第二,用 Java 虚拟线程来处理并行工具调用。Agent 执行过程中,如果一个决策触发了多个彼此独立的工具调用,串行执行会白白浪费几十秒时间。传统 Java 的线程模型处理这种场景很别扭——用线程池要管理生命周期、要设置超时、要处理线程等待。虚拟线程就不一样,它可以极轻量地创建和销毁,几十个并发调用完全无压力,代码写法和同步调用一致,没有异步编程的复杂度。这是我在实际对比了 CompletableFuture 方案之后才确定的路线。
第三,工具方法返回类型用泛型描述,而不是用具体的封装类。调用工具的时候,Agent 只知道“这个工具需要一个 X 类型的参数”,至于 X 怎么实例化,是在运行时通过TypeReference反射完成的。这个后边单独讲,它是 Java 实现工具动态调用的一个关键设计。
1.3 模块职责与扩展边界
JavaManus 的源代码目录刻意设计成“你扫一眼包名就知道这模块是干嘛的”的结构:
agent-core:循环器、消息路由、上下文管理,是框架的大脑。agent-tool:工具抽象、探测机制、参数解析,是框架的手。agent-llm:LLM 供应商适配层,是框架的眼睛和耳朵。agent-runtime:把上述模块拼装成可运行 Agent 的启动器,附带调试接口。
每个模块之间只通过接口通信。比如agent-core根本不知道某个工具是怎么实现的,它只知道调用ToolRegistry的execute(name, args)方法,拿到一个ToolResult。新增一个工具时,只需要实现Tool接口并注册进去,核心循环一行代码都不用改。这些“接口优先”的边界,是 Java 强类型特性带来的忠实的回报——编译期不对,运行就不可能对。
2. Tool 抽象层:把 Java 的静态类型“扭”成动态调用
2.1 统一工具描述模型(ToolSpec)
Agent 框架里最难处理的部分之一,是工具的动态发现和调用。Python 生态里因为动态语言的特性,函数直接传入框架就能被反射调用,参数的校验也可以在运行时临时推断。到了 Java 这里,如果你沿用 Python 的思路,第一步就会卡死——Java 没有语言层面的动态函数结构。
解决这个问题的核心,是定义一个统一的工具描述模型ToolSpec。
public record ToolSpec( String name, String description, List<ParameterSpec> parameters, boolean needsConfirmation ) { public record ParameterSpec( String name, String type, // integer, number, string, boolean, array, object String description, boolean required, String defaultValue ) {} }这里最关键的是type字段。它对应的是大模型 API 里 Function Calling 的 JSON Schema 定义。为什么要单独定义一套自己的描述模型,而不直接复用 JSON Schema?因为大模型 API 对 Function Calling 的参数格式有严格约定,而定义工具时如果用纯 JSON Schema,就会在解析时丢失 Java 的类型信息。比如 JSON Schema 里的integer对应 Java 的什么?是int还是long还是Integer?这套ToolSpec相当于在“Java 类型”和“大模型 API 描述格式”之间做了一层映射桥。
2.2 工具注册与参数解析:关于 Java 泛型的“高级魔法”
当一个工具被注册进框架时,JavaManus 会扫描其方法签名,自动生成对应的ToolSpec。这里的核心机制,是用 Java 的Method对象配合注解:
@AgentTool(name = "get_stock_price", description = "获取指定股票的实时价格") public String getStockPrice( @ToolParam(description = "股票代码,如 AAPL") String symbol ) { // 实际的工具逻辑 }在注册阶段,框架通过反射拿到Method的注解信息,构建ToolSpec。但这里有个进阶问题——当工具方法的参数是一个复杂对象时,如何把它从 LLM 返回的 JSON 字符串转换回 Java 对象?
最直接的方式是:
Object arg = objectMapper.readValue(jsonString, method.getParameterTypes()[0]);但如果是泛型类型,比如List<Order>、Map<String, User>这种,getParameterTypes()返回的只是List.class,类型信息丢失,反序列化出来的就是一个List<LinkedHashMap>。运行时对元素进行类型转换时会抛ClassCastException。
解决方法是借助 JBoss 的TypeReference拿到完整的泛型信息:
Type type = new TypeReference<List<Order>>() {}.getType(); Object arg = objectMapper.readValue(jsonString, type);工具作者在定义工具方法时,把完整泛型信息声明出来,框架在构建ParameterSpec时把这个“类型令牌”记录下来,到运行时再用于反序列化。
这里我踩过一个坑:一开始用method.getGenericParameterTypes()[i]拿ParameterizedType,但那个类型信息有时会和实际期望的 Bean 结构对不上。后来实践下来,让工具方法直接显式声明完整的泛型返回类型,反而最稳定。
2.3 工具调用的幂等性与并发保障
Agent 框架的另一个隐蔽问题是幂等。当一个 Agent 决策调用某个工具时,如果执行超时,框架应该重试,但如果这个工具不是幂等的(比如“扣款”操作),一旦重试会出大事。所以ToolSpec里加了一个idempotent标记,非幂等工具执行时,框架会禁止自动重试并要求人工确认。
考虑到工具并发调用的场景,我在ToolRegistry里为每个工具标注了线程安全性。状态无关的工具(比如查询天气)可以无限制并行执行;有状态修改的工具(比如写库操作)会在内部依赖锁串行处理,避免并发写覆盖。
3. Agent 核心循环:工具调用回环(Tool Calling Loop)的完整实现
3.1 循环骨架:Think→Act→Observe
没有一个智能体可以不“循环”就完成复杂任务,因为大模型本质上是无状态的——每次调用都像失忆了重来。所以 JavaManus 最核心的部分就是一个循环器。
我来画出这个循环的基本骨架:
系统初始化 ↓ 准备消息列表(System Prompt + 历史 + 新用户输入) ↓ 调用LLM接口 ↓ 解析响应 ├── 纯文本响应 → 返回给用户,流程结束 └── 工具调用请求 → 解析出工具名+参数JSON ↓ 在ToolRegistry中查找工具 ↓ 执行工具调用(可能触发多个并行子任务) ↓ 将工具执行结果包装成消息,追加到消息列表 ↓ 回到“调用LLM接口”步骤,继续循环这个循环说起来很简单,但实现时每一步都有隐藏的复杂度和各种坑。
3.2 系统提示词(System Prompt)的生成策略
循环的第一步,是构建发给 LLM 的消息列表。很多人在这里把它简单地理解为“把历史对话原样丢给模型”,但其实没那么简单。JavaManus 对 System Prompt 的构建有一套模块化的方式:
- 角色定义模块:告诉模型“你是一个擅长使用工具的智能助手”。
- 工具清单模块:把所有已注册工具的
ToolSpec转成 LLM 能理解的 JSON Schema 格式。 - 行为约束模块:定义模型什么时候应该调工具、什么时候应该直接回复、遇到错误时的处理原则。
- 输出格式模块:对不同的模型给出针对性的格式要求,提示它“如果你需要调用工具,请以特定格式输出”。
这里有个细节值得提一下:工具描述的质量对 Agent 的成功率影响非常大。同一个工具,如果描述写成“获取天气”,模型就会经常用错参数;但写成“获取指定城市今天的实时天气数据,支持输入城市名(中文或英文),输出包括温度、湿度、风速和天气状况”,模型准确使用的概率就高得多。JavaManus 在工具注册时会做一次“描述健康检查”,描述少于 15 个字符的工具会被标记为警告,提醒开发者补充。
不同模型的提示词敏感度不一样。有的模型在 System Prompt 里给一个 JSON 示例,它就会照着严格的格式输出;有的模型则需要把示例特别显眼地放在最后一句提示里才有效。所以提示词的组装逻辑必须做成策略模式,每个模型供应商可以覆盖默认策略。
3.3 解析 LLM 响应:与“幻觉”和“格式不稳定”作斗争
LLM 返回的响应并不总是严格遵循约束,这是 Agent 开发中最折磨人的地方。我们发出去的是一个严格的结构化请求,返回来的却可能是:
- 规范的 JSON 工具调用片段
- 带 Markdown 代码块包裹的 JSON
- 夹杂一段自然语言解释后再给出 JSON
- 甚至干脆只有“我觉得应该调用 get_stock_price”这种自然语言描述,而没有明确的 JSON
JavaManus 在解析环节建立了一条容错流水线。第一步用正则和状态机剥离 Markdown 代码块。第二步用策略化的ObjectMapper去解析,开启FAIL_ON_UNKNOWN_PROPERTIES禁用,开启ALLOW_COMMENTS等宽松选项。第三步如果解析失败,不会直接报错,而是走一个“启发式恢复”通道,尝试查找以{开头、以}结尾的可疑 JSON 片段,进行二次解析。最后一步还是失败的话,就把 LLM 的这段自然语言回复直接作为文本响应返回给用户,同时记录一条解析失败日志,供后续调试。
这套容错流水线看似繁琐,实际效果非常明显。在我对各种主流模型的实测里,解析成功率从 65% 左右提升到了 95% 左右。剩下 5% 的失败发生在模型输出极端混乱的场景下,这时让 Agent 暂停并请求用户重新描述任务,比强行解析更安全。
3.4 消息路由与状态管理
循环的主体是消息列表的持续追加。这里踩过一个大坑:如果每轮循环都把全部历史消息(包括系统提示词、每轮工具调用、每轮工具结果)发给 LLM,上下文窗口很快就会溢出。JavaManus 设计了一个MessageHistory管理器,它维护一个定长的滑动窗口,默认保留最近 20 轮交互。窗口溢出时,较早的消息会被压缩成一个摘要,而不是直接丢弃。
摘要的方式有两种:一种是将旧消息交给 LLM 生成一段几十字的总结,塞回历史里;另一种是直接用模板提取关键信息。我实现的是混合模式——普通对话历史用模板提取,涉及工具调用的关键决策节点保留原文。
3.5 退出条件:为什么纯靠“模型自己说结束”不可靠
循环什么时候结束?最简单的方法是让 LLM 自己判断“我已经回答完了,输出纯文本结束”。但实测发现模型有时候会“自作主张”提前结束——明明用户让它做一连串操作,它完成第一步就直接输出“好的,已完成”。这是 LLM 的“省事”倾向。
所以 JavaManus 的退出机制除了依赖模型的文本响应,还加了一道硬性校验:如果用户请求里包含了明确的多步任务关键词(比如“先……然后……”“对比一下……和……”),循环器会检查已发生的工具调用是否覆盖了所有必要的工具。如果发现有遗漏,会主动提示模型“你还有 XX 步骤没有完成,请继续”。虽然这不能完全杜绝模型偷懒,但能明显减少提前结束的概率。
4. 并行工具调用:把虚拟线程和 CompletableFuture 结合的高级玩法
4.1 并行调用的场景与困境
Agent 经常需要一次调用多个工具。比如用户问“对比 A 和 B 这两款手机的综合参数和当前价格”,如果串行执行,get_phone_info(A)、get_phone_info(B)、get_price(A)、get_price(B) 四个工具调用依次等待,每个调用平均 2 秒,光工具执行就花掉 8 秒,用户体验很差。但如果并行执行,理论上只需要 2 秒左右。
JavaManus 引入了并行调用执行器。核心逻辑是:解析 LLM 返回时,如果发现一条响应里包含多个工具调用请求,就按依赖关系分组。没有依赖的,提交到并行执行器;有依赖的,按拓扑顺序分批执行。
4.2 虚拟线程实现的并行执行器
我用 Java 虚拟线程做了个简单的执行器:
public ToolCallResultSet executeInParallel(List<ToolInvocation> invocations) { List<VirtualThread> threads = invocations.stream() .map(invocation -> Thread.startVirtualThread(() -> { ToolResult result = executeSingle(invocation); results.put(invocation.id(), result); })) .toList(); for (Thread thread : threads) { thread.join(DEFAULT_TIMEOUT_MS); } return new ToolCallResultSet(results); }这里没有使用传统线程池,因为虚拟线程的资源开销极低。一次跑二三十个工具调用,传统线程池要么排队、要么需要精心调参,虚拟线程则完全不用操心。当然虚拟线程不能无脑乱用,它只适合这种 IO 密集型任务,对于 CPU 密集的操作还是得上传统线程池。
还有一个细节:join的超时控制。如果某个工具卡死了,必须保证整个执行器不会无限等待。这里我给每个工具设了默认超时和最大超时两种配置,超时后的结果会标记为“TIMEOUT”,并返回一条半结构化错误信息。
4.3 数据竞争与状态一致性的处理
并行调用最大的隐患是数据竞争。比如两个工具同时往同一个缓存里写数据,或者一个测试工具内部维护了一个静态计数器。JavaManus 的处理策略很简单:
- 状态类工具默认标记为
@ConcurrencySafe(false),在注册时被放进串行执行通道。 - 纯只读工具标记为
@ConcurrencySafe(true),允许并行执行。
工具作者需要明确声明自己的工具是否并发安全。如果声明错误(比如把内部有状态修改的工具标成了并发安全),框架在运行时会通过日志记录潜在的数据竞争警告。
5. 上下文管理:让 Agent 不再“失忆”,同时管住 Token 成本
5.1 上下文窗口裁剪策略
LLM 的上下文窗口虽然越来越大,但 Agent 框架不能依赖它无限膨胀。理由有两个:一是 Token 费用是真实成本,二是过长的上下文会影响模型的注意力分布,导致“中间遗忘”。
JavaManus 采用三级上下文管理:
- 第一级,精简:系统提示词 + 工具描述,这部分通过按需动态重构来缩减体积。比如只注入当前可能会用到的工具描述,而不是全部工具。
- 第二级,窗口:对话历史保留最近 20 轮。
- 第三级,压缩:超过窗口的旧消息,调用 LLM 生成摘要并替换原文。
由于摘要调用本身也要消耗 Token,所以框架专门做了一个“摘要预算”控制:如果对话摘要预计超过原始长度的 40%,宁可直接截断也不要生成摘要,因为这种情况下摘要性地丢弃细节反而比重新生成更经济。
5.2 摘要生成的关键经验
用 LLM 做摘要时,有一个很经典的问题:摘要后 Agent 会忘记它曾经调用过哪些工具。用户后来问“你刚才是怎么查到那个数据的”,Agent 答不上来,因为摘要里没写。
解决办法是在摘要模板里强制要求保留所有工具名称及核心参数,不纠结于自然语言细节。这算是实测过最有效的方案之一——工具调用记录是推理链条的关键骨架,必须完整保留,哪怕自然语言描述被压缩得再干瘪。
5.3 消息 ID 与依赖追踪
在一个多轮循环里,消息之间其实是有依赖关系的:工具调用请求消息 → 工具执行结果消息 → 模型的下一轮决策。JavaManus 为每条消息分配了全局唯一的MessageId,并在工具调用结果里携带对应“上游消息的 ID”。这样在审计、调试、回溯“Agent 为什么会做出这个决定”时,可以顺着 ID 构建完整的决策链路图,而不只是一堆时间戳和字符串。
6. 流式输出与调试:把“黑盒”变成“透明箱”
6.1 SSE 流式输出的实现方案
Agent 执行过程中,如果是长时间的工具调用循环,用户面对一个“转圈20秒”的空页面,体验极糟。JavaManus 提供了 SSE(Server-Sent Events)流式输出:
- 模型决策阶段:逐个推送文本增量。
- 工具调用阶段:推送“正在调用工具:get_stock_price,参数:{...}”。
- 工具结果返回:推送工具执行结果摘要。
具体实现时,就是在一个异步线程池里跑 Agent 循环,然后通过SseEmitter把事件推给前端。这里要特别提醒一个细节:SSE 的线程和 Agent 循环线程必须独立管理,否则一个长时间的工具调用会阻塞整条 SSE 流的推送线程。
6.2 Agent Trace 日志:开发调试的灵魂
Agent 的调试是出了名的难,普通日志根本定位不了问题。JavaManus 内置了一个 Trace 系统,每轮 Agent 循环都会生成一条结构化 Trace 记录,包含:
- 当前等待处理的用户请求
- LLM 的原始输入(完整消息列表)
- LLM 的原始输出
- 解析结果(是纯文本还是工具调用)
- 每个工具调用的耗时与结果摘要
- 循环次数及当前累计 Token 消耗
这套 Trace 信息既可以输出到日志文件,也可以通过 Debug 接口实时查询。我强烈建议任何 Agent 框架开发者都做这种“可观测性”设计,否则线上出问题,排查成本比开发成本还高。
6.3 运行时热更新与 Debug API
考虑到 Agent 工具经常需要调整,JavaManus 还实现了简单的热更新机制——通过 JMX 暴露工具注册信息,运行中可以动态加载新的工具类并注册进正在运行的 Agent 实例。这个功能在开发调试阶段尤其好用,无需重启整个服务,改一行工具逻辑就能立刻生效。
7. 完整示例:从零构建一个“研究助手”Agent
7.1 场景定义与需求梳理
这里用一个完整的例子来演示整个框架的用法。我准备构建一个“研究助手 Agent”,它的任务是:给定一个研究主题,自动搜索相关资讯、抓取页面内容、生成摘要,最后综合输出一份简要报告。
这个 Agent 需要四个工具:
web_search(query):调用搜索引擎 API 获取搜索结果列表。web_fetch(url):抓取指定 URL 的页面文本内容。text_summarize(text, max_words):调用 LLM 对文本做摘要。save_note(content, tags):把笔记存到本地文件系统。
7.2 工具实现与注册
每个工具就像写一个普通 Java 方法一样简单:
@AgentTool(name = "web_search", description = "根据关键词搜索互联网,返回相关网页标题、URL 和摘要") public List<SearchResult> webSearch( @ToolParam(description = "搜索关键词") String query, @ToolParam(description = "返回结果数量,默认5", defaultValue = "5") int topK ) { return searchClient.search(query, topK); } @AgentTool(name = "web_fetch", description = "抓取指定URL的网页正文内容,返回纯文本") public String webFetch( @ToolParam(description = "要抓取的完整网页URL") String url ) { return pageFetcher.fetch(url); } @AgentTool(name = "text_summarize", description = "使用大模型对长文本进行摘要,返回指定字数的简洁摘要") public String textSummarize( @ToolParam(description = "需要被摘要的原文") String content, @ToolParam(description = "摘要最大字数", defaultValue = "200") int maxWords ) { return llmClient.summarize(content, maxWords); } @AgentTool(name = "save_note", description = "将研究过程中的重要发现以笔记形式持久化保存") public String saveNote( @ToolParam(description = "笔记正文内容") String content, @ToolParam(description = "标签列表") List<String> tags ) { return noteRepository.save(content, tags); }注册也很简单:
Agent agent = AgentBuilder.create() .withLlmProvider(openAiLlmProvider) .withTools(List.of(webSearchTool, webFetchTool, textSummarizeTool, saveNoteTool)) .build();7.3 执行效果与实测数据
我用 JavaManus 跑了一个实际任务:“对比 Spring Boot 和 Quarkus 的启动性能,并生成一份简要调研报告”。实际执行过程如下:
- 第一轮决策:调用
web_search(query: "Spring Boot startup benchmark Quarkus")。 - 拿到 10 条搜索结果后,第二轮决策:调用
web_fetch抓取前 3 个相关链接的内容。 - 第三轮决策:对抓取的内容分别调用
text_summarize,然后综合生成最终回答。
全程耗时约 9 秒,其中大部分时间花在搜索和抓取等待上。Token 消耗大致在 4500 左右(包含中间摘要)。如果并行优化一下,耗时可以降到 5 秒内。这个流程在传统的人工操作里,至少要花 15 分钟以上,Agent 把它压缩到了个位数秒——这就是 Agent 框架的实际价值。
8. 避坑指南:Agent 框架开发中的经典问题与排查实录
8.1 数据结构层面:识别和处理 LLM 的输出陷阱
问题一:模型返回工具调用时随机给出多余的字段。比如明明要求只返回{"name": "get_weather", "args": {...}},实际却返回了{"name": "get_weather", "args": {...}, "thought": "..."}。如果你在反序列化时开启了FAIL_ON_UNKNOWN_PROPERTIES,直接抛异常。解决方法是自定义ObjectMapper,逐字段校验,未知字段忽略。这个坑几乎每个 Agent 框架开发者都踩过,提早处理能少走很多弯路。
问题二:参数名不匹配。模型有时把query写成了q,把topK写成了top_k。JavaManus 在 ToolSchema 生成时做了一个启发式别名映射,自动允许模型输出中常见的camelCase和snake_case互认。实现思路是给ParameterSpec生成一个aliases字段,解析时按别名匹配,最后再映射回 Java 字段名。
问题三:循环失控。Agent 陷入了死循环,反复调用同一个工具却无法收敛。JavaManus 内置了循环次数上限(默认 8 轮),超过后强制终止并将已收集的结果按最后文本组装输出。这里要强调一下,终止不能粗暴地抛异常,而是应该返回“已完成部分任务,但因循环限制提前结束”的半成品结果,避免用户侧产生“服务报错”的错觉。
8.2 线程层面:虚拟线程和线程池工具共用时的那些坑
虚拟线程虽好,但和某个依赖线程局部变量的传统工具库共用时会出问题。比如有的老牌 HTTP 客户端库在多个线程间共享一个ThreadLocal连接池上下文,当虚拟线程切换时,ThreadLocal 内容可能混乱。
所以我在框架里做了两层保险:一是工具执行时统一包装成 Callable,捕获所有上下文异常;二是对工具作者建议,不要在工具内部依赖ThreadLocal存过程数据,一定要依赖时就得用InheritableThreadLocal或者显式传递上下文。这个建议是实践中得来的——排查一次偶发性的数据串线问题,我花了整整一个下午,最后定位到就是ThreadLocal在虚拟线程间“串门”导致的。
8.3 资源层面:Token 用量控制、限流和并发保护
Agent 比普通 API 调用消耗的 Token 多得多。原因是每个工具的执行结果都会回传 LLM,在一次复杂任务中,可能有几十轮循环。如果业务量大,Token 成本会让人眼前一黑。
JavaManus 做了一套 Token 预算机制:在每个 Agent 实例上设置maxContextTokens,当累计 Token 消耗超过预算时,自动开启“只保留必要信息”模式(减少摘要、只保留最近 5 轮消息、工具全文参数改为摘要参数)。这样虽然可能略微影响推理质量,但把成本锁在了可接受范围内。
另外对 LLM API 的限流也要注意。同一个模型供应商通常有 RPM(每分钟请求数)和 TPM(每分钟 Token 数)两个维度限制。JavaManus 在 LLM 适配器层内置了滑动窗口限流器,超过限制时自动排队重试,而不是直接把 429 错误抛给上层。这个设计在并发请求多的时候尤其重要——我见过团队上线 Agent 服务后,因为没做限流保护,直接被供应商封禁了一个小时。
8.4 安全层面:让 Agent 的“手”不要乱摸
Agent 能调用工具,也就意味着你的系统向 LLM 敞开了“手”的接口。如果某个工具是删除文件、发送消息这类敏感操作,被模型误用会造成严重后果。JavaManus 给这类工具加上了needsConfirmation=true标记,执行前会暂停循环,向用户发起确认请求,只有用户点击“确认”按钮后才会真正执行。这个设计,某种程度上是把“人在回路”(Human-in-the-Loop)原则落到了代码层面,也是 Agent 落地到生产环境时的一个必要保险。
9. 回望与心得:Java 生态里做 Agent 框架,真正难的是什么
项目从零开始到第一版可运行,我断断续续花了大约一个月的时间。把整个设计和实现过程回头看一遍,最有价值的心得,反而不是那些精巧的代码技巧,而是对“Java 和 Agent 之间关系”的理解。
很多人问我不去直接用现成的 Java Agent 框架,为什么要从零造轮子?我的回答是:只有自己从零构建,才能真正理解“工具定义即接口”这句话的深层含义。Agent 框架的成功与否,百分之七十取决于工具抽象层的设计,剩下的百分之三十才是 LLM 调用和循环管理。而工具抽象层在 Java 里,恰恰是最考验对语言特性理解深度的地方——泛型、反射、注解、接口设计、并发模型,每一个都能成为整个框架的瓶颈或者助推器。
我也清醒地认识到,JavaManus 和一些工业级框架的差距依然明显:比如它没有内置完善的多模态工具支持,没有分布式的多 Agent 协调机制,工具生态也远不够丰富。但正因为一切都摊开了、读得懂、改得动,所以它成了我理解 Agent 体系最好的“解剖样本”。
最后分享一个非常实际的小建议:不管你是从零造轮子还是用现成框架,一定一定要在项目初期就把“可观测性”做进去——记录每一轮消息、每一个工具调用、每一步 Token 消耗。等你的 Agent 在线上真实服务用户的时候,就会知道这些日志就是救命的稻草。