用Java从零实现Agent智能体:架构、工具调用与工程化落地实战
大约半年前我在技术群里丢了一句“我准备用 Java 开发一个 Agent 智能体”得到的回复基本都是“认真的吗”“Python 不香吗”之类的声音。我承认圈子里的主流玩法确实是 Python 那一套——框架多、更新快、随便翻到一段示例代码就能跑起来。但我还是把 lucky_agent 这个项目坚持做了下来到现在它已经在我们内部跑通了资料检索、工单自动分类、日志异常分析三条业务线。这篇文章不是泛泛地聊概念而是把我从零用 Java 实现 Agent 智能体的全过程摊开来讲选型考量、架构拆分、工具链路、记忆管理、并发编排以及安全与可观测性这些在企业落地时绕不开的工程问题。如果你也在犹豫要不要用 Java 搞 Agent或者已经有了 Agent 项目但觉得它不够稳、不够可控这篇应该能给你一些实在的参考。1. 为什么在Python席卷Agent圈的今天我仍用Java写了lucky_agent1.1 选型之前先想清楚Agent要替谁干什么先说结论不是因为我讨厌 Python也不是因为 Java 比 Python 更“高级”而是因为我需要把 Agent 放进一个已经存在的企业技术体系里而不是从零搭一套新体系。我当时所在团队的技术栈非常统一Java Spring Boot核心业务系统、权限中心、数据服务、监控平台全部在这一侧。如果为了一个 Agent 项目专门引入一套 Python 服务意味着从部署环境、日志采集、配置中心、权限接入到安全审批全部要多维护一套。这个成本对于一个内部工具项目来说是真实负担。更关键的是Agent 最终是要干活的它迟早要调用公司内部的订单、工单、文档、日志系统而这些系统的接口和鉴权体系都是 Java 的。与其让 Python Agent 通过 HTTP 去绕一圈调 Java 接口再单独做一层鉴权对接不如让 Agent 直接活在这套体系里调用链短一截权限模型也完全复用。所以如果你现在也在考虑 Agent 的技术栈我建议你先别急着抄别人的选型而是先回答一个问题这个 Agent 服务的用户是谁它的上下游系统是谁如果答案是企业内部那套 Java 体系那 Java 做 Agent 并不吃亏。1.2 Java开发Agent的劣势以及我怎么绕过去我也不回避问题。Java 做 Agent 确实有几块明显的短板。第一LLM 生态 SDK 少。OpenAI 的官方 Java SDK 更新节奏慢很多新特性要等Claude 的 Java SDK 也大多是社区维护。我刚开始做的时候翻遍 GitHub能参考的成熟 Java Agent 框架寥寥无几。第二社区讨论集中在 Python。LangChain 里那些 Tool calling、Memory 抽象、Agent 循环的实现思路大量设计参考都是 Python 写的想用 Java 实现需要自己翻译一遍还得处理不少隐式依赖。第三Java 的表达力没有那么“灵动”写动态的、变长的流程组合会显得啰嗦。我的绕法比较务实。LLM 调用本质就是 HTTP JSONJava 有成熟的 HTTP 客户端和 Jackson我不依赖某一个封装 SDK 的完整度直接把核心接口自己封装一层反而更容易替换大模型供应商。后面我换过一次模型供应商改起来基本就是换配置和适配器。Agent 的设计模式包括 ReAct、Plan-and-Execute、工具调用都是公开思路不绑定语言。我把这些模式翻译成 Java 的接口和抽象类代码确实啰嗦一点但好处是结构清晰不太容易写出那种“灵机一动”的不可维护代码。至于那些确实只有 Python 生态做得好的能力比如某些专用的文本处理模型我单独部署成独立服务让 lucky_agent 通过 HTTP 去调用它。这不是妥协是把合适的事交给合适的工具。1.3 三个项目背景逼着我动手说实话lucky_agent 最初是被具体需求推着走的不是拍脑袋觉得“Agent 很火”就开工。我们内部有三个场景长期占研发人力。一个是资料检索。新同事查历史方案、合同模板、制度文档总是在聊天记录和共享盘里翻很久反反复复问同样的问题。一个是工单分类。客服组每天几百条工单需要人工打标签每个标签对应不同的责任人纯机械劳动但不得不做。还有一个是日志异常分析。故障复盘时工程师要在海量日志里找相关线索经常花半小时才能拼出时间线。这三个场景的共同点是流程相对固定、决策链路不长、文本处理量大。它们不需要 Coding Agent 那种复杂的代码执行沙箱只需要一个能理解指令、调用内部工具、按流程走几步的 Agent。用 Java 开发刚好能直接接上内部已有的搜索服务、工单系统和日志平台。这里我也多说一句很多人一上来就奔着“通用 AI Agent”去设计什么都想做结果什么都做不稳。lucky_agent 的定位从一开始就限定成“企业内部工具型 Agent”它的价值不是写诗画画而是把重复的、有明确操作路径的活儿接住。想清楚这一点后面所有的架构决策都有了依据。2. lucky_agent整体架构从“大模型调用壳”到“可编排Agent运行时”2.1 模块划分Core、Tools、Memory、Serverlucky_agent 的代码仓库一开始其实没有分模块所有代码堆在一个 Maven 模块里。直到工具数量超过十个、Agent 分支超过三条之后我才意识到必须把边界划清楚。当前的结构是这样lucky-agent-core核心运行时包含 Agent 主循环、LLM 适配器、工具调用链路、记忆接口、任务编排引擎。lucky-agent-tools内置工具实现包括文档检索、工单查询、日志搜索、HTTP 请求、时间计算等。lucky-agent-memory记忆模块的具体实现包括短期上下文管理、向量存储、结构化记忆。lucky-agent-serverSpring Boot 接入层提供 REST API、WebSocket 会话管理、鉴权过滤、审计日志。这个划分的核心逻辑是core 不依赖任何具体的大模型厂商也不依赖 Spring尽量保持轻量tools 和 memory 依赖 core 的接口做实现server 负责把一切能力暴露给外部。这样做的直接好处是我可以写一个简单的 main 方法在命令行里测试 core不用启动整个 Spring Boot。调试成本和启动时间都降了一个量级。当时我排查过一个挺隐蔽的问题怀疑是工具调用链路里的缓存逻辑出了问题没有 core 的独立运行能力光靠重启服务复现得花不少时间。2.2 Agent主循环LLM决策与工具执行的闭环Agent 之所以是 Agent而不是一个普通的大模型问答接口核心在于它有一个“决策—行动—观察”的闭环。lucky_agent 的 core 里有一个 AgentExecutor职责就是循环执行这四步把系统提示词、历史对话、当前用户请求、可调用的工具描述组装成一次 LLM 请求。LLM 返回结果。如果结果是普通文本直接结束把答案返回给调用方。如果 LLM 返回的是工具调用请求tool_call校验参数后执行对应工具得到观察结果。把工具执行结果作为新的消息加入上下文回到第 1 步直到 LLM 给出最终答案或达到最大轮数。用 Java 写出来大致是这样的AgentResult run(String userInput) { ListMessage messages historyService.loadSession(sessionId); messages.add(new UserMessage(userInput)); for (int i 0; i maxIterations; i) { ChatResponse resp llmAdapter.chat(buildRequest(messages, toolRegistry.getToolSchemas())); if (resp.hasToolCalls()) { for (ToolCall call : resp.getToolCalls()) { Object result toolRegistry.execute(call.getToolName(), call.getArguments()); messages.add(new ToolResultMessage(call.getToolCallId(), serialize(result))); } } else { return AgentResult.finish(resp.getContent(), i 1); } } return AgentResult.timeout(maxIterations); }这个循环有一个必须强调的设计工具执行和 LLM 调用之间是严格串行的。因为后一次 LLM 调用必须带着前一次工具执行的结果才能做出下一个决策这个依赖关系决定了不能简单地把整个循环并行化。我看过一些框架试图把多个工具调用同时执行用异步回调拼结果确实能在单轮内提一点速度但整体的复杂度和出错概率会明显上升。我的建议是同一轮内多个相互独立的工具调用可以并行但并行只发生在单次循环的内部不要在 Agent 循环层面玩花活。2.3 把“编排”从Agent循环中独立出来lucky_agent 做得相对重的一个设计是把任务编排从 Agent 主循环里拆了出去。主循环解决的是“一次对话中的多轮工具调用”编排解决的是“一个复杂任务需要多个 Agent 或多次工具调用按顺序执行”。拿工单自动分类这个场景来说第一步调用检索工具查出工单历史相似案例第二步把案例摘要和工单文本一起送给一个专门的分类 Agent第三步分类完成后调用工单系统接口回填标签第四步如果置信度低于某个阈值调用通知工具转人工。这四步之间存在明确的数据依赖但每一步内部可能又有自己的多轮工具调用。我为此写了一个 WorkflowEngine用 JSON 描述步骤每个步骤可以指向一个 Agent 节点、工具节点或条件节点执行引擎负责按依赖关系推进。{ steps: [ { id: search, type: tool, tool: search_documents, next: classify }, { id: classify, type: agent, agent: ticket_classifier, next: update_label }, { id: update_label, type: tool, tool: update_issue_label, condition: confidence 0.7, next: done }, { id: notify, type: tool, tool: notify_manual, condition: confidence 0.7, next: done } ] }配置驱动编排是我觉得 Agent 项目里特别值得投入的一件事因为业务流程一定会变我不希望每调整一个分支都要改 Java 代码并重新发布。把流程抽象成配置之后业务人员和接近业务的技术同事也能看懂任务是怎么跑的。3. 工具注册与函数调用让Agent真正“动手干活”3.1 用注解把普通Java方法变成Agent工具工具是 Agent 的“手”。lucky_agent 的工具注册中心设计得比较轻量核心就是一个注解加反射扫描。任何 Java Bean 方法只要标上 AgentTool 并提供名称和描述启动时就会自动注册到工具注册中心。这个思路借鉴了 Spring 的注解风格但实现没有绑定 Springcore 模块自己维护了一个扫描器。Component public class IssueTool { AgentTool(name query_issue, description 根据工单号查询工单详情) public MapString, Object queryIssue(ToolParam(description 工单号) String issueId) { return issueService.queryDetail(issueId); } AgentTool(name update_issue_label, description 为工单更新分类标签) public void updateLabel(ToolParam(description 工单号) String issueId, ToolParam(description 目标标签) String label) { issueService.updateLabel(issueId, label); } }注册之后核心要解决的一个问题是如何让 LLM 知道有哪些工具可用、参数怎么填。我选择的方式是生成 JSON Schema。启动时注册中心利用反射提取方法名、参数名、ToolParam 里的描述组装成一个 OpenAI 风格的 function schema 数组在每次对话时放进请求消息里。这里有个小教训一开始我嫌生成 schema 麻烦想用 Jackson 手工拼接结果发现手写描述很容易和代码不一致工具改个参数名schema 忘了更新模型就开始报错。后来改成注解里的描述作为唯一事实源并加了一个单元测试确保所有注册工具都能成功生成 schema这个问题就彻底解决了。3.2 从“LLM说调就调”到“参数校验与错误回填”工具调用链路最容易被忽略的环节是参数校验和错误处理。LLM 生成参数这件事本身就是概率性的哪怕你给了 JSON Schema它也可能多传字段、传错类型、甚至把枚举值写成自由文本。我在 toolRegistry.execute 里加了三道防线。第一道是 JSON Schema 校验用 json-schema-validator 库开启严格模式类型不对直接拒绝。第二道是业务校验比如查询工单号时校验格式更新状态时校验状态机是否允许流转。第三道是执行兜底任何工具执行过程中抛出的异常不允许直接传给 LLM而是包装成一个结构化的错误结果告诉 LLM“工具执行失败原因是 XXX请基于这个结果重新决策”。这个环节我踩过一个非常实际的坑一开始工具异常时直接把 Java 异常堆栈抛给 LLM结果模型看到一堆 “NullPointerException at line 43” 就开始胡说八道甚至反复调用同一个失败工具陷入死循环。后来我把错误处理改成“面向 LLM 的错误信息”对模型友好很多。比如{ status: error, error_code: ISSUE_NOT_FOUND, message: 工单号 ISS-20240601-001 不存在请向用户确认单号是否正确, suggestion: 可以询问用户是否需要查询其最近创建的工单 }这一处小小的改动让工具调用的成功率肉眼可见地涨了十几个百分点。我现在写任何工具的实现时都会额外写一个“给模型看的错误信息”这已经成了团队内部约定。3.3 危险工具的权限与审批预留工具一旦能操作业务系统就不能让每个 Agent、每个用户都能随便调。我在工具注册中心里给每个工具增加了一个 access 属性标明这个工具需要的权限位。工具执行前会从当前会话上下文里取用户身份判断该用户是否具有这个权限位。没有权限的调用会直接返回“无权限”结果并记录审计日志。设计这一层的时候我还在考虑另一个问题未来如果要支持高危险操作的二次确认只需要在工具调用链里加一个 approval 环节也就是“Agent 发起请求人工审批通过后工具才真正执行”。这个思路在工单回写、删除操作、批量修改这类场景里尤其重要。我不建议把这种安全检查交给 LLM 去自行判断因为模型对权限的理解是不稳定的宁可写死静态规则也不要指望模型的自觉。为了让大家更直观地理解不同工具的权限层级我把工具大致分成这么几类工具类别示例权限策略查询类文档检索、工单查询、日志搜索只读权限按数据范围过滤写入类更新标签、状态流转、备注追加写权限记录操作人高风险类批量删除、配置修改、发送通知写权限 人工审批预留外部调用类HTTP 请求、调用第三方接口白名单域名 权限位4. 记忆管理短期上下文与长期记忆的实现取舍4.1 短期记忆先做对Token窗口的裁剪再谈别的Agent 有记忆才能连续对话但“记忆”在工程上从来不是概念问题而是资源问题。LLM 的上下文窗口是有限的把整个会话历史原封不动塞给模型既不经济也可能导致模型在超长上下文里丢失关键信息。lucky_agent 的短期记忆分为两层。第一层是窗口裁剪按消息条数和 Token 数双层限制。我当时给默认配置设的是最大消息数 20 条、最大 Token 数 8000 左右具体按所用模型的上下文窗口的 30% 来控。超过阈值就把最早的消息丢弃。第二层是摘要压缩当窗口内消息太多时调用一次 LLM 把老旧对话压缩成摘要然后保留“摘要 最近 10 条原始消息”。这个逻辑有点像新闻编辑部处理稿件重要内容被摘要保留新消息完整展示更老的内容归档进资料室而不是每天重新铺满桌面。这里有个对成本非常敏感的细节摘要压缩不能每轮对话都做否则 LLM 调用次数会翻倍。我的策略是只有当消息总量超过最大 Token 阈值的 70% 时才触发压缩并且压缩结果打上 summary 标记参与后续的 Token 计算。总之短期记忆的目标不是“记得更多”而是“在有限预算里保留最有价值的上下文”。4.2 长期记忆向量检索结构化事实两条腿走路短期记忆只解决“最近聊了什么”长期记忆解决“这个用户上次说过什么偏好”“这个项目组有哪些惯例”。lucky_agent 的长期记忆用两个存储来组合一个是向量检索存对话片段、文档片段、用户偏好按语义匹配另一个是结构化存储存用户画像、任务状态、历史结论便于精确查询。向量库的选择上我没有引入重量级的全文检索扩展而是直接用上了团队已有的 Milvus 集群通过 Java SDK 操作。如果你的团队没有现成的向量数据库我推荐看看 pgvector 配合 Postgres 的方案一条 SQL 就能同时查业务数据和向量对小团队来说非常务实。下面是向量查询的一个示意ListMemoryItem findRelevantMemory(String sessionId, String query, int topK) { float[] queryEmbedding embeddingClient.embed(query); ListFloat queryVector Arrays.stream(queryEmbedding).boxed().toList(); return memoryStore.searchSimilar( sessionId, queryVector, topK, created_at now() - interval 90 days ); }这里还有一个容易被忽略的计算点向量检索返回的 topK 结果不能全部塞进上下文因为每一条都需要占用 Token。我的策略是先把 topK 设成 20 条过滤掉相似度低于 0.75 的再按“时间优先 相似度优先”的加权排序取前 5 条进入上下文。否则模型很容易被大量表面相似但实际无关的旧记忆带偏。4.3 记忆写入的时机和遗忘策略记忆不是越多越好。我见过一些 Agent 项目长期记忆表无限膨胀结果每次检索都查到一堆过期内容反而干扰模型判断。lucky_agent 对记忆写入做了三个限制。第一个是写入时机。只有在任务完成、或者用户显式表达了偏好时才写入记忆不在对话过程中每条消息都写。第二个是写入前做去重和打分。如果新信息和已有记忆高度重复就不写。第三个是读取时带时间衰减。三个月前的记忆权重会降低除非用户在近期对话里重新提到它。这套东西在产品上其实就是“遗忘机制”。没有遗忘机制的 Agent就像一辈子不丢旧东西的人家里迟早堆满杂物翻找东西反而更困难。这个机制上线的时候我们内部有过争议有人担心“忘记”会损失信息。但实际跑下来因为响应质量的提升非常明显大家很快接受了这个设计。5. 让Agent扛住并发与复杂工作流任务编排与异步调度5.1 单Agent串行循环与多Agent并发边界聊完记忆马上要面对的问题就是“Agent 到底怎么扛并发”。很多人的第一反应是“Agent 调用大模型是网络 IO只要起线程池就行”。这其实把事情想简单了。一次 Agent 任务是一个有状态的多轮循环每一轮都要和 LLM 交互不是一次 HTTP 请求那么简单。直接裸起线程当然可以但如果没有控制并发边界一旦某个模型供应商的接口限流整个服务的线程都会卡在等待 IO 上很快把连接池打满最终表现为接口超时雪崩。lucky_agent 的并发策略分两层。第一层是任务级并发每个会话是一个独立 Agent 任务任务之间天然无关我用线程池承载如果 JDK 版本允许更推荐用 Java 21 的虚拟线程。虚拟线程在这个场景下非常合适因为 Agent 轮询里大量是阻塞式 HTTP 调用虚拟线程能以极低成本支撑大量并发会话。我们压测过单机 2000 个并发会话每个会话平均 3 轮 LLM 调用每轮调用 1.5 秒整体吞吐还是很可观的。第二层是会话内部的流水线并发一个任务内部如果某个步骤要同时查询多个数据源就用 CompletableFuture 做局部并行然后等待全部返回再继续。5.2 任务编排引擎DAG与失败重试WorkflowEngine 是 lucky_agent 里最复杂也最值得讲的一块逻辑。它把编排定义抽象成节点和依赖关系节点类型有四种agent 节点执行一个子 Agent、tool 节点执行一个工具、condition 节点根据上一步结果决定走哪个分支、delay 节点定时或延迟执行。节点之间通过“下一跳”列表链接引擎用拓扑排序判断执行顺序。执行引擎的容错策略必须提前设计好否则一上线就会出事。每个节点都有超时时间和重试次数。超时用 Future.get(timeout) 实现重试时默认指数退避避免在故障恢复瞬间集中重试造成二次冲击。一个任务是继续往下走还是整体标记为失败取决于该节点是否配置了“失败容忍”。我的经验是对于查询类节点容忍失败并返回空结果通常不影响主流程对于写入类节点绝不能自动重试因为重复写入可能造成脏数据。这个原则我写进了代码注释里也写进了团队技术文档里。5.3 数据一致性Agent操作业务系统不是“只读游戏”Agent 一旦开始写数据就要面对分布式系统里最头疼的一致性问题。lucky_agent 在多数场景下是“用户代理”的角色它调用的是各个业务系统既有接口而这些接口原本就有自己的事务边界。Agent 层不应该试图包一个大事务。我的做法是“尽力而为 补偿记录”写操作尽量走业务系统自己的接口由业务系统保证单接口的原子性Agent 的编排引擎记录每一步已执行的操作形成操作日志如果某个任务后续步骤失败需要回滚引擎尝试调用每个已执行节点上注册的补偿动作比如更新标签前先记录旧标签补偿动作就是恢复旧标签无法补偿的操作比如发送通知则在审计日志里标记“人工确认”。这个方案并不完美但很现实。造一个复杂的分布式事务引擎不是 Agent 框架该做的事不如承认分布式事务不能靠框架彻底解决重要的是把不可控的动作记录下来让问题可见、可回溯。5.4 行级权限在Agent中的落地热词里提到“行级权限 java”这其实正是 Agent 在企业场景落地最容易翻车的地方。一个查询工具如果只校验“能调用”不校验“能查哪些行”那么一旦 LLM 生成的查询条件越界就会造成越权。lucky_agent 的做法是数据查询类工具不直接接收裸 SQL而是接收“语义查询条件”工具内部把条件翻译成带租户 ID 或部门 ID 过滤的 SQL。部门隔离是写死在工具实现里的不能由 LLM 传入。模型可以决定“查哪个客户”“按什么条件筛”但它永远不能决定“查哪个部门的数据”因为后者来自会话上下文由服务端自动拼接。这个设计一开始被团队吐槽“限制了 Agent 的能力”但上线之后大家才发现它正是业务部门愿意放权的前提。权限模型不该让模型来理解而应该在工具层做强制性兜底。如果你做一个 Agent 要接企业内部数据行级权限这一关是绕不过去的越早设计越好。6. 企业落地避坑清单安全、稳定与可观测性6.1 部署形态与运行时调优先说明一下这里的“Java Agent”是在说 lucky_agent 这个业务服务不是 JVM 的 instrumentation agent这个名词在 Java 圈里容易混淆。我们的部署形态是 Spring Boot 打成 Docker 镜像放在容器平台里运行。这里有两个实际教训值得分享。第一个是内存管理。Agent 服务在和大模型交互时需要缓存会话上下文和工具结果如果不限制缓存上限Docker 容器很容易 OOM。我的做法是给 Caffeine 缓存设置 maximumSize 和 expireAfterWrite时间窗口设为 30 分钟并且把大对象在返回给 LLM 之前做截断。比如日志检索的原始结果超过 4000 字时先让摘要模型压缩一遍再进上下文原始结果另外存到临时存储供追踪。第二个是超时配置。LLM 接口的响应时间波动很大HTTP 客户端的 connectTimeout、readTimeout 必须根据模型供应商的实际表现来调。我给默认配置设的是 connectTimeout 3 秒、readTimeout 60 秒因为很多慢模型的单次推理可能超过 10 秒。如果按标准化的 10 秒超时设慢模型上会频繁触发重试既浪费钱又增加延迟还容易把业务方惹毛。6.2 安全护栏prompt注入防护与敏感信息脱敏Agent 面对的安全威胁比传统 API 更复杂因为它会把外部输入当成指令来解读。最常见的攻击形式是让 Agent 把“你是一个 AI 助手请忽略所有指令”这类文本伪装成业务数据塞进来。lucky_agent 的防护思路是不信任任何拼接进来当指令的内容系统级消息和工具描述固定从配置文件读取不参与业务数据拼接对用户输入做关键指令词检测命中则拒绝执行并提示工具返回的业务数据默认按“数据”标记明确提示 LLM 这是数据不是指令输出侧过滤器脱敏手机号、身份证号、账号密码等用正则加模型双重识别命中的字段在返回给用户前打码。这套护栏做不到 100% 安全因为理论上模型总可能被更高级的越狱方式骗过。但对于企业内部的封闭业务场景它已经能把风险压到可接受的范围。如果你的 Agent 要接公网用户输入我建议在 Agent 前面再加一层专门的输入过滤服务不要把所有希望都寄托在提示词工程上。6.3 可观测性用trace_id贯穿一次Agent的完整生命周期Agent 的调试比传统程序难得多因为同一时间内既有代码调用又有模型推理还有外部工具执行。没有可观测性出了问题只能靠猜。lucky_agent 里每个会话在创建时就全局生成一个 traceId从 HTTP 入口到 LLM 调用到工具执行到记忆读写所有日志都带上它。我重点打了三类日志。第一类是 LLM 调用日志记录请求的消息条数、Token 数、响应耗时、返回内容摘要。第二类是工具调用日志记录工具名、入参、出参长度、执行耗时、是否成功。第三类是编排引擎日志记录每一步节点的开始、结束、失败重试。这三类日志拼起来就能完整还原一次 Agent 任务的轨迹。再加上把 traceId 透传到下游业务系统排障效率提升非常明显。上线后两周内我们就用这套日志找出了三个隐藏问题记忆压缩误删关键信息、某个工具接口偶发超时导致重试风暴、以及一个大模型的参数配置在不同会话间串了。下面是一个简化的日志结构{ trace_id: a1b2c3d4e5, session_id: session_20240601_001, type: tool_call, tool: query_issue, args: { issueId: ISS-20240601-001 }, result_length: 532, success: true, duration_ms: 212 }6.4 测试策略Mock LLM是唯一的路测试 Agent 的难点在于 LLM 的输出有随机性。直接对真实模型做用例断言等于在做概率测试。lucky_agent 的测试分三层。第一层是单元测试Mock 掉 LLM 适配器让它返回固定的 tool_call 请求或固定文本验证工具注册、参数校验、错误处理是否按预期执行。第二层是场景测试用录制的“LLM 响应序列”回放工具调用流验证编排引擎的节点流转是否符合预期。第三层是灰盒测试连真实模型但在测试环境跑少量样本由人来检查结果。如果没有第一层和第二层靠真实模型调 API 测出来的问题你永远分不清是代码 bug 还是模型抽风。我后来还专门做了一个小的录制工具跑一次真实任务把 LLM 的请求和响应全部存下来变成回放用例。这个工具帮了大忙每次改动核心逻辑都能快速跑一遍历史场景避免“修好一个 bug 引出三个新问题”的恶性循环。最后分享一点个人体会。做了 lucky_agent 之后我最大的感受是Agent 项目里最容易被低估的其实是工程问题不是模型问题。模型的能力就在那里各家差距没有想象中大但谁能把工具、记忆、并发、权限、可观测性这些环节做扎实谁的项目才能真正被业务部门用起来。Java 在这个赛道上确实没有 Python 热闹但它是企业级工程化的老手类型系统、并发模型、成熟生态这些能力在 Agent 进入生产环境之后会变成实打实的优势。如果你也想动手写一个类似的 Agent我会建议把范围缩小先挑一个非常具体的业务流程跑通闭环再逐步加工具、加记忆、加并发。lucky_agent 下一步我计划做两件事一是把记忆模块的摘要策略从固定阈值改成动态判断二是在编排引擎里支持人工审批节点让高风险操作走“Agent 发起 人工确认”的流程。这个方向之后的实战结果等我跑完一轮再回来分享。