Documentation

Intégrité des checkpoints

Laya télécharge les poids du modèle depuis le Hugging Face Hub au chargement. Par défaut, il prend ce que pointe la révision par défaut du dépôt, ce qui est pratique et correspond à ce que contient déjà un cache hors ligne. Si tu préfères épingler un commit revu, ou refuser de charger un checkpoint dont les octets ont changé, les deux sont disponibles et les deux sont opt-in.

Rien ici ne change ce que Laya charge tant que tu ne le demandes pas, donc ajouter ces options à un déploiement existant est sûr. Les deux sont au niveau de la bibliothèque : les digests atteignent le serveur HTTP via une variable d’environnement, et l’épinglage de révision via LAYA_REVISION — voir Épingler une révision dans le serveur.

Liés : Docker pour les variables de déploiement, laya.load et Agent, et Router.

Épingler une révision

Passe revision à n’importe quel chargeur. Il accepte un SHA de commit, une branche, ou un tag, et est transmis au Hub.

import laya

agent = laya.load("convaiinnovations/laya", revision="<commit sha, branch, or tag>")
print(agent.revision)   # what the download resolved to

Préfère les SHA revus qui sont livrés avec Laya à un littéral de ton cru : ils sont mis à jour avec les checkpoints, donc cette forme ne peut pas devenir obsolète.

from laya import PINNED_REVISIONS

agent = laya.load(
    "convaiinnovations/laya",
    revision=PINNED_REVISIONS["convaiinnovations/laya"],
)

Router prend le même revision, et revisions pour épingler chaque checkpoint séparément. Les clés de PINNED_REVISIONS sont les trois dépôts standalone, donc épingle un Router avec standalone_repos=True :

router = laya.Router(standalone_repos=True, revisions={
    "english": PINNED_REVISIONS["convaiinnovations/laya"],
    "multilingual": PINNED_REVISIONS["convaiinnovations/laya-multilingual"],
    "typed-decisions": PINNED_REVISIONS["convaiinnovations/laya-typed-decisions"],
})

Cela compte parce qu’un Router par défaut charge les trois checkpoints depuis le dépôt bundle unique (convaiinnovations/laya, avec multilingual/ et typed-decisions/ comme sous-dossiers), et un SHA de commit de laya-multilingual n’existe pas dans le dépôt bundle. Sans standalone_repos, l’épinglage n’est pas simplement ignoré — le chargement échoue. Si tu préfères rester sur le dépôt bundle, épingle-le avec un seul revision= pour les trois au lieu de revisions= par modèle.

Épingle les trois même si tu n’en sers que deux. Un Router offre chaque checkpoint qu’il connaît, indépendamment de ce que tu précharges, donc une entrée non épinglée est à une décision de routage d’un chargement non épinglé.

Pourquoi l’épinglage n’est pas le défaut

Épingler par défaut casserait le chargement depuis un ancien instantané en cache, ce qui compte pour les déploiements sur appareil et isolés : HF_HUB_OFFLINE=1 avec un cache antérieur à l’épinglage cesserait de fonctionner. Laya garde donc le défaut du Hub sauf si tu passes une révision, et met les SHA revus à disposition pour quand tu en veux.

Vérifier les digests d’artefacts

Une révision épinglée dit quel commit récupérer. Un digest dit quels octets tu attends. Chaque fichier que tu listes est haché avant qu’aucun d’eux ne soit parsé et avant que les poids n’atteignent le runtime. La carte est {path relative to the checkpoint: sha256 hex} — génère-la d’abord, puis passe-la.

As-tu besoin des deux ?

Une révision épinglée fixe déjà le contenu : le Hub est git, donc le commit détermine l’arbre, et les gros fichiers sont adressés par leur propre SHA-256. Si tu épingles et que le téléchargement réussit, tu as les octets que nomme ce commit. Donc le digest n’est pas là pour répéter cette vérification — il diffère en ce en quoi il a confiance.

Une révision demande un commit au Hub et croit la réponse. Un digest est un enregistrement que toi tu as fait et que toi tu gardes, comparé à chaque chargement. Cela achète trois choses que l’épinglage n’achète pas :

  • Couverture du cas courant, qui est non épinglé. L’épinglage est opt-in et désactivé par défaut, donc la plupart des déploiements suivent une branche mouvante. Un digest est alors la seule chose qui remarque un changement.
  • Un contrôle sur ton propre disque. Après le téléchargement, le checkpoint est de simples fichiers dans un cache que n’importe quoi sur la machine peut modifier. Rien ne les revérifie au chargement — sauf un digest.
  • Indépendance vis-à-vis de la source. Si un miroir, un proxy ou le Hub lui-même servait des octets différents, le digest est le seul contrôle qui ne demande pas à la chose testée de se porter garante elle-même.

