ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java智能体从对话到任务执行:完整落地路线图与代码骨架

Java智能体从对话到任务执行:完整落地路线图与代码骨架 对话接口谁都会接把某个大模型的SDK包一层起一个Spring Boot工程写个Controller转发请求再配上流式输出一星期就能做出一个能聊天的Demo。但真正让智能体从“玩具”变成“生产力工具”的是它能不能执行任务你让它创建一个工单它就真的调用你们的工单系统把单建好你说帮我在招聘网站上发布一个岗位它就真的把JD发上去而不是回复你一段“我已经帮你写好了发布文案请复制到后台操作”。我从去年开始陆续做了几个Java生态里的智能体项目从最开始的“套壳对话接口”到后来逐步把任务执行、调度、告警、幂等这些工程能力补进去这个过程踩的坑比写代码的时间多得多。这篇文章把我的完整思路和落地代码骨架整理出来给正在做AI Agent落地的Java工程师一个可以直接参考的路线图。1. 对话只是入场券智能体的价值在于任务闭环1.1 为什么我放弃了纯聊天的智能体方案先说一个判断如果一个智能体只能对话那它本质上是个带业务知识库的聊天机器人在工程上的复杂度不比做个客服问答高多少。企业在智能体上投入预算账是算得很清楚的——它得省人、省时间、替代重复劳动。企业要的不是“它很能聊”而是“它把活干完了”。比如我做过的一个招聘场景。HR在群里发一句“帮我在招聘网站发布一个Java开发岗位JD按下面这个链接里的要求来”如果智能体回复一段“已为您准备好发布内容”那HR还是得自己登录后台复制粘贴一遍省不了多少事。但如果是这样智能体自己打开JD链接抓取职位描述解析出岗位名称、薪资范围、任职要求调招聘网站OpenAPI自动创建职位草稿然后回一句“职位草稿已创建请在企业微信里点这里审核”这才叫任务执行。纯对话与任务执行之间的差距不是加几个API调用就能抹平的。它涉及对话状态管理、动作指令的可靠传递、任务的持久化、失败重试、以及和外部系统打交道时的鉴权、幂等、审计。这一整套链路才是智能体项目真正的工程量所在。1.2 从“能说”到“会做”任务执行才是分水岭我把一个智能体项目按能力分成四个层次第一层感知输入就是接收用户消息、解析意图。第二层生成回复就是调用LLM产出自然语言内容。第三层调用工具就是大模型决定“调哪个函数、传什么参数”代码负责真正执行。第四层闭环任务就是工具调用产生业务结果再把结果反馈给用户并且对失败负责。大部分团队做到第三层就停了因为这一层已经能让Demo看起来很酷。但第四层才是真正产生业务价值的地方也是唯一需要动用大量Java工程能力的地方。做第四层的时候你会发现LLM只是整条链路里的一环它负责“决策”而真正干活的是你写的那一堆任务模型、执行器、队列和补偿逻辑。1.3 Java在这个场景里的特殊优势既然要做任务闭环语言选型就很重要。Python在AI生态里确实方便模型SDK多、写起来快但一旦进入企业级任务执行Java的成熟度就体现出来了Spring Boot的依赖注入和自动配置能快速搭起服务骨架Quartz、XXL-Job、DolphinScheduler这些任务调度框架久经考验Redis、RabbitMQ等中间件的客户端在Java里性能稳定更关键的是企业现有系统的核心业务逻辑大多跑在Java上智能体要调的那些工单、CRM、招聘系统接口本身就是Java服务。智能体用Java写和现有系统集成时连跨语言调用都省了。当然这不代表Java的开发效率低。现在Java生态里LLM接入方案已经很成熟Spring AI、LangChain4j、以及各家大模型官方Java SDK都做得不错。我自己的选择是用原生HTTP调用加Jackson解析目的就是少依赖框架的黑盒逻辑把对话循环和任务执行的每一步都掌握在自己手里。2. 对话接口的核心设计把自然语言变成结构化指令2.1 消息模型四条消息类型是底线开发智能体对话接口第一件事是定消息结构。不要图省事只存“用户发送的文本”和“机器人返回的文本”因为任务执行需要工具调用的参与对话中会出现四种角色system、user、assistant、tool。我项目里的核心消息模型长这样public class ChatMessage { private String role; // system / user / assistant / tool private String content; // 文本内容工具调用时可为空 private String toolCallId; // 工具调用IDassistant提出、tool响应时使用 private String toolName; // 工具名称 private String arguments; // 工具参数JSON字符串 }对话循环里assistant的消息可能有两种形态一种是纯文本回复比如“好的我这就帮你发布岗位”另一种是工具调用请求content为空但toolName是publishJobarguments里携带JSON参数。收到工具调用消息后服务端要真正执行对应的方法然后把执行结果以tool角色的消息发回给大模型大模型再基于结果给出最终回复。这四条消息类型的循环是整个对话接口的核心协议。最开始我为了省事只处理user和assistant两种结果工具调用结果怎么都送不回大模型后来改了消息结构才跑通。2.2 Tool Call大模型与Java方法的桥接协议Tool Call工具调用机制的本质是让大模型输出结构化的“函数调用意图”再由我们自己的代码去执行。大模型不会真的去调你们的系统接口它只负责决策该调哪个工具、参数是什么。Java工程要做两件事一是把工具清单和参数Schema告诉大模型二是接收到工具调用请求后把它路由到真正的Bean方法上执行。工具注册的代码设计可以参考下面这个模式Component public class ToolRegistry { private final MapString, ToolHandler handlers new ConcurrentHashMap(); public void register(String name, ToolHandler handler, String description, String jsonSchema) { handlers.put(name, new ToolDefinition(name, description, jsonSchema, handler)); } public ToolResult execute(String toolName, String argumentsJson) { ToolHandler handler handlers.get(toolName); if (handler null) { return ToolResult.failure(tool not found: toolName); } try { JsonNode args ObjectMapperSingleton.get().readTree(argumentsJson); return handler.execute(args); } catch (Exception e) { log.error(tool execute error, tool{}, toolName, e); return ToolResult.failure(工具执行异常 e.getMessage()); } } }工具定义描述给大模型的清单要尽可能详细这是决定模型选对工具的关键因素。描述写得越具体参数命名越贴近业务语义调用准确率越高。比如同一个发布岗位接口如果你只写“发布职位”大模型可能会把参数填错如果你补齐“职位名称、工作城市、薪资下限、薪资上限、职位描述支持HTML”出错的概率大幅下降。2.3 意图识别的三层兜底策略理论上只要工具描述写得好大模型基本能选对工具。但生产环境的输入千奇百怪“帮我招个Java”和“发布一个Java后端岗位”是两种说法如果第一个就精确匹配不到就需要兜底策略。我在线上项目里做了三层识别第一层是强制工具约束直接在请求里指定“只允许调用以下工具”让模型在候选集里做选择第二层是语义近似匹配把用户输入向量化之后跟工具描述做余弦相似度如果相似度最高值低于阈值就进入第三层第三层是走兜底话术明确告诉用户“我能帮你做以下几件事”引导用户把需求说清楚。这个三层设计最直接的好处是把LLM的“自由发挥空间”压缩到最小。毕竟在任务执行场景里错误的工具调用比不调用更可怕——它可能产生一笔错误订单、一个错误工单甚至继续错下去污染更多数据。3. 任务执行层从一句话到一条可调度的Job3.1 任务模型与持久化设计对话接口解析出动作指令后如果直接把方法调用执行完就结束那是最简单的做法。但真实场景里任务往往需要异步、定时、重试必须把它从一次方法调用变成一条“有状态的任务记录”。我用的任务模型字段大致如下public class AgentTask { private String taskId; // 业务唯一IDUUID或雪花ID private String sessionId; // 关联对话会话 private String taskType; // 任务类型JOB_PUBLISH / TICKET_CREATE / ... private String status; // CREATED / EXECUTING / SUCCEEDED / FAILED / RETRYING private String payload; // 任务参数JSON来源于Tool Call的arguments private Integer retryCount; // 当前重试次数 private Integer maxRetry; // 最大重试次数 private LocalDateTime createTime; private LocalDateTime nextRetryTime; }任务必须持久化不能只放在内存里。原因是任务在执行中可能遇到服务重启、消息丢失、网络抖动一旦任务状态只存在于内存重启后这条任务就消失了用户以为已经提交的任务其实永远没有执行。我用MySQL存任务主表Redis存待执行的定时任务索引两者配合既保证可靠性又保证调度性能。3.2 三条执行路径即时、延迟与定时任务调度我拆成了三条路径分别对应不同业务场景。即时执行是最常见的路径。用户提出诉求工具调用参数齐备任务创建后立刻进入执行队列消费者拿到任务就调目标系统的接口。适合查天气、算工龄、解析文档这类耗时短的操作。延迟执行适用于需要给用户确认时间的场景。比如创建工单前先问“是否确认创建”任务先以PENDING状态存起来设置一个5分钟的确认期限用户没确认就自动关闭用户确认了才真正执行。这里用Redis的延迟队列实现很合适ZSet按执行时间排序定时扫描到点的任务。定时执行对应“每天早上9点汇总昨日告警并发给值班群”这类周期性需求。这部分不建议自己重新造轮子直接用现成的调度框架更稳妥。我现在用的就是DolphinScheduler因为它是分布式调度任务失败有告警插件还支持DAG编排——当智能体的一个决策需要拆成多个步骤并行执行时DAG比代码里硬编排直观得多。3.3 执行器的接口抽象与扩展机制任务类型不同执行逻辑肯定不同所以执行器天然要用策略模式。我在代码里定义了一个统一接口public interface TaskExecutor { boolean support(String taskType); TaskResult execute(AgentTask task); }每种任务类型对应一个Executor比如PublishJobExecutor、CreateTicketExecutor、SendReportExecutor。调度器拿到任务后遍历所有Executor找到support返回true的那一个执行。这里有两个细节值得注意。第一个是每个Executor应该尽量保证执行逻辑独立、无状态参数全部从payload里读不要在Executor内部缓存业务数据。第二个是要在Executor里做好目标系统的接口调用封装统一处理鉴权、超时和异常映射。拿招聘发布来说招聘网站OpenAPI的鉴权token会过期Executor里要集成刷新逻辑接口返回的错误码五花八门Executor要把它们翻译成统一的失败原因方便后续告警和人工介入时快速定位。4. 实战落地一个招聘助手智能体的完整链路4.1 需求拆解HR的一个模糊需求怎么拆成步骤为了把上面的设计串起来我拿一个实际项目——“招聘助手智能体”做完整拆解。业务场景是HR在企业微信群里发一句话帮我在招聘网站上发布一个Java开发岗位JD看这条链接。就这么一句话背后要拆成六步意图识别识别出“发布岗位”意图确定要调用openJdUrl和publishJob两个工具。抓取JD链接内容调用openJdUrl解析链接里的职位描述抽取出岗位名称、职责、要求、薪资范围。整理参数并请求确认把解析结果返回给大模型让大模型生成一段确认话术“我将发布以下岗位请确认”。用户确认后创建任务把确认后的完整参数写入AgentTask状态为CREATED异步执行。调用招聘网站OpenAPI发布职位携带公司凭证创建职位草稿。结果回传发布成功后把职位链接和草稿审核地址回复给HR。从这个拆解能看出来智能体不是一个“输入一句话、吐出一段话”的黑盒它本质上是把一条业务链路拆成了多个工具调用再由代码按顺序编排执行。对话接口只是这条链路的入口。4.2 核心代码骨架对话循环与事件驱动招聘助手里的核心循环用伪代码表达大概是这样public ChatResult handleUserMessage(String sessionId, String userMessage) { ListChatMessage history chatHistoryStore.get(sessionId); history.add(ChatMessage.user(userMessage)); for (int i 0; i MAX_TOOL_CALL_ROUNDS; i) { ChatResponse response llmClient.chat(history, toolRegistry.getToolDefinitions()); ChatMessage assistantMsg response.getMessage(); if (assistantMsg.isToolCall()) { history.add(assistantMsg); ToolResult result toolRegistry.execute(assistantMsg.getToolName(), assistantMsg.getArguments()); history.add(ChatMessage.tool(assistantMsg.getToolCallId(), assistantMsg.getToolName(), result.toJson())); } else { history.add(assistantMsg); chatHistoryStore.save(sessionId, history); return ChatResult.text(assistantMsg.getContent(), history); } } return ChatResult.text(操作步骤太复杂我已经记录您的需求稍后由人工介入处理。, history); }MAX_TOOL_CALL_ROUNDS我一般设为5防止大模型在一个循环里反复调用工具停不下来。每一轮工具调用的结果都会进入历史消息作为下一轮决策的依据。任务确认之后进入执行部分。创建任务的地方不直接调Executor而是先检查这个任务是否已经存在避免重复创建然后写入任务表投递到消息队列。独立的Worker监听队列把任务状态从CREATED更新为EXECUTING再交给执行器执行。4.3 智能体技能的管理与敏感变量处理招聘助手里“技能”这个概念非常重要。一个技能对应一组相关的工具函数和一个负责编排的Prompt模板。比如“发布岗位技能”包含了抓取JD、解析JD、检查岗位是否重复、发布职位四个工具。大模型只有在用户意图命中这个技能时才会使用这些工具。技能里的敏感变量处理我在Java侧统一管理。招聘网站的API Key、公司账号凭证绝不写入对话上下文也不出现在工具参数里。工具函数执行时所有敏感信息从配置中心拉取仅保存在执行器局部变量里。对话历史的日志打印也要脱敏token、密钥统一做掩码。我之前在这块吃过亏有一次调试时把完整的API Key打印到了日志里被安全团队点名。从那以后凡是涉及外部系统的鉴权信息一律不允许进日志工具参数里出现密码字段也要自动用星号替换。5. 可靠性设计任务跑飞了必须有人知道5.1 三层重试机制与指数退避任务执行失败是常态。目标系统可能临时宕机、网络可能抖动、参数可能被对方接口校验拦下。所以不做重试机制的任务执行跟不做事务的订单系统一样等于在上线前就给自己埋雷。我的重试机制分三层。第一层是进程内重试针对网络超时这类瞬时错误立即重试1到2次。第二层是消息队列级别的延迟重试任务执行失败后不直接标记FAILED而是按指数退避策略重新投递到延迟队列。退避时间我用的参数是第一次重试延迟30秒第二次2分钟第三次5分钟之后不再自动重试进入待人工处理状态。第三层是人工介入。任务在达到最大重试次数后状态更新为FAILED并触发告警通知开发或运营人员去后台排查。三层设计要配合一个“失败分类”逻辑。比如因为参数格式错误导致的失败重试100次结果都一样没有任何意义应该直接进人工但如果是目标系统返回502、503这类状态码大概率是对方服务恢复后就能成功重试才有价值。所以我让每个Executor在返回失败结果时必须附带一个failType字段RETRYABLE或NON_RETRYABLE调度器根据这个字段决定是否进入重试流程。5.2 幂等性同样的话术不能产生两笔订单任务执行中最隐蔽的坑是重复执行。用户点了两次“确认”或者重试机制和目标系统之间发生了消息重复消费同一个任务被执行了两次结果就是产生两条重复的职位草稿、两张重复的工单。解决这个问题不能靠“重试次数少就不会发生”必须靠幂等设计。最简单可靠的幂等策略是给每一个任务绑定一个唯一的业务键。创建任务时生成taskId在调用外部系统时把这个taskId作为幂等键传过去目标系统记录这个键第二次收到同样键的请求直接返回第一次的结果。这里有个前提条件就是目标系统的接口必须支持幂等键。如果对方不支持就需要在本地加一张“外部请求记录表”把每次外部调用的请求参数和返回结果都记录成一条审计日志同一taskId重复执行时先查这张表如果有成功记录就直接返回历史结果不再发起外部调用。招聘助手的场景里职位草稿创建接口是支持幂等键的这在很大程度上降低了我的实现成本。但我也见过不少OpenAPI不支持幂等键的情况遇到那种接口就别偷懒本地请求记录表必须做。5.3 企业微信告警任务失败要让正确的人知道任务失败不能只写日志等开发者自己去看生产环境里要靠告警主动推送给正确的人。告警我优先用企业微信机器人Webhook因为团队日常沟通就在企微消息可以直接进群响应速度快。告警消息的模板我自定义了一套核心包含任务ID、任务类型、失败阶段、失败原因、重试次数、payload摘要、相关会话链接。原则是让收到告警的人一眼能定位到问题而不是还得去查日志系统。【智能体任务失败告警】 任务ID: 8f3c2a0e-6b1d-4f5e-9a2c-1d0b3c4a5e6f 任务类型: JOB_PUBLISH 失败阶段: EXECUTING 失败原因: 招聘网站OpenAPI返回401token刷新失败 重试次数: 3/3 payload摘要: titleJava开发工程师location上海 处理建议: 检查招聘平台应用凭证是否过期告警不是发了就完事还要设升级策略。同一个任务失败告警如果2小时内没有被标记为“处理中”需要自动值班开发。告警风暴也要防短时间同一类型任务大量失败要合并成一条聚合消息比如“过去10分钟内有15个发布任务因招聘网站接口503失败”而不是发15条一样的消息。6. 工具选型与运维沉淀的一些经验6.1 异步任务队列选型Redis Stream还是RabbitMQ任务执行绕不开异步队列。选型时我纠结过Redis Stream和RabbitMQ。如果团队没有现成的RabbitMQ集群建议直接用Redis Stream理由有三个一是Redis基本每个Java项目都有不需要额外引入运维组件二是Stream的消费者组机制天然支持负载均衡满足智能体任务的消费需求三是配合Redis的ZSet可以实现秒级的延迟消息延迟重试队列和任务调度能共用一套基础设施。RabbitMQ的优势在于消息确认机制成熟路由规则灵活但它的运维成本比Redis高不少。对多数智能体项目的任务量来说Redis Stream的性能完全够。我用Redis Stream存的消费者信息是任务taskId消费者收到后先查任务状态防止同一个任务被多个消费者重复处理。6.2 对话上下文的瘦身工具定义不能无限膨胀工具个数一旦多起来对话上下文的token消耗会快速增长。每个工具的完整定义名称、描述、参数JSON Schema都要随着每次对话请求发送给大模型十几个工具还好几十个工具时光是工具定义就可能在每轮对话里消耗几千token既慢又贵。我的做法是启用“工具分组加载”。根据用户当前消息的语义只把命中技能组里的工具定义发给大模型而不是把所有工具都带上。比如用户聊的是招聘那就只带招聘相关的8个工具其他运维类、工单类的工具一概不出现。这样既节省了token又提高了工具选择的准确率。如果会话很长历史消息也要控制。超过一定轮数后把较早的消息做摘要压缩用一段几百字的“历史摘要”替换原文再把最近的几轮消息完整保留。6.3 关于LLM选型与Java SDK接入的一点补充选哪个大模型要看任务类型和预算。对话质量要求最高的“确认话术”环节我用的是效果较好但成本较高的旗舰模型而像JD链接解析这种结构化任务我用的是推理速度快的轻量模型返回JSON时配合“强制JSON模式”设置解析成功率接近100%。不同的模型在工具调用能力上差异很大同一个工具参数Schema有的模型会漏填必填字段有的模型会把字符串数组填成逗号分隔字符串。这块没有捷径只能拿真实的工具调用样本去批量跑评测看哪个模型在你的任务集上准确率高。Java接入LLM我推荐直接用官方SDK或者LangChain4j这类封装不建议自己裸写HTTP轮询和SSE解析。理由很简单SSE流式解析里的边界情况特别多消息可能分包、断线要重连、中文多字节字符可能被截断这些都是细节活交给成熟库处理更省心。7. 一点体会与后续规划把招聘助手从“能聊”做到“能发布职位”前后花了大概两个月。回头看我最大的体会是智能体开发的技术难点从来不在对话接口而在于你愿不愿意按照做业务系统的标准去对待任务执行任务有没有持久化执行失不失败知不知道重复执行会不会出事责任人能不能找到把这些环节都做扎实了对话接口反而是整个项目里最简单的那一层。如果你想从零开始做一个Java智能体项目我的建议是别一上来就追求“什么都能干”。先挑一个具体、有明确结果产出的场景比如“发工作通知”“查项目排期”“生成周报并发送到群”把对话到执行的闭环完整跑通再慢慢加技能。现在的技术栈已经足够成熟剩下的事就是像搭积木一样一块一块把工程基础打牢。
返回列表