从知识库问答到多 Agent 协作:一个知识管理 Agent 的架构实践

从知识库问答说起

在上一篇关于 RAG 与长上下文的文章中,我提到 RAG 可以进一步扩展为上下文工程:模型每次推理需要的不只是知识库片段,还可能包括对话历史、用户偏好、可用工具和工具的执行结果。

最近,我围绕个人知识管理做了一个 Agent 项目。它的目标是把本地文档组织起来,让模型能够查找资料、阅读文件、回答问题,并在需要时调用工具完成一些操作。进一步地,不同 Agent 可以拥有不同的提示词、模型和工具权限,主 Agent 也可以把明确的任务交给子 Agent。

举个例子,用户提出:

帮我查找知识库中关于向量检索的资料,归纳它与关键词检索的区别,并把总结写入已有的 Markdown 文件。

要完成这个请求,系统需要确定知识范围、查找相关内容、阅读证据、组织回答,并在具备写入条件时操作文件。仅仅调用一次大模型,或者简单地把检索结果拼到 Prompt 中,已经不足以解释完整过程。

本文围绕这个项目讨论 Agent 架构,重点包括:

  1. Agent 由哪些模块组成,一次请求如何在模块之间流转。
  2. 模型如何使用工具,以及工具权限、执行结果和失败如何处理。
  3. 对话历史和长期记忆如何管理,它们与知识库有什么区别。
  4. 如何结合倒排检索、向量检索和文件搜索查找信息。
  5. 多 Agent 如何配置、委派任务,以及为什么需要控制协作边界。

项目在开发过程中使用了 AI 辅助编程(Vibe Coding)。本文讨论的是项目当前实现及其设计思路,不把规划中的能力当成已经落地的功能。它是面向本机或内网场景的个人项目,仍有不少可以继续完善的地方。

一、Agent 的本质:让模型在循环中使用外部能力

大模型可以根据上下文生成文本,也可以输出符合特定格式的工具调用请求。但模型本身不会因为输出了一个函数名,就自动完成数据库查询或文件写入。真正执行操作的仍然是应用程序。

因此,可以用下面的方式理解一个基础 Agent:

Agent = 模型 + 上下文 + 可调用工具 + 运行时控制循环

这个表达式并不是严格定义,但足够帮助我们拆分系统:

  • 模型负责理解当前任务、选择下一步动作,以及整合工具返回的信息。
  • 上下文向模型提供当前问题、对话历史、长期记忆和外部证据。
  • 工具把检索、文件读写、任务管理等能力封装为模型可以请求调用的接口。
  • 运行时负责执行工具、更新状态、控制循环、处理错误和结束条件。

一个典型流程可以表示为:

用户提出任务
    ↓
组装上下文:系统提示词 + 历史 + 记忆 + 检索证据
    ↓
调用模型
    ├── 输出最终回答 → 结束
    └── 请求调用工具
             ↓
       校验并执行工具
             ↓
       将结果返回模型
             └────────→ 再次调用模型

这里最关键的变化是,模型不必在第一次生成时就拥有全部信息。它可以根据现有上下文提出进一步查询,再基于工具结果继续处理。当然,这也带来了新的问题:工具是否允许使用?一次调用失败后如何处理?如何防止无限循环?当上下文太长时应该保留什么?这些问题需要通过架构设计解决。

1.1 RAG 和 Agent 的关系

RAG 的核心流程是“检索外部信息,再根据检索结果生成回答”。Agent 则引入了更灵活的执行循环,检索可以成为其中的一种能力。

两者并不是互斥关系:

  • 简单问答可以固定执行一次检索,然后交给模型生成。
  • Agent 可以先检索,再决定是否补充查询、读取文件或调用其他工具。
  • 某些任务并不需要知识库,例如管理已有待办事项;另一些任务则需要多次检索和文件操作。

当前项目同时使用了两种方式:每次 Agent 运行时,流程会先尝试执行一次知识库检索;之后,模型还可以在工具授权范围内调用 retrieve_knowledge 等工具。这是一种“固定初始检索 + 后续按需调用工具”的实现,而不是完全由模型决定是否检索。

二、项目整体架构

项目采用前后端分离的方式,前端提供知识库、对话与配置界面,后端负责数据管理、检索、Agent 运行和异步任务。其主要模块如下:

                   用户
                    │
           React / TypeScript 前端
                    │ REST / SSE
                    ▼
                FastAPI
                    │
      ┌─────────────┼──────────────┐
      ▼             ▼              ▼
 知识库与文件管理   Agent 运行时     配置与任务管理
      │          LangGraph          │
      │       检索 → 模型 ↔ 工具     │
      ▼             │              ▼
 异步解析与索引      ├── 对话历史 / 长期记忆
      │             ├── 知识检索 / 文件工具
      │             └── 子 Agent 委派
      ▼
┌───────────┬───────────┬──────────────┐
│ 对象存储  │ 倒排索引  │ 向量索引     │
│ MinIO     │ ES        │ Milvus       │
└───────────┴───────────┴──────────────┘
             PostgreSQL / Redis

上图省略了部分实现细节,但保留了主要职责:

模块 技术或组件 职责
交互层 React、TypeScript 文件管理、Agent 配置、对话与过程展示
接口层 FastAPI 接收请求,提供 REST 和 SSE 接口
Agent 运行时 LangGraph 编排初始检索、模型调用和工具循环
文件处理 LiteParse、pypdf、Celery PDF 解析、文本切分和异步索引
关键词检索 Elasticsearch 基于文本和倒排索引进行全文检索
向量检索 Embedding、Milvus 根据语义相似度召回文本片段
业务数据 PostgreSQL 保存工作区、文件、对话、记忆和运行记录
文件存储 MinIO 保存原始文件及文件版本
任务队列 Redis、Celery 处理异步文件导入和索引任务

这里有一个容易忽略的区别:知识库中的文件、检索索引和对话记忆并不是同一份数据。原始 PDF 存放在对象存储;用于检索的文本块及其索引分布在数据库、Elasticsearch 和 Milvus;对话消息与长期记忆则作为业务数据单独管理。

拆分的好处是职责清晰。例如,替换向量数据库时,理论上不需要修改文件上传逻辑;更换聊天模型时,也不应该要求重新上传原始文件。不过模块拆分也会带来一致性问题:一个文件更新或删除后,需要考虑原文件、数据库记录和两类索引如何同步。

2.1 两条主要链路

从数据流来看,系统有两条主要链路。

知识入库链路:

上传或导入 PDF → 保存原文件 → 异步解析 → 按页提取文本
    → 文本切分 → 保存文本块 → 建立关键词索引和向量索引

Agent 执行链路:

用户提问 → 读取会话和记忆 → 初始知识检索 → 组装上下文
    → 模型推理 → 按需调用工具 → 模型整合结果 → 返回并保存

入库发生在提问之前,可以消耗一定时间构建索引;查询发生在用户交互时,更关注延迟、召回质量和结果解释。区分这两条链路,有助于定位问题:如果答案没有引用正确内容,原因可能在解析和切分阶段,也可能在查询、融合或生成阶段。

三、Agent 的运行时设计

3.1 用状态描述一次执行

Agent 运行时需要记录什么?最直接的做法是定义一个状态对象,把运行过程中需要读取和更新的信息放在一起。项目中的 AgentState 包含的问题、知识库范围、历史消息、记忆、证据、模型配置、工具权限和执行步数等字段。

可以将其简化理解为:

# 示意:只展示关键字段,非项目完整代码
class AgentState(TypedDict, total=False):
    question: str
    workspace_ids: list[int] | None
    history: list[dict]
    memories: list[str]
    evidence: list[dict]
    messages: list[dict]
    pending_tool_calls: list[dict]
    allowed_tools: list[str]
    allowed_subagents: list[int]
    tool_trace: list[dict]
    steps: int
    max_steps: int
    answer: str

将状态显式化以后,检索节点只需要关心怎样获得证据,模型节点只需要关心如何根据消息生成结果,工具节点则根据待执行调用来完成操作。各节点通过状态交接,而不需要依赖大量分散的全局变量。

需要注意的是,状态字段存在,并不意味着该状态已经具备持久化恢复能力。项目每次调用时构建运行图;对话、记忆和运行记录保存在业务数据库中,但当前没有接入 LangGraph Checkpointer,不能将其等同于图执行过程的 checkpoint 恢复。

3.2 用图组织执行流程

项目的核心图结构比较简单:

START
  │
  ▼
retrieve:检索知识库并构造初始消息
  │
  ▼
model:调用 LLM
  ├── 没有工具请求 ─────────→ END
  └── 有工具请求 → tools ───→ model

这种结构的重点不是节点数量,而是把模型调用与外部操作分开。模型可以请求调用某个工具,运行时再依据服务端规则决定是否执行;工具结果被加入消息后,模型继续判断下一步。

与固定工作流相比,这里的 model → tools → model 允许执行路径随问题变化。例如,用户只问一个概念时,模型可能直接根据初始证据回答;要求整理文件时,模型可能先查找文件,再读取内容,最后请求写入。

不过,图中并没有独立的复杂规划节点。界面展示的“正在理解问题并规划下一步”等内容是进度提示,不代表系统有一个可检查的独立规划器。复杂任务能否被拆解,主要依赖模型指令、工具描述及后续推理。

3.3 结束条件与运行预算

Agent 一旦可以循环调用工具,就必须有结束条件,否则容易出现重复检索、工具调用失败后反复重试,或者在两个动作之间来回切换。

项目使用 max_steps 限制工具循环,服务端将配置约束在 1 到 50 的范围。达到限制时,运行时不会无限制继续执行工具,而是结束当前循环并给出相应结果。

除了步数,实际系统还可以进一步限制:

  • 单次模型调用和整个任务的超时时间。
  • 最大 Token 使用量与模型调用次数。
  • 同一种工具的重复调用次数。
  • 子 Agent 数量、并发量和调用深度。
  • 失败后的重试次数以及可重试错误类型。

这些属于可以继续扩展的预算控制,不能仅靠 Prompt 中的一句“不要重复调用工具”来保证。

3.4 状态、轨迹与前端反馈

Agent 执行往往比普通问答耗时更长。如果前端只在任务结束后显示最终答案,用户很难判断系统是否仍在工作。项目通过 SSE 返回进度提示、工具调用状态和回答增量,并保存运行状态与工具轨迹。

