Skip to Content
Mélodium 0.10.3 is now available!
DocsExemplesRouteur LLM intelligent

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.

Note

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.option
asStream2: stream<Json>() decisionLast.last -> asStream2.block pickTierCall: process[engine=router](code="pickTier(value)") asStream2.stream -> pickTierCall.value

Routage 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.select

La 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.prompt

Les 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