ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

基于LangChain4j的多Provider切换与RAG、Agent架构实战

基于LangChain4j的多Provider切换与RAG、Agent架构实战 1. 为什么要在项目里做多 Provider 切换做过 AI 应用的人大概都有过这种体验项目刚起步时接了一家大模型代码写得挺顺结果业务方突然说“我们想试试另一家的效果”或者某天接口开始限流、响应变慢你才发现整个调用逻辑跟那家 SDK 绑得死死的改起来牵一发动全身。这就是我在做这个 AI 模块架构时踩过的第一个坑也是我把“多 Provider 切换”放在架构最底层的原因。所谓多 Provider说白了就是让系统能同时对接多家大模型服务并且能在运行时按需切换。它解决的核心问题是解耦——业务代码不应该关心底层到底调的是哪家模型只关心“我发一段话拿回一个结果”。这个思路跟数据库连接池、消息队列抽象层是一个道理只不过对象换成了大模型。我见过太多项目把模型调用直接写死在 Service 里new OpenAiClient()一挂后面想换模型就得全局搜索替换。这种写法在 Demo 阶段没问题一旦进入真实业务尤其是需要做 A/B 测试、成本控制、故障降级的场景就会非常痛苦。举个实际例子我们有个功能对响应速度要求高用某家模型延迟稳定在 800ms 左右但另一家只要 400ms 却贵一倍。如果没有 Provider 抽象层你只能二选一有了抽象层你可以让这个功能走快的、那个功能走便宜的甚至高峰期自动降级到便宜的那家。从技术选型上看我最终选了LangChain4j作为基础框架。原因很直接它原生支持多家模型 Provider接口统一而且对 RAG 和 Agent 的支持是内建的不用自己从零搭轮子。LangChain4j 的ChatLanguageModel接口就是那个抽象层OpenAI、通义千问、DeepSeek、Ollama 本地模型都实现了它切换时只需要换一个实现类业务代码一行不用动。这里有个关键设计点值得展开说Provider 的配置不能硬编码。我采用的是“配置驱动 工厂模式”的组合。配置文件里定义每个 Provider 的base_url、api_key、model_name、timeout等参数启动时由工厂类读取配置并实例化对应的 Model 对象注册到一个ProviderRegistry里。业务层通过ProviderRegistry.get(providerName)拿到模型实例。这样做的好处是新增一家 Provider 只需要加一段配置不用改代码、不用重新编译。注意base_url配置缺失是新手最容易犯的错。我见过好几次报错信息里写着“provider 缺少 base_url 配置”排查半天发现是配置文件里漏了一行。建议在工厂类里做启动时校验缺参数直接抛异常别等到运行时才报错。还有一个容易被忽略的点是超时和重试策略。不同 Provider 的响应特性差异很大有的首包快但整体慢有的首包慢但流式输出稳。我在每个 Provider 的配置里都单独设了connectTimeout、readTimeout和maxRetries并且重试逻辑做了区分网络类错误重试参数类错误直接失败不重试。这个细节后面在问题排查章节还会细说。2. RAG 知识库的架构设计与落地细节RAG 这个词这两年已经被说烂了但真正落地时你会发现从“知道 RAG 是什么”到“RAG 命中率能看”之间隔着一条鸿沟。我在这个模块里把 RAG 拆成了四个独立环节文档加载、切分、向量化、检索每个环节都可以单独调优这样出问题时能快速定位是哪一步拖了后腿。先说文档加载。LangChain4j 提供了DocumentLoader接口支持从文件系统、URL、数据库等多种来源加载。我实际项目里主要是 PDF、Word 和 Markdown 三种格式。PDF 解析用的是 Apache PDFBox这里有个坑扫描版 PDF 直接解析出来是空的需要先做 OCR。我的处理方式是加载时先判断文本提取结果的长度如果低于阈值就标记为“需 OCR”走另一条处理链路。Word 文档相对简单但要注意表格内容的提取默认解析器会把表格拍平成纯文本丢失结构信息如果知识库里有大量表格建议自定义解析逻辑保留行列关系。文档切分是影响检索质量的关键一步。LangChain4j 内置了多种DocumentSplitter我常用的是RecursiveCharacterTextSplitter它按段落、句子、字符的优先级递归切分尽量保持语义完整。参数上chunkSize我一般设 500 到 800 个字符chunkOverlap设 50 到 100。为什么是这个范围因为太小了语义不完整检索出来答非所问太大了向量表示会被稀释相似度计算不准。这个值没有标准答案得根据你的文档类型调。技术文档可以小一点叙述性内容可以大一点。向量化环节我选的是本地嵌入模型加远程嵌入模型双轨制。本地用 Ollama 跑一个轻量嵌入模型适合开发调试和隐私敏感场景生产环境用远程嵌入 API效果好但要注意成本和限流。LangChain4j 的EmbeddingModel接口同样做了抽象切换嵌入模型和切换对话模型一样简单。这里要提醒一句嵌入模型换了整个向量库必须重建因为不同模型生成的向量空间不兼容混用会导致检索结果完全错乱。检索环节我做了两层优化。第一层是混合检索把向量相似度检索和关键词检索的结果做融合。纯向量检索对语义匹配好但对专有名词、型号、代码片段这类精确匹配弱关键词检索正好互补。LangChain4j 支持通过EmbeddingStoreContentRetriever配置我在此基础上加了一个基于 Lucene 的关键词检索器两路结果用 RRF倒数排名融合算法合并。第二层是重排序检索出 Top 20 后用一个交叉编码器模型对每个候选做精排取 Top 5 送给大模型。这一步能把命中率提升 15% 到 25%代价是增加一点延迟但非常值得。环节常用方案关键参数调优方向文档加载PDFBox 自定义解析文本长度阈值表格保留、OCR 兜底文档切分RecursiveCharacterTextSplitterchunkSize500-800按文档类型调整向量化本地 Ollama / 远程 API维度、批量大小成本与效果平衡检索向量 关键词混合TopK、RRF 参数加交叉编码器重排实操心得RAG 的瓶颈往往不在检索算法而在文档质量。我花在清洗文档上的时间比调参多得多。建议在入库前做一轮预处理去掉页眉页脚、合并断行、统一标点、剔除乱码。这些脏数据对检索的负面影响远超你的想象。另外提一下 GraphRAG 和本体 RAG 这两个进阶方向。GraphRAG 是把文档里的实体和关系抽出来构建知识图谱检索时同时走图查询和向量查询适合关系密集型知识库比如专利、法律、医疗领域。本体 RAG 则是预先定义好领域本体结构让检索和生成都围绕本体展开。这两个方案效果确实好但构建成本高我一般建议先用基础 RAG 跑通有明确瓶颈再上。3. Agent 编排的核心机制与实现路径Agent 是这个模块里最复杂也最有意思的部分。简单说Agent 就是让大模型不只是“回答问题”而是能“决定做什么、调用什么工具、按什么顺序做”。LangChain4j 对 Agent 的支持主要通过AiServices和工具调用机制实现我在此基础上做了一层编排层。先讲工具调用。LangChain4j 允许你用Tool注解把一个 Java 方法暴露给大模型模型在需要时会生成调用请求框架负责执行并把结果回传。这个机制是 Agent 的基础能力。我项目里定义了几类工具知识库检索工具、数据库查询工具、外部 API 调用工具、计算工具。每个工具都有清晰的描述和参数说明因为模型是靠这些描述来决定用哪个工具的。描述写得含糊模型就会乱调或者不调。工具定义有个细节参数类型要简单。我试过用复杂的嵌套对象做参数模型经常生成不合法的 JSON。后来改成扁平的基本类型加字符串成功率大幅提升。如果确实需要复杂结构就让参数是 JSON 字符串在工具方法内部自己解析这样模型只需要保证 JSON 格式正确即可。Agent 编排的核心是执行循环。一个典型的 Agent 执行流程是这样的接收用户输入模型判断是否需要调用工具如果需要就生成工具调用请求框架执行工具并把结果追加到对话历史模型基于新上下文继续判断直到模型认为可以给出最终答案。这个循环要有最大轮次限制否则模型可能陷入死循环。我设的是 10 轮超过就强制终止并返回当前结果。LangChain4j 的AiServices提供了声明式的 Agent 定义方式你定义一个接口用注解标注哪些方法需要工具支持框架自动生成实现。这种方式适合简单场景。复杂场景我建议自己写编排逻辑因为你需要控制每一步的异常处理、超时、日志和状态管理。我的做法是定义一个AgentExecutor内部维护对话状态、工具注册表和执行策略每一步都有明确的输入输出和错误处理。多 Agent 协作是另一个层次。我项目里有一个“研究 Agent”负责检索和整理资料一个“写作 Agent”负责生成内容一个“审核 Agent”负责检查事实和格式。它们之间通过消息传递协作由一个协调器决定任务分配和流转。这种架构适合复杂任务但调试难度也大。我的经验是先从单 Agent 加多工具开始确实需要分工再拆多 Agent否则你会花大量时间在 Agent 之间的通信和状态同步上。注意Agent 执行中最常见的错误是“工具调用参数不合法”和“模型不按预期调用工具”。前者靠简化参数类型解决后者靠优化工具描述和给模型提供 few-shot 示例解决。我在系统提示词里会放两三个工具调用的正确示例效果立竿见影。还有一个实际问题是执行终止条件。除了最大轮次我还加了“连续两次调用同一工具且参数相同”就终止的逻辑防止模型卡在某个工具上反复调用。另外如果工具执行抛异常我会把异常信息作为工具结果返回给模型让它自己决定是重试还是换方案而不是直接中断整个流程。这个设计让 Agent 的鲁棒性好了很多。4. 多 Provider 切换的实操配置与代码落地理论说完了这一节直接上可复制的配置和代码。我用的是 Spring Boot 加 LangChain4j 的组合配置走application.yml工厂类负责实例化。先看配置文件结构。我为每个 Provider 定义一组参数用前缀区分ai: providers: openai: base-url: https://api.openai.com/v1 api-key: ${OPENAI_API_KEY} model-name: gpt-4o timeout: 30s max-retries: 2 deepseek: base-url: https://api.deepseek.com/v1 api-key: ${DEEPSEEK_API_KEY} model-name: deepseek-chat timeout: 60s max-retries: 1 ollama: base-url: http://localhost:11434 model-name: qwen2.5:7b timeout: 120s max-retries: 0 default-provider: openai工厂类的核心逻辑是读取配置、校验必填项、创建对应的ChatLanguageModel实例并注册。LangChain4j 对不同 Provider 有不同的构建器OpenAI 兼容的用OpenAiChatModelOllama 用OllamaChatModel。我写了一个ProviderFactory根据配置里的类型字段决定用哪个构建器。Component public class ProviderFactory { private final MapString, ChatLanguageModel registry new ConcurrentHashMap(); public void register(String name, ProviderConfig config) { if (config.getBaseUrl() null || config.getBaseUrl().isBlank()) { throw new IllegalStateException(Provider [ name ] 缺少 base_url 配置); } ChatLanguageModel model switch (config.getType()) { case OPENAI_COMPATIBLE - OpenAiChatModel.builder() .baseUrl(config.getBaseUrl()) .apiKey(config.getApiKey()) .modelName(config.getModelName()) .timeout(config.getTimeout()) .maxRetries(config.getMaxRetries()) .build(); case OLLAMA - OllamaChatModel.builder() .baseUrl(config.getBaseUrl()) .modelName(config.getModelName()) .timeout(config.getTimeout()) .build(); }; registry.put(name, model); } public ChatLanguageModel get(String name) { ChatLanguageModel model registry.get(name); if (model null) { throw new IllegalArgumentException(未注册的 Provider: name); } return model; } }业务层调用时通过一个ModelRouter决定用哪个 Provider。路由策略我实现了三种固定路由按配置指定、权重路由按比例分流做 A/B 测试、降级路由主 Provider 失败时切备用。降级路由的实现是在调用外层包一个 try-catch捕获超时和连接异常后切换到备用 Provider 重试一次。public String chat(String providerName, String userMessage) { try { return providerFactory.get(providerName).generate(userMessage); } catch (Exception e) { log.warn(Provider [{}] 调用失败尝试降级, providerName, e); String fallback routingConfig.getFallback(providerName); if (fallback ! null) { return providerFactory.get(fallback).generate(userMessage); } throw e; } }这里有个实操细节降级不能无脑切。如果失败原因是参数错误比如消息格式不对切到另一个 Provider 一样会失败白白增加延迟。所以我在 catch 里判断异常类型只对网络类、超时类、限流类异常做降级参数类异常直接抛出。提示base_url末尾不要带斜杠有些 SDK 会拼接出双斜杠导致 404。这个坑我踩过排查了半小时才发现是配置里多了一个/。流式输出也要考虑。LangChain4j 的StreamingChatLanguageModel接口支持流式返回但不同 Provider 的流式实现细节有差异。我在路由层统一做了适配对外暴露的接口是FluxString内部把各家的流式回调转成响应式流。这样前端只需要处理一种数据格式。5. RAG 知识库从零搭建的完整流程这一节我把 RAG 的搭建过程拆成可执行的步骤你照着做就能跑起来。我用 Ollama 加本地嵌入模型做演示因为零成本、可离线、适合入门。第一步是环境准备。装好 Ollama 后拉两个模型一个对话模型一个嵌入模型。对话模型我选qwen2.5:7b中文效果好且体积适中嵌入模型选nomic-embed-text维度 768够用且快。命令很简单ollama pull qwen2.5:7b和ollama pull nomic-embed-text等下载完就行。第二步是引入 LangChain4j 依赖。Maven 里加langchain4j、langchain4j-ollama、langchain4j-easy-rag三个包。easy-rag是 LangChain4j 提供的一站式 RAG 组件适合快速验证但生产环境我建议自己组装各环节可控性更强。第三步是文档入库。核心代码逻辑是加载文档、切分、向量化、存入向量库。向量库我用的是内存版InMemoryEmbeddingStore适合小规模知识库数据量大就换 Milvus 或 PgVector。入库代码大概长这样EmbeddingModel embeddingModel OllamaEmbeddingModel.builder() .baseUrl(http://localhost:11434) .modelName(nomic-embed-text) .build(); EmbeddingStoreTextSegment store new InMemoryEmbeddingStore(); DocumentSplitter splitter new RecursiveCharacterTextSplitter(600, 80); ListDocument documents FileSystemDocumentLoader.loadDocuments(/path/to/docs); ListTextSegment segments splitter.splitAll(documents); ListEmbedding embeddings embeddingModel.embedAll(segments).content(); store.addAll(embeddings, segments);第四步是检索配置。我配了向量检索加关键词检索的混合模式检索 Top 10再用重排序取 Top 3。重排序模型可以用bge-reranker系列Ollama 也支持。如果不想加重排序至少把 TopK 设大一点比如 8 到 10让大模型自己从更多上下文里挑。第五步是接入对话。把检索到的内容拼进提示词让模型基于上下文回答。提示词模板很关键我用的结构是系统指令说明“只基于提供的上下文回答上下文没有的信息不要编造”然后是上下文内容最后是用户问题。这个模板能显著降低幻觉。步骤操作耗时参考常见问题环境准备安装 Ollama 拉模型10-30 分钟模型下载慢依赖引入加 Maven 依赖2 分钟版本冲突文档入库加载切分向量化视文档量编码乱码检索配置混合检索加重排30 分钟命中率低对话接入提示词加检索20 分钟幻觉实操心得文档入库时一定要打印每个环节的中间结果。我习惯在切分后打印前三个 segment 的内容向量化后打印向量维度检索后打印命中的文本片段。这样出问题时一眼就能看出是哪一步不对。很多人跳过这步结果检索效果差却不知道差在哪。关于 RAG 命中率我再补充一个技巧给文档加元数据。每个 segment 除了文本内容还存来源文件名、章节标题、页码等信息。检索时可以按元数据过滤比如只在某个章节里搜或者把来源信息一起送给模型帮助它判断可信度。LangChain4j 的TextSegment支持Metadata用起来很方便。6. Agent 编排的实操与工具定义Agent 的落地我分三块讲工具定义、执行器实现、多 Agent 协作。工具定义用Tool注解方法描述要写清楚“这个工具做什么、什么时候用、参数是什么”。我举个例子public class KnowledgeTools { Tool(根据关键词检索内部知识库返回相关文档片段。当用户问题涉及公司内部资料时使用。) public String searchKnowledge( P(检索关键词多个关键词用空格分隔) String query) { ListTextSegment results retriever.retrieve(query); return results.stream() .map(TextSegment::text) .collect(Collectors.joining(\n---\n)); } }描述里的“当用户问题涉及公司内部资料时使用”这句话很重要它告诉模型触发条件。我试过不写触发条件模型要么不用工具要么滥用工具。加上之后准确率明显提升。执行器我手写了一个核心是一个 while 循环加状态机。每轮把当前对话历史发给模型解析返回结果是文本还是工具调用请求。如果是工具调用执行工具、把结果追加到历史、继续循环如果是文本返回给用户并结束。循环上限 10 轮同时记录每轮的 token 消耗和耗时方便后续优化。public AgentResult execute(String userInput) { ListChatMessage history new ArrayList(); history.add(SystemMessage.from(SYSTEM_PROMPT)); history.add(UserMessage.from(userInput)); for (int round 0; round MAX_ROUNDS; round) { ChatResponse response model.generate(history); AiMessage aiMessage response.content(); history.add(aiMessage); if (!aiMessage.hasToolExecutionRequests()) { return AgentResult.success(aiMessage.text()); } for (ToolExecutionRequest request : aiMessage.toolExecutionRequests()) { String result toolExecutor.execute(request); history.add(ToolExecutionResultMessage.from(request, result)); } } return AgentResult.maxRoundsExceeded(history); }多 Agent 协作我用的是“协调器 消息总线”模式。协调器持有多个 Agent 的引用根据任务类型决定调用哪个。Agent 之间不直接通信都通过协调器转发消息。这样做的好处是解耦每个 Agent 只关心自己的输入输出不关心上下游是谁。缺点是协调器逻辑会变复杂需要仔细设计任务路由规则。注意多 Agent 场景下每个 Agent 的提示词要明确边界。我见过“研究 Agent”和“写作 Agent”职责重叠结果两个都在检索资料浪费资源还互相干扰。解决办法是在系统提示词里写清楚“你只负责 X不要做 Y”并且协调器在分配任务时明确告诉 Agent 当前阶段的目标。工具执行的安全问题也要考虑。Agent 能调用的工具必须做权限控制尤其是涉及写操作、外部 API 调用的工具。我的做法是给工具加一个RequiresPermission注解执行前检查当前会话的权限没权限直接返回错误信息给模型。另外所有工具调用都记审计日志包括入参、出参、耗时、调用者方便追溯。7. 常见问题与排查技巧实录这一节是我踩坑最多的地方整理成速查表你遇到问题时可以直接对照。问题现象可能原因排查方向解决方案provider 缺少 base_url配置漏写或拼写错检查配置文件补全配置启动时校验模型不可用模型名错误或服务未启动确认模型名和端点核对文档检查服务状态请求被拒绝参数格式不符看错误详情按 Provider 要求调整RAG 答非所问切分粒度或检索参数问题打印检索结果调 chunkSize 和 TopKAgent 死循环工具反复调用看执行日志加轮次和重复调用限制流式输出中断超时或网络问题看超时配置调大 readTimeout第一个高频问题是配置错误。报错信息里经常出现“缺少 base_url 配置”或“缺少 api_key”这类问题最好在启动时就暴露。我在工厂类的register方法里做了必填校验缺任何一项直接抛异常应用启动失败。这样比运行时才发现要好得多。另外配置项建议用环境变量注入密钥别写死在文件里。第二个高频问题是模型不可用。原因可能是模型名写错、服务没启动、或者账号额度用完。排查时先确认服务端点能通再确认模型名和文档一致最后看账号状态。我习惯在启动时发一个简单的测试请求验证每个 Provider 都可用不可用的打警告日志但不阻塞启动运行时再降级。第三个问题是RAG 命中率低。这个最考验耐心。我的排查顺序是先看切分结果是否合理再看检索返回的片段是否相关最后看提示词是否把上下文用好了。很多时候问题出在切分比如把一句话切成两半或者把不相关的内容切到一起。调整chunkSize和chunkOverlap通常能解决大部分问题。如果还不行就上重排序。第四个问题是Agent 行为不符合预期。表现是乱调工具、不调工具、或者调了工具不用结果。乱调工具通常是工具描述太模糊模型分不清该用哪个不调工具是描述里没写触发条件调了不用结果是提示词没强调“必须基于工具结果回答”。这三个问题我都遇到过解决办法分别是细化描述、加触发条件、强化系统提示词。实操心得排查 Agent 问题时把完整的对话历史和工具调用记录打出来看。我一般会记录每一轮的输入消息、模型输出、工具调用请求、工具执行结果。这样一眼就能看出模型在哪一步“想歪了”。光看最终结果很难定位问题。还有一个隐蔽的问题是并发下的状态污染。Agent 执行器如果设计成有状态的单例多个请求同时进来会互相干扰。我的做法是每次执行创建一个新的执行上下文所有状态都存在上下文对象里执行器本身无状态。这个设计在压测时验证过并发 50 个请求没有出现串数据的情况。最后说一个性能相关的坑向量检索的延迟。数据量小的时候内存检索很快上万条之后延迟明显上升。解决办法是换专业向量库或者加缓存。我给检索结果加了基于查询文本的缓存相同查询直接返回缓存结果命中率在重复问题多的场景下能到 30% 以上延迟降得很明显。8. 架构扩展与后续优化方向这套架构跑通之后我陆续做了一些扩展这里分享几个觉得有价值的方向。第一个是成本追踪。每个 Provider 的计费方式不同我在调用层记录了每次请求的输入输出 token 数按 Provider 的单价算出成本汇总到监控面板。这样能清楚看到哪个功能烧钱最多有针对性地优化。比如发现某个功能用贵模型但效果提升有限就切到便宜模型。第二个是效果评估。我建了一个小规模的评测集包含问题和标准答案定期跑一遍看各 Provider 和 RAG 配置的准确率。这个评测集不用很大几十条就够关键是持续跑能发现模型更新或配置调整带来的效果波动。第三个是提示词版本管理。提示词改动对效果影响很大我把提示词存在数据库里带版本号每次改动记录变更内容和评测结果。这样能回溯“哪个版本效果最好”也方便 A/B 测试。第四个是降级链路细化。除了 Provider 降级我还加了 RAG 降级检索失败时直接用模型知识回答和 Agent 降级工具调用失败时退化为普通对话。每一层降级都有明确的触发条件和日志保证系统在部分组件故障时仍能提供基本服务。这套东西搭下来最大的体会是架构的价值在于应对变化。模型会换、需求会变、数据会增长好的架构让你在这些变化面前只需要改配置或加模块而不是推倒重来。多 Provider 切换、RAG、Agent 编排这三块本质上都是在为“变化”留出空间。我一开始也觉得抽象层麻烦但经历过几次紧急切换 Provider 之后就再也不想回到硬编码的时代了。
返回列表