ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

AI应用生产级架构实战:多Provider路由、RAG检索优化与Agent编排

AI应用生产级架构实战:多Provider路由、RAG检索优化与Agent编排 1. 从单点调用到多 Provider 路由为什么不能把模型写死做过 AI 应用的人大概都经历过这个阶段第一版代码里模型名是硬编码的API Key 是写死在配置文件里的调用逻辑就是一次 HTTP 请求加一层 try-catch。跑 Demo 没问题一旦要上线、要换模型、要做灰度整个调用层就得推倒重来。我见过太多项目卡在这一步。业务侧说“DeepSeek 便宜先切过去试试”工程侧打开代码一看模型名散落在十几个文件里base_url 和 api_key 的读取逻辑各写各的切一次要改半天还得回归测试。这不是技术难题是架构债。多 Provider 切换要解决的核心问题其实就三个统一调用契约、运行时动态路由、故障自动降级。听起来像微服务那套东西本质上确实就是——把大模型当成一个不稳定的外部依赖来对待。1.1 统一调用契约把不同厂商的差异吃掉不同厂商的 API 长得不一样。有的用messages数组有的用prompt字符串有的返回choices[0].message.content有的返回output.text流式返回的 SSE 格式也各有各的写法。如果业务代码直接对接这些差异那每接一家就是一次重构。正确的做法是在 Provider 层做一层适配对外暴露统一的接口。我通常定义这样一个抽象class BaseProvider: def chat(self, messages: list, **kwargs) - ChatResponse: raise NotImplementedError def stream_chat(self, messages: list, **kwargs): raise NotImplementedError def embed(self, texts: list[str]) - list[list[float]]: raise NotImplementedError每个具体 Provider 继承这个基类把自家 API 的请求格式、响应解析、错误码映射全部封装在内部。业务层只认chat和stream_chat不关心背后是哪个模型。这里有个容易忽略的细节错误码的归一化。不同厂商对“限流”的返回码不一样有的是 429有的是自定义的 error code。如果不做归一化上层做重试和降级时就得写一堆 if-else。我的做法是定义一组内部错误类型比如RateLimitError、AuthError、ContextLengthError、ProviderUnavailableError每个 Provider 负责把自家错误映射过来。1.2 运行时路由配置驱动而不是代码驱动Provider 的选择不应该写死在代码里。我习惯用一份配置来描述路由规则providers: deepseek: type: openai_compatible base_url: https://api.deepseek.com/v1 api_key: ${DEEPSEEK_API_KEY} models: [deepseek-chat, deepseek-reasoner] qwen: type: openai_compatible base_url: https://dashscope.aliyuncs.com/compatible-mode/v1 api_key: ${QWEN_API_KEY} models: [qwen-max, qwen-plus] routing: default: deepseek/deepseek-chat rules: - match: {task: reasoning} target: deepseek/deepseek-reasoner - match: {task: long_context} target: qwen/qwen-max fallback: [qwen/qwen-plus, deepseek/deepseek-chat]这份配置的价值在于换模型不用改代码改配置重启即可灰度新模型只需要调整 routing 规则某个 Provider 挂了fallback 链自动接管。注意配置里的 api_key 一定要走环境变量注入不要明文写在 YAML 里。我见过有人把 key 提交到 Git 仓库第二天就收到了异常调用账单。1.3 降级策略什么时候该切什么时候不该切降级不是无脑重试。有些错误重试有用网络抖动、临时限流有些错误重试一万次也没用认证失败、参数错误、上下文超长。我的经验是分三类处理错误类型处理策略是否切换 Provider网络超时、连接失败同 Provider 重试 2 次重试失败后切换429 限流指数退避重试连续 3 次后切换401/403 认证失败不重试直接报错切换可能是 key 失效上下文超长不重试切换到大上下文模型参数格式错误不重试不切换这是代码 bug这套策略落地后线上因为单个 Provider 抖动导致的服务不可用基本消失了。实测下来加了 fallback 链之后端到端成功率从 97% 左右提到了 99.5% 以上。2. RAG 知识库从“能检索”到“检索得准”RAG 这个词现在被用烂了好像只要把文档切块、向量化、存进向量库就叫 RAG。但真正跑过生产环境的人都知道RAG 的难点从来不是搭起来而是检索命中率。用户问一个问题你召回了 5 个片段其中 3 个是无关的模型基于这些噪声生成答案结果就是胡说八道。2.1 切块策略固定长度是最偷懒也最坑的做法很多人上手 RAG 的第一版代码就是RecursiveCharacterTextSplitter(chunk_size500, chunk_overlap50)然后发现检索效果一塌糊涂。问题出在哪固定长度切块会把一个完整的语义单元拦腰截断。比如一段讲“配置 base_url”的说明前半段在 chunk 3后半段在 chunk 4用户搜“base_url 怎么配”可能只召回 chunk 3信息不完整。我的做法是按文档结构切而不是按字符数切。Markdown 按标题层级切代码文档按函数/类切PDF 按段落和表格边界切。切完之后再做一层“语义合并”如果相邻两个 chunk 的向量相似度超过阈值说明它们讲的是同一件事合并成一个。def semantic_merge(chunks, embed_fn, threshold0.85): merged [chunks[0]] for chunk in chunks[1:]: sim cosine_sim(embed_fn(merged[-1]), embed_fn(chunk)) if sim threshold: merged[-1] \n chunk else: merged.append(chunk) return merged这个逻辑不复杂但效果提升很明显。我在一个技术文档库上测过语义合并后检索命中率从 62% 提到了 81%。2.2 混合检索向量不是万能的纯向量检索有个致命问题它对精确匹配不敏感。用户搜“error code 4001”向量检索可能返回一堆讲“错误处理”的片段但就是找不到那个具体的 4001。因为向量空间里“4001”和“4002”几乎没区别。解决办法是混合检索——向量检索 关键词检索BM25然后做融合排序。我通常用 RRFReciprocal Rank Fusion来合并两路结果def rrf_fusion(vector_results, bm25_results, k60): scores {} for rank, doc in enumerate(vector_results): scores[doc.id] scores.get(doc.id, 0) 1 / (k rank) for rank, doc in enumerate(bm25_results): scores[doc.id] scores.get(doc.id, 0) 1 / (k rank) return sorted(scores.items(), keylambda x: -x[1])RRF 的好处是不需要调权重两路结果直接按排名融合工程上很省心。实测在技术问答场景下混合检索比纯向量检索的 Top-5 命中率高出 20 个百分点以上。2.3 重排序最后一道精度闸门召回阶段追求的是“不漏”重排序阶段追求的是“精准”。我一般先用混合检索召回 Top-20再用一个 Cross-Encoder 重排序模型精排出 Top-5 送给大模型。重排序模型的选择上如果追求效果可以用 bge-reranker 系列如果追求速度可以用轻量级的。这里有个经验重排序的收益在召回质量差的时候特别明显但如果召回本身就很准重排序的提升有限。所以不要一上来就堆重排序先把切块和混合检索做好。提示重排序模型和嵌入模型最好来自同一家族这样语义空间一致效果更稳。混用不同厂商的模型有时候会有意想不到的偏差。2.4 知识库更新的工程问题RAG 不是建一次就完事的。文档会更新旧内容会失效。如果每次更新都全量重建索引成本高且没必要。我的做法是给每个 chunk 打上doc_id和version标签更新时只重建变化的文档对应的 chunk删除时按doc_id批量清理。这里有个坑向量库的删除操作往往比插入慢。如果文档更新频繁建议用“软删除 定期压缩”的策略而不是每次实时删除。3. Agent 编排让模型自己决定下一步做什么Agent 和普通 Chain 的区别在于Chain 的流程是人写死的Agent 的流程是模型在运行时决定的。这个区别听起来很酷但落地的时候会发现——模型经常做出愚蠢的决定。所以 Agent 编排的核心不是“让模型自由发挥”而是“在可控的范围内给模型选择权”。3.1 工具设计粒度比数量重要新手做 Agent 最容易犯的错是工具给太多。一口气注册二十个工具模型在选工具的时候就开始犯迷糊要么选错要么反复调用同一个工具。我的经验是单个 Agent 的工具数量控制在 5-8 个超过这个数就应该拆分成多个 Agent。工具的描述也很关键。不要写“查询数据库”这种模糊描述要写清楚“根据用户 ID 查询订单状态输入参数为 user_id字符串返回订单列表”。模型是靠描述来选工具的描述越具体选错的概率越低。tools [ { name: search_knowledge_base, description: 在技术文档知识库中检索相关内容。适用于回答产品功能、配置方法、错误排查类问题。输入为自然语言查询语句。, parameters: { type: object, properties: { query: {type: string, description: 检索查询语句} }, required: [query] } } ]3.2 编排模式ReAct 不是唯一选择提到 Agent 编排很多人第一反应就是 ReActReasoning Acting。ReAct 确实通用但它不是万能的。我实际用下来不同场景适合不同的编排模式编排模式适用场景优点缺点ReAct开放式任务步骤不确定灵活容易绕圈token 消耗大Plan-and-Execute复杂任务步骤可预规划全局视角好计划可能脱离实际Workflow流程固定的业务稳定可控不灵活Multi-Agent需要多角色协作分工明确通信开销大我的建议是能用 Workflow 就别用 Agent能用单 Agent 就别用 Multi-Agent。每增加一层自主性就增加一层不确定性。很多所谓的“Agent 需求”拆开看其实就是几个固定的步骤用 Workflow 编排反而更稳。3.3 状态管理与上下文控制Agent 执行过程中会产生大量中间状态思考过程、工具调用记录、工具返回结果。如果不加控制上下文会迅速膨胀最后要么超长报错要么模型被无关信息干扰。我的做法是分层管理上下文短期记忆当前任务的完整对话历史保留最近 N 轮工作记忆当前步骤的工具调用结果用完即弃长期记忆跨会话的重要信息存向量库按需检索工具返回的结果不要原封不动塞回上下文。比如搜索返回了 10 个片段先做一次摘要压缩只把最相关的 2-3 句放回去。这一步能省大量 token也能提升模型判断的准确率。3.4 失败处理Agent 卡住了怎么办Agent 最常见的失败模式是“死循环”——反复调用同一个工具或者在一个步骤上反复纠结。我一般设三个保险最大步数限制超过 N 步强制终止返回当前最优结果重复检测如果连续两次工具调用参数完全相同中断并提示模型换策略超时控制单次任务总时长超过阈值就终止这些限制看起来简单但能避免 90% 的线上事故。我踩过一次坑一个 Agent 因为工具返回格式异常陷入了无限重试半小时烧掉了几百万 token。从那以后所有 Agent 都必须带步数和超时限制。4. 三者的协同Provider、RAG、Agent 怎么串起来单独看 Provider 切换、RAG、Agent 编排每个都不算太难。但真正做项目的时候难点在于把它们串成一个整体还要保证稳定性和可观测性。4.1 调用链路的分层设计我习惯把整个系统分成四层接入层处理用户请求做鉴权和限流编排层Agent 逻辑决定调用哪些工具、走什么流程能力层RAG 检索、工具执行、Provider 调用基础设施层向量库、缓存、日志、监控分层的价值在于每层可以独立替换和测试。比如换一个向量库只动基础设施层换一个模型 Provider只动能力层的 Provider 适配器。编排层的逻辑不受影响。4.2 可观测性出了问题要能定位AI 应用最头疼的就是“效果不好”但你不知道是哪一步出了问题。是检索没召回是模型理解错了还是工具返回了脏数据我的做法是全链路打点每次请求记录用户原始输入检索召回的 chunk 列表及分数重排序后的结果送给模型的完整 prompt模型的原始输出工具调用记录及返回最终响应各阶段耗时这些数据存下来出问题的时候可以完整回放。我一般还会做一个“检索质量看板”统计每天的召回命中率、重排序提升幅度、Agent 平均步数等指标。数据一掉马上能发现。4.3 成本控制别让 Agent 变成烧钱机器Agent 的 token 消耗是普通对话的几倍甚至几十倍因为每一步都要带上完整上下文。如果不加控制成本会失控。几个实用的省钱技巧缓存相同或相似的查询直接返回缓存结果尤其是 RAG 检索结果模型分级简单任务用小模型复杂任务才用大模型上下文压缩工具返回结果先摘要再入上下文提前终止Agent 已经拿到足够信息就停止不要为了“完整”而多跑几步我实测过加了缓存和上下文压缩之后同样的任务 token 消耗降低了 60% 左右效果基本没损失。4.4 一个真实的踩坑记录最后分享一个我踩过的坑。有一次上线新版本Provider 配置里加了一个新的模型routing 规则也改了。测试环境一切正常上线后却发现部分请求返回空结果。排查了半天发现是 fallback 链的问题主 Provider 返回了一个“模型不存在”的错误但这个错误被归类成了“可重试”于是系统不断重试重试失败后切换到 fallback但 fallback 的模型不支持当前请求的某个参数又失败了。最终返回空。根因是错误分类不够细。“模型不存在”应该是不可重试错误直接切换 Provider而不是先重试。这个坑让我意识到错误分类的粒度直接决定了降级策略的有效性。后来我把错误类型从 5 种细化到了 12 种类似的问题再没出现过。这套架构跑了大半年最大的体会是AI 应用的不确定性来自模型但稳定性来自工程。Provider 路由、RAG 检索、Agent 编排本质上都是在用工程手段给模型的不确定性兜底。模型会犯错但系统不能崩。把这三块做扎实AI 应用才能从 Demo 走向生产。
返回列表