这里需要区分三个概念:

  1. 状态描述当前运行需要的数据,例如消息、证据和待执行的工具调用。
  2. 轨迹记录发生过什么,例如调用了哪个工具、是否成功。
  3. 最终回答是系统对用户任务的结果呈现。

轨迹对于排查问题很有帮助:答案不准确时,可以先检查是否找到了正确证据;文件操作失败时,可以检查工具参数和错误结果。与此同时,日志和轨迹可能包含文档片段、工具参数或个人信息,实际部署时需要设置访问范围和脱敏策略。

四、工具调用:让模型获得可执行能力

4.1 一次工具调用是如何发生的

以查询知识库为例,一次工具调用通常包含以下步骤:

  1. 应用向模型提供工具名称、功能描述和参数 Schema。
  2. 模型根据问题生成工具调用请求,例如指定 retrieve_knowledge 和查询内容。
  3. 运行时检查工具是否注册、是否启用、当前 Agent 是否被允许使用。
  4. 服务端校验参数和当前工作区范围,然后执行实际检索。
  5. 应用将结果作为工具消息返回模型,由模型继续生成或调用其他工具。

可以用一个简化示例表示:

{
  "name": "retrieve_knowledge",
  "arguments": {
    "query": "向量检索和关键词检索的区别"
  }
}

这只是模型请求的结构示意,实际响应还包含调用标识等信息。核心是:工具描述是给模型看的接口契约;工具实现和权限判断由服务端负责。

工具的描述过于模糊时,模型可能不知道什么时候使用;参数设计过于复杂时,又容易产生无效调用。因此,工具接口应尽量做到职责单一、参数明确、错误可解释。例如,查找文件与读取文件可以分开:先通过 glob 找到候选文件,再通过 read_file 读取指定文件。

4.2 项目中的工具分类

项目当前注册的工具主要覆盖以下几类:

类别 工具 作用
知识查找 retrieve_knowledge 通过混合检索查找知识库片段
文件定位 glob、grep 按文件名模式或文本内容查找
文件操作 read_file、write_text_file 读取文件,或对支持的文本文件写入新版本
文件统计 get_file_stats 查询所选知识库的文件和索引流程状态统计
任务管理 manage_todos 管理会话或工作区内的待办事项
记忆管理 save_memory 保存后续可使用的长期记忆
Agent 协作 delegate_to_agent 将明确问题委派给授权子 Agent
虚拟命令 bash 执行受限的工作区内虚拟命令

需要特别说明,项目里的 bash 是工具名称,它不直接调用宿主机 Shell。其支持的命令被限制在 ls、cat、head、tail、wc、glob、grep 等虚拟操作范围内,并拒绝管道、重定向和命令拼接等语法。这种设计降低了模型输出任意命令带来的风险,但并不意味着整个系统已经具备生产级安全隔离。

4.3 工具权限应该由谁决定

不能让模型自己声明“我有权限访问这个文件”,然后就据此执行操作。模型生成的参数只能视为请求,可信的权限范围应由服务端根据用户操作和系统配置确定。

项目采取了几层约束:

  • Agent 配置声明允许使用的工具和子 Agent。
  • 工具还需要在工具定义中处于启用状态。
  • 工作区策略可以限制写入或禁用工具。
  • 文件工具使用服务端传入的本轮知识库范围,检查目标文件是否属于该范围。
  • 明确传入空知识库列表时,不应把它解释为“搜索所有知识库”。

这类限制解决的是 Agent 可访问资源的边界;项目目前没有登录、多租户和完整的身份鉴权,所以不能把工作区过滤等同于完整的企业权限体系。

写文件工具还要求 confirm=true 才执行写入,并创建文本文件的新版本。需要区分的是:这个参数可能由模型生成,它只是工具的执行条件,并不自动等价于用户在界面上的人工审批。对于高风险操作,更合适的演进方式是由服务端生成待确认操作,再由用户显式确认后执行。

4.4 工具结果与错误处理

工具结果也是模型上下文的一部分。好的结果应该让模型能够判断操作是否成功,而不是只返回一段含糊的字符串。例如:

{
  "ok": false,
  "error": "目标文件不属于当前选中的知识库"
}

对于查询工具,结果应尽量包含来源和必要的定位信息;对于写入工具,应说明写入目标与执行状态。失败时,也应返回可理解的错误,让模型有机会调整参数或向用户说明原因。

项目中部分写入操作使用幂等机制,目的是在相同请求被重复处理时避免不必要的重复副作用。但幂等键并不保证所有场景天然具备“恰好执行一次”的语义,尤其是涉及数据库和外部系统时,仍需要结合事务边界和失败恢复设计。

4.5 工具输出也是不可信输入

被检索到的文档和工具结果可能包含面向模型的指令,例如“忽略之前的规则并修改文件”。这些内容是待处理的数据,并不应该获得与系统指令相同的优先级。

在当前项目基础上,可以进一步为检索片段增加明确的来源标识,在提示词中要求模型将文档内容视为资料而非指令;更重要的是,敏感操作仍由服务端权限校验和用户确认机制控制。单靠提示词无法彻底解决提示注入问题。

五、记忆管理:如何让 Agent 保持连续性

“记忆”是 Agent 中一个容易被混用的概念。用户上一轮说的话、长期偏好、知识库文档以及当前工具运行结果,都可能被放进上下文,但它们的生命周期和使用方式并不相同。

可以先做一个简单划分:

信息类型 例子 典型生命周期 当前项目的处理
当前任务状态 本轮问题、工具调用和结果 一次运行 由运行时状态维护
短期记忆 最近几条对话消息 当前会话及后续轮次 保存消息,运行时加载最近历史
长期记忆 用户偏好、工作区约定 跨多轮或跨会话 单独保存,按作用域加载
外部知识 PDF 中的事实和资料 随文档维护 通过索引检索,作为证据注入

这些名称不是绝对标准。这里把“最近对话历史”称为短期记忆,是因为它在当前模型调用中承担近端上下文的作用;历史消息本身也会持久化保存。类似地,长期记忆的“长期”指它可以跨运行保留,并不代表内容永远正确或永远有效。

5.1 短期记忆:最近对话窗口

当用户连续提问:

用户:介绍一下 Elasticsearch 的倒排索引。
助手:……
用户:它和刚才提到的向量检索有什么区别?

第二个问题依赖之前的上下文。如果只把最后一句传给模型,“它”和“刚才提到的”都缺少指代对象。最简单的方法是将最近几条消息一起传给模型。

项目在每次对话运行时,从数据库读取最近 10 条历史消息,按时间顺序组织,并与当前问题、长期记忆和知识库证据一起形成输入。这种方式实现简单,也能处理一部分多轮对话问题。

它的局限同样明显:

  • 消息数量限制不等于 Token 预算;十条长消息仍可能超出模型窗口。
  • 重要信息如果发生在较早的轮次,可能被窗口截掉。
  • 把全部历史放进上下文会增加成本,也可能引入无关信息。
  • 当前项目中的固定初始检索使用本轮问题;它没有实现专门的多轮 Query 改写模块。对于“它”这类省略指代,模型可能理解对话,但初始检索未必能找到正确资料。

未来可以按 Token 预算截取窗口,并结合会话摘要、历史检索或 Query 改写。需要注意,摘要也是有损压缩,可能遗漏日期、否定词和条件,关键约束不宜只依赖摘要保留。

5.2 长期记忆:按适用范围保存

有些信息不只对当前几轮对话有用。例如,用户偏好用中文回答、某个工作区约定术语的含义,或者某个会话中的持续任务背景。项目把长期记忆分为三个作用域:

  • user:用户级信息,例如回答风格偏好。
  • workspace:工作区级信息,例如该知识库的术语约定。
  • conversation:会话级信息,例如当前对话持续遵循的任务要求。

系统支持通过接口管理记忆,也提供 save_memory 工具让 Agent 保存记忆。运行时根据用户、当前工作区和当前会话加载适用记录,再将内容加入模型输入。当前实现默认按创建时间倒序取最近的 10 条适用记忆,而不是通过向量相似度自动选出与问题最相关的记忆。

一个需要特别注意的细节是:当一轮消息选择多个知识库时,项目支持跨选中知识库检索文件,但当前只有恰好选中一个知识库时才会设置主工作区。选择多个或零个知识库时,运行时加载用户级和会话级记忆,不会自动加载各工作区记忆。对跨知识库场景,记忆的归属和合并策略仍有改进空间。

5.3 记忆不应该无条件保存

如果把每轮对话都写入长期记忆,系统会很快积累大量过期、重复甚至错误的信息。更合理的记忆管理至少需要考虑:

  1. 是否值得保存:临时问题与长期偏好需要区别对待。
  2. 适用范围:项目特有术语不应自动变成全局偏好。
  3. 来源和可信度:用户明确要求保存的信息,与模型推测出的信息应有所区别。
  4. 更新与删除:用户偏好发生变化时,旧记忆需要可修改或删除。
  5. 冲突处理:同一问题出现不同记忆时,不能简单把所有内容无差别地交给模型。
  6. 隐私边界:敏感信息是否应该保存、保留多久,需要清晰的产品规则。

当前项目已经提供记忆的创建、读取、更新和删除,以及作用域字段、来源和置信度等数据字段;但没有完整实现自动去重、冲突消解、过期策略或基于相关性的记忆检索。这些更适合作为后续演进方向。

5.4 长期记忆与 RAG 的区别

长期记忆和知识库都可以作为模型的外部上下文,但侧重点不同。知识库通常保存文档事实、业务规范和资料;记忆更偏向用户偏好、交互经历或任务状态。

例如:

  • “向量检索通过向量相似度查找候选内容”适合作为知识文档内容。
  • “我希望技术问题优先给出代码示例”更像用户偏好。
  • “这次报告只分析 2025 年数据”更像当前会话的任务约束。

这不是不可逾越的分类边界。未来可以用相似的检索技术搜索记忆和知识,但仍应保留各自的来源、权限与更新规则。否则,模型可能把用户偏好误当成客观事实,或者把旧文档结论误当成用户的长期要求。

六、信息查找:倒排、向量与文件搜索

6.1 从 PDF 到可检索的文本块

项目当前入库解析主要支持 PDF。上传后先保存原始文件,再由解析流程提取文本。解析器优先使用 LiteParse;失败或不可用时,使用 pypdf 作为回退。解析结果按页组织,随后使用 LlamaIndex 的 SentenceSplitter 切分;异常时采用内置字符切分。

