
1. 项目背景与核心设计思路1.1 为什么选 Java 写 Agent先交代一下背景。我做了多年 Java 后端平时工作中大量接触 Spring Boot、微服务、消息队列这些东西。大概从去年开始Agent 这个概念在技术圈热度一路飙升身边不少同事都在用 Python 写 Agent 原型。客观说Python 在这个领域生态确实强LangChain、LlamaIndex 这些框架先入为主社区的参考案例也几乎清一色 Python。但我考虑之后还是决定用 Java 自己写一个 Agent 智能体也就是现在这个 lucky_agent。原因主要有几个。第一我的主力技术栈就是 Java团队里其他人也都是 Java 背景如果引入一个 Python 服务后续维护、部署、代码评审都要多养一套技术栈沟通成本很高。第二公司现有的基础设施——配置中心、日志平台、监控告警、权限体系全部是 Java 生态的。Agent 作为业务系统的一部分天然需要跟这些组件打通。与其额外搭一个 Python sidecar 去做协议转换不如直接用 Java 把 Agent 能力内嵌到现有的服务架构里。第三Java 在稳定性和并发处理上有优势。Agent 跑起来不是单次请求就结束的它内部有循环、有状态、有多轮工具调用这些场景下 JVM 的成熟调度能力和内存管理反而让我更放心。当然我也承认Java 生态里 Agent 相关的框架和参考项目相对少很多概念要自己从底层能力出发去实现。这相当于开荒但反过来想正因为没有现成框架的约束我可以按自己的业务需要去设计 Agent 的结构和调度逻辑反而是件好事。1.2 lucky_agent 要解决什么问题我在设计 lucky_agent 之前先梳理了业务侧的真实诉求。我们内部有不少后台运营场景比如工单分类、报表解读、数据的自然语言查询、日常值班的问答回复。这些活儿以前靠人工 一堆硬编码规则去做规则一多就维护不动换个说法就识别不了。Agent 能带来什么价值核心是理解自然语言的目标然后自己规划步骤、调用工具、拿到结果、再组织语言返回。所以 lucky_agent 定位不是做一个通用的 ChatGPT 套壳而是做一个能接入公司内部业务工具的 Agent 底座。它能理解用户用大白话提出的诉求把这个诉求拆解成若干个可执行的步骤然后按步骤去调用对应的工具——查数据库、调接口、读文档最后把结果整合成自然语言反馈给用户。整个过程中要能感知上下文要能处理多轮对话还要能应对工具调用失败的意外情况。这个定位决定了我的架构思路不追求大而全的通用智能而是优先保证 Agent 在特定领域内“用得起来、结果可靠”。所有设计都围绕这个目标展开。2. 技术选型与整体架构2.1 选型清单从 LLM 接入到工具调用先列一下当前 lucky_agent 的核心技术清单都是实际跑起来的版本组件选型选型理由开发语言Java 17团队主流版本支持 record、switch 表达式等现代语法核心框架Spring Boot 3.x依赖注入、配置管理、模块化开发都省心LLM 接入自研 HttpClient 封装兼容 OpenAI 兼容协议不绑死某个厂商便于切换或私有化部署模型JSON 处理Jackson生态成熟处理 tool calling 参数解析足够编排调度自研状态机 线程池轻量可控不引入重型工作流引擎工具注册Spring Bean 扫描 注解声明复用 Spring 容器能力新工具接入成本低日志与追踪SLF4J MDC多轮对话链路追踪问题排查靠它向量存储暂未接入先用关键词检索兜底业务语料规模还小控制初始复杂度这里我想重点说一下 LLM 接入层的设计。现在市面上的大模型接口五花八门但绝大多数模型厂商都提供 OpenAI 兼容的 Chat Completions 协议格式。我做了一个统一的接口抽象把请求体、响应体、流式输出的解析都封装好底层对接的模型通过配置切换而不是在代码里写死。这样以后不管是换厂商还是从公有大模型切到私有化部署的模型都只需要改配置和少量适配逻辑。2.2 整体架构三个核心模块lucky_agent 的整体架构可以拆成三层来看。最底下是支撑层包含模型接入、工具仓库、记忆存储。这一层解决的是“Agent 有什么可用”的问题。模型是大脑工具是手脚记忆是临时工作区。工具仓库这里我特别设计成 Spring Bean 的形式每个工具就是容器里的一个 Bean通过自定义注解声明工具名称、描述、入参结构。这样新增一个工具只需写一个方法加几行注解做完自动扫描注册。中间是编排层包含任务规划器、状态机控制器、上下文管理器。这一层解决的是“Agent 怎么用这些资源”的问题。收到用户消息后编排层决定要不要调用 LLM、要不要触发工具、当前处于什么状态、下一步该怎么走。状态机是整个 Agent 循环的核心它把一次完整的任务处理过程拆成多个阶段意图识别、任务规划、工具执行、结果汇总、回复生成。每个阶段有明确的输入输出和异常处理入口。最上面是接入层处理跟外部系统的对接。目前提供三种接入形态同步 HTTP 接口、SSE 流式接口、以及一个嵌入式的 Java API方便在同一个 JVM 里直接调用 Agent。整个架构设计里我最想强调的一点是Agent 本身不应该是黑盒每一步都要可追踪、可干预、可回退。所以我特别在意状态机的可观测性每次状态流转都有日志记录每个工具调用的参数和返回值都完整落日志。这个在后面排查问题时会带来巨大回报。3. 核心模块实现细节3.1 模型接入层的封装实践LLM 接入层我参考了 OpenAI Chat Completions 协议来设计核心类就是ChatClient。它对外提供两个方法chat(String message)和chat(ListChatMessage messages)后者用于多轮对话场景。内部通过 HttpClient 发起请求支持同步和流式两种模式。这里有一个经验想分享不要直接在业务代码里拼 JSON 请求体。我刚开始图省事直接在调用处用字符串拼接 messages 数组结果只要某条消息内容里包含特殊字符比如引号或换行整个请求就非法了。后来统一改用 Jackson 的 ObjectNode 构造请求体虽然多写几行代码但至少结构化地保证序列化正确。流式输出这块我实现了一个基于ResponseBodyEmitter的 SSE 接口。流程是这样的Agent 在工具执行过程中先把中间状态通过事件推给前端让用户看到“正在分析需求”“正在查询数据库”这样的进度提示最后再推送最终答案。这让 Agent 的使用体验好很多——用户不会面对一个长时间无响应的页面发呆而且当工具执行出现问题时中间的日志也能帮助定位是哪个环节卡住了。3.2 工具注册与调用的设计思路工具的注册机制我用了一套注解 Spring Bean 扫描的方式。定义了一个AgentTool注解标注在方法上包含 name 和 description 两个属性。方法参数通过ToolParam注解声明包含参数的描述、是否必填、JSON Schema 类型等元信息。框架启动的时候用一个ApplicationRunner扫描 Spring 容器里所有标注了AgentTool的方法把它们包装成ToolDefinition对象注册到工具仓库中。ToolDefinition里保留了一个Method引用以及参数类型信息。当 LLM 决定调用某个工具时框架负责把 LLM 返回的 JSON 参数反序列化成实际的方法入参然后通过反射执行这个方法。这里有个设计要点工具的方法签名尽量保持简单。如果一个工具需要很多参数不要设计成十个参数的 Java 方法而是定义一个请求参数对象然后用ToolParam的方式让 LLM 去填写字段。这样既能结构化校验又能避免 LLM 生成参数顺序错误的问题。工具执行时还需要考虑上下文传递。我的做法是引入一个ToolContext参数工具方法可以声明这个类型框架会自动注入当前会话的上下文信息比如用户身份、会话 ID、当前时间。工具如果需要查询数据库或者调用内部接口可以直接从 Spring 容器拿 Bean不用自己搞静态单例因为工具方法本身就是 Spring 管理的。3.3 记忆与上下文管理多轮对话的关键初版 lucky_agent 跑起来之后我发现一个尴尬的问题单轮对话效果还不错但多轮就“失忆”。用户第二句说“那价格呢”Agent 根本不知道“那价格”指代的是哪个产品的价格。原因很简单我把每次用户消息都当成独立请求没有做上下文拼装。要解决这个问题需要引入记忆机制。我的实现分两层。第一层是短期对话记忆。每个用户会话维护一个ChatMessage列表系统消息System Prompt在最前面然后是用户跟 Agent 的历史对话。请求模型时把这个列表直接透传。这种方式的优点是没有额外开销模型自带上下文理解能力。缺点是 token 消耗会随对话轮数线性增长长对话成本高而且模型有上下文窗口上限。第二层是关键信息提取与覆盖。借鉴了 LangChain 里 Memory 模块的思想我实现了一个简单的ConversationMemory专门提取对话中的核心事实。比如用户提到“我要买红色的那款”我就把“颜色红色”这个键值对存到内存里。后续轮次如果提供的信息更新了这个维度就覆盖旧值。请求模型时把记忆中的键值对压缩成一段摘要文本放在系统消息里。这个设计在实测中效果很好。用户第一轮说“推荐一款适合户外使用的蓝牙音箱”Agent 执行了产品推荐工具拿到结果第二轮用户说“只要黑色的”我先把记忆里已有的约束拼进去再让模型基于新的约束重新规划回复的准确率比我直接把两轮消息丢给模型要高不少。4. 任务规划与 Agent 执行循环4.1 Agent 循环从意图到动作lucky_agent 的执行核心是AgentExecutor它维护了一个有限状态机状态包括INIT、THINKING、ACTING、OBSERVING、FINISHED、ERROR。来看一次完整处理流程的状态流转用户消息进入状态从 INIT 进入 THINKING此时 Agent 把消息、当前上下文、可用工具列表组装成 Prompt发给 LLM。LLM 返回结果。这里有两种情况如果 LLM 觉得信息足够直接返回最终答案对应结束动作状态进入 FINISHED如果 LLM 认为需要调用工具会返回结构化的 tool_calls状态进入 ACTING。ACTING 阶段执行工具调用。我在这里额外引入了一个工具调用的超时控制每个工具最多跑 30 秒超时则记录错误并进入 OBSERVING。OBSERVING 阶段把工具返回的结果或者异常信息作为新的消息追加到对话上下文然后回到 THINKING让模型基于工具结果继续推理。循环往复直到模型输出最终答案或者达到最大迭代次数我默认设 6 次超过就把当前状态标记为 ERROR 并返回“任务过于复杂”的提示。这个循环一点也不神秘本质就是一个“思考 → 行动 → 观察 → 再思考”的循环。但难就难在实施的工程细节上。4.2 最大迭代次数与过期兜底我实测中遇到过不少模型陷入循环的情况。典型场景是模型判断需要调用 A 工具A 工具返回的数据不太符合它的预期它又调用 B 工具B 返回的数据又让它想重新调用 A。我见过一次最夸张的循环模型连续调了 9 次工具还是没有给出最终答案白白花了一堆 token。针对这种情况我的处理方案是双保险。第一硬限制迭代次数到 6 就强制终止返回已经收集到的部分信息并向用户说明“任务已部分完成”。第二软引导在系统提示词里主动声明“你必须在前 N 次工具调用内做出结论如果信息不足请基于已有信息提供最合理的建议并说明局限性”。别小看这第二句它确实能显著降低模型的循环频率。还有一类情况需要特别处理LLM 编造了一个不存在的工具。比如我注册了queryOrderInfo和queryProductInfo模型却调用了一个queryOrderDetail。在工具注册表中找不到对应定义时我不会直接抛异常终止而是把“该工具不存在可用工具列表如下”这个错误信息作为 OBSERVING 的结果还给模型让它重新决策。有些模型会比较固执连续编造同一个工具那我就会记录一条 warning 日志并计入本轮迭代上限。4.3 提示词组织与结构化输出Agent 的表现差异很大程度来自 Prompt 组织是否合理。我的系统提示词采用三段式结构。第一段是角色与能力边界。明确告诉模型它是 lucky_agent只能使用给定的工具处理用户请求不做工具调用时回复要简洁。第二段是可用工具清单。把工具名称、这个工具的用途、参数说明、返回值结构都展开这段内容是根据工具注册表动态生成的。注意这里不要把整个 JSON Schema 原样塞进去压缩成自然语言描述反而更节省 token实测识别率更高。第三段是行为规则约束。包括一般情况下先进行少量推理再决定是否调用工具如果一个工具就能解决问题不要拆成多个工具工具调用失败时如实说明原因并尝试替代方案回答要基于工具返回的真实数据不要无中生有。此外我还要求 LLM 在返回最终答案时如果有必要用结构化格式输出。比如查询某个月的销售额模型可以输出一个 Markdown 表格不管它内部愿不愿意我通过 Prompt 指定了“如果回答包含多项数据对比用表格呈现”。这样做既提升了回复的可读性也为后续接前端渲染减少了解析成本。5. Spring Boot 集成与实际运行效果5.1 项目结构与配置管理lucky_agent 的工程结构按模块划分每个模块顶层包名是cn.wangkey.luckyagent大致如下lucky-agent ├── lucky-agent-core # 核心模型接入、Agent 编排、状态机 ├── lucky-agent-tool # 工具注册表、内置工具实现 ├── lucky-agent-spring # Spring Boot 自动装配、HTTP 接口 └── lucky-agent-demo # 示例工程包含自定义工具核心模块不依赖 Spring这是刻意的设计。如果 Agent 的编排逻辑耦合了 Spring 的 IoC 容器以后想在非 Spring 项目里迁移就很麻烦。工具模块和 Spring 模块依赖 Spring因为它们天然需要容器能力。这样分层带来的好处是单元测试可以直接测 core 模块不需要加载整个 Spring 上下文。配置项全部走 Spring 的ConfigurationProperties。我把常用的参数整理成了一个配置类包括模型名称、接口地址、apiKey、超时时间、最大迭代次数、工具执行超时以及是否开启流式输出等。配置的默认值以“保守可跑”为原则比如最大迭代次数默认 6工具超时默认 30 秒模型温度默认 0.3。温度这个参数专门调低因为 Agent 场景下我们希望模型尽量严谨而不是天马行空。5.2 内置工具示例让 Agent 跑起来lucky_agent 内置了几个基础工具主要给示例项目提供可演示的能力。看两个典型的。第一个是时间与日期工具AgentTool(name getCurrentDateTime, description 获取当前日期和时间可指定格式) public String getCurrentDateTime( ToolParam(description 日期格式如 yyyy-MM-dd HH:mm:ss默认为 yyyy-MM-dd HH:mm:ss, required false) String pattern ) { if (StringUtils.isBlank(pattern)) { pattern yyyy-MM-dd HH:mm:ss; } try { return LocalDateTime.now().format(DateTimeFormatter.ofPattern(pattern)); } catch (Exception e) { return LocalDateTime.now().format(DateTimeFormatter.ofPattern(yyyy-MM-dd HH:mm:ss)); } }别看这个工具逻辑简单它在实测中帮了大忙。模型经常被问“今天是几号”或者“现在是几点”如果 Agent 没有时间感知能力模型只能用训练数据里的截止时间瞎猜经常错得离谱。让模型主动调用这个工具来获取当前时间准确率直接拉满。第二个是随机数工具用于预测场景。用户问“下一期双色球预测一下”Agent 会调用 random 工具生成一组随机数然后明确告知用户“基于随机数生成仅供参考娱乐不具备预测能力”。这个工具本身没什么技术含量但它是验证 tool_calling 全链路的好帮手也方便我在演示时向新人讲解 Agent 的循环逻辑。工具返回体我统一包装成一个ToolResult对象包含success、data、errorMessage三个字段。工具方法可以直接返回这个对象也可以返回 String框架自动包装成 successtrue 的 ToolResult。我推荐在工具实现里主动封装尤其是在查询场景查到空数据究竟算成功还是失败业务上要有明确的语义而不是统一抛异常。5.3 接口设计与协议约定接入层提供了两个 HTTP 接口。一个是同步接口POST /api/agent/chat请求体包含sessionId和message返回AgentResponse里面带reply和traceId。sessionId 由调用方传入服务端根据它维护会话状态。如果没有传 sessionId服务端会生成一个新的 UUID 附加到响应里。另一个是流式接口POST /api/agent/chat/stream返回text/event-stream。事件类型包括status阶段变化、tool_call工具调用信息、message最终回答。前端拿到 status 事件就可以渲染“正在思考中”“正在查询数据”这类动态提示。实际接入中发现一个问题同步调用遇上一个复杂任务很可能要等几十秒HTTP 连接都要超时了。所以我默认把流式作为推荐接入方式。同步接口保留给内部系统当工具调用用比如定时任务直接把告警内容文本化发过来Agent 返回处理建议同步接收是完全够用的。6. 常见问题与排查技巧实录6.1 模型返回格式解析失败这是我最早遇到的坑。模型在 tool calling 模式下返回的内容应该是一个结构化的tool_calls数组每个元素包含函数名和参数对象。但有些模型或者某些参数设置下会把工具调用信息以字符串形式写在 content 字段里比如返回一句“我需要调用查询订单工具参数如下订单号 12345”。这种问题没有银弹我的经验是分层兜底。第一层严格按照 OpenAI 协议的tool_calls字段解析。第二层如果解析出来为空把模型返回的 content 丢给一个弱规则引擎用正则尝试识别“工具名 参数列表”。第三层都没识别出来就把 content 原样作为最终回答返回不让流程断掉。另外一定要加返回内容的 schema 校验。模型偶尔会返回非法的 JSON比如少一个右花括号或者多了一个逗号。我封装了一个通用函数先尝试标准解析失败后在字符串尾部边补右括号边尝试解析最多补三层。这个小技巧在实测中救了很多次合计能把 JSON 解析成功率从 94% 提到 99% 以上。6.2 工具并发与线程安全多用户同时使用 Agent 时工具方法会在多个线程中并发执行。如果你的工具是 Spring 管理的单例 Bean而且内部用了有状态的对象比如往实例变量里存中间结果那并发场景下必然出现数据错乱。我的处理原则是工具方法尽可能做成无状态的所有中间数据通过方法参数传递通过返回值输出。如果一个工具真的需要多步骤状态建议引入独立的会话作用域对象而不是把状态放在工具实例上。框架内部针对同一个 sessionId 的执行是串行的通过一个基于 sessionId 的锁来控制。不同 session 之间则是并行执行各自用独立的上下文对象互不干扰。还有一个跟线程相关的细节我用了一个专门的单线程调度器来执行 LLM 请求回调。因为 spring-webmvc 的异步请求完成回调需要注意线程切换的上下文问题比如 MDC 里的 traceId 可能会丢失。解决方法是进入新线程前手动把 MDC 的上下文复制一份回调完成后再恢复和清理。6.3 上下文与 token 消耗控制长对话场景下 token 消耗会越来越大。我测试过一个 20 轮的对话最后一次请求的上下文居然超过了 15k token响应慢了不少。目前我在用的控制策略有三个。第一摘要压缩如果上下文超过设置的阈值比如 8k把最早的对话消息从列表中移除由模型生成一段摘要放入系统消息用摘要替代那些原始消息。第二记忆替代前文提到的关键信息键值对提取每次请求只需注入几十个 token 的结构化摘要而不是把所有历史都塞进去。第三截断保护硬编码一个绝对上限超出就丢弃最早的非系统消息保证请求必达。这几个策略虽然朴素但实测效果远超预期而且不容易出错。市面上一些搬用复杂记忆模型的框架反而在这种业务场景下因为难以解释和调优成了维护包袱。6.4 稳定性治理超时、重试与降级Agent 类应用跟传统后端接口不一样它一次请求可能要好几秒甚至几十秒中间还依赖外部模型服务的稳定性。我做了以下几层治理。模型接口超时统一设 60 秒包括连接超时和读取超时防止某个模型节点卡死导致线程池耗尽。模型返回 5xx 或网络异常时最多重试 1 次。重试时要带上相同的会话上下文避免因部分消息丢了导致回答前后不一致。工具执行超时 30 秒超时立即中断把超时信息作为工具结果还给模型。如果 Agent 整体运行失败响应里的errorCode会区分是模型错误、工具错误还是流程超时方便前端呈现不同的提示文案。稳定性治理还有一个容易被忽略的点线程池隔离。Agent 的 HTTP 接口不应该跟普通业务接口共用 Tomcat 线程池。我给 Agent 请求单独配置了一个线程池核心线程数 8最大 32队列容量 200拒绝策略是让 Agent 接口快速抛出 503。这样即使模型服务变慢也不会影响其他普通业务接口的响应。6.5 日志追踪一次请求全链路可见Agent 的链路比普通接口长得多没有好的日志追踪出了问题就像大海捞针。我在框架里加了几个关键埋点。用 MDC 存放sessionId和traceId在请求进入时生成并设置在请求结束时清理。Logback 的 pattern 里加上这两个字段这样每一行日志都能对应到具体的会话。除了通用日志我还单独打印了三类 JSON 日志模型请求日志包含发送给模型的完整消息列表、工具调用日志工具名、入参、耗时、返回值摘要、状态流转日志从 INIT 到 FINISHED 的状态变化。有一个真实案例能说明这套日志的价值。上线初期有同事反馈“Agent 回答很慢”我看了工具调用日志发现某个工具平均耗时 12 秒但单独测这个工具接口只需要 200 毫秒。后来排查发现是工具方法内有一个数据库连接池等待超时因为连接池被其他业务线程占满了。如果没有工具耗时日志这个隐患会藏得非常深。7. 教训总结与后续扩展说几个这个项目里让我印象最深的技术判断。第一项目中引入 Agent 能力尽量从业务驱动的工具调用切入不要一上来就做复杂推理。我的初版 lucky_agent 几乎没有做复杂的常识推理所有能力都建立在“模型能正确选择工具、工具能稳定返回结果”之上。先把工具链打磨扎实后面再升级模型、增加推理能力水到渠成。第二模型的选型要跟随任务难度走。我在 demo 阶段用相对便宜的模型就能跑通但到了业务并发阶段便宜模型在工具调用成功率上的差距会被放大百倍千倍。所以框架里要预留模型热切换能力不同场景、不同成本预算可以用不同的模型配置而不是硬编码。第三Agent 项目的可观测性投入一定不能省。这个项目中期我花了不少时间补日志和追踪后面排查所有线上问题都变得轻松很多整体省下的时间远远大于投入。后续我计划把多智能体协作能力加进来。目前 lucky_agent 是单 Agent 处理所有请求但业务里有些场景适合多个 Agent 配合比如一个 Agent 做意图判断另一个 Agent 做专业问答还有一个 Agent 负责仲裁汇总。终极形态是让多个角色 Agent 在同一个会话里各自维护上下文通过一套消息总线协作。工程上接口已经留好了状态机也可以扩展只要再设计一层 Agent 调度协议就能跑起来。如果你也在用 Java 做 Agent 方向的尝试希望这篇实践笔记能给你一些参考。项目本身的源码和 demo 我整理之后会放出来到时候感兴趣可以照着跑一遍自己动手改一改体会会比只看文章深得多。不管用什么语言Agent 的核心思路是相通的规划、行动、观察、迭代把这四个环节的工程细节打磨好你的 Agent 就差不到哪里去。