02-使用FastAPI封装统一的大模型调用服务
使用 FastAPI 封装统一的大模型调用服务
系列:Python + 大模型应用开发(第 2 篇)
目标:把大模型调用代码封装成统一的 HTTP 服务,为网页、小程序、企业微信侧边栏和业务系统提供后端接口。
1. 为什么要增加一层后端服务
上一篇中,Python 程序直接调用了模型服务。如果未来需要接入网页、CRM、小程序或企业微信,一种危险做法是让每个客户端都直接携带模型 API Key。
更合理的基础架构是:
网页 / 小程序 / 企业微信 / CRM
↓
自己的 FastAPI 服务
↓
鉴权、参数校验、日志、限流
↓
大模型服务商
增加 FastAPI 层可以解决以下问题:
- 模型密钥只保存在后端;
- 多个业务系统使用统一接口;
- 集中完成输入校验、异常转换和日志记录;
- 后续可以统一增加鉴权、限流、缓存和成本统计;
- 更换模型服务商时,前端不需要跟着修改。
本文使用异步 HTTP 客户端 httpx.AsyncClient 调用模型,避免在 FastAPI 的异步接口中使用同步网络请求阻塞事件循环。
2. 目标接口
我们准备提供两个接口:
| 方法 | 路径 | 用途 |
|---|---|---|
| GET | /health | 判断当前 FastAPI 进程是否正常运行 |
| POST | /api/v1/chat | 接收用户问题并返回模型回答 |
聊天请求:
{
"user_message": "请解释 Python 装饰器。",
"temperature": 0.2
}
聊天响应:
{
"content": "模型生成的回答",
"model": "your-model-id",
"usage": {
"prompt_tokens": 20,
"completion_tokens": 80,
"total_tokens": 100
}
}
usage 是否存在、包含哪些字段,由实际模型服务决定,因此代码必须允许它为 null。
3. 创建项目
本文使用 Python 3.10 及以上版本。
项目结构:
llm_fastapi_service/
├── app/
│ ├── __init__.py
│ ├── config.py
│ ├── schemas.py
│ ├── llm_client.py
│ └── main.py
└── requirements.txt
创建虚拟环境:
python -m venv .venv
.\.venv\Scripts\python.exe -m pip install --upgrade pip
requirements.txt:
fastapi>=0.115,<1
uvicorn[standard]>=0.30,<1
httpx>=0.27,<1
pydantic>=2.7,<3
安装依赖:
.\.venv\Scripts\python.exe -m pip install -r requirements.txt
app/__init__.py 可以是空文件,它表示 app 是一个 Python 包。
4. 编写配置模块
新建 app/config.py:
import os
from dataclasses import dataclass
@dataclass(frozen=True)
class Settings:
"""保存大模型服务配置。"""
api_key: str
base_url: str
model: str
def load_settings() -> Settings:
"""读取环境变量,并尽早发现缺失配置。"""
# 所有敏感配置都从运行环境读取,不写入代码仓库
api_key = os.getenv("LLM_API_KEY", "").strip()
base_url = os.getenv("LLM_BASE_URL", "").strip().rstrip("/")
model = os.getenv("LLM_MODEL", "").strip()
missing_variables = []
if not api_key:
missing_variables.append("LLM_API_KEY")
if not base_url:
missing_variables.append("LLM_BASE_URL")
if not model:
missing_variables.append("LLM_MODEL")
if missing_variables:
names = ", ".join(missing_variables)
raise RuntimeError(f"缺少环境变量:{names}")
# 远程传输密钥时必须使用 HTTPS
if not base_url.startswith("https://"):
raise RuntimeError("LLM_BASE_URL 必须使用 https:// 地址")
return Settings(
api_key=api_key,
base_url=base_url,
model=model,
)
设置环境变量:
$env:LLM_API_KEY = "替换为真实密钥"
$env:LLM_BASE_URL = "https://替换为模型服务地址/v1"
$env:LLM_MODEL = "替换为真实模型标识"
这些示例值不能直接使用,必须根据实际服务商文档填写。
5. 使用 Pydantic 定义数据结构
新建 app/schemas.py:
from typing import Any
from pydantic import BaseModel, Field, field_validator
class ChatRequest(BaseModel):
"""调用聊天接口时,客户端允许提交的数据。"""
user_message: str = Field(
min_length=1,
max_length=4000,
description="用户问题,长度为 1 到 4000 个字符",
)
temperature: float = Field(
default=0.2,
ge=0,
le=2,
description="常见的模型随机性参数,真实范围以模型文档为准",
)
@field_validator("user_message")
@classmethod
def message_must_not_be_blank(cls, value: str) -> str:
"""阻止只包含空格、换行符的无效问题。"""
cleaned_value = value.strip()
if not cleaned_value:
raise ValueError("user_message 不能为空白字符串")
# 返回清理后的内容,后续业务代码无需重复 strip()
return cleaned_value
class ChatResponse(BaseModel):
"""聊天接口成功时的统一响应。"""
content: str
model: str
# 不同模型服务商返回的 usage 字段可能不同,因此用 Any 表示值类型
usage: dict[str, Any] | None = None
class HealthResponse(BaseModel):
"""健康检查接口响应。"""
status: str
为什么不让调用者提交 system_message?
因为系统提示词属于服务端规则。如果任意前端用户都能修改它,就可能把“严谨的业务助手”改成其他身份,绕过原有业务约束。本文把系统提示词固定在后端,后续如果存在多个业务助手,可以使用服务端维护的 assistant_id 白名单进行选择。
6. 编写异步模型客户端
新建 app/llm_client.py:
from typing import Any
import httpx
from app.config import Settings
class LLMServiceError(RuntimeError):
"""表示调用上游模型服务时发生的可预期错误。"""
def __init__(self, message: str, http_status: int = 502) -> None:
super().__init__(message)
# http_status 是自己的 FastAPI 服务要返回给调用方的状态码
self.http_status = http_status
class AsyncLLMClient:
"""基于 httpx.AsyncClient 的异步模型客户端。"""
def __init__(self, settings: Settings) -> None:
self.settings = settings
# 60 秒是默认总超时,建立连接最多等待 5 秒
timeout = httpx.Timeout(60.0, connect=5.0)
# AsyncClient 应在应用生命周期内复用,不能每次请求都重新创建
self.http_client = httpx.AsyncClient(timeout=timeout)
async def chat(
self,
user_message: str,
system_message: str,
temperature: float,
) -> tuple[str, dict[str, Any] | None]:
"""异步调用模型,返回回答文本和可选的 Token 用量。"""
request_url = f"{self.settings.base_url}/chat/completions"
request_headers = {
"Authorization": f"Bearer {self.settings.api_key}",
"Content-Type": "application/json",
}
request_body = {
"model": self.settings.model,
"messages": [
{"role": "system", "content": system_message},
{"role": "user", "content": user_message},
],
"temperature": temperature,
}
try:
# await 表示当前协程等待网络结果时,可以让出执行权
response = await self.http_client.post(
request_url,
headers=request_headers,
json=request_body,
)
except httpx.TimeoutException as exc:
# 504 表示作为网关等待上游服务超时
raise LLMServiceError(
"等待模型服务响应超时",
http_status=504,
) from exc
except httpx.ConnectError as exc:
raise LLMServiceError(
"无法连接模型服务",
http_status=502,
) from exc
except httpx.HTTPError as exc:
raise LLMServiceError(
"调用模型服务时发生网络异常",
http_status=502,
) from exc
# 上游鉴权失败属于后端配置问题,不向前端暴露密钥细节
if response.status_code == 401:
raise LLMServiceError("模型服务鉴权失败", http_status=502)
# 自己的服务当前无法满足请求,统一转换成 503
if response.status_code == 429:
raise LLMServiceError(
"模型服务繁忙或额度受限,请稍后重试",
http_status=503,
)
if 400 <= response.status_code < 500:
raise LLMServiceError(
f"模型服务拒绝了请求,上游状态码:{response.status_code}",
http_status=502,
)
if response.status_code >= 500:
raise LLMServiceError(
"模型服务暂时不可用",
http_status=503,
)
try:
# httpx 的 response.json() 会把 JSON 转成 Python 对象
data: dict[str, Any] = response.json()
except ValueError as exc:
raise LLMServiceError("模型服务返回了无效 JSON") from exc
try:
content = data["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as exc:
raise LLMServiceError("模型响应缺少预期字段") from exc
if not isinstance(content, str) or not content.strip():
raise LLMServiceError("模型返回了空内容")
# usage 不是所有服务都提供,因此使用 get() 安全读取
raw_usage = data.get("usage")
usage = raw_usage if isinstance(raw_usage, dict) else None
return content.strip(), usage
async def close(self) -> None:
"""关闭异步客户端,释放连接池资源。"""
await self.http_client.aclose()
为什么 FastAPI 中使用异步客户端
模型请求的大部分时间都消耗在网络等待上。如果异步接口内部使用阻塞式网络请求,事件循环会被阻塞,其他请求也可能受到影响。
这里的关键不是把函数名称前面简单加上 async,而是内部网络库本身也要支持异步,并且在调用时使用 await。
7. 创建 FastAPI 应用
新建 app/main.py:
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.config import Settings, load_settings
from app.llm_client import AsyncLLMClient, LLMServiceError
from app.schemas import ChatRequest, ChatResponse, HealthResponse
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""管理应用启动和关闭时需要创建、释放的资源。"""
# 启动阶段读取配置;配置有误时服务会直接启动失败
settings = load_settings()
# 创建一个供整个应用复用的异步模型客户端
llm_client = AsyncLLMClient(settings)
# 将对象放入 app.state,路由函数可以通过 Request 取得它们
app.state.settings = settings
app.state.llm_client = llm_client
# yield 之前是启动逻辑,yield 之后是关闭逻辑
yield
# 服务关闭时释放 HTTP 连接池
await llm_client.close()
app = FastAPI(
title="统一大模型调用服务",
version="1.0.0",
lifespan=lifespan,
)
@app.exception_handler(LLMServiceError)
async def handle_llm_service_error(
request: Request,
exc: LLMServiceError,
) -> JSONResponse:
"""把内部模型异常转换成统一且可理解的 HTTP 响应。"""
# request 在当前示例中未读取,后续可用它取得请求 ID 或用户身份
return JSONResponse(
status_code=exc.http_status,
content={"detail": str(exc)},
)
@app.get("/health", response_model=HealthResponse)
async def health() -> HealthResponse:
"""判断当前 FastAPI 进程是否正常运行。"""
# 这里只验证自己的服务进程,不代表上游模型一定可用
return HealthResponse(status="running")
@app.post("/api/v1/chat", response_model=ChatResponse)
async def chat(
chat_request: ChatRequest,
request: Request,
) -> ChatResponse:
"""接收用户问题,通过统一模型客户端生成回答。"""
# 系统提示词由服务端控制,调用者不能通过请求任意覆盖
system_message = (
"你是一名严谨的 Python 教师。"
"请基于事实回答;不确定时明确说明,不要编造。"
)
# 从应用状态中取得启动时创建的共享对象
llm_client: AsyncLLMClient = request.app.state.llm_client
settings: Settings = request.app.state.settings
# 等待异步模型调用完成
content, usage = await llm_client.chat(
user_message=chat_request.user_message,
system_message=system_message,
temperature=chat_request.temperature,
)
# response_model 会再次校验接口输出结构
return ChatResponse(
content=content,
model=settings.model,
usage=usage,
)
8. 启动并测试服务
在项目根目录执行:
.\.venv\Scripts\python.exe -m uvicorn app.main:app --reload
--reload 会在代码变化后自动重启,只适合本地开发,不应直接作为生产环境启动方式。
浏览器访问:
http://127.0.0.1:8000/docs
FastAPI 会生成交互式 API 文档,可以直接测试 /health 和 /api/v1/chat。
也可以使用 PowerShell 调用聊天接口:
# 使用哈希表构造请求体,再转换为 JSON
$body = @{
user_message = "请用三个要点解释 Python 列表和元组的区别"
temperature = 0.2
} | ConvertTo-Json
# 调用自己的 FastAPI 服务,而不是让前端直接调用模型厂商
Invoke-RestMethod `
-Method Post `
-Uri "http://127.0.0.1:8000/api/v1/chat" `
-ContentType "application/json" `
-Body $body
9. FastAPI 自动完成了哪些工作
当请求到达 /api/v1/chat 时,FastAPI 和 Pydantic 会:
- 读取 JSON 请求体;
- 检查
user_message是否存在; - 检查字符串长度;
- 拦截只有空格的输入;
- 检查
temperature是否在规定范围内; - 把合法数据转换成
ChatRequest对象; - 使用
ChatResponse校验响应结构; - 自动生成 OpenAPI 接口文档。
例如,请求中把 temperature 设置为 5,FastAPI 会直接返回 422,而不会继续调用模型并产生费用。
10. 为什么不能每个请求都创建 AsyncClient
下面的写法虽然可能运行,但不适合高频调用:
# 不推荐:每个业务请求都创建和关闭新的连接池
async with httpx.AsyncClient() as client:
response = await client.post("模型地址")
本文通过 lifespan 在应用启动时创建一次 AsyncClient,在关闭服务时统一释放。这样可以复用连接池,职责也更加清晰。
11. 对抗性审查:当前服务还有哪些风险
1. 自己的接口还没有身份认证
当前示例用于本地学习,任何能够访问该端口的人都可以调用接口并消耗模型额度。生产环境至少需要用户身份认证、权限校验和调用额度控制。
2. 健康检查不代表模型可用
/health 只说明 FastAPI 进程在运行。若要检查模型服务是否可用,应单独设计 Readiness(就绪检查),并谨慎控制检查频率,避免不断产生模型费用。
3. 错误信息不能泄露敏感数据
不应直接把上游完整响应、请求头、API Key 或内部堆栈返回给前端。详细异常可以写入经过脱敏的内部日志,对外只返回必要信息。
4. 输入长度限制不等于成本控制
字符数与 Token 数不是完全相同的概念。生产服务还需要根据实际模型的计费和上下文限制统计 Token,并设置用户级额度。
5. 系统提示词不是安全边界
把系统提示词保存在后端可以减少被直接修改的风险,但不能只依赖一句 Prompt 实现权限控制。数据库查询、文件访问和外部操作必须在代码层执行身份认证和权限判断。
6. 模型回答仍然可能错误
HTTP 200 只代表服务正常处理了请求,不代表回答符合事实。高风险业务需要增加 RAG、规则校验、结构化输出和人工审核。
12. 下一步如何扩展
这个统一服务可以继续增加:
- API 用户鉴权;
- 请求 ID 和结构化日志;
- 429、5xx 的有限重试;
- Redis 限流和缓存;
- Token 用量与成本统计;
- 流式输出 SSE(服务器发送事件);
- 多轮对话和历史消息存储;
- 多模型适配器;
- RAG 企业知识库;
- Prompt 和模型效果评测。
13. 总结
本文完成了从“Python 直接调用模型”到“统一后端模型服务”的升级:
客户端
↓
FastAPI 参数校验
↓
服务端系统规则
↓
异步模型客户端
↓
模型服务
↓
统一异常与响应结构
真正有价值的不是多包装了一层接口,而是建立了明确的系统边界:密钥属于后端、规则由服务端控制、外部输入必须校验、上游错误需要转换、网络资源需要统一管理。
14. 练习题
- 将
user_message最大长度改为 2000,并测试超长请求; - 将
temperature设置为 3,观察 FastAPI 返回的 422; - 删除一个必需环境变量,观察服务如何在启动阶段失败;
- 为
/health增加当前服务版本字段; - 为成功响应增加一个由后端生成的
request_id; - 思考:如果接入三个模型厂商,怎样避免在路由函数中编写大量
if/else?
更多推荐
所有评论(0)