从一次企业问答,看懂 DeepAgents、LangGraph 与多模态 RAG

5 阅读1小时+

从一次企业问答,看懂 DeepAgents、LangGraph 与多模态 RAG

这篇文章对应简历中的“企业多模态知识库与智能问答平台”。目标不是背技术栈,而是能解释:一个问题进来后,谁负责决策,谁负责查资料,数据经过哪些处理,为什么需要这些组件,以及面试官追问时怎样把实现讲清楚。

**阅读约定:**全文按完整项目方案组织,不按开发阶段写进度报告。原理依据你的学习笔记和已有示例;用于连接完整系统的类型、流程和代码会明确标为“示意”,不代表项目仓库已经存在同名集成接口。具体实现状态仍以项目 README 为准,旧版 Python DocResearch 的测试结果不作为新平台的效果证明。

不必一次背完两篇长文。下面按你真正可能卡住的问题,给出最短阅读路径;本篇负责跨模块主线,多模态专题负责媒体细节。

我现在想弄懂什么最短阅读路径读完应该能做什么
一次任务谁调用谁第 1.3、4.2、5.4 节画出模型、工具、检索图的调用顺序
DeepAgents 中间件究竟做了什么第 3.3 至 3.5 节区分 hook、wrapper、业务服务
为什么还要 LangGraph第 5.1、5.3、5.5 节解释状态、条件边、预算和退出
RAG 后为什么还读文件第 6.5 节;专题第 11.4 节判断何时已有片段足够,何时需要媒体核对
流式答案与引用如何对应第 9.7 节区分增量、最终完成和中断
简历亮点怎样接住追问第 11 节;专题第 18 节用输入、变化、结果、失败场景介绍

1. 先知道自己在做什么项目

1.1 业务问题不是“接一个大模型聊天接口”

你的项目笔记以装修公司的销售培训、案例学习和售后答疑为背景。这里把它作为业务场景,不是声称系统已经服务了笔记中提到的人数。

公司资料可能包括制度 PDF、产品手册、案例照片、培训录音和视频。员工有三个实际困难:资料分散,问题需要跨资料查找,不同部门又不能看到全部内容。

因此,平台需要完成两件事:

  1. 把资料整理成可管理、可检索、能追溯来源的知识。
  2. 在用户权限内查到证据,组织回答,并让用户能打开对应原文或视频片段。

Agent 解决“这次该怎么查、是否需要继续查”;RAG 解决“回答依据从哪里来”。它们不是同一个东西。

1.2 用一个问题贯穿全文

下面的资料和内容都是自编教学样例:

资料内容当前用户是否可见
案例说明 doc_case海棠 A 户型使用的材料及施工信息可见
售后制度 doc_policy销售解释售后流程时需要说明的事项可见
培训视频 doc_video讲解案例材料与售后沟通的片段可见
采购底价表 doc_price材料采购价格与内部议价记录不可见

员工问:

海棠 A 户型用了什么防水材料,由谁供货?销售应该怎样解释售后流程?请给出培训视频出处。

这不是把问题发给模型就结束。系统需要识别案例、查材料与供应商关系、查制度和视频转录,并且不能把采购底价混入回答。

1.3 系统有两条主链路

资料入库:
上传 -> 鉴权与资料登记 -> 按类型解析 -> 统一文本与来源信息
     -> 分片 -> 向量/全文索引 -> 实体关系抽取 -> 发布可检索版本

问答:
用户问题 -> 身份与资料范围 -> DeepAgents 主 Agent
         -> 直接调用检索工具,或按需委派子 Agent
         -> LangGraph 检索子流程 -> 有权限的证据
         -> 主 Agent 整合答案 -> SSE 文本 / TTS 音频

入库准备知识,问答使用知识。不能每次提问都重新解析全部资料,也不能只建立索引、不维护原文件与来源关联。

1.4 先把四种数据分清

名称是什么例子
业务文档 Document一份受管理、有版本和权限的资料doc_policy 的第 3 版
检索片段 Chunk从资料中切出来、可以单独召回的文本第 6 页的一段售后流程
证据 Evidence被选入本次回答、带来源的文本或关系结果片段正文 + 文档 ID + 页码
Agent 状态 State执行这次任务需要的工作数据当前问题、已查证据、补查次数

文档不是 Agent 的聊天历史,知识库不是用户长期记忆,向量也不是证据本身。模型最终需要看到可读的证据内容。

2. 架构:每个组件到底负责什么

2.1 不要把所有技术都叫“Agent 框架”

组件在项目中的职责不应该承担的职责
NestJSHTTP 接口、身份校验、业务服务、依赖注入、流式响应不让模型自行决定用户权限
LangChain模型、工具、文档处理、输出解析等统一接口不代替业务数据库和权限系统
DeepAgents主 Agent 运行框架,组合工具、规划、子任务和上下文管理能力不自动知道公司的业务规则
自定义 LangGraph 流程显式管理检索、融合、证据检查、补查与退出不再建一套重复的总指挥
PostgreSQL + pgvector业务数据、文档元数据、分片与向量不直接回答自然语言问题
Elasticsearch全文索引和 BM25 关键词召回不作为业务权限的唯一事实来源
Neo4j实体、关系、多跳关联及其来源不把抽取出的关系自动变成真相
MinIO原文件、文档图片和音视频对象不负责语义检索
Redis有边界的近期会话数据不等于无限上下文或永久记忆
Mem0提取和召回用户级长期记忆不代替企业制度,也不扩大权限
LangSmith / OpenEvals固定测试集实验与评估不证明未经测量的准确率提升
Langfuse运行时调用链、耗时、用量、错误和成本观测不代替回答质量的业务评审

这套设计选择 PostgreSQL + pgvector,不是同时再引入 Milvus。你早期检索练习用过 Milvus,原理可以迁移,但数据库客户端和过滤接口不能直接照搬。

2.2 一张架构图

flowchart TD
    User["Web 问答与资料管理"] --> API["NestJS 接口 / 身份 / 权限"]
    API --> Ingest["资料解析与入库"]
    Ingest --> Objects["MinIO 原文件与媒体"]
    Ingest --> PG["PostgreSQL / pgvector"]
    Ingest --> ES["Elasticsearch"]
    Ingest --> Neo["Neo4j"]
    API --> Main["DeepAgents 主 Agent"]
    Main --> Child["按需子 Agent / 独立对话"]
    Child -->|"精简结果与来源"| Main
    Main --> Flow["LangGraph 检索与证据流程"]
    Child --> Flow
    Flow --> Guard["服务端资料范围与版本约束"]
    Guard --> PG
    Guard --> ES
    Guard --> Neo
    Main --> Memory["Redis 会话 / Mem0 长期记忆"]
    Main --> Answer["答案与引用"]
    Answer --> Stream["SSE 文本 / WebSocket 音频"]
    Stream --> User

注意两个边界:主 Agent 和子 Agent 都通过受控工具访问数据;图中“有权限”是服务端查询条件,不是 Prompt 中一句“不要越权”。

3. DeepAgents:不是换个名字调用模型

3.1 从你已经写过的 createAgent 理解

普通模型调用是:输入消息,得到一次模型输出。

Agent 在这之上增加了一个循环:

模型读消息与可用工具
    -> 直接回答?结束
    -> 请求调用工具?校验参数并执行工具
    -> 工具结果写回消息
    -> 模型继续判断

工具通常由名称、说明、参数 schema 和执行函数组成。模型生成的是“调用哪个工具、传什么参数”的请求,真正读数据库、访问文件的是后端代码,不是模型本身。

你的 middleware-test.mjs 使用 LangChain 的 createAgent,展示了这个循环上的生命周期扩展。深度调研助手则使用 createDeepAgent,配置了主 Agent、researcher、analyst、editor、文件后端和技能目录。

这两组练习是连在一起的,不是两套互不相关的知识。

3.2 DeepAgents 具体增加了什么

可以把它理解成一个已经组织好常见能力的 Agent 运行框架,也就是 harness:

  • 规划能力帮助模型把复杂目标拆成任务,学习笔记中使用 write_todos。
  • 文件工具用于读写工作笔记、证据整理和报告,不需要把全部材料一直塞进消息。
  • 子 Agent 委派把局部问题交给另一个独立执行循环。
  • 摘要能力在上下文变长时压缩旧消息。
  • 配置的记忆来源和技能提供可复用知识或操作指南。

**这些能力需要正确的版本、工具和后端配置,不能理解为创建对象后业务能力全部自动具备。**例如文件持久化取决于后端,长期用户记忆不会因为用了 DeepAgents 就自动接入 Mem0。

我核对了你本机安装的 DeepAgents 1.14.1:其创建函数确实会组装文件、子 Agent、摘要等中间件,最终调用 LangChain 的 createAgent。省略版本配置后的结构是:

// 核心类别与组合方式示意,不是完整栈,也不表示固定执行顺序。
const builtinMiddleware = [
  filesystemMiddleware,
  subagentMiddleware,
  summarizationMiddleware,
  patchToolCallsMiddleware,
];
const middleware = mergeMiddlewareStack(
  builtinMiddleware,
  customMiddleware,
  otherConfiguredMiddleware,
);

