Routeur LLM intelligent
Source: showcase/smart_llm_router See in Playground
Une fonction de décision en JavaScript lit chaque prompt entrant, estime sa complexité réelle, et le route vers l’un des trois niveaux RemoteLlm préconfigurés, plutôt que de toujours payer le modèle le plus puissant avec le plus grand budget de réponse, quelle que soit la demande.
Nécessite une véritable clé API d’un fournisseur LLM. Ajoutez éventuellement --api-report avec un jeton API (MELODIUM_API_TOKEN) pour suivre l’exécution sur Cadence.CI .
Exécution
cd showcase/smart_llm_router
melodium run Compo.toml --api_key sk-...
curl -X POST http://127.0.0.1:8080/chat -d "What year is it?"
curl -X POST http://127.0.0.1:8080/chat -d "Can you summarize the main differences between REST and GraphQL APIs?"
curl -X POST http://127.0.0.1:8080/chat -d "Explain, step by step, how a hash map resizes, and compare it to a B-tree's rebalancing cost."La première requête (courte, factuelle) est routée vers le niveau economy ; la seconde (longueur modérée, un signal de complexité) vers standard ; la troisième (trois signaux de complexité distincts : « explain », « step by step », « compare ») vers premium. Le journal du serveur affiche la décision du routeur et son raisonnement pour chaque requête :
{"complexity_score":-1.0,"estimated_input_tokens":4.0,"reason":"short, simple request","tier":"economy","word_count":4}
{"complexity_score":1.0,"estimated_input_tokens":18.0,"reason":"moderate length or complexity","tier":"standard","word_count":11}
{"complexity_score":3.0,"estimated_input_tokens":24.0,"reason":"long or explicitly complex request","tier":"premium","word_count":17}complexity_score et estimated_input_tokens (construits par arithmétique : +=, Math.min, Math.ceil) s’affichent avec un .0 final, même pour des nombres entiers ; word_count (lu directement depuis Array.length) n’en a pas. JavaScript n’a pas de type entier séparé, donc cela vient de la façon dont le moteur JS embarqué représente en interne chaque valeur, pas d’un choix d’arrondi côté Mélodium. Ne présumez pas que chaque champ numérique se convertit proprement en entier en aval sans vérification.
Fonctionnement
Trois modèles RemoteLlm sont déclarés comme des préréglages fixes (model, max_tokens), car RemoteLlm fixe son model et son max_tokens une seule fois par instance de modèle, pas par requête. « Optimiser le budget de tokens pour cette requête » ne peut donc pas signifier « calculer un nombre arbitraire à chaque fois » : cela signifie « choisir le bon niveau parmi quelques présets prédéfinis » :
model EconomyLlm(const api_key: string, const model: string) : RemoteLlm {
backend = "anthropic"
api_key = |wrap<string>(api_key)
base_url = ""
model = model
system = "You are a fast, concise assistant. Answer as briefly and directly as accuracy allows."
max_tokens = |wrap<u64>(200)
temperature = |wrap<f32>(1.0)
top_p = _
timeout = _
}temperature est fixé explicitement à |wrap<f32>(1.0) plutôt que laissé à _. Sur un modèle déclaré côté Rust comme RemoteLlm, _ n’omet pas le paramètre comme il le ferait sur un modèle déclaré en .mel : il envoie la valeur 0, que certains fournisseurs refusent purement et simplement pour leurs modèles les plus récents. top_p, qui n’a pas de valeur particulière ici, reste _ et est correctement omis.
StandardLlm et PremiumLlm suivent la même forme, avec respectivement 600 et 1500 tokens, et des prompts système progressivement plus détaillés.
Noter la requête, puis récupérer le niveau choisi
Un modèle JavaScriptEngine contient une fonction decide() qui note chaque prompt selon plusieurs heuristiques (correspondances de mots-clés, points d’interrogation, contenu ressemblant à du code, nombre de mots) et renvoie un objet tier/reason/word_count, plus un petit auxiliaire pickTier() qui ne projette que le champ tier :
model Router() : JavaScriptEngine {
code = ${{function decide(text) {
var trimmed = text.trim();
var words = trimmed.length ? trimmed.split(/\s+/) : [];
var wordCount = words.length;
// Estimation approximative, indépendante du fournisseur : environ 4 caractères par token.
var estimatedInputTokens = Math.ceil(text.length / 4);
var complexityKeywords = /\b(explain\w*|analyz\w*|analys\w*|compar\w*|design\w*|architecture\w*|prov\w*|deriv\w*|debug\w*|refactor\w*|summar\w*|step by step|in detail|pros and cons)\b/gi;
var keywordMatches = (text.match(complexityKeywords) || []).length;
var looksLikeCode = /```|function\s*\(|class\s+\w+|SELECT\s+.+FROM/i.test(text);
var questionMarks = (text.match(/\?/g) || []).length;
var score = 0;
score += Math.min(keywordMatches, 3);
if (looksLikeCode) score += 2;
if (questionMarks > 1) score += 1;
if (wordCount > 60) score += 1;
if (wordCount <= 8 && !looksLikeCode) score -= 1;
var tier, reason;
if (score >= 3 || wordCount > 120) {
tier = "premium";
reason = "long or explicitly complex request";
} else if (score >= 1 || wordCount > 25) {
tier = "standard";
reason = "moderate length or complexity";
} else {
tier = "economy";
reason = "short, simple request";
}
return {
tier: tier,
word_count: wordCount,
complexity_score: score,
estimated_input_tokens: estimatedInputTokens,
reason: reason
};
}
function pickTier(decision) {
return decision.tier;
}
}}
}Comme il n’existe toujours pas d’accès champ par champ à une valeur Json analysée en dehors de JavaScript, enchaîner un second appel process sur le résultat du premier appel est la façon de lire un champ dans un résultat déjà calculé en JS :
decideCall: process[engine=router](code="decide(value)")
asJson.json -> decideCall.value
decisionOpt: unwrapOr<Json>(default=|null())
decideCall.result -> decisionOpt.optionasStream2: stream<Json>()
decisionLast.last -> asStream2.block
pickTierCall: process[engine=router](code="pickTier(value)")
asStream2.stream -> pickTierCall.valueRoutage avec equalTo et filterBlock
Le prompt est routé avec trois portes equalTo plus filterBlock, une par niveau, l’équivalent au niveau bloc du motif filter utilisé sur les flux ailleurs :
isEconomy: equalTo<string>(value="economy")
tierLast.last -> isEconomy.data
gateEconomy: filterBlock<string>()
promptLast.last -> gateEconomy.value
isEconomy.result -> gateEconomy.selectLa sortie accepted d’exactement une seule porte transporte réellement le prompt ; les deux autres se ferment vides. Comme un traitement LLM stream alimenté par un flux de prompt vide n’appelle jamais le fournisseur, les deux niveaux non choisis ne coûtent rien, pas même une requête :
economyPrompt: stream<string>()
gateEconomy.accepted -> economyPrompt.block
economyReply: llmStream[llm=economyLlm]()
economyPrompt.stream -> economyReply.promptLes trois flux de tokens (majoritairement vides) sont combinés avec deux merge en un seul ; comme une seule branche a réellement produit quelque chose, le flux fusionné n’est que la sortie de cette branche.
Dépendances
[dependencies]
std = "0.10.3" # flux de base, journalisation, structures de données
http = "0.10.3" # client et serveur HTTP
net = "0.10.3" # aides pour adresses IP
encoding = "0.10.3" # encodage / décodage UTF-8
json = "0.10.3" # analyse et sérialisation JSON
javascript = "0.10.3" # moteur JavaScript embarqué
ml = "0.10.3" # LLM, STT, TTS et inférence de modèles locaux