目录

  1. 前言
  2. 前置环境与依赖准备
  3. 整体架构与选型思路
  4. 核心模块代码实现与面试考点拆解                                                                                        4.1 基础配置与大模型初始化                                                                                                4.2 文档加载与语义分块实现                                                                                                4.3 Embedding 向量化与 Milvus 向量库构建                                                                        4.4 向量检索 + BGE 重排模块                                                                                              4.5 对话记忆管理                                                                                                                  4.6 Agent 工具定义与智能体搭建                                                                                          4.7 项目统一入口封装
  5. 开发调试踩坑复盘
  6. 落地常用的轻量化优化思路
  7. 全文面试考点自检清单
  8. 写在最后

前言

这段时间一直在做大模型落地相关的工作,知识点散得很,想着不如自己手搓一套完整的 RAG+Agent 知识库,把文档分块、向量化、检索、重排、对话记忆、工具调用、增量更新这些常碰到的点全串进去。一边写代码一边顺面试里常被问到的问题,比死记硬背知识点印象深多了。

向量库这次用的 Milvus,也是平时项目里用得最多的方案,从几万条数据到上亿条都能撑住。部门内部知识库、业务文档助手这类场景,拿这套架子改改就能直接用。

写的时候特意把每段代码对应的面试题和答题思路都嵌在下面了,看完代码直接看考点,不用来回翻,顺着思路走不脱节。文末也整理了纯问题清单,只是借鉴用,别真的傻傻的拿去面试说了。


2. 前置环境与依赖准备

我本地用的 Python 3.10.20,版本别太低,LangChain 一些新特性对老版本兼容不好。模型用的阿里云通义千问 API,国内访问稳定,Embedding 和 Rerank 用同一套密钥就行,不用对接多家平台。

向量库用 Milvus,生产环境很常用。本地开发调试直接用 Docker 起单机版就行,不用搭复杂集群,等真要上线了再切集群版,业务代码不用动。

先装 Python 依赖:

pip install langchain langchain-community langchain-core pymilvus \
    sentence-transformers dashscope python-dotenv pydantic

本地启动 Milvus 单机版:

wget https://github.com/milvus-io/milvus/releases/download/v2.4.0/milvus-standalone-docker-compose.yml -O docker-compose.yml
docker compose up -d

默认服务端口 19530,自带的 Attu 可视化管理界面在 3000 端口,建集合、看数据都很方便,调试的时候省不少事。

需要提前准备的东西:

  • 阿里云 DashScope 的 API Key
  • 运行中的 Milvus 服务(本地单机版或者远程集群都可以)
  • 几份测试文档(TXT、Markdown、PDF 都行,我用的是公司内部的制度流程文档)

3. 整体架构与选型思路

先把完整数据流说清楚:用户发提问过来,带着会话 ID 取到历史对话记忆,一起交给 Agent;Agent 自己判断该直接回答还是调用工具 —— 如果是制度、流程类问题,就走 RAG 链路:先从 Milvus 里召回一批候选片段,再用 Rerank 精排,拼好上下文喂给大模型生成答案;如果是计算、查时间这类问题,就直接调对应工具;最后把结果返回给用户,同时更新对话记忆。

整体可以拆成几层,大家心里有个框架就行:

  • 接入层:统一对话入口,收提问、返结果、管会话 ID,支持流式输出
  • 智能体层(Agent):做任务规划、选工具、拼结果,用的 ReAct 模式
  • 工具层:知识库检索、计算器、时间查询,后面可以随便加新工具
  • RAG 核心层:文档分块、向量化、向量检索、重排、Prompt 组装
  • 存储层:Milvus 向量库、对话记忆、原始文档、元数据

聊几个关键选型的考虑,这块面试基本都会问到。 为什么选 Milvus?主要是看长期扩展性,一开始数据量小的时候感觉不出来,等后面文档越积越多、并发上来,专用向量数据库的优势就很明显。Milvus 支持分片、多副本、水平扩容,性能和稳定性都经过验证了,从几十万条到上亿条向量都能扛。本地开发用单机版,上线切集群,代码几乎不用改,迁移成本很低。 为什么一定要加 Rerank?纯向量检索是算语义相似度,召回的结果里经常混着一堆沾边但没用的片段。Rerank 是逐句判断相关性,准度高很多,加一层之后效果提升特别明显,属于花很小代价换很大收益的优化,基本做 RAG 都会这么搭。 为什么把 RAG 做成 Agent 的一个工具,不是直接做 RAG 对话?主要是为了后面好扩展。不可能永远只做知识库问答,后面说不定要接数据库查询、内部 API 调用、文件处理,用 Agent 架构打底,加新工具不用动主流程,扩展性好很多。

