Java技术栈实现智能体:对话接口与任务执行全流程解析
发布时间:2026/10/1 20:02:06来源:尧图网络
最近一个项目里我用纯Java给业务团队搭了个能对话、能自动调内部接口干活的智能体。做完之后最大的感受是Java做这一套完全不是伪命题只是很多人被“智能体必须用Python”的印象带偏了。对话接口负责听懂人话任务执行负责把听懂的意图变成真实动作——这两件事串起来就是一个能落地的Agent。这篇文章把我踩过的坑、拆过的设计、最终跑通的完整链路都写出来给准备在Java技术栈里做智能体的朋友一个参考。1. 内容整体设计与思路拆解1.1 智能体的本质听懂意图只是第一步很多人对智能体的理解停留在“能聊天”上。但真正有价值的智能体聊天只是入口干活才是核心。拿我们团队做的内部运维助手举例用户说“帮我把订单模块的日志级别调成DEBUG”这句话有两层信息——意图是“修改日志级别”执行对象是“订单模块”。如果只做对话模型回答一句“好的已为您调整”就结束了但系统里没有任何变化这就是典型的“假智能体”。所以我在设计最一开始就定了原则对话接口负责语义理解任务执行负责把语义落成可验证的动作。二者通过一个明确的任务模型衔接而不是让模型直接操作业务系统。这个设计思路来自实际教训——早期版本让模型直接拼接SQL去查库模型一旦理解偏差生成的SQL可能把表扫瘫痪。后来改成让模型选择“预设工具”由工具内部做参数校验和权限控制安全性立刻上了一个台阶。1.2 技术选型Java生态里能用的组合拳智能体开发绕不开LLM调用但不见得非要上LangChain。我评估过三条路直接用OpenAI/DeepSeek的HTTP接口、用Spring AI这类框架、或者用LangChain4j。最终选了“HTTP接口 自研调度”的方案原因有三点一是团队Java基础扎实自研代码出了问题能快速定位二是Agent的核心逻辑其实不在模型调用而在“规划-执行-反馈”的调度节奏这部分自研完全可控三是Spring AI虽然封装好但版本迭代快抽象层反而限制了灵活度。模型侧我用的DeepSeek的API兼容OpenAI格式函数调用Function Calling能力也够用。Java侧的核心依赖就两个Spring Boot 3.x提供Web容器和依赖注入Jackson处理JSON序列化。整个项目骨架不到200行就能跑通第一批对话后面再把调度、任务、工具注册一层层加进来。选轻量起步的好处是架构演进不会被框架绑架。1.3 消息流转设计一张图看懂数据走向整个系统里无非三类消息在流转用户输入、模型响应、工具执行结果。它们的流向决定了智能体的行为模式。标准流程是用户消息进入会话上下文 - 拼接系统提示词和历史记录 - 发给LLM - 如果模型选择调用工具则把工具名和参数解析成任务 - 执行任务得到结果 - 把结果追加回上下文 - 再次调用LLM生成最终回复。这个“循环”是Agent区别于普通聊天机器人的关键。普通聊天机器人一次请求一次响应就结束了而Agent可能要经过“多次模型调用多次工具调用”才能完成一个复杂目标。我把这个循环做了状态管理避免死循环——最大迭代次数设为8次超过就强制返回当前结果。实测下来95%以上的任务在4次以内就能收敛8次足够应对大多数场景。2. 对话接口实现让模型听懂人话2.1 消息对象模型不只是数组而是带角色的对话流对话接口最基础的部分是消息结构。我这里沿用了LLM API通用的“角色消息”模型system、user、assistant三种角色分别对应系统指令、用户输入、模型输出。Java里建一个简洁的POJOpublic record ChatMessage( String role, // system / user / assistant String content, // 文本内容 ListToolCall toolCalls, // 模型发起的工具调用请求 String toolCallId // 工具调用结果要关联的ID ) {} public record ToolCall( String id, // 工具调用ID用于把结果匹配回去 String type, // 目前固定 function FunctionCall function ) {} public record FunctionCall( String name, // 工具名比如 queryOrderStatus String arguments // JSON字符串比如 {orderId:A10086} ) {}这里有个容易踩的坑工具调用的结果必须通过toolCallId回绑给对应的调用否则模型不知道这个结果是哪个请求返回的。我最早没做关联多轮工具调用时模型经常把上一个结果当成下一个的输入逻辑彻底乱掉。加了这个ID之后消息流就变成清晰的“请求-响应”对模型可以精确引用每一条执行结果。2.2 上下文管理窗口再大也经不住无限塞LLM的上下文窗口是有限资源Java侧要主动管理不能把所有历史都发给模型。我参考了常见的滑动窗口策略固定保留系统提示词截取最近20条消息作为历史超出部分做摘要压缩。截取策略放在Java代码里处理避免每次都要重放整个会话——会话一长token费用和响应延迟都会飙升。摘要压缩是后来加的优化。用户和智能体聊了50轮之后前30轮的内容相关性大幅下降但某些关键信息比如用户偏好的时间格式可能还很重要。我的做法是每10轮对话做一次轻量摘要把“用户的关键约束”提炼成一小段文本放进系统提示词里。这个操作实测能把上下文占用降低40%而且不会明显损失回答质量。public class ContextWindow { private static final int MAX_HISTORY 20; public ListChatMessage buildMessages() { ListChatMessage messages new ArrayList(); messages.add(systemPrompt()); // 系统提示词 messages.addAll(history.tail(MAX_HISTORY)); // 最近20条 return messages; } }两张表对比一下不同上下文策略的取舍策略优点缺点适用场景全量发送信息完整token成本高、响应慢短会话、调试期滑动窗口成本可控、实现简单早期信息可能丢失大多数生产场景摘要压缩兼顾性能和完整度需要额外一次模型调用长会话、复杂任务2.3 流式输出种地里的“打字机效果”真实产品里用户等不了全量生成完毕再看结果。流式输出是对话接口的标配Java里用SSEServer-Sent Events实现最方便。Spring Boot的WebMvcConfigurer里给响应对象设置text/event-stream然后用SseEmitter把模型返回的数据块逐个推给前端。GetMapping(value /chat/stream, produces MediaType.TEXT_EVENT_STREAM_VALUE) public SseEmitter streamChat(RequestParam String userInput) { SseEmitter emitter new SseEmitter(60_000L); executor.submit(() - { try { llmClient.streamChat(buildMessages(userInput)) .forEach(chunk - emitter.send(SseEmitter.event().data(chunk))); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; }这里有个小细节SSE默认只支持文本事件JSON数据要手动序列化。我封装了一个ChatChunk对象把增量内容和工具调用标记都放进去前端根据标记决定是直接展示文字还是触发工具等待动画。流式场景下模型可能会先输出一段话再决定调用工具这种情况前端要做“先展示思考过程、再展示执行结果”的交互分层。实测中用户对“看得见思考过程”的体验评分远高于黑盒等待。2.4 函数声明让模型知道“有哪些工具能用”对话接口要支撑任务执行必须把“工具列表”告诉模型。这一步是通过函数声明完成的——用JSON Schema描述每个工具的名称、参数、用途。模型阅读这些描述后在合适的时候返回一个结构化的“调用请求”而不是自由发挥的文本。public static String buildFunctionSchemas() { ListMapString, Object schemas List.of( Map.of( type, function, function, Map.of( name, queryOrderStatus, description, 根据订单ID查询订单当前状态用于跟进物流和售后, parameters, Map.of( type, object, properties, Map.of( orderId, Map.of(type, string, description, 订单号格式如A10086) ), required, List.of(orderId) ) ) ) ); return objectMapper.writeValueAsString(schemas); }这段代码有几个关键点description字段写清楚“什么情况下用这个工具”模型对这个字段的敏感度极高描述越详细选错工具的概率越低required字段列出必填参数模型缺参时会主动反问用户补充参数类型要写严格我见过模型把数字参数填成字符串的情况schema里限制了type能在很大程度上避免。3. 任务执行设计从“听懂”到“做到”3.1 任务模型与状态机执行链路的地基对话接口解析出工具调用后就到了任务执行的地盘。我设计了一个任务对象包含任务ID、工具名、参数、状态、错误信息、重试次数这几个核心字段。任务状态机有五个状态PENDING - RUNNING - SUCCESS / FAILED - CANCELED。之所以引入状态机是因为任务可能被用户中途取消也可能执行失败需要重试没有状态管理的话并发场景下会出现“任务已经结束但代码还在跑”的混乱情况。public enum TaskStatus { PENDING, RUNNING, SUCCESS, FAILED, CANCELED } public record AgentTask( String taskId, String toolName, String arguments, TaskStatus status, String errorMessage, int retryCount ) {}每个状态切换都在同一个方法里做持久化更新我用的本地内存ConcurrentHashMap生产环境会换成数据库或用Redis做分布式锁。任务执行前校验参数、执行后校验结果两步校验能拦截90%以上的异常情况。3.2 工具注册中心别写if-else地狱智能体要能调用多个工具最忌讳的写法是if (toolName.equals(queryOrder)) { ... } else if (toolName.equals(sendMessage)) { ... }每加一个工具就要改一次分发逻辑维护成本爆炸。我用Spring的依赖注入做一个工具注册中心工具实现统一接口注册表自动收集所有实现类public interface AgentTool { String getName(); String getDescription(); String execute(String argumentsJson); } Component public class ToolRegistry { private final MapString, AgentTool tools; public ToolRegistry(ListAgentTool toolList) { tools toolList.stream().collect(Collectors.toMap(AgentTool::getName, t - t)); } public AgentTool getTool(String name) { AgentTool tool tools.get(name); if (tool null) { throw new ToolNotFoundException(未注册的工具: name); } return tool; } }新增一个工具只需要写一个类加Component注解注册中心自动感知。这个设计让我后期扩展工具时几乎不动老代码唯一要注意的是工具名不允许重复否则依赖注入阶段就会启动失败虽然是个“麻烦”但总比运行时才发现调错了工具好。3.3 执行引擎Agent循环调度器执行引擎是智能体的大脑负责驱动“模型推理 - 工具执行 - 结果反馈 - 再次推理”的循环。我用一个AgentRunner类实现这个调度逻辑public class AgentRunner { private static final int MAX_ITERATIONS 8; public AgentResponse run(String userInput, String sessionId) { // 维护本轮对话的消息列表 ListChatMessage messages new ArrayList(buildHistory(sessionId)); messages.add(new ChatMessage(user, userInput, null, null)); for (int i 0; i MAX_ITERATIONS; i) { ChatResponse response llmClient.chat(messages); // 情况1模型直接给出最终回答不需要调用工具 if (response.toolCalls().isEmpty()) { return buildFinalResponse(response.content()); } // 情况2模型请求调用工具 for (ToolCall call : response.toolCalls()) { AgentTask task taskDispatcher.submit(call); ToolResult result taskExecutor.awaitAndGet(task); // 把工具结果追加回消息序列 messages.add(new ChatMessage(assistant, null, List.of(call), null)); messages.add(new ChatMessage(tool, result.toText(), null, call.id())); } } return buildFallbackResponse(任务过于复杂已自动终止); } }这个循环有一个必须注意的点把模型返回的toolCall和工具执行结果都追加回消息列表顺序不能乱。先加assistant消息带toolCall再加tool消息带toolCallId模型才能正确理解“刚才调用了哪个工具、结果是什么”。顺序反了模型会认为结果是无来源的文本直接忽略掉。3.4 调度与重试任务失败不能静默吞掉工具调用可能因为各种原因失败参数校验不通过、下游接口超时、网络抖动。抗失败是任务执行的核心能力之一。我在TaskExecutor里实现了重试策略默认最多重试2次间隔采用指数退避1秒、2秒重试仍失败则把错误消息返回给模型——让模型根据错误内容决定是修正参数后重试还是换一个工具亦或直接告诉用户系统暂时无法完成。public ToolResult executeWithRetry(AgentTask task) { Exception lastException null; for (int attempt 0; attempt MAX_RETRY; attempt) { try { AgentTool tool registry.getTool(task.toolName()); String result tool.execute(task.arguments()); return ToolResult.success(result); } catch (Exception e) { lastException e; try { Thread.sleep(Duration.ofSeconds((long) Math.pow(2, attempt))); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); break; } } } return ToolResult.failure(任务执行失败: lastException.getMessage()); }这里有一个分量很重的坑重试不能覆盖“不可重试”的错误。比如参数格式不对重试一百次也是白搭反而白白浪费时间。我加了错误分类参数错误直接判失败不重试超时和网络错误才触发重试。分类逻辑放在工具实现的异常类型上进行判断目前看是最可靠的做法。3.5 结果反馈与生成式总结工具执行完返回的通常是结构化数据比如JSON字符串“{“status”: “SHIPPED”, “eta”: “2026-03-10”}”。直接把这个JSON原样返回给用户用户看不懂体验极差。所以链路最后一步是让模型“翻译”执行结果——把JSON数据转成顺畅的自然语言回复。// 把工具执行结果追加进消息 messages.add(new ChatMessage(tool, jsonResult, null, callId())); // 再次调用让模型生成最终回答 ChatResponse finalResponse llmClient.chat(messages);这一步的效果很夸张。同样的工具返回让模型直接说人话用户清晰度瞬间提升。要注意的是别让模型返回JSON之外的内容格式。我给系统提示词里写了“你是任务结果的解释器只输出给用户看的自然语言不要输出代码或JSON”然后模型就规规矩矩地转述了。4. 实操过程一个智能体任务的完整生命周期拆解4.1 场景设定与需求分析拿我们内部一个实际场景举例用户关心订单发货进度。用户问“我订单A10086发货了吗什么时候能到”。这个任务看起来简单但背后涉及两个工具的协同查询订单状态工具、查询物流轨迹工具。如果不做任务规划模型可能只调第一个工具就回复物流时间预测就缺失了。需求分析下来这个场景需要三个要素订单ID提取从用户自然语言中抽取、订单状态查询核心工具、物流轨迹查询增值信息。三者的调用顺序有依赖物流查询需要先拿到订单关联的物流单号所以必须先调订单状态查询拿到物流单号后再调轨迹查询。4.2 工具开发两个AgentTool的完整代码先写订单状态查询工具。参数是订单ID返回的是订单状态、物流公司、运单号。为了演示清晰我用了Mock数据模拟下游接口Component public class QueryOrderStatusTool implements AgentTool { Override public String getName() { return queryOrderStatus; } Override public String getDescription() { return 根据订单ID查询订单当前状态、物流公司和运单号适合回答发货和物流相关问题; } Override public String execute(String argsJson) { JsonNode args objectMapper.readTree(argsJson); String orderId args.get(orderId).asText(); if (!orderId.matches([A-Z]\\d{5})) { throw new InvalidParameterException(订单号格式不正确); } // 模拟下游订单系统查询 OrderInfo order orderService.queryById(orderId); return objectMapper.writeValueAsString(Map.of( orderId, orderId, status, order.status().name(), carrier, order.carrier() null ? : order.carrier(), trackingNo, order.trackingNo() null ? : order.trackingNo() )); } }再写物流轨迹查询工具。它的参数是运单号和物流公司返回轨迹列表。两个工具串起来才能完整回答用户的问题。Component public class QueryLogisticsTraceTool implements AgentTool { Override public String getName() { return queryLogisticsTrace; } Override public String getDescription() { return 根据运单号和物流公司查询物流轨迹列表返回每条轨迹的时间和地点描述; } Override public String execute(String argsJson) { JsonNode args objectMapper.readTree(argsJson); String trackingNo args.get(trackingNo).asText(); String carrier args.get(carrier).asText(); ListTracePoint traces logisticsService.queryTrace(carrier, trackingNo); return objectMapper.writeValueAsString(traces); } }工具开发时一个经验描述字段要写成“用户视角”而不是“实现视角”。比如“查询订单状态”就不如“根据订单ID查询订单当前状态、物流公司和运单号适合回答发货和物流相关问题”来得精准。模型靠描述判断“要不要用这个工具”描述写得好工具被正确调用的概率显著提升。我们做过对比实验描述优化后工具选择准确率从78%提升到93%。4.3 请求链路全流程追踪用户发出提问后整个链路这样走第一步请求进入ControllerAgentRunner接收用户输入构建消息列表并发送给LLM。第二步LLM识别出意图是查询订单状态返回函数调用请求queryOrderStatus({“orderId”: “A10086”})。注意模型返回的是结构化的调用请求不是自然语言。第三步AgentRunner把函数调用请求封装成AgentTask提交给TaskExecutor执行。TaskExecutor从工具注册中心找到QueryOrderStatusTool校验参数、执行Mock业务逻辑拿到结果JSON。第四步工具结果追加回消息列表再次调用LLM。模型读完结果后再次判断还需要物流轨迹才能回答“什么时候能到”于是继续查询物流——生成第二个函数调用请求queryLogisticsTrace({“trackingNo”: “SF1234567890”, “carrier”: “顺丰”})。第五步第二次工具执行完成后模型拿到完整信息生成最终自然语言回复“您的订单A10086已发货承运商为顺丰预计3月10日前送达。最新动态是快件已到达上海转运中心。”第六步更新会话内存把这一轮完整消息流持久化用户后续追问“那到北京要多久”时模型能引用刚才的上下文继续推理。4.4 日志与可观测性没有日志的Agent调试等于抓瞎智能体的调试比普通接口难一个量级因为中间过程不可见。我给每个任务开了traceId从用户输入到每次模型调用、每次工具执行全部打点。日志格式统一成JSON结构便于检索。log.info(agent_trace traceId{} stepuser_input content{}, traceId, userInput); log.info(agent_trace traceId{} steptool_call name{} args{}, traceId, call.function().name(), call.function().arguments()); log.info(agent_trace traceId{} steptool_result status{} data{}, traceId, result.status(), result.data()); log.info(agent_trace traceId{} stepfinal_answer content{}, traceId, finalContent);这个做法帮我解决过一个非常隐蔽的问题——某次用户反馈“智能体老是不按我的要求来”打开日志一看发现工具调用后消息列表里tool消息没有正确匹配toolCallId导致模型读到的结果是“无头数据”上下文理解出了问题。没有traceId这种问题要现场反复复现才能定位有了日志一眼扫过去就知道哪一步断链了。5. 常见问题与排查技巧实录5.1 模型不调用工具光说不做最普遍的问题是模型明明看到了工具声明却在一本正经地“假装执行”。比如用户问“帮我查下天气”模型回答“好的为您查询今天的天气今日晴转多云”——但它根本没调用工具天气信息是它自己瞎编的。原因通常在两个地方一是工具description不够触发条件模型不认为需要调用工具二是模型能力版本偏低函数调用支持不好。我的排查顺序先看工具声明是否准确触发再看返回里有没有tool_calls字段最后看模型参数里是否忘了传tools。还有一个小技巧在系统提示词里加一句“你必须先调用工具才能回答事实性问题”能明显改善“光说不做”的问题。5.2 多个工具调用顺序混乱复杂任务中工具间存在依赖关系。比如要先查订单再查物流模型有时会直接跳过第一步凭空编造一个运单号传给第二步。这个问题排查出来的原因很直接工具描述里没有写清楚“前置条件”。解决办法是在QueryLogisticsTraceTool的描述里明确“此工具需要先通过queryOrderStatus获取运单号不要凭空生成运单号”。模型对description的敏感性远超预期写明前置条件后调用顺序准确率大幅提升。还可以在参数校验逻辑里加一道防线运单号不符合规则就直接抛错阻止错误数据流入下游。5.3 工具结果太长挤爆上下文一次物流轨迹查询可能返回50条记录全量塞给模型不仅token爆炸模型还会被冗余信息干扰。我的做法是做工具结果压缩——只取关键字段时间、地点、状态合并同质数据。// 压缩逻辑抽象 public String summarizeResult(ToolResult result) { JsonNode data objectMapper.readTree(result.data()); ListString points new ArrayList(); data.forEach(node - points.add(node.get(time).asText() node.get(location).asText())); // 如果超过10条只保留前3条和最后1条 if (points.size() 10) { return 共 points.size() 条轨迹最新: points.get(points.size() - 1); } return String.join(; , points); }5.4 并发会话下上下文串线Java服务天然是多线程的如果不加控制多个用户会话的消息列表会互相污染。早期我用了一个静态Map存所有会话消息结果两个用户同时提问时A用户的历史记录被B用户冲掉。解法是会话隔离每个会话建独立的上下文对象用sessionId做Key。执行引擎处理请求时确保消息列表互不共享。我用了ConcurrentHashMap加细粒度锁保证同一会话的消息追加是原子的不同会话之间完全隔离。实测并发50路请求没有上下文串线问题。5.5 常见问题速查表现象可能原因排查方法模型不调用工具tools参数未传、描述不触发检查请求体里的tools字段、优化description措辞工具调用乱序依赖关系未在描述中声明在工具描述里写明前置条件、增加参数校验结果上下文爆炸工具返回数据过大未压缩增加结果摘要逻辑只保留关键字段多轮任务上下文丢失toolCallId未正确回绑检查消息序列顺序确认tool消息有关联ID并发会话串线使用了共享静态变量按sessionId隔离上下文对象6. 从对话接口到任务执行的经验总结整个项目做下来我最深的一个体会是Java做智能体难点从来不是调用大模型而是把“调用大模型”这件事跟现有业务系统缝合好。对话接口相对简单无非是组织消息、管理上下文、处理流式返回任务执行才是真正决定智能体有没有价值的地方——工具怎么注册、任务怎么调度、失败怎么恢复、结果怎么回传每一环都藏着生产环境才能暴露的细节问题。如果你正准备在Java技术栈里启动智能体项目我给三个建议。第一从小闭环开始先做一个工具、跑通一次“用户提问 - 模型选工具 - 工具执行 - 结果回复”的完整链路再逐步扩展。这个闭环就像打通任督二脉后面的复杂度都建立在它之上。第二随时关注模型返回的原始响应不要只盯着最终结果看——中间的工具调用请求、参数内容、工具结果格式每一步都可能出问题日志打点和原始响应留存是排查问题的救命稻草。第三把工具数量控制在10个以内工具太多模型选错的概率会显著上升宁可让工具功能大而全也不要拆得碎片化。最后再分享一个小技巧善用系统提示词约束模型行为边界。我在系统提示词里写清楚了“你是公司的智能客服助手只能使用可用工具回答问题工具查询不到的信息要明确告诉用户‘暂未查到’禁止编造”这一条提示词直接让“模型编造答案”的比率下降了80%以上。别小看这句话它会贯穿每一轮对话、每一次工具调用的决策过程比其他任何代码层面的检查都更有用。
网站建设高端定制企业官网