Agent框架重构与RAG混合检索优化:工程实践全记录 这个系列写到第五篇这周动手做了两件一直想做的事情把搭了大半年的 AI Agent 框架重新梳理了一遍同时对 RAG检索增强生成管线做了一次比较系统的重构。说实话这两件事放在一起做是有意的——Agent 的能力越来越依赖外部知识而外部知识的入口就是检索。如果框架本身模块边界混乱检索链路再先进也很难调试反过来检索链路不优化框架再干净也只能在低质量答案上反复打转。这篇文章会把这次改动的思路、遇到的坑、最终落地的模块结构和参数选择都讲清楚。内容偏工程实现涉及代码结构、检索策略、评测方法适合已经能跑通基础 Agent 功能的开发者也适合正在纠结“为什么我的 Agent 总是答非所问”的人。1. 为什么我要动框架重构前那些越来越难忍的细节1.1 功能越加越多代码像被塞满的杂物间最初搭 Agent 的时候我只是把“模型调用 工具函数 提示词拼接”三件事放在一个文件里。在只有两三个工具的场景下这种写法非常痛快改一个函数立刻生效加一个接口复制粘贴即可。但是当工具数量增加到十几个、并发了多个会话、开始接入了记忆模块问题就接踵而至。最明显的是新增工具的成本。我想给 Agent 加一个查询库存接口却发现要改动的地方包括工具函数本体、工具参数定义、模型 tools 列表、示例 prompt 里的调用描述、权限判断逻辑以及日志里的工具名映射。这六处如果不同步就会出现模型看到了 tools 声明但实际函数找不到或者权限规则覆盖不到新入口的情况。我有一次就是因为漏改了 prompt 示例导致模型在测试时总是不调用工具整整排查了一个下午。另一个痛点是会话状态的隔离。早期版本里我用了全局字典来存放会话消息记录当多个用户同时触发不同的流程时就会出现上下文交叉污染的现象。A 会话的任务执行到一半B 会话的动作结果被写进了同一个状态里后续推理全乱套。这类问题特别难排查因为它不是每次都复现只在并发稍高的时候出现。1.2 真正的触发点一次让我排查三小时的上下文污染真正让我决定重构的是一次看起来非常“离奇”的异常。Agent 在执行一个两步任务时每一步都正确调用了工具但第二步的工具参数里多出了一个不属于当前任务的字段。当时的代码结构里工具执行的入参直接取自模型返回但我为了方便直接往模型消息里附加了额外的上下文片段这个片段会在某些情况下被模型当作必须携带的参数。这就暴露了一个结构性问题提示词的拼装逻辑、业务上下文、工具调用结果全都在同一个函数里互相穿插。我花了三个小时逐行打印中间变量才定位到是这个附加上下文在起作用。这件事之后我意识到不把 Agent 的核心循环和实现细节拆开这种问题以后只会越来越多。1.3 重构的目标和边界只改结构不增能力重构开始前我给自己定了三条原则避免把重构做成无限膨胀的“大跃进”第一本次不新增任何 Agent 能力只调整代码结构、模块边界和配置方式确保原功能通过回归测试。第二保留外部接口不变之前写给上层业务调用的函数签名尽量不动避免连带改动一堆调用方。第三结构化所有可配置项把模型参数、工具启用开关、检索阈值、提示词模板位置全部挪出代码。这三条帮我守住了范围。重构最怕的不是代码复杂而是做到一半忍不住又想加新功能最后旧问题没解决新问题还搭进来一车。2. 重构落地把 Agent 拆成感知、记忆、行动与决策四层2.1 模块划分的思路不要让“循环调度器”去实现业务功能重构后的整体架构核心思路是把 Agent 看作一个“循环调度器”而不是某个业务功能的执行器。现在我把代码拆成了四个角色清晰的模块再加上一个事件总线用于解耦。感知层接收用户消息和外部事件负责把不同来源的输入统一成内部消息结构。记忆层管理会话短期记忆与长期知识短期记忆就是当前会话的上下文列表长期记忆包括向量库里检索到的文档块和用户偏好画像。行动层是所有工具与技能的集合每个工具只做一件事入参出参都走统一接口。决策层是大脑负责读感知输入、查短期记忆、决定是否调用行动层、把结果写回记忆、生成最终回复。这四层各管一段调试的时候路径非常清晰。比如回答内容不对先去感知层看输入解析是否丢了字段再去记忆层看上下文是否完整再去行动层看工具返回是否正常每一层都有独立的日志边界。2.2 统一接口让工具和记忆都能“即插即用”行动层的核心是一个统一的工具接口。我之前在让模型调用工具时最头疼的就是不同工具的参数风格不一致有的用 snake_case有的用 camelCase有的返回字符串有的返回 JSON。这次我用一个统一的 descriptor 定义工具注册信息内部统一解析参数。底层代码如下from dataclasses import dataclass, field from typing import Any, Dict, List, Optional dataclass class AgentContext: run_id: str session_id: str messages: List[Dict[str, str]] variables: Dict[str, Any] field(default_factorydict) class BaseTool: 所有工具的统一基类 name: str description: str parameters: Dict[str, Any] field(default_factorydict) def run(self, params: Dict[str, Any], context: AgentContext) - Any: raise NotImplementedError def to_openai_tool(self) - Dict[str, Any]: return { type: function, function: { name: self.name, description: self.description, parameters: self.parameters, }, } def validate_params(self, params: Dict[str, Any]) - Dict[str, Any]: # 按 parameters 中定义的 required 字段过滤并校验 required self.parameters.get(required, []) for key in required: if key not in params: raise ValueError(ftool{self.name} missing required param: {key}) return {k: v for k, v in params.items() if k in self.parameters.get(properties, {})}这样模型返回的 tool_calls 参数统一经过 validate_params 过一道缺失字段在行动层抛出明确错误而不是让错误参数流进业务函数。记忆层也同样抽象了 BaseMemory 接口短期记忆用基于列表的窗口实现长期知识用向量检索实现后续想换存储介质只需要实现接口。2.3 事件总线与可观测性从“黑盒对话”到“全链路回放”重构另一个重要成果是加了一个轻量的事件总线。在这个框架里以下环节都会发送事件收到输入、改写问题、触发检索、得到检索结果、决定调用工具、工具执行完成、调用模型、生成最终回复。每个事件都绑定了 run_id 和 session_id。这样一来一次完整的交互可以通过 run_id 聚合出完整链路日志相当于给 Agent 的每次决策做了一次“录像”。调试时我可以直接查看某个问题的检索命中了哪些文档、模型是否看到了正确上下文、工具调用是否通过了参数校验。相比之前那种“只看到最终答案不知道中间发生了什么”的状态效率提升非常明显。建议如果你现在还在用 print 打印调试信息尽量早点换成结构化的事件日志。不一定要上很重的链路追踪系统只需要把关键环节的事件和耗时落表就已经能解决绝大多数“为什么它这么答”的问题。2.4 配置化带来的收益改参数不用再改代码框架重构后所有 Agent 相关配置从代码里剥离开来落地成一份 YAML 配置。模型名称、温度参数、工具白名单、检索阈值、提示词模板地址都在这份配置里声明。给不同业务场景创建一个新 Agent现在只需要复制一份配置并修改工具列表和提示词路径不需要改动任何 Python 文件。这个改动带来的直接收益是“改参数不再需要重新部署”。上个场景里调检索 top_k 需要改代码、重新构建现在直接修改配置里的检索参数重启服务即可生效。看起来这只是几行配置但在频繁调试参数的时候体验差很多。3. RAG 优化真正要解决的问题检索质量才是天花板3.1 文档切分不是越碎越好从“常识做法”到“实际坑”RAG 管线里第一个被重新思考的环节是文档切分。最初我的切分思路很简单粗暴按固定字符长度切成若干 chunk不设重叠或者设置很小的重叠。这种做法在处理问答型知识库时暴露了不少问题。第一个问题是语义块被切断。一份带有层级结构的文档里一个完整的上报流程表格被机械地从中间劈开模型检索到的是半张表根本无法给出准确答案。第二个问题是上下文缺失。某些文档块的结论依赖前文定义切分后单独拿出来看显得没头没尾模型看到这种检索内容经常答非所问。第三个问题是切得太碎带来的噪声。chunk 越小检索时命中的片段就越零散最终返回给模型的上下文可能拼凑出互相矛盾的信息。现在我的切分策略改为“结构优先”先按文档的标题层级识别出一二级章节如果有表格则整表保留再结合 token 限制做二次切分每个 chunk 保留 section_path 元数据。chunk_size 设定在 512 token 左右overlap 设置为 64 token。对于结构特别散乱的纯文本会退回固定切分并保留段落边界。这样处理之后检索到的片段在语义上是相对完整的单元而不是任意截取的字符块。3.2 用户提问要被“改写”而不是“直查”一个一直忽略的步骤很多 RAG 方案的检索效果差问题往往不在向量化而在于直接拿用户原话去检索。真实用户问题里充满了口语表达、指代、缺主语和语气词。比如有人问“上次你提到那个模块的上报接口怎么调”文档里根本不会有“上次”、“怎么调”这类词直接向量化检索总是命中不理想。3.3 召回质量不佳时不要急着调模型在回头审视旧的 RAG 管线时我发现真正影响答案质量的瓶颈往往不在生成模型而在召回的完整性上。旧方案里我用了纯向量检索遇到精确匹配词比如接口名、型号、订单编号时经常把正确答案漏在 top_k 之外而生成阶段无论模型多强看不到文档就跟“没看过”一样。更麻烦的是原来没有任何量化手段。我只知道“答案有时候好有时候差”但说不清楚差在哪里是切分问题、召回问题还是生成指令问题。这次重构我补上了一个评测集把知识库里高频问题挑出 50 条做了标准答案标注每轮优化都跑同一套数据用数据说话。这是 RAG 优化里最值得投入的一步——没有评测集所谓优化只是在猜。4. 重写检索链路混合召回 重排 语义缓存的完整管线4.1 混合检索向量和关键词一路都别放这次重构给检索链路加上了混合召回机制。具体思路是一条查询同时走向量检索和稀疏关键词检索。向量检索擅长处理同义改写与语义相关的内容它对“上报方法”、“上传方式”这类表达差异能识别为同义而稀疏检索擅长精确匹配词比如订单号、报错码、专有名称这类词如果靠向量检索很容易被淹没在语义相近的其他文档里。两路结果合并之后做去重并加权重归一化。不同领域这两路权重的设置差异很大如果知识库里包含大量代码报错和参数名关键词路权重要上调如果知识库以文章、问答为主向量路权重占大头更加合理。我目前把默认权重设为 BM25 0.3、向量 0.7并做成配置项每次实验结果变化后调整再跑评测集。4.2 重排阶段把 top 20 精修成 top 5混合召回返回的候选集我会取 20 条左右经过重排打分后只保留 top 5 进入生成上下文。重排模型本质上是对“查询-文档块”的对齐程度做精细打分效果比单纯依赖向量相似度好不少。核心检索函数大致是这个结构def retrieve(query: str, top_k: int 20, rerank_top_k: int 5): rewritten query_rewrite(query) bm25_hits bm25_index.search(rewritten, top_k) vec_hits vector_index.search(embed(rewritten), top_k) merged merge_and_normalize( bm25_hits, vec_hits, bm25_weight0.3, vec_weight0.7, ) reranked reranker.rerank( queryrewritten, candidatesmerged, top_krerank_top_k, ) return reranked重排模型不能盲目装大的。小模型速度快对短文档块的排序效果就已经够用装一个超大模型每次检索延迟从 300ms 涨到 1s 以上在实时问答场景里会严重拖慢体验。4.3 索引结构不要忘了父子块和元数据过滤在索引层面我做了一个我之前一直回避的设计父子块索引。子块用于检索父块用于生成。意思是每个文档块在存入时不仅保存自己的内容还记录所属的父级章节。检索命中子块后如果子块内容过短就把父块作为上下文返回给模型。这个设计主要解决前面提到的切分导致信息不完整的问题。比如一个流程文档被切分成 5 个子块用户问流程第一步子块命中了但模型可能还需要后续几步的描述才能给出完整步骤。带上父块之后回答完整度会明显提升。元数据过滤也很有价值。我在每个 chunk 上挂了 doc_name、section_path、publish_date、doc_type 等标签。这样查询时可以用 metadata filter 先缩小候选范围比如只检索“操作手册”类型文档避免公告类、营销类的无关内容混入上下文。4.4 语义缓存给高频重复问题减负重构后的 RAG 管线还加了一层语义缓存。对完全相同的用户查询直接返回缓存结果不重新走一遍检索和生成的完整链路。语义缓存和普通缓存的不同在于它允许一定程度的相似度容忍比如“上报接口怎么调用”和“上报接口调用方式”会被视为同一条查询。这一层带来的收益在我的内部 Demo 里大概能减少三成左右的重复计算。不过需要小心的是如果知识库内容更新比较频繁缓存需要绑定文档版本号否则文档更新后模型会一直返回旧答案。4.5 评测数据用指标验证优化是否有效我在前面提到要建设评测集这里给出这次重构前后的一组模拟观测数据。因为我手头的知识库规模不大这里列出的是内部 Demo 的对比不代表任何具体产品指标的参照但优化方向是可以参考的指标重构前重构后Hit Rate5答案文档在前5条候选内出现71%89%MRR5正确答案在候选中的平均排名0.580.79端到端可用回答率回答与标准答案语义一致或部分一致62%83%答非所问比例22%9%Hit Rate 提升到 89% 之后生成阶段只要不犯错大部分问题都能有据可依。剩下的 11% 没有命中的大概率是文档里根本没有对应信息这时候再强的生成模型也无济于事。与其盲目加大模型的上下文窗口不如先把检索做好。5. 重构与 RAG 联调后的几个细节阈值、日志和提示词边界5.1 阈值不能一刀切要分场景设档RAG 管线跑起来之后最容易出现的一个问题是“没有答案硬编一个答案”。明明检索结果的分数已经很低说明文档里没有可支持的内容但模型还是会根据常识强行回答从而产生“幻觉”。我加了一层拒答逻辑重排打分低于阈值时直接返回“当前知识库中没有找到相关信息”引导用户换一种问法。但这里有个细节阈值不能只设一个。有些场景用户问的是产品促销活动时间这种“变化频繁”的信息0.3 分以上也可以作为线索给出有些场景问的是操作步骤、故障码含义这类信息最少要 0.5 以上才值得返回。我现在的做法是把拒答阈值做成分级配置配合系统提示词中“未检索到足够置信度的信息时请明确请用户换一个说法”的指令。比例一压最终的可用回答率提升还是很明显的。5.2 提示词与检索内容的边界别让背景知识抢占检索结果的位置这次重构里另一个值得记下来的点是系统提示词和检索内容的关系。我之前为了让 Agent 更懂业务在系统提示词里写了一大段领域背景知识。结果发现模型往往会优先采信提示词里写的背景描述而不是实时检索到的知识库内容。背景知识和检索上下文在模型的注意力里变成了竞争关系。现在的处理方式是把提示词里的静态背景知识大幅压缩只保留行为规范和输出格式所有动态领域知识全部走检索通道。这是一个人人都容易忽略的点不要用旧知识库填充提示词让它在检索的时候变惰性。模型只有在没有检索到内容时才会依赖提示词兜底。经过这个调整检索结果的采纳率明显上升回答中出现旧内容的频率下降了很多。5.3 日志链路聚合把每次回答变成一份可审计的报告重构前的日志分散在各处想看一个问题的处理过程得翻服务端文件。这次重构之后我按 run_id 把事件聚合成了类似“决策报告”的记录用户问什么、查询改写成了什么、召回了几条、重排分数多少、最终采纳了哪几条、模型给出了什么回答。排查线上问题变成了看一份报告就能定位而不是盲猜搜索引擎。对任何准备长期维护 Agent 项目的人来说我都建议早点把可观测基础打好。功能可以慢慢加但一旦链路不透明后面每优化一个点都要付出数倍时间成本。结语重构和 RAG 优化最难的其实是承认原来的设计不够好这次把框架和检索链路一起重写我感触最深的一点是很多问题早就意识到但一直拖着。功能在跑就不想动底层结构答案偶尔不行就安慰自己“再调调提示词就好”。直到问题积累到维护成本完全失控才不得不一次性还债。如果让我给计划做同样事情的人提一个最重要的建议我会说动手重构前先花时间把评测集和日志链路建好。这两个东西决定了你后续所有调整是在“试”还是在“改”。没有它们你可能会得到一堆看起来能跑、但改一次就崩一次的脆弱系统。有了它们每次调整都是基于数据的推进方向感会完全不同。