第一部分:导入依赖

from fastapi import FastAPI, UploadFile, File

FastAPI — Web 框架

特性说明
异步用 async def,能同时处理大量并发请求
自动文档启动后访问 http://127.0.0.1:8000/docs 就有 Swagger UI
类型校验和 Pydantic 深度集成,请求参数自动校验

拓展:FastAPI vs Flask vs Django

框架特点学习曲线
Flask轻量、灵活、“微型”,功能都要自己装中
Django全功能、自带后台/ORM/Admin陡
FastAPI异步、自动文档、Pydantic 集成、类型安全缓

FastAPI 是目前 Python Web 框架里增长最快的,适合 AI 后端场景。

UploadFile 和 File — 文件上传

async def upload_file(file: UploadFile = File(...)):
    content = await file.read()
类型作用
UploadFile文件对象,含 name/size/content,可用 .read() 读取内容
File(...)FastAPI 专有参数声明,表示从请求体里取文件(multipart/form-data 格式)

...(省略号)在 Python 里是什么意思?

... 是 Python 的字面量(Literal),三个点,读作"Ellipsis"。常见用法:

# 1. 类型注解中,表示"这个位置必须填"
def func(x: str = ...):
    pass  # x 必须显式传参,不能省略

# 2. NumPy 多维数组切片
arr = np.array([[1,2],[3,4]])
arr[..., 0]  # 所有行的第0列 → [1, 3]

# 3. Tuple[int, ...]	任意个整数的元组	任意长度

🔧 删了/改了会怎样

  • 删掉 File(...),只写 UploadFile:FastAPI 依然能接收文件,但语义不对——File 是专门给 multipart/form-data 设计的,不写的话 Swagger UI 里看不到"form-data"的标注,参数描述也不清晰。
  • 把 File(...) 改成 File(default=None):这意味着文件变成可选的,调用方不传文件也不会报错,业务逻辑里就要自己判断 file is None 的情况。
  • 把 UploadFile 换成 bytes:两种方式的区别是——bytes 会把整个文件读进内存(大文件容易 OOM),而 UploadFile 是流式的,边读边处理,内存占用小得多。生产环境上传文件一定要用 UploadFile。

🌐 还能用在哪里

  • Django 视图:Django 用 request.FILES 接收上传文件,语义不同但场景一致。
  • Flask:直接写 request.files.get('file'),不需要类型声明。
  • NestJS(TypeScript):@UploadedFile() 装饰器对应 FastAPI 的 File(...) 参数声明。
  • 省略号 ...:在 NumPy 切片里用得极多,如 arr[:, 0, :] 保持某维度不变;还可以在 dataclass 里写 field(default=...) 表示必须显式赋值。

📌 实际应用注意点

  • File(...) 的省略号不能省略,它表示这个参数是必填的,与函数默认参数 def foo(x=...) 是两个完全不同的语义。
  • 上传大文件(比如 PDF、音频)时,不要用 await file.read() 一次性读进内存。建议用 async for chunk in file 流式处理或直接传文件路径给下游。
  • UploadFile 的 content_type 属性可以判断文件类型,但不要把它当安全验证用——这个值是请求头里传过来的,前端可以伪造。真正的安全验证要靠文件魔数(Magic Number)判断。

from fastapi.middleware.cors import CORSMiddleware

CORS — 跨域资源共享

前端跑在 :8501,后端跑在 :8000。浏览器发现"协议+域名+端口"有一个不一样,就会阻止请求,这叫同源策略(Same-Origin Policy)。

CORS 就是让后端告诉浏览器:“放行这个来源的请求”。

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],   # 允许所有来源(开发用,生产建议指定域名)
    allow_methods=["*"],   # 允许所有方法
    allow_headers=["*"],   # 允许所有请求头
)

⚠️ 生产环境不要用 allow_origins=["*"] + allow_credentials=True

这两个一起用会冲突——浏览器安全规范不允许。如果要携带 Cookie/Token,必须指定具体域名。


🔧 删了/改了会怎样

  • 删掉整个 CORS 配置:前端 localhost:8501 访问后端 localhost:8000 时,浏览器直接报 Access to fetch at 'http://localhost:8000/chat' from origin 'http://localhost:8501' has been blocked by CORS policy,请求根本到不了后端。
  • allow_origins=["*"] 改成具体域名 ["http://localhost:8501"]:更安全,但每次换前端端口都要记得更新。
  • allow_methods=["*"] 改成 ["GET", "POST"]:如果后续加了 DELETE、PUT 接口,这些请求会被 CORS 拦截,返回 405 Method Not Allowed。
  • allow_credentials=True 没删掉,只改 allow_origins 为通配符:浏览器会直接报CORS错误,拒绝本次请求。

🌐 还能用在哪里

  • Vue/React 开发服务器:Vite 默认 localhost:5173,需要后端开放 CORS 才能请求 API。
  • 微服务架构:网关(Gateway)和各微服务之间也常配置 CORS,避免内部服务互相调用被拦截。
  • CDN + API 分离:前端部署在 CDN(域名A),API 部署在内网或另一个域名(域名B),必须配置 CORS。
  • 浏览器插件开发:插件的 background.js 请求第三方 API 时,同样受 CORS 约束。
  • WebSocket:WebSocketMiddleware 也要单独配置 CORS,不能和 HTTP 中间件混用。

📌 实际应用注意点

  • 生产环境建议从环境变量读取允许的域名列表,绝对不要硬编码 ["*"]:
    allow_origins = os.environ.get("ALLOWED_ORIGINS", "*").split(",")
    
  • 如果前端用 Token 认证(Authorization Header),不需要 allow_credentials=True;只有用 Cookie(Session)认证时才需要。两者混用是常见错误。
  • 预检请求(OPTIONS)也会被 CORS 中间件拦截,必须确保 allow_methods 里包含 OPTIONS,否则部分 API 调用会莫名其妙失败。
  • 2024 年后,许多浏览器对跨域请求的来源校验更严格,SameSite=Lax / SameSite=Strict Cookie 属性也会影响认证行为。

from openai import OpenAI

OpenAI SDK — 统一接口调用各种大模型

DeepSeek、硅基流动、智谱等国内模型都兼容 OpenAI 格式,只需要换 base_url:

# DeepSeek
client = OpenAI(api_key="sk-xxx", base_url="https://api.deepseek.com")

# 硅基流动(国内中转,便宜)
client = OpenAI(api_key="sk-xxx", base_url="https://api.siliconflow.cn/v1")

# 本地 Ollama(免费,完全离线)
client = OpenAI(api_key="ollama", base_url="http://localhost:11434/v1")

拓展:SDK 是什么?

SDK = Software Development Kit(软件开发工具包)

不用 SDK 也能调 API(直接发 HTTP 请求),但 SDK 做了:

  • 请求体的封装(自动把字典转成 JSON)
  • 响应体的解析(自动把 JSON 转成对象)
  • 错误处理(超时、重试、Token 计算)
  • 流式输出的封装(stream=True 时逐字返回)

就好比:不用 SDK = 自己用砖头盖房子,用 SDK = 用预制板盖房子。


🔧 删了/改了会怎样

  • 删掉 base_url 参数:默认请求 api.openai.com,国内无法访问,直接报网络错误。
  • api_key 写错一个字符:API 返回 401 Unauthorized,SDK 会抛出 AuthenticationError,不处理的话前端收到 500 报错。
  • base_url 末尾没有 /v1:比如写成 "https://api.deepseek.com" 而非 "https://api.deepseek.com/v1",请求路径会变成 /chat/completions 而不是 /v1/chat/completions,API 返回 404。

🌐 还能用在哪里

  • LangChain:底层调用 LLM 时用的是同一个 OpenAI SDK,只是套了 LangChain 的链式封装壳。
  • CrewAI / AutoGen:多 Agent 框架里每个 Agent 的 llm 参数,背后也是 OpenAI SDK。
  • LlamaIndex:索引和检索模块生成的上下文,也要用这个客户端发给大模型。
  • 测试场景:可以用 unittest.mock.patch 把真实的 OpenAI 替换成 Mock 对象,避免调用真实 API 扣费:
    with patch('app.OpenAI') as mock_client:
        mock_client.return_value.chat.completions.create.return_value = fake_response
        # 执行测试...
    

📌 实际应用注意点

  • 国内调用 DeepSeek 等模型时,强烈建议在 SDK 外层再加一层幂等重试(网络抖动时自动重发):
    from tenacity import retry, stop_after_attempt
    @retry(stop=stop_after_attempt(3))
    def call_llm(messages):
        return client.chat.completions.create(model="deepseek-chat", messages=messages)
    
  • 2025 年各模型 API 价格变化快,model 参数要能动态切换,避免硬编码。建议放配置或数据库里。
  • 流式返回(stream=True)时,SDK 返回的是生成器(Generator),要用 for chunk in response 逐块迭代,不能直接 .json()。

from pydantic import BaseModel

Pydantic — 数据校验

class ChatRequest(BaseModel):
    message: str

FastAPI 看到 ChatRequest 参数时,会:

  1. 从 HTTP 请求体里读 JSON
  2. 校验 message 字段存在且是 str 类型
  3. 类型不对 → 自动返回 422 错误,不进入业务代码
  4. 类型对了 → 转成 Python 对象传给函数

💡 Pydantic 的核心价值:让错误在入口处就被拦住

没有它,数据可能在业务逻辑深处才出问题,排查困难。有了它,请求一进来就校验,错误信息清晰。


🔧 删了/改了会怎样

  • 把 BaseModel 删掉,只写 req: dict:FastAPI 收到请求后不会做任何校验,前端传 {"message": 12345}(数字而非字符串),代码里 req["message"] 拿到的是 12345,进大模型时才报莫名其妙类型的错,排查困难。
  • message: str 改成 message: Optional[str]:必填变可选,前端不传 message 字段也不会报错,而是变成 None 进业务逻辑,后续很可能 NPE(NullPointerError)。
  • 给字段加 message: str = "hello" 默认值:同样变成可选,危害同上。

🌐 还能用在哪里

  • FastAPI 请求/响应模型(最常见场景)。
  • Django Ninja:Django 生态的 FastAPI 替代品,用的也是 Pydantic 做 schema。
  • CLI 工具参数校验:用 typer + Pydantic 模型定义命令行参数。
  • 数据库 ORM:SQLModel(FastAPI 作者出品)是 Pydantic + SQLAlchemy 的合体。
  • LangChain 工具定义:给 Agent 定义工具的参数 schema。
  • 配置管理:Pydantic 的 BaseSettings 类专门做环境变量配置(pip install pydantic-settings)。

📌 实际应用注意点

  • Pydantic v2(2023年发布) 改动很大,推荐直接学 v2:
    # v1(旧写法,仍兼容)
    class ChatRequest(BaseModel):
        message: str
    
    # v2(推荐)
    from pydantic import BaseModel, Field
    class ChatRequest(BaseModel):
        message: str = Field(..., min_length=1, max_length=4000)
    
  • Field 可以加更丰富的验证:min_length、max_length、regex、gt(大于)、lt(小于)等,比手写 validator 简洁得多。
  • 嵌套验证:如 List[ItemModel] 会自动递归验证每个元素,不需要手动写循环。

from typing import List, Optional

typing — 类型注解

写法含义
List[str]字符串列表
Optional[str]可以是 str 也可以是 None
List[dict]字典列表
List[str] = []有默认值,默认空列表

💡 Python 运行时不强制类型!

def add(a: int, b: int) -> int:
    return a + b
#
add("hello", "world")  # 不报错!类型注解只是给 IDE/静态检查工具看的

这叫 Type Hints(类型提示),不是强制的。要强制检查,用 mypy:

pip install mypy
mypy app.py  # 扫描类型错误

Python 3.10+ 引入了更简洁的写法:

# 旧写法
from typing import List, Optional
def foo(x: List[Optional[str]]) -> Optional[List[str]]:

# 新写法(3.10+)
def foo(x: list[str | None] | None) -> list[str] | None:

🔧 删了/改了会怎样

  • 把 List[dict] 注解删掉,只写 chat_history = []:IDE 无法推断类型,chat_history.append 时不提示字典的 key,补全失效,代码提示体验差很多。
  • 把 Optional[str] 写成 str | None(Python 3.10+):如果代码跑在 Python 3.9 环境,会报 SyntaxError。注意团队统一 Python 版本。
  • 写错类型注解(如 @return str 写成 @return String):完全无害——注解错了不会报错,只是 IDE 提示失效。