const agent = createAgent({ model, tools, middleware, checkpointer, store });

其中 mergeMiddlewareStack 是框架内部组合逻辑,不是业务代码需要自己调用的公共入口。这个版本会按中间件名称合并,同名自定义项可能替换内置项,并非永远简单追加到最后。实际还受 skills、模型 profile 等配置影响。

patchToolCalls 维护工具调用与结果的消息协议完整性,不负责修正检索事实或授予权限。理解这一点后,就不会把框架的协议处理能力和自己的业务能力混在一起。

LangChain 的 Agent 运行又建立在 LangGraph 执行机制之上。所以 DeepAgents 与 LangGraph 不是二选一的竞品。

3.3 中间件究竟是什么设计模式

你笔记里的“给 Agent 增加额外功能”是正确起点,但“不影响运行”需要讲得更准确:

中间件让我们不必把日志、计数、上下文处理、权限检查等逻辑重复写进每个业务工具;它本身可以改变输入、状态、工具输出,甚至中止执行。

它不是只在 HTTP 请求外面包一层。Agent 内部会多次调用模型和工具,中间件可以进入这些更细的执行边界。

对比HTTP 中间件Agent 中间件
典型处理对象HTTP Request / ResponseAgent state、模型请求、工具调用与工具结果
典型边界一次接口请求进入和响应返回一次任务里的多轮模型/工具执行
例子验证登录 token、添加响应头累加模型次数、限制工具调用、压缩旧消息

二者可以同时存在:HTTP 层先确认用户身份,Agent 层再观测执行过程;真正的数据服务始终按该身份授权,不依赖某一层日志中间件替代鉴权。

从设计思想看,需要区分两种机制:

机制怎么理解本项目的例子
生命周期钩子框架在固定时机调用扩展函数beforeModel 更新模型调用次数
环绕调用 wrapper接管一次调用的前后过程,再决定是否调用下一个处理器wrapToolCall 做权限检查和耗时记录

**环绕调用具有责任链和装饰器式组合的特点;生命周期钩子是扩展机制。**面试不用把整个框架硬归类成某一个 GoF 模式。说清扩展点、组合方式和变化隔离,比只报“责任链模式”更重要。

两个环绕中间件可以这样理解:

权限 wrapper:检查能不能调用
    日志 wrapper:记录开始
        真正的工具函数
    日志 wrapper:记录结果或错误
权限 wrapper:返回结果

这是 around-wrapper 的嵌套示意,不代表所有生命周期 hook 都按同一种正反顺序执行。依赖顺序的行为应对照所用版本,并用模拟工具验证。

3.4 你的日志中间件究竟统计了什么

下面是基于你学习示例整理的简化代码,展示 state 更新,不调用任何真实 API:

import { createMiddleware } from "langchain";
import { z } from "zod";

const loggingMiddleware = createMiddleware({
  name: "loggingMiddleware",
  stateSchema: z.object({
    modelCallCount: z.number().default(0),
  }),
  beforeAgent: () => {
    console.log("Agent started");
    return { modelCallCount: 0 };
  },
  beforeModel: (state) => ({
    modelCallCount: state.modelCallCount + 1,
  }),
  afterModel: (state) => {
    const message = state.messages.at(-1);
    console.log("Model returned", message?.getType?.());
  },
  afterAgent: (state) => {
    console.log("Model calls:", state.modelCallCount);
  },
});

假设一次正常任务的执行是:

模型第 1 次:决定查售后制度
工具第 1 次:返回制度片段
模型第 2 次:决定补查材料供应关系
工具第 2 次:返回关联证据
模型第 3 次:生成最终回答

在这个简化路径里,beforeAgent 执行一次,beforeModel 和 afterModel 各执行三次,afterAgent 正常结束时执行一次,最终计数是 3。

return { modelCallCount: ... } 不是再次调用模型,而是把部分状态交回框架,按字段的状态更新规则合并。这里是数字替换为新值;不能泛化成所有字段都会自动相加。

代码中在 beforeAgent 重置计数,是为了明确统计本轮。如果同一 thread 的状态通过 checkpointer 延续,只依赖默认值并不保证每轮从零开始。

面试还要补充四个边界:

  1. 这是 Agent 层的模型调用次数,不一定等于 SDK 内部重试产生的 HTTP 请求次数。
  2. 父 Agent 的局部计数不自动包含全部子 Agent,整体用量需要聚合父子执行记录。
  3. 异常时不能只依赖 afterAgent 收尾;环绕调用应通过 try/catch/finally 记录错误并清理资源。
  4. 并行工具不能随便把各自读到的 count + 1 写回同一字段,否则可能丢更新。精确计量可聚合独立 span,或采用经过验证的 reducer/计量服务。

3.5 wrapToolCall 为什么比普通日志更有用

你的 middleware-test2.mjs 已经有具体演示:

进入 wrapToolCall
    -> handler(request) 执行原工具
    -> 取得 ToolMessage
    -> 保留 tool_call_id,包装工具结果
    -> 返回 Command,更新 messages 和工具调用计数

tool_call_id 的作用是把模型发起的某次调用与它的工具结果对应起来。丢掉它,就可能破坏 Agent 消息协议。

Command 是框架可识别的状态更新指令,不是普通业务答案,也不是另一个模型。你的示例利用它同时返回工具消息和更新计数。

落到知识库项目,同一个环绕入口可以承担:

  • 执行前确认工具是否允许访问、参数是否合法。
  • 将服务端身份和资料范围交给真正的检索服务。
  • 记录工具名称、开始时间、结束时间和返回证据数量。
  • 将内部错误转换为可控结果,或继续抛给统一异常处理。

但仍须由数据服务检查权限。只拦住某个工具名,不代表工具内部的任意文档访问都安全。工具返回 status: "error" 的消息也算失败,不能只把抛异常的情况记为失败。

3.6 框架内置中间件怎样对应业务

能力技术机制在知识库中怎么用容易误解的地方
文件操作文件工具 + backend保存本次研究笔记和整理结果默认文件工具不是企业文档权限系统
子任务task 工具 + 子 Agent 运行把独立证据搜集任务分出去子 Agent 不等于一个独立进程
摘要达到阈值后压缩旧消息保留近期任务和关键结论摘要可能丢细节,不等于事实原文
记忆来源读取配置的记忆文本加载项目约定等稳定信息文件记忆不等于 Mem0 用户记忆
自定义中间件模型/工具 hook 与 wrapper观测、计量、输入输出约束使用框架 API 不等于自研了整套中间件引擎

另一个配置边界也要记住:主 Agent 配置了 skills 或 memory,不代表普通自定义 isolated 子 Agent 启动时自动获得相同提示内容。子 Agent 需要的技能和记忆能力应明确配置、传递或按需读取。默认通用子 Agent、isolated 和 fork 的继承规则不同,不能混着介绍。共享 backend 中能读到文件,也不等于文件已经被注入子 Agent 的 Prompt。

你的摘要示例有一个很有价值的踩坑:trigger、keep、summaryPrompt 属于摘要中间件,错放到文件中间件并不会自动生效。面试里可以借此说明:能力是通过明确的配置契约组合的,不是靠相似参数名猜出来的。

面试怎么说:

我使用 DeepAgents 组织 Agent 的运行能力,而不是从头重写工具循环。横切能力通过中间件组合:模型调用前后可以做计数和上下文处理,工具调用通过 wrapper 记录耗时、检查参数和处理错误。业务检索仍放在独立服务里,中间件不承载全部业务规则。

追问:你自研了什么?

中间件执行引擎、工具消息协议和基础文件能力来自框架。我负责的是业务工具、授权范围的接入、需要的自定义观测逻辑和各模块之间的输入输出约定,不能把使用框架说成从零实现框架。

4. 主 Agent 和子 Agent 怎样分工、怎样通信

4.1 主 Agent 保留能力,不是只会派活

简单问题“售后制度在哪里”可能只需要一次检索。强行拆成三个 Agent,只会增加模型调用、交接信息和失败点。

主 Agent 应保留直接调用检索、读取授权来源、使用工作笔记的能力。复杂问题才拆分,例如材料供货关系与售后制度分别搜集,再由主 Agent 整合。

你的深度调研练习配置了 researcher、analyst 和 editor。知识库可以借鉴“搜集、分析、复核”的职责,但不必照搬所有角色,更不必为每个数据库创建一个 Agent。执行 ES 查询的是工具或服务,不需要一个大模型专门“扮演 ES”。

4.2 一次委派实际发生了什么

主 Agent 的模型输出 task 工具调用
    -> 指定 subagent_type 和子任务说明
    -> 框架查找对应子 Agent
    -> 创建该子任务的执行输入
    -> 子 Agent 自己调用模型与工具
    -> 得到最终结果
    -> 以 task 的工具结果返回主 Agent
    -> 主 Agent 根据结果继续决策

