大家好!今天向大家介绍的是企业智能问答办公助手项目的后端配置架构设计,项目后端采用FastAPI + LangGraph + RAG 技术栈。 其中,本文主要聚焦 core 和 nodes 两个文件夹的每一个文件的作用,重点放在拆解每个文件的具体作用、设计逻辑以及每个文件在项目中的定位,本系列也会逐步更新,欢迎关注。
一、backend项目结构概览
backend/app/
├── core/ ← 地基层(配置、数据库、安全、日志……所有与业务无关的通用能力)
├── nodes/ ← 节点层(AI 流程的每一步"积木块",LangGraph 的最小处理单元)
├── agents/ ← 编排层(把 nodes 组装成完整的对话流程图)
├── chains/ ← 链路层(简单的顺序 LLM 调用链)
├── services/ ← 服务层(业务逻辑,调用 nodes/chains 完成具体功能)
├── models/ ← 数据模型层(数据库表 + 请求/响应的 Pydantic 模型)
├── vectorstore/ ← 向量知识库(企业文档的向量化索引,RAG 的弹药库)
└── utils/ ← 工具函数(杂项)
另外,我发表在码上掘金的html文件可以点击详细查看各个文件的功能(第一次用这个功能,很有意思),也可以下载到本地查看。
二、core 文件夹 — 地基层,8 个文件各司其职
config.py 相当于 application.properties/application.yml + @Configuration 类
database:对标 MyBatis,负责数据库连接、数据表创建,按 lifespan 在服务启动和关闭阶段记录关键信息
llm:封装 OpenAI /其他大模型的 get_chat_model 方法,按配置创建对应的聊天模型
logging:日志脱敏处理+全局日志配置
middleware:中间件模块,请求日志记录+跨域处理+全局异常兜底功能
security:实现安全认证,完成令牌签发、密码生成与校验
streaming:封装 SSE 流式输出,定制流式响应与进度条推送格式
2.1 config.py — 全局配置中心
类比 Java:application.yml + @Configuration 类,合二为一。
项目所有配置(数据库地址、API Key、跨域白名单……)集中在一个地方管理,从 .env文件自动读取,整个项目任何地方需要配置直接调 get_settings() 拿。
用到的库:
- (一)pydantic-settings 专门做 配置管理的库,继承
BaseSettings后,类里的字段会自动从.env文件和系统环境变量读取,不需要手动写os.getenv()
pydantic-settings配置管理全家桶
- 1. BaseSettings:配置类基类,继承后自动从
.env文件、环境变量读取配置值 Pydantic- 2. SettingsConfigDict:控制配置加载行为,如配置文件路径、编码、大小写敏感等
- 3. PydanticBaseSettingsSource:配置来源抽象基类,支持自定义配置加载逻辑
-
(二)pydantic.Field:给字段加额外规则,比如默认值工厂、描述、校验正则
-
(三)functools.lru_cache:缓存装饰器,把函数返回值记住,下次同样参数直接返回缓存,不重新执行 maxsize=None:缓存无限大(Python 3.9 + 可用
@cache替代) -
(四)Path拼接库:
from pathlib import Path面向对象的路径操作,自动适配 Windows/Linux/macOS 系统的路径分隔符,不会出现路径拼写错误,比手动字符串拼接更安全、更稳定、跨平台兼容性更强。 -
(五)quote_plus库 :
from urllib.parse import quote_plusURL 编码,把特殊字符(比如密码里的@#)转成%xx格式 | Java 的URLEncoder.encode(),JS 的encodeURIComponent()
from __future__ import annotations
from functools import lru_cache
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
app_name: str = "企业智能助手"
database_url: str # 没有默认值 = 必须在 .env 里配置,否则启动报错
openai_api_key: str
secret_key: str
llm_model: str = "gpt-4o"
cors_origins: list[str] = Field(
default_factory=lambda: [
"http://localhost:5173",
"http://127.0.0.1:5173",
]
)
model_config = SettingsConfigDict(
env_file=".env",
extra="ignore"
)
@lru_cache
def get_settings() -> Settings:
return Settings()
🥕annotations延迟解析库:from __future__ import annotations
Python 是逐行解释执行的。如果在类内部用到了自己的类型(比如 Settings 的某个方法返回 Settings),执行到第 3 行时类还没定义完,Python 就会报"找不到这个类型"的错。加上这行后,所有类型注解变成字符串延迟解析,等整个文件都加载完再去解析,就不会报错了。
TypeScript 没有这个问题,因为 TS 编译期直接把类型擦掉,运行时根本不存在类型,所以不会有找不到类型的情况。
🥕lambda列表:default_factory=lambda: [...] — 可变类型默认值陷阱
这是 Python 初学者最容易踩的坑,直接看对比:
# ❌ 危险写法:所有实例共享同一个列表对象
class Config:
cors_origins: list = []
a = Config()
b = Config()
a.cors_origins.append("http://evil.com")
print(b.cors_origins) # ["http://evil.com"] ← b 也被污染了!
原因:Python 类定义时 [] 只创建一次,所有实例都指向同一个列表,改了 a 的,b 也跟着变。
# ✅ 正确写法:每次创建实例时调用 lambda,返回一个全新的列表
cors_origins: list[str] = Field(default_factory=lambda: [...])
lambda: [...] 是一个没有名字、没有参数的小函数,每次被调用都返回一个全新的列表。等价于:
def get_default_cors():
return ["http://localhost:5173", "http://127.0.0.1:5173"]
规律记住:list[]、dict{}、set() 这三种可变类型的默认值,必须用 default_factory,不能直接写 = []。
@lru_cache — 函数级单例
不加 @lru_cache,每次调用 get_settings() 都会重新读取 .env 文件、重新创建 Settings 对象,浪费 IO。加上之后第一次调用会执行并缓存结果,之后无论调用多少次都直接返回同一个对象,等价于 Java 的单例模式,但写法更轻量。
2.2 database.py — 异步数据库层
类比 Java:MyBatis 的 DataSource 配置 + SqlSessionFactory + 数据库迁移(建表),三合一。
这个文件干什么的:创建异步数据库连接引擎,定义 Session 工厂,提供给 FastAPI 路由通过依赖注入获取数据库连接。所有数据库 Model 也继承这里定义的 Base。
用到的库:
SQLAlchemy:Python 最主流的 ORM 框架,相当于 Java 的 MyBatis + JPA 的结合体sqlalchemy.ext.asyncio:SQLAlchemy 的异步扩展,配合 FastAPI 的异步路由使用,不阻塞事件循环
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import DeclarativeBase, sessionmaker
from app.core.config import get_settings
settings = get_settings()
engine = create_async_engine(settings.database_url, echo=False)
AsyncSessionLocal = sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False
)
class Base(DeclarativeBase):
pass
async def get_db():
async with AsyncSessionLocal() as session:
yield session
创建数据库连接引擎:create_async_engine
创建数据库连接引擎,管理底层的连接池。echo=False 表示不在控制台打印每条 SQL,生产环境一般关掉,调试时可以改成 True。
session工厂:sessionmaker
Session 工厂,相当于 Java 的 SqlSessionFactory。不直接用 Session,而是用工厂来创建,是为了复用配置(每次创建 Session 都应该绑定同一个 engine,用同样的参数)。
expire_on_commit=False 的意思:默认情况下 session.commit() 之后,所有 ORM 对象的属性会被标记为"过期",下次访问会重新查数据库。关掉这个行为是因为异步场景下,commit 之后 Session 可能已经关闭,再查就报错。
yield:get_db() 里的 yield
yield 是这里最重要的关键字。它把函数变成一个"生成器",配合 async with 实现了资源的自动管理:
yield session之前:打开数据库连接,创建 Sessionyield session:把 Session "借"给调用方(FastAPI 路由函数)使用- 路由函数执行完毕后:自动回到
yield之后的代码,async with块结束,Session 自动关闭
等价于 Java 的 try-with-resources,不需要手动写 finally: session.close()。
FastAPI 路由里的用法:
# FastAPI 看到参数里有 Depends(get_db),会自动调用 get_db(),把 yield 出来的 session 注入进来
@router.get("/users")
async def get_users(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(User))
return result.scalars().all()
2.3 llm.py — 模型工厂
类比 Java:工厂模式,相当于 Spring 的 BeanFactory + 工厂方法。
类别JS的组件化思想
这个文件干什么的:把"创建 LLM 实例"这件事封装成一个函数。调用方不需要关心用的是哪家模型、怎么配置,直接调 get_chat_model() 拿来用。
用到的库:
langchain-openai:LangChain 对 OpenAI 系列模型的封装,提供统一接口,底层可以替换成 Claude、Gemini 等其他模型
from langchain_openai import ChatOpenAI
from app.core.config import get_settings
settings = get_settings()
def get_chat_model(temperature: float = 0.7) -> ChatOpenAI:
return ChatOpenAI(
model=settings.llm_model,
api_key=settings.openai_api_key,
temperature=temperature,
streaming=True,
)
temperature :随机性参数
控制模型输出的"随机性"。0 表示每次输出几乎一样,结果最稳定(适合分类、提取、结构化任务);1 表示创意最强但可能飘(适合写作、头脑风暴)。企业助手一般用 0.3~0.7 之间,在准确和灵活之间取平衡。
工厂模式的好处
如果把 ChatOpenAI(...) 的创建代码散落在各个 node 和 chain 里,哪天要换成 Claude,就要改几十个文件。现在统一在这里,换模型只改这一个文件,其余代码零改动。
2.4 lifespan.py — 生命周期钩子
类比 Java:@PostConstruct(启动后执行)+ @PreDestroy(关闭前执行),或者 Spring Boot 的 ApplicationRunner。
在服务启动时做初始化工作(创建数据库表、预热连接池、记录启动日志),在服务关闭时做清理工作(释放连接池、保存状态)。
用到的库:
contextlib.asynccontextmanager:把一个异步生成器函数变成上下文管理器(支持async with语法)
from contextlib import asynccontextmanager
from fastapi import FastAPI
import logging
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("🚀 服务启动,初始化数据库表结构...")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
logger.info("✅ 初始化完成,开始接受请求")
yield # ← 服务运行阶段,在这里"暂停",等待关闭信号
logger.info("👋 收到关闭信号,释放数据库连接池...")
await engine.dispose()
logger.info("✅ 资源释放完毕")
app = FastAPI(lifespan=lifespan)
关键:yield 把函数切成两半
yield 是这里的核心。它把整个函数分成"启动"和"关闭"两个阶段:
yield之前的代码:服务启动时执行一次yield这一行:FastAPI 在这里"暂停"这个函数,开始正常接受 HTTP 请求- 当服务收到关闭信号(
Ctrl+C或docker stop)时,FastAPI 回来继续执行yield之后的代码 yield之后的代码:服务关闭时执行一次
整个过程就像一个"括号":启动是左括号,关闭是右括号,中间夹着整个服务运行期间。
2.5 logging.py — 日志脱敏 + 全局配置
类比 Java:Logback 配置文件 + 自定义 Filter 做日志脱敏。
这个文件干什么的:统一配置全局日志格式(时间、级别、文件名、行号),并在日志输出之前自动替换掉敏感信息(API Key、密码、Token),防止敏感数据泄露到日志文件。
用到的库:
logging:Python 标准库,提供Logger、Handler、Filter等日志组件re:正则表达式标准库,用来匹配和替换敏感字段
import logging
import re
class SensitiveDataFilter(logging.Filter):
PATTERNS = [
(re.compile(r'(api_key[\s":=]+)\S+', re.IGNORECASE), r'\1***'),
(re.compile(r'(password[\s":=]+)\S+', re.IGNORECASE), r'\1***'),
(re.compile(r'(token[\s":=]+)\S+', re.IGNORECASE), r'\1***'),
(re.compile(r'(secret[\s":=]+)\S+', re.IGNORECASE), r'\1***'),
]
def filter(self, record: logging.LogRecord) -> bool:
msg = str(record.getMessage())
for pattern, replacement in self.PATTERNS:
msg = pattern.sub(replacement, msg)
record.msg = msg
record.args = ()
return True
def setup_logging():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s | %(levelname)-8s | %(name)s:%(lineno)d | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
root_logger = logging.getLogger()
root_logger.addFilter(SensitiveDataFilter())
logging.Filter 的工作原理
Python 日志系统在输出每条日志之前,会把这条日志依次传给所有注册的 Filter。
Filter.filter() 方法返回 True 表示"这条日志可以继续输出",返回 False 表示"把这条日志丢弃"。
这里的 SensitiveDataFilter 不丢弃任何日志,只是在 filter() 里把日志内容里的敏感信息替换掉,再允许它继续输出,相当于一个"改写中间件"。
企业项目必须做日志脱敏:如果日志里出现了 api_key=sk-xxx...,运维或者第三方日志平台(ELK、Datadog)的人都能看到。这是严重的安全漏洞。脱敏后日志变成 api_key=***,功能调试不受影响,但敏感信息永远不会出现在任何存储介质上。
2.6 middleware.py — 三合一中间件
类比 Java:Spring 的 Filter(请求日志)+ CorsFilter(跨域)+ @ControllerAdvice(全局异常处理),三个东西合在一个文件。
这个文件干什么的:
- 记录每个请求的方法、路径、响应码、耗时
- 允许前端(localhost:5173)跨域访问后端(localhost:8000)
- 捕获所有未被处理的异常,统一返回 500 错误,防止服务器内部信息泄露给用户
用到的库:
fastapi.middleware.cors.CORSMiddleware:FastAPI 内置的跨域中间件starlette.middleware.base.BaseHTTPMiddleware:所有自定义中间件的基类,继承后重写dispatch方法
import time
import logging
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from starlette.middleware.base import BaseHTTPMiddleware
from app.core.config import get_settings
settings = get_settings()
logger = logging.getLogger(__name__)
def register_middleware(app: FastAPI):
# 1. 跨域中间件
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 2. 请求日志中间件
app.add_middleware(LoggingMiddleware)
# 3. 全局异常兜底
@app.exception_handler(Exception)
async def global_exception_handler(request: Request, exc: Exception):
logger.error(f"未捕获异常 [{request.method} {request.url}]: {exc}", exc_info=True)
return JSONResponse(
status_code=500,
content={"error": "服务器内部错误,请稍后重试"}
)
class LoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
start_time = time.time()
response = await call_next(request)
duration = time.time() - start_time
logger.info(
f"{request.method} {request.url.path} "
f"→ {response.status_code} "
f"({duration * 1000:.1f}ms)"
)
return response
dispatch 方法里的 call_next:放行这个请求,让它继续走到路由处理函数
call_next(request) 是"放行这个请求,让它继续走到路由处理函数"。在它之前可以做"请求前处理",在它之后(拿到 response 之后)可以做"响应后处理"。这就是洋葱模型——中间件层层包裹,请求进来时从外到内,响应出去时从内到外。
为什么要全局异常兜底
如果路由函数抛出了未捕获的异常,FastAPI 默认会返回一个包含详细报错堆栈的响应,这些信息暴露了服务器的内部实现,是安全隐患。全局 exception handler 把所有异常统一拦截,对用户只返回"服务器内部错误",具体错误信息只记录在日志里。
2.7 security.py — 认证安全
类比 Java:Spring Security + BCryptPasswordEncoder + JWT 工具类。
这个文件干什么的:实现两件事——1. 密码的哈希和校验(用户注册/登录)2. JWT Token 的签发和解析(接口鉴权)。
用到的库:
passlib:密码哈希库,支持 bcrypt、argon2 等算法。哈希是单向不可逆的,数据库里永远只存哈希值python-jose:JWT(JSON Web Token)的 Python 实现,用于签发和验证 Token
from datetime import datetime, timedelta
from passlib.context import CryptContext
from jose import jwt, JWTError
from app.core.config import get_settings
settings = get_settings()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain_password: str, hashed_password: str) -> bool:
return pwd_context.verify(plain_password, hashed_password)
def create_access_token(data: dict, expires_delta: timedelta = timedelta(hours=24)) -> str:
payload = data.copy()
payload["exp"] = datetime.utcnow() + expires_delta
return jwt.encode(payload, settings.secret_key, algorithm="HS256")
def decode_access_token(token: str) -> dict:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=["HS256"])
return payload
except JWTError:
return None
密码哈希为什么不可逆
bcrypt 是一种哈希算法,不是加密算法。加密可以解密,哈希不能"反哈希"。用户登录时,是把输入的密码重新哈希一遍,和数据库里的哈希值比较是否一致,而不是把数据库里的哈希值解密出来对比。
这意味着即使数据库被攻击者拿走,他们也只能看到一堆乱码,无法还原用户密码。这是行业铁律:任何系统都不应该能查询到用户的明文密码。
JWT 的结构
JWT 由三部分组成,用 . 连接:header.payload.signature
header:算法信息(base64 编码)payload:携带的数据,比如{"user_id": 123, "exp": 1234567890}(base64 编码,不加密,任何人都能解码看到内容)signature:用secret_key对前两部分做签名,防止篡改
关键点:payload 不加密,所以不能把密码放进去。JWT 的安全性在于"签名不可伪造"——攻击者无法在不知道 secret_key 的情况下造一个合法的 Token。
2.8 streaming.py — SSE 流式输出
把 LLM 的流式输出(一个个 token 依次产生)封装成 SSE(Server-Sent Events)格式,实时推送给前端,实现"打字机效果"。
用到的库:
fastapi.responses.StreamingResponse:FastAPI 的流式响应类,接收一个异步生成器,把它的每次 yield 内容逐步发给客户端
import json
from typing import AsyncGenerator
from fastapi.responses import StreamingResponse
async def generate_sse_stream(llm_generator: AsyncGenerator) -> AsyncGenerator[str, None]:
try:
async for chunk in llm_generator:
content = chunk.content if hasattr(chunk, "content") else str(chunk)
if content:
data = json.dumps({"type": "token", "content": content}, ensure_ascii=False)
yield f"data: {data}\n\n"
yield 'data: {"type": "done"}\n\n'
except Exception as e:
error_data = json.dumps({"type": "error", "message": str(e)}, ensure_ascii=False)
yield f"data: {error_data}\n\n"
def create_streaming_response(llm_generator: AsyncGenerator) -> StreamingResponse:
return StreamingResponse(
generate_sse_stream(llm_generator),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
}
)
为什么要流式输出
大模型生成一段 500 字的回答可能需要 5~10 秒。如果等全部生成完再返回,用户会看到一个转圈 10 秒然后文字突然全部出现,体验极差。流式输出是每生成一个 token(约一两个字)就立刻推送,用户看到文字实时"打"出来,感知等待时间大幅缩短。
SSE 协议格式
SSE 是 HTTP 的一种特殊用法,服务端保持连接不断开,持续往里写数据。浏览器用 EventSource 原生支持这个协议。
SSE 每条消息的格式固定是:data: <内容>\n\n(两个换行是消息分隔符,缺一个不行)。
X-Accel-Buffering: no
这个 Header 是告诉 Nginx(如果有的话)不要缓冲这个响应。默认情况下 Nginx 会把响应内容积攒一批再转发,这会导致流式输出变成"批量输出",失去效果。加上这个 Header 之后,Nginx 收到多少立刻转发多少。
三、nodes 文件夹 — LangGraph 节点层
3.1 LangGraph 的核心概念
LangGraph 是什么:一个用于构建有状态、多步骤 AI 应用的框架。把一个复杂的 AI 对话任务拆分成多个小步骤(nodes),用有向图连接起来,控制执行流程。
核心概念:State(状态/共享黑板)
**整个图里所有 nodes 共享同一个 State 字典。每个 node 接收完整的 State,只修改自己负责的字段,返回修改后的部分,LangGraph 负责合并。
**
from typing import TypedDict, Annotated
from operator import add
class AssistantState(TypedDict):
user_input: str # 用户的原始输入(由入口节点写入,其他节点只读)
intent: str # 意图识别结果(由 intent_router 写入)
retrieved_docs: list # 检索到的文档(由 knowledge_rag 写入)
filtered_docs: list # 过滤后的文档(由 grader 写入)
generation: str # 模型生成的回答(由 generate 写入)
hallucination_score: str # 幻觉检测结果(由 hallucination_check 写入)
messages: Annotated[list, add] # 对话历史(每个节点可以追加,add 操作符表示"追加"而非"覆盖")
State 就像一张"工单",每道工序(node)在工单上填写自己负责的部分,下一道工序接过来继续填,和前端state类似。
核心概念:Graph(有向图)
用户输入
↓
intent_router(意图识别)
↓ 根据 intent 字段分叉
├→ knowledge_rag(知识库问答)→ grader → generate → hallucination_check
├→ salary_query(薪资查询)→ generate
├→ travel_booking(差旅预订)→ generate
├→ web_search(联网搜索)→ generate
└→ personal_info(个人信息)→ generate
↓
输出给用户
3.2 intent_router_node.py — 意图路由(图的入口大脑)
类比 Java:Spring MVC 的 DispatcherServlet,接到请求后判断该交给哪个 Controller 处理。
这个文件接收用户的输入,让 LLM 判断用户的意图是什么(查知识库?查薪资?订机票?联网搜索?),然后在 State 里写入 intent 字段。LangGraph 根据这个字段决定下一步走哪条路。
from app.core.llm import get_chat_model
from app.nodes.state import AssistantState
llm = get_chat_model(temperature=0)
INTENT_PROMPT = """
你是一个意图分类器。根据用户输入,判断其意图属于以下哪一类:
- knowledge_qa:询问公司制度、规章、产品、业务相关知识
- salary_query:查询薪资、绩效、工资单
- travel_booking:预订机票、酒店、报销差旅
- web_search:需要查询最新的互联网信息
- personal_info:查询个人信息、修改个人资料
- general_chat:闲聊或不属于以上类别的对话
只返回类别名称,不要解释。
用户输入:{user_input}
"""
async def intent_router_node(state: AssistantState) -> dict:
prompt = INTENT_PROMPT.format(user_input=state["user_input"])
response = await llm.ainvoke(prompt)
intent = response.content.strip().lower()
# 防止模型返回不在预期范围内的值
valid_intents = {"knowledge_qa", "salary_query", "travel_booking", "web_search", "personal_info", "general_chat"}
if intent not in valid_intents:
intent = "general_chat"
return {"intent": intent}
def route_by_intent(state: AssistantState) -> str:
return state["intent"]
temperature=0 — 为什么分类任务要用 0
意图分类是一个"有明确正确答案"的任务,我们不需要模型有任何创意,只需要它稳定地输出正确类别。temperature=0 让模型每次都选择概率最高的 token,输出最稳定。如果用 0.7,模型可能今天说 knowledge_qa,明天说 knowledge-qa,导致路由失败。
防御性校验
LLM 不是程序,不保证输出严格符合格式。哪怕 prompt 写得再清楚,模型偶尔还是会返回 "这是一个知识查询" 而不是 "knowledge_qa"。所以必须做校验:如果返回值不在预期集合里,兜底到 general_chat,而不是让程序崩溃。
route_by_intent 是条件边函数
这个函数不是 node,而是 LangGraph 的"条件边"。它告诉 LangGraph:执行完 intent_router_node 之后,根据 State 里的 intent 字段,决定下一个执行哪个 node。
# 在 graph 定义时这样用:
graph.add_conditional_edges(
"intent_router", # 从这个节点出发
route_by_intent, # 用这个函数决定走哪条边
{ # 映射表:函数返回值 → 下一个节点名
"knowledge_qa": "knowledge_rag",
"salary_query": "salary_query",
"travel_booking": "travel_booking",
"web_search": "web_search",
"personal_info": "personal_info",
"general_chat": "generate",
}
)
3.3 knowledge_rag_node.py — 企业知识库 RAG 检索
这个文件干什么的:RAG(Retrieval-Augmented Generation,检索增强生成)的核心节点。用户提问时,先去企业知识库向量数据库里检索最相关的文档片段,把这些片段作为"参考资料"提供给 LLM,让 LLM 基于真实资料回答,而不是靠自己"背诵"的知识(背诵的知识可能是错的,也可能是过时的)。
为什么要 RAG 而不直接问 LLM:LLM 只知道训练数据里的内容,不知道你们公司的规章制度、产品文档、内部流程。而且 LLM 会"幻觉"——对不知道的事情也会自信地给一个听起来合理但完全错误的答案。RAG 给 LLM 提供了"参考书",把回答锚定在真实文档上。
from langchain_openai import OpenAIEmbeddings
from app.core.config import get_settings
from app.nodes.state import AssistantState
settings = get_settings()
embeddings = OpenAIEmbeddings(api_key=settings.openai_api_key)
async def knowledge_rag_node(state: AssistantState, vectorstore) -> dict:
user_input = state["user_input"]
# 在向量库里找最相似的 4 条文档片段
docs = await vectorstore.asimilarity_search(user_input, k=4)
return {"retrieved_docs": docs}
向量检索是怎么工作的
向量检索是语义相似度搜索。
- 离线阶段(建库时):把企业所有文档切成小片段,每个片段用 Embedding 模型转成一个高维数字向量(比如 1536 维),存入向量数据库
- 在线阶段(用户提问时):把用户的问题也转成向量,在向量库里找"方向最接近"的 k 个片段返回
比如用户问"年假怎么申请",即使文档里写的是"带薪休假申请流程",向量检索也能找到,因为语义相似,而关键词搜索会找不到。
检索条数k=4 的取舍
检索条数越多,提供的参考信息越全面,但 LLM 的 context window(能处理的文本长度)是有限的,而且塞太多无关信息反而会干扰 LLM 的判断。k=4 是一个常见的起点,实际项目中会根据文档平均长度和模型 context 大小调整。
3.4 grader_node.py — 文档相关性评分,对每条检索文档做二次判断,把不相关的过滤掉
这个文件干什么的:RAG 检索出来的 k 条文档不一定全都和问题相关(向量相似度高不等于真的有用)。grader_node 对每条检索文档做二次判断:这条文档真的和用户问题相关吗?把不相关的过滤掉,只把真正有用的文档传给后续节点。
from app.core.llm import get_chat_model
from app.nodes.state import AssistantState
llm = get_chat_model(temperature=0)
GRADER_PROMPT = """
你是一个文档相关性评估器。
用户问题:{question}
文档内容:
{document}
这份文档对回答用户问题是否有帮助?只回答 yes 或 no。
"""
async def grader_node(state: AssistantState) -> dict:
question = state["user_input"]
retrieved_docs = state["retrieved_docs"]
filtered_docs = []
for doc in retrieved_docs:
prompt = GRADER_PROMPT.format(
question=question,
document=doc.page_content
)
response = await llm.ainvoke(prompt)
grade = response.content.strip().lower()
if grade == "yes":
filtered_docs.append(doc)
return {"filtered_docs": filtered_docs}
为什么要做二次过滤
向量相似度是数学距离,不是人类理解的"相关性"。极端例子:用户问"请假流程",可能检索到一篇关于"出勤记录"的文档,向量距离很近(都属于 HR 领域),但对回答这个问题没帮助。二次用 LLM 评估,能过滤掉这种"数学上相近但实际无用"的干扰文档,提升最终回答质量。
3.5 hallucination_check_node.py — 幻觉检测
模型生成回答之后,用另一个 LLM 评判:这个回答有没有超出检索文档的范围、有没有编造事实?如果检测到幻觉,可以让图回退重新检索或者拒绝输出,保证企业助手的可信度。
from app.core.llm import get_chat_model
from app.nodes.state import AssistantState
llm = get_chat_model(temperature=0)
HALLUCINATION_PROMPT = """
你是一个事实核查员。
参考文档:
{documents}
模型生成的回答:
{generation}
这个回答中的所有事实性陈述,是否都有参考文档的支撑?没有文档依据的内容属于幻觉。
只回答 yes(无幻觉)或 no(存在幻觉)。
"""
async def hallucination_check_node(state: AssistantState) -> dict:
docs_content = "\n\n".join(
[doc.page_content for doc in state.get("filtered_docs", [])]
)
prompt = HALLUCINATION_PROMPT.format(
documents=docs_content,
generation=state["generation"]
)
response = await llm.ainvoke(prompt)
score = response.content.strip().lower()
return {"hallucination_score": score}
def route_after_hallucination_check(state: AssistantState) -> str:
if state["hallucination_score"] == "yes":
return "end" # 没有幻觉,正常结束
else:
return "knowledge_rag" # 有幻觉,回退重新检索
关键逻辑:用 LLM 检查 LLM
这是"自我审查"机制,也叫 LLM-as-judge(用 LLM 作为评判者)。用一个低温度、高精度的模型来评判生成模型的输出质量。
理论上可以用同一个模型,但更好的实践是用不同的模型或不同的 prompt 风格,避免"自我辩护"——让一个模型评价自己生成的内容,它可能倾向于认为自己说的是对的。
条件边:幻觉检测的分叉逻辑:如果检测到幻觉,图会回退到 knowledge_rag 重新检索,最多重试 2~3 次。如果多次还是有幻觉,可以选择输出"根据现有资料无法确定"而不是给出错误答案。
3.6 其他 nodes 一览
| 节点 | 功能 | 关键逻辑 |
|---|---|---|
auth_check_node.py | 验证用户 JWT Token 是否有效、是否有权限访问 | 在图的最开头做鉴权,无效 Token 直接 END,不进入后续节点 |
generate_node.py | 把检索到的文档 + 用户问题组合成 prompt,调用 LLM 生成最终回答 | 这是"真正生成回答"的节点,前面所有节点都是为它做准备 |
personal_info_node.py | 查询/更新用户的个人信息(姓名、部门、联系方式等) | 直接查数据库,不走 LLM,纯 CRUD |
planner_node.py | 把复杂任务拆分成多个子任务,规划执行步骤 | 用 LLM 做任务规划,输出结构化的执行计划 |
salary_query_node.py | 查询用户的薪资、绩效数据 | 安全敏感节点,会额外检查用户只能查自己的数据 |
travel_booking_node.py | 处理差旅预订请求(机票、酒店、用车) | 可能调用外部差旅系统 API,是工具调用的典型例子 |
web_search_node.py | 用搜索引擎搜索最新互联网信息 | 解决 LLM 知识截止日期问题,用 Tavily 或 SerpAPI |
四、Python 核心语法速查 — 对照 Java/TS 理解
4.1 @classmethod — 动态绑定的类方法
@classmethod 和 Java 的 static 最大的区别,在于它的第一个参数 cls 是运行时动态绑定的,而不是编译期固定的。
cls 代表"调用这个方法的类是谁,cls 就是谁"。这让子类可以正确继承类方法,而不会创建出父类的实例。
class Food:
def __init__(self, name: str):
self.name = name
@classmethod
def create(cls, name: str):
# cls 是动态的:谁调用这个方法,cls 就等于谁
return cls(name)
class Bun(Food):
pass # pass 是 Python 的占位符,表示"这里什么都不写",防止空类语法报错
class Noodle(Food):
pass
food = Food.create("食物") # cls = Food,返回 Food 实例
bun = Bun.create("肉包") # cls = Bun,返回 Bun 实例(不是 Food!)
noodle = Noodle.create("面") # cls = Noodle,返回 Noodle 实例
Java 的 static 是编译期写死的,子类调用父类的 static 工厂方法,里面 new 的永远是父类。Python 的 @classmethod是运行时动态的,子类调用时 cls 自动变成子类,体现了多态。
4.2 Pydantic BaseModel — 数据校验利器
Pydantic 是 FastAPI 和 LangChain 的基石。可以把它理解为:TypeScript 的 interface(类型定义)+ Java 的 @Valid 注解(校验)+ Jackson(序列化/反序列化),三合一,而且还会自动做类型转换。
反序列化=》1. 前端传 JSON → Jackson 自动反序列化 → Controller 直接接收对象(把 JSON 字符串 → 还原成 Java 对象
序列化=》2. Controller 返回对象 → Jackson 自动序列化 → 前端收到 JSON(把 Java 对象 → 变成 JSON 字符串)
from pydantic import BaseModel, Field
from typing import Optional
class UserCreateRequest(BaseModel):
username: str
age: int
email: str = Field(..., pattern=r"^[\w.-]+@[\w.-]+.\w+$")
department: Optional[str] = None # Optional = 可以传 None,也可以不传
# 自动类型转换:传字符串 "25" 自动转成 int 25
user = UserCreateRequest(username="张三", age="25", email="zhangsan@company.com")
print(user.age) # 25,类型是 int,不是字符串
# 类型错误直接抛 ValidationError,不需要手动校验
# UserCreateRequest(username="李四", age="二十五", email="...") # 报错!
4.3 装饰器 @xxx — Python 版 AOP
相当于 Java 的 AOP 切面,在不改动原函数代码的前提下,在它的前后插入额外逻辑。
# 手写一个计时装饰器,理解本质
def timer(func):
async def wrapper(*args, **kwargs):
import time
start = time.time()
result = await func(*args, **kwargs) # 调用原函数
duration = (time.time() - start) * 1000
print(f"{func.__name__} 耗时 {duration:.1f}ms")
return result
return wrapper
# @timer 等价于:intent_router_node = timer(intent_router_node)
@timer
async def intent_router_node(state):
...
本项目里的装饰器对照表:
| 装饰器 | 作用 | Java 类比 |
|---|---|---|
@lru_cache | 缓存函数返回值 | 手写单例 |
@classmethod | 类方法,cls 动态绑定 | static(但更强) |
@app.get("/path") | 注册 HTTP 路由 | @GetMapping("/path") |
@asynccontextmanager | 把函数变成 async with 支持的上下文管理器 | 实现 AutoCloseable |
@app.exception_handler(Exception) | 全局异常处理 | @ControllerAdvice |
五、总结
1. 关注点分离:core 处理所有横切关注点(配置、安全、日志),业务代码完全不需要关心这些;nodes 只关心单步 AI 逻辑;agents 只关心流程编排。每层职责清晰,改一处不影响其他层。
2. 可观测性优先:middleware.py 记录每个请求,logging.py 做脱敏,lifespan.py 记录启停事件,streaming.py 实时推送进度。这不是"锦上添花",是企业级系统的基本要求——出了问题要能查到根因。
3. 防御性编程:security.py 的密码哈希、middleware.py 的异常兜底、intent_router_node.py 的无效意图兜底、hallucination_check_node.py 的幻觉回退——每一层都假设"下一层可能出错",做好防护。这是地基稳不稳的关键。