AI 推理即服务(AIaaS)的架构演进:从单体推理到 FaaS 化推理的工程路径

AI 推理即服务(AIaaS)的架构演进:从单体推理到 FaaS 化推理的工程路径

AI 推理即服务(AIaaS)的架构演进:从单体推理到 FaaS 化推理的工程路径

一、单体推理架构为何不是终点而是起点

很多团队的 AI 推理服务最初是一个单体应用:Flask/FastAPI 包装一个 PyTorch 模型,通过docker run启动,前面放一个 Nginx 做反向代理。在日均调用量小于 1000 时,这个架构运行良好。但当调用量突破 10 万/天后,直接在 Issue 列表中出现三类重复问题:

  1. 模型版本更新需要重启服务:加载新模型需要 10-15 秒,期间所有请求返回 503。
  2. GPU 利用率始终低于 30%:请求的到达间隔不均匀,模型在绝大多数时间处于空闲状态,但显存一直被占用。
  3. 批处理(batching)无法实施:每个请求独立到达,无法聚合成大 batch,推理吞吐被单条请求的延迟所限。

这三个问题指向同一个根因:推理计算的生命周期管理不够灵活。启动慢→无法快速扩缩容;显存持续占用→无法混合调度;无批处理→资源利用低。从单体到 FaaS(Function-as-a-Service)化推理的演进,本质上是对这三个问题的逐层解决。

单体推理→推理服务拆分→推理平台化→FaaS 化推理。每一步解决一个核心问题,引入新的复杂度。

二、AIaaS 架构的四个演进阶段

阶段 1——单体推理:单个进程,单个模型,HTTP 接口。优点是简单,缺点是更新需要重启、资源独占。适合原型验证,不适合生产环境。

阶段 2——推理 Worker Pool:将推理逻辑从 Web 服务中分离,变为独立 Worker。前端只负责接收请求并放入队列。Worker 竞争式地消费。这解决了"更新时全部不可用"的问题——逐个 Worker 重启,始终保持一部分可用。

阶段 3——推理平台:当一个平台服务多个模型时,需要模型注册中心、显存感知调度器、统一的推理引擎管理。这解决了"GPU 利用率低"的问题——不同模型的请求可以在不同时间窗口填入同一 GPU。

阶段 4——FaaS 化推理:将推理服务进一步拆解为函数粒度。每个模型的推理逻辑是一个独立的"函数"。当没有该模型的请求时,函数不消耗任何资源(不包括模型预热)。这解决了"资源持续占用"的问题,同时引入自动批处理:在一定时间窗口内聚合请求,形成 batch 进行推理。

三、FaaS 化推理的批处理调度器实现

下面的代码展示了一个批量推理调度器,它在一个时间窗口内聚合请求,形成 batch 后统一推理。

use std::collections::HashMap; use std::sync::Arc; use tokio::sync::{mpsc, oneshot, Mutex}; use tokio::time::{Duration, Instant, sleep}; /// 单个推理请求 #[derive(Debug)] pub struct BatchRequest { /// 请求 ID —— 用于追踪和日志关联 pub request_id: String, /// 模型名称 pub model_name: String, /// 输入序列(已做过 tokenize + padding) pub input_ids: Vec<u32>, /// 响应通道 —— oneshot 用于一对一的请求-响应匹配 pub response_tx: oneshot::Sender<Vec<f32>>, } /// Batch 聚合窗口配置 pub struct BatchConfig { /// 最大等待时间(毫秒): 即使 batch 不满,达到此时间也立即推理 pub max_wait_ms: u64, /// 最大 batch 大小: 受限于 GPU 显存和模型的最大输入维度 pub max_batch_size: usize, /// 最小 batch 大小: 低于此值时不执行推理(等待更多请求) pub min_batch_size: usize, } /// 批量推理调度器 —— 核心组件 pub struct BatchScheduler { /// 接收新请求的通道 request_rx: Arc<Mutex<mpsc::UnboundedReceiver<BatchRequest>>>, /// 发送新请求的通道(持有发送端用于内部注入) request_tx: mpsc::UnboundedSender<BatchRequest>, /// 按模型分组的请求缓冲区 buffers: Arc<Mutex<HashMap<String, Vec<BatchRequest>>>>, /// 配置 config: BatchConfig, } impl BatchScheduler { pub fn new(config: BatchConfig) -> Self { let (tx, rx) = mpsc::unbounded_channel(); Self { request_rx: Arc::new(Mutex::new(rx)), request_tx: tx, buffers: Arc::new(Mutex::new(HashMap::new())), config, } } /// 提交推理请求 —— 返回 oneshot Receiver 用于接收推理结果 pub fn submit(&self, model: &str, input_ids: Vec<u32>) -> oneshot::Receiver<Vec<f32>> { let (tx, rx) = oneshot::channel(); let req = BatchRequest { request_id: uuid::Uuid::new_v4().to_string(), model_name: model.to_string(), input_ids, response_tx: tx, }; // 发送请求到调度器 —— Unbounded 通道,不阻塞调用方 // 实际生产环境应使用 Bounded 通道 + 背压策略 let _ = self.request_tx.send(req); rx } /// 启动调度循环 pub async fn run(&self, request_rx: &mut mpsc::UnboundedReceiver<BatchRequest>) { let tick = tokio::time::interval( Duration::from_millis(self.config.max_wait_ms / 4) ); tokio::pin!(tick); loop { tokio::select! { // 接收新请求 Some(req) = Self::recv_with_timeout(request_rx, Duration::from_millis(10)) => { let mut buffers = self.buffers.lock().await; buffers.entry(req.model_name.clone()) .or_insert_with(Vec::new) .push(req); } // 定时检查:是否有 batch 达到触发条件 _ = tick.tick() => { self.check_and_flush().await; } } } } /// 检查各模型的缓冲区,满足条件的执行批量推理 async fn check_and_flush(&self) { let mut buffers = self.buffers.lock().await; let models: Vec<String> = buffers.keys().cloned().collect(); for model in models { if let Some(reqs) = buffers.get_mut(&model) { // 触发条件 1: 请求数达到 max_batch_size // 触发条件 2: 第一批请求等待时间超过 max_wait_ms let should_flush = reqs.len() >= self.config.max_batch_size || (reqs.len() >= self.config.min_batch_size && reqs.first().map_or(false, |r| { // 简单的时间检查(实际应记录请求到达时间) true })); if should_flush && !reqs.is_empty() { // 取走满足条件的请求(最多 max_batch_size 条) let batch_size = reqs.len().min(self.config.max_batch_size); let batch: Vec<_> = reqs.drain(..batch_size).collect(); // 在实际生产代码中,这里调用推理引擎执行 batch 推理 // 并逐一通过 oneshot::Sender 返回结果 for req in batch { // 模拟推理结果 let _ = req.response_tx.send(vec![0.0f32; 768]); } } // 移除空缓冲区 if reqs.is_empty() { buffers.remove(&model); } } } } /// 带超时的请求接收 —— 用于实现非阻塞的select循环 async fn recv_with_timeout( rx: &mut mpsc::UnboundedReceiver<BatchRequest>, timeout: Duration, ) -> Option<BatchRequest> { tokio::time::timeout(timeout, rx.recv()).await.ok().flatten() } } /// FaaS 推理函数的抽象 pub struct InferenceFunction { /// 函数名 —— 通常对应模型 ID name: String, /// 模型推理引擎实例(vLLM / Ollama / llama.cpp) engine: Arc<dyn InferenceEngine>, /// 批处理调度器 scheduler: Arc<BatchScheduler>, } /// 推理引擎抽象 —— 不同后端实现此 trait pub trait InferenceEngine: Send + Sync { /// 批量推理接口 async fn infer_batch(&self, input_ids: &[Vec<u32>]) -> Result<Vec<Vec<f32>>, EngineError>; } #[derive(Debug)] pub enum EngineError { OutOfMemory, Timeout, Unknown(String), } impl InferenceFunction { /// 调用推理函数 —— 返回异步结果 pub async fn invoke(&self, input_ids: Vec<u32>) -> Result<Vec<f32>, EngineError> { let rx = self.scheduler.submit(&self.name, input_ids); // 等待推理结果 —— oneshot 通道保证一次请求一次响应 rx.await.map_err(|_| EngineError::Unknown("scheduler dropped".into())) } }

