ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java生态下构建Agent智能体:Spring Boot集成与核心设计实践

Java生态下构建Agent智能体:Spring Boot集成与核心设计实践 先聊一个背景我大概从去年开始就一直琢磨着怎么把大模型的能力真正用进企业系统里。市面上讨论Agent的视频和文章铺天盖地但绝大多数是用Python写的Demo真正能落进Java技术栈、和Spring Boot服务融为一体的方案非常少。所以我用了大概三周业余时间基于JDK 17 Spring Boot 3.2做了一款叫lucky_agent的Agent智能体。这篇文章就把这个项目的整个设计过程、核心代码思路以及我踩过的坑完整记录下来写给所有想在Java生态里做AI Agent的朋友。如果你手上正好有一个Java后端项目想给它接入能自己规划任务、调用工具、处理多步骤业务的能力那么这篇文章会很对胃口。我会从最底层的Agent循环模型讲起到Function Calling的Java抽象再到多Agent协作的编排最后是排查经验。内容会比较具体建议你跟着动手把核心链路写一遍真正理解它为什么要这么设计。1. 项目整体设计与思路拆解1.1 先搞明白Agent智能体和普通接口调用的差别很多Java开发者一开始会把Agent想象成一个加强版API调用这是最要命的误解。普通的接口调用是确定性的——你传参数进去服务返回结果两边都提前约定好了契约。Agent完全不一样它面对的是目标导向的动态执行你给它一个目标它要自己决定调哪些工具、按什么顺序调、调完结果不满意怎么办整个过程充满不确定性。打个比方可能更好懂。传统开发像是你去餐厅点菜——菜谱是固定的后厨按流程做你付钱拿餐。Agent像是你雇了一个临时助理你告诉他把这次客户拜访的跟进报告写出来顺便整理出下次会谈需要准备的三个问题助理需要自己决定先查哪些历史记录、调哪些内部系统、甚至发现缺少信息时回头追问你。这个自己决定怎么干活的过程就是Agent存在的核心价值。所以我在设计lucky_agent时第一原则就是绝对不能把Agent做成一个披着聊天外衣的查询接口。它必须包含四个核心构件一个能拆解目标的规划器Planner一堆能被模型调用的工具集合Tools一个能记住上下文和决策历史的记忆存储Memory一个能反复执行想一下-做一下-看结果的循环引擎Runtime这四个构件缺一不可。如果你只做了模型调用工具执行而没有循环引擎那它就是个单轮问答助手如果没有记忆Agent做复杂任务时就会反复丢失自己前面已经确认过的信息没有规划器遇到帮我整理一份季度数据报告这种模糊目标时它根本不知道从哪下手。1.2 lucky_agent的设计目标与模块切分明确了Agent的本质之后就要落到工程结构上。我给lucky_agent定的设计目标是能嵌入任何Java后端服务能以最小的改造成本暴露业务能力给大模型并且能支撑多个Agent并行工作。整个项目按功能拆成四个主模块模块职责关键设计点agent-core调度内核与循环引擎维护Agent状态机控制最大迭代次数管理规划-执行-观察循环tool-registry工具注册与发现基于注解扫描Java方法自动生成大模型可识别的JSON Schema描述model-adapter大模型接入抽象层统一各家大模型的调用协议支持流式与非流式输出memory-store记忆与上下文管理保存短期会话记忆支持对历史消息做摘要压缩这样拆有一个非常大的好处业务开发和Agent内核开发可以完全解耦。团队里的后端同学只需要关注自己负责的业务方法怎么加注解、怎么写参数描述完全不用关心模型是怎么调用、循环是怎么处理的。而内核的维护者也不需要知道业务方法内部逻辑只需要处理模型的输入输出和Agent状态流转。依赖关系上我严格控制了方向agent-core依赖tool-registry的接口但不依赖具体业务实现model-adapter是独立的最底层模块memory-store只被agent-core使用。整个项目可以Maven打包成多个jar引入方按需依赖。1.3 为什么坚持用Java而不是随大流选Python说实话做Agent用Python确实会更顺手生态里现成的框架多写起来也快。但这次项目有个非常明确的约束它要服务于一个银行客户的项目整个技术栈就是Java Spring Boot线上环境根本不允许再起一个Python服务。在硬约束之外Java做Agent也有一些被低估的优势。最明显的是类型系统的约束力。Python的灵活在写脚本时是优点但在Agent这种模型输出结果不可控的场景里反而是风险——模型返回一个字符串你没法在编译期知道它是不是合法的JSON结构。Java的强类型配合严格的序列化框架让我能在模型输出进入业务方法之前就把格式问题拦截掉。另一个优势是并发能力。Agent做复杂任务时往往需要多个子任务并行执行Java的虚拟线程和CompletableFuture配合起来非常顺手。我在Planner拆解完任务清单之后用ExecutorService同时派发给多个执行Agent这个并行控制的代码量在Python里反而没有Java这么清晰。当然Java的劣势我也认生态确实不如Python丰富很多最新的模型推理框架Java SDK支持滞后。我的解决办法是在model-adapter层尽量走OpenAI兼容协议而不是绑定某个特定SDK。这样哪怕Java SDK更新慢我也可以用HTTP客户端直接调模型网关不阻塞开发。2. Agent核心原理与关键模型设计2.1 核心循环模型感知、规划、行动、迭代Agent的运行机制如果用一个词概括就是循环。大模型本质上是个单次推理的机器你给它一段上下文、几个可用工具它输出一次决策。但要完成复杂任务模型必须看得到上一步的执行结果然后根据结果再决定下一步动作。所以我给lucky_agent设计了一个四状态循环PLANNING规划Agent把用户目标拆解成可执行的小计划。简单场景下就是一次工具选择复杂场景下会输出一个有序的工具调用序列。EXECUTION执行根据模型的决策从ToolRegistry中找到对应方法通过反射把参数绑定好然后调用。OBSERVATION观察把工具执行结果成功或失败、返回了什么数据作为新的一轮上下文重新交回给模型。ITERATION迭代重复以上过程直到模型认为自己已完成任务或者达到我们设置的最大迭代次数。这个模式在业界被称为ReAct模式但我自己在Java里实现时最大的感受是循环本身不难难在控制循环的边界。如果没有最大迭代次数模型可能会在一个错误决策上反复打转白白消耗token。如果最大迭代次数设得太死复杂任务又会执行不完。我最后把迭代上限设置成可配置的默认10轮同时对单轮工具调用的超时时间单独控制避免某个外部系统卡住整个循环。循环的另一个关键是每轮迭代后必须更新上下文。很多人写Agent时会把历史对话一股脑全塞进模型请求里这样前几轮没问题到第5轮之后上下文就可能会超长。我的做法是用一个AgentMessageRecord对象保存每一轮的增量信息只有模型决策和各步执行结果不保留中间的打印日志这样上下文增长是可控的。2.2 工具调用的Java抽象让模型理解你的方法Java方法是不可能直接被大模型执行调用的。模型输出的是一段结构化的JSON里面写着它想调用哪个函数、参数是什么。所以ToolRegistry要做的事就是把Java方法翻译成大模型能理解的语言再把模型返回的参数翻译回Java调用。第一步是生成函数描述。模型比如支持Function Calling的模型在下一次请求里会看到当前可用的所有工具包括函数名、功能描述、参数结构、哪些字段必填。这个描述必须严格遵循JSON Schema格式。我在项目里实现了一个AgentTool注解标记在public方法上然后通过反射读取方法签名、参数名、参数注解自动组装出Schema。这里有个关键点参数描述的质量直接决定调用成功率。你光写参数keyword类型string模型大概率会猜错传什么值。但如果写成参数keyword类型string描述传入完整的客户姓名支持模糊匹配模型的表现会好得多。工具描述就是模型的地图地图画得越清楚导航就越不容易出错。第二步是参数绑定。模型返回的参数值本质上还是JSON我需要用Jackson把JSON反序列化成方法参数的Java对象。这个过程最大的坑在于类型映射。模型对数值类型特别不敏感你定义的是int它可能返回3.0或者3的字符串你定义的是枚举它可能返回一个不在枚举列表里的值。所以我写了一套宽松的反序列化规则数值类型做合理的强制转换枚举匹配不上时优先尝试忽略大小写再不行就返回工具执行错误信息给模型让它重试。2.3 任务规划与多Agent协作的工程实践单Agent的循环能解决调用工具完成任务的问题但遇到更复杂的业务目标时单Agent的推理质量其实很差。比如生成一份竞品分析报告并汇总到文档里这个目标涉及搜索、数据整理、文本生成、写入文档四个不同类型的动作任何一个Agent自己从头到尾干完中间必然会出现上下文污染和注意力分散。我的做法是引入多Agent协作模式但不是复杂到搞什么角色扮演对话而是清晰的分工结构Planner Agent负责解析用户目标拆解成若干个独立的小任务每个任务指定负责人和预期产出。Executor Agent每个任务分配一个独立Agent实例它只关注自己那一步调用工具、拿到结果就交付。Inspector Agent所有任务完成后由一个审查Agent统一检查结果完整性发现缺漏就返回给Planner重新规划。这三个角色的编排我放在了agent-core的CollaborationEngine里。实际落地时我遇到的第一个问题是Planner输出任务清单后怎么分配给Executor。最终是用一个简单的任务队列来实现的Planner产出任务后提交到一个线程安全的阻塞队列多个Executor从队列里取任务完成后把结果写回共享的Map键是任务ID。Java的ConcurrentHashMap和Executors.newFixedThreadPool在这里派上了大用场代码简洁且线程安全。第二个问题是怎么防止多个Agent互相等待形成死锁。我用了一个很土的方案每个任务设置独立超时时间Executor执行任务超时后直接标记失败并释放线程Inspector在汇总阶段看到失败标记就触发Planner重规划。这个设计不算精巧但胜在可控。3. 实操过程与核心环节实现3.1 项目骨架与技术选型lucky_agent的开发环境用的是JDK 17 Spring Boot 3.2.4 Maven代码仓库里分成了上面提到的那几个子模块。我的建议是不要用单模块工程来写Agent因为Agent项目的演进速度极快今天写的工具注册逻辑明天很可能要抽出来被其他服务复用多模块从第一天就避免了后面重构的麻烦。依赖方面没有引入太多重型框架。除Spring Boot本身的starter-web和starter-validation之外主要加了JacksonJSON序列化与反序列化Hutool一些方便的工具方法比如HTTP调用和JSON解析的兜底Lombok减少样板代码一个简单的H2内存数据库用来做记忆持久化的实验模型接入方面考虑到环境限制我用了OpenAI兼容协议的HTTP接口来对接模型这样无论是DeepSeek、通义千问还是其他国内模型服务只要它们暴露了兼容接口就能直接适配。项目里不依赖任何特定模型的SDK只写了一个轻量的HTTP客户端封装用Jackson解析返回结果。3.2 模型适配层的接口设计模型适配层是整个项目的底层接口设计上我遵循了一条核心原则上层永远不需要关心调的是哪家模型。所以我定义了ChatClient接口它只有一个方法public interface ChatClient { ChatResponse chat(ChatRequest request); }ChatRequest里面包含模型名、消息列表、可用工具列表、温度参数等。ChatResponse则包括模型返回的文本内容、是否触发了工具调用、如果是工具调用则包含函数名和参数JSON。上层Agent循环只跟这两个对象打交道完全不感知底层HTTP协议的差异。实现这个接口时有个细节非常容易踩坑各家模型的流式输出协议不同。我一开始为了拿到Token级别的返回速度实现了流式接口结果发现不同模型的流式增量格式差异很大解析代码写得又长又脆。后来我干脆对Agent循环内部全部使用非流式调用只在最终输出给用户时再做一次流式转发。这样既保证了Agent循环的代码简单稳定又让用户感知不到明显的等待。模型适配层还承担了错误映射的职责。模型服务返回限流、超时、网络错误时我会把这些错误统一转换成自定义的AgentModelException带上明确的状态码和重试建议上层捕获到这个异常就可以决定是用备用模型还是直接降级。3.3 工具注册中心的实现注解驱动我做工具注册中心时最想避免的一件事就是每种业务工具都手动写一遍JSON Schema。那太反人类了业务同学根本不想学JSON Schema语法。所以我的设计目标是只写一个Java方法加一行注解工具就自动变成模型可调用的能力。AgentTool注解长这样Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface AgentTool { String name(); // 工具名称模型将以此识别 String description(); // 工具功能描述要写得足够详细 boolean enabled() default true; }对应的ToolRegistry在Spring容器启动后扫描所有带着这个注解的Bean通过反射解析方法签名用Jackson生成参数Schema缓存到一个ConcurrentHashMap里。下面是核心扫描与注册的代码示意Component public class ToolRegistry implements ApplicationListenerApplicationReadyEvent { private final MapString, ToolDefinition tools new ConcurrentHashMap(); private final ApplicationContext context; public void onApplicationEvent(ApplicationReadyEvent event) { MapString, Object beans context.getBeansWithAnnotation(Component.class); for (Object bean : beans.values()) { Method[] methods bean.getClass().getMethods(); for (Method method : methods) { AgentTool tool method.getAnnotation(AgentTool.class); if (tool null) continue; ToolDefinition def buildDefinition(tool, method, bean); tools.put(def.getName(), def); } } } }buildDefinition里最重的逻辑是解析方法参数生成符合模型要求的JSON Schema格式。这一步我实现了以下几个规则基础类型String、int、boolean等直接映射成对应的JSON类型。自定义POJO类递归解析字段生成object类型加properties。List、Map等容器类型正确处理items和additionalProperties。参数上的ToolParam注解可以补充描述、默认值、是否必填。这里要特别强调一个细节参数名默认必须保留。Java编译时如果不加-parameters参数反射是拿不到方法参数名的只能拿到arg0、arg1。我Maven的compiler插件里专门开了这个参数保留选项否则生成的工具Schema会是废的。工具注册完成后模型在执行Agent循环时就会看到一张能力清单。我在开发环境里调试时经常用这个清单来检查工具描述质量——如果某个工具描述连人看了都一头雾水那模型多半也不会调用正确。3.4 Agent主循环的编码实现Agent主循环是整个项目的发动机。我把它实现成了一个独立的AgentRuntime类尽量保持无状态设计这样同一个Agent定义可以服务于多个用户会话只是每个会话拥有自己的记忆实例。核心循环逻辑如下public AgentResult execute(AgentGoal goal, AgentMemory memory) { for (int step 0; step maxIterations; step) { ChatRequest request buildRequest(goal, memory, tools); ChatResponse response chatClient.chat(request); if (response.hasContent()) { memory.appendAssistantMessage(response.getContent()); } if (response.hasToolCalls()) { for (ToolCall call : response.getToolCalls()) { ToolResult result toolRegistry.execute(call.getName(), call.getArguments()); memory.appendToolResult(call.getName(), result); } continue; // 执行完工具继续循环 } if (response.isFinished()) { return AgentResult.completed(response.getContent()); } } return AgentResult.maxIterationsExceeded(); }这个循环看起来很简单但有几个设计细节我觉得值得展开。首先是什么时候退出循环——我依赖模型返回一个特定的finish标记也就是Function Calling里没有工具调用且文本内容不为空时就认为Agent完成了任务。但这在早期版本里经常误判因为模型有时说了句好的我来处理一下就停了根本没干活。后来我在System Prompt里强烈要求模型在完成所有工具调用、拿到全部结果之后才输出最终内容误判率下降了不少。其次是每轮迭代的请求组装。这里要小心不是简单把所有历史消息全部拼接。我维护了三个可见区域系统提示词、可见上下文、工具结果区。可见上下文会包含用户最初目标和最近几轮的Agent思考与动作而不把很早的中间步骤全部放入以控制每次请求的token预算。工具结果区则始终在最新一轮请求里完整保留因为模型需要基于最新状态做决策。最后是超时机制。在for循环之外我包了一层Future用Executors.newSingleThreadExecutor()去提交整个循环任务同时用future.get(timeout, TimeUnit.SECONDS)拦截整体超时。这样即使某个工具调用挂起整个Agent任务也不会无限拖住线程。3.5 一次完整演示让lucky_agent查天气并生成出行建议空讲代码不如直接跑一个例子。我拿一个极其常见的场景来展示用户给lucky_agent发了一句明天北京适合出行吗给我一个建议并且生成一段格式化的文案。第一步Planner先把目标拆解成两个子任务查天气、生成建议文案。查天气这步需要调工具我提前在ToolRegistry里注册了一个queryWeather方法AgentTool(name queryWeather, description 根据城市名查询未来三天的天气情况返回温度、天气现象、风力等信息) public String queryWeather(ToolParam(description 城市名如北京、上海) String city) { // 内部调用第三方天气API返回结构化JSON字符串 }第二步Executor Agent开始循环。第一轮模型看到可用的queryWeather工具于是返回一个工具调用请求参数是{city: 北京}。ToolRegistry执行这个方法拿到天气数据把结果以工具消息的形式追加到上下文。第三步模型看到天气结果后发现不需要再调用其他工具于是输出了一段自然语言北京明天晴转多云气温5到13度风力3级空气质量良好适合出行。建议穿厚外套带上围巾。并调用一个格式化工具把这端文案包装成带标题、时间的段落。至此循环退出Agent返回完整结果给用户。这个过程如果让行外人看会觉得这算什么智能但懂行的人能意识到这背后其实完成了一次自然语言目标到程序化工具执行的自动翻译。而整个过程中用户不需要知道天气API叫什么、参数是什么、返回结构长什么样Agent自己完成了这些适配。3.6 多Agent协作场景的实测记录单体Agent演示已经很能说明问题但真正让lucky_agent在业务场景里站稳脚跟的是多Agent协作。我测试过一个内部场景让Agent基于一批销售数据生成周报并输出为Word文档。Planner Agent先输出的任务清单大致是分析数据、提炼关键指标、生成周报文案、把文案写入Word文档。前三个任务属于计算密集型第四个任务涉及文档生成。我用线程池同时启动三个Executor它们各自持有独立的模型会话并行执行。其中一个Executor调用数据分析工具得到关键指标另一个根据指标生成摘要文案第三个把摘要整理成Word需要的段落结构。三个任务完成后Inspector Agent检查了步骤三的产物发现Word文档生成时引用了一张图片路径是错误的于是返回给Planner重新触发任务。这个过程中的体验让我印象最深的是Agent协作架构的调试难度远超单Agent。因为多个Agent并行时日志交错在一起排查一个错误任务要看好几个Agent的轨迹。后来我专门给每个Agent实例分配了一个全局唯一的traceId并且把每个Agent的关键决策日志单独输出到一个以traceId命名的日志文件中排查效率大幅提升。这个技巧非常推荐给要搞多Agent并行的人借鉴。4. 常见问题与排查技巧实录4.1 模型返回的JSON永远在花式出错怎么办我在整个开发过程中花在解析模型输出上的时间大概占了三成。模型返回的JSON不是标准JSON的情况太常见了多了前后解释性的文字、键名跟Schema不完全一致、数组套了三层、数字变成带引号的字符串。最离谱的一次模型把布尔值true返回成了是。我的排查思路分三路第一路是宽容解析——拿到模型返回的字符串先尝试直接解析失败就找第一个{和最后一个}做子串截取再解析一遍。第二路是自动修复——对键名做大小写无关匹配对值类型做宽松转换枚举匹配不上就按第一个枚举值兜底。第三路是重试机制——如果前两步都失败了把这个解析错误作为工具执行结果的一部分回带给模型明确告诉它你上一次输出的工具参数无法解析原因是XXX请检查后重新输出。实测下来这个错误回带机制能解决多数解析问题因为模型看到自己的错误反馈后通常会老老实实重新生成正确格式。4.2 Function Calling参数匹配不上的问题工具方法的参数类型越是复杂模型越容易传错。我遇到过一个典型问题工具方法参数是一个有嵌套结构的POJO模型传参时只传了第一层字段把嵌套对象整个漏掉了。还有一次参数是ListString模型却传了一个String。排查之后总结出来两条规律。第一参数描述得越具体传错概率越低。我在ToolParam里会写明支持模糊查询如果值为空请传null这类约束模型在生成参数时会遵循这些提示。第二复杂POJO参数尽量拆成多个基础类型参数。比如一个queryOrder(String userId, String status, LocalDate startDate)就比传一个OrderQuery query好用得多。虽然这牺牲了一些优雅性但对模型非常友好。4.3 上下文窗口被Agent自己刷爆了这是多轮Agent场景下最头疼的问题。Agent每执行一个工具工具结果就会作为新消息塞进上下文。如果工具返回的数据特别大——比如查询了500条订单记录——上下文立刻飙升而且后续每次迭代都会带着这批数据导致token成本爆炸还可能触发模型的上下文长度上限。我目前的解决手段是分三层第一工具结果摘要化——工具执行完返回原始数据但记录到记忆里的只是摘要描述比如查询订单成功共500条记录其中待支付120条。模型需要看明细时再调用专门的工具去取。第二历史消息滑动窗口——Agent保留最近10轮消息更早的则被压缩成一段摘要。我用模型自己给旧消息做一两句话的压缩比简单截断效果好很多。第三把大块数据写进外部存储——对于需要模型反复看的内容写入H2内存库然后只把查询路径传给模型。4.4 并发与限流多个Agent共享模型服务的坑在并发场景下多个Agent同时调用模型服务必然碰到限流。模型服务的限流策略很复杂有按QPS的有按Token数每分钟的。我在没有仔细看文档的情况下直接开并发压测结果一堆429限流错误所有Agent任务连环失败。找到的解决办法是在model-adapter层做了一个令牌桶限流器单位时间内的并发出站请求数可配置。同时为每个模型请求增加重试机制——遇到429或5xx错误时根据响应头里的Retry-After等待一段时间再重试重试次数默认3次。更重要的是我调整了框架层级的并发策略同一个用户会话内的Agent任务严格串行不同用户会话之间才并发。这样既降低了限流压力也避免了单个会话上下文交叉污染。4.5 一些易被忽略的Java工程坑除了模型相关的问题Java工程本身的坑也值得一提。第一个是反射扫描的性能问题Spring容器启动时全量扫描所有Bean的方法并生成Schema在工具方法很多时可能拖慢启动。我的优化方案是把工具注册做成懒加载只有第一次被调用时才生成Schema并且用ConcurrentHashMap的computeIfAbsent保证每个工具只生成一次。第二个是JSON序列化循环引用工具返回的POJO如果存在对象循环引用Jackson序列化时会抛异常。这个问题在查询关联实体时特别容易触发解决方法是给ObjectMapper配置FAIL_ON_SELF_REFERENCES关闭并加上JsonIgnoreProperties处理关联字段。第三个是虚拟线程与线程池混用的问题。项目里既有虚拟线程跑即时任务又有普通线程池跑重任务如果混用会导致虚拟线程被阻塞在池化线程上产生可观测的延迟。后来我做了严格隔离所有IO密集型任务走虚拟线程CPU密集型任务走自定义线程池两种任务的提交入口分开避免交叉。5. 这个项目后续还能怎么扩展5.1 我的实际体验与心得把lucky_agent从零写到现在我最深的感触是Java做Agent完全可行但别指望抄一个框架就能开箱即用。真正需要投入精力的不是JSON解析、不是循环控制而是对业务场景的理解和对工具描述的质量打磨。模型本身聪明与否反而没那么关键因为Agent的价值在于把模型能力嵌入到管用的业务流程里。如果你打算在Java项目里引入Agent我会给你三个具体建议。第一从最窄的场景切入先只做查数据写摘要这类低风险任务不要在第一个版本里就想着多Agent协作。第二给Agent设定清晰的安全边界所有工具调用必须经过审计日志记录敏感操作必须二次确认。第三做好日志和监控Agent和传统接口不一样同一个目标可能走完全不同的执行路径没有完善的日志你很难复盘一次失败是模型的问题还是工具的问题。5.2 可以预期的迭代方向这个项目目前还在持续迭代我接下来打算做几个方向。一个是更强的规划能力当前Planner的任务拆解过于依赖模型单次输出我想引入一次规划校验环节让模型在拆解后自查一遍任务依赖关系。另一个是工具自动生成理想情况下业务系统可以基于OpenAPI文档自动生成工具描述省掉大部分手工注解的工作。第三个是评估体系给Agent跑测试用例时不只是看最终结果对不对还要评估工具调用路径是否合理、Token开销是否超限这样才能在模型升级或者Prompt调整时量化对比效果。这些方向每前进一步都意味着新的坑但也是这类项目最有价值的部分。希望这篇分享能帮你少踩一些我踩过的坑如果你也在用Java做Agent智能体欢迎一起交流。
返回列表