FastAPI 工程实践:LLM 应用的 API 设计之道

一、LLM 应用的 API 设计挑战

LLM 应用的 API 与传统的 CRUD API 有本质区别:

维度 CRUD API LLM API
响应时间 <100ms 2s-30s
资源初始化 数据库连接池 模型加载 + 向量索引加载
状态管理 数据库持久化 长对话上下文 + Agent 中间状态
错误模式 参数校验失败 LLM 调用超时 / Token 超限 / 幻觉
流式需求 罕见 几乎必需
并发模式 大量短连接 少量长连接

本文基于 LexRAG 和 HeritageMind 两个项目的 FastAPI 实践,系统讲解 LLM 应用的 API 工程化设计。


二、项目结构约定

两个项目都遵循相同的模块组织方式:

bash 复制代码
project/
├── api.py                 # FastAPI 应用入口
├── config.py              # Pydantic Settings 配置
├── main.py                # Streamlit 前端(独立服务)
│
├── src/
│   ├── agent/             # Agent 与工作流
│   ├── retrieval/         # 检索模块
│   ├── graph/             # 知识图谱
│   └── ...
│
├── data/                  # 数据文件
└── tests/                 # 测试

核心原则:api.py 只做路由和请求响应处理 ,业务逻辑全部在 src/ 中。这保持了 api.py 的简洁(约 400 行),方便维护和测试。


三、全局组件管理与 Lifespan

3.1 问题:模型不能每个请求都加载一次

python 复制代码
# ❌ 致命错误:每个请求重新加载模型
@app.post("/query")
async def query(request: QueryRequest):
    embeddings = BGEEmbeddings()          # 加载 1.3GB 模型!
    retriever = LawRetriever(embeddings)  # 加载向量索引
    workflow = LegalRAGWorkflow(...)
    return workflow.query(request.question)
# 每个请求耗时 30s+,内存爆炸

3.2 解决方案:Lifespan + 全局单例

ini 复制代码
from contextlib import asynccontextmanager
from fastapi import FastAPI
​
# ── 全局组件单例 ──
components: dict = {}
​
@asynccontextmanager
async def lifespan(app: FastAPI):
    """
    应用生命周期管理:
    - yield 之前:启动时初始化(只执行一次)
    - yield 之后:关闭时清理
    """
    # ======== 启动阶段 ========
    logger.info("Initializing components...")
​
    try:
        # 1. 加载配置
        settings = Settings()
​
        # 2. 初始化嵌入模型(延迟加载:首次使用时才加载模型文件)
        embeddings = EmbeddingManager(
            model_name=settings.embedding_model
        )
​
        # 3. 初始化检索器(需要嵌入模型和向量索引)
        law_indexer = LawIndexer(embeddings, persist_dir=settings.chroma_persist_directory)
        law_retriever = LawRetriever(law_indexer)
        bm25_retriever = BM25Retriever() if settings.bm25_enabled else None
        reranker = CrossEncoderReranker() if settings.reranker_enabled else None
​
        # 4. 初始化知识图谱
        law_graph = LawGraph.from_json("data/law_graph.json")
​
        # 5. 初始化工作流
        workflow = LegalRAGWorkflow(
            llm=ChatOpenAI(model=settings.deepseek_model, ...),
            retriever=law_retriever,
            bm25=bm25_retriever,
            reranker=reranker,
            graph=law_graph,
        )
​
        # 6. 存入全局容器
        components["settings"] = settings
        components["workflow"] = workflow
        components["law_indexer"] = law_indexer
        components["law_graph"] = law_graph
​
        logger.info("All components initialized successfully")
​
    except Exception as e:
        logger.error(f"Initialization failed: {e}")
        raise
​
    # ======== 运行阶段 ========
    yield  # ← 应用在这里运行
​
    # ======== 关闭阶段 ========
    logger.info("Shutting down...")
    # ChromaDB 的持久化由各自的 client.close() 处理,无需额外操作
​
# ── 创建应用 ──
app = FastAPI(
    title="LexRAG API",
    description="法律 RAG 知识问答系统",
    version="1.0.0",
    lifespan=lifespan,
)

关键设计决策:

python 复制代码
# ✅ 正确:通过 lifespan 初始化,存入全局字典
components["workflow"] = LegalRAGWorkflow(...)
​
# ❌ 错误:使用模块级变量(无法保证初始化顺序)
workflow = None  # 可能在 lifespan 之前被访问
​
# ❌ 错误:使用 Depends() 注入(每次请求都重新创建)
async def get_workflow():
    return LegalRAGWorkflow(...)  # 每个请求一个实例

