嘿,朋友!欢迎来到 ChainMind 的学习之旅。这是一个非常有意思的项目,它能让你理解如何构建一个智能知识库问答系统——就像给 AI 装上了一个会思考的图书馆!通过这个项目,你会学到 RAG 技术、FastAPI 后端开发、前后端交互等实用技能。准备好了吗?让我们开始吧!

📋 学习前准备

在开始学习之前,你需要具备以下基础知识:

必学基础

  • Python 基础:熟悉函数、类、异步编程(async/await)的基本概念
  • HTTP 基础:理解 GET/POST 请求、JSON 数据格式
  • 命令行操作:能够在终端执行命令

推荐了解

  • JavaScript 基础:能读懂 React 组件(前端部分)
  • 数据库概念:知道什么是存储和查询

环境准备

工具 版本要求 安装说明
Python 3.8+ 官网下载或使用 conda
Node.js 16+ 官网下载
Ollama 最新版 用于运行本地 LLM 模型

环境检查清单

  • Python 版本:python --version 显示 3.8+
  • Node.js 版本:node --version 显示 16+
  • 包管理器:pip 或 npm 可用

🗺️ 学习路线图

整个学习过程可以分为以下几个阶段,建议按顺序学习:

plaintext

第一阶段:理解概念(1-2小时)
    │
    ├── 什么是 RAG?为什么需要它?
    ├── 向量数据库是什么?
    └── FastAPI 为什么这么火?
    │
    ▼
第二阶段:后端开发(3-4小时)
    │
    ├── FastAPI 核心语法
    ├── 路由和中间件
    ├── 服务层架构
    └── 单例模式的应用
    │
    ▼
第三阶段:核心功能(4-5小时)
    │
    ├── 文档上传和处理
    ├── 文本切分策略
    ├── 向量存储和检索
    └── RAG 检索增强
    │
    ▼
第四阶段:高级特性(2-3小时)
    │
    ├── 记忆系统的实现
    ├── 智能代理模式
    └── 工具注册表设计
    │
    ▼
第五阶段:前端交互(2-3小时)
    │
    ├── React 组件开发
    ├── 状态管理
    ├── API 调用封装
    └── CORS 跨域处理

🎯 学完后你能做什么

学完这个项目后,你将具备以下能力:

1. 构建自己的 RAG 系统

  • 能够上传文档(PDF、TXT、MD)
  • 实现基于文档内容的智能问答
  • 优化检索效果和回答质量

2. 掌握 FastAPI 全栈开发

  • 快速构建高性能的 Python API
  • 设计清晰的服务层架构
  • 处理文件上传和复杂业务逻辑

3. 理解 AI 应用架构

  • RAG 的原理和实现方式
  • Agent 智能代理的设计模式
  • 记忆系统和上下文管理

4. 面试加分项 💰

  • 能讲清楚 RAG 的工作原理
  • 能解释向量检索的思路
  • 能说明前后端分离架构的设计

安装步骤:

```bash

# 1. 进入后端目录

cd backend



# 2. 安装依赖

pip install -r requirements.txt



# 3. 启动后端服务

cd app

uvicorn main:app --host 0.0.0.0 --port 8000 --reload

# 1. 进入前端目录
cd frontend

# 2. 安装依赖
npm install

# 3. 启动前端服务
npm run dev

第一部分:核心技术概念

1.1 RAG 技术详解

什么是 RAG?

RAG = Retrieval(检索)+ Augmented(增强)+ Generation(生成)

想象一下,如果你要回答"《红楼梦》里林黛玉葬花时说了什么"这个问题,你会怎么做?

没有 RAG 的做法(就像一个死记硬背的学生):

  • 直接凭记忆回答:"花谢花飞花满天,红消香断有谁怜..."
  • 问题:如果记错了呢?如果问题超出记忆范围呢?

有 RAG 的做法(就像一个会查资料的学生)

  1. 先去图书馆找到《红楼梦》的相关章节 🔍
  2. 仔细阅读找到具体内容 📖
  3. 根据找到的内容组织答案 ✍️

RAG 就是给 AI 装了一个"随时可以查阅的图书馆"!

RAG 的工作流程

plaintext

用户提问:"什么是机器学习?"
         │
         ▼
┌─────────────────────────────────────┐
│  1️⃣ 检索阶段 (Retrieval)            │
│                                     │
│  "把问题变成坐标,在图书馆(本地知识库)里找书"     │
│                                     │
│  问题 → 向量化 → 在向量库中搜索       │
│         ↓                          │
│  找到最相关的 3 个文档片段          │
└─────────────────────────────────────┘
         │
         ▼
┌─────────────────────────────────────┐
│  2️⃣ 增强阶段 (Augmented)            │
│                                     │
│  "把找到的资料整理好"               │
│                                     │
│  将检索结果组合成上下文             │
│  加上原始问题,组成完整提示         │
└─────────────────────────────────────┘
         │
         ▼
┌─────────────────────────────────────┐
│  3️⃣ 生成阶段 (Generation)           │
│                                     │
│  "根据资料写出答案"                │
│                                     │
│  LLM 阅读上下文 + 问题 → 生成答案   │
└─────────────────────────────────────┘
         │
         ▼
    最终答案输出

为什么需要 RAG?

方式 优点 缺点 适用场景
纯 LLM 回答流畅、上下文理解好 可能胡编乱造、知识过时 创意写作、通用对话
RAG 答案有据可查、可更新知识 需要额外存储和检索 知识库问答、文档分析

加分点:RAG 解决了 LLM 的两个核心问题——幻觉问题(胡编乱造)和知识过时问题(无法获取最新信息)。

1.2 向量数据库详解

什么是向量?

向量 = 一串数字 = 空间中的一个点

想象一下:每个文档都可以被"翻译"成一串数字,这串数字就是文档的向量表示

plaintext

"机器学习是人工智能的一个分支"
  ↓ 向量化 ↓
[0.23, -0.45, 0.78, 0.12, -0.33, ...]  ← 这就是向量!

"深度学习使用神经网络"
  ↓ 向量化 ↓
[0.25, -0.42, 0.75, 0.15, -0.30, ...]  ← 相似的句子,向量也相似!

什么是向量检索?

向量检索 = 在空间中找"最近的邻居

类比理解:

  • 把每本书想象成图书馆里的一个书架位置 📚
  • 相似的内容,书架位置就靠近
  • 当你问问题时,先找到最近的几个书架,然后从这些书架上找答案

简单检索 vs 向量检索

plaintext

代码中的向量存储(简化版)

python

# 这是一个简化版的向量存储
# 实际生产环境会使用 Chroma、Pinecone、Milvus 等专业向量数据库

class VectorStore:
    def __init__(self):
        self.documents = []  # 存储文档内容
    
    def add_documents(self, chunks):
        """添加文档"""
        for chunk in chunks:
            self.documents.append({
                "content": chunk,
                # 实际会存储:embedding(向量)、metadata(元数据)
            })
    
    def search(self, query, top_k=3):
        """检索相关文档"""
        # 1. 将查询转为向量
        # 2. 计算与所有文档的相似度
        # 3. 返回 top_k 个最相似的

1.3 FastAPI 快速入门

为什么选择 FastAPI?

框架 特点 学习曲线 性能
Flask 轻量、灵活 简单 中等
Django 全功能、ORM 陡峭 良好
FastAPI 现代、自动化、快 中等 极佳

FastAPI 的三大优势

  1. 自动生成 API 文档 📚

    • 写完代码就有 Swagger UI 可以测试
    • 再也不用手动写 API 文档了!
  2. 类型安全 🛡️

    • 用 Pydantic 自动验证请求数据
    • 写错类型直接报错,不用等到运行时才发现
  3. 异步支持

    • 原生支持 async/await
    • 高并发处理能力

FastAPI 核心概念

python

from fastapi import FastAPI

app = FastAPI()

# 装饰器定义路由
@app.get("/hello")
async def say_hello():
    return {"message": "Hello World!"}

# 启动方式
# uvicorn main:app --host 0.0.0.0 --port 8000

请求流程图

plaintext

用户请求 GET /hello
        │
        ▼
┌───────────────────────┐
│   FastAPI 应用        │
│                       │
│   路由匹配 → /hello   │
│         ↓             │
│   执行 say_hello()    │
│         ↓             │
│   Pydantic 验证响应   │
└───────────────────────┘
        │
        ▼
    返回 JSON 响应

1.4 CORS 跨域原理

什么是跨域?

跨域 = 浏览器的安全机制

浏览器规定:如果一个网页想从 A 网站获取数据,必须得到 A 网站的"许可"。

plaintext

浏览器地址:http://localhost:3000 (前端)
              │
              │ 发送请求
              ▼
    http://localhost:8000/api/chat (后端)
    
    ❌ 浏览器拦截!因为端口不同(3000 vs 8000)

怎么解决?

python

# 后端 FastAPI 配置 CORS
from fastapi.middleware.cors import CORSMiddleware

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],  # 允许所有来源(开发环境)
    # 生产环境应该设置为具体域名:["http://localhost:3000"]
    allow_credentials=True,
    allow_methods=["*"],  # 允许所有请求方法
    allow_headers=["*"],   # 允许所有请求头
)

类比理解:CORS 就像是在后端门口挂一个牌子:"欢迎来自 3000 端口的访客!"

⚠️ 安全注意事项

python

# ❌ 生产环境不要这样写
allow_origins=["*"]  # 允许所有来源

# ✅ 生产环境应该指定具体域名
allow_origins=["http://localhost:3000", "https://yourdomain.com"]

1.5 单例模式详解

什么是单例模式?

单例 = 只创建一个实例

类比理解:

  • 餐厅只有一个厨房(实例)
  • 所有服务员(调用方)都共用这个厨房
  • 不会每来一个服务员就新建一个厨房

为什么需要单例?

python

# ❌ 每次调用都创建新实例(浪费资源)
def bad_example():
    store = VectorStore()  # 每次都 new 一个新的
    return store

# ✅ 全局只创建一次(单例模式)
_vector_store = None  # 全局变量

def get_vector_store():
    global _vector_store
    if _vector_store is None:  # 懒加载:用到时才创建
        _vector_store = VectorStore()
    return _vector_store

单例的好处

  1. 节省资源:不需要重复创建和销毁对象
  2. 保持一致:所有地方用的是同一个实例,数据同步
  3. 延迟加载:用的时候才创建,提高启动速度

第二部分:后端核心模块详解

2.1 主入口文件 (main.py)

学习目标

  • 理解 FastAPI 应用的创建和配置
  • 掌握路由注册的方法
  • 了解 CORS 中间件的配置

技术原理

FastAPI 应用本质上是一个 ASGI 应用,它接收请求、处理业务逻辑、返回响应。

python

"""
FastAPI 应用主入口
"""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
# 以相对路径导入,就是从当前目录下的 routers/ 包中导入 upload, chat, rag, memory, agent
from .routers import upload, chat, rag, memory, agent

# 1️⃣ 创建FastAPI应用实例
app = FastAPI(
    title="ChainMind API",              # API 标题(文档中显示)
    description="基于 FastAPI + Ollama + LangChain 的本地知识库问答系统API",
    version="1.0.0"                      # 版本号
)

# 2️⃣ 配置 CORS 中间件(即是否允许前端跨域访问,例子:前端从3000端访问后端8000)
# 中间件:在请求到达路由之前/响应返回之前做一些处理
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],        # 生产环境要改成具体域名这个项目是http://localhost:3000!
    allow_credentials=True,     # 是否允许携带 Cookie
    allow_methods=["*"],        # 允许的方法:GET, POST, PUT, DELETE...
    allow_headers=["*"],        # 允许的请求头
)

# 3️⃣ 注册路由
# 路由:将 URL 路径映射到处理函数
app.include_router(upload.router)   # /upload/*
app.include_router(chat.router)     # /chat/*
app.include_router(rag.router)     # /rag/*
app.include_router(memory.router)  # /memory/*
app.include_router(agent.router)   # /agent/*

# 根路径的健康检查
@app.get("/")
async def root():
    """根路径 - 健康检查"""
    return {
        "name": "ChainMind API",
        "status": "running",
        "version": "1.0.0",
        "endpoints": {
            "docs": "/docs",
            "upload": "/upload",
            "chat": "/chat",
            "rag": "/rag",
            "memory": "/memory",
            "agent": "/agent"
        }
    }

# 4️⃣ 健康检查端点
@app.get("/health")
async def health_check():
    """
    健康检查 = 探测服务是否存活
    
    类比:去医院体检,检查身体各项指标是否正常
    用途:k8s/运维监控、负载均衡探测
    """
    return {"status": "healthy", "message": "ChainMind API is running"}
# 本地开发服务器启动
if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

代码执行流程

plaintext

启动服务: uvicorn main:app --port 8000
                │
                ▼
    ┌─────────────────────────┐
    │  创建 FastAPI 应用     │
    │  加载配置文件           │
    └─────────────────────────┘
                │
                ▼
    ┌─────────────────────────┐
    │  注册 CORS 中间件       │
    │  (跨域资源共享)       │
    └─────────────────────────┘
                │
                ▼
    ┌─────────────────────────┐
    │  注册所有路由模块       │
    │  - upload.router        │
    │  - chat.router          │
    │  - rag.router            │
    │  - memory.router         │
    │  - agent.router          │
    └─────────────────────────┘
                │
                ▼
    ┌─────────────────────────┐
    │  服务启动完成           │
    │  监听 http://localhost  │
    │  :8000                   │
    └─────────────────────────┘
                │
                ▼
    接收请求 → 匹配路由 → 执行处理函数 → 返回响应

面试考点

Q1: FastAPI 和 Flask 有什么区别?

回答要点

 
  • FastAPI 自动生成 API 文档(Swagger UI)
  • FastAPI 基于 Pydantic 提供自动类型验证
  • FastAPI 原生支持异步(async/await)
  • Flask 更轻量灵活,FastAPI 更适合构建大型 API 服务

Q2: 什么是 ASGI?

回答要点

 
  • ASGI = Asynchronous Server Gateway Interface(异步服务器网关接口)
  • 是 WSGI 的异步升级版
  • 支持长连接、WebSocket 等特性
  • Uvicorn 是 ASGI 服务器的实现

Q3: 中间件是什么?执行顺序是怎样的?

回答要点

 
  • 中间件是拦截请求/响应的"拦截器"
  • 请求顺序:外部中间件 → 内部中间件 → 路由处理
  • 响应顺序:路由处理 → 内部中间件 → 外部中间

2.2 配置文件 (config.py)

学习目标

  • 理解配置文件的最佳实践
  • 掌握关键配置参数的含义
  • 学会根据环境切换配置

技术原理

配置分离的好处

  • 开发/生产环境使用不同配置
  • 敏感信息不硬编码在代码中
  • 方便统一管理和修改

python

"""
应用配置文件
"""
import os
from pathlib import Path

# 项目根目录
BASE_DIR = Path(__file__).parent.parent.parent

# 数据目录
DATA_DIR = BASE_DIR / "data"
UPLOAD_DIR = DATA_DIR / "uploads"
CHROMA_DIR = DATA_DIR / "chroma_db"

# 确保目录存在
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
CHROMA_DIR.mkdir(parents=True, exist_ok=True)

# ========== Ollama 配置 ==========
# Ollama = 本地运行的 LLM 模型服务
# 类似 OpenAI API,但数据不会离开你的电脑 💻

OLLAMA_BASE_URL = os.getenv("OLLAMA_BASE_URL", "http://localhost:11434") # Ollama 默认端口
OLLAMA_MODEL = os.getenv("OLLAMA_MODEL", "llama3:8b")   # 使用的模型名称

# ========== 文档处理配置RAG ==========
DEFAULT_TOP_K = 3
CHUNK_SIZE = 500      # 每个文本块的字符数
CHUNK_OVERLAP = 50    # 相邻块之间的重叠字符数

# ========== 向量存储配置 ==========
VECTOR_STORE_PERSIST_DIR = "./chroma_db"  # 向量数据库的存储路径

# Embedding配置
EMBEDDING_MODEL = "all-MiniLM-L6-v2"
os.environ["HF_ENDPOINT"] = "https://hf-mirror.com"

文档切分策略详解

为什么需要切分?

plaintext

假设有一本 10 万字的书 📚

❌ 直接处理整本书:
   - LLM 的上下文窗口有限,装不下
   - 检索粒度太粗,找到的内容太多

✅ 切成小块处理:
   - 每个小块 500 字符,刚好合适
   - 可以精确定位到相关内容

chunk_size 和 chunk_overlap 的关系

plaintext

文本: "ABCDEFGHIJKLMNOPQRSTUVWXYZ" (26个字符)

chunk_size=10, chunk_overlap=5

块1: [0-10)     → "ABCDEFGHIJ"
块2: [5-15)     → "FGHIJLMNOP"  ← 与块1重叠5个字符
块3: [10-20)    → "LMNOPQRSTU"
块4: [15-25)    → "PQRSTUVWXYZ"

好处: "LMNO" 不会因为切分而丢失上下文!

延伸思考:如何选择合适的 chunk_size?

场景 推荐 chunk_size 原因
短问答 200-300 字符 问题简单,不需要太多上下文
文档分析 500-800 字符 保留更多语义完整性
代码检索 100-200 字符 代码块通常较小
长文章总结 1000+ 字符 需要足够上下文来总结

2.3 上传路由 (upload.py)

学习目标

  • 掌握 FastAPI 文件上传的处理方式
  • 理解文件类型验证和大小限制
  • 学会处理批量上传

技术原理

文件上传的流程

plaintext

用户选择文件 → 浏览器将文件封装为 FormData → POST 请求发送到服务器
                          │
                          ▼
            服务器接收 → 验证文件类型/大小 → 保存到磁盘
                          │
                          ▼
            调用文档处理服务 → 返回处理结果

核心代码解析

python

"""
文档上传路由
功能:接收用户上传的文档,保存到服务器,并调用文档处理服务
"""
import os
import shutil
from pathlib import Path
from typing import List

from fastapi import APIRouter, UploadFile, File, HTTPException
from fastapi.responses import JSONResponse

from ..services.document_processor import process_document
from ..services.vector_store import get_vector_store

#  FastAPI 路由配置​
router = APIRouter(prefix="/upload", tags=["文档上传"])

# 支持的文件类型(白名单机制)
SUPPORTED_EXTENSIONS = {".txt", ".md", ".pdf"}

def get_upload_dir():
    """获取上传目录的绝对路径"""
    base_dir = Path(__file__).parent.parent
    data_dir = base_dir / "data"
    upload_dir = data_dir / "uploads"
    os.makedirs(upload_dir, exist_ok=True)
    return upload_dir

@router.post("/file")
async def upload_file(file: UploadFile = File(...)):
    """
    上传单个文档
    
    核心步骤:
    1. 验证文件类型
    2. 检查文件大小
    3. 保存文件到磁盘
    4. 处理文档内容
    5. 添加到向量库
    """
    
    # 📌 1. 验证文件类型
    file_extension = Path(file.filename).suffix.lower()
    if file_extension not in SUPPORTED_EXTENSIONS:
        raise HTTPException(
            status_code=400,
            detail=f"不支持的文件类型: {file_extension},仅支持: {', '.join(SUPPORTED_EXTENSIONS)}"
        )
    
    # 📌 2. 检查文件大小(限制 10MB)
    contents = await file.read()
    if len(contents) > 10 * 1024 * 1024:  # 10MB = 10 * 1024 * 1024
        # raise 语句用于主动触发异常
        raise HTTPException(status_code=400, detail="文件大小超过 10MB 限制")
    
    # 📌 3. 保存文件
    upload_dir = get_upload_dir()
    file_path = upload_dir / file.filename
    with open(file_path, "wb") as buffer:
        buffer.write(contents)
    
    # 📌 4. 处理文档(切分成小块)
    # 处理文档
    try:
        result = process_document(str(file_path))
        result["status"] = "success"
        
        # ✅ 自动将 chunks 添加到向量库
        if result.get("chunks"):
            vector_store = get_vector_store()
            vector_store.add_documents(result["chunks"], file.filename)
            result["vector_stored"] = True
        
        return result
    except Exception as e:
        # 处理失败,删除已上传的文件
        if file_path.exists():
            file_path.unlink()
        raise HTTPException(status_code=500, detail=f"文档处理失败: {str(e)}")
    
@router.post("/files")
async def upload_multiple_files(files: List[UploadFile] = File(...)):
    """
    批量上传文档
    
    - files: 要上传的文件列表
    
    Returns:
        所有文档的处理结果
    """
    results = []
    uploaded_files = []
    
    for file in files:
        # 检查文件类型
        file_extension = Path(file.filename).suffix.lower()
        if file_extension not in SUPPORTED_EXTENSIONS:
            results.append({
                "file_name": file.filename,
                "status": "error",
                "error": f"不支持的文件类型: {file_extension}"
            })
            continue
        
        # 保存文件
        contents = await file.read()
        upload_dir = get_upload_dir()
        file_path = upload_dir / file.filename
        
        try:
            with open(file_path, "wb") as buffer:
                buffer.write(contents)
            uploaded_files.append(file_path)
            
            # 处理文档
            result = process_document(str(file_path))
            result["status"] = "success"
            
            # ✅ 自动将 chunks 添加到向量库
            if result.get("chunks"):
                vector_store = get_vector_store()
                vector_store.add_documents(result["chunks"], file.filename)
                result["vector_stored"] = True
            
            results.append(result)
        except Exception as e:
            results.append({
                "file_name": file.filename,
                "status": "error",
                "error": str(e)
            })
            # 清理已上传的文件
            if file_path.exists():
                file_path.unlink()
    
    return {
        "total": len(files),
        "success_count": sum(1 for r in results if r["status"] == "success"),
        "results": results
    }

@router.get("/list")
async def list_uploaded_files():
    """
    列出已上传的所有文档
    """
    files = []
    upload_dir = get_upload_dir()
    for file_path in upload_dir.iterdir():
        if file_path.is_file():
            files.append({
                "name": file_path.name,
                "size": file_path.stat().st_size,
                "extension": file_path.suffix
            })
    return {"files": files}

@router.delete("/{filename}")
async def delete_file(filename: str):
    """
    删除指定的已上传文件
    """
    upload_dir = get_upload_dir()
    file_path = upload_dir / filename
    if not file_path.exists():
        raise HTTPException(status_code=404, detail="文件不存在")
    
    file_path.unlink()
    return {"status": "deleted", "filename": filename}

代码执行流程图

plaintext

POST /upload/file
        │
        ▼
┌───────────────────────────────┐
│  接收 UploadFile 对象         │
└───────────────────────────────┘
        │
        ▼
┌───────────────────────────────┐
│  检查文件扩展名                │
│  (.txt/.md/.pdf?)             │
│  ❌ 不符合 → 返回 400 错误     │
│  ✅ 符合 → 继续              │
└───────────────────────────────┘
        │
        ▼
┌───────────────────────────────┐
│  读取文件内容                 │
│  检查大小 > 10MB?             │
│  ❌ 超限 → 返回 400 错误      │
│  ✅ 正常 → 继续              │
└───────────────────────────────┘
        │
        ▼
┌───────────────────────────────┐
│  保存文件到 ./data/uploads/   │
└───────────────────────────────┘
        │
        ▼
┌───────────────────────────────┐
│  调用 process_document()     │
│  - 读取文件内容               │
│  - 切分成小块 (chunks)        │
└───────────────────────────────┘
        │
        ▼
┌───────────────────────────────┐
│  调用 get_vector_store()     │
│  - 获取单例向量库             │
│  - 添加 chunks 到向量库       │
└───────────────────────────────┘
        │
        ▼
┌───────────────────────────────┐
│  返回处理结果                 │
│  {                           │
│    "file_name": "xxx.pdf",   │
│    "total_chunks": 42,       │
│    "vector_stored": true     │
│  }                           │
└───────────────────────────────┘

实战技巧

1. 防止文件名冲突

python

# ❌ 直接使用原始文件名(可能重复)
file_path = upload_dir / file.filename

# ✅ 使用时间戳或 UUID 生成唯一文件名
import uuid
unique_name = f"{uuid.uuid4().hex}_{file.filename}"
file_path = upload_dir / unique_name

2. 异步文件读取

python

# ✅ 推荐:使用 await 异步读取
contents = await file.read()

# ❌ 不推荐:同步读取会阻塞事件循环
contents = file.file.read()

面试考点

Q1: FastAPI 中 File 和 UploadFile 的区别?

回答要点

 
  • UploadFile 提供异步文件操作、更方便的文件元数据访问
  • File 直接用于 formdata["file"] 的场景
  • 实际上传大文件时,UploadFile 配合 async def 使用异步读写效率更高

Q2: 为什么要在处理失败时删除已上传的文件?

回答要点

 
  • 避免磁盘空间浪费
  • 保持文件系统整洁
  • 防止因部分失败导致的数据不一致

2.4 聊天路由 (chat.py)

学习目标

  • 理解 RAG 在聊天场景中的应用
  • 掌握聊天历史的管理方式
  • 学会构建 RAG 提示词

技术原理

聊天 + RAG 的工作流程

plaintext

用户: "什么是机器学习?"
        │
        ▼
┌─────────────────────────────────────┐
│  1️⃣ 检索相关文档                    │
│     vector_store.search("什么是机器学习") │
│     ↓                               │
│     找到最相关的 3 个文档片段        │
└─────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────┐
│  2️⃣ 构建上下文                      │
│     "来源: 机器学习.pdf"            │
│     "内容: 机器学习是..."           │
└─────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────┐
│  3️⃣ 构建提示词                      │
│     基于以下上下文回答:              │
│     {检索到的内容}                  │
│     用户问题: 什么是机器学习?      │
└─────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────┐
│  4️⃣ 调用 LLM 生成答案              │
│     (这里目前是模拟返回)            │
└─────────────────────────────────────┘
        │
        ▼
    返回答案 + 来源列表

代码解析

python

from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import List, Optional
import httpx
import json
import asyncio

from ..services.vector_store import get_vector_store

router = APIRouter(prefix="/chat", tags=["聊天"])

# 模拟的对话历史
chat_history = {}
# AI 对话 API 数据模型设计
class ChatRequest(BaseModel):
    question: str
    session_id: str = "default"
    use_rag: bool = True  # 是否启用 RAG 增强
    top_k: int = 3  # RAG 检索数量

class SourceItem(BaseModel):
    content: str
    filename: str
    distance: Optional[float] = None

class ChatResponse(BaseModel):
    answer: str
    sources: List[SourceItem]
    used_rag: bool
#  Ollama 本地模型 API 交互的流式响应生成器
async def generate_streaming_response(prompt: str, model: str = "llama3:8b"):
    """生成流式响应"""
    try:
        async with httpx.AsyncClient(timeout=120.0) as client:
            async with client.stream(
                "POST",
                "http://localhost:11434/api/generate",
                json={
                    "model": model,
                    "prompt": prompt,
                    "stream": True
                }
            ) as response:
                async for line in response.aiter_lines():
                    if line:
                        try:
                            data = json.loads(line)
                            if "response" in data:
                                yield data["response"]
                        except json.JSONDecodeError:
                            continue
    except Exception as e:
        yield f"错误: {str(e)}"

async def chat_with_rag(request: ChatRequest):
    """带RAG增强的聊天"""
    #  获取向量存储实例
    vector_store = get_vector_store()

    retrieved_docs = vector_store.search(request.question, top_k=request.top_k)

    sources = []
    if retrieved_docs:
        context_parts = []
        for i, doc in enumerate(retrieved_docs, 1):
            context_parts.append(f"[文档{i}] ({doc['filename']}):\n{doc['content']}")
        context = "\n\n".join(context_parts)

        prompt = f"""你是一个知识库问答助手。请根据以下参考资料回答用户的问题。
如果问题与参考资料相关,请基于参考资料回答;如果无关,你可以根据自己的知识回答。(但是务必用中文回答)

参考资料:
{context}

用户问题:{request.question}

回答:"""

        sources = [
            SourceItem(
                content=doc["content"],
                filename=doc["filename"],
                distance=doc.get("distance")
            )
            for doc in retrieved_docs
        ]
    else:
        prompt = f"你是一个知识库问答助手,请回答用户的问题。(用中文回答)\n\n问题:{request.question}"

    return prompt, sources

@router.post("/", response_model=ChatResponse)
async def chat(request: ChatRequest):    
    """
    聊天接口:基于 RAG 的智能问答
    
    📌 Pydantic 模型自动验证请求数据
    """
    # 1. 如果启用 RAG,先检索相关文档
    prompt = None
    sources = []
    used_rag = False


    
    #  检索相关文档
    # top_k=3 表示返回最相关的 3 个文档
    sources = vector_store.search(request.message, top_k=3)
    
    
    #  构建 RAG 提示词
 if request.use_rag:
        try:
            prompt, sources = await chat_with_rag(request)
            used_rag = True
        except Exception as e:
            prompt = f"你是一个知识库问答助手,请回答用户的问题。(用中文回答)\n\n问题:{request.question}"
    else:
        prompt = f"你是一个知识库问答助手,请回答用户的问题。(用中文回答)\n\n问题:{request.question}"

    #  调用 LLM
answer = ""
    try:
        async with httpx.AsyncClient(timeout=120.0) as client:
            response = await client.post(
                "http://localhost:11434/api/generate",
                json={
                    "model": "llama3:8b",
                    "prompt": prompt,
                    "stream": False
                }
            )
            result = response.json()
            answer = result.get("response", "抱歉,暂时无法回答")
    except Exception as e:
        answer = f"抱歉,AI 服务暂时不可用:{str(e)}"
    
    
    #  保存到聊天历史
if request.session_id not in chat_history:
        chat_history[request.session_id] = []
    chat_history[request.session_id].append({
        "question": request.question,
        "answer": answer
    })

    return ChatResponse(
        answer=answer,
        sources=sources,
        used_rag=used_rag
    )
    
@router.post("/stream")
async def chat_stream(request: ChatRequest):
    """
    流式聊天问答接口
    支持 RAG 增强:从向量库检索相关文档,作为上下文回答
    """
    # 1. 如果启用 RAG,先检索相关文档
    prompt = None
    sources = []
    used_rag = False

    if request.use_rag:
        try:
            prompt, sources = await chat_with_rag(request)
            used_rag = True
        except Exception as e:
            prompt = f"你是一个知识库问答助手,请回答用户的问题。(用中文回答)\n\n问题:{request.question}"
    else:
        prompt = f"你是一个知识库问答助手,请回答用户的问题。(用中文回答)\n\n问题:{request.question}"

    # 2. 保存历史
    if request.session_id not in chat_history:
        chat_history[request.session_id] = []
    chat_history[request.session_id].append({
        "question": request.question,
        "answer": ""  # 先占位,流式完成后再更新
    })

    # 3. 返回流式响应
    async def stream_generator():
        full_response = ""
        try:
            async for chunk in generate_streaming_response(prompt, "llama3:8b"):
                full_response += chunk
                # 发送 SSE 格式的数据
                yield f"data: {json.dumps({'token': chunk, 'done': False})}\n\n"

            # 流式结束后,发送完成信号
            yield f"data: {json.dumps({'token': '', 'done': True, 'sources': [s.dict() for s in sources]})}\n\n"

            # 更新历史记录
            if chat_history[request.session_id]:
                chat_history[request.session_id][-1]["answer"] = full_response

        except Exception as e:
            error_msg = f"错误: {str(e)}"
            yield f"data: {json.dumps({'token': error_msg, 'done': True, 'error': True})}\n\n"

    return StreamingResponse(
        stream_generator(),
        media_type="text/event-stream",
        headers={
            "Cache-Control": "no-cache",
            "Connection": "keep-alive",
            "X-Accel-Buffering": "no"
        }
    )

@router.get("/history/{session_id}")
async def get_history(session_id: str):
    """获取聊天历史"""
    return {"session_id": session_id, "messages": chat_history.get(session_id, [])}


@router.delete("/history/{session_id}")
async def clear_history(session_id: str):
    """清除聊天历史"""
    if session_id in chat_history:
        chat_history[session_id] = []
    return {"status": "success", "message": f"会话 {session_id} 的历史已清除"}

RAG 提示词设计

好的 RAG 提示词应该包含

plaintext

📌 角色设定(可选)
你是一个专业的知识库助手

📌 上下文信息
基于以下参考资料回答问题:
---
[文档1的内容]
[文档2的内容]
[文档3的内容]
---

📌 明确要求
1. 只基于提供的上下文回答
2. 如果上下文中没有相关信息,请说明"我没有找到相关信息"
3. 引用时说明来源

📌 用户问题
{用户的实际问题}

面试考点

Q1: RAG 相比纯 LLM 回答有什么优势?

回答要点

 
  1. 可溯源性:答案来自哪个文档,清清楚楚
  2. 减少幻觉:不是凭空编造,有据可查
  3. 知识更新:更新文档即可更新知识,无需重新训练
  4. 成本更低:比 fine-tuning 便宜很多

Q2: 为什么需要保存聊天历史?

回答要点

 
  • 支持多轮对话,后面的问题可以引用前面的上下文
  • 实现"记忆"功能,AI 能记住之前聊过的内容
  • 方便用户回顾之前的对话

Q3: 为什么使用 Pydantic BaseModel?

回答要点

 
  • 自动数据验证(类型、必填、默认值)
  • 自动生成文档

2.5 文档处理服务 (document_processor.py)

学习目标

  • 掌握不同文档格式的读取方法
  • 理解文本切分的策略和实现
  • 学会处理边界情况

技术原理

文档处理的三步骤

plaintext

原始文档 → 文本提取 → 文本切分 → 文本块列表
  PDF       ↓
  TXT       ↓
  MD        ↓
         三种格式统一输出为纯文本

核心代码解析

python

"""
文档处理服务
功能:从各类文档(PDF、TXT、MD)中提取文本,并切分成小块
"""
import os
from pathlib import Path
from typing import List

def load_document(file_path: str, file_extension: str) -> str:
    """
    根据文件类型加载文档
    Args:
        file_path: 文件路径
        file_extension: 文件扩展名(.txt, .md, .pdf)
    
    Returns:
        文档文本内容

    设计思路:策略模式
    - 不同文件类型用不同的读取方法
    - 统一输出为纯文本(str)
    """
    
    if file_extension == ".txt" or file_extension == ".md":
        #  文本文件直接读取
        with open(file_path, 'r', encoding='utf-8') as f:
            return f.read()
    
    elif file_extension == ".pdf":
        # 使用 pypdf 读取 PDF
        from pypdf import PdfReader
        reader = PdfReader(file_path)
        text = ""
        for page in reader.pages:
            text += page.extract_text() + "\n"
        return text
    
    else:
        raise ValueError(f"不支持的文件类型: {file_extension}")

def split_text(text: str, chunk_size: int = 500, chunk_overlap: int = 50) -> List[str]:
    """
    将长文本切分成小块
    
    Args:
        text: 要切分的文本
        chunk_size: 每个块的最大字符数
        chunk_overlap: 相邻块之间的重叠字符数
    
    Returns:
        切分后的文本块列表
    """
    chunks = []
    start = 0
    while start < len(text):
        # 计算当前块的结束位置
        end = start + chunk_size
        
        # 如果不是最后一块,尝试在句子边界处切割
        if end < len(text):
            # 往后查找最后一个换行符或句子结束符
            for sep in ['\n\n', '\n', '。', '!', '?', '. ', '! ', '? ']:
                last_sep = text.rfind(sep, start, end)
                if last_sep > start:
                    end = last_sep + len(sep)
                    break
        
        # 提取当前块
        chunk = text[start:end].strip()
        if chunk:
            chunks.append(chunk)
        
        # 移动起始位置(考虑重叠)
        start = end - chunk_overlap
        if start <= 0:
            start = end
    
    return chunks

def process_document(file_path: str) -> dict:
    """
    处理单个文档:加载 + 切分
    
    Args:
        file_path: 文件路径
    
    Returns:
        包含文档信息的字典
    """
    file_path = Path(file_path)
    file_extension = file_path.suffix.lower()
    
    # 加载文档
    text = load_document(str(file_path), file_extension)
    
    # 切分文档
    chunks = split_text(text)
    
    return {
        "file_name": file_path.name,
        "file_size": os.path.getsize(file_path),
        "total_chars": len(text),
        "total_chunks": len(chunks),
        "chunks": chunks
    }

文本切分详解

python

def split_text(text: str, chunk_size: int = 500, chunk_overlap: int = 50) -> List[str]:
    """
    智能文本切分
    
    核心思路:
    1. 按固定大小切分
    2. 在句子边界处微调(避免把一句话切成两半)
    3. 保持相邻块之间的重叠
    """
    
    chunks = []
    start = 0
    
    while start < len(text):
        # 1️⃣ 计算当前块的结束位置
        end = start + chunk_size
        
        # 2️⃣ 如果不是最后一块,在句子边界处切割
        if end < len(text):
            # 按优先级查找句子结束符
            # 优先在段落边界切(\n\n)
            # 其次是句号(。)
            # 最后是换行(\n)
            for sep in ['\n\n', '\n', '。', '!', '?', '. ', '! ', '? ']:
                last_sep = text.rfind(sep, start, end)  # 从后往前找
                if last_sep > start:  # 找到了
                    end = last_sep + len(sep)  # 在此处切割
                    break
        
        # 3️⃣ 提取当前块
        chunk = text[start:end].strip()
        if chunk:
            chunks.append(chunk)
        
        # 4️⃣ 移动起始位置(考虑重叠)
        # overlap 确保相邻块有共同的上下文
        start = end - chunk_overlap
        if start <= 0:
            start = end
    
    return chunks

切分流程图解

plaintext

原文: "段落一的内容。\n\n段落二的内容。\n\n段落三的内容很长..."
chunk_size=20, chunk_overlap=5

迭代1:
  start=0, end=20
  在 "。" 处切割
  块1: "段落一的内容。"
  
迭代2:
  start=15 (20-5), end=35
  在 "\n\n" 处切割
  块2: "段落二的内容。"
  
迭代3:
  start=30 (35-5), end=50
  块3: "段落三的内容很长..."

实战技巧:PDF 解析的坑

python

# ⚠️ PDF 解析的常见问题:

# 1. 扫描版 PDF(图片)无法直接提取文字
# 需要用 OCR(如 pytesseract)处理

# 2. 复杂排版的 PDF 可能提取不完整
# 可以考虑:
#   - 减小 chunk_size
#   - 预处理清理格式

# 3. 中文 PDF 的编码问题
# 确保使用 utf-8 编码读取

面试考点

Q1: 如何选择 chunk_size?

回答要点

 
  • 太小:丢失上下文,每个块语义不完整
  • 太大:超出 LLM 上下文窗口,检索粒度粗
  • 经验值:500-1000 字符是常见选择
  • 可以根据文档类型和业务场景调整

Q2: chunk_overlap 的作用是什么?

回答要点

 
  • 保证语义完整性:重要信息不会被"腰斩"
  • 增加检索命中率:相关内容更容易被匹配到
  • 代价是存储和计算量增加约 10%

2.6 向量存储服务 (vector_store.py)

学习目标

  • 理解向量存储的设计思路
  • 掌握单例模式的实现
  • 理解向量检索的基本原理

技术原理

向量存储 = 文档的"数字坐标系统"

类比理解:

  • 想象一座城市,每个建筑物都有 GPS 坐标
  • 当你问"附近有什么餐厅"时,系统会:
    1. 把你当前位置转为坐标
    2. 找最近的几个坐标点
    3. 返回这些点对应的餐厅信息

核心代码解析:

python

class VectorStore:
    """向量存储类,提供文档存储和检索功能"""
    
    def __init__(self, persist_directory: str = "./chroma_db"):
        """
        初始化向量存储
        
        Args:
            persist_directory: 持久化目录
        """
        self.persist_directory = persist_directory
        os.makedirs(persist_directory, exist_ok=True)

        # 使用内存存储(生产环境会用 Chroma/FAISS 等)
        self.documents = []
    
    def add_documents(self, chunks: List[str], filename: str) -> Dict:
        """
        将文本块添加到存储
        
        Args:
            chunks: 文本块列表
            filename: 来源文件名
        
        Returns:
            添加结果
        
        实际生产中会做:
        1. 调用 Embedding 模型将文本转为向量
        2. 存储向量 + 元数据
        """
        if not chunks:
            return {"status": "success", "count": 0}
        # 添加到内存
        for chunk in chunks:
            self.documents.append({
                "id": f"{filename}_{uuid.uuid4().hex[:8]}",
                "content": chunk,
                "filename": filename
            })
        
        return {
            "status": "success",
            "count": len(chunks),
            "filename": filename
        }
    
    def search(self, query: str, top_k: int = 5) -> List[Dict]:
        """
        检索相关文档
        
        Args:
            query: 查询文本
            top_k: 返回结果数量
        Returns:
            相关文档列表,每项包含 content, filename, distance

        简化版:关键词匹配
        生产版:向量相似度计算
        """
        # 1️⃣ 计算每个文档与查询的匹配分数
        results = []
        for doc in self.documents:
            # 计算简单的匹配分数
            score = sum(1 for word in query.lower().split() 
                       if word in doc['content'].lower())
            if score > 0:
                results.append({
                    "content": doc['content'],
                    "filename": doc['filename'],
                    "distance": 1.0 / (score + 1)  # 分数越高,距离越小
                })
        
        # 2️⃣ 按相似度排序,返回 top_k 个
        results.sort(key=lambda x: x['distance'])
        return results[:top_k]

单例模式实现

python

# 全局变量存储单例实例
_vector_store: Optional[VectorStore] = None

def get_vector_store() -> VectorStore:
    """
    获取全局向量存储实例(单例模式)
    
    懒加载:用的时候才创建,创建后一直复用
    """
    global _vector_store
    if _vector_store is None:
        _vector_store = VectorStore()
    return _vector_store

# 使用示例
store1 = get_vector_store()
store2 = get_vector_store()
print(store1 is store2)  # True,同一个实例!

向量检索进阶(了解即可)

生产环境的向量检索

python

# 实际生产中会使用专业的向量数据库
# 这里以 Chroma 为例:

import chromadb
from chromadb.config import Settings

# 创建客户端
client = chromadb.Client(Settings(
    persist_directory="./chroma_db"
))

# 创建集合(类似表)
collection = client.create_collection("documents")

# 添加文档时自动向量化
collection.add(
    documents=["文本内容1", "文本内容2"],
    ids=["id1", "id2"],
    metadatas=[{"source": "file1.pdf"}, {"source": "file2.txt"}]
)

# 检索
results = collection.query(
    query_texts=["查询内容"],
    n_results=3
)

面试考点

Q1: 什么是向量嵌入(Embedding)?

回答要点

 
  • 将文本/图像等转换为固定长度的向量
  • 相似的文本在向量空间中距离近
  • 常用模型:OpenAI Embedding、BGE、M3E

Q2: 向量检索和关键词检索的区别?

回答要点

 
维度 关键词检索 向量检索
原理 匹配关键词 计算向量距离
语义 ❌ 不支持 ✅ 支持
同义词 ❌ 不支持 ✅ 支持
速度 较慢(需要索引优化)
典型工具 Elasticsearch Chroma, FAISS

Q3: 为什么使用单例模式?

回答要点

 
  • 节省内存:避免创建多个相同实例
  • 保持状态:向量库中的数据全局共享
  • 线程安全:单例访问,避免竞态条件

2.7 记忆服务 (memory.py)

学习目标

  • 理解对话记忆的设计思路
  • 掌握记忆的增删改查操作
  • 理解记忆召回的原理

技术原理

为什么需要记忆系统?

plaintext

对话1:
  你:我是小明 🎓
  AI:你好小明!

对话2:
  你:今天吃了啥?
  AI:❓ 不知道你是谁,也没记住你说过的话
  
有记忆系统后:
  对话2:
    AI:查一下记忆...小明之前说今天要去吃火锅?🍲

核心代码解析(不完整的代码,只做解析)

python

class ConversationBufferMemory:
    """
    对话缓冲记忆
    管理会话历史,支持 session_id 隔离
    """
    
    def __init__(self):
        """初始化记忆管理器"""
        self.sessions: Dict[str, List[dict]] = {}
    
    def add_message(self, session_id: str, role: str, content: str) -> None:
        """
        添加消息到会话历史
        
        Args:
            session_id: 会话ID
            role: 角色 (user/assistant)
            content: 消息内容
        """
        if session_id not in self.sessions:
            self.sessions[session_id] = []
        
        self.sessions[session_id].append({
            "role": role,
            "content": content,
            "timestamp": datetime.now().isoformat()
        })

def get_history(self, session_id: str, limit: Optional[int] = None) -> List[dict]:
        """
        获取会话历史
        
        Args:
            session_id: 会话ID
            limit: 限制返回的消息数量(最近N条)
        
        Returns:
            消息历史列表
        """
        history = self.sessions.get(session_id, [])
        if limit is not None:
            return history[-limit:]
        return history


# 全局记忆实例
memory = ConversationBufferMemory()


def get_memory() -> ConversationBufferMemory:
    """获取记忆实例"""
    return memory


def sync_chat_history_to_memory():
    """
    将 chat.py 中的 chat_history 同步到 memory
    保持与现有聊天功能的兼容性
    """
    # 延迟导入以避免循环导入问题
    try:
        from ..routers.chat import chat_history
    except Exception:
        return

    for session_id, messages in chat_history.items():
        for msg in messages:
            role = msg.get("role", "user")
            content = msg.get("content", "")
            if role == "user":
                memory.add_message(session_id, "user", content)
            else:
                memory.add_message(session_id, "assistant", content)

记忆系统的设计模式

plaintext

┌─────────────────────────────────────────────┐
│  对话流程中的记忆系统                        │
│                                             │
│  用户消息 → 存储到记忆 → 召回相关记忆 → 加入上下文 │
│      ↓                                      │
│  AI 回复 → 存储到记忆(可选)                │
└─────────────────────────────────────────────┘

记忆类型:
1. 短期记忆:当前对话中的内容
2. 长期记忆:用户偏好、历史信息
3. 工作记忆:正在处理任务的相关信息

2.8 智能代理服务 (agent.py)

学习目标

  • 理解 Agent(智能代理)的概念
  • 掌握工具注册表的设计
  • 理解 Tool Calling 的原理

技术原理

Agent = 会使用工具的 AI

类比理解:

  • 普通 AI:只能聊天,像一个只会说话的人 🗣️
  • Agent:可以调用工具,像一个能做事的人 🧑‍🔧
    • 可以搜索资料
    • 可以执行计算
    • 可以访问网页
    • 可以操作文件

核心代码解析:

python

class Agent:
    """智能代理类"""
    
    def __init__(self, config: Optional[AgentConfig] = None):
                """
        初始化Agent
        
        Args:
            config: Agent配置
        """
        self.config = config or AgentConfig()
        self.tool_registry = get_tool_registry()
        self.memory = get_memory()    # 记忆服务
        self._init_tools()               
    
    async def run(self, query: str, session_id: str = "default") -> Dict[str, Any]:
        """
        运行Agent处理查询
        
        Args:
            query: 用户查询
            session_id: 会话ID
        
        Returns:
            处理结果

        处理用户消息
        
        Agent 的思考流程:
        1. 理解用户意图
        2. 决定是否需要调用工具
        3. 执行工具获取结果
        4. 根据结果生成回答
        """
        
        # 1️⃣ 保存用户消息到记忆
        self.memory.add_message(session_id, "user", query)

        # 构建初始提示词
        system_prompt = self._build_system_prompt()

        # 获取对话历史
        history = self.memory.get_history(session_id, limit=10)
        history_context = self._format_history(history)
        
        full_prompt = f"{system_prompt}\n\n{history_context}\n\n用户问题: {query}"
    
        # ReAct循环
        thoughts = []
        iteration = 0
        final_answer = None

        while iteration < self.config.max_iterations:
            iteration += 1
            
            # 调用LLM
            llm_output = await self._call_llm(full_prompt)
            
            if self.config.verbose:
                print(f"[Iteration {iteration}] LLM Output:\n{llm_output}\n")
            
            # 解析输出
            parsed = self._parse_llm_output(llm_output)
            
            if parsed.get("is_final"):
                final_answer = parsed.get("final_answer", llm_output)
                break
            
            # 执行工具调用
            if parsed.get("action"):
                tool_name = parsed["action"]
                tool_input = parsed.get("action_input", {})
                
                # 记录思考过程
                thought = AgentThought(
                    thought=parsed.get("thought", ""),
                    action=tool_name,
                    action_input=tool_input
                )
                
                # 调用工具
                tool_result = self.tool_registry.call_tool(tool_name, **tool_input)
                thought.observation = str(tool_result)
                thoughts.append(thought)
                
                # 将工具结果添加到上下文
                full_prompt += f"\n\n{llm_output}\n观察结果: {tool_result}"
            else:
                # 没有解析到action,直接作为最终回答
                final_answer = llm_output
                break
        
        if final_answer is None:
            final_answer = "抱歉,我无法完成这个任务。"
        
        # 添加助手回复到记忆
        self.memory.add_message(session_id, "assistant", final_answer)
        
        return {
            "answer": final_answer,
            "thoughts": [t.dict() for t in thoughts],
            "iterations": iteration,
            "used_tools": [t.action for t in thoughts if t.action]
        }

工具注册表设计(tool_registry.py)

核心代码解析:

python

class ToolRegistry:
    """工具注册表:管理所有可用的工具"""
    
    def __init__(self):
        """初始化工具注册表"""
        self._tools: Dict[str, Callable] = {}
        self._tool_schemas: Dict[str, dict] = {}
        self._register_builtin_tools()
    
    def _register_builtin_tools(self):
        """注册内置工具"""
        # 时间工具
        self.register(
            name="get_current_time",
            func=self._get_current_time,
            description="获取当前时间",
            parameters={
                "type": "object",
                "properties": {},
                "required": []
            }
        )
        
        # 计算器工具
        self.register(
            name="calculator",
            func=self._calculator,
            description="执行数学计算,支持加减乘除和常见数学函数",
            parameters={
                "type": "object",
                "properties": {
                    "expression": {
                        "type": "string",
                        "description": "数学表达式,如 '2+3*4' 或 'sqrt(16)'"
                    }
                },
                "required": ["expression"]
            }
        )
        
        # 搜索工具(模拟)
        self.register(
            name="search",
            func=self._search,
            description="搜索互联网信息",
            parameters={
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "搜索关键词"
                    }
                },
                "required": ["query"]
            }
        )

Agent 工作流程图

plaintext

用户: "北京今天天气怎么样?"
        │
        ▼
┌─────────────────────────────────────┐
│  Agent 思考:                       │
│  "这个问题需要查询天气工具"         │
└─────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────┐
│  调用工具: get_weather              │
│  参数: {"location": "北京"}         │
└─────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────┐
│  工具执行,返回结果:               │
│  "北京今天晴,25度"                 │
└─────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────┐
│  Agent 将工具结果整合到回答中:      │
│  "北京今天天气晴朗,气温25度..."    │
└─────────────────────────────────────┘

面试考点

Q1: 什么是 Tool Calling?Agent 如何决定调用哪个工具?

回答要点

 
  • Tool Calling = 让 LLM 能够调用外部工具
  • 实现方式:给 LLM 提供工具定义(名称、描述、参数)
  • LLM 根据用户问题判断是否需要调用工具、调用哪个
  • 常见应用:联网搜索、API 调用、代码执行

Q2: Agent 和 普通 Chatbot 的区别?

回答要点

 
  • Chatbot:纯生成,输入→输出
  • Agent:可以执行动作,输入→思考→工具调用→整合→输出
  • Agent 有更强的"执行力",能真正完成任务

第三部分:前端核心模块

3.1 主应用结构 (App.jsx)

学习目标

  • 理解 React 组件的基本结构
  • 掌握状态管理(useState)
  • 理解条件渲染和组件组合

技术原理

React = 构建用户界面的库

类比理解:

  • HTML = 静态页面(像一张打印好的纸)
  • React = 动态页面(像一个活的、会变化的页面)

jsx

import { useState } from 'react'
import ChatWindow from './components/ChatWindow'
import RAGPanel from './components/RAGPanel'

export default function App() {
    // 状态管理:存储当前活动的标签页
    const [activeTab, setActiveTab] = useState('chat')
    
    return (
    <div className="min-h-screen bg-gradient-to-br from-blue-50 to-indigo-50 p-4 md:p-8">
      <div className="max-w-6xl mx-auto">
        <div className="text-center mb-8">
          <h1 className="text-3xl md:text-4xl font-bold text-gray-800 mb-2">
            ChainMind
          </h1>
          <p className="text-gray-600">本地AI知识库问答系统</p>
        </div>
        
        <div className="bg-white rounded-xl shadow-lg overflow-hidden">
          <div className="tabs">
            <button 
              onClick={() => setActiveTab('chat')}
              className={`tab ${activeTab === 'chat' ? 'active' : 'inactive'}`}
            >
              💬 聊天
            </button>
            <button 
              onClick={() => setActiveTab('rag')}
              className={`tab ${activeTab === 'rag' ? 'active' : 'inactive'}`}
            >
              📚 RAG
            </button>
          </div>
          
          <div className="p-4 md:p-6">
            {activeTab === 'chat' && <ChatWindow />}
            {activeTab === 'rag' && <RAGPanel />}
          </div>
        </div>
        
        <div className="mt-6 text-center text-sm text-gray-500">
          <p>Powered by FastAPI + Ollama + LangChain</p>
        </div>
      </div>
    </div>
  )
}

状态管理详解

jsx

// useState:React 的状态钩子
const [activeTab, setActiveTab] = useState('chat')
//     ↑            ↑                    ↑
//  当前值       更新函数              初始值

// 点击按钮时更新状态
<button onClick={() => setActiveTab('rag')}>
    切换到 RAG
</button>

// 条件渲染
{activeTab === 'chat' && <ChatWindow />}
// 等价于:
if (activeTab === 'chat') {
    return <ChatWindow />
}

3.2 文件上传组件 (UploadPanel.jsx)

学习目标

  • 掌握文件上传的实现方式
  • 理解拖拽上传的原理
  • 学会处理上传状态和错误

技术原理

文件上传的三种方式

plaintext

1️⃣ 点击选择文件
   <input type="file" onChange={handleFileChange} />

2️⃣ 拖拽上传
   onDragOver → onDrop → 处理文件

3️⃣ 批量上传
   <input type="file" multiple />

核心代码解析

jsx

export default function UploadPanel() {
    const [file, setFile] = useState(null)      // 当前选中的文件
    const [uploading, setUploading] = useState(false)  // 上传状态
    const [dragOver, setDragOver] = useState(false)    // 拖拽状态
    
    // 📌 处理文件选择
    const handleFileChange = (e) => {
        const selectedFile = e.target.files[0]
        if (selectedFile) {
            setFile(selectedFile)
        }
    }
    
    // 📌 处理拖拽悬停
    const handleDragOver = (e) => {
        e.preventDefault()  // 阻止默认行为(打开文件)
        setDragOver(true)
    }
    
    // 📌 处理文件放下
    const handleDrop = (e) => {
        e.preventDefault()
        setDragOver(false)
        const droppedFile = e.dataTransfer.files[0]
        if (droppedFile) {
            setFile(droppedFile)
        }
    }
    
    // 📌 上传文件
    const handleUpload = async () => {
        if (!file) return
        
        setUploading(true)
        
        // 1️⃣ 创建 FormData
        const formData = new FormData()
        formData.append('file', file)
        
        try {
            // 2️⃣ 发送请求
            const response = await fetch('/api/upload/file', {
                method: 'POST',
                body: formData  // FormData 自动设置 Content-Type
            })
            
            const data = await response.json()
            setResult({ success: true, data })
            
        } catch (error) {
            setResult({ success: false, error: error.message })
        } finally {
            setUploading(false)
        }
    }
    
    return (
        <div onDragOver={handleDragOver} onDrop={handleDrop}>
            {/* 拖拽区域 */}
            <div className="border-2 border-dashed">
                拖拽文件到此处
            </div>
            
            {/* 上传按钮 */}
            <button onClick={handleUpload} disabled={uploading}>
                {uploading ? '上传中...' : '上传'}
            </button>
        </div>
    )
}

拖拽上传原理解析

plaintext

浏览器默认行为:
  拖拽文件到页面 → 打开文件(不想要这个效果)

我们需要:
  拖拽文件到上传区 → 显示高亮 → 放开 → 上传文件

实现步骤:
1. onDragOver: e.preventDefault() → 阻止默认打开
2. onDragLeave: 移除高亮样式
3. onDrop: e.preventDefault() → 阻止默认打开,获取文件

第四部分:面试实战总结

核心知识点速记卡

RAG 相关

问题 核心答案
RAG 是什么? Retrieval-Augmented Generation,检索增强生成
RAG 解决什么问题? LLM 幻觉、知识过时、可溯源性
RAG 的三个步骤? 检索 → 增强 → 生成
为什么要切分文档? LLM 上下文有限、检索粒度需要适中

FastAPI 相关

问题 核心答案
FastAPI 优势? 自动文档、类型验证、异步支持
如何配置 CORS? add_middleware(CORSMiddleware, ...)
什么是中间件? 请求/响应的"拦截器"
Pydantic 的作用? 数据验证、类型转换、自动文档

系统设计相关

问题 核心答案
为什么要用单例模式? 节省资源、保持状态一致
前端如何调用后端 API? fetch/axios 发送 HTTP 请求
为什么需要记忆系统? 支持多轮对话、保持上下文

高频面试题详解

题目 1:画出 RAG 的工作流程

参考答案

plaintext

用户问题
    │
    ▼
┌─────────────────────────────────────┐
│  1. 向量化问题                      │
│     text → [0.23, -0.45, ...]       │
└─────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────┐
│  2. 向量检索                        │
│     在向量库中找到 top_k 个相似文档  │
└─────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────┐
│  3. 构建 Prompt                     │
│     系统提示 + 检索结果 + 用户问题   │
└─────────────────────────────────────┘
    │
    ▼
┌─────────────────────────────────────┐
│  4. LLM 生成答案                    │
│     基于上下文生成准确答案           │
└─────────────────────────────────────┘
    │
    ▼
    返回答案 + 引用来源

题目 2:FastAPI 如何实现文件上传?

参考答案

python

from fastapi import FastAPI, UploadFile, File

app = FastAPI()

@router.post("/upload")
async def upload_file(file: UploadFile = File(...)):
    # 1. 验证文件类型和大小
    # 2. 读取文件内容
    contents = await file.read()
    # 3. 保存到磁盘
    with open(f"./uploads/{file.filename}", "wb") as f:
        f.write(contents)
    # 4. 返回结果
    return {"filename": file.filename, "size": len(contents)}

关键点

  • UploadFile vs File:UploadFile 提供更好的异步支持
  • await file.read():异步读取,避免阻塞
  • 一定要验证文件类型,防止恶意文件

题目 3:什么是向量检索?

参考答案

向量检索是一种基于语义相似度的搜索技术。

 

核心思想:将文本转换为向量(数字数组),相似的文本在向量空间中距离更近。

 

工作原理

 
  1. 使用 Embedding 模型将文档转为向量
  2. 用户查询也转为向量
  3. 计算查询向量与所有文档向量的"距离"
  4. 返回距离最近(最相似)的文档
 

优势:能理解语义,找到"意思相近"的内容,不依赖关键词匹配。

题目 4:如何优化 RAG 的效果?

参考答案

优化方向 具体方法
检索质量 更好的 Embedding 模型、混合检索(向量+关键词)
文档切分 调整 chunk_size、使用语义切分而非固定长度
上下文 增加元数据过滤、重排序(Rerank)
提示词 优化 Prompt 设计、few-shot 示例
答案质量 选择更强的 LLM、调整 temperature

展示效果:

延伸学习资源

推荐阅读

  1. 《Building RAG Applications》 - RAG 系统构建指南
  2. FastAPI 官方文档 - fastapi.tiangolo.com
  3. LangChain 官方文档 - python.langchain.com

总结

  • ✅ RAG 系统的核心原理和实现
  • ✅ FastAPI 后端开发的最佳实践
  • ✅ 前后端分离架构的设计思路
  • ✅ 单例模式、工具注册表等设计模式
  • ✅ 文件上传、异步处理等实战技能

这些知识不仅能帮助你理解 AI 应用开发的基础,也为你后续学习更高级的 Agent 系统打下了坚实的基础。

项目地址(需要把里面的本地模型换为你自己的模型API)https://github.com/AiW520/ChainMind-https://github.com/AiW520/ChainMind-.git

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