对应面试考点(附答题思路)

  1. 你们的 RAG+Agent 整体架构是怎样的?完整数据流怎么走? 答:整体是分层架构,从接入到存储一共五层,数据流很清晰。用户提问先到接入层,携带会话 ID 拉取历史对话记忆,一起传给 Agent 层;Agent 基于 ReAct 模式做任务规划,判断问题类型选择对应工具:如果是文档类问题,就调用知识库检索工具,先从 Milvus 向量库粗召回 Top10 候选,再经过 Rerank 精排选出 Top3 相关片段,拼接成上下文后传给大模型生成答案;如果是通用工具类问题,直接执行对应工具;最后 Agent 把结果整合返回,同时更新对话记忆。

  2. 向量数据库选型你是怎么考虑的?Milvus、FAISS、Chroma、ES 有什么区别和适用场景? 答:选型我主要看三个点:数据规模、运维成本、扩展能力,会结合项目当前阶段和未来增长来选,不会一上来就堆最重的方案。 四个方案的定位差别挺大的:

    • FAISS 是纯算法工具库,不是完整的数据库,纯内存检索速度很快,但没有持久化,没有服务端,也不支持分布式,只适合本地做 Demo、算法实验,不能直接上生产。
    • Chroma 是嵌入式轻量向量库,开箱即用,不用单独部署,但分布式和高并发能力弱,适合原型验证、小型内部工具,数据量在十万级以内用没问题。
    • ES 本质是全文搜索引擎,向量检索是附加功能,优势是能同时做关键词 + 向量混合召回,如果团队本来就有 ES 栈,不用额外引入新组件,但纯向量性能不如专用库,适合中等规模、需要混合检索的场景。
    • Milvus 是专门的分布式向量数据库,性能、稳定性、扩展性都很好,支持亿级数据、分片多副本、水平扩容,是生产环境很常用的方案,适合数据量持续增长、对稳定性有要求的项目。 我们项目考虑到后续文档会持续新增,所以选了 Milvus,前期用单机版开发,后期扩容也很平滑。
  3. RAG 链路里为什么要加入 Rerank?它和向量检索的本质区别是什么? 答:加 Rerank 主要是为了提升检索准确率,属于性价比很高的优化。向量检索用的是双编码器,query 和文档分别编码成向量,再算相似度,好处是快,可以全库检索,但因为编码是独立的,对细粒度相关性的判断没那么准,召回结果里容易混进不相关内容。 Rerank 用的是交叉编码器,会把 query 和候选文档放在一起做注意力计算,逐字判断相关性,准度高很多,但计算量大,没法直接搜全库。 所以一般都是两级架构:先用向量检索从全库里快速召回几十条候选,再用 Rerank 对这几十条做精排,兼顾速度和准确率。我们自己测下来,加了 Rerank 之后 Top3 的命中率能从六成提到九成左右,提升很明显。

  4. Agent 的规划模式你了解哪些?ReAct 和 Plan-and-Execute 各有什么优劣? 答:常用的主要是 ReAct、Plan-and-Execute,还有大模型原生的 Function Calling。 ReAct 是思考 - 行动 - 观察循环走,每想一步就执行一次工具,拿到结果再想下一步,优点是轻量、省 token、调试直观,能根据中间结果灵活调整,适合工具不多、任务不复杂的场景;缺点是面对多步复杂任务容易跑偏,没有全局规划。 Plan-and-Execute 是先做整体规划,把任务拆成子步骤,再一步步执行,优点是对长任务把控力强,逻辑更连贯;缺点是 token 消耗大,执行链路长,简单任务用有点重。 我们这个项目以知识库问答为主,工具数量不多,任务复杂度不高,用 ReAct 就足够了,轻量高效;后面如果接复杂业务流程,再考虑升级成规划 + 执行的模式。

  5. 把 RAG 封装成 Agent 工具和直接做 RAG 对话相比,有什么优势和劣势? 答:优势主要是扩展性好,系统能力不局限于查文档,后面加数据库查询、API 调用、工单创建这些工具都很方便,不用改主架构;另外 Agent 可以自己判断要不要查知识库,简单问题直接回答,能减少不必要的检索,省成本。 劣势是多了一层 Agent 调度,链路更长,延迟会高一点;而且对 Agent 选工具的准确率有要求,选错工具反而会影响效果,如果是纯知识库问答的简单场景,这么搭有点过度设计。 我们是考虑到后面会扩展更多业务工具,所以提前用 Agent 架构打底,后续迭代成本更低。


4. 核心模块代码实现与面试考点拆解

4.1 基础配置与大模型初始化

先把配置抽出来,用 dotenv 管密钥和地址,别硬写在代码里,基本的工程规范还是要有的。

import os
from dotenv import load_dotenv
from langchain_community.chat_models import ChatTongyi
from langchain_community.embeddings import DashScopeEmbeddings
from langchain_community.document_compressors import DashScopeRerank

# 加载环境变量
load_dotenv()
DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY")
MILVUS_HOST = os.getenv("MILVUS_HOST", "localhost")
MILVUS_PORT = os.getenv("MILVUS_PORT", "19530")

# 初始化对话大模型,qwen-plus平衡效果和速度
llm = ChatTongyi(
    model="qwen-plus",
    api_key=DASHSCOPE_API_KEY,
    temperature=0.1,  # 知识库场景温度调低,减少幻觉
    streaming=True
)

# 初始化Embedding模型
embedding = DashScopeEmbeddings(
    model="text-embedding-v2",
    api_key=DASHSCOPE_API_KEY
)

# 初始化Rerank重排模型
reranker = DashScopeRerank(
    model="gte-rerank",
    api_key=DASHSCOPE_API_KEY,
    top_n=3  # 重排后保留Top3片段
)

最开始我试过本地用 sentence-transformers 加载 BGE,CPU 跑起来太慢了,换成 API 调用省心很多。如果是内网部署不能调外网 API,换成开源本地模型也可以,LangChain 接口都兼容,改动不大。

对应面试考点(附答题思路)

  1. 大模型的 temperature 参数你是怎么设置的?不同业务场景怎么调? 答:知识库场景我设的是 0.1,调得比较低。因为知识库问答要求准确严谨,不能瞎编,低温度能让输出更稳定,更贴合给定的上下文,减少幻觉和发散。 不同场景取值不一样:创意写作、头脑风暴这类需要发散的,一般设 0.7 到 1.0;代码生成、知识问答这类要准确的,设 0.1 到 0.3;事实抽取、分类这种要稳定输出的,甚至可以设到 0。 一般不会同时大幅调 temperature 和 top_p,以一个参数为主就行。

  2. Embedding 模型选型你关注哪些指标?中文场景为什么常用 BGE 系列? 答:主要看这几个点:语义召回效果、向量维度、推理速度、部署成本、商用授权。核心肯定是效果,尤其是对应垂直领域的表现;然后维度要平衡,维度越高效果越好,但存储和计算成本也越高。 中文场景常用 BGE 系列,因为它是专门针对中文优化的,在中文评测集上的表现比早期通用模型好很多,而且轻量、开源可商用,本地部署也方便。我们用的通义 Embedding 也是同技术路线,不用自己部署运维,调用 API 就行。

  3. 流式输出是怎么实现的?SSE 和 WebSocket 适用场景有什么区别? 答:流式输出本质就是服务端边生成边返回 token,前端逐段渲染,不用等整段生成完,能降低首字延迟,体验好很多。 常用的实现方式是 SSE 和 WebSocket。SSE 是基于 HTTP 的单向推送,只有服务端往客户端发数据,实现简单,浏览器原生支持,适合对话问答这种只有服务端推流的场景,大部分 RAG 应用用 SSE 就够了。 WebSocket 是全双工双向通信,客户端和服务端都能实时发消息,适合高频交互的场景,比如实时协作、复杂指令下发,但实现和维护更复杂,资源消耗也更高。 我们这个是知识库问答,用 SSE 就够用,简单稳定。

  4. Rerank 的 top_n 你设多少?怎么确定这个数值? 答:我设的是 3。这个值是结合上下文窗口和实测效果定的:单块文档大概 500 字符,3 块就是 1500 字符左右,加上问题和历史对话,不会占太多上下文窗口,留给生成的空间还很充足;另外实测下来 Top3 基本能覆盖九成以上的相关内容,再加更多块收益越来越小,反而会引入噪声,还增加 token 成本和延迟。 具体数值没有统一标准,核心是平衡召回完整度和上下文噪声,文档内容散就适当调高,内容集中就调低,一般 2 到 5 都常用,最终要拿自己的业务数据测。