Cette indépendance est aussi pourquoi générer la carte est une étape manuelle : une empreinte cesse d’être un enregistrement indépendant au moment où la chose qu’elle vérifie la produit pour toi.

Générer la carte

Génère-la depuis un checkpoint que tu as revu plutôt que de copier des digests de quelque part, y compris de cette page. Il n’y a délibérément aucune commande qui produit cela pour toi : une carte calculée depuis la copie que Laya vient de télécharger hacherait ces octets puis les vérifierait contre eux-mêmes. Le contrôle ne vaut quelque chose que parce qu’une personne a décidé que les octets étaient ceux qu’elle voulait, donc générer la carte est l’étape où cette décision est enregistrée. Une carte appartient à exactement un checkpoint : le dépôt bundle contient un rl_agent_config.json différent à sa racine (le checkpoint anglais) qu’en multilingual/, donc une carte générée depuis l’un échouera contre l’autre.

import hashlib, json, os

CHECKPOINT = "/path/to/checkpoint"   # the directory a load actually reads
FILES = [
    "rl_agent_config.json",
    "tokenizer/tokenizer.json",
    "encoder/config.json",
    "model.safetensors",
]

def sha256(path):
    h = hashlib.sha256()
    with open(path, "rb") as f:
        for chunk in iter(lambda: f.read(1 << 20), b""):
            h.update(chunk)
    return h.hexdigest()

digests = {rel: sha256(os.path.join(CHECKPOINT, rel)) for rel in FILES}
with open("digests.json", "w") as f:
    json.dump(digests, f, indent=2)

Un chargement torch Agent parse cinq fichiers, et ceux-ci en sont quatre. (ONNXAgent lit un ensemble différent et accepte en plus les clés onnx et onnx_path pour hacher le graphe lui-même.) Le cinquième, tokenizer/tokenizer_config.json, est délibérément laissé de côté : Laya peut le normaliser après la vérification et le réécrire, auquel cas l’épingler fait échouer le prochain chargement. Cette réécriture est conditionnelle — elle se déclenche seulement quand le fichier ne déclare pas de tokenizer_class, ou déclare TokenizersBackend, ou porte extra_special_tokens comme liste — donc sur certains checkpoints elle n’a jamais lieu et épingler le fichier semblerait fonctionner. Le laisser de côté est le choix portable, et cela signifie qu’un fichier parsé n’est pas vérifié. Voir Ce que ça protège et ne protège pas.

Utiliser la carte

import json

import laya

with open("digests.json") as f:
    agent = laya.load("convaiinnovations/laya", expected_sha256=json.load(f))

Les clés sont des chemins relatifs au répertoire du checkpoint. Une non-concordance lève ValueError, et un fichier listé qui est absent lève FileNotFoundError. Les fichiers que tu ne listes pas ne sont pas vérifiés du tout, donc la carte est aussi la définition de ce que tu protèges. Cela fonctionne sur un répertoire local aussi bien que sur un téléchargement Hub.

Sans toucher au code

LAYA_SHA256_DIGESTS contient la même carte en JSON et s’applique chaque fois qu’un chargeur est appelé sans expected_sha256 explicite :

export LAYA_SHA256_DIGESTS="$(cat digests.json)"
laya-serve

Rien ne génère cela pour toi : la valeur est ta propre carte, issue d’un checkpoint que tu as revu. Sous Docker, elle doit être dans l’environnement avant que compose ne démarre, soit exportée comme ci-dessus, soit dans le fichier .env que lit compose — le service transmet ${LAYA_SHA256_DIGESTS:-}, donc une variable non définie signifie silencieusement aucune vérification :

echo "LAYA_SHA256_DIGESTS=$(cat digests.json)" >> .env
docker compose -f compose.yaml -f compose.http.yaml up laya-serve

Il n’y a pas de variante _FILE de cette variable : cette indirection existe pour les secrets, et une carte de digests n’en est pas un.

Nomme chaque checkpoint quand un processus en charge plusieurs. La variable prend deux formes, et les types de valeurs disent laquelle :

# one set of files, checked on every checkpoint the process loads
LAYA_SHA256_DIGESTS='{"rl_agent_config.json": "<sha256>"}'