🌐 还能用在哪里

  • 所有 Python Web 项目(FastAPI、Django、Flask):类型注解是现代 Python 后端的标准实践。
  • 大型代码库维护:Stripe、Microsoft 等公司的开源 Python 库全部有完整的类型注解。
  • AI 代码生成:GitHub Copilot、Cursor 等工具依赖类型注解提供精确补全——类型越完整,AI 生成代码质量越高。
  • 数据管道:Apache Airflow、Prefect 等任务调度框架里用类型注解标注 DAG 任务的输入输出。
  • 工具类:Pyright(微软出品,比 mypy 更快)是 2025 年主流选择,配合 VS Code 的 Pylance 插件使用。

📌 实际应用注意点

  • 实际项目一定要配 mypy 或 pyright:类型注解的价值只有配合静态检查工具才能体现。建议加到 CI/CD 流水线里,PR 不通过类型检查不让合并。
  • Python 3.10 以下:from typing import Union → Python 3.10+ 可以直接写 str | int。
  • 不要过度注解:私有函数、内部函数的类型注解可以简化;公共 API 和接口处要完整。注解太多会让代码变得冗余。
  • Any 类型是万金油——能用 Any 的地方,尽量用具体类型,静态检查工具才能真正帮到你。

import os
import json
import numpy as np
from datetime import datetime
包用途
os路径操作 os.path.join()、文件是否存在 os.path.exists()
jsonJSON 序列化/反序列化(和文件配合做持久化)
numpy as np向量运算(矩阵乘法、求范数)——RAG 检索核心工具
datetime时间戳格式化 datetime.now().isoformat()

💡 import numpy as np,为什么起别名?

numpy 是 Python 科学计算的基础库,但名字太长了。给它起别名 np 是社区惯例:

  • 代码更短
  • 所有人都懂,一看到 np. 就知道是 numpy

类似的惯例:

import pandas as pd          # 数据分析
import tensorflow as tf       # 深度学习
import matplotlib.pyplot as plt  # 可视化

🔧 删了/改了会怎样

  • 把 numpy as np 删掉,用纯 Python 写向量运算:代码会变成几十行嵌套循环,性能差 100 倍以上。
  • np.linalg.norm 用错了——写成 np.linalg.norm(query_emb, axis=1)(加了 axis):query_emb 是 1 维向量,没有 axis 参数,会报错 ValueError: axis must be None for 1-D。
  • 把 np.array([...]) 换成 Python 列表做向量运算:数值是对的,但后续的矩阵乘法(np.dot)会报 TypeError: unsupported operand type(s) for *——列表不支持向量乘法。

🌐 还能用在哪里

  • 数据分析:Pandas 底层就是 NumPy,大量数据操作本质是 NumPy 数组运算。
  • 图像处理:OpenCV、pillow 处理的图像本身就是 NumPy 数组(shape: (H, W, C))。
  • 推荐系统:向量相似度计算(余弦、点积)、协同过滤矩阵分解。
  • 语音处理:MFCC 特征提取、音频信号处理的频域分析。
  • 强化学习:Q-Learning 的 Q-Table 存储在 NumPy 数组里做批量更新。
  • 时序预测:Prophet、statsmodels 的核心计算也用 NumPy 数组。

📌 实际应用注意点

  • 数值精度:NumPy 默认 float64,一个向量 384 维 × 8字节 = 3KB。数据量大(百万级向量)时内存压力很大,可以考虑 float32(精度略低但省一半内存),Pinecone 等向量数据库默认就是 float32。
  • 向量数据库替代:本项目向量存在内存列表里,chunks 多了(>1万条)后查询速度会变慢。真实项目建议用 Milvus、Qdrant、ChromaDB 等向量数据库,支持索引加速(IVF、HNSW)。
  • np.dot vs np.matmul:dot 遇到 (N,384) × (384,) 自动做批量点积返回 (N,);matmul 做矩阵乘法,维度要求更严格。本场景用 np.dot 更方便。

第二部分:创建 FastAPI 应用

app = FastAPI()

FastAPI() 实例化

这行代码创建一个 ASGI 应用实例(ASGI = Async Server Gateway Interface,异步服务器网关接口)。

💡 ASGI vs WSGI

协议特点用途
WSGI同步,只能一个请求处理完才能处理下一个Flask、Django 传统模式
ASGI异步,能同时处理大量并发连接FastAPI、Quart 现代模式

AI 对话场景用 FastAPI(ASGI)很合适——用户发起请求后,大模型可能要 10-30 秒才返回,这期间 ASGI 可以去处理其他用户的请求,不卡死。


🔧 删了/改了会怎样

  • 把 FastAPI() 改成 app = Flask(__name__):瞬间变成同步框架,所有请求必须排队——10个用户同时发消息,第10个要等前9个全部处理完才能开始,大模型等待期间服务器完全空闲。
  • 不写 CORSMiddleware:前端直接报跨域错误,后端功能完全正常但用户感知"接口坏了"。
  • app = FastAPI() 后不加任何路由:访问 localhost:8000/docs 依然能看到 Swagger UI(FastAPI 自动根路径),只是没有可用接口——容易让人误以为服务没启动。

🌐 还能用在哪里

  • Quart:ASGI 版 Flask,API 和 FastAPI 几乎一致。
  • Starlette:FastAPI 底层用的框架,可以单独使用做轻量 ASGI 应用。
  • Django 3.0+:自带 ASGI 支持,用 uvicorn myproject.asgi:application 跑 Django。
  • Channel layers(Django):用 ASGI 做 WebSocket,支持实时推送(聊天、通知)。
  • LangServe:LangChain 的 ASGI 服务框架,快速把 LangChain 链部署成 HTTP API。

📌 实际应用注意点

  • uvicorn 的 workers 参数:多进程部署时(如 --workers 4),每个进程有独立的全局变量副本,chat_history 就不共享了。要么不用多进程,要么用 Redis 做共享存储。
  • 启动速度:uvicorn app:app --reload 在开发时方便,但生产环境不要加 --reload(有安全风险且性能差)。
  • ASGI 的局限:ASGI 适合 I/O 密集型(HTTP 请求),但 CPU 密集型任务(大模型推理本身不在 Python 里跑,这里只是发 HTTP 请求)不需要 asyncio,真正占 CPU 的是下游模型服务,Python 端只是等待网络响应。

第三部分:配置

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["*"],
    allow_headers=["*"],
)

上面已讲。


# ⚠️ 请替换为你的 DeepSeek API Key
DEEPSEEK_API_KEY = "sk-your-api-key-here"
client = OpenAI(
    api_key=DEEPSEEK_API_KEY,
    base_url="https://api.deepseek.com",
)

API Key 放在代码里的问题

⚠️ 安全原则:Key 永远不要硬编码在代码里

代码一旦上传到 GitHub(哪怕是私有仓库),Key 就可能泄露。

正确做法:

# 方式一:环境变量(推荐)
import os
DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY")

# 方式二:.env 文件(需要 python-dotenv)
from dotenv import load_dotenv
load_dotenv()
DEEPSEEK_API_KEY = os.getenv("DEEPSEEK_API_KEY")

.env 文件内容:

DEEPSEEK_API_KEY=sk-your-real-key-here

.env 文件要加入 .gitignore,永远不上传。


🔧 删了/改了会怎样

  • 把 API Key 直接写死在代码里:上传 GitHub 后几分钟内就可能被机器人扫描到并滥用(DeepSeek 等平台有密钥异常监控,但如果被大量调用,费用会暴增)。
  • .env 文件没加 .gitignore:即使仓库是 private,CI/CD 日志、git diff、历史 commit 里也可能泄露。
  • 用 os.environ.get("KEY") 但环境变量没设置:拿到 None,传给 SDK 时报 AuthenticationError,但错误信息不明确,排查慢。

🌐 还能用在哪里

  • 所有需要密钥的服务:AWS(AWS_ACCESS_KEY_ID)、OpenAI(OPENAI_API_KEY)、MongoDB(连接字符串)、Redis(密码)。
  • 12-Factor App 架构:12 条应用开发原则里明确要求"把配置放在环境变量里",这是云原生应用的标准实践。
  • Docker / K8s:容器化应用通过 ENV 注入密钥,通过 Secret 对象管理,不挂载到容器文件系统里。
  • GitHub Actions:用 GITHUB_ENV 或 secrets.XXX 存储密钥,workflow 里通过 ${{ secrets.XXX }} 引用。

📌 实际应用注意点

  • .env 文件的安全边界:.env 只在本地开发用,生产密钥存在 K8s Secret、AWS Secrets Manager、Vault 等专门的密钥管理服务里。
  • 不要在 Slack/微信/邮件里发 Key:这是最常见的泄露途径——Copilot、Claude 等 AI 工具也可能把你的消息用于训练,Key 会被发送出去。建议用专门的密钥管理工具(如 1Password CLI)传 Key。
  • 2025 年趋势:服务端代理模式(后端不暴露真实 API Key 给前端)越来越流行,前端请求先到后端代理,代理转发给模型 API,后端只维护 Key,彻底杜绝前端泄露。

第四部分:数据存储路径

DATA_DIR = "D:\\agent-demo\\data"

路径分隔符:Windows 用 \\,Linux/Mac 用 /

系统路径写法
WindowsD:\\agent-demo\\data 或 r"D:\agent-demo\data"(原始字符串)
Linux/Mac/home/user/agent-demo/data
跨平台用 os.path.join() 自动适配

💡 为什么 Windows 用 \\ 而不是 \?

Python 里 \ 是转义字符:

"D:\new\folder"   # ❌ \n 会被当成换行符!
"D:\\new\\folder"  # ✅ \\ 转义成单个 \
r"D:\new\folder"   # ✅ 原始字符串,\ 不转义

os.path.join() 自动处理,跨平台代码必用:

path = os.path.join("D:", "agent-demo", "data", "knowledge")
# Windows: D:\agent-demo\data\knowledge\n> # Linux:   /D/agent-demo/data/knowledge

🔧 删了/改了会怎样

  • 用字符串拼接代替 os.path.join:

    path = "D:/" + "agent-demo/" + "data"  # Linux 上也正常
    path = "D:\\" + "agent-demo\\" + "data"  # Windows 上看着正确,但 Linux 上路径变成了 D:\agent-demo\data(带反斜杠,Linux 不认)\n  ```\n- 把 `r"D:\path"` 原始字符串的 `r` 删掉:`"D:\new\folder"` 里的 `\n` 变成换行符,`\f` 变成 Form Feed,路径直接出错。
    
  • os.path.join 里写了绝对路径在前:os.path.join("D:\agent-demo", "D:\data") 会直接返回 D:\data(后面的绝对路径覆盖前面的),而不是拼接。\n\n🌐 还能用在哪里

  • Flask/Django 项目:静态文件路径、模板路径、数据库文件路径都用 os.path.join 或 pathlib。

  • 自动化脚本:备份脚本、文件同步脚本如果不处理路径,Windows 上跑得好好的脚本到 Linux 上就报"找不到文件"。

  • 数据管道:ETL 脚本在不同服务器(Windows 开发机 → Linux 生产机)上运行时,路径兼容性是关键。

  • 游戏开发:Asset 资源路径、Save 文件路径都要跨平台处理。

📌 实际应用注意点

  • 推荐用 pathlib(Python 3.4+,内置):
    from pathlib import Path
    data_dir = Path("D:/agent-demo/data")  # 自动适配 Windows/Linux
    knowledge_dir = data_dir / "knowledge"  # 用 / 运算符,比 join 更直观
    knowledge_dir.mkdir(parents=True, exist_ok=True)
    
  • Windows 上 Python 的 pathlib.Path 会把路径转换成 WindowsPath 对象,在做字符串比较时要注意。跨平台共享路径字符串时(写入配置文件),建议统一用正斜杠 / 格式(path.as_posix())。
  • Linux 没有盘符概念,D: 在 Linux 里是根目录下的子目录名 D,不是 Windows 的 D 盘。代码里写死 D:\ 在 Linux 上完全不可用。\n\n—\n\npython\nos.makedirs(DATA_DIR, exist_ok=True)\n\n\n### os.makedirs — 递归创建目录\n\n| 参数 | 作用 |
    |------|------|
    | exist_ok=True | 目录已存在时不报错(没有这个参数会抛异常) |

💡 三种创建目录的方式对比

方法特点
os.mkdir("path")只创建最后一层目录,父目录不存在会报错
os.makedirs("path")递归创建,所有层级的目录都能创建
Path("path").mkdir(parents=True)pathlib 写法,更直观,推荐新代码使用

pathlib 新写法:

from pathlib import Path
Path("D:/agent-demo/data").mkdir(parents=True, exist_ok=True)

