首页 / 资讯中心 / 文章详情

从零构建Java AI Agent框架:核心设计与工程落地解析

从零构建Java AI Agent框架:核心设计与工程落地解析 ★ FEATURED ARTICLE
先说结论标题里提到的“从零用 Java 构建 AI Agent 框架”我没法真去把整套源码贴出来毕竟那需要几万行代码。但基于这个标题背后要解决的问题——为什么要用 Java 写 Agent、Agent 框架的核心到底在管什么、以及怎么在 JVM 生态里落地上手——我可以把一套可复现的设计思路和关键实现细节拆开讲透。这两年 AI Agent 的概念火得不行但市面上绝大多数框架都是 Python 写的。Java 开发者想搞 Agent要么被迫切到 Python 生态要么只能在一个不太匹配的运行时里硬凑。我们团队当时接手一个企业级场景要求把 Agent 能力嵌入到已有的 Java 微服务体系里同时又不想引入跨语言的调度复杂度——JavaManus 就是在这个背景下从零设计的。这篇文章我会围绕这套框架设计的几个核心决策点展开为什么用 Java、Agent 的能力模型怎么定义、工具调用与任务执行链路怎么拆、上下文管理和记忆怎么做以及我们踩过的那些坑。适合想用 JVM 技术栈落地 Agent 的团队参考也适合对 Agent 底层机制感兴趣的读者。1. 整体架构设计与思路拆解1.1 为什么偏要用 Java 写 Agent 框架先聊一个最容易被杠的问题现在做大模型应用Python 明显更顺手生态也更丰富为什么还要用 Java 重新造轮子我给出的答案是要看你的 Agent 最终要跑在哪。如果你只是做研究和原型验证Python 确实无可挑剔langchain、llama-index这些库把大部分琐碎工作都封装好了。但一旦 Agent 要进入企业生产环境事情就变了。Java 的杀手锏在于它的运行时稳定性和生态纵深。我们当时面临的情况很典型Agent 需要实时读取内部系统的订单数据需要调用一组很老的 SOAP 接口还要把分析结果写进 Oracle 数据库。这些能力 Python 也能做但我们的技术团队本身全是 Java 背景与其用 Python 重写一遍接口对接逻辑不如直接在 JVM 里做原生集成。另一个被低估的点是 Java 的并发模型在 Agent 场景下的适用性。Agent 的执行天然是“多路探索”的一个任务要被拆成多个子任务若干工具调用可能需要并行执行同时不同 Agent 实例之间还可能有协作。Java 的虚拟线程在 JDK 21 中正式落地之后这种高并发的任务调度变得极其轻量一个 JVM 里跑几千个虚拟线程毫无压力。这是 Python 的 GIL 模型下需要费很大力气才能达到的效果。第三点则是运维的维度。Agent 系统一旦上线就需要日志记录、指标监控、优雅停机、配置热更新这些能力。Java 生态里这些东西沉淀了二十多年拿来即用。与其等 Agent 相关的 Python 运维设施慢慢成熟不如用一套已经很成熟的运行时去承载新的业务逻辑。1.2 Agent 框架的核心那不是聊天那是干活说个很多人对 Agent 的误会。很多人觉得 Agent 就是把大模型的对话接口包一层加上工具调用就算完事。但实际上Agent 真正复杂的不是“对话”而是“干活”——也就是把一个目标转化为一系列可执行、可观测、可回滚的操作。一个合格的 Agent 框架我认为至少要解决四个基本问题目标拆解。用户给一个笼统的指令比如“帮我分析过去三个月各区域销售额的波动原因”Agent 首先得把它拆成一个可以执行的计划。这跟人解决问题的思路是一样的先明确分析维度、再确定数据来源、再选择分析方法最后组织输出。工具调度。拆解出的计划要落地就必须调用外部工具——查数据库、调接口、跑脚本、写文件。工具可能是本地方法也可能是远程服务Agent 要能识别哪个工具适合当前子任务用什么样的参数去调用以及如果调用失败怎么重试。状态跟踪。Agent 的执行不是一次请求就完事的它往往需要多轮迭代。每一轮执行到了什么状态、还差什么信息、哪些中间结果对后续步骤有用都要被持续追踪。否则Agent 就像个失忆的人每次决策都缺上下文。结果验证。大模型生成的结论不一定对。工具调用的返回值也可能是脏数据。框架需要在 Agent 的内部增加验证环节确保每一步执行的结果是可信的然后再进入下一步。这些需求被汇总起来就构成了我设计中所谓的 Agent Core——一个独立于具体大模型的服务编排层。模型可以换工具可以增删但这个 Core 必须是稳定的。1.3 基于 JavaManus 的整体分层模型基于上面的思路我们在设计 JavaManus 时把整个框架分为四层。这里直接给出我们最后定型的分层结构你可以把它当作一个抽象的参考架构。层级核心职责关键组件接入层提供对外交互入口HTTP API、消息队列消费者、定时任务触发器编排层Agent 的任务规划与执行调度Agent Core、任务队列、步骤状态机能力层挂载各类工具与记忆存储Tool Registry、Memory Store、Vector Store模型层屏蔽多模型差异LLM Provider 接口、Token 计算器、流式适配器这个分层是照着“依赖往里收、能力往外放”的原则设计的。接入层只是入口不承载业务逻辑编排层处理 Agent 的核心生命周期能力层负责把各种外部依赖封装成统一的工具接口模型层则让上层完全不感知底层是 GPT、Claude 还是国产模型。我见过很多团队做 Agent 时犯的一个错误是把逻辑全塞进 Controller 或者 Service 里刚开始能跑但一加新工具就到处改代码一换模型就崩。分层的意义在于每个组件只关心自己那一层的事情上层不需要关心下层的实现细节。比如接入层收到 HTTP 请求只需要把任务丢给编排层不用关心这个任务最终是调用哪个模型、执行哪些工具。2. 核心细节解析与实操要点2.1 Agent 的能力模型从 Prompt 到结构化指令Agent 的核心实体是一个“智能体”它跟一个普通的 HTTP 接口的最大区别在于它需要理解目标并自主决定怎么完成目标。这个“自主决定”的过程在工程上其实就是一套结构化的指令和上下文传递机制。我们在 JavaManus 里定义了 AgentTask 和 AgentStep 两个核心模型。AgentTask代表一次完整的任务生命周期。包含任务 ID、目标描述、初始上下文、状态、执行计划引用。这里最重要的就是goal字段——它承载用户输入的原始的、模糊的目标同时还有一版经过解析器提炼出的formattedGoal其中提取了关键任务类型、目标实体、预期产出。AgentStep代表一步具体动作。它包含该步骤的输入参数、要调用的工具名、预期产出描述、依赖的上一步 ID用于构建 DAG、执行状态、以及可选的失败降级策略。正是因为有了步骤这个细粒度的抽象Agent 的执行轨迹才变得可观测也能在失败时精准定位到具体哪个环节出了问题。在 Prompt 构造上我们采用了一套“结构化指令 工具描述注入 历史摘要”的组合拳。工具描述不是把方法的注释搬上去而是让模型能读懂“什么场景用什么工具、参数怎么传”。这里我直接放一段我们工具描述的标准格式已经算是一个模板{ toolName: query_sales_data, description: 查询指定时间段内各区域的销售额数据。适用场景用户询问销售趋势、区域对比、业绩达成率。, params: { startDate: yyyy-MM-dd 格式的起始日期, endDate: yyyy-MM-dd 格式的结束日期, region: 可选区域名称不填则查询全区域 }, returns: 包含日期、区域、销售额字段的 JSON 数组 }这里有个容易被忽视的细节工具描述的清晰度直接影响模型选工具的正确率。我们做过 AB 测试描述里明确写了“适用场景”之后工具选择的准确率从 72% 提升到了 89%。原因很简单——大模型的工具选择本质上是语义匹配你给的场景线索越多匹配越准。2.2 上下文与记忆管理别让 Agent 变“金鱼”Agent 跟普通程序的一个显著区别是它需要记忆。但这种记忆又跟传统数据库的持久化不一样。Agent 的记忆包含三个层次——工作内存、短期记忆、长期记忆。工作内存对应一次任务执行过程中的全部上下文。比如当前任务目标、已完成的步骤和结果、工具返回的中间数据。这部分生命周期最短任务结束即释放。短期记忆对应最近几次任务的关键信息。比如用户偏好的输出格式、上次任务中哪些工具执行失败过。这层记忆可以存在本地缓存里TTL 设置成几个小时或一天。长期记忆则是 Agent 跨会话沉淀的可复用知识。例如某个用户经常查询华东区域的数据长期记忆就把“华东区域”设为该用户的默认过滤条件再比如某类业务的处理逻辑较复杂、有特殊路径长期记忆就保存这些经验提示在之后的同类任务里注入。JavaManus 里用MemoryStore接口来抽象这三类记忆。工作内存就是一个HashMap短期记忆用了 Caffeine 缓存长期记忆则存进了向量数据库。但存储本身不是重点重点是“怎么检索和注入”。直接说我们的做法吧。在每次 Agent 准备执行任务之前编排层会把用户的profile和与当前任务语义相关的历史经验取出来拼装到系统提示里。做法并不复杂但要控制注入量。经验太多了Prompt 就会膨胀模型注意力会被稀释。我们一般限制每次最多注入 5 条记忆超过的部分宁可丢弃也不要把 Prompt 搞成一锅粥。有一个很典型的坑把记忆检索做成了“全量检索”。听起来合理但如果向量库里已有上千条历史记录每次任务执行前都来一次全量相似度计算响应延迟根本扛不住。我们后来改成“先粗筛、再精排”先用关键词过滤掉完全无关的记忆只对候选集做向量相似度计算。实测响应时间从 800ms 降到了 120ms。这个优化逻辑其实跟搜索引擎的原理是一样的回忆一下倒排索引的思路就通了。2.3 工具注册与动态发现机制工具是 Agent 的“手脚”。在 JavaManus 的设计里工具不是写死在代码里的if-else分支而是一套可插拔的注册机制。我们定义了一个Tool接口所有工具都必须实现它public interface Tool { String getName(); String getDescription(); MapString, Object getParameterSchema(); ToolResult execute(MapString, Object params); }这里比较关键的是getParameterSchema()。它返回的是一个 JSON Schema——描述这个工具接受什么参数、参数类型是什么、哪些是必填的。为什么这么做因为 Agent 的决策是由大模型驱动的模型需要“看”到这个工具的参数签名才知道怎么传参。Java 方法签名没法直接被模型读取所以必须通过 Schema 做一层桥接。在工具加载上我们用了 Spring Boot 的自动装配机制——所有实现Tool接口的 Bean 会在容器初始化时被自动扫描并注册到ToolRegistry。这带来的好处是新增一个工具只需要写一个类、实现对应接口、加注解不需要修改任何已有代码。工具的可插拔性让 Agent 的能力可以无限扩展——query_sales_data、generate_report、send_email每加一个工具就是给 Agent 加了一只手。你可能想问工具太多了模型选不准怎么办这就是ToolRegistry的另一个职责——按需注入。不是把所有工具的描述一股脑丢给模型而是根据任务的语义只把最相关的前 N 个工具描述注入到 Prompt 里。这一步用 TF-IDF 关键词匹配就能做到成本低但效果明显。可以把这理解成“让模型的范围从几百个工具缩小到几个候选”决策准确性自然就上来了。2.4 多模型适配层别把自己绑死在一家供应商现在的大模型市场已经非常多元了今天用得好好的模型明天可能价格调整也可能某个新模型在特定任务上表现更好。如果你的 Agent 框架跟某一家的 SDK 深度绑定替换成本会非常高。所以我们抽象了LLMProvider接口。它非常薄只做三件事同步/流式生成、Token 统计、模型名与配置解析。流式输出这一点值得重点说说。Agent 的有些任务是长耗时的——比如分析几百条数据并生成报告如果等整个模型推理完才输出用户端会一直“转圈”体验很糟。JavaManus 里通过StreamingResponse的方式把模型的输出一点一点推给前端。在技术上我们用 Spring WebFlux 的Flux来做响应式流推送后端实时把模型生成的 Token 转发给前端同时编排层会额外跑一个订阅任务把这些流式 Token 累积起来等完整结果出来后继续做下游处理和工具调度。这样既保证了交互的实时性又不丢失完整的结果语义。实际上实现多模型接入并不需要魔法。每个模型厂商的接口无非是输入消息列表、返回文本结果。我们的适配器核心逻辑就是做一个格式转换——把厂商格式转成统一的LLMRequest再把响应转回统一的LLMResponse。如果某天想换一家模型只需写一个新的适配器类在配置里切换一下。我这里给一个可能的配置片段java: manus: llm: provider: openai-compatible base-url: http://localhost:9997/v1 model-name: qwen-plus temperature: 0.3 max-tokens: 4096这个配置的关键是openai-compatible——很多开源模型和云厂商都提供 OpenAI 兼容接口所以写一个通用的 OpenAI 兼容适配器就等于打通了市面上绝大多数模型服务。3. 实操过程与核心环节实现3.1 过一遍完整链路从任务输入到结果返回纸上谈兵讲了半天我们把一条完整链路走一遍你就知道这些模块是怎么协作的了。假设一个用户请求是“生成 2024 年 Q4 华东区的销售分析报告”。第一步接入层的 Controller 收到这个请求包装成一个AgentTask对象丢给编排层的AgentScheduler。编排层会根据任务的复杂度决定使用什么样的执行计划策略。由于这里有一个明确的“生成报告”动词和一个“销售数据”对象可以走预定义流程模板。第二步AgentScheduler从ToolRegistry里检索与当前任务相关的工具。正常情况下会命中query_sales_data和generate_report两个工具。随后执行计划会被构建成一个简单 DAG——先查数据后生成报告。第三步进入实际的执行循环。第一步调用query_sales_data传入 startDate、endDate 和 region。参数不是凭空生成的而是AgentCore在调用模型时把工具的描述注入 Prompt然后让模型返回结构化的ToolCall指令再由框架解析执行。翻译成人话就是大模型“决定”了该用什么参数框架负责“执行”这个调用。第四步工具调用完成后返回的销售数据会被写回工作内存。AgentCore把数据和当前状态一并传给模型询问下一步该怎么做。模型通常会回答“数据已获取开始生成报告”。随后generate_report工具被执行把数据渲染成一份标准化的 Markdown 或 PDF 报告。第五步报告生成完成后框架将最终结果返回给调用端。整条链路看下来就五个环节但每个环节都涉及状态流转、上下文更新和异常处理。为了完整呈现这个链路我给你梳理一个时序表接入层 - 编排层: 提交任务包含目标描述 编排层 - 工具注册中心: 检索可用工具 编排层 - 决策引擎: 规划执行步骤构建 DAG 执行引擎 - 大模型: 注入上下文和工具描述请求决策 大模型 - 执行引擎: 返回工具调用指令 执行引擎 - 工具实例: 执行调用获取结果 执行引擎 - 大模型: 注入工具结果请求下一步 大模型 - 执行引擎: 返回最终回复 / 下一个工具调用 执行引擎 - 输出适配器: 格式化结果 输出适配器 - 接入层: 返回最终响应3.2 Java 流式处理在执行引擎中的落地Agent 的执行过程本质上是一连串数据处理流的串联。数据从工具 A 流出进入模型 A 的上下文模型的输出又成为工具 B 的输入。如果每一次交互都靠阻塞式的调用不仅性能差代码也会写得像回调地狱。JavaManus 里我们对这个问题的解法是引入响应式编程范式核心基于 Project Reactor。每个 AgentStep 的执行结果会被封装成一个MonoToolResult多个步骤之间的数据流转通过flatMap串联。比如“先查数据再生成报告”这个链路用代码表达出来长这样public MonoToolResult executePlan(AgentTask task) { return Mono.just(task) .flatMap(this::planSteps) .flatMap(dag - executeDag(dag, task.getContext())) .onErrorResume(e - Mono.just(ToolResult.failure(e.getMessage()))); } private MonoListAgentStep planSteps(AgentTask task) { // 调用模型生成步骤列表或匹配预定义流程模板 } private MonoToolResult executeDag(ListAgentStep dag, Context ctx) { // 逐个执行步骤每步结果写入 ctx }这里面有个重要的细节为什么不用普通的CompletableFuture或者ExecutorService原因是错误传播。Agent 的步骤之间是有依赖关系的如果步骤二依赖步骤一的输出而步骤一执行失败了步骤二再跑就没有意义。CompletableFuture确实也能做编排但处理“几步中有一步失败、其余该怎么办”的逻辑非常繁琐。而 Reactor 的onErrorResume、mergeWith这些组合子能让我们更清晰地表达这类分支逻辑。尤其当 DAG 复杂起来之后响应式的声明式写法明显更整洁。3.3 可观测性追踪一次任务的全链路轨迹Agent 系统跟普通后端系统最大的不同是它的执行路径不是预先写死的而是模型每次实时决定的。这就意味着同样的输入两次执行可能走完全不同的工具调用路径。如果你没有一套全链路追踪机制排查问题时基本等于盲人摸象。我们在 JavaManus 里定义了ExecutionTrace数据结构。这次任务里的每一步——模型怎么思考、选择了什么工具、传了什么参数、工具返回了什么结果、耗时多少——都会被记录。这些数据有三个用途一是实时的任务状态展示二是问题定位三是 Prompt 调优的参考。实现上我们借用了 OpenTelemetry 的 span 概念。一个任务是一个 root span每执行一个步骤就创建一个 child span。span 上挂的 attribute 包括工具名、参数摘要、模型名、token 消耗。所有 span 最终被汇入一套可观测后端进行查询和分析。这套体系上线后我们发现了一个特别有意思的现象很多“Agent 执行结果不对”的问题根因并不是模型选择错误而是框架在工具结果传递过程中出现了数据截断。比如模型返回了 2000 个字符的结果但工具上下文只保留了前 500 字符。没有 trace 的话这类问题极难定位。而一旦有了完整的轨迹问题一眼就能看出来。3.4 项目落地需要避开的性能陷阱说几个我们在压测中真正遇到的性能问题以及对应的解决方案。第一个陷阱是 Token 计算带来的性能负担。每次调用大模型前框架都要估算当前上下文的 Token 数决定是否触发摘要压缩。如果用官方 SDK 自带的 tokenizer几十次调用下来 CPU 开销很可观。我们的优化方式是在上线前做一次校准统计模型在常见上下文长度下的 token 消耗拟合一个粗略的“字符数 × 系数”预估公式。虽然不精确但足够用于触发阈值判断。精确统计只保留在最终计费日志阶段。第二个陷阱是工具调用的网络开销。Agent 在工具之间切换时频繁建立新的 HTTP 连接会产生很大的连接池压力。我们统一把所有远程工具调用收敛到一个HttpClientManager复用底层连接池并针对不同工具设置合理的connectTimeout和readTimeout。刚开始图省事所有工具共用一个超时时间结果有一个慢接口把整个 Agent 的任务周期拖到超时最后一次性拆开配置问题就没了。第三个陷阱是上下文压缩策略的执行时机。如果不做压缩长对话场景下 Prompt 会越来越大响应越来越慢但压缩过头模型又会丢失重要信息。我们最终采用了“双阈值”方案当上下文总长度超过上限的 70% 时触发压缩但要求压缩后的摘要至少保留原始信息的 40%。这个阈值不是拍脑袋定的是在压测里反复调出来的一个平衡点。4. 常见问题与排查技巧实录4.1 工具调用循环与终止条件控制Agent 用着用着容易进入一个死循环模型反复决定调用某个工具每次调用结果都类似但迟迟不给出最终答案。这种情况在复杂任务里非常常见本质上是模型的“任务完成判断”出了问题。我们的解法是加入一个“最大迭代次数”的硬限制把默认值设在 8 步。超过这个步数强制让模型进入总结模式把当前已有信息整合成最终回复。同时在系统提示里加一句硬约束当你认为当前信息已经足够满足用户目标时直接输出最终答案不需要继续调用工具。句话说模型确实有这个“收尾能力”只是需要有人给它一个明确的信号。另外还有一个隐蔽的坑模型调用了工具但工具返回的结果是空数据或错误信息而模型又把这段错误信息当成正常上下文继续下一步推理。所以我们在工具执行层加了一个验证逻辑——工具返回之后先检查结果是否包含明确的错误标志。有错就直接走失败路径不让脏数据污染后续推理。4.2 并发安全与执行环境隔离Agent 的多实例并发执行时最怕的是上下文串线。用户 A 的任务上下文跑到了用户 B 的任务里——这在多租户场景下是严重事故。我们使用“每任务一个上下文”的原则AgentContext是在任务创建时实例化的所有步骤共享同一个引用但它的归属严格限定在当前任务内部绝对不允许有任何静态变量存储任务状态。你可能觉得这是常识但实际踩过坑最初版本的ToolRegistry里有一个缓存 Map 保存“最近一次工具调用结果”本意是想做结果复用结果测试的时候发现两个任务之间发生了数据交叉。改动的方案也很直接——把结果缓存改成 per-task 的局部变量。这个坑记忆很深也让我对“Agent 是状态化还是无状态化”有了新的认识。理想中 Agent 是无状态的但现实是为了性能一定会引入缓存这时就务必把缓存的隔离性和生命周期想清楚。4.3 模型“犯糊涂”时的兜底策略模型毕竟是概率系统它有时候会生成格式完全不对的输出——比如工具调用的参数缺了必填项或者返回了不存在的工具名。这类问题无法通过“更聪明的 Prompt”完全消除必须靠框架兜底。我们的策略很简单三层兜底第一层工具调用参数校验。如果模型返回的ToolCall在ToolRegistry里找不到对应工具或者参数格式不合法直接把这次调用标记为失败并给模型回传错误信息“该工具不存在或参数格式错误请重新决策”。模型收到错误信息后通常会修正决策。第二层重试机制。定义一个重试次数上限——默认 3 次。每次重试时把上一次错误的详细原因追加到上下文里让模型带着反馈重新决策。可以这样理解模型一开始可能因为上下文不清而犯错但给了纠错反馈之后它其实能做出正确的判断。第三层人工接管兜底。如果重试后仍失败或者用户任务的置信度一直低于阈值就把任务标记为NEED_HUMAN_REVIEW推到审批队列。这一步在企业场景里特别重要——Agent 不是用来完全替代人的它越界时必须有一个人能拉得住缰绳。4.4 问题排查速查表把我们从真实故障里总结出的排查经验整理成一张表遇到问题时可以按图索骥症状常见原因解决思路Agent 长时间不响应上下文过大导致模型推理慢检查 Token 统计触发摘要压缩工具调用频繁报错参数 Schema 描述与实际情况不符对比工具声明与真实调用日志不同任务结果互相干扰静态变量存储了任务状态全局搜索 static 关键字清理共享状态相同输入、不同结果大模型本身的随机性调整 temperature 参数过高时降为 0.2-0.3步骤执行顺序错乱DAG 依赖关系定义不完整检查步骤之间的依赖 ID 是否都建立响应内容过短或答非所问记忆注入不完整导致丢失背景检查记忆检索的候选集大小与相关性阈值任务结果格式不稳定输出约束 Prompt 不清晰在系统提示中给定 JSON 输出格式示例最后分享一个“非技术”的建议Agent 框架的调试别只盯日志。把模型每一步的完整输入输出存下来生成一份可读的 trace 报告配合任务失败时的上下文回溯排查效率会提升很多。尤其是在模型决策路径不稳定的情况下trace 比日志信息有价值得多。个人做下来的体会是Java 写 Agent 框架并非逆流而上反而在某些场景里是一种务实的选择。它没有 Python 那么轻巧但当你需要把 Agent 嵌入到企业级系统里时JVM 的稳定性和生态纵深会变成实实在在的优势。核心不在于语言在于你把 Agent 的骨架搭得多稳、多利于维护。JavaManus 这套设计的思路希望能给你提供了一个可以参考的起点。
阅读完成 · 觉得有帮助?
咨询建站