3.3 延迟加载模式

对于不需要在启动时就用的组件,使用延迟加载减少启动时间:

python 复制代码
class EmbeddingManager:
    def __init__(self, model_name: str):
        self.model_name = model_name
        self._model = None  # 延迟加载
​
    @property
    def model(self):
        if self._model is None:
            logger.info(f"Loading embedding model: {self.model_name}")
            self._model = HuggingFaceEmbeddings(
                model_name=self.model_name,
                model_kwargs={"device": "cpu"},
                encode_kwargs={"normalize_embeddings": True}
            )
        return self._model
​
    def embed(self, texts: List[str]) -> List[List[float]]:
        return self.model.embed_documents(texts)

延迟加载意味着 API 容器可以快速启动并响应健康检查,模型在实际查询到来时才加载。


四、端点设计

4.1 完整的端点地图

以 LexRAG 为例,API 暴露出 7 个端点:

python 复制代码
# ============================================================
# 系统端点
# ============================================================
​
@app.get("/")
async def root():
    """系统信息"""
    return {
        "message": "LexRAG - 法律知识增强检索系统",
        "version": "1.0.0",
        "docs": "/docs",
    }
​
​
@app.get("/health")
async def health_check():
    """健康检查 ------ 供 Docker healthcheck 和负载均衡器使用"""
    return {
        "status": "healthy",
        "timestamp": datetime.now().isoformat(),
        "version": "1.0.0",
        "components": {
            "workflow": "ok" if components.get("workflow") else "unavailable",
            "law_indexer": "ok" if components.get("law_indexer") else "unavailable",
            "law_graph": "ok" if components.get("law_graph") else "unavailable",
        }
    }
​
​
# ============================================================
# 核心业务端点
# ============================================================
​
@app.post("/query", response_model=QueryResponse)
async def query(request: QueryRequest):
    """法律问答 ------ 核心端点"""
    ...
​
​
@app.post("/upload")
async def upload_document(request: UploadRequest):
    """上传法律文档并索引"""
    ...
​
​
# ============================================================
# 知识图谱端点
# ============================================================
​
@app.get("/graph")
async def get_graph_stats():
    """图谱统计信息"""
    ...
​
​
@app.post("/graph/query")
async def query_graph(request: GraphQueryRequest):
    """图谱查询(引用链、最短路径)"""
    ...
​
​
# ============================================================
# 验证与追溯端点
# ============================================================
​
@app.get("/verify/{query_id}")
async def get_verification(query_id: str):
    """获取某次查询的验证结果"""
    ...
​
​
@app.get("/intent/{intent_type}")
async def get_intent_examples(intent_type: str):
    """获取某意图类型的示例问题"""
    ...

4.2 端点的 RESTful 命名原则

原则 示例 说明
GET 读、POST 写 GET /graph vs POST /graph/query Graph 查询有副作用(计算),用 POST
资源名用名词 /query 而非 /ask 资源导向
子资源用路径嵌套 /verify/{query_id} 清晰表达层级关系
批量操作做特殊标注 /graph 返回全量,/graph/query 返回特定查询

五、请求与响应模型

5.1 Pydantic 模型分层

python 复制代码
from pydantic import BaseModel, Field
from typing import Optional, List, Dict
​
​
# ── 请求模型 ──
​
class QueryRequest(BaseModel):
    """查询请求"""
    query: str = Field(
        ...,
        min_length=1,
        max_length=2000,
        description="法律问题文本",
        example="合同在什么情况下可以撤销?"
    )
    session_id: Optional[str] = Field(
        default=None,
        description="会话 ID,用于多轮对话追溯"
    )
    config: Optional[QueryConfig] = Field(
        default=None,
        description="查询配置覆盖"
    )
​
    class Config:
        json_schema_extra = {
            "example": {
                "query": "合同在什么情况下可以撤销?",
                "session_id": "sess_abc123"
            }
        }
​
​
class QueryConfig(BaseModel):
    """可选的查询参数覆盖"""
    top_k: Optional[int] = Field(default=None, ge=1, le=20)
    temperature: Optional[float] = Field(default=None, ge=0, le=1)
    enable_verification: bool = True
