GitHub avatar

Fox's Blog

Luna Protocol: Shared Brains, Emotion Classification, and Interesting/Futile Routing

Luna Protocol moved from a monolith to a four-layer architecture: adapters, brain, emotion classifier, and inference. Featuring embedding centroids, interesting/futile routing, and LLM parameter tuning by valence and arousal.

Luna Protocol: shared brains, emotion classification, and interesting/futile routing

In the two previous articles, I presented Luna Protocol as a single Discord bot with a complex behavioral system and a fine-tuned model. But the architecture has evolved a lot since. What was a monolith -- a single Node.js process handling the Discord bot, behavior, and LLM calls -- turned into four independent layers, each with its own responsibility, language, and lifecycle.

That split brought unexpected benefits: sharing "brains" across several platforms, an emotion classification system that dynamically tunes LLM parameters, and smart message routing between two models based on perceived conversation importance.

The evolution didn't happen at once: it followed an organic path. I first split the server/ folder out of the bot repo, creating Krystal on one side and leaving Jade as the Discord adapter. Then I created Pixieglow (Matrix adapter) by reusing Jade's llm-core and event bus. Then came Sapphire to introduce GENERIC/SEMANTIC classification with DistilBERT. Results weren't conclusive, so I moved to embedding centroids, more malleable for example enrichment and more precise; classification became FUTILE/INTERESTING. I finally added valence and arousal centroids to regulate the LLM's temperature and repeat penalty. Lastly, I removed all redundant code between Jade and Pixieglow by creating Emerald, the shared brain, turning Jade and Pixieglow into plain socket-driven clients.

In parallel, I kept a website tracking the project's progress: protocol-luna.github.io.

This article tells how and why I cut those layers, what each service does exactly, and how concepts like centroids (mean embedding vectors) and resentment variables (inspired by the 70s PARRY chatbot) turned a plain Discord bot into a surprisingly coherent multi-platform system.


The problem with the monolith

Originally, Luna Protocol fit in a single Node.js process. The code handled:

  • Connecting to Discord (via the Eris library)
  • Trigger evaluation (mentions, keywords, follow-up...)
  • Human behavior simulation (typos, hesitations, sleep...)
  • HTTP calls to the local LLM server (llama.cpp)
  • Session and anti-spam management
  • The TTS pipeline

Everything lived in the same process, communicating through typed event buses (TypedBus). It worked, but with limits:

  • Impossible to add a Matrix client without duplicating all behavior code
  • The LLM and bot were in the same repo: the server/ folder already existed, but evolving one without touching the other was impossible
  • Undifferentiated processing: every message was handled the same way, whether a "lol" or an existential question
  • Emotional amnesia: the bot "felt" nothing

Cutting into layers solved all those problems.


The four layers

Luna Protocol's current architecture is organized as a four-level funnel:

Matrix / Discord
      |
      v
  [ADAPTERS]       Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [BRAIN]          Emerald (WebSocket, port 3126)
      |
      v
  [CLASSIFIER]     Sapphire (HTTP, port 3123)
      |
      v
  [INFERENCE]      Krystal (llama.cpp, ports 3124 / 3125)

Each layer can be restarted, updated, or replaced independently.


Layer 1: adapters (Pixieglow and Jade)

These are the simplest layers. Their only job is translating a messaging platform's events into a standardized protocol toward Emerald:

  • Jade is the Discord adapter. It uses the Eris library to connect to Discord and forwards messages to Emerald over WebSocket. It also handles the TTS pipeline (voice synthesis via Piper, OGG conversion, Discord upload).
  • Pixieglow is the Matrix adapter. It uses the Matrix Client-Server HTTP API directly (no SDK), with long-poll sync. No TTS.

Both adapters share the same WebSocket protocol defined in emerald-client.ts:

type ClientId = "jade" | "pixieglow";

// Events (adapter -> Emerald)
type InEvent = MessageEvent | ReadyEvent | BotMessageEvent | PresenceEvent;

// Commands (Emerald -> adapter)
type OutCommand = RespondCommand | TypingCommand | SetPresenceCommand
                | SpontaneousCommand | ForgotCommand;

The existence of two adapters with the same interface proves the sharing: the same "brain" (Emerald) serves a Discord bot and a Matrix bot alike, with identical behaviors. The protocol is declarative: Emerald doesn't tell the adapter how to send a message, it says what to send (the text with a delay, possibly a burst plan, a reaction, etc.). Each adapter implements concrete execution for its platform.

That's the strength of this architecture: to add Telegram, Signal, or other support, you just write an adapter implementing the WebSocket protocol.


Layer 2: the brain (Emerald)

Emerald is the central decision service. It listens on port 3126 over WebSocket and handles:

  • Trigger evaluation: mention, DM, name, keyword, follow-up, random
  • Behavior simulation: concentration delays, typos, hesitations, forgetfulness, burst, thematic fatigue
  • Sleep cycles: sleep / slow / short modes
  • Session management: cooldown, session limits, anti-spam
  • Routing to Sapphire: sending messages, receiving streamed replies

Emerald is the central service that enabled sharing, and the one that benefited most from the split. Before, each behavior (typo, burst, hesitation) was tangled with Discord code. Now they're in dedicated modules under behavior/:

emerald/src/behavior/
  burst.ts         -- Burst message planning
  mannerisms.ts    -- Delays, hesitations, reactions, forgetfulness
  sleep.ts         -- Sleep schedule evaluation
  typo.ts          -- Typo simulation (AZERTY/QWERTY)

The brain doesn't know which platform it runs on. It receives a MessageEvent with a clientId ("jade" or "pixieglow"), makes a decision, and returns a command. The adapter handles the rest.


Layer 3: the emotion classifier (Sapphire)

Sapphire is the most technically interesting service. It's a LLM middleware written in Python with FastAPI, playing four critical roles:

  1. Binary FUTILE / INTERESTING classifier via embedding centroids
  2. Emotion scorer (valence / arousal) via centroids
  3. Backend router to Krystal (small model vs big model)
  4. Few-shot injector and session manager

Centroids: the heart of classification

A centroid is a simple concept: the mean of a set of embedding vectors. Concretely, I gathered hundreds of example messages, ran them through an embedding model (BAAI/bge-small-en-v1.5, 384 dimensions), and averaged the resulting vectors.

There are two classification centroids:

  • futile_centroid: ~683 trivial messages ("lol", "ok", "hello") via k-means (k=10, seed=42)
  • interessant_centroid: ~678 substantial messages (technical, personal, philosophical) via k-means (k=10, seed=42)

When a message arrives:

def classify(text, embedder, futile_centroids, interessant_centroids):
    emb = embedder.query_embed(text)                     # 384-D vector
    sim_f = max(cos(emb, c) for c in futile_centroids)     # max over 10
    sim_i = max(cos(emb, c) for c in interessant_centroids) # max over 10
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

The class score is the maximum cosine similarity across its 10 centroids. That captures subtypes within each category: a greeting and a goodbye both land near one of the 10 futile centroids even if far apart in embedding space. Just k-means at startup and dot products at runtime: no training, no GPU.

Why two models?

That classification result decides which LLM backend gets invoked:

Label Krystal backend Model Port
FUTILE generic Luna-Protocol-1.5B (941 MB, Q4_K_M) 3124
INTERESSANT semantic Hermes-3-3B or 8B (per config) 3125

The intuition is simple: a "lol" or "nm just chillin u" doesn't deserve invoking an 8 billion parameter model. The small fine-tuned Luna 1.5B model, trained on 200,000 Discord samples, is plenty for light exchanges. A question about life, a confidence, or a technical debate gets routed to the big model that can produce a richer reply.

That economical routing considerably reduces load on the LLM server: about 70% of messages classify as FUTILE and get handled by the small model, freeing the big model for conversations truly worth it.

The emotion axis: valence and arousal

But that's not all. Sapphire uses the same centroid mechanism on an independent axis to evaluate the message's emotion:

There are four emotion centroids:

Pole Examples
positive "hell yeah", "love that", "this is great"
negative "shut up", "i hate this", "this sucks"
high_arousal "WHAT THE HELL", "omg omg omg", "AAAAA"
low_arousal "just chilling", "meh", "i guess"

The score computes as a similarity difference on each axis:

valence = sim(emb, positive) - sim(emb, negative)     # [-1, +1]
arousal = sim(emb, high_arousal) - sim(emb, low_arousal)  # [-1, +1]

Valence measures whether the message is positive or negative. Arousal measures its emotional intensity. Together they form the circumplex model of affect (Russell, 1980), the same psychological model that inspired the PARRY chatbot in 1972.

Resentment variables: how emotions control the LLM

This is where the PARRY inspiration becomes tangible. PARRY (created by Kenneth Colby in 1972) was a chatbot designed to simulate a paranoid patient. It had internal variables (fear, anger, mistrust) that modified its replies. A "scared" PARRY, for example, replied more aggressively.

Sapphire does the same thing, but with continuous variables and a more elegant method: the LLM's sampling parameters adjust in real time based on the conversation's emotional state.

Temperature follows arousal
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Arousal Temperature Effect
-1.0 (calm) 0.40 Low creativity, predictable replies
0.0 (neutral) 0.70 Default creativity
+1.0 (excited) 1.00 Max randomness, surprising replies

When someone is excited or angry (high arousal), temperature rises. The model produces more varied, more creative, sometimes more chaotic replies, like a human getting "worked up". When the conversation is calm, temperature drops, replies stay level-headed.

Repeat penalty follows valence
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
Valence Repeat Penalty Effect
-1.0 (negative) 1.25 Strong penalty, avoids repetition
0.0 (neutral) 1.15 Default value
+1.0 (positive) 1.05 Weak penalty, allows repetition

The more negative the conversation, the more the model is pushed to avoid repeating itself, like someone picking their words in a tense argument. The more positive the conversation, the more the model can allow redundant affirmations, like a relaxed chat.

Cumulative emotional state

Those scores don't just cover the immediate message. An EmotionState keeps an exponential moving average of valence and arousal per session:

class EmotionState:
    def __init__(self, decay=0.85, deadzone=0.06):
        self.decay = decay
        self.deadzone = deadzone

    def update(self, key, valence_delta, arousal_delta):
        if abs(valence_delta) < self.deadzone:
            valence_delta = 0.0
        if abs(arousal_delta) < self.deadzone:
            arousal_delta = 0.0
        s = self._state.setdefault(key, {"valence": 0.0, "arousal": 0.0})
        s["valence"] = s["valence"] * self.decay + valence_delta * (1 - self.decay)
        s["arousal"] = s["arousal"] * self.decay + arousal_delta * (1 - self.decay)
        return s

The decay at 0.85 means 85% of the previous state is kept on each message, and 15% of the new signal is folded in. That gives an emotional memory smoothing brutal variations: a single negative message doesn't make the bot "sad", but a series of negative messages gradually shifts its mood.

In practice: if someone starts a conversation very excited (arousal=+0.8), temperature stays high for several exchanges, even if later messages are calmer. Emotion takes time to come down, like a human staying "heated" after an argument.


Layer 4: inference (Krystal)

Krystal is the lowest layer: a wrapper around llama.cpp exposing an OpenAI-compatible API (/v1/chat/completions). It runs as two PM2 instances:

  • krystal-small: the fine-tuned Luna 1.5B model, on port 3124, with CPU affinity 0
  • krystal-large: a Hermes 3B model, on port 3125, with CPU affinity 0,1

Both instances are precompiled llama-server processes, launched with taskset for CPU pinning.

The Luna model fine-tune also evolved since the second article: it is now trained on 200,000 samples (up from 50,000), still from Qwen2.5-1.5B-Instruct via QLoRA. The 200k samples are a subset of the Discord-Dialogues dataset, filtered to keep only the most natural and diverse conversations. The goal: widen the model's stylistic register without losing the flexibility that makes few-shot priming so effective.


The full picture: a message in transit

Here's what concretely happens when someone sends "je suis vraiment triste aujourd'hui" ("i feel really sad today") on Discord:

  1. Jade receives the message via the Discord Gateway API. It turns it into a MessageEvent and sends it to Emerald over WebSocket.
  2. Emerald evaluates the trigger (direct mention? name? keyword?). It's a direct mention. It computes a concentration delay, checks cooldown, session, thematic fatigue. It decides to reply and sends the message to Sapphire over HTTP.
  3. Sapphire embeds the message with bge-small-en-v1.5.
    • Classification: the message is closer to the interessant centroid than the futile one (diff = +0.31) -> INTERESSANT
    • Emotion: negative valence (-0.42), moderate arousal (0.35)
    • Routing: toward KRYSTAL_SEMANTIC_URL (port 3125, big model)
    • Sampling params: temperature = 0.80 (raised arousal), repeat_penalty = 1.19 (negative valence)
    • The session's emotional state gets updated with those values
  4. Krystal (large instance) generates the reply with the emotionally tuned parameters and returns it to Sapphire.
  5. Sapphire streams the reply to Emerald with metadata (label, valence, arousal, debug stats).
  6. Emerald decides to add a hesitation ("oh..."), plans a burst (2 fragments), and picks a reaction. It sends a RespondCommand to Jade.
  7. Jade executes: waits the initial delay, sends the first fragment with the hesitation, waits 1.5s, sends the second fragment. It shows the typing indicator throughout generation.

All of that in under 3 seconds for the user.


Centroids: why it's better than a neural classifier

The choice of embedding centroids over a traditional classifier (like the DistilBERT I used before) deserves an explanation.

A neural classifier learns a decision boundary between classes, typically a non-linear transform projecting inputs to probabilities. It's precise, but:

  • It needs labeled training data
  • It's sensitive to distribution shift (data drift)
  • It's hard to interpret
  • It must be retrained to add a new class

A centroid, in contrast, is a mean vector of example embeddings. Classification happens by cosine similarity to that mean vector. Advantages:

  • No training required: you just average embeddings of hand-picked examples
  • Easy to interpret: you can look at which examples sit closest to the centroid to understand "what the centroid learned"
  • Adding a class: you just add a new centroid, no retraining
  • Robust: the centroid is a mean, so outliers have little impact

The real power of centroids is turning a classification problem into a spatial distance measurement problem. You can visualize categories as regions in a 384-dimensional space (or in 2D/3D after PCA/t-SNE dimensionality reduction).

3D visualization of centroids

In practice, here's what classification centroids look like in embedding space. Each point is an example message, projected to 3D by PCA (the original 384 dimensions reduced to 3 for visualization). Blue points are futile messages, yellow points interesting messages. The 20 diamond markers are the k-means centroids (10 per class, seed=42). Hover a point to see the example's original text.

Two test examples show in red: "lol" (classified futile) and "i feel sad today" (classified interesting). Even after reducing 384 to 3 dimensions (14.7% explained variance), both clusters are clearly separated. The top annotation shows exact counts and the ambiguous zone.

The input message's centroid wanders that space based on its content. FUTILE/INTERESSANT classification is simply measuring which centroid is closest by cosine similarity. So you can picture each message as a point in a multi-dimensional space, each dimension matching a semantic property.


What it changes in practice

Users never see the layers, centroids, or temperature tweaks. But they feel the effects:

  • Faster replies for simple messages (the small model is 2x faster and handles 70% of traffic)
  • Adaptive tone: if you're angry, the bot "senses" the anger and adapts its style
  • Cross-platform coherence: a Matrix bot and a Discord bot share the same brain and emotional state
  • Preserved authentic tone: fine-tune + few-shot + smart routing avoid corporate replies

Moving to 200k training samples for the small model reinforced those effects: the model better captures Discord conversation diversity without losing the malleability that few-shot priming enables.


The full infrastructure

Here are the services currently running:

Service Technology Port(s) Role
Pixieglow TypeScript (Bun) -- Matrix adapter
Jade TypeScript (esbuild) -- Discord adapter
Emerald TypeScript (Bun) 3126 (WebSocket) Brain / decisions
Sapphire Python (FastAPI) 3123 (HTTP) Classifier + emotion
Krystal small llama.cpp (PM2) 3124 Small model (1.5B, futile)
Krystal large llama.cpp (PM2) 3125 Big model (3B+, interesting)

Service dependencies are one-way: the adapter depends on Emerald, Emerald depends on Sapphire, Sapphire depends on Krystal. The graph stays acyclic. Each service can restart independently.


Conclusion

Splitting Luna Protocol into four layers wasn't just an architecture exercise. It answered concrete limits: no Matrix support, no emotional awareness, no smart message prioritization.

Today the system is more robust (an LLM crash doesn't kill the bot), more extensible (a Telegram or WhatsApp adapter would follow the same WebSocket protocol), and more "alive": the bot adapts its behavior, tone, and even LLM parameters to the conversation's perceived emotional state.

Embedding centroids are the key element making all of that possible without undue complexity: no trained neural net, no labeled data pipeline, just vector means and cosine similarities. A simple technique, incredibly effective, and terribly underestimated.

Resource Link
Project website protocol-luna.github.io
Pixieglow protocol-luna/pixieglow
Emerald protocol-luna/emerald
Sapphire protocol-luna/sapphire
Krystal protocol-luna/krystal
Article 1: the Discord bot Luna Protocol: I built an autonomous Discord bot
Article 2: fine-tuning Luna Protocol: why I fine-tuned a 1.5B model

Luna Protocol : mutualisation des cerveaux, classification émotionnelle, et routage intéressant/futile

Luna Protocol est passé d'un monolithe à une architecture en quatre couches : adaptateurs, brain, classifieur émotionnel, et inference. Au programme : centroids d'embeddings, routage intéressant/futile, et ajustement des paramètres du LLM par valence et arousal.

Luna Protocol : mutualisation des cerveaux, classification émotionnelle, et routage intéressant/futile

Dans les deux articles précédents, j'ai présenté Luna Protocol comme un bot Discord unique avec un système comportemental complexe et un modèle fine-tuné. Mais l'architecture a depuis bien évolué. Ce qui était un monolithe : un seul processus Node.js qui gérait à la fois le bot Discord, le comportement, et les appels LLM, il s'est transformé en quatre couches indépendantes, chacune avec sa propre responsabilité, son propre langage, et son propre cycle de vie.

Cette séparation a apporté des bénéfices inattendus : la mutualisation des "cerveaux" entre plusieurs plateformes, un système de classification émotionnelle qui ajuste dynamiquement les paramètres du LLM, et un routage intelligent des messages entre deux modèles selon l'importance perçue de la conversation.

L'évolution ne s'est pas faite d'un coup : elle a suivi un chemin organique. J'ai d'abord séparé le dossier server/ du repo du bot, créant ainsi Krystal d'un côté et laissant Jade comme adaptateur Discord. Puis j'ai créé Pixieglow (adaptateur Matrix) en reprenant le llm-core et le bus d'événements de Jade. Ensuite est venu Sapphire pour introduire une classification GENERIC/SEMANTIC avec DistilBERT. Les résultats n'étaient pas concluants, je suis donc passé par des centroids d'embeddings, plus malléables pour l'enrichissement d'exemples et plus précis ; la classification est devenue FUTILE/INTERESSANT. J'ai finalement ajouté des centroids de valence et arousal pour réguler la température et le repeat penalty du LLM. Pour finir, j'ai dégagé tout le code redondant entre Jade et Pixieglow en créant Emerald, le cerveau mutualisé, transformant Jade et Pixieglow en simples clients socket-driven.

En parallèle, j'ai tenu à jour un site web qui retrace l'avancement du projet : protocol-luna.github.io.

Cet article raconte comment et pourquoi j'ai découpé ces couches, ce que chaque service fait exactement, et comment des concepts comme les centroids (des vecteurs moyens d'embeddings) et les variables de ressentiment (inspirées du chatbot PARRY des années 70) ont transformé un simple bot Discord en un système multi-plateforme étonnamment cohérent.


Le problème avec le monolithe

Au départ, Luna Protocol tenait dans un seul processus Node.js. Le code gérait :

  • La connexion à Discord (via la bibliothèque Eris)
  • L'évaluation des déclencheurs (mentions, mots-clés, follow-up...)
  • La simulation de comportements humains (fautes de frappe, hésitations, sommeil...)
  • Les appels HTTP au serveur LLM local (llama.cpp)
  • La gestion des sessions et de l'anti-spam
  • Le pipeline TTS

Tout était dans le même processus, communiquant via des bus d'événements typés (TypedBus). Ça fonctionnait, mais avec des limites :

  • Impossible d'ajouter un client Matrix sans dupliquer tout le code de comportement
  • Le LLM et le bot étaient dans le même repo : le dossier server/ existait déjà, mais impossible de faire évoluer l'un sans toucher à l'autre
  • Traitement indifférencié : chaque message était traité de la même façon, qu'il s'agisse d'un "lol" ou d'une question existentielle
  • Amnésie émotionnelle : le bot ne "ressentait" rien

Le découpage en couches a résolu tous ces problèmes.


Les quatre couches

L'architecture actuelle de Luna Protocol est organisée comme un entonnoir à quatre niveaux :

Matrix / Discord
      |
      v
  [ADAPTATEURS]      Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [BRAIN]            Emerald (WebSocket, port 3126)
      |
      v
  [CLASSIFIEUR]      Sapphire (HTTP, port 3123)
      |
      v
  [INFERENCE]        Krystal (llama.cpp, ports 3124 / 3125)

Chaque couche peut être redémarrée, mise à jour, ou remplacée indépendamment.


Couche 1 : les adaptateurs (Pixieglow et Jade)

Ce sont les couches les plus simples. Leur seul travail est de traduire les événements d'une plateforme de messagerie en un protocole standardisé vers Emerald :

  • Jade est l'adaptateur Discord. Il utilise la bibliothèque Eris pour se connecter à Discord et forwarder les messages vers Emerald via WebSocket. Il gère aussi le pipeline TTS (synthèse vocale via Piper, conversion en OGG, upload sur Discord).
  • Pixieglow est l'adaptateur Matrix. Il utilise l'API HTTP Client-Server de Matrix directement (pas de SDK), avec un long-poll sync. Il n'a pas de TTS.

Les deux adaptateurs partagent le même protocole WebSocket défini dans emerald-client.ts :

type ClientId = "jade" | "pixieglow";

// Événements (adaptateur -> Emerald)
type InEvent = MessageEvent | ReadyEvent | BotMessageEvent | PresenceEvent;

// Commandes (Emerald -> adaptateur)
type OutCommand = RespondCommand | TypingCommand | SetPresenceCommand
                | SpontaneousCommand | ForgotCommand;

L'existence de deux adaptateurs avec la même interface prouve la mutualisation : le même "cerveau" (Emerald) sert indifféremment un bot Discord et un bot Matrix, avec des comportements identiques. Le protocole est déclaratif : Emerald ne dit pas à l'adaptateur comment envoyer un message, il dit quoi envoyer (le texte avec un délai, éventuellement un plan de burst, une réaction, etc.). Chaque adaptateur implémente l'exécution concrète selon sa plateforme.

C'est la force de cette architecture : pour ajouter le support de Telegram, Signal, ou autre, il suffit d'écrire un adaptateur qui implémente le protocole WebSocket.


Couche 2 : le cerveau (Emerald)

Emerald est le service central de décision. Il écoute sur le port 3126 en WebSocket et gère :

  • L'évaluation des déclencheurs : mention, DM, nom, mot-clé, follow-up, aléatoire
  • La simulation comportementale : délais de concentration, fautes de frappe, hésitations, oublis, burst, fatigue thématique
  • Les cycles de sommeil : modes sleep / slow / short
  • La gestion des sessions : cooldown, limites de session, anti-spam
  • Le routage vers Sapphire : envoi des messages, réception des réponses streamées

Emerald est le service central qui a permis la mutualisation, et c'est celui qui a le plus bénéficié de la séparation. Avant, chaque comportement (typo, burst, hesitation) était entrelacé avec le code Discord. Maintenant, ils sont dans des modules dédiés dans behavior/ :

emerald/src/behavior/
  burst.ts         -- Planification des messages en rafales
  mannerisms.ts    -- Délais, hésitations, réactions, oublis
  sleep.ts         -- Évaluation des horaires de sommeil
  typo.ts          -- Simulation de fautes de frappe (AZERTY/QWERTY)

Le cerveau ne sait pas sur quelle plateforme il tourne. Il recoit un MessageEvent avec un clientId ("jade" ou "pixieglow"), prend une décision, et renvoie une commande. L'adaptateur se charge du reste.


Couche 3 : le classifieur émotionnel (Sapphire)

Sapphire est le service le plus intéressant sur le plan technique. C'est un middleware LLM écrit en Python avec FastAPI, qui joue quatre rôles critiques :

  1. Classifieur binaire FUTILE / INTERESSANT via centroids d'embeddings
  2. Scoreur émotionnel (valence / arousal) via centroids
  3. Routeur de backends vers Krystal (petit modèle vs grand modèle)
  4. Injecteur few-shot et gestionnaire de sessions

Les centroids : le coeur de la classification

Un centroid est un concept simple : c'est la moyenne d'un ensemble de vecteurs d'embeddings. Concrètement, j'ai rassemblé des centaines d'exemples de messages, je les ai passés dans un modèle d'embedding (BAAI/bge-small-en-v1.5, 384 dimensions), et j'ai moyenné les vecteurs obtenus.

Il y a deux centroids de classification :

  • futile_centroid : ~683 messages triviaux ("lol", "ok", "hello") via k-means (k=10, seed=42)
  • interessant_centroid : ~678 messages substantiels (techniques, personnels, philosophiques) via k-means (k=10, seed=42)

Quand un message arrive :

def classify(text, embedder, futile_centroids, interessant_centroids):
    emb = embedder.query_embed(text)                     # vecteur 384-D
    sim_f = max(cos(emb, c) for c in futile_centroids)     # max sur 10
    sim_i = max(cos(emb, c) for c in interessant_centroids) # max sur 10
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

Le score par classe est la similarité cosinus maximale parmi ses 10 centroids. Cela capture les sous-types dans chaque catégorie : une salutation et un au revoir tombent tous deux près d'un des 10 centroids futiles même s'ils sont éloignés dans l'espace d'embedding. Seulement du k-means au démarrage et des produits scalaires à l'exécution : sans entraînement, sans GPU.

Pourquoi deux modèles ?

Le résultat de cette classification décide quel backend LLM est invoqué :

Label Backend Krystal Modèle Port
FUTILE generic Luna-Protocol-1.5B (941 Mo, Q4_K_M) 3124
INTERESSANT semantic Hermes-3-3B ou 8B (selon config) 3125

L'intuition est simple : un "lol" ou un "nm just chillin u" ne mérite pas d'invoquer un modèle de 8 milliards de paramètres. Le petit modèle fine-tuné Luna 1.5B, entraîné sur 200 000 échantillons Discord, suffit largement pour les échanges légers. En revanche, une question sur la vie, une confidence, ou un débat technique est routée vers le grand modèle qui peut produire une réponse plus riche.

Ce routage économique réduit considérablement la charge sur le serveur LLM : environ 70% des messages sont classés FUTILE et traités par le petit modèle, libérant le grand modèle pour les conversations qui en valent vraiment la peine.

L'axe émotionnel : valence et arousal

Mais ce n'est pas tout. Sapphire utilise le même mécanisme de centroids sur un axe indépendant pour évaluer l'émotion du message :

Il y a quatre centroids émotionnels :

Pôle Exemples
positive "hell yeah", "love that", "this is great"
negative "shut up", "i hate this", "this sucks"
high_arousal "WHAT THE HELL", "omg omg omg", "AAAAA"
low_arousal "just chilling", "meh", "i guess"

Le score se calcule comme une différence de similarité sur chaque axe :

valence = sim(emb, positive) - sim(emb, negative)     # [-1, +1]
arousal = sim(emb, high_arousal) - sim(emb, low_arousal)  # [-1, +1]

Valence mesure si le message est positif ou négatif. Arousal mesure son intensité émotionnelle. Ensemble, ils forment le modèle circumplex de l'affect (Russell, 1980), le même modèle psychologique qui a inspiré le chatbot PARRY en 1972.

Les variables de ressentiment : comment les émotions contrôlent le LLM

C'est là que l'inspiration de PARRY devient tangible. PARRY (créé par Kenneth Colby en 1972) était un chatbot conçu pour simuler un patient paranoïaque. Il possédait des variables internes (peur, colère, méfiance) qui modifiaient ses réponses. Par exemple, un PARRY "effrayé" répondait de façon plus agressive.

Sapphire fait la même chose, mais avec des variables continues et une méthode plus élégante : les paramètres d'échantillonnage du LLM sont ajustés en temps réel selon l'état émotionnel de la conversation.

La température suit l'arousal
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Arousal Température Effet
-1.0 (calme) 0.40 Basse créativité, réponses prévisibles
0.0 (neutre) 0.70 Créativité par défaut
+1.0 (excité) 1.00 Maximum de randomité, réponses surprenantes

Quand quelqu'un est excité ou énervé (arousal élevé), la température monte. Le modèle produit des réponses plus variées, plus créatives, parfois plus chaotiques, comme un humain qui "s'emballe". Quand la conversation est calme, la température baisse, les réponses sont plus posées.

Le repeat penalty suit la valence
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
Valence Repeat Penalty Effet
-1.0 (négatif) 1.25 Forte pénalité, évite les répétitions
0.0 (neutre) 1.15 Valeur par défaut
+1.0 (positif) 1.05 Faible pénalité, permet les répétitions

Plus la conversation est négative, plus le modèle est poussé à éviter de se répéter, comme quelqu'un qui cherche ses mots dans une dispute tendue. Plus la conversation est positive, plus le modèle peut se permettre des affirmations redondantes, comme une conversation détendue.

L'état émotionnel cumulatif

Ces scores ne portent pas que sur le message immédiat. Un EmotionState maintient une moyenne exponentielle mobile de valence et arousal par session :

class EmotionState:
    def __init__(self, decay=0.85, deadzone=0.06):
        self.decay = decay
        self.deadzone = deadzone

    def update(self, key, valence_delta, arousal_delta):
        if abs(valence_delta) < self.deadzone:
            valence_delta = 0.0
        if abs(arousal_delta) < self.deadzone:
            arousal_delta = 0.0
        s = self._state.setdefault(key, {"valence": 0.0, "arousal": 0.0})
        s["valence"] = s["valence"] * self.decay + valence_delta * (1 - self.decay)
        s["arousal"] = s["arousal"] * self.decay + arousal_delta * (1 - self.decay)
        return s

Le decay à 0.85 signifie que 85% de l'état précédent est conservé à chaque message, et 15% du nouveau signal est intégré. Cela donne une mémoire émotionnelle qui lisse les variations brutales : un seul message négatif ne rend pas le bot "triste", mais une série de messages négatifs fait progressivement dériver son humeur.

En pratique : si quelqu'un commence une conversation de façon très excitée (arousal=+0.8), la température reste élevée pendant plusieurs échanges, même si les messages suivants sont plus calmes. L'émotion met du temps à redescendre, comme un humain qui reste "chaud" après une dispute.


Couche 4 : l'inférence (Krystal)

Krystal est la couche la plus basse : un wrapper autour de llama.cpp qui expose une API compatible OpenAI (/v1/chat/completions). Il tourne en deux instances PM2 :

  • krystal-small : le modèle Luna 1.5B fine-tuné, sur le port 3124, avec affinité CPU 0
  • krystal-large : un modèle Hermes 3B, sur le port 3125, avec affinité CPU 0,1

Les deux instances sont des processus llama-server pré-compilés, lancés avec taskset pour le pinning CPU.

Le fine-tune du modèle Luna a lui aussi évolué depuis le deuxième article : il est maintenant entraîné sur 200 000 échantillons (contre 50 000 précédemment), toujours à partir de Qwen2.5-1.5B-Instruct via QLoRA. Les 200k échantillons sont un sous-ensemble du dataset Discord-Dialogues, filtrés pour ne garder que les conversations les plus naturelles et les plus diverses. Le but : élargir le registre stylistique du modèle sans perdre la flexibilité qui rend le few-shot priming si efficace.


Le schéma complet : un message en transit

Voici ce qui se passe concrètement quand quelqu'un envoie "je suis vraiment triste aujourd'hui" sur Discord :

  1. Jade recoit le message via l'API Gateway Discord. Il le transforme en MessageEvent et l'envoie à Emerald via WebSocket.
  2. Emerald évalue le déclencheur (mention ? nom ? mot-clé ?). C'est une mention directe. Il calcule un délai de concentration, vérifie le cooldown, la session, la fatigue thématique. Il décide de répondre et envoie le message à Sapphire via HTTP.
  3. Sapphire embedd le message avec bge-small-en-v1.5.
    • Classification : le message est plus proche du centroid interessant que du centroid futile (diff = +0.31) -> INTERESSANT
    • Émotion : valence négative (-0.42), arousal modéré (0.35)
    • Routage : direction KRYSTAL_SEMANTIC_URL (port 3125, grand modèle)
    • Paramètres échantillonnage : température = 0.80 (arousal augmenté), repeat_penalty = 1.19 (valence négative)
    • L'état émotionnel de la session est mis à jour avec ces valeurs
  4. Krystal (instance large) génère la réponse avec les paramètres ajustés émotionnellement et la renvoie à Sapphire.
  5. Sapphire stream la réponse vers Emerald avec les métadonnées (label, valence, arousal, statistiques de débogage).
  6. Emerald décide d'ajouter une hésitation ("oh..."), planifie un burst (2 fragments), et choisit une réaction. Il envoie une RespondCommand à Jade.
  7. Jade exécute : attend le délai initial, envoie le premier fragment avec l'hésitation, attend 1.5s, envoie le second fragment. Il montre l'indicateur de frappe pendant toute la génération.

Tout cela en moins de 3 secondes pour l'utilisateur.


Les centroids : pourquoi c'est mieux qu'un classifieur neuronal

Le choix des centroids d'embeddings plutôt qu'un classifieur traditionnel (comme le DistilBERT que j'utilisais avant) mérite une explication.

Un classifieur neuronal apprend une frontière de décision entre les classes, typiquement une transformation non-linéaire qui projette les entrées vers des probabilités. Il est précis, mais :

  • Il nécessite des données d'entraînement étiquetées
  • Il est sensible au changement de distribution (data drift)
  • Il est difficile à interpréter
  • Il doit être ré-entraîné pour ajouter une nouvelle classe

Un centroid, en revanche, est un vecteur moyen d'embeddings d'exemples. La classification se fait par similarité cosinus à ce vecteur moyen. Avantages :

  • Aucun entraînement requis : on calcule juste la moyenne d'embeddings d'exemples choisis à la main
  • Facile à interpréter : on peut regarder quels exemples sont les plus proches du centroid pour comprendre "ce que le centroid a appris"
  • Ajout d'une classe : on ajoute juste un nouveau centroid, sans ré-entraînement
  • Robuste : le centroid est une moyenne, donc les outliers ont peu d'impact

Le vrai pouvoir des centroids, c'est qu'ils transforment un problème de classification en un problème de mesure de distance spatiale. On peut visualiser les catégories comme des régions dans un espace à 384 dimensions (ou en 2D/3D après réduction dimensionnelle PCA/t-SNE).

Visualisation 3D des centroids

En pratique, voici à quoi ressemblent les centroids de classification dans l'espace d'embedding. Chaque point est un message d'exemple, projeté en 3D par PCA (les 384 dimensions originales sont réduites à 3 pour la visualisation). Les points bleus sont les messages futiles, les points jaunes les messages intéressants. Les 20 marqueurs en diamant sont les centroids k-means (10 par classe, seed=42). Passez la souris sur un point pour voir le texte original de l'exemple.

Deux exemples de test sont affichés en rouge : "lol" (classé futile) et "i feel sad today" (classé intéressant). Même après réduction de 384 à 3 dimensions (14,7% de variance expliquée), les deux clusters sont clairement séparés. L'annotation en haut montre les comptes exacts et la zone ambiguë.

Le centroid du message d'entrée se promène dans cet espace en fonction de son contenu. La classification FUTILE/INTERESSANT consiste simplement à mesurer quel centroid est le plus proche par similarité cosinus. On peut ainsi représenter chaque message comme un point dans un espace à multiples dimensions, chaque dimension correspondant à une propriété sémantique.


Ce que ça change en pratique

Les utilisateurs ne voient pas les couches, les centroids, ou les ajustements de température. Mais ils ressentent les effets :

  • Réponses plus rapides pour les messages simples (le petit modèle est 2x plus rapide et gère 70% du trafic)
  • Ton adaptatif : si vous êtes énervé, le bot "sent" l'énervement et adapte son style
  • Cohérence cross-plateforme : un bot Matrix et un bot Discord partagent le même cerveau et le même état émotionnel
  • Ton authentique préservé : le fine-tune + few-shot + routage intelligent évite les réponses corporate

Le passage à 200k échantillons d'entraînement pour le petit modèle a encore renforcé ces effets : le modèle capture mieux la diversité des conversations Discord sans perdre la malléabilité que permet le few-shot priming.


L'infrastructure complète

Voici les services qui tournent actuellement :

Service Technologie Port(s) Rôle
Pixieglow TypeScript (Bun) -- Adaptateur Matrix
Jade TypeScript (esbuild) -- Adaptateur Discord
Emerald TypeScript (Bun) 3126 (WebSocket) Cerveau / décisions
Sapphire Python (FastAPI) 3123 (HTTP) Classifieur + émotion
Krystal small llama.cpp (PM2) 3124 Petit modèle (1.5B, futile)
Krystal large llama.cpp (PM2) 3125 Grand modèle (3B+, interessant)

Les dépendances entre services sont unidirectionnelles : l'adaptateur dépend d'Emerald, Emerald dépend de Sapphire, Sapphire dépend de Krystal. Le graphe reste acyclique. Chaque service peut être redémarré indépendamment.


Conclusion

Diviser Luna Protocol en quatre couches n'a pas été qu'un exercice d'architecture. C'était une réponse à des limitations concrètes : impossibilité de supporter Matrix, manque de conscience émotionnelle, absence de priorisation intelligente des messages.

Aujourd'hui, le système est plus robuste (un crash du LLM ne tue pas le bot), plus extensible (un adaptateur Telegram ou WhatsApp suivrait le même protocole WebSocket), et plus "vivant" : le bot adapte son comportement, son ton, et même les paramètres du LLM à l'état émotionnel perçu de la conversation.

Les centroids d'embeddings sont l'élément clé qui rend tout cela possible sans complexité démesurée : pas de réseau de neurones entraîné, pas de pipeline de données étiquetées, juste des moyennes de vecteurs et des similarités cosinus. C'est une technique simple, incroyablement efficace, et terriblement sous-estimée.

Ressource Lien
Site web du projet protocol-luna.github.io
Pixieglow protocol-luna/pixieglow
Emerald protocol-luna/emerald
Sapphire protocol-luna/sapphire
Krystal protocol-luna/krystal
Article 1 : le bot Discord Luna Protocol : j'ai créé un bot Discord autonome
Article 2 : le fine-tuning Luna Protocol : pourquoi j'ai fine-tuné un modèle de 1,5B

Luna Protocol:大脑复用、情绪分类、有趣/无聊路由

Luna Protocol 从单体拆成四层架构:适配器、大脑、情绪分类器、推理。centroid embedding、情绪变量、valence/arousal 调 LLM 参数。

Luna Protocol:大脑复用、情绪分类、有趣/无聊路由

前两篇文章里我把 Luna Protocol 讲成单个 Discord 机器人,复杂行为系统加微调模型。但架构早就变了。当初的单体:一个 Node.js 进程管 Discord 机器人、行为、LLM 调用,拆成四个独立层,各有职责、语言、生命周期。

拆分带来意外好处:多平台"大脑"复用,按 LLM 参数动态调的情绪分类系统,按对话感知重要性在两模型间路由消息。

演进不是一刀切:有机生长。我先从机器人仓库拆 server/ 目录,一边 Krystal 一边剩下 Jade 当 Discord 适配器。再拿 Jade 的 llm-core 和事件总线做 Pixieglow(Matrix 适配器)。接着 Sapphire 上 DistilBERT 做 GENERIC/SEMANTIC 分类。结果不服,换 embedding centroid,更好加示例更准,分类变 FUTILE/INTERESSANT。再加 valence 和 arousal centroid 调 LLM 温度和 repeat penalty。最后把 Jade 和 Pixieglow 重复代码全抽了造 Emerald,共用大脑,Jade 和 Pixieglow 退成 socket 驱动的瘦客户端。

中间我还搭网站记录进展:protocol-luna.github.io。

这篇讲为什么切这几层,每层服务到底干什么,centroid(embedding 平均向量)和怨念变量(70 年代 PARRY 聊天机器人来的灵感)怎么把简单 Discord 机器人变成跨平台还出奇一致的系统。


单体的问题

最初 Luna Protocol 是一个 Node.js 进程。管:

  • Discord 连接(Eris 库)
  • 触发判定(mention、关键词、follow-up……)
  • 人类行为模拟(typo、犹豫、睡觉……)
  • 本地 llama.cpp HTTP 调用
  • session 管理和反 spam
  • TTS 管线

全一进程,类型化事件总线(TypedBus)串着。能跑,但有墙:

  • 加 Matrix 客户端要复制整套行为代码
  • LLM 和机器人同仓库:server/ 目录早就在,但动一边必碰另一边
  • 无差别处理: "lol" 和存在主义问题走同一套
  • 情绪失忆:机器人"没感觉"

切层全解了。


四层

现在 Luna Protocol 架构是四级漏斗:

Matrix / Discord
      |
      v
  [适配器]      Pixieglow(Matrix)/ Jade(Discord)
      |
      v
  [大脑]            Emerald(WebSocket,3126 端口)
      |
      v
  [分类]          Sapphire(HTTP,3123 端口)
      |
      v
  [推理]          Krystal(llama.cpp,3124 / 3125 端口)

每层独立重启、升级、换掉。


第 1 层:适配器(Pixieglow 和 Jade)

最薄的两层。只做一件事:把聊天平台事件翻译成往 Emerald 发的标准协议:

  • Jade 是 Discord 适配器。Eris 库连 Discord,经 WebSocket 把消息转发 Emerald。还管 TTS 管线(Piper 语音合成、转 OGG、传 Discord)。
  • Pixieglow 是 Matrix 适配器。直接调 Matrix Client-Server HTTP API(不用 SDK),长轮询 sync。没 TTS。

两适配器共享 emerald-client.ts 定的同一 WebSocket 协议:

type ClientId = "jade" | "pixieglow";

// 事件(适配器 → Emerald)
type InEvent = MessageEvent | ReadyEvent | BotMessageEvent | PresenceEvent;

// 命令(Emerald → 适配器)
type OutCommand = RespondCommand | TypingCommand | SetPresenceCommand
                | SpontaneousCommand | ForgotCommand;

两适配器同接口证明复用成立:同一个"大脑"(Emerald)同时伺候 Discord 机器人和 Matrix 机器人,行为一致。协议是声明式的:Emerald 不教适配器怎么发消息,告诉它发什么(文本加延迟,可能带 burst 计划、reaction 等)。各适配器按平台具体执行。

这架构的力:加 Telegram、Signal 之类,写个实现 WebSocket 协议的适配器就行。


第 2 层:大脑(Emerald)

Emerald 是中央决策服务。3126 端口听 WebSocket,管:

  • 触发判定:mention、DM、名字、关键词、follow-up、随机
  • 行为模拟:注意力延迟、typo、犹豫、忘事、burst、话题疲劳
  • 睡眠周期:sleep / slow / short 模式
  • session 管理:冷却、session 上限、反 spam
  • 往 Sapphire 路由:发消息,收流式回复

Emerald 是促成复用的中央层,也是拆分受益最大的。以前每种行为(typo、burst、hesitation)和 Discord 代码绞在一起。现在 behavior/ 下独立模块:

emerald/src/behavior/
  burst.ts         -- 连发消息计划
  mannerisms.ts    -- 延迟、犹豫、reaction、忘事
  sleep.ts         -- 睡眠时间判定
  typo.ts          -- typo 模拟(AZERTY/QWERTY)

大脑不知道自己在哪个平台。它收带 clientId("jade" 或 "pixieglow")的 MessageEvent,做决定,回命令。执行适配器管。


第 3 层:情绪分类器(Sapphire)

Sapphire 技术上最有意思。是 Python FastAPI 写的 LLM 中间件,四个关键角色:

  1. 二分类 FUTILE / INTERESSANT 走 embedding centroid
  2. 情绪打分(valence / arousal)走 centroid
  3. 往 Krystal 路由后端(小模型对大模型)
  4. few-shot 注入和 session 管理

centroid:分类的心脏

centroid 概念简单:embedding 向量集合的平均。实际我攒几百条消息示例,过 embedding 模型(BAAI/bge-small-en-v1.5,384 维),平均向量。

两颗分类 centroid:

  • futile_centroid:约 683 条琐碎消息("lol"、"ok"、"hello"),k-means(k=10,seed=42)
  • interessant_centroid:约 678 条实质消息(技术、个人、哲学),k-means(k=10,seed=42)

消息进来:

def classify(text, embedder, futile_centroids, interessant_centroids):
    emb = embedder.query_embed(text)                     # 384 维向量
    sim_f = max(cos(emb, c) for c in futile_centroids)     # 10 个取最大
    sim_i = max(cos(emb, c) for c in interessant_centroids) # 10 个取最大
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

每类分数是 10 个 centroid 里最大余弦相似度。抓住每类子类:问候和道别都靠近某颗 futile centroid,哪怕 embedding 空间里离得远。启动 k-means 一次,运行全点积:不训练,不用 GPU。

为什么两模型?

分类结果定调哪个 LLM 后端:

标签 Krystal 后端 模型 端口
FUTILE generic Luna-Protocol-1.5B(941M,Q4_K_M) 3124
INTERESSANT semantic Hermes-3-3B 或 8B(看配置) 3125

直觉简单:"lol" 或 "nm just chillin u" 不值得调 80 亿参数模型。Discord 微调小 Luna 1.5B(20 万样本训)应付闲聊足够。人生问题、掏心窝、硬核技术 debate 路由大模型给更厚回复。

经济路由大幅减 LLM 服务器负载:约 70% 消息判 FUTILE 走小模型,大模型留给真值得的对话。

情绪轴:valence 和 arousal

不止这些。Sapphire 同 centroid 机制独立轴给消息打情绪分:

四颗情绪 centroid:

极 示例
positive "hell yeah"、"love that"、"this is great"
negative "shut up"、"i hate this"、"this sucks"
high_arousal "WHAT THE HELL"、"omg omg omg"、"AAAAA"
low_arousal "just chilling"、"meh"、"i guess"

分数是两轴相似度差:

valence = sim(emb, positive) - sim(emb, negative)     # [-1, +1]
arousal = sim(emb, high_arousal) - sim(emb, low_arousal)  # [-1, +1]

valence 量正负。arousal 量情绪强度。合起来是情感环状模型(Russell,1980),1972 年 PARRY 聊天机器人灵感来源的同一心理学模型。

怨念变量:情绪怎么调 LLM

PARRY 灵感落地的点。PARRY(Kenneth Colby 1972 年造)是装偏执病人的聊天机器人。它有内部变量(恐惧、愤怒、猜疑)改回复。比如"害怕"的 PARRY 回得更冲。

Sapphire 同样的事,连续变量加更优雅的方法:LLM 采样参数按对话情绪状态实时调。

温度跟 arousal 走
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Arousal 温度 效果
-1.0(平静) 0.40 低 creativity,可预测回复
0.0(中性) 0.70 默认 creativity
+1.0(激动) 1.00 最大随机,意外回复

人激动上头(arousal 高),温度升。模型输出更多变更 creative 甚至混乱,像人"上头"。对话平静温度降,回复稳。

repeat penalty 跟 valence 走
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
Valence Repeat Penalty 效果
-1.0(负) 1.25 重罚,避重复
0.0(中性) 1.15 默认
+1.0(正) 1.05 轻罚,允许重复

对话越负,模型越被推着别复读,像紧张吵架找词的人。对话越正,越允许肯定式复读,像松弛闲聊。

累积情绪状态

分数不只看当下消息。EmotionState 按 session 维护 valence 和 arousal 指数滑动平均:

class EmotionState:
    def __init__(self, decay=0.85, deadzone=0.06):
        self.decay = decay
        self.deadzone = deadzone

    def update(self, key, valence_delta, arousal_delta):
        if abs(valence_delta) < self.deadzone:
            valence_delta = 0.0
        if abs(arousal_delta) < self.deadzone:
            arousal_delta = 0.0
        s = self._state.setdefault(key, {"valence": 0.0, "arousal": 0.0})
        s["valence"] = s["valence"] * self.decay + valence_delta * (1 - self.decay)
        s["arousal"] = s["arousal"] * self.decay + arousal_delta * (1 - self.decay)
        return s

decay 0.85 意思是每消息保留 85% 旧状态,融 15% 新信号。得到情绪记忆抹平剧烈抖动:一条负消息不让机器人"难过",一连串负的慢慢带偏心情。

实际:有人开场很激动(arousal=+0.8),后面消息平了温度还高几轮。情绪下来慢,像人吵完还"热"着。


第 4 层:推理(Krystal)

Krystal 最底层:llama.cpp 套 OpenAI 兼容 API(/v1/chat/completions)壳。PM2 起两实例:

  • krystal-small:Luna 微调 1.5B 模型,3124 端口,CPU 亲和 0
  • krystal-large:Hermes 3B 模型,3125 端口,CPU 亲和 0、1

两实例是预编译 llama-server 进程,taskset 绑 CPU。

Luna 微调相对第二篇也进化了:训练到 20 万样本(之前 5 万),还是 Qwen2.5-1.5B-Instruct QLoRA。20 万是从 Discord-Dialogues 子集滤的,留最自然最多样的对话。目标:拓宽小模型风格 register,不丢 few-shot 启动的弹性。


全图:一句话旅行

"我今天真难过"发 Discord 上,具体发生什么:

  1. Jade 经 Discord Gateway API 收消息。转 MessageEvent 经 WebSocket 发 Emerald。
  2. Emerald 判触发(mention?名字?关键词?)。直接 mention。它算注意力延迟,查冷却、session、话题疲劳。定回复,经 HTTP 发 Sapphire。
  3. Sapphire 拿 bge-small-en-v1.5 embed 消息。
    • 分类:消息离 interessant centroid 比 futile 近(diff = +0.31)→ INTERESSANT
    • 情绪:valence 负(-0.42),arousal 中(0.35)
    • 路由:KRYSTAL_SEMANTIC_URL(3125 端口,大模型)
    • 采样参数:温度 = 0.80(arousal 推高),repeat_penalty = 1.19(valence 负)
    • session 情绪状态更新
  4. Krystal(大实例)按情绪调参生成回复,回 Sapphire。
  5. Sapphire 流式回 Emerald,带元数据(标签、valence、arousal、debug 统计)。
  6. Emerald 定加犹豫("oh……"),排 burst(2 段),挑 reaction。发 RespondCommand 给 Jade。
  7. Jade 执行:等初始延迟,发第一段带犹豫,1.5 秒后发第二段。生成全程打字指示器开着。

用户侧全程不到 3 秒。


centroid:为什么比神经分类器好

Sapphire 以前 DistilBERT 分类器,值得解释为什么换 embedding centroid。

神经分类器学类间决策边界,一般非线性投影到概率。准,但:

  • 要标注训练数据
  • 数据漂移敏感(data drift)
  • 难解释
  • 加类要重训

centroid 是示例 embedding 平均向量。分类靠和平均向量的余弦相似度。好处:

  • 零训练:手挑示例 embedding 平均一下
  • 好解释:看离 centroid 最近的示例就知道"它学了什么"
  • 加类:加颗 centroid,不重训
  • 稳健:平均抹掉 outlier

centroid 真威力是把分类变成空间距离度量。类别是 384 维空间区域(PCA/t-SNE 降 2D/3D 可视化)。

centroid 3D 可视化

实际分类 centroid 在 embedding 空间长这样。每点一条示例消息,PCA 压 3D(384 维压 3 维看)。蓝点 futile 消息,黄点 interessant 消息。20 颗钻石标记是 k-means centroid(每类 10 颗,seed=42)。悬停看示例原文。

两条红色测试示例:"lol"(判 futile),"i feel sad today"(判 interessant)。384 压 3 维只剩 14.7% 解释方差,两簇还清楚分开。顶部注记总样本数和模糊区。

输入消息的 centroid 在空间里按内容飘。FUTILE/INTERESSANT 分类就是量它离哪颗 centroid 余弦最近。每条消息能看成多维空间一点,每维对应某种语义属性。


实际改变什么

用户看不见层、centroid、温度调。但感觉得到:

  • 简单消息回更快(小模型快 2 倍,吃 70% 流量)
  • 语气自适应:你急机器人"感"到急,风格跟变
  • 跨平台一致:Matrix 机器人和 Discord 机器人共享大脑和情绪状态
  • 本味保住:微调加 few-shot 加智能路由,不出企业腔

小模型训到 20 万样本又强化了这些:Discord 对话多样性吃更好,few-shot 弹性还在。


完整 infra

现在跑的服务:

服务 技术 端口 角色
Pixieglow TypeScript(Bun) -- Matrix 适配器
Jade TypeScript(esbuild) -- Discord 适配器
Emerald TypeScript(Bun) 3126(WebSocket) 大脑 / 决策
Sapphire Python(FastAPI) 3123(HTTP) 分类加情绪
Krystal small llama.cpp(PM2) 3124 小模型(1.5B,futile)
Krystal large llama.cpp(PM2) 3125 大模型(3B+,interessant)

服务依赖单向:适配器依赖 Emerald,Emerald 依赖 Sapphire,Sapphire 依赖 Krystal。图无环。每服务独立重启。


结语

Luna Protocol 拆四层不只是架构练习。是对具体墙的回应:Matix 支持不了,情绪感知没有,消息轻重不分。

现在系统更稳(LLM crash 不杀机器人),更能长(Telegram、WhatsApp 适配器走同一 WebSocket 协议就行),更"活":行为、语气、甚至 LLM 参数都随感知到的对话情绪变。

embedding centroid 是让一切成立的关键,还简单得离谱:不训神经网络,不搞标注管线,平均向量加余弦相似度。简单,巨有效,严重被低估。

资源 链接
项目站 protocol-luna.github.io
Pixieglow protocol-luna/pixieglow
Emerald protocol-luna/emerald
Sapphire protocol-luna/sapphire
Krystal protocol-luna/krystal
第 1 篇:Discord 机器人 Luna Protocol:我做了个装人类的 Discord 机器人
第 2 篇:微调 Luna Protocol:为什么我在 5 万 Discord 样本上微调 15 亿参数模型

Luna Protocol:頭の共有、感情分類、おもしろ・くだらないルーティング

Luna Protocolはモノリスから4層アーキテクチャへ変わった。アダプター、brain、感情分類器、inferenceだ。内容は埋め込みcentroid、おもしろ・くだらないルーティング、価位と覚醒度によるLLMパラメータ調整。

Luna Protocol:頭の共有、感情分類、おもしろ・くだらないルーティング

前の2本の記事では、Luna Protocolを複雑な挙動システムと微調整モデルを持つ単体のDiscordボットとして紹介した。でもアーキテクチャはその後かなり変わった。モノリスだったもの、つまりDiscordボットも挙動もLLM呼び出しも扱う単一Node.jsプロセスが、4つの独立層に変わった。各々独自の責務、独自の言語、独自のライフサイクルを持つ。

この分離は思いがけない利益を運んだ。複数プラットフォーム間の「頭」の共有、LLMパラメータを動的調整する感情分類システム、会話の知覚重要度で2モデル間を振り分ける賢いルーティングだ。

進化は一気に起きたんじゃない。有機的な道をたどった。まずボットリポジトリからserver/フォルダを分けて、Krystalを作ってJadeをDiscordアダプターとして残した。次にJadeのllm-coreとイベントバスを引き継いでPixieglow(Matrixアダプター)を作った。次にDistilBERTでGENERIC/SEMANTIC分類を入れるSapphireが来た。結果は決定的じゃなくて、埋め込みcentroid経由になった。例追加に柔らかくて正確だからだ。分類はFUTILE/INTERESSANTになった。最後にLLMの温度とrepeat penaltyを調整する価位と覚醒度のcentroidを足した。締めにJadeとPixieglowの重複コード全部を剥がしてEmerald、共有の頭を作って、JadeとPixieglowを単純なsocket-drivenクライアントに変えた。

並行して、プロジェクト進捗をたどるウェブサイトを保った。protocol-luna.github.ioだ。

この記事では層をどうなぜ切ったか、各サービスが正確に何するか、centroid(埋め込みの平均ベクトル)や感情変数(70年代チャットボットPARRY由来)みたいな概念が単なるDiscordボットを驚くほど一貫したマルチプラットフォームシステムにどう変えたか語る。


モノリス問題

最初、Luna Protocolは単一Node.jsプロセスに収まってた。コードが扱ってたのはこうだ。

  • Discord接続(Erisライブラリ経由)
  • 起動評価(メンション、キーワード、フォローアップなど)
  • 人間挙動の再現(タイプミス、ためらい、睡眠など)
  • ローカルLLMサーバー(llama.cpp)へのHTTP呼び出し
  • セッションとスパム対策の管理
  • TTSパイプライン

全部同じプロセスで、型付きイベントバス(TypedBus)経由で通信してた。動いてた。でも限界があった。

  • 行動コード全部を複製せずMatrixクライアントを足せない
  • LLMとボットが同じリポジトリ:server/フォルダはすでにあったけど、片方を触らずもう片方を進化させられない
  • 無差別処理:各メッセージが同じ扱いだった。「lol」も実存の問いもね
  • 感情健忘:ボットは何も「感じ」なかった

層分割がこの問題全部を解いた。


4層

今のLuna Protocolアーキテクチャは4水準の漏斗として組まれてる。

Matrix / Discord
      |
      v
  [ADAPTATEURS]      Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [BRAIN]            Emerald (WebSocket, port 3126)
      |
      v
  [CLASSIFIEUR]      Sapphire (HTTP, port 3123)
      |
      v
  [INFERENCE]        Krystal (llama.cpp, ports 3124 / 3125)

各層は独立に再起動も更新も置換もできる。


層1:アダプター(PixieglowとJade)

いちばん単純な層だ。唯一の仕事はメッセージングプラットフォームのイベントをEmerald向け標準化プロトコルに翻訳することだ。

  • JadeはDiscordアダプターだ。ErisライブラリでDiscord接続して、WebSocket経由でメッセージをEmeraldへ転送する。TTSパイプラインも扱う(Piper経由の音声合成、OGG変換、Discordアップロード)。
  • PixieglowはMatrixアダプターだ。MatrixのHTTP Client-Server APIを直接使う(SDKなし)。long-poll sync付きだ。TTSはない。

両アダプターはemerald-client.ts定義の同じWebSocketプロトコルを共有する。

type ClientId = "jade" | "pixieglow";

// イベント(アダプターからEmeraldへ)
type InEvent = MessageEvent | ReadyEvent | BotMessageEvent | PresenceEvent;

// コマンド(Emeraldからアダプターへ)
type OutCommand = RespondCommand | TypingCommand | SetPresenceCommand
                | SpontaneousCommand | ForgotCommand;

同じインターフェースの2アダプター存在が共有を証明する。同じ「頭」(Emerald)がDiscordボットにもMatrixボットにも無差別に仕えるんだ。挙動は同一だ。プロトコルは宣言的だ。Emeraldはアダプターにメッセージ送信の方法を言わない。何を送るか言うんだ(遅延付きテキスト、バースト計画、リアクションなど)。各アダプターがプラットフォーム別に具体的実行を実装する。

このアーキテクチャの強さはここだ。TelegramやSignalなどの対応を足すには、WebSocketプロトコルを実装するアダプターを書くだけでいい。


層2:頭(Emerald)

Emeraldは中央決定サービスだ。ポート3126でWebSocketを聞いて、こう管理する。

  • 起動評価:メンション、DM、名前、キーワード、フォローアップ、ランダム
  • 挙動再現:集中遅延、タイプミス、ためらい、忘却、バースト、話題疲れ
  • 睡眠周期:sleepやslowやshortモード
  • セッション管理:クールダウン、セッション上限、スパム対策
  • Sapphireへの振り分け:メッセージ送信、配信応答の受信

Emeraldは共有を可能にした中央サービスで、分離の利益がいちばん大きかった。以前、各挙動(typo、burst、hesitation)はDiscordコードと絡み合ってた。今はbehavior/内の専用モジュールにある。

emerald/src/behavior/
  burst.ts         -- 連投メッセージの計画
  mannerisms.ts    -- 遅延、ためらい、反応、忘却
  sleep.ts         -- 睡眠時間の評価
  typo.ts          -- タイプミス再現(AZERTYやQWERTY)

頭はどのプラットフォームで回ってるか知らない。clientId(「jade」か「pixieglow」)付きMessageEventを受けて、決定して、コマンドを返す。残りはアダプターがやる。


層3:感情分類器(Sapphire)

Sapphireは技術的にいちばん面白いサービスだ。FastAPI製PythonのLLMミドルウェアで、4つの重要役割を果たす。

  1. 埋め込みcentroidによる二値分類FUTILE / INTERESSANT
  2. centroidによる感情スコア(価位や覚醒度)
  3. Krystalへのバックエンド振り分け(小モデル対大モデル)
  4. few-shot注入とセッション管理

centroid:分類の心臓

centroidは単純な概念だ。埋め込みベクトル集合の平均だ。具体的には、何百ものメッセージ例を集めて、埋め込みモデル(BAAI/bge-small-en-v1.5、384次元)に通して、得たベクトルを平均した。

分類centroidは2つある。

  • futile_centroid:約683の他愛ないメッセージ(「lol」「ok」「hello」)。k-means経由(k=10、seed=42)
  • interessant_centroid:約678の実質メッセージ(技術、個人、哲学)。k-means経由(k=10、seed=42)

メッセージが来たらこうだ。

def classify(text, embedder, futile_centroids, interessant_centroids):
    emb = embedder.query_embed(text)                     # 384次元ベクトル
    sim_f = max(cos(emb, c) for c in futile_centroids)     # 10個の最大
    sim_i = max(cos(emb, c) for c in interessant_centroids) # 10個の最大
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

クラス別スコアは10 centroid中の最大コサイン類似度だ。これで各カテゴリ内サブタイプを掴む。あいさつも別れも埋め込み空間で遠くてもくだらない10 centroidのどれか近くに落ちる。起動時k-meansだけで実行時は内積だけだ。訓練なし、GPUなしだ。

なぜ2モデルか

この分類結果で呼び出すLLMバックエンドが決まる。

ラベル Krystalバックエンド モデル ポート
FUTILE generic Luna-Protocol-1.5B(941MB、Q4_K_M) 3124
INTERESSANT semantic Hermes-3-3Bか8B(設定による) 3125

直観は単純だ。「lol」とか「nm just chillin u」に80億パラメータモデルを呼ぶ価値はない。Discord 20万サンプルで微調整した小モデルLuna 1.5Bで軽いやり取りには十分だ。逆に、人生の問いや打ち明けや技術議論は豊かな応答を出せる大モデルへ振る。

この経済的振り分けはLLMサーバー負荷をかなり減らす。メッセージの約70パーセントはFUTILE分類で小モデル処理になって、本当に価値ある会話向けに大モデルが空く。

感情軸:価位と覚醒度

でも全部じゃない。Sapphireは同じcentroid仕組みを独立軸で使ってメッセージ感情を評価する。

感情centroidは4つある。

極 例
positive "hell yeah"、"love that"、"this is great"
negative "shut up"、"i hate this"、"this sucks"
high_arousal "WHAT THE HELL"、"omg omg omg"、"AAAAA"
low_arousal "just chilling"、"meh"、"i guess"

スコアは各軸の類似度差分で計算する。

valence = sim(emb, positive) - sim(emb, negative)     # [-1, +1]
arousal = sim(emb, high_arousal) - sim(emb, low_arousal)  # [-1, +1]

価位はメッセージが肯定か否定か測る。覚醒度は感情強度を測る。合わせると情動のcircumplexモデルになる(Russell、1980年)。1972年のチャットボットPARRYに着想を与えたのと同じ心理学モデルだ。

感情変数:感情がLLMをどう操るか

PARRY由来の着想が具体になるところだ。PARRY(Kenneth Colby作、1972年)はパラノイア患者を真似る設計のチャットボットだった。内部変数(恐怖、怒り、不信)が応答を変えた。たとえば「怯えた」PARRYは攻撃的に応答した。

Sapphireは同じことをする。でも連続変数でもっと優雅な方法でね。LLMの抽出パラメータは会話の感情状態に応じてリアルタイム調整される。

温度は覚醒度に従う
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
覚醒度 温度 効果
-1.0(静) 0.40 低創造性、予測可能応答
0.0(中立) 0.70 デフォルト創造性
+1.0(興奮) 1.00 最大ランダム性、驚く応答

誰かが興奮か立腹してる(高覚醒度)と温度が上がる。モデルは多様で創造的で、ときどき混沌な応答を出す。「熱く」なる人間みたいにね。会話が静かなら温度は下がって、応答は落ち着く。

repeat penaltyは価位に従う
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
価位 Repeat Penalty 効果
-1.0(否定) 1.25 強い罰、繰り返し回避
0.0(中立) 1.15 デフォルト値
+1.0(肯定) 1.05 弱い罰、繰り返し許容

会話が否定なほど、モデルは繰り返し避けを強く押される。緊迫口論で言葉探す人みたいにね。会話が肯定なほど、冗長な断言を許せる。くつろいだ会話みたいにね。

累積感情状態

このスコアは直後メッセージだけのものじゃない。EmotionStateはセッションごとに価位と覚醒度の指数移動平均を保つ。

class EmotionState:
    def __init__(self, decay=0.85, deadzone=0.06):
        self.decay = decay
        self.deadzone = deadzone

    def update(self, key, valence_delta, arousal_delta):
        if abs(valence_delta) < self.deadzone:
            valence_delta = 0.0
        if abs(arousal_delta) < self.deadzone:
            arousal_delta = 0.0
        s = self._state.setdefault(key, {"valence": 0.0, "arousal": 0.0})
        s["valence"] = s["valence"] * self.decay + valence_delta * (1 - self.decay)
        s["arousal"] = s["arousal"] * self.decay + arousal_delta * (1 - self.decay)
        return s

decay 0.85は各メッセージで前状態の85パーセントを保って、新信号の15パーセントを入れるってことだ。これで感情メモリができて、荒い変動を均す。否定メッセージ1つでボットが「悲しく」はならない。でも否定メッセージ連続で気分は少しずつ流れる。

実際はこうだ。誰かがとても興奮して会話を始めたら(arousal=+0.8)、後続メッセージが静かでも数往復は温度が高いままだ。感情は下がるのに時間がかかる。口論後に「熱い」ままの人間みたいにね。


層4:推論(Krystal)

Krystalはいちばん下の層だ。OpenAI互換API(/v1/chat/completions)を出すllama.cppラッパーだ。PM2インスタンス2つで回る。

  • krystal-small:微調整Luna 1.5Bモデル。ポート3124、CPU affinity 0
  • krystal-large:Hermes 3Bモデル。ポート3125、CPU affinity 0,1

両インスタンスはプリコンパイルllama-serverプロセスで、taskset付きでCPU固定して起動する。

Lunaモデルの微調整も2つ目の記事から進化した。今は200,000サンプルで訓練する(前は50,000)。QLoRA経由でQwen2.5-1.5B-Instructからだ。20万サンプルはDiscord-Dialoguesデータセットの部分集合で、いちばん自然で多様な会話だけ残すよう濾過した。狙い:few-shot primingをあれだけ効かせる柔軟さを失わず、モデルの文体レパートリーを広げることだ。


完全図:通過中の1メッセージ

Discordで「je suis vraiment triste aujourd'hui」って誰かが送ったら具体的にこうなる。

  1. JadeがDiscord API Gateway経由でメッセージを受ける。MessageEventに変えてWebSocketでEmeraldへ送る。
  2. Emeraldが起動を評価する(メンション?名前?キーワード?)。直接メンションだ。集中遅延を計算して、クールダウン、セッション、話題疲れを確認する。返事を決めてHTTPでSapphireへメッセージを送る。
  3. Sapphireがbge-small-en-v1.5でメッセージを埋め込む。
    • 分類:メッセージはfutile centroidよりinteressant centroidに近い(diff = +0.31)→ INTERESSANT
    • 感情:否定価位(-0.42)、中程度覚醒度(0.35)
    • 振り分け:KRYSTAL_SEMANTIC_URL方向だ(ポート3125、大モデル)
    • 抽出パラメータ:温度 = 0.80(覚醒度上昇)、repeat_penalty = 1.19(否定価位)
    • セッション感情状態はこの値で更新する
  4. Krystal(大インスタンス)が感情調整パラメータで応答を生成してSapphireへ返す。
  5. Sapphireが応答をメタデータ付き(ラベル、価位、覚醒度、デバッグ統計)でEmeraldへ配信する。
  6. Emeraldがためらい(「oh...」)追加を決めて、バースト(2断片)を計画して、リアクションを選ぶ。RespondCommandをJadeへ送る。
  7. Jadeが実行する。初期遅延を待って、ためらい付き第1断片を送って、1.5秒待って、第2断片を送る。生成中ずっと入力中表示を出す。

ユーザーには全部3秒未満だ。


centroid:なぜ神経分類器より良いか

埋め込みcentroidを伝統分類器(前に使ってたDistilBERTみたいの)じゃなく選んだのは説明が要る。

神経分類器はクラス間の決定境界を学ぶ。だいたい入力を確率へ映す非線形変換だ。正確だけど、こうだ。

  • ラベル付き訓練データが要る
  • 分布変化(data drift)に弱い
  • 解釈が難しい
  • 新クラス追加に再訓練が要る

centroidは逆に、例埋め込みの平均ベクトルだ。分類はこの平均ベクトルへのコサイン類似度でやる。利点:

  • 訓練不要:手選び例の埋め込み平均を計算するだけだ
  • 解釈しやすい:centroidにいちばん近い例を見れば「centroidが何学んだか」分かる
  • クラス追加:再訓練なしで新centroidを足すだけだ
  • 頑丈:centroidは平均だから外れ値の影響が小さい

centroidの本当の力は分類問題を空間距離測定問題に変えることだ。カテゴリを384次元空間の領域として可視化できる(PCAやt-SNE次元削減後の2Dや3Dで)。

centroidの3D可視化

実際、埋め込み空間の分類centroidはこんな感じだ。各点がメッセージ例で、PCAで3D投影してる(可視化のため元384次元を3に減らす)。青点がくだらないメッセージ、黄点がおもしろメッセージだ。20のダイヤ印がk-means centroidだ(クラス10個ずつ、seed=42)。点にホバーで元の例文が見られる。

テスト例2つが赤で出てる。「lol」(くだらない分類)と「i feel sad today」(おもしろ分類)だ。384から3次元に減らした後も(説明分散14.7パーセント)、2クラスタは明確に分離する。上注釈が正確な数と曖昧域を示す。

入力メッセージのcentroidは内容に応じてこの空間を歩く。FUTILE/INTERESSANT分類はコサイン類似度でどっちのcentroidが近いか測るだけだ。各メッセージを多次元空間の点として表せる。各次元が意味特性に対応する。


実際何が変わるか

ユーザーは層もcentroidも温度調整も見ない。でも効果は感じる。

  • 速い応答:単純メッセージ向けだ(小モデルは2倍速くてトラフィック70パーセントをさばく)
  • 適応する調子:立腹してたらボットは立腹を「感じ」て文体を適応する
  • プラットフォーム横断の一貫性:MatrixボットとDiscordボットは同じ頭と同じ感情状態を共有する
  • 守られる本物調子:微調整とfew-shotと賢い振り分けで事務的な応答を避ける

小モデルの訓練サンプル20万化はこの効果をさらに強めた。few-shot primingが許す柔らかさを失わず、Discord会話の多様性をモデルがよく掴む。


完全土台

今回ってるサービスはこうだ。

サービス 技術 ポート 役割
Pixieglow TypeScript (Bun) -- Matrixアダプター
Jade TypeScript (esbuild) -- Discordアダプター
Emerald TypeScript (Bun) 3126 (WebSocket) 頭や決定
Sapphire Python (FastAPI) 3123 (HTTP) 分類と感情
Krystal small llama.cpp (PM2) 3124 小モデル(1.5B、くだらない)
Krystal large llama.cpp (PM2) 3125 大モデル(3B+、おもしろ)

サービス間依存は一方向だ。アダプターはEmerald依存、EmeraldはSapphire依存、SapphireはKrystal依存だ。グラフは非循環のままだ。各サービスは独立再起動できる。


結論

Luna Protocolを4層に割ったのはアーキテクチャの体操だけじゃなかった。具体的な限界への答えだった。Matrix対応不可、感情意識欠落、メッセージの賢い優先付け欠如だ。

今、仕組みは頑丈だ(LLMクラッシュでボットは死なない)。拡張できる(TelegramやWhatsAppアダプターは同じWebSocketプロトコルに従う)。「生きて」る。ボットは会話の知覚感情状態に挙動も調子も果てはLLMパラメータも適応する。

埋め込みcentroidが全部可能にする鍵要素だ。過大な複雑さなしにね。訓練済み神経ネットなし、ラベル付きデータパイプラインなし。ベクトル平均とコサイン類似度だけだ。単純で、信じられないほど効いて、ひどく過小評価されてる技術だ。

リソース リンク
プロジェクトサイト protocol-luna.github.io
Pixieglow protocol-luna/pixieglow
Emerald protocol-luna/emerald
Sapphire protocol-luna/sapphire
Krystal protocol-luna/krystal
記事1:Discordボット Luna Protocol:Discord自律ボットを作った
記事2:微調整 Luna Protocol:なぜ1.5Bモデルを微調整したか

Luna Protocol: 두뇌 공유, 감정 분류, 흥미와 하찮음 라우팅

Luna Protocol은 모놀리스에서 네 층 아키텍처로 바뀌었어요. 어댑터와 브레인, 감정 분류기, 추론. 임베딩 중심점과 흥미와 하찮음 라우팅, 가수와 각성에 따른 LLM 매개변수 조정 이야기에요.

Luna Protocol: 두뇌 공유, 감정 분류, 흥미와 하찮음 라우팅

앞의 두 글과 글에서는 Luna Protocol을 복잡한 행동 시스템과 미세 조정 모델이 있는 단일 Discord 봇으로 소개했어요. 하지만 아키텍처는 그 이후 많이 바뀌었어요. 모놀리스였던 것이, Discord 봇과 행동, LLM 호출을 함께 다루는 단일 Node.js 프로세스가 네 개의 독립 층으로 바뀌었어요. 각자 책임과 언어, 생애주기가 따로 있어요.

이 분리는 예상 밖 이득을 가져왔어요. 여러 플랫폼 사이의 "두뇌" 공유, LLM 매개변수를 동적으로 조정하는 감정 분류 시스템, 대화의 지각된 중요도에 따라 두 모델 사이 메시지를 가르는 지능적 라우팅.

진화는 한 번에 일어나지 않았어요. 유기적 길을 따랐어요. 먼저 봇 저장소에서 server/ 폴더를 떼어서 한쪽에 Krystal을 만들고 Jade를 Discord 어댑터로 남겼어요. 다음에 Jade의 llm-core와 이벤트 버스를 가져와 Pixieglow(Matrix 어댑터)를 만들었어요. 이어서 DistilBERT로 GENERIC과 SEMANTIC 분류를 넣으려고 Sapphire가 왔어요. 결과가 conclusive하지 않아서 임베딩 중심점으로 넘어갔어요. 예시 보강에 더 다루기 쉽고 더 정확해서요. 분류는 FUTILE과 INTERESSANT가 됐어요. 마지막으로 LLM 온도와 반복 벌점을 조절하려고 가수와 각성 중심점을 더했어요. 끝으로 Jade와 Pixieglow 사이 중복 코드를 전부 걷어내고 공유 브레인 Emerald를 만들어서, Jade와 Pixieglow를 단순한 소켓 조종 클라이언트로 바꿨어요.

나란히 프로젝트 진전을 그리는 웹사이트도 유지했어요. protocol-luna.github.io.

이 글에서는 왜 이 층들을 나눴는지, 각 서비스가 정확히 무엇을 하는지, 중심점(임베딩 평균 벡터)과 원망 변수(70년대 PARRY 챗봇에서 영감을 받음) 같은 개념이 단순 Discord 봇을 놀랍게 일관된 멀티플랫폼 시스템으로 어떻게 바꿨는지 이야기해요.


모놀리스의 문제

처음에 Luna Protocol은 단일 Node.js 프로세스에 들어 있었어요. 코드가 이렇게 다뤘어요.

  • Discord 연결 (Eris 라이브러리 경유)
  • 트리거 평가 (멘션, 키워드, 후속...)
  • 인간 행동 흉내 (오타, 망설임, 수면...)
  • 로컬 LLM 서버(llama.cpp)에 HTTP 호출
  • 세션과 안티스팸 관리
  • TTS 파이프라인

전부 같은 프로세스에 있었고, 타입화된 이벤트 버스(TypedBus)로 소통했어요. 동작했지만 한계가 있었어요.

  • 행동 코드 전체를 복제하지 않고 Matrix 클라이언트를 추가할 수 없어요
  • LLM과 봇이 같은 저장소에 있었어요. server/ 폴더가 이미 있었지만 하나를 건드리지 않고 하나를 발전시킬 수 없어요
  • 무차별 처리: "lol"이든 실존적 질문이든 각 메시지를 같은 방식으로 처리했어요
  • 감정적 기억상실: 봇이 아무것도 "느끼지" 않았어요

층으로 나누기가 이 문제들을 모두 풀었어요.


네 층

현재 Luna Protocol 아키텍처는 네 수준 깔때기로 짜여 있어요.

Matrix / Discord
      |
      v
  [어댑터]      Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [브레인]            Emerald (WebSocket, 포트 3126)
      |
      v
  [분류기]      Sapphire (HTTP, 포트 3123)
      |
      v
  [추론]        Krystal (llama.cpp, 포트 3124 / 3125)

각 층은 독립적으로 다시 시작하고, 고치고, 바꿀 수 있어요.


1층: 어댑터 (Pixieglow와 Jade)

가장 단순한 층이에요. 유일한 일은 메시징 플랫폼 이벤트를 Emerald행 표준 프로토콜로 번역하는 거예요.

  • Jade는 Discord 어댑터예요. Eris 라이브러리로 Discord에 접속하고 WebSocket으로 메시지를 Emerald에 전달해요. TTS 파이프라인도 다뤄요 (Piper 음성 합성과 OGG 변환, Discord 업로드).
  • Pixieglow는 Matrix 어댑터예요. Matrix Client-Server HTTP API를 직접 써요 (SDK 없어요). 롱폴 sync로요. TTS는 없어요.

두 어댑터는 emerald-client.ts에 정한 같은 WebSocket 프로토콜을 공유해요.

type ClientId = "jade" | "pixieglow";

// 이벤트 (어댑터에서 Emerald로)
type InEvent = MessageEvent | ReadyEvent | BotMessageEvent | PresenceEvent;

// 명령 (Emerald에서 어댑터로)
type OutCommand = RespondCommand | TypingCommand | SetPresenceCommand
                | SpontaneousCommand | ForgotCommand;

같은 인터페이스의 어댑터 둘이 존재한다는 것이 공유를 증명해요. 같은 "두뇌"(Emerald)가 Discord 봇과 Matrix 봇을 가리지 않고 섬기고, 행동이 똑같아요. 프로토콜은 선언적이에요. Emerald는 어댑터에게 메시지를 어떻게 보내라고 말하지 않고 무엇을 보내라고 말해요 (지연이 있는 텍스트, 버스트 계획, 반응 등). 각 어댑터는 플랫폼 따라 구체 실행을 구현해요.

이 아키텍처의 힘이 바로 이거예요. Telegram이나 Signal 지원을 더하려면 WebSocket 프로토콜을 구현하는 어댑터를 쓰면 돼요.


2층: 브레인 (Emerald)

Emerald는 중앙 결정 서비스예요. WebSocket으로 포트 3126을 듣고 이렇게 다뤄요.

  • 트리거 평가: 멘션, DM, 이름, 키워드, 후속, 무작위
  • 행동 흉내: 집중 지연, 오타, 망설임, 깜빡함, 버스트, 주제 피로
  • 수면 주기: sleep과 slow, short 모드
  • 세션 관리: 쿨다운, 세션 한도, 안티스팸
  • Sapphire행 라우팅: 메시지 보내기, 스트리밍 답장 받기

Emerald는 공유를 가능하게 한 중앙 서비스고, 분리 이득을 가장 많이 본 곳이에요. 전에는 각 행동(오타, 버스트, 망설임)이 Discord 코드와 얽혀 있었어요. 이제는 behavior/ 전용 모듈에 있어요.

emerald/src/behavior/
  burst.ts         -- 버스트 메시지 계획
  mannerisms.ts    -- 지연, 망설임, 반응, 깜빡함
  sleep.ts         -- 수면 시간 평가
  typo.ts          -- 오타 흉내 (AZERTY와 QWERTY)

브레인은 어떤 플랫폼에서 도는지 몰라요. clientId("jade" 또는 "pixieglow")가 있는 MessageEvent를 받고, 결정을 내리고, 명령을 돌려줘요. 나머지는 어댑터가 맡아요.


3층: 감정 분류기 (Sapphire)

Sapphire는 기술적으로 가장 흥미로운 서비스예요. FastAPI로 쓴 Python LLM 미들웨어로, 네 가지 핵심 역할을 해요.

  1. 임베딩 중심점 경유 이진 분류기 FUTILE과 INTERESSANT
  2. 중심점 경유 감정 점수 (가수와 각성)
  3. Krystal행 백엔드 라우터 (작은 모델 대 큰 모델)
  4. 퓨샷 주입기와 세션 관리자

중심점: 분류의 심장

중심점은 단순한 개념이에요. 임베딩 벡터 집합의 평균이에요. 구체적으로 수백 개 예시 메시지를 모아서 임베딩 모델(BAAI/bge-small-en-v1.5, 384차원)에 통과시키고, 얻은 벡터들을 평균 냈어요.

두 개 분류 중심점이 있어요.

  • futile_centroid: k-means (k=10, seed=42) 경유 하찮은 메시지 ~683개 ("lol", "ok", "hello")
  • interessant_centroid: k-means (k=10, seed=42) 경유 묵직한 메시지 ~678개 (기술, 개인, 철학)

메시지가 오면요.

def classify(text, embedder, futile_centroids, interessant_centroids):
    emb = embedder.query_embed(text)                     # 384차원 벡터
    sim_f = max(cos(emb, c) for c in futile_centroids)     # 10개 중 최대
    sim_i = max(cos(emb, c) for c in interessant_centroids) # 10개 중 최대
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

클래스별 점수는 10개 중심점 중 최대 코사인 유사도예요. 각 범주의 하위 종류를 잡아요. 인사와 작별이 임베딩 공간에서 멀어도 둘 다 하찮은 중심점 10개 중 하나 근처에 떨어져요. 시작할 때 k-means만 하고 실행할 때는 내적만 해요. 훈련도 GPU도 없어요.

왜 두 모델인가요?

이 분류 결과가 어떤 LLM 백엔드를 부를지 정해요.

라벨 Krystal 백엔드 모델 포트
FUTILE generic Luna-Protocol-1.5B (941 Mo, Q4_K_M) 3124
INTERESSANT semantic Hermes-3-3B 또는 8B (설정 따라) 3125

직관은 단순해요. "lol"이나 "nm just chillin u"에 80억 매개변수 모델을 부를 자격이 없어요. Discord 20만 샘플로 미세 조정된 작은 Luna 1.5B 모델이면 가벼운 교환에 충분해요. 반대로 삶에 대한 질문이나 고백, 기술 토론은 더 풍부한 답장을 만들 수 있는 큰 모델로 라우팅돼요.

이 경제적 라우팅이 LLM 서버 부하를 크게 줄여요. 메시지의 약 70퍼센트가 FUTILE로 분류되어 작은 모델이 처리하고, 큰 모델은 정말 값어치 하는 대화에 풀려요.

감정 축: 가수와 각성

하지만 전부가 아니에요. Sapphire는 메시지 감정을 평가하려고 독립 축에 같은 중심점 구조를 써요.

네 개 감정 중심점이 있어요.

극 예시
positive "hell yeah", "love that", "this is great"
negative "shut up", "i hate this", "this sucks"
high_arousal "WHAT THE HELL", "omg omg omg", "AAAAA"
low_arousal "just chilling", "meh", "i guess"

점수는 각 축의 유사도 차이로 계산돼요.

valence = sim(emb, positive) - sim(emb, negative)     # [-1, +1]
arousal = sim(emb, high_arousal) - sim(emb, low_arousal)  # [-1, +1]

가수는 메시지가 긍정적인지 부정적인지 재요. 각성은 감정 강도를 재요. 함께 정동 원형 모델(Russell, 1980년)을 이루고, 1972년 챗봇 PARRY에 영감을 준 것과 같은 심리 모델이에요.

원망 변수: 감정이 LLM을 조종하는 방법

PARRY 영감이 만져지는 지점이 바로 여기예요. PARRY(1972년 Kenneth Colby가 만듦)는 편집증 환자를 흉내 내려고 설계된 챗봇이었어요. 내부 변수(두려움, 분노, 불신)가 답장을 바꿨어요. 예를 들어 "무서운" PARRY는 더 공격적으로 답했어요.

Sapphire는 같은 일을 하지만 연속 변수와 더 우아한 방법으로 해요. 대화의 감정 상태에 따라 LLM 샘플링 매개변수가 실시간으로 조정돼요.

온도가 각성을 따라가요
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
각성 온도 효과
-1.0 (차분) 0.40 낮은 창의성, 예측 가능한 답장
0.0 (중립) 0.70 기본 창의성
+1.0 (흥분) 1.00 최대 무작위, 놀라운 답장

누군가 흥분하거나 화나면 (높은 각성) 온도가 올라가요. 모델이 더 다양하고 창의적이며 가끔 더 혼란스러운 답장을 만들어내요. "달아오르는" 사람처럼요. 대화가 차분하면 온도가 내려가고 답장이 더 가라앉아요.

반복 벌점이 가수를 따라가요
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
가수 반복 벌점 효과
-1.0 (부정적) 1.25 강한 벌점, 반복을 피해요
0.0 (중립) 1.15 기본값
+1.0 (긍정적) 1.05 약한 벌점, 반복을 허용해요

대화가 부정적일수록 모델이 반복을 피하려고 더 밀려요. 긴장된 다툼에서 말을 찾는 사람처럼요. 대화가 긍정적일수록 모델이 중복 긍정을 더 허용해요. 편안한 대화처럼요.

누적 감정 상태

이 점수들은 바로 오는 메시지만 보지 않아요. EmotionState가 세션별 가수와 각성의 지수 이동 평균을 유지해요.

class EmotionState:
    def __init__(self, decay=0.85, deadzone=0.06):
        self.decay = decay
        self.deadzone = deadzone

    def update(self, key, valence_delta, arousal_delta):
        if abs(valence_delta) < self.deadzone:
            valence_delta = 0.0
        if abs(arousal_delta) < self.deadzone:
            arousal_delta = 0.0
        s = self._state.setdefault(key, {"valence": 0.0, "arousal": 0.0})
        s["valence"] = s["valence"] * self.decay + valence_delta * (1 - self.decay)
        s["arousal"] = s["arousal"] * self.decay + arousal_delta * (1 - self.decay)
        return s

0.85 감쇠는 메시지마다 이전 상태 85퍼센트를 유지하고 새 신호 15퍼센트를 섞는다는 뜻이에요. 급격한 변동을 매끄럽게 하는 감정 기억이 생겨요. 부정 메시지 하나가 봇을 "슬프게" 만들지 않지만, 부정 메시지 연속이 서서히 기분을 흘러가게 해요.

실제로요. 누군가 아주 흥분해서 대화를 시작하면 (arousal=+0.8) 이어지는 메시지가 더 차분해도 몇 번 교환 동안 온도가 높게 남아요. 다툼 뒤에도 "열"이 남아 있는 사람처럼 감정이 내려가는 데 시간이 걸려요.


4층: 추론 (Krystal)

Krystal은 가장 낮은 층이에요. OpenAI 호환 API(/v1/chat/completions)를 노출하는 llama.cpp 래퍼. PM2 인스턴스 둘로 돌아요.

  • krystal-small: 미세 조정 Luna 1.5B 모델, 포트 3124, CPU 친화도 0
  • krystal-large: Hermes 3B 모델, 포트 3125, CPU 친화도 0,1

두 인스턴스는 미리 컴파일된 llama-server 프로세스로, CPU 고정을 위해 taskset으로 실행돼요.

Luna 모델 미세 조정도 두 번째 글 이후 바뀌었어요. 이제 200,000개 샘플로 훈련해요 (전에는 50,000개). QLoRA로 Qwen2.5-1.5B-Instruct에서, 여전히요. 200k 샘플은 Discord-Dialogues 데이터셋의 부분집합이고, 가장 자연스럽고 다양한 대화만 남기도록 필터링했어요. 목표: 퓨샷 프라이밍을 효과적으로 만드는 유연함을 잃지 않고 모델의 문체 폭을 넓히기.


전체 그림: 이동 중인 메시지 하나

Discord에 "je suis vraiment triste aujourd'hui"라고 누군가 보내면 실제로 일어나는 일이에요.

  1. Jade가 Discord Gateway API로 메시지를 받아요. MessageEvent로 바꿔 WebSocket으로 Emerald에 보내요.
  2. Emerald가 트리거를 평가해요 (멘션? 이름? 키워드?). 직접 멘션이에요. 집중 지연을 계산하고, 쿨다운과 세션, 주제 피로를 확인해요. 답하기로 하고 HTTP로 메시지를 Sapphire에 보내요.
  3. Sapphire가 bge-small-en-v1.5로 메시지를 임베딩해요.
    • 분류: 메시지가 futile 중심점보다 interessant 중심점에 가까워요 (diff = +0.31) → INTERESSANT
    • 감정: 부정적 가수 (-0.42), 중간 각성 (0.35)
    • 라우팅: KRYSTAL_SEMANTIC_URL 방향 (포트 3125, 큰 모델)
    • 샘플링 매개변수: 온도 = 0.80 (올라간 각성), repeat_penalty = 1.19 (부정적 가수)
    • 세션 감정 상태가 이 값들로 고쳐져요
  4. Krystal (large 인스턴스)이 감정 조정된 매개변수로 답장을 만들고 Sapphire에 돌려줘요.
  5. Sapphire가 메타데이터(라벨, 가수, 각성, 디버그 통계)와 함께 Emerald로 답장을 스트리밍해요.
  6. Emerald가 망설임("oh...") 추가를 정하고, 버스트(조각 2개)를 계획하고, 반응을 골라요. RespondCommand를 Jade에 보내요.
  7. Jade가 실행해요. 초기 지연을 기다리고, 망설임이 있는 첫 조각을 보내고, 1.5초 기다리고, 두 번째 조각을 보내요. 만드는 내내 입력 중 표시기를 보여줘요.

사용자에게 전부 3초 미만에요.


중심점: 신경 분류기보다 나은 이유

전통 분류기(전에 쓰던 DistilBERT 같은) 대신 임베딩 중심점을 고른 것은 설명할 만해요.

신경 분류기는 클래스 사이 결정 경계를 배워요. 보통 입력을 확률로 투영하는 비선형 변환이에요. 정확하지만요.

  • 라벨된 훈련 자료가 필요해요
  • 분포 변화(data drift)에 민감해요
  • 해석하기 어려워요
  • 새 클래스를 더하려면 다시 훈련해야 해요

반면 중심점은 예시 임베딩들의 평균 벡터예요. 분류는 이 평균 벡터와의 코사인 유사도로 해요. 이점:

  • 훈련 필요 없어요: 손으로 고른 예시 임베딩 평균을 계산할 뿐이에요
  • 해석 쉬워요: 중심점에 가장 가까운 예시들을 봐서 "중심점이 배운 것"을 이해할 수 있어요
  • 클래스 추가: 다시 훈련 없이 새 중심점을 더하면 돼요
  • 강건해요: 중심점은 평균이라 이상치가 영향이 적어요

중심점의 진짜 힘은 분류 문제를 공간 거리 측정 문제로 바꾼다는 거예요. 범주를 384차원 공간의 영역으로 시각화할 수 있어요 (PCA와 t-SNE 차원 축소 뒤 2D와 3D로).

중심점 3D 시각화

실제로 분류 중심점이 임베딩 공간에서 이렇게 보여요. 각 점이 PCA로 3D 투영된 예시 메시지예요 (시각화를 위해 원래 384차원을 3으로 줄였어요). 파란 점은 하찮은 메시지, 노란 점은 흥미로운 메시지. 다이아몬드 마커 20개는 k-means 중심점 (클래스별 10개, seed=42). 점에 마우스를 올리면 예시 원문이 보여요.

빨간색 테스트 예시 둘: "lol"(하찮게 분류)과 "i feel sad today"(흥미롭게 분류). 384차원을 3으로 줄인 뒤에도 (설명 분산 14.7퍼센트) 두 클러스터가 분명히 분리돼요. 위 주석에 정확한 개수와 모호 구역이 나와요.

입력 메시지의 중심점이 내용에 따라 이 공간을 돌아다녀요. FUTILE과 INTERESSANT 분류는 코사인 유사도로 어떤 중심점이 가장 가까운지 재는 것일 뿐이에요. 각 메시지를 여러 차원 공간의 점으로 나타낼 수 있고, 각 차원이 의미 속성에 해당해요.


실제로 바뀌는 것

사용자는 층도 중심점도 온도 조정도 보지 못해요. 하지만 효과를 느껴요.

  • 단순 메시지에 더 빠른 답장 (작은 모델이 2배 빠르고 트래픽 70퍼센트를 다뤄요)
  • 적응 톤: 화나 있으면 봇이 분노를 "느끼고" 문체를 맞춰요
  • 플랫폼 간 일관성: Matrix 봇과 Discord 봇이 같은 두뇌와 같은 감정 상태를 공유해요
  • 지켜지는 진짜 톤: 미세 조정 + 퓨샷 + 지능 라우팅이 corporate 답장을 막아요

작은 모델 훈련을 200k 샘플로 늘린 것이 이 효과를 더 키웠어요. 퓨샷 프라이밍이 주는 다루기 쉬움을 잃지 않고 모델이 Discord 대화 다양성을 더 잘 잡아요.


전체 인프라

현재 도는 서비스는 이렇습니다.

서비스 기술 포트 역할
Pixieglow TypeScript (Bun) -- Matrix 어댑터
Jade TypeScript (esbuild) -- Discord 어댑터
Emerald TypeScript (Bun) 3126 (WebSocket) 브레인과 결정
Sapphire Python (FastAPI) 3123 (HTTP) 분류기와 감정
Krystal small llama.cpp (PM2) 3124 작은 모델 (1.5B, 하찮음)
Krystal large llama.cpp (PM2) 3125 큰 모델 (3B+, 흥미로움)

서비스 사이 의존은 단방향이에요. 어댑터는 Emerald에 기대고, Emerald는 Sapphire에 기대고, Sapphire는 Krystal에 기대요. 그래프는 비순환으로 남아요. 각 서비스는 독립적으로 다시 시작할 수 있어요.


결론

Luna Protocol을 네 층으로 나누기는 아키텍처 연습이 아니었어요. 구체적 한계에 대한 답이었어요. Matrix 지원 불가, 감정 의식 부재, 메시지 지능 우선순위 부재.

오늘날 시스템은 더 강건해요 (LLM 충돌이 봇을 죽이지 않아요). 더 확장돼요 (Telegram이나 WhatsApp 어댑터도 같은 WebSocket 프로토콜을 따를 거예요). 더 "살아 있어요". 봇이 지각된 대화 감정 상태에 행동과 톤, 심지어 LLM 매개변수를 맞춰요.

임베딩 중심점이 이 모든 것을 과도한 복잡함 없이 가능하게 하는 핵심이에요. 훈련된 신경망도 없고, 라벨 자료 파이프라인도 없고, 벡터 평균과 코사인 유사도뿐이에요. 단순하고, 믿을 수 없을 정도로 효과적이고, 심하게 과소평가된 기법이에요.

자료 링크
프로젝트 웹사이트 protocol-luna.github.io
Pixieglow protocol-luna/pixieglow
Emerald protocol-luna/emerald
Sapphire protocol-luna/sapphire
Krystal protocol-luna/krystal
글 1: Discord 봇 Luna Protocol: 자율 Discord 봇을 만들었어요
글 2: 미세 조정 Luna Protocol: 1.5B 모델을 미세 조정했어요

Luna Protocol: beyinlerin ortaklaştırılması, duygusal sınıflandırma ve ilginç/faydasız yönlendirme

Luna Protocol monolitten dört katmanlı mimariye geçti: adaptörler, brain, duygusal sınıflandırıcı ve inference. Programda: embedding centroidleri, ilginç/faydasız yönlendirme ve valence ile arousal üzerinden LLM parametre ayarı.

Luna Protocol: beyinlerin ortaklaştırılması, duygusal sınıflandırma ve ilginç/faydasız yönlendirme

Önceki iki makalede Luna Protocolü karmaşık davranış sistemi ve ince ayarlı modelli tekil Discord botu sundum. Ama mimari o zamandan iyi evrildi. Monolit olan şey: Discord botu, davranışı ve LLM çağrılarını yöneten tek Node.js süreci, dört bağımsız katmana dönüştü; her birinin kendi sorumluluğu, kendi dili ve kendi yaşam döngüsü var.

Bu ayrım beklenmedik faydalar getirdi: birkaç platform arasında "beyinlerin" ortaklaştırılması, LLM parametrelerini dinamik ayarlayan duygusal sınıflandırma sistemi ve konuşmanın algılanan önemine göre iki model arası akıllı mesaj yönlendirme.

Evrim bir seferde olmadı: organik bir yolu izledi. Önce bot deposundan server/ klasörünü ayırdım, böylece bir yanda Krystal yaratıp Jadei Discord adaptörü bıraktım. Sonra Jadein llm-core ve olay veriyolunu geri alıp Pixieglow (Matrix adaptörü) yarattım. Sonra DistilBERT ile GENERIC/SEMANTIC sınıflandırma getirmek için Sapphire geldi. Sonuçlar ikna edici değildi, o yüzden örnek zenginleştirme için daha yoğrulabilir ve daha hassas embedding centroidlerine geçtim; sınıflandırma FUTILE/INTERESSANT oldu. Sonunda LLM sıcaklık ve repeat penalty değerlerini düzenlemek için valence ve arousal centroidleri ekledim. Bitirmek için Jade ile Pixieglow arası tüm yedek kodu Emerald, ortaklaştırılmış beyin yaratarak kaldırdım; Jade ile Pixieglow soket sürümlü basit istemcilere dönüştü.

Paralel olarak projenin ilerlemesini izleyen bir web sitesi tuttum: protocol-luna.github.io.

Bu makale bu katmanları nasıl ve neden böldüğümü, her servisin tam ne yaptığını ve centroid (embedding vektör ortalamaları) ile 70lerin PARRY chatbotundan ilhamlı hissetme değişkenleri gibi kavramların basit Discord botunu şaşırtıcı tutarlı çok platformlu sisteme nasıl dönüştürdüğünü anlatır.


Monolitle ilgili sorun

Başlangıçta Luna Protocol tek Node.js sürecine sığıyordu. Kod şunları yönetiyordu:

  • Eris kütüphanesiyle Discorda bağlantı
  • Tetikleyici değerlendirmesi (bahsetmeler, anahtar kelimeler, takip...)
  • İnsani davranış simülasyonu (yazım hataları, tereddütler, uyku...)
  • Yerel LLM sunucusuna (llama.cpp) HTTP çağrıları
  • Oturum ve anti-spam yönetimi
  • TTS pipelineı

Her şey aynı süreçteydi, tipli olay veriyollarıyla (TypedBus) iletişim kuruyordu. Çalışıyordu ama limitlerle:

  • Davranış kodunun tamamını kopyalamadan Matrix istemcisi eklemek imkânsız
  • LLM ve bot aynı depodaydı: server/ klasörü zaten vardı ama birini diğerine dokunmadan evrimleştirmek imkânsız
  • Farksız işleme: her mesaj aynı işleniyordu, "lol" ya da varoluşsal soru olsun
  • Duygusal amnezi: bot hiçbir şey "hissetmiyordu"

Katmanlara bölme tüm bu sorunları çözdü.


Dört katman

Luna Protocol mevcut mimarisi dört seviyeli huni düzenlenmiştir:

Matrix / Discord
      |
      v
  [ADAPTÖRLER]      Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [BRAIN]            Emerald (WebSocket, port 3126)
      |
      v
  [SINIFLANDIRICI]   Sapphire (HTTP, port 3123)
      |
      v
  [INFERENCE]        Krystal (llama.cpp, port 3124 / 3125)

Her katman bağımsız yeniden başlatılabilir, güncellenebilir ya da değiştirilebilir.


Katman 1: adaptörler (Pixieglow ve Jade)

En basit katmanlardır. Tek işleri mesajlaşma platformu olaylarını Emeralda standart protokole çevirmektir:

  • Jade Discord adaptörüdür. Discorda bağlanmak için Eris kütüphanesini kullanır ve mesajları WebSocket üzerinden Emeralda iletir. TTS pipelineını da yönetir (Piper ile ses sentezi, OGGye dönüştürme, Discorda yükleme).
  • Pixieglow Matrix adaptörüdür. Doğrudan Matrix HTTP Client-Server API kullanır (SDK yok), uzun yoklama eşzamanlamasıyla. TTSsi yoktur.

İki adaptör emerald-client.ts içinde tanımlı aynı WebSocket protokolü paylaşır:

type ClientId = "jade" | "pixieglow";

// Olaylar (adaptör -> Emerald)
type InEvent = MessageEvent | ReadyEvent | BotMessageEvent | PresenceEvent;

// Komutlar (Emerald -> adaptör)
type OutCommand = RespondCommand | TypingCommand | SetPresenceCommand
                | SpontaneousCommand | ForgotCommand;

Aynı arayüzlü iki adaptörün varlığı ortaklaştırmayı kanıtlar: aynı "beyin" (Emerald) farksız Discord botu ve Matrix botu sunar, aynı davranışlarla. Protokol bildirimseldir: Emerald adaptöre mesajı nasıl göndereceğini söylemez, neyi göndereceğini söyler (gecikmeli metin, belki patlama planı, tepki vs.). Her adaptör platforma göre somut yürütmeyi uygular.

Bu mimarinin gücü budur: Telegram, Signal ya da başka destek eklemek için WebSocket protokolünü uygulayan bir adaptör yazmak yeter.


Katman 2: beyin (Emerald)

Emerald merkezi karar servisidir. 3126 portunda WebSocket dinler ve yönetir:

  • Tetikleyici değerlendirmesi: bahsetme, DM, ad, anahtar kelime, takip, rastgele
  • Davranış simülasyonu: konsantrasyon gecikmeleri, yazım hataları, tereddütler, unutkanlıklar, patlama, tematik yorgunluk
  • Uyku döngüleri: sleep / slow / short modları
  • Oturum yönetimi: bekleme süresi, oturum limitleri, anti-spam
  • Sapphirea yönlendirme: mesaj gönderme, akışlı yanıtlar alma

Emerald ortaklaştırmayı mümkün kılan merkezi servistir ve ayrılıktan en çok yararlanan odur. Önce her davranış (yazım hatası, patlama, tereddüt) Discord koduyla iç içeydi. Artık behavior/ içinde adanmış modüllerdedirler:

emerald/src/behavior/
  burst.ts         -- Patlama mesaj planlaması
  mannerisms.ts    -- Gecikmeler, tereddütler, tepkiler, unutkanlıklar
  sleep.ts         -- Uyku saati değerlendirmesi
  typo.ts          -- Yazım hatası simülasyonu (AZERTY/QWERTY)

Beyin hangi platformda döndüğünü bilmez. clientId ("jade" ya da "pixieglow") ile MessageEvent alır, karar verir ve komut döndürür. Geri kalanı adaptör halleder.


Katman 3: duygusal sınıflandırıcı (Sapphire)

Sapphire teknik açıdan en ilginç servistir. Dört kritik rol oynayan FastAPI ile Python yazılmış LLM middlewaredir:

  1. Embedding centroidleriyle ikili FUTILE / INTERESSANT sınıflandırıcı
  2. Centroidlerle duygusal skorlayıcı (valence / arousal)
  3. Krystala backend yönlendirici (küçük model ve büyük model)
  4. Few-shot enjektör ve oturum yöneticisi

Centroidler: sınıflandırmanın kalbi

Centroid basit kavramdır: embedding vektör kümesinin ortalamasıdır. Somut olarak yüzlerce örnek mesaj topladım, embedding modelinden (BAAI/bge-small-en-v1.5, 384 boyut) geçirdim ve elde edilen vektörlerin ortalamasını aldım.

İki sınıflandırma centroidi vardır:

  • futile_centroid: k-means (k=10, seed=42) ile ~683 önemsiz mesaj ("lol", "ok", "hello")
  • interessant_centroid: k-means (k=10, seed=42) ile ~678 önemli mesaj (teknik, kişisel, felsefi)

Mesaj gelince:

def classify(text, embedder, futile_centroids, interessant_centroids):
    emb = embedder.query_embed(text)                     # 384 boyutlu vektör
    sim_f = max(cos(emb, c) for c in futile_centroids)     # 10 üzerinde maks
    sim_i = max(cos(emb, c) for c in interessant_centroids) # 10 üzerinde maks
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

Sınıf başına skor 10 centroidi arasında maksimum kosinüs benzerliğidir. Bu her kategoride alt türleri yakalar: selamlama ve vedalaşma embedding uzayında uzak olsalar bile 10 faydasız centroidten birine yakın düşer. Başlangıçta yalnızca k-means ve çalışırken nokta çarpımlar: eğitimsiz, GPUsuz.

Neden iki model?

Bu sınıflandırmanın sonucu hangi LLM backendinin çağrılacağını belirler:

Etiket Krystal Backend Model Port
FUTILE generic Luna-Protocol-1.5B (941 MB, Q4_K_M) 3124
INTERESSANT semantic Hermes-3-3B ya da 8B (yapılandırmaya göre) 3125

Sezgi basittir: "lol" ya da "nm just chillin u" 8 milyar parametreli modeli çağırmayı hak etmez. 200.000 Discord örneğinde ince ayarlı küçük Luna 1.5B modeli hafif değiş tokuşlara fazlasıyla yeter. Buna karşılık hayat sorusu, sır ya da teknik tartışma daha zengin yanıt üretebilen büyük modele yönlendirilir.

Bu ekonomik yönlendirme LLM sunucusu yükünü önemli azaltır: mesajların yaklaşık %70i FUTILE sınıflanır ve küçük modelce işlenir, büyük model gerçekten değer konuşmalara serbest kalır.

Duygusal eksen: valence ve arousal

Ama hepsi bu değildir. Sapphire duyguyu değerlendirmek için bağımsız eksende aynı centroid mekanizmasını kullanır:

Dört duygusal centroid vardır:

Kutup Örnekler
positive "hell yeah", "love that", "this is great"
negative "shut up", "i hate this", "this sucks"
high_arousal "WHAT THE HELL", "omg omg omg", "AAAAA"
low_arousal "just chilling", "meh", "i guess"

Skor her eksende benzerlik farkı hesaplanır:

valence = sim(emb, positive) - sim(emb, negative)     # [-1, +1]
arousal = sim(emb, high_arousal) - sim(emb, low_arousal)  # [-1, +1]

Valence mesajın olumlu mu olumsuz mu olduğunu ölçer. Arousal duygusal yoğunluğunu ölçer. Birlikte 1972de PARRY chatbotuna ilham veren aynı psikolojik model olan etki çember modelini (Russell, 1980) oluşturur.

Hissetme değişkenleri: duygular LLMyi nasıl kontrol eder

PARRY ilhamının somutlaştığı yer burasıdır. PARRY (1972de Kenneth Colby tarafından yaratılmış) paranoyak hastayı simüle tasarlanmış bir chatbottu. Yanıtlarını değiştiren iç değişkenleri (korku, öfke, güvensizlik) vardı. Örneğin "korkmuş" PARRY daha saldırgan yanıtlıyordu.

Sapphire aynısını yapar ama sürekli değişkenler ve daha zarif yöntemle: sohbetin duygusal durumuna göre LLM örnekleme parametreleri gerçek zamanlı ayarlanır.

Sıcaklık arousalı izler
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Arousal Sıcaklık Etki
-1.0 (sakin) 0.40 Düşük yaratıcılık, öngörülebilir yanıtlar
0.0 (nötr) 0.70 Varsayılan yaratıcılık
+1.0 (heyecanlı) 1.00 Maksimum rastgelelik, şaşırtıcı yanıtlar

Biri heyecanlı ya da kızgınsa (yüksek arousal) sıcaklık yükselir. Model daha çeşitli, daha yaratıcı, bazen daha kaotik yanıtlar üretir; "coşan" insan gibi. Sohbet sakinse sıcaklık düşer, yanıtlar daha soğukkanlıdır.

Repeat penalty valencei izler
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
Valence Repeat Penalty Etki
-1.0 (olumsuz) 1.25 Güçlü ceza, tekrarlardan kaçınır
0.0 (nötr) 1.15 Varsayılan değer
+1.0 (olumlu) 1.05 Zayıf ceza, tekrarlara izin verir

Sohbet ne olumsuz, model kendini tekrar etmemeye o kadar itilir; gergin tartışmada kelimelerini arayan biri gibi. Sohbet ne olumlu, model gereksiz olumlamalara o kadar izin verebilir; rahat sohbet gibi.

Birikimli duygusal durum

Bu skorlar yalnızca anlık mesajı taşımaz. EmotionState, oturum başına valence ve arousal üstel hareketli ortalamasını tutar:

class EmotionState:
    def __init__(self, decay=0.85, deadzone=0.06):
        self.decay = decay
        self.deadzone = deadzone

    def update(self, key, valence_delta, arousal_delta):
        if abs(valence_delta) < self.deadzone:
            valence_delta = 0.0
        if abs(arousal_delta) < self.deadzone:
            arousal_delta = 0.0
        s = self._state.setdefault(key, {"valence": 0.0, "arousal": 0.0})
        s["valence"] = s["valence"] * self.decay + valence_delta * (1 - self.decay)
        s["arousal"] = s["arousal"] * self.decay + arousal_delta * (1 - self.decay)
        return s

0,85 decay, her mesajda önceki durumun %85i korunur ve yeni sinyalin %15i bütünleşir demektir. Bu ani değişimleri yumuşatan duygusal bellek verir: tek olumsuz mesaj botu "üzmez" ama bir dizi olumsuz mesaj ruh halini yavaşça sürükler.

Pratikte: biri sohbete çok heyecanlı başlarsa (arousal=+0,8) sonraki mesajlar daha sakin olsa bile sıcaklık birkaç değiş tokuş yüksek kalır. Duygu düşmeye zaman alır; tartışmadan sonra "sıcak" kalan insan gibi.


Katman 4: inference (Krystal)

Krystal en alt katmandır: llama.cpp etrafında OpenAI uyumlu API (/v1/chat/completions) sunan sarmalayıcı. İki PM2 örneğinde döner:

  • krystal-small: 3124 portunda ince ayarlı Luna 1.5B modeli, CPU yakınlığı 0
  • krystal-large: 3125 portunda Hermes 3B modeli, CPU yakınlığı 0,1

İki örnek taskset ile CPU sabitlemeli çalıştırılan önceden derlenmiş llama-server süreçleridir.

Luna modelinin ince ayarı da ikinci makaleden beri evrildi: artık 200.000 örnekte eğitilir (önce 50.000), hep Qwen2.5-1.5B-Instruct üzerinden QLoRA ile. 200 bin örnek Discord-Dialogues veri kümesinin alt kümesidir, en doğal ve çeşitli konuşmalar tutulmak için filtrelenmiş. Amaç: few-shot priming etkili kılan esnekliği kaybetmeden modelin tarz kaydını genişletmek.


Eksiksiz şema: transitte bir mesaj

Discordda biri "je suis vraiment triste aujourd'hui" gönderince somut olan:

  1. Jade mesajı Discord API Gateway üzerinden alır. MessageEvent dönüştürüp WebSocket ile Emeralda gönderir.
  2. Emerald tetikleyiciyi değerlendirir (bahsetme mi? ad mı? anahtar kelime mi?). Doğrudan bahsetmedir. Konsantrasyon gecikmesi hesaplar, bekleme süresini, oturumu, tematik yorgunluğu kontrol eder. Yanıtlamaya karar verip mesajı HTTP ile Sapphirea gönderir.
  3. Sapphire mesajı bge-small-en-v1.5 ile gömer.
    • Sınıflandırma: mesaj futile centroidtense interessant centroidine yakındır (diff = +0,31) -> INTERESSANT
    • Duygu: olumsuz valence (-0,42), orta arousal (0,35)
    • Yönlendirme: KRYSTAL_SEMANTIC_URL yönü (port 3125, büyük model)
    • Örnekleme parametreleri: sıcaklık = 0,80 (artmış arousal), repeat_penalty = 1,19 (olumsuz valence)
    • Oturumun duygusal durumu bu değerlerle güncellenir
  4. Krystal (büyük örnek) duygusal ayarlanmış parametrelerle yanıtı üretir ve Sapphirea döndürür.
  5. Sapphire yanıtı meta verilerle (etiket, valence, arousal, hata ayıklama istatistikleri) Emeralda akıtır.
  6. Emerald tereddüt ("oh...") eklemeye, patlama planlamaya (2 parça) ve tepki seçmeye karar verir. Jade RespondCommand gönderir.
  7. Jade çalıştırır: başlangıç gecikmesini bekler, tereddütlü ilk parçayı gönderir, 1,5 sn bekler, ikinci parçayı gönderir. Tüm üretim boyunca yazıyor göstergesini gösterir.

Bunların hepsi kullanıcı için 3 saniyeden kısa sürer.


Centroidler: neden sinir sınıflandırıcıdan iyidir

Geleneksel sınıflandırıcı (önce kullandığım DistilBERT gibi) yerine embedding centroidleri seçimi açıklamayı hak eder.

Sinir sınıflandırıcı sınıflar arası karar sınırı öğrenir, tipik girdileri olasılıklara yansıtan doğrusal olmayan dönüşüm. Hassastır ama:

  • Etiketli eğitim verisi gerektirir
  • Dağılım değişimine duyarlıdır (data drift)
  • Yorumlaması zordur
  • Yeni sınıf eklemek için yeniden eğitilmelidir

Centroid ise tersine örnek embeddinglerinin ortalama vektörüdür. Sınıflandırma bu ortalama vektöre kosinüs benzerliğiyle yapılır. Avantajlar:

  • Eğitim gerekmez: elle seçilmiş örnek embeddinglerinin ortalaması hesaplanır
  • Yorumlaması kolay: centroidin "ne öğrendiğini" anlamak için ona en yakın örneklere bakılabilir
  • Sınıf ekleme: yeniden eğitimsiz yeni centroid eklenir
  • Sağlam: centroid ortalamadır, o yüzden aykırı değerlerin etkisi azdır

Centroidlerin gerçek gücü sınıflandırma sorununu uzamsal mesafe ölçümü sorununa dönüştürmesidir. Kategoriler 384 boyutlu uzayda bölgeler görselleştirilebilir (PCA/t-SNE ile boyut indirgeme sonrası 2D/3D).

Centroidlerin 3D görselleştirmesi

Pratikte sınıflandırma centroidleri embedding uzayında şöyle görünür. Her nokta PCA ile 3D yansıtılmış örnek mesajdır (görselleştirme için 384 orijinal boyut 3e indirilir). Mavi noktalar faydasız mesajlar, sarı noktalar ilginç mesajlardır. 20 elmas işaret k-means centroidleridir (sınıf başına 10, seed=42). Metin için nokta üzerine gel.

İki test örneği kırmızıyla gösterilir: "lol" (faydasız sınıflı) ve "i feel sad today" (ilginç sınıflı). 384 boyuttan 3e indirgemeden sonra bile (%14,7 açıklanmış varyans) iki küme açık ayrıdır. Üstteki ek açıklama tam sayımları ve belirsiz bölgeyi gösterir.

Girdi mesajının centroidi içeriğine göre bu uzayda gezinir. FUTILE/INTERESSANT sınıflandırması basitçe kosinüs benzerliğince hangi centroidin en yakın olduğunu ölçer. Böylece her mesaj çok boyutlu uzayda bir nokta temsil edilebilir, her boyut anlamsal özelliğe karşılık gelir.


Pratikte neyi değiştirir

Kullanıcılar katmanları, centroidleri ya da sıcaklık ayarlarını görmez. Ama etkileri hisseder:

  • Basit mesajlar için daha hızlı yanıtlar (küçük model 2 kat hızlı ve trafiğin %70ini yönetir)
  • Uyarlanabilir ton: kızgınsan bot kızgınlığı "hisseder" ve tarzını uyarlar
  • Platformlar arası tutarlılık: Matrix botu ve Discord botu aynı beyni ve aynı duygusal durumu paylaşır
  • Korunmuş otantik ton: ince ayar + few-shot + akıllı yönlendirme kurumsal yanıtları engeller

Küçük model için 200 bin eğitim örneğine geçiş bu etkileri daha güçlendirdi: model few-shot priming mümkün kılan yoğrulabilirliği kaybetmeden Discord konuşmalarının çeşitliliğini iyi yakalar.


Eksiksiz altyapı

İşte şu an dönen servisler:

Servis Teknoloji Port(lar) Rol
Pixieglow TypeScript (Bun) -- Matrix adaptörü
Jade TypeScript (esbuild) -- Discord adaptörü
Emerald TypeScript (Bun) 3126 (WebSocket) Beyin / kararlar
Sapphire Python (FastAPI) 3123 (HTTP) Sınıflandırıcı + duygu
Krystal small llama.cpp (PM2) 3124 Küçük model (1.5B, faydasız)
Krystal large llama.cpp (PM2) 3125 Büyük model (3B+, ilginç)

Servisler arası bağımlılıklar tek yönlüdür: adaptör Emeralda, Emerald Sapphirea, Sapphire Krystala bağlıdır. Graf döngüsüz kalır. Her servis bağımsız yeniden başlatılabilir.


Sonuç

Luna Protocolü dört katmana bölmek sadece mimari egzersiz değildi. Somut limitlere yanıttı: Matrix destekleyememe, duygusal bilinç yokluğu, akıllı mesaj önceliği yokluğu.

Bugün sistem daha sağlamdır (LLM crashi botu öldürmez), daha genişletilebilirdir (Telegram ya da WhatsApp adaptörü aynı WebSocket protokolünü izlerdi) ve daha "canlıdır": bot davranışını, tonunu ve hatta LLM parametrelerini sohbetin algılanan duygusal durumuna uyarlar.

Embedding centroidleri bütün bunları ölçüsüz karmaşıklık olmadan mümkün kılan kilit öğedir: eğitilmiş sinir ağı yok, etiketli veri pipelineı yok, sadece vektör ortalamaları ve kosinüs benzerlikleri. Basit, inanılmaz etkili ve feci az değer gören bir tekniktir.

Kaynak Bağlantı
Proje web sitesi protocol-luna.github.io
Pixieglow protocol-luna/pixieglow
Emerald protocol-luna/emerald
Sapphire protocol-luna/sapphire
Krystal protocol-luna/krystal
Makale 1: Discord botu Luna Protocol: özerk bir Discord botu yarattım
Makale 2: ince ayar Luna Protocol: 1,5B modeli neden ince ayarladım

Luna Protocol: condivisione dei cervelli, classificazione emozionale, e routing interessante/futile

Luna Protocol è passato da un monolite a un'architettura a quattro strati: adattatori, brain, classificatore emozionale, e inference. In programma: centroid di embedding, routing interessante/futile, e regolazione dei parametri LLM per valenza e arousal.

Luna Protocol: condivisione dei cervelli, classificazione emozionale, e routing interessante/futile

Nei due articoli precedenti, ho presentato Luna Protocol come un bot Discord unico con un sistema comportamentale complesso e un modello fine-tunato. Ma l'architettura da allora si è evoluta molto. Ciò che era un monolite: un solo processo Node.js che gestiva sia il bot Discord, il comportamento, e le chiamate LLM, si è trasformato in quattro strati indipendenti, ognuno con la sua responsabilità, il suo linguaggio, e il suo ciclo di vita.

Questa separazione ha portato benefici inattesi: la condivisione dei "cervelli" tra più piattaforme, un sistema di classificazione emozionale che regola dinamicamente i parametri dell'LLM, e un routing intelligente dei messaggi tra due modelli secondo l'importanza percepita della conversazione.

L'evoluzione non è avvenuta d'un colpo: ha seguito un percorso organico. Ho prima separato la cartella server/ dal repo del bot, creando così Krystal da un lato e lasciando Jade come adattatore Discord. Poi ho creato Pixieglow (adattatore Matrix) riprendendo l'llm-core e il bus di eventi di Jade. Poi è arrivato Sapphire per introdurre una classificazione GENERIC/SEMANTIC con DistilBERT. I risultati non erano convincenti, quindi sono passato per centroid di embedding, più malleabili per l'arricchimento di esempi e più precisi; la classificazione è diventata FUTILE/INTERESSANTE. Ho infine aggiunto centroid di valenza e arousal per regolare temperatura e repeat penalty dell'LLM. Per finire, ho tolto tutto il codice ridondante tra Jade e Pixieglow creando Emerald, il cervello condiviso, trasformando Jade e Pixieglow in semplici client socket-driven.

In parallelo, ho tenuto aggiornato un sito web che traccia l'avanzamento del progetto: protocol-luna.github.io.

Questo articolo racconta come e perché ho tagliato questi strati, cosa fa esattamente ogni servizio, e come concetti come i centroid (vettori medi di embedding) e le variabili di risentimento (ispirate al chatbot PARRY anni 70) hanno trasformato un semplice bot Discord in un sistema multi-piattaforma sorprendentemente coerente.


Il problema col monolite

All'inizio, Luna Protocol stava in un solo processo Node.js. Il codice gestiva:

  • La connessione a Discord (via la libreria Eris)
  • La valutazione dei trigger (menzioni, parole chiave, follow-up...)
  • La simulazione di comportamenti umani (refusi, esitazioni, sonno...)
  • Le chiamate HTTP al server LLM locale (llama.cpp)
  • La gestione delle sessioni e dell'anti-spam
  • La pipeline TTS

Tutto era nello stesso processo, comunicando via bus di eventi tipizzati (TypedBus). Funzionava, ma con limiti:

  • Impossibile aggiungere un client Matrix senza duplicare tutto il codice di comportamento
  • LLM e bot erano nello stesso repo: la cartella server/ esisteva già, ma impossibile far evolvere l'uno senza toccare l'altro
  • Elaborazione indifferenziata: ogni messaggio era trattato allo stesso modo, che fosse un "lol" o una domanda esistenziale
  • Amnesia emozionale: il bot non "sentiva" niente

Il taglio in strati ha risolto tutti questi problemi.


I quattro strati

L'architettura attuale di Luna Protocol è organizzata come un imbuto a quattro livelli:

Matrix / Discord
      |
      v
  [ADATTATORI]      Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [BRAIN]            Emerald (WebSocket, porta 3126)
      |
      v
  [CLASSIFICATORE]   Sapphire (HTTP, porta 3123)
      |
      v
  [INFERENCE]        Krystal (llama.cpp, porte 3124 / 3125)

Ogni strato può essere riavviato, aggiornato, o sostituito in modo indipendente.


Strato 1: gli adattatori (Pixieglow e Jade)

Sono gli strati più semplici. Il loro unico lavoro è tradurre gli eventi di una piattaforma di messaggistica in un protocollo standardizzato verso Emerald:

  • Jade è l'adattatore Discord. Usa la libreria Eris per connettersi a Discord e inoltrare i messaggi verso Emerald via WebSocket. Gestisce anche la pipeline TTS (sintesi vocale via Piper, conversione in OGG, upload su Discord).
  • Pixieglow è l'adattatore Matrix. Usa l'API HTTP Client-Server di Matrix direttamente (niente SDK), con un long-poll sync. Non ha TTS.

Entrambi gli adattatori condividono lo stesso protocollo WebSocket definito in emerald-client.ts:

// protocollo WebSocket condiviso tra adattatori ed Emerald
type ClientId = "jade" | "pixieglow";

// Eventi (adattatore -> Emerald)
type InEvent = MessageEvent | ReadyEvent | BotMessageEvent | PresenceEvent;

// Comandi (Emerald -> adattatore)
type OutCommand = RespondCommand | TypingCommand | SetPresenceCommand
                | SpontaneousCommand | ForgotCommand;

L'esistenza di due adattatori con la stessa interfaccia prova la condivisione: lo stesso "cervello" (Emerald) serve indifferentemente un bot Discord e un bot Matrix, con comportamenti identici. Il protocollo è dichiarativo: Emerald non dice all'adattatore come inviare un messaggio, dice cosa inviare (il testo con un ritardo, eventualmente un piano di burst, una reazione, ecc.). Ogni adattatore implementa l'esecuzione concreta secondo la sua piattaforma.

È la forza di questa architettura: per aggiungere il supporto di Telegram, Signal, o altro, basta scrivere un adattatore che implementa il protocollo WebSocket.


Strato 2: il cervello (Emerald)

Emerald è il servizio centrale di decisione. Ascolta sulla porta 3126 in WebSocket e gestisce:

  • La valutazione dei trigger: menzione, DM, nome, parola chiave, follow-up, casuale
  • La simulazione comportamentale: ritardi di concentrazione, refusi, esitazioni, dimenticanze, burst, fatica tematica
  • I cicli di sonno: modalità sleep / slow / short
  • La gestione delle sessioni: cooldown, limiti di sessione, anti-spam
  • Il routing verso Sapphire: invio dei messaggi, ricezione delle risposte in stream

Emerald è il servizio centrale che ha permesso la condivisione, ed è quello che ha beneficiato di più dalla separazione. Prima, ogni comportamento (typo, burst, hesitation) era intrecciato col codice Discord. Ora, stanno in moduli dedicati in behavior/:

emerald/src/behavior/
  burst.ts         -- Pianificazione dei messaggi a raffica
  mannerisms.ts    -- Ritardi, esitazioni, reazioni, dimenticanze
  sleep.ts         -- Valutazione degli orari di sonno
  typo.ts          -- Simulazione di refusi (AZERTY/QWERTY)

Il cervello non sa su quale piattaforma gira. Riceve un MessageEvent con un clientId ("jade" o "pixieglow"), prende una decisione, e rinvia un comando. L'adattatore si occupa del resto.


Strato 3: il classificatore emozionale (Sapphire)

Sapphire è il servizio più interessante sul piano tecnico. È un middleware LLM scritto in Python con FastAPI, che gioca quattro ruoli critici:

  1. Classificatore binario FUTILE / INTERESSANTE via centroid di embedding
  2. Punteggiatore emozionale (valenza / arousal) via centroid
  3. Router di backend verso Krystal (modello piccolo contro modello grande)
  4. Iniettore few-shot e gestore di sessioni

I centroid: il cuore della classificazione

Un centroid è un concetto semplice: è la media di un insieme di vettori di embedding. In concreto, ho raccolto centinaia di esempi di messaggi, li ho passati in un modello di embedding (BAAI/bge-small-en-v1.5, 384 dimensioni), e ho mediato i vettori ottenuti.

Ci sono due centroid di classificazione:

  • futile_centroid: circa 683 messaggi banali ("lol", "ok", "hello") via k-means (k=10, seed=42)
  • interessant_centroid: circa 678 messaggi sostanziali (tecnici, personali, filosofici) via k-means (k=10, seed=42)

Quando arriva un messaggio:

# classifica per similarità coseno max ai centroid
def classify(text, embedder, futile_centroids, interessant_centroids):
    emb = embedder.query_embed(text)                     # vettore 384-D
    sim_f = max(cos(emb, c) for c in futile_centroids)     # max su 10
    sim_i = max(cos(emb, c) for c in interessant_centroids) # max su 10
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

Il punteggio per classe è la similarità coseno massima tra i suoi 10 centroid. Cattura i sottotipi in ogni categoria: un saluto e un arrivederci cadono entrambi vicino a uno dei 10 centroid futili anche se sono lontani nello spazio di embedding. Solo k-means all'avvio e prodotti scalari all'esecuzione: senza allenamento, senza GPU.

Perché due modelli?

Il risultato di questa classificazione decide quale backend LLM è invocato:

Label Backend Krystal Modello Porta
FUTILE generic Luna-Protocol-1.5B (941 MB, Q4_K_M) 3124
INTERESSANT semantic Hermes-3-3B o 8B (secondo config) 3125

L'intuizione è semplice: un "lol" o un "nm just chillin u" non merita di invocare un modello da 8 miliardi di parametri. Il piccolo modello fine-tunato Luna 1.5B, allenato su 200 000 campioni Discord, basta ampiamente per gli scambi leggeri. Invece, una domanda sulla vita, una confidenza, o un dibattito tecnico è instradato verso il grande modello che può produrre una risposta più ricca.

Questo routing economico riduce molto il carico sul server LLM: circa il 70% dei messaggi è classificato FUTILE e trattato dal piccolo modello, liberando il grande modello per le conversazioni che valgono davvero la pena.

L'asse emozionale: valenza e arousal

Ma non è tutto. Sapphire usa lo stesso meccanismo di centroid su un asse indipendente per valutare l'emozione del messaggio:

Ci sono quattro centroid emozionali:

Polo Esempi
positive "hell yeah", "love that", "this is great"
negative "shut up", "i hate this", "this sucks"
high_arousal "WHAT THE HELL", "omg omg omg", "AAAAA"
low_arousal "just chilling", "meh", "i guess"

Il punteggio si calcola come differenza di similarità su ogni asse:

# valenza e arousal come differenze di similarità
valence = sim(emb, positive) - sim(emb, negative)     # [-1, +1]
arousal = sim(emb, high_arousal) - sim(emb, low_arousal)  # [-1, +1]

Valenza misura se il messaggio è positivo o negativo. Arousal misura la sua intensità emozionale. Insieme, formano il modello circumplex dell'affect (Russell, 1980), lo stesso modello psicologico che ha ispirato il chatbot PARRY nel 1972.

Le variabili di risentimento: come le emozioni controllano l'LLM

È lì che l'ispirazione di PARRY diventa tangibile. PARRY (creato da Kenneth Colby nel 1972) era un chatbot pensato per simulare un paziente paranoico. Aveva variabili interne (paura, rabbia, diffidenza) che modificavano le sue risposte. Per esempio, un PARRY "spaventato" rispondeva in modo più aggressivo.

Sapphire fa la stessa cosa, ma con variabili continue e un metodo più elegante: i parametri di campionamento dell'LLM sono regolati in tempo reale secondo lo stato emozionale della conversazione.

La temperatura segue l'arousal
# temperatura più alta quando la conversazione si scalda
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Arousal Temperatura Effetto
-1.0 (calmo) 0.40 Bassa creatività, risposte prevedibili
0.0 (neutro) 0.70 Creatività di default
+1.0 (eccitato) 1.00 Massimo di casualità, risposte sorprendenti

Quando qualcuno è eccitato o arrabbiato (arousal alto), la temperatura sale. Il modello produce risposte più varie, più creative, a volte più caotiche, come un umano che "si scalda". Quando la conversazione è calma, la temperatura scende, le risposte sono più posate.

Il repeat penalty segue la valenza
# repeat penalty più forte su conversazioni negative
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
Valenza Repeat Penalty Effetto
-1.0 (negativo) 1.25 Forte penalità, evita ripetizioni
0.0 (neutro) 1.15 Valore di default
+1.0 (positivo) 1.05 Debole penalità, permette ripetizioni

Più la conversazione è negativa, più il modello è spinto a evitare di ripetersi, come qualcuno che cerca le parole in una lite tesa. Più la conversazione è positiva, più il modello può permettersi affermazioni ridondanti, come una conversazione rilassata.

Lo stato emozionale cumulativo

Questi punteggi non riguardano solo il messaggio immediato. Un EmotionState mantiene una media esponenziale mobile di valenza e arousal per sessione:

# memoria emozionale con media mobile esponenziale
class EmotionState:
    def __init__(self, decay=0.85, deadzone=0.06):
        self.decay = decay
        self.deadzone = deadzone

    def update(self, key, valence_delta, arousal_delta):
        if abs(valence_delta) < self.deadzone:
            valence_delta = 0.0
        if abs(arousal_delta) < self.deadzone:
            arousal_delta = 0.0
        s = self._state.setdefault(key, {"valence": 0.0, "arousal": 0.0})
        s["valence"] = s["valence"] * self.decay + valence_delta * (1 - self.decay)
        s["arousal"] = s["arousal"] * self.decay + arousal_delta * (1 - self.decay)
        return s

Il decay a 0.85 significa che l'85% dello stato precedente è conservato a ogni messaggio, e il 15% del nuovo segnale è integrato. Dà una memoria emozionale che smussa le variazioni brutali: un solo messaggio negativo non rende il bot "triste", ma una serie di messaggi negativi fa derivare progressivamente il suo umore.

In pratica: se qualcuno inizia una conversazione in modo molto eccitato (arousal=+0.8), la temperatura resta alta per vari scambi, anche se i messaggi seguenti sono più calmi. L'emozione mette tempo a scendere, come un umano che resta "caldo" dopo una lite.


Strato 4: l'inferenza (Krystal)

Krystal è lo strato più basso: un wrapper intorno a llama.cpp che espone un'API compatibile OpenAI (/v1/chat/completions). Gira in due istanze PM2:

  • krystal-small: il modello Luna 1.5B fine-tunato, sulla porta 3124, con affinità CPU 0
  • krystal-large: un modello Hermes 3B, sulla porta 3125, con affinità CPU 0,1

Entrambe le istanze sono processi llama-server pre-compilati, lanciati con taskset per il pinning CPU.

Il fine-tune del modello Luna si è evoluto anche lui dal secondo articolo: è ormai allenato su 200 000 campioni (contro 50 000 prima), sempre da Qwen2.5-1.5B-Instruct via QLoRA. I 200k campioni sono un sottoinsieme del dataset Discord-Dialogues, filtrati per tenere solo le conversazioni più naturali e diverse. Lo scopo: allargare il registro stilistico del modello senza perdere la flessibilità che rende il few-shot priming così efficace.


Lo schema completo: un messaggio in transito

Ecco cosa succede in concreto quando qualcuno invia "sono davvero triste oggi" su Discord:

  1. Jade riceve il messaggio via l'API Gateway Discord. Lo trasforma in MessageEvent e lo invia a Emerald via WebSocket.
  2. Emerald valuta il trigger (menzione? nome? parola chiave?). È una menzione diretta. Calcola un ritardo di concentrazione, verifica cooldown, sessione, fatica tematica. Decide di rispondere e invia il messaggio a Sapphire via HTTP.
  3. Sapphire fa embed del messaggio con bge-small-en-v1.5.
    • Classificazione: il messaggio è più vicino al centroid interessant che al centroid futile (diff = +0.31) -> INTERESSANT
    • Emozione: valenza negativa (-0.42), arousal moderato (0.35)
    • Routing: direzione KRYSTAL_SEMANTIC_URL (porta 3125, grande modello)
    • Parametri campionamento: temperatura = 0.80 (arousal aumentato), repeat_penalty = 1.19 (valenza negativa)
    • Lo stato emozionale della sessione è aggiornato con questi valori
  4. Krystal (istanza large) genera la risposta coi parametri regolati emozionalmente e la rinvia a Sapphire.
  5. Sapphire fa stream della risposta verso Emerald coi metadati (label, valenza, arousal, statistiche di debug).
  6. Emerald decide di aggiungere un'esitazione ("oh..."), pianifica un burst (2 frammenti), e sceglie una reazione. Invia una RespondCommand a Jade.
  7. Jade esegue: aspetta il ritardo iniziale, invia il primo frammento con l'esitazione, aspetta 1.5s, invia il secondo frammento. Mostra l'indicatore di digitazione durante tutta la generazione.

Tutto questo in meno di 3 secondi per l'utente.


I centroid: perché è meglio di un classificatore neurale

La scelta dei centroid di embedding invece di un classificatore tradizionale (come il DistilBERT che usavo prima) merita una spiegazione.

Un classificatore neurale impara una frontiera di decisione tra le classi, tipicamente una trasformazione non lineare che proietta gli input verso probabilità. È preciso, ma:

  • Richiede dati di allenamento etichettati
  • È sensibile al cambio di distribuzione (data drift)
  • È difficile da interpretare
  • Deve essere ri-allenato per aggiungere una nuova classe

Un centroid, invece, è un vettore medio di embedding di esempi. La classificazione avviene per similarità coseno a questo vettore medio. Vantaggi:

  • Nessun allenamento richiesto: calcoliamo solo la media di embedding di esempi scelti a mano
  • Facile da interpretare: possiamo guardare quali esempi sono più vicini al centroid per capire "cosa ha imparato il centroid"
  • Aggiunta di una classe: aggiungiamo solo un nuovo centroid, senza ri-allenamento
  • Robusto: il centroid è una media, quindi gli outlier hanno poco impatto

Il vero potere dei centroid, è che trasformano un problema di classificazione in un problema di misura di distanza spaziale. Possiamo visualizzare le categorie come regioni in uno spazio a 384 dimensioni (o in 2D/3D dopo riduzione dimensionale PCA/t-SNE).

Visualizzazione 3D dei centroid

In pratica, ecco come sono i centroid di classificazione nello spazio di embedding. Ogni punto è un messaggio di esempio, proiettato in 3D con PCA (le 384 dimensioni originali sono ridotte a 3 per la visualizzazione). I punti blu sono i messaggi futili, i punti gialli i messaggi interessanti. I 20 marcatori a diamante sono i centroid k-means (10 per classe, seed=42). Passa il mouse su un punto per vedere il testo originale dell'esempio.

Due esempi di test sono mostrati in rosso: "lol" (classificato futile) e "i feel sad today" (classificato interessante). Anche dopo riduzione da 384 a 3 dimensioni (14,7% di varianza spiegata), i due cluster sono chiaramente separati. L'annotazione in alto mostra i conteggi esatti e la zona ambigua.

Il centroid del messaggio in ingresso passeggia in questo spazio in base al suo contenuto. La classificazione FUTILE/INTERESSANTE consiste semplicemente a misurare quale centroid è più vicino per similarità coseno. Possiamo così rappresentare ogni messaggio come un punto in uno spazio a più dimensioni, ogni dimensione corrispondente a una proprietà semantica.


Cosa cambia in pratica

Gli utenti non vedono gli strati, i centroid, o le regolazioni di temperatura. Ma sentono gli effetti:

  • Risposte più rapide per i messaggi semplici (il piccolo modello è 2x più rapido e gestisce il 70% del traffico)
  • Tono adattivo: se sei arrabbiato, il bot "sente" l'arrabbiatura e adatta il suo stile
  • Coerenza cross-piattaforma: un bot Matrix e un bot Discord condividono lo stesso cervello e lo stesso stato emozionale
  • Tono autentico preservato: il fine-tune + few-shot + routing intelligente evita risposte corporate

Il passaggio a 200k campioni di allenamento per il piccolo modello ha rafforzato ancora questi effetti: il modello cattura meglio la diversità delle conversazioni Discord senza perdere la malleabilità che permette il few-shot priming.


L'infrastruttura completa

Ecco i servizi che girano attualmente:

Servizio Tecnologia Porta/e Ruolo
Pixieglow TypeScript (Bun) -- Adattatore Matrix
Jade TypeScript (esbuild) -- Adattatore Discord
Emerald TypeScript (Bun) 3126 (WebSocket) Cervello / decisioni
Sapphire Python (FastAPI) 3123 (HTTP) Classificatore + emozione
Krystal small llama.cpp (PM2) 3124 Piccolo modello (1.5B, futile)
Krystal large llama.cpp (PM2) 3125 Grande modello (3B+, interessante)

Le dipendenze tra servizi sono unidirezionali: l'adattatore dipende da Emerald, Emerald dipende da Sapphire, Sapphire dipende da Krystal. Il grafo resta aciclico. Ogni servizio può essere riavviato in modo indipendente.


Conclusione

Dividere Luna Protocol in quattro strati non è stato solo un esercizio di architettura. Era una risposta a limitazioni concrete: impossibilità di supportare Matrix, mancanza di consapevolezza emozionale, assenza di prioritizzazione intelligente dei messaggi.

Oggi, il sistema è più robusto (un crash dell'LLM non uccide il bot), più estensibile (un adattatore Telegram o WhatsApp seguirebbe lo stesso protocollo WebSocket), e più "vivo": il bot adatta il suo comportamento, il suo tono, e persino i parametri dell'LLM allo stato emozionale percepito della conversazione.

I centroid di embedding sono l'elemento chiave che rende tutto questo possibile senza complessità smisurata: niente rete neurale allenata, niente pipeline di dati etichettati, solo medie di vettori e similarità coseno. È una tecnica semplice, incredibilmente efficace, e terribilmente sottovalutata.

Risorsa Link
Sito web del progetto protocol-luna.github.io
Pixieglow protocol-luna/pixieglow
Emerald protocol-luna/emerald
Sapphire protocol-luna/sapphire
Krystal protocol-luna/krystal
Articolo 1: il bot Discord Luna Protocol: ho creato un bot Discord autonomo
Articolo 2: il fine-tuning Luna Protocol: perché ho fine-tunato un modello da 1,5B

Luna Protocol: Gehirn-Pooling, emotionale Klassifikation und interessant/futil-Routing

Luna Protocol ist von einem Monolithen zu einer Vier-Schichten-Architektur geworden: Adapter, Brain, emotionaler Klassifizierer und Inference. Auf dem Programm: Embedding-Centroide, interessant/futil-Routing und Anpassung der LLM-Parameter nach Valenz und Arousal.

Luna Protocol: Gehirn-Pooling, emotionale Klassifikation und interessant/futil-Routing

In den zwei vorherigen Artikeln habe ich Luna Protocol als einen einzigen Discord-Bot mit komplexem Verhaltenssystem und feingetuntem Modell vorgestellt. Aber die Architektur hat sich seitdem gut weiterentwickelt. Was ein Monolith war: Ein einziger Node.js-Prozess, der zugleich Discord-Bot, Verhalten und LLM-Calls handhabte, hat sich in vier unabhängige Schichten verwandelt, jede mit eigener Verantwortung, eigener Sprache und eigenem Lebenszyklus.

Diese Trennung brachte unerwartete Vorteile: Das Pooling der "Gehirne" zwischen mehreren Plattformen, ein emotionales Klassifikationssystem, das die LLM-Parameter dynamisch anpasst, und ein intelligentes Routing der Nachrichten zwischen zwei Modellen je nach wahrgenommener Wichtigkeit der Konversation.

Die Evolution passierte nicht auf einmal: Sie folgte einem organischen Weg. Ich habe zuerst den server/-Ordner aus dem Bot-Repo getrennt und so Krystal auf der einen Seite geschaffen und Jade als Discord-Adapter gelassen. Dann habe ich Pixieglow (Matrix-Adapter) erstellt, indem ich llm-core und den Eventbus von Jade wiederverwendet habe. Danach kam Sapphire, um eine GENERIC/SEMANTIC-Klassifikation mit DistilBERT einzuführen. Die Ergebnisse waren nicht überzeugend, also bin ich zu Embedding-Centroiden übergegangen, formbarer für Beispielanreicherung und präziser; die Klassifikation wurde FUTILE/INTERESSANT. Schließlich habe ich Valenz- und Arousal-Centroide hinzugefügt, um Temperatur und Repeat Penalty des LLM zu regulieren. Zum Schluss habe ich den ganzen redundanten Code zwischen Jade und Pixieglow beseitigt, indem ich Emerald geschaffen habe, das gepoolte Gehirn, und Jade und Pixieglow zu simplen Socket-driven Clients transformiert habe.

Parallel habe ich eine Website gepflegt, die den Projektfortschritt nachzeichnet: protocol-luna.github.io.

Dieser Artikel erzählt, wie und warum ich diese Schichten aufgeschnitten habe, was jeder Service exakt tut, und wie Konzepte wie Centroide (gemittelte Embedding-Vektoren) und Ressentiment-Variablen (inspiriert vom PARRY-Chatbot der 70er) aus einem simplen Discord-Bot ein erstaunlich kohärentes Multi-Plattform-System gemacht haben.


Das Problem mit dem Monolithen

Anfangs passte Luna Protocol in einen einzigen Node.js-Prozess. Der Code handhabte:

  • Die Discord-Verbindung (via Eris-Bibliothek)
  • Die Trigger-Evaluierung (Mentions, Keywords, Follow-up...)
  • Die Simulation menschlichen Verhaltens (Tippfehler, Zögern, Schlaf...)
  • Die HTTP-Calls an den lokalen LLM-Server (llama.cpp)
  • Session- und Anti-Spam-Verwaltung
  • Die TTS-Pipeline

Alles war im selben Prozess und kommunizierte via typisierte Eventbusse (TypedBus). Das funktionierte, aber mit Grenzen:

  • Unmöglich, einen Matrix-Client hinzuzufügen, ohne den ganzen Verhaltenscode zu duplizieren
  • LLM und Bot waren im selben Repo: Der server/-Ordner existierte schon, aber unmöglich, das eine weiterzuentwickeln, ohne das andere anzufassen
  • Undifferenzierte Verarbeitung: Jede Nachricht wurde gleich behandelt, ob "lol" oder existenzielle Frage
  • Emotionale Amnesie: Der Bot "fühlte" nichts

Die Schichtenaufteilung hat all diese Probleme gelöst.


Die vier Schichten

Die aktuelle Luna-Protocol-Architektur ist wie ein vierstufiger Trichter organisiert:

Matrix / Discord
      |
      v
  [ADAPTATEURS]      Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [BRAIN]            Emerald (WebSocket, port 3126)
      |
      v
  [CLASSIFIEUR]      Sapphire (HTTP, port 3123)
      |
      v
  [INFERENCE]        Krystal (llama.cpp, ports 3124 / 3125)

Jede Schicht kann unabhängig neu gestartet, aktualisiert oder ersetzt werden.


Schicht 1: die Adapter (Pixieglow und Jade)

Das sind die simpelsten Schichten. Ihr einziger Job ist, Events einer Messaging-Plattform in ein standardisiertes Protokoll zu Emerald zu übersetzen:

  • Jade ist der Discord-Adapter. Er nutzt die Eris-Bibliothek, um sich mit Discord zu verbinden und Nachrichten via WebSocket an Emerald weiterzuleiten. Er handhabt auch die TTS-Pipeline (Sprachsynthese via Piper, Konvertierung zu OGG, Upload auf Discord).
  • Pixieglow ist der Matrix-Adapter. Er nutzt die Matrix-HTTP-Client-Server-API direkt (kein SDK), mit Long-Poll-Sync. Er hat kein TTS.

Beide Adapter teilen dasselbe in emerald-client.ts definierte WebSocket-Protokoll:

type ClientId = "jade" | "pixieglow";

// Events (Adapter -> Emerald)
type InEvent = MessageEvent | ReadyEvent | BotMessageEvent | PresenceEvent;

// Commands (Emerald -> Adapter)
type OutCommand = RespondCommand | TypingCommand | SetPresenceCommand
                | SpontaneousCommand | ForgotCommand;

Die Existenz zweier Adapter mit derselben Schnittstelle beweist das Pooling: Dasselbe "Gehirn" (Emerald) bedient unterschiedslos einen Discord-Bot und einen Matrix-Bot, mit identischen Verhaltensweisen. Das Protokoll ist deklarativ: Emerald sagt dem Adapter nicht, wie er eine Nachricht schicken soll, es sagt, was es schicken soll (der Text mit einer Verzögerung, eventuell ein Burst-Plan, eine Reaktion usw.). Jeder Adapter implementiert die konkrete Ausführung je nach Plattform.

Das ist die Stärke dieser Architektur: Um Telegram-, Signal- oder anderen Support hinzuzufügen, reicht es, einen Adapter zu schreiben, der das WebSocket-Protokoll implementiert.


Schicht 2: das Gehirn (Emerald)

Emerald ist der zentrale Entscheidungsservice. Er lauscht auf Port 3126 per WebSocket und handhabt:

  • Die Trigger-Evaluierung: Mention, DM, Name, Keyword, Follow-up, Random
  • Die Verhaltenssimulation: Konzentrationsverzögerungen, Tippfehler, Zögern, Vergessen, Burst, Themenmüdigkeit
  • Die Schlafzyklen: Sleep-/Slow-/Short-Modi
  • Die Sessionverwaltung: Cooldown, Session-Limits, Anti-Spam
  • Das Routing zu Sapphire: Nachrichtensendung, Empfang gestreamter Antworten

Emerald ist der zentrale Service, der das Pooling ermöglicht hat, und er hat am meisten von der Trennung profitiert. Vorher war jedes Verhalten (Typo, Burst, Hesitation) mit dem Discord-Code verwoben. Jetzt liegen sie in dedizierten Modulen in behavior/:

emerald/src/behavior/
  burst.ts         -- Planung von Burst-Nachrichten
  mannerisms.ts    -- Verzögerungen, Zögern, Reaktionen, Vergessen
  sleep.ts         -- Evaluierung der Schlafzeiten
  typo.ts          -- Simulation von Tippfehlern (AZERTY/QWERTY)

Das Gehirn weiß nicht, auf welcher Plattform es läuft. Es empfängt ein MessageEvent mit einer clientId ("jade" oder "pixieglow"), trifft eine Entscheidung und schickt einen Command zurück. Der Adapter kümmert sich um den Rest.


Schicht 3: der emotionale Klassifizierer (Sapphire)

Sapphire ist technisch der interessanteste Service. Es ist eine LLM-Middleware geschrieben in Python mit FastAPI, die vier kritische Rollen spielt:

  1. Binärer FUTILE / INTERESSANT-Klassifizierer via Embedding-Centroide
  2. Emotionaler Scorer (Valenz / Arousal) via Centroide
  3. Backend-Router zu Krystal (kleines Modell vs. großes Modell)
  4. Few-Shot-Injektor und Sessionmanager

Die Centroide: das Herz der Klassifikation

Ein Centroid ist ein simples Konzept: Es ist der Mittelwert einer Menge von Embedding-Vektoren. Konkret habe ich Hunderte Beispielnachrichten gesammelt, sie durch ein Embedding-Modell (BAAI/bge-small-en-v1.5, 384 Dimensionen) geschickt und die erhaltenen Vektoren gemittelt.

Es gibt zwei Klassifikations-Centroide:

  • futile_centroid: ~683 triviale Nachrichten ("lol", "ok", "hello") via k-means (k=10, seed=42)
  • interessant_centroid: ~678 substanzielle Nachrichten (technisch, persönlich, philosophisch) via k-means (k=10, seed=42)

Wenn eine Nachricht reinkommt:

def classify(text, embedder, futile_centroids, interessant_centroids):
    emb = embedder.query_embed(text)                     # 384-D Vektor
    sim_f = max(cos(emb, c) for c in futile_centroids)     # max über 10
    sim_i = max(cos(emb, c) for c in interessant_centroids) # max über 10
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

Der Score pro Klasse ist die maximale Kosinus-Ähnlichkeit unter ihren 10 Centroiden. Das erfasst die Subtypen in jeder Kategorie: Eine Begrüßung und ein Abschied fallen beide nahe eines der 10 futilen Centroide, selbst wenn sie im Embedding-Raum weit entfernt sind. Nur k-means beim Start und Skalarprodukte zur Runtime: Ohne Training, ohne GPU.

Warum zwei Modelle?

Das Ergebnis dieser Klassifikation entscheidet, welches LLM-Backend aufgerufen wird:

Label Krystal-Backend Modell Port
FUTILE generic Luna-Protocol-1.5B (941 Mo, Q4_K_M) 3124
INTERESSANT semantic Hermes-3-3B oder 8B (je nach Config) 3125

Die Intuition ist simpel: Ein "lol" oder "nm just chillin u" verdient es nicht, ein 8-Milliarden-Parameter-Modell aufzurufen. Das kleine feingetunte Luna-1.5B-Modell, trainiert auf 200.000 Discord-Samples, reicht für leichte Austausche völlig. Dagegen wird eine Lebensfrage, ein Geständnis oder eine technische Debatte an das große Modell geroutet, das eine reichere Antwort produzieren kann.

Dieses ökonomische Routing reduziert die Last auf dem LLM-Server erheblich: Etwa 70% der Nachrichten werden FUTILE klassifiziert und vom kleinen Modell verarbeitet, was das große Modell für Gespräche freimacht, die es wirklich wert sind.

Die Emotionsachse: Valenz und Arousal

Aber das ist nicht alles. Sapphire nutzt denselben Centroid-Mechanismus auf einer unabhängigen Achse, um die Emotion der Nachricht zu evaluieren:

Es gibt vier emotionale Centroide:

Pol Beispiele
positive "hell yeah", "love that", "this is great"
negative "shut up", "i hate this", "this sucks"
high_arousal "WHAT THE HELL", "omg omg omg", "AAAAA"
low_arousal "just chilling", "meh", "i guess"

Der Score berechnet sich als Ähnlichkeitsdifferenz auf jeder Achse:

valence = sim(emb, positive) - sim(emb, negative)     # [-1, +1]
arousal = sim(emb, high_arousal) - sim(emb, low_arousal)  # [-1, +1]

Valenz misst, ob die Nachricht positiv oder negativ ist. Arousal misst ihre emotionale Intensität. Zusammen bilden sie das Circumplex-Affektmodell (Russell, 1980), dasselbe psychologische Modell, das den Chatbot PARRY 1972 inspirierte.

Die Ressentiment-Variablen: wie Emotionen das LLM steuern

Hier wird die PARRY-Inspiration greifbar. PARRY (1972 von Kenneth Colby geschaffen) war ein Chatbot, entworfen um einen paranoiden Patienten zu simulieren. Er besaß interne Variablen (Angst, Wut, Misstrauen), die seine Antworten modifizierten. Zum Beispiel antwortete ein "verängstigter" PARRY aggressiver.

Sapphire macht dasselbe, aber mit kontinuierlichen Variablen und einer eleganteren Methode: Die Sampling-Parameter des LLM werden live nach dem emotionalen Zustand der Konversation justiert.

Die Temperatur folgt dem Arousal
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Arousal Temperatur Effekt
-1.0 (ruhig) 0.40 Niedrige Kreativität, vorhersehbare Antworten
0.0 (neutral) 0.70 Standardkreativität
+1.0 (aufgeregt) 1.00 Maximum an Zufälligkeit, überraschende Antworten

Wenn jemand aufgeregt oder genervt ist (hohes Arousal), steigt die Temperatur. Das Modell produziert variablere, kreativere, manchmal chaotischere Antworten, wie ein Mensch, der sich "ereifert". Wenn die Konversation ruhig ist, sinkt die Temperatur, die Antworten sind gesetzter.

Der Repeat Penalty folgt der Valenz
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
Valenz Repeat Penalty Effekt
-1.0 (negativ) 1.25 Starke Penalty, vermeidet Wiederholungen
0.0 (neutral) 1.15 Standardwert
+1.0 (positiv) 1.05 Schwache Penalty, erlaubt Wiederholungen

Je negativer die Konversation, desto mehr wird das Modell gedrängt, sich nicht zu wiederholen, wie jemand, der in einem angespannten Streit nach Worten sucht. Je positiver die Konversation, desto mehr kann sich das Modell redundante Bekräftigungen erlauben, wie in einer entspannten Unterhaltung.

Der kumulative Emotionszustand

Diese Scores betreffen nicht nur die unmittelbare Nachricht. Ein EmotionState pflegt einen exponentiellen gleitenden Mittelwert von Valenz und Arousal pro Session:

class EmotionState:
    def __init__(self, decay=0.85, deadzone=0.06):
        self.decay = decay
        self.deadzone = deadzone

    def update(self, key, valence_delta, arousal_delta):
        if abs(valence_delta) < self.deadzone:
            valence_delta = 0.0
        if abs(arousal_delta) < self.deadzone:
            arousal_delta = 0.0
        s = self._state.setdefault(key, {"valence": 0.0, "arousal": 0.0})
        s["valence"] = s["valence"] * self.decay + valence_delta * (1 - self.decay)
        s["arousal"] = s["arousal"] * self.decay + arousal_delta * (1 - self.decay)
        return s

Das decay von 0.85 heißt, dass 85% des vorherigen Zustands bei jeder Nachricht erhalten bleiben und 15% des neuen Signals integriert werden. Das gibt ein emotionales Gedächtnis, das brutale Schwankungen glättet: Eine einzige negative Nachricht macht den Bot nicht "traurig", aber eine Serie negativer Nachrichten lässt seine Stimmung progressiv driften.

In der Praxis: Wenn jemand eine Konversation sehr aufgeregt beginnt (arousal=+0.8), bleibt die Temperatur über mehrere Austausche hoch, selbst wenn die folgenden Nachrichten ruhiger sind. Die Emotion braucht Zeit zum Absinken, wie ein Mensch, der nach einem Streit noch "heiß" bleibt.


Schicht 4: die Inference (Krystal)

Krystal ist die unterste Schicht: Ein Wrapper um llama.cpp, der eine OpenAI-kompatible API exponiert (/v1/chat/completions). Es läuft in zwei PM2-Instanzen:

  • krystal-small: Das feingetunte Luna-1.5B-Modell, auf Port 3124, mit CPU-Affinität 0
  • krystal-large: Ein Hermes-3B-Modell, auf Port 3125, mit CPU-Affinität 0,1

Beide Instanzen sind präkompilierte llama-server-Prozesse, gestartet mit taskset für CPU-Pinning.

Das Fine-Tune des Luna-Modells hat sich seit dem zweiten Artikel auch weiterentwickelt: Es ist jetzt auf 200.000 Samples trainiert (gegenüber 50.000 zuvor), weiterhin ausgehend von Qwen2.5-1.5B-Instruct via QLoRA. Die 200k Samples sind eine Teilmenge des Discord-Dialogues-Datasets, gefiltert, um nur die natürlichsten und diversesten Gespräche zu behalten. Das Ziel: Das stilistische Register des Modells erweitern, ohne die Flexibilität zu verlieren, die Few-Shot-Priming so effektiv macht.


Das komplette Schema: eine Nachricht im Transit

Hier passiert konkret, wenn jemand "je suis vraiment triste aujourd'hui" auf Discord schickt:

  1. Jade empfängt die Nachricht via Discord-Gateway-API. Er transformiert sie in ein MessageEvent und schickt es via WebSocket an Emerald.
  2. Emerald evaluiert den Trigger (Mention? Name? Keyword ?). Es ist eine direkte Mention. Es berechnet eine Konzentrationsverzögerung, prüft Cooldown, Session, Themenmüdigkeit. Es entscheidet zu antworten und schickt die Nachricht via HTTP an Sapphire.
  3. Sapphire embeddet die Nachricht mit bge-small-en-v1.5.
    • Klassifikation: Die Nachricht liegt näher am interessant-Centroid als am futile-Centroid (diff = +0.31) -> INTERESSANT
    • Emotion: Negative Valenz (-0.42), moderates Arousal (0.35)
    • Routing: Richtung KRYSTAL_SEMANTIC_URL (Port 3125, großes Modell)
    • Sampling-Parameter: Temperatur = 0.80 (erhöhtes Arousal), repeat_penalty = 1.19 (negative Valenz)
    • Der emotionale Sessionzustand wird mit diesen Werten aktualisiert
  4. Krystal (große Instanz) generiert die Antwort mit den emotional justierten Parametern und gibt sie an Sapphire zurück.
  5. Sapphire streamt die Antwort mit den Metadaten (Label, Valenz, Arousal, Debug-Statistiken) an Emerald.
  6. Emerald entscheidet, ein Zögern hinzuzufügen ("oh..."), plant einen Burst (2 Fragmente) und wählt eine Reaktion. Es schickt ein RespondCommand an Jade.
  7. Jade führt aus: Wartet die initiale Verzögerung ab, schickt das erste Fragment mit dem Zögern, wartet 1.5s, schickt das zweite Fragment. Er zeigt den Tippindikator während der ganzen Generierung.

Alles in weniger als 3 Sekunden für den Nutzer.


Die Centroide: warum es besser ist als ein neuronaler Klassifizierer

Die Wahl von Embedding-Centroiden statt eines traditionellen Klassifizierers (wie dem DistilBERT, das ich vorher nutzte) verdient eine Erklärung.

Ein neuronaler Klassifizierer lernt eine Entscheidungsgrenze zwischen Klassen, typischerweise eine nicht-lineare Transformation, die Eingaben auf Wahrscheinlichkeiten projiziert. Er ist präzise, aber:

  • Er braucht gelabelte Trainingsdaten
  • Er ist sensitiv für Distributionswechsel (Data Drift)
  • Er ist schwer interpretierbar
  • Er muss für eine neue Klasse neu trainiert werden

Ein Centroid dagegen ist ein gemittelter Vektor von Beispiel-Embeddings. Die Klassifikation passiert per Kosinus-Ähnlichkeit zu diesem gemittelten Vektor. Vorteile:

  • Kein Training nötig: Man berechnet nur den Mittelwert von Embeddings handgewählter Beispiele
  • Leicht interpretierbar: Man kann schauen, welche Beispiele dem Centroid am nächsten sind, um zu verstehen, "was der Centroid gelernt hat"
  • Hinzufügen einer Klasse: Man fügt nur einen neuen Centroid hinzu, ohne Neutraining
  • Robust: Der Centroid ist ein Mittelwert, also haben Outlier wenig Impact

Die wahre Macht der Centroide ist, dass sie ein Klassifikationsproblem in ein räumliches Distanzmessungs-Problem verwandeln. Man kann Kategorien als Regionen in einem 384-dimensionalen Raum visualisieren (oder in 2D/3D nach PCA/t-SNE-Dimensionsreduktion).

3D-Visualisierung der Centroide

In der Praxis sehen die Klassifikations-Centroide im Embedding-Raum so aus. Jeder Punkt ist eine Beispielnachricht, per PCA in 3D projiziert (die 384 Originaldimensionen sind auf 3 zur Visualisierung reduziert). Die blauen Punkte sind futile Nachrichten, die gelben Punkte interessante Nachrichten. Die 20 Diamantmarker sind die k-means-Centroide (10 pro Klasse, seed=42). Fahre mit der Maus über einen Punkt, um den Originaltext des Beispiels zu sehen.

Zwei Testbeispiele werden rot angezeigt: "lol" (als futil klassifiziert) und "i feel sad today" (als interessant klassifiziert). Selbst nach Reduktion von 384 auf 3 Dimensionen (14,7% erklärte Varianz) sind beide Cluster klar getrennt. Die Annotation oben zeigt die exakten Counts und die ambige Zone.

Der Centroid der Eingabenachricht wandert in diesem Raum je nach Inhalt. Die FUTILE/INTERESSANT-Klassifikation besteht schlicht darin zu messen, welcher Centroid per Kosinus-Ähnlichkeit am nächsten ist. So kann man jede Nachricht als Punkt in einem mehrdimensionalen Raum darstellen, jede Dimension entspricht einer semantischen Eigenschaft.


Was das in der Praxis ändert

Die Nutzer sehen weder Schichten noch Centroide noch Temperaturanpassungen. Aber sie spüren die Effekte:

  • Schnellere Antworten für simple Nachrichten (das kleine Modell ist 2x schneller und handhabt 70% des Traffics)
  • Adaptiver Ton: Wenn du genervt bist, "spürt" der Bot die Genervtheit und passt seinen Stil an
  • Plattformübergreifende Kohärenz: Ein Matrix-Bot und ein Discord-Bot teilen dasselbe Gehirn und denselben emotionalen Zustand
  • Authentischer Ton bewahrt: Fine-Tune + Few-Shot + intelligentes Routing vermeidet Corporate-Antworten

Der Wechsel auf 200k Trainingssamples für das kleine Modell hat diese Effekte noch verstärkt: Das Modell erfasst die Diversität der Discord-Gespräche besser, ohne die Formbarkeit zu verlieren, die Few-Shot-Priming erlaubt.


Die komplette Infrastruktur

Hier die aktuell laufenden Services:

Service Technologie Port(s) Rolle
Pixieglow TypeScript (Bun) -- Matrix-Adapter
Jade TypeScript (esbuild) -- Discord-Adapter
Emerald TypeScript (Bun) 3126 (WebSocket) Gehirn / Entscheidungen
Sapphire Python (FastAPI) 3123 (HTTP) Klassifizierer + Emotion
Krystal small llama.cpp (PM2) 3124 Kleines Modell (1.5B, futil)
Krystal large llama.cpp (PM2) 3125 Großes Modell (3B+, interessant)

Die Abhängigkeiten zwischen Services sind unidirektional: Der Adapter hängt von Emerald ab, Emerald hängt von Sapphire ab, Sapphire hängt von Krystal ab. Der Graph bleibt azyklisch. Jeder Service kann unabhängig neu gestartet werden.


Fazit

Luna Protocol in vier Schichten zu teilen war nicht nur eine Architekturübung. Es war eine Antwort auf konkrete Limitationen: Unmöglichkeit, Matrix zu supporten, fehlendes emotionales Bewusstsein, fehlende intelligente Nachrichtenpriorisierung.

Heute ist das System robuster (ein LLM-Crash tötet den Bot nicht), erweiterbarer (ein Telegram- oder WhatsApp-Adapter würde demselben WebSocket-Protokoll folgen) und "lebendiger": Der Bot passt sein Verhalten, seinen Ton und sogar die LLM-Parameter an den wahrgenommenen emotionalen Zustand der Konversation an.

Die Embedding-Centroide sind das Schlüsselelement, das all das ohne übermäßige Komplexität möglich macht: Kein trainiertes neuronales Netz, keine gelabelte Datenpipeline, nur Vektormittelwerte und Kosinus-Ähnlichkeiten. Eine simple, unglaublich effektive und schrecklich unterschätzte Technik.

Ressource Link
Projektwebsite protocol-luna.github.io
Pixieglow protocol-luna/pixieglow
Emerald protocol-luna/emerald
Sapphire protocol-luna/sapphire
Krystal protocol-luna/krystal
Artikel 1: der Discord-Bot Luna Protocol: Ich habe einen autonomen Discord-Bot gebaut
Artikel 2: das Fine-Tuning Luna Protocol: warum ich ein 1,5B-Modell feingetunt habe

Luna Protocol : мутуализация мозгов, эмоциональная классификация и роутинг интересное/тщетное

Luna Protocol перешёл от монолита к четырёхслойной архитектуре : адаптеры, мозг, эмоциональный классификатор и инференс. В программе : центроиды эмбеддингов, роутинг интересное/тщетное и подстройка параметров LLM по валентности и возбуждению.

Luna Protocol : мутуализация мозгов, эмоциональная классификация и роутинг интересное/тщетное

В двух предыдущих статьях я представил Luna Protocol как уникального Discord-бота со сложной поведенческой системой и дообученной моделью. Но архитектура с тех пор сильно развилась. То, что было монолитом : один процесс Node.js, управлявший одновременно Discord-ботом, поведением и вызовами LLM, превратилось в четыре независимых слоя, у каждого своя ответственность, свой язык и свой жизненный цикл.

Это разделение принесло неожиданные выгоды : мутуализацию "мозгов" между несколькими платформами, систему эмоциональной классификации, динамически подстраивающую параметры LLM, и умный роутинг сообщений между двумя моделями по perceived важности разговора.

Эволюция случилась не разом : она шла органическим путём. Я сначала отделил папку server/ из репозитория бота, создав так Krystal с одной стороны и оставив Jade как Discord-адаптер. Затем создал Pixieglow (адаптер Matrix), переиспользовав llm-core и шину событий Jade. Потом пришёл Sapphire, чтобы ввести классификацию GENERIC/SEMANTIC с DistilBERT. Результаты были неубедительными, так что я перешёл на центроиды эмбеддингов, более податливые для обогащения примерами и более точные ; классификация стала FUTILE/INTERESSANT. Наконец добавил центроиды валентности и возбуждения, чтобы регулировать температуру и repeat penalty у LLM. В конце вычистил весь избыточный код между Jade и Pixieglow, создав Emerald, мутуализированный мозг, превратив Jade и Pixieglow в простых socket-driven клиентов.

Параллельно я поддерживал сайт, отслеживающий прогресс проекта : protocol-luna.github.io.

Эта статья рассказывает, как и зачем я разрезал эти слои, что ровно делает каждый сервис и как концепции вроде центроидов (средних векторов эмбеддингов) и переменных ressentiment (вдохновлённых чат-ботом PARRY 70-х) превратили простого Discord-бота в удивительно связную мультиплатформенную систему.


Проблема с монолитом

Изначально Luna Protocol умещался в один процесс Node.js. Код управлял :

  • Подключением к Discord (через библиотеку Eris)
  • Оценкой триггеров (упоминания, ключевые слова, follow-up...)
  • Симуляцией человеческих поведений (опечатки, колебания, сон...)
  • HTTP-вызовами локального LLM-сервера (llama.cpp)
  • Управлением сессиями и антиспамом
  • TTS-пайплайном

Всё было в одном процессе, общаясь через типизированные шины событий (TypedBus). Это работало, но с лимитами :

  • Невозможно добавить клиент Matrix, не дублируя весь код поведения
  • LLM и бот были в одном репо : папка server/ уже существовала, но развивать одно, не трогая другое, было невозможно
  • Недифференцированная обработка : каждое сообщение обрабатывалось одинаково, будь то "lol" или экзистенциальный вопрос
  • Эмоциональная амнезия : бот ничего не "чувствовал"

Разрезание на слои решило все эти проблемы.


Четыре слоя

Текущая архитектура Luna Protocol организована как четырёхуровневая воронка :

Matrix / Discord
      |
      v
  [АДАПТЕРЫ]      Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [МОЗГ]           Emerald (WebSocket, порт 3126)
      |
      v
  [КЛАССИФИКАТОР]  Sapphire (HTTP, порт 3123)
      |
      v
  [ИНФЕРЕНС]       Krystal (llama.cpp, порты 3124 / 3125)

Каждый слой можно перезапускать, обновлять или заменять независимо.


Слой 1 : адаптеры (Pixieglow и Jade)

Это самые простые слои. Их единственная работа транслировать события мессенджер-платформы в стандартизированный протокол к Emerald :

  • Jade это Discord-адаптер. Он использует библиотеку Eris для подключения к Discord и форвардит сообщения в Emerald через WebSocket. Он управляет и TTS-пайплайном (синтез речи через Piper, конверсия в OGG, загрузка в Discord).
  • Pixieglow это Matrix-адаптер. Он использует напрямую HTTP Client-Server API Matrix (без SDK), с long-poll sync. TTS у него нет.

Оба адаптера разделяют один WebSocket-протокол, определённый в emerald-client.ts :

type ClientId = "jade" | "pixieglow";

// События (адаптер -> Emerald)
type InEvent = MessageEvent | ReadyEvent | BotMessageEvent | PresenceEvent;

// Команды (Emerald -> адаптер)
type OutCommand = RespondCommand | TypingCommand | SetPresenceCommand
                | SpontaneousCommand | ForgotCommand;

Существование двух адаптеров с одним интерфейсом доказывает мутуализацию : один "мозг" (Emerald) обслуживает безразлично Discord-бота и Matrix-бота с идентичными поведениями. Протокол декларативный : Emerald не говорит адаптеру, как отправлять сообщение, он говорит, что отправлять (текст с задержкой, возможно план burst, реакция и т.д). Каждый адаптер реализует конкретное исполнение под свою платформу.

В этом сила архитектуры : чтобы добавить поддержку Telegram, Signal или ещё чего, достаточно написать адаптер, реализующий WebSocket-протокол.


Слой 2 : мозг (Emerald)

Emerald центральный сервис решений. Он слушает порт 3126 по WebSocket и управляет :

  • Оценкой триггеров : упоминание, DM, имя, ключевое слово, follow-up, случайное
  • Поведенческой симуляцией : задержки концентрации, опечатки, колебания, забывания, burst, тематическая усталость
  • Циклами сна : режимы sleep / slow / short
  • Управлением сессиями : cooldown, лимиты сессий, антиспам
  • Роутингом в Sapphire : отправка сообщений, приём стримимых ответов

Emerald центральный сервис, сделавший мутуализацию возможной, и именно он сильнее всего выиграл от разделения. Раньше каждое поведение (typo, burst, hesitation) переплеталось с кодом Discord. Теперь они в выделенных модулях в behavior/ :

emerald/src/behavior/
  burst.ts         -- Планирование сообщений очередями
  mannerisms.ts    -- Задержки, колебания, реакции, забывания
  sleep.ts         -- Оценка расписания сна
  typo.ts          -- Симуляция опечаток (AZERTY/QWERTY)

Мозг не знает, на какой платформе крутится. Он получает MessageEvent с clientId ("jade" или "pixieglow"), принимает решение и возвращает команду. Остальным занимается адаптер.


Слой 3 : эмоциональный классификатор (Sapphire)

Sapphire технически самый интересный сервис. Это LLM-middleware, написанный на Python с FastAPI, играющий четыре критические роли :

  1. Бинарный классификатор FUTILE / INTERESSANT через центроиды эмбеддингов
  2. Эмоциональный скорер (валентность / возбуждение) через центроиды
  3. Роутер бэкендов к Krystal (маленькая модель против большой модели)
  4. Инжектор few-shot и менеджер сессий

Центроиды : сердце классификации

Центроид это простая концепция : среднее множества векторов эмбеддингов. Конкретно я собрал сотни примеров сообщений, прогнал их через модель эмбеддингов (BAAI/bge-small-en-v1.5, 384 измерения) и усреднил полученные векторы.

Есть два классификационных центроида :

  • futile_centroid : ~683 тривиальных сообщения ("lol", "ok", "hello") через k-means (k=10, seed=42)
  • interessant_centroid : ~678 субстантивных сообщений (технические, личные, философские) через k-means (k=10, seed=42)

Когда приходит сообщение :

def classify(text, embedder, futile_centroids, interessant_centroids):
    emb = embedder.query_embed(text)                     # вектор 384-D
    sim_f = max(cos(emb, c) for c in futile_centroids)     # максимум по 10
    sim_i = max(cos(emb, c) for c in interessant_centroids) # максимум по 10
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

Балл на класс это максимальная косинусная схожесть среди его 10 центроидов. Это захватывает подтипы внутри каждой категории : приветствие и прощание оба падают рядом с одним из 10 тщетных центроидов, даже если далеки в пространстве эмбеддингов. Только k-means на старте и скалярные произведения в рантайме : без обучения, без GPU.

Почему две модели?

Результат этой классификации решает, какой LLM-бэкенд вызывается :

Метка Бэкенд Krystal Модель Порт
FUTILE generic Luna-Protocol-1.5B (941 Mo, Q4_K_M) 3124
INTERESSANT semantic Hermes-3-3B или 8B (по конфигу) 3125

Интуиция проста : "lol" или "nm just chillin u" не заслуживают вызова модели на 8 миллиардов параметров. Маленькая дообученная модель Luna 1.5B, обученная на 200 000 образцах Discord, более чем достаточна для лёгких обменов. А вот вопрос о жизни, откровенность или технический спор роутится на большую модель, способную дать более богатый ответ.

Этот экономный роутинг значительно снижает нагрузку на LLM-сервер : около 70 % сообщений классифицируются FUTILE и обрабатываются маленькой моделью, освобождая большую модель для разговоров, которые реально того стоят.

Эмоциональная ось : валентность и возбуждение

Но это не всё. Sapphire использует тот же механизм центроидов на независимой оси, чтобы оценивать эмоцию сообщения :

Есть четыре эмоциональных центроида :

Полюс Примеры
positive "hell yeah", "love that", "this is great"
negative "shut up", "i hate this", "this sucks"
high_arousal "WHAT THE HELL", "omg omg omg", "AAAAA"
low_arousal "just chilling", "meh", "i guess"

Балл считается как разница схожестей на каждой оси :

valence = sim(emb, positive) - sim(emb, negative)     # [-1, +1]
arousal = sim(emb, high_arousal) - sim(emb, low_arousal)  # [-1, +1]

Валентность измеряет, позитивно сообщение или негативно. Возбуждение измеряет его эмоциональную интенсивность. Вместе они образуют циркумплексную модель аффекта (Russell, 1980), ту же психологическую модель, что вдохновила чат-бот PARRY в 1972.

Переменные ressentiment : как эмоции управляют LLM

Здесь вдохновение PARRY становится осязаемым. PARRY (созданный Kenneth Colby в 1972) был чат-ботом, задуманным симулировать параноидального пациента. У него были внутренние переменные (страх, гнев, недоверчивость), модифицировавшие его ответы. Например, "испуганный" PARRY отвечал агрессивнее.

Sapphire делает то же самое, но с непрерывными переменными и более элегантным методом : параметры семплирования LLM подстраиваются в реальном времени по эмоциональному состоянию разговора.

Температура следует за возбуждением
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Возбуждение Температура Эффект
-1.0 (calme) 0.40 Низкая креативность, предсказуемые ответы
0.0 (neutre) 0.70 Креативность по умолчанию
+1.0 (excité) 1.00 Максимум случайности, удивительные ответы

Когда кто-то возбуждён или зол (высокое возбуждение), температура растёт. Модель даёт более разнообразные, креативные, иногда более хаотичные ответы, как человек, который "заводится". Когда разговор спокоен, температура падает, ответы более взвешенные.

Repeat penalty следует за валентностью
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
Валентность Repeat Penalty Эффект
-1.0 (négatif) 1.25 Сильный штраф, избегает повторов
0.0 (neutre) 1.15 Значение по умолчанию
+1.0 (positif) 1.05 Слабый штраф, допускает повторы

Чем негативнее разговор, тем сильнее модель толкается избегать повторов, как человек, подбирающий слова в напряжённом споре. Чем позитивнее разговор, тем больше модель может позволить избыточных утверждений, как в расслабленной беседе.

Кумулятивное эмоциональное состояние

Эти баллы относятся не только к немедленному сообщению. EmotionState держит экспоненциальное скользящее среднее валентности и возбуждения на сессию :

class EmotionState:
    def __init__(self, decay=0.85, deadzone=0.06):
        self.decay = decay
        self.deadzone = deadzone

    def update(self, key, valence_delta, arousal_delta):
        if abs(valence_delta) < self.deadzone:
            valence_delta = 0.0
        if abs(arousal_delta) < self.deadzone:
            arousal_delta = 0.0
        s = self._state.setdefault(key, {"valence": 0.0, "arousal": 0.0})
        s["valence"] = s["valence"] * self.decay + valence_delta * (1 - self.decay)
        s["arousal"] = s["arousal"] * self.decay + arousal_delta * (1 - self.decay)
        return s

decay на 0.85 значит, что 85 % предыдущего состояния сохраняется при каждом сообщении, а 15 % нового сигнала интегрируется. Это даёт эмоциональную память, сглаживающую резкие вариации : одно негативное сообщение не делает бота "грустным", но серия негативных сообщений постепенно сдвигает его настроение.

На практике : если кто-то начинает разговор очень возбуждённо (arousal=+0.8), температура остаётся высокой несколько обменов, даже если следующие сообщения спокойнее. Эмоции нужно время, чтобы остыть, как человеку, остающемуся "горячим" после спора.


Слой 4 : инференс (Krystal)

Krystal самый нижний слой : обёртка вокруг llama.cpp, выставляющая OpenAI-совместимый API (/v1/chat/completions). Он крутится в двух инстансах PM2 :

  • krystal-small : дообученная модель Luna 1.5B, на порту 3124, с аффинностью CPU 0
  • krystal-large : модель Hermes 3B, на порту 3125, с аффинностью CPU 0,1

Оба инстанса это предскомпилированные процессы llama-server, запущенные с taskset для CPU-пиннинга.

Дообучение модели Luna тоже развилось со второй статьи : теперь она обучена на 200 000 образцах (против 50 000 раньше), всё так же из Qwen2.5-1.5B-Instruct через QLoRA. 200k образцов это подмножество датасета Discord-Dialogues, отфильтрованное так, чтобы оставить самые естественные и разнообразные разговоры. Цель : расширить стилевой регистр модели, не потеряв гибкость, делающую few-shot priming таким эффективным.


Полная схема : сообщение в пути

Вот что конкретно происходит, когда кто-то отправляет "мне сегодня реально грустно" в Discord :

  1. Jade получает сообщение через Discord Gateway API. Он превращает его в MessageEvent и отправляет в Emerald через WebSocket.
  2. Emerald оценивает триггер (упоминание? имя? ключевое слово?). Это прямое упоминание. Он вычисляет задержку концентрации, проверяет cooldown, сессию, тематическую усталость. Он решает ответить и отправляет сообщение в Sapphire через HTTP.
  3. Sapphire эмбеддит сообщение через bge-small-en-v1.5.
    • Классификация : сообщение ближе к центроиду interessant, чем к центроиду futile (diff = +0.31) -> INTERESSANT
    • Эмоция : негативная валентность (-0.42), умеренное возбуждение (0.35)
    • Роутинг : направление KRYSTAL_SEMANTIC_URL (порт 3125, большая модель)
    • Параметры семплирования : температура = 0.80 (возбуждение повышено), repeat_penalty = 1.19 (валентность негативна)
    • Эмоциональное состояние сессии обновлено этими значениями
  4. Krystal (большой инстанс) генерирует ответ с эмоционально подстроенными параметрами и возвращает его в Sapphire.
  5. Sapphire стримит ответ в Emerald с метаданными (label, valence, arousal, отладочная статистика).
  6. Emerald решает добавить колебание ("oh..."), планирует burst (2 фрагмента) и выбирает реакцию. Он отправляет RespondCommand в Jade.
  7. Jade исполняет : ждёт начальную задержку, отправляет первый фрагмент с колебанием, ждёт 1.5s, отправляет второй фрагмент. Он показывает индикатор печати всё время генерации.

Всё это меньше чем за 3 секунды для пользователя.


Центроиды : почему это лучше нейронного классификатора

Выбор центроидов эмбеддингов вместо традиционного классификатора (вроде DistilBERT, что я использовал раньше) заслуживает объяснения.

Нейронный классификатор выучивает границу решения между классами, обычно нелинейную трансформацию, проецирующую входы в вероятности. Он точен, но :

  • Ему нужны размеченные обучающие данные
  • Он чувствителен к смене распределения (data drift)
  • Его трудно интерпретировать
  • Его надо переобучать, чтобы добавить новый класс

Центроид, напротив, это средний вектор эмбеддингов примеров. Классификация делается косинусной схожестью к этому среднему вектору. Преимущества :

  • Никакого обучения не требуется : просто считаем среднее эмбеддингов вручную выбранных примеров
  • Легко интерпретировать : можно посмотреть, какие примеры ближе всего к центроиду, чтобы понять, "чему центроид научился"
  • Добавление класса : просто добавляем новый центроид без переобучения
  • Робастность : центроид это среднее, так что выбросы мало влияют

Настоящая мощь центроидов в том, что они превращают задачу классификации в задачу измерения пространственной дистанции. Можно визуализировать категории как регионы в пространстве на 384 измерения (или в 2D/3D после редукции PCA/t-SNE).

3D-визуализация центроидов

На практике вот как выглядят классификационные центроиды в пространстве эмбеддингов. Каждая точка это сообщение-пример, спроецированное в 3D через PCA (исходные 384 измерения ужаты до 3 для визуализации). Синие точки это тщетные сообщения, жёлтые это интересные сообщения. 20 маркеров-ромбов это k-means центроиды (10 на класс, seed=42). Наведи курсор на точку, чтобы увидеть исходный текст примера.

Два тестовых примера показаны красным : "lol" (классифицирован тщетным) и "i feel sad today" (классифицирован интересным). Даже после ужатия с 384 до 3 измерений (14,7 % объяснённой дисперсии) оба кластера чётко разделены. Аннотация сверху показывает точные counts и неоднозначную зону.

Центроид входного сообщения гуляет в этом пространстве в зависимости от содержимого. Классификация FUTILE/INTERESSANT состоит просто в измерении, какой центроид ближе по косинусной схожести. Так можно представить каждое сообщение как точку в многомерном пространстве, каждое измерение соответствует семантическому свойству.


Что это меняет на практике

Пользователи не видят слои, центроиды или подстройки температуры. Но они чувствуют эффекты :

  • Более быстрые ответы на простые сообщения (маленькая модель в 2 раза быстрее и обрабатывает 70 % трафика)
  • Адаптивный тон : если ты зол, бот "чует" злость и адаптирует стиль
  • Кросс-платформенная связность : Matrix-бот и Discord-бот разделяют один мозг и одно эмоциональное состояние
  • Сохранённый authentic тон : дообучение + few-shot + умный роутинг избегают корпоративных ответов

Переход на 200k обучающих образцов для маленькой модели ещё усилил эти эффекты : модель лучше захватывает разнообразие разговоров Discord, не теряя податливости, даваемой few-shot priming.


Полная инфраструктура

Вот сервисы, крутящиеся сейчас :

Сервис Технология Порт(ы) Роль
Pixieglow TypeScript (Bun) -- Matrix-адаптер
Jade TypeScript (esbuild) -- Discord-адаптер
Emerald TypeScript (Bun) 3126 (WebSocket) Мозг / решения
Sapphire Python (FastAPI) 3123 (HTTP) Классификатор + эмоции
Krystal small llama.cpp (PM2) 3124 Маленькая модель (1.5B, тщетное)
Krystal large llama.cpp (PM2) 3125 Большая модель (3B+, интересное)

Зависимости между сервисами однонаправленные : адаптер зависит от Emerald, Emerald зависит от Sapphire, Sapphire зависит от Krystal. Граф остаётся ацикличным. Каждый сервис можно перезапускать независимо.


Заключение

Разделение Luna Protocol на четыре слоя было не просто упражнением в архитектуре. Это был ответ на конкретные лимиты : невозможность поддерживать Matrix, отсутствие эмоционального сознания, отсутствие умной приоритизации сообщений.

Сегодня система робастнее (краш LLM не убивает бота), расширяемее (адаптер Telegram или WhatsApp следовал бы тому же WebSocket-протоколу) и более "живая" : бот адаптирует поведение, тон и даже параметры LLM к perceived эмоциональному состоянию разговора.

Центроиды эмбеддингов ключевой элемент, делающий всё это возможным без чрезмерной сложности : никакой обученной нейросети, никакого пайплайна размеченных данных, только средние векторов и косинусные схожести. Это простая техника, невероятно эффективная и ужасно недооценённая.

Ресурс Ссылка
Сайт проекта protocol-luna.github.io
Pixieglow protocol-luna/pixieglow
Emerald protocol-luna/emerald
Sapphire protocol-luna/sapphire
Krystal protocol-luna/krystal
Статья 1 : Discord-бот Luna Protocol : j'ai créé un bot Discord autonome
Статья 2 : дообучение Luna Protocol : зачем я дообучил модель 1,5B

Luna Protocol: mutualización de cerebros, clasificación emocional, y ruteo interesante/fútil

Luna Protocol pasó de un monolito a una arquitectura en cuatro capas: adaptadores, brain, clasificador emocional, e inference. En el programa: centroids de embeddings, ruteo interesante/fútil, y ajuste de los parámetros del LLM por valencia y arousal.

Luna Protocol: mutualización de cerebros, clasificación emocional, y ruteo interesante/fútil

En los dos artículos anteriores, presenté Luna Protocol como un bot Discord único con un sistema conductual complejo y un modelo fine-tuneado. Pero la arquitectura evolucionó mucho desde entonces. Lo que era un monolito: un solo proceso Node.js que gestionaba a la vez el bot Discord, el comportamiento, y las llamadas LLM, se transformó en cuatro capas independientes, cada una con su propia responsabilidad, su propio lenguaje, y su propio ciclo de vida.

Esa separación aportó beneficios inesperados: la mutualización de los "cerebros" entre varias plataformas, un sistema de clasificación emocional que ajusta dinámicamente los parámetros del LLM, y un ruteo inteligente de los mensajes entre dos modelos según la importancia percibida de la conversación.

La evolución no se hizo de golpe: siguió un camino orgánico. Primero separé la carpeta server/ del repo del bot, creando así Krystal por un lado y dejando Jade como adaptador Discord. Luego creé Pixieglow (adaptador Matrix) retomando el llm-core y el bus de eventos de Jade. Después vino Sapphire para introducir una clasificación GENERIC/SEMANTIC con DistilBERT. Los resultados no eran concluyentes, así que pasé por centroids de embeddings, más maleables para el enriquecimiento de ejemplos y más precisos; la clasificación se volvió FUTILE/INTERESSANT. Finalmente añadí centroids de valencia y arousal para regular la temperatura y el repeat penalty del LLM. Para terminar, quité todo el código redundante entre Jade y Pixieglow creando Emerald, el cerebro mutualizado, transformando Jade y Pixieglow en simples clientes socket-driven.

En paralelo, mantuve al día un sitio web que recorre el avance del proyecto: protocol-luna.github.io.

Este artículo cuenta cómo y por qué troceé esas capas, qué hace exactamente cada servicio, y cómo conceptos como los centroids (vectores medios de embeddings) y las variables de resentimiento (inspiradas en el chatbot PARRY de los 70) transformaron un simple bot Discord en un sistema multiplataforma sorprendentemente coherente.


El problema con el monolito

Al principio, Luna Protocol cabía en un solo proceso Node.js. El código gestionaba:

  • La conexión a Discord (vía la librería Eris)
  • La evaluación de los disparadores (menciones, palabras clave, follow-up...)
  • La simulación de comportamientos humanos (erratas, vacilaciones, sueño...)
  • Las llamadas HTTP al servidor LLM local (llama.cpp)
  • La gestión de las sesiones y del antispam
  • El pipeline TTS

Todo estaba en el mismo proceso, comunicando vía buses de eventos tipados (TypedBus). Funcionaba, pero con límites:

  • Imposible añadir un cliente Matrix sin duplicar todo el código de comportamiento
  • El LLM y el bot estaban en el mismo repo: la carpeta server/ ya existía, pero imposible hacer evolucionar uno sin tocar el otro
  • Procesamiento indiferenciado: cada mensaje se trataba igual, fuera un "lol" o una pregunta existencial
  • Amnesia emocional: el bot no "sentía" nada

El troceado en capas resolvió todos esos problemas.


Las cuatro capas

La arquitectura actual de Luna Protocol está organizada como un embudo de cuatro niveles:

Matrix / Discord
      |
      v
  [ADAPTATEURS]      Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [BRAIN]            Emerald (WebSocket, port 3126)
      |
      v
  [CLASSIFIEUR]      Sapphire (HTTP, port 3123)
      |
      v
  [INFERENCE]        Krystal (llama.cpp, ports 3124 / 3125)

Cada capa puede reiniciarse, actualizarse, o reemplazarse independientemente.


Capa 1: los adaptadores (Pixieglow y Jade)

Son las capas más simples. Su único trabajo es traducir los eventos de una plataforma de mensajería en un protocolo estandarizado hacia Emerald:

  • Jade es el adaptador Discord. Usa la librería Eris para conectarse a Discord y reenviar los mensajes hacia Emerald vía WebSocket. También gestiona el pipeline TTS (síntesis vocal vía Piper, conversión a OGG, subida a Discord).
  • Pixieglow es el adaptador Matrix. Usa la API HTTP Client-Server de Matrix directamente (sin SDK), con un long-poll sync. No tiene TTS.

Ambos adaptadores comparten el mismo protocolo WebSocket definido en emerald-client.ts:

type ClientId = "jade" | "pixieglow";

// Eventos (adaptador -> Emerald)
type InEvent = MessageEvent | ReadyEvent | BotMessageEvent | PresenceEvent;

// Comandos (Emerald -> adaptador)
type OutCommand = RespondCommand | TypingCommand | SetPresenceCommand
                | SpontaneousCommand | ForgotCommand;

La existencia de dos adaptadores con la misma interfaz prueba la mutualización: el mismo "cerebro" (Emerald) sirve indistintamente un bot Discord y un bot Matrix, con comportamientos idénticos. El protocolo es declarativo: Emerald no dice al adaptador cómo enviar un mensaje, dice qué enviar (el texto con un retardo, eventualmente un plan de burst, una reacción, etc.). Cada adaptador implementa la ejecución concreta según su plataforma.

Esa es la fuerza de esta arquitectura: para añadir soporte de Telegram, Signal, u otro, basta escribir un adaptador que implemente el protocolo WebSocket.


Capa 2: el cerebro (Emerald)

Emerald es el servicio central de decisión. Escucha en el puerto 3126 en WebSocket y gestiona:

  • La evaluación de los disparadores: mención, DM, nombre, palabra clave, follow-up, aleatorio
  • La simulación conductual: retardos de concentración, erratas, vacilaciones, olvidos, burst, fatiga temática
  • Los ciclos de sueño: modos sleep / slow / short
  • La gestión de las sesiones: cooldown, límites de sesión, antispam
  • El ruteo hacia Sapphire: envío de los mensajes, recepción de las respuestas streameadas

Emerald es el servicio central que permitió la mutualización, y es el que más se benefició de la separación. Antes, cada comportamiento (typo, burst, hesitation) estaba entrelazado con el código Discord. Ahora, están en módulos dedicados en behavior/:

emerald/src/behavior/
  burst.ts         -- Planificación de los mensajes en ráfagas
  mannerisms.ts    -- Retardos, vacilaciones, reacciones, olvidos
  sleep.ts         -- Evaluación de los horarios de sueño
  typo.ts          -- Simulación de erratas (AZERTY/QWERTY)

El cerebro no sabe en qué plataforma corre. Recibe un MessageEvent con un clientId ("jade" o "pixieglow"), toma una decisión, y devuelve un comando. El adaptador se encarga del resto.


Capa 3: el clasificador emocional (Sapphire)

Sapphire es el servicio más interesante en el plano técnico. Es un middleware LLM escrito en Python con FastAPI, que juega cuatro roles críticos:

  1. Clasificador binario FUTILE / INTERESSANT vía centroids de embeddings
  2. Puntuador emocional (valencia / arousal) vía centroids
  3. Ruteador de backends hacia Krystal (modelo pequeño vs modelo grande)
  4. Inyector few-shot y gestor de sesiones

Los centroids: el corazón de la clasificación

Un centroid es un concepto simple: es la media de un conjunto de vectores de embeddings. En concreto, reuní cientos de ejemplos de mensajes, los pasé por un modelo de embedding (BAAI/bge-small-en-v1.5, 384 dimensiones), y promedié los vectores obtenidos.

Hay dos centroids de clasificación:

  • futile_centroid: ~683 mensajes triviales ("lol", "ok", "hello") vía k-means (k=10, seed=42)
  • interessant_centroid: ~678 mensajes sustanciales (técnicos, personales, filosóficos) vía k-means (k=10, seed=42)

Cuando llega un mensaje:

def classify(text, embedder, futile_centroids, interessant_centroids):
    emb = embedder.query_embed(text)                     # vector 384-D
    sim_f = max(cos(emb, c) for c in futile_centroids)     # max sobre 10
    sim_i = max(cos(emb, c) for c in interessant_centroids) # max sobre 10
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

La puntuación por clase es la similitud coseno máxima entre sus 10 centroids. Eso captura los subtipos en cada categoría: un saludo y una despedida caen ambos cerca de uno de los 10 centroids fútiles aunque estén lejos en el espacio de embedding. Solo k-means al arrancar y productos escalares en ejecución: sin entrenamiento, sin GPU.

¿Por qué dos modelos?

El resultado de esa clasificación decide qué backend LLM se invoca:

Label Backend Krystal Modelo Puerto
FUTILE generic Luna-Protocol-1.5B (941 MB, Q4_K_M) 3124
INTERESSANT semantic Hermes-3-3B u 8B (según config) 3125

La intuición es simple: un "lol" o un "nm just chillin u" no merece invocar un modelo de 8 mil millones de parámetros. El modelo pequeño fine-tuneado Luna 1.5B, entrenado sobre 200 000 muestras Discord, basta de sobra para los intercambios ligeros. En cambio, una pregunta sobre la vida, una confidencia, o un debate técnico se rutea hacia el modelo grande que puede producir una respuesta más rica.

Ese ruteo económico reduce considerablemente la carga sobre el servidor LLM: alrededor del 70% de los mensajes se clasifican FUTILE y los procesa el modelo pequeño, liberando el modelo grande para las conversaciones que de verdad valen la pena.

El eje emocional: valencia y arousal

Pero eso no es todo. Sapphire usa el mismo mecanismo de centroids sobre un eje independiente para evaluar la emoción del mensaje:

Hay cuatro centroids emocionales:

Polo Ejemplos
positive "hell yeah", "love that", "this is great"
negative "shut up", "i hate this", "this sucks"
high_arousal "WHAT THE HELL", "omg omg omg", "AAAAA"
low_arousal "just chilling", "meh", "i guess"

La puntuación se calcula como una diferencia de similitud en cada eje:

valence = sim(emb, positive) - sim(emb, negative)     # [-1, +1]
arousal = sim(emb, high_arousal) - sim(emb, low_arousal)  # [-1, +1]

Valencia mide si el mensaje es positivo o negativo. Arousal mide su intensidad emocional. Juntos forman el modelo circumplex del afecto (Russell, 1980), el mismo modelo psicológico que inspiró el chatbot PARRY en 1972.

Las variables de resentimiento: cómo las emociones controlan el LLM

Ahí la inspiración de PARRY se vuelve tangible. PARRY (creado por Kenneth Colby en 1972) era un chatbot diseñado para simular un paciente paranoico. Tenía variables internas (miedo, ira, desconfianza) que modificaban sus respuestas. Por ejemplo, un PARRY "asustado" respondía de forma más agresiva.

Sapphire hace lo mismo, pero con variables continuas y un método más elegante: los parámetros de muestreo del LLM se ajustan en tiempo real según el estado emocional de la conversación.

La temperatura sigue el arousal
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Arousal Temperatura Efecto
-1.0 (tranquilo) 0.40 Baja creatividad, respuestas previsibles
0.0 (neutro) 0.70 Creatividad por defecto
+1.0 (excitado) 1.00 Máximo de aleatoriedad, respuestas sorprendentes

Cuando alguien está excitado o cabreado (arousal alto), la temperatura sube. El modelo produce respuestas más variadas, más creativas, a veces más caóticas, como un humano que "se embala". Cuando la conversación está tranquila, la temperatura baja, las respuestas son más pausadas.

El repeat penalty sigue la valencia
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
Valencia Repeat Penalty Efecto
-1.0 (negativo) 1.25 Fuerte penalización, evita las repeticiones
0.0 (neutro) 1.15 Valor por defecto
+1.0 (positivo) 1.05 Débil penalización, permite las repeticiones

Cuanto más negativa la conversación, más se empuja al modelo a evitar repetirse, como alguien que busca sus palabras en una discusión tensa. Cuanto más positiva la conversación, más el modelo puede permitirse afirmaciones redundantes, como una conversación relajada.

El estado emocional acumulativo

Esas puntuaciones no solo versan sobre el mensaje inmediato. Un EmotionState mantiene una media exponencial móvil de valencia y arousal por sesión:

class EmotionState:
    def __init__(self, decay=0.85, deadzone=0.06):
        self.decay = decay
        self.deadzone = deadzone

    def update(self, key, valence_delta, arousal_delta):
        if abs(valence_delta) < self.deadzone:
            valence_delta = 0.0
        if abs(arousal_delta) < self.deadzone:
            arousal_delta = 0.0
        s = self._state.setdefault(key, {"valence": 0.0, "arousal": 0.0})
        s["valence"] = s["valence"] * self.decay + valence_delta * (1 - self.decay)
        s["arousal"] = s["arousal"] * self.decay + arousal_delta * (1 - self.decay)
        return s

El decay a 0.85 significa que el 85% del estado previo se conserva en cada mensaje, y el 15% de la nueva señal se integra. Eso da una memoria emocional que suaviza las variaciones brutales: un solo mensaje negativo no pone al bot "triste", pero una serie de mensajes negativos hace derivar progresivamente su humor.

En la práctica: si alguien empieza una conversación de forma muy excitada (arousal=+0.8), la temperatura queda alta durante varios intercambios, aunque los mensajes siguientes sean más tranquilos. La emoción tarda en bajar, como un humano que sigue "caliente" tras una discusión.


Capa 4: la inferencia (Krystal)

Krystal es la capa más baja: un wrapper alrededor de llama.cpp que expone una API compatible OpenAI (/v1/chat/completions). Corre en dos instancias PM2:

  • krystal-small: el modelo Luna 1.5B fine-tuneado, en el puerto 3124, con afinidad CPU 0
  • krystal-large: un modelo Hermes 3B, en el puerto 3125, con afinidad CPU 0,1

Ambas instancias son procesos llama-server precompilados, lanzados con taskset para el pinning CPU.

El fine-tune del modelo Luna también evolucionó desde el segundo artículo: ahora está entrenado sobre 200 000 muestras (contra 50 000 antes), siempre desde Qwen2.5-1.5B-Instruct vía QLoRA. Las 200k muestras son un subconjunto del dataset Discord-Dialogues, filtradas para guardar solo las conversaciones más naturales y diversas. El objetivo: ampliar el registro estilístico del modelo sin perder la flexibilidad que hace el few-shot priming tan eficaz.


El esquema completo: un mensaje en tránsito

Esto es lo que pasa en concreto cuando alguien envía "je suis vraiment triste aujourd'hui" en Discord:

  1. Jade recibe el mensaje vía la API Gateway Discord. Lo transforma en MessageEvent y lo envía a Emerald vía WebSocket.
  2. Emerald evalúa el disparador (¿mención? ¿nombre? ¿palabra clave?). Es una mención directa. Calcula un retardo de concentración, comprueba el cooldown, la sesión, la fatiga temática. Decide responder y envía el mensaje a Sapphire vía HTTP.
  3. Sapphire hace embed del mensaje con bge-small-en-v1.5.
    • Clasificación: el mensaje está más cerca del centroid interessant que del centroid futile (diff = +0.31) -> INTERESSANT
    • Emoción: valencia negativa (-0.42), arousal moderado (0.35)
    • Ruteo: dirección KRYSTAL_SEMANTIC_URL (puerto 3125, modelo grande)
    • Parámetros muestreo: temperatura = 0.80 (arousal aumentado), repeat_penalty = 1.19 (valencia negativa)
    • El estado emocional de la sesión se actualiza con esos valores
  4. Krystal (instancia large) genera la respuesta con los parámetros ajustados emocionalmente y la devuelve a Sapphire.
  5. Sapphire streamea la respuesta hacia Emerald con los metadatos (label, valencia, arousal, estadísticas de debug).
  6. Emerald decide añadir una vacilación ("oh..."), planifica un burst (2 fragmentos), y elige una reacción. Envía una RespondCommand a Jade.
  7. Jade ejecuta: espera el retardo inicial, envía el primer fragmento con la vacilación, espera 1.5s, envía el segundo fragmento. Muestra el indicador de escritura durante toda la generación.

Todo eso en menos de 3 segundos para el usuario.


Los centroids: por qué es mejor que un clasificador neuronal

La elección de los centroids de embeddings en vez de un clasificador tradicional (como el DistilBERT que usaba antes) merece una explicación.

Un clasificador neuronal aprende una frontera de decisión entre las clases, típicamente una transformación no lineal que proyecta las entradas hacia probabilidades. Es preciso, pero:

  • Necesita datos de entrenamiento etiquetados
  • Es sensible al cambio de distribución (data drift)
  • Es difícil de interpretar
  • Hay que reentrenarlo para añadir una nueva clase

Un centroid, en cambio, es un vector medio de embeddings de ejemplos. La clasificación se hace por similitud coseno a ese vector medio. Ventajas:

  • Ningún entrenamiento requerido: solo calculamos la media de embeddings de ejemplos elegidos a mano
  • Fácil de interpretar: podemos mirar qué ejemplos están más cerca del centroid para entender "lo que el centroid aprendió"
  • Añadir una clase: solo añadimos un nuevo centroid, sin reentrenamiento
  • Robusto: el centroid es una media, así que los outliers tienen poco impacto

El verdadero poder de los centroids es que transforman un problema de clasificación en un problema de medida de distancia espacial. Podemos visualizar las categorías como regiones en un espacio de 384 dimensiones (o en 2D/3D tras reducción dimensional PCA/t-SNE).

Visualización 3D de los centroids

En la práctica, así se ven los centroids de clasificación en el espacio de embedding. Cada punto es un mensaje de ejemplo, proyectado en 3D por PCA (las 384 dimensiones originales se reducen a 3 para la visualización). Los puntos azules son los mensajes fútiles, los puntos amarillos los mensajes interesantes. Los 20 marcadores en diamante son los centroids k-means (10 por clase, seed=42). Pasa el ratón sobre un punto para ver el texto original del ejemplo.

Dos ejemplos de test se muestran en rojo: "lol" (clasificado fútil) e "i feel sad today" (clasificado interesante). Incluso tras reducción de 384 a 3 dimensiones (14,7% de varianza explicada), ambos clusters quedan claramente separados. La anotación arriba muestra los conteos exactos y la zona ambigua.

El centroid del mensaje de entrada se pasea en ese espacio según su contenido. La clasificación FUTILE/INTERESSANT consiste simplemente en medir qué centroid está más cerca por similitud coseno. Así podemos representar cada mensaje como un punto en un espacio de múltiples dimensiones, cada dimensión correspondiendo a una propiedad semántica.


Lo que cambia en la práctica

Los usuarios no ven las capas, los centroids, ni los ajustes de temperatura. Pero sienten los efectos:

  • Respuestas más rápidas para los mensajes simples (el modelo pequeño es 2x más rápido y gestiona el 70% del tráfico)
  • Tono adaptativo: si estás cabreado, el bot "siente" el cabreo y adapta su estilo
  • Coherencia cross-plataforma: un bot Matrix y un bot Discord comparten el mismo cerebro y el mismo estado emocional
  • Tono auténtico preservado: el fine-tune + few-shot + ruteo inteligente evita las respuestas corporate

El paso a 200k muestras de entrenamiento para el modelo pequeño reforzó aún esos efectos: el modelo captura mejor la diversidad de las conversaciones Discord sin perder la maleabilidad que permite el few-shot priming.


La infraestructura completa

Aquí los servicios que corren actualmente:

Servicio Tecnología Puerto(s) Rol
Pixieglow TypeScript (Bun) -- Adaptador Matrix
Jade TypeScript (esbuild) -- Adaptador Discord
Emerald TypeScript (Bun) 3126 (WebSocket) Cerebro / decisiones
Sapphire Python (FastAPI) 3123 (HTTP) Clasificador + emoción
Krystal small llama.cpp (PM2) 3124 Modelo pequeño (1.5B, futile)
Krystal large llama.cpp (PM2) 3125 Modelo grande (3B+, interessant)

Las dependencias entre servicios son unidireccionales: el adaptador depende de Emerald, Emerald depende de Sapphire, Sapphire depende de Krystal. El grafo sigue acíclico. Cada servicio puede reiniciarse independientemente.


Conclusión

Dividir Luna Protocol en cuatro capas no fue solo un ejercicio de arquitectura. Era una respuesta a limitaciones concretas: imposibilidad de soportar Matrix, falta de conciencia emocional, ausencia de priorización inteligente de los mensajes.

Hoy, el sistema es más robusto (un crash del LLM no mata el bot), más extensible (un adaptador Telegram o WhatsApp seguiría el mismo protocolo WebSocket), y más "vivo": el bot adapta su comportamiento, su tono, e incluso los parámetros del LLM al estado emocional percibido de la conversación.

Los centroids de embeddings son el elemento clave que hace todo eso posible sin complejidad desmedida: sin red neuronal entrenada, sin pipeline de datos etiquetados, solo medias de vectores y similitudes coseno. Es una técnica simple, increíblemente eficaz, y terriblemente subestimada.

Recurso Enlace
Sitio web del proyecto protocol-luna.github.io
Pixieglow protocol-luna/pixieglow
Emerald protocol-luna/emerald
Sapphire protocol-luna/sapphire
Krystal protocol-luna/krystal
Artículo 1: el bot Discord Luna Protocol: creé un bot Discord autónomo
Artículo 2: el fine-tuning Luna Protocol: por qué fine-tuneé un modelo de 1,5B

Luna Protocol: cérebros compartilhados, classificação emocional e roteamento interessante/fútil

O Luna Protocol passou de um monólito para uma arquitetura de quatro camadas: adaptadores, brain, classificador emocional e inferência. No cardápio: centroides de embeddings, roteamento interessante/fútil, e ajuste dos parâmetros do LLM por valência e ativação (arousal).

Luna Protocol: cérebros compartilhados, classificação emocional e roteamento interessante/fútil

Nos dois artigos anteriores, apresentei o Luna Protocol como um único bot do Discord com um sistema comportamental complexo e um modelo fine-tuned. Mas a arquitetura evoluiu bastante desde então. O que era um monólito -- um único processo Node.js que gerenciava o bot do Discord, o comportamento e as chamadas ao LLM -- se transformou em quatro camadas independentes, cada uma com sua própria responsabilidade, sua própria linguagem e seu próprio ciclo de vida.

Essa separação trouxe benefícios inesperados: o compartilhamento de "cérebros" entre várias plataformas, um sistema de classificação emocional que ajusta dinamicamente os parâmetros do LLM, e um roteamento inteligente de mensagens entre dois modelos conforme a importância percebida da conversa.

A evolução não aconteceu de uma vez -- seguiu um caminho orgânico. Primeiro, separei a pasta server/ do repositório do bot, criando assim o Krystal de um lado e deixando o Jade como adaptador do Discord. Depois criei o Pixieglow (adaptador do Matrix) reaproveitando o llm-core e o barramento de eventos do Jade. Em seguida veio o Sapphire, que introduziu uma classificação GENERIC/SEMANTIC com DistilBERT -- mas os resultados não eram convincentes, então mudei para centroides de embeddings, mais maleáveis para enriquecer exemplos e mais precisos; a classificação passou a ser FÚTIL/INTERESSANTE. Por fim, adicionei centroides de valência e ativação (arousal) para regular a temperatura e o repeat penalty do LLM. Para terminar, removi todo o código redundante entre Jade e Pixieglow criando o Emerald, o cérebro compartilhado, transformando Jade e Pixieglow em simples clientes orientados por socket.

Em paralelo, mantive atualizado um site que documenta o progresso do projeto: protocol-luna.github.io.

Este artigo conta como e por que dividi essas camadas, o que cada serviço faz exatamente, e como conceitos como centroides (vetores médios de embeddings) e variáveis de ressentimento (inspiradas no chatbot PARRY dos anos 70) transformaram um simples bot do Discord em um sistema multiplataforma surpreendentemente coerente.


O problema com o monólito

No início, o Luna Protocol cabia em um único processo Node.js. O código cuidava de:

  • A conexão com o Discord (via biblioteca Eris)
  • A avaliação de gatilhos (menções, palavras-chave, follow-ups...)
  • A simulação de comportamentos humanos (erros de digitação, hesitações, sono...)
  • As chamadas HTTP ao servidor LLM local (llama.cpp)
  • O gerenciamento de sessões e o anti-spam
  • O pipeline de TTS

Tudo estava no mesmo processo, comunicando-se via barramentos de eventos tipados (TypedBus). Funcionava, mas com limitações:

  • Impossível adicionar um cliente Matrix sem duplicar todo o código de comportamento
  • O LLM e o bot estavam no mesmo repositório: a pasta server/ já existia, mas era impossível evoluir um sem mexer no outro
  • Tratamento indiferenciado : cada mensagem era tratada do mesmo jeito, fosse um "lol" ou uma pergunta existencial
  • Amnésia emocional : o bot não "sentia" nada

A divisão em camadas resolveu todos esses problemas.


As quatro camadas

A arquitetura atual do Luna Protocol está organizada como um funil de quatro níveis:

Matrix / Discord
      |
      v
  [ADAPTADORES]   Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [BRAIN]         Emerald (WebSocket, porta 3126)
      |
      v
  [CLASSIFICADOR] Sapphire (HTTP, porta 3123)
      |
      v
  [INFERÊNCIA]    Krystal (llama.cpp, portas 3124 / 3125)

Cada camada pode ser reiniciada, atualizada ou substituída de forma independente.


Camada 1: os adaptadores (Pixieglow e Jade)

Essas são as camadas mais simples. Seu único trabalho é traduzir eventos de uma plataforma de mensagens para um protocolo padronizado em direção ao Emerald:

  • Jade é o adaptador do Discord. Usa a biblioteca Eris para se conectar ao Discord e encaminha mensagens ao Emerald via WebSocket. Também gerencia o pipeline de TTS (síntese de voz via Piper, conversão para OGG, upload para o Discord).
  • Pixieglow é o adaptador do Matrix. Usa diretamente a API HTTP Client-Server do Matrix (sem SDK), com um long-poll sync. Não tem TTS.

Os dois adaptadores compartilham o mesmo protocolo WebSocket definido em emerald-client.ts:

type ClientId = "jade" | "pixieglow";

// Eventos (adaptador -> Emerald)
type InEvent = MessageEvent | ReadyEvent | BotMessageEvent | PresenceEvent;

// Comandos (Emerald -> adaptador)
type OutCommand = RespondCommand | TypingCommand | SetPresenceCommand
                | SpontaneousCommand | ForgotCommand;

A existência de dois adaptadores com a mesma interface prova que o compartilhamento funciona: o mesmo "cérebro" (Emerald) atende indiferentemente um bot do Discord e um bot do Matrix, com comportamentos idênticos. O protocolo é declarativo: o Emerald não diz ao adaptador como enviar uma mensagem, ele diz o quê enviar (o texto com um atraso, possivelmente um plano de burst, uma reação, etc.). Cada adaptador implementa a execução concreta de acordo com sua plataforma.

É essa a força dessa arquitetura: para adicionar suporte ao Telegram, Signal, ou qualquer outra plataforma, basta escrever um adaptador que implemente o protocolo WebSocket.


Camada 2: o cérebro (Emerald)

O Emerald é o serviço central de decisão. Ele escuta na porta 3126 via WebSocket e gerencia:

  • A avaliação de gatilhos: menção, DM, nome, palavra-chave, follow-up, aleatório
  • A simulação comportamental: atrasos de concentração, erros de digitação, hesitações, esquecimentos, burst, fadiga temática
  • Os ciclos de sono: modos sleep / slow / short
  • O gerenciamento de sessões: cooldown, limites de sessão, anti-spam
  • O roteamento para o Sapphire: envio de mensagens, recepção de respostas em streaming

O Emerald é o serviço central que possibilitou o compartilhamento, e é o que mais se beneficiou da separação. Antes, cada comportamento (erro de digitação, burst, hesitação) estava emaranhado com o código do Discord. Agora eles estão em módulos dedicados dentro de behavior/:

emerald/src/behavior/
  burst.ts         -- Planejamento de mensagens em burst
  mannerisms.ts    -- Atrasos, hesitações, reações, esquecimentos
  sleep.ts         -- Avaliação dos horários de sono
  typo.ts          -- Simulação de erros de digitação (AZERTY/QWERTY)

O cérebro não sabe em qual plataforma está rodando. Ele recebe um MessageEvent com um clientId ("jade" ou "pixieglow"), toma uma decisão e retorna um comando. O adaptador cuida do resto.


Camada 3: o classificador emocional (Sapphire)

O Sapphire é o serviço tecnicamente mais interessante. É um middleware de LLM escrito em Python com FastAPI, que desempenha quatro papéis críticos:

  1. Classificador binário FÚTIL / INTERESSANTE via centroides de embeddings
  2. Avaliador emocional (valência / ativação) via centroides
  3. Roteador de backends para o Krystal (modelo pequeno vs modelo grande)
  4. Injetor few-shot e gerenciador de sessões

Os centroides: o coração da classificação

Um centroide é um conceito simples : é a média de um conjunto de vetores de embeddings. Na prática, reuni centenas de mensagens de exemplo, passei por um modelo de embedding (BAAI/bge-small-en-v1.5, 384 dimensões), e tirei a média dos vetores obtidos.

Existem dois centroides de classificação:

  • futile_centroid : ~683 mensagens triviais ("lol", "ok", "hello") via k-means (k=10, seed=42)
  • interessant_centroid : ~678 mensagens substanciais (técnicas, pessoais, filosóficas) via k-means (k=10, seed=42)

Quando uma mensagem chega:

def classify(text, embedder, futile_centroids, interessant_centroids):
    emb = embedder.query_embed(text)                     # vetor 384-D
    sim_f = max(cos(emb, c) for c in futile_centroids)     # máx entre 10
    sim_i = max(cos(emb, c) for c in interessant_centroids) # máx entre 10
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

O score por classe é a similaridade de cosseno máxima entre seus 10 centroides. Isso captura os subtipos em cada categoria : uma saudação e uma despedida caem ambos perto de um dos 10 centroides fúteis mesmo estando distantes no espaço de embeddings. Só k-means na inicialização e produtos escalares na execução : sem treinamento, sem GPU.

Por que dois modelos?

O resultado dessa classificação decide qual backend de LLM é invocado:

Rótulo Backend Krystal Modelo Porta
FUTILE generic Luna-Protocol-1.5B (941 Mo, Q4_K_M) 3124
INTERESSANT semantic Hermes-3-3B ou 8B (conforme config) 3125

A intuição é simples: um "lol" ou um "nm just chillin u" não merece invocar um modelo de 8 bilhões de parâmetros. O modelo pequeno Luna 1.5B fine-tuned, treinado com 200.000 amostras do Discord, é mais que suficiente para trocas leves. Já uma pergunta sobre a vida, uma confidência, ou um debate técnico é roteado para o modelo grande, que pode produzir uma resposta mais rica.

Esse roteamento econômico reduz consideravelmente a carga no servidor LLM: cerca de 70% das mensagens são classificadas como FÚTIL e tratadas pelo modelo pequeno, liberando o modelo grande para as conversas que realmente valem a pena.

O eixo emocional: valência e ativação (arousal)

Mas isso não é tudo. O Sapphire usa o mesmo mecanismo de centroides em um eixo independente para avaliar a emoção da mensagem:

Existem quatro centroides emocionais:

Polo Exemplos
positive "hell yeah", "love that", "this is great"
negative "shut up", "i hate this", "this sucks"
high_arousal "WHAT THE HELL", "omg omg omg", "AAAAA"
low_arousal "just chilling", "meh", "i guess"

O score é calculado como uma diferença de similaridades em cada eixo:

valence = sim(emb, positive) - sim(emb, negative)     # [-1, +1]
arousal = sim(emb, high_arousal) - sim(emb, low_arousal)  # [-1, +1]

A valência mede se a mensagem é positiva ou negativa. A ativação (arousal) mede sua intensidade emocional. Juntas, formam o modelo circumplexo do afeto (Russell, 1980) -- o mesmo modelo psicológico que inspirou o chatbot PARRY em 1972.

As variáveis de ressentimento: como as emoções controlam o LLM

É aqui que a inspiração do PARRY se torna tangível. O PARRY (criado por Kenneth Colby em 1972) era um chatbot projetado para simular um paciente paranoico. Ele possuía variáveis internas -- medo, raiva, desconfiança -- que alteravam suas respostas. Por exemplo, um PARRY "assustado" respondia de forma mais agressiva.

O Sapphire faz a mesma coisa, mas com variáveis contínuas e um método mais elegante: os parâmetros de amostragem do LLM são ajustados em tempo real conforme o estado emocional da conversa.

A temperatura segue a ativação
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Ativação Temperatura Efeito
-1.0 (calmo) 0.40 Baixa criatividade, respostas previsíveis
0.0 (neutro) 0.70 Criatividade padrão
+1.0 (excitado) 1.00 Máxima aleatoriedade, respostas surpreendentes

Quando alguém está excitado ou irritado (ativação alta), a temperatura sobe. O modelo produz respostas mais variadas, mais criativas, às vezes mais caóticas -- como um humano que "se empolga". Quando a conversa está calma, a temperatura cai, e as respostas ficam mais ponderadas.

O repeat penalty segue a valência
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
Valência Repeat Penalty Efeito
-1.0 (negativa) 1.25 Penalidade forte, evita repetições
0.0 (neutra) 1.15 Valor padrão
+1.0 (positiva) 1.05 Penalidade baixa, permite repetições

Quanto mais negativa a conversa, mais o modelo é empurrado a evitar se repetir -- como alguém que procura as palavras em uma discussão tensa. Quanto mais positiva a conversa, mais o modelo pode se permitir afirmações redundantes, como em uma conversa relaxada.

O estado emocional cumulativo

Esses scores não dizem respeito apenas à mensagem imediata. Um EmotionState mantém uma média móvel exponencial de valência e ativação por sessão:

class EmotionState:
    def __init__(self, decay=0.85, deadzone=0.06):
        self.decay = decay
        self.deadzone = deadzone

    def update(self, key, valence_delta, arousal_delta):
        if abs(valence_delta) < self.deadzone:
            valence_delta = 0.0
        if abs(arousal_delta) < self.deadzone:
            arousal_delta = 0.0
        s = self._state.setdefault(key, {"valence": 0.0, "arousal": 0.0})
        s["valence"] = s["valence"] * self.decay + valence_delta * (1 - self.decay)
        s["arousal"] = s["arousal"] * self.decay + arousal_delta * (1 - self.decay)
        return s

O decay de 0.85 significa que 85% do estado anterior é conservado a cada mensagem, e 15% do novo sinal é integrado. Isso cria uma memória emocional que suaviza variações bruscas: uma única mensagem negativa não deixa o bot "triste", mas uma série de mensagens negativas faz seu humor derivar progressivamente.

Na prática: se alguém começa uma conversa de forma muito excitada (arousal=+0.8), a temperatura permanece alta por várias trocas, mesmo que as mensagens seguintes sejam mais calmas. A emoção demora a baixar -- como um humano que permanece "aquecido" após uma discussão.


Camada 4: a inferência (Krystal)

O Krystal é a camada mais baixa: um wrapper em torno do llama.cpp que expõe uma API compatível com a da OpenAI (/v1/chat/completions). Roda em duas instâncias PM2:

  • krystal-small: o modelo Luna 1.5B fine-tuned, na porta 3124, com afinidade de CPU 0
  • krystal-large: um modelo Hermes 3B, na porta 3125, com afinidade de CPU 0,1

Ambas as instâncias são processos llama-server pré-compilados, iniciados com taskset para o pinning de CPU.

O fine-tune do modelo Luna também evoluiu desde o segundo artigo: agora é treinado em 200.000 amostras (contra 50.000 anteriormente), ainda partindo do Qwen2.5-1.5B-Instruct via QLoRA. As 200 mil amostras são um subconjunto do dataset Discord-Dialogues, filtradas para manter apenas as conversas mais naturais e diversas. O objetivo: ampliar o repertório estilístico do modelo sem perder a flexibilidade que torna o few-shot priming tão eficaz.


O esquema completo: uma mensagem em trânsito

Eis o que acontece concretamente quando alguém envia "estou muito triste hoje" no Discord:

  1. Jade recebe a mensagem via a API Gateway do Discord. Transforma-a em um MessageEvent e a envia ao Emerald via WebSocket.
  2. Emerald avalia o gatilho (menção? nome? palavra-chave?). É uma menção direta. Calcula um atraso de concentração, verifica o cooldown, a sessão, a fadiga temática. Decide responder e envia a mensagem ao Sapphire via HTTP.
  3. Sapphire gera o embedding da mensagem com bge-small-en-v1.5.
    • Classificação: a mensagem está mais próxima do centroide interessante do que do centroide futil (diff = +0.31) -> INTERESSANTE
    • Emoção: valência negativa (-0.42), ativação moderada (0.35)
    • Roteamento: direção KRYSTAL_SEMANTIC_URL (porta 3125, modelo grande)
    • Parâmetros de amostragem: temperatura = 0.80 (ativação aumentada), repeat_penalty = 1.19 (valência negativa)
    • O estado emocional da sessão é atualizado com esses valores
  4. Krystal (instância large) gera a resposta com os parâmetros ajustados emocionalmente e a devolve ao Sapphire.
  5. Sapphire transmite a resposta em streaming ao Emerald com os metadados (rótulo, valência, ativação, estatísticas de debug).
  6. Emerald decide adicionar uma hesitação ("oh..."), planeja um burst (2 fragmentos), e escolhe uma reação. Envia um RespondCommand ao Jade.
  7. Jade executa: espera o atraso inicial, envia o primeiro fragmento com a hesitação, espera 1.5s, envia o segundo fragmento. Mostra o indicador de digitação durante toda a geração.

Tudo isso em menos de 3 segundos para o usuário.


Os centroides: por que são melhores que um classificador neural

A escolha dos centroides de embeddings em vez de um classificador tradicional (como o DistilBERT que eu usava antes) merece uma explicação.

Um classificador neural aprende uma fronteira de decisão entre as classes -- tipicamente uma transformação não linear que projeta as entradas em probabilidades. É preciso, mas:

  • Requer dados de treinamento rotulados
  • É sensível à mudança de distribuição (data drift)
  • É difícil de interpretar
  • Precisa ser retreinado para adicionar uma nova classe

Um centroide, por outro lado, é um vetor médio de embeddings de exemplos. A classificação é feita por similaridade de cosseno com esse vetor médio. Vantagens:

  • Sem treinamento: basta calcular a média dos embeddings de exemplos escolhidos manualmente
  • Fácil de interpretar: dá para ver quais exemplos estão mais próximos do centroide para entender "o que o centroide aprendeu"
  • Adição de uma classe: basta adicionar um novo centroide -- sem retreinamento
  • Robusto: o centroide é uma média, então os outliers têm pouco impacto

O verdadeiro poder dos centroides é que eles transformam um problema de classificação em um problema de medição de distância espacial. Dá para visualizar as categorias como regiões em um espaço de 384 dimensões (ou em 2D/3D após uma redução dimensional PCA/t-SNE).

Visualização 3D dos centroides

Na prática, eis como os centroides de classificação aparecem no espaço de embeddings. Cada ponto é uma mensagem de exemplo, projetada em 3D via PCA (as 384 dimensões originais são reduzidas a 3 para a visualização). Os pontos azuis são as mensagens fúteis, os pontos amarelos as mensagens interessantes. Os 20 marcadores em diamante são os centroides k-means (10 por classe, seed=42). Passe o mouse sobre um ponto para ver o texto original do exemplo.

Dois exemplos de teste aparecem em vermelho : "lol" (classificado fútil) e "i feel sad today" (classificado interessante). Mesmo após redução de 384 para 3 dimensões (14,7% de variância explicada), os dois clusters estão claramente separados. A anotação no topo mostra as contagens exatas e a zona ambígua.

O centroide da mensagem de entrada se desloca nesse espaço conforme seu conteúdo. A classificação FÚTIL/INTERESSANTE consiste simplesmente em medir qual centroide está mais próximo por similaridade de cosseno. Assim, é possível representar cada mensagem como um ponto em um espaço multidimensional, com cada dimensão correspondendo a uma propriedade semântica.


O que isso muda na prática

Os usuários não veem as camadas, os centroides, ou os ajustes de temperatura. Mas sentem os efeitos:

  • Respostas mais rápidas para mensagens simples (o modelo pequeno é 2x mais rápido e cuida de 70% do tráfego)
  • Tom adaptativo: se você está irritado, o bot "sente" a irritação e adapta seu estilo
  • Consistência entre plataformas: um bot do Matrix e um bot do Discord compartilham o mesmo cérebro e o mesmo estado emocional
  • Sem "modo assistente": o fine-tune + few-shot + roteamento inteligente evita respostas corporativas

A passagem para 200 mil amostras de treinamento no modelo pequeno reforçou ainda mais esses efeitos: o modelo captura melhor a diversidade das conversas do Discord sem perder a maleabilidade proporcionada pelo few-shot priming.


A infraestrutura completa

Estes são os serviços atualmente em execução:

Serviço Tecnologia Porta(s) Papel
Pixieglow TypeScript (Bun) -- Adaptador do Matrix
Jade TypeScript (esbuild) -- Adaptador do Discord
Emerald TypeScript (Bun) 3126 (WebSocket) Cérebro / decisões
Sapphire Python (FastAPI) 3123 (HTTP) Classificador + emoção
Krystal small llama.cpp (PM2) 3124 Modelo pequeno (1.5B, fútil)
Krystal large llama.cpp (PM2) 3125 Modelo grande (3B+, interessante)

As dependências entre os serviços são unidirecionais: o adaptador depende do Emerald, o Emerald depende do Sapphire, o Sapphire depende do Krystal. Sem ciclos. Cada serviço pode ser reiniciado de forma independente.


Conclusão

Dividir o Luna Protocol em quatro camadas não foi apenas um exercício de arquitetura. Foi uma resposta a limitações concretas: a impossibilidade de suportar o Matrix, a falta de consciência emocional, a ausência de priorização inteligente de mensagens.

Hoje, o sistema é mais robusto (uma queda do LLM não mata o bot), mais extensível (um adaptador do Telegram ou WhatsApp seguiria o mesmo protocolo WebSocket), e mais "vivo": o bot adapta seu comportamento, seu tom, e até os parâmetros do LLM ao estado emocional percebido da conversa.

Os centroides de embeddings são a peça-chave que torna tudo isso possível sem complexidade excessiva: nenhuma rede neural treinada, nenhum pipeline de dados rotulados, apenas médias de vetores e similaridades de cosseno. É uma técnica simples, incrivelmente eficaz, e terrivelmente subestimada.

Recurso Link
Site do projeto protocol-luna.github.io
Pixieglow protocol-luna/pixieglow
Emerald protocol-luna/emerald
Sapphire protocol-luna/sapphire
Krystal protocol-luna/krystal
Artigo 1: o bot do Discord Luna Protocol: criei um bot do Discord autônomo
Artigo 2: o fine-tuning Luna Protocol: por que fiz o fine-tuning de um modelo de 1,5B

Luna Protocol : mutualisasi otak, klasifikasi emosional, dan routing menarik/tak berguna

Luna Protocol beralih dari monolit ke arsitektur empat lapisan : adaptor, brain, pengklasifikasi emosional, dan inference. Isinya : centroid embedding, routing menarik/tak berguna, dan penyesuaian parameter LLM berdasar valence dan arousal.

Luna Protocol : mutualisasi otak, klasifikasi emosional, dan routing menarik/tak berguna

Dalam dua artikel sebelumnya, saya memperkenalkan Luna Protocol sebagai bot Discord unik dengan sistem perilaku kompleks dan model yang di fine-tune. Tapi arsitekturnya sejak itu banyak berkembang. Yang dulunya monolit : satu proses Node.js yang menangani bot Discord, perilaku, dan panggilan LLM, telah berubah menjadi empat lapisan independen, masing-masing dengan tanggung jawab sendiri, bahasa sendiri, dan siklus hidup sendiri.

Pemisahan ini membawa manfaat tak terduga : mutualisasi "otak" antar banyak platform, sistem klasifikasi emosional yang menyesuaikan dinamis parameter LLM, dan routing cerdas pesan antara dua model menurut pentingnya percakapan yang dirasakan.

Evolusinya tidak terjadi sekaligus : ia mengikuti jalan organik. Saya pertama memisahkan folder server/ dari repo bot, menciptakan Krystal di satu sisi dan membiarkan Jade sebagai adaptor Discord. Lalu saya membuat Pixieglow (adaptor Matrix) dengan memakai ulang llm-core dan bus event Jade. Kemudian datang Sapphire untuk memperkenalkan klasifikasi GENERIC/SEMANTIC dengan DistilBERT. Hasilnya tidak meyakinkan, jadi saya beralih lewat centroid embedding, yang lebih lentur untuk pengayaan contoh dan lebih tepat ; klasifikasinya menjadi FUTILE/INTERESSANT. Akhirnya saya menambahkan centroid valence dan arousal untuk meregulasi temperatur dan repeat penalty LLM. Terakhir, saya menyingkirkan semua kode redundan antara Jade dan Pixieglow dengan menciptakan Emerald, otak yang dimutualisasi, mengubah Jade dan Pixieglow menjadi klien socket-driven sederhana.

Paralel dengan itu, saya memelihara situs web yang menelusuri kemajuan proyek : protocol-luna.github.io.

Artikel ini menceritakan bagaimana dan kenapa saya memotong lapisan ini, apa yang dilakukan tiap layanan tepatnya, dan bagaimana konsep seperti centroid (vektor rata-rata embedding) dan variabel kebencian (terinspirasi chatbot PARRY tahun 70-an) mengubah bot Discord sederhana menjadi sistem multi-platform yang mengejutkan koheren.


Masalah dengan monolit

Awalnya, Luna Protocol muat dalam satu proses Node.js. Kode menangani :

  • Koneksi ke Discord (lewat pustaka Eris)
  • Evaluasi pemicu (mention, kata kunci, follow-up...)
  • Simulasi perilaku manusia (salah ketik, keraguan, tidur...)
  • Panggilan HTTP ke server LLM lokal (llama.cpp)
  • Manajemen sesi dan anti-spam
  • Pipeline TTS

Semuanya dalam proses yang sama, berkomunikasi lewat bus event bertipe (TypedBus). Itu berhasil, tapi dengan batas :

  • Mustahil menambahkan klien Matrix tanpa menduplikasi seluruh kode perilaku
  • LLM dan bot ada dalam repo yang sama : folder server/ sudah ada, tapi mustahil mengembangkan satu tanpa menyentuh yang lain
  • Pemrosesan tak terdiferensiasi : setiap pesan diperlakukan sama, entah "lol" atau pertanyaan eksistensial
  • Amnesia emosional : bot tidak "merasakan" apa pun

Pemotongan berlapis menyelesaikan semua masalah ini.


Empat lapisan

Arsitektur Luna Protocol saat ini disusun seperti corong empat tingkat :

Matrix / Discord
      |
      v
  [ADAPTOR]      Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [BRAIN]            Emerald (WebSocket, port 3126)
      |
      v
  [PENGKLASIFIKASI]      Sapphire (HTTP, port 3123)
      |
      v
  [INFERENCE]        Krystal (llama.cpp, port 3124 / 3125)

Setiap lapisan bisa di restart, diperbarui, atau diganti independen.


Lapisan 1 : adaptor (Pixieglow dan Jade)

Ini lapisan paling sederhana. Satu-satunya tugas mereka adalah menerjemahkan event platform pesan menjadi protokol terstandarisasi ke Emerald :

  • Jade adalah adaptor Discord. Ia memakai pustaka Eris untuk terhubung ke Discord dan meneruskan pesan ke Emerald lewat WebSocket. Ia juga menangani pipeline TTS (sintesis vokal lewat Piper, konversi ke OGG, unggah ke Discord).
  • Pixieglow adalah adaptor Matrix. Ia memakai API HTTP Client-Server Matrix langsung (tanpa SDK), dengan long-poll sync. Ia tidak punya TTS.

Kedua adaptor berbagi protokol WebSocket yang sama yang didefinisikan di emerald-client.ts :

type ClientId = "jade" | "pixieglow";

// Event (adaptor -> Emerald)
type InEvent = MessageEvent | ReadyEvent | BotMessageEvent | PresenceEvent;

// Perintah (Emerald -> adaptor)
type OutCommand = RespondCommand | TypingCommand | SetPresenceCommand
                | SpontaneousCommand | ForgotCommand;

Keberadaan dua adaptor dengan antarmuka yang sama membuktikan mutualisasi : "otak" yang sama (Emerald) melayani bot Discord dan bot Matrix secara acuh, dengan perilaku identik. Protokolnya deklaratif : Emerald tidak memberi tahu adaptor cara mengirim pesan, ia memberi tahu apa yang dikirim (teks dengan penundaan, mungkin rencana burst, reaksi, dll.). Setiap adaptor mengimplementasikan eksekusi konkret menurut platformnya.

Inilah kekuatan arsitektur ini : untuk menambah dukungan Telegram, Signal, atau lain, cukup menulis adaptor yang mengimplementasikan protokol WebSocket.


Lapisan 2 : otak (Emerald)

Emerald adalah layanan keputusan sentral. Ia mendengarkan di port 3126 dalam WebSocket dan menangani :

  • Evaluasi pemicu : mention, DM, nama, kata kunci, follow-up, acak
  • Simulasi perilaku : penundaan konsentrasi, salah ketik, keraguan, kelupaan, burst, kelelahan tematis
  • Siklus tidur : mode sleep / slow / short
  • Manajemen sesi : cooldown, batas sesi, anti-spam
  • Routing ke Sapphire : pengiriman pesan, penerimaan respons yang di stream

Emerald adalah layanan sentral yang memungkinkan mutualisasi, dan dialah yang paling diuntungkan dari pemisahan. Sebelumnya, setiap perilaku (typo, burst, hesitation) terjalin dengan kode Discord. Sekarang, mereka ada dalam modul khusus di behavior/ :

emerald/src/behavior/
  burst.ts         -- Perencanaan pesan beruntun
  mannerisms.ts    -- Penundaan, keraguan, reaksi, kelupaan
  sleep.ts         -- Evaluasi jadwal tidur
  typo.ts          -- Simulasi salah ketik (AZERTY/QWERTY)

Otak tidak tahu di platform mana ia berjalan. Ia menerima MessageEvent dengan clientId ("jade" atau "pixieglow"), mengambil keputusan, dan mengembalikan perintah. Adaptor mengurus sisanya.


Lapisan 3 : pengklasifikasi emosional (Sapphire)

Sapphire adalah layanan paling menarik secara teknis. Ini middleware LLM yang ditulis dalam Python dengan FastAPI, yang memainkan empat peran kritis :

  1. Pengklasifikasi biner FUTILE / INTERESSANT lewat centroid embedding
  2. Penskora emosional (valence / arousal) lewat centroid
  3. Perute backend ke Krystal (model kecil vs model besar)
  4. Penyuntik few-shot dan manajer sesi

Centroid : jantung klasifikasi

Centroid adalah konsep sederhana : rata-rata dari himpunan vektor embedding. Konkretnya, saya mengumpulkan ratusan contoh pesan, melewatkannya ke model embedding (BAAI/bge-small-en-v1.5, 384 dimensi), dan merata-ratakan vektor yang didapat.

Ada dua centroid klasifikasi :

  • futile_centroid : ~683 pesan trivial ("lol", "ok", "hello") lewat k-means (k=10, seed=42)
  • interessant_centroid : ~678 pesan substansial (teknis, personal, filosofis) lewat k-means (k=10, seed=42)

Ketika pesan tiba :

def classify(text, embedder, futile_centroids, interessant_centroids):
    emb = embedder.query_embed(text)                     # vektor 384-D
    sim_f = max(cos(emb, c) for c in futile_centroids)     # maks atas 10
    sim_i = max(cos(emb, c) for c in interessant_centroids) # maks atas 10
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

Skor per kelas adalah kemiripan kosinus maksimal di antara 10 centroidnya. Ini menangkap subtipe dalam tiap kategori : sapaan dan perpisahan keduanya jatuh dekat salah satu dari 10 centroid tak berguna meski jauh di ruang embedding. Hanya k-means saat startup dan dot product saat runtime : tanpa pelatihan, tanpa GPU.

Kenapa dua model?

Hasil klasifikasi ini memutuskan backend LLM mana yang dipanggil :

Label Backend Krystal Model Port
FUTILE generic Luna-Protocol-1.5B (941 MB, Q4_K_M) 3124
INTERESSANT semantic Hermes-3-3B atau 8B (menurut config) 3125

Intuisinya sederhana : "lol" atau "nm just chillin u" tidak layak memanggil model 8 miliar parameter. Model kecil Luna 1.5B yang di fine-tune, dilatih pada 200 000 sampel Discord, sudah lebih dari cukup untuk pertukaran ringan. Sebaliknya, pertanyaan tentang hidup, curhatan, atau debat teknis di route ke model besar yang bisa menghasilkan respons lebih kaya.

Routing ekonomis ini sangat mengurangi beban pada server LLM : sekitar 70% pesan diklasifikasikan FUTILE dan diproses model kecil, membebaskan model besar untuk percakapan yang benar-benar layak.

Sumbu emosional : valence dan arousal

Tapi belum semuanya. Sapphire memakai mekanisme centroid yang sama pada sumbu independen untuk menilai emosi pesan :

Ada empat centroid emosional :

Kutub Contoh
positive "hell yeah", "love that", "this is great"
negative "shut up", "i hate this", "this sucks"
high_arousal "WHAT THE HELL", "omg omg omg", "AAAAA"
low_arousal "just chilling", "meh", "i guess"

Skor dihitung sebagai perbedaan kemiripan pada tiap sumbu :

valence = sim(emb, positive) - sim(emb, negative)     # [-1, +1]
arousal = sim(emb, high_arousal) - sim(emb, low_arousal)  # [-1, +1]

Valence mengukur apakah pesan positif atau negatif. Arousal mengukur intensitas emosionalnya. Bersama, mereka membentuk model circumplex dari afek (Russell, 1980), model psikologis yang sama yang menginspirasi chatbot PARRY pada 1972.

Variabel kebencian : cara emosi mengendalikan LLM

Di sinilah inspirasi PARRY menjadi nyata. PARRY (dibuat Kenneth Colby pada 1972) adalah chatbot yang dirancang mensimulasikan pasien paranoid. Ia punya variabel internal (takut, marah, curiga) yang mengubah responsnya. Misalnya, PARRY yang "takut" menjawab lebih agresif.

Sapphire melakukan hal yang sama, tapi dengan variabel kontinu dan metode lebih elegan : parameter sampling LLM disesuaikan waktu nyata menurut keadaan emosional percakapan.

Temperatur mengikuti arousal
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Arousal Temperatur Efek
-1.0 (tenang) 0.40 Kreativitas rendah, respons bisa diprediksi
0.0 (netral) 0.70 Kreativitas bawaan
+1.0 (bersemangat) 1.00 Randomitas maksimum, respons mengejutkan

Ketika seseorang bersemangat atau kesal (arousal tinggi), temperatur naik. Model menghasilkan respons lebih bervariasi, lebih kreatif, kadang lebih kacau, seperti manusia yang "terbawa". Ketika percakapan tenang, temperatur turun, respons lebih tenang.

Repeat penalty mengikuti valence
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
Valence Repeat Penalty Efek
-1.0 (negatif) 1.25 Penalti kuat, menghindari pengulangan
0.0 (netral) 1.15 Nilai bawaan
+1.0 (positif) 1.05 Penalti lemah, memungkinkan pengulangan

Makin negatif percakapan, makin model didorong menghindari pengulangan, seperti seseorang yang mencari kata dalam perdebatan tegang. Makin positif percakapan, makin model bisa membiarkan penegasan redundan, seperti percakapan santai.

Keadaan emosional kumulatif

Skor ini tidak hanya soal pesan segera. EmotionState memelihara rata-rata bergerak eksponensial valence dan arousal per sesi :

class EmotionState:
    def __init__(self, decay=0.85, deadzone=0.06):
        self.decay = decay
        self.deadzone = deadzone

    def update(self, key, valence_delta, arousal_delta):
        if abs(valence_delta) < self.deadzone:
            valence_delta = 0.0
        if abs(arousal_delta) < self.deadzone:
            arousal_delta = 0.0
        s = self._state.setdefault(key, {"valence": 0.0, "arousal": 0.0})
        s["valence"] = s["valence"] * self.decay + valence_delta * (1 - self.decay)
        s["arousal"] = s["arousal"] * self.decay + arousal_delta * (1 - self.decay)
        return s

decay 0.85 berarti 85% keadaan sebelumnya dipertahankan di setiap pesan, dan 15% sinyal baru diintegrasikan. Ini memberikan memori emosional yang menghaluskan variasi brutal : satu pesan negatif tidak membuat bot "sedih", tapi rangkaian pesan negatif berangsur menggeser moodnya.

Dalam praktik : kalau seseorang memulai percakapan dengan sangat bersemangat (arousal=+0.8), temperatur tetap tinggi selama beberapa pertukaran, meski pesan berikutnya lebih tenang. Emosi butuh waktu untuk turun, seperti manusia yang tetap "panas" setelah perdebatan.


Lapisan 4 : inference (Krystal)

Krystal adalah lapisan terbawah : wrapper sekitar llama.cpp yang mengekspos API yang kompatibel OpenAI (/v1/chat/completions). Ia berjalan dalam dua instans PM2 :

  • krystal-small : model Luna 1.5B yang di fine-tune, di port 3124, dengan afinitas CPU 0
  • krystal-large : model Hermes 3B, di port 3125, dengan afinitas CPU 0,1

Kedua instans adalah proses llama-server yang dikompilasi sebelumnya, diluncurkan dengan taskset untuk pinning CPU.

Fine-tune model Luna juga berkembang sejak artikel kedua : kini dilatih pada 200 000 sampel (lawan 50 000 sebelumnya), tetap dari Qwen2.5-1.5B-Instruct lewat QLoRA. 200k sampel adalah subset dataset Discord-Dialogues, difilter untuk hanya menyimpan percakapan paling alami dan paling beragam. Tujuannya : memperluas register stilistika model tanpa kehilangan fleksibilitas yang membuat few-shot priming begitu efektif.


Skema lengkap : pesan dalam transit

Berikut yang terjadi konkret ketika seseorang mengirim "saya benar-benar sedih hari ini" di Discord :

  1. Jade menerima pesan lewat API Gateway Discord. Ia mengubahnya menjadi MessageEvent dan mengirimnya ke Emerald lewat WebSocket.
  2. Emerald mengevaluasi pemicu (mention? nama? kata kunci?). Ini mention langsung. Ia menghitung penundaan konsentrasi, memeriksa cooldown, sesi, kelelahan tematis. Ia memutuskan menjawab dan mengirim pesan ke Sapphire lewat HTTP.
  3. Sapphire meng embed pesan dengan bge-small-en-v1.5.
    • Klasifikasi : pesan lebih dekat ke centroid interessant daripada centroid futile (diff = +0.31) -> INTERESSANT
    • Emosi : valence negatif (-0.42), arousal sedang (0.35)
    • Routing : arah KRYSTAL_SEMANTIC_URL (port 3125, model besar)
    • Parameter sampling : temperatur = 0.80 (arousal naik), repeat_penalty = 1.19 (valence negatif)
    • Keadaan emosional sesi diperbarui dengan nilai ini
  4. Krystal (instans besar) membangkitkan respons dengan parameter yang disesuaikan emosional dan mengembalikannya ke Sapphire.
  5. Sapphire me stream respons ke Emerald dengan metadata (label, valence, arousal, statistik debug).
  6. Emerald memutuskan menambahkan keraguan ("oh..."), merencanakan burst (2 fragmen), dan memilih reaksi. Ia mengirim RespondCommand ke Jade.
  7. Jade mengeksekusi : menunggu penundaan awal, mengirim fragmen pertama dengan keraguan, menunggu 1.5 dtk, mengirim fragmen kedua. Ia menunjukkan indikator mengetik selama seluruh pembangkitan.

Semua itu dalam kurang dari 3 detik bagi pengguna.


Centroid : kenapa lebih baik daripada pengklasifikasi neural

Pilihan centroid embedding alih-alih pengklasifikasi tradisional (seperti DistilBERT yang saya pakai sebelumnya) layak dijelaskan.

Pengklasifikasi neural mempelajari batas keputusan antara kelas, biasanya transformasi non-linear yang memproyeksikan masukan ke probabilitas. Ia tepat, tapi :

  • Ia membutuhkan data pelatihan berlabel
  • Ia sensitif pada perubahan distribusi (data drift)
  • Ia sulit diinterpretasikan
  • Ia harus dilatih ulang untuk menambah kelas baru

Centroid, sebaliknya, adalah vektor rata-rata dari embedding contoh. Klasifikasi dilakukan lewat kemiripan kosinus pada vektor rata-rata ini. Keunggulan :

  • Tanpa pelatihan apa pun yang dibutuhkan : kita hanya menghitung rata-rata embedding contoh yang dipilih tangan
  • Mudah diinterpretasikan : kita bisa melihat contoh mana yang paling dekat dengan centroid untuk memahami "apa yang dipelajari centroid"
  • Penambahan kelas : kita hanya menambah centroid baru, tanpa pelatihan ulang
  • Tangguh : centroid adalah rata-rata, jadi outlier sedikit berdampak

Kekuatan sejati centroid adalah mereka mengubah masalah klasifikasi menjadi masalah pengukuran jarak spasial. Kita bisa memvisualisasikan kategori sebagai wilayah dalam ruang 384 dimensi (atau dalam 2D/3D setelah reduksi dimensional PCA/t-SNE).

Visualisasi 3D centroid

Dalam praktik, beginilah tampilan centroid klasifikasi di ruang embedding. Setiap titik adalah pesan contoh, diproyeksikan ke 3D oleh PCA (384 dimensi asli direduksi menjadi 3 untuk visualisasi). Titik biru adalah pesan tak berguna, titik kuning pesan menarik. 20 penanda berlian adalah centroid k-means (10 per kelas, seed=42). Arahkan mouse pada titik untuk melihat teks asli contohnya.

Dua contoh uji ditampilkan merah : "lol" (diklasifikasikan tak berguna) dan "i feel sad today" (diklasifikasikan menarik). Bahkan setelah reduksi dari 384 ke 3 dimensi (14,7% varians yang dijelaskan), kedua cluster jelas terpisah. Anotasi di atas menunjukkan hitungan persis dan zona ambigu.

Centroid pesan masukan berjalan di ruang ini menurut isinya. Klasifikasi FUTILE/INTERESSANT hanya mengukur centroid mana yang paling dekat lewat kemiripan kosinus. Kita bisa merepresentasikan setiap pesan sebagai titik dalam ruang multi-dimensi, tiap dimensi cocok dengan properti semantik.


Apa yang berubah dalam praktik

Pengguna tidak melihat lapisan, centroid, atau penyesuaian temperatur. Tapi mereka merasakan efeknya :

  • Respons lebih cepat untuk pesan sederhana (model kecil 2x lebih cepat dan menangani 70% trafik)
  • Nada adaptif : kalau kamu kesal, bot "merasakan" kekesalan dan menyesuaikan gayanya
  • Koherensi lintas platform : bot Matrix dan bot Discord berbagi otak dan keadaan emosional yang sama
  • Nada otentik terjaga : fine-tune + few-shot + routing cerdas menghindari respons korporat

Peralihan ke 200k sampel pelatihan untuk model kecil makin memperkuat efek ini : model lebih menangkap keragaman percakapan Discord tanpa kehilangan kelenturan yang dimungkinkan few-shot priming.


Infrastruktur lengkap

Berikut layanan yang berjalan saat ini :

Layanan Teknologi Port Peran
Pixieglow TypeScript (Bun) -- Adaptor Matrix
Jade TypeScript (esbuild) -- Adaptor Discord
Emerald TypeScript (Bun) 3126 (WebSocket) Otak / keputusan
Sapphire Python (FastAPI) 3123 (HTTP) Pengklasifikasi + emosi
Krystal small llama.cpp (PM2) 3124 Model kecil (1.5B, tak berguna)
Krystal large llama.cpp (PM2) 3125 Model besar (3B+, menarik)

Dependensi antar layanan satu arah : adaptor bergantung pada Emerald, Emerald bergantung pada Sapphire, Sapphire bergantung pada Krystal. Graf tetap asiklik. Setiap layanan bisa di restart independen.


Kesimpulan

Membagi Luna Protocol menjadi empat lapisan bukan sekadar latihan arsitektur. Ini jawaban atas batasan konkret : ketidakmungkinan mendukung Matrix, ketiadaan kesadaran emosional, ketiadaan prioritasasi cerdas pesan.

Hari ini, sistem lebih tangguh (crash LLM tidak membunuh bot), lebih bisa diperluas (adaptor Telegram atau WhatsApp akan mengikuti protokol WebSocket yang sama), dan lebih "hidup" : bot menyesuaikan perilakunya, nadanya, bahkan parameter LLM pada keadaan emosional percakapan yang dirasakan.

Centroid embedding adalah elemen kunci yang membuat semua ini mungkin tanpa kerumitan berlebihan : tanpa jaringan saraf yang dilatih, tanpa pipeline data berlabel, hanya rata-rata vektor dan kemiripan kosinus. Ini teknik sederhana, luar biasa efektif, dan sangat diremehkan.

Sumber daya Tautan
Situs web proyek protocol-luna.github.io
Pixieglow protocol-luna/pixieglow
Emerald protocol-luna/emerald
Sapphire protocol-luna/sapphire
Krystal protocol-luna/krystal
Artikel 1 : bot Discord Luna Protocol : saya membuat bot Discord otonom
Artikel 2 : fine-tuning Luna Protocol : kenapa saya men fine-tune model 1,5B

Luna Protocol : दिमागों की साझेदारी, भावनात्मक वर्गीकरण, और दिलचस्प/तुच्छ routing

Luna Protocol monolithe से चार परत आर्किटेक्चर में गया : adaptateur, brain, भावनात्मक वर्गीकरणकर्ता, और inference। कार्यक्रम में : embedding centroid, दिलचस्प/तुच्छ routing, और valence और arousal से LLM पैरामीटर समायोजन।

Luna Protocol : दिमागों की साझेदारी, भावनात्मक वर्गीकरण, और दिलचस्प/तुच्छ routing

पिछले दो लेखों में, मैंने Luna Protocol को जटिल व्यवहार तंत्र और fine-tune मॉडल वाले अनोखे Discord बॉट के रूप में पेश किया। लेकिन आर्किटेक्चर तब से काफी विकसित हुआ है। जो monolithe था : एक ही Node.js प्रक्रिया जो Discord बॉट, व्यवहार, और LLM कॉल एक साथ संभालती थी, वह चार स्वतंत्र परतों में बदल गया है, हर एक अपनी जिम्मेदारी, अपनी भाषा, और अपने जीवन चक्र के साथ।

इस अलगाव ने अप्रत्याशित लाभ लाए : कई प्लेटफार्मों के बीच "दिमागों" की साझेदारी, भावनात्मक वर्गीकरण तंत्र जो LLM पैरामीटर गतिशील समायोजित करता है, और बातचीत के महसूस महत्व अनुसार दो मॉडलों के बीच संदेशों का बुद्धिमान routing।

विकास एक बार में नहीं हुआ : वह जैविक रास्ते चला। मैंने पहले बॉट repo से server/ फोल्डर अलग किया, इस तरह एक तरफ Krystal बनाया और Jade को Discord adaptateur छोड़ा। फिर मैंने Jade के llm-core और घटना बस लेकर Pixieglow (Matrix adaptateur) बनाया। फिर DistilBERT से GENERIC/SEMANTIC वर्गीकरण लाने के लिए Sapphire आया। परिणाम निर्णायक नहीं थे, इसलिए मैं embedding centroid से गुजरा, उदाहरण समृद्धि के लिए ज्यादा लचीले और ज्यादा सटीक ; वर्गीकरण FUTILE/INTERESSANT बना। मैंने LLM तापमान और repeat penalty नियमित करने के लिए valence और arousal centroid आखिर जोड़े। अंत में, Emerald, साझा दिमाग बनाकर Jade और Pixieglow के बीच सारा अतिरेक कोड हटाया, Jade और Pixieglow को साधारण socket-driven client बनाया।

समानांतर में, मैंने web साइट अद्यतन रखी जो प्रोजेक्ट प्रगति बताती है : protocol-luna.github.io।

यह लेख बताता है कि मैंने ये परतें कैसे और क्यों काटीं, हर service ठीक क्या करता है, और centroid (embedding के औसत सदिश) और भावना चर (70 के दशक के PARRY chatbot से प्रेरित) जैसी अवधारणाओं ने साधारण Discord बॉट को आश्चर्यजनक सुसंगत बहु मंच तंत्र में कैसे बदला।


monolithe की समस्या

शुरू में, Luna Protocol एक ही Node.js प्रक्रिया में समाता था। कोड संभालता था :

  • Discord कनेक्शन (Eris लाइब्रेरी से)
  • trigger मूल्यांकन (उल्लेख, keyword, follow-up...)
  • मानवीय व्यवहार अनुकरण (टाइपो, हिचकिचाहट, नींद...)
  • स्थानीय LLM serveur (llama.cpp) को HTTP कॉल
  • सत्र और एंटी spam प्रबंधन
  • TTS पाइपलाइन

सब उसी प्रक्रिया में था, typed घटना बस (TypedBus) से संवाद करता हुआ। यह काम करता था, लेकिन सीमाओं के साथ :

  • Matrix client जोड़ना असंभव व्यवहार का सारा कोड दोहराए बिना
  • एक ही repo में LLM और बॉट : server/ फोल्डर पहले से मौजूद था, लेकिन एक को दूसरे को छुए बिना विकसित करना असंभव
  • अविभेदित प्रोसेसिंग : हर संदेश वैसे ही प्रोसेस होता था, "lol" हो या अस्तित्वगत प्रश्न
  • भावनात्मक स्मृतिलोप : बॉट कुछ "महसूस" नहीं करता था

परतों में काटने ने ये सभी समस्याएँ सुलझाईं।


चार परतें

Luna Protocol की वर्तमान आर्किटेक्चर चार स्तर funnel की तरह व्यवस्थित है :

Matrix / Discord
      |
      v
  [ADAPTATEURS]      Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [BRAIN]            Emerald (WebSocket, port 3126)
      |
      v
  [CLASSIFIEUR]      Sapphire (HTTP, port 3123)
      |
      v
  [INFERENCE]        Krystal (llama.cpp, ports 3124 / 3125)

हर परत स्वतंत्र पुनरारंभ, अद्यतन, या बदली जा सकती है।


परत 1 : adaptateur (Pixieglow और Jade)

ये सबसे सरल परतें हैं। उनका एकमात्र काम संदेश मंच की घटनाओं को Emerald की ओर मानकीकृत प्रोटोकॉल में अनुवाद करना है :

  • Jade Discord adaptateur है। वह Discord से जुड़ने के लिए Eris लाइब्रेरी इस्तेमाल करता है और WebSocket से Emerald की ओर संदेश forward करता है। वह TTS पाइपलाइन भी संभालता है (Piper से ध्वनि संश्लेषण, OGG में रूपांतरण, Discord पर upload)।
  • Pixieglow Matrix adaptateur है। वह सीधे Matrix HTTP Client-Server API इस्तेमाल करता है (कोई SDK नहीं), long-poll sync के साथ। उसके पास TTS नहीं है।

दोनों adaptateur emerald-client.ts में परिभाषित वही WebSocket प्रोटोकॉल साझा करते हैं :

type ClientId = "jade" | "pixieglow";

// घटनाएँ (adaptateur -> Emerald)
type InEvent = MessageEvent | ReadyEvent | BotMessageEvent | PresenceEvent;

// कमांड (Emerald -> adaptateur)
type OutCommand = RespondCommand | TypingCommand | SetPresenceCommand
                | SpontaneousCommand | ForgotCommand;

वही इंटरफेस वाले दो adaptateur का अस्तित्व साझेदारी साबित करता है : वही "दिमाग" (Emerald) उदासीन Discord बॉट और Matrix बॉट सेवा देता है, समान व्यवहारों के साथ। प्रोटोकॉल घोषणात्मक है : Emerald adaptateur को नहीं कहता संदेश कैसे भेजना है, वह कहता है क्या भेजना है (विलंब वाला पाठ, संभवतः burst योजना, प्रतिक्रिया, आदि)। हर adaptateur अपने मंच अनुसार ठोस निष्पादन लागू करता है।

इस आर्किटेक्चर की ताकत यही है : Telegram, Signal, या अन्य समर्थन जोड़ने के लिए, WebSocket प्रोटोकॉल लागू करने वाला adaptateur लिखना काफी है।


परत 2 : दिमाग (Emerald)

Emerald केंद्रीय निर्णय service है। वह WebSocket पर port 3126 सुनता है और संभालता है :

  • trigger मूल्यांकन : उल्लेख, DM, नाम, keyword, follow-up, बेतरतीब
  • व्यवहार अनुकरण : एकाग्रता विलंब, टाइपो, हिचकिचाहट, भूल, burst, विषयगत थकान
  • नींद चक्र : sleep / slow / short मोड
  • सत्र प्रबंधन : cooldown, सत्र सीमाएँ, एंटी spam
  • Sapphire की ओर routing : संदेश भेजना, stream उत्तर प्राप्ति

Emerald केंद्रीय service है जिसने साझेदारी संभव की, और वह है जिसने अलगाव से सबसे ज्यादा लाभ पाया। पहले, हर व्यवहार (typo, burst, hesitation) Discord कोड से गुँथा था। अब, वे behavior/ में समर्पित मॉड्यूल में हैं :

emerald/src/behavior/
  burst.ts         -- बौछार संदेश योजना
  mannerisms.ts    -- विलंब, हिचकिचाहट, प्रतिक्रियाएँ, भूल
  sleep.ts         -- नींद समय मूल्यांकन
  typo.ts          -- टाइपो अनुकरण (AZERTY/QWERTY)

दिमाग नहीं जानता कि वह किस मंच पर चलता है। वह clientId ("jade" या "pixieglow") वाला MessageEvent पाता है, निर्णय लेता है, और कमांड लौटाता है। बाकी adaptateur संभालता है।


परत 3 : भावनात्मक वर्गीकरणकर्ता (Sapphire)

Sapphire तकनीकी तल पर सबसे दिलचस्प service है। यह FastAPI वाला Python LLM middleware है, जो चार महत्वपूर्ण भूमिकाएँ निभाता है :

  1. embedding centroid से द्विआधारी वर्गीकरण FUTILE / INTERESSANT
  2. centroid से भावनात्मक स्कोरर (valence / arousal)
  3. Krystal की ओर backend router (छोटा मॉडल बनाम बड़ा मॉडल)
  4. few-shot इंजेक्टर और सत्र प्रबंधक

centroid : वर्गीकरण का हृदय

centroid सरल अवधारणा है : embedding सदिशों के समुच्चय का औसत है। ठोस रूप में, मैंने संदेशों के सैकड़ों उदाहरण एकत्र किए, उन्हें embedding मॉडल (BAAI/bge-small-en-v1.5, 384 आयाम) में डाला, और प्राप्त सदिशों का औसत निकाला।

दो वर्गीकरण centroid हैं :

  • futile_centroid : k-means (k=10, seed=42) से ~683 तुच्छ संदेश ("lol", "ok", "hello")
  • interessant_centroid : k-means (k=10, seed=42) से ~678 महत्वपूर्ण संदेश (तकनीकी, व्यक्तिगत, दार्शनिक)

जब संदेश आता है :

def classify(text, embedder, futile_centroids, interessant_centroids):
    emb = embedder.query_embed(text)                     # 384-D सदिश
    sim_f = max(cos(emb, c) for c in futile_centroids)     # 10 पर अधिकतम
    sim_i = max(cos(emb, c) for c in interessant_centroids) # 10 पर अधिकतम
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

प्रति वर्ग स्कोर उसके 10 centroid में अधिकतम cosine समानता है। यह हर श्रेणी में उप प्रकार पकड़ता है : अभिवादन और विदाई दोनों 10 तुच्छ centroid में से एक के पास गिरते हैं भले ही वे embedding स्थान में दूर हों। शुरू में सिर्फ k-means और निष्पादन पर बिंदु गुणनफल : बिना प्रशिक्षण, बिना GPU।

दो मॉडल क्यों ?

इस वर्गीकरण का परिणाम तय करता है कि कौन सा LLM backend बुलाया जाता है :

लेबल Krystal Backend मॉडल Port
FUTILE generic Luna-Protocol-1.5B (941 Mo, Q4_K_M) 3124
INTERESSANT semantic Hermes-3-3B या 8B (config अनुसार) 3125

अंतर्ज्ञान सरल है : "lol" या "nm just chillin u" 8 अरब पैरामीटर मॉडल बुलाने योग्य नहीं है। 200 000 Discord नमूनों पर प्रशिक्षित छोटा fine-tune Luna 1.5B मॉडल हल्के आदान प्रदान के लिए काफी है। इसके विपरीत, जीवन प्रश्न, विश्वास, या तकनीकी बहस बड़े मॉडल की ओर route होती है जो समृद्ध उत्तर बना सकता है।

यह किफायती routing LLM serveur भार काफी घटाता है : लगभग 70% संदेश FUTILE वर्गीकृत होते हैं और छोटे मॉडल से प्रोसेस होते हैं, बड़े मॉडल को उन बातचीत के लिए मुक्त करते हुए जो सच में योग्य हैं।

भावनात्मक अक्ष : valence और arousal

लेकिन बस इतना नहीं। Sapphire संदेश भावना मूल्यांकन के लिए स्वतंत्र अक्ष पर वही centroid तंत्र इस्तेमाल करता है :

चार भावनात्मक centroid हैं :

ध्रुव उदाहरण
positive "hell yeah", "love that", "this is great"
negative "shut up", "i hate this", "this sucks"
high_arousal "WHAT THE HELL", "omg omg omg", "AAAAA"
low_arousal "just chilling", "meh", "i guess"

स्कोर हर अक्ष पर समानता अंतर के रूप में गणना होता है :

valence = sim(emb, positive) - sim(emb, negative)     # [-1, +1]
arousal = sim(emb, high_arousal) - sim(emb, low_arousal)  # [-1, +1]

Valence मापती है कि संदेश सकारात्मक है या नकारात्मक। Arousal उसकी भावनात्मक तीव्रता मापता है। एक साथ, वे affect का circumplex मॉडल (Russell, 1980) बनाते हैं, वही मनोवैज्ञानिक मॉडल जिसने 1972 में PARRY chatbot प्रेरित किया था।

भावना चर : भावनाएँ LLM कैसे नियंत्रित करती हैं

यहीं PARRY प्रेरणा ठोस होती है। PARRY (1972 में Kenneth Colby बनाया) chatbot था जो पागल रोगी अनुकरण के लिए डिजाइन किया गया था। उसके पास आंतरिक चर (भय, क्रोध, अविश्वास) थे जो उसके उत्तर बदलते थे। उदाहरण के लिए, "डरा" PARRY ज्यादा आक्रामक जवाब देता था।

Sapphire वही करता है, लेकिन सतत चरों और ज्यादा सुरुचिपूर्ण विधि से : LLM नमूनाकरण पैरामीटर बातचीत भावनात्मक स्थिति अनुसार वास्तविक समय समायोजित होते हैं।

तापमान arousal का अनुसरण करता है
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Arousal तापमान प्रभाव
-1.0 (शांत) 0.40 कम रचनात्मकता, पूर्वानुमेय उत्तर
0.0 (तटस्थ) 0.70 डिफ़ॉल्ट रचनात्मकता
+1.0 (उत्तेजित) 1.00 अधिकतम बेतरतीबता, आश्चर्यजनक उत्तर

जब कोई उत्तेजित या नाराज होता है (उच्च arousal), तापमान बढ़ता है। मॉडल ज्यादा विविध, ज्यादा रचनात्मक, कभी ज्यादा अराजक उत्तर बनाता है, मानव की तरह जो "बहक जाता" है। जब बातचीत शांत होती है, तापमान घटता है, उत्तर ज्यादा शांत होते हैं।

repeat penalty valence का अनुसरण करता है
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
Valence Repeat Penalty प्रभाव
-1.0 (नकारात्मक) 1.25 मजबूत दंड, दोहराव से बचता है
0.0 (तटस्थ) 1.15 डिफ़ॉल्ट मान
+1.0 (सकारात्मक) 1.05 कमजोर दंड, दोहराव देता है

बातचीत जितनी नकारात्मक, मॉडल उतना दोहराने से बचने को धकेला जाता है, किसी की तरह जो तनावपूर्ण बहस में शब्द खोजता है। बातचीत जितनी सकारात्मक, मॉडल उतना अतिरेक कथन दे सकता है, शांत बातचीत की तरह।

संचयी भावनात्मक स्थिति

ये स्कोर सिर्फ तात्कालिक संदेश पर नहीं हैं। EmotionState प्रति सत्र valence और arousal की घातांकीय चल औसत रखता है :

class EmotionState:
    def __init__(self, decay=0.85, deadzone=0.06):
        self.decay = decay
        self.deadzone = deadzone

    def update(self, key, valence_delta, arousal_delta):
        if abs(valence_delta) < self.deadzone:
            valence_delta = 0.0
        if abs(arousal_delta) < self.deadzone:
            arousal_delta = 0.0
        s = self._state.setdefault(key, {"valence": 0.0, "arousal": 0.0})
        s["valence"] = s["valence"] * self.decay + valence_delta * (1 - self.decay)
        s["arousal"] = s["arousal"] * self.decay + arousal_delta * (1 - self.decay)
        return s

0.85 decay का मतलब है कि हर संदेश पर पिछली स्थिति का 85% रखा जाता है, और नए संकेत का 15% एकीकृत होता है। यह भावनात्मक स्मृति देता है जो क्रूर बदलाव चिकना करती है : एक ही नकारात्मक संदेश बॉट को "उदास" नहीं बनाता, लेकिन नकारात्मक संदेशों की शृंखला धीरे धीरे उसका मूड बहाती है।

व्यवहार में : अगर कोई बातचीत बहुत उत्तेजित (arousal=+0.8) शुरू करता है, तो तापमान कई आदान प्रदान तक ऊँचा रहता है, भले ही अगले संदेश शांत हों। भावना नीचे आने में समय लेती है, मानव की तरह जो बहस बाद "गरम" रहता है।


परत 4 : inference (Krystal)

Krystal सबसे निचली परत है : llama.cpp के चारों ओर wrapper जो OpenAI संगत API (/v1/chat/completions) उजागर करता है। वह दो PM2 उदाहरणों में चलता है :

  • krystal-small : fine-tune Luna 1.5B मॉडल, port 3124 पर, CPU आत्मीयता 0 के साथ
  • krystal-large : Hermes 3B मॉडल, port 3125 पर, CPU आत्मीयता 0,1 के साथ

दोनों उदाहरण पूर्व संकलित llama-server प्रक्रियाएँ हैं, CPU pinning के लिए taskset से चलाई गई।

Luna मॉडल fine-tune भी दूसरे लेख से विकसित हुआ है : वह अब 200 000 नमूनों (पहले 50 000 के मुकाबले) पर प्रशिक्षित है, हमेशा QLoRA से Qwen2.5-1.5B-Instruct से। 200k नमूने Discord-Dialogues डेटासेट का उपसमुच्चय हैं, सिर्फ सबसे स्वाभाविक और सबसे विविध बातचीत रखने के लिए छाँटे गए। लक्ष्य : few-shot priming को प्रभावी बनाने वाले लचीलेपन खोए बिना मॉडल का शैलीगत दायरा बढ़ाना।


पूरा schema : पारगमन में संदेश

यहाँ ठोस रूप में होता है जब कोई Discord पर "je suis vraiment triste aujourd'hui" भेजता है :

  1. Jade Discord API Gateway से संदेश पाता है। वह उसे MessageEvent में बदलता है और WebSocket से Emerald को भेजता है।
  2. Emerald trigger मूल्यांकित करता है (उल्लेख ? नाम ? keyword ?)। यह सीधा उल्लेख है। वह एकाग्रता विलंब गणना करता है, cooldown, सत्र, विषयगत थकान जाँचता है। वह जवाब देने का फैसला करता है और HTTP से Sapphire को संदेश भेजता है।
  3. Sapphire संदेश bge-small-en-v1.5 से embed करता है।
    • वर्गीकरण : संदेश futile centroid से ज्यादा interessant centroid के करीब है (diff = +0.31) -> INTERESSANT
    • भावना : नकारात्मक valence (-0.42), मध्यम arousal (0.35)
    • Routing : दिशा KRYSTAL_SEMANTIC_URL (port 3125, बड़ा मॉडल)
    • नमूनाकरण पैरामीटर : तापमान = 0.80 (बढ़ा arousal), repeat_penalty = 1.19 (नकारात्मक valence)
    • सत्र भावनात्मक स्थिति इन मानों से अद्यतन होती है
  4. Krystal (बड़ा उदाहरण) भावनात्मक समायोजित पैरामीटर से उत्तर बनाता है और Sapphire को लौटाता है।
  5. Sapphire उत्तर Emerald की ओर stream करता है मेटाडेटा (लेबल, valence, arousal, डिबग आँकड़े) के साथ।
  6. Emerald हिचकिचाहट ("oh...") जोड़ने का फैसला करता है, burst योजना बनाता है (2 टुकड़े), और प्रतिक्रिया चुनता है। वह Jade को RespondCommand भेजता है।
  7. Jade निष्पादित करता है : प्रारंभिक विलंब प्रतीक्षा करता है, हिचकिचाहट वाला पहला टुकड़ा भेजता है, 1.5s प्रतीक्षा करता है, दूसरा टुकड़ा भेजता है। वह पूरी पीढ़ी के दौरान टाइपिंग सूचक दिखाता है।

यह सब उपयोगकर्ता के लिए 3 सेकंड से कम में।


centroid : neural वर्गीकरणकर्ता से बेहतर क्यों है

DistilBERT जैसे पारंपरिक वर्गीकरणकर्ता (जैसा मैं पहले इस्तेमाल करता था) के बजाय embedding centroid चुनाव व्याख्या योग्य है।

neural वर्गीकरणकर्ता वर्गों के बीच निर्णय सीमा सीखता है, आमतौर पर अरैखिक रूपांतरण जो input को संभावनाओं की ओर प्रक्षेपित करता है। वह सटीक है, लेकिन :

  • उसे लेबल प्रशिक्षण डेटा चाहिए
  • वह वितरण बदलाव (data drift) के प्रति संवेदनशील है
  • उसे व्याख्या करना मुश्किल है
  • नई वर्ग जोड़ने के लिए उसे फिर से प्रशिक्षित करना होगा

centroid, इसके विपरीत, उदाहरण embedding का औसत सदिश है। वर्गीकरण इस औसत सदिश से cosine समानता से होता है। फायदे :

  • कोई प्रशिक्षण जरूरी नहीं : हम हाथ से चुने उदाहरणों के embedding का औसत गणना करते हैं
  • व्याख्या आसान : हम देख सकते हैं कि कौन से उदाहरण centroid के सबसे करीब हैं यह समझने के लिए "centroid ने क्या सीखा"
  • वर्ग जोड़ना : हम बिना पुनः प्रशिक्षण नया centroid जोड़ते हैं
  • मजबूत : centroid औसत है, इसलिए outlier का कम प्रभाव

centroid की असली शक्ति यह है कि वे वर्गीकरण समस्या को स्थानिक दूरी माप समस्या में बदलते हैं। हम श्रेणियों को 384 आयाम वाले स्थान में क्षेत्रों के रूप में देख सकते हैं (या आयाम न्यूनीकरण PCA/t-SNE बाद 2D/3D में)।

centroid का 3D दृश्य

व्यवहार में, embedding स्थान में वर्गीकरण centroid ऐसे दिखते हैं। हर बिंदु उदाहरण संदेश है, PCA से 3D में प्रक्षेपित (दृश्य के लिए 384 मूल आयाम 3 घटाए गए)। नीले बिंदु तुच्छ संदेश हैं, पीले बिंदु दिलचस्प संदेश हैं। 20 हीरा marker k-means centroid हैं (प्रति वर्ग 10, seed=42)। मूल उदाहरण पाठ देखने के लिए बिंदु पर mouse ले जाओ।

दो परीक्षण उदाहरण लाल में दिखाए गए हैं : "lol" (तुच्छ वर्गीकृत) और "i feel sad today" (दिलचस्प वर्गीकृत)। 384 से 3 आयाम न्यूनीकरण बाद भी (14,7% व्याख्या भिन्नता), दोनों cluster स्पष्ट अलग हैं। ऊपर annotation सटीक गिनती और अस्पष्ट क्षेत्र दिखाता है।

input संदेश का centroid उसकी सामग्री अनुसार इस स्थान में घूमता है। FUTILE/INTERESSANT वर्गीकरण बस cosine समानता से निकटतम centroid मापना है। हम इस तरह हर संदेश को बहु आयामी स्थान में बिंदु के रूप में दर्शा सकते हैं, हर आयाम अर्थगत गुण से मेल खाता है।


व्यवहार में यह क्या बदलता है

उपयोगकर्ता परतें, centroid, या तापमान समायोजन नहीं देखते। लेकिन वे प्रभाव महसूस करते हैं :

  • सरल संदेशों के लिए तेज उत्तर (छोटा मॉडल 2x तेज है और 70% ट्रैफिक संभालता है)
  • अनुकूली स्वर : अगर तुम नाराज हो, तो बॉट नाराजगी "महसूस" करता है और अपनी शैली अनुकूलित करता है
  • cross-platform सुसंगतता : Matrix बॉट और Discord बॉट वही दिमाग और वही भावनात्मक स्थिति साझा करते हैं
  • संरक्षित प्रामाणिक स्वर : fine-tune + few-shot + बुद्धिमान routing corporate उत्तरों से बचता है

छोटे मॉडल के लिए 200k प्रशिक्षण नमूनों पर जाना इन प्रभावों को और मजबूत करता है : मॉडल few-shot priming संभव बनाने वाले लचीलेपन खोए बिना Discord बातचीत विविधता बेहतर पकड़ता है।


पूरी बुनियादी ढाँचा

यहाँ वर्तमान में चल रही services हैं :

Service तकनीक Port भूमिका
Pixieglow TypeScript (Bun) -- Matrix Adaptateur
Jade TypeScript (esbuild) -- Discord Adaptateur
Emerald TypeScript (Bun) 3126 (WebSocket) दिमाग / निर्णय
Sapphire Python (FastAPI) 3123 (HTTP) वर्गीकरणकर्ता + भावना
Krystal small llama.cpp (PM2) 3124 छोटा मॉडल (1.5B, तुच्छ)
Krystal large llama.cpp (PM2) 3125 बड़ा मॉडल (3B+, दिलचस्प)

service निर्भरताएँ एकदिश हैं : adaptateur Emerald पर निर्भर, Emerald Sapphire पर निर्भर, Sapphire Krystal पर निर्भर। ग्राफ अचक्रीय रहता है। हर service स्वतंत्र पुनरारंभ हो सकती है।


निष्कर्ष

Luna Protocol को चार परतों में बाँटना सिर्फ आर्किटेक्चर अभ्यास नहीं था। यह ठोस सीमाओं का उत्तर था : Matrix समर्थन असंभव, भावनात्मक चेतना कमी, संदेशों की बुद्धिमान प्राथमिकता अनुपस्थिति।

आज, तंत्र ज्यादा मजबूत है (LLM crash बॉट नहीं मारता), ज्यादा विस्तार योग्य (Telegram या WhatsApp adaptateur वही WebSocket प्रोटोकॉल चलेगा), और ज्यादा "जीवंत" : बॉट अपना व्यवहार, अपना स्वर, और यहाँ तक LLM पैरामीटर बातचीत की महसूस भावनात्मक स्थिति अनुकूलित करता है।

embedding centroid मुख्य तत्व हैं जो यह सब अत्यधिक जटिलता के बिना संभव बनाते हैं : कोई प्रशिक्षित neural network नहीं, कोई लेबल डेटा पाइपलाइन नहीं, सिर्फ सदिश औसत और cosine समानताएँ। यह सरल, अविश्वसनीय प्रभावी, और भयानक कम आँकी तकनीक है।

संसाधन लिंक
प्रोजेक्ट web साइट protocol-luna.github.io
Pixieglow protocol-luna/pixieglow
Emerald protocol-luna/emerald
Sapphire protocol-luna/sapphire
Krystal protocol-luna/krystal
लेख 1 : Discord बॉट Luna Protocol : मैंने स्वायत्त Discord बॉट बनाया
लेख 2 : fine-tuning Luna Protocol : मैंने 1,5B मॉडल fine-tune क्यों किया

Luna Protocol : تجميع الأدمغة، والتصنيف العاطفي، والتوجيه ممتع/تافه

انتقل Luna Protocol من monolithe إلى بنية من أربع طبقات : محولات وbrain ومصنف عاطفي وinference. في البرنامج : centroids للـembeddings وتوجيه ممتع/تافه وضبط معاملات LLM حسب التكافؤ والاستثارة.

Luna Protocol : تجميع الأدمغة، والتصنيف العاطفي، والتوجيه ممتع/تافه

في المقالين السابقين، قدمت Luna Protocol كبوت Discord وحيد مع نظام سلوكي معقد ونموذج مضبوط. لكن البنية تطورت كثيرا منذ ذلك الحين. ما كان monolithe : عملية Node.js واحدة تدير في آن واحد بوت Discord والسلوك واستدعاءات LLM، تحول إلى أربع طبقات مستقلة، لكل واحدة مسؤوليتها ولغتها ودورة حياتها الخاصة.

جلب هذا الفصل فوائد غير متوقعة : تجميع "الأدمغة" بين عدة منصات، ونظام تصنيف عاطفي يضبط ديناميكيا معاملات LLM، وتوجيه ذكي للرسائل بين نموذجين حسب الأهمية المدركة للمحادثة.

لم يحدث التطور دفعة واحدة : بل اتبع مسارا عضويا. فصلت أولا مجلد server/ عن مستودع البوت، منشئا Krystal من جهة وتاركا Jade كمحول Discord. ثم أنشأت Pixieglow (محول Matrix) مستعيدا llm-core وناقل الأحداث من Jade. ثم جاء Sapphire لإدخال تصنيف GENERIC/SEMANTIC مع DistilBERT. لم تكن النتائج مقنعة، فانتقلت إلى centroids الـembeddings، الأكثر قابلية للتشكيل لإثراء الأمثلة والأدق ؛ وصار التصنيف FUTILE/INTERESSANT. أضفت أخيرا centroids للـvalence والـarousal لتنظيم حرارة LLM وrepeat penalty. وفي النهاية، أزحت كل الكود الزائد بين Jade وPixieglow بإنشاء Emerald، الدماغ المجمَّع، محوّلا Jade وPixieglow إلى مجرد عميلين يقودهما socket.

بالتوازي، حافظت على موقع ويب يروي تقدم المشروع : protocol-luna.github.io.

يروي هذا المقال كيف ولماذا قطّعت هذه الطبقات، وماذا يفعل كل خدمة بالضبط، وكيف حوّلت مفاهيم مثل centroids (متجهات متوسطة للـembeddings) ومتغيرات الشعور (المستوحاة من شاتبوت PARRY في السبعينيات) بوت Discord بسيطا إلى نظام متعدد المنصات متسق بشكل مدهش.


المشكلة مع الـmonolithe

في البداية، كان Luna Protocol في عملية Node.js واحدة. كان الكود يدير :

  • الاتصال بـDiscord (عبر مكتبة Eris)
  • تقييم المحفزات (إشارات وكلمات مفتاحية ومتابعة...)
  • محاكاة السلوكيات الإنسانية (أخطاء مطبعية وتردد ونوم...)
  • استدعاءات HTTP لخادم LLM المحلي (llama.cpp)
  • إدارة الجلسات ومكافحة السبام
  • خط TTS

كان كل شيء في نفس العملية، يتواصل عبر نواقل أحداث مُنمَّطة (TypedBus). كان يعمل، لكن بحدود :

  • مستحيل إضافة عميل Matrix دون تكرار كل كود السلوك
  • LLM والبوت في نفس المستودع : مجلد server/ موجود فعلا، لكن مستحيل تطوير أحدهما دون لمس الآخر
  • معالجة غير مميزة : كل رسالة عولجت بنفس الطريقة، سواء كانت "lol" أو سؤالا وجوديا
  • فقدان عاطفي : البوت لم "يشعر" بشيء

حلّ التقطيع إلى طبقات كل هذه المشاكل.


الطبقات الأربع

البنية الحالية لـLuna Protocol منظمة كقمع من أربعة مستويات :

Matrix / Discord
      |
      v
  [ADAPTATEURS]      Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [BRAIN]            Emerald (WebSocket, port 3126)
      |
      v
  [CLASSIFIEUR]      Sapphire (HTTP, port 3123)
      |
      v
  [INFERENCE]        Krystal (llama.cpp, ports 3124 / 3125)

يمكن إعادة تشغيل كل طبقة أو تحديثها أو استبدالها مستقلة.


الطبقة 1 : المحولات (Pixieglow وJade)

وهي أبسط الطبقات. عملها الوحيد هو ترجمة أحداث منصة مراسلة إلى بروتوكول معياري نحو Emerald :

  • Jade هو محول Discord. يستخدم مكتبة Eris للاتصال بـDiscord وتمرير الرسائل إلى Emerald عبر WebSocket. يدير أيضا خط TTS (تركيب صوتي عبر Piper، وتحويل إلى OGG، ورفع على Discord).
  • Pixieglow هو محول Matrix. يستخدم Client-Server HTTP API الخاص بـMatrix مباشرة (دون SDK)، مع sync بالاستطلاع الطويل. لا TTS لديه.

يتشارك المحولان نفس بروتوكول WebSocket المعرّف في emerald-client.ts :

type ClientId = "jade" | "pixieglow";

// أحداث (محول ← Emerald)
type InEvent = MessageEvent | ReadyEvent | BotMessageEvent | PresenceEvent;

// أوامر (Emerald ← محول)
type OutCommand = RespondCommand | TypingCommand | SetPresenceCommand
                | SpontaneousCommand | ForgotCommand;

يثبت وجود محولين بنفس الواجهة التجميع : نفس "الدماغ" (Emerald) يخدم بلا تمييز بوت Discord وبوت Matrix، بسلوكيات متطابقة. البروتوكول تصريحي : لا يقول Emerald للمحول كيف يرسل رسالة، بل يقول ماذا يرسل (النص مع مهلة، وربما خطة دفعة، وتفاعل، إلخ). ينفذ كل محول التنفيذ الملموس حسب منصته.

إنها قوة هذه البنية : لإضافة دعم Telegram أو Signal أو غيرهما، يكفي كتابة محول ينفذ بروتوكول WebSocket.


الطبقة 2 : الدماغ (Emerald)

Emerald هو خدمة القرار المركزية. يستمع على المنفذ 3126 عبر WebSocket ويدير :

  • تقييم المحفزات : إشارة ورسالة خاصة واسم وكلمة مفتاحية ومتابعة وعشوائي
  • المحاكاة السلوكية : مهلات تركيز وأخطاء مطبعية وتردد ونسيان ودفعة وتعب موضوعي
  • دورات النوم : أوضاع sleep / slow / short
  • إدارة الجلسات : تهدئة وحدود جلسة ومكافحة سبام
  • التوجيه نحو Sapphire : إرسال الرسائل، واستلام الردود المبثوثة

Emerald هو الخدمة المركزية التي أتاحت التجميع، وهي التي استفادت أكثر من الفصل. قبل ذلك، كان كل سلوك (خطأ مطبعي ودفعة وتردد) متشابكا مع كود Discord. الآن، هي في وحدات مخصصة في behavior/ :

emerald/src/behavior/
  burst.ts         -- تخطيط الرسائل في دفعات
  mannerisms.ts    -- مهلات وتردد وتفاعلات ونسيان
  sleep.ts         -- تقييم جداول النوم
  typo.ts          -- محاكاة الأخطاء المطبعية (AZERTY/QWERTY)

لا يعرف الدماغ على أي منصة يعمل. يستلم MessageEvent مع clientId ("jade" أو "pixieglow")، ويتخذ قرارا، ويعيد أمرا. ويتكفل المحول بالباقي.


الطبقة 3 : المصنف العاطفي (Sapphire)

Sapphire هو الخدمة الأكثر إثارة تقنيا. إنه وسيط LLM مكتوب بـPython مع FastAPI، ويلعب أربعة أدوار حاسمة :

  1. مصنف ثنائي FUTILE / INTERESSANT عبر centroids الـembeddings
  2. مُحرِز عاطفي (valence / arousal) عبر centroids
  3. موجّه backends نحو Krystal (نموذج صغير ضد نموذج كبير)
  4. حاقن few-shot ومدير جلسات

الـcentroids : قلب التصنيف

الـcentroid مفهوم بسيط : إنه متوسط مجموعة من متجهات الـembeddings. عمليا، جمعت مئات الأمثلة من الرسائل، ومررتها في نموذج embedding (BAAI/bge-small-en-v1.5، 384 بعدا)، وحسبت متوسط المتجهات الناتجة.

هناك centroidان للتصنيف :

  • futile_centroid : ~683 رسالة تافهة ("lol" و"ok" و"hello") عبر k-means (k=10 وseed=42)
  • interessant_centroid : ~678 رسالة جوهرية (تقنية وشخصية وفلسفية) عبر k-means (k=10 وseed=42)

عندما تصل رسالة :

def classify(text, embedder, futile_centroids, interessant_centroids):
    emb = embedder.query_embed(text)                     # متجه 384-D
    sim_f = max(cos(emb, c) for c in futile_centroids)     # الأقصى على 10
    sim_i = max(cos(emb, c) for c in interessant_centroids) # الأقصى على 10
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

نتيجة كل فئة هي أقصى تشابه جيبي بين centroids العشرة. وهذا يلتقط الأنواع الفرعية في كل فئة : تحية ووداع يقعان كلاهما قرب واحد من centroids التافهة العشرة حتى لو كانا بعيدين في فضاء الـembedding. فقط k-means عند الإقلاع وجداءات نقطية عند التشغيل : دون تدريب، ودون GPU.

لماذا نموذجان ؟

تقرر نتيجة هذا التصنيف أي backend للـLLM يُستدعَى :

التسمية Backend الخاص بـKrystal النموذج المنفذ
FUTILE generic Luna-Protocol-1.5B (941 Mo وQ4_K_M) 3124
INTERESSANT semantic Hermes-3-3B أو 8B (حسب الإعداد) 3125

الحدس بسيط : "lol" أو "nm just chillin u" لا تستحق استدعاء نموذج من 8 مليارات معامل. النموذج الصغير المضبوط Luna 1.5B، المدرَّب على 200 000 عينة Discord، يكفي إلى حد كبير للتبادلات الخفيفة. بينما سؤال عن الحياة أو اعتراف أو نقاش تقني يُوجَّه نحو النموذج الكبير الذي يستطيع إنتاج رد أغنى.

يقلل هذا التوجيه الاقتصادي الحمل على خادم LLM كثيرا : حوالي 70% من الرسائل تُصنَّف FUTILE وتُعالَج بالنموذج الصغير، محرِّرة النموذج الكبير للمحادثات التي تستحق فعلا.

المحور العاطفي : valence وarousal

لكن هذا ليس كل شيء. يستخدم Sapphire نفس آلية centroids على محور مستقل لتقييم عاطفة الرسالة :

هناك أربعة centroids عاطفية :

القطب أمثلة
positive "hell yeah" و"love that" و"this is great"
negative "shut up" و"i hate this" و"this sucks"
high_arousal "WHAT THE HELL" و"omg omg omg" و"AAAAA"
low_arousal "just chilling" و"meh" و"i guess"

يُحسب الرصيد كفرق تشابه على كل محور :

valence = sim(emb, positive) - sim(emb, negative)     # [-1, +1]
arousal = sim(emb, high_arousal) - sim(emb, low_arousal)  # [-1, +1]

تقيس Valence إن كانت الرسالة إيجابية أو سلبية. وتقيس Arousal شدتها العاطفية. معا، تشكلان نموذج circumplex للوجدان (Russell، 1980)، نفس النموذج النفسي الذي ألهم شاتبوت PARRY في 1972.

متغيرات الشعور : كيف تتحكم العواطف في LLM

هنا يصبح إلهام PARRY ملموسا. PARRY (أنشأه Kenneth Colby في 1972) كان الشات بوت PARRY مصمما لمحاكاة مريض مصاب بالبارانويا. كان يملك متغيرات داخلية (خوف وغضب وارتياب) تعدّل ردوده. فمثلا PARRY "الخائف" كان يرد بطريقة أكثر عدوانية.

يفعل Sapphire الشيء نفسه، لكن بمتغيرات مستمرة وطريقة أكثر أناقة : تُضبط معاملات أخذ عينات LLM في الوقت الحقيقي حسب الحالة العاطفية للمحادثة.

الحرارة تتبع arousal
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Arousal الحرارة الأثر
-1.0 (هادئ) 0.40 إبداعية منخفضة، وردود متوقعة
0.0 (محايد) 0.70 إبداعية افتراضية
+1.0 (متحمس) 1.00 أقصى عشوائية، وردود مفاجئة

عندما يكون شخص متحمسا أو غاضبا (arousal مرتفع)، ترتفع الحرارة. ينتج النموذج ردودا أكثر تنوعا وإبداعية، وأحيانا أكثر فوضوية، كإنسان "يتحمس". وعندما تكون المحادثة هادئة، تنخفض الحرارة، والردود أكثر رزانة.

repeat penalty يتبع valence
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
Valence Repeat Penalty الأثر
-1.0 (سلبي) 1.25 عقوبة قوية، تتجنب التكرارات
0.0 (محايد) 1.15 القيمة الافتراضية
+1.0 (إيجابي) 1.05 عقوبة ضعيفة، تسمح بالتكرارات

كلما كانت المحادثة أكثر سلبية، دُفع النموذج أكثر لتجنب تكرار نفسه، كشخص يبحث عن كلماته في جدال متوتر. وكلما كانت أكثر إيجابية، استطاع النموذج السماح بتأكيدات زائدة، كمحادثة مسترخية.

الحالة العاطفية التراكمية

لا تحمل هذه الدرجات الرسالة الفورية فقط. يحافظ EmotionState على متوسط متحرك أسّي للـvalence والـarousal لكل جلسة :

class EmotionState:
    def __init__(self, decay=0.85, deadzone=0.06):
        self.decay = decay
        self.deadzone = deadzone

    def update(self, key, valence_delta, arousal_delta):
        if abs(valence_delta) < self.deadzone:
            valence_delta = 0.0
        if abs(arousal_delta) < self.deadzone:
            arousal_delta = 0.0
        s = self._state.setdefault(key, {"valence": 0.0, "arousal": 0.0})
        s["valence"] = s["valence"] * self.decay + valence_delta * (1 - self.decay)
        s["arousal"] = s["arousal"] * self.decay + arousal_delta * (1 - self.decay)
        return s

يعني decay عند 0.85 أن 85% من الحالة السابقة تُحفَظ مع كل رسالة، و15% من الإشارة الجديدة تُدمَج. وهذا يعطي ذاكرة عاطفية تملّس التغيرات العنيفة : رسالة سلبية واحدة لا تجعل البوت "حزينا"، لكن سلسلة رسائل سلبية تحرف مزاجه تدريجيا.

عمليا : إذا بدأ شخص محادثة بطريقة متحمسة جدا (arousal=+0.8)، تبقى الحرارة مرتفعة لعدة تبادلات، حتى لو كانت الرسائل التالية أهدأ. تستغرق العاطفة وقتا لتهدأ، كإنسان يبقى "ساخنا" بعد جدال.


الطبقة 4 : الاستدلال (Krystal)

Krystal هي الطبقة الدنيا : غلاف حول llama.cpp يعرض API متوافقة مع OpenAI (/v1/chat/completions). يعمل في نسختي PM2 :

  • krystal-small : نموذج Luna 1.5B المضبوط، على المنفذ 3124، مع تقارب CPU 0
  • krystal-large : نموذج Hermes 3B، على المنفذ 3125، مع تقارب CPU 0,1

النسختان عمليتا llama-server مُجمَّعتين مسبقا، تُطلقان مع taskset لتثبيت CPU.

تطور ضبط نموذج Luna هو أيضا منذ المقال الثاني : صار مدرَّبا على 200 000 عينة (مقابل 50 000 سابقا)، دائما انطلاقا من Qwen2.5-1.5B-Instruct عبر QLoRA. عينات الـ200k مجموعة فرعية من طقم Discord-Dialogues، مُرشَّحة للاحتفاظ بالمحادثات الأكثر طبيعية وتنوعا. الهدف : توسيع السجل الأسلوبي للنموذج دون فقدان المرونة التي تجعل التمهيد few-shot فعالا جدا.


المخطط الكامل : رسالة في transit

إليكم ما يحدث عمليا عندما يرسل شخص "أنا حزين فعلا اليوم" على Discord :

  1. يستلم Jade الرسالة عبر Discord Gateway API. يحوّلها إلى MessageEvent ويرسلها إلى Emerald عبر WebSocket.
  2. يقيّم Emerald المحفز (إشارة ؟ اسم ؟ كلمة مفتاحية ؟). إنها إشارة مباشرة. يحسب مهلة تركيز، ويتحقق من التهدئة والجلسة والتعب الموضوعي. يقرر الرد ويرسل الرسالة إلى Sapphire عبر HTTP.
  3. يضمّن Sapphire الرسالة مع bge-small-en-v1.5.
    • التصنيف : الرسالة أقرب إلى centroid الـinteressant من centroid الـfutile (diff = +0.31) ← INTERESSANT
    • العاطفة : valence سلبية (-0.42)، وarousal معتدل (0.35)
    • التوجيه : اتجاه KRYSTAL_SEMANTIC_URL (منفذ 3125، نموذج كبير)
    • معاملات أخذ العينات : الحرارة = 0.80 (arousal مرتفع)، وrepeat_penalty = 1.19 (valence سلبية)
    • تُحدَّث الحالة العاطفية للجلسة بهذه القيم
  4. يولّد Krystal (النسخة الكبيرة) الرد بالمعاملات المضبوطة عاطفيا ويعيده إلى Sapphire.
  5. يبث Sapphire الرد نحو Emerald مع البيانات الوصفية (التسمية والـvalence والـarousal وإحصاءات التصحيح).
  6. يقرر Emerald إضافة تردد ("oh...")، ويخطط دفعة (جزأين)، ويختار تفاعلا. يرسل RespondCommand إلى Jade.
  7. ينفذ Jade : ينتظر المهلة الأولية، ويرسل الجزء الأول مع التردد، وينتظر 1.5s، ويرسل الجزء الثاني. يعرض مؤشر الكتابة طوال التوليد.

كل ذلك في أقل من 3 ثوانٍ للمستخدم.


الـcentroids : لماذا أفضل من مصنف عصبي

يستحق اختيار centroids الـembeddings بدلا من مصنف تقليدي (مثل DistilBERT الذي كنت أستخدمه قبل ذلك) تفسيرا.

يتعلم المصنف العصبي حد قرار بين الفئات، عادة تحويلا غير خطي يسقط المدخلات نحو احتمالات. إنه دقيق، لكن :

  • يتطلب بيانات تدريب موسومة
  • حساس لتغير التوزيع (data drift)
  • صعب التفسير
  • يجب إعادة تدريبه لإضافة فئة جديدة

أما الـcentroid، فهو متجه متوسط لـembeddings أمثلة. يتم التصنيف بالتشابه الجيبي مع هذا المتجه المتوسط. المزايا :

  • لا تدريب مطلوب : نحسب فقط متوسط embeddings أمثلة مختارة يدويا
  • سهل التفسير : يمكن النظر لأي أمثلة أقرب للـcentroid لفهم "ما تعلمه الـcentroid"
  • إضافة فئة : نضيف فقط centroid جديدا، دون إعادة تدريب
  • متين : الـcentroid متوسط، لذا القيم الشاذة قليلة الأثر

القوة الحقيقية للـcentroids أنها تحوّل مشكلة تصنيف إلى مشكلة قياس مسافة مكانية. يمكن تصور الفئات كمناطق في فضاء من 384 بعدا (أو في 2D/3D بعد اختزال الأبعاد PCA/t-SNE).

تصور ثلاثي الأبعاد للـcentroids

عمليا، إليكم كيف تبدو centroids التصنيف في فضاء الـembedding. كل نقطة رسالة مثال، مسقطة ثلاثية الأبعاد بـPCA (الأبعاد الأصلية الـ384 تُختزَل إلى 3 للتصور). النقاط الزرقاء رسائل تافهة، والنقاط الصفراء رسائل ممتعة. علامات المعين الـ20 هي centroids الـk-means (10 لكل فئة، seed=42). مرر الفأرة فوق نقطة لرؤية نص المثال الأصلي.

مثالا الاختبار معروضان بالأحمر : "lol" (مصنف تافه) و"i feel sad today" (مصنف ممتع). حتى بعد الاختزال من 384 إلى 3 أبعاد (14,7% من التباين المفسَّر)، العنقودان منفصلان بوضوح. يظهر التعليق في الأعلى الأعداد الدقيقة والمنطقة الغامضة.

يتجول centroid رسالة المدخلات في هذا الفضاء حسب محتواها. يقتصر تصنيف FUTILE/INTERESSANT ببساطة على قياس أي centroid أقرب بالتشابه الجيبي. ويمكننا هكذا تمثيل كل رسالة كنقطة في فضاء متعدد الأبعاد، كل بعد يقابل خاصية دلالية.


ما الذي يغيره عمليا

لا يرى المستخدمون الطبقات والـcentroids وضبط الحرارة. لكنهم يشعرون بالآثار :

  • ردود أسرع للرسائل البسيطة (النموذج الصغير أسرع بمرتين ويدير 70% من الترافيك)
  • نبرة متكيفة : إذا كنت غاضبا، "يشعر" البوت بالغضب ويكيّف أسلوبه
  • اتساق عبر المنصات : بوت Matrix وبوت Discord يتشاركان نفس الدماغ ونفس الحالة العاطفية
  • نبرة أصيلة محفوظة : الضبط + few-shot + التوجيه الذكي يتجنب الردود المؤسساتية

عزز الانتقال إلى 200k عينة تدريب للنموذج الصغير هذه الآثار أكثر : يلتقط النموذج تنوع محادثات Discord أفضل دون فقدان القابلية للتشكيل التي يتيحها التمهيد few-shot.


البنية التحتية الكاملة

إليكم الخدمات العاملة حاليا :

الخدمة التقنية المنفذ/المنافذ الدور
Pixieglow TypeScript (Bun) -- محول Matrix
Jade TypeScript (esbuild) -- محول Discord
Emerald TypeScript (Bun) 3126 (WebSocket) الدماغ / القرارات
Sapphire Python (FastAPI) 3123 (HTTP) المصنف + العاطفة
Krystal small llama.cpp (PM2) 3124 نموذج صغير (1.5B، تافه)
Krystal large llama.cpp (PM2) 3125 نموذج كبير (3B+، ممتع)

التبعيات بين الخدمات أحادية الاتجاه : المحول يعتمد على Emerald، وEmerald يعتمد على Sapphire، وSapphire يعتمد على Krystal. يبقى الرسم غير دوري. يمكن إعادة تشغيل كل خدمة مستقلة.


الخلاصة

لم يكن تقسيم Luna Protocol إلى أربع طبقات مجرد تمرين بنية. بل كان جوابا على حدود ملموسة : استحالة دعم Matrix، وغياب الوعي العاطفي، وغياب ترتيب ذكي للرسائل.

اليوم، النظام أكثر متانة (انهيار LLM لا يقتل البوت)، وأكثر قابلية للتوسع (محول Telegram أو WhatsApp سيتبع نفس بروتوكول WebSocket)، وأكثر "حياة" : يكيّف البوت سلوكه ونبرته، وحتى معاملات LLM، مع الحالة العاطفية المدركة للمحادثة.

centroids الـembeddings هي العنصر الأساسي الذي يجعل كل هذا ممكنا دون تعقيد مفرط : لا شبكة عصبية مُدرَّبة، ولا خط بيانات موسومة، فقط متوسطات متجهات وتشابهات جيبية. إنها تقنية بسيطة، وفعالة بشكل لا يصدق، ومُقلَّل من شأنها بشكل رهيب.

المورد الرابط
موقع المشروع protocol-luna.github.io
Pixieglow protocol-luna/pixieglow
Emerald protocol-luna/emerald
Sapphire protocol-luna/sapphire
Krystal protocol-luna/krystal
المقال 1 : بوت Discord Luna Protocol : أنشأت بوت Discord مستقلا
المقال 2 : الضبط Luna Protocol : لماذا ضبطت نموذج 1,5B

Luna Protocol : dùng chung não, phân loại cảm xúc, và định tuyến phù phiếm/thú vị

Luna Protocol đã đi từ monolith tới kiến trúc bốn lớp : adapter, brain, bộ phân loại cảm xúc, và inference. Với centroid embedding, định tuyến phù phiếm/thú vị, và chỉnh tham số LLM theo valence và arousal.

Luna Protocol : dùng chung não, phân loại cảm xúc, và định tuyến phù phiếm/thú vị

Trong hai bài trước, tôi đã giới thiệu Luna Protocol như bot Discord duy nhất với hệ thống hành vi phức tạp và mô hình fine-tune. Nhưng kiến trúc từ đó đã tiến hóa nhiều. Thứ từng là monolith : một tiến trình Node.js duy nhất vừa quản lý bot Discord, hành vi, vừa gọi LLM, đã biến thành bốn lớp độc lập, mỗi lớp có trách nhiệm, ngôn ngữ, và vòng đời riêng.

Tách ra mang lại lợi ích bất ngờ : dùng chung "bộ não" giữa nhiều nền tảng, hệ thống phân loại cảm xúc điều chỉnh động tham số LLM, và định tuyến thông minh tin nhắn giữa hai mô hình theo tầm quan trọng cảm nhận của cuộc trò chuyện.

Tiến hóa không diễn ra một lúc : nó theo đường hữu cơ. Tôi đầu tiên tách thư mục server/ khỏi repo bot, tạo ra Krystal một bên và để lại Jade làm adapter Discord. Rồi tôi tạo Pixieglow (adapter Matrix) bằng cách lấy lại llm-core và bus sự kiện của Jade. Tiếp đó tới Sapphire để đưa phân loại GENERIC/SEMANTIC bằng DistilBERT. Kết quả không thuyết phục, nên tôi chuyển qua centroid embedding, dễ tạo giàu ví dụ và chính xác hơn ; phân loại thành FUTILE/INTERESSANT. Cuối cùng tôi thêm centroid valence và arousal để điều tiết temperature và repeat penalty của LLM. Để kết, tôi gỡ mọi mã dư thừa giữa Jade và Pixieglow bằng cách tạo Emerald, bộ não dùng chung, biến Jade và Pixieglow thành client socket-driven đơn thuần.

Song song, tôi duy trì website ghi lại tiến độ dự án : protocol-luna.github.io.

Bài này kể cách và vì sao tôi cắt các lớp, mỗi service làm chính xác gì, và cách các khái niệm như centroid (vector trung bình embedding) và biến oán giận (lấy cảm hứng từ chatbot PARRY thập niên 70) đã biến bot Discord đơn thuần thành hệ thống đa nền tảng nhất quán đáng ngạc nhiên.


Vấn đề với monolith

Ban đầu, Luna Protocol nằm gọn trong một tiến trình Node.js. Mã quản lý :

  • Kết nối tới Discord (qua thư viện Eris)
  • Đánh giá kích hoạt (mention, keyword, follow-up...)
  • Mô phỏng hành vi con người (lỗi gõ phím, ngập ngừng, giấc ngủ...)
  • Gọi HTTP tới máy chủ LLM cục bộ (llama.cpp)
  • Quản lý phiên và chống spam
  • Pipeline TTS

Mọi thứ cùng một tiến trình, giao tiếp qua bus sự kiện có kiểu (TypedBus). Nó chạy, nhưng có giới hạn :

  • Không thể thêm client Matrix mà không nhân đôi toàn bộ mã hành vi
  • LLM và bot cùng repo : thư mục server/ đã tồn tại, nhưng không thể tiến hóa bên này mà không đụng bên kia
  • Xử lý không phân biệt : mỗi tin nhắn xử lý giống nhau, dù là "lol" hay câu hỏi hiện sinh
  • Mất trí cảm xúc : bot không "cảm" gì

Cắt thành lớp giải quyết mọi vấn đề đó.


Bốn lớp

Kiến trúc hiện tại của Luna Protocol tổ chức như phễu bốn tầng :

Matrix / Discord
      |
      v
  [ADAPTER]      Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [BRAIN]        Emerald (WebSocket, cổng 3126)
      |
      v
  [BỘ PHÂN LOẠI] Sapphire (HTTP, cổng 3123)
      |
      v
  [INFERENCE]    Krystal (llama.cpp, cổng 3124 / 3125)

Mỗi lớp có thể khởi động lại, cập nhật, hay thay thế độc lập.


Lớp 1 : adapter (Pixieglow và Jade)

Đây là các lớp đơn giản nhất. Việc duy nhất của chúng là dịch sự kiện của nền tảng nhắn tin thành giao thức chuẩn hóa về Emerald :

  • Jade là adapter Discord. Nó dùng thư viện Eris để nối Discord và chuyển tiếp tin nhắn về Emerald qua WebSocket. Nó cũng quản lý pipeline TTS (tổng hợp giọng bằng Piper, đổi sang OGG, tải lên Discord).
  • Pixieglow là adapter Matrix. Nó dùng trực tiếp API HTTP Client-Server của Matrix (không SDK), với long-poll sync. Nó không có TTS.

Cả hai adapter dùng chung giao thức WebSocket định nghĩa trong emerald-client.ts :

type ClientId = "jade" | "pixieglow";

// Sự kiện (adapter -> Emerald)
type InEvent = MessageEvent | ReadyEvent | BotMessageEvent | PresenceEvent;

// Lệnh (Emerald -> adapter)
type OutCommand = RespondCommand | TypingCommand | SetPresenceCommand
                | SpontaneousCommand | ForgotCommand;

Việc hai adapter cùng interface chứng minh dùng chung : cùng một "bộ não" (Emerald) phục vụ tùy ý bot Discord và bot Matrix, với hành vi giống hệt. Giao thức mang tính khai báo : Emerald không bảo adapter cách gửi tin nhắn, nó bảo gửi gì (văn bản với độ trễ, có thể kèm kế hoạch burst, phản ứng, v.v.). Mỗi adapter triển khai thực thi cụ thể theo nền tảng của nó.

Đó là sức mạnh của kiến trúc này : để hỗ trợ Telegram, Signal, hay khác, chỉ cần viết adapter triển khai giao thức WebSocket.


Lớp 2 : bộ não (Emerald)

Emerald là service quyết định trung tâm. Nó nghe trên cổng 3126 bằng WebSocket và quản lý :

  • Đánh giá kích hoạt : mention, DM, tên, keyword, follow-up, ngẫu nhiên
  • Mô phỏng hành vi : trễ tập trung, lỗi gõ phím, ngập ngừng, quên, burst, mệt mỏi chủ đề
  • Chu kỳ ngủ : chế độ sleep / slow / short
  • Quản lý phiên : cooldown, giới hạn phiên, chống spam
  • Định tuyến về Sapphire : gửi tin nhắn, nhận phản hồi stream

Emerald là service trung tâm cho phép dùng chung, và là nơi hưởng lợi nhiều nhất từ tách ra. Trước đây, mỗi hành vi (typo, burst, hesitation) đan xen với mã Discord. Giờ đây, chúng nằm trong module chuyên trong behavior/ :

emerald/src/behavior/
  burst.ts         -- Lập kế hoạch tin nhắn chùm
  mannerisms.ts    -- Trễ, ngập ngừng, phản ứng, quên
  sleep.ts         -- Đánh giá giờ ngủ
  typo.ts          -- Mô phỏng lỗi gõ phím (AZERTY/QWERTY)

Bộ não không biết mình chạy trên nền tảng nào. Nó nhận MessageEvent với clientId ("jade" hay "pixieglow"), quyết định, và trả về lệnh. Adapter lo phần còn lại.


Lớp 3 : bộ phân loại cảm xúc (Sapphire)

Sapphire là service thú vị nhất về kỹ thuật. Đó là middleware LLM viết bằng Python với FastAPI, đóng bốn vai trò then chốt :

  1. Bộ phân loại nhị phân FUTILE / INTERESSANT qua centroid embedding
  2. Chấm cảm xúc (valence / arousal) qua centroid
  3. Bộ định tuyến backend về Krystal (mô hình nhỏ so với mô hình lớn)
  4. Bộ bơm few-shot và quản lý phiên

Centroid : lõi phân loại

centroid là khái niệm đơn giản : trung bình của một tập vector embedding. Cụ thể, tôi gom hàng trăm ví dụ tin nhắn, chạy chúng qua mô hình embedding (BAAI/bge-small-en-v1.5, 384 chiều), và lấy trung bình các vector thu được.

Có hai centroid phân loại :

  • futile_centroid : ~683 tin nhắn tầm thường ("lol", "ok", "hello") qua k-means (k=10, seed=42)
  • interessant_centroid : ~678 tin nhắn có chất (kỹ thuật, cá nhân, triết lý) qua k-means (k=10, seed=42)

Khi tin nhắn tới :

def classify(text, embedder, futile_centroids, interessant_centroids):
    emb = embedder.query_embed(text)                     # vector 384-D
    sim_f = max(cos(emb, c) for c in futile_centroids)     # max trên 10
    sim_i = max(cos(emb, c) for c in interessant_centroids) # max trên 10
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

Điểm mỗi lớp là tương đồng cosin tối đa trong 10 centroid của nó. Việc này bắt được kiểu con trong mỗi danh mục : lời chào và lời tạm biệt đều rơi gần một trong 10 centroid phù phiếm dù chúng xa nhau trong không gian embedding. Chỉ k-means lúc khởi động và tích vô hướng lúc chạy : không huấn luyện, không GPU.

Vì sao hai mô hình?

Kết quả phân loại này quyết backend LLM nào được gọi :

Nhãn Backend Krystal Mô hình Cổng
FUTILE generic Luna-Protocol-1.5B (941 Mo, Q4_K_M) 3124
INTERESSANT semantic Hermes-3-3B hay 8B (tùy config) 3125

Trực giác đơn giản : "lol" hay "nm just chillin u" không đáng gọi mô hình 8 tỷ tham số. Mô hình nhỏ Luna 1.5B fine-tune, huấn luyện trên 200 000 mẫu Discord, quá đủ cho trao đổi nhẹ. Ngược lại, câu hỏi về cuộc sống, tâm sự, hay tranh luận kỹ thuật được định tuyến tới mô hình lớn cho phản hồi giàu hơn.

Định tuyến kinh tế này giảm đáng kể tải trên máy chủ LLM : khoảng 70% tin nhắn xếp FUTILE và do mô hình nhỏ xử lý, nhường mô hình lớn cho cuộc trò chuyện thực sự xứng đáng.

Trục cảm xúc : valence và arousal

Nhưng chưa hết. Sapphire dùng cùng cơ chế centroid trên trục độc lập để đánh giá cảm xúc tin nhắn :

Có bốn centroid cảm xúc :

Cực Ví dụ
positive "hell yeah", "love that", "this is great"
negative "shut up", "i hate this", "this sucks"
high_arousal "WHAT THE HELL", "omg omg omg", "AAAAA"
low_arousal "just chilling", "meh", "i guess"

Điểm tính bằng hiệu tương đồng trên mỗi trục :

valence = sim(emb, positive) - sim(emb, negative)     # [-1, +1]
arousal = sim(emb, high_arousal) - sim(emb, low_arousal)  # [-1, +1]

Valence đo tin nhắn tích cực hay tiêu cực. Arousal đo cường độ cảm xúc. Cùng nhau, chúng tạo mô hình circumplex của cảm xúc (Russell, 1980), cùng mô hình tâm lý đã truyền cảm hứng cho chatbot PARRY năm 1972.

Biến oán giận : cảm xúc điều khiển LLM thế nào

Đây là lúc cảm hứng PARRY thành hữu hình. PARRY (Kenneth Colby tạo năm 1972) là chatbot mô phỏng bệnh nhân hoang tưởng. Nó có biến nội tại (sợ, giận, ngờ) sửa đổi phản hồi. Ví dụ, PARRY "sợ hãi" trả lời hung hăng hơn.

Sapphire làm tương tự, nhưng với biến liên tục và phương pháp thanh lịch hơn : tham số lấy mẫu của LLM được chỉnh thời gian thực theo trạng thái cảm xúc của cuộc trò chuyện.

Temperature theo arousal
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Arousal Temperature Hiệu ứng
-1.0 (bình tĩnh) 0.40 Sáng tạo thấp, phản hồi đoán được
0.0 (trung tính) 0.70 Sáng tạo mặc định
+1.0 (kích động) 1.00 Ngẫu nhiên tối đa, phản hồi bất ngờ

Khi ai đó hào hứng hay bực (arousal cao), temperature tăng. Mô hình cho phản hồi đa dạng, sáng tạo hơn, đôi khi hỗn loạn hơn, như con người "cuốn theo". Khi cuộc trò chuyện bình tĩnh, temperature giảm, phản hồi điềm đạm hơn.

Repeat penalty theo valence
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
Valence Repeat Penalty Hiệu ứng
-1.0 (tiêu cực) 1.25 Phạt mạnh, tránh lặp lại
0.0 (trung tính) 1.15 Giá trị mặc định
+1.0 (tích cực) 1.05 Phạt nhẹ, cho phép lặp lại

Cuộc trò chuyện càng tiêu cực, mô hình càng bị đẩy tránh lặp lại, như người tìm lời trong tranh cãi căng. Cuộc trò chuyện càng tích cực, mô hình càng được khẳng định dư thừa, như trò chuyện thư giãn.

Trạng thái cảm xúc tích lũy

Các điểm này không chỉ mang tin nhắn tức thì. EmotionState duy trì trung bình trượt lũy thừa của valence và arousal cho mỗi phiên :

class EmotionState:
    def __init__(self, decay=0.85, deadzone=0.06):
        self.decay = decay
        self.deadzone = deadzone

    def update(self, key, valence_delta, arousal_delta):
        if abs(valence_delta) < self.deadzone:
            valence_delta = 0.0
        if abs(arousal_delta) < self.deadzone:
            arousal_delta = 0.0
        s = self._state.setdefault(key, {"valence": 0.0, "arousal": 0.0})
        s["valence"] = s["valence"] * self.decay + valence_delta * (1 - self.decay)
        s["arousal"] = s["arousal"] * self.decay + arousal_delta * (1 - self.decay)
        return s

decay ở 0.85 nghĩa là 85% trạng thái trước được giữ mỗi tin nhắn, và 15% tín hiệu mới hòa vào. Việc này cho trí nhớ cảm xúc làm mượt biến động đột ngột : một tin nhắn tiêu cực đơn không khiến bot "buồn", nhưng chuỗi tin nhắn tiêu cực dần đẩy tâm trạng của nó.

Thực tế : nếu ai đó mở cuộc trò chuyện rất hào hứng (arousal=+0.8), temperature giữ cao nhiều lượt trao đổi, dù các tin nhắn sau bình tĩnh hơn. Cảm xúc mất thời gian hạ, như con người vẫn "nóng" sau tranh cãi.


Lớp 4 : inference (Krystal)

Krystal là lớp thấp nhất : wrapper quanh llama.cpp phơi bày API tương thích OpenAI (/v1/chat/completions). Nó chạy trong hai instance PM2 :

  • krystal-small : mô hình Luna 1.5B fine-tune, trên cổng 3124, với affinity CPU 0
  • krystal-large : mô hình Hermes 3B, trên cổng 3125, với affinity CPU 0,1

Cả hai instance là tiến trình llama-server tiền biên dịch, chạy với taskset để ghim CPU.

Fine-tune mô hình Luna cũng tiến hóa từ bài thứ hai : giờ nó huấn luyện trên 200 000 mẫu (thay vì 50 000 trước), vẫn từ Qwen2.5-1.5B-Instruct qua QLoRA. 200k mẫu là tập con của dataset Discord-Dialogues, lọc chỉ giữ cuộc trò chuyện tự nhiên và đa dạng nhất. Mục tiêu : mở rộng thanh ghi phong cách của mô hình mà không mất linh hoạt giúp few-shot priming hiệu quả.


Sơ đồ đầy đủ : một tin nhắn quá cảnh

Đây là chuyện xảy ra cụ thể khi ai đó gửi "je suis vraiment triste aujourd'hui" trên Discord :

  1. Jade nhận tin nhắn qua API Gateway Discord. Nó đổi thành MessageEvent và gửi tới Emerald qua WebSocket.
  2. Emerald đánh giá kích hoạt (mention? tên? keyword?). Đây là mention trực tiếp. Nó tính trễ tập trung, kiểm tra cooldown, phiên, mệt mỏi chủ đề. Nó quyết trả lời và gửi tin nhắn tới Sapphire qua HTTP.
  3. Sapphire nhúng tin nhắn bằng bge-small-en-v1.5.
    • Phân loại : tin nhắn gần centroid interessant hơn centroid futile (diff = +0.31) -> INTERESSANT
    • Cảm xúc : valence tiêu cực (-0.42), arousal vừa (0.35)
    • Định tuyến : hướng KRYSTAL_SEMANTIC_URL (cổng 3125, mô hình lớn)
    • Tham số lấy mẫu : temperature = 0.80 (arousal tăng), repeat_penalty = 1.19 (valence tiêu cực)
    • Trạng thái cảm xúc của phiên được cập nhật bằng các giá trị này
  4. Krystal (instance lớn) sinh phản hồi với tham số chỉnh theo cảm xúc và trả về Sapphire.
  5. Sapphire stream phản hồi về Emerald kèm metadata (nhãn, valence, arousal, thống kê gỡ lỗi).
  6. Emerald quyết thêm ngập ngừng ("oh..."), lập kế hoạch burst (2 mảnh), và chọn phản ứng. Nó gửi RespondCommand tới Jade.
  7. Jade thực thi : chờ trễ ban đầu, gửi mảnh đầu kèm ngập ngừng, chờ 1.5s, gửi mảnh hai. Nó hiện chỉ báo đang gõ suốt quá trình sinh.

Tất cả dưới 3 giây với người dùng.


Centroid : vì sao tốt hơn bộ phân loại nơ-ron

Chọn centroid embedding thay vì bộ phân loại truyền thống (như DistilBERT tôi từng dùng) đáng được giải thích.

Bộ phân loại nơ-ron học ranh giới quyết định giữa các lớp, thường là biến đổi phi tuyến chiếu đầu vào thành xác suất. Nó chính xác, nhưng :

  • Nó đòi dữ liệu huấn luyện gán nhãn
  • Nó nhạy với thay đổi phân bố (data drift)
  • Nó khó diễn giải
  • Nó phải huấn luyện lại để thêm lớp mới

Centroid, ngược lại, là vector trung bình của embedding ví dụ. Phân loại bằng tương đồng cosin tới vector trung bình này. Lợi thế :

  • Không cần huấn luyện : chỉ tính trung bình embedding của ví dụ chọn tay
  • Dễ diễn giải : có thể xem ví dụ nào gần centroid nhất để hiểu "centroid đã học gì"
  • Thêm lớp : chỉ thêm centroid mới, không huấn luyện lại
  • Vững : centroid là trung bình, nên outlier ít ảnh hưởng

Sức mạnh thật của centroid là biến bài toán phân loại thành bài toán đo khoảng cách không gian. Ta có thể trực quan hóa các danh mục như vùng trong không gian 384 chiều (hay 2D/3D sau giảm chiều PCA/t-SNE).

Trực quan hóa 3D centroid

Thực tế, đây là dáng centroid phân loại trong không gian embedding. Mỗi điểm là ví dụ tin nhắn, chiếu 3D bằng PCA (384 chiều gốc rút còn 3 để trực quan). Điểm xanh là tin nhắn phù phiếm, điểm vàng là tin nhắn thú vị. 20 dấu kim cương là centroid k-means (10 cho mỗi lớp, seed=42). Di chuột lên điểm để thấy văn bản gốc của ví dụ.

Hai ví dụ test hiện màu đỏ : "lol" (xếp phù phiếm) và "i feel sad today" (xếp thú vị). Ngay cả sau khi rút từ 384 còn 3 chiều (giải thích 14,7% phương sai), hai cụm tách rõ. Chú thích trên cho số lượng chính xác và vùng mơ hồ.

Centroid của tin nhắn tới dạo quanh không gian này tùy nội dung. Phân loại FUTILE/INTERESSANT chỉ là đo centroid nào gần nhất bằng tương đồng cosin. Nhờ vậy ta có thể biểu diễn mỗi tin nhắn như điểm trong không gian đa chiều, mỗi chiều khớp một thuộc tính ngữ nghĩa.


Điều đó đổi gì trong thực hành

Người dùng không thấy lớp, centroid, hay chỉnh temperature. Nhưng họ cảm hiệu ứng :

  • Phản hồi nhanh hơn cho tin nhắn đơn giản (mô hình nhỏ nhanh gấp 2 và xử lý 70% lưu lượng)
  • Giọng thích ứng : nếu bạn bực, bot "cảm" bực và thích ứng phong cách
  • Nhất quán xuyên nền tảng : bot Matrix và bot Discord dùng chung não và trạng thái cảm xúc
  • Giọng chân thật giữ nguyên : fine-tune + few-shot + định tuyến thông minh tránh phản hồi corporate

Chuyển sang 200k mẫu huấn luyện cho mô hình nhỏ càng củng cố các hiệu ứng này : mô hình nắm tốt hơn đa dạng hội thoại Discord mà không mất dẻo dai giúp few-shot priming hiệu quả.


Hạ tầng đầy đủ

Đây là các service đang chạy :

Service Công nghệ Cổng Vai trò
Pixieglow TypeScript (Bun) -- Adapter Matrix
Jade TypeScript (esbuild) -- Adapter Discord
Emerald TypeScript (Bun) 3126 (WebSocket) Não / quyết định
Sapphire Python (FastAPI) 3123 (HTTP) Bộ phân loại + cảm xúc
Krystal small llama.cpp (PM2) 3124 Mô hình nhỏ (1.5B, phù phiếm)
Krystal large llama.cpp (PM2) 3125 Mô hình lớn (3B+, thú vị)

Phụ thuộc giữa service một chiều : adapter phụ thuộc Emerald, Emerald phụ thuộc Sapphire, Sapphire phụ thuộc Krystal. Đồ thị vẫn không chu trình. Mỗi service có thể khởi động lại độc lập.


Kết luận

Chia Luna Protocol thành bốn lớp không chỉ là bài tập kiến trúc. Đó là câu trả lời cho giới hạn cụ thể : không thể hỗ trợ Matrix, thiếu ý thức cảm xúc, thiếu ưu tiên thông minh tin nhắn.

Ngày nay, hệ thống vững hơn (LLM crash không giết bot), mở rộng được hơn (adapter Telegram hay WhatsApp sẽ theo cùng giao thức WebSocket), và "sống" hơn : bot thích ứng hành vi, giọng, và ngay cả tham số LLM theo trạng thái cảm xúc cảm nhận của cuộc trò chuyện.

Centroid embedding là yếu tố mấu chốt giúp tất cả khả thi mà không phức tạp quá : không mạng nơ-ron huấn luyện, không pipeline dữ liệu gán nhãn, chỉ trung bình vector và tương đồng cosin. Đó là kỹ thuật đơn giản, hiệu quả bất ngờ, và bị đánh giá thấp tệ hại.

Tài nguyên Link
Website dự án protocol-luna.github.io
Pixieglow protocol-luna/pixieglow
Emerald protocol-luna/emerald
Sapphire protocol-luna/sapphire
Krystal protocol-luna/krystal
Bài 1 : bot Discord Luna Protocol : tôi đã tạo bot Discord tự chủ
Bài 2 : fine-tuning Luna Protocol : vì sao tôi fine-tune mô hình 1,5B

Luna Protocol: สมองที่ใช้ร่วมกัน การจำแนกอารมณ์ และการจัดเส้นทางข้อความน่าสนใจ/ไร้สาระ

Luna Protocol พัฒนาจากโมโนลิธมาเป็นสถาปัตยกรรมสี่ชั้น: adapter, brain, ตัวจำแนกอารมณ์ และ inference ในบทความนี้: centroid ของ embedding, การจัดเส้นทางข้อความน่าสนใจ/ไร้สาระ และการปรับพารามิเตอร์ LLM ตาม valence และ arousal

Luna Protocol: สมองที่ใช้ร่วมกัน การจำแนกอารมณ์ และการจัดเส้นทางข้อความน่าสนใจ/ไร้สาระ

ในสองบทความก่อนหน้านี้ ฉันได้นำเสนอ Luna Protocol ในฐานะบอท Discord เดี่ยวที่มีระบบพฤติกรรมซับซ้อนและโมเดลที่ผ่านการ fine-tune แต่สถาปัตยกรรมได้พัฒนาไปมากตั้งแต่นั้นมา สิ่งที่เคยเป็นโมโนลิธ -- โปรเซส Node.js เดียวที่จัดการทั้งบอท Discord พฤติกรรม และการเรียก LLM -- ได้กลายเป็น สี่ชั้นอิสระ แต่ละชั้นมีความรับผิดชอบ ภาษา และวงจรชีวิตของตัวเอง

การแยกนี้นำมาซึ่งประโยชน์ที่ไม่คาดคิด: การแบ่งปัน "สมอง" ข้ามหลายแพลตฟอร์ม ระบบจำแนกอารมณ์ที่ปรับพารามิเตอร์ของ LLM แบบไดนามิก และการจัดเส้นทางข้อความอย่างชาญฉลาดระหว่างสองโมเดลตามความสำคัญที่รับรู้ได้ของบทสนทนา

วิวัฒนาการนี้ไม่ได้เกิดขึ้นในคราวเดียว -- มันเดินตามเส้นทางที่เป็นธรรมชาติ ก่อนอื่นฉันแยกโฟลเดอร์ server/ ออกจาก repo ของบอท สร้าง Krystal ขึ้นมาด้านหนึ่ง และเหลือ Jade ไว้เป็น adapter ของ Discord จากนั้นฉันสร้าง Pixieglow (adapter ของ Matrix) โดยนำ llm-core และ event bus ของ Jade กลับมาใช้ใหม่ ต่อมาคือ Sapphire ซึ่งนำการจำแนกแบบ GENERIC/SEMANTIC ด้วย DistilBERT มาใช้ -- แต่ผลลัพธ์ไม่น่าประทับใจ ฉันจึงเปลี่ยนไปใช้ centroid ของ embedding ซึ่งยืดหยุ่นกว่าในการเพิ่มตัวอย่างและแม่นยำกว่า การจำแนกจึงกลายเป็น FUTILE/INTERESTING (ไร้สาระ/น่าสนใจ) ในที่สุดฉันเพิ่ม centroid ของ valence และ arousal เพื่อควบคุม temperature และ repeat penalty ของ LLM สุดท้าย ฉันลบโค้ดที่ซ้ำซ้อนระหว่าง Jade และ Pixieglow ทั้งหมดโดยสร้าง Emerald สมองที่ใช้ร่วมกัน ทำให้ Jade และ Pixieglow กลายเป็นไคลเอนต์ที่ขับเคลื่อนด้วย socket แบบง่ายๆ

ควบคู่ไปกับสิ่งนี้ ฉันยังคงอัปเดตเว็บไซต์ที่ติดตามความคืบหน้าของโปรเจกต์: protocol-luna.github.io

บทความนี้เล่าเรื่องราวว่าฉันแยกชั้นเหล่านี้อย่างไรและทำไม แต่ละบริการทำอะไรกันแน่ และแนวคิดอย่าง centroid (เวกเตอร์ embedding เฉลี่ย) และ ตัวแปร resentment (ได้แรงบันดาลใจจากแชทบอท PARRY ในยุค 1970) เปลี่ยนบอท Discord ธรรมดาให้กลายเป็นระบบมัลติแพลตฟอร์มที่สอดคล้องกันอย่างน่าประหลาดใจได้อย่างไร


ปัญหาของโมโนลิธ

ในตอนแรก Luna Protocol อยู่ในโปรเซส Node.js เดียว โค้ดจัดการ:

  • การเชื่อมต่อ Discord (ผ่านไลบรารี Eris)
  • การประเมิน trigger (การเมนชัน คำสำคัญ การตอบต่อเนื่อง...)
  • การจำลองพฤติกรรมมนุษย์ (พิมพ์ผิด ลังเล นอนหลับ...)
  • การเรียก HTTP ไปยังเซิร์ฟเวอร์ LLM ในเครื่อง (llama.cpp)
  • การจัดการเซสชันและป้องกันสแปม
  • ไปป์ไลน์ TTS

ทุกอย่างอยู่ในโปรเซสเดียวกัน สื่อสารผ่าน event bus ที่มีการกำหนดประเภท (TypedBus) มันใช้งานได้ แต่มีข้อจำกัด:

  • ไม่สามารถเพิ่มไคลเอนต์ Matrix ได้ โดยไม่ต้องทำโค้ดพฤติกรรมซ้ำทั้งหมด
  • LLM และบอทอยู่ใน repo เดียวกัน: โฟลเดอร์ server/ มีอยู่แล้ว แต่คุณไม่สามารถพัฒนาส่วนหนึ่งได้โดยไม่แตะต้องอีกส่วนหนึ่ง
  • ไม่มีการจำแนกอย่างชาญฉลาด: ทุกข้อความถูกปฏิบัติเหมือนกัน ไม่ว่าจะเป็น "lol" หรือคำถามเชิงอัตถิภาวนิยม
  • ไม่มีสถานะทางอารมณ์ที่คงอยู่: บอทไม่ "รู้สึก" อะไรเลย

การแยกเป็นชั้นๆ แก้ปัญหาเหล่านี้ทั้งหมด


สี่ชั้น

สถาปัตยกรรมปัจจุบันของ Luna Protocol จัดวางเป็นกรวยสี่ระดับ:

Matrix / Discord
      |
      v
  [ADAPTERS]      Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [BRAIN]         Emerald (WebSocket, พอร์ต 3126)
      |
      v
  [CLASSIFIER]    Sapphire (HTTP, พอร์ต 3123)
      |
      v
  [INFERENCE]     Krystal (llama.cpp, พอร์ต 3124 / 3125)

แต่ละชั้นสามารถรีสตาร์ท อัปเดต หรือแทนที่ได้อย่างอิสระ


ชั้นที่ 1: adapter (Pixieglow และ Jade)

นี่คือชั้นที่ง่ายที่สุด หน้าที่เดียวของพวกมันคือแปลอีเวนต์จากแพลตฟอร์มการส่งข้อความให้เป็นโปรโตคอลมาตรฐานไปยัง Emerald:

  • Jade คือ adapter ของ Discord ใช้ไลบรารี Eris เพื่อเชื่อมต่อกับ Discord และส่งต่อข้อความไปยัง Emerald ผ่าน WebSocket นอกจากนี้ยังจัดการไปป์ไลน์ TTS (การสังเคราะห์เสียงผ่าน Piper การแปลง OGG การอัปโหลดไปยัง Discord)
  • Pixieglow คือ adapter ของ Matrix ใช้ Matrix Client-Server HTTP API โดยตรง (ไม่มี SDK) พร้อม long-poll sync ไม่มี TTS

adapter ทั้งสองใช้โปรโตคอล WebSocket เดียวกันที่กำหนดไว้ใน emerald-client.ts:

type ClientId = "jade" | "pixieglow";

// อีเวนต์ (adapter -> Emerald)
type InEvent = MessageEvent | ReadyEvent | BotMessageEvent | PresenceEvent;

// คำสั่ง (Emerald -> adapter)
type OutCommand = RespondCommand | TypingCommand | SetPresenceCommand
                | SpontaneousCommand | ForgotCommand;

การมี adapter สองตัวที่มี interface เดียวกันพิสูจน์ว่าการแบ่งปันสมองใช้ได้ผลจริง: "สมอง" เดียวกัน (Emerald) ให้บริการทั้งบอท Discord และบอท Matrix โดยไม่แตกต่างกัน ด้วยพฤติกรรมที่เหมือนกันทุกประการ โปรโตคอลนี้เป็นแบบ declarative: Emerald ไม่ได้บอก adapter ว่า ต้องส่งข้อความอย่างไร แต่บอกว่า ต้องส่งอะไร (ข้อความพร้อมความล่าช้า อาจมีแผน burst ปฏิกิริยา ฯลฯ) แต่ละ adapter จะดำเนินการจริงตามแพลตฟอร์มของตัวเอง

นั่นคือจุดแข็งของสถาปัตยกรรมนี้: การเพิ่มการรองรับ Telegram, Signal หรืออื่นๆ คุณเพียงแค่ต้องเขียน adapter ที่ implement โปรโตคอล WebSocket

สมองไม่รู้ว่ามันกำลังทำงานบนแพลตฟอร์มใด มันได้รับ MessageEvent พร้อม clientId ("jade" หรือ "pixieglow") ตัดสินใจ แล้วส่งคำสั่งกลับไป adapter จะจัดการส่วนที่เหลือ


ชั้นที่ 2: สมอง (Emerald)

Emerald คือบริการตัดสินใจส่วนกลาง มันฟังอยู่ที่พอร์ต 3126 ผ่าน WebSocket และจัดการ:

  • การประเมิน trigger: การเมนชัน DM ชื่อ คำสำคัญ การตอบต่อเนื่อง สุ่ม
  • การจำลองพฤติกรรม: ความล่าช้าในการโฟกัส พิมพ์ผิด ลังเล ขี้ลืม burst ความเหนื่อยล้าของหัวข้อ
  • วงจรการนอน: โหมด sleep / slow / short
  • การจัดการเซสชัน: cooldown ขีดจำกัดเซสชัน ป้องกันสแปม
  • การจัดเส้นทางไปยัง Sapphire: ส่งข้อความ รับการตอบกลับแบบสตรีม

Emerald คือบริการส่วนกลางที่ทำให้การแบ่งปันสมองเป็นไปได้ และเป็นบริการที่ได้ประโยชน์มากที่สุดจากการแยกชั้น ก่อนหน้านี้ พฤติกรรมทุกอย่าง (พิมพ์ผิด burst ลังเล) พันกันอยู่กับโค้ดของ Discord ตอนนี้พวกมันอยู่ในโมดูลเฉพาะภายใต้ behavior/:

emerald/src/behavior/
  burst.ts         -- การวางแผนข้อความ burst
  mannerisms.ts    -- ความล่าช้า ลังเล ปฏิกิริยา ขี้ลืม
  sleep.ts         -- การประเมินตารางการนอน
  typo.ts          -- การจำลองพิมพ์ผิด (AZERTY/QWERTY)

ชั้นที่ 3: ตัวจำแนกอารมณ์ (Sapphire)

Sapphire เป็นบริการที่น่าสนใจที่สุดในเชิงเทคนิค มันเป็น LLM middleware เขียนด้วย Python และ FastAPI ทำหน้าที่สำคัญสี่บทบาท:

  1. ตัวจำแนกไบนารี FUTILE / INTERESTING ผ่าน centroid ของ embedding
  2. ตัวให้คะแนนอารมณ์ (valence / arousal) ผ่าน centroid
  3. ตัวจัดเส้นทาง backend ไปยัง Krystal (โมเดลเล็ก vs โมเดลใหญ่)
  4. ตัวฉีด few-shot และตัวจัดการเซสชัน

Centroid: หัวใจของการจำแนก

Centroid เป็นแนวคิดง่ายๆ: มันคือค่าเฉลี่ยของชุดเวกเตอร์ embedding โดยเฉพาะ ฉันได้รวบรวมข้อความตัวอย่างหลายร้อยข้อความ ส่งผ่านโมเดล embedding (BAAI/bge-small-en-v1.5 384 มิติ) และหาค่าเฉลี่ยของเวกเตอร์ผลลัพธ์

มี centroid สำหรับการจำแนกสองตัว:

  • futile_centroid: embedding เฉลี่ยของข้อความไม่สำคัญประมาณ 683 ข้อความ ("lol", "ok", "hello") via k-means (k=10, seed=42)
  • interesting_centroid: embedding เฉลี่ยของข้อความที่มีเนื้อหาสาระประมาณ 678 ข้อความ (เทคนิค, ส่วนตัว, ปรัชญา) via k-means (k=10, seed=42)

เมื่อมีข้อความเข้ามา:

def classify(text, embedder, futile_centroids, interesting_centroids):
    emb = embedder.query_embed(text)                        # 384-D vector
    sim_f = max(cos(emb, c) for c in futile_centroids)     # max over 10
    sim_i = max(cos(emb, c) for c in interesting_centroids)     # max over 10
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

ความคล้ายคลึงแบบโคไซน์ระหว่างข้อความและแต่ละ centroid เป็นตัวกำหนดหมวดหมู่ ค่าความต่างสัมบูรณ์ให้ค่าความมั่นใจ มันเรียบง่าย รวดเร็ว (ไม่ต้องผ่าน LLM forward pass) และมีประสิทธิภาพอย่างน่าประหลาดใจ

ทำไมต้องมีสองโมเดล?

ผลของการจำแนกนี้กำหนดว่า LLM backend ตัวไหนจะถูกเรียกใช้:

ป้ายกำกับ Backend ของ Krystal โมเดล พอร์ต
FUTILE generic Luna-Protocol-1.5B (941 MB, Q4_K_M) 3124
INTERESTING semantic Hermes-3-3B หรือ 8B (ขึ้นอยู่กับการตั้งค่า) 3125

สัญชาตญาณนั้นง่ายมาก: "lol" หรือ "nm just chillin u" ไม่คู่ควรที่จะเรียกใช้โมเดลแปดพันล้านพารามิเตอร์ โมเดล Luna 1.5B ขนาดเล็กที่ผ่านการ fine-tune ซึ่งฝึกด้วยตัวอย่าง Discord 200,000 ตัวอย่าง เพียงพอเกินพอสำหรับการสนทนาเบาๆ ในทางกลับกัน คำถามเกี่ยวกับชีวิต การสารภาพ หรือการถกเถียงทางเทคนิคจะถูกจัดเส้นทางไปยังโมเดลใหญ่ ซึ่งสามารถสร้างคำตอบที่สมบูรณ์กว่าได้

การจัดเส้นทางแบบประหยัดนี้ช่วยลดภาระบนเซิร์ฟเวอร์ LLM ได้อย่างมาก: ประมาณ 70% ของข้อความถูกจำแนกเป็น FUTILE และจัดการโดยโมเดลเล็ก ทำให้โมเดลใหญ่ว่างสำหรับบทสนทนาที่คู่ควรจริงๆ

แกนอารมณ์: valence และ arousal

แต่นั่นยังไม่หมด Sapphire ใช้ กลไก centroid เดียวกัน บนแกนอิสระเพื่อประเมินอารมณ์ของข้อความ:

มี centroid ทางอารมณ์สี่ตัว:

ขั้ว ตัวอย่าง
positive "hell yeah", "love that", "this is great"
negative "shut up", "i hate this", "this sucks"
high_arousal "WHAT THE HELL", "omg omg omg", "AAAAA"
low_arousal "just chilling", "meh", "i guess"

คะแนนถูกคำนวณเป็นผลต่างของความคล้ายคลึงกันในแต่ละแกน:

valence = sim(emb, positive) - sim(emb, negative)     # [-1, +1]
arousal = sim(emb, high_arousal) - sim(emb, low_arousal)  # [-1, +1]

Valence วัดว่าข้อความเป็นบวกหรือลบ Arousal วัดความเข้มข้นทางอารมณ์ของมัน เมื่อรวมกันแล้วทั้งสองก่อตัวเป็นแบบจำลอง circumplex ของอารมณ์ (circumplex model of affect, Russell, 1980) -- แบบจำลองทางจิตวิทยาเดียวกับที่เป็นแรงบันดาลใจให้กับแชทบอท PARRY ในปี 1972

ตัวแปร resentment: อารมณ์ควบคุม LLM อย่างไร

ตรงนี้เองที่แรงบันดาลใจจาก PARRY กลายเป็นรูปธรรม PARRY (สร้างโดย Kenneth Colby ในปี 1972) เป็นแชทบอทที่ออกแบบมาเพื่อจำลองผู้ป่วยหวาดระแวง มันมีตัวแปรภายใน -- ความกลัว ความโกรธ ความไม่ไว้วางใจ -- ที่เปลี่ยนแปลงคำตอบของมัน ตัวอย่างเช่น PARRY ที่ "หวาดกลัว" จะตอบสนองอย่างก้าวร้าวมากขึ้น

Sapphire ทำสิ่งเดียวกัน แต่ด้วยตัวแปรต่อเนื่องและวิธีที่สง่างามกว่า: พารามิเตอร์การสุ่มตัวอย่างของ LLM ถูกปรับแบบเรียลไทม์ตามสถานะทางอารมณ์ของบทสนทนา

Temperature ตาม arousal
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Arousal Temperature ผลกระทบ
-1.0 (สงบ) 0.40 ความคิดสร้างสรรค์ต่ำ คำตอบที่คาดเดาได้
0.0 (เป็นกลาง) 0.70 ความคิดสร้างสรรค์ปกติ
+1.0 (ตื่นเต้น) 1.00 ความสุ่มสูงสุด คำตอบที่น่าประหลาดใจ

เมื่อมีคนตื่นเต้นหรือหงุดหงิด (arousal สูง) temperature จะเพิ่มขึ้น โมเดลจะสร้างคำตอบที่หลากหลายมากขึ้น สร้างสรรค์มากขึ้น บางครั้งวุ่นวายมากขึ้น -- เหมือนมนุษย์ที่ "เผลอใจ" เมื่อบทสนทนาสงบ temperature จะลดลง และคำตอบจะสุขุมมากขึ้น

Repeat penalty ตาม valence
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
Valence Repeat Penalty ผลกระทบ
-1.0 (ลบ) 1.25 บทลงโทษรุนแรง หลีกเลี่ยงการซ้ำ
0.0 (เป็นกลาง) 1.15 ค่าเริ่มต้น
+1.0 (บวก) 1.05 บทลงโทษต่ำ อนุญาตให้ซ้ำ

บทสนทนายิ่งเป็นลบมากเท่าไหร่ โมเดลก็ยิ่งถูกผลักดันให้หลีกเลี่ยงการพูดซ้ำมากขึ้นเท่านั้น -- เหมือนคนที่กำลังหาคำพูดในการโต้เถียงที่ตึงเครียด บทสนทนายิ่งเป็นบวกมากเท่าไหร่ โมเดลก็ยิ่งสามารถยอมรับคำพูดที่ซ้ำซ้อนได้มากขึ้น เหมือนบทสนทนาที่ผ่อนคลาย

สถานะอารมณ์สะสม

คะแนนเหล่านี้ไม่ได้ใช้กับข้อความปัจจุบันเท่านั้น EmotionState รักษา ค่าเฉลี่ยเคลื่อนที่แบบเอ็กซ์โพเนนเชียล ของ valence และ arousal ต่อเซสชัน:

class EmotionState:
    def __init__(self, decay=0.85, deadzone=0.06):
        self.decay = decay
        self.deadzone = deadzone

    def update(self, key, valence_delta, arousal_delta):
        if abs(valence_delta) < self.deadzone:
            valence_delta = 0.0
        if abs(arousal_delta) < self.deadzone:
            arousal_delta = 0.0
        s = self._state.setdefault(key, {"valence": 0.0, "arousal": 0.0})
        s["valence"] = s["valence"] * self.decay + valence_delta * (1 - self.decay)
        s["arousal"] = s["arousal"] * self.decay + arousal_delta * (1 - self.decay)
        return s

decay ที่ 0.85 หมายความว่า 85% ของสถานะก่อนหน้าถูกรักษาไว้ในแต่ละข้อความ โดย 15% ของสัญญาณใหม่ถูกผสานเข้าไป สิ่งนี้สร้าง ความทรงจำทางอารมณ์ ที่ทำให้การเปลี่ยนแปลงอย่างฉับพลันราบรื่นขึ้น: ข้อความลบเพียงข้อความเดียวไม่ทำให้บอท "เศร้า" แต่ชุดของข้อความลบจะค่อยๆ ทำให้อารมณ์ของมันเปลี่ยนไปในทิศทางนั้น

ในทางปฏิบัติ: หากมีคนเริ่มบทสนทนาด้วยความตื่นเต้นมาก (arousal=+0.8) temperature จะยังคงสูงอยู่หลายรอบการสนทนา แม้ว่าข้อความถัดไปจะสงบกว่าก็ตาม อารมณ์ต้องใช้เวลาในการลดลง -- เหมือนมนุษย์ที่ยังคง "ร้อน" อยู่หลังจากการโต้เถียง


ชั้นที่ 4: inference (Krystal)

Krystal คือชั้นล่างสุด: wrapper รอบ llama.cpp ที่เปิดเผย API ที่เข้ากันได้กับ OpenAI (/v1/chat/completions) มันทำงานเป็นสอง instance ของ PM2:

  • krystal-small: โมเดล Luna 1.5B ที่ผ่านการ fine-tune บนพอร์ต 3124 ด้วย CPU affinity 0
  • krystal-large: โมเดล Hermes 3B บนพอร์ต 3125 ด้วย CPU affinity 0,1

ทั้งสอง instance เป็นโปรเซส llama-server ที่คอมไพล์ไว้ล่วงหน้า เปิดใช้งานด้วย taskset สำหรับการ pin CPU

การ fine-tune ของโมเดล Luna ก็พัฒนาไปเช่นกันตั้งแต่บทความที่สอง: ตอนนี้มันฝึกด้วย ตัวอย่าง 200,000 ตัวอย่าง (เพิ่มขึ้นจาก 50,000 ก่อนหน้านี้) ยังคงเริ่มต้นจาก Qwen2.5-1.5B-Instruct ผ่าน QLoRA ตัวอย่าง 200,000 ตัวอย่างนี้เป็นส่วนย่อยของชุดข้อมูล Discord-Dialogues ที่กรองให้เหลือเฉพาะบทสนทนาที่เป็นธรรมชาติและหลากหลายที่สุด เป้าหมายคือ: ขยายขอบเขตเชิงสไตล์ของโมเดลโดยไม่สูญเสียความยืดหยุ่นที่ทำให้ few-shot priming มีประสิทธิภาพมาก


ภาพรวมทั้งหมด: ข้อความหนึ่งที่กำลังเดินทาง

นี่คือสิ่งที่เกิดขึ้นจริงเมื่อมีคนส่ง "i'm really sad today" บน Discord:

  1. Jade รับข้อความผ่าน Discord Gateway API มันแปลงเป็น MessageEvent และส่งไปยัง Emerald ผ่าน WebSocket
  2. Emerald ประเมิน trigger (การเมนชัน? ชื่อ? คำสำคัญ?) มันคือการเมนชันโดยตรง มันคำนวณความล่าช้าในการโฟกัส ตรวจสอบ cooldown เซสชัน ความเหนื่อยล้าของหัวข้อ มันตัดสินใจตอบกลับและส่งข้อความไปยัง Sapphire ผ่าน HTTP
  3. Sapphire ทำการ embed ข้อความด้วย bge-small-en-v1.5
    • การจำแนก: ข้อความใกล้เคียงกับ centroid interesting มากกว่า centroid futile (diff = +0.31) -> INTERESTING
    • อารมณ์: valence เป็นลบ (-0.42) arousal ปานกลาง (0.35)
    • การจัดเส้นทาง: ทิศทาง KRYSTAL_SEMANTIC_URL (พอร์ต 3125 โมเดลใหญ่)
    • พารามิเตอร์การสุ่มตัวอย่าง: temperature = 0.80 (arousal เพิ่มขึ้น), repeat_penalty = 1.19 (valence เป็นลบ)
    • สถานะทางอารมณ์ของเซสชันถูกอัปเดตด้วยค่าเหล่านี้
  4. Krystal (instance ใหญ่) สร้างคำตอบด้วยพารามิเตอร์ที่ปรับตามอารมณ์แล้วส่งกลับไปยัง Sapphire
  5. Sapphire สตรีมคำตอบไปยัง Emerald พร้อมกับ metadata (ป้ายกำกับ valence arousal สถิติดีบัก)
  6. Emerald ตัดสินใจเพิ่มความลังเล ("oh...") วางแผน burst (2 ชิ้นส่วน) และเลือกปฏิกิริยา มันส่ง RespondCommand ไปยัง Jade
  7. Jade ดำเนินการ: รอความล่าช้าเริ่มต้น ส่งชิ้นส่วนแรกพร้อมความลังเล รอ 1.5 วินาที ส่งชิ้นส่วนที่สอง มันแสดงตัวบ่งชี้การพิมพ์ตลอดกระบวนการสร้างคำตอบ

ทั้งหมดนี้เกิดขึ้นภายในเวลาไม่ถึง 3 วินาทีสำหรับผู้ใช้


Centroid: ทำไมมันดีกว่าตัวจำแนกแบบนิวรอล

การเลือกใช้ centroid ของ embedding แทนตัวจำแนกแบบดั้งเดิม (เช่น DistilBERT ที่ฉันเคยใช้มาก่อน) สมควรได้รับคำอธิบาย

ตัวจำแนกแบบนิวรอลเรียนรู้ขอบเขตการตัดสินใจระหว่างคลาส -- โดยทั่วไปคือการแปลงแบบไม่เชิงเส้นที่แม็ปอินพุตเป็นความน่าจะเป็น มันแม่นยำ แต่:

  • ต้องการข้อมูลฝึกที่มีป้ายกำกับ
  • ไวต่อการเปลี่ยนแปลงของการกระจายข้อมูล (data drift)
  • ตีความยาก
  • ต้องฝึกใหม่เพื่อเพิ่มคลาสใหม่

ในทางกลับกัน centroid คือ เวกเตอร์เฉลี่ย ของ embedding ตัวอย่าง การจำแนกทำได้โดยใช้ความคล้ายคลึงแบบโคไซน์กับเวกเตอร์เฉลี่ยนั้น ข้อดี:

  • ไม่ต้องฝึก: คุณเพียงแค่คำนวณค่าเฉลี่ยของ embedding สำหรับตัวอย่างที่เลือกด้วยมือ
  • ตีความง่าย: คุณสามารถดูว่าตัวอย่างไหนใกล้กับ centroid มากที่สุดเพื่อเข้าใจว่า "centroid เรียนรู้อะไร"
  • การเพิ่มคลาส: คุณเพียงแค่เพิ่ม centroid ใหม่ -- ไม่ต้องฝึกใหม่
  • แข็งแกร่ง: centroid คือค่าเฉลี่ย ดังนั้นค่าผิดปกติจึงมีผลกระทบน้อย

พลังที่แท้จริงของ centroid คือมันเปลี่ยนปัญหาการจำแนกให้กลายเป็นปัญหา การวัดระยะห่างเชิงพื้นที่ คุณสามารถแสดงหมวดหมู่เป็นภาพในรูปแบบพื้นที่ 384 มิติ (หรือใน 2D/3D หลังจากการลดมิติด้วย PCA/t-SNE)

การแสดงภาพ centroid แบบ 3D

ในทางปฏิบัติ นี่คือลักษณะของ centroid การจำแนกในพื้นที่ embedding แต่ละจุดคือข้อความตัวอย่างที่ถูกฉายลงใน 3D ผ่าน PCA (384 มิติดั้งเดิมถูกลดลงเหลือ 3 เพื่อการแสดงภาพ) จุดสีน้ำเงินคือข้อความไร้สาระ (futile) จุดสีเหลืองคือข้อความน่าสนใจ (interesting) เพชรขนาดใหญ่สองเม็ดคือ centroid ที่คำนวณได้ -- ค่าเฉลี่ยของแต่ละกลุ่ม เลื่อนเมาส์ไปเหนือจุดเพื่อดูข้อความต้นฉบับของตัวอย่าง

ตัวอย่างสองอย่างแสดงเป็นสีแดง: "lol" (จำแนกเป็น futile) และ "i feel sad today" (จำแนกเป็น interesting) "lol" ตกอยู่ในกลุ่มเมฆสีน้ำเงินของข้อความไร้สาระ ในขณะที่ "i feel sad today" อยู่ทางด้านจุดสีเหลือง การแยกยังคงมองเห็นได้แม้หลังจากลดลงเหลือ 3 มิติ (อธิบายความแปรปรวนทั้งหมดได้เพียง 14.7%) ในมิติ 384 ขอบเขตจะชัดเจนกว่ามาก

Centroid ของข้อความอินพุตเคลื่อนที่ผ่านพื้นที่นี้ขึ้นอยู่กับเนื้อหาของมัน การจำแนก FUTILE/INTERESTING เป็นเพียงการวัดว่า centroid ใดใกล้กว่าโดยใช้ความคล้ายคลึงแบบโคไซน์ สิ่งนี้ช่วยให้เราแสดงแต่ละข้อความเป็นจุดในพื้นที่หลายมิติ โดยแต่ละมิติสอดคล้องกับคุณสมบัติเชิงความหมาย


สิ่งนี้เปลี่ยนแปลงอะไรในทางปฏิบัติ

ผู้ใช้ไม่เห็นชั้นต่างๆ centroid หรือการปรับ temperature แต่พวกเขารู้สึกถึงผลกระทบ:

  • คำตอบที่เร็วขึ้น สำหรับข้อความง่ายๆ (โมเดลเล็กเร็วกว่า 2 เท่าและจัดการทราฟฟิก 70%)
  • โทนที่ปรับตัว: หากคุณรำคาญ บอทจะ "รับรู้" ความหงุดหงิดและปรับสไตล์ของมัน
  • ความสอดคล้องข้ามแพลตฟอร์ม: บอท Matrix และบอท Discord ใช้สมองเดียวกันและสถานะทางอารมณ์เดียวกัน
  • ไม่มี "โหมดผู้ช่วย": fine-tune + few-shot + การจัดเส้นทางอย่างชาญฉลาดช่วยหลีกเลี่ยงคำตอบที่ฟังดูเป็นองค์กร

การเพิ่มชุดฝึกของโมเดลเล็กเป็น 200,000 ตัวอย่างยิ่งเสริมผลกระทบเหล่านี้: โมเดลจับความหลากหลายของบทสนทนา Discord ได้ดีขึ้นโดยไม่สูญเสียความยืดหยุ่นที่ few-shot priming มอบให้


โครงสร้างพื้นฐานทั้งหมด

นี่คือบริการที่กำลังทำงานอยู่ในปัจจุบัน:

บริการ เทคโนโลยี พอร์ต บทบาท
Pixieglow TypeScript (Bun) -- Adapter ของ Matrix
Jade TypeScript (esbuild) -- Adapter ของ Discord
Emerald TypeScript (Bun) 3126 (WebSocket) สมอง / การตัดสินใจ
Sapphire Python (FastAPI) 3123 (HTTP) ตัวจำแนก + อารมณ์
Krystal small llama.cpp (PM2) 3124 โมเดลเล็ก (1.5B, futile)
Krystal large llama.cpp (PM2) 3125 โมเดลใหญ่ (3B+, interesting)

การพึ่งพากันระหว่างบริการเป็นแบบทิศทางเดียว: adapter พึ่งพา Emerald, Emerald พึ่งพา Sapphire, Sapphire พึ่งพา Krystal ไม่มีวงจร แต่ละบริการสามารถรีสตาร์ทได้อย่างอิสระ


บทสรุป

การแยก Luna Protocol เป็นสี่ชั้นไม่ใช่แค่การฝึกฝนด้านสถาปัตยกรรม มันเป็นการตอบสนองต่อข้อจำกัดที่เป็นรูปธรรม: ความไม่สามารถรองรับ Matrix การขาดการรับรู้ทางอารมณ์ และการไม่มีลำดับความสำคัญของข้อความอย่างชาญฉลาด

วันนี้ ระบบมีความแข็งแกร่งมากขึ้น (การล่มของ LLM ไม่ทำให้บอทตายไปด้วย) ขยายได้มากขึ้น (adapter ของ Telegram หรือ WhatsApp จะทำตามโปรโตคอล WebSocket เดียวกัน) และ "มีชีวิต" มากขึ้น: บอทปรับพฤติกรรม โทน และแม้แต่พารามิเตอร์ของ LLM ตามสถานะทางอารมณ์ที่รับรู้ได้ของบทสนทนา

Centroid ของ embedding คือชิ้นส่วนสำคัญที่ทำให้ทั้งหมดนี้เป็นไปได้โดยไม่ต้องมีความซับซ้อนมากเกินไป: ไม่มีเครือข่ายนิวรอลที่ฝึกแล้ว ไม่มีไปป์ไลน์ข้อมูลที่มีป้ายกำกับ มีเพียงค่าเฉลี่ยของเวกเตอร์และความคล้ายคลึงแบบโคไซน์ มันเป็นเทคนิคที่เรียบง่าย มีประสิทธิภาพอย่างเหลือเชื่อ และถูกประเมินค่าต่ำเกินไปอย่างน่าเสียดาย

ทรัพยากร ลิงก์
เว็บไซต์โปรเจกต์ protocol-luna.github.io
Pixieglow protocol-luna/pixieglow
Emerald protocol-luna/emerald
Sapphire protocol-luna/sapphire
Krystal protocol-luna/krystal
บทความที่ 1: บอท Discord Luna Protocol: ฉันสร้างบอท Discord อัตโนมัติ
บทความที่ 2: การ fine-tune Luna Protocol: ทำไมฉันถึง fine-tune โมเดล 1.5B

Related Articles