你学习代码中的 subagents 是注册表配置,名字必须和调用时对应。之前修复的 subAgents 大小写问题有实际意义:配置未生效时,Prompt 写得再详细,也不会凭空注册一个子 Agent。

这里的通信主要是工具调用参数和工具返回结果,不需要先假设消息队列、Agent 之间互发 HTTP 或共享整段聊天记录。

4.3 “独立上下文”具体隔离什么

在你本机这一版本中,普通 isolated 子 Agent 的消息输入以子任务说明构造,不直接复制父 Agent 的完整 messages;结果通过 task 对应的 ToolMessage 回传。框架也支持不同的上下文模式,因此不能泛称所有子 Agent 都永远不继承上下文。

还要注意:**消息隔离不等于所有 state、文件和身份都隔离。**框架可以传递、返回部分非消息状态;文件后端也可能共享。因此项目需要自己规定哪些数据允许跨边界。

一个合理的子任务输入是:

{
  "task": "查找海棠 A 户型材料与供应商的来源依据",
  "caseId": "case_haitang_a",
  "requiredOutput": "结论、来源、缺口",
  "maxEvidenceItems": 5
}

上面的预算值只是教学示意。用户身份、租户和授权范围应由服务端 runtime context 传入,不允许模型靠修改 JSON 参数获得更大权限。

希望子 Agent 返回的是可供决策的小结果,例如:

{
  "summary": "已找到案例材料和供应商的关联依据。",
  "claims": [
    {
      "text": "案例材料 M 由供应商 S 供货。",
      "evidence": [
        {
          "docId": "doc_case",
          "unitId": "unit_table_materials_row_1",
          "chunkId": "case_v3_chunk_8",
          "documentVersion": 3,
          "excerpt": "教学样例中的材料与供应商记录"
        }
      ]
    }
  ],
  "gaps": []
}

这份结构是项目结果契约示意,不是 DeepAgents 默认会自动生成的字段。需要通过输出 schema 或服务端校验落实,不能只写一句“请结构化返回”。

**为什么不只返回 source ID?**因为 ID 能定位证据,但本身不说明结论是否被支持。可以附上必要的短摘录;主 Agent 需要核对时,再通过受控工具补取片段,而不是拉回子 Agent 的全部工具历史。

4.4 文件工作区和上下文是两种隔离

问题对应措施
子 Agent 不应看到主 Agent 全部对话独立消息输入、最小任务说明
两个子 Agent 不应覆盖彼此笔记Agent/任务级路径、写入范围约束
子 Agent 不应读到采购底价检索、来源读取和下载接口的服务端授权
主 Agent 不应收回全部原文精简结果契约、证据数量与长度预算

你的学习助手用一个 FilesystemBackend 指向项目目录,这种配置不能直接宣称“每个 Agent 都拥有私有文件空间”。virtualMode 提供虚拟路径映射,也不等于容器级沙箱。

业务上可以为本次任务映射 /runs/<runId>/agents/<agentId>/ 等工作路径,再由 backend 和文件服务限制访问。路径约定只是起点,还需要检查路径逃逸、链接和共享目录规则。主 Agent 的读写能力不必被剥夺,它按自己的授权范围操作即可。

面试怎么说:

我不是每个问题都启动多 Agent。简单问答由主 Agent 直接检索,复杂且可分解的问题才委派。子 Agent 接收最小任务信息,返回结论、必要摘录、来源和资料缺口,主 Agent 不接收完整执行历史。消息上下文隔离、文件工作区隔离和文档权限是三个不同问题,分别由执行输入、backend 和服务端数据授权落实。

追问:多 Agent 一定更省 token 吗?

不一定。它能控制主 Agent 的上下文,但会增加子 Agent 的 Prompt、重复信息与调用开销。是否划算要看任务是否可拆分、工具和资料是否能缩小范围,以及是否真的减少了重复搜索。不能只凭用了多 Agent 就声称成本下降。

5. 最关键的分工:到底用 LangGraph 做什么

5.1 区分两层“编排”

层级回答的问题实现位置
任务层用户在问什么?是否拆分?先用哪个工具?如何整合?DeepAgents 主 Agent
检索层双路如何召回?如何融合?证据够不够?补查几次就停?自定义 LangGraph 业务子流程

因此主 Agent 调用的可以是一个 retrieveEvidence 工具。工具内部运行 LangGraph,最终返回证据和缺口,而不是再启动一个负责整个项目的“总 Agent”。

**两者并用不是技术上的必需。**如果检索始终是一次固定查询,普通函数已经够用;当节点有分支、状态、补查和退出条件,显式的图流程才有实际收益。

5.2 你的 GraphRAG 学习示例就是具体起点

graphrag.mjs 中的状态包括:

messages:输入消息
query:提取出的用户问题
cypher:模型生成的图查询
context:执行图查询得到的结果
answer:根据结果生成的回答

四个节点通过 state 交接数据:

START
  -> parseQuestion:从消息提取问题
  -> generateCypher:根据问题和图谱 schema 生成查询
  -> executeCypher:执行查询,把结果写入 context
  -> generateAnswer:根据 context 回答
  -> END

这证明你已经练习了“节点处理一件事,返回部分状态,边决定下一步”的方法。

但这个例子是线性图,不是已经具备自动补查的 Agentic RAG。使用 StateGraph 不会自动创造问题复杂度判断、循环策略或安全退出条件,这些都需要业务设计。

5.3 在完整知识库里,把检索封装成受控子流程

下面是完整方案的流程示意,不是上述四节点示例的原样代码:

flowchart TD
    Begin[&#34;接收问题 / 服务端运行上下文&#34;] --> Prepare[&#34;整理查询与检索计划&#34;]
    Prepare --> Search[&#34;授权范围内双路召回&#34;]
    Search --> Rank[&#34;RRF 融合 / Rerank&#34;]
    Rank --> NeedGraph{&#34;需要关系证据吗&#34;}
    NeedGraph -->|&#34;需要&#34;| Graph[&#34;受控图谱查询&#34;]
    NeedGraph -->|&#34;不需要&#34;| Check[&#34;检查证据覆盖与来源&#34;]
    Graph --> Check
    Check -->|&#34;足够&#34;| Return[&#34;返回证据与缺口&#34;]
    Check -->|&#34;不足且还有预算&#34;| Rewrite[&#34;针对缺口补充查询&#34;]
    Rewrite --> Search
    Check -->|&#34;预算耗尽或无可用资料&#34;| Return

各节点拿到的是显式状态,例如:

// 数据契约示意。授权信息来自可信运行上下文,不由模型生成。
type RetrievalState = {
  question: string;
  queries: string[];
  candidateIds: string[];
  selectedEvidenceIds: string[];
  needsRelations: boolean;
  assessment: "pending" | "sufficient" | "insufficient";
  gaps: string[];
  rounds: number;
};

这里的关键不在于字段数量,而在于每个节点都能明确回答:“我读哪些字段,产出哪些字段,下一步根据什么条件走。”

权限范围、总预算、取消信号等控制信息应由可信运行上下文或服务端配置约束,不能让 LLM 返回一段 JSON 就改掉它们。

把图落实为代码,核心就是注册节点并连接边。下面只展示连接方式,nodes 是已实现节点函数的集合,不是让框架自动生成这些函数:

import { StateGraph, START, END } from "@langchain/langgraph";

function connectRetrievalGraph(stateSchema, nodes, runBudget) {
  return new StateGraph(stateSchema)
    .addNode("prepare", nodes.prepare)
    .addNode("retrieve", nodes.retrieve)
    .addNode("rank", nodes.rank)
    .addNode("relations", nodes.relations)
    .addNode("check", nodes.check)
    .addNode("rewrite", nodes.rewrite)
    .addEdge(START, "prepare")
    .addEdge("prepare", "retrieve")
    .addEdge("retrieve", "rank")
    .addConditionalEdges("rank", (state) =>
      state.needsRelations ? "relations" : "check",
      ["relations", "check"],
    )
    .addEdge("relations", "check")
    .addConditionalEdges("check", (state) => {
      const stop = state.assessment === "sufficient"
        || state.rounds >= runBudget.maxRetrievalRounds
        || runBudget.isExhausted();
      return stop ? END : "rewrite";
    }, [END, "rewrite"])
    .addEdge("rewrite", "retrieve")
    .compile();
}

nodes.retrieve 每执行一轮,要由代码递增 rounds 并消耗任务预算;check 校验来源和覆盖,rewrite 只针对缺口补查。没有这些节点实现,单独把图连起来并不会产生检索能力。

这里的 runBudget.isExhausted() 只是退出判断,不是唯一额度保护。每个可能产生费用或访问后端的操作之前,还要由请求共享的预算服务原子执行 tryReserve/consume;预留失败就返回受控的预算耗尽结果,不能让并发子 Agent 都先执行、最后才检查超额。示例中的 rounds 是本次检索图的局部次数,不是所有子任务共享的总计数。token 预算可以先预留上限、调用后按实际 usage 结算,但必须考虑 provider 的计费与取消行为,不能承诺零超额。

再看“Agent 怎么调用它”。下面用请求级闭包演示可信身份如何进入工具,不依赖某个版本的 runtime API 名称:

import { tool } from "langchain";
import { z } from "zod";

function createEvidenceTool(principal, services, runBudget) {
  const graph = services.buildRetrievalGraph({ principal, runBudget });
  return tool(async ({ question }) => {
    const state = await graph.invoke({
      question,
      queries: [question],
      candidateIds: [],
      selectedEvidenceIds: [],
      needsRelations: false,
      assessment: "pending",
      gaps: [],
      rounds: 0,
    });
    const evidence = await services.documents.loadAuthorizedEvidence(
      principal,
      state.selectedEvidenceIds,
    );
    return JSON.stringify({ evidence, gaps: state.gaps });
  }, {
    name: "retrieve_evidence",
    description: "检索当前用户有权限的资料并返回证据与缺口",
    schema: z.object({ question: z.string().min(1) }),
  });
}

这也是接口示意。principal 由 Nest 鉴权得到,services 是注入的业务服务,runBudget 由服务端为本次任务创建;模型可见参数只有 question,不能传入“我是管理员”改变闭包中的身份。

检索图的节点通过这些服务执行当前权限校验,返回前 loadAuthorizedEvidence 再确认选中证据仍可见,并限制证据数量与长度。不能把这个末尾校验当作前面允许越权搜索的理由。

主、子 Agent 都应获得绑定同一用户身份和任务预算的受控工具。更高效的实现可以复用已编译图并使用框架的类型化运行上下文,但必须验证身份如何传播,不能放进所有用户共用的可变全局变量。

失败时不能仍返回一个看似成功的证据包

上面的闭包代码只展开正常返回路径。完整接口还要表达结果状态,例如:

type EvidenceToolResult = {
  runId: string;
  status: "ok" | "partial" | "budget_exhausted" | "cancelled" | "failed";
  evidenceSetId?: string;
  evidenceIds: string[];
  completedStages: string[];
  gaps: string[];
  retryable: boolean;
};

这里 ok 表示工具完成了约定流程,不保证问题一定有答案;“没有足够依据”由证据和 gaps 表达。partial 表示只完成了部分能力,例如转录可用但画面核对超时;主 Agent 不能把未核对部分说成确定事实。

retryable 是服务端判断的提示,不是让模型无限重试的许可;重试仍受同一任务预算、截止时间和次数约束。没有权限也不能被重试成有权限,重复读取也不会自动把证据升级为已确认。

取消要形成一条传播链:请求上下文的 AbortSignal -> 主/子 Agent 执行 -> 检索图 -> 数据读取、媒体处理与 provider 的受支持取消接口。对不支持取消的依赖,要停止等待或停止后续使用,并记录仍在运行的后台工作,不能声称费用已经回收。

框架层的取消通常应传播给最外层任务,让它停止继续调用模型;不要仅返回一条普通“cancelled”工具消息,然后让 Agent 又开始规划。这个状态供任务协调和审计使用。一个可选通道超时可返回 partial,整次用户取消则终止执行,这两种情况不能混在一起。

读操作可以在有界条件下重试;写操作与入库任务需单独设计幂等。多个子 Agent 若复用同一媒体处理结果,也必须匹配租户、当前权限、资料版本与处理配置,不以“同一张图片”作为跨用户无条件复用的理由。

5.4 跟着一次状态变化走

节点输入产出
整理查询原始问题案例供货关系、售后流程、视频出处三个检索目标
双路召回查询 + 服务端范围制度和案例的候选片段
融合与重排两路带排名结果更相关的少量片段
图谱查询案例实体 ID案例 -> 材料 -> 供应商及来源
证据检查目标 + 已有证据售后与供货已覆盖,但缺视频时间段
补查缺口“视频出处”带开始和结束时间的视频转录片段
返回覆盖结果证据集合、引用字段、仍未解决的缺口

证据检查可以使用规则和结构化模型判断结合:先检查来源是否真实存在、版本是否有效,再判断问题的各个部分是否有依据。不能把模型一句“证据充分”当成确定事实。

5.5 为什么不是让主 Agent 随便多查几次

主 Agent 的自由决策适合选择任务方向。固定的检索业务规则更适合放在一个可检查的模块中:

  • 任何入口都必须执行同样的权限过滤。
  • RRF 和重排不依赖模型临时写算法。
  • 证据不足有明确补查路径,也有明确结束路径。
  • 每个节点可以单独 mock 和测试。
  • 一次失败能定位到召回、重排还是证据检查,而不只是“Agent 回答不好”。

局部流程限制补查轮数还不够:主 Agent 可能重复调用整个工具,多个子 Agent 也可能同时消耗预算。因此还需要本次任务级的模型次数、工具次数、并发、时间与 token 限额,不能只靠子流程内部的 rounds。

5.6 不要混淆状态保存和长期记忆

LangGraph 的 checkpointer 保存执行状态,回答的是“流程走到哪里、下一步依据什么继续”。

Redis 会话记录回答“刚才聊过什么”;Mem0 回答“这个用户有哪些值得长期保留的信息”。它们解决的问题不同。

你的笔记提到 MemorySaver。这类内存 checkpointer 可以用于学习和同进程状态延续,但进程退出后并不自动持久化。跨进程恢复需要合适的持久化 checkpointer、thread 标识和恢复控制;暂停、异常、重试也不是同一种操作。

即使保存了图状态,外部写操作也可能重放,仍须设计幂等。不能说“用了 LangGraph,所以自动保证恢复、去重和事务一致性”。

面试怎么说:

DeepAgents 管任务级决策,自定义 LangGraph 管一次检索工具内部的业务流程。我把召回、融合重排、关系查询、证据检查和有限补查建成节点,用 state 传递中间结果,条件边决定返回还是补查。这样主 Agent 不需要掌握检索实现细节,也不会有两个重复的主调度器。

追问:既然 DeepAgents 底层就是 LangGraph,为何还要写图?

框架底层的图解决通用模型与工具循环,我自己定义的图解决知识库业务流程。就像 Web 框架已有请求处理链,业务仍然可以有订单状态机。如果业务流程很简单,我也可以只写函数,不为了技术栈强行套图。

6. 资料怎样变成可检索、可引用的知识

本节先建立总览。图片、表格、音视频的解析与检索、跨模态向量、来源定位、权限、版本和面试追问,详见企业级多模态知识库专题。尤其要区分“资料转为文字参与 RAG”和“直接跨模态检索”,两者不是同一能力。

6.1 多模态的首要任务是建立统一的证据表达

原始资料解析方式进入文本检索的内容必须保留的定位
PDF / Word文档解析;扫描件再 OCR正文、标题、表格文本文档、版本、页码或结构位置
图片OCR + 图片理解图片中的文字、可观察内容描述图片对象、所属文档或案例
音频ASR带分段信息的转录音频对象、开始与结束时间
视频分片、音轨转录、关键画面理解时间段对应的文字和画面描述视频对象、时间段、相关帧

统一成 Markdown 的意义,是给不同解析器一个可处理的文本输出,不是 Markdown 天生比其他格式检索更准。片段的来源元数据仍应是结构化字段,不能只藏在 Markdown 链接里。

OCR 适合读字;图片理解可以描述场景,但不能仅凭照片确定施工材料、隐蔽工程质量或制度结论。音频转录与视觉描述也可能出错,需要保留它们来自哪种解析过程。

**文本化的多模态入库不等于真正的跨模态向量检索。**如果要实现“上传一张图找相似案例”,还需要合适的视觉或共享多模态 embedding 与对应索引,不能说文本 embedding 自动支持以图搜图。

6.2 一个片段应该长什么样

// 用于解释统一证据接口,不是仓库已有数据库 schema。
type Evidence = {
  evidenceId: string;
  citationId: string;
  tenantId: string;
  docId: string;
  documentVersion: number;
  unitId: string;
  chunkId?: string;
  text: string;
  modality: "document" | "image" | "audio" | "video";
  mediaRef?: string;
  page?: number;
  startMs?: number;
  endMs?: number;
};

例如视频片段的 text 用来检索,startMs/endMs 用来定位播放。mediaRef 是服务端登记的受控媒体引用,不是 MinIO 对象键;文件服务先检查身份和资料版本,再解析到内部对象位置。citationId 用于答案引用,两种引用都不是持有即可越权访问的授权凭证。

这个接口面向 Agent,不直接暴露内部 objectKey 或永久公开 URL。unitId 是可定位的通用证据单元,chunkId 是文本分片的标识;它们如何与图片、转录、向量关联,统一看专题第 10.5 节,不需要读者为两篇文章记两套互不相干的模型。

docId 或 source_id 解决“来自哪份资料”,chunkId 解决“来自哪一段”,版本解决“依据的是不是当前版本”。它们不能互相完全代替。

6.3 分片不是随便每 500 个字切一刀

先按标题、段落和语义结构切,再用长度上限控制大小;必要的重叠用于保留边界上下文。表格、编号条款和时间段不能为了凑字符数被任意打散。

分片过小会丢背景,过大会引入无关内容并增加输入成本。chunk size、overlap 和召回数量需要固定问题集评估,而不是凭“行业标准值”直接认定最佳。

同一片段写入 pgvector 和 ES 时,应使用一致的身份和版本。否则两路查到同一内容也无法正确融合,引用还可能指向不同版本。

6.4 多个存储怎样保持一致

不能用一次 PostgreSQL 事务就假装同时提交了 ES、Neo4j 和 MinIO。

一种合理的业务设计是:

  1. PostgreSQL 记录文档、版本、权限和各处理步骤状态。
  2. 解析与索引任务按文档版本幂等执行,写入统一片段 ID。
  3. 必要索引达到可用条件后,才发布该版本参与问答。
  4. 图谱等增强能力可单独记录状态;不可用时明确降级,不把缺失关系编造出来。
  5. 更新、删除、权限变更时同步索引,检索返回和来源读取再校验当前有效性。

这样即使 ES 暂时残留旧片段,服务端仍可以依据权威版本状态拦住它,而不是让“索引中存在”直接等于“可以给模型看”。

可以用下面的最小状态变化理解发布,而不是只记“最终一致性”四个字:

新版本 PROCESSING
  -> 解析成功 + pgvector/ES 必要索引成功
  -> READY
  -> PostgreSQL 切换有效版本 -> PUBLISHED

必要索引失败 -> FAILED / 等待重试,不发布新版本
图谱增强失败 -> 记录 graphStatus,不声称关系查询已可用
删除或撤权 -> 先更新权威可见状态,再清理各后端与缓存

这些状态名只是教学约定。前提是把 pgvector/ES 定义为当前发布要求;如果产品允许单路降级,也必须显式记录和告知,不能把残缺版本当完整版本使用。

索引失败可以按文档版本重试,新版本未发布时保留仍有效的旧版本。需要可靠恢复时,将待执行任务持久化;也可以把任务事件与文档状态放进同一事务,再异步投递。任务可能重复,所以各后端按文档版本和片段 ID 幂等处理。撤权不能等待清理任务完成才生效,检索和来源服务必须依据已更新的权威权限拦截。

6.5 RAG、原文件和 read_file 各在什么时候出现

这是你之前问过的关键问题:

RAG:按问题从大量资料里找到少量相关片段。
原文件存储:保存完整资料,提供预览和追溯。
来源读取:片段不够时,按文档和版本补取上下文。
工作区 read_file:让 Agent 读取自己的笔记或被授权映射的文本。

检索结果已经带有片段正文和来源时,可以直接作为证据回答,不要求每次都再读原文件。只有需要核对相邻条款、完整表格或引用预览时,再读取对应内容。

来源 ID 不代替正文;原文件存储也不代替检索。新平台不必为了沿用旧项目命名,额外造一个含义模糊的 Corpus 类。重要的是分清“可检索片段”和“完整来源”的职责。

例如主 Agent 需要补读某个条款,正常路径是调用 read_source({docId, version, chunkId}) 这样的受控业务工具。文档服务验证当前用户和版本后,返回邻近条款或预览定位。模型不直接接收一个任意 MinIO 路径去读文件。read_source 是这里用于解释职责的接口名,不是声称仓库已有同名实现。

面试怎么说:

我把多种资料转换为统一检索文本,同时保留原文件、页码、时间段和版本信息。RAG 在问答时选取相关片段,答案引用通过这些字段追溯到原始资料。多模态首先是入库能力,不会把文本化处理夸成已经具备任意跨模态向量搜索。

7. 混合检索与 GraphRAG,分别解决什么问题

7.1 为什么要 pgvector 和 Elasticsearch 两路

向量检索擅长找语义相近的描述。例如“漏水后找谁处理”和“渗漏售后工单”文字不同,但意思接近。

关键词检索更适合产品型号、案例名、编号、专业词等精确线索。“材料 M-208”不能只依靠语义相似度。

因此同一问题可以并行查询:

问题 -> embedding -> pgvector 向量召回
问题 -> 分词与全文查询 -> Elasticsearch BM25 召回
两路结果 -> 身份对齐 -> 融合 -> 重排 -> 少量证据

embedding 是把文本转换成向量,不是在这一阶段生成最终答案。入库和查询需要使用兼容的 embedding 模型、维度和处理约定;换模型后通常不能拿新向量直接查旧索引。

pgvector 可以使用 HNSW 等近似索引来减少搜索开销,但“用了索引就必定更准、更快”并不成立,需要结合数据规模、过滤条件和召回目标测量。

7.2 为什么不能直接相加两个分数

ES 的 BM25 分数和向量相似度不是统一量纲。直接把 8.5 和 0.82 相加,没有可靠的比较意义。

RRF 使用排名而不是原始分数:

RRF(d) = 各路召回中 1 / (k + rank(d)) 的和

rank 从 1 开始,k 是平滑参数。下面只是演示,不是项目调参结果:

路径第 1 名第 2 名第 3 名
向量召回ABC
关键词召回BDA

若取 k = 60:

A = 1/61 + 1/63,约 0.03227
B = 1/62 + 1/61,约 0.03252
C = 1/63,约 0.01587
D = 1/62,约 0.01613

融合顺序:B、A、D、C

注意,要保留同一 chunk 在各路中的排名再累计贡献,最终才输出一条合并记录。不能先把第二路的重复项彻底丢掉,再声称完成了 RRF。

RRF 分数是融合排序信号,不是“答案正确概率”。

7.3 Rerank 又做了什么

RRF 只知道“每一路把谁排在前面”,不重新理解问题与片段的具体关系。

Reranker 会进一步评估“这个问题与这个候选片段是否相关”,把候选集合精排后选少量内容给回答模型。它不是把召回结果再随意打乱,也不是再让生成模型写一遍答案。

你的学习代码用 DashscopeRerank.compressDocuments 接口完成这一步。完整方案可以保持相同的服务边界,但具体 provider、候选数量和截断策略要通过实验确定。

**需要记住的代码事实:**现有混检学习示例是 ES + Milvus,合并逻辑是拼接后按 ID 去重,并没有实现 RRF。本文的 pgvector + ES + RRF 是完整方案的组合设计,不能指着那个示例说它已经包含这三部分。

7.4 图谱补充的是显式关系,不是另一个相似度列表

在示例问题中,“案例用了什么材料,材料由谁供货”可以表达为:

案例 Case -> USES -> 材料 Material -> SUPPLIED_BY -> 供应商 Supplier

向量检索可能分别找到案例介绍、材料介绍、供应商介绍,却不一定准确拼出它们之间的关系。Neo4j 存储明确节点与边,Cypher 用来沿这些关系查询。

**知识图谱是数据组织方式,LangGraph 是执行流程组织方式。**两者都带 Graph,但一个表示业务实体关系,一个表示程序节点与状态转移。

图谱入库可以拆成:

文档片段
  -> LLM 按 schema 抽取实体与关系
  -> 校验类型、字段与来源
  -> 同名实体识别与消歧
  -> 写入 Neo4j,并关联文档/片段/版本

不能仅靠名字相同就合并实体。例如同名供应商、不同型号材料需要稳定业务标识或消歧规则。抽取错误也可能被图传播,因此关系必须保留来源,必要时回看原文。

7.5 用户问题怎样转成 Cypher

你的四节点学习例通过 Prompt 让模型直接生成 Cypher,再交给 graph.query。它适合展示原理,但不能把“Prompt 里写只读”当成数据库安全。

企业知识库可以更受控地实现:

  1. 让模型输出结构化查询意图,例如 case_material_supplier 和案例 ID。
  2. 服务端只接受允许的查询类型和参数。
  3. 选定受控 Cypher 模板,使用参数绑定,不拼接用户字符串。
  4. 数据库使用只读最小权限账户,并设置返回数量、跳数和超时约束。
  5. 校验路径与返回字段的来源权限,只投影有权限的证据。

如果确实允许自由生成查询,还需要更严格的语法/AST 或规则检查。参数绑定只能保护参数位置,不能自动证明任意生成查询都安全。

一条多跳路径的每一段关系都需要授权来源。不能仅检查起点案例可见,就把后面受限采购关系一并返回;也不能从全局实体属性中泄露只由隐藏资料提供的信息。

7.6 图谱结果如何进入回答

不能只给模型一堆孤立名称。应返回可理解的关系、支撑片段和来源标识,把它们转换成与文本证据兼容的输出:

关系:案例 A 使用材料 M,材料 M 的供应商为 S。
来源:doc_case / 版本 3 / unit_table_materials_row_1;另一条供货关系的授权来源片段。
限制:没有找到该批次的采购价格,因此不作价格结论。

主 Agent 再把关系证据与制度、视频证据一起整合。图里有一条边,不代表可以脱离文档来源给出确定业务结论。

面试怎么说:

混合检索覆盖语义表达和精确关键词,先按统一片段身份做 RRF 融合,再用 Reranker 精排。GraphRAG 处理案例、材料、供应商之间的显式关联;图谱和文本结果都要带授权来源,最终按证据回答,而不是把相似度或图关系直接当成事实正确性。

追问:为什么不用向量库解决所有问题?

向量回答“哪些内容相近”,并不天然保证实体间关系和多跳路径。图谱的价值在于显式关系,但它也有抽取、消歧和维护成本,所以我只在确实需要关联查询时使用,不强迫每个问题都走图谱。

8. 权限与记忆:两个最容易讲虚的模块

8.1 RBAC 和文档数据权限不是同一件事

RBAC 的基础是用户、角色和权限关系。它回答“这个角色能否使用资料上传、用户管理等功能”。你的笔记已经设计了 kh_user、kh_role 和 kh_user_role。

但“销售可以使用问答功能”不等于“销售可以查看所有资料”。还需要文档所有者、部门、公开范围或显式 ACL 等数据规则。

完整的请求应该是:

验证登录身份
  -> 查询账号状态和角色权限
  -> 确定本次可访问的文档范围
  -> 所有检索入口都绑定该范围
  -> 返回模型前确认来源仍有效
  -> 点击引用或下载时再次授权

前端隐藏按钮只是交互控制。模型返回的 userId、role 或 allowedDocIds 也不能作为可信身份。

8.2 三个检索后端都要执行范围约束

后端授权需要落在哪里
PostgreSQL / pgvector租户、文档可见关系、有效版本等查询条件
Elasticsearch服务端生成的过滤条件,必要时结合权威数据校验
Neo4j节点、关系和投影字段的授权来源约束

不能先把所有部门的正文交给模型,再要求它“不要说出机密”。只要内容已经进入模型上下文,就已经越过了数据边界。

这里的“检索前过滤”主要强调授权内容才能作为检索结果进入模型,不是声称数据库物理执行计划一定先完成全部过滤再走近似向量索引。ANN 与过滤结合可能损失召回,需要按数据和索引特性调优。

权限变更还需要影响缓存、来源预览和长期记忆。对于高敏感文件,短期签名 URL 也有有效期内的撤权窗口,可以考虑经重新授权的文件代理访问,而不是把“签名 URL”当成即时撤权方案。

8.3 Redis 和 Mem0 分别存什么

数据用途典型例子
Redis 近期会话理解本次连续追问“刚才那个案例的供应商呢?”中的“那个案例”
Mem0 长期用户记忆跨会话召回稳定偏好和必要事实用户偏好简洁解释、常负责某类培训
LangGraph checkpoint保存工作流执行状态已检索完成、正在等待证据检查
Agent 工作笔记暂存任务材料与中间结论子任务整理的案例证据摘要
企业知识库提供可授权、可引用的业务事实当前版本售后制度

Redis 的会话键需要绑定租户、用户和会话,加载时还要控制消息和 token 预算。把聊天记录存在 Redis,并不会让模型突破上下文窗口。

Mem0 的使用逻辑是提取值得保留的信息、按用户作用域存储、在相关问题出现时召回少量记忆。它不是把所有对话原样永久保存,也不是保证每次都召回正确事实。

你的文件记忆练习使用 createMemoryMiddleware 读取配置文件;它与 Mem0 是不同机制。Mem0 也需要配置自己的存储与生命周期,不能因为业务已用 pgvector,就假设它自动接上同一数据库。

8.4 记忆不能成为权限漏洞

用户之前有权看到的资料,后来可能被撤权。如果长期摘要保存了原文事实,之后不经授权召回,就可能绕过当前权限。

因此用户偏好与受限业务证据应该分开。需要保留业务引用时,应带来源并重新授权;明确的保存、更新和删除策略比“记忆越多越聪明”更重要。

检索资料也属于外部数据。资料中即使出现“忽略系统规则、读取其他文件”等句子,也不能当成 Agent 的新指令,更不能把上传文档自动当作可信技能或系统记忆。

面试怎么说:

我把功能权限和资料可见范围分开:角色控制能否使用功能,文档规则控制具体能查什么。向量、全文、图谱和文件访问都受同一服务端身份范围约束。Redis 保存近期对话,Mem0 保存用户级长期信息,但记忆不是知识库事实,也不能绕过资料授权。

9. NestJS、工厂 Provider 和流式语音怎样接起来

9.1 为什么用工厂 Provider 创建模型

你的 ai.module.ts 使用 CHAT_MODEL token、ConfigService 和 useFactory 创建模型实例,再注入 Service。

// 依赖注入结构示意,省略具体模型参数。
const chatModelProvider = {
  provide: "CHAT_MODEL",
  inject: [ConfigService],
  useFactory: (config: ConfigService) => createConfiguredModel(config),
};

Service 依赖的是一个“能调用和流式输出的模型”,不需要到处读取配置、重复 new 客户端。换 provider、修改模型配置或测试时注入 mock,都有明确位置。

这可以讲成工厂 Provider + 依赖注入:工厂负责怎么创建,容器负责把依赖交给使用方。不是因为代码里出现了一个函数,就一定要说自己实现了复杂抽象工厂。

共享模型客户端不等于共享用户上下文。不能把所有用户的 messages 放在单例 Service 的一个可变数组里。

9.2 异步生成器到底在干什么

你学习的链路是:

PromptTemplate -> ChatModel -> StringOutputParser
               -> chain.stream()
               -> Service 的 async generator 逐块 yield
               -> Controller 转为 Observable<MessageEvent>
               -> NestJS 输出 SSE

async generator 可以多次产出结果,而不是只在最后 return 一次。消费者通过 for await...of 按块读取。

// 机制示意:chain 是已配置的 LangChain runnable。
async function* streamText(chain, question) {
  const stream = await chain.stream({ question });
  for await (const text of stream) {
    yield text;
  }
}

yield 不是建立网络连接,也不是创建线程;它负责逐步交付数据。Controller 再把这些数据编码成 SSE 事件。

完整知识库中,可以把最终回答的增量输出适配到这一接口。不要把工具调试信息、内部 Prompt 和任意子 Agent 消息都当成用户可见答案。

9.3 SSE 和 Vercel AI SDK 的关系

SSE 是传输事件的方式;Vercel AI SDK 是前端消息状态与 AI 交互工具集,两者不在同一层。

Nest 原型的 @Sse 返回普通 SSE,并不保证它可以被 AI SDK 的默认 transport 直接消费。需要明确采用其支持的 UI 消息协议,或编写 transport/协议适配,处理文本增量、消息标识、完成和错误等语义。

浏览器原生 EventSource 常用 GET,而且不便自定义请求头;需要 POST 消息体或 Bearer 鉴权时,可以采用 fetch 流式读取等实现。不要简单说“SSE 协议只允许 GET”。

9.4 ASR 和 TTS 是两个方向

输入:麦克风 -> 录音 Blob -> 服务端 ASR -> 文本问题 -> 正常问答
输出:可输出的回答文本 -> 句子缓冲 -> TTS -> 音频分片 -> 播放

ASR 是语音转文字,TTS 是文字转语音,embedding 是文本向量化。三者不能混为一谈。

你的学习代码分别练习了浏览器录音、独立 ASR 调用、WebSocket TTS,以及 Nest SSE。理解完整方案时,要知道这些链路需要通过服务端接起来,不能把前端请求某个 URL 等同于后端已经实现。

9.5 为什么文本用 SSE、音频用 WebSocket

文本回答主要是服务端单向增量推送,SSE 简单直接。WebSocket 支持二进制帧,也适合音频会话的发送、完成和中断控制。

但不是“SSE 绝对不能传音频”。可以编码传输,只是通常不如二进制通道自然。协议选择是对场景的权衡,不是绝对规则。

还要区分两段连接:

后端 -> TTS 云服务:鉴权、发送待合成文本、接收音频。
浏览器 -> 自己的后端:接收可播放音频与任务状态。

云服务密钥和签名逻辑应留在后端,不能把学习脚本中的服务凭证搬进浏览器。

9.6 真正容易被追问的是缓冲和取消

模型的 token/chunk 边界不等于句子边界。每来几个字就发一次 TTS,可能导致停顿、发音不自然、调用过多。因此要结合标点和长度做文本缓冲,同时控制等待时间。

音频还需要顺序、缓冲和播放格式处理。收到一个二进制帧,不代表它一定是能独立解码播放的一首 MP3。

用户点击停止时,应沿请求传播取消信号,停止后续模型/TTS 工作并清理资源;重连、断线和用户主动取消也要区分。服务端对正常完成信号、超时、异常都应有明确收尾逻辑,不能只在某个 callback 打印“结束”。

9.7 流式输出怎样与最终引用保持一致

先区分两件事:**引用 ID 确实存在,不等于这句话一定被引用支持。**前者能做确定性校验,后者还需要证据核对、生成约束和评估。

一种清晰的接口约定是:正式输出本次正文前,固定 runId 与 evidenceSetId,保存所用文档版本、证据单元和允许引用的映射。若需要补充新证据,要在正文生成前更新这份快照,不能边改来源、边让同一个引用指向不同内容。

事件表达什么前端如何处理
status正在检索或核对,不是业务结论展示处理状态
evidence_ready本次正文使用的证据集合已确定关联 run 与证据集合
text_delta带 answerId、revision、序号的正文增量按序号去重并追加,尚未视为最终成功
final权威全文快照,带同一 answerId/revision、lastSeq、replace=true 及引用、缺口和完成结果原子替换该答案的累积正文,不再追加;然后标记结束
error / cancelled出错或取消,属于另一种终止结果标记不完整,不伪装成完整回答

这是业务事件语义示意,不是 Vercel AI SDK 已支持的原样协议;接入时仍需适配 transport 和消息状态。

这里明确选择“最终全文快照”方案,避免前端把 delta 累积结果与 final 全文拼接两遍。一次生成固定 answerId/revision,重复序号不重复追加;已终止任务的迟到增量不能覆盖最终结果。若重新生成,使用新的生成身份或显式 revision,不悄悄修改旧答案。断线恢复则应读取已持久化快照或按事件游标恢复,不从“最后一段看起来完整”猜测完成状态。

服务端检查引用标记只能来自该 evidenceSetId,最终引用关联的是实际使用的版本。若保留历史,先可靠保存最终正文及引用,再发成功的 final。连接断开但没有收到成功终止事件,客户端不能仅凭“已有一段文字”就认为任务完成;断线续传还需要持久化事件与游标,不是 SSE 自动提供。

流式文本已经发出后,无法靠最后一次校验让用户“没看见过它”。普通场景可以在流中检查引用标记,并在失败时明确标为未完成;需要先保证整篇内容通过审核的高风险场景,应先生成、校验再交付,接受更高首字延迟。不能同时承诺任意实时输出与事前完整语义审查。

回答结束也不代表所有问题都有答案;final 可以明确给出“已确认部分 + 未解决缺口”。取消时还要停止后续 TTS,区分已经播放的内容和尚未合成的内容。

面试怎么说:

NestJS 通过工厂 Provider 统一注入模型,Service 用异步生成器逐段输出,Controller 转成 SSE 交给前端。语音输入先 ASR 转文本再走相同问答链路,输出则按句缓冲后送给流式 TTS,通过音频通道播放。AI SDK 负责前端消息交互,后端需要适配它的协议,而不是任意 SSE 都自动兼容。

10. 评估和 Trace:一个判断效果,一个解释过程

10.1 LangSmith / OpenEvals 怎样做 RAG 评估

你的学习笔记已经有完整思路:固定问题集,定义应用执行函数 target,配置评估器,调用 evaluate(target, config) 跑实验。

在知识库里,target 的结果可以整理为:

{
  "answer": "根据授权资料组织的回答",
  "contexts": ["实际使用的证据片段正文"],
  "sourceIds": ["doc_policy"],
  "traceId": "本次任务的追踪标识"
}

这也是示意契约,不是 OpenEvals 强制的固定字段。评估适配器负责把应用结果转换为各评委需要的输入。

指标评委主要看什么要回答的问题
检索相关性问题 + 检索上下文找来的资料和问题有关吗?
回答忠实度实际上下文 + 回答回答是否被给到的材料支持?
帮助性问题 + 回答有没有回应用户需求、是否清楚有用?

你的 evaluators.mjs 正是分别限制评委可见输入,而不是把所有内容无差别塞给每个 judge。

三个分数分开,才容易归因:资料不相关先查召回与重排,资料相关但出现编造再查生成约束,依据充分却没有回答问题则查回答策略。但这只是排查方向,不是自动证明唯一原因。

忠实度也不等于事实正确性:如果原资料过期,回答忠于它仍可能不符合当前业务。需要正确、有效的资料和必要的参考答案/人工评审共同约束。

10.2 “离线评估”不代表不联网

这里的离线,是相对实时用户流量而言:拿固定测试集,重复比较系统版本。

它完全可能联网调用真实模型和 LLM judge,并产生费用。在线评估则面对实际运行数据或抽样请求。不要把这两个词解释为“本地假模型”和“真实模型”。

LLM judge 分数也不是天然准确率。若要报告 Recall@K、引用正确率或越权率,需要相应标注和明确计算规则,不能把一个 0 到 1 的 judge 分数改名成“准确率”。

10.3 测试集应该覆盖什么

  • 能直接从一份制度回答的普通问题。
  • 包含型号、案例编号的精确问题。
  • 需要跨文档和多跳关系的问题。
  • 图片 OCR 或视频转录中的问题。
  • 用户无权限、资料不存在、证据冲突、版本已更新的问题。
  • 连续追问及长期记忆可能误导当前回答的问题。

比较版本时固定数据集、judge、Prompt 与关键配置,记录 embedding、分片、召回和重排等实验变量。反复根据同一批题调参后,还需要独立验证题,避免只对测试集“做熟题”。

10.4 Trace 是怎样生成的

中间件、框架回调或官方集成在执行边界记录事件,并把它们关联成父子 span。一个典型结构是:

一次问答 request
  主 Agent
    模型调用
    task:证据搜集子任务
      子 Agent 模型调用
      retrieveEvidence 工具
        LangGraph 双路召回
        融合与重排
        证据检查
    最终回答模型调用
  TTS 处理

每个 span 可以记录 traceId、spanId、parentSpanId、组件名称、耗时、错误、证据数量以及模型实际返回的 usage。

中间件记录的是调用过程,不等于获取了模型完整的内部思维。只有调用次数也算不出精确成本,成本还需要输入/输出 token、模型价格和缓存等计费信息。

LangSmith 和 Langfuse 都具备不止一种能力。项目选择前者管理开发实验、后者承接运行观测,是职责安排,不是“一个只能开发、一个只能生产”。小项目也可以先用一个平台,避免重复采集和重复成本。

10.5 pytest 或 Vitest 在这里干什么

测试框架负责执行场景和断言,不是自动产生 Trace 的来源。

例如用模拟模型连续返回“调用检索工具、生成答案”,再断言:

  • 工具调用前后有对应记录,父子 span 正确关联。
  • 模型次数与预期相符,工具异常有错误状态。
  • 没有把密钥、完整隐私原文或其他用户会话写进日志。
  • 补查达到预算后一定退出,无权限资料从未进入模型输入。

新平台采用 TypeScript,可以使用对应的 JS/TS 测试工具;旧项目使用 pytest 不能直接证明新平台的中间件已经被覆盖。

面试怎么说:

我把效果评估和运行观测分开。固定问题集通过 target 和 OpenEvals 评估器比较检索相关性、忠实度与帮助性;运行时用框架回调或中间件在模型、工具和子任务边界记录父子调用关系。测试框架验证这些行为,不负责凭空生成调用链。没有实验数据时,我只说明方法,不编造提升比例。

11. 设计模式与简历条目,怎样讲成自己的理解

11.1 只讲确实解决问题的模式

设计思想对应位置解决的变化不要夸成什么
分层与职责分离接口、Agent、检索流程、存储服务HTTP、决策和数据处理可以分别变化不是每层都要拆成微服务
工厂 Provider + DINest 模型创建与注入模型配置、供应商、测试替身不一定是 GoF 抽象工厂
策略按文件类型选择解析方式,按问题选择检索方式增加格式或检索策略不是出现 if 就叫策略模式
适配器统一解析输出、检索证据和流式协议不同 provider 的输入输出差异不只是给旧函数换名字
中间件组合模型与工具的 hook / wrapper观测、计量、上下文等横切逻辑不把安全全交给日志层
显式状态机LangGraph 节点、state 和条件边检索分支、补查、退出和调试不等于自动拥有持久化保证
Agent-as-tool 委派主 Agent 调用 task隔离局部执行与结果交接这是架构做法,不必冒充经典 GoF 名称

最有说服力的回答格式是:原来会有什么耦合 -> 在哪里建立边界 -> 新需求出现时改哪里 -> 代价是什么。

例如新增音频格式时,改解析器和适配层,不必重写 Agent;新增模型时,优先改 Provider;修改补查策略时,改检索图,而不是把限制散落在所有 Prompt。

11.2 把简历的六项亮点逐条展开

简历条目面试必须讲清的实现本文位置
多模态知识入库解析器、Markdown、结构化来源、分片、MinIO 与索引版本第 6 节
Agent 编排与 RAGDeepAgents 中间件、主子 Agent、检索子图、混合召回与重排第 3、4、5、7 节
知识图谱增强实体关系抽取、消歧、受控 Cypher、多跳来源第 7 节
权限与记忆管理RBAC/ACL、检索范围、来源读取、Redis/Mem0 差异第 8 节
语音与流式交互Provider、async generator、SSE 协议、ASR/TTS 与取消第 9 节
评估与可观测性固定集、不同 judge 输入、实验变量、父子 span第 10 节

不要只背“我用了这些技术”。每一条至少准备一个输入、一个中间结果、一个最终输出,以及一个失败场景。

多模态实现细节以专题为主:第 10.5 节有实例级来源账本,第 11.6 节解释怎样真正把画面送给模型,第 16.1 节给解析产物契约,第 19 节提供不用模型的验证练习。

11.3 项目介绍模板

下面是架构介绍模板。介绍时可以解释完整设计,但被问到实际落地、数据规模或运行效果时,必须回到自己能展示的代码和记录,不把模板当作虚构经历。

这个项目面向企业内部培训和业务答疑,主要解决资料分散、跨资料查找以及部门权限隔离的问题。

我把系统分成资料入库和智能问答两条链路。入库时把文档、图片和音视频转成统一检索文本,用 MinIO 保留原文件,同时保存页码、时间段和版本。问答时,DeepAgents 主 Agent 判断任务,简单问题直接检索,复杂问题才委派独立上下文的子 Agent。

检索工具内部用 LangGraph 组织双路召回、融合重排、关系查询、证据检查和有限补查。pgvector 处理语义,ES 处理关键词,Neo4j 补充材料、案例和供应商等显式关联。主 Agent 整合证据并返回引用,不需要接收子 Agent 的全部执行历史。

另外,权限在服务端检索和来源访问时落实,Redis 与 Mem0 分别处理近期对话和长期用户信息。文本走 SSE,语音先 ASR 再问答,回答按句送到 TTS。效果用固定测试集比较,调用过程通过模型、工具和子任务边界的 Trace 定位。

11.4 面试官继续追问时,先回答哪个核心点

问:你的 Agentic 在哪里?不就是普通 RAG 吗?

如果永远是检索一次再回答,那只是普通 RAG。这里的 Agentic 体现在根据问题选择工具,必要时分解任务,并依据证据缺口决定是否补查;但循环边界、权限和预算仍由程序约束。Agentic 不代表所有步骤都让模型自由决定。

问:为什么你既用 DeepAgents,又用 LangGraph?是不是重复建设?

一个是任务级 Agent 运行框架,一个是我明确组织的检索业务子流程。DeepAgents 调用这个流程作为工具。简单流程可以只用函数,我选择图是为了表达分支、补查状态和退出条件,不是为了凑技术栈。

问:中间件模式究竟给你带来什么?

模型与工具调用是所有任务的共同边界。把计量、日志和通用约束放在这些边界,可以不修改每个检索工具就统一增强。wrapper 能决定是否继续执行,hook 能更新状态;它不只是 console.log,也不应该吞掉全部业务逻辑。

问:给每个子 Agent 换个 Prompt,就叫上下文隔离吗?

不够。还要检查实际传入的 messages 和状态,避免继承整段父对话;规定结果只包含必要结论和来源。文件 backend、共享 state 与资料授权要另外限制,不能由换 Prompt 自动保证。

问:怎样证明比单 Agent 更好?

要固定问题集比较总 token、时延、证据覆盖和回答质量,尤其区分简单与复杂任务。没有这组对照结果,我只能解释设计动机,不能声称多 Agent 一定更省或更准。

问:如何处理知识库里没有答案的情况?

先判断是否存在可通过补查弥补的资料缺口;在预算内补查,之后仍没有依据就返回不能确认的部分。不能因为用户期待一个答案,就用用户记忆或模型常识补造企业制度。

问:用了这么多存储,会不会过度设计?

每个存储要有独立价值:PG 管业务与向量,ES 管全文,Neo4j 管关系,MinIO 管对象。图谱价值不明显时可以不走图查询,不同能力也允许独立降级。但多索引带来同步与权限维护成本,必须在收益与复杂度之间取舍。

问:你遇到过什么具体问题?

可以讲自己学习代码中真实修复过的问题,例如 subagents 配置大小写导致子 Agent 没注册,摘要选项放错中间件导致配置未生效,或 SDK 的实验 API 与预期不同。讲现象、定位依据和修复原因,不把普通接口调试包装成未经验证的生产故障。

11.5 从“能解释”到“能证明”,还差哪类证据

这张表不是开发阶段说明,而是防止面试时把不同证据混用。完整架构可以介绍,但“这里有独立样例”与“平台已端到端验证”不是同一结论。

简历主线当前可回看的依据还不能从中推导什么
DeepAgents 与中间件主/子 Agent、文件后端、hook/wrapper 的学习代码已接通新平台权限、媒体与结果协议
混合检索与图谱ES/Milvus 混检、重排、四节点图谱示例新平台 pgvector/RRF 和企业图谱已集成
多模态入库四类资料方案、语音独立调用样例、本文数据契约PDF/表格/视频链路已完成真实验收
权限与记忆RBAC 表设计、框架记忆练习、本文授权设计企业 ACL、Redis/Mem0 已端到端联动
流式与语音Nest SSE、浏览器录音及独立 ASR/TTS 示例上传到问答再到引用播放已完全串通
评估与 TraceLangSmith/OpenEvals 文本 RAG 示例新平台已有多模态评估成绩和 Langfuse 运行数据

这些边界对应项目 README 的材料清单。本轮只审核文档与教学例子,不用旧 Python 测试冒充新平台集成证据,也不把文章里的免费练习称为 OCR/ASR 或真实检索效果测试。

被问到具体落地时,按“我能展示的代码是什么 -> 它验证了哪一段 -> 完整集成还需要哪些契约”回答。实际证据变了,再更新这张清单;不需要在每个原理段落里重复进度说明。

12. 读完后,用六个场景检查自己是否真正理解

场景一:用户只问“售后制度在哪”

合理路径是主 Agent 直接调用受控检索工具,返回制度来源。没有必要为了展示多 Agent,先启动调研员、分析师和编辑。

场景二:检索结果包含完整相关条款和来源

可以直接基于片段回答,不必再 read_file 通读整份制度。需要补充上下文时才按来源读取。

场景三:子 Agent 返回几十页工具日志

不符合精简交接目标。应约束结果 schema、证据数量和长度,把完整轨迹留给观测系统,不直接进入主 Agent 上下文。

场景四:图谱查到供应商,但其供货关系来自无权访问的资料

不能仅因案例名称公开就返回关系。需要路径和字段级的授权来源;缺少可见证据时明确说明无法确认。

场景五:模型说证据不足,开始一直补查

检查自定义图的退出条件和任务级预算。Prompt 中“最多三次”只是指导,必须有程序计数、截止时间和结束路径。

场景六:评估分数高,但员工仍说答案错

检查资料是否过期、测试问题是否覆盖真实任务、judge 是否只测忠实度、引用是否正确,以及是否遗漏权限或业务规则。不能用一个平均分替代问题分析。

如果能把这六个场景不看稿讲通,你理解的就不再是技术名词,而是系统的控制流、数据流和边界。

附录:回到你的学习笔记核对

A. 笔记里几句话,面试时要补上条件

学习时的简化说法更准确的表述
LangChain 是线性的,LangGraph 是网状的LangChain 提供组件和 Agent API,也能组织执行;LangGraph 更显式地表达状态、分支、循环等控制流
多 Agent 消耗更少、效果更好它能缩小各角色上下文,但总调用与交接有开销,需要对照实验
MemorySaver 保存后就能恢复内存保存不等于跨进程持久化;恢复还需要 thread、存储和执行控制
中间件不影响 Agent不必侵入业务代码,但可以改变输入、状态、输出与控制流
有图谱就有推理链图谱提供关系路径,是否可靠仍取决于抽取、来源、权限与查询
向量和关键词去重就是 RRF去重只统一身份,RRF 还要保留并累计各路排名贡献

B. 阅读入口

以下链接使用已核对的学习仓库版本。它们是原理和模块示例依据,不是新平台全部集成完成的证明。

要复习的内容对应笔记或代码
企业知识库场景、技术选型、权限、数据库项目笔记
DeepAgents 定位与主子 Agent 分工DeepAgents 笔记
模型生命周期与状态计数middleware-test.mjs
环绕工具、ToolMessage 与 Commandmiddleware-test2.mjs
主 Agent、subagents、文件后端与技能agent.mjs
文件记忆与自动摘要的不同机制memory-agent.mjs、summarization-agents.mjs
双路召回与当前去重、重排实现混检笔记、hybrid-retrieval.mjs
图数据库与四节点 LangGraph 练习GraphRAG 笔记、graphrag.mjs
ASR/TTS、SSE 与 WebSocket 的选择语音笔记
工厂 Provider、生成器和 SSE ControllerNestJS AI 模块
固定测试集、评估器和实验 APILangSmith 评估笔记

本地还可对照 LangGraph 学习笔记;框架版本核对来自 本机 DeepAgents package.json。项目实际实现边界见 README。

最后记住一条完整链路:NestJS 确认谁能问什么,DeepAgents 决定怎样完成任务,自定义 LangGraph 约束怎样找证据,存储服务提供授权数据,主 Agent 负责有依据的表达,流式层交付结果,评估和 Trace 分别解释效果与过程。