GitHub avatar

Fox's Blog

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

Luna Protocol went from a monolith to a four-layer architecture: adapters, brain, emotion classifier, and inference. On the menu: 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 then. What used to be a monolith -- a single Node.js process handling the Discord bot, the behavior, and the LLM calls -- has turned into four independent layers, each with its own responsibility, its own language, and its own lifecycle.

This split brought unexpected benefits: sharing "brains" across multiple platforms, an emotion classification system that dynamically tunes the LLM's parameters, and smart routing of messages between two models based on the perceived importance of the conversation.

The evolution didn't happen all at once -- it followed an organic path. I first split the server/ folder out of the bot's 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. Next came Sapphire, introducing a GENERIC/SEMANTIC classification with DistilBERT -- but the results weren't convincing, so I switched to embedding centroids, which are more malleable for enriching examples and more accurate; the classification became FUTILE/INTERESTING. I eventually added valence and arousal centroids to regulate the LLM's temperature and repeat penalty. Finally, I removed all the redundant code between Jade and Pixieglow by creating Emerald, the shared brain, turning Jade and Pixieglow into simple socket-driven clients.

Alongside this, I've kept a website up to date that tracks the project's progress: protocol-luna.github.io.

This article tells the story of how and why I split these layers, what each service does exactly, and how concepts like centroids (average embedding vectors) and resentment variables (inspired by the 1970s PARRY chatbot) turned a simple Discord bot into a surprisingly coherent multi-platform system.


The problem with the monolith

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

  • The Discord connection (via the Eris library)
  • Trigger evaluation (mentions, keywords, follow-ups...)
  • Simulation of human behaviors (typos, hesitations, sleep...)
  • HTTP calls to the local LLM server (llama.cpp)
  • Session management and anti-spam
  • The TTS pipeline

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

  • Impossible to add a Matrix client without duplicating all the behavior code
  • The LLM and the bot were in the same repo: the server/ folder already existed, but you couldn't evolve one without touching the other
  • No smart classification: every message was treated the same way, whether it was a "lol" or an existential question
  • No persistent emotional state: the bot didn't "feel" anything

Splitting into layers solved all of these 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: the adapters (Pixieglow and Jade)

These are the simplest layers. Their only job is to translate events from a messaging platform 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 via WebSocket. It also handles the TTS pipeline (speech synthesis via Piper, OGG conversion, upload to Discord).
  • Pixieglow is the Matrix adapter. It uses the Matrix Client-Server HTTP API directly (no SDK), with a long-poll sync. It has 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 brain-sharing works: the same "brain" (Emerald) serves a Discord bot and a Matrix bot indifferently, with identical behaviors. The protocol is declarative: Emerald doesn't tell the adapter how to send a message, it tells it what to send (the text with a delay, possibly a burst plan, a reaction, etc.). Each adapter implements the concrete execution for its platform.

That's the strength of this architecture: to add support for Telegram, Signal, or anything else, you just need to write an adapter that implements the WebSocket protocol.


Layer 2: the brain (Emerald)

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

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

Emerald is the central service that enabled brain-sharing, and it's the one that benefited most from the split. Before, every behavior (typo, burst, hesitation) was tangled up with the Discord code. Now they live 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's running 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 an 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 large model)
  4. Few-shot injector and session manager

Centroids: the heart of classification

A centroid is a simple concept: it's the average 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", "nm just chillin u") via k-means (k=10, seed=42)
  • interesting_centroid: ~678 substantial messages (technical, personal, philosophical) via k-means (k=10, seed=42)

When a message comes in:

def classify(text, embedder, futile_centroids, interesting_centroids):
    emb = embedder.query_embed(text)            # 384-D vector of the message
    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 = "INTERESTING" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

The score per class is the maximum cosine similarity across its 10 centroids. This captures sub-types within each category -- a greeting and a farewell both land near one of the 10 futile centroids even though they're far from each other in embedding space. No training, no GPU, just k-means at startup and dot products at runtime.

Why two models?

The result of this classification decides which LLM backend is invoked:

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

The intuition is simple: a "lol" or a "nm just chillin u" doesn't deserve to invoke an 8-billion-parameter model. The small fine-tuned Luna 1.5B model, trained on 200,000 Discord samples, is more than enough for light exchanges. On the other hand, a question about life, a confession, or a technical debate gets routed to the large model, which can produce a richer response.

This economical routing considerably reduces the load on the LLM server: about 70% of messages are classified as FUTILE and handled by the small model, freeing up the large model for conversations that actually deserve it.

The emotional axis: valence and arousal

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

There are four emotional 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 is computed as a difference of similarities 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 altered its responses. For example, a "scared" PARRY would respond more aggressively.

Sapphire does the same thing, but with continuous variables and a more elegant method: the LLM's sampling parameters are adjusted 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 responses
0.0 (neutral) 0.70 Default creativity
+1.0 (excited) 1.00 Maximum randomness, surprising responses

When someone is excited or upset (high arousal), the temperature goes up. The model produces more varied, more creative, sometimes more chaotic responses -- like a human who "gets carried away." When the conversation is calm, the temperature drops, and responses become more measured.

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 Low penalty, allows repetition

The more negative the conversation, the more the model is pushed to avoid repeating itself -- like someone searching for words in a tense argument. The more positive the conversation, the more the model can afford redundant statements, like a relaxed conversation.

Cumulative emotional state

These scores don't just apply to the immediate message. An EmotionState maintains 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 of 0.85 means that 85% of the previous state is kept at each message, with 15% of the new signal integrated. This creates an emotional memory that smooths out sudden swings: a single negative message doesn't make the bot "sad," but a series of negative messages gradually drifts its mood.

In practice: if someone starts a conversation very excitedly (arousal=+0.8), the temperature stays high for several exchanges, even if the following messages are calmer. The emotion takes time to come back down -- like a human who stays "heated" after an argument.


Layer 4: inference (Krystal)

Krystal is the lowest layer: a wrapper around llama.cpp that exposes 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 pre-compiled llama-server processes, launched with taskset for CPU pinning.

The Luna model's fine-tune has also evolved since the second article: it's now trained on 200,000 samples (up from 50,000 previously), still starting 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: broaden the model's stylistic range without losing the flexibility that makes few-shot priming so effective.


The full picture: a message in transit

Here's what actually happens when someone sends "i'm really sad today" on Discord:

  1. Jade receives the message via the Discord Gateway API. It converts it into a MessageEvent and sends it to Emerald over WebSocket.
  2. Emerald evaluates the trigger (mention? name? keyword?). It's a direct mention. It computes a focus delay, checks the cooldown, the session, the topic fatigue. It decides to respond 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 interesting centroid than the futile centroid (diff = +0.31) -> INTERESTING
    • Emotion: negative valence (-0.42), moderate arousal (0.35)
    • Routing: direction KRYSTAL_SEMANTIC_URL (port 3125, large model)
    • Sampling parameters: temperature = 0.80 (arousal increased), repeat_penalty = 1.19 (negative valence)
    • The session's emotional state is updated with these values
  4. Krystal (large instance) generates the response with the emotionally-adjusted parameters and sends it back to Sapphire.
  5. Sapphire streams the response to Emerald along with metadata (label, valence, arousal, debug statistics).
  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 the generation.

All of this in under 3 seconds for the user.


Centroids: why they're 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 transformation that maps inputs to probabilities. It's accurate, but:

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

A centroid, on the other hand, is an average vector of example embeddings. Classification is done by cosine similarity to that average vector. Advantages:

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

The real power of centroids is that they turn 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 centroid visualization

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

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

The centroid of the input message wanders through this space depending on its content. FUTILE/INTERESTING classification simply consists of measuring which centroid is closer by cosine similarity. This lets us represent each message as a point in a multi-dimensional space, with each dimension corresponding to a semantic property.


What this changes in practice

Users don't see the layers, the centroids, or the temperature adjustments. But they feel the effects:

  • Faster responses for simple messages (the small model is 2x faster and handles 70% of the traffic)
  • Adaptive tone: if you're annoyed, the bot "senses" the irritation and adapts its style
  • Cross-platform consistency: a Matrix bot and a Discord bot share the same brain and the same emotional state
  • No "assistant mode": the fine-tune + few-shot + smart routing avoids corporate-sounding responses

Bumping the small model's training set to 200k samples further reinforced these effects: the model better captures the diversity of Discord conversations without losing the malleability that few-shot priming provides.


The complete 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 Large model (3B+, interesting)

Dependencies between services are unidirectional: the adapter depends on Emerald, Emerald depends on Sapphire, Sapphire depends on Krystal. No cycles. Each service can be restarted independently.


Conclusion

Splitting Luna Protocol into four layers wasn't just an architectural exercise. It was a response to concrete limitations: the inability to support Matrix, the lack of emotional awareness, and the absence of 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, its tone, and even the LLM's parameters to the perceived emotional state of the conversation.

Embedding centroids are the key piece that makes all of this possible without excessive complexity: no trained neural network, no labeled data pipeline, just vector averages and cosine similarities. It's a simple technique, incredibly effective, and terribly underrated.

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 -- 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 -- mais 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
  • Pas de classification intelligente : chaque message était traité de la même façon, qu'il s'agisse d'un "lol" ou d'une question existentielle
  • Pas d'état émotionnel persistant : 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. Pas d'entraînement, pas de GPU, juste du k-means au démarrage et des produits scalaires à l'exécution.

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 :

  • Pas d'entraînement : 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 -- pas de 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
  • Pas de "mode assistant" : 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. Pas de cycle. 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 从一个单体架构演变为四层架构:适配器、大脑、情感分类器和推理层。本文将介绍嵌入质心、有趣/无聊路由,以及根据效价和唤醒度调整 LLM 参数的方法。

Luna Protocol:共享大脑、情感分类,以及有趣/无聊的路由机制

在前两篇文章中,我把 Luna Protocol 介绍成一个拥有复杂行为系统和微调模型的单一 Discord 机器人。但此后架构发生了巨大的演变。曾经的单体架构——一个处理 Discord 机器人、行为逻辑和 LLM 调用的单一 Node.js 进程——如今已经变成了四个独立的层,每一层都有自己的职责、自己的语言和自己的生命周期。

这次拆分带来了意想不到的好处:跨多个平台共享"大脑"、一个能动态调整 LLM 参数的情感分类系统,以及根据对话感知重要性在两个模型之间智能路由消息的机制。

这次演进并非一蹴而就——而是遵循了一条有机的路径。我首先把 server/ 文件夹从机器人仓库中拆分出来,创建了 Krystal,并保留 Jade 作为 Discord 适配器。接着我复用了 Jade 的 llm-core 和事件总线,创建了 Pixieglow(Matrix 适配器)。然后是 Sapphire 的加入,它引入了基于 DistilBERT 的 GENERIC/SEMANTIC 分类——但效果并不理想,于是我转向了嵌入质心,这种方法在丰富示例方面更灵活,也更精确;分类变成了无聊/有趣。最终我加入了效价和唤醒度质心,用来调节 LLM 的温度和重复惩罚。最后,我通过创建共享大脑 Emerald,消除了 Jade 和 Pixieglow 之间所有冗余代码,把它们变成了简单的、由 socket 驱动的客户端。

与此同时,我一直维护着一个网站,用来追踪项目的进展:protocol-luna.github.io。

本文将讲述我为什么以及如何拆分这些层,每个服务具体做什么,以及质心(嵌入向量的平均值)和怨恨变量(灵感来自 1970 年代的聊天机器人 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)

每一层都可以独立重启、更新或替换。


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

这是最简单的一层。它们唯一的工作就是把某个消息平台的事件,翻译成发往 Emerald 的标准化协议:

  • Jade 是 Discord 适配器。它使用 Eris 库连接 Discord,并通过 WebSocket 把消息转发给 Emerald。它还负责 TTS 流水线(通过 Piper 进行语音合成、转换为 OGG、上传到 Discord)。
  • Pixieglow 是 Matrix 适配器。它直接使用 Matrix 的 Client-Server HTTP API(不依赖 SDK),采用长轮询同步。它没有 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 协议的适配器即可。


第二层:大脑(Emerald)

Emerald 是中枢决策服务。它通过 WebSocket 在 3126 端口监听,负责:

  • 触发条件评估:提及、私信、名字、关键词、跟进消息、随机
  • 行为模拟:专注延迟、打字错误、犹豫、遗忘、连发消息、话题疲劳
  • 睡眠周期:sleep / slow / short 三种模式
  • 会话管理:冷却时间、会话限制、反垃圾信息
  • 向 Sapphire 的路由:发送消息、接收流式返回的响应

Emerald 是使共享成为可能的核心服务,也是从这次拆分中受益最多的一个。以前,每种行为(打字错误、连发消息、犹豫)都与 Discord 代码紧密纠缠在一起。现在它们都被放在 behavior/ 目录下的专用模块中:

emerald/src/behavior/
  burst.ts         -- 连发消息的规划
  mannerisms.ts    -- 延迟、犹豫、反应、遗忘
  sleep.ts         -- 睡眠时间表的评估
  typo.ts          -- 打字错误模拟 (AZERTY/QWERTY)

大脑并不知道自己运行在哪个平台上。它接收一个带有 clientId("jade" 或 "pixieglow")的 MessageEvent,做出决定,并返回一条命令。剩下的事情由适配器负责。


第三层:情感分类器(Sapphire)

Sapphire 是技术上最有趣的服务。它是一个用 Python 和 FastAPI 编写的 LLM 中间件,承担着四个关键角色:

  1. 通过嵌入质心实现的无聊 / 有趣二元分类器
  2. 通过质心实现的情感评分器(效价 / 唤醒度)
  3. 面向 Krystal 的后端路由器(小模型 vs 大模型)
  4. 少样本(few-shot)注入器与会话管理器

质心:分类的核心

质心是一个简单的概念:它是一组嵌入向量的平均值。具体来说,我收集了数百条示例消息,把它们输入一个嵌入模型(BAAI/bge-small-en-v1.5,384 维),然后对得到的向量取平均值。

有两个分类质心:

  • futile_centroid:约 683 条琐碎消息("lol"、"ok"、"hello") via k-means (k=10, seed=42)
  • interesting_centroid:约 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

消息与每个质心之间的余弦相似度决定了它的类别。绝对差值则给出置信度。这种方法简单、快速(不需要 LLM 的前向传播),而且效果出奇地好。

为什么用两个模型?

这次分类的结果决定了要调用哪个 LLM 后端:

标签 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"不值得调用一个 80 亿参数的模型。经过 20 万条 Discord 样本微调的小型 Luna 1.5B 模型,处理轻松的对话已经绰绰有余。而关于人生的问题、心事倾诉或技术辩论,则会被路由到能产生更丰富回复的大模型上。

这种经济型路由大大降低了 LLM 服务器的负载:大约 70% 的消息被分类为"无聊",由小模型处理,从而把大模型解放出来,专门服务于那些真正值得的对话。

情感维度:效价与唤醒度

但这还不是全部。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]