🔧 删了/改了会怎样

  • 用 os.mkdir 代替 os.makedirs:如果 DATA_DIR 不存在,os.makedirs(DATA_DIR) 能自动创建,但 os.mkdir(DATA_DIR) 会报 FileNotFoundError: [WinError 3] 系统找不到指定的路径。
  • exist_ok=True 删掉:第二次运行程序时会抛 FileExistsError,虽然不影响功能(目录已存在),但每次启动都报一次错,不美观。
  • parents=True 删掉(pathlib 写法):如果父目录不存在,会报 FileNotFoundError。

🌐 还能用在哪里

  • 日志目录:Flask/Django 启动时自动创建 logs/ 目录,存放日志文件。
  • 用户上传目录:文件上传服务启动时创建 uploads/、temp/ 目录。
  • Docker-entrypoint:容器启动脚本里用 mkdir -p(等价于 os.makedirs(parents=True))确保数据目录存在后再运行服务。
  • 机器学习项目:训练前创建 checkpoints/、outputs/、tensorboard/ 目录,避免代码里写死路径。

📌 实际应用注意点

  • 权限问题:Linux/Mac 上如果目录创建在 /var/ 等系统目录下,需要 sudo 权限;建议用用户主目录 Path.home() / "myapp" 来避免权限问题。
  • Windows 长路径:Windows 有 MAX_PATH(260字符)限制,启用长路径支持(组策略或注册表)后支持到 32767 字符。新代码用 pathlib 自动处理。
  • 幂等性:exist_ok=True(os.makedirs)或 parents=True(pathlib)都是为了幂等——多次运行不会报错,这是健壮服务的基本要求。

HISTORY_FILE = os.path.join(DATA_DIR, "chat_history.json")
KNOWLEDGE_DIR = os.path.join(DATA_DIR, "knowledge")
os.makedirs(KNOWLEDGE_DIR, exist_ok=True)

把对话历史存成 JSON 文件,把知识库文档存成文件夹。


第五部分:全局变量

knowledge_files = {}  # {filename: content}
chat_history: List[dict] = []

全局变量的意义

这两个变量在进程内存里:

  • 服务启动 → 从磁盘加载进来
  • 服务运行中 → 所有请求共享同一份数据
  • 服务重启 → 内存清空,从磁盘重新加载

💡 进程 vs 线程 vs 协程

概念定义隔离性
进程操作系统分配资源的最小单位,有独立内存空间完全隔离,A进程挂了不影响B
线程进程里的执行单元,共享进程内存同一进程内共享,开销比进程小
协程线程里的"轻量线程",由程序自己调度最轻量,但一个地方卡住全卡住

FastAPI 用的是异步协程(uvicorn 基于 asyncio)。chat_history 作为全局变量,在单进程内所有协程共享,所以可以正常读写。


🔧 删了/改了会怎样

  • 把 uvicorn app:app 改成 uvicorn app:app --workers 4:瞬间变成 4 个进程,每个进程的 chat_history 独立——用户 A 在进程1 发消息,用户 B 在进程2 查历史,查不到。这是最隐蔽的 bug。
  • 把全局变量 chat_history 删掉:单进程模式下服务能启动,但每次请求的 chat_history 都会在函数结束时被垃圾回收,对话历史完全丢失。
  • 把列表 [] 初始化成 None:函数里没加 global 声明时,chat_history = None 实际上创建了局部变量 None,访问不到全局的 [],接口返回永远为空。

🌐 还能用在哪里

  • Flask 全局 g 对象:from flask import g,g 是请求级的全局对象,每个请求独立。
  • FastAPI 的 app.state:app.state.cache = {},比真正的全局变量更清晰,生命周期和 app 一致。
  • Django 的 threading.local():线程局部存储,每个线程有独立的数据副本。
  • Node.js 的 global:globalThis.dbConnection,全局单例连接池。
  • Redis 做跨进程共享:多进程部署时用 Redis List/Hash 做 chat_history,解决进程隔离问题。

📌 实际应用注意点

  • 全局变量 + 大数据量 = 内存炸弹:chat_history 没有上限地增长,重启前永远不会释放。真实项目建议:
    MAX_HISTORY = 200
    chat_history = chat_history[-MAX_HISTORY:]  # 超过200条自动截断旧消息
    
  • uvicorn 多进程慎重使用:除非你用了 Redis/RabbitMQ 等消息队列做进程间通信,否则全局变量会"失忆"。建议用 gunicorn + uvicorn 的方式:gunicorn app:app -w 4 -k uvicorn.workers.UvicornWorker,这样 4 个 worker 共享一个 asyncio 事件循环,不会有进程隔离问题。
  • 协程安全:单个协程里操作 chat_history.append 是安全的(Python 的 GIL 保护列表操作),但如果用了多线程(如 run_in_executor),要注意竞态条件。

chat_history: List[dict] = []

冒号后面的是类型注解,运行时不生效,只是给 IDE 和开发者看的提示。

# 以下三行,运行效果完全相同
chat_history = []                        # 无类型注解
chat_history: list = []                  # Python 3.9+ 新写法
chat_history: List[dict] = []            # 旧写法,需要 import typing

🔧 删了/改了会怎样

  • 把类型注解删掉:chat_history = [] 也能跑,但 IDE 无法推断类型,chat_history.append 后不提示字典的 key 名称("role"、"content"),代码提示质量断崖式下降。
  • 注解写成 List[Dict](大写)但没 import:NameError: name 'Dict' is not defined,虽然 Python 运行时忽略注解,但部分工具(Pydantic v2 的 model_rebuild)会尝试解析注解导致报错。
  • 写成 list[dict](小写,Python 3.9+):在 Python 3.8 环境里跑会报 SyntaxError,团队 Python 版本不统一时容易踩坑。

🌐 还能用在哪里

  • 所有 Python Web 框架:FastAPI、Django Ninja、Flask-smorest。
  • SDK 和开源库:完整的类型注解是 2025 年优质开源库的标准(Stripe Python SDK、Azure SDK、Pydantic 本身)。
  • 代码生成工具:MyPy、Pyright、Copilot 都依赖类型注解提供精确辅助。
  • Protocol Buffers / gRPC:Proto 文件定义的 .proto 相当于外部类型注解,Python 端用注解对应。

📌 实际应用注意点

  • 类型注解是给工具看的,不是给运行时看的——它不改变程序行为,但能极大提升代码质量和维护效率。
  • 建议团队统一 Python 版本(3.10+)并统一用新式注解写法(list[str] 而不是 List[str]),减少 import 噪音。
  • 可读性优先:过于复杂的泛型嵌套(如 Dict[str, List[Optional[dict]]])反而让代码更难读,适当拆分或加注释。

第六部分:RAG 引擎核心类

class RAGEngine:
    """检索增强生成引擎:切块 → 向量化 → 相似度检索"""

类(Class)的概念

class RAGEngine:
    # __init__ 是构造方法,创建实例时自动调用
    def __init__(self):
        self.model = None
        self.chunks = []
概念说明
class类的定义,描述一类事物的共性
self指代"当前这个实例对象"
__init__构造方法,RAGEngine() 创建实例时自动执行
self.xxx实例属性,这个实例独有的数据

💡 类 vs 实例

class Dog:
    def __init__(self, name):
        self.name = name  # 每个实例有自己独立的 name

dog1 = Dog("旺财")  # 实例1
dog2 = Dog("小白")  # 实例2
print(dog1.name)     # 旺财
print(dog2.name)     # 小白

def __init__(self):
    self.model = None          # 延迟加载,避免启动时卡住
    self.chunks = []           # [{id, filename, text, embedding}]
    self.chunk_size = 300      # 每块大约 300 字
    self.overlap = 50          # 块之间重叠 50 字,保证上下文不断裂
    self.score_threshold = 0.3 # 相似度低于 0.3 的结果不返回

五个实例属性的含义

属性默认值含义
modelNoneEmbedding 模型,首次调用才加载
chunks[]所有文本块的列表,每个块含文本+向量
chunk_size300每块字数上限
overlap50相邻块重叠的字数
score_threshold0.3相似度最低门槛

💡 为什么 model 用 None 而不是直接初始化?

如果直接写 self.model = SentenceTransformer(...),意味着服务一启动就下载+加载模型(约120MB),导致:

  • uvicorn 启动要等很久
  • 如果网络不通,直接报错无法启动

改成 None + _ensure_model() 延迟加载后:

  • 服务启动秒开
  • 用户第一次上传文件时,才真正加载模型
  • 加载完成后一直驻留内存,后续调用秒响应

🔧 删了/改了会怎样

  • 把 self.model = None 改成直接初始化:SentenceTransformer(...) 写在 __init__ 里,服务启动命令(uvicorn app:app)会卡住 10-30 秒,运维重启服务时容易触发超时告警。
  • None 改成 False:判断条件要改成 if not self.model,实际效果相同。但如果模型加载失败设为 False,下次还会尝试加载;设 None 可以区分"未加载"和"加载失败"两种状态,更清晰。
  • 删掉 _ensure_model() 检查:直接调用 self.model.encode(...),self.model 是 None,报 AttributeError: 'NoneType' object has no attribute 'encode',崩溃。

🌐 还能用在哪里

  • 深度学习模型加载:PyTorch 的 model = None; model = torch.load(...) 延迟加载;TensorFlow 的 model = tf.saved_model.LoadOptions(lazy_load=True)。
  • 数据库连接池:启动时不建立连接,第一次查询时才真正连接。
  • 日志处理器:Python 的 logging.Handler 可以延迟实例化,避免启动时导入重模块。
  • LangChain 模型:LLM 和 Embeddings 都可以延迟初始化,加快 LangServe 等框架的启动速度。

📌 实际应用注意点

  • 多进程重启后模型会重新加载:uvicorn --reload 检测到文件变化后会重启进程,全局 self.model = None 又回来了,会再次触发延迟加载。这是正常的,但首次请求会有几百毫秒的延迟。
  • 更好的做法:用单例模式确保模型只加载一次,配合日志让运维知道"模型正在加载中":
    _MODEL_CACHE = None
    def get_model():
        global _MODEL_CACHE
        if _MODEL_CACHE is None:
            _MODEL_CACHE = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
        return _MODEL_CACHE
    
  • 2025 年趋势:vLLM、Ollama 等推理服务化工具支持模型常驻内存,启动时一次性加载,后续请求无需等待,是生产环境的主流方案。

def _ensure_model(self):
    """第一次使用时才加载模型(约 120MB,下载需要一点时间)"""
    if self.model is None:
        print("⏳ 正在加载 Embedding 模型(首次启动需要下载约120MB)...")
        from sentence_transformers import SentenceTransformer
        self.model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')
        print("✅ Embedding 模型加载完成!")

单下划线 _ 前缀 — 命名约定

def _ensure_model(self):  # _前缀 = "这是内部方法,不建议外部调用"

Python 没有真正的私有方法,但有命名约定:

写法约定含义
_method()内部方法,子类可能会用
__method()名称会被"名字改编"(mangled),子类无法轻易覆盖
__method__()特殊方法(魔术方法),如 __init__、__str__

💡 _ensure_model 里的 from 为什么写在函数内部?

def _ensure_model(self):
    from sentence_transformers import SentenceTransformer

写在函数内部的好处:

  • 延迟导入,模块只在第一次调用时才加载,节省启动时间
  • 如果永远不调用这个方法,import 永远不执行

缺点:

  • 每次调用都要检查 import 是否已完成
  • 如果放在文件顶部 from sentence_transformers import ...,服务一启动就加载

这是一个典型的时间换空间策略。


🔧 删了/改了会怎样

  • 把 import 移到文件顶部:sentence_transformers 在服务启动时就加载,uvicorn 启动变慢约 5-10 秒;如果网络不通,app.py 导入时就报错,整个服务无法启动。
  • 在 __init__ 里写 self._ensure_model():服务启动时强制加载,延迟加载的意义完全消失。
  • 函数内部 from 写错路径或包名:只有第一次调用时才报错,且报错位置在 add_document 而非顶部,难以定位。

🌐 还能用在哪里

  • FastAPI 的懒路由:app.include_router(router) 可以在需要时才加载,避免所有路由在启动时注册。
  • Pydantic 模型的延迟校验:复杂模型的 validator 可以延迟到第一次实例化时才执行。
  • Django ORM 的 lazy query:.objects.all() 不会立即查数据库,遍历时才查。
  • React / Vue 的 Code Splitting:React.lazy(() => import('./HeavyComponent')) 延迟加载组件。