​
​
class UploadRequest(BaseModel):
    """文档上传请求"""
    content: str = Field(..., min_length=1, description="法律文档全文")
    law_name: str = Field(..., description="法律名称,如'中华人民共和国民法典'")
    version: Optional[str] = Field(default="1.0", description="版本号")
​
​
# ── 响应模型 ──
​
class QueryResponse(BaseModel):
    """查询响应"""
    query_id: str
    query: str
    answer: str
    intent: str
    citations: List[CitationInfo] = []
    verification_status: str  # "PASS" | "FAIL" | "SKIPPED"
    issues: List[str] = []
    sub_questions: List[str] = []
    metadata: Dict[str, any] = {}
​
​
class CitationInfo(BaseModel):
    """引用信息"""
    article_id: str          # "民法典第157条"
    citation_text: str       # 回答中引用的内容
    is_verified: bool        # 是否通过验证
    original_text: str = ""  # 法条原文
​
​
class HealthResponse(BaseModel):
    """健康检查响应"""
    status: str
    timestamp: str
    version: str
    components: Dict[str, str]

5.2 Field 验证的最佳实践

ini 复制代码
# ❌ 偷懒的写法
class BadRequest(BaseModel):
    query: str  # 没有约束
    top_k: int  # 没有范围限制
​
# ✅ 工程化的写法
class GoodRequest(BaseModel):
    query: str = Field(
        ...,
        min_length=1,              # 不能为空
        max_length=2000,           # 防止超长输入
        description="法律问题文本",
        example="合同在什么情况下可以撤销?"
    )
    top_k: int = Field(
        default=5,
        ge=1,                      # 至少返回1条
        le=20,                     # 最多返回20条
        description="返回的检索结果数量"
    )

5.3 统一响应格式

两个项目都在回答中附注来源:

python 复制代码
# HeritageMind 的来源标注
def add_source_annotations(content: str, agent_names: List[str]) -> str:
    sources = "\n\n---\n"
    sources += "📚 **参考来源**:\n"
    for name in agent_names:
        sources += f"- {AGENT_LABELS[name]}\n"
    return content + sources
​
# AGENT_LABELS = {
#     "craft_expert": "🏺 技艺知识专家",
#     "history_expert": "📜 历史文化专家",
#     "heritage_expert": "🌱 传承现状专家",
# }

六、错误处理与异常体系

6.1 分层异常设计

python 复制代码
# ── 自定义异常层次 ──
​
class LexRAGException(Exception):
    """基础异常"""
    def __init__(self, message: str, detail: dict = None):
        self.message = message
        self.detail = detail or {}
​
​
class RetrievalException(LexRAGException):
    """检索阶段异常"""
    pass
​
​
class LLMException(LexRAGException):
    """LLM 调用异常"""
    pass
​
​
class VerificationException(LexRAGException):
    """验证阶段异常"""
    pass
​
​
class DocumentParseException(LexRAGException):
    """文档解析异常"""
    pass

6.2 全局异常处理器

python 复制代码
from fastapi import Request
from fastapi.responses import JSONResponse
​
@app.exception_handler(LexRAGException)
async def lexrag_exception_handler(request: Request, exc: LexRAGException):
    """LexRAG 领域异常处理"""
    return JSONResponse(
        status_code=500,
        content={
            "error": exc.message,
            "error_type": type(exc).__name__,
            "detail": exc.detail,
        }
    )
​
​
@app.exception_handler(LLMException)
async def llm_exception_handler(request: Request, exc: LLMException):
    """LLM 异常 ------ 可能是 API Key 问题、超时、限流"""
    status_code = 503 if "timeout" in str(exc).lower() else 500
    return JSONResponse(
        status_code=status_code,
        content={
            "error": exc.message,
            "error_type": "LLMException",
            "retryable": status_code == 503,
        }
    )
​
​
@app.exception_handler(Exception)
async def general_exception_handler(request: Request, exc: Exception):
    """兜底异常处理"""
    logger.error(f"Unhandled exception: {exc}", exc_info=True)
    return JSONResponse(
        status_code=500,
        content={
            "error": "Internal server error",
            "error_type": type(exc).__name__,
        }
    )

6.3 业务层优雅降级

python 复制代码
@app.post("/query")
async def query(request: QueryRequest):
    workflow = components.get("workflow")
    if not workflow:
        raise HTTPException(status_code=503, detail="Workflow not initialized")
