Building LoRA Multi-Adapter Inference on AWS SageMaker
Decoupling Weights for Scale: A Guide to Dynamic Multi-Adapter Orchestration.
By Kuriko IWAI

Table of Contents
IntroductionLoRA Multiple-Adapter OrchestrationArchitectural Deep Dive: Secure Ingress and Artifact OrchestrationLoRA in Action: From 4-Bit Quantization to Lazy LoadingWrapping Up: From Concept to PlaygroundIntroduction
Low-Rank Adaptation (LoRA) has become the gold standard for efficiently fine-tuning large language models without the massive computational overhead of full parameter tuning.
While its underlying math of decomposing large weight matrices into low-rank pairs is relatively simple, common pitfalls arise during implementation; memory management, latency spikes, and the complexities of scaling multiple task-specialized models.
In this article, I’ll walk you through how to deploy a LoRA multi-adapter inference on Amazon SageMaker Multi-Model Endpoints (MME), while implementing tuning techniques to avoid the pitfalls.
Developer Note:
In production, I faced a challenge — the latency spikes by 400% when I swap models incorrectly on MME. I designed this stack to solve the high-density switching problem I witnessed at scale.
LoRA Multiple-Adapter Orchestration
A LoRA multi-adapter orchestration system serves multiple LoRA adapters on a single shared base model.
It swaps the lightweight adapters dynamically as per client requests, avoiding the heavy latency penalties associated with full model context switching.
The diagram below illustrates its concept:

Kernel Labs | Kuriko IWAI | kuriko-iwai.com
Figure A: A comparative diagram showing independent full fine-tuned model endpoints (left) versus a single hosting LoRA adapters (rightmost) (Created by Kuriko IWAI)
Full fine-tuning requires massive parameter updates (Figure A, red box on the left side) and a dedicated endpoint for each of the three models (The left three boxes in Figure A host fully-tuned Models A, B, and C, respectively).
In contrast, multi-adapter serving utilizes a single endpoint, where an adapter is swapped based on the client request. In Figure A (rightmost), the red adapter is activated while the others remain dormant, saving significant amount of computational overhead compared to the full-tuning.
◼ Defining Task Profiles & Configuration
To demonstrate the efficacy of the adapter swapping, I train three distinct LoRA adapters tailored to handle highly divergent tasks:
Task 1. Medical Documentation: Translate complex clinical terminology into plain English.
Task 2. Sales Record Structuring: Parse unstructured sales dialogue into validated JSON schemas.
Task 3. Linguistic Localization: Transcribe and normalize colloquial Singaporean English into Standard English.
▫ Balancing Rank and Scaling Factor
Because each adapter handles a task with different complexity, tuning its hyperparameters based on the task-specific requirements is critical to maintaining high service performance.
Let us recall that LoRA approximates the full fine-tuning weight updates ΔW with the product of two low-rank matrices, A and B:
This formula has two important hyperparameters; the rank (r) determines the capacity of the update, while the scaling parameter (α) balances the adapter's influence against the frozen pre-trained weights.
When handling complex tasks and/or limited training set (< 100 samples), a high-rank LoRA with a high scaling parameter tends to perform better because a high rank ensures the model has sufficient capacity to capture intricate patterns, and a high scaling parameter can prioritize the newly learned knowledge.
On the other hand, when the task complexity is moderate and the training set is expansive, a low-rank LoRA is preferable to prevent overfitting.
Hyperparameter configurations for Tasks 1, 2, and 3 are defined below:
| Task | Rank (r) | Alpha (α) | Rationale |
| Task 1. Medical (Domain-specific Document Understanding) | Extremely High (e.g., 64) | High | Required to capture high-density, domain-specific nomenclature far from the base model's distribution. Required an extended logic horizon to maintain medical accuracy over 2,000+ tokens. |
| Task 2. Sales (Schema Enforcement) | Standard (e.g., 8) | Standard (r x 2) | Prioritizes structural adherence (JSON) over creative generation to prevent chatter. Narrow logic horizon is sufficient here, as I prioritize strict JSON structural adherence over creative reasoning. |
| Task 3. Localization (Tone) | Standard to High (e.g., 8, 16) | Standard (r x 2, r x 1) | Requires a large capacity to map complex dialectic nuances, using a standard scale to maintain fluency. |
Table 1. Recommended LoRA Rank and Alpha Configurations by Task Domain
Developer Note:
Based on recent empirical research, I set LoRA to target all dense layers (including Feed-Forward Networks and all Attention projections).
◼ The ROI - $450 vs. $12,000
Deploying the multi-adapter system is extremely cost effective.
When we deploy Task 1, 2, and 3 as three separate 7B models on dedicated endpoints, the monthly bill would exceed $12,000.
As Figure A shows, the multi-adapter system consolidates the three adapters onto a single endpoint of the base model. This would cost us $450+/mo, saving over 90% of the computational expense.
The system can significantly reduce costs and inference latency, while maintaining performance comparable to fully tuned models.
Architectural Deep Dive: Secure Ingress and Artifact Orchestration
The system architecture leverages a decoupled, serverless entry point to orchestrate requests to a SageMaker Multi-Model Endpoint (MME), while ensuring high security and efficient model artifact management.
The diagram below illustrates the system architecture and data flow:

Kernel Labs | Kuriko IWAI | kuriko-iwai.com
Figure B. Architectural data flow diagram illustrating secure client ingress via AWS Lambda, SageMaker MME, Amazon S3 artifact retrieval, and Redis ElastiCache (Created by Kuriko IWAI)
Multi-adapter systems struggle VRAM management and face Out-of-Memory (OOM) problems.
MME leverages high-density bin packing and dynamic VRAM orchestration to tackle this challenge.
◼ Secure Ingress with Lambda Proxy
First, the Lambda function receives the client request as an API Gateway proxy.
To ensure a secure architecture, requests are strictly governed by IAM using temporary execution role credentials (shown in Figure B, originating from the rightmost edge of the system).
The system eliminates the need to expose long-term sensitive data like AWS Access/Secret Keys to the client-side environment.
Once authenticated, the Lambda validates the request, enforces the required schema, and forwards the payload to the SageMaker MME endpoint.
◼ SageMaker MME Orchestration
After receiving the Lambda request, SageMaker MME on a GPU instance (e.g., ml.g5.xlarge) starts to manage the model lifecycle.
Here’s the steps:
Pre-loading: Loads the base model and tokenizer from S3 before performing inference (Avoid loading during inference to minimize the inference latency).
Pinning: Saves the base model and tokenizer as a shared resource in GPU memory.
Activation: Loads and activates an adapter on the base model as per the client request.
Inference: The base model with the active adapter performs inference.
Caching (optional): Saves the inference results in ElastiCache (72 hours).
Memory management: Handles the loading and unloading of adapter into GPU memory based on Least-Recently-Used (LRU) approach.
In Figure A, Adapter 1 (rightmost, red box) performs inference, while Adapters 2 and 3 (white boxes) remain dormant, but are pre-loaded into GPU memory.
Any additional adapters (Adapters 4, 5, …) are stored in the S3 bucket, and pulled into GPU memory only when the client requests.
Keeping only limited number of adapters on GPU memory significantly reduces the VRAM overhead (e.g., Adapters 1, 2, and 3 in total < 0.5GB), while maintaining system responsiveness.
◼ Inference
The inference logic is stored in a custom Docker image hosted in Amazon ECR (leftmost, Figure B).
The container runs a specialized inference server like NVIDIA Triton optimized for handling multiple model workers, ensuring that the environment for the base model and the dynamic LoRA loading remains consistent and reproducible.
◼ The Trust Layer
To comply with a Least Privilege security rule, the infrastructure relies on two pillars:
▫ IAM Trust Policies
Every component (Lambda, SageMaker, S3, ECR) is assigned an IAM role with a specific Trust Policy.
This includes the SageMaker service to assume roles for pulling the Docker image from ECR and reading artifacts from S3, while the Lambda to invoke the specific SageMaker endpoint.
▫ VPC Isolation
These components are deployed within a VPC (Virtual Private Cloud) to control ingress/egress traffic.
This prevents data exfiltration and ensures that all communication—especially between the GPU instance and the Redis cache—is secured in the private network rather than the public internet.
LoRA in Action: From 4-Bit Quantization to Lazy Loading
The implementation consists of six steps:
Loading training datasets.
Instantiating the PEFT model.
Configuring and training the LoRA adapters.
Managing VRAM capacities.
Performing inference.
Deploying the system.
◼ Loading Training Datasets
First, I generate 100 paired notes and ground-truth labels in JSON.
For Task 1. Medical:
1[
2 {
3 "category": "Respiratory",
4 "raw": "Pt c/o SOB, r/o PE.",
5 "label": "The patient complains of shortness of breath; rule out pulmonary embolism.",
6 "note": "Expands 'c/o' (complains of), 'SOB' (shortness of breath), and 'PE' (pulmonary embolism)."
7 },
8 {
9 "category": "Cardiology",
10 "raw": "hx of HTN and AFib, s/p ablation.",
11 "label": "The patient has a history of hypertension and atrial fibrillation, status post ablation.",
12 "note": "Converts 'hx' (history), 'HTN' (hypertension), and 's/p' (status post)."
13 }
14]
15
Then, I split the dataset into training and validation subsets to evaluate the LoRA adapter during training:
1import os
2from typing import Any
3from datasets import load_dataset, Dataset
4
5def formatting_prompts_func(sample: dict[str, Any]) -> dict[str, str]:
6 text = (
7 f"<|im_start|>system\n{system_prompt}<|im_end|>\n"
8 f"<|im_start|>user\nCorrect this: {sample['raw']}<|im_end|>\n"
9 f"<|im_start|>assistant\n{sample['label']}<|im_end|>"
10 )
11 return {'text': text}
12
13# load dataset
14full_dataset = load_dataset('json', data_files=dataset_path, split='train')
15
16# create train and eval datasets
17split_dataset = full_dataset.train_test_split(test_size=0.1, seed=42)
18train_dataset = split_dataset['train']
19eval_dataset = split_dataset['test']
20
21# format
22train_dataset = train_dataset.map(formatting_prompts_func, batched=False)
23eval_dataset = eval_dataset.map(formatting_prompts_func, batched=False)
24
25return train_dataset, eval_dataset
26
Developer Note:
While a dataset as small as 100 samples can be effective for LoRA, success depends heavily on selecting a base model already aligned with the target domain and precisely tuning the LoRA configuration.
Specifically, using a higher rank and scaling factor can help the adapter capture complex patterns from limited data, though this increases the risk of overfitting if not balanced with appropriate regularization.
◼ Instantiating PEFT Model
Next, I instantiate a PEFT model and pre-trained tokenizer from the HuggingFace’s transformers library:
1import torch
2from transformers import (
3 PreTrainedModel,
4 PreTrainedTokenizerBase,
5 AutoModelForCausalLM,
6 AutoTokenizer,
7 BitsAndBytesConfig
8)
9
10# 4-bit quantization (optional)
11bnb_config = BitsAndBytesConfig(
12 load_in_4bit=True,
13 bnb_4bit_quant_type="nf4",
14 bnb_4bit_compute_dtype=torch.float16,
15)
16# base model
17base_model = AutoModelForCausalLM.from_pretrained(
18 model_id, # model_id from the hf library
19 quantization_config=bnb_config,
20 device_map="auto",
21 trust_remote_code=True
22)
23
24# tokenizer corresponding to the selected model_id
25tokenizer = AutoTokenizer.from_pretrained(model_id)
26tokenizer.pad_token = tokenizer.eos_token
27
28return base_model, tokenizer
29
Although optional, the bnb_config in the code snippet enables 4-bit quantization.
The quantization instructs the GPU to compress the model weights from 16-bit floating point to a 4-bit Normal Float (NF4) format, significantly reducing the memory footprint (e.g., For a 7B parameter model: from 28GB to 6GB VRAM requirement).
◼ Configuring and Training LoRA Adapter
Next, I configure and train each LoRA adapter.
Instead of manually wrestling with 50+ hyperparameters, the system utilizes an SFT-Orchestrator designed for the high-density requirements of SageMaker MME:
1from lora_core.engine import MultiTaskTrainer
2
3# define trainer (SFTTrainer-based)
4trainer = MultiTaskTrainer(
5 base_model="llama-3.1-8b",
6 task_profile="medical_specialization", # auto-configures rank, alpha, target modules
7 precision="nf4-bf16", # vram-optimized for ml.g5 instances
8)
9
10# train the lora adapter
11trainer.train(
12 dataset=train_dataset,
13 output_s3_path="s3://<BUCKET_NAME>/<PREFIX>/",
14 max_steps=1000
15)
16
17train_result = trainer.train()
18
19return train_results
20
The custom MultiTaskTrainer class inherits the SFTTrainer class from the trl library.
It automatically calculates the optimal rank and scaling factor based on the selected task.
To round out the deployment, I implement the following configurations:
▫ Quantization
When the hardware allows, the system utilizes Bfloat16 (bf16) to prevent gradient explosion during training (Ampere-generation GPUs like RTX 3090/4090, A100/H100 are applicable).
Else, the system falls back to the standard fp16 to ensure that it won’t crash on older GPUs.
▫ Training Dynamics - Step vs Epoch
The SFTTrainer instance needs to choose either step-based or epoch-based strategy.
A step (also called "optimization step") is a single update of the model's weights.
In a single step, the model takes one batch of data, performs a forward pass to calculate loss, a backward pass to calculate gradients, and then the optimizer updates the weights.
When using a gradient accumulation of 4 steps for example, the weights are updated only after four batches have been processed and their gradients aggregated.
An epoch, on the other hand, is one complete pass through the entire dataset.
In a single epoch, the model has seen every single row of the training dataset exactly once.
Fine-tuning a language model only requires a few epochs (e.g., 1 to 3) because the models pre-learn so much that they overfit quickly if shown the same data too many times.
Steps and epochs define how the trainer distributes the total training samples (N) across the learning process:
For example:
Total: 1,000 samples.
Batch size: 2 samples.
Gradient accumulation steps: 4 steps.
Total effective batch size: 8 samples (2 samples x 4 steps).
Then, 1 epoch contains 125 steps (1,000 samples / 8 samples).
▫ Choosing the Right Strategy
A step-based strategy is ideal for most cases because it can protect computational budget, while providing frequent progress updates.
An epoch-based strategy is ideal for small datasets (e.g., 100–500 samples) where a full pass takes under 15 minutes, or for specific final reporting requirements.
Here’s the summary:
| Feature | Step | Epoch |
| Definition | One gradient update (one batch). | One full pass through the entire dataset. |
| Frequency | Happens constantly (e.g., every few seconds). | Happens rarely (e.g., once an hour or once a day). |
| Granularity | High. Great for seeing jitter in loss. | Low. Only gives us a big picture. |
| Best for... | Early stopping and real-time monitoring. | Final reporting and/or small datasets. |
Table 2: Step-Based vs. Epoch-Based Training Strategies for the SFTTrainer instance.
▫ Regularization
Lastly, the system implements three regularization techniques to prevent overfitting:
Early Stopping.
Weight Decay.
LoRA Dropout.
Early stopping is a regularization technique that halts the training process once the validation loss stops improving, preventing the model from over-fitting to the training data.
For example, a patience of 5 with an evaluation of every 10 steps will terminate the training process if the loss fails to improve for 5 consecutive evaluation calls—effectively giving the model a window of 50 steps (5 × 10 steps).
Weight decay is a regularization method that adds a penalty proportional to the magnitude of the weights.
The method encourages the model to maintain smaller, simpler weights to improve generalization, while discouraging excessively high values, preventing any single parameter from having too much influence.
LoRA dropout is a technique that randomly deactivates a percentage of neurons within the LoRA adapter layers during training.
The technique can prevent the model from becoming overly dependent on specific features.
1from trl.trainer.sft_trainer import SFTTrainer
2from trl.trainer.sft_config import SFTConfig
3
4class MultiTaskTrainer(SFTTrainer):
5 def __init__(self, **kwargs):
6 # early stopping
7 eval_strategy="steps"
8 eval_steps=10
9 early_stop_patience=5
10
11 # weight decay
12 weight_decay=0.00
13
14 # lora dropout
15 lora_dropout = 0.00
16
Developer Note:
When sample size is small, a cosine decay (lr_scheduler_type='cosine') helps the model settle into a global minimum more smoothly than a constant learning rate.
◼ Managing VRAM Capacities with Multiple LoRA Adapters
To perform inference, the system loads and activates the specific adapter requested by the client.
As Figure A shows, an instance can host multiple adapters simultaneously; however, concurrency is constrained by VRAM capacity, which can trigger Out-of-Memory (OOM) errors.
▫ Lazy Loading Adapters
Best practice in loading adapters is a lazy loading approach.
This approach loads the adapter only when it is not in the active VRAM cache, so help minimizing unnecessary I/O overhead and reduces latency for subsequent requests using the same adapter.
1# load the adapter only when base model has not loaded it yet
2if adapter_name not in base_model.peft_config:
3 base_model.load_adapter(adapter_dir, adapter_name=adapter_name)
4
▫ OOM Challenge and Eviction Strategy
To prevent Out-of-Memory (OOM) errors within the VRAM constraints of MME, the system enforces two hard caps:
Total number of adapters concurrently loaded onto the base model—for instance, a maximum of five active adapters for a 7B parameter model.
VRAM usage - the ratio of consumed VRAM to total available capacity.
Once one of these thresholds is reached, a Least Recently Used (LRU) eviction policy is triggered; the system offloads the least active adapter to make room for incoming client requests.
1import torch
2from collections import OrderedDict
3
4# list of adapters loaded on the base model
5adapters = OrderedDict()
6
7# evict oldest when pre-emptive vram below the buffer
8if not ensure_vram_headroom(threshold=0.85):
9 evict_oldest()
10
11# evict oldest when hitting the cap
12elif len(adapters) >= max_adapters:
13 evict_oldest()
14
15# empty cache
16torch.cuda.empty_cache()
17
◼ Performing Inference
Lastly, the model performs inference activating the adapter as per a client request:
1import gc, re, datetime, argparse, contextlib, torch
2from transformers import PreTrainedModel, PreTrainedTokenizerBase
3from peft import PeftModel
4
5device = model.device
6
7# structure prompts
8messages = [
9 {'role': 'system', 'content': system_prompt},
10 {'role': 'user', 'content': user_query}
11]
12text = tokenizer.apply_chat_template(messages, tokenize=False, add_generation_prompt=True)
13inputs = tokenizer(text, return_tensors='pt').to(device)
14
15# perform inference
16model.eval()
17with torch.no_grad(), context_manager:
18 input_ids = inputs['input_ids'].to(device)
19 attention_mask = inputs['attention_mask'].to(device)
20 outputs = model.generate(
21 input_ids=input_ids,
22 attention_mask=attention_mask,
23 max_new_tokens=2048,
24 use_cache=True # preventing memory spikes
25 )
26 res = tokenizer.decode(outputs[0], skip_special_tokens=True)
27return res
28
E.g., Task 1. Medical. System prompts and user query.
1{
2 "system": "You are a board-certified medical scribe. You must expand rapid clinical shorthand into full, standardized medical documentation for legal health records.",
3 "query": "HEENT exam unremarkable. NPO after midnight for EGD.",
4}
5
◼ Deploying the System
The deployment process has four primary steps:
Configure a VPC with AWS resources: Lambda, S3, ECR, ErastiCache, and SageMaker MME endpoint.
Push a Docker image to the AWS ECR.
Configure a SageMaker MME endpoint.
Deploy a Lambda function to call the MME endpoint.
▫ Configuring a VPC and AWS Resources
I first define IAM roles with trust policies to grant an access to each component:
Lambda: Proxy layer between client and SM MME.
S3: Model storage.
ECR: Docker image registry.
ErastiCache: Cache registry.
SageMaker MME: The core engine.
1chmod +x scripts/aws_setup_sm.sh && uv run scripts/aws_setup_sm.sh
2
The script includes creating dedicate IAM roles, attaching trust policies to grant an access from other AWS resources, and create repositories on S3, ECR, and ErastiCache.
▫ Pushing Docker Image
Next, I create Dockerfile and push the Docker image to AWS ECR:
1chmod +x scripts/docker_build.sh && uv run scripts/docker_build.sh <IMAGE_NAME>
2
▫ Configuring SageMaker MME Endpoint
Next, I configure an endpoint on SageMaker MME:
1uv run python src/sagemaker_handler.py --cache-clear
2
SageMaker MME has a structure:
Model > Endpoint > Endpoint Configuration
So, the script first flushes endpoint configurations and/or endpoints if necessary for clear start, and create a new SageMaker model, its corresponding endpoint, and a dedicate configuration.
▫ Deploying Lambda Proxy
Finally, I create and deploy a Lambda function as MME proxy for client:
1chmod +x scripts/aws_deploy_lambda.sh && uv run scripts/aws_deploy_lambda.sh
2
The Lambda acts as a thin routing layer to extract the model_id from the client request and map it to the TargetModel header required by SageMaker:
1import boto3
2sm_runtime_client = boto3('sagemaker-runtime', region_name=$YOUR_AWS_REGION)
3res = sagemaker_runtime.invoke_endpoint(
4 EndpointName=mme_endpoint_name,
5 TargetModel=f"{event['body']['model_id']}.tar.gz", # calling an mme routing key
6 Body=event.get('body'),
7 ContentType='application/json'
8)
9
Wrapping Up: From Concept to Playground
To bring these concepts to life, I developed a LoRA Multi-Adapter Playground.
This environment allows you to experiment with hyperparameter configurations and witness real-time task swapping.
You might observe:
Task 1. Medical: A slight performance dip. This is expected; medical terminology diverges significantly from the base model’s weights, highlighting the importance of domain-specific fine-tuning.
Tasks 2 & 3: These exhibit robust results, showcasing the adapter's ability to pivot between distinct domains without losing precision.
Efficiency: Despite the minimal computational overhead, the playground maintains high-quality output and low latency.
◼ VRAM Allocation Strategies
The primary engineering hurdle in LoRA MME is the management gap; SageMaker handles the file system, but the developer must manage the VRAM segments.
▫ Memory Economics Deep Dive: Calculating VRAM Headroom and LRU Eviction
When serving multiple LoRA adapters, the system functions essentially as a cache manager.
The number of adapters (N) host on a single endpoint is generalized:
The formula indicates that the remaining VRAM capacity after loading the base model (Base Model) and KV cache (KV) is allocated to the adapters.
For example, in the case of the 4B model on an ml.g4dn.xlarge instance (16GB, NVIDIA T4 VRAM):
| Component | VRAM Usage | Headroom Left |
| Foundation Model (4B) | ~8.5 GB | 7.5 GB |
| KV Cache | ~4 GB | 3.5 GB |
| Safety Buffer | 85% | 2.8GB |
| LoRA Adapters | ~100 MB / adapter x 28 adapters | 0 GB |
| Total Available | 16 GB | - |
Table 3. VRAM memory mapping for the ml.g4dn.xlarge instance
Note: In production environments, frameworks like vLLM or NVIDIA Triton (with LoRA exchange) handle this logic at the C++ level. This is significantly more performant than manual Python-side swapping.
Continue Your Learning
If you enjoyed this blog, these related entries will complete the picture:
Deconstructing LoRA: The Math and Mechanics of Low-Rank Adaptation
The Definitive Guide to LLM Fine-Tuning: Objectivee, Mechanisms, and Hardware
Transformer Architecture: Self-Attention & MLOps Guide
Tokenization Strategies for LLM Applications
Optimizing LLM Performance: Context Window Impact on RAG Accuracy
Regularizing LLMs with Kullback-Leibler Divergence
Related Books for Further Understanding
These books cover the wide range of theories and practices; from fundamentals to PhD level.

Linear Algebra Done Right

Foundations of Machine Learning, second edition (Adaptive Computation and Machine Learning series)

Designing Machine Learning Systems: An Iterative Process for Production-Ready Applications

Machine Learning Design Patterns: Solutions to Common Challenges in Data Preparation, Model Building, and MLOps

Hands-On Large Language Models: Language Understanding and Generation
Share What You Learned
Kuriko IWAI, "Building LoRA Multi-Adapter Inference on AWS SageMaker" in Kernel Labs
https://kuriko-iwai.com/lora-multi-adapter-inference-optimization
Looking for Solutions?
- Deploying ML Systems 👉 Book a briefing session
- Hiring an ML Engineer 👉 Drop an email
- Learn by Doing 👉 Enroll AI Engineering Masterclass
Written by Kuriko IWAI. All images, unless otherwise noted, are by the author. All experimentations on this blog utilize synthetic or licensed data.