📌 实际应用注意点

  • 延迟 import 的缺点是:函数每次执行到那行都要做一次 import 检查(Python 会缓存已导入的模块,第二次调用时很快)。但 sentence_transformers 这种大模块,首次 import 确实有开销,不能写在热路径(高频调用的函数)里。
  • 推荐折中方案:模块顶部先 import,但用 try...except 捕获加载失败,只在实际调用时报错:
    try:
        from sentence_transformers import SentenceTransformer
        _MODEL = SentenceTransformer(...)
    except ImportError:
        _MODEL = None  # 加载失败,后续 _ensure_model 再试
    
  • 现代 Python(3.7+)的 import 缓存机制已经很高效,不必过度优化。

self.model = SentenceTransformer('paraphrase-multilingual-MiniLM-L12-v2')

Sentence-Transformers — 把文字变成数字

这行代码的作用:

"退货政策:7天内可申请退货"  →  [0.12, 0.85, -0.33, 0.71, ...]  (384个数字)

这 384 个数字就是向量(Vector),也叫嵌入(Embedding)。

💡 什么是 Embedding(嵌入)?

Embedding = 用一串数字来表示一段文字的"语义"

核心思想:语义相近的文字,它们的数字序列也相近

"如何申请退货"  →  [0.12,  0.85,  0.33]
"退货流程是什么" →  [0.13,  0.82,  0.30]  ← 和上面很接近!
"今天天气很好"   →  [-0.45, 0.12, -0.78]  ← 和上面差很远!

这样,只要比较两段文字的向量有多"接近",就知道它们语义有多相似。


🔧 删了/改了会怎样

  • 把 Embedding 换成 TF-IDF:TF-IDF 只统计词频,"退货政策"和"退货流程"因为用词不同,相似度反而低;"苹果"和"苹果手机"在 Embedding 里相近,TF-IDF 里可能远。
  • 把向量维度 384 改成 768:数学上没问题,但余弦相似度计算里向量长度变了,np.dot 和 norm 的 shape 不匹配,报 ValueError。向量数据库存储量翻倍。
  • 用错 Embedding 模型(英文模型处理中文):中文"退货"和"申请"的向量可能落在相近的数值区域(因为训练数据少),检索结果质量极差。

🌐 还能用在哪里

  • 搜索引擎(Elasticsearch):6.0+ 版本内置 dense_vector 字段,支持向量检索。
  • 推荐系统:抖音/B站 的"相似视频"推荐,底层是 Embedding + 余弦相似度。
  • 异常检测:正常行为的 Embedding 向量聚成一团,异常行为落点在远处,触发告警。
  • 聚类分析:用户评论的 Embedding 聚类,自动发现投诉主题(客服场景用得多)。
  • RAG 架构:LangChain、LlamaIndex、RAGFlow 等主流框架的核心都是 Embedding + 向量检索。
  • 多模态:CLIP 模型把图片和文字映射到同一个向量空间,实现"图搜文"或"文搜图"。

📌 实际应用注意点

  • Embedding 质量比算法重要:选对模型(中文多语言)带来的提升远大于调参。2025 年主流中文 Embedding 模型有 m3e-base(铭之光)、BGE-large-zh(智源)、text2vec-large-chinese(阿里)。
  • 向量要定期重新生成:业务知识更新后(新增文档),旧向量依然在检索结果里出现,但内容已过时。建议用版本号管理向量,定时重新索引。
  • Embedding 不是越大越好:paraphrase-multilingual-MiniLM-L12-v2(384维)比 all-mpnet-base-v2(768维)快很多,在个人项目里足够。生产环境可根据 QPS 需求选型。

💡 为什么选 paraphrase-multilingual-MiniLM-L12-v2?

模型名语言维度速度效果
all-MiniLM-L6-v2仅英文384最快好
all-mpnet-base-v2仅英文768较慢最好
paraphrase-multilingual-MiniLM-L12-v2多语言384快好(中文优秀)

中文场景必选多语言模型。MiniLM 是轻量版,牺牲一点精度换速度,对个人项目足够。


def chunk_text(self, text: str) -> List[str]:

函数注解

def chunk_text(self, text: str) -> List[str]:
#     ↑ 参数      ↑ 参数类型    ↑ 返回值类型

这只是提示,不强制。但配合 IDE(Pycharm/VS Code)会自动提示参数类型。


if not text or not text.strip():
    return []

空值和短路求值

写法等价于
if not text如果 text 是 None、""、[]、{}、0
if not text.strip()先执行 strip(),再取反
if text == ""只判断空字符串
if not text or not text.strip()判断 None 或者 空字符串(" " 也算空)

💡 Python 的真值表(哪些东西是 False?)

bool(None)      # False
bool("")        # False
bool("   ")     # True  ← 有空格,不是空!
bool(0)         # False
bool([])        # False
bool({})        # False
bool("hello")   # True
bool(1)         # True

paragraphs = [p.strip() for p in text.split('\n\n') if p.strip()]

列表推导式 + 链式操作

拆解这行代码的执行顺序:

text.split('\n\n')
# ① split 把文本按双换行切成一个列表
# "退货政策\n\n申请流程" → ["退货政策", "申请流程"]

[p.strip() for p in ... if p.strip()]
# ② 遍历每个段落
# ③ 先 if p.strip() 过滤空段落
# ④ 再 p.strip() 去掉首尾空格
# ⑤ 最后生成新列表

💡 列表推导式 vs 普通循环

# 列表推导式(一行搞定,Pythonic)
squares = [x**2 for x in range(10)]

# 普通循环(更易读,适合复杂逻辑)
squares = []
for x in range(10):
    squares.append(x**2)

# 带条件过滤
evens = [x for x in range(10) if x % 2 == 0]

chunks = []
current = ""

for para in paragraphs:
    if len(current) + len(para) <= self.chunk_size:
        current = (current + "\n\n" + para).strip()
    else:
        if current:
            chunks.append(current)
        if len(para) > self.chunk_size:
            for i in range(0, len(para), self.chunk_size - self.overlap):
                chunk = para[i:i + self.chunk_size]
                if chunk:
                    chunks.append(chunk.strip())
            current = ""
        else:
            current = para

if current:
    chunks.append(current)

切块算法逻辑图解

假设 chunk_size=10, overlap=2

输入文本: "退货政策:七天内可申请退货,需保留原包装。"

段落1: "退货政策:七天内可申请退货,需保留原包装。"
len=20 > 10,超长了!

步长 = chunk_size - overlap = 10 - 2 = 8

i=0:  para[0:10]  = "退货政策:七天内可"  → 块1
i=8:  para[8:18]  = "天内可申请退货,需保"  → 块2  (和块1有2字重叠)
i=16: para[16:26] = "退货,需保留原包装。"  → 块3  (末尾截断)

结果: ["退货政策:七天内可", "天内可申请退货,需保", "退货,需保留原包装。"]

💡 为什么要有 overlap(重叠)?

不用 overlap:

块1: "退货政策:七天"
块2: "内可申请退货,      ← "内" 被切掉了!语义断裂!

用 overlap=2:

块1: "退货政策:七天"
块2: "天内可申请退货,    ← 重叠保证了"内可"在一起,语义完整

overlap 的本质是"滑动窗口",和卷积神经网络(CNN)的滑动窗口一个思想。


🔧 删了/改了会怎样

  • overlap=0(完全去掉重叠):跨块边界的语义会断裂——如果"退货"在前一块末尾、"政策"在后一块开头,两个关键信息被切开,大模型拿到不完整的上下文,可能误解意思。
  • chunk_size 设成 50:每个块太短,语义不完整,检索到的片段可能只含一个词,无法回答复杂问题。
  • chunk_size 设成 2000:每个块太长,上下文窗口被大量无关内容稀释,大模型的注意力被分散,回答质量下降。
  • 中文分词没处理:代码直接按字符数切块,英文单词可能被从中间截断(如"retur nPolicy"),但对中文影响更大——一句话被切成两段,语义丢失。

🌐 还能用在哪里

  • LLM 的上下文窗口:GPT-4 的 128K 上下文也是"滑动窗口"思想,把长文本分批读入。
  • 视频抽帧:1小时视频每秒抽1帧,是时间轴上的"块+overlap"。
  • 音频处理:Whisper 转录时用滑动窗口输出,防止长音频截断丢失上下文。
  • MapReduce 大数据处理:把大文件分成多个 Map 块,块之间有数据重叠保证跨块任务不遗漏。
  • LangChain 的 RecursiveCharacterTextSplitter:比本项目更智能的切块策略,先按段落,再按句子,再按字符,逐级尝试保留语义边界。

📌 实际应用注意点

  • chunk_size 和 overlap 的经验值:中文 RAG 场景,chunk_size=300~500、overlap=50~100 是主流配置;英文可以用更大的块(512~1024)。
  • 语义切分比字符切分更好:真实项目推荐用 langchain.text_splitter.RecursiveCharacterTextSplitter,它会尽量在段落、句子边界切,避免在句子中间断开。中文可以用 jieba 分词配合句子边界识别。
  • overlap 不要太大:overlap=chunk_size 的话等于没切,还多了一倍计算量。overlap 应该小于 chunk_size 的 20%。
  • 保留块序号和来源:本项目的 id: f"{filename}__{i}" 设计很实用,方便后续定位来源和调试。重复上传同一文件时,旧块一定要清理干净,否则向量列表越来越膨胀。

def add_document(self, filename: str, content: str):
    """把文档切块并生成向量索引"""
    self._ensure_model()

方法调用:self._ensure_model()

在类的方法内部,调用同类其他方法,必须加 self.。

class RAGEngine:
    def method_a(self):
        return "hello"

    def method_b(self):
        result = self.method_a()  # ✅ 加 self
        # result = method_a()       # ❌ NameError

    self.chunks = [c for c in self.chunks if c['filename'] != filename]

列表推导式过滤 + 重新上传覆盖

这行的作用:把同名文件的旧块删掉。

# 举例:chunks = [{filename:"a.txt"}, {filename:"b.txt"}, {filename:"a.txt"}]
self.chunks = [c for c in self.chunks if c['filename'] != "a.txt"]
# 结果:chunks = [{filename:"b.txt"}]   ← a.txt 的块被删了

💡 列表推导式做过滤的四种等价写法

# 写法1:列表推导式(最快,最 Pythonic)
result = [c for c in chunks if c['filename'] != filename]

# 写法2:filter + lambda
result = list(filter(lambda c: c['filename'] != filename, chunks))

# 写法3:普通循环
result = []
for c in chunks:
    if c['filename'] != filename:
        result.append(c)

# 写法4:列表的 remove(不推荐,要删很多个时效率低)
for c in chunks[:]:  # 注意要切片[:] 复制一份再遍历,否则边删边遍历会漏
    if c['filename'] == filename:
        chunks.remove(c)

列表推导式在 Python 中比 filter() 略快(因为是 C 实现的),但 filter() 更函数式。


🔧 删了/改了会怎样

  • 写反了过滤条件:[c for c in self.chunks if c['filename'] == filename](不是 !=)会只保留要删除的文件块,其他全删光,数据灾难。
  • 不用过滤,直接 self.chunks = []:所有文件的块都没了,用户上传的文档全部消失。
  • 用 remove 但没切片 [:]:边遍历边 remove,索引错位,漏删或重复删,部分旧块残留。

🌐 还能用在哪里

  • Django ORM:User.objects.filter(role='admin') 底层就是列表推导式的数据库版。
  • Pandas:df[df['age'] > 18] 条件过滤,是 Pandas 版的列表推导式。
  • 异步过滤:asyncio.gather 配合 async for 做并发过滤。

📌 实际应用注意点

  • 数据量大时慎用列表推导式:如果 self.chunks 有百万级数据,每次重新上传文件都做列表推导式过滤,内存会翻倍(推导式生成新列表)。可以用生成器表达式替代:
    # 生成器(不占额外内存)
    def filter_chunks(chunks, filename):
        for c in chunks:
            if c['filename'] != filename:
                yield c
    
  • 原地过滤比新建列表更省内存:如果确实要过滤,可以 self.chunks[:] = [c for c in self.chunks if ...],这样不创建新列表对象。
  • 可读性 vs 性能:小数据量时列表推导式可读性好;复杂逻辑(多层嵌套条件)时,用普通循环加注释更清晰。

    embeddings = self.model.encode(text_chunks, show_progress_bar=False)

model.encode() — 批量向量化

text_chunks = ["退货政策", "申请流程", "常见问题"]
embeddings = self.model.encode(text_chunks)
# 返回 shape: (3, 384)  ← 3个文本,每个384维向量
参数作用
text_chunks列表,一次性传入所有文本
show_progress_bar=False禁止 tqdm 进度条(适合 API 调用)

💡 批量处理 vs 逐条处理

# 批量(一次处理10条)
embeddings = model.encode(text_chunks)  # 速度: 快

