小智 Agent 后端代码逐句详解
第一部分:导入依赖
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=StrictCookie 属性也会影响认证行为。
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 参数时,会:
- 从 HTTP 请求体里读 JSON
- 校验
message字段存在且是str类型 - 类型不对 → 自动返回 422 错误,不进入业务代码
- 类型对了 → 转成 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() |
json | JSON 序列化/反序列化(和文件配合做持久化) |
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.dotvsnp.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 用 /
| 系统 | 路径写法 |
|---|---|
| Windows | D:\\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 的结果不返回
五个实例属性的含义
| 属性 | 默认值 | 含义 |
|---|---|---|
model | None | Embedding 模型,首次调用才加载 |
chunks | [] | 所有文本块的列表,每个块含文本+向量 |
chunk_size | 300 | 每块字数上限 |
overlap | 50 | 相邻块重叠的字数 |
score_threshold | 0.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(...))拆开写:
效果相同,但原始写法更紧凑(一次性拿到 i、chunk_text、emb)。for i, chunk_text in enumerate(text_chunks): emb = embeddings[i] # ✅ 也可以,但多一次下标访问 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?
两个原因:
- JSON 序列化:numpy 数组不能直接
json.dumps(),会报错import json json.dumps(np.array([1,2,3])) # ❌ TypeError json.dumps([1,2,3]) # ✅ 正常- 可读性:调试时打印 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 []
# 正常逻辑继续...
💡 守卫语句的好处
- 减少嵌套:不需要
if套if套if- 快速失败:不符合条件时立刻返回,不浪费计算
- 可读性:先处理特殊情况,主逻辑更清晰
🔧 删了/改了会怎样
- 把守卫语句删掉:
这样虽然不报错,但白跑了 embedding 计算,浪费时间和 Token。守卫语句虽然简单,但省去了不必要的计算。# if not self.chunks: return [] ← 删了这行 query_emb = self.model.encode([query]) # chunks 为空时,model.encode 正常执行,但后续检索结果永远是空的
🌐 还能用在哪里
- 商业代码标准实践: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,而是在计算前检查向量是否为零:
这样比 epsilon 更显式,日志里能看到有多少无效查询。if np.linalg.norm(query_emb) < 1e-10: return [] # 查询向量无效,直接返回空 - 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
更多推荐




所有评论(0)