ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

LangChain4j从@Tool到Agent流水线:RAG与多路召回实战

2026/10/7 5:19:51 拓冰建站 浏览量
LangChain4j从@Tool到Agent流水线:RAG与多路召回实战 1. 从单个工具到流水线为什么需要重新理解 LangChain4j 的定位很多人第一次接触 LangChain4j都是从Tool注解开始的。写一个 Java 方法加个注解注册到AiServices里模型就能调用它了。这个体验确实很爽几行代码就能让大模型帮你查天气、算汇率、读数据库。但如果你只停在这一步那基本上等于买了一台工作站只用来扫雷。我刚开始用 LangChain4j 的时候也是这样觉得Tool就是全部。直到项目里需要做一个能自主规划、多步推理、动态调用外部知识的 Agent才发现单个工具调用根本撑不起这个场景。模型需要记住上下文、需要决定什么时候调用哪个工具、需要在多个工具之间传递结果、需要在失败时重试或者换路径。这些需求叠加在一起就逼着你从“写一个工具”升级到“设计一条流水线”。LangChain4j 这个库的野心其实很大。它不只是给你一个Tool注解而是提供了一整套从底层模型接入、到工具注册、到 Agent 编排、再到 RAG 知识增强的完整能力。你可以只用其中一层也可以把整条链路串起来。问题在于官方文档和大部分教程都是按模块拆开讲的很少有人告诉你这些模块怎么组合成一个真正能跑的生产级 Agent。这篇文章就是来解决这个问题的。我会从Tool这个最基础的入口开始一步步拆解怎么把它扩展成 Agent 流水线中间会涉及工具注册的底层机制、Agent 的编排策略、RAG 的接入时机、多路召回的设计以及实际落地时踩过的坑。适合已经用过 LangChain4j 基础功能、想往 Agent 方向进阶的 Java 开发者也适合正在选型 Agent 框架、想了解 LangChain4j 到底能做什么的技术负责人。2. 工具调用的本质Tool 注解背后发生了什么2.1 从注解到可执行工具注册与描述生成Tool这个注解看起来简单但它做的事情比你想的多。当你把一个方法标记为Tool并注册到AiServices时LangChain4j 会在底层做三件事解析方法签名、生成工具描述、建立调用映射。解析方法签名的时候它会提取方法名、参数类型、参数名、返回值类型。这些信息决定了模型能不能正确理解这个工具是干什么的。比如你写了一个getWeather(String city)模型看到的就是一个接收城市名、返回天气信息的工具。但如果你写的是getData(String input)模型就懵了它不知道这个input应该传什么。生成工具描述的时候LangChain4j 会优先使用Tool注解里的value字段作为工具描述。如果你没写它就会用方法名来凑。这里有个很关键的细节工具描述的质量直接决定了模型调用工具的准确率。我实测下来一个描述清晰的工具调用准确率能比描述模糊的高出 30% 以上。// 描述模糊模型容易调错 Tool(获取数据) public String getData(String input) { ... } // 描述清晰模型知道什么时候该用 Tool(根据城市名称查询当前天气状况返回温度和天气描述。输入参数为城市中文名如北京) public String getWeather(String city) { ... }建立调用映射的时候LangChain4j 会用反射把模型返回的工具调用请求映射到具体的方法执行。这个过程涉及参数的反序列化如果参数类型复杂比如嵌套对象就需要额外注意 JSON 结构的匹配。2.2 工具调用的完整生命周期一个工具调用从模型决定使用它到最终返回结果中间经历了这么几个阶段模型根据当前对话上下文和可用工具列表决定是否需要调用工具如果需要模型生成一个工具调用请求包含工具名和参数LangChain4j 拦截这个请求找到对应的 Java 方法反序列化参数执行方法把方法返回值序列化塞回对话上下文模型根据工具返回结果继续生成回复或调用下一个工具这个生命周期里最容易出问题的是第 4 步和第 5 步。参数反序列化失败、返回值格式不对、工具执行抛异常都会导致整个链路中断。而且这些错误在模型层面是“不可见”的模型只知道工具调用失败了但不知道为什么失败。注意工具方法的返回值尽量用简单的 POJO 或者 String避免返回复杂的嵌套结构。如果必须返回复杂结构确保它有清晰的 JSON 序列化配置。2.3 工具注册的几种方式与选型建议LangChain4j 提供了多种工具注册方式每种适合不同的场景注册方式适用场景优点缺点注解扫描工具类已经存在方法上有 Tool零配置自动发现不够灵活无法动态增删手动注册需要动态控制工具列表完全可控代码量大容易漏注册工具提供者工具数量多需要分组管理结构清晰需要额外实现 ToolProvider 接口动态工具根据用户权限或上下文动态决定安全性高实现复杂度高我个人的经验是如果工具数量少于 10 个直接用注解扫描就够了。如果超过 10 个或者需要根据用户角色动态控制工具可见性那就上工具提供者模式。动态工具一般用在多租户场景普通项目用不到。3. 从工具到 Agent编排层的设计思路3.1 Agent 和普通工具调用的本质区别普通工具调用是“一问一答”的模式用户问一个问题模型决定调不调工具调完就结束。Agent 不一样Agent 是“目标驱动”的你给它一个目标它自己决定分几步走、每步用什么工具、中间结果怎么传递、失败了怎么重试。举个例子。普通工具调用像是你问助理“现在几点了”助理看一眼表告诉你。Agent 像是你告诉助理“帮我安排明天下午的会议”助理需要查日历、看参会人时间、订会议室、发邀请、确认回复中间任何一步出问题都要自己想办法解决。LangChain4j 里实现 Agent 的核心接口是Agent和AgentExecutor。Agent负责决策AgentExecutor负责执行。你可以用内置的OpenAiAgent也可以自己实现Agent接口来做更复杂的决策逻辑。3.2 编排策略ReAct、Plan-and-Execute 与混合模式目前主流的 Agent 编排策略有三种ReAct 模式推理和行动交替进行。模型先思考一步执行一个动作观察结果再思考下一步。这种模式适合步骤不确定、需要根据中间结果动态调整的场景。LangChain4j 默认的 Agent 就是这种模式。Plan-and-Execute 模式先制定完整计划再逐步执行。这种模式适合步骤明确、可以提前规划的场景。优点是执行效率高缺点是计划一旦制定就很难调整。混合模式先做粗粒度规划执行过程中根据实际情况动态调整后续步骤。这种模式结合了前两者的优点但实现复杂度最高。我在实际项目里用的是混合模式。具体做法是先用一个轻量级的规划 Agent 生成高层步骤然后用 ReAct Agent 执行每一步执行过程中如果发现计划不可行就触发重新规划。// 伪代码示意混合编排 public class HybridAgent { private Agent planner; private Agent executor; public String execute(String goal) { ListStep plan planner.plan(goal); for (Step step : plan) { try { executor.execute(step); } catch (Exception e) { // 执行失败触发重新规划 plan planner.replan(goal, step, e); } } } }3.3 Agent 记忆机制的设计与实现Agent 如果没有记忆每次对话都是全新的开始那就谈不上“智能”。LangChain4j 提供了ChatMemory接口来管理对话历史但默认的实现比较简单就是按时间顺序存消息。实际项目里你需要考虑几个问题记忆存多久存多少条怎么检索怎么压缩我的做法是分层存储短期记忆用MessageWindowChatMemory只保留最近 N 条消息长期记忆用向量数据库把重要的对话摘要存进去需要的时候检索出来。这样既控制了 token 消耗又保留了关键信息。提示记忆窗口的大小需要根据模型的上下文长度来定。比如模型支持 8K token那记忆窗口最好控制在 4K 以内留一半给当前对话和工具返回结果。4. RAG 接入让 Agent 拥有外部知识4.1 RAG 在 Agent 流水线中的位置RAG 不是 Agent 的替代品而是 Agent 的一个工具。这个定位很重要。很多人把 RAG 和 Agent 对立起来觉得有了 RAG 就不需要 Agent或者有了 Agent 就不需要 RAG。实际上它们是互补的。Agent 负责决策和编排RAG 负责提供外部知识。当 Agent 需要回答一个需要专业知识的问题时它可以调用 RAG 工具去检索相关文档然后把检索结果作为上下文继续推理。在 LangChain4j 里你可以把 RAG 封装成一个Tool方法让 Agent 在需要的时候调用。这样 RAG 就变成了 Agent 工具箱里的一个普通工具和其他工具没有本质区别。Tool(根据问题检索相关文档返回最匹配的文档片段) public String retrieveDocuments(String query) { ListContent contents embeddingStore.findRelevant( embeddingModel.embed(query).content(), 5); return contents.stream() .map(Content::textSegment) .collect(Collectors.joining(\n---\n)); }4.2 多路召回的设计与实现单一向量检索有个很大的问题它只能捕捉语义相似性对于关键词匹配、结构化查询、时间范围过滤这些需求无能为力。多路召回就是同时用多种检索策略然后把结果合并排序。我在项目里常用的多路召回组合是向量检索 关键词检索 结构化过滤。向量检索负责语义匹配关键词检索负责精确匹配结构化过滤负责范围限定。三路结果用 RRF 算法合并效果比单路好很多。召回路径实现方式适用场景权重建议向量检索Embedding 向量数据库语义相似问题0.5关键词检索BM25 或全文索引精确术语匹配0.3结构化过滤元数据字段过滤时间、类别限定0.2权重的分配不是固定的需要根据实际数据分布来调。我一般会先跑一批测试问题看每路召回的命中率然后按命中率来分配权重。4.3 RAG 知识库的选型与数据准备LangChain4j 支持的向量数据库很多常见的有PgVector、Milvus、Chroma、Redis等。选型的时候主要看几个维度数据量、查询延迟、运维成本、生态集成。数据量小于 100 万条用PgVector就够了运维简单和现有 PostgreSQL 复用。数据量在 100 万到 1000 万之间考虑Milvus或者Redis。超过 1000 万那就得上专门的向量数据库集群了。数据准备这块最容易踩的坑是文档切分。切分粒度太粗检索精度低切分粒度太细上下文丢失。我的经验是技术文档按段落切每段 300-500 字法律合同按条款切每条独立对话记录按轮次切一问一答为一组。注意切分的时候一定要保留元数据比如来源文件、章节标题、时间戳。这些元数据在后续过滤和排序时非常有用。5. 完整流水线的搭建与实操5.1 环境准备与依赖配置先说一下我用的环境JDK 17、Maven 3.9、LangChain4j 0.35.0。向量数据库用的 PgVector模型用的 OpenAI GPT-4o。如果你用其他模型接口是兼容的只需要换一下ChatLanguageModel的实现。Maven 依赖需要加这几个dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version0.35.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version0.35.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-pgvector/artifactId version0.35.0/version /dependency配置模型和向量库ChatLanguageModel model OpenAiChatModel.builder() .apiKey(System.getenv(OPENAI_API_KEY)) .modelName(gpt-4o) .temperature(0.2) .build(); EmbeddingModel embeddingModel OpenAiEmbeddingModel.builder() .apiKey(System.getenv(OPENAI_API_KEY)) .modelName(text-embedding-3-small) .build(); EmbeddingStoreTextSegment embeddingStore PgVectorEmbeddingStore.builder() .host(localhost) .port(5432) .database(vectordb) .user(postgres) .password(password) .table(embeddings) .dimension(1536) .build();温度参数我设的是 0.2因为 Agent 场景需要稳定输出不需要太多创造性。如果你做的是创意类应用可以调到 0.7 以上。5.2 工具层的实现与注册工具层我分了三个类知识检索工具、业务操作工具、辅助工具。知识检索工具负责 RAG 查询业务操作工具负责具体的业务逻辑辅助工具负责时间、计算等通用能力。public class KnowledgeTools { private final EmbeddingStoreTextSegment store; private final EmbeddingModel embeddingModel; Tool(根据问题检索知识库返回最相关的文档片段) public String searchKnowledge(String query) { Embedding queryEmbedding embeddingModel.embed(query).content(); ListEmbeddingMatchTextSegment matches store.findRelevant(queryEmbedding, 5); return matches.stream() .map(m - m.embedded().text()) .collect(Collectors.joining(\n---\n)); } } public class BusinessTools { Tool(查询订单状态输入订单号) public String queryOrder(String orderId) { // 实际业务逻辑 return orderService.getStatus(orderId); } Tool(创建退款申请输入订单号和退款原因) public String createRefund(String orderId, String reason) { return refundService.create(orderId, reason); } }注册的时候把所有工具类实例传给AiServicesAssistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(model) .tools(new KnowledgeTools(store, embeddingModel), new BusinessTools()) .chatMemory(MessageWindowChatMemory.withMaxMessages(20)) .build();5.3 Agent 编排层的实现编排层我用的是自定义 Agent因为内置的OpenAiAgent不够灵活。自定义 Agent 的核心是Agent接口的实现主要实现execute方法。public class CustomAgent implements Agent { private final ChatLanguageModel model; private final ListToolSpecification tools; private final ChatMemory memory; Override public String execute(String goal) { memory.add(UserMessage.from(goal)); int maxIterations 10; for (int i 0; i maxIterations; i) { ChatResponse response model.generate( memory.messages(), ToolSpecifications.toolSpecificationsFrom(tools) ); if (response.hasToolExecutionRequests()) { for (ToolExecutionRequest request : response.toolExecutionRequests()) { String result executeTool(request); memory.add(ToolExecutionResultMessage.from(request, result)); } } else { return response.content().text(); } } return 达到最大迭代次数任务未完成; } }最大迭代次数我设的是 10这是一个经验值。设太小复杂任务跑不完设太大万一模型陷入循环会浪费大量 token。10 次对于大多数任务足够了。5.4 多路召回的代码实现多路召回的关键是把不同来源的结果合并排序。我用的是 RRF 算法它的好处是不需要归一化分数直接按排名合并。public ListTextSegment multiRecall(String query, int topK) { // 向量召回 ListEmbeddingMatchTextSegment vectorResults store.findRelevant(embeddingModel.embed(query).content(), topK * 2); // 关键词召回 ListTextSegment keywordResults keywordIndex.search(query, topK * 2); // RRF 合并 MapString, Double scores new HashMap(); for (int i 0; i vectorResults.size(); i) { String id vectorResults.get(i).embeddingId(); scores.merge(id, 1.0 / (60 i 1), Double::sum); } for (int i 0; i keywordResults.size(); i) { String id keywordResults.get(i).metadata(id); scores.merge(id, 1.0 / (60 i 1), Double::sum); } return scores.entrySet().stream() .sorted(Map.Entry.String, DoublecomparingByValue().reversed()) .limit(topK) .map(e - findById(e.getKey())) .collect(Collectors.toList()); }RRF 公式里的 60 是一个平滑常数来自原始论文。实际用的时候可以根据数据特点调整但 60 是个比较稳的默认值。6. 常见问题与排查技巧实录6.1 工具调用失败的高频原因工具调用失败是最常见的问题我整理了一个排查表现象可能原因排查方法解决方案模型不调用工具工具描述不清晰检查 Tool 描述补充使用场景和参数说明参数反序列化失败参数类型不匹配看日志里的 JSON简化参数类型用 String工具执行超时外部依赖慢加日志计时设超时加降级逻辑返回值被截断返回内容太长检查返回值长度截断或摘要后再返回循环调用同一工具模型陷入死循环看调用历史加最大迭代限制6.2 RAG 检索质量差的调优思路RAG 检索质量差通常不是单一原因而是多个环节都有问题。我的调优顺序是先看切分再看 embedding最后看召回策略。切分问题最常见。很多人直接把整篇文档塞进去结果检索出来的都是大段无关内容。正确的做法是按语义单元切分每个单元 300-500 字保留上下文标题。Embedding 问题次之。不同模型对不同语言的 embedding 效果差异很大。中文场景建议用专门的 multilingual 模型不要用纯英文模型。召回策略问题最后看。如果单路召回效果不好就上多路召回。如果多路召回效果还不好那就得考虑重排序了。6.3 Agent 执行效率优化的几个技巧Agent 执行慢是另一个高频问题。优化手段主要有几个减少工具数量工具越多模型决策越慢。把不常用的工具分组按需加载。缓存工具结果同样的查询不要重复执行。用 Caffeine 或者 Redis 做一层缓存。并行执行独立工具如果多个工具之间没有依赖关系可以并行执行。LangChain4j 本身不支持并行工具调用但你可以自己用 CompletableFuture 包装。压缩记忆记忆越长每次请求的 token 越多。定期把历史记忆摘要成短文本。提示Agent 的响应时间主要由模型推理时间决定工具执行时间通常只占小头。所以优化重点应该放在减少模型调用次数上而不是优化工具本身。6.4 生产环境部署的注意事项生产环境部署 Agent 有几个坑必须提前避开超时设置模型调用和工具调用都要设超时。模型调用建议 30 秒工具调用建议 10 秒。超时后要有降级方案不能直接报错。限流模型 API 通常有 QPS 限制要做好限流。用 Resilience4j 或者 Sentinel 都行。日志Agent 的决策过程一定要打日志包括每次工具调用的输入输出。出问题的时候这些日志是唯一的排查依据。监控关键指标要监控比如工具调用成功率、平均响应时间、token 消耗量。这些指标能帮你提前发现潜在问题。安全工具方法要有权限校验不能让模型调用它不该调用的工具。特别是涉及写操作的工具一定要加确认机制。7. 一些个人体会这套流水线我在两个项目里落地过一个是客服问答系统一个是内部知识助手。客服系统那边Agent 主要用来做多轮对话和工单流转RAG 用来查知识库。内部知识助手那边Agent 用来做文档检索和摘要RAG 用来查技术文档。最大的体会是Agent 不是越复杂越好。我一开始设计了一个非常复杂的编排逻辑结果调试成本极高效果还不稳定。后来简化成“规划 执行”两层反而更可靠。另一个体会是RAG 的质量决定了 Agent 的上限。Agent 再聪明如果检索出来的文档是错的它也答不对。所以在 RAG 上花的时间通常比在 Agent 编排上花的时间更值。最后分享一个小技巧调试 Agent 的时候把每次模型调用的完整 prompt 和 response 都打到日志里。这样你能清楚地看到模型是怎么决策的为什么调用了这个工具而不是那个。这个习惯帮我省了很多排查时间。