☰
Java开发Agent智能体:从核心循环到生产化实践
2026/9/28 15:21:50 网站建设 项目流程

最近把用Java写的Agent智能体lucky_agent从原型推进到了内部可用的状态,终于有底气把完整的思路和经验整理出来。简单说,lucky_agent是一个跑在Java技术栈里的Agent运行时,目前承担着团队内部的数据查询、告警排障、自动化运维指令执行这类活。选择Java而不是Python,不是因为Python做不了,而是因为我们核心业务的服务端全是Java,Agent要顺手接工单、接告警、接发布流程,就必须长在这个生态里。这篇文章会把选型逻辑、核心循环设计、记忆管理、踩坑记录和生产化改造挨个过一遍,适合正在纠结“Java能不能做Agent、怎么做Agent”的开发者,也适合准备Java面试时想拿Agent项目当亮点的朋友。

1. 为什么用Java写Agent:选型背后的现实考量

1.1 Python全家桶之外的另一条路

很多人一提到Agent,默认就是Python加LangChain,再套个FastAPI,好像不用Python就做不了智能体。我不这么看。如果只是搭个Demo跑通概念,Python确实最快,生态齐全,写起来爽。但lucky_agent要对接的全是Java内部系统:工单系统是Java,配置中心是Java,发布系统是Java,数据库访问层用的也是JDBC。Agent要调用这些内部能力,最顺手的方案是直接复用现有SDK,而不是用Python在外面再包一层HTTP接口。

举个例子,团队里排障需要拉取服务日志,原本就有现成的LogService客户端,Java这边一行代码就能调用。如果换成Python,就得先给这个能力单独开一个REST接口,还要考虑鉴权、网络策略、超时控制。一套流程下来,Agent没写多少,集成代码倒是堆了一堆。用Java写Agent,意味着可以直接用公司的SSO认证、权限模型、RPC客户端来当Agent的手脚,省掉的集成工作量非常可观。

另外还有一层原因,很多Java团队对Python技术栈不熟,部署运维能力也偏弱。lucky_agent跑在Spring Boot应用里,直接打包成容器镜像,走现有的CI/CD流程就能上线,监控、日志、告警全复用。我觉得这才是Java做Agent最大的优势:不是模型能力,而是工程生态的融合成本很低。

1.2 lucky_agent的定位:服务侧的Agent运行时

我给lucky_agent的定位不是聊天机器人,而是“能执行任务的智能体”。聊天机器人重在对话体验,而Agent重在任务闭环。目前lucky_agent在内部主要干三件事:

  • 通过一句话查询生成数据查询逻辑,执行后返回结果并解释含义,比如查订单量、查失败率、查某个用户的历史行为。
  • 收到告警后自动拉取关联日志和系统状态,初步排查原因,给出处置建议。
  • 执行运维指令,比如重启某个服务、调整配置项、触发版本回滚,这类操作必须走审批流。

这些场景有个共同点:需要频繁和内部系统交互,需要对执行过程有强控制,需要每一步都可审计。定位清楚之后,lucky_agent的架构就围绕“可插拔工具、可控循环、完整审计”来设计。这里插一句,现在很多Agent教程都讲得很宏观,热词也很多,什么“Agent架构”“Agent框架”,其实拆开看就是一个循环加一堆工具。别被概念绕晕,先搞清楚自己要做的是聊天玩具还是任务执行器,再去选框架。

2. Agent的核心循环:从一次对话到一次任务闭环

2.1 Agent运行时需要哪几块积木

Agent的本质是一个循环:接收任务,思考,调用工具,观察结果,再思考,直到产出最终答案。lucky_agent的核心循环是一个可以被截断的for循环,不是黑盒。整个运行时至少有五块积木,缺一块都跑不顺。

第一块是LLM客户端,Java侧我直接用HttpClient封装了一个OpenAI兼容协议的客户端,一套接口同时支持多家模型服务。第二块是工具注册中心,负责把Java方法以标准Schema的形式暴露给模型。第三块是上下文组装器,把系统提示词、历史消息、工具描述拼成一次请求。第四块是输出解析器,从模型返回里提取最终答案或者工具调用指令。第五块是执行器,负责真正调用工具方法,处理异常和超时。