# a map per checkpoint, which is what a router serving several needs
LAYA_SHA256_DIGESTS='{"english": {"rl_agent_config.json": "<sha256>"},
                      "multilingual": {"rl_agent_config.json": "<sha256>"}}'

La forme plate est la propre lecture de verify_digests et applique les mêmes chemins à tout, donc sur un routeur elle ne peut jamais correspondre qu’à un seul checkpoint et refuse les autres. Le dépôt bundle livre un model.safetensors et un rl_agent_config.json séparés par checkpoint, donc nomme-les :

flat map generated from the english checkpoint
  load english        ok
  load multilingual   ValueError: laya: SHA-256 mismatch for rl_agent_config.json

Un checkpoint que la carte imbriquée ne nomme pas est délibérément non épinglé plutôt qu’une erreur, et un nom de modèle que le routeur ne connaît pas lève une erreur plutôt que de laisser ce checkpoint non vérifié. en se résout en english, la même normalisation que celle qu’applique Router(sha256_digests=...).

Les deux routes diffèrent sur ce dernier point, ce qui est facile à trébucher si tu utilises les deux. Un checkpoint que la carte imbriquée de l’environnement omet est épinglé à une carte vide, donc une carte plate ne peut pas y fuiter. Un checkpoint omis de Router(sha256_digests=...) dans le code n’a aucune entrée du tout, donc il retombe encore sur ce que dit l’environnement. Nomme chaque checkpoint que tu comptes épingler dans celle que tu utilises.

Une variable non définie ou vide signifie aucune vérification, donc c’est sûr de l’omettre des environnements qui n’en ont pas besoin. Un JSON malformé lève une erreur plutôt que de sauter silencieusement le contrôle, et mélanger les deux formes dans un seul objet est refusé par son nom.

À quoi ressemble une non-concordance dans le serveur

La façon dont elle remonte dépend du préchargement. Le laya-serve nu précharge par défaut (LAYA_PRELOAD=1), donc une non-concordance échoue au démarrage — bruyamment et de façon déterministe. Les conteneurs de ce dépôt mettent LAYA_PRELOAD=0 (compose.http.yaml, et Docker documente l’override), donc là le premier chargement a lieu sur une requête et rien n’est vérifié avant. Une non-concordance est alors un 422 sur le ticket qui route vers ce checkpoint : laya/serve.py mappe la ValueError sur HTTPException(422) et renvoie le texte du digest à l’appelant. Un fichier listé mais absent lève plutôt FileNotFoundError, qui retombe sur un 500 « inference failed » générique avec la raison seulement dans le log du conteneur.

Prévois le 422. Il se classe comme une erreur client dans les logs, les tableaux de bord et les règles d’alerte, donc l’endroit par défaut où un opérateur cherche un déploiement cassé est le seul endroit où celui-ci n’apparaîtra pas.

Épingler une révision dans le serveur

LAYA_REVISION contient un commit, une branche ou un tag appliqué à chaque téléchargement de checkpoint, ou le mot reviewed, qui cherche chaque dépôt dans PINNED_REVISIONS et utilise son propre SHA :

LAYA_REVISION=reviewed laya-serve

reviewed pour un dépôt absent de la table lève une erreur plutôt que de le charger non épinglé — un épinglage qui se résout silencieusement à rien est la défaillance que ce contrôle existe pour prévenir. Un argument revision= explicite l’emporte quand même sur la variable, et non défini ou vide signifie « pas demandé », donc un cache HF_HUB_OFFLINE=1 continue de charger exactement comme avant.

Dans le code, Router prend les deux par modèle :

router = laya.Router(
    revisions={"english": PINNED_REVISIONS["convaiinnovations/laya"]},
    sha256_digests={"english": {"rl_agent_config.json": "<sha256>"}},
)

Les digests sont toujours par modèle — il n’y a pas d’équivalent de revision à l’échelle du routeur, parce qu’un SHA de commit peut être partagé entre checkpoints et qu’un digest ne peut pas l’être. Voir Docker pour les variables de déploiement et Router pour le constructeur complet.

Quand un checkpoint est mis à jour

Les deux contrôles se comportent différemment, et un seul d’entre eux attend quelque chose de toi.

Une révision épinglée te garde où tu es. Un nouveau checkpoint n’atteint pas un déploiement épinglé avant que tu ne changes l’épinglage, ce qui est le but de l’épinglage. PINNED_REVISIONS bouge avec la bibliothèque, donc prendre un commit revu plus récent signifie mettre à jour Laya, pas éditer un SHA.

Les digests arrêtent le chargement, exprès. Ta carte a été générée depuis des octets que tu as revus. Des octets différents lèvent ValueError avant que quoi que ce soit ne soit parsé :