**效价(Valence)**衡量消息是正面还是负面。**唤醒度(Arousal)**衡量它的情感强度。两者结合起来构成了情感环状模型(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 = 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

decay 值为 0.85,意味着每条消息都会保留 85% 的先前状态,并融入 15% 的新信号。这形成了一种情感记忆,能够平滑掉剧烈波动:单独一条负面消息不会让机器人"悲伤"起来,但一连串的负面消息会逐渐让它的情绪发生偏移。

在实践中:如果有人以非常兴奋的状态开始一段对话(arousal=+0.8),即使后续消息更加平静,温度也会在好几轮交流中保持在较高水平。情绪需要时间才能平复下来——就像一个人在争吵之后仍然"余怒未消"一样。


第四层:推理(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 数据集的一个子集,经过筛选,只保留了最自然、最多样化的对话。目标是:在不损失使少样本引导(few-shot priming)如此有效的灵活性的前提下,拓宽模型的风格表达范围。


完整流程:一条消息的旅程

下面是当有人在 Discord 上发送"我今天真的很难过"时具体发生的事情:

  1. Jade 通过 Discord Gateway API 接收到消息。它将消息转换为一个 MessageEvent,并通过 WebSocket 发送给 Emerald。
  2. Emerald 评估触发条件(是提及?名字?还是关键词?)。这是一次直接提及。它计算出一个专注延迟,检查冷却时间、会话状态、话题疲劳度。它决定回应,并通过 HTTP 把消息发送给 Sapphire。
  3. Sapphire 使用 bge-small-en-v1.5 对消息进行嵌入。
    • 分类:该消息更接近 interesting 质心而非 futile 质心(差值 = +0.31)-> 有趣(INTERESTING)
    • 情感:负面效价(-0.42),中等唤醒度(0.35)
    • 路由:方向为 KRYSTAL_SEMANTIC_URL(3125 端口,大模型)
    • 采样参数:温度 = 0.80(唤醒度提高所致),repeat_penalty = 1.19(负面效价所致)
    • 会话的情感状态被更新为这些数值
  4. Krystal(大模型实例)用经过情感调整的参数生成回应,并返回给 Sapphire。
  5. Sapphire 将这个回应连同元数据(标签、效价、唤醒度、调试统计信息)流式传输给 Emerald。
  6. Emerald 决定加入一句犹豫的话("哦……"),规划一次连发(2 个片段),并选择一个反应。它向 Jade 发送一个 RespondCommand。
  7. Jade 执行操作:等待初始延迟,发送带有犹豫语气的第一个片段,等待 1.5 秒,再发送第二个片段。在整个生成过程中,它会一直显示"正在输入"的提示。

这一切在用户看来,都发生在不到 3 秒的时间里。


质心:为什么它比神经网络分类器更好

选择嵌入质心而不是传统分类器(比如我之前使用的 DistilBERT)的原因,值得解释一下。

神经网络分类器学习的是各个类别之间的决策边界——通常是一种把输入映射为概率的非线性变换。它很精确,但存在以下问题:

  • 需要有标签的训练数据
  • 对分布变化(数据漂移)很敏感
  • 难以解释
  • 每添加一个新类别都需要重新训练

而质心则是一组示例嵌入的平均向量。分类过程通过与这个平均向量的余弦相似度来完成。它的优点包括:

  • 无需训练:只需计算手工挑选的示例嵌入的平均值即可
  • 易于解释:可以查看哪些示例最接近质心,从而理解"这个质心学到了什么"
  • 添加类别很简单:只需添加一个新的质心——无需重新训练
  • 鲁棒性强:质心是一种平均值,因此离群点的影响很小

质心真正的力量在于,它把一个分类问题转化成了一个空间距离度量问题。我们可以把各个类别想象成 384 维空间中的一个个区域(经过 PCA/t-SNE 降维后,也可以在 2D/3D 中可视化)。

质心的 3D 可视化

在实践中,嵌入空间中的分类质心大致是这个样子。每个点代表一条示例消息,通过 PCA 投影到 3D 空间中(原始的 384 维被降维到 3 维用于可视化)。蓝色的点是"无聊"消息,黄色的点是"有趣"消息。两个大的菱形是计算出的质心——即每组的平均值。把鼠标悬停在某个点上,即可看到该示例的原始文本。

图中用红色标出了两个示例:"lol"(被分类为无聊)和 "i feel sad today"(被分类为有趣)。"lol" 落在了"无聊"消息的蓝色云团中,而 "i feel sad today" 则位于黄色点的一侧。即使降维到 3 维之后,这种分离依然清晰可见(尽管只解释了总方差的 14.7%)。在完整的 384 维空间中,分类边界要清晰得多。

输入消息的质心会根据其内容在这个空间中游走。无聊/有趣的分类,本质上就是通过余弦相似度来判断哪个质心更近。这样一来,每条消息都可以被表示成多维空间中的一个点,每个维度对应一种语义属性。


这在实践中改变了什么

用户看不到这些层、质心,或是温度调整。但他们能感受到这些效果:

  • 更快的响应速度:对于简单消息(小模型速度快 2 倍,处理 70% 的流量)
  • 自适应的语气:如果你感到恼怒,机器人能"感受到"这种恼怒,并调整自己的表达风格
  • 跨平台一致性:Matrix 机器人和 Discord 机器人共享同一个大脑和同一个情感状态
  • 没有"助手模式":微调 + 少样本引导 + 智能路由,避免了那种公司腔调的回应

将小模型的训练样本增加到 20 万条,进一步强化了这些效果:模型能更好地捕捉 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
文章一:Discord 机器人 Luna Protocol:我打造了一个自主的 Discord 机器人
文章二:微调过程 Luna Protocol:为什么我要微调一个 1.5B 模型

Luna Protocol:脳の共有、感情分類、そして「面白い/どうでもいい」ルーティング

Luna Protocolはモノリスから4層アーキテクチャへと進化した:アダプター、brain、感情分類器、そして推論。埋め込みのセントロイド、面白い/どうでもいいルーティング、valenceとarousalによるLLMパラメータ調整を紹介する。

Luna Protocol:脳の共有、感情分類、そして「面白い/どうでもいい」ルーティング

前2本の記事では、Luna Protocolを複雑な行動システムとファインチューニング済みモデルを備えた単一のDiscordボットとして紹介した。しかしそれ以来、アーキテクチャは大きく進化した。かつてはモノリス -- Discordボット、行動、LLM呼び出しをすべて処理する単一のNode.jsプロセス -- だったものが、4つの独立したレイヤーへと変わった。それぞれが独自の責務、独自の言語、独自のライフサイクルを持つ。

この分離は予期しない利点をもたらした。複数プラットフォーム間での「脳」の共有、LLMのパラメータを動的に調整する感情分類システム、そして会話の重要度に応じて2つのモデル間でメッセージをインテリジェントにルーティングする仕組みだ。

この進化は一気に起きたわけではなく、有機的な道筋をたどった。まずserver/フォルダをボットのリポジトリから切り離し、Krystalを片方に、JadeをDiscordアダプターとして残した。次に、Jadeのllm-coreとイベントバスを再利用してPixieglow(Matrixアダプター)を作った。続いてSapphireが登場し、DistilBERTによるGENERIC/SEMANTIC分類を導入したが、結果は納得のいくものではなかったため、例の充実により柔軟で精度も高い埋め込みセントロイドに切り替えた。分類は「どうでもいい/面白い」になった。最終的に、LLMのtemperatureとrepeat penaltyを調整するためにvalence(快・不快)とarousal(覚醒度)のセントロイドを追加した。最後に、JadeとPixieglowの間の冗長なコードをすべて取り除き、共有脳であるEmeraldを作成し、JadeとPixieglowをシンプルなソケット駆動のクライアントに変えた。

並行して、プロジェクトの進捗を追うウェブサイトを更新し続けている:protocol-luna.github.io。

この記事では、なぜ・どのようにこれらのレイヤーを分割したのか、各サービスが具体的に何をしているのか、そしてセントロイド(埋め込みの平均ベクトル)やレゼントメント変数(1970年代のチャットボット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
  [ADAPTERS]      Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [BRAIN]         Emerald (WebSocket, ポート 3126)
      |
      v
  [CLASSIFIER]    Sapphire (HTTP, ポート 3123)
      |
      v
  [INFERENCE]     Krystal (llama.cpp, ポート 3124 / 3125)

各レイヤーは独立して再起動、更新、置き換えができる。


レイヤー1:アダプター(PixieglowとJade)

最もシンプルなレイヤーだ。彼らの唯一の仕事は、メッセージングプラットフォームのイベントをEmeraldへの標準化されたプロトコルに変換することである。

  • JadeはDiscordアダプターだ。Erisライブラリを使ってDiscordに接続し、WebSocket経由でメッセージをEmeraldに転送する。TTSパイプライン(Piperによる音声合成、OGG変換、Discordへのアップロード)も処理する。
  • PixieglowはMatrixアダプターだ。SDKを使わずMatrixのClient-Server HTTP APIを直接利用し、long-pollによる同期を行う。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は脳の共有を可能にした中枢サービスであり、分離から最も恩恵を受けたものでもある。以前は、各行動(誤字、バースト、ためらい)はDiscordのコードと絡み合っていた。今ではbehavior/以下の専用モジュールに収まっている。

emerald/src/behavior/
  burst.ts         -- バーストメッセージの計画
  mannerisms.ts    -- 遅延、ためらい、リアクション、忘却
  sleep.ts         -- 睡眠スケジュールの評価
  typo.ts          -- 誤字のシミュレーション (AZERTY/QWERTY)

脳は自分がどのプラットフォーム上で動いているか知らない。clientId("jade"または"pixieglow")を含むMessageEventを受け取り、決定を下し、コマンドを返す。残りはアダプターが処理する。


レイヤー3:感情分類器(Sapphire)

Sapphireは技術的に最も興味深いサービスだ。Python + FastAPIで書かれたLLMミドルウェアであり、4つの重要な役割を担う。

  1. 埋め込みセントロイドによる**「どうでもいい/面白い」の2値分類器**
  2. セントロイドによる感情スコアラー(valence / arousal)
  3. Krystalへのバックエンドルーター(小型モデル vs 大型モデル)
  4. Few-shotインジェクターとセッションマネージャー

セントロイド:分類の核心

セントロイドはシンプルな概念だ。埋め込みベクトルの集合の平均である。具体的には、数百のメッセージ例を集め、それらを埋め込みモデル(BAAI/bge-small-en-v1.5、384次元)に通し、得られたベクトルを平均した。

2つの分類セントロイドがある。

  • futile_centroid:約683件のありふれたメッセージ("lol"、"ok"、"hello") via k-means (k=10, seed=42)
  • interesting_centroid:約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

メッセージと各セントロイドとのコサイン類似度がカテゴリを決定する。絶対差が確信度を示す。LLMのforward passが不要でシンプル、高速、そして驚くほど効果的だ。

なぜ2つのモデルなのか

この分類結果によって、どのLLMバックエンドを呼び出すかが決まる。

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

直感はシンプルだ。「lol」や「nm just chillin u」に80億パラメータのモデルを呼び出す価値はない。20万件のDiscordサンプルで訓練された小型のファインチューニング済みLuna 1.5Bモデルで、軽いやり取りには十分すぎるほどだ。一方、人生についての質問、打ち明け話、技術的な議論は、より豊かな応答を生成できる大型モデルにルーティングされる。

この経済的なルーティングにより、LLMサーバーの負荷は大幅に削減される。メッセージの約70%が「どうでもいい」に分類され小型モデルで処理されるため、大型モデルは本当に価値のある会話のために解放される。

感情の軸:valenceとarousal

だがそれだけではない。Sapphireは同じセントロイドの仕組みを独立した軸で使い、メッセージの感情を評価する。

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]

Valenceはメッセージがポジティブかネガティブかを測る。Arousalはその感情的な強度を測る。両者を合わせると、情動の円環モデル(Russell, 1980)を形成する -- 1972年のチャットボットPARRYにインスピレーションを与えたのと同じ心理学モデルだ。

レゼントメント変数:感情がLLMをどう制御するか

ここでPARRYからのインスピレーションが具体的な形になる。PARRY(1972年にKenneth Colbyが作成)は、妄想性の患者をシミュレートするために設計されたチャットボットだった。恐怖、怒り、不信といった内部変数を持ち、それらが応答を変化させていた。例えば「怯えた」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%が統合されることを意味する。これにより、急激な変動を滑らかにする感情的な記憶が生まれる。1件のネガティブなメッセージだけではボットは「悲しく」ならないが、一連のネガティブなメッセージは徐々にその機嫌を変化させていく。

実際には、誰かがとても興奮した状態で会話を始めると(arousal=+0.8)、その後のメッセージがより落ち着いていても、temperatureは数回のやり取りにわたって高いままだ。感情が落ち着くには時間がかかる -- 口論の後もしばらく「熱くなったまま」でいる人間のように。


レイヤー4:推論(Krystal)

Krystalは最下層のレイヤーだ。OpenAI互換のAPI(/v1/chat/completions)を公開するllama.cppのラッパーである。2つのPM2インスタンスとして動作する。

  • krystal-small:ファインチューニング済みのLuna 1.5Bモデル、ポート3124、CPUアフィニティ0
  • krystal-large:Hermes 3Bモデル、ポート3125、CPUアフィニティ0,1

両インスタンスとも事前コンパイルされたllama-serverプロセスで、CPUピンニングのためにtasksetで起動されている。

Lunaモデルのファインチューニングも第2記事以降進化している。今では以前の5万件に対し20万件のサンプルで訓練され、依然としてQwen2.5-1.5B-InstructをベースにQLoRAで行っている。この20万件は、Discord-Dialoguesデータセットのサブセットで、最も自然で多様な会話だけを残すようフィルタリングされている。目標は、few-shot primingをこれほど効果的にしている柔軟性を失うことなく、モデルのスタイルの幅を広げることだ。


全体の流れ:メッセージの通過

Discordで誰かが「今日は本当に悲しい」と送ったとき、具体的に何が起きるかを見てみよう。

  1. JadeがDiscord Gateway API経由でメッセージを受信する。それをMessageEventに変換し、WebSocket経由でEmeraldに送信する。
  2. Emeraldがトリガーを評価する(メンションか?名前か?キーワードか?)。これは直接のメンションだ。集中の遅延を計算し、クールダウン、セッション、話題疲れを確認する。応答することを決定し、HTTP経由でメッセージをSapphireに送る。
  3. Sapphireがbge-small-en-v1.5でメッセージを埋め込む。
    • 分類:メッセージはfutileセントロイドよりinterestingセントロイドに近い(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(大型インスタンス)が感情的に調整されたパラメータで応答を生成し、Sapphireに返す。
  5. Sapphireがメタデータ(ラベル、valence、arousal、デバッグ統計)とともに応答をEmeraldにストリーミングする。
  6. Emeraldがためらい(「あ...」)を加えることを決め、バースト(2つの断片)を計画し、リアクションを選ぶ。RespondCommandをJadeに送る。
  7. Jadeが実行する:初期の遅延を待ち、ためらいを含む最初の断片を送り、1.5秒待ち、2つ目の断片を送る。生成中はずっと入力中インジケーターを表示する。

これらすべてがユーザーにとって3秒未満で完了する。


セントロイド:なぜニューラル分類器より優れているのか

従来の分類器(以前使っていたDistilBERTなど)に対して埋め込みセントロイドを選んだ理由は説明に値する。

ニューラル分類器はクラス間の決定境界を学習する -- 典型的には、入力を確率へ写像する非線形変換だ。精度は高いが、以下の欠点がある。

  • ラベル付きの訓練データが必要
  • 分布の変化(データドリフト)に敏感
  • 解釈が難しい
  • 新しいクラスを追加するには再訓練が必要

一方セントロイドは、例の埋め込みの平均ベクトルである。分類はこの平均ベクトルとのコサイン類似度によって行われる。利点は以下の通り。

  • 訓練不要:手で選んだ例の埋め込みの平均を計算するだけでよい
  • 解釈しやすい:どの例がセントロイドに最も近いかを見ることで、「セントロイドが何を学んだか」を理解できる
  • クラスの追加:新しいセントロイドを追加するだけで、再訓練は不要
  • 頑健:セントロイドは平均なので、外れ値の影響が小さい

セントロイドの真の力は、分類問題を空間的な距離測定の問題に変えることにある。カテゴリを384次元空間内の領域として(あるいはPCA/t-SNEによる次元削減後に2D/3Dで)可視化できる。

セントロイドの3D可視化

実際には、埋め込み空間における分類セントロイドはこのように見える。各点は例のメッセージであり、PCAによって3Dに投影されている(可視化のため、元の384次元は3次元に削減されている)。青い点は「どうでもいい」メッセージ、黄色い点は「面白い」メッセージだ。2つの大きなダイヤモンドが計算されたセントロイド -- 各グループの平均である。点にマウスを乗せると、その例の元のテキストが表示される。

赤で示された2つの例がある:「lol」(どうでもいいに分類)と「i feel sad today」(面白いに分類)だ。「lol」は「どうでもいい」の青い雲の中に落ち、「i feel sad today」は黄色い点の側に位置する。3次元に削減した後でも分離は視認できる(全分散のうちわずか14.7%しか説明されていないにもかかわらずだ)。384次元では、境界ははるかに明確になる。

入力メッセージのセントロイドは、その内容に応じてこの空間内を動き回る。「どうでもいい/面白い」の分類は、単にどちらのセントロイドがコサイン類似度で近いかを測るだけだ。こうして各メッセージを多次元空間内の点として表現でき、各次元が意味的な性質に対応する。


実際に何が変わるのか

ユーザーはレイヤーもセントロイドもtemperatureの調整も目にすることはない。しかしその効果は感じ取れる。

  • シンプルなメッセージへの高速な応答(小型モデルは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のパラメータまで調整する。

埋め込みセントロイドは、過剰な複雑さなしにこれらすべてを可能にする鍵となる要素だ -- 訓練済みニューラルネットワークもなく、ラベル付きデータのパイプラインもなく、あるのはベクトルの平均とコサイン類似度だけ。シンプルでありながら驚くほど効果的で、ひどく過小評価されている技術だ。

リソース リンク
プロジェクトのウェブサイト 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은 모놀리스에서 4계층 아키텍처로 진화했다: 어댑터, 브레인, 감정 분류기, 추론. 임베딩 센트로이드, 흥미로운/사소한 메시지 라우팅, valence와 arousal에 따른 LLM 파라미터 튜닝을 소개한다.

Luna Protocol: 공유 브레인, 감정 분류, 그리고 흥미로운/사소한 라우팅

이전 두 기사에서 나는 Luna Protocol을 복잡한 행동 시스템과 파인튜닝된 모델을 갖춘 단일 Discord 봇으로 소개했다. 하지만 그 이후로 아키텍처는 크게 진화했다. 예전에는 모놀리스 -- Discord 봇, 행동, LLM 호출을 모두 처리하는 하나의 Node.js 프로세스 -- 였던 것이 이제는 네 개의 독립된 계층으로 나뉘었다. 각 계층은 저마다의 책임, 언어, 라이프사이클을 갖는다.

이 분리는 예상치 못한 이점을 가져왔다: 여러 플랫폼 간의 "브레인" 공유, LLM의 파라미터를 동적으로 조정하는 감정 분류 시스템, 그리고 대화의 체감 중요도에 따라 두 모델 사이에서 메시지를 스마트하게 라우팅하는 기능이다.

이 진화는 한 번에 일어난 것이 아니라 유기적인 경로를 따랐다. 나는 먼저 봇 저장소에서 server/ 폴더를 분리해 한쪽에 Krystal을 만들고, Jade를 Discord 어댑터로 남겼다. 그다음 Jade의 llm-core와 이벤트 버스를 재사용해 Pixieglow(Matrix 어댑터)를 만들었다. 이어서 Sapphire가 등장해 DistilBERT를 이용한 GENERIC/SEMANTIC 분류를 도입했지만 결과가 만족스럽지 않았고, 그래서 예시를 보강하기에 더 유연하고 정확한 임베딩 센트로이드로 전환했다. 분류는 FUTILE/INTERESTING(사소함/흥미로움)이 되었다. 이후 LLM의 temperature와 repeat penalty를 조절하기 위해 valence(정서가)와 arousal(각성도) 센트로이드를 추가했다. 마지막으로 Jade와 Pixieglow 사이의 중복 코드를 모두 제거하기 위해 공유 브레인인 Emerald를 만들었고, Jade와 Pixieglow는 단순한 소켓 기반 클라이언트가 되었다.

이와 함께 프로젝트의 진행 상황을 추적하는 웹사이트를 계속 업데이트하고 있다: protocol-luna.github.io.

이 글에서는 내가 이 계층들을 왜, 어떻게 분리했는지, 각 서비스가 정확히 무엇을 하는지, 그리고 센트로이드(평균 임베딩 벡터)와 레젠트먼트 변수(1970년대 챗봇 PARRY에서 영감을 받음) 같은 개념이 어떻게 단순한 Discord 봇을 놀랍도록 일관된 멀티플랫폼 시스템으로 바꿨는지 이야기한다.


모놀리스의 문제점

처음에는 Luna Protocol이 하나의 Node.js 프로세스 안에 다 들어갔다. 코드는 다음을 처리했다:

  • Discord 연결(Eris 라이브러리 사용)
  • 트리거 평가(멘션, 키워드, 후속 발화 등)
  • 인간 행동 시뮬레이션(오타, 망설임, 수면 등)
  • 로컬 LLM 서버(llama.cpp)로의 HTTP 호출
  • 세션 관리와 스팸 방지
  • TTS 파이프라인

모든 것이 같은 프로세스에서 살면서 타입이 지정된 이벤트 버스(TypedBus)로 통신했다. 작동은 했지만 한계가 있었다:

  • Matrix 클라이언트 추가가 불가능: 모든 행동 코드를 중복하지 않고서는 불가능했다
  • LLM과 봇이 같은 저장소에 있었다: server/ 폴더가 이미 존재했지만, 한쪽을 건드리지 않고서는 다른 쪽을 발전시킬 수 없었다
  • 스마트한 분류가 없었다: "lol"이든 실존적 질문이든 모든 메시지가 똑같이 취급되었다
  • 지속적인 감정 상태가 없었다: 봇은 아무것도 "느끼지" 않았다

계층으로 분리하면서 이 모든 문제가 해결되었다.


네 개의 계층

Luna Protocol의 현재 아키텍처는 4단계 깔때기 형태로 구성되어 있다:

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: 어댑터 (Pixieglow와 Jade)

가장 단순한 계층이다. 이들의 유일한 역할은 메시징 플랫폼의 이벤트를 Emerald 쪽 표준 프로토콜로 변환하는 것이다:

  • Jade는 Discord 어댑터다. Eris 라이브러리를 사용해 Discord에 연결하고, WebSocket을 통해 메시지를 Emerald로 전달한다. 또한 TTS 파이프라인(Piper를 통한 음성 합성, OGG 변환, Discord 업로드)도 처리한다.
  • Pixieglow는 Matrix 어댑터다. SDK 없이 Matrix Client-Server HTTP API를 직접 사용하며, 롱폴 동기화를 이용한다. 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 프로토콜을 구현하는 어댑터를 하나 작성하기만 하면 된다.

브레인은 자신이 어떤 플랫폼에서 실행되고 있는지 모른다. clientId("jade" 또는 "pixieglow")가 포함된 MessageEvent를 받아 결정을 내리고 명령을 반환한다. 나머지는 어댑터가 처리한다.


계층 2: 브레인 (Emerald)

Emerald는 중앙 의사결정 서비스다. 포트 3126에서 WebSocket으로 대기하며 다음을 처리한다:

  • 트리거 평가: 멘션, DM, 이름, 키워드, 후속 발화, 무작위
  • 행동 시뮬레이션: 집중 지연, 오타, 망설임, 건망증, 버스트, 주제 피로도
  • 수면 주기: sleep / slow / short 모드
  • 세션 관리: 쿨다운, 세션 제한, 스팸 방지
  • Sapphire로의 라우팅: 메시지 전송, 스트리밍된 응답 수신

Emerald는 브레인 공유를 가능하게 한 중앙 서비스이며, 분리로 가장 큰 혜택을 본 서비스이기도 하다. 예전에는 모든 행동(오타, 버스트, 망설임)이 Discord 코드와 뒤엉켜 있었다. 이제는 behavior/ 아래 전용 모듈에서 관리된다:

emerald/src/behavior/
  burst.ts         -- 버스트 메시지 계획
  mannerisms.ts    -- 지연, 망설임, 리액션, 건망증
  sleep.ts         -- 수면 스케줄 평가
  typo.ts          -- 오타 시뮬레이션 (AZERTY/QWERTY)

계층 3: 감정 분류기 (Sapphire)

Sapphire는 기술적으로 가장 흥미로운 서비스다. Python과 FastAPI로 작성된 LLM 미들웨어로, 네 가지 핵심 역할을 수행한다:

  1. 임베딩 센트로이드를 이용한 이진 FUTILE / INTERESTING 분류기
  2. 센트로이드를 이용한 감정 점수 산출기 (valence / arousal)
  3. Krystal로의 백엔드 라우터 (소형 모델 vs 대형 모델)
  4. Few-shot 주입기 및 세션 관리자

센트로이드: 분류의 핵심

센트로이드는 단순한 개념이다: 임베딩 벡터 집합의 평균이다. 구체적으로 나는 수백 개의 예시 메시지를 모아 임베딩 모델(BAAI/bge-small-en-v1.5, 384차원)에 통과시킨 뒤 결과 벡터들을 평균했다.

두 개의 분류 센트로이드가 있다:

  • futile_centroid: 약 683개의 사소한 메시지("lol", "ok", "hello") via k-means (k=10, seed=42)
  • interesting_centroid: 약 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

메시지와 각 센트로이드 사이의 코사인 유사도가 카테고리를 결정한다. 절댓값 차이는 신뢰도를 나타낸다. 단순하고, 빠르며(LLM forward pass가 필요 없다), 놀랍도록 효과적이다.

왜 두 개의 모델인가?

이 분류 결과는 어떤 LLM 백엔드가 호출될지를 결정한다:

라벨 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" 같은 메시지는 80억 파라미터 모델을 불러올 가치가 없다. 20만 개의 Discord 샘플로 훈련된 소형 파인튜닝 Luna 1.5B 모델이면 가벼운 대화에는 충분하고도 남는다. 반대로 삶에 대한 질문, 고백, 기술적 논쟁은 더 풍부한 응답을 낼 수 있는 대형 모델로 라우팅된다.

이 경제적인 라우팅은 LLM 서버의 부하를 상당히 줄여준다: 약 70%의 메시지가 FUTILE로 분류되어 소형 모델이 처리하고, 그 덕분에 대형 모델은 실제로 그럴 가치가 있는 대화에 집중할 수 있다.

감정 축: valence와 arousal

이게 전부가 아니다. 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]

Valence는 메시지가 긍정적인지 부정적인지를 측정한다. Arousal은 감정적 강도를 측정한다. 이 둘이 합쳐져 정서의 원형 모델(circumplex model of affect, Russell, 1980)을 이루는데, 이는 1972년 챗봇 PARRY에 영감을 준 것과 같은 심리학 모델이다.

레젠트먼트 변수: 감정이 LLM을 제어하는 방식

바로 여기서 PARRY의 영감이 구체화된다. PARRY(1972년 Kenneth Colby가 만듦)는 편집증 환자를 시뮬레이션하도록 설계된 챗봇이었다. 두려움, 분노, 불신 같은 내부 변수를 가지고 있었고, 이것이 응답을 바꾸었다. 예를 들어 "겁먹은" 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

0.85의 decay는 매 메시지마다 이전 상태의 85%가 유지되고, 새 신호의 15%가 반영된다는 뜻이다. 이는 급격한 변화를 완화하는 감정 기억을 만든다: 단 하나의 부정적 메시지가 봇을 "슬프게" 만들지는 않지만, 부정적 메시지가 연속되면 기분이 점차 그쪽으로 기울어진다.

실제로: 누군가 매우 흥분한 상태로 대화를 시작하면(arousal=+0.8), 이후 메시지가 더 차분하더라도 여러 차례의 교환 동안 temperature는 높게 유지된다. 감정은 다시 가라앉는 데 시간이 걸린다 -- 논쟁 후에도 한동안 "흥분 상태"가 유지되는 사람처럼.


계층 4: 추론 (Krystal)

Krystal은 가장 하위 계층으로, OpenAI 호환 API(/v1/chat/completions)를 노출하는 llama.cpp의 래퍼다. 두 개의 PM2 인스턴스로 실행된다:

  • krystal-small: 파인튜닝된 Luna 1.5B 모델, 포트 3124, CPU affinity 0
  • krystal-large: Hermes 3B 모델, 포트 3125, CPU affinity 0,1

두 인스턴스 모두 사전 컴파일된 llama-server 프로세스이며, CPU 고정을 위해 taskset으로 실행된다.

Luna 모델의 파인튜닝도 두 번째 글 이후로 발전했다: 이제는 (이전 5만 개에서 늘어난) 20만 개의 샘플로 훈련되며, 여전히 Qwen2.5-1.5B-Instruct에서 QLoRA를 통해 시작한다. 이 20만 개 샘플은 Discord-Dialogues 데이터셋의 일부로, 가장 자연스럽고 다양한 대화만 남도록 필터링되었다. 목표는 few-shot 프라이밍을 그토록 효과적으로 만드는 유연성을 잃지 않으면서 모델의 문체 범위를 넓히는 것이다.


전체 그림: 메시지가 지나가는 경로

Discord에서 누군가 "i'm really sad today"라고 보냈을 때 실제로 일어나는 일은 다음과 같다:

  1. Jade가 Discord Gateway API를 통해 메시지를 받는다. 이를 MessageEvent로 변환해 WebSocket으로 Emerald에 전송한다.
  2. Emerald가 트리거를 평가한다(멘션? 이름? 키워드?). 직접적인 멘션이다. 집중 지연을 계산하고, 쿨다운, 세션, 주제 피로도를 확인한다. 응답하기로 결정하고 HTTP로 메시지를 Sapphire에 보낸다.
  3. Sapphire가 bge-small-en-v1.5로 메시지를 임베딩한다.
    • 분류: 메시지가 futile 센트로이드보다 interesting 센트로이드에 더 가깝다(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(대형 인스턴스)이 감정적으로 조정된 파라미터로 응답을 생성해 Sapphire로 돌려보낸다.
  5. Sapphire가 메타데이터(라벨, valence, arousal, 디버그 통계)와 함께 응답을 Emerald로 스트리밍한다.
  6. Emerald가 망설임("oh...")을 추가하기로 하고, 버스트(2개 조각)를 계획하고, 리액션을 선택한다. RespondCommand를 Jade에 보낸다.
  7. Jade가 실행한다: 초기 지연을 기다린 뒤 망설임과 함께 첫 번째 조각을 보내고, 1.5초를 기다린 뒤 두 번째 조각을 보낸다. 생성이 진행되는 동안 계속 타이핑 표시를 보여준다.

사용자 입장에서는 이 모든 것이 3초 이내에 일어난다.


센트로이드: 왜 신경망 분류기보다 나은가

전통적인 분류기(이전에 사용했던 DistilBERT 같은)보다 임베딩 센트로이드를 선택한 이유는 설명할 가치가 있다.

신경망 분류기는 클래스 사이의 결정 경계를 학습한다 -- 일반적으로 입력을 확률로 매핑하는 비선형 변환이다. 정확하지만:

  • 라벨이 붙은 훈련 데이터가 필요하다
  • 분포 변화(데이터 드리프트)에 민감하다
  • 해석하기 어렵다
  • 새 클래스를 추가하려면 재훈련이 필요하다

반면 센트로이드는 예시 임베딩들의 평균 벡터다. 분류는 이 평균 벡터와의 코사인 유사도로 이루어진다. 장점:

  • 훈련이 필요 없다: 직접 고른 예시들의 임베딩 평균을 계산하기만 하면 된다
  • 해석이 쉽다: 센트로이드에 가장 가까운 예시들을 살펴보면 "센트로이드가 무엇을 학습했는지" 알 수 있다
  • 클래스 추가: 새 센트로이드를 하나 추가하기만 하면 된다 -- 재훈련이 필요 없다
  • 강건함: 센트로이드는 평균이므로 이상치의 영향이 적다

센트로이드의 진짜 힘은 분류 문제를 공간적 거리 측정 문제로 바꾼다는 데 있다. 384차원 공간(또는 PCA/t-SNE 차원 축소 후 2D/3D) 안의 영역으로 카테고리를 시각화할 수 있다.

3D 센트로이드 시각화

실제로 임베딩 공간에서 분류 센트로이드가 어떻게 보이는지는 다음과 같다. 각 점은 PCA를 통해 3D로 투영된 예시 메시지다(원래 384차원이 시각화를 위해 3차원으로 축소되었다). 파란 점은 사소한(futile) 메시지, 노란 점은 흥미로운(interesting) 메시지다. 20개의 다이아몬드 마커는 k-means 중심점입니다 (클래스당 10개)는 계산된 센트로이드 -- 각 그룹의 평균 -- 다. 점 위에 마우스를 올리면 예시의 원문을 볼 수 있다.

두 개의 예시가 빨간색으로 표시되어 있다: "lol"(futile로 분류)과 "i feel sad today"(interesting으로 분류)이다. "lol"은 사소한 메시지의 파란 구름 속에 떨어지는 반면, "i feel sad today"는 노란 점들이 있는 쪽에 위치한다. 3차원으로 축소한 후에도(전체 분산의 14.7%만 설명함) 분리가 눈에 보인다. 384차원에서는 경계가 훨씬 더 선명하다.

입력 메시지의 센트로이드는 내용에 따라 이 공간을 이동한다. FUTILE/INTERESTING 분류는 단순히 코사인 유사도로 어느 센트로이드가 더 가까운지 측정하는 것이다. 이를 통해 각 메시지를 다차원 공간의 한 점으로 표현할 수 있으며, 각 차원은 하나의 의미론적 속성에 해당한다.


실제로 무엇이 달라지는가

사용자는 계층도, 센트로이드도, temperature 조정도 보지 못한다. 하지만 그 효과는 느낀다:

  • 단순한 메시지에 대한 더 빠른 응답(소형 모델은 2배 빠르고 트래픽의 70%를 처리한다)
  • 적응형 톤: 짜증이 나 있으면 봇이 그 짜증을 "감지"하고 스타일을 맞춘다
  • 플랫폼 간 일관성: Matrix 봇과 Discord 봇이 같은 브레인과 같은 감정 상태를 공유한다
  • "어시스턴트 모드" 없음: 파인튜닝 + few-shot + 스마트 라우팅이 기업스러운 응답을 피하게 한다

소형 모델의 훈련 데이터를 20만 개로 늘린 것도 이 효과들을 한층 강화했다: 모델은 few-shot 프라이밍이 제공하는 유연성을 잃지 않으면서 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, futile)
Krystal large llama.cpp (PM2) 3125 대형 모델 (3B+, interesting)

서비스 간 의존성은 단방향이다: 어댑터는 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: paylaşılan beyinler, duygu sınıflandırması ve ilginç/anlamsız yönlendirme

Luna Protocol tek parça bir yapıdan dört katmanlı bir mimariye dönüştü: adaptörler, beyin, duygu sınıflandırıcı ve çıkarım. Menüde: embedding centroid'leri, ilginç/anlamsız yönlendirme ve valans ile uyarılmaya göre LLM parametre ayarı var.

Luna Protocol: paylaşılan beyinler, duygu sınıflandırması ve ilginç/anlamsız yönlendirme

İki önceki makalede, Luna Protocol'ü karmaşık bir davranış sistemine ve fine-tune edilmiş bir modele sahip tek bir Discord botu olarak tanıtmıştım. Ama mimari o zamandan beri epey değişti. Eskiden tek parça bir yapı olan şey -- Discord botunu, davranışı ve LLM çağrılarını yöneten tek bir Node.js süreci -- artık 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ışma beklenmedik faydalar getirdi: birden fazla platform arasında "beyin" paylaşımı, LLM'nin parametrelerini dinamik olarak ayarlayan bir duygu sınıflandırma sistemi ve konuşmanın algılanan önemine göre iki model arasında akıllı mesaj yönlendirmesi.

Bu evrim bir anda olmadı -- organik bir yol izledi. Önce server/ klasörünü botun deposundan ayırarak bir tarafta Krystal'ı oluşturdum ve Jade'i Discord adaptörü olarak bıraktım. Sonra Jade'in llm-core ve olay veri yolunu yeniden kullanarak Pixieglow'u (Matrix adaptörü) oluşturdum. Ardından DistilBERT ile GENERIC/SEMANTIC sınıflandırması getiren Sapphire geldi -- ama sonuçlar ikna edici değildi, bu yüzden örnekleri zenginleştirmek için daha esnek ve daha isabetli olan embedding centroid'lerine geçtim; sınıflandırma FUTILE/INTERESTING (anlamsız/ilginç) oldu. Sonunda LLM'nin sıcaklığını ve tekrar cezasını düzenlemek için valans ve uyarılma centroid'lerini ekledim. Son olarak, Emerald'ı, yani paylaşılan beyni oluşturarak Jade ile Pixieglow arasındaki tüm gereksiz kodu kaldırdım; Jade ve Pixieglow'u basit soket tabanlı istemcilere dönüştürdüm.

Bunun yanında, projenin ilerlemesini takip eden bir web sitesini güncel tutuyorum: protocol-luna.github.io.

Bu makale, bu katmanları nasıl ve neden ayırdığımın, her servisin tam olarak ne yaptığının ve centroid'ler (ortalama embedding vektörleri) ile kızgınlık değişkenleri (1970'lerin PARRY chatbot'undan esinlenilmiş) gibi kavramların basit bir Discord botunu şaşırtıcı derecede tutarlı, çoklu platform destekli bir sisteme nasıl dönüştürdüğünün hikayesini anlatıyor.


Tek parça yapının sorunu

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

  • Discord bağlantısı (Eris kütüphanesi üzerinden)
  • Tetikleyici değerlendirmesi (bahsetmeler, anahtar kelimeler, takipler...)
  • İnsan davranışlarının simülasyonu (yazım hataları, tereddütler, uyku...)
  • Yerel LLM sunucusuna (llama.cpp) HTTP çağrıları
  • Oturum yönetimi ve spam önleme
  • TTS (metinden sese) hattı

Her şey aynı süreçte, tipli olay veri yolları (TypedBus) üzerinden iletişim kurarak yaşıyordu. İşe yarıyordu, ama sınırlamaları vardı:

  • Tüm davranış kodunu tekrarlamadan bir Matrix istemcisi eklemek imkansızdı
  • LLM ve bot aynı depodaydı: server/ klasörü zaten vardı, ama birini diğerine dokunmadan geliştiremiyordunuz
  • Akıllı sınıflandırma yoktu: her mesaj, ister "lol" ister varoluşsal bir soru olsun, aynı şekilde ele alınıyordu
  • Kalıcı duygusal durum yoktu: bot hiçbir şey "hissetmiyordu"

Katmanlara ayırmak tüm bu sorunları çözdü.


Dört katman

Luna Protocol'ün mevcut mimarisi dört seviyeli bir huni olarak organize edilmiştir:

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)

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


1. Katman: adaptörler (Pixieglow ve Jade)

Bunlar en basit katmanlardır. Tek görevleri, bir mesajlaşma platformundan gelen olayları Emerald'a doğru standartlaştırılmış bir protokole çevirmektir:

  • Jade, Discord adaptörüdür. Discord'a bağlanmak için Eris kütüphanesini kullanır ve mesajları WebSocket üzerinden Emerald'a iletir. Ayrıca TTS hattını da yönetir (Piper üzerinden konuşma sentezi, OGG dönüşümü, Discord'a yükleme).
  • Pixieglow, Matrix adaptörüdür. Matrix Client-Server HTTP API'sini doğrudan kullanır (SDK yok), uzun-yoklama (long-poll) senkronizasyonu ile. TTS'i yoktur.

Her iki adaptör de emerald-client.ts içinde tanımlanan aynı WebSocket protokolünü paylaşır:

type ClientId = "jade" | "pixieglow";

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

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

Aynı arayüze sahip iki adaptörün varlığı, beyin paylaşımının işe yaradığını kanıtlıyor: aynı "beyin" (Emerald), bir Discord botuna ve bir Matrix botuna birbirinden ayırt etmeden, aynı davranışlarla hizmet veriyor. Protokol bildirimseldir: Emerald, adaptöre bir mesajı nasıl göndereceğini söylemez, ne göndereceğini söyler (gecikmeli bir metin, muhtemelen bir patlama planı, bir tepki vb.). Her adaptör, kendi platformu için somut yürütmeyi gerçekleştirir.

Bu mimarinin gücü tam da burada: Telegram, Signal veya başka bir şey için destek eklemek için, sadece WebSocket protokolünü uygulayan bir adaptör yazmanız yeterli.


2. Katman: beyin (Emerald)

Emerald, merkezi karar verme servisidir. 3126 portunda WebSocket üzerinden dinler ve şunları yönetir:

  • Tetikleyici değerlendirmesi: bahsetme, DM, isim, anahtar kelime, takip, rastgele
  • Davranışsal simülasyon: odaklanma gecikmeleri, yazım hataları, tereddütler, unutkanlık, patlamalar, konu yorgunluğu
  • Uyku döngüleri: uyku / yavaş / kısa modları
  • Oturum yönetimi: bekleme süresi, oturum limitleri, spam önleme
  • Sapphire'a yönlendirme: mesaj gönderme, akışlı yanıtları alma

Emerald, beyin paylaşımını mümkün kılan merkezi servistir ve ayrımdan en çok faydalanan da odur. Daha önce, her davranış (yazım hatası, patlama, tereddüt) Discord koduyla iç içe geçmişti. Şimdi bunlar behavior/ altında özel modüllerde yaşıyor:

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)

Beyin hangi platformda çalıştığını bilmez. Bir clientId ("jade" veya "pixieglow") ile bir MessageEvent alır, bir karar verir ve bir komut döndürür. Adaptör gerisini halleder.


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

Sapphire, teknik olarak en ilginç servistir. Python ve FastAPI ile yazılmış bir LLM ara katmanıdır (middleware) ve dört kritik rol üstlenir:

  1. Embedding centroid'leri üzerinden ikili FUTILE / INTERESTING sınıflandırıcı
  2. Centroid'ler üzerinden duygu puanlayıcı (valans / uyarılma)
  3. Krystal'a arka uç yönlendirici (küçük model ile büyük model)
  4. Few-shot enjektörü ve oturum yöneticisi

Centroid'ler: sınıflandırmanın kalbi

Bir centroid, basit bir kavramdır: bir dizi embedding vektörünün ortalamasıdır. Somut olarak, yüzlerce örnek mesaj topladım, bunları bir embedding modelinden (BAAI/bge-small-en-v1.5, 384 boyut) geçirdim ve ortaya çıkan vektörlerin ortalamasını aldım.

İki sınıflandırma centroid'i vardır:

  • futile_centroid: ~683 önemsiz mesajın ortalama embedding'i via k-means (k=10, seed=42) ("lol", "ok", "hello", "nm just chillin u")
  • interesting_centroid: ~678 içerikli mesajın ortalama embedding'i (teknik sorular, itiraflar, felsefe)

Bir mesaj geldiğinde:

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

Mesaj ile her centroid arasındaki kosinüs benzerliği kategoriyi belirler. Mutlak fark ise güveni verir. Basit, hızlı (LLM ileri geçişi yok) ve şaşırtıcı derecede etkilidir.

Neden iki model?

Bu sınıflandırmanın sonucu, hangi LLM arka ucunun çağrılacağına karar verir:

Etiket Krystal arka ucu Model Port
FUTILE generic Luna-Protocol-1.5B (941 MB, Q4_K_M) 3124
INTERESTING semantic Hermes-3-3B veya 8B (yapılandırmaya bağlı) 3125

Sezgi basit: bir "lol" ya da "nm just chillin u" sekiz milyar parametreli bir modeli çağırmayı hak etmiyor. 200.000 Discord örneği üzerinde eğitilmiş, fine-tune edilmiş küçük Luna 1.5B modeli, hafif alışverişler için fazlasıyla yeterli. Öte yandan, hayat hakkında bir soru, bir itiraf veya teknik bir tartışma, daha zengin bir yanıt üretebilen büyük modele yönlendirilir.

Bu ekonomik yönlendirme, LLM sunucusundaki yükü önemli ölçüde azaltır: mesajların yaklaşık %70'i FUTILE olarak sınıflandırılır ve küçük model tarafından ele alınır, bu da büyük modeli gerçekten hak eden konuşmalar için serbest bırakır.

Duygusal eksen: valans ve uyarılma

Ama hepsi bu kadar değil. Sapphire, mesajın duygusunu değerlendirmek için aynı centroid mekanizmasını bağımsız bir eksende de 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"

Puan, her eksende benzerliklerin farkı olarak hesaplanır:

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

Valans, mesajın olumlu mu olumsuz mu olduğunu ölçer. Uyarılma, duygusal yoğunluğunu ölçer. Birlikte, duygunun çemberimsi (circumplex) modelini oluştururlar (Russell, 1980) -- 1972'de PARRY chatbot'una ilham veren aynı psikolojik model.

Kızgınlık değişkenleri: duygular LLM'yi nasıl kontrol ediyor

İşte PARRY ilhamının somutlaştığı yer burası. PARRY (1972'de Kenneth Colby tarafından yaratıldı), paranoyak bir hastayı simüle etmek için tasarlanmış bir chatbot'tu. Yanıtlarını değiştiren dahili değişkenleri vardı -- korku, öfke, güvensizlik. Örneğin, "korkmuş" bir PARRY daha saldırgan yanıt verirdi.

Sapphire de aynı şeyi yapar, ama sürekli değişkenlerle ve daha zarif bir yöntemle: LLM'nin örnekleme parametreleri, konuşmanın duygusal durumuna göre gerçek zamanlı olarak ayarlanır.

Sıcaklık uyarılmayı takip eder
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Uyarılma 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 heyecanlandığında ya da üzüldüğünde (yüksek uyarılma), sıcaklık yükselir. Model daha çeşitli, daha yaratıcı, bazen daha kaotik yanıtlar üretir -- "kendini kaptıran" bir insan gibi. Konuşma sakin olduğunda, sıcaklık düşer ve yanıtlar daha ölçülü hale gelir.

Tekrar cezası valansı takip eder
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
Valans Tekrar Cezası Etki
-1.0 (olumsuz) 1.25 Güçlü ceza, tekrardan kaçınır
0.0 (nötr) 1.15 Varsayılan değer
+1.0 (olumlu) 1.05 Düşük ceza, tekrara izin verir

Konuşma ne kadar olumsuzsa, model kendini o kadar çok tekrar etmekten kaçınmaya itilir -- gergin bir tartışmada kelime arayan biri gibi. Konuşma ne kadar olumluysa, model o kadar rahat bir sohbette olduğu gibi fazlalık ifadeler kullanabilir.

Birikimli duygusal durum

Bu puanlar sadece anlık mesaja uygulanmaz. Bir EmotionState, oturum başına valans ve uyarılmanın ü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'lik decay, her mesajda önceki durumun %85'inin korunduğu, yeni sinyalin %15'inin entegre edildiği anlamına gelir. Bu, ani dalgalanmaları yumuşatan bir duygusal hafıza yaratır: tek bir olumsuz mesaj botu "üzgün" yapmaz, ama bir dizi olumsuz mesaj onun ruh halini yavaş yavaş kaydırır.

Pratikte: eğer biri bir konuşmaya çok heyecanlı başlarsa (arousal=+0.8), sonraki mesajlar daha sakin olsa bile sıcaklık birkaç alışveriş boyunca yüksek kalır. Duygunun geri inmesi zaman alır -- bir tartışmadan sonra "kızgın" kalan bir insan gibi.


4. Katman: çıkarım (Krystal)

Krystal en alt katmandır: OpenAI uyumlu bir API (/v1/chat/completions) sunan llama.cpp etrafında bir sarmalayıcıdır. İki PM2 örneği olarak çalışır:

  • krystal-small: fine-tune edilmiş Luna 1.5B modeli, 3124 portunda, CPU yakınlığı 0
  • krystal-large: bir Hermes 3B modeli, 3125 portunda, CPU yakınlığı 0,1

Her iki örnek de önceden derlenmiş llama-server süreçleridir ve CPU sabitleme için taskset ile başlatılır.

Luna modelinin fine-tune'u da ikinci makaleden bu yana gelişti: artık 200.000 örnek üzerinde eğitiliyor (önceki 50.000'den artarak), hâlâ QLoRA üzerinden Qwen2.5-1.5B-Instruct'tan başlıyor. 200 bin örnek, Discord-Dialogues veri setinin bir alt kümesidir ve yalnızca en doğal ve çeşitli konuşmaları tutmak için filtrelenmiştir. Amaç: few-shot priming'i bu kadar etkili kılan esnekliği kaybetmeden modelin üslup yelpazesini genişletmek.


Tüm resim: geçiş halindeki bir mesaj

İşte Discord'da biri "i'm really sad today" yazdığında gerçekte ne oluyor:

  1. Jade, mesajı Discord Gateway API'si üzerinden alır. Onu bir MessageEvent'e dönüştürür ve WebSocket üzerinden Emerald'a gönderir.
  2. Emerald, tetikleyiciyi değerlendirir (bahsetme mi? isim mi? anahtar kelime mi?). Bu doğrudan bir bahsetme. Bir odaklanma gecikmesi hesaplar, bekleme süresini, oturumu, konu yorgunluğunu kontrol eder. Yanıt vermeye karar verir ve mesajı HTTP üzerinden Sapphire'a gönderir.
  3. Sapphire, mesajı bge-small-en-v1.5 ile embed eder.
    • Sınıflandırma: mesaj futile centroid'inden çok interesting centroid'ine daha yakın (fark = +0.31) -> INTERESTING
    • Duygu: olumsuz valans (-0.42), orta düzey uyarılma (0.35)
    • Yönlendirme: KRYSTAL_SEMANTIC_URL yönü (3125 portu, büyük model)
    • Örnekleme parametreleri: sıcaklık = 0.80 (uyarılma arttı), tekrar cezası = 1.19 (olumsuz valans)
    • Oturumun duygusal durumu bu değerlerle güncellenir
  4. Krystal (büyük örnek), duygusal olarak ayarlanmış parametrelerle yanıtı üretir ve Sapphire'a geri gönderir.
  5. Sapphire, yanıtı meta verilerle (etiket, valans, uyarılma, hata ayıklama istatistikleri) birlikte Emerald'a akışlı olarak gönderir.
  6. Emerald, bir tereddüt eklemeye ("oh...") karar verir, bir patlama planlar (2 parça) ve bir tepki seçer. Jade'e bir RespondCommand gönderir.
  7. Jade yürütür: ilk gecikmeyi bekler, tereddütle birlikte ilk parçayı gönderir, 1,5 saniye bekler, ikinci parçayı gönderir. Üretim boyunca yazıyor göstergesini gösterir.

Kullanıcı için tüm bunlar 3 saniyeden kısa sürede gerçekleşir.


Centroid'ler: neden sinirsel bir sınıflandırıcıdan daha iyiler

Embedding centroid'lerinin geleneksel bir sınıflandırıcıya (daha önce kullandığım DistilBERT gibi) tercih edilmesi bir açıklamayı hak ediyor.

Sinirsel bir sınıflandırıcı, sınıflar arasında bir karar sınırı öğrenir -- genellikle girdileri olasılıklara eşleyen doğrusal olmayan bir dönüşüm. Doğrudur, ama:

  • Etiketlenmiş eğitim verisi gerektirir
  • Dağılım kaymasına (data drift) karşı hassastır
  • Yorumlanması zordur
  • Yeni bir sınıf eklemek için yeniden eğitilmesi gerekir

Bir centroid ise, örnek embedding'lerinin ortalama vektörüdür. Sınıflandırma, bu ortalama vektöre kosinüs benzerliği ile yapılır. Avantajları:

  • Eğitim yok: sadece elle seçilmiş örneklerin embedding'lerinin ortalamasını hesaplarsınız
  • Yorumlaması kolay: centroid'in "ne öğrendiğini" anlamak için hangi örneklerin ona en yakın olduğuna bakabilirsiniz
  • Sınıf eklemek: sadece yeni bir centroid eklersiniz -- yeniden eğitime gerek yok
  • Sağlam: centroid bir ortalama olduğundan, aykırı değerlerin etkisi azdır

Centroid'lerin gerçek gücü, bir sınıflandırma problemini bir uzamsal mesafe ölçümü problemine dönüştürmeleridir. Kategorileri 384 boyutlu bir uzayda bölgeler olarak (ya da PCA/t-SNE boyut indirgemesinden sonra 2B/3B olarak) görselleştirebilirsiniz.

3B centroid görselleştirmesi

Pratikte, sınıflandırma centroid'lerinin embedding uzayında nasıl göründüğü şöyle: her nokta, PCA aracılığıyla 3B'ye yansıtılmış bir örnek mesajdır (orijinal 384 boyut görselleştirme için 3'e indirgenmiştir). Mavi noktalar anlamsız mesajlardır, sarı noktalar ilginç mesajlardır. 20 elmas işaretleyici k-means merkez noktalarıdır (sınıf başına 10), hesaplanan centroid'lerdir -- her grubun ortalaması. Örneğin orijinal metnini görmek için bir noktanın üzerine gelin.

İki örnek kırmızı ile gösterilmiştir: "lol" (anlamsız olarak sınıflandırılmış) ve "i feel sad today" (ilginç olarak sınıflandırılmış). "lol", anlamsız mesajların mavi bulutuna düşerken, "i feel sad today" sarı noktaların tarafında yer alır. Ayrım, 3 boyuta indirgendikten sonra bile görünür (toplam varyansın sadece %15,6'sı açıklanmıştır). 384 boyutta, sınır çok daha keskindir.

Girdi mesajının centroid'i, içeriğine bağlı olarak bu uzayda dolaşır. FUTILE/INTERESTING sınıflandırması, basitçe hangi centroid'in kosinüs benzerliği açısından daha yakın olduğunu ölçmekten ibarettir. Bu, her mesajı, her boyutun bir anlamsal özelliğe karşılık geldiği çok boyutlu bir uzayda bir nokta olarak temsil etmemizi sağlar.


Pratikte bu neyi değiştiriyor

Kullanıcılar katmanları, centroid'leri ya da sıcaklık ayarlarını görmezler. Ama etkilerini hissederler:

  • Basit mesajlar için daha hızlı yanıtlar (küçük model 2 kat daha hızlı ve trafiğin %70'ini karşılıyor)
  • Uyarlanabilir ton: sinirliyseniz, bot bunu "hissediyor" ve tarzını buna göre uyarlıyor
  • Platformlar arası tutarlılık: bir Matrix botu ile bir Discord botu aynı beyni ve aynı duygusal durumu paylaşıyor
  • "Asistan modu" yok: fine-tune + few-shot + akıllı yönlendirme, kurumsal görünen yanıtlardan kaçınıyor

Küçük modelin eğitim setinin 200 bine çıkarılması bu etkileri daha da güçlendirdi: model, few-shot priming'in sağladığı esnekliği kaybetmeden Discord konuşmalarının çeşitliliğini daha iyi yakalıyor.


Tam altyapı

Şu anda çalışan servisler şunlar:

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, anlamsız)
Krystal large llama.cpp (PM2) 3125 Büyük model (3B+, ilginç)

Servisler arasındaki bağımlılıklar tek yönlüdür: adaptör Emerald'a bağımlıdır, Emerald Sapphire'a bağımlıdır, Sapphire Krystal'a bağımlıdır. Döngü yoktur. Her servis bağımsız olarak yeniden başlatılabilir.


Sonuç

Luna Protocol'ü dört katmana ayırmak sadece mimari bir alıştırma değildi. Somut sınırlamalara verilmiş bir yanıttı: Matrix'i destekleyememe, duygusal farkındalık eksikliği ve akıllı mesaj önceliklendirmesinin bulunmaması.

Bugün sistem daha sağlam (bir LLM çökmesi botu öldürmüyor), daha genişletilebilir (bir Telegram veya WhatsApp adaptörü aynı WebSocket protokolünü izleyecektir) ve daha "canlı": bot, davranışını, tonunu ve hatta LLM'nin parametrelerini konuşmanın algılanan duygusal durumuna göre uyarlıyor.

Embedding centroid'leri, tüm bunu aşırı karmaşıklık olmadan mümkün kılan kilit parçadır: eğitilmiş bir sinir ağı yok, etiketlenmiş bir veri hattı yok, sadece vektör ortalamaları ve kosinüs benzerlikleri var. Basit ama inanılmaz derecede etkili ve fena halde hafife alınan bir teknik.

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 inşa ettim
Makale 2: fine-tuning Luna Protocol: neden 1.5B'lik bir modeli fine-tune ettim

Luna Protocol: cervelli condivisi, classificazione emotiva e routing interessante/futile

Luna Protocol è passato da un monolite a un'architettura a quattro livelli: adattatori, brain, classificatore emotivo e inferenza. In programma: centroidi di embedding, routing interessante/futile e regolazione dei parametri del LLM in base a valenza e arousal.

Luna Protocol: cervelli condivisi, classificazione emotiva e routing interessante/futile

Nei due articoli precedenti ho presentato Luna Protocol come un unico bot Discord con un sistema comportamentale complesso e un modello fine-tuned. Ma l'architettura si è evoluta parecchio da allora. Quello che era un monolite -- un unico processo Node.js che gestiva il bot Discord, il comportamento e le chiamate al LLM -- si è trasformato in quattro livelli indipendenti, ognuno con la propria responsabilità, il proprio linguaggio e il proprio ciclo di vita.

Questa separazione ha portato benefici inaspettati: la condivisione dei "cervelli" tra più piattaforme, un sistema di classificazione emotiva che regola dinamicamente i parametri del LLM, e un routing intelligente dei messaggi tra due modelli in base all'importanza percepita della conversazione.

L'evoluzione non è avvenuta tutta insieme -- ha seguito un percorso organico. Ho prima separato la cartella server/ dal repository del bot, creando così Krystal da un lato e lasciando Jade come adattatore Discord. Poi ho creato Pixieglow (adattatore Matrix) riutilizzando llm-core e il bus di eventi di Jade. Poi è arrivato Sapphire, che ha introdotto una classificazione GENERIC/SEMANTIC con DistilBERT -- ma i risultati non erano convincenti, quindi sono passato ai centroidi di embedding, più malleabili per l'arricchimento di esempi e più precisi; la classificazione è diventata FUTILE/INTERESSANTE. Infine ho aggiunto centroidi di valenza e arousal per regolare la temperatura e il repeat penalty del LLM. Per finire, ho eliminato tutto il codice ridondante tra Jade e Pixieglow creando Emerald, il cervello condiviso, trasformando Jade e Pixieglow in semplici client guidati da socket.

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

Questo articolo racconta come e perché ho suddiviso questi livelli, cosa fa esattamente ogni servizio, e come concetti come i centroidi (vettori medi di embedding) e le variabili di risentimento (ispirate al chatbot PARRY degli anni '70) hanno trasformato un semplice bot Discord in un sistema multipiattaforma sorprendentemente coerente.


Il problema con il monolite

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

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

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

  • Impossibile aggiungere un client Matrix senza duplicare tutto il codice di comportamento
  • Il LLM e il bot erano nello stesso repository: la cartella server/ esisteva già, ma era impossibile far evolvere l'uno senza toccare l'altro
  • Nessuna classificazione intelligente: ogni messaggio veniva trattato allo stesso modo, che fosse un "lol" o una domanda esistenziale
  • Nessuno stato emotivo persistente: il bot non "provava" nulla

La suddivisione in livelli ha risolto tutti questi problemi.


I quattro livelli

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
  [INFERENZA]     Krystal (llama.cpp, porte 3124 / 3125)

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


Livello 1: gli adattatori (Pixieglow e Jade)

Sono i livelli più semplici. Il loro unico compito è 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 inoltra i messaggi a Emerald via WebSocket. Gestisce anche la pipeline TTS (sintesi vocale via Piper, conversione in OGG, upload su Discord).
  • Pixieglow è l'adattatore Matrix. Usa direttamente l'API HTTP Client-Server di Matrix (senza SDK), con una sincronizzazione long-poll. Non ha il TTS.

I due adattatori condividono lo stesso protocollo WebSocket definito in emerald-client.ts:

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 dimostra che la condivisione funziona: 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, gli dice cosa inviare (il testo con un ritardo, eventualmente un piano di burst, una reazione, ecc.). Ogni adattatore implementa l'esecuzione concreta secondo la propria piattaforma.

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


Livello 2: il cervello (Emerald)

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

  • La valutazione dei trigger: menzione, DM, nome, parola chiave, follow-up, casuale
  • La simulazione comportamentale: ritardi di concentrazione, errori di battitura, esitazioni, dimenticanze, burst, affaticamento tematico
  • 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 streaming

Emerald è il servizio centrale che ha permesso la condivisione, ed è quello che ha beneficiato di più della separazione. Prima, ogni comportamento (errore di battitura, burst, esitazione) era intrecciato con il codice Discord. Ora sono in moduli dedicati sotto behavior/:

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

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


Livello 3: il classificatore emotivo (Sapphire)

Sapphire è il servizio tecnicamente più interessante. È un middleware LLM scritto in Python con FastAPI, che svolge quattro ruoli critici:

  1. Classificatore binario FUTILE / INTERESSANTE tramite centroidi di embedding
  2. Valutatore emotivo (valenza / arousal) tramite centroidi
  3. Router di backend verso Krystal (modello piccolo vs modello grande)
  4. Iniettore few-shot e gestore delle sessioni

I centroidi: il cuore della classificazione

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

Ci sono due centroidi di classificazione:

  • futile_centroid: la media degli embedding di ~683 messaggi banali via k-means (k=10, seed=42) ("lol", "ok", "hello", "nm just chillin u")
  • interessante_centroid: la media degli embedding di ~678 messaggi sostanziali (domande tecniche, confidenze, filosofia)

Quando arriva un messaggio:

def classify(text, embedder, futile_centroids, interessante_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 interessante_centroids)     # max over 10
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

La similarità coseno tra il messaggio e ciascun centroide determina la categoria. La differenza assoluta dà la confidenza. È semplice, veloce (nessun forward pass del LLM) e sorprendentemente efficace.

Perché due modelli?

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

Etichetta Backend Krystal Modello Porta
FUTILE generic Luna-Protocol-1.5B (941 MB, Q4_K_M) 3124
INTERESSANTE semantic Hermes-3-3B o 8B (a seconda della configurazione) 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 Luna 1.5B fine-tuned, addestrato su 200.000 campioni Discord, basta abbondantemente per gli scambi leggeri. Al contrario, una domanda sulla vita, una confidenza o un dibattito tecnico viene instradata verso il modello grande, che può produrre una risposta più ricca.

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

L'asse emotivo: valenza e arousal

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

Ci sono quattro centroidi emotivi:

Polo Esempi
positivo "hell yeah", "love that", "this is great"
negativo "shut up", "i hate this", "this sucks"
arousal alto "WHAT THE HELL", "omg omg omg", "AAAAA"
arousal basso "just chilling", "meh", "i guess"

Il punteggio viene calcolato come differenza di similarità su ciascun asse:

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

La valenza misura se il messaggio è positivo o negativo. L'arousal misura la sua intensità emotiva. Insieme formano il modello circumplesso dell'affetto (Russell, 1980) -- lo stesso modello psicologico che ha ispirato il chatbot PARRY nel 1972.

Le variabili di risentimento: come le emozioni controllano il LLM

È qui che l'ispirazione di PARRY diventa tangibile. PARRY (creato da Kenneth Colby nel 1972) era un chatbot progettato per simulare un paziente paranoico. Possedeva variabili interne -- paura, rabbia, diffidenza -- che modificavano le sue risposte. Ad 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 del LLM vengono regolati in tempo reale in base allo stato emotivo della conversazione.

La temperatura segue l'arousal
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à predefinita
+1.0 (eccitato) 1.00 Massima casualità, risposte sorprendenti

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

Il repeat penalty segue la valenza
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
Valenza Repeat Penalty Effetto
-1.0 (negativa) 1.25 Penalità forte, evita le ripetizioni
0.0 (neutra) 1.15 Valore predefinito
+1.0 (positiva) 1.05 Penalità bassa, permette le ripetizioni

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

Lo stato emotivo cumulativo

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

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 viene conservato ad ogni messaggio, e il 15% del nuovo segnale viene integrato. Questo dà una memoria emotiva che attenua le variazioni brusche: un singolo messaggio negativo non rende il bot "triste", ma una serie di messaggi negativi fa progressivamente slittare il suo umore.

In pratica: se qualcuno inizia una conversazione in modo molto eccitato (arousal=+0.8), la temperatura rimane alta per diversi scambi, anche se i messaggi successivi sono più calmi. L'emozione impiega tempo a scendere -- come un umano che rimane "accaldato" dopo una discussione.


Livello 4: l'inferenza (Krystal)

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

  • krystal-small: il modello Luna 1.5B fine-tuned, 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 precompilati, avviati con taskset per il pinning della CPU.

Anche il fine-tuning del modello Luna si è evoluto dal secondo articolo: ora è addestrato su 200.000 campioni (contro i 50.000 precedenti), sempre partendo da Qwen2.5-1.5B-Instruct via QLoRA. I 200k campioni sono un sottoinsieme del dataset Discord-Dialogues, filtrati per mantenere solo le conversazioni più naturali e diversificate. L'obiettivo: ampliare 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 concretamente quando qualcuno invia "oggi sono davvero triste" su Discord:

  1. Jade riceve il messaggio tramite l'API Gateway di Discord. Lo trasforma in un 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 il cooldown, la sessione, l'affaticamento tematico. Decide di rispondere e invia il messaggio a Sapphire via HTTP.
  3. Sapphire genera l'embedding del messaggio con bge-small-en-v1.5.
    • Classificazione: il messaggio è più vicino al centroide interessante che al centroide futile (diff = +0.31) -> INTERESSANTE
    • Emozione: valenza negativa (-0.42), arousal moderato (0.35)
    • Routing: direzione KRYSTAL_SEMANTIC_URL (porta 3125, modello grande)
    • Parametri di campionamento: temperatura = 0.80 (arousal aumentato), repeat_penalty = 1.19 (valenza negativa)
    • Lo stato emotivo della sessione viene aggiornato con questi valori
  4. Krystal (istanza large) genera la risposta con i parametri regolati emotivamente e la restituisce a Sapphire.
  5. Sapphire trasmette in streaming la risposta a Emerald con i metadati (etichetta, valenza, arousal, statistiche di debug).
  6. Emerald decide di aggiungere un'esitazione ("oh..."), pianifica un burst (2 frammenti), e sceglie una reazione. Invia un 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 centroidi: perché sono meglio di un classificatore neurale

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

Un classificatore neurale apprende un confine di decisione tra le classi -- tipicamente una trasformazione non lineare che proietta gli input verso delle probabilità. È preciso, ma:

  • Richiede dati di addestramento etichettati
  • È sensibile al cambiamento di distribuzione (data drift)
  • È difficile da interpretare
  • Deve essere riaddestrato per aggiungere una nuova classe

Un centroide, invece, è un vettore medio di embedding di esempi. La classificazione avviene tramite similarità coseno con questo vettore medio. Vantaggi:

  • Nessun addestramento: si calcola semplicemente la media degli embedding di esempi scelti a mano
  • Facile da interpretare: si può vedere quali esempi sono più vicini al centroide per capire "cosa ha imparato il centroide"
  • Aggiunta di una classe: si aggiunge semplicemente un nuovo centroide -- nessun riaddestramento
  • Robusto: il centroide è una media, quindi i valori anomali hanno poco impatto

Il vero potere dei centroidi è che trasformano un problema di classificazione in un problema di misurazione della distanza spaziale. Si possono visualizzare le categorie come regioni in uno spazio a 384 dimensioni (o in 2D/3D dopo una riduzione dimensionale PCA/t-SNE).

Visualizzazione 3D dei centroidi

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

Due esempi sono mostrati in rosso: "lol" (classificato futile) e "i feel sad today" (classificato interessante). "lol" ricade nella nuvola blu dei futili, mentre "i feel sad today" si trova dal lato dei punti gialli. La separazione è visibile anche dopo una riduzione a 3 dimensioni (solo il 14,7% della varianza totale spiegata). In 384 dimensioni, il confine è molto più netto.

Il centroide del messaggio in ingresso si muove in questo spazio in base al suo contenuto. La classificazione FUTILE/INTERESSANTE consiste semplicemente nel misurare quale centroide è più vicino per similarità coseno. Si può così rappresentare ogni messaggio come un punto in uno spazio multidimensionale, dove ogni dimensione corrisponde a una proprietà semantica.


Cosa cambia in pratica

Gli utenti non vedono i livelli, i centroidi o le regolazioni di temperatura. Ma ne percepiscono gli effetti:

  • Risposte più rapide per i messaggi semplici (il modello piccolo è 2 volte più veloce e gestisce il 70% del traffico)
  • Tono adattivo: se sei nervoso, il bot "sente" il nervosismo e adatta il suo stile
  • Coerenza cross-piattaforma: un bot Matrix e un bot Discord condividono lo stesso cervello e lo stesso stato emotivo
  • Nessuna "modalità assistente": il fine-tune + few-shot + routing intelligente evita risposte da assistente aziendale

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


L'infrastruttura completa

Ecco i servizi attualmente in esecuzione:

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 Modello piccolo (1.5B, futile)
Krystal large llama.cpp (PM2) 3125 Modello grande (3B+, interessante)

Le dipendenze tra i servizi sono unidirezionali: l'adattatore dipende da Emerald, Emerald dipende da Sapphire, Sapphire dipende da Krystal. Nessun ciclo. Ogni servizio può essere riavviato in modo indipendente.


Conclusione

Dividere Luna Protocol in quattro livelli non è stato solo un esercizio di architettura. È stata una risposta a limiti concreti: l'impossibilità di supportare Matrix, la mancanza di consapevolezza emotiva, l'assenza di una prioritizzazione intelligente dei messaggi.

Oggi, il sistema è più robusto (un crash del 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 del LLM allo stato emotivo percepito della conversazione.

I centroidi di embedding sono l'elemento chiave che rende tutto questo possibile senza una complessità eccessiva: nessuna rete neurale addestrata, nessuna 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 fatto il fine-tuning di un modello da 1,5B

Luna Protocol: geteilte Gehirne, Emotionsklassifikation und interessant/belanglos-Routing

Luna Protocol hat sich von einem Monolithen zu einer vierschichtigen Architektur entwickelt: Adapter, Brain, Emotionsklassifikator und Inferenz. Im Programm: Embedding-Centroids, interessant/belanglos-Routing und LLM-Parameteranpassung nach Valenz und Erregung.

Luna Protocol: geteilte Gehirne, Emotionsklassifikation und interessant/belanglos-Routing

In den beiden vorherigen Artikeln habe ich Luna Protocol als einen einzelnen Discord-Bot mit einem komplexen Verhaltenssystem und einem fine-getunten Modell vorgestellt. Doch die Architektur hat sich seitdem stark weiterentwickelt. Was einmal ein Monolith war -- ein einziger Node.js-Prozess, der den Discord-Bot, das Verhalten und die LLM-Aufrufe verwaltete -- ist zu vier unabhängigen Schichten geworden, jede mit eigener Verantwortung, eigener Sprache und eigenem Lebenszyklus.

Diese Trennung brachte unerwartete Vorteile: die gemeinsame Nutzung von "Gehirnen" über mehrere Plattformen hinweg, ein Emotionsklassifikationssystem, das die LLM-Parameter dynamisch anpasst, und ein intelligentes Routing von Nachrichten zwischen zwei Modellen je nach wahrgenommener Wichtigkeit der Konversation.

Die Entwicklung geschah nicht auf einen Schlag -- sie folgte einem organischen Weg. Zuerst habe ich den server/-Ordner aus dem Bot-Repository ausgelagert und so Krystal geschaffen, während Jade als Discord-Adapter zurückblieb. Dann erstellte ich Pixieglow (Matrix-Adapter), indem ich Jades llm-core und Event-Bus wiederverwendete. Danach kam Sapphire, das eine GENERIC/SEMANTIC-Klassifikation mit DistilBERT einführte -- aber die Ergebnisse waren nicht überzeugend, also wechselte ich zu Embedding-Centroids, die formbarer für die Anreicherung von Beispielen und genauer sind; die Klassifikation wurde zu BELANGLOS/INTERESSANT. Schließlich fügte ich Valenz- und Erregungs-Centroids hinzu, um Temperatur und Repeat Penalty des LLM zu regulieren. Zum Schluss habe ich den gesamten redundanten Code zwischen Jade und Pixieglow entfernt, indem ich Emerald, das geteilte Gehirn, schuf, und Jade und Pixieglow in einfache socket-gesteuerte Clients verwandelte.

Parallel dazu habe ich eine Website aktuell gehalten, die den Fortschritt des Projekts dokumentiert: protocol-luna.github.io.

Dieser Artikel erzählt, wie und warum ich diese Schichten aufgeteilt habe, was jeder Dienst genau macht, und wie Konzepte wie Centroids (durchschnittliche Embedding-Vektoren) und Ressentiment-Variablen (inspiriert vom PARRY-Chatbot der 1970er Jahre) einen einfachen Discord-Bot in ein erstaunlich kohärentes plattformübergreifendes System verwandelt haben.


Das Problem mit dem Monolithen

Anfangs passte Luna Protocol in einen einzigen Node.js-Prozess. Der Code kümmerte sich um:

  • Die Discord-Verbindung (über die Eris-Bibliothek)
  • Die Auswertung von Triggern (Erwähnungen, Schlüsselwörter, Follow-ups...)
  • Die Simulation menschlichen Verhaltens (Tippfehler, Zögern, Schlaf...)
  • HTTP-Aufrufe an den lokalen LLM-Server (llama.cpp)
  • Sitzungsverwaltung und Anti-Spam
  • Die TTS-Pipeline

Alles lief im selben Prozess und kommunizierte über typisierte Event-Busse (TypedBus). Es funktionierte, aber mit Einschränkungen:

  • Unmöglich, einen Matrix-Client hinzuzufügen, ohne den gesamten Verhaltenscode zu duplizieren
  • Das LLM und der Bot waren im selben Repository: der server/-Ordner existierte bereits, aber es war unmöglich, das eine weiterzuentwickeln, ohne das andere anzufassen
  • Keine intelligente Klassifikation: jede Nachricht wurde gleich behandelt, egal ob "lol" oder eine existenzielle Frage
  • Kein dauerhafter emotionaler Zustand: der Bot "fühlte" nichts

Die Aufteilung in Schichten hat all diese Probleme gelöst.


Die vier Schichten

Die aktuelle Architektur von Luna Protocol ist als vierstufiger Trichter organisiert:

Matrix / Discord
      |
      v
  [ADAPTER]       Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [BRAIN]         Emerald (WebSocket, Port 3126)
      |
      v
  [KLASSIFIKATOR] Sapphire (HTTP, Port 3123)
      |
      v
  [INFERENZ]      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 einfachsten Schichten. Ihre einzige Aufgabe ist es, Ereignisse 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 leitet Nachrichten per WebSocket an Emerald weiter. Er verwaltet auch die TTS-Pipeline (Sprachsynthese via Piper, OGG-Konvertierung, Upload zu Discord).
  • Pixieglow ist der Matrix-Adapter. Er nutzt direkt die Matrix-Client-Server-HTTP-API (kein SDK), mit einem Long-Poll-Sync. Er hat kein TTS.

Beide Adapter teilen sich dasselbe WebSocket-Protokoll, das in emerald-client.ts definiert ist:

type ClientId = "jade" | "pixieglow";

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

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

Die Existenz zweier Adapter mit derselben Schnittstelle beweist die gemeinsame Nutzung: dasselbe "Gehirn" (Emerald) bedient gleichermaßen einen Discord-Bot und einen Matrix-Bot, mit identischem Verhalten. Das Protokoll ist deklarativ: Emerald sagt dem Adapter nicht, wie er eine Nachricht senden soll, sondern was gesendet werden soll (den Text mit einer Verzögerung, eventuell einen Burst-Plan, eine Reaktion usw.). Jeder Adapter implementiert die konkrete Ausführung entsprechend seiner Plattform.

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


Schicht 2: das Gehirn (Emerald)

Emerald ist der zentrale Entscheidungsdienst. Er lauscht auf Port 3126 per WebSocket und verwaltet:

  • Die Trigger-Auswertung: Erwähnung, DM, Name, Schlüsselwort, Follow-up, zufällig
  • Die Verhaltenssimulation: Konzentrationsverzögerungen, Tippfehler, Zögern, Vergesslichkeit, Bursts, thematische Ermüdung
  • Die Schlafzyklen: sleep / slow / short Modi
  • Die Sitzungsverwaltung: Cooldown, Sitzungsgrenzen, Anti-Spam
  • Das Routing zu Sapphire: Senden von Nachrichten, Empfangen von gestreamten Antworten

Emerald ist der zentrale Dienst, der die gemeinsame Nutzung ermöglicht hat, und derjenige, der am meisten von der Trennung profitiert hat. Vorher war jedes Verhalten (Tippfehler, Burst, Zögern) mit dem Discord-Code verflochten. Jetzt liegen sie in dedizierten Modulen unter behavior/:

emerald/src/behavior/
  burst.ts         -- Planung von Nachrichten-Bursts
  mannerisms.ts    -- Verzögerungen, Zögern, Reaktionen, Vergesslichkeit
  sleep.ts         -- Auswertung der Schlafzeiten
  typo.ts          -- Tippfehler-Simulation (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 gibt einen Befehl zurück. Der Adapter kümmert sich um den Rest.


Schicht 3: der Emotionsklassifikator (Sapphire)

Sapphire ist der technisch interessanteste Dienst. Es ist eine in Python mit FastAPI geschriebene LLM-Middleware, die vier kritische Rollen erfüllt:

  1. Binärer BELANGLOS / INTERESSANT-Klassifikator über Embedding-Centroids
  2. Emotions-Scorer (Valenz / Erregung) über Centroids
  3. Backend-Router zu Krystal (kleines vs. großes Modell)
  4. Few-Shot-Injektor und Sitzungsverwalter

Die Centroids: das Herzstück der Klassifikation

Ein Centroid ist ein einfaches Konzept: es ist der Durchschnitt einer Menge von Embedding-Vektoren. Konkret habe ich Hunderte von Beispielnachrichten gesammelt, sie durch ein Embedding-Modell laufen lassen (BAAI/bge-small-en-v1.5, 384 Dimensionen) und die entstandenen Vektoren gemittelt.

Es gibt zwei Klassifikations-Centroids:

  • futile_centroid: der Durchschnitt der Embeddings von ~683 trivialen Nachrichten via k-means (k=10, seed=42) ("lol", "ok", "hello", "nm just chillin u")
  • interessant_centroid: der Durchschnitt der Embeddings von ~678 inhaltsreichen Nachrichten via k-means (k=10, seed=42) (technische Fragen, Vertraulichkeiten, Philosophie)

Wenn eine Nachricht eintrifft:

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

Die Kosinus-Ähnlichkeit zwischen der Nachricht und jedem Centroid bestimmt die Kategorie. Die absolute Differenz gibt die Konfidenz an. Es ist einfach, schnell (kein LLM-Forward-Pass) und erstaunlich effektiv.

Warum zwei Modelle?

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

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

Die Intuition ist einfach: ein "lol" oder ein "nm just chillin u" verdient es nicht, ein Modell mit 8 Milliarden Parametern aufzurufen. Das kleine fine-getunte Luna-1.5B-Modell, trainiert auf 200.000 Discord-Beispielen, reicht für leichte Konversationen völlig aus. Eine Frage über das Leben, eine Vertraulichkeit oder eine technische Debatte hingegen wird an das große Modell weitergeleitet, das eine reichhaltigere Antwort produzieren kann.

Dieses sparsame Routing reduziert die Last auf dem LLM-Server erheblich: etwa 70% der Nachrichten werden als BELANGLOS klassifiziert und vom kleinen Modell bearbeitet, wodurch das große Modell für Konversationen frei bleibt, die es wirklich wert sind.

Die emotionale Achse: Valenz und Erregung

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

Es gibt vier emotionale Centroids:

Pol Beispiele
positiv "hell yeah", "love that", "this is great"
negativ "shut up", "i hate this", "this sucks"
hohe Erregung "WHAT THE HELL", "omg omg omg", "AAAAA"
niedrige Erregung "just chilling", "meh", "i guess"

Der Score wird als Differenz der Ähnlichkeiten auf jeder Achse berechnet:

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. Erregung misst ihre emotionale Intensität. Zusammen bilden sie das zirkumplexe Modell des Affekts (Russell, 1980) -- dasselbe psychologische Modell, das den Chatbot PARRY im Jahr 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, der entwickelt wurde, um einen paranoiden Patienten zu simulieren. Er besaß interne Variablen -- Angst, Wut, Misstrauen -- die seine Antworten veränderten. Ein "verängstigtes" PARRY antwortete beispielsweise aggressiver.

Sapphire macht dasselbe, aber mit kontinuierlichen Variablen und einer eleganteren Methode: die Sampling-Parameter des LLM werden in Echtzeit je nach emotionalem Zustand der Konversation angepasst.

Die Temperatur folgt der Erregung
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Erregung Temperatur Effekt
-1,0 (ruhig) 0,40 Geringe Kreativität, vorhersehbare Antworten
0,0 (neutral) 0,70 Standard-Kreativität
+1,0 (aufgeregt) 1,00 Maximale Zufälligkeit, überraschende Antworten

Wenn jemand aufgeregt oder verärgert ist (hohe Erregung), steigt die Temperatur. Das Modell produziert vielfältigere, kreativere, manchmal chaotischere Antworten -- wie ein Mensch, der sich "hineinsteigert". Wenn die Konversation ruhig ist, sinkt die Temperatur, und die Antworten werden gelassener.

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 Strafe, vermeidet Wiederholungen
0,0 (neutral) 1,15 Standardwert
+1,0 (positiv) 1,05 Geringe Strafe, erlaubt Wiederholungen

Je negativer die Konversation, desto mehr wird das Modell dazu gedrängt, Wiederholungen zu vermeiden -- wie jemand, der in einem angespannten Streit nach Worten sucht. Je positiver die Konversation, desto mehr kann sich das Modell redundante Aussagen erlauben, wie in einer entspannten Unterhaltung.

Der kumulative emotionale Zustand

Diese Scores beziehen sich nicht nur auf die unmittelbare Nachricht. Ein EmotionState führt einen exponentiell gleitenden Durchschnitt von Valenz und Erregung pro Sitzung:

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

Der decay-Wert von 0,85 bedeutet, dass bei jeder Nachricht 85% des vorherigen Zustands erhalten bleiben und 15% des neuen Signals integriert werden. Das ergibt ein emotionales Gedächtnis, das abrupte Schwankungen glättet: eine einzelne negative Nachricht macht den Bot nicht "traurig", aber eine Reihe negativer Nachrichten lässt seine Stimmung schrittweise abdriften.

In der Praxis: Wenn jemand ein Gespräch sehr aufgeregt beginnt (arousal=+0.8), bleibt die Temperatur über mehrere Austausche hinweg hoch, selbst wenn die folgenden Nachrichten ruhiger sind. Die Emotion braucht Zeit, um wieder abzuklingen -- wie ein Mensch, der nach einem Streit noch "aufgeheizt" bleibt.


Schicht 4: die Inferenz (Krystal)

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

  • krystal-small: das fine-getunte 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 vorkompilierte llama-server-Prozesse, die mit taskset für das CPU-Pinning gestartet werden.

Auch das Fine-Tuning des Luna-Modells hat sich seit dem zweiten Artikel weiterentwickelt: es wird jetzt auf 200.000 Beispielen trainiert (gegenüber vorher 50.000), weiterhin ausgehend von Qwen2.5-1.5B-Instruct via QLoRA. Die 200k Beispiele sind eine Teilmenge des Discord-Dialogues-Datensatzes, gefiltert, um nur die natürlichsten und vielfältigsten Konversationen zu behalten. Das Ziel: das stilistische Spektrum des Modells zu erweitern, ohne die Flexibilität zu verlieren, die Few-Shot-Priming so effektiv macht.


Das vollständige Schema: eine Nachricht im Durchlauf

Hier ist, was konkret passiert, wenn jemand "ich bin heute wirklich traurig" auf Discord sendet:

  1. Jade empfängt die Nachricht über die Discord Gateway API. Sie wandelt sie in ein MessageEvent um und sendet es per WebSocket an Emerald.
  2. Emerald wertet den Trigger aus (Erwähnung? Name? Schlüsselwort?). Es ist eine direkte Erwähnung. Es berechnet eine Konzentrationsverzögerung, prüft Cooldown, Sitzung und thematische Ermüdung. Es entscheidet sich zu antworten und sendet die Nachricht per HTTP an Sapphire.
  3. Sapphire embeddet die Nachricht mit bge-small-en-v1.5.
    • Klassifikation: die Nachricht ist näher am interessant-Centroid als am belanglos-Centroid (Diff = +0,31) -> INTERESSANT
    • Emotion: negative Valenz (-0,42), moderate Erregung (0,35)
    • Routing: Richtung KRYSTAL_SEMANTIC_URL (Port 3125, großes Modell)
    • Sampling-Parameter: Temperatur = 0,80 (Erregung erhöht), repeat_penalty = 1,19 (negative Valenz)
    • Der emotionale Zustand der Sitzung wird mit diesen Werten aktualisiert
  4. Krystal (große Instanz) generiert die Antwort mit den emotional angepassten Parametern und sendet sie an Sapphire zurück.
  5. Sapphire streamt die Antwort zusammen mit Metadaten (Label, Valenz, Erregung, Debug-Statistiken) an Emerald.
  6. Emerald entscheidet sich, ein Zögern hinzuzufügen ("oh..."), plant einen Burst (2 Fragmente) und wählt eine Reaktion. Es sendet ein RespondCommand an Jade.
  7. Jade führt aus: wartet die anfängliche Verzögerung ab, sendet das erste Fragment mit dem Zögern, wartet 1,5s, sendet das zweite Fragment. Es zeigt während der gesamten Generierung den Tipp-Indikator an.

Das alles in weniger als 3 Sekunden für den Nutzer.


Die Centroids: warum sie besser sind als ein neuronaler Klassifikator

Die Wahl von Embedding-Centroids gegenüber einem traditionellen Klassifikator (wie dem DistilBERT, das ich vorher nutzte) verdient eine Erklärung.

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

  • Er benötigt gelabelte Trainingsdaten
  • Er ist empfindlich gegenüber Verteilungsverschiebungen (Data Drift)
  • Er ist schwer zu interpretieren
  • Er muss neu trainiert werden, um eine neue Klasse hinzuzufügen

Ein Centroid hingegen ist ein Durchschnittsvektor von Beispiel-Embeddings. Die Klassifikation erfolgt durch Kosinus-Ähnlichkeit zu diesem Durchschnittsvektor. Vorteile:

  • Kein Training: man berechnet einfach den Durchschnitt der Embeddings von handverlesenen Beispielen
  • Leicht zu interpretieren: man kann sich ansehen, welche Beispiele dem Centroid am nächsten sind, um zu verstehen, "was der Centroid gelernt hat"
  • Hinzufügen einer Klasse: man fügt einfach einen neuen Centroid hinzu -- kein Neutraining nötig
  • Robust: der Centroid ist ein Durchschnitt, daher haben Ausreißer wenig Einfluss

Die wahre Stärke der Centroids liegt darin, dass sie ein Klassifikationsproblem in ein Problem der räumlichen Distanzmessung verwandeln. Man kann Kategorien als Regionen in einem 384-dimensionalen Raum visualisieren (oder in 2D/3D nach PCA/t-SNE-Dimensionsreduktion).

3D-Visualisierung der Centroids

In der Praxis sehen die Klassifikations-Centroids im Embedding-Raum so aus. Jeder Punkt ist eine Beispielnachricht, per PCA in 3D projiziert (die ursprünglichen 384 Dimensionen werden zur Visualisierung auf 3 reduziert). Blaue Punkte sind belanglose Nachrichten, gelbe Punkte interessante Nachrichten. Die beiden großen Diamanten sind die berechneten Centroids -- der Durchschnitt jeder Gruppe. Fahren Sie mit der Maus über einen Punkt, um den Originaltext des Beispiels zu sehen.

Zwei Beispiele werden in Rot angezeigt: "lol" (als belanglos klassifiziert) und "i feel sad today" (als interessant klassifiziert). "lol" fällt in die blaue Wolke der belanglosen Nachrichten, während "i feel sad today" auf der Seite der gelben Punkte liegt. Die Trennung ist selbst nach einer Reduktion auf 3 Dimensionen sichtbar (nur 14,7% der Gesamtvarianz erklärt). In 384 Dimensionen ist die Grenze weit deutlicher.

Der Centroid der Eingangsnachricht bewegt sich je nach ihrem Inhalt durch diesen Raum. Die BELANGLOS/INTERESSANT-Klassifikation besteht einfach darin, zu messen, welcher Centroid per Kosinus-Ähnlichkeit näher liegt. So kann man jede Nachricht als Punkt in einem mehrdimensionalen Raum darstellen, wobei jede Dimension einer semantischen Eigenschaft entspricht.


Was das in der Praxis ändert

Nutzer sehen die Schichten, die Centroids oder die Temperaturanpassungen nicht. Aber sie spüren die Effekte:

  • Schnellere Antworten bei einfachen Nachrichten (das kleine Modell ist 2x schneller und bewältigt 70% des Verkehrs)
  • Adaptiver Ton: wenn Sie verärgert sind, "spürt" der Bot die Verärgerung und passt seinen Stil an
  • Plattformübergreifende Konsistenz: ein Matrix-Bot und ein Discord-Bot teilen sich dasselbe Gehirn und denselben emotionalen Zustand
  • Kein "Assistenten-Modus": das Fine-Tuning + Few-Shot + intelligentes Routing vermeidet unternehmerisch klingende Antworten

Die Erweiterung auf 200k Trainingsbeispiele für das kleine Modell hat diese Effekte weiter verstärkt: das Modell erfasst die Vielfalt der Discord-Konversationen besser, ohne die durch Few-Shot-Priming ermöglichte Formbarkeit zu verlieren.


Die vollständige Infrastruktur

Hier sind die derzeit laufenden Dienste:

Dienst 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) Klassifikator + Emotion
Krystal small llama.cpp (PM2) 3124 Kleines Modell (1.5B, belanglos)
Krystal large llama.cpp (PM2) 3125 Großes Modell (3B+, interessant)

Die Abhängigkeiten zwischen den Diensten sind unidirektional: der Adapter hängt von Emerald ab, Emerald hängt von Sapphire ab, Sapphire hängt von Krystal ab. Kein Zyklus. Jeder Dienst kann unabhängig neu gestartet werden.


Fazit

Luna Protocol in vier Schichten aufzuteilen war nicht nur eine architektonische Übung. Es war eine Antwort auf konkrete Einschränkungen: die Unfähigkeit, Matrix zu unterstützen, das Fehlen emotionalen Bewusstseins, das Fehlen intelligenter Nachrichtenpriorisierung.

Heute ist das System robuster (ein LLM-Absturz 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.

Embedding-Centroids sind das Schlüsselelement, das all das ohne übermäßige Komplexität ermöglicht: kein trainiertes neuronales Netzwerk, keine gelabelte Datenpipeline, nur Vektordurchschnitte und Kosinus-Ähnlichkeiten. Es ist eine einfache Technik, unglaublich effektiv und schrecklich unterschätzt.

Ressource Link
Projekt-Website 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 erschaffen
Artikel 2: das Fine-Tuning Luna Protocol: warum ich ein 1,5B-Modell fine-getunt habe

Luna Protocol: общие «мозги», классификация эмоций и маршрутизация интересно/бесполезно

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

Luna Protocol: общие «мозги», классификация эмоций и маршрутизация интересно/бесполезно

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

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

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

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

Эта статья рассказывает, как и почему я разделил эти уровни, что именно делает каждый сервис, и как такие концепции, как центроиды (средние векторы эмбеддингов) и переменные обиды (вдохновлённые чат-ботом PARRY 1970-х годов), превратили простого 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
  [BRAIN]         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 синхронизацией. 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 и отвечает за:

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

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

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

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


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

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

  1. Бинарный классификатор БЕСПОЛЕЗНО / ИНТЕРЕСНО через центроиды эмбеддингов
  2. Оценщик эмоций (валентность / возбуждение) через центроиды
  3. Маршрутизатор бэкендов к Krystal (маленькая модель против большой)
  4. Инжектор few-shot и менеджер сессий

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

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

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

  • futile_centroid: среднее эмбеддингов ~683 тривиальных сообщений via k-means (k=10, seed=42) ("lol", "ok", "hello", "nm just chillin u")
  • interesting_centroid: среднее эмбеддингов ~678 содержательных сообщений (технические вопросы, откровения, философия)

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

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

Косинусное сходство между сообщением и каждым центроидом определяет категорию. Абсолютная разница даёт уверенность. Это просто, быстро (без forward pass LLM) и удивительно эффективно.

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

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

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

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

Такая экономная маршрутизация значительно снижает нагрузку на LLM-сервер: около 70% сообщений классифицируются как БЕСПОЛЕЗНЫЕ и обрабатываются маленькой моделью, освобождая большую модель для разговоров, которые действительно того стоят.

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

Но это ещё не всё. 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]

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

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

Именно здесь вдохновение от PARRY становится осязаемым. PARRY (создан Кеннетом Колби в 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% нового сигнала. Это создаёт эмоциональную память, сглаживающую резкие колебания: одно негативное сообщение не делает бота «грустным», но серия негативных сообщений постепенно смещает его настроение.

На практике: если кто-то начинает разговор очень возбуждённо (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. 200 тысяч примеров -- это подмножество датасета 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.
    • Классификация: сообщение ближе к центроиду interesting, чем к центроиду futile (diff = +0.31) -> ИНТЕРЕСНО
    • Эмоция: негативная валентность (-0.42), умеренное возбуждение (0.35)
    • Маршрутизация: направление KRYSTAL_SEMANTIC_URL (порт 3125, большая модель)
    • Параметры сэмплирования: temperature = 0.80 (возбуждение повышено), repeat_penalty = 1.19 (негативная валентность)
    • Эмоциональное состояние сессии обновляется этими значениями
  4. Krystal (большой экземпляр) генерирует ответ с эмоционально скорректированными параметрами и возвращает его в Sapphire.
  5. Sapphire передаёт ответ потоком в Emerald вместе с метаданными (метка, валентность, возбуждение, отладочная статистика).
  6. Emerald решает добавить колебание ("oh..."), планирует burst (2 фрагмента), и выбирает реакцию. Отправляет RespondCommand в Jade.
  7. Jade исполняет: ждёт начальную задержку, отправляет первый фрагмент с колебанием, ждёт 1.5с, отправляет второй фрагмент. Показывает индикатор набора текста на протяжении всей генерации.

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


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

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

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

  • Требует размеченных обучающих данных
  • Чувствителен к смещению распределения (data drift)
  • Трудно интерпретируется
  • Требует переобучения для добавления нового класса

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

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

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

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

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

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

Центроид входящего сообщения перемещается в этом пространстве в зависимости от его содержания. Классификация БЕСПОЛЕЗНО/ИНТЕРЕСНО заключается просто в измерении, какой центроид ближе по косинусному сходству. Таким образом каждое сообщение можно представить как точку в многомерном пространстве, где каждое измерение соответствует семантическому свойству.


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

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

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

Переход на 200 тысяч обучающих примеров для маленькой модели ещё усилил эти эффекты: модель лучше улавливает разнообразие разговоров в 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 под воспринимаемое эмоциональное состояние разговора.

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

Ресурс Ссылка
Сайт проекта 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,5 млрд параметров

Luna Protocol: cerebros compartidos, clasificación emocional y enrutamiento interesante/fútil

Luna Protocol pasó de ser un monolito a una arquitectura de cuatro capas: adaptadores, cerebro, clasificador emocional e inferencia. En el menú: centroides de embeddings, enrutamiento interesante/fútil, y ajuste de los parámetros del LLM según valencia y activación.

Luna Protocol: cerebros compartidos, clasificación emocional y enrutamiento interesante/fútil

En los dos artículos anteriores presenté Luna Protocol como un único bot de Discord con un sistema de comportamiento complejo y un modelo afinado (fine-tuned). Pero la arquitectura ha evolucionado mucho desde entonces. Lo que era un monolito -- un único proceso de Node.js que gestionaba el bot de Discord, el comportamiento y las llamadas al LLM -- se ha transformado en cuatro capas independientes, cada una con su propia responsabilidad, su propio lenguaje y su propio ciclo de vida.

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

La evolución no ocurrió de golpe -- siguió un camino orgánico. Primero separé la carpeta server/ del repositorio del bot, creando así Krystal por un lado y dejando Jade como adaptador de Discord. Luego creé Pixieglow (adaptador de Matrix) reutilizando el llm-core y el bus de eventos de Jade. Después llegó Sapphire, que introdujo una clasificación GENERIC/SEMANTIC con DistilBERT -- pero los resultados no eran convincentes, así que pasé a centroides de embeddings, más maleables para enriquecer ejemplos y más precisos; la clasificación pasó a ser FÚTIL/INTERESANTE. Finalmente añadí centroides de valencia y activación para regular la temperatura y el repeat penalty del LLM. Para terminar, eliminé todo el código redundante entre Jade y Pixieglow creando Emerald, el cerebro compartido, convirtiendo a Jade y Pixieglow en simples clientes dirigidos por sockets.

En paralelo, he mantenido actualizado un sitio web que documenta el avance del proyecto: protocol-luna.github.io.

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


El problema con el monolito

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

  • La conexión a Discord (mediante la biblioteca Eris)
  • La evaluación de los disparadores (menciones, palabras clave, follow-ups...)
  • La simulación de comportamientos humanos (errores tipográficos, dudas, sueño...)
  • Las llamadas HTTP al servidor LLM local (llama.cpp)
  • La gestión de sesiones y el anti-spam
  • El pipeline de TTS

Todo estaba en el mismo proceso, comunicándose mediante buses de eventos tipados (TypedBus). Funcionaba, pero con limitaciones:

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

Dividir en capas resolvió todos estos problemas.


Las cuatro capas

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

Matrix / Discord
      |
      v
  [ADAPTADORES]   Pixieglow (Matrix) / Jade (Discord)
      |
      v
  [CEREBRO]       Emerald (WebSocket, puerto 3126)
      |
      v
  [CLASIFICADOR]  Sapphire (HTTP, puerto 3123)
      |
      v
  [INFERENCIA]    Krystal (llama.cpp, puertos 3124 / 3125)

Cada capa puede reiniciarse, actualizarse o reemplazarse de forma independiente.


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 a un protocolo estandarizado hacia Emerald:

  • Jade es el adaptador de Discord. Usa la biblioteca Eris para conectarse a Discord y reenvía los mensajes a Emerald vía WebSocket. También gestiona el pipeline de TTS (síntesis de voz vía Piper, conversión a OGG, subida a Discord).
  • Pixieglow es el adaptador de Matrix. Usa directamente la API HTTP Client-Server de Matrix (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 demuestra que la compartición funciona: el mismo "cerebro" (Emerald) sirve indistintamente a un bot de Discord y a un bot de Matrix, con comportamientos idénticos. El protocolo es declarativo: Emerald no le dice al adaptador cómo enviar un mensaje, le dice qué enviar (el texto con un retardo, posiblemente un plan de ráfaga, 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 a Telegram, Signal, o cualquier otra plataforma, basta con 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 vía WebSocket y gestiona:

  • La evaluación de disparadores: mención, DM, nombre, palabra clave, follow-up, aleatorio
  • La simulación de comportamiento: retardos de concentración, errores tipográficos, dudas, olvidos, ráfagas, fatiga temática
  • Los ciclos de sueño: modos sleep / slow / short
  • La gestión de sesiones: cooldown, límites de sesión, anti-spam
  • El enrutamiento hacia Sapphire: envío de mensajes, recepción de respuestas en streaming

Emerald es el servicio central que permitió la compartición, y el que más se benefició de la separación. Antes, cada comportamiento (error tipográfico, ráfaga, duda) estaba entrelazado con el código de Discord. Ahora están en módulos dedicados dentro de behavior/:

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

El cerebro no sabe en qué plataforma está corriendo. 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 técnicamente más interesante. Es un middleware de LLM escrito en Python con FastAPI, que cumple cuatro roles críticos:

  1. Clasificador binario FÚTIL / INTERESANTE vía centroides de embeddings
  2. Puntuador emocional (valencia / activación) vía centroides
  3. Enrutador de backends hacia Krystal (modelo pequeño vs modelo grande)
  4. Inyector few-shot y gestor de sesiones

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

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

Hay dos centroides de clasificación:

  • futile_centroid: el promedio de los embeddings de ~683 mensajes triviales via k-means (k=10, seed=42) ("lol", "ok", "hello", "nm just chillin u")
  • interesante_centroid: el promedio de los embeddings de ~678 mensajes sustanciales (preguntas técnicas, confidencias, filosofía)

Cuando llega un mensaje:

def classify(text, embedder, futile_centroids, interesante_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 interesante_centroids)     # max over 10
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

La similitud coseno entre el mensaje y cada centroide determina la categoría. La diferencia absoluta da la confianza. Es simple, rápido (sin forward pass de LLM) y sorprendentemente eficaz.

¿Por qué dos modelos?

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

Etiqueta Backend Krystal Modelo Puerto
FUTIL generic Luna-Protocol-1.5B (941 MB, Q4_K_M) 3124
INTERESANTE semantic Hermes-3-3B u 8B (según configuración) 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 Luna 1.5B afinado, entrenado con 200.000 muestras de Discord, es más que suficiente para intercambios ligeros. En cambio, una pregunta sobre la vida, una confidencia o un debate técnico se enruta hacia el modelo grande, que puede producir una respuesta más rica.

Este enrutamiento económico reduce considerablemente la carga en el servidor LLM: alrededor del 70% de los mensajes se clasifican como FÚTIL y son gestionados por el modelo pequeño, liberando al modelo grande para las conversaciones que realmente lo merecen.

El eje emocional: valencia y activación

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

Hay cuatro centroides emocionales:

Polo Ejemplos
positivo "hell yeah", "love that", "this is great"
negativo "shut up", "i hate this", "this sucks"
activación alta "WHAT THE HELL", "omg omg omg", "AAAAA"
activación baja "just chilling", "meh", "i guess"

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

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

La valencia mide si el mensaje es positivo o negativo. La activación mide su intensidad emocional. Juntas forman el modelo circumplejo del afecto (Russell, 1980) -- el mismo modelo psicológico que inspiró al chatbot PARRY en 1972.

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

Aquí es donde la inspiración de PARRY se vuelve tangible. PARRY (creado por Kenneth Colby en 1972) era un chatbot diseñado para simular a un paciente paranoico. Poseí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 a la activación
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Activación Temperatura Efecto
-1.0 (calmado) 0.40 Baja creatividad, respuestas predecibles
0.0 (neutral) 0.70 Creatividad por defecto
+1.0 (excitado) 1.00 Máxima aleatoriedad, respuestas sorprendentes

Cuando alguien está excitado o molesto (activación alta), la temperatura sube. El modelo produce respuestas más variadas, más creativas, a veces más caóticas -- como un humano que "se deja llevar". Cuando la conversación está calmada, la temperatura baja y las respuestas son más sosegadas.

El repeat penalty sigue a la valencia
repeat_penalty = clamp(1.15 - valence * 0.1, 1.0, 1.3)
Valencia Repeat Penalty Efecto
-1.0 (negativa) 1.25 Penalización fuerte, evita repeticiones
0.0 (neutral) 1.15 Valor por defecto
+1.0 (positiva) 1.05 Penalización baja, permite repeticiones

Cuanto más negativa es 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 es la conversación, más puede el modelo permitirse afirmaciones redundantes, como en una charla relajada.

El estado emocional acumulativo

Estas puntuaciones no se aplican solo al mensaje inmediato. Un EmotionState mantiene una media móvil exponencial de valencia y activación 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 de 0.85 significa que el 85% del estado anterior se conserva en cada mensaje, y el 15% de la nueva señal se integra. Esto da una memoria emocional que suaviza las variaciones bruscas: un único mensaje negativo no pone "triste" al bot, pero una serie de mensajes negativos hace que su humor derive progresivamente.

En la práctica: si alguien empieza una conversación muy excitado (arousal=+0.8), la temperatura se mantiene alta durante varios intercambios, incluso si los mensajes siguientes son más calmados. La emoción tarda en bajar -- como un humano que sigue "acalorado" 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 con OpenAI (/v1/chat/completions). Corre en dos instancias de PM2:

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

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

El fine-tune del modelo Luna también ha evolucionado desde el segundo artículo: ahora está entrenado con 200.000 muestras (frente a las 50.000 anteriores), aún partiendo de Qwen2.5-1.5B-Instruct vía QLoRA. Las 200k muestras son un subconjunto del dataset Discord-Dialogues, filtradas para conservar solo las conversaciones más naturales y diversas. El objetivo: ampliar el registro estilístico del modelo sin perder la flexibilidad que hace que el few-shot priming sea tan eficaz.


El esquema completo: un mensaje en tránsito

Esto es lo que ocurre concretamente cuando alguien envía "hoy estoy realmente triste" en Discord:

  1. Jade recibe el mensaje vía la API Gateway de Discord. Lo transforma en un 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, verifica el cooldown, la sesión, la fatiga temática. Decide responder y envía el mensaje a Sapphire vía HTTP.
  3. Sapphire embebe el mensaje con bge-small-en-v1.5.
    • Clasificación: el mensaje está más cerca del centroide interesante que del centroide futil (diff = +0.31) -> INTERESANTE
    • Emoción: valencia negativa (-0.42), activación moderada (0.35)
    • Enrutamiento: dirección KRYSTAL_SEMANTIC_URL (puerto 3125, modelo grande)
    • Parámetros de muestreo: temperatura = 0.80 (activación aumentada), repeat_penalty = 1.19 (valencia negativa)
    • El estado emocional de la sesión se actualiza con estos valores
  4. Krystal (instancia grande) genera la respuesta con los parámetros ajustados emocionalmente y la devuelve a Sapphire.
  5. Sapphire transmite la respuesta hacia Emerald con los metadatos (etiqueta, valencia, activación, estadísticas de depuración).
  6. Emerald decide añadir una duda ("oh..."), planifica una ráfaga (2 fragmentos), y elige una reacción. Envía un RespondCommand a Jade.
  7. Jade ejecuta: espera el retardo inicial, envía el primer fragmento con la duda, espera 1.5s, envía el segundo fragmento. Muestra el indicador de escritura durante toda la generación.

Todo esto en menos de 3 segundos para el usuario.


Los centroides: por qué son mejores que un clasificador neuronal

La elección de centroides de embeddings frente a 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
  • Debe reentrenarse para añadir una nueva clase

Un centroide, en cambio, es un vector promedio de embeddings de ejemplos. La clasificación se hace por similitud coseno con ese vector promedio. Ventajas:

  • Sin entrenamiento: solo se calcula el promedio de embeddings de ejemplos elegidos a mano
  • Fácil de interpretar: se puede ver qué ejemplos están más cerca del centroide para entender "qué ha aprendido el centroide"
  • Añadir una clase: solo se añade un nuevo centroide -- sin reentrenamiento
  • Robusto: el centroide es un promedio, así que los valores atípicos tienen poco impacto

El verdadero poder de los centroides es que convierten un problema de clasificación en un problema de medición de distancia espacial. Se pueden visualizar las categorías como regiones en un espacio de 384 dimensiones (o en 2D/3D tras una reducción dimensional PCA/t-SNE).

Visualización 3D de los centroides

En la práctica, así es como se ven los centroides de clasificación en el espacio de embeddings. Cada punto es un mensaje de ejemplo, proyectado en 3D mediante PCA (las 384 dimensiones originales se reducen a 3 para la visualización). Los puntos azules son mensajes fútiles, los puntos amarillos son mensajes interesantes. Los 20 marcadores de diamante son los centroides k-means (10 por clase) son los centroides calculados -- el promedio de cada grupo. Pase el ratón sobre un punto para ver el texto original del ejemplo.

Dos ejemplos se muestran en rojo: "lol" (clasificado como fútil) e "i feel sad today" (clasificado como interesante). "lol" cae en la nube azul de los fútiles, mientras que "i feel sad today" se sitúa del lado de los puntos amarillos. La separación es visible incluso tras una reducción a 3 dimensiones (solo el 14,7% de la varianza total explicada). En 384 dimensiones, la frontera es mucho más nítida.

El centroide del mensaje de entrada se mueve por este espacio en función de su contenido. La clasificación FÚTIL/INTERESANTE consiste simplemente en medir qué centroide está más cerca por similitud coseno. Así se puede representar cada mensaje como un punto en un espacio multidimensional, donde cada dimensión corresponde a una propiedad semántica.


Lo que esto cambia en la práctica

Los usuarios no ven las capas, los centroides ni los ajustes de temperatura. Pero sienten los efectos:

  • Respuestas más rápidas para mensajes simples (el modelo pequeño es 2 veces más rápido y gestiona el 70% del tráfico)
  • Tono adaptativo: si estás molesto, el bot "siente" el enfado y adapta su estilo
  • Coherencia entre plataformas: un bot de Matrix y un bot de Discord comparten el mismo cerebro y el mismo estado emocional
  • Sin "modo asistente": el fine-tune + few-shot + enrutamiento inteligente evita las respuestas corporativas

El paso a 200k muestras de entrenamiento para el modelo pequeño reforzó aún más estos efectos: el modelo captura mejor la diversidad de las conversaciones de Discord sin perder la maleabilidad que permite el few-shot priming.


La infraestructura completa

Estos son los servicios que están corriendo actualmente:

Servicio Tecnología Puerto(s) Rol
Pixieglow TypeScript (Bun) -- Adaptador de Matrix
Jade TypeScript (esbuild) -- Adaptador de 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, fútil)
Krystal large llama.cpp (PM2) 3125 Modelo grande (3B+, interesante)

Las dependencias entre servicios son unidireccionales: el adaptador depende de Emerald, Emerald depende de Sapphire, Sapphire depende de Krystal. Sin ciclos. Cada servicio puede reiniciarse de forma independiente.


Conclusión

Dividir Luna Protocol en cuatro capas no fue solo un ejercicio de arquitectura. Fue una respuesta a limitaciones concretas: la imposibilidad de soportar Matrix, la falta de conciencia emocional, la ausencia de priorización inteligente de mensajes.

Hoy, el sistema es más robusto (un fallo del LLM no mata al bot), más extensible (un adaptador de 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 centroides de embeddings son la pieza clave que hace posible todo esto sin complejidad excesiva: sin redes neuronales entrenadas, sin pipeline de datos etiquetados, solo promedios 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 de Discord Luna Protocol: creé un bot de Discord autónomo
Artículo 2: el fine-tuning Luna Protocol: por qué afiné 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
  • Sem classificação inteligente: cada mensagem era tratada da mesma forma, seja um "lol" ou uma pergunta existencial
  • Sem estado emocional persistente: 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-as 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: a média dos embeddings de ~683 mensagens triviais via k-means (k=10, seed=42) ("lol", "ok", "hello", "nm just chillin u")
  • interessante_centroid: a média dos embeddings de ~678 mensagens substanciais (perguntas técnicas, confidências, filosofia)

Quando uma mensagem chega:

def classify(text, embedder, futile_centroids, interessante_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 interessante_centroids)     # max over 10
    diff = sim_i - sim_f
    label = "INTERESSANT" if diff > 0 else "FUTILE"
    return label, abs(diff), sim_f, sim_i

A similaridade de cosseno entre a mensagem e cada centroide determina a categoria. A diferença absoluta dá a confiança. É simples, rápido (sem forward pass do LLM), e surpreendentemente eficaz.

Por que dois modelos?

O resultado dessa classificação decide qual backend de LLM é invocado:

Rótulo Backend Krystal Modelo Porta
FUTIL generic Luna-Protocol-1.5B (941 MB, Q4_K_M) 3124
INTERESSANTE semantic Hermes-3-3B ou 8B (conforme configuração) 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
positivo "hell yeah", "love that", "this is great"
negativo "shut up", "i hate this", "this sucks"
ativação alta "WHAT THE HELL", "omg omg omg", "AAAAA"
ativação baixa "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, é assim que os centroides de classificação se parecem 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 mensagens fúteis, os pontos amarelos são mensagens interessantes. Os 20 marcadores de diamante são os centroides k-means (10 por classe) são os centroides calculados -- a média de cada grupo. Passe o mouse sobre um ponto para ver o texto original do exemplo.

Dois exemplos são exibidos em vermelho: "lol" (classificado como fútil) e "i feel sad today" (classificado como interessante). "lol" cai na nuvem azul dos fúteis, enquanto "i feel sad today" fica do lado dos pontos amarelos. A separação é visível mesmo após uma redução a 3 dimensões (apenas 14,7% da variância total explicada). Em 384 dimensões, a fronteira é bem mais nítida.

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: otak bersama, klasifikasi emosi, dan routing menarik/sia-sia

Luna Protocol berkembang dari monolit menjadi arsitektur empat lapis: adapter, brain, pengklasifikasi emosi, dan inference. Yang dibahas: centroid embedding, routing menarik/sia-sia, dan penyesuaian parameter LLM berdasarkan valence dan arousal.

Luna Protocol: otak bersama, klasifikasi emosi, dan routing menarik/sia-sia

Dalam dua artikel sebelumnya, saya memperkenalkan Luna Protocol sebagai satu bot Discord tunggal dengan sistem perilaku yang kompleks dan model yang telah di-fine-tune. Namun arsitekturnya telah berkembang jauh sejak saat itu. Yang dulunya sebuah monolit -- satu proses Node.js tunggal yang menangani bot Discord, perilaku, dan pemanggilan LLM -- kini telah berubah menjadi empat lapisan independen, masing-masing dengan tanggung jawabnya sendiri, bahasanya sendiri, dan siklus hidupnya sendiri.

Pemisahan ini membawa manfaat tak terduga: berbagi "otak" lintas beberapa platform, sistem klasifikasi emosi yang secara dinamis menyesuaikan parameter LLM, dan routing pintar pesan antara dua model berdasarkan tingkat kepentingan percakapan yang dirasakan.

Evolusi ini tidak terjadi sekaligus -- ia mengikuti jalur yang organik. Saya pertama kali memisahkan folder server/ dari repo bot, menciptakan Krystal di satu sisi dan membiarkan Jade sebagai adapter Discord. Kemudian saya membuat Pixieglow (adapter Matrix) dengan menggunakan kembali llm-core dan event bus milik Jade. Selanjutnya muncul Sapphire, yang memperkenalkan klasifikasi GENERIC/SEMANTIC dengan DistilBERT -- tetapi hasilnya kurang meyakinkan, sehingga saya beralih ke centroid embedding, yang lebih fleksibel untuk memperkaya contoh dan lebih akurat; klasifikasinya pun menjadi FUTILE/INTERESTING (sia-sia/menarik). Akhirnya saya menambahkan centroid valence dan arousal untuk mengatur temperature dan repeat penalty LLM. Terakhir, saya menghapus semua kode redundan antara Jade dan Pixieglow dengan membuat Emerald, otak bersama, yang mengubah Jade dan Pixieglow menjadi klien sederhana berbasis socket.

Selain itu, saya terus memperbarui sebuah situs web yang melacak kemajuan proyek: protocol-luna.github.io.

Artikel ini menceritakan bagaimana dan mengapa saya memisahkan lapisan-lapisan ini, apa sebenarnya yang dilakukan tiap layanan, dan bagaimana konsep seperti centroid (vektor embedding rata-rata) dan variabel resentment (terinspirasi dari chatbot PARRY tahun 1970-an) mengubah bot Discord sederhana menjadi sistem multi-platform yang mengejutkan koherensinya.


Masalah dengan monolit

Awalnya, Luna Protocol muat dalam satu proses Node.js tunggal. Kode tersebut menangani:

  • Koneksi Discord (melalui library Eris)
  • Evaluasi trigger (mention, kata kunci, follow-up...)
  • Simulasi perilaku manusia (typo, keraguan, tidur...)
  • Panggilan HTTP ke server LLM lokal (llama.cpp)
  • Manajemen sesi dan anti-spam
  • Pipeline TTS

Semuanya berada dalam proses yang sama, berkomunikasi melalui event bus bertipe (TypedBus). Ini berfungsi, tapi dengan keterbatasan:

  • Tidak mungkin menambahkan klien Matrix tanpa menduplikasi semua kode perilaku
  • LLM dan bot berada dalam repo yang sama: folder server/ sudah ada, tetapi Anda tidak bisa mengembangkan satu tanpa menyentuh yang lain
  • Tidak ada klasifikasi pintar: setiap pesan diperlakukan sama, entah itu "lol" atau pertanyaan eksistensial
  • Tidak ada status emosional yang persisten: bot tidak "merasakan" apa pun

Pemisahan menjadi lapisan-lapisan menyelesaikan semua masalah ini.


Empat lapisan

Arsitektur Luna Protocol saat ini diatur sebagai corong empat tingkat:

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, port 3124 / 3125)

Setiap lapisan dapat di-restart, diperbarui, atau diganti secara independen.


Lapisan 1: adapter (Pixieglow dan Jade)

Ini adalah lapisan paling sederhana. Satu-satunya tugas mereka adalah menerjemahkan event dari platform pesan menjadi protokol standar menuju Emerald:

  • Jade adalah adapter Discord. Ia menggunakan library Eris untuk terhubung ke Discord dan meneruskan pesan ke Emerald melalui WebSocket. Ia juga menangani pipeline TTS (sintesis suara via Piper, konversi OGG, upload ke Discord).
  • Pixieglow adalah adapter Matrix. Ia menggunakan Matrix Client-Server HTTP API secara langsung (tanpa SDK), dengan long-poll sync. Ia tidak memiliki TTS.

Kedua adapter berbagi protokol WebSocket yang sama yang didefinisikan di emerald-client.ts:

type ClientId = "jade" | "pixieglow";

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

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

Keberadaan dua adapter dengan interface yang sama membuktikan bahwa berbagi otak benar-benar berhasil: "otak" yang sama (Emerald) melayani bot Discord dan bot Matrix tanpa perbedaan, dengan perilaku yang identik. Protokolnya bersifat deklaratif: Emerald tidak memberi tahu adapter bagaimana mengirim pesan, tetapi memberi tahu apa yang harus dikirim (teks dengan delay, mungkin rencana burst, reaksi, dll). Setiap adapter mengimplementasikan eksekusi konkret untuk platformnya masing-masing.

Itulah kekuatan arsitektur ini: untuk menambahkan dukungan Telegram, Signal, atau apa pun lainnya, Anda hanya perlu menulis sebuah adapter yang mengimplementasikan protokol WebSocket.

Otak tidak tahu di platform mana ia berjalan. Ia menerima MessageEvent dengan sebuah clientId ("jade" atau "pixieglow"), membuat keputusan, dan mengembalikan sebuah perintah. Adapter menangani sisanya.


Lapisan 2: otak (Emerald)

Emerald adalah layanan pengambilan keputusan pusat. Ia mendengarkan di port 3126 melalui WebSocket dan menangani:

  • Evaluasi trigger: mention, DM, nama, kata kunci, follow-up, acak
  • Simulasi perilaku: delay fokus, typo, keraguan, kelupaan, burst, kelelahan topik
  • Siklus tidur: mode sleep / slow / short
  • Manajemen sesi: cooldown, batas sesi, anti-spam
  • Routing ke Sapphire: mengirim pesan, menerima respons yang di-stream

Emerald adalah layanan pusat yang memungkinkan berbagi otak, dan menjadi yang paling diuntungkan dari pemisahan ini. Sebelumnya, setiap perilaku (typo, burst, keraguan) terjalin dengan kode Discord. Sekarang mereka berada dalam modul khusus di bawah behavior/:

emerald/src/behavior/
  burst.ts         -- Perencanaan pesan burst
  mannerisms.ts    -- Delay, keraguan, reaksi, kelupaan
  sleep.ts         -- Evaluasi jadwal tidur
  typo.ts          -- Simulasi typo (AZERTY/QWERTY)

Lapisan 3: pengklasifikasi emosi (Sapphire)

Sapphire adalah layanan yang paling menarik secara teknis. Ia adalah middleware LLM yang ditulis dalam Python dengan FastAPI, memainkan empat peran penting:

  1. Pengklasifikasi biner FUTILE / INTERESTING melalui centroid embedding
  2. Penilai emosi (valence / arousal) melalui centroid
  3. Router backend ke Krystal (model kecil vs model besar)
  4. Penyuntik few-shot dan manajer sesi

Centroid: jantung dari klasifikasi

Centroid adalah konsep sederhana: rata-rata dari sekumpulan vektor embedding. Secara konkret, saya mengumpulkan ratusan contoh pesan, memprosesnya melalui model embedding (BAAI/bge-small-en-v1.5, 384 dimensi), dan merata-ratakan vektor hasilnya.

Ada dua centroid klasifikasi:

  • futile_centroid: embedding rata-rata dari ~683 pesan sepele via k-means (k=10, seed=42) ("lol", "ok", "hello", "nm just chillin u")
  • interesting_centroid: embedding rata-rata dari ~678 pesan substansial (pertanyaan teknis, pengakuan, filosofi)

Ketika sebuah pesan masuk:

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

Kemiripan kosinus antara pesan dan tiap centroid menentukan kategorinya. Selisih absolutnya memberikan tingkat kepercayaan. Ini sederhana, cepat (tanpa forward pass LLM), dan mengejutkan efektivitasnya.

Mengapa dua model?

Hasil klasifikasi ini menentukan backend LLM mana yang dipanggil:

Label Backend Krystal Model Port
FUTILE generic Luna-Protocol-1.5B (941 MB, Q4_K_M) 3124
INTERESTING semantic Hermes-3-3B atau 8B (tergantung konfigurasi) 3125

Intuisinya sederhana: "lol" atau "nm just chillin u" tidak pantas memanggil model dengan delapan miliar parameter. Model Luna 1.5B kecil yang telah di-fine-tune, dilatih pada 200.000 sampel Discord, lebih dari cukup untuk percakapan ringan. Sebaliknya, pertanyaan tentang kehidupan, pengakuan, atau debat teknis dirutekan ke model besar, yang dapat menghasilkan respons yang lebih kaya.

Routing yang hemat ini secara signifikan mengurangi beban pada server LLM: sekitar 70% pesan diklasifikasikan sebagai FUTILE dan ditangani oleh model kecil, membebaskan model besar untuk percakapan yang benar-benar layak.

Sumbu emosional: valence dan arousal

Namun itu belum semuanya. Sapphire menggunakan mekanisme centroid yang sama pada sumbu independen untuk mengevaluasi 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 selisih kemiripan pada setiap 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 bersifat positif atau negatif. Arousal mengukur intensitas emosionalnya. Bersama-sama, keduanya membentuk model circumplex afek (Russell, 1980) -- model psikologis yang sama yang menginspirasi chatbot PARRY pada tahun 1972.

Variabel resentment: bagaimana emosi mengendalikan LLM

Di sinilah inspirasi PARRY menjadi nyata. PARRY (dibuat oleh Kenneth Colby pada 1972) adalah chatbot yang dirancang untuk mensimulasikan pasien paranoid. Ia memiliki variabel internal -- ketakutan, kemarahan, ketidakpercayaan -- yang mengubah responsnya. Misalnya, PARRY yang "ketakutan" akan merespons lebih agresif.

Sapphire melakukan hal yang sama, tetapi dengan variabel kontinu dan metode yang lebih elegan: parameter sampling LLM disesuaikan secara real-time berdasarkan status emosional percakapan.

Temperature mengikuti arousal
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
Arousal Temperature Efek
-1.0 (tenang) 0.40 Kreativitas rendah, respons yang dapat diprediksi
0.0 (netral) 0.70 Kreativitas default
+1.0 (bersemangat) 1.00 Keacakan maksimum, respons yang mengejutkan

Ketika seseorang bersemangat atau kesal (arousal tinggi), temperature naik. Model menghasilkan respons yang lebih beragam, lebih kreatif, kadang lebih kacau -- seperti manusia yang "terbawa suasana". Ketika percakapan tenang, temperature turun, dan respons menjadi lebih terukur.

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 default
+1.0 (positif) 1.05 Penalti rendah, mengizinkan pengulangan

Semakin negatif percakapan, semakin model didorong untuk menghindari pengulangan dirinya sendiri -- seperti seseorang yang mencari kata-kata dalam perdebatan yang tegang. Semakin positif percakapan, semakin model dapat menerima pernyataan yang berulang, seperti percakapan yang santai.

Status emosional kumulatif

Skor-skor ini tidak hanya berlaku untuk pesan langsung. Sebuah EmotionState menjaga rata-rata bergerak eksponensial dari 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 sebesar 0,85 berarti 85% status sebelumnya dipertahankan di setiap pesan, dengan 15% sinyal baru diintegrasikan. Ini menciptakan memori emosional yang meredam perubahan mendadak: satu pesan negatif tidak membuat bot "sedih", tetapi serangkaian pesan negatif secara bertahap menggeser suasana hatinya.

Dalam praktiknya: jika seseorang memulai percakapan dengan sangat bersemangat (arousal=+0.8), temperature tetap tinggi selama beberapa pertukaran berikutnya, bahkan jika pesan-pesan selanjutnya lebih tenang. Emosi butuh waktu untuk mereda kembali -- seperti manusia yang tetap "panas" setelah bertengkar.


Lapisan 4: inference (Krystal)

Krystal adalah lapisan paling bawah: sebuah wrapper di sekitar llama.cpp yang mengekspos API yang kompatibel dengan OpenAI (/v1/chat/completions). Ia berjalan sebagai dua instance PM2:

  • krystal-small: model Luna 1.5B yang telah di-fine-tune, di port 3124, dengan CPU affinity 0
  • krystal-large: model Hermes 3B, di port 3125, dengan CPU affinity 0,1

Kedua instance adalah proses llama-server yang telah dikompilasi sebelumnya, dijalankan dengan taskset untuk CPU pinning.

Fine-tune model Luna juga telah berkembang sejak artikel kedua: kini dilatih pada 200.000 sampel (naik dari 50.000 sebelumnya), masih dimulai dari Qwen2.5-1.5B-Instruct melalui QLoRA. 200 ribu sampel tersebut adalah subset dari dataset Discord-Dialogues, difilter untuk hanya menyimpan percakapan yang paling alami dan beragam. Tujuannya: memperluas jangkauan gaya model tanpa kehilangan fleksibilitas yang membuat few-shot priming begitu efektif.


Gambaran lengkap: sebuah pesan dalam perjalanan

Berikut adalah yang sebenarnya terjadi ketika seseorang mengirim "i'm really sad today" di Discord:

  1. Jade menerima pesan melalui Discord Gateway API. Ia mengubahnya menjadi MessageEvent dan mengirimkannya ke Emerald melalui WebSocket.
  2. Emerald mengevaluasi trigger (mention? nama? kata kunci?). Ini adalah mention langsung. Ia menghitung delay fokus, memeriksa cooldown, sesi, kelelahan topik. Ia memutuskan untuk merespons dan mengirim pesan ke Sapphire melalui HTTP.
  3. Sapphire meng-embed pesan dengan bge-small-en-v1.5.
    • Klasifikasi: pesan lebih dekat ke centroid interesting daripada centroid futile (diff = +0.31) -> INTERESTING
    • Emosi: valence negatif (-0.42), arousal sedang (0.35)
    • Routing: arah KRYSTAL_SEMANTIC_URL (port 3125, model besar)
    • Parameter sampling: temperature = 0.80 (arousal meningkat), repeat_penalty = 1.19 (valence negatif)
    • Status emosional sesi diperbarui dengan nilai-nilai ini
  4. Krystal (instance besar) menghasilkan respons dengan parameter yang telah disesuaikan secara emosional dan mengirimkannya kembali ke Sapphire.
  5. Sapphire men-stream respons ke Emerald bersama metadata (label, valence, arousal, statistik debug).
  6. Emerald memutuskan untuk menambahkan keraguan ("oh..."), merencanakan burst (2 fragmen), dan memilih reaksi. Ia mengirim RespondCommand ke Jade.
  7. Jade mengeksekusi: menunggu delay awal, mengirim fragmen pertama dengan keraguan, menunggu 1,5 detik, mengirim fragmen kedua. Ia menampilkan indikator mengetik selama proses generasi berlangsung.

Semua ini terjadi dalam waktu kurang dari 3 detik bagi pengguna.


Centroid: mengapa lebih baik daripada pengklasifikasi neural

Pilihan menggunakan centroid embedding dibandingkan pengklasifikasi tradisional (seperti DistilBERT yang saya gunakan sebelumnya) layak untuk dijelaskan.

Sebuah pengklasifikasi neural mempelajari batas keputusan antar kelas -- biasanya transformasi non-linear yang memetakan input menjadi probabilitas. Ini akurat, tetapi:

  • Membutuhkan data pelatihan berlabel
  • Sensitif terhadap pergeseran distribusi (data drift)
  • Sulit diinterpretasikan
  • Perlu dilatih ulang untuk menambahkan kelas baru

Sebaliknya, centroid adalah vektor rata-rata dari embedding contoh. Klasifikasi dilakukan dengan kemiripan kosinus terhadap vektor rata-rata tersebut. Keuntungannya:

  • Tanpa pelatihan: Anda hanya perlu menghitung rata-rata embedding dari contoh-contoh yang dipilih secara manual
  • Mudah diinterpretasikan: Anda dapat melihat contoh mana yang paling dekat dengan centroid untuk memahami "apa yang telah dipelajari centroid tersebut"
  • Menambahkan kelas: Anda cukup menambahkan centroid baru -- tanpa perlu pelatihan ulang
  • Kuat: centroid adalah rata-rata, sehingga outlier memiliki dampak kecil

Kekuatan sejati dari centroid adalah mereka mengubah masalah klasifikasi menjadi masalah pengukuran jarak spasial. Anda dapat memvisualisasikan kategori sebagai wilayah dalam ruang 384 dimensi (atau dalam 2D/3D setelah reduksi dimensi PCA/t-SNE).

Visualisasi centroid 3D

Dalam praktiknya, berikut adalah tampilan centroid klasifikasi dalam ruang embedding. Setiap titik adalah contoh pesan, diproyeksikan dalam 3D melalui PCA (384 dimensi asli direduksi menjadi 3 untuk visualisasi). Titik biru adalah pesan sia-sia (futile), titik kuning adalah pesan menarik (interesting). Dua berlian besar adalah centroid yang telah dihitung -- rata-rata dari tiap kelompok. Arahkan kursor ke sebuah titik untuk melihat teks asli dari contoh tersebut.

Dua contoh ditampilkan dengan warna merah: "lol" (diklasifikasikan futile) dan "i feel sad today" (diklasifikasikan interesting). "lol" jatuh ke dalam awan biru pesan sia-sia, sementara "i feel sad today" berada di sisi titik-titik kuning. Pemisahan terlihat bahkan setelah direduksi menjadi 3 dimensi (hanya 14,7% dari total varian yang dijelaskan). Dalam 384 dimensi, batasnya jauh lebih tajam.

Centroid dari pesan input bergerak melalui ruang ini tergantung pada isinya. Klasifikasi FUTILE/INTERESTING sederhananya hanya mengukur centroid mana yang lebih dekat dengan kemiripan kosinus. Ini memungkinkan kita merepresentasikan setiap pesan sebagai titik dalam ruang multi-dimensi, dengan setiap dimensi sesuai dengan properti semantik.


Apa yang berubah dalam praktik

Pengguna tidak melihat lapisan-lapisan, centroid, atau penyesuaian temperature. Tetapi mereka merasakan efeknya:

  • Respons lebih cepat untuk pesan sederhana (model kecil 2x lebih cepat dan menangani 70% lalu lintas)
  • Nada adaptif: jika Anda kesal, bot "merasakan" iritasi tersebut dan menyesuaikan gayanya
  • Konsistensi lintas platform: bot Matrix dan bot Discord berbagi otak yang sama dan status emosional yang sama
  • Tanpa "mode asisten": fine-tune + few-shot + routing pintar menghindari respons yang terdengar seperti korporat

Meningkatkan set pelatihan model kecil menjadi 200.000 sampel semakin memperkuat efek-efek ini: model lebih baik menangkap keragaman percakapan Discord tanpa kehilangan fleksibilitas yang diberikan few-shot priming.


Infrastruktur lengkap

Berikut adalah layanan yang saat ini berjalan:

Layanan Teknologi Port Peran
Pixieglow TypeScript (Bun) -- Adapter Matrix
Jade TypeScript (esbuild) -- Adapter 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, futile)
Krystal large llama.cpp (PM2) 3125 Model besar (3B+, interesting)

Ketergantungan antar layanan bersifat satu arah: adapter bergantung pada Emerald, Emerald bergantung pada Sapphire, Sapphire bergantung pada Krystal. Tidak ada siklus. Setiap layanan dapat di-restart secara independen.


Kesimpulan

Memisahkan Luna Protocol menjadi empat lapisan bukan sekadar latihan arsitektur. Ini adalah respons terhadap keterbatasan konkret: ketidakmampuan mendukung Matrix, kurangnya kesadaran emosional, dan tidak adanya prioritas pesan yang cerdas.

Hari ini, sistem ini lebih tangguh (crash LLM tidak mematikan bot), lebih dapat diperluas (adapter Telegram atau WhatsApp akan mengikuti protokol WebSocket yang sama), dan lebih "hidup": bot menyesuaikan perilakunya, nadanya, dan bahkan parameter LLM-nya terhadap status emosional percakapan yang dirasakan.

Centroid embedding adalah bagian kunci yang membuat semua ini mungkin tanpa kompleksitas berlebihan: tidak ada jaringan neural yang dilatih, tidak ada pipeline data berlabel, hanya rata-rata vektor dan kemiripan kosinus. Ini adalah teknik yang 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 membangun bot Discord otonom
Artikel 2: fine-tuning Luna Protocol: mengapa saya melakukan fine-tune model 1,5B

लूना प्रोटोकॉल: साझा दिमाग, भावना वर्गीकरण, और दिलचस्प/निरर्थक रूटिंग

लूना प्रोटोकॉल एक मोनोलिथ से चार-परत वाली आर्किटेक्चर में बदल गया: एडाप्टर, ब्रेन, भावना वर्गीकरणकर्ता, और इनफेरेंस। इसमें शामिल है: एम्बेडिंग सेंट्रॉइड, दिलचस्प/निरर्थक रूटिंग, और वैलेंस व अराउज़ल के अनुसार LLM पैरामीटर ट्यूनिंग।

लूना प्रोटोकॉल: साझा दिमाग, भावना वर्गीकरण, और दिलचस्प/निरर्थक रूटिंग

पिछले दो लेखों में, मैंने लूना प्रोटोकॉल को एक जटिल व्यवहार प्रणाली और फाइन-ट्यून किए गए मॉडल वाले एकल Discord बॉट के रूप में प्रस्तुत किया था। लेकिन आर्किटेक्चर तब से बहुत विकसित हो चुका है। जो पहले एक मोनोलिथ था -- एक ही Node.js प्रक्रिया जो Discord बॉट, व्यवहार, और LLM कॉल्स को संभालती थी -- अब चार स्वतंत्र परतों में बदल गया है, जिनमें से प्रत्येक की अपनी ज़िम्मेदारी, अपनी भाषा, और अपना जीवनचक्र है।

इस विभाजन से अप्रत्याशित लाभ मिले: कई प्लेटफ़ॉर्मों में "दिमाग" साझा करना, एक भावना वर्गीकरण प्रणाली जो LLM के पैरामीटर को गतिशील रूप से ट्यून करती है, और बातचीत के अनुभवी महत्व के आधार पर दो मॉडलों के बीच संदेशों की स्मार्ट रूटिंग।

यह विकास एक साथ नहीं हुआ -- यह एक जैविक रास्ते पर चला। मैंने पहले बॉट के रिपॉजिटरी से server/ फ़ोल्डर को अलग किया, एक तरफ Krystal बनाया और Jade को Discord एडाप्टर के रूप में छोड़ दिया। फिर मैंने Jade के llm-core और इवेंट बस का पुन: उपयोग करके Pixieglow (Matrix एडाप्टर) बनाया। इसके बाद Sapphire आया, जिसने DistilBERT के साथ GENERIC/SEMANTIC वर्गीकरण पेश किया -- लेकिन परिणाम संतोषजनक नहीं थे, इसलिए मैंने एम्बेडिंग सेंट्रॉइड की ओर रुख किया, जो उदाहरणों को समृद्ध करने के लिए अधिक लचीले और अधिक सटीक हैं; वर्गीकरण FUTILE/INTERESTING (निरर्थक/दिलचस्प) बन गया। अंततः मैंने LLM के temperature और repeat penalty को नियंत्रित करने के लिए valence और arousal सेंट्रॉइड जोड़े। अंत में, मैंने Jade और Pixieglow के बीच सभी अनावश्यक कोड को हटाकर साझा दिमाग Emerald बनाया, जिससे Jade और Pixieglow साधारण सॉकेट-संचालित क्लाइंट बन गए।

इसके साथ ही, मैं एक वेबसाइट को अपडेट रखता हूँ जो प्रोजेक्ट की प्रगति को ट्रैक करती है: protocol-luna.github.io।

यह लेख बताता है कि मैंने इन परतों को कैसे और क्यों विभाजित किया, प्रत्येक सेवा वास्तव में क्या करती है, और कैसे सेंट्रॉइड (औसत एम्बेडिंग वेक्टर) और रिज़ेंटमेंट वेरिएबल (1970 के दशक के PARRY चैटबॉट से प्रेरित) जैसी अवधारणाओं ने एक साधारण Discord बॉट को आश्चर्यजनक रूप से सुसंगत मल्टी-प्लेटफ़ॉर्म प्रणाली में बदल दिया।


मोनोलिथ की समस्या

शुरुआत में, लूना प्रोटोकॉल एक ही Node.js प्रक्रिया में समा जाता था। कोड इन चीज़ों को संभालता था:

  • Discord कनेक्शन (Eris लाइब्रेरी के माध्यम से)
  • ट्रिगर मूल्यांकन (मेंशन, कीवर्ड, फॉलो-अप...)
  • मानव व्यवहार का सिमुलेशन (टाइपो, हिचकिचाहट, नींद...)
  • स्थानीय LLM सर्वर (llama.cpp) को HTTP कॉल्स
  • सत्र प्रबंधन और एंटी-स्पैम
  • TTS पाइपलाइन

सब कुछ एक ही प्रक्रिया में रहता था, टाइप्ड इवेंट बस (TypedBus) के माध्यम से संचार करता था। यह काम करता था, लेकिन इसकी सीमाएँ थीं:

  • Matrix क्लाइंट जोड़ना असंभव था बिना सारे व्यवहार कोड को दोहराए
  • LLM और बॉट एक ही रिपॉजिटरी में थे: server/ फ़ोल्डर पहले से मौजूद था, लेकिन आप एक को छुए बिना दूसरे को विकसित नहीं कर सकते थे
  • कोई स्मार्ट वर्गीकरण नहीं था: हर संदेश को एक जैसा माना जाता था, चाहे वह "lol" हो या एक अस्तित्ववादी प्रश्न
  • कोई स्थायी भावनात्मक स्थिति नहीं थी: बॉट कुछ भी "महसूस" नहीं करता था

परतों में विभाजित करने से ये सभी समस्याएँ हल हो गईं।


चार परतें

लूना प्रोटोकॉल की वर्तमान आर्किटेक्चर एक चार-स्तरीय फ़नल के रूप में व्यवस्थित है:

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: एडाप्टर (Pixieglow और Jade)

ये सबसे सरल परतें हैं। इनका एकमात्र काम है मैसेजिंग प्लेटफ़ॉर्म से इवेंट्स को Emerald की ओर एक मानकीकृत प्रोटोकॉल में अनुवादित करना:

  • Jade Discord एडाप्टर है। यह Discord से जुड़ने के लिए Eris लाइब्रेरी का उपयोग करता है और WebSocket के माध्यम से संदेशों को Emerald को भेजता है। यह TTS पाइपलाइन (Piper के माध्यम से स्पीच सिंथेसिस, OGG रूपांतरण, Discord पर अपलोड) भी संभालता है।
  • Pixieglow Matrix एडाप्टर है। यह सीधे Matrix Client-Server HTTP API का उपयोग करता है (कोई SDK नहीं), लॉन्ग-पोल सिंक के साथ। इसमें 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 प्रोटोकॉल को लागू करने वाला एक एडाप्टर लिखना है।

दिमाग को यह नहीं पता कि वह किस प्लेटफ़ॉर्म पर चल रहा है। इसे एक clientId ("jade" या "pixieglow") के साथ एक MessageEvent मिलता है, यह एक निर्णय लेता है, और एक कमांड लौटाता है। बाकी काम एडाप्टर संभालता है।


परत 2: दिमाग (Emerald)

Emerald केंद्रीय निर्णय-निर्माण सेवा है। यह पोर्ट 3126 पर WebSocket के माध्यम से सुनता है और इन्हें संभालता है:

  • ट्रिगर मूल्यांकन: मेंशन, DM, नाम, कीवर्ड, फॉलो-अप, यादृच्छिक
  • व्यवहार सिमुलेशन: फोकस देरी, टाइपो, हिचकिचाहट, भूलने की प्रवृत्ति, बर्स्ट, विषय थकान
  • नींद चक्र: sleep / slow / short मोड
  • सत्र प्रबंधन: कूलडाउन, सत्र सीमाएँ, एंटी-स्पैम
  • Sapphire को रूटिंग: संदेश भेजना, स्ट्रीम की गई प्रतिक्रियाएँ प्राप्त करना

Emerald वह केंद्रीय सेवा है जिसने दिमाग-साझाकरण को संभव बनाया, और यह वह सेवा है जिसे विभाजन से सबसे अधिक लाभ हुआ। पहले, हर व्यवहार (टाइपो, बर्स्ट, हिचकिचाहट) Discord कोड के साथ उलझा हुआ था। अब वे behavior/ के तहत समर्पित मॉड्यूल में रहते हैं:

emerald/src/behavior/
  burst.ts         -- बर्स्ट संदेश योजना
  mannerisms.ts    -- देरी, हिचकिचाहट, रिएक्शन, भूलने की प्रवृत्ति
  sleep.ts         -- नींद शेड्यूल मूल्यांकन
  typo.ts          -- टाइपो सिमुलेशन (AZERTY/QWERTY)

परत 3: भावना वर्गीकरणकर्ता (Sapphire)

Sapphire तकनीकी रूप से सबसे दिलचस्प सेवा है। यह Python में FastAPI के साथ लिखा गया एक LLM मिडलवेयर है, जो चार महत्वपूर्ण भूमिकाएँ निभाता है:

  1. एम्बेडिंग सेंट्रॉइड के माध्यम से बाइनरी FUTILE / INTERESTING वर्गीकरणकर्ता
  2. सेंट्रॉइड के माध्यम से भावना स्कोरर (वैलेंस / अराउज़ल)
  3. Krystal के लिए बैकएंड राउटर (छोटा मॉडल बनाम बड़ा मॉडल)
  4. फ्यू-शॉट इंजेक्टर और सत्र प्रबंधक

सेंट्रॉइड: वर्गीकरण का हृदय

एक सेंट्रॉइड एक सरल अवधारणा है: यह एम्बेडिंग वेक्टरों के एक समूह का औसत है। ठोस रूप से, मैंने सैकड़ों उदाहरण संदेशों को इकट्ठा किया, उन्हें एक एम्बेडिंग मॉडल (BAAI/bge-small-en-v1.5, 384 आयाम) से गुज़ारा, और परिणामी वेक्टरों का औसत निकाला।

दो वर्गीकरण सेंट्रॉइड हैं:

  • futile_centroid: लगभग 683 तुच्छ संदेशों ("lol", "ok", "hello") via k-means (k=10, seed=42)
  • interesting_centroid: लगभग 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

संदेश और प्रत्येक सेंट्रॉइड के बीच कोसाइन समानता श्रेणी निर्धारित करती है। पूर्ण अंतर विश्वास स्तर देता है। यह सरल है, तेज़ है (कोई LLM फॉरवर्ड पास नहीं), और आश्चर्यजनक रूप से प्रभावी है।

दो मॉडल क्यों?

इस वर्गीकरण का परिणाम तय करता है कि कौन सा LLM बैकएंड आमंत्रित किया जाएगा:

लेबल 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" एक अरब-पैरामीटर मॉडल को आमंत्रित करने योग्य नहीं है। 200,000 Discord नमूनों पर प्रशिक्षित छोटा फाइन-ट्यून किया गया 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]

वैलेंस यह मापता है कि संदेश सकारात्मक है या नकारात्मक। अराउज़ल इसकी भावनात्मक तीव्रता को मापता है। साथ में, वे प्रभाव का सर्कमप्लेक्स मॉडल (circumplex model of affect, Russell, 1980) बनाते हैं -- वही मनोवैज्ञानिक मॉडल जिसने 1972 में PARRY चैटबॉट को प्रेरित किया।

रिज़ेंटमेंट वेरिएबल: भावनाएँ LLM को कैसे नियंत्रित करती हैं

यहीं पर PARRY की प्रेरणा मूर्त रूप लेती है। PARRY (1972 में Kenneth Colby द्वारा बनाया गया) एक चैटबॉट था जिसे एक पैरानॉयड मरीज़ का अनुकरण करने के लिए डिज़ाइन किया गया था। इसमें आंतरिक चर थे -- डर, गुस्सा, अविश्वास -- जो इसकी प्रतिक्रियाओं को बदलते थे। उदाहरण के लिए, एक "डरा हुआ" PARRY अधिक आक्रामक रूप से प्रतिक्रिया देता था।

Sapphire वही काम करता है, लेकिन निरंतर चर और अधिक सुरुचिपूर्ण तरीके के साथ: LLM के सैंपलिंग पैरामीटर बातचीत की भावनात्मक स्थिति के आधार पर वास्तविक समय में समायोजित किए जाते हैं।

Temperature अराउज़ल का अनुसरण करता है
temperature = clamp(0.7 + arousal * 0.3, 0.4, 1.0)
अराउज़ल Temperature प्रभाव
-1.0 (शांत) 0.40 कम रचनात्मकता, अनुमानित प्रतिक्रियाएँ
0.0 (तटस्थ) 0.70 डिफ़ॉल्ट रचनात्मकता
+1.0 (उत्साहित) 1.00 अधिकतम यादृच्छिकता, आश्चर्यजनक प्रतिक्रियाएँ

जब कोई उत्साहित या परेशान होता है (उच्च अराउज़ल), तो temperature बढ़ जाता है। मॉडल अधिक विविध, अधिक रचनात्मक, कभी-कभी अधिक अराजक प्रतिक्रियाएँ उत्पन्न करता है -- जैसे एक इंसान जो "बह जाता है"। जब बातचीत शांत होती है, तो temperature गिर जाता है, और प्रतिक्रियाएँ अधिक संयमित हो जाती हैं।

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

0.85 का decay इसका मतलब है कि हर संदेश पर पिछली स्थिति का 85% बनाए रखा जाता है, जिसमें नए सिग्नल का 15% शामिल किया जाता है। यह एक भावनात्मक स्मृति बनाता है जो अचानक बदलावों को सहज बनाती है: एक अकेला नकारात्मक संदेश बॉट को "उदास" नहीं बनाता, लेकिन नकारात्मक संदेशों की एक श्रृंखला धीरे-धीरे इसके मूड को उस दिशा में ले जाती है।

व्यवहार में: यदि कोई व्यक्ति बहुत उत्साह से बातचीत शुरू करता है (arousal=+0.8), तो temperature कई आदान-प्रदानों तक ऊँचा रहता है, भले ही बाद के संदेश शांत हों। भावना को वापस नीचे आने में समय लगता है -- जैसे कोई इंसान जो बहस के बाद भी "गर्म" बना रहता है।


परत 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 प्रक्रियाएँ हैं, जिन्हें CPU पिनिंग के लिए taskset के साथ लॉन्च किया गया है।

Luna मॉडल का फाइन-ट्यून भी दूसरे लेख के बाद से विकसित हुआ है: अब यह (पहले के 50,000 से बढ़कर) 200,000 नमूनों पर प्रशिक्षित है, जो अभी भी QLoRA के माध्यम से Qwen2.5-1.5B-Instruct से शुरू होता है। ये 200k नमूने Discord-Dialogues डेटासेट का एक उपसमुच्चय हैं, जिन्हें केवल सबसे स्वाभाविक और विविध बातचीत रखने के लिए फ़िल्टर किया गया है। लक्ष्य: फ्यू-शॉट प्राइमिंग को इतना प्रभावी बनाने वाली लचीलेपन को खोए बिना मॉडल की शैलीगत सीमा को व्यापक बनाना।


पूरी तस्वीर: पारगमन में एक संदेश

यहाँ बताया गया है कि जब कोई Discord पर "i'm really sad today" भेजता है तो वास्तव में क्या होता है:

  1. Jade Discord Gateway API के माध्यम से संदेश प्राप्त करता है। यह इसे एक MessageEvent में परिवर्तित करता है और इसे WebSocket के माध्यम से Emerald को भेजता है।
  2. Emerald ट्रिगर का मूल्यांकन करता है (मेंशन? नाम? कीवर्ड?)। यह एक सीधा मेंशन है। यह फोकस देरी की गणना करता है, कूलडाउन, सत्र, विषय थकान की जांच करता है। यह प्रतिक्रिया देने का निर्णय लेता है और HTTP के माध्यम से संदेश को Sapphire को भेजता है।
  3. Sapphire bge-small-en-v1.5 के साथ संदेश को एम्बेड करता है।
    • वर्गीकरण: संदेश futile सेंट्रॉइड की तुलना में interesting सेंट्रॉइड के करीब है (diff = +0.31) -> INTERESTING
    • भावना: नकारात्मक वैलेंस (-0.42), मध्यम अराउज़ल (0.35)
    • रूटिंग: दिशा KRYSTAL_SEMANTIC_URL (पोर्ट 3125, बड़ा मॉडल)
    • सैंपलिंग पैरामीटर: temperature = 0.80 (अराउज़ल बढ़ा), repeat_penalty = 1.19 (नकारात्मक वैलेंस)
    • सत्र की भावनात्मक स्थिति इन मूल्यों के साथ अपडेट की जाती है
  4. Krystal (बड़ा इंस्टेंस) भावनात्मक रूप से समायोजित पैरामीटरों के साथ प्रतिक्रिया उत्पन्न करता है और इसे Sapphire को वापस भेजता है।
  5. Sapphire मेटाडेटा (लेबल, वैलेंस, अराउज़ल, डिबग आंकड़ों) के साथ प्रतिक्रिया को Emerald को स्ट्रीम करता है।
  6. Emerald एक हिचकिचाहट ("oh...") जोड़ने का निर्णय लेता है, एक बर्स्ट (2 टुकड़े) की योजना बनाता है, और एक रिएक्शन चुनता है। यह Jade को एक RespondCommand भेजता है।
  7. Jade निष्पादित करता है: प्रारंभिक देरी की प्रतीक्षा करता है, हिचकिचाहट के साथ पहला टुकड़ा भेजता है, 1.5 सेकंड प्रतीक्षा करता है, दूसरा टुकड़ा भेजता है। यह पूरी जनरेशन प्रक्रिया के दौरान टाइपिंग इंडिकेटर दिखाता है।

उपयोगकर्ता के लिए यह सब 3 सेकंड से भी कम समय में होता है।


सेंट्रॉइड: वे न्यूरल क्लासिफायर से बेहतर क्यों हैं

पारंपरिक क्लासिफायर (जैसे मेरे द्वारा पहले उपयोग किया गया DistilBERT) की तुलना में एम्बेडिंग सेंट्रॉइड को चुनना समझाने योग्य है।

एक न्यूरल क्लासिफायर वर्गों के बीच एक निर्णय सीमा सीखता है -- आमतौर पर एक गैर-रेखीय परिवर्तन जो इनपुट को संभावनाओं में मैप करता है। यह सटीक है, लेकिन:

  • इसके लिए लेबल किए गए प्रशिक्षण डेटा की आवश्यकता होती है
  • यह वितरण बदलाव (डेटा ड्रिफ्ट) के प्रति संवेदनशील है
  • इसकी व्याख्या करना कठिन है
  • एक नया वर्ग जोड़ने के लिए इसे फिर से प्रशिक्षित करने की आवश्यकता होती है

दूसरी ओर, एक सेंट्रॉइड उदाहरण एम्बेडिंग का एक औसत वेक्टर है। वर्गीकरण उस औसत वेक्टर के साथ कोसाइन समानता द्वारा किया जाता है। फायदे:

  • कोई प्रशिक्षण नहीं: आपको बस हाथ से चुने गए उदाहरणों के एम्बेडिंग का औसत निकालना है
  • व्याख्या करना आसान: आप देख सकते हैं कि कौन से उदाहरण सेंट्रॉइड के सबसे करीब हैं यह समझने के लिए कि "सेंट्रॉइड ने क्या सीखा है"
  • वर्ग जोड़ना: आप बस एक नया सेंट्रॉइड जोड़ते हैं -- फिर से प्रशिक्षण की आवश्यकता नहीं
  • मज़बूत: सेंट्रॉइड एक औसत है, इसलिए आउटलायर्स का बहुत कम प्रभाव पड़ता है

सेंट्रॉइड की असली शक्ति यह है कि वे एक वर्गीकरण समस्या को एक स्थानिक दूरी माप समस्या में बदल देते हैं। आप श्रेणियों को 384-आयामी स्पेस में क्षेत्रों के रूप में देख सकते हैं (या PCA/t-SNE आयाम न्यूनीकरण के बाद 2D/3D में)।

3D सेंट्रॉइड विज़ुअलाइज़ेशन

व्यवहार में, एम्बेडिंग स्पेस में वर्गीकरण सेंट्रॉइड ऐसे दिखते हैं। प्रत्येक बिंदु PCA के माध्यम से 3D में प्रक्षेपित एक उदाहरण संदेश है (मूल 384 आयामों को विज़ुअलाइज़ेशन के लिए 3 में घटाया गया है)। नीले बिंदु निरर्थक (futile) संदेश हैं, पीले बिंदु दिलचस्प (interesting) संदेश हैं। 20 हीरे के निशान (डायमंड मार्कर) k-means सेंट्रॉइड (केंद्रक) हैं -- प्रति वर्ग 10 -- प्रत्येक समूह का औसत। उदाहरण का मूल टेक्स्ट देखने के लिए किसी बिंदु पर होवर करें।

दो उदाहरण लाल रंग में दिखाए गए हैं: "lol" (futile के रूप में वर्गीकृत) और "i feel sad today" (interesting के रूप में वर्गीकृत)। "lol" निरर्थक संदेशों के नीले बादल में गिरता है, जबकि "i feel sad today" पीले बिंदुओं की तरफ बैठता है। 3 आयामों तक घटाने के बाद भी अलगाव दिखाई देता है (कुल भिन्नता का केवल 14.7% ही समझाया गया है)। 384 आयामों में, सीमा कहीं अधिक तीव्र है।

इनपुट संदेश का सेंट्रॉइड इस स्पेस में अपनी सामग्री के आधार पर घूमता है। FUTILE/INTERESTING वर्गीकरण में बस यह मापना शामिल है कि कोसाइन समानता से कौन सा सेंट्रॉइड करीब है। यह हमें प्रत्येक संदेश को एक बहु-आयामी स्पेस में एक बिंदु के रूप में प्रस्तुत करने देता है, जिसमें प्रत्येक आयाम एक सिमेंटिक गुण से मेल खाता है।


व्यवहार में यह क्या बदलता है

उपयोगकर्ता परतों, सेंट्रॉइड, या temperature समायोजन को नहीं देखते। लेकिन वे प्रभावों को महसूस करते हैं:

  • सरल संदेशों के लिए तेज़ प्रतिक्रियाएँ (छोटा मॉडल 2 गुना तेज़ है और 70% ट्रैफ़िक संभालता है)
  • अनुकूली स्वर: यदि आप नाराज़ हैं, तो बॉट जलन को "महसूस" करता है और अपनी शैली को अनुकूलित करता है
  • क्रॉस-प्लेटफ़ॉर्म स्थिरता: एक Matrix बॉट और एक Discord बॉट एक ही दिमाग और एक ही भावनात्मक स्थिति साझा करते हैं
  • कोई "असिस्टेंट मोड" नहीं: फाइन-ट्यून + फ्यू-शॉट + स्मार्ट रूटिंग कॉर्पोरेट-ध्वनि वाली प्रतिक्रियाओं से बचाती है

छोटे मॉडल के प्रशिक्षण सेट को 200k नमूनों तक बढ़ाने से इन प्रभावों को और मज़बूत किया गया: मॉडल फ्यू-शॉट प्राइमिंग द्वारा प्रदान की गई लचीलेपन को खोए बिना Discord बातचीत की विविधता को बेहतर तरीके से पकड़ता है।


पूरा इंफ्रास्ट्रक्चर

यहाँ वर्तमान में चल रही सेवाएँ हैं:

सेवा तकनीक पोर्ट(s) भूमिका
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+, interesting)

सेवाओं के बीच निर्भरताएँ एकदिशीय हैं: एडाप्टर Emerald पर निर्भर करता है, Emerald Sapphire पर निर्भर करता है, Sapphire Krystal पर निर्भर करता है। कोई चक्र नहीं है। प्रत्येक सेवा को स्वतंत्र रूप से पुनः आरंभ किया जा सकता है।


निष्कर्ष

लूना प्रोटोकॉल को चार परतों में विभाजित करना केवल एक आर्किटेक्चरल अभ्यास नहीं था। यह ठोस सीमाओं की प्रतिक्रिया थी: 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 बॉट लूना प्रोटोकॉल: मैंने एक स्वायत्त Discord बॉट बनाया
लेख 2: फाइन-ट्यूनिंग लूना प्रोटोकॉल: मैंने 1.5B मॉडल को फाइन-ट्यून क्यों किया

بروتوكول لونا: أدمغة مشتركة، تصنيف المشاعر، وتوجيه مثير/تافه

انتقل بروتوكول لونا من كتلة برمجية واحدة إلى بنية من أربع طبقات: المحولات، الدماغ، مصنف المشاعر، والاستدلال. على القائمة: مراكز التضمين (centroids)، توجيه مثير/تافه، وضبط معاملات النموذج اللغوي حسب التكافؤ والإثارة.

بروتوكول لونا: أدمغة مشتركة، تصنيف المشاعر، وتوجيه مثير/تافه

في المقالين السابقين الاثنين، قدّمت بروتوكول لونا كبوت ديسكورد واحد بنظام سلوكي معقّد ونموذج مضبوط بدقة. لكن البنية تطورت كثيرًا منذ ذلك الحين. ما كان في السابق كتلة برمجية واحدة -- عملية Node.js وحيدة تتولى بوت ديسكورد والسلوك واستدعاءات النموذج اللغوي -- تحول إلى أربع طبقات مستقلة، لكل منها مسؤوليتها الخاصة، ولغتها الخاصة، ودورة حياتها الخاصة.

جلب هذا الانقسام فوائد غير متوقعة: مشاركة "الأدمغة" عبر منصات متعددة، ونظام تصنيف مشاعر يضبط معاملات النموذج اللغوي ديناميكيًا، وتوجيه ذكي للرسائل بين نموذجين بناءً على الأهمية المتصورة للمحادثة.

لم يحدث هذا التطور دفعة واحدة -- بل اتبع مسارًا عضويًا. بدأت بفصل مجلد server/ عن مستودع البوت، فأنشأت كريستال من جهة وتركت جيد كمحول ديسكورد. ثم أنشأت بيكسيغلو (محول ماتريكس) بإعادة استخدام llm-core وناقل الأحداث الخاص بجيد. بعد ذلك جاءت سافاير، التي أدخلت تصنيفًا عامًا/دلاليًا باستخدام DistilBERT -- لكن النتائج لم تكن مقنعة، فتحولت إلى مراكز التضمين (centroids)، وهي أكثر مرونة لإثراء الأمثلة وأكثر دقة؛ فأصبح التصنيف تافه/مثير. أضفت لاحقًا مراكز التكافؤ والإثارة لتنظيم درجة حرارة النموذج اللغوي وعقوبة التكرار. وأخيرًا، أزلت كل الكود المكرر بين جيد وبيكسيغلو بإنشاء إمرالد، الدماغ المشترك، محوّلًا جيد وبيكسيغلو إلى مجرد عملاء يعملون عبر المقابس (sockets).

إلى جانب ذلك، حافظت على تحديث موقع ويب يتتبع تقدم المشروع: protocol-luna.github.io.

يحكي هذا المقال قصة كيف ولماذا قسّمت هذه الطبقات، وماذا تفعل كل خدمة بالضبط، وكيف حوّلت مفاهيم مثل المراكز (centroids) (متوسط متجهات التضمين) ومتغيرات الاستياء (المستوحاة من بوت PARRY في السبعينيات) بوت ديسكورد بسيطًا إلى نظام متعدد المنصات متماسك بشكل مفاجئ.


مشكلة الكتلة البرمجية الواحدة

في البداية، كان بروتوكول لونا يتسع في عملية Node.js واحدة. كان الكود يتولى:

  • الاتصال بديسكورد (عبر مكتبة Eris)
  • تقييم المحفزات (الإشارات، الكلمات المفتاحية، المتابعات...)
  • محاكاة السلوكيات البشرية (الأخطاء الإملائية، التردد، النوم...)
  • استدعاءات HTTP إلى خادم النموذج اللغوي المحلي (llama.cpp)
  • إدارة الجلسات ومكافحة البريد المزعج
  • خط أنابيب تحويل النص إلى كلام (TTS)

كان كل شيء يعيش في نفس العملية، متواصلاً عبر نواقل أحداث مصنّفة (TypedBus). كان الأمر يعمل، لكن بقيود:

  • استحالة إضافة عميل ماتريكس دون تكرار كل كود السلوك
  • النموذج اللغوي والبوت كانا في نفس المستودع: مجلد server/ كان موجودًا بالفعل، لكن لم يكن بالإمكان تطوير أحدهما دون المساس بالآخر
  • لا يوجد تصنيف ذكي: كانت كل رسالة تُعامل بنفس الطريقة، سواء كانت "lol" أو سؤالًا وجوديًا
  • لا توجد حالة عاطفية دائمة: البوت لم يكن "يشعر" بأي شيء

حلّ التقسيم إلى طبقات كل هذه المشاكل.


الطبقات الأربع

بنية بروتوكول لونا الحالية منظمة كقمع من أربعة مستويات:

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)

يمكن إعادة تشغيل كل طبقة أو تحديثها أو استبدالها بشكل مستقل.


الطبقة الأولى: المحولات (بيكسيغلو وجيد)

هذه هي أبسط الطبقات. مهمتها الوحيدة هي ترجمة أحداث منصة المراسلة إلى بروتوكول موحد باتجاه إمرالد:

  • جيد هو محول ديسكورد. يستخدم مكتبة Eris للاتصال بديسكورد ويُرسل الرسائل إلى إمرالد عبر WebSocket. كما يتولى خط أنابيب TTS (تركيب الكلام عبر Piper، تحويل OGG، الرفع إلى ديسكورد).
  • بيكسيغلو هو محول ماتريكس. يستخدم واجهة برمجة تطبيقات ماتريكس (Client-Server HTTP API) مباشرة (بدون SDK)، مع مزامنة long-poll. ليس لديه TTS.

يشترك كلا المحولين في نفس بروتوكول WebSocket المعرّف في 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;

يثبت وجود محولين بنفس الواجهة أن مشاركة الدماغ تعمل فعلاً: نفس "الدماغ" (إمرالد) يخدم بوت ديسكورد وبوت ماتريكس بلا تمييز، بسلوكيات متطابقة. البروتوكول تصريحي: لا يخبر إمرالد المحول كيف يرسل رسالة، بل يخبره بماذا يرسل (النص مع تأخير، ربما خطة دفعات، رد فعل، إلخ). كل محول ينفّذ التنفيذ الفعلي الخاص بمنصته.

هذه هي قوة هذه البنية: لإضافة دعم لتيليغرام أو سيغنال أو أي شيء آخر، كل ما تحتاجه هو كتابة محول ينفّذ بروتوكول WebSocket.


الطبقة الثانية: الدماغ (إمرالد)

إمرالد هو خدمة اتخاذ القرار المركزية. يستمع على المنفذ 3126 عبر WebSocket ويتولى:

  • تقييم المحفزات: الإشارة، الرسالة المباشرة، الاسم، الكلمة المفتاحية، المتابعة، العشوائي
  • المحاكاة السلوكية: تأخيرات التركيز، الأخطاء الإملائية، التردد، النسيان، الدفعات، إرهاق الموضوع
  • دورات النوم: أوضاع النوم / البطيء / القصير
  • إدارة الجلسات: فترة التهدئة، حدود الجلسة، مكافحة البريد المزعج
  • التوجيه إلى سافاير: إرسال الرسائل، استقبال الردود المتدفقة (streamed)

إمرالد هو الخدمة المركزية التي مكّنت مشاركة الدماغ، وهو الذي استفاد أكثر من التقسيم. سابقًا، كان كل سلوك (الخطأ الإملائي، الدفعة، التردد) متشابكًا مع كود ديسكورد. الآن تعيش في وحدات مخصصة تحت 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)

الدماغ لا يعرف على أي منصة يعمل. يستقبل MessageEvent مع clientId ("jade" أو "pixieglow")، يتخذ قرارًا، ويعيد أمرًا. المحول يتولى الباقي.


الطبقة الثالثة: مصنف المشاعر (سافاير)

سافاير هي الخدمة الأكثر إثارة للاهتمام من الناحية التقنية. إنها وسيط نموذج لغوي (LLM middleware) مكتوب بلغة Python باستخدام FastAPI، يلعب أربعة أدوار حاسمة:

  1. مصنف ثنائي تافه / مثير عبر مراكز التضمين
  2. مقيّم المشاعر (التكافؤ / الإثارة) عبر المراكز
  3. موجّه الخلفية إلى كريستال (النموذج الصغير مقابل النموذج الكبير)
  4. حاقن التمهيد بالأمثلة القليلة ومدير الجلسات

المراكز: قلب التصنيف

المركز (centroid) مفهوم بسيط: إنه متوسط مجموعة من متجهات التضمين. عمليًا، جمعت مئات الرسائل النموذجية، مررتها عبر نموذج تضمين (BAAI/bge-small-en-v1.5، 384 بُعدًا)، وحسبت متوسط المتجهات الناتجة.

توجد مركزان للتصنيف:

  • futile_centroid: متوسط تضمين ~683 رسالة تافهة via k-means (k=10, seed=42) ("lol"، "ok"، "hello"، "nm just chillin u")
  • interesting_centroid: متوسط تضمين ~678 رسالة جوهرية (أسئلة تقنية، اعترافات، فلسفة)

عندما تصل رسالة:

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

التشابه الجيبي (cosine similarity) بين الرسالة وكل مركز يحدد الفئة. الفرق المطلق يعطي الثقة. الأمر بسيط وسريع (بدون تمرير أمامي للنموذج اللغوي) وفعّال بشكل مفاجئ.

لماذا نموذجان؟

نتيجة هذا التصنيف تحدد أي خلفية نموذج لغوي يتم استدعاؤها:

التصنيف خلفية كريستال النموذج المنفذ
FUTILE generic Luna-Protocol-1.5B (941 MB, Q4_K_M) 3124
INTERESTING semantic Hermes-3-3B أو 8B (حسب الإعداد) 3125

الفكرة بسيطة: "lol" أو "nm just chillin u" لا تستحق استدعاء نموذج بثمانية مليارات معامل. نموذج لونا الصغير المضبوط بدقة 1.5B، المدرب على 200,000 عينة من ديسكورد، أكثر من كافٍ للتبادلات الخفيفة. من ناحية أخرى، سؤال عن الحياة، أو اعتراف، أو نقاش تقني، يتم توجيهه إلى النموذج الكبير، الذي يمكنه إنتاج رد أغنى.

هذا التوجيه الاقتصادي يقلّل بشكل كبير من الحمل على خادم النموذج اللغوي: حوالي 70% من الرسائل تُصنَّف كتافهة ويتولاها النموذج الصغير، مما يحرر النموذج الكبير للمحادثات التي تستحقه فعلاً.

المحور العاطفي: التكافؤ والإثارة

لكن هذا ليس كل شيء. تستخدم سافاير نفس آلية المراكز على محور مستقل لتقييم عاطفة الرسالة:

توجد أربعة مراكز عاطفية:

القطب أمثلة
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.

متغيرات الاستياء: كيف تتحكم المشاعر في النموذج اللغوي

هنا يصبح إلهام PARRY ملموسًا. كان PARRY (الذي ابتكره Kenneth Colby عام 1972) بوتًا مصممًا لمحاكاة مريض بجنون العظمة. كانت لديه متغيرات داخلية -- الخوف، الغضب، عدم الثقة -- تغيّر ردوده. مثلاً، PARRY "الخائف" كان يرد بعدوانية أكبر.

تفعل سافاير الشيء نفسه، لكن بمتغيرات مستمرة وطريقة أكثر أناقة: تُضبط معاملات أخذ العينات (sampling) للنموذج اللغوي في الوقت الفعلي بناءً على الحالة العاطفية للمحادثة.

درجة الحرارة تتبع الإثارة
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

decay بقيمة 0.85 يعني أن 85% من الحالة السابقة تُحفظ في كل رسالة، مع دمج 15% من الإشارة الجديدة. هذا يخلق ذاكرة عاطفية تُخفف التقلبات المفاجئة: رسالة سلبية واحدة لا تجعل البوت "حزينًا"، لكن سلسلة من الرسائل السلبية تُحرّف مزاجه تدريجيًا.

عمليًا: إذا بدأ شخص ما محادثة بحماس شديد (arousal=+0.8)، تبقى درجة الحرارة عالية لعدة تبادلات، حتى لو كانت الرسائل التالية أكثر هدوءًا. تأخذ العاطفة وقتًا للعودة إلى مستواها -- مثل إنسان يبقى "محتدًا" بعد جدال.


الطبقة الرابعة: الاستدلال (كريستال)

كريستال هي الطبقة الأدنى: غلاف حول llama.cpp يعرض واجهة برمجة تطبيقات متوافقة مع OpenAI (/v1/chat/completions). تعمل كعمليتي PM2:

  • krystal-small: نموذج لونا 1.5B المضبوط بدقة، على المنفذ 3124، بتخصيص وحدة معالجة مركزية 0
  • krystal-large: نموذج Hermes 3B، على المنفذ 3125، بتخصيص وحدة معالجة مركزية 0,1

كلا العمليتين هما عمليتا llama-server مُجمّعتان مسبقًا، تُشغَّلان باستخدام taskset لتثبيت وحدة المعالجة المركزية.

تطور ضبط نموذج لونا الدقيق أيضًا منذ المقال الثاني: الآن مدرب على 200,000 عينة (مقارنة بـ 50,000 سابقًا)، ولا يزال يبدأ من Qwen2.5-1.5B-Instruct عبر QLoRA. العينات الـ200 ألف هي مجموعة فرعية من مجموعة بيانات Discord-Dialogues، مُصفّاة للاحتفاظ فقط بأكثر المحادثات طبيعية وتنوعًا. الهدف: توسيع النطاق الأسلوبي للنموذج دون فقدان المرونة التي تجعل التمهيد بالأمثلة القليلة فعالاً للغاية.


الصورة الكاملة: رسالة أثناء عبورها

إليك ما يحدث فعليًا عندما يرسل شخص ما "i'm really sad today" على ديسكورد:

  1. جيد يستقبل الرسالة عبر واجهة برمجة تطبيقات ديسكورد Gateway. يحوّلها إلى MessageEvent ويرسلها إلى إمرالد عبر WebSocket.
  2. إمرالد يقيّم المحفز (إشارة؟ اسم؟ كلمة مفتاحية؟). إنها إشارة مباشرة. يحسب تأخير التركيز، يفحص فترة التهدئة، الجلسة، إرهاق الموضوع. يقرر الرد ويرسل الرسالة إلى سافاير عبر HTTP.
  3. سافاير تُضمّن الرسالة باستخدام bge-small-en-v1.5.
    • التصنيف: الرسالة أقرب إلى مركز interesting منه إلى مركز futile (الفرق = +0.31) -> مثيرة
    • المشاعر: تكافؤ سلبي (-0.42)، إثارة معتدلة (0.35)
    • التوجيه: باتجاه KRYSTAL_SEMANTIC_URL (المنفذ 3125، النموذج الكبير)
    • معاملات أخذ العينات: درجة الحرارة = 0.80 (الإثارة ارتفعت)، عقوبة التكرار = 1.19 (التكافؤ السلبي)
    • تُحدَّث الحالة العاطفية للجلسة بهذه القيم
  4. كريستال (النسخة الكبيرة) يولّد الرد بالمعاملات المعدّلة عاطفيًا ويرسله إلى سافاير.
  5. سافاير تُدفّق الرد إلى إمرالد مع بيانات وصفية (التصنيف، التكافؤ، الإثارة، إحصائيات التصحيح).
  6. إمرالد يقرر إضافة تردد ("oh...")، يخطط لدفعة (جزأين)، ويختار رد فعل. يرسل أمر RespondCommand إلى جيد.
  7. جيد ينفّذ: ينتظر التأخير الأولي، يرسل الجزء الأول مع التردد، ينتظر 1.5 ثانية، يرسل الجزء الثاني. يُظهر مؤشر الكتابة طوال فترة التوليد.

كل هذا في أقل من 3 ثوانٍ بالنسبة للمستخدم.


المراكز: لماذا هي أفضل من مصنف عصبي

اختيار مراكز التضمين على مصنف تقليدي (مثل DistilBERT الذي استخدمته سابقًا) يستحق شرحًا.

المصنف العصبي يتعلم حدًا فاصلًا بين الفئات -- عادة تحويل غير خطي يحوّل المدخلات إلى احتمالات. إنه دقيق، لكن:

  • يتطلب بيانات تدريب مُصنَّفة
  • حساس لانحراف التوزيع (data drift)
  • صعب التفسير
  • يحتاج إعادة تدريب لإضافة فئة جديدة

أما المركز، من ناحية أخرى، فهو متجه متوسط لتضمينات الأمثلة. يتم التصنيف عبر التشابه الجيبي إلى ذلك المتجه المتوسط. المزايا:

  • بدون تدريب: فقط تحسب متوسط تضمينات أمثلة مختارة يدويًا
  • سهل التفسير: يمكنك النظر إلى أقرب الأمثلة إلى المركز لفهم "ما تعلّمه المركز"
  • إضافة فئة: فقط تضيف مركزًا جديدًا -- لا حاجة لإعادة التدريب
  • متين: المركز متوسط، لذا القيم الشاذة لها تأثير ضئيل

القوة الحقيقية للمراكز هي أنها تحوّل مشكلة تصنيف إلى مشكلة قياس مسافة مكانية. يمكنك تصور الفئات كمناطق في فضاء 384 بُعدًا (أو في بُعدين/ثلاثة أبعاد بعد تقليل الأبعاد عبر PCA/t-SNE).

تصوّر ثلاثي الأبعاد للمراكز

عمليًا، إليك كيف تبدو مراكز التصنيف في فضاء التضمين. كل نقطة هي رسالة نموذجية، مُسقَطة في ثلاثة أبعاد عبر PCA (الأبعاد الأصلية 384 مُختزلة إلى 3 للتصوير). النقاط الزرقاء هي رسائل تافهة، النقاط الصفراء هي رسائل مثيرة. المعينان الكبيران هما المركزان المحسوبان -- متوسط كل مجموعة. مرّر المؤشر فوق أي نقطة لرؤية نص المثال الأصلي.

يُعرض مثالان باللون الأحمر: "lol" (مصنَّفة تافهة) و"i feel sad today" (مصنَّفة مثيرة). "lol" تقع ضمن السحابة الزرقاء للرسائل التافهة، بينما "i feel sad today" تقع في جانب النقاط الصفراء. الفصل واضح حتى بعد الاختزال إلى 3 أبعاد (14.7% فقط من التباين الكلي مُفسَّر). في 384 بُعدًا، يكون الحد أكثر وضوحًا بكثير.

مركز الرسالة المُدخلة يتجول في هذا الفضاء حسب محتواها. تصنيف تافه/مثير يتمثل ببساطة في قياس أي مركز أقرب عبر التشابه الجيبي. هذا يسمح لنا بتمثيل كل رسالة كنقطة في فضاء متعدد الأبعاد، حيث يقابل كل بُعد خاصية دلالية.


ما يتغير هذا عمليًا

المستخدمون لا يرون الطبقات، ولا المراكز، ولا تعديلات درجة الحرارة. لكنهم يشعرون بالتأثيرات:

  • ردود أسرع للرسائل البسيطة (النموذج الصغير أسرع بمرتين ويتولى 70% من الحركة)
  • نبرة تكيفية: إذا كنت منزعجًا، "يستشعر" البوت الانزعاج ويكيّف أسلوبه
  • اتساق عبر المنصات: بوت ماتريكس وبوت ديسكورد يشتركان في نفس الدماغ ونفس الحالة العاطفية
  • لا "وضع المساعد": الضبط الدقيق + التمهيد بالأمثلة القليلة + التوجيه الذكي يتجنب الردود ذات الطابع المؤسسي

رفع مجموعة تدريب النموذج الصغير إلى 200 ألف عينة عزّز هذه التأثيرات أكثر: يلتقط النموذج تنوع محادثات ديسكورد بشكل أفضل دون فقدان المرونة التي يوفرها التمهيد بالأمثلة القليلة.


البنية التحتية الكاملة

إليك الخدمات التي تعمل حاليًا:

الخدمة التقنية المنفذ (المنافذ) الدور
بيكسيغلو TypeScript (Bun) -- محول ماتريكس
جيد TypeScript (esbuild) -- محول ديسكورد
إمرالد TypeScript (Bun) 3126 (WebSocket) الدماغ / القرارات
سافاير Python (FastAPI) 3123 (HTTP) المصنف + المشاعر
كريستال الصغير llama.cpp (PM2) 3124 النموذج الصغير (1.5B، تافه)
كريستال الكبير llama.cpp (PM2) 3125 النموذج الكبير (3B+، مثير)

التبعيات بين الخدمات أحادية الاتجاه: المحول يعتمد على إمرالد، إمرالد يعتمد على سافاير، سافاير يعتمد على كريستال. لا توجد دورات (cycles). يمكن إعادة تشغيل كل خدمة بشكل مستقل.


الخاتمة

تقسيم بروتوكول لونا إلى أربع طبقات لم يكن مجرد تمرين معماري. كان ردًا على قيود ملموسة: عدم القدرة على دعم ماتريكس، وغياب الوعي العاطفي، وانعدام أولوية ذكية للرسائل.

اليوم، النظام أكثر متانة (انهيار النموذج اللغوي لا يقتل البوت)، وأكثر قابلية للتوسع (محول تيليغرام أو واتساب سيتبع نفس بروتوكول WebSocket)، وأكثر "حيوية": يكيّف البوت سلوكه ونبرته وحتى معاملات النموذج اللغوي وفق الحالة العاطفية المتصورة للمحادثة.

مراكز التضمين هي القطعة الأساسية التي تجعل كل هذا ممكنًا دون تعقيد مفرط: لا شبكة عصبية مدربة، لا خط أنابيب بيانات مُصنَّفة، فقط متوسطات متجهات وتشابهات جيبية. إنها تقنية بسيطة، فعّالة بشكل لا يُصدق، ومُقلَّلة القيمة بشكل مروّع.

المصدر الرابط
موقع المشروع protocol-luna.github.io
بيكسيغلو protocol-luna/pixieglow
إمرالد protocol-luna/emerald
سافاير protocol-luna/sapphire
كريستال protocol-luna/krystal
المقال الأول: بوت ديسكورد بروتوكول لونا: أنشأت بوت ديسكورد مستقل
المقال الثاني: الضبط الدقيق بروتوكول لونا: لماذا ضبطت نموذج 1.5B بدقة

Luna Protocol: bộ não dùng chung, phân loại cảm xúc, và định tuyến thú vị/vô ích

Luna Protocol đã chuyển từ một khối nguyên khối sang kiến trúc bốn lớp: adapter, brain, bộ phân loại cảm xúc, và inference. Trong bài viết: centroid embedding, định tuyến thú vị/vô ích, và tinh chỉnh tham số LLM theo valence và arousal.

Luna Protocol: bộ não dùng chung, phân loại cảm xúc, và định tuyến thú vị/vô ích

Trong hai bài viết trước, tôi đã giới thiệu Luna Protocol như một bot Discord đơn lẻ với hệ thống hành vi phức tạp và một mô hình đã được fine-tune. Nhưng kiến trúc đã tiến hóa rất nhiều kể từ đó. Thứ từng là một khối nguyên khối -- một tiến trình Node.js duy nhất xử lý bot Discord, hành vi, và các lệnh gọi LLM -- giờ đã trở thành bốn lớp độc lập, mỗi lớp có trách nhiệm riêng, ngôn ngữ riêng, và vòng đời riêng.

Sự tách lớp này mang lại những lợi ích bất ngờ: chia sẻ "bộ não" giữa nhiều nền tảng, một hệ thống phân loại cảm xúc điều chỉnh động các tham số của LLM, và định tuyến thông minh các tin nhắn giữa hai mô hình dựa trên mức độ quan trọng cảm nhận được của cuộc trò chuyện.

Quá trình tiến hóa này không diễn ra cùng một lúc -- nó đi theo một con đường tự nhiên. Đầu tiên tôi tách thư mục server/ ra khỏi repo của bot, tạo ra Krystal ở một bên và giữ lại Jade làm adapter Discord. Sau đó tôi tạo Pixieglow (adapter Matrix) bằng cách tái sử dụng llm-core và event bus của Jade. Tiếp theo là Sapphire, giới thiệu phân loại GENERIC/SEMANTIC bằng DistilBERT -- nhưng kết quả không thuyết phục, nên tôi chuyển sang centroid embedding, vốn dễ uốn nắn hơn khi làm giàu ví dụ và chính xác hơn; việc phân loại trở thành FUTILE/INTERESTING (vô ích/thú vị). Cuối cùng tôi thêm các centroid valence và arousal để điều chỉnh temperature và repeat penalty của LLM. Sau cùng, tôi loại bỏ toàn bộ mã trùng lặp giữa Jade và Pixieglow bằng cách tạo ra Emerald, bộ não dùng chung, biến Jade và Pixieglow thành những client đơn giản chạy trên socket.

Song song đó, tôi vẫn duy trì một trang web cập nhật tiến độ dự án: protocol-luna.github.io.

Bài viết này kể lại câu chuyện về cách và lý do tôi tách các lớp này, mỗi dịch vụ làm chính xác điều gì, và các khái niệm như centroid (vector embedding trung bình) và biến resentment (lấy cảm hứng từ chatbot PARRY những năm 1970) đã biến một bot Discord đơn giản thành một hệ thống đa nền tảng gắn kết đến bất ngờ như thế nào.


Vấn đề với khối nguyên khối

Ban đầu, Luna Protocol nằm gọn trong một tiến trình Node.js duy nhất. Mã nguồn xử lý:

  • Kết nối Discord (qua thư viện Eris)
  • Đánh giá trigger (nhắc tên, từ khóa, tin nhắn nối tiếp...)
  • Mô phỏng hành vi con người (gõ sai, ngập ngừng, ngủ...)
  • Gọi HTTP tới server LLM cục bộ (llama.cpp)
  • Quản lý phiên và chống spam
  • Pipeline TTS

Tất cả sống trong cùng một tiến trình, giao tiếp qua các event bus có kiểu (TypedBus). Nó hoạt động, nhưng có những giới hạn:

  • Không thể thêm client Matrix mà không phải nhân bản toàn bộ mã hành vi
  • LLM và bot nằm trong cùng một repo: thư mục server/ đã tồn tại, nhưng bạn không thể phát triển cái này mà không đụng vào cái kia
  • Không có phân loại thông minh: mọi tin nhắn đều được xử lý như nhau, dù là "lol" hay một câu hỏi mang tính hiện sinh
  • Không có trạng thái cảm xúc bền vững: bot không "cảm nhận" gì cả

Việc tách thành các lớp đã giải quyết tất cả những vấn đề này.


Bốn lớp

Kiến trúc hiện tại của Luna Protocol được tổ chức như một phễu bốn cấp:

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

Mỗi lớp có thể được khởi động lại, cập nhật, hoặc thay thế một cách độc lập.


Lớp 1: các adapter (Pixieglow và Jade)

Đây là các lớp đơn giản nhất. Nhiệm vụ duy nhất của chúng là dịch các sự kiện từ một nền tảng nhắn tin thành một giao thức chuẩn hóa hướng tới Emerald:

  • Jade là adapter Discord. Nó dùng thư viện Eris để kết nối với Discord và chuyển tiếp tin nhắn đến Emerald qua WebSocket. Nó cũng xử lý pipeline TTS (tổng hợp giọng nói qua Piper, chuyển đổi OGG, tải lên Discord).
  • Pixieglow là adapter Matrix. Nó dùng trực tiếp Matrix Client-Server HTTP API (không SDK), với long-poll sync. Nó không có TTS.

Cả hai adapter đều dùng chung một giao thức WebSocket được đị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;

Sự tồn tại của hai adapter với cùng một interface chứng minh rằng việc chia sẻ bộ não thực sự hiệu quả: cùng một "bộ não" (Emerald) phục vụ cả bot Discord lẫn bot Matrix một cách không phân biệt, với hành vi giống hệt nhau. Giao thức mang tính khai báo: Emerald không nói cho adapter biết cách gửi tin nhắn, mà nói cái gì cần gửi (văn bản kèm độ trễ, có thể là một kế hoạch burst, một reaction, v.v.). Mỗi adapter tự thực thi cụ thể cho nền tảng của mình.

Đó là sức mạnh của kiến trúc này: để thêm hỗ trợ cho Telegram, Signal, hay bất cứ thứ gì khác, bạn chỉ cần viết một adapter triển khai giao thức WebSocket.

Bộ não không biết nó đang chạy trên nền tảng nào. Nó nhận một MessageEvent với một clientId ("jade" hoặc "pixieglow"), đưa ra quyết định, và trả về một lệnh. Adapter xử lý phần còn lại.


Lớp 2: bộ não (Emerald)

Emerald là dịch vụ ra quyết định trung tâm. Nó lắng nghe trên cổng 3126 qua WebSocket và xử lý:

  • Đánh giá trigger: nhắc tên, DM, tên riêng, từ khóa, tin nhắn nối tiếp, ngẫu nhiên
  • Mô phỏng hành vi: độ trễ tập trung, gõ sai, ngập ngừng, hay 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 tới Sapphire: gửi tin nhắn, nhận phản hồi dạng stream

Emerald là dịch vụ trung tâm giúp việc chia sẻ bộ não trở nên khả thi, và cũng là dịch vụ hưởng lợi nhiều nhất từ việc tách lớp. Trước đây, mọi hành vi (gõ sai, burst, ngập ngừng) đều bị trộn lẫn với mã Discord. Giờ đây chúng nằm trong các module chuyên biệt dưới behavior/:

emerald/src/behavior/
  burst.ts         -- Lập kế hoạch tin nhắn burst
  mannerisms.ts    -- Độ trễ, ngập ngừng, reaction, hay quên
  sleep.ts         -- Đánh giá lịch trình ngủ
  typo.ts          -- Mô phỏng gõ sai (AZERTY/QWERTY)

Lớp 3: bộ phân loại cảm xúc (Sapphire)

Sapphire là dịch vụ thú vị nhất về mặt kỹ thuật. Nó là một middleware LLM viết bằng Python với FastAPI, đảm nhận bốn vai trò quan trọng:

  1. Bộ phân loại nhị phân FUTILE / INTERESTING thông qua centroid embedding
  2. Bộ tính điểm cảm xúc (valence / arousal) thông qua centroid
  3. Bộ định tuyến backend tới Krystal (mô hình nhỏ vs mô hình lớn)
  4. Bộ tiêm few-shot và quản lý phiên

Centroid: trái tim của việc phân loại

Centroid là một khái niệm đơn giản: đó là trung bình của một tập các vector embedding. Cụ thể, tôi đã thu thập hàng trăm tin nhắn mẫu, cho chúng qua một mô hình embedding (BAAI/bge-small-en-v1.5, 384 chiều), và lấy trung bình các vector kết quả.

Có hai centroid phân loại:

  • futile_centroid: embedding trung bình của khoảng 683 tin nhắn tầm thường ("lol", "ok", "hello") via k-means (k=10, seed=42)
  • interesting_centroid: embedding trung bình của khoảng 678 tin nhắn thực chất (kỹ thuật, cá nhân, triết học) via k-means (k=10, seed=42)

Khi một tin nhắn đến:

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

Độ tương đồng cosine giữa tin nhắn và mỗi centroid quyết định danh mục. Hiệu số tuyệt đối cho biết độ tin cậy. Nó đơn giản, nhanh (không cần forward pass của LLM), và hiệu quả đến bất ngờ.

Tại sao lại hai mô hình?

Kết quả phân loại này quyết định backend LLM nào sẽ được gọi:

Nhãn Backend Krystal Mô hình Cổng
FUTILE generic Luna-Protocol-1.5B (941 MB, Q4_K_M) 3124
INTERESTING semantic Hermes-3-3B hoặc 8B (tùy cấu hình) 3125

Trực giác rất đơn giản: một câu "lol" hay "nm just chillin u" không xứng đáng để gọi một mô hình 8 tỷ tham số. Mô hình Luna 1.5B nhỏ đã fine-tune, huấn luyện trên 200.000 mẫu Discord, đã quá đủ cho các trao đổi nhẹ nhàng. Ngược lại, một câu hỏi về cuộc sống, một lời tâm sự, hay một cuộc tranh luận kỹ thuật sẽ được định tuyến tới mô hình lớn, vốn có thể tạo ra phản hồi phong phú hơn.

Việc định tuyến tiết kiệm này giảm đáng kể tải trên server LLM: khoảng 70% tin nhắn được phân loại là FUTILE và do mô hình nhỏ xử lý, giải phóng mô hình lớn cho những cuộc trò chuyện thực sự xứng đáng.

Trục cảm xúc: valence và arousal

Nhưng đó chưa phải là tất cả. Sapphire dùng cùng cơ chế centroid trên một trục độc lập để đánh giá cảm xúc của 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 số được tính như hiệu số của các độ 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 mức độ tích cực hay tiêu cực của tin nhắn. Arousal đo cường độ cảm xúc của nó. Kết hợp lại, chúng tạo thành mô hình vòng tròn cảm xúc (circumplex model of affect, Russell, 1980) -- cùng mô hình tâm lý học đã truyền cảm hứng cho chatbot PARRY năm 1972.

Biến resentment: cảm xúc điều khiển LLM như thế nào

Đây là chỗ nguồn cảm hứng từ PARRY trở nên hữu hình. PARRY (do Kenneth Colby tạo ra năm 1972) là một chatbot được thiết kế để mô phỏng một bệnh nhân hoang tưởng. Nó có các biến nội tại -- sợ hãi, tức giận, nghi kỵ -- làm thay đổi phản hồi của nó. Ví dụ, một PARRY đang "sợ hãi" sẽ phản hồi hung hăng hơn.

Sapphire làm điều tương tự, nhưng với các biến liên tục và một phương pháp thanh lịch hơn: các tham số lấy mẫu của LLM được điều chỉnh theo thời gian thực dựa trên 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 dễ đoán
0.0 (trung tính) 0.70 Sáng tạo mặc định
+1.0 (hào hứng) 1.00 Ngẫu nhiên tối đa, phản hồi bất ngờ

Khi ai đó hào hứng hoặc bực bội (arousal cao), temperature tăng lên. Mô hình tạo ra các phản hồi đa dạng hơn, sáng tạo hơn, đôi khi hỗn loạn hơn -- giống như một con người "bị cuốn theo". Khi cuộc trò chuyện bình lặng, temperature giảm xuống, và phản hồi trở nên chừng mực 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ị thúc đẩy tránh lặp lại chính mình -- giống như ai đó đang tìm từ trong một cuộc tranh cãi căng thẳng. Cuộc trò chuyện càng tích cực, mô hình càng có thể chấp nhận những phát biểu dư thừa, như một cuộc trò chuyện thư giãn.

Trạng thái cảm xúc tích lũy

Những điểm số này không chỉ áp dụng cho tin nhắn tức thời. Một EmotionState duy trì trung bình động theo cấp số nhân 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 là 0.85 nghĩa là 85% trạng thái trước đó được giữ lại ở mỗi tin nhắn, với 15% tín hiệu mới được tích hợp vào. Điều này tạo ra một bộ nhớ cảm xúc làm dịu đi những biến động đột ngột: một tin nhắn tiêu cực đơn lẻ không khiến bot "buồn", nhưng một chuỗi tin nhắn tiêu cực sẽ dần dần kéo tâm trạng của nó đi theo.

Trong thực tế: nếu ai đó bắt đầu cuộc trò chuyện rất hào hứng (arousal=+0.8), temperature vẫn cao trong vài lượt trao đổi tiếp theo, ngay cả khi các tin nhắn sau đó bình tĩnh hơn. Cảm xúc cần thời gian để lắng xuống -- giống như một con người vẫn còn "nóng" sau một cuộc tranh cãi.


Lớp 4: inference (Krystal)

Krystal là lớp thấp nhất: một wrapper quanh llama.cpp cung cấp một API tương thích OpenAI (/v1/chat/completions). Nó chạy dưới dạng hai instance PM2:

  • krystal-small: mô hình Luna 1.5B đã fine-tune, trên cổng 3124, với CPU affinity 0
  • krystal-large: một mô hình Hermes 3B, trên cổng 3125, với CPU affinity 0,1

Cả hai instance đều là các tiến trình llama-server được biên dịch sẵn, khởi chạy bằng taskset để ghim CPU.

Việc fine-tune mô hình Luna cũng đã tiến hóa kể từ bài viết thứ hai: giờ đây nó được huấn luyện trên 200.000 mẫu (tăng từ 50.000 trước đó), vẫn xuất phát từ Qwen2.5-1.5B-Instruct qua QLoRA. 200 nghìn mẫu này là một tập con của bộ dữ liệu Discord-Dialogues, được lọc để chỉ giữ lại những cuộc trò chuyện tự nhiên và đa dạng nhất. Mục tiêu: mở rộng phạm vi phong cách của mô hình mà không đánh mất sự linh hoạt khiến few-shot priming trở nên hiệu quả đến vậy.


Bức tranh toàn cảnh: một tin nhắn đang được xử lý

Đây là những gì thực sự xảy ra khi ai đó gửi "i'm really sad today" trên Discord:

  1. Jade nhận tin nhắn qua Discord Gateway API. Nó chuyển đổi thành một MessageEvent và gửi tới Emerald qua WebSocket.
  2. Emerald đánh giá trigger (nhắc tên? tên riêng? từ khóa?). Đây là một lần nhắc tên trực tiếp. Nó tính toán độ trễ tập trung, kiểm tra cooldown, phiên, mệt mỏi chủ đề. Nó quyết định phản hồi và gửi tin nhắn tới Sapphire qua HTTP.
  3. Sapphire embedding tin nhắn bằng bge-small-en-v1.5.
    • Phân loại: tin nhắn gần centroid interesting hơn centroid futile (diff = +0.31) -> INTERESTING
    • Cảm xúc: valence tiêu cực (-0.42), arousal vừa phải (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 với các giá trị này
  4. Krystal (instance lớn) tạo ra phản hồi với các tham số đã điều chỉnh theo cảm xúc và gửi lại cho Sapphire.
  5. Sapphire stream phản hồi tới Emerald cùng với metadata (nhãn, valence, arousal, số liệu debug).
  6. Emerald quyết định thêm một sự ngập ngừng ("oh..."), lên kế hoạch burst (2 mảnh), và chọn một reaction. Nó gửi một RespondCommand tới Jade.
  7. Jade thực thi: chờ độ trễ ban đầu, gửi mảnh đầu tiên kèm sự ngập ngừng, chờ 1,5 giây, gửi mảnh thứ hai. Nó hiển thị chỉ báo đang gõ trong suốt quá trình tạo phản hồi.

Tất cả điều này diễn ra trong chưa đầy 3 giây đối với người dùng.


Centroid: tại sao chúng tốt hơn một bộ phân loại neural

Việc lựa chọn centroid embedding thay vì một bộ phân loại truyền thống (như DistilBERT tôi từng dùng trước đây) đáng để giải thích.

Một bộ phân loại neural học một ranh giới quyết định giữa các lớp -- thường là một phép biến đổi phi tuyến ánh xạ đầu vào thành xác suất. Nó chính xác, nhưng:

  • Nó cần dữ liệu huấn luyện đã gán nhãn
  • Nó nhạy cảm với sự thay đổi phân phối (data drift)
  • Nó khó diễn giải
  • Nó cần được huấn luyện lại để thêm một lớp mới

Ngược lại, centroid là một vector trung bình của các embedding mẫu. Việc phân loại được thực hiện bằng độ tương đồng cosine với vector trung bình đó. Ưu điểm:

  • Không cần huấn luyện: bạn chỉ cần tính trung bình embedding của các ví dụ được chọn thủ công
  • Dễ diễn giải: bạn có thể xem những ví dụ nào gần centroid nhất để hiểu "centroid đã học được gì"
  • Thêm một lớp: bạn chỉ cần thêm một centroid mới -- không cần huấn luyện lại
  • Vững chắc: centroid là một trung bình, nên các giá trị ngoại lai có ít tác động

Sức mạnh thực sự của centroid là chúng biến một bài toán phân loại thành một bài toán đo khoảng cách không gian. Bạn có thể hình dung các danh mục như những vùng trong không gian 384 chiều (hoặc trong 2D/3D sau khi giảm chiều bằng PCA/t-SNE).

Trực quan hóa centroid 3D

Trong thực tế, đây là hình ảnh của các centroid phân loại trong không gian embedding. Mỗi điểm là một tin nhắn mẫu, được chiếu vào 3D qua PCA (384 chiều gốc được giảm xuống còn 3 để trực quan hóa). Các điểm màu xanh là tin nhắn vô ích (futile), các điểm màu vàng là tin nhắn thú vị (interesting). Hai viên kim cương lớn là các centroid đã tính toán -- trung bình của mỗi nhóm. Di chuột qua một điểm để xem văn bản gốc của ví dụ.

Hai ví dụ được hiển thị màu đỏ: "lol" (phân loại futile) và "i feel sad today" (phân loại interesting). "lol" rơi vào đám mây xanh của các tin nhắn vô ích, trong khi "i feel sad today" nằm về phía các điểm màu vàng. Sự phân tách vẫn có thể nhìn thấy ngay cả sau khi giảm xuống 3 chiều (chỉ giải thích được 14,7% tổng phương sai). Trong không gian 384 chiều, ranh giới sắc nét hơn nhiều.

Centroid của tin nhắn đầu vào di chuyển trong không gian này tùy theo nội dung của nó. Việc phân loại FUTILE/INTERESTING đơn giản chỉ là đo xem centroid nào gần hơn bằng độ tương đồng cosine. Điều này cho phép chúng ta biểu diễn mỗi tin nhắn như một điểm trong không gian đa chiều, với mỗi chiều tương ứng với một thuộc tính ngữ nghĩa.


Điều này thay đổi gì trong thực tế

Người dùng không nhìn thấy các lớp, các centroid, hay các điều chỉnh temperature. Nhưng họ cảm nhận được hiệu ứng:

  • Phản hồi nhanh hơn cho các tin nhắn đơn giản (mô hình nhỏ nhanh gấp 2 lần và xử lý 70% lưu lượng)
  • Giọng điệu thích ứng: nếu bạn đang khó chịu, bot "cảm nhận" được sự bực bội và điều chỉnh phong cách của nó
  • Tính nhất quán đa nền tảng: một bot Matrix và một bot Discord chia sẻ cùng một bộ não và cùng một trạng thái cảm xúc
  • Không có "chế độ trợ lý": fine-tune + few-shot + định tuyến thông minh giúp tránh những phản hồi nghe như doanh nghiệp

Việc tăng tập huấn luyện của mô hình nhỏ lên 200.000 mẫu càng củng cố thêm những hiệu ứng này: mô hình nắm bắt tốt hơn sự đa dạng của các cuộc trò chuyện trên Discord mà không đánh mất sự linh hoạt mà few-shot priming mang lại.


Toàn bộ hạ tầng

Đây là các dịch vụ đang chạy hiện tại:

Dịch vụ Công nghệ Cổng Vai trò
Pixieglow TypeScript (Bun) -- Adapter Matrix
Jade TypeScript (esbuild) -- Adapter Discord
Emerald TypeScript (Bun) 3126 (WebSocket) Bộ 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, futile)
Krystal large llama.cpp (PM2) 3125 Mô hình lớn (3B+, interesting)

Các phụ thuộc giữa các dịch vụ là một chiều: adapter phụ thuộc vào Emerald, Emerald phụ thuộc vào Sapphire, Sapphire phụ thuộc vào Krystal. Không có vòng lặp. Mỗi dịch vụ có thể được khởi động lại một cách độc lập.


Kết luận

Việc tách Luna Protocol thành bốn lớp không chỉ là một bài tập kiến trúc. Đó là câu trả lời cho những hạn chế cụ thể: không thể hỗ trợ Matrix, thiếu nhận thức cảm xúc, và không có sự ưu tiên thông minh cho tin nhắn.

Ngày nay, hệ thống mạnh mẽ hơn (một sự cố LLM không làm chết bot), có thể mở rộng hơn (một adapter Telegram hay WhatsApp sẽ tuân theo cùng một giao thức WebSocket), và "sống động" hơn: bot điều chỉnh hành vi, giọng điệu, và thậm chí cả các tham số của LLM theo trạng thái cảm xúc cảm nhận được của cuộc trò chuyện.

Centroid embedding là mảnh ghép then chốt giúp tất cả những điều này khả thi mà không cần sự phức tạp quá mức: không có mạng neural đã huấn luyện, không có pipeline dữ liệu gán nhãn, chỉ có các vector trung bình và độ tương đồng cosine. Đó là một kỹ thuật đơn giản, hiệu quả đến kinh ngạc, và bị đánh giá thấp một cách tệ hại.

Tài nguyên Liên kết
Trang web dự án protocol-luna.github.io
Pixieglow protocol-luna/pixieglow
Emerald protocol-luna/emerald
Sapphire protocol-luna/sapphire
Krystal protocol-luna/krystal
Bài viết 1: bot Discord Luna Protocol: tôi đã xây dựng một bot Discord tự động
Bài viết 2: fine-tuning Luna Protocol: tại sao tôi fine-tune một 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