# 逐条(循环10次)
for chunk in text_chunks:                # 速度: 慢10倍
    emb = model.encode([chunk])

原因:模型推理有固定开销(加载模型、计算图初始化),批量处理把开销均摊了。


🔧 删了/改了会怎样

  • 把 text_chunks 改成单个字符串:
    embeddings = self.model.encode(text_chunks)  # text_chunks 是字符串,不是列表
    # TypeError: expected string or bytes object
    
  • show_progress_bar=False 改成 True:服务端日志里每秒输出 tqdm 进度条,影响日志可读性,生产环境不要开。
  • 传空列表 model.encode([]):返回空数组 [],后续 zip(text_chunks, embeddings) 空循环,不报错但也不生成任何块。

🌐 还能用在哪里

  • 图像模型批量推理:torchvision.models 的 model(images) 接受 batch 输入,比循环单张快 10-50 倍。
  • Pandas 向量化:df['col'] * 2 比 for i in range(len(df)): df.loc[i, 'col'] *= 2 快 100 倍以上。
  • NumPy 向量化:np.dot(A, B) 比 Python 嵌套循环快 100-1000 倍。
  • LangChain 的 Embedding LLM:OpenAIEmbeddings 的 embed_documents(texts) 方法内部就是批量调用。

📌 实际应用注意点

  • 批大小的选择:model.encode() 内部对大批量会做 mini-batch 分片处理,如果内存不够(OOM),可以手动分批:
    batch_size = 32
    all_embeddings = []
    for i in range(0, len(text_chunks), batch_size):
        batch = text_chunks[i:i+batch_size]
        all_embeddings.extend(model.encode(batch))
    
  • 内存占用估算:1000 个 chunk × 384维 × 8字节(float64) = 3MB;10000 个 chunk = 30MB,内存压力不大。但如果是 768 维模型 + float64,10000 个 chunk = 60MB,要留意。
  • NumPy 转 list 的开销:.tolist() 在大数据量时耗时明显,可以用 embeddings.tolist() 一次性转换,不要逐个 tolist()。

    for i, (chunk_text, emb) in enumerate(zip(text_chunks, embeddings)):

enumerate + zip — 遍历两个列表

text_chunks = ["退货政策", "申请流程"]
embeddings  = [np.array([...]), np.array([...])]

for i, (chunk_text, emb) in enumerate(zip(text_chunks, embeddings)):
    print(i, chunk_text, emb)
# 输出:
# 0 退货政策  [0.12, 0.85, ...]
# 1 申请流程  [0.33, 0.71, ...]
函数作用
zip(a, b)把两个列表"拉链式"合并,每次取一个元素
enumerate(xs)给列表加编号,每次返回 (索引, 元素)
enumerate(zip(...))组合用法,同时拿到索引和两个列表的元素

💡 enumerate 的底层原理

# enumerate 等价于:
def enumerate(iterable):
    i = 0
    for item in iterable:
        yield i, item
        i += 1

# zip 等价于:
def zip(list_a, list_b):
    for a, b in zip_longest(list_a, list_b):
        if a is not None and b is not None:
            yield a, b

🔧 删了/改了会怎样

  • 把 enumerate(zip(...)) 拆开写:
    for i, chunk_text in enumerate(text_chunks):
        emb = embeddings[i]  # ✅ 也可以,但多一次下标访问
    
    效果相同,但原始写法更紧凑(一次性拿到 i、chunk_text、emb)。
  • zip 的两个列表长度不同:短的遍历完就停止,不会报错,但如果一边少了某个元素,对应的元素就对不上。
  • 交换 enumerate 和 zip 的顺序写成 zip(enumerate(a), b):结果完全错误,zip 会把元组当元素,类型全乱了。

🌐 还能用在哪里

  • Django 模板:{% forloop.counter0 %} 对应 enumerate 的索引。
  • Pandas 迭代:df.iterrows() 返回 (index, Series) 元组,和 enumerate 思路一致。
  • 异步迭代:async for i, item in async_enumerate(async_generator): ...。

📌 实际应用注意点

  • zip 在 Python 3 里返回生成器(不是列表),所以不会立即检查长度差异;如果两个列表长度不同,调试时才报错,很难定位。建议加一个断言:
    assert len(text_chunks) == len(embeddings), "块数量和向量数量不匹配!"
    for i, (chunk_text, emb) in enumerate(zip(text_chunks, embeddings)):
    
  • enumerate 的起始值可以改:enumerate(iterable, start=1) 从 1 开始编号,常用于人类可读的序号。

        self.chunks.append({
            'id': f"{filename}__{i}",
            'filename': filename,
            'text': chunk_text,
            'embedding': emb.tolist()   # numpy数组 → 普通列表
        })

emb.tolist() — NumPy 数组转 Python 列表

emb = np.array([0.12, 0.85, -0.33])     # numpy 数组
emb_list = emb.tolist()                  # [0.12, 0.85, -0.33]  普通列表

💡 为什么要转成 list?

两个原因:

  1. JSON 序列化:numpy 数组不能直接 json.dumps(),会报错
    import json
    json.dumps(np.array([1,2,3]))  # ❌ TypeError
    json.dumps([1,2,3])            # ✅ 正常
    
  2. 可读性:调试时打印 list 比 numpy 数组更直观

反过来:np.array([1,2,3]) 把 list 转回 numpy 数组。


🔧 删了/改了会怎样

  • 直接存 numpy 数组不转 list:如果把 emb(numpy array)直接 append 到 chunks 里,json.dump() 到文件会报 TypeError: Object of type float32 is not JSON serializable,文件写不进去。
  • 用 str(emb) 代替 emb.tolist():能序列化,但存的是 "[0.123 0.456...]"(字符串),读出来还是字符串,要再转换,麻烦。
  • 忘了转类型,直接返回给前端:前端收到 numpy 类型,JS 那边 typeof 返回 object,后续计算会出问题。

🌐 还能用在哪里

  • 向量数据库存储:Pinecone、Milvus、Qdrant 的接口接受 list[float] 而非 numpy,直接存 list 省一次转换。
  • Redis 缓存:用 json.dumps(embedding.tolist()) 存向量,取出来 json.loads() 再转 np.array。
  • gRPC / Protobuf:序列化 numpy 数组用 tensor_util.make_tensor_proto,不用 .tolist()。
  • API 响应:FastAPI 的 response_model 用 Pydantic,会自动处理 numpy → Python 类型转换,不需要手动 .tolist()。

📌 实际应用注意点

  • Pydantic v2 自动处理 numpy 类型:如果你用 from pydantic import BaseModel,Pydantic v2 会自动把 numpy 类型转成 Python 类型,不需要手动 .tolist()。所以这段代码如果升级到 Pydantic v2,可以去掉 .tolist(),但保留也无害。
  • float32 vs float64 的 JSON 精度:float32 精度约 7 位有效数字,float64 约 15 位。对于语义相似度(0~1 之间),float32 足够,省一半内存。
  • numpy 数组的 .tolist() 开销:10000 个向量 × 384维,.tolist() 约耗时 50-100ms,数据量大时注意这个时间。

def search(self, query: str, top_k: int = 3) -> List[dict]:

函数默认参数

def search(self, query: str, top_k: int = 3):
    #                      ↑ 默认值 = 3,可不传

⚠️ 默认参数的一个经典坑:可变默认参数

# ❌ 错误:默认列表会跨调用共享!
def add_item(item, items=[]):
    items.append(item)
    return items

add_item("a")  # ["a"]
add_item("b")  # ["a", "b"]  ← "a" 还在!不是 ["b"]!

# ✅ 正确:默认 None,函数内部创建新列表
def add_item(item, items=None):
    if items is None:
        items = []
    items.append(item)
    return items

add_item("a")  # ["a"]
add_item("b")  # ["b"]  ← 正常了

原因:Python 函数定义时,默认参数只求值一次,存成对象。如果默认是列表,这个列表对象会被所有调用共享。


🔧 删了/改了会怎样

  • top_k: int = 3 改成 top_k: int = 0:检索返回空结果(因为 range(0) = []),用户会觉得"知识库搜不到东西"。
  • 把默认参数删掉:def search(self, query: str, top_k),前端不传 top_k 就报 TypeError: missing 1 required positional argument: 'top_k'。
  • 把 top_k 默认值设成负数:top_k = -1,range(0, -1) 返回空列表,同样返回空结果。

🌐 还能用在哪里

  • Flask 路由:@app.route('/api', defaults={'page': 1}) 是 Flask 版的默认参数。
  • Click / Typer CLI:命令行工具的参数默认值用 @click.option('--count', default=10) 定义。
  • Pydantic BaseModel:message: str 没有默认值 = 必填;message: str = "hello" = 有默认值 = 可选。
  • Django CBV:类视图的 http_method_names = ['get', 'post'] 也是可变默认参数的坑,Django 源码里有特别处理。

📌 实际应用注意点

  • 所有可变对象(list/dict/set)都不要做默认参数,这是 Python 的经典八股文之一,面试常问。
  • 真实项目中默认值常用 None,然后函数内部做判断并初始化,这样更显式。
  • 不可变对象(int/str/tuple)做默认参数是安全的,因为不可变对象不存在"共享修改"问题。
  • 用 mypy 或 pyright 可以静态检测出这个坑,运行 mypy app.py 会报警告 Mutable default values in function signatures are unsafe。

    if not self.chunks:
        return []

守卫语句(Guard Clause)

# 写法1:普通写法(嵌套深)
def search(self, query, top_k):
    if self.chunks:  # 有内容才继续
        # 大量逻辑...

# 写法2:守卫语句(提前退出,嵌套浅)✓ 推荐
def search(self, query, top_k):
    if not self.chunks:
        return []
    # 正常逻辑继续...

💡 守卫语句的好处

  1. 减少嵌套:不需要 if 套 if 套 if
  2. 快速失败:不符合条件时立刻返回,不浪费计算
  3. 可读性:先处理特殊情况,主逻辑更清晰

🔧 删了/改了会怎样

  • 把守卫语句删掉:
    # if not self.chunks: return []  ← 删了这行
    query_emb = self.model.encode([query])  # chunks 为空时,model.encode 正常执行,但后续检索结果永远是空的
    
    这样虽然不报错,但白跑了 embedding 计算,浪费时间和 Token。守卫语句虽然简单,但省去了不必要的计算。

🌐 还能用在哪里

  • 商业代码标准实践:Google、Python 官方库都用守卫语句,PEP 8 规范推荐"Early return"风格。
  • Django/Flask 视图:if not request.user.is_authenticated: return JsonResponse({'error': '未登录'}, status=401)。
  • 递归函数:递归出口用守卫语句,if not node: return None 防止无限递归。

📌 实际应用注意点

  • 守卫语句要放在函数最前面,越早返回越省计算。
  • 守卫条件和主逻辑的 else 分支要清晰,避免"守卫条件漏了,主逻辑跑进去了"的情况。
  • 本项目守卫后返回空列表 [],前端拿到空结果后可以显示"知识库为空",用户体验比直接报错好。

    query_emb = self.model.encode([query], show_progress_bar=False)[0]

向量化用户问题

query_emb = self.model.encode([query])[0]
#                  ↑ 包装成列表  ↑ 取第一个结果(因为只传了一个问题)

为什么是 [query] 而不是 query?

encode() 接受的是列表,批量处理用。如果传字符串:

model.encode("退货政策")      # ❌ 字符串按字符逐个编码,不是按句子
model.encode(["退货政策"])    # ✅ 正确:单元素列表

    chunk_embeddings = np.array([c['embedding'] for c in self.chunks])

列表推导式收集所有向量

[c['embedding'] for c in self.chunks]
# 把每个chunk的embedding字段取出来,组成新列表

np.array([...])
# 把 Python 列表转成 numpy 二维数组
# shape: (N, 384)  ← N 个块,每个 384 维

    similarities = np.dot(chunk_embeddings, query_emb) / (
        np.linalg.norm(chunk_embeddings, axis=1) * np.linalg.norm(query_emb) + 1e-8
    )

核心:余弦相似度公式拆解

这是全代码最重要的数学公式,拆解如下:

余弦相似度 = A·B / (|A| × |B|)
           ─────────────  ─────────────
             向量点积       两向量长度的乘积
# A·B = 向量点积 = 对应元素相乘再相加
# "退货"·"申请" = 0.12×0.33 + 0.85×0.71 + ...
np.dot(chunk_embeddings, query_emb)
# shape: (N,)  ← 每个chunk和问题向量的点积

# |A| = 向量A的L2范数 = sqrt(各元素平方和)
np.linalg.norm(chunk_embeddings, axis=1)
# shape: (N,)  ← 每个chunk向量的长度