ValueError: laya: SHA-256 mismatch for rl_agent_config.json: expected ae287b56…, got 25061739…

C’est la fonctionnalité qui marche, pas un bug à contourner. L’ordre compte :

  1. Découvre pourquoi les octets ont changé — une release intentionnelle, ou quelque chose que tu n’attendais pas.
  2. Révise le nouveau checkpoint.
  3. Régénère la carte depuis la copie revue.
  4. Déploie la nouvelle carte.

Ne saute pas à l’étape 3. Relancer le générateur contre ce qui vient d’arriver fait passer le contrôle et ne vérifie rien — il enregistre les nouveaux octets comme dignes de confiance parce qu’ils sont présents, ce qui est précisément l’état que le digest existait pour détecter.

Deux détails. Une nouvelle carte livrée via LAYA_SHA256_DIGESTS nécessite que le processus soit redémarré, parce qu’un serveur en cours garde l’environnement avec lequel il a démarré. Et cette séquence ne s’applique qu’à un déploiement qui n’est pas épinglé à une révision : avec les deux contrôles actifs, les nouveaux octets n’arrivent jamais avant que tu ne déplaces l’épinglage.

Confirmer ce qui a réellement été chargé

Chaque agent enregistre le commit d’où il vient, None pour un répertoire local :

agent.revision                 # Agent and ONNXAgent
router.loaded_revisions        # {"english": "55cf4c4e…", …} for each resident agent

agent.revision rapporte l’instantané vers lequel le téléchargement a résolu, en retombant sur ce que tu as passé, donc épingler par branche ou tag renvoie ce nom plutôt qu’un SHA — épingle par SHA si tu veux que ce champ en soit un. Charger depuis un répertoire local rapporte None, et revision y est ignoré, parce qu’il n’y a pas d’instantané Hub à résoudre.

Le serveur rapporte la même chose, ce qui est le moyen le plus rapide de confirmer qu’un déploiement exécute le checkpoint que tu crois :

curl -s localhost:8000/health
# {"status":"ok","loaded":["english"],"revisions":{"english":"55cf4c4e…"},"device":"auto"}

laya-ts

Le paquet TypeScript reflète les parties épinglage et digest — revision, expectedSha256, et la relecture de la révision. Il n’a pas d’équivalent de LAYA_SHA256_DIGESTS ni de serveur, donc les deux sections ci-dessus ne s’y appliquent pas :

import { loadNodeBundle, PINNED_REVISIONS } from "laya-ts";

const bundle = await loadNodeBundle("convaiinnovations/laya", {
  revision: PINNED_REVISIONS["convaiinnovations/laya"],
  expectedSha256: { "rl_agent_config.json": "<sha256 of that file>" },
});

Une révision explicite rejoint le chemin de cache sur disque sous ~/.cache/laya-ts/, donc des artefacts épinglés différemment n’entrent jamais en collision. Dans le navigateur, la révision voyage dans l’URL de requête à la place, ce qui indexe CacheStorage de la même façon. createNodeProvider accepte expectedSha256 pour les graphes ONNX qu’il charge.

Ce que ça protège et ne protège pas

Cela détecte un checkpoint dont le contenu a changé par rapport à ce que tu as revu — une modification de dépôt en amont, un miroir compromis, un téléchargement corrompu, ou une copie locale modifiée.

Cela ne rend pas un checkpoint non revu sûr. Un digest dit seulement que les octets correspondent à ce que tu as enregistré ; décider que ces octets sont dignes de confiance reste ton affaire.

Trois limites à connaître avant de t’y fier :

  • Seuls les fichiers listés sont vérifiés. Il n’y a pas de mode « tout vérifier » ni de moyen de refuser un fichier que tu n’as pas listé, donc un artefact absent de ta carte est chargé sans vérification. La carte est la frontière de la garantie.
  • Un fichier parsé est donc en dehors. tokenizer/tokenizer_config.json est parsé, mais Laya peut le normaliser et le réécrire immédiatement après le contrôle de digest, donc l’épingler peut réussir au premier chargement et échouer au suivant. La carte recommandée le laisse de côté pour cette raison, ce qui signifie que ses octets ne sont pas vérifiés. La réécriture dépend de ce que déclare le fichier, donc qu’elle ait lieu dépend du checkpoint.
  • La vérification n’a lieu qu’au chargement. Rien ne revérifie un fichier ensuite, qu’il soit remplacé par un attaquant ou par le processus lui-même.