​
    try:
        result = workflow.query(request.query)
    except LLMException as e:
        # LLM 调用失败时,返回原始检索结果(降级但不崩溃)
        logger.warning(f"LLM call failed, returning retrieval-only results: {e}")
        return {
            "query_id": str(uuid.uuid4()),
            "query": request.query,
            "answer": "抱歉,AI 推理服务暂时不可用。以下是检索到的相关法条:\n\n"
                      + self._format_retrieval_results(e.detail.get("documents", [])),
            "status": "degraded",
            "error": str(e),
        }
    except Exception as e:
        logger.error(f"Query failed: {e}", exc_info=True)
        raise HTTPException(status_code=500, detail=str(e))
​
    return result

七、请求生命周期追踪

7.1 请求 ID 贯穿

python 复制代码
import uuid
import time
from fastapi import Request
​
@app.middleware("http")
async def request_tracking_middleware(request: Request, call_next):
    """为每个请求注入 request_id,记录耗时和状态"""
    request_id = str(uuid.uuid4())[:8]
    start_time = time.time()
​
    # 注入到请求状态
    request.state.request_id = request_id
​
    # 记录请求
    logger.info(f"[{request_id}] {request.method} {request.url.path}")
​
    # 处理请求
    response = await call_next(request)
​
    # 记录响应
    duration = (time.time() - start_time) * 1000
    response.headers["X-Request-ID"] = request_id
    logger.info(
        f"[{request_id}] {response.status_code} "
        f"({duration:.0f}ms)"
    )
​
    return response

7.2 查询结果的内存存储

python 复制代码
# api.py
query_results: Dict[str, QueryResponse] = {}  # 查询结果缓存
​
@app.post("/query", response_model=QueryResponse)
async def query(request: QueryRequest):
    result = workflow.query(request.query)
    query_results[result.query_id] = result  # 存储供后续验证追溯
    return result
​
​
@app.get("/verify/{query_id}")
async def get_verification(query_id: str):
    """根据 query_id 追溯验证结果"""
    result = query_results.get(query_id)
    if not result:
        raise HTTPException(status_code=404, detail="Query not found")
    return {
        "query_id": result.query_id,
        "verification_status": result.verification_status,
        "citations": result.citations,
        "issues": result.issues,
    }

设计说明:LexRAG 使用内存字典存储查询结果(非数据库)。这是有意的简化------法律问答的查询历史不需要跨会话持久化,内存存储足够且避免了引入数据库依赖。


八、CORS 与安全

8.1 CORS 配置

ini 复制代码
from fastapi.middleware.cors import CORSMiddleware
​
app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],              # 开发阶段:允许所有来源
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

生产环境建议

ini 复制代码
# 生产环境:精确配置
app.add_middleware(
    CORSMiddleware,
    allow_origins=[
        "http://localhost:8501",      # Streamlit 前端
        "https://your-domain.com",    # 生产域名
    ],
    allow_credentials=True,
    allow_methods=["GET", "POST"],
    allow_headers=["Content-Type", "Authorization"],
)

8.2 API Key 保护

ini 复制代码
# config.py
class Settings(BaseSettings):
    deepseek_api_key: str = Field(
        ...,
        env="DEEPSEEK_API_KEY",    # 从环境变量读取,永不硬编码
    )
​
    class Config:
        env_file = ".env"
        env_file_encoding = "utf-8"
        # .env 文件不应提交到 git
bash 复制代码
# .gitignore
.env          # 包含 API Key,绝对不能提交
.env.example  # 可以提交(只含占位符,不含真实 Key)

九、测试

9.1 API 端点测试

python 复制代码
from fastapi.testclient import TestClient
from api import app
​
client = TestClient(app)
​
​
def test_root():
    response = client.get("/")
    assert response.status_code == 200
    data = response.json()
    assert "version" in data
​
​
def test_health():
    response = client.get("/health")
    assert response.status_code == 200
    assert response.json()["status"] == "healthy"
​
​
def test_query_empty():
    """空查询应返回 422(Pydantic 验证失败)"""
    response = client.post("/query", json={"query": ""})
    assert response.status_code == 422
​
​
def test_query_success():
    """正常查询(需要 mock LLM)"""
    with patch("src.agent.workflow.LegalRAGWorkflow.query") as mock_query:
        mock_query.return_value = QueryResponse(
            query_id="test-001",
            query="合同成立的条件?",
            answer="根据民法典第XXX条...",
            verification_status="PASS",
        )
        response = client.post("/query", json={
            "query": "合同成立的条件?"
        })
        assert response.status_code == 200
        assert response.json()["verification_status"] == "PASS"