项目默认索引配置中的 chunk_size 为 1200,chunk_overlap 为 160。需要注意,不同切分器对长度的计量方式可能不同:SentenceSplitter 的长度与其分词方式有关,而字符回退按字符位置切分。因此不能简单地把这两个数字在所有情况下都理解为相同数量的中文字符或 Token。

切分时保留重叠区域,是为了减少句子或段落被截断后上下文缺失的风险,但重叠过多也会产生重复片段、增加索引体积并占用模型上下文。切分粒度需要根据文档类型、查询方式和评测结果调整。

每个文本块还会关联文件、工作区、页码等信息。这样做的价值在于:检索结果不仅是一段文字,还能告诉用户它来自哪里。页码或章节信息在原始解析结果缺失时也可能为空,不能假定每个片段都有完整定位信息。

6.2 倒排检索:从词找到文档

倒排索引可以理解为一种“词 → 包含该词的文档或文本块”的映射。假设有三段内容:

片段 A:Elasticsearch 使用倒排索引进行全文检索
片段 B:Milvus 用于向量相似度检索
片段 C:混合检索结合关键词检索和向量检索

经过分词和索引后,可以得到概念上的映射:

倒排索引 → A
向量检索 → B、C
关键词检索 → C

这里是帮助理解的数据结构示意,真实结果取决于分词器、字段映射和查询方式。查询时,搜索引擎对输入进行分析,查找相关词项,再根据匹配情况和相关性评分返回结果。Elasticsearch 的全文检索流程可以参考官方文档。

倒排检索通常适合查找明确的关键词、专业术语、产品名称或标识符。例如,用户知道某个配置名或错误码时,词面匹配可能比宽泛的语义相似更有价值。但其效果依赖分词与字段设计,尤其是中文词语、缩写、下划线和特殊符号。并且全文 match 不等于严格的原文精确匹配;精确标识符往往需要结合合适的字段映射和查询方式。

项目使用 Elasticsearch 的 match 查询检索文本字段,并通过工作区和索引配置进行过滤。目前没有在代码中专门配置中文分词器,因此中文检索的实际效果应结合部署时 Elasticsearch 的分析器配置和数据进行验证,不能直接假定已经完成中文分词优化。

6.3 向量检索:从语义相似找到候选

向量检索先使用 Embedding 模型将文本表示为向量。入库时对文档片段生成向量,查询时对问题生成向量,再寻找向量空间中相近的候选片段。

例如,用户搜索“怎样减少大模型编造内容”,文档里可能写的是“通过外部证据缓解幻觉”。两者用词不同,但语义接近,向量检索有机会找到这段内容。相反,对于型号、条款编号和容易混淆的数字,语义接近不一定意味着完全匹配。

项目中,文档向量由 Embedding 服务生成并写入 Milvus;查询时生成问题向量,检索出匹配的 chunk 标识,再从业务数据库取回文本和元数据。聊天模型和 Embedding 模型可以分别配置,因此可以独立选择生成服务和向量服务。

向量检索并不天然保证事实正确。它只是提供候选内容:如果原文解析错误、切分丢失条件、Embedding 模型不适合领域,或者查询本身缺少上下文,返回的相似片段仍可能不适合回答问题。

6.4 为什么需要 IndexProfile

一个经常被低估的问题是:向量并不是只要维度相同就可以混用。不同模型、不同预处理方式或不同指令生成的向量,可能属于不同的表示空间。即使两个模型都输出相同维度的向量,也不代表它们可以互相比较。

项目用 IndexProfile 将检索和建索引的相关配置组织为一个契约,包括 Embedding 模型名、维度、归一化与指令元数据、切分配置、向量集合、距离度量和关键词索引等。运行时会检查模型名、向量维度及相关后端配置,以降低索引和查询配置不一致的风险。

Profile 还会根据配置生成向量空间标识和配置指纹,帮助区分不同配置。但它不是自动迁移系统:修改 Embedding 模型或切分策略后,旧索引需要重新构建或通过明确的迁移方案更新。当前 Profile 注册表仍在内存中,如何持久化、管理多版本和执行完整迁移,是后续需要补齐的工程能力。

6.5 混合检索:RRF 融合两路结果

关键词检索和向量检索分别从不同角度寻找信息。项目并行执行 Elasticsearch 与 Milvus 检索,再使用 RRF(Reciprocal Rank Fusion,倒数排名融合)合并结果。

假设某个片段在多个结果列表中的排名分别为 $rank_i(d)$,其 RRF 分数可以写为:

项目中默认 $k=60$。如果一个片段同时出现在两路结果里,它会得到两路排名贡献的分数;如果只出现在一路,也可以进入最终结果。融合时使用文本块标识去重,并按融合分数排序。RRF 使用排名而不是直接相加两种检索分数,因此不要求关键词分数和向量相似度拥有相同量纲。其计算方式可参考 Elastic 的 RRF 文档。

例如,当片段 A 在关键词和向量结果中都排第一时,分数为 $1/61 + 1/61$;片段 B 只在一路排第一时,分数为 $1/61$。这说明 RRF 会奖励多路共同召回的片段,但也可能让“多路均有一般排名”的片段超过“某一路非常准确但另一路未召回”的片段。是否适合具体任务,仍应通过评测判断。

RRF 是排序融合,不等同于使用 Cross-Encoder 等模型对候选结果做语义重排。当前项目没有接入独立的 Rerank 模型,也没有宣称混合检索一定优于单路检索。实际收益需要用领域数据验证。

