
前阵子有个朋友问我公司全栈 Java老板让搞 AI Agent网上教程翻来覆去全是 Python 的 LangChain、LlamaIndex难道要把智能体服务整体用 Python 重写一遍我当时给的答复是如果你只想要个 demo用 Dify 这类平台确实快但如果是正经 Java 团队LangChain4j 这套库其实能让你从 Tool 到 Agent 流水线一路做完全程不换栈。这篇文章就把这条路完整捋一遍Tool 注解怎么让模型调用你的方法AiServices 怎么把工具、记忆挂载成可对话的智能体RAG 多路召回怎么接进流水线以及最后怎么用 Java 代码编排出多 Agent 协作的业务流程。适合看这篇的人有两类一是已经在用 LangChain4j 写基础 LLM 调用、但对 Agent 化改造还没下手的 Java 后端二是被各种Java 不适合做 AI论调劝退、想验证一下工程可行性的技术负责人。后面的内容基本按我实际开发的路径来写没有理论堆砌全部是能拷贝到项目里改一改就跑的代码。1. Java 团队做 Agent 的尴尬和 LangChain4j 的破局点先说大多数人遇到的实际场景。公司已有的业务系统是 Spring Boot MySQL Redis服务部署在自建机房或云主机上。现在要接入大模型能力最简单的做法是直接调 OpenAI 或国内大模型厂商的 HTTP 接口用 RestTemplate 发请求再把返回的字符串塞回页面。这种玩法做聊天机器人没问题但一旦涉及让模型根据用户意图调用咱们内部系统的接口麻烦就来了。最原始的方案是手写 JSON Schema把每个接口的参数定义、描述、枚举值全部写成模型能看懂的结构然后塞进 system prompt 里。模型返回一个 function_call 的 JSON你再去解析、反射、调用本地方法。这么干几天之后你会崩溃接口一多Schema 维护就是噩梦模型偶尔会给错参数类型函数名稍微含糊一点它就选错工具。更别提还要自己管理多轮对话历史、向量检索、上下文压缩这些周边能力。这也是为什么 LangChain4j 这几年的存在感越来越高。它的思路和 Python 生态的 LangChain 一致但 API 设计完全面向 Java 开发者的习惯——注解、接口代理、链式 builder甚至比 Python 版本更符合 Java 的静态类型直觉。你在方法上写一个Tool注解它自动把方法签名变成 JSON Schema你用AiServices.builder()构建一个接口的代理实例工具、记忆、RAG 组件全部通过 builder 挂进去。一套链路下来确实能做到一个库打全套。不过要提醒一点LangChain4j 目前 API 变动仍然频繁早期 0.x 版本和 1.x 版本的写法差异很大。网上大量教程还是 0.3x 时代的老代码很多类名和 builder 方法已经改了。建议直接用 1.x 最新版以官方文档的 API 名称为准。我这篇文章里的示例代码也按 1.x 的 API 风格来写。2. Tool 不是魔法注解从方法签名到 LLM 函数调用意图的完整链路2.1 LLM 不会执行你的代码它只产生调用意图理解 Tool 之前必须先搞明白一个核心事实大模型本身不会执行任何代码。你给它一个函数的 JSON Schema它只是在生成文本时额外输出一段结构化的我想调用某个函数参数是这些的内容。真正执行函数、拿到结果、把结果送回给模型的是你的业务系统。这个过程通常叫 Function Calling中文常译为工具调用或函数调用。LangChain4j 的Tool注解做的就是把方法签名 → JSON Schema这个繁琐步骤自动化并在模型返回调用意图后自动反射调用你的方法把返回值塞回对话上下文。整条链路是这样的你定义带Tool注解的方法结构包括方法名、描述、参数名、参数描述。LangChain4j 启动时扫描这些注解生成对应的 JSON Schema随请求发给模型。用户在对话中表达需求模型判断这个需求需要调用某个工具来完成。模型输出 function call 的 JSON包含工具名和参数值。LangChain4j 解析 JSON反射调用你对应的方法拿到返回值。返回值作为一条消息追加进对话历史再次发给模型模型根据结果组织最终回复。这里最容易误解的地方是有人觉得 Tool 方法一定得放在被 AI 调用的那个 Agent 类里。实际上完全不需要。你可以把工具方法放在普通的 Service 类、Repository 类甚至独立的工具类中只要方法上有Tool注解并且这个对象被传入.tools(...)LangChain4j 就能提取到。2.2 一个能直接跑的菜单查询工具我用一个内部订餐助手的场景来演示。假设公司有个餐饮系统员工问 AI 今天有什么川菜 Agent 需要调用本地方法查询菜单数据库。下面是工具的写法import dev.langchain4j.agent.tool.Tool; import dev.langchain4j.agent.tool.P; public class MenuTools { private final MenuRepository menuRepository; public MenuTools(MenuRepository menuRepository) { this.menuRepository menuRepository; } Tool(查询今日菜单中指定分类的菜品列表分类如川菜、粤菜、甜品、饮品) public ListDishBrief listDishes( P(菜品分类) String category) { return menuRepository.findByCategory(category); } Tool(根据菜品ID查询菜品详细信息包括价格、辣度、剩余份数) public DishDetail getDishDetail( P(菜品ID) Long dishId) { return menuRepository.findById(dishId); } Tool(根据关键词搜索菜品支持模糊匹配菜名或配料) public ListDishBrief searchDishes( P(搜索关键词) String keyword, P(value 最多返回条数, defaultValue 5) Integer limit) { return menuRepository.search(keyword, limit); } }这里有几个细节值得展开。Tool注解里的 description 直接决定模型对该工具的认知。描述里最好说清楚这个工具是做什么的、什么场景用它、参数大概怎么填。模型选工具时本质上是拿用户的自然语言和你给出的工具描述做语义匹配。描述写得太笼统比如只写查询菜单模型可能在有更具体工具可用时仍然选错写得太啰嗦又会挤占上下文窗口。我一般控制在 20 到 50 个字把关键约束比如分类枚举、是否模糊匹配放进去。P注解的 description 同样重要。模型从用户的话里抽取参数值时就是靠这个描述来定位信息。举个例子用户说来一份辣一点的川菜如果你的参数描述是菜品ID模型会一脸茫然因为对话里没有 ID 信息如果描述是菜品分类如川菜、粤菜模型就能准确抽出川菜。参数描述本质上是给模型做的信息锚点。返回值这块我用的是DishBrief和DishDetail两个轻量 record而不是直接把 JPA 实体扔回去。原因有两个一是实体里常有createTime、updateTime、内部状态码这类对模型毫无意义的字段白白占用 token二是某些字段比如成本价、供应商联系方式一旦被模型拿到用户通过 prompt 注入可能套出来。工具返回值应当遵循最小暴露原则只返回模型组织答案时真正需要的信息。2.3 必填参数与可选参数的坑Java 方法签名里有必填和可选的天然区分基本类型int,long,boolean和final字段通常被视为必填而Integer、String这类包装类型或带默认值的方法LangChain4j 倾向于把它们判定为可选。我之前遇到过一个问题工具方法是searchDishes(String keyword, Integer limit)我没给limit加描述模型调用时就经常不传这个参数导致 NPE。后来改成P(value 最多返回条数, defaultValue 5) Integer limit问题立刻消失。还有一种情况参数确实是可选的但模型不如实传。比如查询接口支持status参数不传就默认查全部。模型可能自作聪明地往描述里没有的值上猜比如statusavailable结果查出来是空。我现在的习惯是所有可选参数都在P的 description 里写明不传默认XX并且方法内部做一次兜底null 或空字符串都落到默认行为。这样模型的自由度被约束在可控范围内。再补充一个返回值类型的经验如果工具方法的返回值结构复杂模型在组织自然语言时反而容易出错比如漏掉某个重要字段或者把数字格式转错。最稳妥的做法是返回一个结构扁平的 record或者干脆返回格式化好的 String。我在后面做 RAG 检索工具时就吃了这个亏后面章节会细说。3. AiServices 挂载工具与记忆让 Agent 真正拥有手脚和短期记忆3.1 手动拼 prompt 的老路与 AiServices 的代理机制有了Tool注解下一步是怎么把这些工具挂载到一个能对话的智能体上。老写法是每次调用模型时手动把工具列表塞进请求里然后自己处理 messages 的历史累积。代码会变成一团乱麻ListChatMessage messages new ArrayList(); messages.add(SystemMessage.from(你是订餐助手...)); messages.add(UserMessage.from(userInput)); // 还要自己维护历史、自己传工具定义、自己解析function call...LangChain4j 的AiServices解决的就是这个问题的抽象层。它的核心思路是你定义一个 Java 接口接口里的方法就是智能体对外暴露的对话入口AiServices.builder()接收这个接口返回一个代理实现。所有工具调用、记忆管理、上下文组装都在代理内部完成代码层面你只管调用接口方法。import dev.langchain4j.service.AiServices; import dev.langchain4j.service.SystemMessage; import dev.langchain4j.memory.chat.MessageWindowChatMemory; public interface Assistant { String chat(String userMessage); } // 构建智能体 Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(openAiChatModel) .tools(new MenuTools(menuRepository), new OrderTools(orderRepository)) .chatMemory(MessageWindowChatMemory.builder() .maxMessages(20) .build()) .build(); // 调用 String answer assistant.chat(今天有哪些川菜推荐一个辣的);这段代码背后发生了什么AiServices.builder()根据Assistant接口生成一个动态代理把chat()方法的调用包装成一次完整的 LLM 对话加载历史消息 → 加上 system prompt → 带上工具 schema → 发送模型 → 如果返回 function call 就执行工具 → 把结果回传模型 → 返回最终回复。同时chatMemory会自动把每一轮用户消息和 AI 回复追加到内存窗口中下一轮对话时自动带上。你会发现自己只写了一个接口和一个 builder剩下的事情全靠框架完成。这就是一个库打全套的第一个体验你不用关心 function call 的解析、消息历史的拼接、工具结果的回传这些全部是标准路径。3.2 记忆窗口不是越大越好MessageWindowChatMemory是 LangChain4j 内置的滑动窗口记忆实现按条数而不是 token 数截断。很多人上来就设maxMessages(100)觉得这样模型记得多。实际上窗口越大单次请求的 token 越多费用越高响应越慢更麻烦的是如果用户和 Agent 之间夹杂了多次工具调用这些工具调用产生的中间消息也会占用窗口。我个人的经验值纯对话类场景maxMessages(10)到20就够用需要连续多轮操作任务的场景比如先查询菜品、再下单、再确认配送时间可以放宽到30。超过这个量不如引入摘要记忆——把更早的历史用模型压缩成摘要塞进 system prompt而不是把原始消息全量带着。ChatMemory chatMemory MessageWindowChatMemory.builder() .maxMessages(20) .build();这里还有个小坑MessageWindowChatMemory是单实例内存态意味着如果你把它直接挂在单例 Assistant 上所有用户共享同一个对话历史。这在开发环境无所谓一旦上了生产就是灾难。用户 A 问完我要点宫保鸡丁用户 B 下一条消息里模型可能还记得 A 的上下文——因为 window 里存的是所有混合消息。解决办法放在第五章讲这里先记住结论生产环境必须按用户隔离记忆。3.3 工具之间怎么配合当 Agent 同时挂了多个工具对象时LangChain4j 会把所有对象里带Tool注解的方法全部提取出来合并成一个工具列表交给模型。模型在单轮对话里可以连续调用多个工具LangChain4j 会循环执行模型返回调用 → 执行工具 → 把结果回传 → 模型继续决策的过程直到模型认为信息足够、生成最终回复。实际跑下来最顺的场景是这样的用户我想吃辣的预算 30 以内帮我看看有什么推荐。模型内部决策过程大概会是这样调用searchDishes(keyword辣)拿到辣味菜品列表。调用getDishDetail(dishId...)查看其中几个菜的价格。比对价格和预算从结果中挑出合适的菜品。组合成推荐语回复用户。如果其中某个工具调用失败LangChain4j 默认会把异常信息回传给模型模型可能尝试换参数重试。这个机制有好有坏——好的一面是它能自愈比如用户没说清楚分类模型可以根据异常提示反问他坏的一面是异常信息如果包含敏感堆栈等于变相暴露内部结构。我建议工具方法内部做一层对外包装任何异常都转成用户能理解的中文描述不要把SQLException、NullPointerException这类堆栈直接抛给模型。4. 把 RAG 接进流水线多路召回与 Easy RAG 的落地姿势4.1 为什么单靠向量检索不够用Agent 只有工具和记忆还不够很多业务知识比如公司制度、产品文档、历史客服对话没法预先写死在 prompt 里得靠 RAG 检索。但RAG 检索四个字背后有无数细节其中最常见的问题是单路向量检索的召回质量不够稳定。举个例子。用户问公司年假怎么休如果 FAQ 库里正好有一条年假休假制度语义相似度很高向量检索能命中。但如果用户问得比较口语化比如我入职一年能休几天向量检索可能匹配不到精确答案只召回一些相关度一般的片段。这种时候如果能同时用关键词检索比如 BM25去抓年假休入职这些词再和向量召回结果合并去重整体命中率会明显提升。这就是关键词里出现多路召回的原因——不是炫技是被单路检索的召回率逼出来的。4.2 LangChain4j 的 RAG 组件拆解LangChain4j 的 RAG 能力不是一个大而全的模块而是由几个可替换的组件组合而成。核心包括EmbeddingModel负责把文本转成向量可以接 OpenAI 的 embedding 接口也可以用本地模型。EmbeddingStore向量数据库的抽象支持内存、PGVector、Redis、Milvus 等实现。ContentRetriever检索器负责根据用户查询从 EmbeddingStore 里召回相关内容。ContentAggregator/ContentInjector对召回结果做合并、压缩、注入 prompt 的环节。Easy RAG 的说法来自官方文档里的快速集成方式你把 EmbeddingStore、EmbeddingModel 配好用ContentRetriever绑定到 AiServices 上它就会自动走查询 → 向量召回 → 注入上下文 → 模型回答的标准链路。EmbeddingModel embeddingModel OpenAiEmbeddingModel.builder() .apiKey(apiKey) .modelName(text-embedding-3-small) .build(); EmbeddingStoreTextSegment embeddingStore InMemoryEmbeddingStore .fromJsonFile(embeddings.json); ContentRetriever retriever ContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(3) .minScore(0.5) .build(); Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .contentRetriever(retriever) .build();这段配置跑起来很容易但真正到生产环境你会发现一个问题默认的ContentRetriever只有一路向量召回没有关键词召回更没 rerank。如果知识库里条目很多、用户问题经常口语化召回质量就全靠向量模型的质量和minScore设得够不够低。设得太高召回为空设得太低又进来一堆噪声。4.3 自己实现一个 CompositeRetriever 做多路召回LangChain4j 的ContentRetriever是个接口意味着你可以自己实现把多路召回逻辑封装进去。我的做法是写一个CompositeRetriever内部同时持有向量检索器和 BM25 检索器召回后合并、去重、按融合分数排序。import dev.langchain4j.rag.content.Content; import dev.langchain4j.rag.content.retriever.ContentRetriever; import dev.langchain4j.rag.query.Query; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; public class CompositeRetriever implements ContentRetriever { private final ContentRetriever vectorRetriever; private final ContentRetriever keywordRetriever; public CompositeRetriever(ContentRetriever vectorRetriever, ContentRetriever keywordRetriever) { this.vectorRetriever vectorRetriever; this.keywordRetriever keywordRetriever; } Override public ListContent retrieve(Query query) { ListContent vectorHits vectorRetriever.retrieve(query); ListContent keywordHits keywordRetriever.retrieve(query); SetString seen new HashSet(); ListContent merged new ArrayList(); for (Content hit : vectorHits) { String key hit.textSegment().text(); if (seen.add(key)) { merged.add(hit); } } for (Content hit : keywordHits) { String key hit.textSegment().text(); if (seen.add(key)) { merged.add(hit); } } return merged; } }这个类的实现逻辑很简单先分别拿到两路结果按文本内容去重合并最后把合并后的列表交给模型。排序时向量检索的结果在前关键词检索的结果兜底在后这样既保留语义相关性高的片段又不会漏掉关键词硬命中的内容。BM25 那一路在 Java 生态里一般用 Lucene 或者 OpenSearch 的关键词查询来实现。如果你的知识库本身就存在 OpenSearch / Elasticsearch 里那这路召回直接查它的match查询就行不需要额外引入组件。多路召回的关键不在算法多高级而在多路覆盖不同的召回信号语义信号、关键词信号、有时还有用户画像或热门程度的信号。召回之后如果追求更高精度再接一个 rerank 模型把最终 top K 排得更准。4.4 RAG 的另一种姿势把检索器封装成 ToolLangChain4j 里接 RAG 有两种姿势。上面那种是ContentRetriever标准流程模型每次回答前自动检索、自动注入上下文。另一种是把检索能力封装成一个Tool方法让模型自己决定什么时候需要查知识库、查哪类知识库、查完还要不要继续追问。第二种姿势更适合知识库数量多、需要模型判断检索来源的场景。比如公司内部有制度库、产品手册、历史工单三个知识库模型每次回答前并不需要全查一遍而是根据用户问题判断该查哪个。这种按需检索用标准 ContentRetriever 很难实现但封装成工具就非常自然Tool(查询员工手册中关于年假、病假、调休等假期的规定) public String searchPolicy(String question) { ListContent hits policyRetriever.retrieve(Query.from(question)); return formatHits(hits); } Tool(查询产品使用手册中关于功能操作的问题) public String searchManual(String question) { ListContent hits manualRetriever.retrieve(Query.from(question)); return formatHits(hits); }模型拿到这些工具后会自动根据问题内容选择查哪个库。如果问题同时涉及制度和操作它甚至会把两个工具都调一遍再综合回答。这种RAG 工具化的思路比把所有内容塞进一个 EmbeddingStore 更可控也更适合企业里知识库本来就按域划分的场景。需要提醒的是工具化 RAG 的返回格式非常重要。formatHits里我会把每段内容加上来源文档名、章节标题、页码方便模型在回答时引用出处也方便前端展示来源链接。5. 多用户并发下的 Agent 沙盒记忆隔离与虚拟线程实践5.1 记忆串线是生产事故级的 bug前面提过MessageWindowChatMemory是单实例内存态直接共用会导致用户 A 和用户 B 的上下文搅在一起。这在 Agent 场景里比普通聊天更致命因为工具调用是有状态的——用户 A 让 Agent 查了菜品并下了单如果记忆里混入了用户 B 的消息模型可能误以为 B 也要下单或者把 A 的订单信息告诉 B。正确做法是按用户隔离记忆LangChain4j 提供了ChatMemoryProvider来做这件事。它本质上是一个工厂根据 memoryId 返回不同的 ChatMemory 实例import dev.langchain4j.memory.ChatMemory; import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.service.AiServices; import java.util.concurrent.ConcurrentHashMap; public class PerUserChatMemoryProvider implements ChatMemoryProvider { private final ConcurrentHashMapObject, ChatMemory memories new ConcurrentHashMap(); Override public ChatMemory get(Object memoryId) { return memories.computeIfAbsent(memoryId, id - MessageWindowChatMemory.builder() .maxMessages(20) .build()); } } Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .tools(menuTools, orderTools) .chatMemoryProvider(new PerUserChatMemoryProvider()) .build();调用时就要把用户 ID 传进去String reply assistant.chat(userId, 帮我看看有什么川菜);这里chat()方法是我在Assistant接口里额外声明的带 memoryId 的重载方法。AiServices能识别出带有MemoryId参数的方法签名public interface Assistant { String chat(MemoryId String userId, String userMessage); }MemoryId注解的作用是告诉 AiServices对话历史按这个参数的值来隔离。用户 ID 不同各用各的 ChatMemory 实例互不干扰。生产环境强烈建议用这个方案而不是自己写 ConcurrentHashMap 然后每次手动拼记忆。还有一个细节ConcurrentHashMap会一直持有所有用户的记忆用户量大了会占内存。需要给每个用户的记忆加过期清理比如基于 LRU 或者定时任务把超过 N 小时不活跃的用户记忆释放掉。这个不做的话跑几个月内存就爆了。5.2 虚拟线程处理高并发对话Java 21 之后处理大量并发对话用虚拟线程几乎是天然的选择。Agent 调用模型的耗时通常在几秒到几十秒期间线程大部分时间在等待网络 IO用平台线程扛会很快耗尽线程池。虚拟线程则完全没这个压力。ExecutorService executor Executors.newVirtualThreadPerTaskExecutor(); ListCompletableFutureString futures userIds.stream() .map(userId - CompletableFuture.supplyAsync( () - assistant.chat(userId, 帮我推荐一款适合下午茶的甜品), executor)) .toList(); ListString replies futures.stream() .map(CompletableFuture::join) .toList();这里要注意CompletableFuture的默认线程池是 ForkJoinPool.commonPool不适合做 IO 密集型任务所以一定要传自定义 executor。用虚拟线程的好处是每个用户请求都能分到一个轻量线程系统能同时处理的对话数大幅提升而不会像平台线程那样受限于几百个的线程数上限。不过虚拟线程并不是银弹。如果线程里跑的是 CPU 密集计算比如向量重排、本地 embedding虚拟线程反而可能因为竞争导致吞吐下降。这类任务应该丢到专门的固定线程池里。原则上IO 等待用虚拟线程CPU 计算用固定线程池。5.3 熔断、限流与超时兜底Agent 化之后你的服务对模型 API 的依赖会成倍增加。一次对话可能触发三四个工具调用每个工具调用又是一次模型请求。这意味着模型 API 的 rate limit 很快就可能被打爆。我实际处理方案分三层。第一层是应用层限流每个用户每分钟最多 N 次对话请求用令牌桶或简单计数器实现超出的请求返回请稍后再试。第二层是超时控制调用模型和工具的整个链路设置总超时比如 30 秒。超过就中断返回兜底话术暂时无法处理请稍后重试。第三层是熔断连续 N 次模型 API 调用失败打开熔断开关直接降级为不调用模型、返回固定提示避免雪崩。String reply; try { reply CompletableFuture.supplyAsync(() - assistant.chat(userId, prompt), executor) .get(30, TimeUnit.SECONDS); } catch (TimeoutException e) { reply 我的处理超时了请换一种问法试试。; } catch (Exception e) { reply 当前服务不稳定请稍后再试。; }顺便说一句工具调用的超时一样要控制。如果某个工具方法内部调外部接口很慢整个 Agent 响应会被拖死。我一般给每个工具方法内部加自己的超时比如数据库查询 3 秒超时、外部 HTTP 调用 5 秒超时宁可不查也不阻塞整个链路。5.4 工具权限的最小化原则Agent 有了工具就相当于有了手能直接操作系统、操作数据。如果工具权限设计得不好用户完全可以通过 prompt 注入让 Agent 帮他执行非授权操作。比如订餐系统里普通员工如果能让 Agent 调用修改菜品价格的工具那事情就大了。我习惯在每个工具方法上加断言判断当前调用方是否有权限。userId从MemoryId参数进来工具方法里再根据 userId 查角色权限不够直接拒绝。不要相信模型帮你做权限判断——模型只会根据对话内容决定调不调工具它不负责鉴权。工具执行前的权限校验必须落在你的代码里这是不能省的安全底线。另一个容易被忽略的点是不要把删除类、修改类工具和查询类工具混在一个对象里交给模型。模型对删除订单和查询订单的边界判断并不总是可靠的。更稳妥的做法是不同角色的用户挂载不同的工具集合员工端 Agent 只挂查询工具管理员端 Agent 才挂修改工具。这样即使模型判断失误底层也没有执行路径。6. 从单 Agent 到流水线多个 AiServices 是怎么协作出一整套业务流程的6.1 流水线不等于一个 Agent 干所有事很多人以为Agent 流水线就是把一个 Agent 做得无比强大什么工具都挂上什么问题都能解决。实际开发中这恰恰是灾难的开始。工具一多模型的选择空间就大误选、漏选、来回试探的概率跟着飙升。更麻烦的是如果你的业务流程有严格的先后顺序比如先查库存、再锁库存、再下单、再通知仓库让模型自己控制顺序根本不可靠它可能跳过某一步。流水线在这里的正确理解是把一次复杂的用户请求拆分成多个阶段每个阶段由一个职责单一的 Agent 处理阶段之间的流转由 Java 代码控制而不是让 LLM 自由发挥。模型只负责它擅长的事——理解意图、抽取信息、生成语言流程控制、状态流转、数据一致性由你的代码保证。6.2 一个完整的订餐流水线拆解我用订餐场景做一个流水线的例子。整个流程分四个阶段意图识别 Agent判断用户是想查看菜单、下单、还是催单。信息收集 Agent从对话中抽取菜品、数量、配送地址等结构化信息。订单执行服务调用业务接口完成下单、扣库存不需要 LLM 参与。结果回复 Agent把订单结果转成自然语言反馈给用户。第一阶段和第二阶段的 Agent 可以共享同一个大模型但挂载的工具完全不同。意图识别 Agent 甚至可以不挂工具因为它的输出就是分类标签。// 第一阶段意图识别 public enum IntentType { QUERY_MENU, PLACE_ORDER, CHECK_ORDER, CHITCHAT } public interface IntentAgent { SystemMessage(你是一个意图识别器。只输出以下枚举值之一QUERY_MENU、PLACE_ORDER、CHECK_ORDER、CHITCHAT。不要输出其他内容。) IntentType classify(String userMessage); }这里有个技巧让模型输出枚举值时直接把枚举名写死在 system prompt 里并要求只输出枚举值。LangChain4j 会尝试把模型输出映射为枚举实例如果模型不按规矩输出可以用UserMessage加 few-shot 示例来约束。public interface IntentAgent { SystemMessage(...) UserMessage( 用户说我要一份宫保鸡丁少辣。 意图PLACE_ORDER 用户说今天有什么汤 意图QUERY_MENU 用户说{{userMessage}} 意图) IntentType classify(UserMessage String userMessage); }放在UserMessage里的模板会提示模型按照示例格式输出实际效果比单纯在 system prompt 里强调只输出枚举稳定很多。这个模式我在多个项目里用过准确率通常在九成以上。第二阶段的信息收集 Agent 挂的工具主要是菜单查询和订单模板校验。它要做的事是从用户语句里抽出菜品名、数量、备注必要时调用工具确认菜品是否存在、是否有货。然后流程进入第三阶段这一步是纯代码// 第三阶段业务执行不经过LLM OrderDraft draft OrderDraft.builder() .dishName(intentResult.getDishName()) .quantity(intentResult.getQuantity()) .note(intentResult.getNote()) .build(); OrderResult result orderService.createOrder(draft);最后第四阶段把OrderResult交给一个结果回复 Agent让它基于结构化结果生成一段自然语言回复比如您的宫保鸡丁已下单预计 30 分钟送达订单号是 20250312xxxx。6.3 编排器是流水线的骨架四个阶段串起来需要一个编排器。它的职责是接收用户消息 → 调意图识别 → 按意图走不同分支 → 收集信息 → 执行业务 → 生成回复。整个过程用 Java 代码写清楚LLM 不参与流程决策。public class OrderPipeline { private final IntentAgent intentAgent; private final InformationAgent infoAgent; private final OrderService orderService; private final ReplyAgent replyAgent; public String execute(String userId, String userMessage) { IntentType intent intentAgent.classify(userMessage); return switch (intent) { case QUERY_MENU - handleQueryMenu(userId, userMessage); case PLACE_ORDER - handlePlaceOrder(userId, userMessage); case CHECK_ORDER - handleCheckOrder(userId, userMessage); case CHITCHAT - 我是订餐助手可以帮您查菜单、下单、查订单。; }; } private String handlePlaceOrder(String userId, String userMessage) { OrderDraft draft infoAgent.extractOrder(userMessage); OrderResult result orderService.createOrder(draft); return replyAgent.reply(userId, result); } }这个编排器看起来很朴素但它保证了业务流的确定性用户说下单代码一定走收集信息 → 创建订单 → 回复这条链路中间不会因为模型的自由发挥而跳出流程。LLM 的灵活性集中体现在意图识别和信息抽取两个点上恰恰是它擅长的部分。我见过一些团队一上来就追求Agent 自主规划全流程结果上线后经常出现模型跳过支付验证、漏掉库存检查这类问题。把稳定部分放在代码里、语义理解部分留给模型是我踩坑后的核心结论。所谓Harness和Agent之争也在于此Agent 负责感知和决策Harness 提供执行环境和流程边界LangChain4j 里没有一个完整的 Harness 抽象但用 Java 编排器完全可以实现同样的效果。6.4 流水线里的状态管理多 Agent 协作时状态管理需要注意。每个 Agent 是一个独立的 AiServices 实例它们之间不共享对话记忆。如果第二阶段的信息收集 Agent 需要知道第一阶段已经识别出用户想下单就得把上下文通过参数传过去而不是指望它记得上一轮。我的做法是定义统一的上下文对象在流水线各阶段之间传递public class OrderContext { private String userId; private String originalMessage; private IntentType intent; private OrderDraft draft; private OrderResult result; }OrderContext在编排器里创建每个阶段从里面取需要的输入、写入自己的输出。这样做的好处是每个 Agent 保持无状态流水线天然可重跑、可观测、方便加日志和 trace。如果你想做更复杂的图编排一个 Agent 的结果分发给另外两个 Agent也能在这个基础上扩展只是别让 Agent 之间直接互相调用容易把依赖搞成乱麻。7. 上线前必须避开的 5 个坑以及我是怎么修的7.1 工具方法名冲突模型选错工具当工具数量超过 10 个模型偶尔会搞混名字相近的工具。比如getOrderStatus和getOrderDetail模型可能用错参数或选错工具。规避办法是命名时加上业务前缀比如order_status、order_detail别用太泛的query、get。同时把工具描述写得差异化让模型能通过描述精确区分。7.2 工具返回了大量无关字段把上下文撑爆第一次把 JPA 实体直接作为工具返回值的开发几乎都会踩这个坑。实体有二十多个字段模型一遍遍把全部字段读进来几分钟对话就把上下文窗口塞满了。我现在的规范化做法是为每个工具定义专用的 record 作为返回类型只保留模型需要的最小字段如果数据量很大优先返回摘要而非明细让模型在需要时再通过另一个工具查详情。这既能省 token也能避免无关数据泄露。7.3 模型把工具异常信息原样读走前面提到工具方法内部异常会回传给模型。有次我的某个 Agent 工具直接抛出了数据库连接异常的原生堆栈模型在回复时居然把连接池参数也复述了出来。从那以后所有工具方法出口统一 try-catch对外只暴露查询失败请稍后重试或菜品不存在请更换名称这类中性描述。内部堆栈只进日志不进对话上下文。7.4 记忆窗口和系统提示词互相挤占MessageWindowChatMemory的窗口条数上限包含系统消息和工具调用产生的中间消息。如果 system prompt 很长再加上几轮工具调用模型实际能看到的用户历史对话可能很少。排查手段是打开 LangChain4j 的请求日志看每次请求实际发送的消息条数和 token 数。如果发现工具调用消息占了大部分窗口可以把工具调用结果转成精简摘要再放回上下文。7.5 并发场景的共享状态污染除了用户记忆要隔离工具对象本身也要注意线程安全。如果你的工具类里有SimpleDateFormat或非线程安全的缓存字段在虚拟线程并发调用下会出各种诡异问题。我习惯让工具类保持无状态所有依赖通过构造器注入方法内部只操作局部变量。有状态的地方比如用户临时购物车显式放到 ConcurrentHashMap 之类并发容器里按 userId 隔离。8. 最后说点实在话LangChain4j 给我的感觉是Java 生态里终于有一套能认真做 Agent 的框架了。它不完美API 还在快速演化部分模块比如复杂编排、Graph 流控需要自己用代码补齐但核心链路确实做到了一个库覆盖模型接入、工具调用、记忆管理、RAG、多轮对话到我这篇文章里的多 Agent 流水线没有引入第二个重量级框架。如果你正准备从零开始做 Agent 化改造我建议按这个顺序落地先只写 2 到 3 个Tool用AiServices跑通单 Agent 对话然后加上记忆、按用户隔离再往前走再接 RAG先单路召回跑通再逐步加多路和 rerank最后才考虑拆流水线。步子不迈太大每层都验证稳定了再往上叠。这套路线我实际走下来比一上来就想搞全自动多 Agent 编排靠谱得多。