9.2 使用 TestClient + Mock LLM

python 复制代码
@pytest.fixture
def mock_llm():
    """Mock DeepSeek LLM 调用,避免测试消耗 API 配额"""
    with patch("langchain_openai.ChatOpenAI.invoke") as mock:
        mock.return_value = AIMessage(content="这是 mock 的法律回答...")
        yield mock

十、自动生成的 API 文档

FastAPI 的一大优势是自动生成 OpenAPI 文档:

bash 复制代码
启动后访问:
  http://localhost:8000/docs      # Swagger UI(交互式)
  http://localhost:8000/redoc     # ReDoc(美观的文档页面)

Pydantic 的 Field 描述和 example 会自动反映在文档中:

ini 复制代码
class QueryRequest(BaseModel):
    query: str = Field(
        ...,
        description="法律问题文本",
        example="合同在什么情况下可以撤销?"
    )

这会在 Swagger UI 中生成带有示例值的输入框,降低了 API 的使用门槛。


十一、两个项目的端点对比

端点 LexRAG HeritageMind
GET / 系统信息 系统信息
GET /health 健康检查 健康检查
POST /query 法律问答 非遗问答
POST /query/simple -- 简化表单提交
POST /upload 上传法律文档 上传非遗文档
GET /graph 图谱统计 图谱统计
GET /graph/visualize -- 交互式图谱 HTML
GET /graph/subgraph/{name} -- 技艺子图
POST /graph/query 图谱查询 --
GET /verify/{query_id} 验证追溯 --
GET /intent/{type} 意图示例 --
GET /gap-report -- 知识缺口报告
POST /switch-profile -- 切换用户画像
GET /crafts -- 支持的技艺列表
GET /profiles -- 可用的用户画像
GET /documents/summary -- 文档库摘要

两个项目都遵循 "最少可用端点" 原则:每个端点解决一个明确的需求,没有冗余。


十二、设计原则总结

原则 1:Lifespan 管理重量级资源

不要让每个请求重新加载模型。Lifespan 是 FastAPI 提供的正确方式。

原则 2:全局组件字典优于依赖注入

对于 LLM 应用中的重量级单例,全局字典比 FastAPI 的 Depends() 依赖注入更合适------后者会为每个请求调用工厂函数,这在传统应用中无所谓(只是创建一个数据库连接),但在 LLM 应用中会导致重复加载模型。

原则 3:异常分层次,降级保可用

复制代码
LLMException → 降级返回检索结果
RetrievalException → 降级返回空结果 + 提示
UnknownException → 500 + 日志告警

原则 4:请求可追溯

每个请求有唯一 ID,关键中间产出(如查询结果、验证状态)可后续查询。

原则 5:Pydantic Field 要有约束和示例

min_lengthmax_lengthgeleexample------这些不是多余的装饰,它们是 API 的文档、验证和安全边界。

原则 6:自动文档是不可浪费的优势

FastAPI 的 /docs 是免费获得的。把 Field 的 descriptionexample 写好,API 的使用门槛就降低了一半。

相关推荐
Java面试题总结12 小时前
Go 语言大白话入门 6 - 判断与循环
开发语言·后端·golang
hsg7713 小时前
简述:Rust、GeoRust、自主研发GIS平台
开发语言·后端·rust
谭光志20 小时前
Vibe Coding 时代,程序员该何去何从
前端·后端·ai编程
GoGeekBaird21 小时前
我用 BeeWeave 跑完了一次完整写作闭环
后端·github
仿生狮子1 天前
别再说“全栈”了,AI 时代团队只认这 5 种人
前端·人工智能·后端
Reart1 天前
Leetcode 213.打家劫舍2(内含闲谈,打劫真是技术活,好题,716)
后端·算法
触底反弹1 天前
🔥 RAG 到底是怎么工作的?掰开揉碎了给你讲明白!
javascript·人工智能·后端
techdashen1 天前
Go 1.26 新增 `bytes.Buffer.Peek`:只看数据,不移动读取位置
开发语言·后端·golang
Reart1 天前
Leetcode 198.打家劫舍(716)
后端·算法
Java编程爱好者1 天前
Spring6.0+Boot3.0:秒级启动、万级并发的开发新姿势
后端