6.6 降级与检索质量是两回事

项目允许一条检索链路不可用时继续使用另一条。如果两路都没有结果,则尝试使用数据库文本匹配作为兜底。没有配置聊天模型时,系统也可以返回检索片段,而不是生成模型回答。

这些设计提高了服务在部分依赖不可用时的可用性,但不能等同于检索质量不受影响。数据库 ILIKE 兜底是简单文本匹配,和 Elasticsearch 的分析器、相关性排序以及向量检索都不同。对中文长句或复杂问题,它未必能够召回合适的片段。

此外,空结果可能由“确实没有相关资料”或“检索服务异常”引起。如果把两种情况都静默当成没有资料,用户很难判断答案是否可靠。项目已有部分日志与降级逻辑,后续可以进一步向用户展示检索源状态和降级原因。

6.7 RAG、glob 和 grep 各自解决什么问题

在知识管理场景里,“搜索”并不只有一种方式:

方式 适合的问题 主要限制
关键词/倒排检索 按术语、关键词找相关片段 依赖分词、字段和查询配置
向量检索 用不同措辞寻找语义相关内容 对编号、否定和细微事实差异可能不敏感
glob 按文件名或路径模式定位文件 不理解文件内容
grep 在所选文件内容中查找文本或正则 依赖内容读取和明确的匹配模式
read_file 阅读已确定的文件 单次读取范围与成本需要控制

例如,“找文件名包含报告的 PDF”适合 glob;“在文件中找到 max_steps 出现的位置”适合 grep;“解释多 Agent 为什么需要限制委派深度”则可以先使用知识检索,再按需读取原文。

目前的 grep 面向工作区文件内容执行搜索,不应误解为所有文件都预先建立了独立的 grep 索引。随着文件数量增长,需要关注读取成本、超时和结果数量限制。

七、多 Agent 架构:把任务交给合适的执行者

7.1 为什么需要多个 Agent

当一个 Agent 同时负责资料检索、技术分析、报告生成和文件操作时,系统提示词、工具列表和上下文可能不断膨胀。多 Agent 的一个思路,是让不同 Agent 拥有不同职责和工具配置:

                主 Agent
            理解问题、整合结果
                  │
       ┌──────────┴──────────┐
       ▼                     ▼
   检索 Agent             写作 Agent
查资料、提供证据        根据输入组织内容

这是一种架构示意,不代表项目默认内置了上述具体角色。用户可以根据实际需求配置 Agent;是否值得拆分,应看任务边界是否清晰,以及拆分后能否提高可维护性或效果。

多 Agent 也有成本。每增加一个 Agent,可能增加模型调用、上下文传递、延迟和错误来源。如果一个任务只需要一次检索和一次生成,单 Agent 往往更简单。尤其当多个 Agent 使用同一个模型、接收相似提示词且没有明确分工时,数量本身并不会自动带来更好的结果。

7.2 项目的 Agent 配置

项目中,Agent 不是一段写死的提示词,而是一组可管理的配置,包括名称、描述、系统提示词、模型配置、允许使用的工具、允许委派的子 Agent、工作区策略和最大执行步数。

例如,可以让一个检索 Agent 只拥有知识检索与文件读取权限,而让一个整理 Agent 在合适的工作区内具备文本文件写入能力。不同 Agent 使用的模型参数也可以独立配置。

配置和执行之间需要区分:有了“允许调用某工具”的配置,不代表模型一定会调用;模型提出调用请求后,也仍需要经过服务端的启用状态和范围校验。实际业务中,还应评估子 Agent 的权限是否会绕过父 Agent 的预期限制,尤其在存在写入操作时需要更严格的策略设计。

7.3 主 Agent 如何委派

项目中的协作方式是父子委派:主 Agent 调用 delegate_to_agent,传入目标 Agent 和明确的问题;服务端检查目标是否在授权列表中,再启动子 Agent 运行。子 Agent 返回回答与证据,主 Agent 将返回内容作为工具结果继续处理。

一个概念上的委派过程如下:

主 Agent:用户希望比较两种检索方式
    ↓
委派给知识检索 Agent:查找与倒排检索有关的资料
    ↓
子 Agent:检索 → 模型处理 → 返回回答和证据
    ↓
主 Agent:结合其他资料,形成最终回答

当前子 Agent 会继承本轮选中的知识库范围,并使用自己的系统提示词、模型配置、工具列表和步数配置。代码在委派时向子 Agent 传入空的历史和长期记忆列表,因此子 Agent 不会自动获得父 Agent 的完整对话历史或长期记忆。父 Agent 应在委派问题中提供完成子任务所需的背景,避免只发送“继续处理上面的内容”之类缺少上下文的指令。

这种方式的好处是子任务边界相对明确,也减少了无关历史被无限复制;代价是上下文传递不足时,子 Agent 可能不知道约束条件。实际系统可以考虑定义结构化的任务契约,明确目标、输入、限制、预期输出和来源要求。

7.4 深度限制与结果整合

项目限制子 Agent 委派深度,当前最大深度为两层,用来避免无界递归。主 Agent 只能委派给其授权列表中的子 Agent;目标不存在或问题为空时,工具会返回错误。