易错点提醒:很多人只知道 temperature 越大越随机,说不出不同场景的具体取值;还有人分不清 Embedding 和 Rerank 的原理区别,这都是容易被问住的地方。

Embedding 与 Rerank 原理核心区分

1. 网络结构差异

1)Embedding(双编码器) query、文档文本分开独立编码,互不交互。

  • 离线:所有文档提前算好向量存入 Milvus;
  • 在线:仅实时编码用户问题向量,做相似度匹配;
  • 优点:速度极快,支持全库百万 / 亿级检索;
  • 缺陷:两段文本编码全程无交互,细粒度语义匹配弱,容易召回看似相近、实际无关的内容。

2)Rerank(交叉编码器) query + 单条候选文档拼接输入同一模型,内部做交叉注意力,字词互相可见。

  • 不能预计算,必须在线逐条推理;
  • 只能作用于少量召回候选(Top10/Top20),无法全库检索;
  • 优点:细粒度相关性判断精准,大幅过滤噪声;
  • 缺陷:计算开销大,逐条推理会增加接口耗时。

2. 分工与落地链路(两级检索架构)

  • Embedding:粗召回,从海量向量库快速捞出一批候选;
  • Rerank:精排序,对少量候选重新打分,筛选真正匹配的片段交给大模型。

3. 答题

问:很多人分不清 Embedding 和 Rerank,二者本质区别是什么? 答:核心是编码时文本是否交互,分工完全不同。 Embedding 属于双编码器,问题和文档分开向量化,靠余弦相似度粗筛,优势是速度快、支持全库检索,但细粒度匹配差; Rerank 是交叉编码器,把问题和候选文档拼接在一起同步推理,字词之间能交互计算相关性,打分更精准,但速度慢,只能对 Embedding 召回后的少量候选做二次排序。 项目采用 “Embedding 粗召回 + Rerank 精排” 两级架构,平衡检索速度和答案准确率。

4. 配套易错坑点补充

  1. 不能只用 Rerank 直接检索全库,延迟完全无法接受;
  2. 更换 Embedding 模型必须重建 Milvus 向量库,Rerank 模型更换无需改动向量库;
  3. 仅靠 Embedding 很容易出现 “语义相近但答非所问”,Rerank 是低成本解决该问题的核心优化。

4.2 文档加载与语义分块实现

分块是 RAG 的底子,分不好后面再怎么优化都没用。我一开始图省事用固定长度硬切,结果经常把一句话劈成两半,明明文档里有答案就是搜不到,后来调了分隔符和重叠度,改善特别明显。

from langchain_community.document_loaders import DirectoryLoader, TextLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.schema import Document

def load_documents(doc_dir: str):
    """加载指定目录下所有文档,支持txt、md,PDF可以换成PyPDFLoader"""
    loader = DirectoryLoader(
        doc_dir,
        glob="**/*",
        loader_cls=TextLoader,
        show_progress=True,
        loader_kwargs={"encoding": "utf-8"}
    )
    docs = loader.load()
    print(f"文档加载完成,共{len(docs)}个文件")
    return docs