# |B| = query向量的长度
np.linalg.norm(query_emb)
# shape: ()   ← 一个标量

# + 1e-8(极小值)防止除以零

💡 L2 范数(欧氏距离的长度)

vector = [3, 4]
norm = sqrt(3² + 4²) = 5

# 代码验证
np.linalg.norm([3, 4])  # 5.0

为什么要除以长度?因为向量可以缩放:

向量A: [0.12, 0.85]  → 点积会随缩放变化
除以|A|×|B|后:结果永远在 [-1, 1] 范围,不受向量"长度"影响
只衡量"方向"是否接近

🔧 删了/改了会怎样

  • 把除以 |A| 的部分删掉:余弦相似度退化成向量点积,值不再归一化到 [-1, 1],范围取决于向量长度和模型,不同 chunk 之间无法横向比较。
  • 删掉 + 1e-8:如果用户输入空字符串或无意义字符,模型输出的 embedding 可能是全零向量,|B| = 0,除以零导致 inf 或 nan,后续排序和比较全部出错。
  • 把 np.linalg.norm(query_emb) 写成 np.linalg.norm(chunk_embeddings, axis=1):shape 不匹配——前者是标量,后者是向量,NumPy 广播后结果全错。

🌐 还能用在哪里

  • 推荐系统:User-Item 矩阵的相似度计算,抖音/B站 的"猜你喜欢"。
  • 图像检索:以图搜图,把图片编码成向量后,用余弦相似度找相似图片(Google Images 底层原理)。
  • 文本分类:多标签分类时,计算文本向量和各类别向量的相似度,取最高者。
  • 语义聚类:K-Means 聚类在向量空间里做,余弦相似度是常用的距离度量。
  • 异常检测:正常样本聚成一团,异常样本距离中心远,设定阈值触发告警。

📌 实际应用注意点

  • 除了余弦相似度,还有欧氏距离(Euclidean):
    • 余弦相似度:衡量"方向"是否接近,不受长度影响。适合文档检索(语义相似)。
    • 欧氏距离:衡量"绝对距离",受长度和方向共同影响。适合推荐系统(评分预测)。
      本项目用余弦是对的,因为检索关心的是"语义相近"而非"长度相近"。
  • 向量维度很高(>1000)时,余弦相似度的"维度诅咒"问题会出现——所有向量趋向正交,高维稀疏。此时建议用HNSW索引(Pinecone、Qdrant 默认)或降维(PCA)处理。
  • 2025 年主流:Pinecone、Milvus 等向量数据库内部已经实现了优化的余弦相似度计算,不需要自己写 np.dot / norm,直接调数据库 API 即可。

💡 向量点积的直观理解

np.dot([1, 0], [1, 0])    # = 1    同方向,最大
np.dot([1, 0], [0, 1])    # = 0    垂直,无关
np.dot([1, 0], [-1, 0])   # = -1   相反,最大负相关

点积 > 0 → 方向接近
点积 = 0 → 完全无关
点积 < 0 → 方向相反


🔧 删了/改了会怎样

  • 把点积换成欧氏距离:欧氏距离越小越相似(和余弦相反),阈值要重新标定,而且高维向量的欧氏距离受向量长度影响大,检索质量下降。
  • 把 [0][0] 写成 [0](少取一个维度):
    query_emb = self.model.encode([query])  # shape: (1, 384)
    np.dot(chunk_embeddings, query_emb)    # (N,384) × (1,384) → ValueError: shapes (N,384) and (1,384) not aligned
    
    必须 [0] 取出来变成 (384,) 才能做矩阵乘法。
  • 用 * 运算符代替 np.dot:chunk_embeddings * query_emb 是逐元素相乘,不是向量点积,结果完全错误。

🌐 还能用在哪里

  • 注意力机制(Attention):Transformer 的核心就是 Q·K^T(Query 和 Key 的点积),决定每个词关注哪些词。
  • 协同过滤:用户-物品评分矩阵的预测分数 = 用户向量 · 物品向量。
  • 信号处理:互相关(Cross-correlation),判断两个信号序列的相似程度。
  • 搜索引擎排序:BM25 可以理解为稀疏向量的点积,语义搜索是密集向量的点积。

📌 实际应用注意点

  • NumPy 的矩阵乘法有多种写法,适用场景不同:
    • np.dot(A, B):通用,1-D 向量返回标量,2-D 矩阵返回矩阵。
    • A @ B(Python 3.5+):最推荐,A @ B 等价于 np.dot(A, B),但语法更直观。
    • np.multiply(A, B):逐元素乘法,不是矩阵乘法。
    # 本项目代码可以用更现代的写法:
    similarities = (chunk_embeddings @ query_emb) / (
        np.linalg.norm(chunk_embeddings, axis=1) * np.linalg.norm(query_emb) + 1e-8
    )
    
  • 数值稳定性:当向量维度很高时,np.dot 的结果可能超出 float32 范围,建议转成 float64 计算,或在点积前对向量做 L2 归一化(归一化后余弦相似度就等于点积,不需要再除以 norm)。

💡 为什么 + 1e-8?

防止 |query_emb| = 0 时除以零(用户输入空字符串时,模型可能吐出全零向量)。
1e-8 = 0.00000001,加上去后永远不会除以零,但影响几乎为零。


🔧 删了/改了会怎样

  • 直接删掉 + 1e-8:正常情况下不影响,但用户输入空字符串或纯空白时,query_emb 全零,|query_emb| = 0,除以零 → inf,排序结果全是 inf,前端的相似度显示 Infinity,用户体验很差。
  • + 1e-8 改成 + 0:和上面一样,没防护。
  • 1e-8 改成 1:+ 1 会把相似度整体抬高约 1,严重扭曲排序结果(本来 0.3 和 0.8 的差距被抹平)。

🌐 还能用在哪里

  • 神经网络除以 epsilon:x / (std + 1e-8) 防止标准差为零时归一化崩溃。
  • log 运算防零:log(x + 1e-8) 防止 log(0) = 负无穷。
  • 矩阵求逆的稳定性:SVD 分解时加小的正则化项 + λI 防止奇异矩阵无法求逆。
  • TF-IDF 的平滑:log(tf + 1) 防止词频为零时贡献为零。

📌 实际应用注意点

  • epsilon 的取值经验:1e-8 是深度学习领域的标准默认值(PyTorch 的 eps 默认值就是 1e-8),太小没效果,太大影响精度。
  • 更健壮的写法:不用 epsilon,而是在计算前检查向量是否为零:
    if np.linalg.norm(query_emb) < 1e-10:
        return []  # 查询向量无效,直接返回空
    
    这样比 epsilon 更显式,日志里能看到有多少无效查询。
  • Pinecone 等向量数据库:在服务端做了这个防护,不需要自己算,但理解原理很重要。

    top_indices = np.argsort(similarities)[-top_k:][::-1]

argsort + 切片排序

similarities = np.array([0.2, 0.8, 0.5, 0.1])

# ① np.argsort: 返回排序后元素原来的索引
np.argsort(similarities)
# [3, 0, 2, 1]  ← 第3小的是0.1,第0小的是0.2,...

# ② [-top_k:] 取最后3个(最大的3个)
# [-3:] → [0, 2, 1]

# ③ [::-1] 翻转(从大到小)
# [1, 2, 0]

top_indices = [1, 2, 0]  # 相似度最高、次高、第三高的索引

💡 argsort 是升序排列

arr = [3, 1, 4, 1, 5]
sorted_indices = np.argsort(arr)
# [1, 3, 0, 2, 4]
# 第1小的是arr[1]=1,第3小的是arr[3]=1,第0小的是arr[0]=3...

# 取最大的3个:
sorted_indices[-3:][::-1]
# [4, 2, 0]  ← 对应 5, 4, 3

🔧 删了/改了会怎样

  • 把 [::-1] 删掉:返回的是相似度最低的 top_k,而不是最高的——用户看到的检索结果全是不相关的。
  • [::-1] 改成 [::-2]:每隔一个取一个,结果跳过了中间值,可能漏掉重要的次优结果。
  • np.argsort 换成 sorted(range(len(similarities)), key=lambda i: similarities[i]):功能等价,但速度慢 10-50 倍(Python 循环 vs NumPy C 实现)。

🌐 还能用在哪里

  • Kaggle 竞赛:比赛里经常用 np.argsort 对特征重要性、模型预测概率排序,取 Top-K 提交。
  • 推荐系统:对用户评分向量 argsort,取前 N 个未交互物品推荐。
  • NLP 词重要性:计算注意力权重后 argsort,找出最重要的 Token。
  • 图像检索:对图片向量 argsort,找最相似的图片展示给用户。

📌 实际应用注意点

  • np.argpartition 更高效:如果只取 Top-K 而不需要全排序,np.argpartition(similarities, -top_k)[-top_k:] 比 argsort 快很多(线性时间 vs O(n log n)),特别是 chunk 数量很大(>10万)时效果明显:
    # 全排序(慢)
    top_indices = np.argsort(similarities)[-top_k:][::-1]
    
    # 分区取Top-K(快)
    top_indices = np.argpartition(similarities, -top_k)[-top_k:]
    top_indices = top_indices[np.argsort(similarities[top_indices])[::-1]]  # 再对Top-K内部排序
    
  • 去重:同一文件可能出现多个块,检索结果可能有重复。可以按文件名去重:seen = set(); for idx in top_indices: if r['filename'] not in seen: results.append(r); seen.add(r['filename'])。
  • argsort 的稳定性:当相似度相等时,argsort 不保证顺序,可能每次运行结果略有不同。如果需要稳定排序,可以加一个次级排序键(如块的原始顺序)。

for idx in top_indices:
    score = float(similarities[idx])
    if score > self.score_threshold:
        results.append({...})

类型转换 + 过滤

score = float(similarities[idx])
# numpy.float32 → Python float
# numpy.float64 → Python float

💡 NumPy 类型和 Python 内置类型

NumPy 的 float32/float64 不是 Python 内置的 float:

type(np.float32(0.85))  # numpy.float32
type(float(0.85))       # float

# JSON 序列化时报错:
import json
json.dumps(np.float32(0.85))  # ❌ TypeError
json.dumps(float(np.float32(0.85)))  # ✅

所以每次从 numpy 里取值,都要 float() 转一下再返回给前端。


🔧 删了/改了会怎样

  • 把 float() 删掉:similarities[idx] 返回 numpy.float32,json.dumps({'score': similarities[idx]}) 报 TypeError,接口 500 错误。
  • 直接 similarities[idx] 不取索引:similarities 是 numpy 数组,json.dumps 同样报错。
  • float() 改成 int():相似度 0.8234 变成 0,检索结果完全失去参考价值。

🌐 还能用在哪里

  • Pydantic v2 自动处理:FastAPI 用 Pydantic v2 做响应模型时,numpy.float32 可以自动转成 Python float,不需要手动 float()。
  • Python 3.9+ 的类型联合语法:float | None 等价于 Optional[float],支持 numpy 类型但不一定完全兼容。
  • TensorFlow / PyTorch:同样有 tf.float32、torch.float32,返回给 API 时也要转 Python 类型。

📌 实际应用注意点

  • float32 vs float64:Embedding 模型输出的向量默认是 float32(单精度),但 .norm() 和 np.dot 结果可能是 float64。本项目相似度计算混合了两种精度,可以用 .astype(np.float64) 统一,避免隐式转换带来的精度问题。
  • 相似度精度显示:0.8234 里的后几位小数意义不大,建议返回前保留 4 位小数即可,避免前端显示过长:round(score, 4)。
  • Pydantic v2:如果升级到 Pydantic v2,响应模型会自动处理类型转换,这段代码的 float() 可以去掉——但保留无害。

第七部分:辅助函数

def save_history():
    """保存对话历史到文件"""
    with open(HISTORY_FILE, "w", encoding="utf-8") as f:\n        json.dump({\n            "messages": chat_history,
            "timestamp": datetime.now().isoformat()
        }, f, ensure_ascii=False, indent=2)

with open(...) as f: — 上下文管理器

with open("file.txt", "w", encoding="utf-8") as f:\n    f.write("hello")

# 等价于:
f = open("file.txt", "w", encoding="utf-8")
try:
    f.write("hello")
finally:
    f.close()  # 无论是否报错,文件都会关闭

💡 为什么用 with?

不用 with用 with
忘了调用 f.close() → 文件句柄泄漏自动关闭,万无一失
代码中途报错 → 文件没关闭任何位置报错都会关闭
手动 try...finally → 代码冗长代码简洁,自动处理