需要注意,深度限制只能控制调用链长度,并不能单独限制所有成本:一个 Agent 在允许的步数内仍可能多次调用子 Agent。因此,如果未来面对大量或高成本任务,还应增加总调用次数、总 Token、并发和整体超时限制。

项目的委派方式是工具调用驱动的父子协作,并不意味着已经具备通用的多 Agent 消息总线、复杂并行调度或自动冲突裁决。主 Agent 需要理解子 Agent 的返回内容,判断结果能否支撑最终回答;即使子 Agent 返回了证据,也不应自动假设结论正确。

八、用一个例子串起完整流程

假设用户选中了一个知识库,并提出:

请根据资料解释关键词检索和向量检索的区别,找到相关原文,并把结论整理到指定的 Markdown 文件。

系统可能经历如下过程。这里描述的是一种可能的执行路径,实际工具是否调用、调用顺序以及结果,取决于 Agent 配置、模型判断、数据和工具执行状态。

第一步:确定范围并准备上下文

服务端接收当前问题和选中的知识库范围,读取最近对话历史,以及适用的用户级、工作区级和会话级长期记忆。这里仅选中一个知识库,因此对应的工作区级记忆可以被加载。随后,Agent 运行图先尝试对当前问题进行知识检索。

如果用户上一轮说过“请重点关注检索准确性”,这条信息可能从最近历史进入上下文;如果用户偏好“回答时列出引用来源”,且存在对应长期记忆,也可能被加载。两者的载入都有数量和范围限制,不保证任意历史信息都会出现。

第二步:获取候选证据

关键词检索从 Elasticsearch 查找文本匹配片段;向量检索从 Milvus 查找语义相似片段。系统使用 RRF 融合结果,形成带文件名、页码等元数据的证据列表。

如果两路结果不足,模型可以在允许的情况下再次调用检索工具,换一种表达方式查找;如果已经知道目标文件,也可以使用文件查找和读取工具补充上下文。初始证据只是候选资料,不保证涵盖用户问题的所有方面。

第三步:判断是否需要委派

如果主 Agent 配置了合适的子 Agent,且模型判断需要委派,它可以把一个明确子任务交给授权对象。例如,让子 Agent 查找倒排检索相关资料。子 Agent 使用自己的配置运行并返回结果,主 Agent 再将其与其他证据结合。

如果没有配置子 Agent,或者模型认为没有必要委派,也可以由单个 Agent 完成整个流程。多 Agent 是一种可选的组织方式,不是每次查询都必须经过的步骤。

第四步:组织内容并操作文件

模型基于证据组织比较结果,并在允许写入的前提下请求调用文件工具。服务端检查目标文件是否属于选中的知识库、文件类型是否支持、工具是否可用以及请求是否满足写入条件,然后创建相应文件版本。

需要再次强调,confirm=true 不是独立的人类审批流程。如果任务涉及高风险修改,应在产品层增加明确的用户确认环节。

第五步:返回结果和运行轨迹

前端通过 SSE 接收过程状态和回答增量,最终呈现结果。后端保存消息、运行状态与工具轨迹,用户或开发者可以据此检查是否检索到了预期资料、是否执行了写入,以及工具调用是否成功。

当结果不理想时,可以沿着链路逐层排查:原文是否解析成功,分块是否完整,检索是否召回,来源是否正确,工具参数是否有效,模型是否忠实使用了证据。这样比只观察最终答案更容易定位问题。

九、工程实践中的几个关键问题

9.1 异步入库与索引一致性

文档解析、切分和向量化可能耗时较长。如果上传请求一直等待所有步骤完成,用户体验和接口稳定性都会受到影响。项目使用 Celery 和 Redis 执行异步文件入库,任务记录阶段、进度与错误,并提供失败重试;本地调试也可以使用同步执行模式。

异步任务带来另一个问题:文件上传成功,并不代表所有索引已经可查询。界面和调用方需要根据文件或任务状态判断处理进度,而不能把“文件已保存”直接理解为“检索已就绪”。此外,任务状态为完成也不等同于持续验证 Elasticsearch 和 Milvus 中的索引仍然存在。

当更新、重试或删除文件时,还需要处理原文件、文本块和索引的一致性。个人项目可以先用明确的状态和重建流程降低问题复杂度;如果走向生产,还需要考虑跨服务失败、重复执行、补偿任务及定期校验。

9.2 可观测性与故障定位

Agent 的失败不总是以异常形式出现。有时系统成功返回了答案,但证据无关;有时工具调用成功,模型却没有正确理解返回值。因此除了常规的错误日志,还应关注完整链路:

  • 检索输入、命中的文档与来源。
  • 工具调用名称、参数、状态和耗时。
  • 模型调用次数、Token 消耗与延迟。
  • 子 Agent 的任务、返回结果和失败原因。
  • 最终回答是否能由证据支持。

项目已保存运行记录和工具轨迹,并通过流式事件展示进度;但这不等同于已经拥有完整的生产级分布式追踪或效果评测平台。设计观测数据时,也需要控制敏感内容的记录范围和保留时间。

9.3 如何评测一个知识管理 Agent

