尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java智能体开发实战:从对话接口到任务执行全链路解析

Java智能体开发实战:从对话接口到任务执行全链路解析 最近接了个内部工具核心要求是用 Java 把大模型从“聊天窗口”里捞出来接上实际业务动作让它能自己调接口、查数据、改状态。项目不大但从对话接口到任务执行这条链路坑不少。这篇就把我在 Java 智能体开发里踩过的、验证过的东西完整梳理一遍给打算做类似事情的朋友一个参考。要说清楚一件事所谓“Java 智能体开发”不是把大模型跑在 JVM 里而是用 Java 搭建一个智能体的运行骨架让它可以接收自然语言请求解析出意图和参数再调度后端已有的服务去完成任务。核心价值在于把大模型当作一个很会“听懂人话”的入口真正的执行能力仍然来自你沉淀多年的 Java 业务系统。这篇文章适合两类人看。一类是想把 ChatGPT、文心、通义这类对话模型接入自己 Java 后端的人另一类是已经在用 Spring Boot 做系统、想给产品加一个“自然语言指挥”能力的人。我会尽量把方案决策的 Why 讲清楚而不是只贴代码。1. 先想清楚智能体的本质与 Java 的用武之地1.1 智能体的工作闭环你别把智能体想得太玄乎。它本质上是一个状态机外面接大模型里面接业务系统中间自己做决策。完整闭环是用户说一句话 - 模型把这句话转成结构化意图和参数 - 程序按规则选择一个或多个任务 - 执行任务时调用具体服务 - 把结果再交给模型整理成人话 - 返回给用户。这个闭环里真正由 Java 控制的部分是中间三段意图到任务的映射、任务执行、结果回填。举个例子用户说“帮我把订单 2025001 的状态改成已发货”大模型负责提取出“改订单状态”和“订单号 2025001”但调用订单服务、做状态校验、记录操作日志这些必须由 Java 来干因为只有它知道事务、权限、幂等这些硬约束。在工作闭环中Java 的定位就是一个可靠的“执行驾驶舱”。大模型容易在细节上出错而 Java 强类型、编译期检查、成熟的框架生态天然适合做那些不允许出错的执行动作。我见过不少团队把执行逻辑也塞给 Python 写最后遇到并发、事务问题非常难受这种架构上的错位要尽量避免。1.2 为什么选 Java 而不是 Python这个问题几乎每次分享都会有人问。我明确说如果你只是做原型验证、写脚本调模型那 Python 确实更快。但如果你要在一个已经有 Spring Boot 服务、MySQL 事务、MQ 消息、权限体系的系统里把智能体做成一个稳定可维护的功能模块Java 的优势非常明显。第一是类型安全。对话接口返回的参数是 JSONPython 拿到手是字典取错 key 可能要运行时报错Java 可以定义 record 类做反序列化编译期就知道结构对不对。第二是治理能力。智能体并不是只调一个模型接口它要串服务、写日志、做链路追踪这些在 Java 生态里都有成熟组件比如 Spring 的 AOP、Micrometer、ZIP 链路追踪。第三是团队现状。大部分做后端的人还是 Java 为主你让团队去维护一套 Python 代码本身就是一个跨技术栈负担。当然我不是说 Java 就没有成本。Java 写智能体最大的成本在于胶水代码多模型返回的 JSON 要做字段映射、要做函数调用的 Schema 定义这部分比 Python 繁琐。但我的观点是在一个严肃业务系统里麻烦但可控好过灵活但失控。项目生命周期越长这个取舍越划算。1.3 整体架构和模块划分我这套做下来的整体架构大概分四层。最外层是接入层就是给对话机器人提供一个 HTTP 接口接收用户消息这一层通常会承接 IM 工具、网页聊天窗或开放平台的 Webhook。第二层叫语义层负责调用大模型并且用 Function Calling 机制让模型输出结构化调用意图这个机制是核心后面重点讲。第三层是调度层维护一份任务注册表把模型输出的函数名映射到具体的 Java Bean 方法第四层是执行层真正的业务服务订单、库存、CRM你原来有什么就是用这些。四层之间的通信我统一用 JSON。语义层输出一个包含functionName和arguments的结构调度层解析后组装参数反射调用执行层。这里有个关键点千万别让大模型直接接触数据库连接或内部 RPC 接口。把所有能力封装成白名单函数宁可多写几个包装方法也不要图省事把一个大 Service 暴露给模型否则它会按自己的想象胡调。我这个架构里其实最值得琢磨的是调度层。它不复杂但对一致性、可观测性要求很高。因为模型可能连续调用多个函数中间任何一步失败都要有明确回滚或者补偿的逻辑。后面几节我会逐个展开讲每一层的实现细节。2. 对话接口让智能体学会“接话”2.1 接口选型用官方 SDK 还是 HTTP 直连对话接入大模型第一件事是选接口方式。现在国内外的模型厂商基本都提供两种接入方式一种是官方 SDK一种是裸 HTTP API。我的建议是在 Java 项目里尽量用裸 HTTP API自己包一层轻量封装别直接依赖厂商 SDK除非你是快速验证概念。为什么因为厂商 SDK 更新频繁锁版本之后升级很痛苦而且 SDK 的抽象粒度是帮你把请求封装好但如果你要自定义参数、统一日志、做重试策略SDK 反而碍事。裸 HTTP API 配一个 Http Client我用的是 JDK 自带的 HttpClient如果用 Spring Boot可以用 RestTemplate 或 WebClient请求体和响应体都是 JSON非常透明。出问题的时候你能直接在日志里看到原始报文排查效率高很多。这里补一个实际经验。有一次模型服务偶发超时SDK 内部已经帮你把异常吃掉了只返回一个空结果排查了半天。后来换成裸 HTTP 直连才发现是网关后端读超时。如果你用 SDK一定要确认它有没有暴露底层超时参数和原始响应很多坑都是这层封装盖住的。2.2 消息协议设计与上下文管理对话接口的表面工作是把用户消息发过去再把模型回复发回来。但智能体场景里上下文管理比表面功夫重要得多。我见过很多初级设计是每次请求都带上完整历史消息结果请求体越来越大费用越来越高模型反而被前面的错误信息干扰。我的做法是做一个 SessionContext为每个会话保留一个消息列表但在每次发给模型前做裁剪。裁剪规则很实用系统提示词永远保留最近 6 轮对话全部保留更早的历史按相关性压缩成摘要。这个压缩可以由另一个轻量模型处理也可以用简单策略删除工具调用中间过程。总之上下文不是越多越好模型的理解力是够用的但上下文窗口是有边际效应的。还有一个细节是工具调用结果的存储。当模型要求执行某个函数时执行结果不应该直接拼进对话文本而是作为函数返回结果单独送回给模型。这个机制在 OpenAI 的函数调用规范里叫 Tool message国产模型也都支持类似字段。你如果把它当成普通文本拼接模型会混淆“结果描述”和“对话回复”导致生成幻觉内容。这个我在踩坑部分还会细说。2.3 意图识别与参数抽取Function Calling 的正确用法很多人问我智能体是不是必须靠微调模型才能实现任务执行其实不用。现在主流模型都支持 Function Calling你可以把本系统能力列表用 JSON Schema 格式发给模型模型输出不是排版好的普通人话而是一个结构化的函数调用请求。这样做意图识别和参数抽取基本不需要正则规则。打个比方。你告诉模型系统里有一个函数search_order(orderId: string)当用户说“帮我查一下订单 2025001 在哪儿”模型就会返回一个 JSON内容是{name: search_order, arguments: {\orderId\:\2025001\}}。你的 Java 程序把这个 JSON 解析出来调用订单查询服务拿到结果后再扔回给模型让它用正常人话组织答案。这样你完全不用自己写意图分类器。这里要强调一下参数抽取的可靠性。模型偶尔会把参数名搞错比如 schema 里定义的是order_no用户说的是“订单号”模型可能输出成orderNumber。解决方式是参数名尽量贴近自然语言比如用orderNo而不是on同时在 schema 描述里给出明确例子orderNo: 订单编号例如2025001。参数抽取的准确率直接影响后续任务执行所以每个 schema 字段的描述都要写清楚这是值得花时间的。3. 核心枢纽从对话转向任务执行的关键层3.1 任务定义与注册把业务能力做成白名单对话接口搞定了下面进入最核心的部分怎么把模型那句“调用 search_order”变成真正的方法调用。我建议做一张“任务注册表”本质上是函数名与 Java 方法之间的映射关系。一种轻量做法是维护一个 MapMapString, FunctionDefinitionkey 是暴露给模型的函数名value 是包含目标 Method 和处理逻辑的对象。在 Spring Boot 里更推荐用注解主动注册类似AgentFunction(search_order)启动时扫描容器里的 Bean收集所有标注过的方法。这样新增加业务能力时只要写一个方法加一个注解智能体就自动拥有这个能力不用改调度核心。注册信息里除了目标方法还要包含函数描述、参数 Schema、是否幂等、是否需要用户确认、超时时间这几个元数据。为什么需要这些函数描述是给模型看的用于决定是否调用幂等标记给调度层判断是否可重试需要用户确认的场景是一个安全开关比如“删除库存”“发红包”这类高风险操作不能模型一句话就执行。这些数据看起来简单但后面对齐非常重要。3.2 任务分发与调度反射调用之外还有细节任务分发流程是这样的模型返回functionCall之后调度层从AgentFunctionRegistry里查到对应的执行器把argumentsJSON 反序列化成参数列表通过反射调用执行器目标方法拿到方法返回值再把返回值作为 Tool result 回传给模型。这个流程看起来简单但有几处非常容易出错。第一是参数顺序Java 反射只认识参数数组不认识参数名除非编译时加-parameters或使用 Spring 的LocalVariableTableParameterNameDiscoverer。我的做法是统一约定被AgentFunction标注的方法第一个参数必须是AgentContext后面再跟业务参数。这样反射调用时不用猜参数名直接按顺序组装即可。第二是类型转换模型输出参数全部是字符串或数字必须按参数类型做转换比如枚举、LocalDateTime这些都要在反射前处理。任务分发还有一个隐蔽问题模型可能一口气要求调用多个函数比如“帮我查未发货订单然后按数量排序”。这要求你的调度层支持连续执行模式。第一次调用search_orders拿到结果再把结果作为 tool result 放回消息列表模型根据结果决定下一步是sort_orders还是直接返回总结。整个过程是一个循环需要控制最大轮数我一般设 5 轮超过就强制结束避免模型在内部一直循环浪费 token。3.3 能力模块的封装接口、注解、SPI 的取舍封装能力模块我认为核心原则是智能体执行能力应该复用现有业务方法而不是重新实现一套。但直接复用有个问题业务方法里的入参往往不是模型友好的。比如一个订单查询方法可能要求OrderQueryDTO里传 10 个字段模型可能只会抽取其中 3 个。这个时候不要改原方法而是写一个包装适配器。适配器方法模式可以这么来还是用AgentFunction但放在一个独立的AgentAdapter类里内部调用原来的 Service 方法。比如Component public class OrderAgentAdapter { AgentFunction(name query_order, description 根据订单号查询订单详情, argumentsSchema {\type\:\object\,\properties\:{\orderNo\:{\type\:\string\,\description\:\订单号例如2025001\}},\required\:[\orderNo\]}) public OrderInfoVO queryOrder(String orderNo) { OrderQueryDTO dto new OrderQueryDTO(); dto.setOrderNo(orderNo); dto.setNeedItems(true); return orderService.query(dto); } }对于模块解耦我不建议一开始就上 SPI。Spring 容器本身就是一个 IoC 注册中心利用 Bean 扫描加自定义注解已经能达到“新增能力不改主流程”的效果。等未来可能有多套智能体产品线、需要按权限动态放权时再考虑引入 SPI 机制。过早抽象只会让调度层排查问题更困难。4. 实操过程手写一个轻量智能体核心4.1 环境准备与依赖这个项目我用的是 Spring Boot 3.2Java 17。选 Java 17 的原因是很多新库已经默认不支持 Java 8 了而且 Java 17 的 record、switch 表达式都很适合写这类代码。模型部分我用的 OpenAI 兼容接口国产模型基本都支持这个协议方便切换。依赖只要三个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.12.0/version /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-databind/artifactId /dependencyOkHttp 在这里只作为 HTTP client简单直接。如果你不想引第三方库用 JDK HttpClient 也完全可以但 OkHttp 的连接池和超时控制更方便日志也能用 interceptor 打出来。我实际用下来这个组合非常清爽没有任何重依赖排查问题时打开压缩日志整个请求链路一目了然。4.2 对话接口实现入口我设计成一个AgentController对外开放的地址是/api/agent/chat。它只接收 userId 和 message然后进入一个 AgentService 的对话循环。为了让你看清楚核心逻辑我简化代码如下RestController RequestMapping(/api/agent) public class AgentController { Resource private AgentService agentService; PostMapping(/chat) public AgentResponse chat(RequestBody ChatRequest request) { return agentService.process(request.getSessionId(), request.getMessage()); } }对话循环是智能体的引擎。第一步拉取该会话的上下文第二步调用模型第三步判断模型输出是普通回复还是函数调用如果是函数调用就执行任务并继续循环否则结束并返回给用户。这个循环是同步阻塞的因为对话交互本身是同步的不需要额外用异步。但注意要设置合理的全流程超时我通常设 30 秒超过就中断循环。模型响应慢时用户体验会差但业务场景可以接受。对话循环的伪代码是这样public AgentResponse process(String sessionId, String userMessage) { SessionContext ctx sessionManager.getOrCreate(sessionId); ctx.addUserMessage(userMessage); for (int i 0; i MAX_AGENT_LOOP; i) { ModelResponse response modelClient.chat(ctx.toMessages()); if (response.isFunctionCall()) { FunctionCall call response.getFunctionCall(); Object result agentRegistry.invoke(call.getName(), call.getArguments()); ctx.addToolResult(call.getName(), result); } else { ctx.addAssistantMessage(response.getReply()); return AgentResponse.of(response.getReply()); } } // 超过循环上限返回一个默认兜底回复 return AgentResponse.of(处理步骤太多请简化指令或分步描述); }4.3 任务执行器与回调任务执行器是整合注册表的核心。我自定义了一个AgentFunction注解和对应的 BeanPostProcessor在 Spring 启动时扫描所有标注了这个注解的方法注册进一个 Map。这里关键点是注册表的并发安全我用ConcurrentHashMap存储读取高频、写入只在启动阶段所以性能完全没问题。执行器反射调用的部分比较关键我把参数转换统一封装了。不同类型的参数由ArgumentResolver负责转换比如字符串、数字、布尔是基础枚举用 valueOf复杂对象则直接用 Jackson 解析。但有一个容易踩的坑如果 Schema 里定义了enum模型输出的是枚举名字字符串你需要保证枚举字段名字唯一如果模型输出了中文枚举值必须在 Schema 描述里映射好。任务执行的回调处理是为了把执行结果转成模型能理解的中性语言。我不建议直接把返回的 List 塞给模型完事应先做摘要或序列化成精简结构。比如一个查询返回 100 条订单直接全量传给模型会超 token 限制我的做法是先由执行器把结果转成摘要文本例如“共查询到 100 条订单前 10 条为...”再把摘要发给模型。这样既省 token又避免模型被长列表搞乱。4.4 测试与运行测试我分了三层。第一层是单元测试测工具函数与参数转换确保反射调用不出错第二层是集成测试启动 Spring 的上下文用一个 Mock 模型接口返回预设的 functionCall验证整个对话循环能不能把任务跑通第三层是端到端测试真的连到模型服务模拟用户语句“帮我查订单 2025001”看最终返回是不是一个像人话的订单信息。集成测试环节特别要注意 Mock 数据。我写了一个 FakeModelClient可以根据输入关键词返回不同的函数调用这样测试完全不依赖外部服务适合放在 CI 里跑。端到端测试则单独标记隔离只在发布前手动执行。整体跑下来最常见的问题是模型返回的函数名不一致其次是参数类型转换失败这两个问题占了 80% 的调试时间。跑通过一个 demo 之后再回头看整个链路其实就是一层一层做约束。模型输出用 Schema 约束函数调用用注册表约束业务执行用 Service 方法约束。约束越清楚系统越稳定。5. 踩坑实录常见问题与排查技巧5.1 模型“自说自话”丢失函数调用指令第一类高频问题是大模型不按 Function Calling 规范输出而是直接返回一段话比如“好的我现在为您查询订单”。这种情况通常发生在系统提示词没有讲清楚规则、或者模型对 functionCall 兼容性不佳时。很多国产模型在 OpenAI 格式兼容上有版本的差异。排查时先看请求日志确认 messages 里是否包含函数定义以及函数定义格式是否符合模型要求。我在国内某模型上就遇到过一个坑它要求 functions 参数用tools而旧版本是functions两个格式都不报错但旧版本没有实际生效。解决方案是统一用一个 ProtocolAdapter 接口针对不同模型切换请求格式不要直接用硬编码结构。其次在系统提示词里把规则写死模型角色的强制性描述。比如“你必须严格使用提供的工具不要自己承诺执行动作。当用户请求涉及工具能力时只输出工具调用不要替用户完成。”这种强约束能把异常率从 30% 降到 5% 以下。5.2 任务执行中断超时与状态丢失对话循环是同步的如果业务方法执行时间过长比如查询一个复杂报表要 10 秒对话请求就会一直占用线程且用户体验很糟。这在这里有一个设计决策任务执行类接口是同步等结果还是异步提交后轮询我的看法是如果单个操作耗时可预期且在 5 秒内同步没问题如果超过 5 秒或者需要长时间后台任务那就得走异步任务系统并且要有“任务号”这个概念。保持状态一致性是异步方案的重中之重。比如智能体帮你“批量发送优惠券”这个动作可能在服务端建了一个批次任务执行了一部分然后因为重启中断了。你必须在执行器里记录批次状态并支持断点续跑。我当时加了一个AgentTaskRecord表记录每个任务的执行阶段、任务参数、状态。这样模型后续问进度时可以直接从库里取真实状态而不是让模型瞎猜。从同步到异步需要调整对话循环的返回结构不再直接返回一个最终结果而是返回一个“任务已受理进度查询码是 xxx”的中间消息。模型会把这个中间消息组织成“好的任务已经开始处理预计 2 分钟完成我可以通过进度码查询”。这里的编排是对话接口的一个进阶后期值得投入。5.3 并发与线程安全同一个会话的任务别并行使用智能体的过程中一个用户可能同时在网页端和手机端各打开一个会话对一个 sessionId 发起并发请求。如果两个请求同时操作 SessionContext会导致消息列表互相覆盖甚至出现上下文错乱。这个问题在真实生产里非常严重。我的解法是给每个 sessionId 加读写锁。同一个会话的请求串行处理不同会话之间并行。实现上可以用ConcurrentHashMapString, ReentrantLock用完后清理避免 key 堆积。另一个关键点是任务执行器的幂等性函数执行必须是幂等的因为模型可能连续两次调用同一个函数或者网络重试导致重复提交。幂等设计要利用业务唯一键比如订单号、批次号在任务登记表里做唯一索引。这里还要注意线程池的隔离。对话循环里的模型调用是 IO 密集业务执行器可能是 CPU 密集或者依赖数据库连接池。如果共用同一个线程池模型慢了会把业务线程也拖垮。我的做法是用两个线程池模型调用和业务调用分开互不干扰。这个是架构层面容易被忽略的点一旦并发量上来后果很明显。5.4 快查表10 个高频问题排查方向为了方便你排查我把实际的坑整理成了一张表。这里的每一项都是我或同行在实际开发里真实遇到过的。现象可能原因排查方向模型不输出函数调用请求格式不对或系统提示词约束不强检查请求体 tools/function 字段加强提示语函数名对不上注册表和模型内存不一致查注册表函数名确认与 Schema name 完全一致参数丢值或类型错Schema 描述不明确在参数描述里加示例值强化 required 字段上下文丢失前几轮会话消息被裁剪过度检查 SessionContext 裁剪策略模型回复像复读机工具结果被当作对话文本拼接改用 tool result 单独字段传回执行超时任务耗时过长拆分任务或转异步执行设置全流程超时并发下上下文串扰同一会话并发请求加 session 锁串行处理同一会话重复执行任务网络重试或模型重复调用任务表加唯一索引函数幂等反射调用参数错位参数名/顺序不可靠统一第一个参数为 AgentContext按顺序组装日志难排查缺少链路中间结果打印模型原始响应、函数调用参数、执行结果摘要这张表可以当做一个 checklist。智能体上线之前拿它过一遍至少能少踩一半坑。5.5 成本控制和令牌用量优化最后说点实际的智能体跑起来后token 费用是持续的成本。尤其是对话循环每一轮都要把历史消息和工具结果重新发一遍token 消耗会成倍增加。你别小看这个生产环境一天几十万次调用token 支出非常可观。我的优化方向有三个。一是历史消息裁剪上一节那句话再强调一遍保留最近几轮完整对话更早的做摘要工具中间结果不保留只保留最终结果摘要。二是函数定义精简。很多模型的 Function Calling 会把 functions 列表也计入 token所以函数描述要写得简洁只保留必要信息不要贴大段文档。三是模型降级策略。对于简单请求比如“你好”“再见”直接用一个快速规则回复不必走模型对于复杂任务才走全链路。这个分流策略能省下不少 token。另外要监控每个会话的平均 token 消耗。我当时在 AgentMonitor 里埋了一个计数器记录每轮对话的输入 token、输出 token 和总耗时。依据这些数据才能去优化哪些函数定义、裁剪多少历史消息。成本优化不能靠拍脑袋要像优化 SQL 一样先看慢日志再动手。6. 关于后续扩展把智能体做成平台能力单个智能体跑通之后你一定会面临一个问题多个场景怎么办人事要一个查假勤的智能体运营要一个分析数据的智能体客服要一个查订单的智能体。如果每个场景都复制一套对话循环和注册表维护成本会非常高。我建议在架构上把“智能体”和“工具集”解耦。一个智能体实例绑定一组 AgentFunction 集合相当于是一个配置。比如agents: order-assistant: name: 订单助手 toolGroup: order-tools systemPrompt: 你是订单助手负责查询和处理订单不同的业务团队只需要申请自己的智能体配置后台给它绑定不同的工具组即可。工具组就是一个字符串集合启动时根据配置决定注册哪些函数。这样一来新增一个业务场景基本不写 Java 代码只需要配置和写少量 adapter 方法。这是从“一个智能体”走向“多个智能体平台”的关键一步。在这个扩展方向上还有一个权限问题。工具组之间必须隔离不能让人事助手去调订单工具这是通过 AgentFunctionRegistry 的权限校验来实现的每个工具方法上标注需要的权限码智能体配置绑定角色权限调用前做一次鉴权。千万不要把用户输入直接拼进 SQL 或方法调用那是安全红线。平台化之后还要关注审计。所有智能体触发的任务执行都要记录操作者、操作时间、调用函数、入参出参摘要。这样一旦出了问题可以回溯到具体某一次模型决策。这类审计逻辑用 AOP 拦截 AgentFunction 标注的方法就可以侵入性极小。我个人的经验是智能体开发并不神秘真正的难点在于如何让模型在现实业务约束下“听话”。Java 的强类型和执行稳定性让这件事明显踏实很多。上面的架构和经验都来自真实项目希望对你做类似设计有所帮助。踩过这么多坑之后我对 Java 智能体的理解就是一句大白话把大模型当成一个聪明的传话筒Java 才是那个真正干活的双手。
返回列表