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

发布时间:2026/7/22 0:54:46

AI 推理即服务(AIaaS)的架构演进:从单体推理到 FaaS 化推理的工程路径 AI 推理即服务AIaaS的架构演进从单体推理到 FaaS 化推理的工程路径一、单体推理架构为何不是终点而是起点很多团队的 AI 推理服务最初是一个单体应用Flask/FastAPI 包装一个 PyTorch 模型通过docker run启动前面放一个 Nginx 做反向代理。在日均调用量小于 1000 时这个架构运行良好。但当调用量突破 10 万/天后直接在 Issue 列表中出现三类重复问题模型版本更新需要重启服务加载新模型需要 10-15 秒期间所有请求返回 503。GPU 利用率始终低于 30%请求的到达间隔不均匀模型在绝大多数时间处于空闲状态但显存一直被占用。批处理batching无法实施每个请求独立到达无法聚合成大 batch推理吞吐被单条请求的延迟所限。这三个问题指向同一个根因推理计算的生命周期管理不够灵活。启动慢→无法快速扩缩容显存持续占用→无法混合调度无批处理→资源利用低。从单体到 FaaSFunction-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: Vecu32, /// 响应通道 —— oneshot 用于一对一的请求-响应匹配 pub response_tx: oneshot::SenderVecf32, } /// 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: ArcMutexmpsc::UnboundedReceiverBatchRequest, /// 发送新请求的通道持有发送端用于内部注入 request_tx: mpsc::UnboundedSenderBatchRequest, /// 按模型分组的请求缓冲区 buffers: ArcMutexHashMapString, VecBatchRequest, /// 配置 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: Vecu32) - oneshot::ReceiverVecf32 { 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::UnboundedReceiverBatchRequest) { 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: VecString 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::UnboundedReceiverBatchRequest, timeout: Duration, ) - OptionBatchRequest { tokio::time::timeout(timeout, rx.recv()).await.ok().flatten() } } /// FaaS 推理函数的抽象 pub struct InferenceFunction { /// 函数名 —— 通常对应模型 ID name: String, /// 模型推理引擎实例vLLM / Ollama / llama.cpp engine: Arcdyn InferenceEngine, /// 批处理调度器 scheduler: ArcBatchScheduler, } /// 推理引擎抽象 —— 不同后端实现此 trait pub trait InferenceEngine: Send Sync { /// 批量推理接口 async fn infer_batch(self, input_ids: [Vecu32]) - ResultVecVecf32, EngineError; } #[derive(Debug)] pub enum EngineError { OutOfMemory, Timeout, Unknown(String), } impl InferenceFunction { /// 调用推理函数 —— 返回异步结果 pub async fn invoke(self, input_ids: Vecu32) - ResultVecf32, EngineError { let rx self.scheduler.submit(self.name, input_ids); // 等待推理结果 —— oneshot 通道保证一次请求一次响应 rx.await.map_err(|_| EngineError::Unknown(scheduler dropped.into())) } }核心设计决策max_wait_ms与min_batch_size的配合这是批处理延迟与吞吐之间的核心权衡。max_wait_ms10, min_batch_size4意味着最多等 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 倍。主要权衡批处理延迟 vs GPU 吞吐max_wait_ms增加 10ms吞吐提升 30%但 P99 延迟同样增加 10ms。需要在 SLA 范围内最大化批处理窗口。冷启动 vs 资源利用率FaaS 的缩容到零是最彻底的节省但带来了可感知的冷启动延迟。组件预热策略需要平衡。min_batch_size的设定过低→低流量时几乎等同逐条推理过高→低流量时请求永久不执行。实际根据 P50 流量计算min_batch_size max(2, P50_request_rate × max_wait_ms / 1000)。五、总结AIaaS 的演进路径是单体推理→Worker Pool→推理平台→FaaS 化推理每一步解决一个核心瓶颈。批处理调度器是 FaaS 推理的核心组件——max_wait_ms控制延迟上限min_batch_size控制吞吐下限两者配合决定系统表现。oneshot::channel是批处理场景下请求-响应匹配的最佳工具天然保证一对一映射。自动批处理Continuous Batching是 vLLM 等现代推理引擎的关键创新FaaS 层应尽量复用引擎的批处理能力。GPU 利用率的提升必然以请求延迟的小幅增加为代价SLA 分析是确定max_wait_ms的前提。

相关新闻