做智能体最怕的是把简单问题复杂化。这句话我想放在最前面LangChain4j 里做智能体核心不是去堆复杂的算法而是把“模型、工具、记忆”这三样东西编排好。今天是这个系列的第八篇前面我们已经聊过模型接入、提示词、结构化输出、记忆、RAG、工具调用现在终于轮到把它们捏合成一个真正会“主动干活”的智能体。跟到这里的朋友接下来这部分往往是最容易出成果的地方也是最容易翻车的地方。这一篇我打算换一种讲法不只说概念而是带着一个具体的“售后客服智能体”案例从设计、编码、调试到排查问题完整走一遍。你会发现所谓智能体和普通问答的最大区别就是它不再只动嘴它能动手——查订单、调接口、写文档、甚至再调用另一个智能体。能做到这一切的前提是先把它的运行机制看清楚然后才谈得上灵活控制。1. 智能体到底在 LangChain4j 里扮演什么角色1.1 别一上来就造轮子先分清智能体和工具调用的关系不少人在做智能体的时候第一反应是“我要自己写一个循环让模型决定下一步干什么”。这个思路没错但在 LangChain4j 里大部分脏活已经被框架做完了。智能体的本质就是一个具备自主决策能力的对话系统模型根据用户的请求自己判断要不要调用某个工具、调用哪个工具、拿到结果之后怎么继续直到它认为任务完成。很多人会把“工具调用Function Calling”和“智能体”混为一谈。其实工具调用只是智能体的一只胳膊它让模型具备了调用外部能力的接口但如果没有决策循环、没有记忆、没有多轮反馈模型只是一次性吐出“我想调用某个工具”的请求后续怎么执行、执行结果怎么消化还得靠业务代码自己收拾。而智能体在这个基础上多了一层循环模型请求调用工具系统执行执行结果作为新的上下文回到模型手里模型再判断下一步直到给出最终答案。这个循环就是智能体的引擎。1.2 前面几篇的技术积累在这里怎么汇合LangChain4j 中做智能体不是从零开始的它把前面几篇讲过的能力全部压缩到了AiServices这个门面里。模型接入你之前配置好的ChatLanguageModel直接拿来用不管是 OpenAI 兼容接口还是本地模型只要支持 function calling就能参与智能体决策。提示词与结构化输出你可以给智能体设置系统提示词告诉它“你是售后客服回答问题必须包含订单号和结论”这些约束会和工具调用的中间过程并存。记忆智能体不能是“金鱼记忆”对话历史必须放进循环里。AiServices支持把ChatMemory注入到 Agent 的每次决策中。RAG当智能体需要查企业知识库时可以让某个工具方法内部调用ContentRetriever相当于给智能体接了一个“资料员”同事。工具调用这是智能体的双手下一节我会重点拆解它和智能体循环是如何协作的。你只要把这些组件配好剩下的编排逻辑交给框架。这并不意味着你可以完全不了解底层——恰恰相反只有理解了底层循环后面遇到模型不听话、工具反复调用、上下文爆炸这些问题时才知道在哪个环节下手。1.3 它适合解决什么问题我个人的体会是智能体最适合的场景有三类第一类是“需要多步骤操作的综合性任务”。比如用户说“我要退掉昨天买的那个红色保温杯”智能体需要先查出订单再查物流状态确认“已发货”或“未发货”最后决定走退款流程还是拦截物流流程。第二类是“需要根据现场信息灵活决策的任务”。用户的问题没法在提交换前穷举模型要在运行时读取真实数据才能决定下一步。比如天气变化后自动调整出行方案。第三类是“需要多个数据源协同的任务”。比如你要写一份周报得从项目管理系统拉进度、从仓库系统拉发布记录、从监控系统拉告警统计这些数据散落在不同接口里智能体可以挨个查完再汇总。反过来说如果你的需求只是一个固定的问答、一个确定的接口返回那就不要硬套智能体。单独一次工具调用加规则判断成本低、可控性强、还容易调试。智能体的价值在“不确定”不在“复杂”。2. 核心机制拆解智能体循环是怎么跑起来的2.1 一次完整智能体调用的幕后流程我用一张大家都能理解的图来解释并不在正文里画复杂图表思路大概是这样用户说“帮我查一下订单 A1001 的物流状态。”这时候智能体内部会发生这些事情。第一历史消息和用户消息会被打包成一组ChatMessage连同系统提示词一起发给模型。模型看到用户请求后若它判断需要知道订单信息就会返回一个AiMessage里面带着ToolExecutionRequest列表而不是最终的文本答案。第二LangChain4j 拿到这个请求列表会找到我们提前注册好的Tool方法解析参数真实调用一次。比如调用物流查询接口拿到“已到达某地”这样的结果。第三工具返回的内容会被包装成ToolExecutionResultMessage追加到原来的消息列表中。此时整个上下文就变成了用户问题、模型想调工具、工具实际返回结果。第四系统拿着这组更新后的消息再次请求模型。模型这回见到工具结果就能组织出最终答案或者继续请求下一个工具。这个“请求-执行-反馈-再请求”的循环就是智能体的骨架。LangChain4j 自动帮你维护了这个循环所以你在业务代码里调用assistant.chat(...)时只看到最终答案但实际上背后可能已经跑了好几轮模型请求。2.2 谁来决定工具能不能被调用ToolSpecification 的作用这里必须讲清楚一个关键点模型并不知道你的 Java 方法长什么样它只知道“有哪些工具可用、每个工具是干什么的、参数是什么格式”。这份描述就是ToolSpecification。在 LangChain4j 中你写一个类里面的方法标注Tool注解框架启动时会扫描这些方法自动生成对应的ToolSpecification。方法的注释、方法名、参数上的Parameter描述统统会变成传给模型的 JSON Schema。换句话说你的注释写得越清楚模型就越不容易乱调。举个例子如果你给工具只写半个字描述Tool(查物流) String getLogistics(String orderId) { ... }模型很可能不知道orderId到底是什么格式、该不该带前缀于是随便给你传个“昨天买的保温杯”你的接口就炸了。如果你写成Tool(根据订单号查询物流状态订单号是纯数字字符串例如 20250101)模型出错率会肉眼可见地下降。不要小看这段描述它就是模型选工具时的说明书。2.3 记忆在智能体循环中的关键作用工具调用本身是无状态的但智能体必须是有状态的。因为循环要工作每一步都要看到上一步发生了什么。LangChain4j 中处理这个问题靠的是ChatMemory。我常用的组合有两种一种是简单的窗口记忆MessageWindowChatMemory.withMaxMessages(10)适合轻量级对话超出窗口直接丢弃最早的消息。另一种是带摘要的ChatMemory在消息太多时把旧消息压缩成摘要避免丢失重要线索。在多用户场景下还要注意隔离不能把 A 用户的订单信息带到 B 用户的对话里。LangChain4j 提供的做法是用MemoryId参数标记会话维度。我在后面第三部分会给出具体写法。理解了这个机制后你就会明白为什么“乱写循环”容易出问题如果每一步工具调用后你不把ToolExecutionResultMessage放回对话列表模型永远看不到工具返回了什么它就只能胡编。而这正是 LangChain4j 已经帮你处理好的部分。3. 从零实现一个可落地的智能体3.1 定义一个会说人话的服务接口在 LangChain4j 里智能体的入口通常是一个接口接口方法上标注怎么接收用户消息、怎么绑定记忆。下面是我在某个售后客服系统里实际用过的简化版本public interface CustomerServiceAgent { String chat(MemoryId String userId, UserMessage String userMessage); }这里有几个点要解释。MemoryId表示按用户区分记忆同一个userId的对话会共享一份历史记录。UserMessage表示这个参数是用户输入的消息主体。接口不需要写实现类AiServices.builder()会在运行时动态生成代理对象。你可能会奇怪就一个接口怎么就成了智能体因为智能体的逻辑不在方法体里而在模型决策、工具调用、记忆这三者的组合里。这个方法只是暴露给业务系统的一个入口。3.2 用 Tool 把方法变成模型手里的“工具”工具类优先做成独立的类方便单元测试也方便以后复用。下面我写一个处理订单和物流的工具类public class OrderTool { private final OrderService orderService; public OrderTool(OrderService orderService) { this.orderService orderService; } Tool(根据订单号查询订单基本信息订单号为纯数字字符串) public String getOrderInfo(Parameter(订单号) String orderId) { return orderService.queryOrder(orderId).toString(); } Tool(根据订单号查询最近一条物流轨迹) public String getLogisticsInfo(Parameter(订单号) String orderId) { return orderService.queryLogistics(orderId).toString(); } Tool(根据订单号办理退款仅当用户明确要求退款且订单状态允许时调用) public String refund(Parameter(订单号) String orderId, Parameter(退款原因) String reason) { return orderService.refund(orderId, reason).toString(); } }注意几个细节第一每个方法上的Tool描述要写清“什么时候该用、参数是什么”。第二方法返回值尽量是 String工具返回给模型的本质是文本。第三工具内部不要跟模型对话它就是纯粹的执行器。组装的时候把模型、工具、记忆全部交给AiServicesChatLanguageModel model OpenAiChatModel.builder() .apiKey(System.getenv(DASHSCOPE_API_KEY)) .modelName(qwen-plus) .build(); CustomerServiceAgent agent AiServices.builder(CustomerServiceAgent.class) .chatLanguageModel(model) .chatMemoryProvider(memoryId - MessageWindowChatMemory.withMaxMessages(20)) .tools(new OrderTool(orderService)) .build(); String answer agent.chat(u_1001, 帮我看一下订单 20250101 到哪了);代码很短但这时候你手里已经有一个能查订单、能查物流、能走退款的智能体了。模型会自己判断用户问物流就调物流工具用户说退款就查完订单状态后决定要不要调退款工具。3.3 手动接管循环让每一步都看得见AiServices适合大多数场景但调试时你常常需要看清“模型到底想调哪个工具、传了什么参数、返回了什么”。这时候可以绕过AiServices直接手动操作模型 API。我自己排查智能体问题时特别喜欢写一个“可视化循环”。核心代码是这样的ListChatMessage messages new ArrayList(); messages.add(SystemMessage.from(你是售后客服只能使用提供的工具获取信息回答必须简洁)); messages.add(UserMessage.from(查一下订单 20250101 的物流)); int maxRounds 5; for (int i 0; i maxRounds; i) { ResponseAiMessage response model.generate(messages); AiMessage aiMessage response.content(); if (!aiMessage.hasToolExecutionRequests()) { // 不再调用工具这就是最终回答 System.out.println(最终回答 aiMessage.text()); break; } ListToolExecutionRequest requests aiMessage.toolExecutionRequests(); for (ToolExecutionRequest request : requests) { System.out.println(第 (i 1) 轮请求调用工具 request.name() 参数 request.arguments()); // 这里根据工具名手动分发 String result dispatchTool(request.name(), request.arguments()); messages.add(ToolExecutionResultMessage.from(request.id(), request.name(), result)); } messages.add(aiMessage); }这个循环的本质就是前面讲的“请求-执行-反馈”。手动写一次你会对框架自动做的事情有更直观的理解。而且排查问题时可以任意打印中间步骤定位是模型意图错了还是工具执行错了还是上下文组装错了。我自己遇到过一种情况框架自动循环一切正常但业务方说“智能体偶尔自己编物流信息”。手动循环排查后发现原来是某个模型的 function calling 返回的工具名和注册的不一致导致工具分发改错了。这种问题靠黑盒观察是发现不了的。3.4 多轮对话场景为每个用户隔离记忆实际系统里不可能只有一个用户。如果所有用户共享一份ChatMemory那 A 用户问订单号B 用户可能直接“继承”上一轮的记忆冷冰冰的结果就是信息串号。用MemoryId是 LangChain4j 的标准解法。上面代码里chatMemoryProvider会按 memoryId 创建独立记忆空间每个用户在自己的管道里对话互不干扰。不过要提醒一句MemoryId的值不要用内部自增数字用户名最好用稳定且唯一的用户标识否则一旦用户ID冲突两个用户的历史又会搅在一起。另外如果用户量很大记忆都在内存里重启就丢。生产环境我建议把ChatMemory换成一个支持持久化的实现把历史消息落到数据库里或者用 Redis 缓存这样用户下次来还能接上话。4. 进阶玩法让智能体真正干起活来4.1 多工具串行与并行让智能体学会“统筹”单个工具只能解决单一问题。真实需求往往是复合的比如用户问“这个订单如果今天发货大概什么时候能到不到我就退款。”这要求智能体先查订单再查物流时效最后根据规则判断是否执行退款。整个过程可能需要三轮工具调用。LangChain4j 支持在一次AiMessage里返回多个ToolExecutionRequest也就是模型想要并行调用多个工具。例如同时查订单和物流减少来回次数。框架默认的ToolExecutor在支持并发的情况下会并行执行这些请求然后把结果一次性塞回上下文。我建议工具设计时遵循一个原则工具粒度不要太细也不要太粗。太细了比如“订单号加一”“订单号减一”这种原子操作模型很难组织出合理的调用序列太粗了比如一个工具内部把查订单、查物流、退款全干了那模型失去决策意义。合适的粒度是“一个工具完成用户可感知的一个环节”。4.2 给智能体接上 RAG让它在知识库里找答案很多企业知识问答并不能只靠模型参数里的知识还得检索最新的制度文档、产品手册。这时候可以让智能体的某个工具内部去调用 RAG 检索器。做法很简单把ContentRetriever注入到工具类中供某个工具方法使用。public class KnowledgeTool { private final ContentRetriever retriever; public KnowledgeTool(ContentRetriever retriever) { this.retriever retriever; } Tool(根据用户问题检索企业知识库返回最相关的制度或说明片段) public String searchKnowledge(Parameter(用户问题) String question) { ListContent contents retriever.retrieve(question); return contents.stream() .map(Content::text) .collect(Collectors.joining(\n---\n)); } }这样做的好处是智能体可以先判断用户的问题需不需要查知识库再决定要不要调用检索工具。比如“我们公司的退货政策是什么”就调用知识库工具“帮我退掉这个订单”就查订单两者互不干扰。不过要注意RAG 检索到的文本可能很长如果一股脑塞给模型既费 token又会稀释工具结果的“可信度”。我习惯在工具方法里先做截断或排序只返回最相关的三五条每条控制在一两百字以内。4.3 用子智能体拆解复杂任务智能体不一定只能调用外部接口它还可以调用另一个智能体。这种“母智能体 子智能体”的设计适合处理那种需要不同领域知识的任务。比如一个综合助手接到需求“帮我分析这个项目周报并且整理成 PPT 大纲。”这涉及到两个完全不同领域数据分析、文案编排。与其让一个智能体同时持有所有工具不如让母智能体通过工具调用子智能体public class AnalysisTool { private final ReportAgent reportAgent; public AnalysisTool(ReportAgent reportAgent) { this.reportAgent reportAgent; } Tool(调用数据分析智能体对给定的文本进行数据提炼和归纳) public String analyze(Parameter(待分析文本) String text) { return reportAgent.chat(analyze, text); } }母智能体负责理解用户意图发现任务需要数据分析时就调用AnalysisTool。子智能体内部可以带着自己的记忆和工具独立完成一次分析。这种嵌套结构在逻辑上很像把一个大任务“外包”出去。但嵌套也有代价每一层嵌套都会增加模型调用次数、延迟和 token 成本。我建议只拆两层最多三层再深就容易出现“传话筒失真”的问题——子智能体返回的信息经过多层压缩关键字段容易丢失。5. 常见问题与排查技巧实录5.1 模型根本不调用工具怎么办这个问题最高频。模型直接给出回答完全不提工具常见原因有三个模型本身不支持 function calling、工具描述太模糊、用户问题没有触发工具的必要条件。排查时我先看模型列表和参数确认当前模型是否支持 function calling不支持就直接换支持 OpenAI function calling 协议或类似协议的模型。然后是工具描述把Tool里的文字改具体写明“在什么时候必须调用此工具”。最后是提示词可以在系统提示词里加一句“如需查询订单、物流等信息必须使用对应工具不得自行编造”。我遇到过最隐蔽的一种情况模型返回了工具请求但 request 里的name和我注册的工具名完全对不上。查了很久才发现是类加载了同一个工具类的两个不同实例导致重复注册。最后统一用Tool方法名加类名前缀来区分解决冲突。5.2 工具调用来回重复死循环了表现是模型不断请求同一个工具结果已经返回了它还是不往下走反反复复执行。通常是因为工具返回的结果没有解决模型的疑问或者模型无法从结果中提炼出有效信息。我排查时先开启手动循环打印每一轮的工具返回和模型消息。如果发现模型一直在问同样的问题大概率是工具返回内容里缺少关键字段。比如物流接口返回了一堆 JSON但模型想看的“是否已发货”这个字段被埋在一大串无意义字段后面模型被干扰了。解决办法就是让工具方法直接面向模型整理返回值去冗余、加结论。另外手动循环里我一定会设置最大轮数比如 5 轮就打住宁可让用户重问一次也不能让它烧着 token 无限循环。框架自动模式下如果底层不禁用轮数业务侧最好也做个超时或轮数保护。5.3 工具参数总是传错JSON 解析报错模型调用工具时会把参数渲染成 JSON 字符串你的Tool方法接收的是 Java 类型框架要做反序列化。一旦参数描述不清楚模型就可能传一个格式不对的值导致反序列化异常。我在设计参数时总是遵循几个原则第一参数类型尽量用简单类型不要搞复杂嵌套对象。第二每个参数必须写Parameter描述包括格式、示例。第三允许为空的参数要写清楚不然模型为了凑数可能传一个 null 或者空字符串。如果某个工具的入参就是要复杂那我会拆成更细粒度的工具让模型一次只传一个订单号、一个日期而不是传一整块 JSON 对象。省下来的调试时间绝对值得。5.4 多轮对话后“失忆”或串话智能体聊得久了记忆窗口满了早期信息被挤掉模型会“忘记”用户之前提的需求。解决办法是升级记忆策略不要只做滑动窗口要在窗口溢出时触发摘要把重要历史信息压缩成一段话放在上下文最前面。另外一种“串话”前面提过是MemoryId用错导致用户之间共享记忆。排查方法很简单打印每个用户对应的 memoryId确认是否保持唯一。如果用的是内存态ChatMemory应用重启后记忆清空也属于正常现象别当成 bug。5.5 Token 消耗太快怎么办智能体天生费 token多轮模型请求、工具返回结果、历史消息全都在反复计算。优化时我一般按排序处理先裁剪工具返回结果只把结论返回给模型。再限制历史消息条数比如只保留最近 6 条完整轮次。然后考虑用更轻量的模型处理简单意图识别只有复杂任务才交给带工具的智能体链路。最后是通过工具内部做汇总比如多个指标查完后在工具侧先合并成一段话而不是让模型自己拼。这套组合拳打下来绝大多数场景的 token 都能降到原来的三分之一左右。最后再分享一个我这几次实战下来最有体感的经验智能体项目上线后真正的维护难点不在代码而在模型的不确定性。同一个工具描述换一个模型版本或者换一个供应商就可能出现完全不同的行为。所以我强烈建议在工具方法里写清楚返回格式在系统提示词里明确边界在业务入口做好轮数保护同时把每一轮的模型请求日志完整落盘。做到这三件事你在智能体上栽过的每个跟头都能变成下次上线前提前避开的坑。