Java AI Agent开发实战:LangChain4j工具调用与多路召回 聊到 AI Agent 开发Java 程序员这几年的心情基本是又酸又急。Python 生态里 LangChain、LlamaIndex 和各类 Agent 框架满天飞落到 JVM 上能打的却少得可怜。直到 LangChain4j 出现我才觉得这块拼图终于补上了——从最基础的 Tool 工具调用到带记忆、带多路召回、带工具链编排的完整 Agent 流水线一个库就能串起来而且写起来还是我们熟悉的那套 Java 声明式风格。这篇文章不是入门科普也不会把 README 抄一遍。我假定你已经跑通过一个最简单的模型对话调用咱们直接聊进阶Tool 怎么写才不会被 LLM 乱用、Agent 决策循环内部发生了什么、多路召回流水线怎么和工具链配合最后用一个能跑起来的客服 Agent Demo 收尾。1. 先说到底LangChain4j 凭什么一个库打全套1.1 Java 生态在 AI Agent 上的尴尬和 LangChain4j 的解法过去几年很多 Java 团队做 LLM 应用第一步都是写个 Prompt 调 API这步没问题。问题出在第二步当你想做 Agent——让模型自己决定调用哪些工具、查哪些资料、循环推理、逐步收敛答案——你在 JVM 生态里几乎是裸奔。Spring AI 早期版本不成熟LangChain 的官方 Java 移植长期不见踪影于是大量团队自研了一套伪 Agent把流程写死逐段拼接 Prompt 和接口调用。那东西看着像流水线实际是木偶戏换个问法就断线。LangChain4j 做的是把 LLM 应用开发里最高频的几件事——模型接入、工具调用、记忆管理、检索增强、Agent 编排——统一收口成一套 Java 原生的注解和 builder API。它不是给 Python 生态套一层 Jython 壳而是直接用 Java 的类型系统和反射机制实现了 function calling 协议。这带来一个很实际的好处你的工具方法就是普通 class 里的普通方法加个 Tool 注解就能被 LLM 调度你的知识库检索就是实现一个 ContentRetriever 接口你的会话记忆就是往 builder 里传一个 ChatMemory。整套东西跟你写 Spring Bean、写 Mapper 的思维习惯完全一致Java 老兵上手成本极低。说一个库打全套必须把边界讲清楚。LangChain4j 解决的是从模型 API 调用到Agent 应用这一层的编排问题它不管模型训练不管向量库集群运维也不负责设计你的业务 Prompt。它就是那台把零件组装成流水线的机床零件本身还是得你自己造。选型上顺带说一句Spring AI 现在也在追赶如果你的项目重度依赖 Spring Boot它同样值得评估LangChain4j 的差异点在于工具调用和 RAG 管线设计更贴近 LangChain 的模型复杂编排时调整空间更大。Google ADK 也出了 JVM 版但太新社区资料一时半会儿追不上。就目前生态完整度而言做技术验证和小规模上线LangChain4j 是最省事的选择文档全、样例多、踩坑帖也多。1.2 这套流水线和 CI/CD 流水线不是一回事Agent 流水线这个词容易让人联想到 Jenkins 里那个 stage 串 stage 的构建管道其实两码事。CI/CD 流水线是确定性的蓝绿发布走到哪一步必然是哪一步Agent 流水线是概率性的模型每一步都可能做出不同决策。你搭的不是固定管道而是一个带分支决策的循环执行器。LangChain4j 的 AiServices 底层就在跑这样一个循环LLM 判断是否调用工具框架执行工具把结果拼回对话上下文再交给 LLM 继续判断——直到它认为信息足够给出最终答案。这个认知一旦建立再看它的记忆、RAG、多路召回这些模块就全都能串起来了它们本质上都是往这个循环里塞更高质量的上下文。这篇文章的主线就是沿着这条链路往下走先拆 Tool 注解的细节再看 Agent 循环怎么运转接着把检索增强和多路召回以流水线方式编排进去最后落到记忆、并发和生产化落地。以我写这篇文章时的 0.36.x 版本为例个别 API 后续可能有调整但设计思想是稳定的照着这个思路迁移到新版本不会太痛。2. Tool 深水区先会写工具再谈 Agent2.1 Tool 的工作原理方法签名如何变成 LLM 的 JSON SchemaTool 注解加在方法上LangChain4j 会在构建 AiServices 时扫描这些方法通过反射提取方法名、参数列表、参数类型和注解里的描述把它们翻译成 OpenAI 兼容的 tools 协议格式本质上就是一份 JSON Schema。模型收到用户消息后如果判断需要某个工具就在响应里输出 tool_calls 请求带上工具名和符合 Schema 的 JSON 参数。框架解析这段 JSON调用对应方法再把返回值作为一条消息追加进对话继续交给模型走下一轮。整个过程对业务代码完全透明你只需要关心方法写得好不好。正因为这种工作机制我见过很多人把 Tool 当成增强版 Swagger 来写。Swagger 是给前端看的写个查询订单足够Tool 是给 LLM 看的差一个词它就可能选错工具。比如你有两个工具一个叫查询订单基本信息一个叫查询订单物流轨迹描述里如果都泛泛写成根据订单号查询订单信息模型大概率随机选一个售后客服就会莫名收到物流查询请求来追问退款政策。正确写法是把职责边界写透public class OrderService { Tool(查询订单基本信息商品、金额、状态、下单时间适用于售后和客服场景) public OrderInfo getOrderInfo(ToolParam(订单号例如 SO20250101001) String orderNo) { return orderRepository.findByOrderNo(orderNo); } Tool(查询订单物流轨迹快递公司、揽收时间、最新节点仅用于物流咨询) public ListLogisticsNode getLogistics(ToolParam(订单号例如 SO20250101001) String orderNo) { return logisticsClient.query(orderNo); } }这两个方法签名几乎一样但描述把职责边界划得清清楚楚。模型面对我耳机现在到哪了和我要退货订单号给你时一眼能分清该调哪个。工具描述里最好交代三件事工具负责什么、什么时候该用它、参数的单位或格式是什么。2.2 ToolParam 是容易被忽略的细节很多人的工具参数只写了类型和变量名觉得够了其实不够。模型理解参数完全依赖它看见的文本描述Java 那种 shortName 的命名风格落到 JSON Schema 里就是裸的 shortName模型猜参数全靠上下文。把单位、枚举值、样例都写进 ToolParam比如金额单位分非元状态码1 待支付 2 已支付 3 已关闭模型才能准确填参。这背后有一次印象深刻的教训。一个工具方法里的日期参数没写格式不同模型服务商给出的值五花八门2025-1-72025/01/0720250107全出现过框架解析 LocalDate 时直接报错客服链路当晚变成 P0。后来我把所有日期参数统一标注ISO 8601 格式例如 2025-01-07再没出现过。不要觉得字段名足够自解释就能省LLM 的自解释和人的自解释完全不是一个标准。2.3 复杂参数类型四个常见的坑写工具方法时参数类型越简单越好。下面四个问题我在不同项目里都实打实遇到过数字精度漂移。LLM 生成的 JSON 里42 和 42.0 都可能出现。方法参数声明成 int 问题不大但如果你图省事写成 long 或 Integer某些模型会把整数输出成浮点格式框架反序列化时类型不匹配就会抛异常。工具参数统一用 int、long、String、boolean 这种结算明确的类型别给模型留解释空间。枚举值失控。方法参数用了自定义枚举但枚举值在模型眼里只是几个字符串。只要 Prompt 里出现过别的叫法它就可能传一个不存在的值。要么在 ToolParam 里把合法值完整列出要么在方法入口做兜底转换非法值统一返回一段固定错误提示让模型自己反思。嵌套 POJO 参数。复杂嵌套对象生成 JSON Schema 没问题但模型经常漏填内部字段或填成 null排查起来很痛苦。我的习惯是工具参数一律拉平——需要三个字段就传三个 String/int 参数方法内部自己组装对象。对模型来说扁平参数比嵌套结构更容易编造正确值。不支持的参数结构。如果你声明了 varargs、泛型通配符、接口类型这类参数生成的 Schema 可能不符合目标模型的协议要求运行时你会看到类似 llm request failed: provider rejected the request schema or tool payload 的报错。这种错误基本等于告诉你工具 Schema 有问题第一时间检查参数声明不要怀疑网络。提示Tool 方法是给模型看的 API它的可预测性比你的 Java 代码整洁度重要得多。3. Agent 不是调一次工具是循环决策3.1 单轮 function calling 和 Agent 循环的本质区别只调一次工具、拿结果回答问题那叫 function calling不叫 Agent。真正的 Agent 核心在循环一次工具调用的结果可能触发下一次工具调用。典型例子客服场景用户说我要退昨天买的那副耳机但是找不到订单页面了。这时模型需要先查用户身份再查订单列表找到那副耳机查退货政策比对是否满足条件最后才能给出结论。每一步都可能需要新工具而且后一步依赖前一步的结果——这就是决策循环。很多团队的第一版 Agent 就是单轮工具调用 固定 Prompt 模板结果场景稍微复杂一点就答非所问因为模型拿不到中间推理的上下文只能靠猜。LangChain4j 的 AiServices 把循环替你管理好了它记录每一轮工具调用的入参和返回值把它们作为对话消息继续参与后续推理直到模型认为信息足够或达到安全上限。你要做的只是定义好工具和接口循环内部的事框架处理。3.2 AiServices 帮你搭好的决策循环具体代码比想象中简单public interface RefundAssistant { String chat(String userMessage); } RefundAssistant assistant AiServices.builder(RefundAssistant.class) .chatLanguageModel(model) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .tools(new OrderService(), new RefundPolicyTool()) .build(); String reply assistant.chat(我要退昨天买的耳机订单号找不到了);最反直觉的地方是你定义了一个接口 RefundAssistant从没手写实现类跑起来居然能用。原理是 AiServices 在启动时用动态代理生成实现把接到的用户消息交给内部循环处理器处理器去调用模型、执行工具、维护记忆最后把模型生成的最终回复以 String 返回。这种设计的好处是业务侧拿到的只是普通接口想 mock、想替换、想单测都容易。但要清醒认识循环能力的代价工具返回值一旦很长每一轮循环都会吃掉大量 token。工具返回几千字时20 轮循环能烧掉一整页对话预算。所以设计工具时必须精简返回值——返回是否可退、原因是什么这种结论型信息不要返回完整订单 JSON。模型真正需要的往往是判断依据不是全部原始字段。提示框架内部对循环次数有上限保护但别依赖它。如果每次对话都是触顶退出核心原因通常是工具描述模糊而不是上限太小。3.3 循环失控卡死、空转与结果冲突循环执行器跑起来之后真正麻烦的往往不是不调用工具而是循环失控。我把高频现象和处置方式整理成表格方便直接对照症状根因处置模型反复调用同一个工具拿到同样结果还继续问工具描述没说明查不到时怎么办模型以为没拿到答案在描述里写明查不到时返回 NOT_FOUND方法对未命中返回明确文案模型绕开工具直接编造订单号往下答Prompt 里缺少必须用工具验证再回答的约束在 system prompt 强约束并提供一个无权限/无法查询的兜底工具两个工具都能查到订单结果互相冲突数据口径孪生收敛数据源或让其中一个工具在描述里声明以本工具为准整段对话在多个工具之间来回倒腾出不来工具边界模糊决策空间太大合并工具职责缩小每个工具描述里的适用范围循环出不来这事真不全怪模型。很多时候是工具设计者给了模型太多自由选择边界模糊模型就在模糊地带原地转圈。把工具看成接口契约描述写严谨循环自然稳定。4. 把流水线串起来RAG 多路召回 工具链协同4.1 多路召回向量、关键词、结构化查询三路并进先解释多路召回这个热词。它来自搜索推荐领域意思是同一轮查询用多个检索策略并行找候选再汇总去重、融合排序。放到 LangChain4j 流水线里常见的组合是向量检索负责语义相关性关键词检索负责精确匹配结构化查询通过工具调数据库或接口负责拿到实时事实。三者召回的内容性质不同相互之间无法替代。举个例子用户问WH-1000XM5 在 2024 年 12 月买的话降噪失效能不能换新。这里有三层信息需求WH-1000XM5是产品型号需要关键词或向量检索从知识库找到对应保修政策2024 年 12 月买是时间类事实向量检索很难精确理解需要订单工具查库确认购买日期降噪失效能不能换新是规则判断需要对比政策条款。任何一个单路检索策略都覆盖不全这就是多路召回的价值。4.2 RetrievalAugmentor 编排查询改写、召回、重排LangChain4j 里实现多路召回核心是构造一个 RetrievalAugmentor 传给 AiServices。它编排三个阶段查询改写、多个 ContentRetriever 并行召回、ContentAggregator 汇总重排ListContentRetriever retrievers List.of( EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(3) .build(), new KeywordRetriever(keywordStore) // 自研的精确匹配召回实现 ); RetrievalAugmentor augmentor DefaultRetrievalAugmentor.builder() .queryTransformer(new CompressingQueryTransformer(chatModel)) .contentRetrievers(retrievers) .contentAggregator(new ReRankingContentAggregator(reRanker, 3)) .build(); Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .retrievalAugmentor(augmentor) .tools(new OrderService()) .build();逐个解释。CompressingQueryTransformer 的作用是把多轮对话里的指代消解成独立查询。用户上一句说WH-1000XM5下一句问它的保修政策呢会被改写成WH-1000XM5 的保修政策是什么否则后续召回的 Query 全是噪音。EmbeddingStoreContentRetriever 做语义召回取 Top 3。KeywordRetriever 是自写的精确召回专门命中型号、编号这类向量模型不擅长的内容。最后 ReRankingContentAggregator 把两路召回候选合并用重排模型按相关度重新打分只保留 Top 3 注入上下文。这套跑下来既懂语义又能抓到精确字段。需要注意的是KeywordRetriever 这类实现官方仓库里并没有开箱即用的大路货很多项目是自己写一个或者借助外部检索引擎。你不一定照抄重点是理解结构多个 retriever 并排注册最终由一个 aggregator 收敛。如果你的场景只是纯知识库问答单个 EmbeddingStoreContentRetriever 配上 maxResults 就够了别为了炫技硬上五路召回——流水线的每一点复杂度都必须用实际收益来对冲。4.3 一个售后场景工具和知识库是怎么配合的回到降噪失效能不能换新的提问完整流水线的实际执行顺序是查询改写把用户问题处理成不依赖上下文的独立查询。多路召回向量路召回耳机保修政策、换新条件相关知识条目关键词路精确命中WH-1000XM5条款。重排聚合选出最相关的 3 条政策注入上下文。LLM 决策看到政策提到购买 30 天内质量问题可换新于是调用 OrderService 的 getOrderInfo 工具确认购买时间。工具结果回填订单显示购买于 2024 年 12 月 20 日模型对比当前日期判断是否在窗口内。最终回答给出可以/不可以换新的结论和原因。这里工具和 RAG 的关系值得强调RAG 提供静态知识工具提供动态事实Agent 循环里模型自己决定什么时候取知识、什么时候查事实。很多教程把 RAG 和 Agent 当成两条路线其实在 LangChain4j 里它们就是同一套流水线的两个进料口都由 AiServices 统一调度。5. 生产化绕不开的记忆、并发、成本三板斧5.1 记忆不是聊天记录而是 Agent 的上下文预算Demo 阶段用 MessageWindowChatMemory.withMaxMessages(20) 完全没问题生产环境就要把它当成上下文预算管理来设计。原因是工具调用会在对话里插入大量中间消息——一条订单 JSON 可能几百 token20 条消息窗口也许只够三四轮工具循环Agent 很快就失忆了用户明明刚报过订单号下一句它又问一遍。解决方向有三个。第一加大窗口但 token 成本线性上涨。第二滑动窗口配合摘要记忆把早期对话压缩成摘要保留LangChain4j 有一套内置机制可以基于模型生成会话摘要。第三做外部记忆把订单号、用户 ID 这类关键实体抽出来存 Redis需要时通过工具读回来。ChatMemoryStore 接口就是留给持久化的口子官方有内存实现你也可以接 Redis 或者 MySQL接口契约只有几个方法落地成本很低。我在生产项目里更推荐第三种思路不是把所有消息都塞给模型而是让 Agent 需要时想起来关键信息。这和系统设计里的按需加载是同一个道理用最少的 token 承载最关键的上下文。5.2 并发怎么扛LangChain4j 只给了地基AI Agent 怎么扛并发是社区高频问题先泼一盆冷水LangChain4j 本身没有集群、没有 worker 池、没有消息队列它是库不是平台。并发能力完全取决于你怎么用它。我实践下来的经验有三条。第一模型客户端可以共享。OpenAI 兼容协议类的 ChatLanguageModel 实现内部持有连接池线程安全一个实例多线程并发调用没问题。不要为每个请求 new 一个模型客户端既浪费连接又容易击穿连接池。第二服务层按请求隔离状态。AiServices 分两种情况不传 chatMemory 时每次调用是独立会话传了内部 chatMemory 就默认持有状态。并发场景必须想清楚单用户单会话的客服 Agent 要按用户维度持有独立的 memory 实例别共享一个否则用户 A 会看到用户 B 的上下文。更稳的做法是把记忆存外部 store按会话 ID 读取服务本身做成无状态。第三外部依赖限流和降级。Agent 一次回答可能串行调 3 次模型、2 次工具单请求 RT 十几秒很正常并发一上来模型服务商的限流会先炸。要么自己做令牌桶限流要么在超时和重试参数上做收敛再配合队列削峰。技术选型上有个便宜可以占Java 21 的虚拟线程在 Agent 场景非常好用。工具调用大多是 IO 等待几百个虚拟线程挂起等模型响应压力远小于同数量级的平台线程。如果你还在用老线程池跑 Agent 服务建议尽早迁移试试。5.3 可观测性工具调用链不打印你根本不知道 Agent 在干嘛Agent 应用最难排查的问题永远是模型为什么这么做。网上大量求助帖没有日志全靠猜。LangChain4j 的模型客户端提供了日志开关调试期务必打开OpenAiChatModel.builder() .apiKey(key) .modelName(gpt-4o-mini) .logRequests(true) .logResponses(true) .build();但只有模型日志还不够建议在工具方法入口和出口都打业务日志记录入参、出参、耗时。这样一条用户提问你能串出完整链路模型调了哪个工具、工具返回了什么、模型基于什么下了结论。谁在编数据、谁在乱调工具一目了然。成本同样要盯紧工具返回值越大上下文越长单请求 token 花费会指数级上涨。上线前用典型场景跑一遍 token 账单做到心里有数。6. 完整 Demo从零组装一个客服 Agent6.1 工程骨架与依赖用一个标准 Maven 工程JDK 17 起步核心依赖两个langchain4j 主体和一个模型接入模块。dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version0.36.2/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version0.36.2/version /dependency如果不想依赖外部商业接口可以把第二个依赖换成 langchain4j-ollama模型配置部分改成 OllamaChatModel.builder().baseUrl(http://localhost:11434).modelName(qwen2.5:7b) 这种写法主线代码完全不用动。不过要提前说一下7B 级别小模型的工具调用稳定性偏差Demo 能跑通但别拿它当生产标准。6.2 核心代码拆解先定义模型客户端温度调低保证输出稳定ChatLanguageModel model OpenAiChatModel.builder() .apiKey(sk-xxx) .modelName(gpt-4o-mini) .temperature(0.2) .timeout(Duration.ofSeconds(60)) .logRequests(true) .logResponses(true) .build();再定义两个工具订单查询和退货政策查询。public record OrderInfo(String productName, long amountCents, String status, String orderDate, String signDate) { } public class OrderService { Tool(查询订单信息商品、金额、状态、下单时间。调用前先确认用户提供了订单号) public OrderInfo getOrderInfo(ToolParam(订单号例如 SO20250101001) String orderNo) { if (SO20250101001.equals(orderNo)) { return new OrderInfo(WH-1000XM5, 189900, 已签收, 2024-12-20, 2024-12-22); } return null; } } public class RefundPolicyTool { Tool(查询售后退货政策返回退货条件的文字说明) public String getRefundPolicy(ToolParam(商品类目例如耳机、手机、笔记本) String category) { if (耳机.equals(category)) { return 耳机类商品自签收之日起 30 天内非人为损坏可申请换新退货运费由平台承担。; } return 请以订单详情页售后标签为准。; } }然后定义 Assistant 接口并装配public interface CustomerServiceAgent { String chat(String userMessage); } CustomerServiceAgent agent AiServices.builder(CustomerServiceAgent.class) .chatLanguageModel(model) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .tools(new OrderService(), new RefundPolicyTool()) .build(); String reply agent.chat(我想退耳机订单号 SO20250101001能退吗); System.out.println(reply);跑起来后模型大概的决策路径是先调 RefundPolicyTool 查耳机退货政策再调 OrderService 查订单签收时间比对 30 天窗口最后给出结论。日志里你能清楚看到哪一步调了哪个工具、返回了什么、最终回答是什么。6.3 三个实际踩过的坑这套 Demo 我复现过不止一次有三个坑几乎人人都会踩到。第一个坑是金额单位的错位。工具里我用 189900 表示 1899 元单位是分但模型并不认识这个约定回答时把金额读成 189900 元逻辑判断全乱。后来我在 ToolParam 里写明金额单位分同时把最终回复要展示的金额直接格式化后放进工具返回值不让模型自己去换算。第二个坑是小模型根本不调用工具。用 Ollama 跑 7B 级别模型时模型对 tool_calls 协议支持不完整典型表现是答非所问或者直接编数据。这通常不是 LangChain4j 的锅而是模型能力边界。排查方法很干脆写一个最小测试强制模型必须调用工具才能回答如果这个测试都过不了换更大参数模型或换一家对 function calling 支持良好的服务商别在框架层无限排查。第三个坑是记忆窗口和工具结果相互吞噬。一开始我设置 maxMessages(10)用户多聊几句后模型就忘了自己刚查过的订单号开始反向追问用户。加大窗口不是万能的日志里看到上下文被工具长文本塞满时要做两件事把工具返回压缩成结论一句话 关键字段的形式同时加大窗口或接入外部记忆。我把 OrderInfo 的返回从完整 JSON 改成精简摘要后同样窗口下能容纳的工具轮次直接翻了一倍。最后分享一个个人使用习惯任何 Agent 功能上线前先准备 20 到 30 条覆盖刁钻场景的回归用例用固定断言跑通工具调用路径。模型在迭代、Prompt 在调整不能指望这次调通了就永远通。把工具定义看成接口契约把 Agent 行为看成契约实现持续回归才是 LangChain4j 项目能稳定上线的底气。