with 背后的协议叫 Context Manager(上下文管理器),Python 用 __enter__ 和 __exit__ 实现。可以用 contextlib 自定义:

from contextlib import contextmanager

@contextmanager
def timer():
    start = time.time()
    yield
    print(f"耗时: {time.time() - start:.2f}s")

with timer():
    # 这段代码执行完后,自动打印耗时
    sleep(1)

🔧 删了/改了会怎样

  • 把 with open(...) as f: 改成手动 open/close:如果 json.dump 抛异常,f.close() 永远不会执行,文件句柄泄漏;反复调用后系统达到打开文件数上限(Linux 默认 1024),服务崩溃。
  • encoding="utf-8" 删掉:Windows 默认用 GBK 编码,中文文件名写入后变成乱码,下次 load_history 读不出来。
  • indent=2 删掉:JSON 变成一行,文件体积小一些,但完全不可读,调试时无法人工检查。

🌐 还能用在哪里

  • 数据库连接:with engine.connect() as conn: 自动 commit/rollback 和关闭连接。
  • 锁:with threading.Lock(): ... 自动获取和释放锁。
  • 临时文件:with tempfile.NamedTemporaryFile() as f: 退出 with 块后自动删除临时文件。
  • pytest fixtures:pytest 的 fixture 底层就是 context manager,@pytest.fixture 装饰的函数 yield 前后分别对应 __enter__ 和 __exit__。

📌 实际应用注意点

  • 并发写入 JSON 文件是灾难:save_history 和 load_history 如果并发执行(多请求同时写),JSON 文件会损坏。解决方案:用 文件锁(fcntl.flock,Linux)或换用 SQLite(内置锁)或 Redis 做历史存储。
  • 大文件不要用 json.dump 全量写入:如果 chat_history 有几万条消息,json.dump 可能耗时几百毫秒,阻塞事件循环。建议用 流式写入(ijson 库)或按消息条数分片存多个文件。
  • always 指定 encoding:encoding="utf-8" 一定要写,默认值在不同系统上不一致(Windows 用 GBK),导致跨平台协作时文件乱码。

json.dump({...}, f, ensure_ascii=False, indent=2)

JSON 序列化参数

参数作用
ensure_ascii=False保留中文字符(False=输出 中 这样的 Unicode 转义)
indent=2格式化缩进,可读性好,文件体积略大
# ensure_ascii=False(中文友好)
{"messages": "你好", "name": "小智"}

# ensure_ascii=True(纯 ASCII)
{"messages": "\u4f60\u597d", "name": "\u5c0f\u667a"}

def load_history():
    global chat_history    # ← 关键!
    if os.path.exists(HISTORY_FILE):
        with open(HISTORY_FILE, "r", encoding="utf-8") as f:\n            data = json.load(f)\n            chat_history = data.get("messages", [])

global 关键字

chat_history = []  # 全局变量(函数外部)

def load_history():
    global chat_history   # 声明:我修改的是全局变量,不是新建局部变量
    chat_history = data.get("messages", [])

💡 没有 global 会怎样?

chat_history = []

def load_history():
    chat_history = data   # ❌ 这行会创建一个新的局部变量!
                          # 全局 chat_history 永远是 []

def load_history():
    global chat_history  # ✅ 明确声明我要改全局的
    chat_history = data  # 全局 chat_history 被修改

简单规则:函数内部要修改全局变量,必须加 global。


🔧 删了/改了会怎样

  • 把 global chat_history 删掉:chat_history = data.get("messages", []) 创建了一个局部变量 chat_history,函数结束后被垃圾回收,全局 chat_history 永远是空列表,前端永远看到空历史。
  • global 拼写错误:global chat_histroy(少了一个 r)——Python 不会报错,只是声明了一个不存在的全局变量名,赋值语句创建新的局部变量,bug 极难发现。
  • 在 async 函数里用 global:async def load_history(): global chat_history——语法上允许,但并发场景下可能有竞态条件,两个请求同时调用 load_history 时,列表操作可能丢失数据(建议加锁)。

🌐 还能用在哪里

  • Flask 全局对象:from flask import current_app, g,避免直接 global。
  • FastAPI 的 app.state:app.state.xxx = value,比 global 清晰,但本质一样。
  • Django 的 threading.local():线程级全局变量,每个线程独立。
  • 配置模块:config.py 里定义全局配置,所有模块 import config 访问,不需要 global 关键字(因为只读不写)。

📌 实际应用注意点

  • 现代 Python 尽量不用 global:可以用依赖注入或单例模式替代,把共享状态封装在类或模块里,只通过接口访问。
  • 读取不需要 global,修改才需要:如果只读取 chat_history(如 len(chat_history)),不需要 global;只有 chat_history.append(...) 这种修改操作才需要。
  • 全局变量 + 多线程 = 竞态条件:如果项目升级成多线程(不是多进程),对列表的 .append() 虽然有 GIL 保护,但如果在 append 和 pop 之间有其他线程介入,数据一致性还是可能出问题。真实项目建议用 collections.deque(maxlen=N) 做有界队列,超出长度自动丢弃旧消息。

data.get("messages", [])

dict.get() — 安全取值

写法行为
data["messages"]键不存在 → 抛 KeyError
data.get("messages")键不存在 → 返回 None
data.get("messages", [])键不存在 → 返回 [](默认值)

💡 .get() 的常见陷阱

data = {"messages": None}
data.get("messages", [])  # 返回 None,不是 []

# 如果 None 也算"无数据",要这样处理:
data.get("messages") or []  # None or [] → []

🔧 删了/改了会怎样

  • 把 .get("messages", []) 改成 ["messages"]:文件格式被破坏时(如手动改了 JSON 文件),直接 KeyError,接口 500;.get 则优雅返回空列表,不崩溃。
  • default=[] 漏掉:文件里 "messages" 字段不存在时返回 None,后面 chat_history = None,.append 报 AttributeError: 'NoneType' object has no attribute 'append'。
  • 用 data["messages"] or [] 代替 .get:语义相似,但如果 messages: [](空列表,bool([])=False),结果变成 None,逻辑错误。

🌐 还能用在哪里

  • Django request.GET/POST:request.GET.get('page', '1'),GET 参数不存在时用默认值。
  • 环境变量读取:os.environ.get('PORT', '8000'),变量不存在时用 8000。
  • Pydantic BaseModel:Field(default_factory=list) 每次创建新列表实例,避免可变默认参数坑。
  • 字典链式取值:configs.get('db', {}).get('host') 防止中间 key 不存在报错。

📌 实际应用注意点

  • 文件反序列化后的 .get() 一定要设默认值:因为文件可能被手动修改、版本不兼容、部分损坏,设默认值是最稳健的做法。
  • None vs 空容器的区别:函数参数用 None 表示"没传",空列表表示"传了但为空"。在设计 API 时要明确区分这两种状态,给前端正确的反馈。
  • Pydantic BaseModel 的 model_validate:可以自动处理字段缺失、不合法类型等问题,比手动 .get() 更健壮,真实项目推荐用 Pydantic 模型替代字典处理 JSON。

第八部分:API 接口

class ChatRequest(BaseModel):
    message: str

Pydantic BaseModel

# 请求:
# POST /chat
# Body: {"message": "退货政策是什么?"}

class ChatRequest(BaseModel):
    message: str    # 必填

# FastAPI 自动:
# 1. 从 HTTP body 读 JSON
# 2. 检查 "message" 字段存在且是 str
# 3. 注入给 chat() 函数的 req 参数

💡 BaseModel 的字段验证规则

class ChatRequest(BaseModel):
    message: str                      # 必填
    temperature: float = 0.7         # 可选,默认0.7
    max_tokens: int | None = None    # 可选,可以是None

🔧 删了/改了会怎样

  • 把 message: str 改成 message: str = "":字段变成可选,前端不传 message 也不会报错,req.message 拿到空字符串 "",发给大模型浪费 Token。
  • 类型写成 message: int:前端传 {"message": "退货政策"} 字符串,FastAPI 返回 422 校验错误(不是你想要的),如果写反了(int→str),前端传数字也接受,但大模型收到数字后行为不可预期。
  • 加了验证器但逻辑写反:if len(message) < 0 写成 if len(message) == 0,拒绝所有消息。

🌐 还能用在哪里

  • FastAPI 响应模型:response_model=ChatResponse,自动过滤返回字段,防止敏感信息泄露。
  • 数据库序列化:SQLModel、Prisma Python 的模型继承自 Pydantic BaseModel。
  • CLI 工具:Typer 配合 Pydantic 做命令行参数校验,错误提示比 argparse 友好得多。
  • LangChain OutputParser:用 Pydantic 定义 LLM 输出格式,强制 JSON Schema 校验。
  • 测试fixtures:pytest 的 parametrize 配合 Pydantic 模型批量生成测试数据。

📌 实际应用注意点

  • Pydantic v2(2023年发布)全面普及,新项目建议直接用 v2 语法:model_validator、field_validator 替代 validator,性能更好:
    # v2 推荐写法
    from pydantic import BaseModel, Field, field_validator
    class ChatRequest(BaseModel):
        message: str = Field(..., min_length=1, max_length=8000)
    
        @field_validator('message')
        @classmethod
        def strip_message(cls, v: str) -> str:
            return v.strip()  # 自动去除首尾空格
    
  • JSON Schema 自动生成:FastAPI + Pydantic 会自动生成 OpenAPI Schema,/docs 页面可以直接测试,字段说明写在 docstring 里:message: str = Field(..., description="用户发送的消息内容")。
  • 避免过度验证:字段验证规则太多会让 API 过于脆弱,建议只验证"绝对不会错"的条件,其他靠业务逻辑处理。

@app.post("/chat")
async def chat(req: ChatRequest):

async def — 异步函数

# 普通函数(同步)
def sync_func():
    result = requests.get("https://api.deepseek.com")
    return result.json()

# 异步函数
async def async_func():
    result = await requests.get("https://api.deepseek.com")
    return result.json()

💡 同步 vs 异步的本质区别

同步:等一个操作完成,才做下一个

发起HTTP请求 → 等10秒收到响应 → 继续执行  ← CPU 在这10秒里空转

异步:发起请求后,不等结果,先去做别的事

发起HTTP请求(不等待) → 去做别的事 → 等回调 → 收到响应
CPU 在等待期间处理了100个其他请求!

什么时候用 async?

  • 有 I/O 操作(网络请求、文件读写、数据库查询)→ 用 async/await
  • 有 CPU 密集计算(向量运算、加密、压缩)→ 用 run_in_executor() 或多进程

⚠️ async 函数内部不能混用同步代码

async def chat():
    # ❌ 同步代码会阻塞整个事件循环!
    result = requests.get(...)  # 同步HTTP请求,会卡住

    # ✅ 要用异步 HTTP 库
    import httpx
    result = await httpx.AsyncClient().get(...)

    # ✅ 或者把同步代码丢到线程池
    import asyncio
    result = await asyncio.to_thread(sync_function, arg)

当前代码用 requests(同步),是因为 uvicorn 默认会自动把同步代码放到线程池执行,所以不会真的卡死。但如果追求极致性能,建议换 httpx 的异步版本。


🔧 删了/改了会怎样

  • 把 async def 改成 def(去掉 async):FastAPI 依然能工作,但 uvicorn 不会再把它丢到线程池,而是直接同步执行,10-30 秒的大模型等待期间,同一进程无法处理其他请求,并发能力降为零。
  • 把 await client.chat.completions.create(...) 的 await 删掉:拿到的是协程对象(Coroutine),没有 await,协程不会执行,最终发给前端的是协程对象本身,不是响应内容。
  • 在 async 函数里用了同步 requests.get(不加 asyncio.to_thread):uvicorn 默认把同步函数放线程池(最多几个线程),请求一多线程池耗尽,新请求排队等待。

🌐 还能用在哪里

  • FastAPI + SQLAlchemy:用 asyncpg 异步驱动数据库,async with async_session 做数据库查询。
  • aiohttp / httpx:异步 HTTP 客户端,并发请求多个 API(asyncio.gather)。
  • Node.js:原生异步(Promise/async-await),和 Python 的 asyncio 思想完全一致。
  • Tornado / Quart:ASGI/WSGI 异步框架,和 FastAPI 是竞争关系。
  • Streamlit:不是 async 的,但通过 @ asyncio.coroutine 配合 Uvicorn 可以在特定场景下使用。