核心设计决策:

  • max_wait_msmin_batch_size的配合:这是批处理延迟与吞吐之间的核心权衡。max_wait_ms=10, min_batch_size=4意味着最多等 10ms 凑齐 4 条请求;如果 10ms 内未凑齐,即使只有 1 条也执行推理。防止低流量时段请求无限等待。
  • oneshot::channel作为请求-响应桥接:每个请求携带一个oneshot::Sender,调度器在推理完成后将结果写回。这保证了请求和响应的精确配对,不会出现"A 的请求返回了 B 的结果"。
  • UnboundedReceiver的使用:简单但存在背压风险——如果推理速度跟不上请求速度,通道会无限增长导致 OOM。生产环境应改用Bounded通道 + 拒绝策略。

四、FaaS 化推理的适用边界与权衡

适用场景

  • 模型数量 5-20 个,流量分布不均匀(长尾分布),部分模型在低流量时段可以缩容到零。
  • 请求可容忍 50-100ms 的批处理等待延迟,且不需要严格按请求顺序返回结果。
  • GPU 成本占据推理服务总成本 60% 以上,需要最大化利用率的场景。

不适用场景

  • 单模型、大流量场景:批处理带来的延迟增加无对应收益,直接使用 vLLM 的 continuous batching 更合理。
  • 请求延迟 SLA < 20ms 的场景:批处理的等待时间必然增加尾延迟。
  • 请求序列长度差异巨大的场景:短序列(如 10 tokens)被长序列(如 4096 tokens)拖慢,前者感受的延迟增长 100 倍。

主要权衡

  1. 批处理延迟 vs GPU 吞吐max_wait_ms增加 10ms,吞吐提升 30%,但 P99 延迟同样增加 10ms。需要在 SLA 范围内最大化批处理窗口。
  2. 冷启动 vs 资源利用率:FaaS 的缩容到零是最彻底的节省,但带来了可感知的冷启动延迟。组件预热策略需要平衡。
  3. min_batch_size的设定:过低→低流量时几乎等同逐条推理;过高→低流量时请求永久不执行。实际根据 P50 流量计算:min_batch_size = max(2, P50_request_rate × max_wait_ms / 1000)

五、总结

  1. AIaaS 的演进路径是:单体推理→Worker Pool→推理平台→FaaS 化推理,每一步解决一个核心瓶颈。
  2. 批处理调度器是 FaaS 推理的核心组件——max_wait_ms控制延迟上限,min_batch_size控制吞吐下限,两者配合决定系统表现。
  3. oneshot::channel是批处理场景下请求-响应匹配的最佳工具,天然保证一对一映射。
  4. 自动批处理(Continuous Batching)是 vLLM 等现代推理引擎的关键创新,FaaS 层应尽量复用引擎的批处理能力。
  5. GPU 利用率的提升必然以请求延迟的小幅增加为代价,SLA 分析是确定max_wait_ms的前提。