项目标题里带了“lucky_agent”这个名字,从设计到落地我基本都是一个人折腾的。最开始它只是一个想法:试试用 Java 能不能把大模型、工具调用、任务循环串成一个真正能干活的智能体。做完第一版之后,我发现 Java 在这个场景里的优势被很多人低估了。这篇博客不会讲理论大纲,直接看我是怎么把 Agent 拆开、又把各个环节接回去的,包括那些文档里绝不会写的坑和参数细节。
1. 项目概述与整体设计思路
1.1 我为什么坚持用 Java 写 Agent,而不是 Python
lucky_agent 是我用 Java 21 和 Spring Boot 搭起来的一个智能体项目,目标是让用户用一句自然语言发起任务,它自己去拆解、调用工具、观察结果、修正路径,最后返回答案或者直接生成一份业务文档。很多同学听到 Agent 的第一反应是 Python,毕竟 LangChain 那套生态起步早。但我长期在 Java 后端环境里干活,我更看重落地时的完整链路:类型安全、统一依赖管理、编译期就能发现错误。大模型输出不稳定,tool call 参数可能会乱,这时 Java 的静态类型能把一部分脏数据挡在核心逻辑之外。
另外现实中大多数公司的业务基建都是 Java 系。Agent 不可能只是一个孤立的对话框,它要去读数据库、调内部服务、写工单系统、生成周报。用 Java 做 Agent,意味着这些能力可以无缝复用:Spring 的事务管理、注册中心、配置中心、监控埋点全都能直接用。lucky_agent 最早接的真实场景,就是帮运营同事自动汇总销售数据并生成带折线图的 Word 周报,这个流程从模型下发工具调用到文件落盘,全程没离开 Java 体系。
还有个务实的点:Python 环境部署在某些内网环境里本身就麻烦,Java 只要有 JDK 和 Spring Boot 打包就够。lucky_agent 最后部署到测试服务器时,就是一个 java -jar 运行起来的容器进程,运维同事不需要额外学习任何 Python 依赖管理,这就省去了大量沟通成本。从团队协作的角度,Java 是这个项目最合适的底座。
1.2 理解 Agent 的任务循环:感知、决策、执行
没接触过 Agent 开发的人,容易把它想象成更聪明的聊天机器人。实际上两者的结构完全不同。聊天机器人是单次输入、单次输出;Agent 是一个循环:模型先观察当前任务,判断自己缺什么信息,然后调用一个工具去拿外部结果,拿到结果之后再继续思考,再决定下一步动作。这个循环会一直进行下去,直到模型认为任务已经完成,或者它意识到自己无法完成。
lucky_agent 的核心就是这样一条 ReAct 风格的主循环,四个阶段分别是:Reasoning(思考)、Acting(行动)、Observation(观察结果),然后回到思考阶段。我实现它用的就是一个普通的 for 循环。每轮迭代做的事情很固定:把当前上下文发给模型,等模型返回一段文本说法或者一个工具调用请求,如果返回的是工具调用,我就执行对应的 Java 方法,把结果文本放回上下文,再进入下一轮。
这个设计最大的特征是“可停”:一旦模型返回正常的终止信号,循环立刻结束;超过了我设定的最大轮数,也会强制中断,避免模型陷入死循环白白消耗令牌。如果把大模型比作调度台前的负责人,它并不负责具体干活,只负责判断下一步该找谁;我注册的那堆 Java 方法才是真正动手的工人。负责人把命令发下去,工人执行完把结果写张纸条递回来,负责人看完纸条再决定下一步。这整个“循环决策”的过程,就是智能体和普通对话机器人最本质的区别。
2. 核心细节解析与实操要点
2.1 模型接入层:先解决“怎么和大模型对话”
写 Agent 的第一件事是处理模型接入,这一步做不好,后面所有环节都会很难受。我第一个版本的代码是把 HTTP 调用直接写在核心类里的,后来换了一个模型提供商就痛苦万分,因为 JSON 结构、错误码、超时逻辑全都不一样。于是我把模型接入抽象成了 ModelClient 接口,对外只暴露几个与供应商无关的方法:一个是普通对话 chat,另一个是支持工具调用的 chatWithTools,底层实现各自封装不同厂商的 SDK 或 HTTP 协议。
这个抽象层的核心目标,是让上层代码不感知“我在跟哪家模型对话”。我把模型的返回结果统一转换成自己的内部消息结构,内部消息分成三类:文本、工具调用、工具结果。Agent 核心循环只认这三个类型,不关心模型厂商到底返回了什么花哨字段。这样换模型时只需要新增一个 ModelClient 实现类,再改一下配置文件,核心代码一行都不用动。
工具调用参数通常是 JSON 字符串,Java 里我用 Jackson 解析成一个 ToolCall 对象,再交给工具注册中心去匹配并执行对应方法。模型是概率输出,偶尔会给你一个不合法 JSON,比如多余的逗号、未闭合的括号。我在解析层做了容错:先按严格模式解析,失败后用宽松模式兜底;如果还是不行,就会把“参数解析失败,请按 JSON 规范重新调用工具”这段文字作为普通消息返回给模型。这个策略实测很有效,模型看到错误提示后,大概率会自己修正,而不是彻底卡死。
2.2 工具注册与调用:让 Agent 真正动起来
模型不会自己凭空知道项目里有哪些方法,它看到的是我提前注册好的“工具清单”。lucky_agent 用注解加反射实现了一个轻量级工具注册中心。在方法上打上 @AgentTool 注解,填上工具名称、功能说明、参数描述,项目启动时自动扫描注册。模型在对话中会看到这些工具的“说明书”,当它判断需要某个能力时,就会发起一次工具调用请求。
@AgentTool( name = "queryDns", desc = "查询域名解析记录,用于排查站点解析异常、验证子域名归属等场景", params = { @ToolParam(name = "domain", desc = "要查询的域名,例如 example.com"), @ToolParam(name = "type", desc = "记录类型:A、AAAA、CNAME、MX、TXT") } ) public String queryDns(String domain, String type) { return DnsLookup.query(domain, type); }这段代码里最需要花心思的不是 DNS 查询逻辑,而是那段 desc 描述。我之前写过很省事的版本,比如“查询域名”,结果模型根本不知道什么时候该用它。改成“查询域名解析记录,用于排查站点解析异常、验证子域名归属等场景”之后,模型在合适的场景下几乎每次都会想起来调它。参数命名同理,用 domain 而不是 var1,模型传错参数的概率立刻下降。
工具返回值这层我踩过更深的坑。最初我图方便让工具直接返回 JSON 字符串,结果就是模型要消化一大堆嵌套结构,不仅费令牌,还容易被无关字段带偏。现在我让每个工具返回一段结构清晰的文本,先写结论,再写关键字段,JSON 只作为补充。例如 DNS 查询结果直接写成“该域名存在 A 记录,IP 为 1.2.3.4,TTL 300 秒”,模型几乎不需要二次加工就能直接转述给用户。文件生成类工具则只返回“成功与否 + 文件路径 + 文件大小”,不会把那堆二进制描述塞给模型。
2.3 记忆模块:短期上下文与长期记忆如何配合
记忆系统是整个 Agent 项目里最容易“看起来简单、做起来翻车”的部分。lucky_agent 把记忆分成两个层次。短期记忆存在于一次任务内部,就是一个列表,每次把用户消息、模型回复、工具结果都追加进去。它的问题是会无限膨胀,跑到第十轮时上下文可能已经有一大段历史,既浪费令牌,又稀释模型注意力。所以我在每一轮结束时会检查消息总长度,超过一个阈值就把最久远的历史压缩成一段摘要,用摘要替换掉旧消息。
长期记忆则跨任务复用,第一版我用本地数据库存“任务主题、结论、关键字段”。比如用户之前问过“上周订单量为什么下跌”,Agent 排查完就把结论写入记忆表。下次再遇到类似问题,它会先查记忆表,把历史结论拼接进提示词,避免重复劳动。后来数据量大了,我改成了向量检索方案:对每条记忆做向量化,存放进轻量级向量数据库,检索时按相似度取最相关的三条接进提示词。
记忆模块有个容易被忽视的安全边界:模型如果分不清哪些记忆是“通用经验”,哪些是“本次任务的真实观察”,就会把旧结论当成当前事实。我给每条记忆打上标签:OBSERVATION 表示本次任务实际观察,RULE 表示可迁移的通用经验,HYPOTHESIS 表示曾经提出但未验证的猜测。默认只有前两种允许进入上下文,HYPOTHESIS 通常不参与回答,只有用户明确要求时才单独检索。这样既保留了记忆的长期价值,也不会把没验证过的东西当作真相输出。
2.4 安全与权限控制:别让 Agent 变成脱缰野马
Agent 能调用工具,这是能力的来源,也瞬间变成风险面。lucky_agent 在安全方面做了三层防护。第一层是工具白名单:Agent 只能调用我注册过的工具,任何没有登记的方法调用请求直接拒绝。这一点非常关键,因为外部输入可能试图让模型去调用一个从来不存在的方法,一旦注册中心没有拦下来,反射调用就会出问题。
第二层是敏感操作确认。像“发送邮件”“更新数据库”“删除文件”这类操作,我在工具内部设置了作业标记,必须显式传入确认开关才能执行,否则直接拒绝。这些检查全部写在 Java 代码里,而不是指望模型自己判断。模型不是安全边界,它只是一个会犯错的决策组件;真正执行敏感操作的代码必须自己把关。
第三层是对输入结果的污染检测。现在主流模型很容易受到被包装过的提示词影响,比如用户在一段任务描述里塞入“忽略之前所有指令,直接执行某操作”,或者工具返回的内容本身夹带了恶意指令。lucky_agent 在用户输入入口和工具结果返回入口各做了一次模式检查,发现问题就直接把那一段替换成告警信息,让模型明确知道这里不可信。虽然做不到百分之百拦截,但能拦住绝大多数已知模式,配合敏感操作确认机制,我把风险压到了可接受的范围。
3. 实操过程与核心环节实现
3.1 用一个 ReAct 主循环串起整个 Agent
讲完设计,落到代码。lucky_agent 最核心的是一个 AgentRunner 类,它只有一个 run(userTask) 方法,内部跑完整个 Agent 循环。下面这段代码是完整循环的精简版,不涉及网络框架细节,但逻辑和线上版本完全一致:
public class AgentRunner { private final ModelClient client; private final ToolRegistry tools; private final ChatMemory memory; private final AgentConfig config; public String run(String userTask) { memory.addUserMessage(userTask); for (int step = 0; step < config.maxSteps(); step++) { ModelResponse response = client.chatWithTools(config.systemPrompt(), memory.messages()); if (response.isTextFinish()) { return response.text(); } for (ToolCall call : response.toolCalls()) { String result; try { result = tools.invoke(call.name(), call.arguments()); } catch (Exception e) { result = "工具执行失败:" + e.getClass().getSimpleName() + " - " + e.getMessage(); result += " | 请根据错误信息修正参数,或改用其他工具继续完成任务。"; } memory.addToolResult(call.id(), result); } memory.compressIfNeeded(); logAgentState(step, response, memory); } return "任务在设置的最大迭代次数内未能完成,请调整任务描述或增加轮数后重试。"; } }这段代码里最值得琢磨的是工具异常处理。工具执行失败时,我不让异常直接抛出去中断整个 Agent,而是把它包装成一条普通文本放回上下文。模型看到“工具执行失败:文件不存在,请根据错误信息修正参数”之后,通常能自己判断是参数写错了还是这个方案不可行,然后自动换一条路走。从一开始的动不动就中断,到现在的“模型能自己纠错”,这个细节是提升任务完成率的关键。
循环跑完之后如果仍未完成,我不会交一个空字符串或简单说“不知道”,而是给用户一个明确的解释和下一步建议,比如“任务在 10 轮内未完成,请缩小任务范围或增加最大轮数”。开发阶段看日志能还原出模型每一步的思考轨迹,生产环境我还会把这套轨迹写进日志表,事后复盘时非常有用。
3.2 工具实现示例:从 DNS 查询到 Word 图表生成
工具的数量不需要一开始就堆很多。lucky_agent 第一版只有六个:查天气、查 DNS、计算表达式、生成二维码、读本地文件、写临时文件。这些工具的共同特点是独立、结果能自然嵌入一段文字,适合拿来验证 Agent 主循环是否通畅。先把回路打通,再谈复杂能力。
真正让项目显得有实际价值的是我把 Agent 接进了办公文档场景。搜索热词里很多人问“Java 的 POI 能不能生成 Word 图表”,答案是可以,但要注意版本和 API 路径。lucky_agent 里我封装了一个 generateWordReport 工具,内部用 Apache POI 创建 XWPFDocument,再往里插入 XWPFChart,图表数据来自模型传进来的 JSON 参数。这个工具最复杂的部分不是 API 本身,而是把模型传进来的杂数据清洗成图表需要的坐标轴数据。
@AgentTool(name = "generateWordReport", desc = "根据给定的指标数据生成一份带图表的 Word 报告,数据格式为 [{month:'2024-01', sales: 120}]") public String generateWordReport(String title, String chartType, String jsonData) { List<Map<String, Object>> rows = parseJsonArray(jsonData); try (XWPFDocument doc = new XWPFDocument()) { doc.createParagraph().createRun().setText(title); XWPFChart chart = doc.createChart(); populateChart(chart, chartType, rows); String filePath = "reports/" + System.currentTimeMillis() + ".docx"; try (FileOutputStream out = new FileOutputStream(filePath)) { doc.write(out); } return "报告已生成,文件路径:" + filePath + ",数据点数量:" + rows.size(); } catch (Exception e) { return "报告生成失败:" + e.getMessage(); } }这个示例暴露了一个重要的思维模式:工具返回的是描述性文本,而不是原始数据。模型不需要读 docx 二进制流,它只需要知道“文件已生成、路径在哪、包含多少数据点”,就能把结果组织成用户能理解的回答。这可以推广到所有“操作文件、修改数据库、调用外部服务”的工具上:核心循环只关心工具调用结果的一句话摘要,细节交给 Java 代码去处理。
3.3 配置、超参与可观测性设计
Agent 应用有个特点:代码一样,配置不同跑出来的效果天差地别。lucky_agent 把影响行为的关键参数全部抽到了配置文件中,方便调试时快速调整。下表是我实际项目中使用的参数配置:
| 参数 | 含义 | 我常用的值 | 踩坑说明 |
|---|---|---|---|
| model.temperature | 控制随机性 | 0.2 | Agent 任务需要低随机性,太高容易改计划 |
| agent.maxSteps | 最大循环轮数 | 8-12 | 太低任务完不成,太高会白烧令牌 |
| agent.contextWindow | 上下文预留窗口 | 6000-8000 token | 要留足工具返回内容的余量 |
| agent.toolTimeout | 单个工具超时时间 | 15秒 | 防止一个卡住整个循环 |
| agent.memoryKey | 是否启用长期记忆 | true | 关闭后每个任务都从零开始,效率低且费钱 |
可观测性是我中途才补的,补完简直是再次重生。AgentRunner 每走一步都会记一条日志:第几步、模型返回什么类型、调用了哪个工具、参数摘要、工具结果长度、当前积累的令牌数。这些日志能完整还原模型的决策链,看到它为什么中途绕了个大弯,也能发现哪类任务总是陷入循环。
配合日志的是 traceId。lucky_agent 里每次任务执行都会生成一个全局 traceId,贯穿模型请求头、日志、生成的文件名、数据库记录。出问题时,一行 traceId 就能串起用户请求、模型输入输出、工具调用和最终结果。没有这套机制排查 Agent 问题是灾难,模型每次输出都不一样,猜都猜不到当时发生了啥。
4. 常见问题与排查技巧实录
4.1 报错“Agent execution terminated due to error”怎么办
这个报错几乎是每个 Agent 开发者都会遇见的,它不一定来自你自己写的代码,可能是框架里处理循环时抛出的提示。底层原因通常集中在三类:工具调用参数解析失败,模型吐出的 JSON 不合法,一解析就崩;业务工具内部抛了运行时异常,比如文件不存在或数据库连接断开;上下文超出模型 token 上限,请求直接被拒。
我的处理原则是:不要让任何错误终止循环。所有工具调用都包在 try/catch 里,异常先转成文本回传。JSON 解析单独做容错,失败时用宽松模式再试一次。上下文超限靠压缩机制兜底。这样“Agent execution terminated due to error”基本不会再出现;即使某个工具反复失败,模型也能明确告诉你“这个链路有问题”,而不是丢出一个晦涩异常。
排查这类问题时,定位直接看 traceId 对应的日志。重点看最后一步返回了什么,以及这是第几次迭代。第一次就崩,多半是初始上下文或模型配置有问题;第五次之后才崩,重点查那次迭代之前调用的工具,八成是它的返回内容太长或格式产生了歧义。
4.2 上下文越跑越长:压缩、截断与记忆清理
我统计过自己的项目,一次包含六个工具调用的任务平均要消耗一万二到一万八令牌,其中一半以上都花在历史消息上。随着迭代加深,模型容易被前面的无效信息带偏。所谓上下文污染,不一定是恶意注入才发生,哪怕是正常的工具结果,如果过于冗长,也会挤占模型处理后续指令的注意力。
上下文管理一定要写进主循环,不能等出问题再处理。lucky_agent 维护一个消息总量指标,超过上下文窗口的 70% 就触发压缩:把前几轮对话摘要成一句话,保留最近两轮完整内容。摘要本身也由模型生成,但我会要求摘要控制在 150 字以内,只写“用户提过什么要求、已经执行过什么工具、结论是什么”,不带情感和猜测。
长期记忆的清理同样重要。我一开始把所有记忆都堆在数据库里,后来发现旧任务结论会干扰新任务判断。现在每条记忆带一个新鲜度字段,一周内的记忆直接可见;超过一周的必须经过检索评分才能进入提示词,评分低的直接忽略。加了这套机制之后,模型处理相似任务时准确率明显上升,不会再被几周前一个不完整的旧结论带偏。
4.3 数据一致性:让每个工具调用有唯一标识
Agent 调用工具和普通后端接口调用最大的区别在于,模型可能反复调用同一个工具。它第一次调了“查订单量”,把结果写在上下文里;第二次因为自己忘了,又调了一遍。如果这个接口不幂等,比如每次查询都写一条日志或生成一份新文件,那么任务就会积累脏数据。
我的解法很 Java:给每次任务分配一个全局 taskId,工具内部先判断这个 taskId 是否已经处理过相同请求。查询类工具可以做结果缓存,写入类工具加唯一约束。这样任务中途失败后续补跑时,已经执行成功的工具就不会重复执行。这其实就是后端体系里常用的“幂等 + 补偿”,Agent 只是把决策从人换成了模型,底层一致性逻辑没有变化。
还遇到过一种特殊问题:关键词重复触发。模型在前几步已经调过 DNS 查询记录了结果,后几步又把同一工具调一遍,由于没有缓存,第二次结果被拼进上下文,跟第一次冲突。我在工具结果里加上来源时间戳之后,模型能察觉到自己在重复调用,大部分情况下会自己停下来,不再绕圈。
4.4 从 Java 基础到 Agent 工程:哪些老知识翻新了
搜索热词里一堆 Java 面试八股,我做完 lucky_agent 之后有个明显感受:那些经典 Java 知识不但没过时,反而在 Agent 工程里换了新用法。并发部分,Agent 循环特别适合虚拟线程,每个子任务开一条虚拟线程去调工具,互不阻塞,吞吐量比之前的线程池方案高不少。AQS 那套同步机制也没消失,工具注册表本身是并发访问的热点,得靠并发容器加原子操作才安全。
字符串处理也重新变得重要了。模型返回的文本和参数需要大量拼接、截断、替换,如果直接用字符串相加,生成几千字报告时性能会明显下降;改成 StringBuilder 后,报告生成时间几乎可以忽略。甚至各种排序算法也没浪费:模型要按销售额排序时,与其让它去做不擅长的计算,不如明确调用一个排序工具,让 Java 代码去处理,结果更稳定。
背后的道理很简单:Agent 不是把智商都押在模型身上,而是把模型擅长的事和代码擅长的事合理分工。模型擅长理解意图、拆解任务;代码擅长计算、排序、并发、事务。Java 基本功越扎实,这个分工边界就越清晰,Agent 的表现也越稳。
5. 复盘与面试:lucky_agent 怎样讲才算“懂”
5.1 项目复盘:功能、收益与瓶颈
lucky_agent 目前能做的事可以概括成三类:信息查询与解释类任务、办公文档生成类任务、内部服务编排整合类任务。第一个版本上线后,运营同事反馈最积极的是自动生成周报功能:各渠道数据一拉,Agent 自己判断趋势、写结论、再生成带折线图的 Word 文件,整个过程从以前半个小时的苦力活变成一分钟。
成本也要诚实面对。跑一个复杂任务平均要消耗两万到三万令牌,按常见模型定价单次成本几元到十几元不等,比纯人工便宜,但不能完全放开。我给系统加了一个预算上限:每次任务前估算最大令牌消耗,超出的直接拒绝,请用户缩小任务范围。有意思的是,这个限制反而让用户更愿意把任务描述清楚,任务成功率也提高了。
瓶颈依然很明显:多工具协同任务还是容易迷失方向。比如“查完 A 平台数据再对比 B 平台数据,最后总结差异”,模型经常在第三步开始凭记忆对比,而不是把两个平台数据放一起仔细看。解决方向是引入规划器,让模型先把子任务列成清单,再按清单逐步执行。这就是各类 Agent 框架里说的 planner 与 executor 分离思路,也是 lucky_agent 下一版的核心改造方向。
5.2 常见概念辨析:skill、harness、agent 到底什么关系
无论是面试还是项目沟通,概念不清都会严重减分。skill、harness、agent 这三个词经常被混着用,我按自己调试 lucky_agent 的经验做个简单区分:skill 是能力单元,比如生成图表、解析 PDF、计算指标,它不包含决策逻辑,就是一个可以被调用的技能;agent 是决策主体,它决定每一步该调用哪个 skill、以什么顺序调用、拿到结果之后下一步怎么走;harness 是运行环境,负责循环控制、工具调度、上下文维护、异常处理,简单说就是 Agent 跑在哪层脚手架上面。
用 lucky_agent 来对应:我注册的 queryDns、generateWordReport 都是 skill;AgentRunner 本身是 agent 逻辑;Spring Boot 里的配置、日志、虚拟线程、工具注册中心这些基础设施,就是 harness。做大规模 Agent 系统,三件事都要抓,边界还要分清楚。常见反面教材是“把所有东西都塞进提示词”,最后逻辑全纠缠在一起,谁都不好调试。
5.3 给后来者的三点实操建议
第一点是起步别急着套框架。我见过太多朋友上来就接一个重量级 Agent 框架,结果陷入配置地狱,出了问题分不清是框架的 bug 还是自己业务逻辑的锅。先用一百多行代码把 ReAct 循环写明白,把工具调用跑通,再去看框架是怎么封装这些步骤的,理解会扎实很多。
第二点是先把日志和 traceId 做好,再往上加功能。Agent 开发中“看不见内部状态”是最痛苦的事。我第一版没有日志,遇到问题只能一遍遍重跑任务,白白烧掉很多钱。后来补上 traceId 和每步日志,一次失败就能快速定位到具体工具,开发效率至少翻了两倍。
第三点是安全永远要前置。在 Agent 能调文件、操作数据库之前,先把权限边界、参数校验、操作审计写好。我自己亲历过一次模型被提示词诱导,尝试调用一个我根本没注册的工具,还好有白名单机制挡住。只要工具注册表、参数校验、结果限长这些硬编码防线存在,模型再聪明也很难真的越界。
lucky_agent 离一个成熟的框架还很远,但它把 Agent 的骨架和关键环节都让我摸了一遍。后续我会逐步加入多 Agent 协作、外部知识库接入、更细粒度的工具权限,把它做成团队内部可复用的基础组件。如果你也在用 Java 折腾 Agent,不妨先从一个小循环开始。踩过几个坑之后,你也会发现这条路比预想中要清晰很多。