如果只凭几个例子感觉“回答得不错”,很难判断系统是否真的变好。一个更可靠的做法是准备覆盖典型问题的评测集,分层检查:

  1. 解析层:PDF 中的段落、表格、页码是否被正确提取。
  2. 检索层:正确片段是否进入候选集合,可使用 Recall@K 等指标。
  3. 排序层:相关片段是否排在靠前位置,可结合 MRR、NDCG 等指标。
  4. 生成层:回答是否覆盖问题,是否与证据一致,引用是否能支持结论。
  5. 工具层:是否选择了合适工具,参数是否正确,失败后是否合理处理。
  6. 任务层:用户目标是否完成,是否出现越权、重复写入或无谓循环。
  7. 工程层:观察延迟、失败率、模型调用次数和资源开销。

例如,针对“查找某配置项的默认值”,可以同时标注目标文件、页码和正确数值。若没有命中正确片段,先优化解析或检索;若命中了正确片段却答错,再检查上下文组织与生成环节。

项目目前有解析、检索、工具和 API 等自动化测试,但尚未建立完整的 OCR、RAG 或 Agent 轨迹效果评测集。测试通过能够说明部分组件行为符合预期,不能代替真实数据上的效果验证,更不能据此声称准确率或效率提升了多少。

9.4 工作区隔离不等于生产权限体系

工作区范围过滤能帮助限制文件检索和工具访问,但项目当前假设主要面向本机或内网单用户场景,没有登录鉴权、多租户以及完整的角色权限管理。即使通过反向代理或防火墙限制访问,也需要根据实际部署环境评估身份认证、网络暴露、密钥管理和审计需求。

另一个风险来自提示注入和数据外发:文档内容可能诱导模型使用工具,工具也可能将敏感内容发给外部模型服务。应根据业务场景决定模型部署位置、工具能力、写入确认和数据访问策略,而不是仅靠系统提示词约束。

十、可以继续演进的方向

结合当前实现,我认为后续可以从以下几个方向逐步完善。

10.1 从固定历史窗口走向上下文预算

根据模型上下文窗口和任务预算,动态分配系统提示词、最近历史、记忆、检索证据和工具结果的 Token。对于较早的历史,可以引入摘要或相关历史检索,并保留用户明确指定的关键约束。摘要和历史检索都需要评测,避免压缩后丢失关键条件。

10.2 从最近记忆走向相关记忆

目前按作用域和创建时间加载记忆。后续可以结合关键词或向量检索、记忆类型、置信度和更新时间选择更相关的记忆,并增加冲突处理、过期和用户可见的修改机制。记忆越自动化,越需要关注错误写入与隐私控制。

10.3 从单次召回走向可评测的检索链路

先构建领域问题集,再评估中文分词、切分策略、混合融合参数和候选数量。在数据支持的情况下,可考虑多轮 Query 改写、结构化元数据过滤、父子块检索或 Rerank。引入额外模块前,应比较效果提升、成本和延迟,而不是默认模块越多越好。

10.4 从简单委派走向可控协作

为子任务定义结构化输入输出,清晰传递任务背景、约束和证据要求;在确有收益的场景下探索并行执行,并建立统一的调用预算和失败处理机制。对涉及文件写入或外部系统操作的任务,可以加入服务端审批节点和可恢复的运行状态。

10.5 补齐持久化与生产能力

可以考虑接入 LangGraph Checkpointer,实现图状态持久化,以及在需要时支持中断和恢复。但业务数据库中已有的消息与运行记录不会自动变成图 checkpoint,两者需要明确职责和一致性策略。

与此同时,需要完善身份认证、授权、密钥管理、索引版本迁移、备份恢复与监控。当前项目更适合用于探索 Agent 架构和个人知识管理,不能直接等同于具备完整安全和运维能力的企业级系统。

总结

通过这个项目,我对 Agent 架构的理解逐渐从“让大模型调用几个函数”,转向“围绕任务组织状态、上下文和外部能力”。模型决定下一步的灵活性固然重要,但系统还要能说明信息从哪里来、工具在什么范围内执行、记忆如何更新,以及失败后如何定位和恢复。

知识库部分也不只是向量数据库:PDF 解析、文本切分、关键词倒排、语义检索、结果融合和证据定位共同决定了模型最终能看到什么。记忆、文件工具和多 Agent 委派则扩展了信息的来源和任务的执行方式,同时引入了权限、成本与一致性问题。

当前实现仍然是一个个人项目:支持 PDF 入库、混合检索、有限的工具循环和父子 Agent 委派,也保留了固定历史窗口、简单记忆加载和缺少图状态恢复等限制。后续优化应从真实问题和评测出发,逐步验证每一项架构复杂度是否带来实际收益。

参考资料

  1. LangGraph 官方文档
  2. Elasticsearch:How full-text search works
  3. Elasticsearch:Reciprocal rank fusion
  4. Milvus 官方文档

  本篇
从知识库问答到多 Agent 协作:一个知识管理 Agent 的架构实践 从知识库问答到多 Agent 协作:一个知识管理 Agent 的架构实践
结合一个基于 LangGraph 的个人知识管理项目,梳理 Agent 的运行循环、工具调用、长短期记忆、倒排与向量混合检索,以及多 Agent 委派的设计和工程边界。
2026-10-05
下一篇 
wsl与clash代理 wsl与clash代理
这几天重置了网络配置,导致WSL的网络代理出了问题,这里记录一下。 配置与问题表现软件环境:WSL2+Clash代理 WSL代理配置:在wsl的~/.zshrc文件中配置如下: export hostip=$(cat /
2025-03-04
   目录