这里顺便把“harness”和“agent”的区别说清楚,很多人问过。在我的理解里,harness是脚手架和调度层,负责上下文构建、循环控制、工具分发的通用部分;agent是你定义的工具集合、提示词策略、任务处理逻辑这部分。lucky_agent实际上就是自己搭了一个轻量harness,然后往里面塞我们自己的agent能力。所以“用Java开发Agent”,本质上是在写harness加业务工具。

2.2 ReAct循环的Java实现思路

模型的推理模式用的是经典的ReAct思路,也就是推理和行动交替进行。Java代码实现起来并不复杂,核心就是一个循环。先定义工具接口,所有工具都实现这个接口:

public interface Tool { String name(); String description(); String parametersJsonSchema(); String execute(String argumentsJson); }

循环主逻辑简化后长这样:

public AgentResult run(String userInput) { List<Message> messages = new ArrayList<>(); messages.add(new Message("system", SYSTEM_PROMPT)); messages.add(new Message("user", userInput)); for (int step = 0; step < maxSteps; step++) { ChatResponse resp = llmClient.chat(messages); if (resp.hasFinalAnswer()) { return AgentResult.finalAnswer(resp.getContent()); } ToolCall call = resp.getToolCall(); Tool tool = toolRegistry.get(call.getName()); if (tool == null) { messages.add(new Message("tool", "工具不存在,请检查可用工具列表后重新选择")); continue; } String result = tool.execute(call.getArguments()); messages.add(new Message("assistant", resp.getRawMessage())); messages.add(new Message("tool", result)); } throw new MaxStepsExceededException(maxSteps); }

这里有个关键选择:为什么用for循环而不是while(true)。核心原因是要给Agent套上限。maxSteps一般设置为5到8,一旦超过就终止,避免模型陷入反复调用工具的循环,把token烧光。这个限制在实验环境好像无所谓,但一放到生产环境,就是真金白银的成本问题。

每个工具返回给模型的结果也需要控制大小。比如查日志的工具,日志文件动辄几百上千行,全塞给模型,还没轮到下一步调用,上下文就爆了。我的做法是:工具执行后做摘要和截断,默认只保留前2000个字符,重要的异常堆栈部分单独提取。这一步已经成了lucky_agent工具层的默认行为。

2.3 工具注册机制与描述质量的实战经验

工具注册我做了两种方式。第一种是注解扫描,类似Spring的@Component配合自定义注解:

@AgentTool(name = "query_order", description = "查询订单状态、退款进度、物流信息,适用于用户询问订单相关问题时调用", parametersSchema = "{...json schema...}") public class QueryOrderTool implements Tool { // 实现逻辑 }

第二种是编码式注册,适合工具数量不多、不想动Spring扫描路径的场景:

toolRegistry.register(new QueryOrderTool()); toolRegistry.register(new QueryLogTool());

两种方式各有适用场景。注解扫描适合工具数量多、团队协作起来之后统一管理;编码式注册直观可控,调试方便。lucky_agent早期用的是编码式,后来工具涨到十几个才迁到注解扫描。

这里我要强调一个非常容易被忽视的点:工具的description写得好不好,直接决定Agent的成功率。模型就是靠这短短几句话判断什么时候该调用哪个工具。比如一个查询订单的工具,如果描述只写“查询订单”,模型在用户问“昨天那笔退款到账了吗”的时候就会犹豫,不知道这个工具适不适合。如果改成“查询订单状态、退款进度、物流信息,适用于用户询问订单、退款、发货相关问题时调用”,模型一眼就能匹配上。我后来给所有工具写描述都遵循一个模板:这个工具是什么、能查到什么、适合在什么场景用。光这一项,工具调用的准确率就提升了至少两成。顺带提一句,Skill可以理解为一组工具的组合配置,把完成某个任务需要的工具和规范打包;Agent是运行时选择Skill并现场决策的主体。分工清楚,代码结构也更好维护。

3. 记忆、上下文与状态管理:让Agent不“失忆”

3.1 短期记忆:上下文窗口的截断与摘要

Agent跑几轮交互之后,消息列表会越来越长。如果不加管理,很快就顶到模型的上下文窗口上限,请求直接被拒绝。lucky_agent的短期记忆策略分三层:系统提示词永远保留,中间的历史消息做一次摘要,最近N轮原始消息完整保留。

具体的实现思路是这样的:维护一个消息队列,当总Token数超过阈值时,把最老的用户和助手消息取出来,调用模型生成一段精简摘要,把摘要作为一条系统消息放回队列,原始消息丢弃。这套逻辑用Java写起来不复杂,却非常管用。Token估算方面,我用的jtokkit,这是一个tiktoken的Java移植版本,能比较准地估算上下文长度。这里提醒一下,不要用字符串长度来估算Token,中英文的Token占用差别很大,估算不准会导致请求频繁被拒。

有人可能会问,摘要会不会丢失关键信息?会,但这是成本换质量的取舍。我的经验是:摘要只针对“中间地带”的对话,最近几轮原始消息完整保留,这样模型既能看到当前任务的最新上下文,又能通过摘要保持对整段会话大方向的把握。实际使用下来,任务完成率没有明显下降。

3.2 长期记忆:向量检索和结构化存储的分工

长期记忆这块,lucky_agent走了两条路线,我自己的体会是路线之间并不冲突,反而互为补充。

结构化记忆存的是固定格式的结论和记录。比如一次排障的最终结论、一个用户的偏好标签、一个任务的执行结果。这些数据有明确字段,直接存MySQL,查询走SQL,效率高,结果精确。Agent在需要的时候可以通过工具直接查出来。

向量检索存的是开放域的知识片段和相似历史案例。比如历史故障的处理过程描述、知识库里一段关于某系统架构的说明。这部分先用Embedding接口把文本向量化,再写入向量存储。Java侧我用过内存向量库和RedisSearch,后者更适合生产。查询时把用户问题向量化,做TopK召回,召回来的片段拼进上下文。

这里必须提醒记忆安全。长期记忆里可能存有机密信息,Agent在做生成的时候可能无意间把不该说的内容带出来。业界已经出现类似a-memguard这种专门对Agent记忆做防护的研究,核心思路是在写入和读取记忆时都做脱敏和访问控制。我在lucky_agent里的做法是:写记忆之前统一过一遍脱敏规则,员工号、手机号、内部IP一律替换成占位符,查询结果返回到前端之前再做一次反向脱敏,确保模型只接触“干净”的数据。

3.3 会话恢复与工具幂等

Agent跑任务过程中可能出事,网络断了、服务重启了、模型超时了。如果每次都是从零开始,用户会很崩溃。lucky_agent落了一个会话状态表,把Agent每一步的状态都存下来:准备调用哪个工具、传了什么参数、工具返回了什么结果、当前在第几步。任务中断之后,重启时读取状态,从断点继续跑,不用重新开始整轮对话。

这里有个Java开发里很容易忽略的点:工具的幂等。特别是写操作类工具,比如“修改配置”“触发发布”,如果Agent第一次执行成功,但因为网络问题没收到结果,它可能重试,重试就会产生第二次写操作。我的做法是:所有写操作工具都必须接收一个幂等键,同一个幂等键只执行一次,结果直接返回上次的执行结果。这一步不做,Agent生产化就是空谈。

4. 实操踩坑:Java生态里Agent的典型问题与排查

4.1 流式输出与异步编排的取舍

我第一次做lucky_agent时,所有LLM调用都是同步HTTP,体验确实差,一个复杂任务要等十几秒,页面像卡死一样。后来想改成流式输出,走SSE,用CompletableFuture做异步编排。结果踩了一脚大的:业务线程池配置不合理,流量稍微上来,线程直接被慢调用占满,整个接口都堵死了。

排查半天才发现问题出在混合使用Spring的@Async线程池和业务自己的线程池。线程饥饿的症状是接口偶尔超时,CPU却不高,让人非常迷惑。后来我调了一个更务实的方案:Agent编排过程大部分用同步阻塞,但限制并发数,同时用数据流的方式处理流式输出。只有面向用户的输出环节走SSE,内部编排不动不动就上异步。异步不是免费的,它会把问题复杂化。如果你也准备用Java做Agent,我建议一开始只对常驻外部接口用异步,内部编排先把正确性跑通再说。

4.2 防御式解析:LLM输出不稳定的程序化处理

模型输出天生不稳定,同一个请求,多次调用返回的格式可能有差异,这是Agent开发里百分之百会遇到的问题。最常见的是模型把工具调用参数包进了Markdown代码块,或者在参数JSON里加注释,或者字段顺序错乱。Java侧的Jackson直接解析这种输出会抛异常,然后整个循环就断了。

lucky_agent定了一套防御式解析流程。先从原始输出里提取可能在代码块里的JSON,再做一次格式修正,最后才是反序列化。提取逻辑大概是:

private String extractJson(String raw) { String s = raw.trim(); if (s.startsWith("```")) { s = s.replaceAll("^```(?:json)?\\s*", "") .replaceAll("```\\s*$", ""); } return s; }

除此之外,还要处理工具名不存在的情况。经典的错误是模型幻想了一个不存在的工具名,Java侧如果直接抛异常,循环就断了,用户看到的是一段底层报错。我的做法是把错误转成工具结果给模型:“工具不存在,请从以下列表中选择”,让模型自我纠正。这招特别管用,好几次模型自己就换对了工具。这种防御式处理也让我意识到,Agent开发其实很大程度是在跟不确定性做对抗,程序里每多一层容错,线上就少一次事故。

4.3 超时、令牌预算与线程池保护

Agent环节多,任何一个环节卡死都会拖垮整个调用链路。lucky_agent对每个环节单独设置超时。模型调用默认30秒,只读工具20秒,写工具60秒封顶,用完即断。超时异常不会直接向外抛,而是作为工具结果返回,让模型知道这一步超时了,可以选择换思路或者向用户说明。

令牌预算方面,每轮请求之前会先估算当前上下文的Token总数,如果超过阈值,就触发摘要或者裁剪逻辑。而且要预留一部分Token给模型回复。这就好比开车不能把油跑干再找加油站,总得留点余量。Java侧实现这套逻辑不难,关键是要有“提前拦截”的意识,而不是等API返回超长错误再去处理。

线程池保护也提一下。Agent任务往往是长耗时操作,一个任务可能在几分钟内占用一个线程。我专门为Agent调用拆分了一个独立的线程池,大小按并发数的上限配置,并且设置了队列拒绝策略,超出能力直接返回“系统繁忙”,而不是无限堆积。

4.4 agent execution terminated due to error 的排查实录

接触Agent比较久的朋友应该对“agent execution terminated due to error”这类报错不陌生。一开始遇到这个问题,我有点懵,因为它太笼统,不带堆栈,看不出具体是哪个环节出错。后来我总结出这类错误最常见的四个原因:

  • 模型返回的工具调用参数无法解析,防御式解析没兜住。
  • 工具执行时抛了未捕获异常,执行器没有统一接住。
  • 上下文超过模型限制,请求被API直接拒绝。
  • 循环达到最大步数仍然没有产出最终答案,被循环终止。

排查这类问题,我最大的心得是:让每一步都有迹可循。lucky_agent里每一步都会打印结构化日志,包含请求ID、当前步骤、模型返回类型、工具名、工具参数、工具耗时、工具结果摘要。出问题时,直接查日志就能看出是模型侧的问题还是工具侧的问题。

为了提升排查效率,我还做了一个“会话回放”页面。每一轮的模型请求和工具结果都存储在数据库里,可以按时间线回放整个过程。有了这个功能,复现问题就变成了点开回放看过程,再也不用靠文字描述去猜。实践中我发现还有一个非常隐蔽的坑:工具异常信息直接拼回了上下文。模型拿到一段Java堆栈之后,容易自我归因,然后开始道歉“抱歉我遇到了技术错误”,之后陷入死循环。所以我规定工具报错必须返回标准化结构,只有错误码和可读描述,不给模型看原始堆栈,这样模型才能做出有效应对。

5. Agent安全与生产化:从玩具到能用的距离

5.1 工具权限:不要让Agent为所欲为

Agent如果什么工具都能调,出事的概率很大。lucky_agent把所有工具按风险等级分成三类:只读、低风险写、高风险写。只读工具比如查日志、查订单,模型可以直接调用。低风险写比如发送通知、创建草稿,可以自动执行但留审计。高风险写比如重启服务、修改配置、触发发布,必须走审批流。

审批流的实现思路很简单:Agent生成“待执行指令”,状态置为等待审批,人工确认后系统才真正调用工具。这个逻辑相当于在Agent和工具之间加了一个“人类闸门”,既保留了Agent的自动化能力,又给关键操作上了保险。开发初期我觉得审批流多余,拖慢效率,后来一次模型幻觉差点触发配置变更,才明白这道闸门是保命的。

5.2 数据脱敏与输出过滤

Agent需要访问的数据往往包含敏感信息。lucky_agent的脱敏在统一入口做,不是在各个工具里各自为政。工具返回的数据进入上下文之前,先过一遍脱敏过滤器,手机号中间四位打码,员工号替换成占位符,内网IP掩码,密钥直接丢弃。这样模型在推理时根本接触不到敏感原文,从源头上避免了大模型“记忆泄露”的风险。

输出过滤是针对最终结果的。有些数据经过了脱敏,但模型可能根据上下文推断出部分敏感内容,或者把脱敏内容原样输出。所以最终结果返回用户之前,还要再跑一道正则过滤,发现疑似敏感内容就替换成提示信息。这一套双保险下来,Agent能放心地在生产环境使用。

5.3 可观测性:日志、指标与轨迹回放

Agent应用跟普通接口不一样,一次任务要经历多次模型调用和工具调用,定位问题必须依靠可观测性。lucky_agent在三个层面做了埋点。日志层面,一次任务有一个全局requestId贯穿所有环节,结构化输出。指标层面,统计模型调用次数、Token消耗、工具成功率、平均步数、平均耗时,这些都是判断系统健康度的核心指标。轨迹回放层面,所有步骤落库,支持按时间轴重现Agent的思考过程和工具调用过程。

这里有个比较隐蔽的经验:模型复杂度评估要基于“步数分布”。如果平均步数突然从3涨到5,大概率是某个工具描述变差了,导致模型多绕圈。这个指标比看成功率更敏感。Agent开发里,很多问题不是“坏没坏”,而是“效率下降了多少”,步数就是那个效率温度计。

5.4 回归测试:怎么知道Agent改坏了

Agent是典型的行为不确定系统,改了Prompt、调了工具描述、换了模型版本,都可能引起行为漂移。lucky_agent准备了约一百条回归用例,覆盖各类典型任务,每次变更后自动跑一遍,再人工抽检。

评估指标有四个维度:任务完成率看是否得到最终答案;工具准确率看是否在正确的时机调用了正确的工具;关键信息覆盖率看结论是否遗漏了必需信息;流程合规率看是否在需要审批的场景主动挂起等待审批。这四个维度合起来,基本能反映Agent的行为质量。

我再补充一个点,关于Skill和Agent的关系在生产中的具体体现:Skill本质上是工具和提示片段的组合包,一个Skill对应一类任务的施工图;Agent是施工队本身,负责现场决策和执行。在lucky_agent的实现里,Skill就是一组工具的编排配置,Agent启动时根据任务类型动态加载对应的Skill,这让工具的组织更清晰,也让模型面对的工具列表更精简,决策噪音更小。

回到Java本身,这次实践让我对Java生态的看法又深了一层。做Agent不等于写算法,大部分工作是在做工程整合、容错处理和系统设计。Java在这方面的积累一点不浪费,类型安全让工具接口不容易写歪,Spring生态让配置和部署顺滑,可观测性工具链成熟。如果你也在Java团队里,想试水Agent,建议从一个只调两个工具的窄场景切入,先把循环跑通,再慢慢加记忆、加工具、加安全策略。最后分享一个压箱底的小技巧:给Agent的System Prompt里明确写好“能做什么、不能做什么、优先用什么工具”,比反复调模型参数管用得多。很多事故不是模型能力不够,而是你忘了告诉它边界在哪里。

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

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

立即咨询