一、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_length、max_length、ge、le、example------这些不是多余的装饰,它们是 API 的文档、验证和安全边界。
原则 6:自动文档是不可浪费的优势
FastAPI 的 /docs 是免费获得的。把 Field 的 description 和 example 写好,API 的使用门槛就降低了一半。