Qui choisit le skill ?
« Pose 3 jours de congés du 4 au 6 novembre dans l'outil RH. » Aucun skill installé ne sait poser des congés ; l'assistant doit le dire. Pourtant la requête contient « congés », « jours », « RH » : un sélecteur par mots-clés ou par similarité choisit leave-balance-calculator ou hr-policy-qa, l'assistant charge 500 tokens d'instructions inutiles, puis répond à côté. Choisir un skill, c'est aussi savoir n'en choisir aucun.
Le mécanisme
Deux familles de sélection coexistent :
- Sélection par le modèle : le catalogue (nom et description) est dans le contexte, avec un outil
activate_skill. Le modèle lit la demande, la compare aux descriptions et décide d'appeler l'outil, ou non. C'est ce que font Claude, Gemini Enterprise (« utilise automatiquement un skill quand il est pertinent ») et ADK, et ce que recommande le guide client. - Sélection par similarité : le harness calcule un embedding de chaque description et de la requête, prend le skill le plus proche (cosinus) et l'active si le score dépasse un seuil. Aucun appel au modèle génératif : un seul appel d'embedding, beaucoup moins cher.
flowchart TB
Q["Requête"] --> X{"Combien de skills ?"}
X -->|"jusqu'à une centaine"| M["Catalogue complet + activate_skill : le modèle décide"]
X -->|"des centaines"| S["Similarité : top 10 des descriptions"]
S --> M2["Catalogue réduit + activate_skill : le modèle décide"]
M --> R["Skill activé, ou aucun"]
M2 --> R
U["Commande explicite /nom-du-skill"] --> R
La troisième voie, toujours disponible, est l'activation explicite : l'utilisateur tape /expense-report-checker ou mentionne le skill avec @ (Gemini Enterprise le propose), et le harness injecte le corps sans demander l'avis du modèle.
La mise en œuvre
Pour mesurer la sélection seule, on n'exécute que le premier tour de la boucle et on lit le premier appel de fonction (selection.py) :
def pick_by_model(client, loader, query, model=MODEL):
r = generate(client, model=model, contents=query, config=types.GenerateContentConfig(
system_instruction=loader.system_instruction(), tools=[loader.tool()],
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True)))
for fc in r.function_calls or []:
if fc.name == "activate_skill":
return (fc.args or {}).get("name"), r.usage_metadata.prompt_token_count
return None, r.usage_metadata.prompt_token_countLe sélecteur par similarité utilise gemini-embedding-2 avec les préfixes de tâche du modèle : title: … | text: … pour les descriptions, task: search result | query: … pour la requête, et une Content par texte (une liste de chaînes dans une seule Content produirait un vecteur agrégé).
self.matrix = self._embed([f"title: {s.name} | text: {s.description}" for s in loader.skills.values()])
...
def scores(self, queries):
return self._embed([f"task: search result | query: {q}" for q in queries]) @ self.matrix.T
def pick(self, row, threshold=None):
i = int(np.argmax(row))
return self.names[i] if row[i] >= (self.threshold if threshold is None else threshold) else NoneRésultat obtenu
python run.py eval-trigger, 30 requêtes (24 qui doivent déclencher un skill précis, 6 qui ne doivent en déclencher aucun, dont 5 quasi-homonymes), 2026-10-15 :
| Sélecteur | Exactitude | Précision des activations | Faux déclenchements (sur 6) |
|---|---|---|---|
Modèle, gemini-3.5-flash, 2 passages (60 décisions) | 1,00 | 1,00 | 0 |
Modèle, gemini-3.5-flash-lite, 1 passage | 0,967 | 0,96 | 1 |
| Similarité, meilleur score sans seuil | 0,80 | 0,80 | 6 |
| Similarité, seuil 0,60 | 0,833 | 0,852 | 4 (et 1 positif perdu) |
scores : min_positifs 0.593 | max_negatifs 0.676
lite : attendu None -> obtenu leave-balance-calculator : "Pose 3 jours de congés du 4 au 6 novembre dans l'outil RH pour moi."
similarité : attendu None -> obtenu csv-data-profiler : 'Écris un script Python qui lit un CSV et insère chaque ligne dans Post'
similarité : attendu None -> obtenu csv-data-profiler : 'Quelle est la différence entre la médiane et la moyenne ?'La similarité classe parfaitement les 24 requêtes positives : le skill le plus proche est toujours le bon. Elle échoue sur la décision « aucun » : la requête hors sujet la plus proche obtient 0,676, plus que la requête positive la moins proche (0,593). Aucun seuil ne sépare les deux groupes sur ce jeu. Le modèle, lui, lit la ligne « Not for » des descriptions et sait refuser. Le coût : 1 632 tokens d'entrée en moyenne pour cette décision, contre quelques dizaines de tokens d'embedding.
Ce qui casse en production
| Symptôme | Cause | Parade |
|---|---|---|
| Un skill se déclenche sur des requêtes voisines (« crée un modèle de note de frais ») | description trop large, pas de « Not for » | ajouter les exclusions avec le nom du skill voisin, retester les quasi-homonymes |
| Un skill ne se déclenche jamais | le modèle traite seul les demandes simples ; la description ne dit pas quand le skill apporte plus | description impérative et insistante (« Use whenever…, even if… ») |
| Le seuil de similarité réglé en recette ne tient pas en production | seuil ajusté sur le jeu de test, distribution différente en réel | ne pas laisser la similarité décider seule ; l'utiliser pour présélectionner |
| Plusieurs skills activés pour une demande simple | consigne système trop incitative | « N'activez jamais un skill au cas où », et mesurer les activations multiples |
| Le choix change d'un jour à l'autre | sélection non déterministe | trois passages par requête et taux de déclenchement, comme le recommande agentskills.io |
La règle de décision
Laissez le modèle décider tant que le catalogue reste de l'ordre de 10 000 à 20 000 tokens, soit une centaine de skills à 100-200 tokens de description. Au-delà, présélectionnez par similarité (top 10) et laissez le modèle trancher sur ce catalogue réduit : c'est le rôle de SkillRegistry.search_skills dans ADK. N'utilisez jamais la similarité seule pour décider qu'un skill doit être activé. Gardez toujours l'activation explicite par commande pour les utilisateurs qui savent ce qu'ils veulent.
Pratique
Vous avez 400 skills. Mettre tout le catalogue dans le prompt coûte 70 000 tokens par requête. Que faites-vous ?
La similarité trouve bien le candidat le plus proche mais ne sait pas dire « aucun » ; le modèle sait refuser mais ne peut pas lire 400 descriptions à chaque requête. L'hybride combine les deux. Le cache réduit le prix mais pas la dilution de l'attention ; fusionner recrée de gros prompts.
Après une mise à jour, gemini-3.5-flash-lite active leave-balance-calculator pour « Pose 3 jours de congés dans l'outil RH ». Que modifiez-vous d'abord ?
Le déclenchement ne dépend que du niveau 1 : nom et description. Le corps n'est lu qu'après l'erreur ; il n'y a pas de seuil dans une sélection par le modèle. Changer de modèle peut aider mais masque un défaut de description qui reviendra.
Exercice. Implémentez le sélecteur hybride : top 3 par similarité, puis pick_by_model sur un SkillLoader réduit à ces 3 skills. Mesurez son exactitude sur les 30 requêtes et son coût moyen en tokens.
Voir la solution
def pick_hybrid(client, loader, sel, query, k=3):
row = sel.scores([query])[0]
top = [sel.names[i] for i in np.argsort(row)[::-1][:k]]
small = SkillLoader({n: loader.skills[n] for n in top})
return pick_by_model(client, small, query)Le catalogue passe de 6 à 3 descriptions, d'où environ 500 tokens de moins par décision. Avec 6 skills, le gain est faible ; l'intérêt apparaît à partir de quelques centaines. Vérifiez que les 6 requêtes « aucun » restent à None : le modèle garde la possibilité de refuser même quand on lui présente 3 candidats.
- Je sais pourquoi la similarité trouve le bon skill mais pas « aucun »
- Je sais mesurer la sélection sur le premier tour seulement
- Je sais quand passer à une présélection par embeddings
- Je sais corriger un faux déclenchement par la description