def split_documents(docs: list[Document]):
    """递归字符分块,优先按段落、句子切割,保留块间重叠"""
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=500,       # 中文场景500字符左右比较均衡
        chunk_overlap=50,     # 块间重叠10%,接住跨块语义
        separators=["\n\n", "\n", "。", "!", "?", " ", ""],  # 按语义单元优先级切割
        length_function=len
    )
    chunks = text_splitter.split_documents(docs)
    print(f"分块完成,共{len(chunks)}个文档块")
    return chunks

    多说一句参数怎么定的:中文别直接照搬英文的 token 标准,我测下来 500 到 800 字符比较合适,太小了上下文不全,太大了噪声多。overlap 一般设 chunk_size 的 10% 到 20%,就是为了接住跨块的知识点,避免一个完整的内容被劈成两半搜不到。这些参数都不是死的,要根据自己的文档类型调。

    对应面试考点(附答题思路)

    1. 你们文档分块用的什么策略?分块大小和重叠度是怎么确定的? 答:用的是递归字符分块,也就是 RecursiveCharacterTextSplitter,它会按分隔符优先级逐层切,优先切段落,再切句子,尽量保证语义完整,是落地里最常用也最实用的方案。 分块大小我设的 500 字符,重叠度 50 字符,也就是 10% 的重叠率。这个数值是针对中文和我们的制度文档调的:中文不能直接照搬英文的 1000token,500 字符大概是正常段落的长度,语义完整,又不会太长带太多噪声;设重叠度是为了避免跨块的知识点被切断,导致召回不到。 最终参数不是拍屁股定的,是拿一批测试问题测召回率调出来的,不同类型的文档参数不一样,长文档可以调大,FAQ 类短文可以调小。

    2. 固定长度分块和语义分块有什么区别?各自的适用场景? 答:固定长度分块就是按固定字符数或者 token 数硬切,不管语义边界,好处是实现简单、速度快、块大小均匀;坏处是很容易破坏语义完整性,把一个知识点劈成两半,导致召回率低。 语义分块是按语义单元切,比如按段落、句子,甚至用语义相似度来分块,尽量保证每个块语义完整独立,好处是检索效果好;坏处是实现复杂,块大小不均匀,速度慢一点。 适用场景:快速验证、简单 Demo 可以用固定长度;正式落地、对召回率有要求的,都要用语义优先的分块策略,递归字符分块其实就是兼顾效率和语义的折中方案。

    3. 分块的时候遇到表格、图片、代码块这类特殊内容怎么处理? 答:这类内容不能直接硬切,要单独处理: 表格:要保证表格结构完整,要么整个表格做一个块,要么拆分的时候保留表头信息,不然切完就没上下文了;更好的方式是把表格转成自然语言描述,再参与分块。 图片:纯文本 RAG 的话要先做 OCR 提取文字,再分块;多模态方案的话就用多模态 Embedding。 代码块:尽量保证函数、代码段的完整性,不要把一个函数劈成两半,不然检索到也没法用。 我们目前主要处理纯文本文档,后面接入多格式的时候会做专项解析。

    4. 长文档和短文档的分块策略需要区别对待吗? 答:肯定要区别对待,不能一套参数走天下。 长文档比如规章制度、白皮书,内容多、有层级结构,分块可以适当大一点,比如 800 到 1000 字符,重叠度也调高;最好先按章节、标题做层级拆分,再在章节内细粒度分块,每个块带上层标题信息,检索更准。 短文档比如通知、FAQ,本身就很短,就不用切太碎,甚至单篇直接做一个块,保证语义完整。 核心原则就是,在保证语义完整的前提下控制块大小,平衡检索精度和上下文完整度。

    易错点提醒:只会背 “一般 1000token”,不区分中英文,也说不出分块大小的依据;不知道 overlap 的作用,这些都是没实操过的典型表现。

    4.3 Embedding 向量化与 Milvus 向量库构建

    分完块就写入 Milvus,LangChain 对 Milvus 的适配很成熟,几行代码就能搞定。

    from langchain_community.vectorstores import Milvus
    
    def build_vector_store(chunks: list[Document], collection_name: str = "enterprise_kb"):
        """构建Milvus向量库,写入文档块"""
        vector_store = Milvus.from_documents(
            documents=chunks,
            embedding=embedding,
            collection_name=collection_name,
            connection_args={
                "host": MILVUS_HOST,
                "port": MILVUS_PORT
            },
            index_params={
                "metric_type": "COSINE",
                "index_type": "IVF_FLAT",
                "params": {"nlist": 1024}
            }
        )
        print(f"向量库构建完成,集合名:{collection_name}")
        return vector_store
    
    def load_vector_store(collection_name: str = "enterprise_kb"):
        """加载已有的Milvus集合"""
        vector_store = Milvus(
            embedding_function=embedding,
            collection_name=collection_name,
            connection_args={
                "host": MILVUS_HOST,
                "port": MILVUS_PORT
            }
        )
        return vector_store

    这里踩过一个很经典的坑:最开始没固定 Embedding 模型,中途换了一个测试,结果向量维度对不上直接报错,最后删了整个集合重建才好。所以 Embedding 模型版本一定要写死在配置里,线上随便换就是事故。

    对应面试考点(附答题思路)

    1. 向量库的持久化是怎么做的? 答:Milvus 是独立的向量数据库服务,数据本身就持久化存储在服务端,应用层只需要通过接口读写,不用额外处理持久化,重启服务数据也不会丢。 如果是 FAISS 这种纯内存的,就需要自己实现持久化,把索引序列化存到磁盘,程序启动的时候再加载进去;Chroma 这种嵌入式的,指定本地存储目录就行。

    2. 如果 Embedding 模型升级了,向量库怎么平滑迁移? 答:首先明确,换 Embedding 模型之后,向量维度和向量空间分布都会变,必须全量重建向量库,没有捷径。 平滑迁移一般用双写方案:先新建一个集合,用新模型把所有文档重新向量化写进去;然后灰度切流量,先放一小部分请求到新库验证效果,没问题再全量切换,最后下线旧集合。 数据量小的项目直接全量重建就行,核心是不能在线上直接换模型,会直接报维度不匹配的错误。

    3. Milvus 里的 collection 是什么概念?多知识库场景怎么管理? 答:collection 就相当于关系型数据库里的表,是向量数据的基本管理单元,每个 collection 有自己的向量维度、索引、元数据。 多知识库场景一般有两种管理方式:一种是每个知识库建一个独立的 collection,物理隔离,数据互不影响,权限也好控制;另一种是共用一个 collection,加一个知识库 ID 的标量字段,查询的时候过滤。 知识库数量不多、数据量大的话,建议分 collection;知识库多、单库数据量小的话,用标量过滤更省资源。我们目前是单知识库,就一个 collection,后面扩展多知识库会按业务域拆分。

    4. 向量相似度计算常用什么算法?余弦相似度和内积的区别是什么? 答:常用的有余弦相似度、内积(点积)、欧氏距离这几种。 余弦相似度衡量的是两个向量的方向相似度,不受向量长度影响,取值 - 1 到 1,值越大越相似,适合做语义相似度匹配,大部分 Embedding 模型输出的向量都用余弦相似度。 内积不仅和方向有关,还和向量的模长有关,如果向量已经做了归一化,内积和余弦相似度结果是一样的;如果没归一化,模长大的向量内积会更高。 一般选什么距离类型要和 Embedding 模型的训练方式对应,大部分中文 Embedding 模型都推荐用余弦相似度。

    易错点提醒:不知道换 Embedding 模型必须重建向量库;说不出不同相似度算法的适用场景,都容易露怯。

    4.4 向量检索 + BGE 重排模块

    先向量召回 Top10 候选,再交给 Rerank 精排留 Top3,两级检索是 RAG 效果的核心。

    from langchain.retrievers import ContextualCompressionRetriever
    from langchain_core.retrievers import BaseRetriever
    
    def get_retriever(vector_store: Milvus) -> BaseRetriever:
        """构建向量粗召回 + Rerank精排的两级检索器"""
        # 基础向量检索,召回Top10候选
        base_retriever = vector_store.as_retriever(
            search_type="similarity",
            search_kwargs={"k": 10}
        )
        
        # 加入Rerank做上下文压缩,精排后返回相关片段
        compression_retriever = ContextualCompressionRetriever(
            base_compressor=reranker,
            base_retriever=base_retriever
        )
        return compression_retriever

    实测下来,不加 Rerank 的时候,Top3 里经常混进不相关的内容,命中率大概六成;加了之后基本都能命中核心内容,能到九成左右,提升非常明显。代价就是多一次 API 调用,延迟多个两三百毫秒,知识库场景完全能接受。

    对应面试考点(附答题思路)

    1. RAG 的检索方式有哪些?相似度检索、MMR 检索的区别和适用场景? 答:常用的检索方式有相似度检索、MMR(最大边际相关性)检索,还有关键词检索、混合检索。 相似度检索就是纯按向量相似度排序,返回最相似的 TopK,适合只看重相关性的场景,实现最简单,也是最常用的。 MMR 检索是同时考虑相似度和多样性,在保证相关性的前提下,尽量让返回的结果内容不重复,覆盖更多角度,适合答案分散在多个不同片段里的场景,避免返回一堆内容重复的结果。 另外还有关键词 + 向量的混合检索,用 BM25 加向量一起召回,适合既有语义需求又有关键词匹配需求的场景,比如法律、医疗这类专业术语多的领域。

    2. Rerank 的原理是什么?为什么它比向量检索更准确? 答:向量检索是双编码器架构,query 和文档分别编码成向量,独立计算相似度,好处是可以提前把文档向量建好存起来,查询的时候只需要编码 query,速度很快;但因为两个编码过程独立,没法做细粒度的交互匹配,所以精度有上限。 Rerank 是交叉编码器架构,会把 query 和每一个候选文档拼在一起输入模型,做交叉注意力计算,模型能同时看到 query 和文档的每一个字,逐字判断相关性,所以精度高很多;但缺点是每次都要把 query 和候选一起计算,速度慢,没法直接全库检索。 所以一般都是两级架构:向量召回负责快,从全库里捞候选;Rerank 负责准,对候选做精排,兼顾速度和效果。

    3. 召回率和准确率怎么平衡?TopK 值怎么确定? 答:召回率是说相关的内容有没有都被捞出来,准确率是说捞出来的内容里有多少是真的相关的,这俩是此消彼长的关系。TopK 设得越大,召回率越高,但准确率会下降,噪声也越多,还会增加后续 Rerank 和生成的成本。 确定 TopK 一般分两步:先看业务场景,对召回要求高就设大一点,对准确率要求高就设小一点;然后拿真实业务数据做测试,看不同 K 值下的召回率和准确率,找一个平衡点。 我们是先召回 Top10 给 Rerank,Rerank 后留 Top3 给大模型,这样召回率有保障,最终给生成的内容又足够精准。

    4. 什么是上下文压缩?除了 Rerank 还有哪些实现方式? 答:上下文压缩就是把检索到的长文档片段,压缩成只和问题相关的内容,再传给大模型,减少无关信息,降低噪声,也省 token。 除了 Rerank 这种基于模型的重排 + 提取,还有几种方式:比如基于规则的关键词提取,只保留包含问题关键词的句子;比如用大模型直接从文档片段里抽取和问题相关的内容;还有嵌入压缩,把文档片段再做一次向量化筛选。 Rerank 是目前效果和成本平衡得最好的方式,也是落地用得最多的。

    面试官深挖方向:如果用户问题和历史对话相关,表述不完整,怎么优化检索?—— 可以引出 query 改写、历史对话补全,把指代不清、省略的内容补全再检索。

    4.5 对话记忆管理

    多轮对话肯定要有记忆,我用的滑动窗口记忆,保留最近 5 轮,简单实用,也不会无限涨 token。

    from langchain.memory import ConversationBufferWindowMemory
    
    def get_chat_memory(session_id: str):
        """按会话ID获取对话记忆,滑动窗口保留最近5轮"""
        memory = ConversationBufferWindowMemory(
            k=5,
            memory_key="chat_history",
            return_messages=True,
            input_key="input"
        )
        return memory

    开发阶段直接存在内存里就行,真要上线的话可以存 Redis,按 session_id 隔离不同用户。如果是超长对话的场景,还可以用摘要记忆,把历史对话压缩成摘要,更省 token,但实现复杂一点,一般场景滑动窗口完全够用。

    对应面试考点(附答题思路)

    1. 对话记忆有哪些实现方式?各自的优缺点是什么? 答:常用的有这几种:

      • 缓存记忆(ConversationBufferMemory):完整保存所有历史对话,优点是信息最完整,不会丢上下文;缺点是对话长了之后 token 会爆炸,很快就超限。
      • 滑动窗口记忆(ConversationBufferWindowMemory):只保留最近 N 轮对话,优点是实现简单,token 可控;缺点是更早的信息会丢失。
      • 摘要记忆(ConversationSummaryMemory):用大模型把历史对话压缩成摘要,优点是省 token,能保留长对话的核心信息;缺点是会损失细节,实现复杂,还有额外的大模型调用成本。 另外还有实体记忆、向量记忆这些更复杂的,适合特殊场景。 一般业务场景用滑动窗口就够了,简单稳定,成本低。
    2. 长对话 token 超限怎么处理?滑动窗口和摘要记忆怎么选型? 答:最常用的就是滑动窗口,只保留最近几轮,简单直接,大部分场景都够用;如果对话很长,又需要保留早期的关键信息,可以用摘要记忆,或者滑动窗口 + 摘要结合,最近几轮用原文,更早的用摘要。 选型主要看对话长度和信息重要性:对话轮次不多、对细节要求高,就用滑动窗口;对话很长、只需要保留核心信息,就用摘要记忆。我们这个是企业内部知识库对话,一般轮次不会特别多,用滑动窗口就够了。

    3. 多用户场景下,对话记忆怎么隔离和持久化? 答:核心是按 session_id 隔离,每个用户的每轮对话对应一个唯一的 session_id,记忆按 session_id 存取,互不干扰。 持久化的话,一般存在 Redis 里,设置过期时间,读写快,适合高频访问;也可以存在数据库里,适合需要长期保存的场景。开发阶段可以先存在内存里,上线再切持久化存储。

    4. 记忆在 Agent 和 RAG 链路里分别起到什么作用? 答:在 RAG 链路里,记忆主要是用来补全用户的问题,比如用户用 “它”“这个” 指代上文提到的内容,有记忆就能把问题补完整再去检索,提升召回准确率。 在 Agent 链路里,记忆是让 Agent 知道之前做了什么、执行过哪些工具,避免重复调用,也能基于历史对话做更连贯的任务规划。

    4.6 Agent 工具定义与智能体搭建

    最核心的 Agent 部分。我定义了三个工具:知识库检索、计算器、当前时间查询,把 RAG 做成 Agent 的一个工具,让模型自己判断什么时候该查知识库。

    用的 LangChain 的 ReAct Agent,调试直观,也比较省 token。

    from langchain.tools import tool
    from langchain.agents import AgentExecutor, create_react_agent
    from langchain import hub
    
    # 工具1:知识库检索
    @tool
    def search_knowledge_base(query: str) -> str:
        """
        从知识库中检索相关文档内容,回答公司制度、业务流程、操作手册类问题。
        当用户询问公司规定、内部流程、产品说明时使用此工具。
        参数: query - 用户的问题
        """
        vector_store = load_vector_store()
        retriever = get_retriever(vector_store)
        docs = retriever.get_relevant_documents(query)
        if not docs:
            return "知识库中未找到相关内容。"
        # 拼接检索到的文档片段
        context = "\n\n".join([f"文档片段{i+1}:{doc.page_content}" for i, doc in enumerate(docs)])
        return context
    
    # 工具2:数学计算
    @tool
    def calculator(expression: str) -> str:
        """
        执行数学计算,支持加减乘除、括号等四则运算。
        当用户需要计算数值时使用此工具。
        参数: expression - 数学表达式字符串,如"100*20+50"
        """
        try:
            result = eval(expression)
            return f"计算结果:{result}"
        except Exception as e:
            return f"计算失败:{str(e)}"
    
    # 工具3:查询当前时间
    @tool
    def get_current_time() -> str:
        """获取当前日期和时间,回答时间相关问题时使用。"""
        from datetime import datetime
        return f"当前时间:{datetime.now().strftime('%Y年%m月%d日 %H:%M:%S')}"
    
    # 工具列表
    tools = [search_knowledge_base, calculator, get_current_time]
    
    # 加载ReAct提示词模板,也可以自己写
    prompt = hub.pull("hwchase17/react")
    
    # 构建Agent执行器
    agent = create_react_agent(llm, tools, prompt)
    agent_executor = AgentExecutor(
        agent=agent,
        tools=tools,
        verbose=True,  # 开启后能看到完整思考过程,调试很有用
        handle_parsing_errors=True,  # 自动兜底工具解析错误
        max_iterations=5  # 最多执行5轮工具调用,防止死循环
    )

    这块踩的坑特别多:最开始工具描述写得太笼统,Agent 经常乱调用,比如问时间它去查知识库;后来把每个工具的适用场景、入参写得越细,准确率就越高。还有格式解析错误的问题,加上 handle_parsing_errors 就自动兜底了,不然经常因为输出不规范直接中断。

    这些踩坑经历面试的时候说出来特别真实,比背标准答案有说服力多了。

    对应面试考点(附答题思路)

    1. Agent 工具调用的完整流程是怎样的? 答:以 ReAct 模式为例,完整流程是循环的:首先 Agent 拿到用户问题和历史对话,生成思考(Thought),分析该做什么;然后决定要调用的工具和参数(Action、Action Input);接着执行工具,拿到返回结果(Observation);再把结果传给 Agent,继续思考下一步,直到问题解决,最终输出答案。 简单的问题可能一轮工具调用就解决了,复杂的问题会循环多轮,依次调用不同工具。

    2. ReAct 模式的核心思想是什么?Thought、Action、Observation 分别代表什么? 答:ReAct 的核心思想是让大模型像人一样,一边思考一边行动,把推理过程和工具调用结合起来,每一步思考都基于上一步的执行结果,动态调整。 Thought 是模型的思考过程,分析当前问题、判断该做什么、为什么这么做;Action 是模型决定要调用的工具名称;Action Input 是调用工具需要的参数;Observation 是工具执行之后返回的结果,作为下一步思考的依据。 就是 “思考→行动→观察→再思考” 的循环,直到得出最终答案。

    3. 怎么提升 Agent 工具调用的准确率?有哪些优化手段? 答:可以从几个方向优化: 首先是工具描述,一定要写得清晰准确,说明白工具是做什么的、什么场景下用、入参是什么,描述越精准,模型选得越准; 然后是 Prompt 优化,在系统提示词里强化格式要求,给几个正确的示例,少给错误示范; 还有参数调整,适当降低 temperature,让输出更稳定,减少格式错误; 最后是加兜底机制,比如解析失败的时候自动重试,或者让模型自己修正格式。 我们最开始工具调用准确率不高,优化了工具描述和 Prompt 之后,提升特别明显。

    4. 工具调用失败、解析错误怎么处理?有哪些兜底方案? 答:首先是预防层面,优化 Prompt 和工具描述,降低出错概率;然后是兜底层面,第一是自动重试,解析失败或者调用失败的时候,把错误信息返回给模型,让它修正后重试;第二是格式容错,用正则等方式尽量从非标准输出里提取出工具和参数;第三是降级策略,多次失败之后就放弃工具调用,直接回答或者提示用户。 LangChain 里的 handle_parsing_errors 就是做这个的,能自动处理大部分解析错误。

    5. 怎么防止 Agent 无限循环调用工具? 答:最直接的就是设置最大迭代次数,比如最多执行 5 轮,超过就强制停止,返回结果;还可以设置重复调用检测,如果连续调用同一个工具、参数都一样,就判定为循环,强制终止;另外也可以在 Prompt 里约束,告诉模型不要重复做无用的调用。 我们设置了 max_iterations=5,基本能覆盖绝大多数场景,也能防止死循环。

    易错点提醒:只能背 ReAct 的概念,说不出完整执行链路;不知道工具描述对准确率的影响,答不出具体优化手段,这些都是容易扣分的地方。

    4.7 项目统一入口封装

    最后封装一个统一的对话入口,把记忆和 Agent 串起来,跑起来就能用。

    def chat(user_input: str, memory):
        """统一对话入口,串联记忆、Agent、工具调用全流程"""
        try:
            result = agent_executor.invoke({
                "input": user_input,
                "chat_history": memory.load_memory_variables({})["chat_history"]
            })
            # 更新对话记忆
            memory.save_context(
                {"input": user_input},
                {"output": result["output"]}
            )
            return result["output"]
        except Exception as e:
            return f"抱歉,处理出错了:{str(e)}"
    
    # 本地测试运行
    if __name__ == "__main__":
        # 首次运行先构建向量库,后续可以注释掉直接加载
        docs = load_documents("./docs")
        chunks = split_documents(docs)
        vector_store = build_vector_store(chunks)
        
        # 初始化会话记忆
        memory = get_chat_memory("session_001")
        
        # 循环对话
        while True:
            user_input = input("你:")
            if user_input.lower() in ["退出", "exit", "quit"]:
                break
            response = chat(user_input, memory)
            print(f"助手:{response}")

    到这里整套链路就完全跑通了。把文档丢进 docs 目录,运行脚本就能对话,既能查知识库,又能算数学、查时间,Agent 会自己判断该用哪个工具。

    对应面试考点(附答题思路)

    1. 一次完整的对话请求,从用户发起到结果返回,经历了哪些步骤? 答:完整步骤是:接收用户提问→根据 session_id 获取历史对话记忆→传入 Agent 做任务规划→Agent 判断并调用对应工具→如果是知识库检索,就执行向量召回 + Rerank 精排,返回文档片段→大模型基于上下文生成答案→更新对话记忆→返回结果给用户。 中间如果 Agent 需要多轮工具调用,就重复 “思考 - 调用工具 - 获取结果” 的循环,直到问题解决。

    2. 你做了哪些异常兜底处理? 答:做了几层兜底:首先是 Agent 工具解析错误的兜底,开启了自动错误处理,解析失败会自动修正;然后是工具调用次数限制,防止死循环;还有最外层的全局异常捕获,任何环节出错都不会让程序崩掉,会返回友好提示;另外对话记忆用滑动窗口,避免 token 超限。 后面上线的话还会加超时控制、限流这些,保障服务稳定。

    3. 如果要把它封装成 HTTP 接口服务,你会怎么改造? 答:首先会加一个 Web 框架,比如 FastAPI,提供对话接口,接收用户提问和 session_id;然后把对话记忆改成 Redis 存储,支持多用户隔离和持久化;向量库和大模型的配置都抽到配置文件里,区分开发、生产环境;加上流式输出支持,用 SSE 返回;还要加参数校验、异常处理、日志、监控这些工程化的东西。 整体保持分层架构,接口层、业务逻辑层、数据层解耦,方便后续扩展。


    5. 开发调试踩坑复盘

    整套东西看着不复杂,写的时候踩的坑真不少,挑几个典型的说说,面试里故障排查的题基本都从这类问题出。

    坑 1:更换 Embedding 模型导致向量库报错

    现象:中途换了个 Embedding 模型测试,加载集合的时候直接报维度不匹配,程序直接崩。 排查思路:对比报错里的维度和当前模型输出维度,发现 Milvus 创建集合的时候,维度就以第一次写入的向量为准了,后面换模型维度不一样就对不上。 解决方案:Embedding 模型不能随便换,要换的话必须重建集合,全量重新向量化。落地的时候模型版本一定要写死在配置里,不能在线上乱改。小项目直接全量重建就行,大项目可以用双集合灰度切换。

    坑 2:Agent 工具调用解析失败

    现象:Agent 经常输出不规范的格式,导致解析不了工具和参数,流程直接中断。 排查思路:开了 verbose 看完整的思考过程,发现大模型有时候会多输出一些话术,没有严格按照 ReAct 的格式来,导致正则解析失败。 解决方案:首先开启 handle_parsing_errors 自动兜底,大部分错误能自动处理;然后优化 Prompt,强化格式约束,给正确示例;再适当降低 temperature,让输出更稳定。改完之后解析失败的情况少了很多。

    坑 3:分块不合理导致召回率低

    现象:文档里明明有答案,但就是检索不到。 排查思路:把召回的 Top10 结果都打出来看,发现相关内容被拆分到了不同的块里,每个块只沾一点边,相似度都不高,排不到前面。 解决方案:调整 chunk_size 和 overlap,增大块间重叠,保证跨块的知识点能被覆盖;同时优化分隔符优先级,优先按段落、句子切割,尽量不破坏语义完整性;再加上 Rerank,把相关的片段往前排。调整之后召回率提升很明显。

    坑 4:长对话 token 超限

    现象:对话轮次多了之后,直接报上下文长度超限。 排查思路:统计了一下输入的 token 数,发现历史对话越积越多,加上检索到的文档片段,直接超出了模型的上下文窗口。 解决方案:把全量记忆改成滑动窗口记忆,只保留最近 5 轮,控制历史对话的 token 量;同时控制检索返回的片段数量,不要返回太多无关内容。如果是超长对话场景,还可以加摘要记忆进一步压缩。

    对应故障排查类面试题(附答题思路)

    1. 如果线上用户反馈知识库答非所问,你会怎么一步步排查? 答:我一般是先复现再分层定位,从结果倒推原因,先区分是检索不准还是生成幻觉,再逐层往下拆。 第一步先拿用户的原始问题本地复现,打开 verbose 看完整链路日志,先看检索回来的 Top3 片段里到底有没有正确答案:如果检索结果里根本没有相关内容,那就是检索侧的问题;如果检索结果里有明确答案,但大模型还是答错了,那就是生成侧的问题。 如果是检索侧的问题,再继续定位:先看用户问题是不是太口语化、有指代省略,没做 query 改写导致匹配不上;再看分块是不是不合理,答案被拆分到不同块里,单块相似度不够排不上来;再排查 Embedding 模型、向量索引有没有变动,是不是维度不匹配或者索引异常;最后看 Rerank 是不是排序失真,把相关内容压到了后面。 如果是生成侧的问题,就查:是不是 Prompt 里的幻觉约束不够,模型自由发挥了;temperature 是不是被调高了,输出太发散;还有是不是上下文里混入了太多无关片段,噪声干扰了模型判断。 定位到根因再针对性修复,比如检索不准就加 query 改写、调分块参数;生成幻觉就加强 Prompt 约束、调低温度。

    2. Agent 频繁调用工具失败,可能的原因有哪些?怎么解决? 答:这块我踩过的坑比较多,常见的原因集中在四类: 第一类也是最常见的,格式解析失败。大模型不按 ReAct 规定的格式输出,夹杂多余话术,导致解析不出工具名和参数。解决方式:开启handle_parsing_errors自动兜底;优化 Prompt,强化格式约束,补充正确示例;适当调低 temperature,提升输出稳定性。 第二类是工具描述有问题。要么描述太笼统,模型分不清工具边界,选错工具;要么参数说明模糊,模型传参格式错误。解决方式:把每个工具的适用场景、入参格式、边界条件写得越细越好,减少歧义,描述越精准,调用准确率越高。 第三类是工具本身执行报错。比如参数类型不合法、网络超时、下游依赖挂了,像 Milvus 连不上、大模型接口限流这类。解决方式:工具内部加异常捕获,返回清晰的错误信息,让 Agent 能基于错误修正;增加超时和重试机制,对非幂等操作做好兜底。 第四类是任务规划失控,复杂问题下模型反复调用同一个工具,陷入死循环。解决方式:设置最大迭代次数强制终止;优化 Prompt 引导规划逻辑;特别复杂的场景可以换成 Plan-and-Execute 模式,先拆分任务再执行。

    3. 系统响应耗时突然变长,你会从哪些方向排查? 答:我会按请求链路分层排查,把每个环节的耗时拆开看,快速定位瓶颈点。 首先查大模型侧。这是最常见的瓶颈:先看大模型 API 本身的耗时是不是涨了,比如高峰期限流、排队,看调用日志里的首 token 延迟和生成耗时就能确认;再看输入 token 是不是变多了,比如历史对话累积、召回的文档片段变多,输入越长生成越慢。 然后查检索侧。看 Milvus 的查询耗时是不是涨了,比如数据量突然大幅增长、索引失效、节点负载高;再看 Rerank 的调用耗时,是不是召回的候选量变多了,重排时间变长。 再查 Agent 侧。看是不是问题变复杂了,工具调用的轮次变多了,原来一轮就能解决,现在要循环三四轮,整体耗时自然翻倍。 最后查基础设施层。比如服务器 CPU / 内存负载高、网络延迟变大,或者 Redis、Milvus 这些依赖服务出现性能瓶颈。 实际排查的时候先看监控埋点,把每个环节的耗时都打出来,很快就能定位到瓶颈段,再针对性优化,比如加热缓存、优化索引、减少工具调用轮次。

    4. 大模型 token 超限有哪些处理和优化方案? 答:主要从输入的各个环节做压缩,分层优化,优先改成本低、见效快的。 第一是对话记忆侧。最常用也最简单的是滑动窗口,只保留最近 N 轮对话,直接丢弃更早的内容,控制记忆的 token 量;如果还要保留历史核心信息,就用摘要记忆,用大模型把历史对话压缩成摘要,只保留关键信息,能省很多 token。 第二是检索上下文侧。控制召回的文档片段数量,不要贪多;用 Rerank 精排之后只留最相关的 2-3 条,减少传给大模型的上下文长度;还可以做更细的上下文压缩,只提取文档片段里和问题直接相关的句子,不用整段都传进去。 第三是 Prompt 侧。优化系统提示词,去掉冗余的套话,在保证效果的前提下把指令写得尽量简洁;少放无用的示例,只保留最必要的约束。 第四是模型侧。如果前面的优化都做了还是不够,就更换更长上下文窗口的模型,比如从 8K 窗口换成 32K 窗口的版本,这是最直接但成本也最高的方案。 另外还要加兜底机制,输入前做 token 计数,超限自动按优先级截断,防止程序直接报错。


    6. 落地常用的轻量化优化思路

    基础版跑通之后,我加了几个成本不高、效果不错的优化,小项目都能直接用上。

    6.1 检索侧:query 改写

    用户提问经常很口语化,比如 “我请假要走啥流程”,直接拿去检索效果一般。加一步 query 改写,用大模型把口语化的问题转成标准的检索问句,再去向量库搜,准确率能再提一截。实现很简单,单独调用一次大模型就行。

    6.2 生成侧:幻觉抑制 Prompt

    在生成答案的 Prompt 里加上约束:“仅基于提供的文档内容回答,禁止编造。如果文档中没有相关信息,请直接说明暂无相关内容。” 就这么一句话,幻觉能少一大半,是成本最低的优化。

    6.3 知识库增量更新

    不用每次加文档都全量重建。记录每个文档的文件名和修改时间,新增或者修改文档时,只对变动的文档做分块向量化,追加到向量库,同时删掉旧的对应块。实现不复杂,满足日常更新需求完全够用。

    6.4 检索结果溯源

    每个答案都附上引用的文档片段和来源文件,用户能看到答案是从哪来的,也方便排查问题,系统的可信度会高很多。


    7. 全文面试考点自检清单

    把所有考点整理成了纯问题清单,临面试前可以快速过一遍,查漏补缺。

    架构类

    1. RAG+Agent 的整体架构与完整数据流
    2. 向量数据库选型对比与选型依据
    3. Rerank 的作用、原理与引入必要性
    4. Agent 常见规划模式对比(ReAct / Plan-and-Execute)

    RAG 核心类

    1. 文档分块策略、分块大小与重叠度的确定依据
    2. Embedding 模型选型考量因素
    3. 常见检索方式对比(相似度、MMR、混合检索)
    4. RAG 幻觉优化完整方案(检索侧 + 生成侧)
    5. 知识库增量更新实现思路
    6. 召回率与准确率的平衡方法

    Agent 类

    1. ReAct 模式的完整执行链路
    2. 工具调用准确率的提升手段
    3. 工具调用异常与解析错误的兜底方案
    4. Agent 死循环的预防与处理

    工程落地类

    1. 对话记忆的实现方式与长对话优化
    2. 知识库答非所问的故障排查思路
    3. Token 超限的处理与优化方案
    4. 流式输出的实现方式与技术选型
    • 基于 LangChain 搭建 RAG+Agent 知识库系统,融合向量检索、Rerank 重排、ReAct 智能体技术,实现多工具自动调度
    • 优化文档分块策略与两级检索链路,引入 Rerank 精排,Top3 检索准确率提升至 90% 以上
    • 实现对话记忆管理、知识库增量更新、结果溯源等能力,适配内部文档问答场景
    • 完善异常兜底机制,处理工具解析错误、token 超限等边界问题,保障系统稳定性

    8. 写在最后

    自己动手从头到尾跑一遍,对着代码把每个环节的问题想明白,比看十篇教程都记得牢。

    这套架子想深化的话,后面还能加多模态解析、用户反馈闭环、更复杂的 Agent 编排,可扩展的地方很多。

    更多推荐