📌 实际应用注意点

  • async/await 的传染性:一旦一个函数变成 async,所有调用它的函数都要加 async/await,漏掉一个就会报错(TypeError: cannot reuse already awaited coroutine)。从项目一开始就想清楚要不要用 async,半途改代价很大。
  • 本项目为什么用 async:主要是 FastAPI 的惯例(FastAPI 本身就是 async 框架),但实际瓶颈是网络 I/O(大模型响应时间),用 asyncio.to_thread(requests.get, ...) 完全够用,不需要改业务代码。
  • 2025 年趋势:LangChain、LlamaIndex 等 AI 框架都支持 async,AI 场景用 async 越来越主流,主要好处是能并发调用多个工具(asyncio.gather)。

    global chat_history
    chat_history.append({"role": "user", "content": req.message})

global 已讲。追加用户消息到对话历史。


    retrieved = rag.search(req.message, top_k=3)

RAG 检索调用

retrieved = rag.search("退货政策是什么?", top_k=3)

# 返回格式:
[
    {'filename': '售后政策.txt', 'text': '退货政策:7天内...', 'score': 0.8234},
    {'filename': 'FAQ.md', 'text': '退换货流程...', 'score': 0.7156},
    {'filename': '售后政策.txt', 'text': '申请退货需联系客服...', 'score': 0.6891},
]

    context_parts = []
    sources = []
    for i, r in enumerate(retrieved):
        context_parts.append(f"【片段{i+1}|来源:{r['filename']}|相似度:{r['score']}】\n{r['text']}")
        sources.append({"filename": r['filename'], "score": r['score']})

    context_text = "\n\n".join(context_parts)

f-string + enumerate 组合构建上下文

# enumerate 给每个检索结果编号(1, 2, 3...)
# f-string 在字符串里嵌入变量
# "\n\n".join 合并成一个大字符串

context_text = """
【片段1|来源:售后政策.txt|相似度:0.8234】
退货政策:7天内可申请退货...

【片段2|来源:FAQ.md|相似度:0.7156】
退换货流程:联系客服后...
"""

💡 f-string(格式化字符串字面量)

name = "小智"
score = 0.85

# f-string(Python 3.6+,最推荐)
f"你好,我叫{name},得分{score:.2f}"   # "你好,我叫小智,得分0.85"

# .format()(Python 2.7+)
"你好,我叫{},得分{:.2f}".format(name, score)

# % 格式化(老式)
"你好,我叫%s,得分%.2f" % (name, score)

    response = client.chat.completions.create(
        model="deepseek-chat",
        messages=[
            {"role": "system", "content": system_prompt},
            *chat_history[-100:]    # ← * 是解包操作
        ],
        stream=False
    )

* 解包操作符

system_msg = {"role": "system", "content": "你叫小智"}
history_msgs = [
    {"role": "user", "content": "你好"},
    {"role": "assistant", "content": "你好!"}
]

# 不用 *:嵌套列表
[system_msg, history_msgs]   # [["system dict"], ["user", "assistant"]]
#                              ❌ 嵌套了一层!

# 用 *:平铺展开
[system_msg, *history_msgs]  # ["system dict", "user dict", "assistant dict"]
#                              ✅ 平铺,正确

💡 * 和 ** 的区别

操作符作用于场景
*列表/元组[a, *b] 把列表 b 拆开平铺
**字典{a:1, **{b:2}} 把字典 b 的键值对合并
# * 解包列表
print(*[1, 2, 3])        # 1 2 3(打印成单独的参数)

# ** 解包字典(作为函数参数)
def greet(name, age):
    print(f"{name} {age}")

params = {"name": "小智", "age": 18}
greet(**params)           # 等价于 greet(name="小智", age=18)

    response = client.chat.completions.create(...)
    reply = response.choices[0].message.content

API 响应解析

# DeepSeek 返回的完整结构(省略了很多字段):
response = {
    "id": "ds-xxx",
    "model": "deepseek-chat",
    "choices": [
        {
            "message": {
                "role": "assistant",
                "content": "退货政策是..."
            },
            "finish_reason": "stop",
            "index": 0
        }
    ],
    "usage": {
        "prompt_tokens": 500,
        "completion_tokens": 100,
        "total_tokens": 600
    }
}

reply = response["choices"][0]["message"]["content"]

💡 choices[0] 为什么用 [0] 而不是 .get()?

因为 choices 是列表不是字典。列表的 .get() 方法不存在。

response.choices[0].message.content
#     ↑ 列表用下标[0]
#              ↑ ChoicesMessage 对象,用 .属性 访问

    return {
        "reply": reply,
        "rag_sources": retrieved if retrieved else []
    }

三元表达式 + 字典字面量返回

# if-else 写成一行
result = value if condition else default

# 等价于:
if condition:
    result = value
else:
    result = default

# 代码中的用法:
"rag_sources": retrieved if retrieved else []
# 如果 retrieved 有内容(truthy)→ 用 retrieved
# 如果 retrieved 为空(falsy)→ 用 []

@app.get("/history")
async def get_history():
    return {
        "messages": chat_history,
        "total": len(chat_history)
    }

GET 接口 — 查询数据

@app.get("/history")    # GET /history
# 前端调用:requests.get("http://127.0.0.1:8000/history")

💡 GET vs POST 的选择

场景方法原因
查数据(状态、历史列表)GET幂等,可缓存,URL 可分享
写数据(发消息、上传文件)POST非幂等,改变服务端状态
删除资源DELETE幂等,删除是确定操作

/history 是查数据,所以用 GET。/chat 是发消息写数据,所以用 POST。


@app.delete("/knowledge/{filename}")
async def delete_knowledge(filename: str):

路径参数

@app.delete("/knowledge/{filename}")
#                          ↑ 路径参数

# 前端调用:
DELETE http://127.0.0.1:8000/knowledge/退货政策.txt
#                             ↑ filename 参数 = "退货政策.txt"

# FastAPI 自动把 URL 里的值提取出来,注入到函数参数

💡 URL 参数编码问题

如果文件名里有空格、中文、特殊字符,直接放 URL 会报错:

DELETE /knowledge/退货政策.txt        # ❌ 非法URL
DELETE /knowledge/退货政策.txt        # ✅ URL编码后

正确做法(前端已处理):

from urllib.parse import quote

filename = "退货政策.txt"
safe_name = quote(filename)  # "退货政策.txt"
DELETE f"/knowledge/{safe_name}"

🔧 删了/改了会怎样

  • 不处理 URL 编码:如果文件名是 "退货政策.txt",直接拼 URL 会报 UnicodeEncodeError 或 404。
  • 路径参数 filename: str 没标注类型:FastAPI 依然能工作,但 Swagger UI 里不显示类型,文档不清晰。
  • 路径参数加了 / 导致截断:如 filename = "a/b.txt",URL 变成 /knowledge/a/b.txt,被解析成嵌套路径,filename 只拿到 a。

🌐 还能用在哪里

  • RESTful API:几乎所有 REST API 都用路径参数表示资源 ID,如 GET /users/{user_id}。
  • Flask:装饰器 @app.route('/user/<int:user_id>') 做类型转换。
  • Django:URLconf path('user/<int:user_id>/', views.user_detail)。
  • Spring Boot:@GetMapping("/user/{id}") 对应 @PathVariable。

📌 实际应用注意点

  • URL 编码是必须的:中文、空格、特殊字符必须编码,否则 HTTP 请求会解析错误。用 urllib.parse.quote 或 JavaScript 的 encodeURIComponent。
  • 路径参数 vs 查询参数:表示资源身份(ID、文件名)用路径参数;表示筛选条件(?page=1&size=10)用查询参数。RESTful 规范不要混用。
  • 敏感信息不要放路径参数:路径参数会出现在服务器日志里,密码、Token 不要放 URL 里,要用 Header。

    if filename in knowledge_files:
        del knowledge_files[filename]
        rag.remove_document(filename)
        filepath = os.path.join(KNOWLEDGE_DIR, filename)
        if os.path.exists(filepath):
            os.remove(filepath)

删除操作的三层清理

内存:knowledge_files.pop(filename)
向量:rag.remove_document(filename)      ← 内存中的向量列表
磁盘:os.remove(filepath)              ← 真正的文件删除

💡 为什么都要删?

操作不删会怎样
不删 knowledge_files内存里有,但文件没了 → 返回给前端时"鬼文件"
不删 rag.remove_document向量索引里有残留 → 检索时搜到不存在的文件
不删磁盘文件用户下次重启服务,文件又加载回来了

三个地方的数据必须保持同步,否则就会出现"幽灵数据"。


🔧 删了/改了会怎样

  • 只删磁盘文件,不删 knowledge_files 和 rag.chunks:内存里还有残留,GET /knowledge 返回已删除的文件,search 还能搜到不存在文件的内容。
  • 只删 knowledge_files,不删磁盘:重启服务后,load_knowledge 又会把文件加载回来,删除无效。
  • 漏掉 rag.remove_document:向量索引和原始文档脱节,用户搜"退货政策"可能返回文件名已不存在的片段。
  • os.remove(filepath) 前忘了 os.path.exists() 检查:文件不存在时抛 FileNotFoundError。

🌐 还能用在哪里

  • 数据库事务:删除用户时,要同时删 User 表记录、删除相关文件、清理缓存,三者必须原子性完成,否则出现"幽灵用户"。
  • Git 的三方删除:删除文件要同时从工作区、暂存区(index)、版本库删除,少一个就恢复不了。
  • 分布式系统:删除数据要同时更新多个节点的状态机,用 Raft/Paxos 保证一致性。

📌 实际应用注意点

  • 顺序很重要:本项目的删除顺序(内存→向量→磁盘)是合理的。磁盘最后删是因为磁盘删除最难恢复(保底),先删磁盘万一后面失败就真的没了。
  • 最好用事务或补偿机制:真实项目删除时建议先标记"待删除",然后异步执行三层删除,全部成功才真正清理。任何一层失败都能回滚。
  • Windows 文件占用问题:os.remove(filepath) 在 Windows 上如果文件被其他进程打开,会报 PermissionError。加个重试或延迟删除可以缓解。

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="127.0.0.1", port=8000)

程序入口守卫

if __name__ == "__main__":

💡 __name__ 是什么?

每个 Python 文件都有一个内置变量 __name__:

运行方式__name__ 值含义
直接运行 python app.py"__main__"我是被执行的脚本
被导入 import app"app"我是被别人用的模块
# app.py
print(__name__)  # 打印看看

# 直接运行:输出 __main__
# 导入运行:输出 app

if __name__ == "__main__": 的作用:只有直接运行这个文件时才执行,被别人 import 时不执行。

实际场景:

# 方式一:直接运行(走 if 分支)
python app.py

# 方式二:uvicorn 启动(不走 if 分支,uvicorn 内部导入 app)
uvicorn app:app --reload

🔧 删了/改了会怎样

  • 把 if __name__ == "__main__": 删掉:uvicorn app:app 启动方式不受影响(uvicorn 导入 app 模块,__name__ 是 "app",不满足条件),但 python app.py 启动方式会多执行一次 uvicorn.run,而 uvicorn 内部其实已经跑了一次,没影响但不必要。
  • 把 uvicorn.run(app, ...) 放在 if 外层:文件被 import 时直接启动服务,很危险——Django/Flask 的 app.run() 也会被 import 触发,这是常见错误。

🌐 还能用在哪里

  • 所有 Python 入口脚本:CLI 工具、安装脚本、自测脚本都用 if __name__ == "__main__": 保护。
  • pytest 入口:pytest 命令内部通过 __main__ 运行测试文件。
  • Node.js 对应:if __name__ == '__main__' 对应 Node.js 的 if (require.main === module)。

📌 实际应用注意点

  • 生产环境用 uvicorn 命令行,不要用 python app.py:uvicorn app:app --host 0.0.0.0 --port 8000 支持热重载、多进程等特性,app.run() 功能有限。
  • host="127.0.0.1" 在生产环境要改成 host="0.0.0.0" 才能从外部访问,但此时必须有反向代理(Nginx/Caddy)限制来源,否则直接暴露在内网。
  • 2025 年容器化趋势:Dockerfile 里用 CMD ["uvicorn", "app:app", "--host", "0.0.0.0"] 代替 python app.py,更标准。

知识回顾图

后端收到 /chat 请求
        │
        ▼
  检查 chunks 有没有数据
        │
        ├── 无 → 返回空知识库提示
        │
        └── 有 → model.encode(user_query)          ← 转成向量
                  │
                  ▼
            np.dot(向量A, 向量B) / (|A|×|B|)         ← 余弦相似度
                  │
                  ▼
            np.argsort() 取 Top-3                   ← 排序取最高的3个
                  │
                  ▼
            拼成 context_text,塞进 system_prompt
                  │
                  ▼
            client.chat.completions.create()        ← 调 DeepSeek
                  │
                  ▼
            返回 reply + rag_sources
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