Pinned Post

Who picks the tool

Bild
The previous post ended with an agent that writes a missing tool into a running node. Generate, sanitize, persist, compile, load, execute, about 25 milliseconds after the model answers. I was happy with that for roughly a week. Then I looked at the demo page again and counted the text fields. Tool name. What it should do. Parameters as JSON. I had filled in all three. The gap-finding was real. The deciding was mine. MetaPlannerAgent.run("Publish the article", [ %{tool: "classic_plan", params: %{goal: "Write draft, review, publish"}}, %{tool: "slugify_text", description: "Turns a title into a URL slug", params: %{text: "Hello World"}} ]) Everything interesting in that call sits in the second argument, and I typed it. The agent resolved slugify_text , noticed it wasn't in the catalog and had it written. It never asked whether the task needed a slug at all. So this post is about the agent in front of that, the on...

Ein ML-Modell und eine Web-App auf zwei BEAM-Knoten trennen — der technische Bauplan

Das Problem in einem Satz

Die Web-App (Phoenix + Postgres) läuft produktiv auf einer kleinen Linux-Box ohne GPU. Das Embedding-Modell (intfloat/multilingual-e5-base, ~278 MB, XLM-RoBERTa-Backbone, EXLA-kompiliert) will Rechenleistung, die dort nicht ist. Die sitzt in einem MacBook Pro. Ziel: nur die schwere Inferenz auf den Mac auslagern, alles andere bleibt auf dem Server — und der aufrufende Code soll nicht wissen, wo gerechnet wird.

Die übliche Antwort wäre ein zweiter Service mit gRPC/REST-API, Serialisierung, Service-Discovery, Timeouts, Retries und einem zweiten Deployment-Artefakt. Auf der BEAM ist „eine andere Maschine" dagegen ein Plattform-Primitive. Was es braucht, ist keine neue Infrastruktur, sondern eine saubere Aufteilung einer Codebasis in zwei Releases und drei Knoten-Rollen.

Das mentale Modell: eine Codebasis, zwei Releases, drei Rollen

Es gibt genau eine Codebasis (ein Umbrella-Projekt) und keine zweite Sprache. Was variiert, ist nur, was ein gestarteter Knoten hochfährt. Das steuert eine einzige Umgebungsvariable, FINANCE_ROLE:

RolleKnotenStartetFINANCE_ROLE
:webLinuxPhoenix, Postgres, Jido, Klassifikations-Logikweb
:mlMacBooknur die Nx.Serving (Modell + EXLA)ml
:allDev/Testalles in einem Knoten(default)

Wichtig: Der Web-Knoten fährt die Klassifikations-Logik hoch (Label-Vektoren aus der DB, Cosine-Similarity), nur den einen schweren Schritt — das Einbetten des Textes in einen Vektor — schiebt er auf den ML-Knoten. Der ML-Knoten hat keine Datenbank, kein Phoenix, kein DATABASE_URL und kein SECRET_KEY_BASE.

Drei Bausteine machen das aus, in dieser Reihenfolge:

  1. Dependency-Partitionierung — welche Libraries landen überhaupt in welchem Release.
  2. Rollenbasierter Supervision-Tree — was jeder Knoten zur Laufzeit startet.
  3. Verteilte Nx.Serving — wie der Inferenz-Aufruf knotenübergreifend geroutet wird, ohne dass der Aufrufer es merkt.

Baustein 1: Dependencies partitionieren mit runtime: false

Das ist der Teil, der am wenigsten offensichtlich und am wichtigsten ist. Der ML-Stack (bumblebee, exla, tokenizers) bringt architektur-spezifische NIFs mit: libexla, libex_tokenizers. Diese werden für die Maschine kompiliert, auf der das Release gebaut wird. Ein auf macOS (arm64) gebautes libexla lädt auf einem Linux-x86_64-Host schlicht nicht. Es darf also gar nicht erst im Web-Release landen.

Der Hebel dafür ist runtime: false in der mix.exs der finance-App:

# apps/finance/mix.exs
defp deps do
[
{:libcluster, "~> 3.5"},
{:ecto_sql, "~> 3.13"},
{:postgrex, ">= 0.0.0"},
# ...
# nx läuft auf BEIDEN Knoten: der Web-Knoten braucht es für die Cosine-
# Similarity und den verteilten Nx.Serving.batched_run. Direkt deklariert,
# damit es im Web-Release bleibt, obwohl seine üblichen Quellen
# (bumblebee/exla) runtime: false sind.
{:nx, "~> 0.12"},
# Der gesamte ML-Stack läuft nur auf dem finance_ml-Knoten. runtime: false
# hält diese — und ihre arch-spezifischen NIFs (libexla, libex_tokenizers) —
# aus dem OTP-Application-Closure, sie landen also nicht im Web-Release.
{:bumblebee, "~> 0.6", runtime: false},
{:exla, "~> 0.9", runtime: false},
{:tokenizers, "~> 0.5", runtime: false, override: true},
{:jido, "~> 2.2"}
]
end

runtime: false bedeutet: Die Dependency wird zum Kompilieren benutzt, aber nicht in das OTP-Application-Closure aufgenommen, das mix release für die Boot-Reihenfolge berechnet. Sie wird also nicht automatisch gestartet und — entscheidend — standardmäßig nicht ins Release gepackt.

nx ist bewusst eine normale Dependency, denn es läuft auf beiden Knoten: Der Web-Knoten braucht Nx für die Cosine-Similarity und für den batched_run-Aufruf selbst. Nur die schwere Compilation (EXLA) und das Modell-Loading (Bumblebee) sind exklusiv für den ML-Knoten.

Zwei Release-Definitionen

Die Release-Definitionen in der Umbrella-mix.exs ziehen die Trennlinie:

# mix.exs (Umbrella-Root)
defp releases do
[
# Web-Knoten (Linux): Phoenix + DB + Klassifikations-Orchestrierung.
finance_web: [
applications: [
finance: :permanent,
finance_web: :permanent
],
include_erts: false,
steps: [:assemble, :tar]
],
# ML-Knoten (MacBook): nur Nx.Serving, kein web/DB. Schließt finance_web
# aus, sodass hier nie ein Endpoint startet. Zieht den ML-Stack explizit
# wieder herein (bumblebee/exla/tokenizers sind runtime: false in finance);
# bumblebee bringt transitiv axon/safetensors/etc. mit.
finance_ml: [
applications: [
finance: :permanent,
bumblebee: :permanent,
exla: :permanent,
tokenizers: :permanent
],
include_erts: true,
steps: [:assemble, :tar]
]
]
end

Zwei Dinge sind hier subtil:

  • finance_web enthält finance_web (Phoenix), aber NICHT den ML-Stack. Weil bumblebee/exla/tokenizers runtime: false sind, sind sie nicht im Closure von finance und landen damit nicht im Web-Release. Genau das wollen wir: keine macOS-NIFs auf dem Linux-Host.
  • finance_ml enthält finance (für die Serving-Definition und die Rollen-Logik), den ML-Stack — aber NICHT finance_web. Auf dem ML-Knoten startet nie ein Phoenix-Endpoint. Indem wir bumblebee, exla und tokenizers explizit in applications: listen, holen wir sie trotz runtime: false zurück in genau dieses Release.

include_erts: warum die zwei Releases sich unterscheiden

  • finance_web: include_erts: false. Das Release bringt keine Erlang-Runtime mit; der Linux-Host muss eine kompatible Erlang/Elixir-Version bereitstellen.
  • finance_ml: include_erts: true. Der ML-Knoten läuft auf derselben Maschine, auf der er gebaut wird (dem Mac), also ist das Bundling der ERTS sicher — und es vermeidet die Abhängigkeit davon, welches Erlang der Run-Ordner über asdf/PATH auflöst. Ein Mismatch dort führt zu einem load_failed von kernel/stdlib beim Boot.

Die Konsequenz für den Build: pro Architektur einmal bauen

Weil NIFs und (bei finance_ml) die ERTS nicht portabel sind, gilt: Jedes Release wird auf seiner Ziel-Architektur gebaut. finance_web auf einem Linux-x86_64-Host, finance_ml auf macOS-arm64. Cross-Compiling ist kein Pfad, den dieses Setup geht.

Ein Stolperstein beim Bauen: mix release --overwrite lässt stale App-Verzeichnisse stehen (z.B. exla, das aus finance_web ausgeschlossen ist). Deshalb wird das Release-Verzeichnis vor jedem Build geleert:

release_web:
rm -rf _build/prod/rel/finance_web
MIX_ENV=prod mix release finance_web

release_ml:
rm -rf _build/prod/rel/finance_ml
MIX_ENV=prod mix release finance_ml

Baustein 2: Der rollenbasierte Supervision-Tree

Zur Laufzeit liest die Application.start/2 die Rolle und setzt den Supervision-Tree aus drei Teilen zusammen. Es ist dieselbe Binary-Logik in beiden Releases — nur die Rolle entscheidet, welche Kinder starten:

# apps/finance/lib/finance/application.ex
def start(_type, _args) do
role = role()

children =
cluster_children() ++ web_children(role) ++ serving_children(role)

case Supervisor.start_link(children, strategy: :one_for_one, name: Finance.Supervisor) do
{:ok, _} = ok ->
if role in [:web, :all] do
if Application.get_env(:finance, :seed_default_labels, true) do
Finance.Transactions.ensure_default_labels()
end

ensure_classification_agent()
end

ok

other ->
other
end
end

defp role, do: Application.get_env(:finance, :role, :all)

Die drei Helfer machen die Trennung explizit:

# Alles außer der Nx.Serving. Die Klassifikations-Logik (Label-Embeddings,
# Cosine-Similarity) lebt hier — sie lagert nur den Embedding-Aufruf an den
# ml-Knoten via verteiltem Nx.Serving.batched_run aus.
defp web_children(:ml), do: []

defp web_children(_role) do
[
Finance.Repo,
{Phoenix.PubSub, name: Finance.PubSub},
Finance.Jido
]
end

# Die Nx.Serving (Modell-Load + EXLA). Startet auf dem ml-Knoten, und auf :all,
# wenn ein echter Classifier konfiguriert ist (Bumblebee in dev, Stub → nichts).
defp serving_children(:web), do: []
defp serving_children(:ml), do: [Finance.Classifier.Bumblebee]

defp serving_children(:all) do
case Application.get_env(:finance, :classifier) do
nil -> []
Finance.Classifier.Stub -> []
module -> [module]
end
end

Lies das als Matrix:

Kind:web:ml:all
Finance.Repo (Postgres)
Phoenix.PubSub, Jido
Nx.Serving (Bumblebee)(config-abh.)
Default-Labels seeden
Classification-Agent starten

Der Web-Knoten hat also die volle App, aber keine Nx.Serving. Der ML-Knoten hat nur die Nx.Serving. Im Dev/Test (:all) läuft alles in einem Prozess — mit dem Stub-Classifier in Tests, sodass kein Modell geladen werden muss.

Baustein 3: Verteilte Nx.Serving — Location-Transparenz

Jetzt die Frage, die alles zusammenhält: Wenn der Web-Knoten keine Serving startet, wie ruft sein Klassifikations-Code die Inferenz auf dem ML-Knoten auf?

Die Antwort: gar nicht anders als lokal. Nx.Serving ist von Grund auf verteilungs-transparent. Eine gestartete Serving registriert sich unter ihrem Namen in einer Prozessgruppe, die clusterweit sichtbar ist. batched_run/2 löst den Namen zu einem Serving-Mitglied auf — egal ob lokal oder auf einem anderen Knoten — und routet die Eingabe dorthin. Der aufrufende Code ist in beiden Fällen identisch:

# apps/finance/lib/finance/classifier/bumblebee.ex
#
# Einziger Einstiegspunkt zur Nx.Serving — prependet den E5-Prefix.
# Die Serving kann auf einem entfernten Knoten laufen. Ist dieser nicht
# erreichbar, *exitet* batched_run mit :noproc statt zu raisen. Wir übersetzen
# das in einen getaggten throw, damit classify/1 {:error, :classifier_unavailable}
# melden kann — abgegrenzt von einem echten Fehler.
defp embed(text, prefix) do
Nx.Serving.batched_run(@serving_name, prefix <> text)
catch
:exit, reason -> throw({:classifier_unavailable, reason})
end

@serving_name ist auf beiden Knoten derselbe Atom (...Bumblebee.Serving). Über die Leitung wandert nur der String (die normalisierte Transaktion mit "query: "-Prefix); zurück kommt der Embedding-Tensor. Die ganze Matrix-Multiplikation passiert auf dem Mac.

Auf dem ML-Knoten wird die Serving ganz normal gestartet:

def start_link do
# exla/bumblebee/tokenizers sind runtime: false, werden also nicht
# auto-gestartet. Hier explizit starten — der einzige Code-Pfad, der
# Modell + EXLA-Compiler braucht.
{:ok, _} = Application.ensure_all_started(:exla)
{:ok, _} = Application.ensure_all_started(:bumblebee)

:persistent_term.erase(@label_cache_key)

repo = hf_repo(@model_repo)
{:ok, model_info} = Bumblebee.load_model(repo)
{:ok, tokenizer} = Bumblebee.load_tokenizer(repo)

serving =
Bumblebee.Text.text_embedding(model_info, tokenizer,
output_attribute: :hidden_state,
output_pool: :mean_pooling,
embedding_processor: :l2_norm,
compile: [batch_size: 1, sequence_length: 160],
defn_options: [compiler: EXLA]
)

Nx.Serving.start_link(
serving: serving,
name: @serving_name,
batch_size: 1,
batch_timeout: 100
)
end

Beachte das Application.ensure_all_started(:exla) / (:bumblebee): Weil diese Deps runtime: false sind, startet die VM sie nicht automatisch. Dieser Code-Pfad — der einzige, der das Modell und den EXLA-Compiler überhaupt braucht — startet sie explizit. Auf dem Web-Knoten wird start_link/0 nie aufgerufen, also werden EXLA/Bumblebee dort nie angefasst (und sind, wie gesagt, gar nicht erst im Release).

Der Web-Knoten braucht trotzdem Nx — aber kein EXLA

Der Web-Knoten rechnet die Cosine-Similarities lokal (Nx.dot/2 auf zwei 768-dim-Vektoren ist trivial). Dafür reicht der reine Elixir-Backend; EXLA wäre sinnlos und nicht installiert. Das setzt die runtime.exs:

# config/runtime.exs
if role == :web do
config :nx, :default_backend, Nx.BinaryBackend
end

Knoten verbinden: libcluster, Cookie, EPMD

Damit batched_run den Namen knotenübergreifend auflösen kann, müssen die Knoten ein verbundener Erlang-Cluster sein. Das übernimmt libcluster mit der statischen Epmd-Strategie. Die Peer-Namen kommen als kommaseparierte Liste aus der Umgebung:

# config/runtime.exs
cluster_hosts =
System.get_env("FINANCE_CLUSTER_HOSTS", "")
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.map(&String.to_atom/1)

if cluster_hosts != [] do
config :libcluster, :topologies,
finance: [
strategy: Cluster.Strategy.Epmd,
config: [hosts: cluster_hosts]
]
end

Der Cluster.Supervisor wird nur gestartet, wenn eine Topologie konfiguriert ist — Dev/Test bleiben dadurch single-node:

# application.ex
defp cluster_children do
case Application.get_env(:libcluster, :topologies) do
nil -> []
topologies -> [{Cluster.Supervisor, [topologies, [name: Finance.ClusterSupervisor]]}]
end
end

Die restliche Verteilungs-Plumbing sind Standard-Release-Variablen. Aus dem lokalen Zwei-Knoten-Smoke-Test (beide auf demselben Mac) im Makefile:

COOKIE = secret
WEB_NODE = finance_web@127.0.0.1
ML_NODE = finance_ml@127.0.0.1
CLUSTER_HOSTS = $(WEB_NODE),$(ML_NODE)

# ml-Knoten ZUERST starten, damit die Serving oben ist, bevor der web-Knoten
# klassifiziert. Der ml-Knoten lädt das Modell (~278 MB) und zahlt den
# EXLA-Compile beim ersten Boot.
run_ml:
FINANCE_ROLE=ml \
RELEASE_DISTRIBUTION=name \
RELEASE_NODE=$(ML_NODE) \
RELEASE_COOKIE=$(COOKIE) \
FINANCE_CLUSTER_HOSTS="$(CLUSTER_HOSTS)" \
_build/prod/rel/finance_ml/bin/finance_ml start_iex

run_web:
FINANCE_ROLE=web \
RELEASE_DISTRIBUTION=name \
RELEASE_NODE=$(WEB_NODE) \
RELEASE_COOKIE=$(COOKIE) \
FINANCE_CLUSTER_HOSTS="$(CLUSTER_HOSTS)" \
PHX_HOST=localhost PORT=4000 \
SECRET_KEY_BASE=... \
DATABASE_URL=ecto://postgres:postgres@localhost/finance_dev \
_build/prod/rel/finance_web/bin/finance_web start_iex

Das Wesentliche:

  • Gemeinsamer RELEASE_COOKIE auf beiden Knoten — das Authentifizierungs-Geheimnis des Erlang-Distribution-Protokolls. Ohne identischen Cookie verbinden sich die Knoten nicht.
  • RELEASE_DISTRIBUTION=name + Long-Names (finance_web@127.0.0.1).
  • FINANCE_CLUSTER_HOSTS listet beide Knoten, sodass libcluster sie findet.
  • Boot-Reihenfolge: ML-Knoten zuerst, damit die Serving registriert ist, bevor der Web-Knoten den ersten batched_run absetzt. (Ist sie es nicht, greift der Fallback unten — kein Crash.)

Steht eine Firewall zwischen echten Maschinen, müssen EPMD (Port 4369) und ein fester Distribution-Port offen sein (via -kernel inet_dist_listen_min/max in einer rel/vm.args.eex). Im lokalen Setup ist das nicht nötig.

Failure-Handling: „ehrlich unsicher" auf Infrastruktur-Ebene

Die kritische Frage bei jeder verteilten Inferenz: Was, wenn der andere Knoten weg ist? Eine naive verteilte App würde abstürzen oder hängen. Nx.Serving.batched_run löst hier kein raise aus, sondern einen :exit mit :noproc (kein registrierter Serving-Prozess auffindbar). Genau den fangen wir und übersetzen ihn in einen abgegrenzten Zustand statt in einen Fehler:

def classify(text) when is_binary(text) do
case label_embeddings() do
[] -> {:ok, @fallback_label}
embeddings ->
%{embedding: text_embedding} = embed(text, @query_prefix)
# ... cosine similarity gegen alle Label-Vektoren, bester Score ...
end
rescue
e -> {:error, e}
catch
# Vom embed/2 geworfen, wenn die (evtl. entfernte) Nx.Serving unerreichbar ist.
:throw, {:classifier_unavailable, _reason} -> {:error, :classifier_unavailable}
end

Das Behaviour macht diesen Zustand zu einem Vertrag:

# apps/finance/lib/finance/classifier.ex
# {:error, :classifier_unavailable} ist reserviert für "die Serving war nicht
# erreichbar" (z.B. der finance_ml-Knoten ist down). Caller behandeln das als
# "später erneut versuchen", nicht als Klassifikations-Ergebnis.
@callback classify(text :: String.t()) ::
{:ok, label :: String.t()}
| {:error, :classifier_unavailable}
| {:error, term()}

Die Folge: Ist das MacBook aus, bleibt die Transaktion unklassifiziert liegen, statt falsch einsortiert zu werden. Das ist dasselbe Prinzip wie der Similarity-Schwellwert auf der Modell-Ebene („lieber Sonstiges als selbstbewusst falsch"), nur eine Schicht tiefer: lieber pending als falsch.

Dass die Klassifikation ohnehin asynchron über einen Jido-Agenten läuft (eine Transaktion pro Durchlauf, stößt sich selbst neu an), macht den Fall billig: Ein nicht erreichbarer ML-Knoten blockiert nie einen Web-Request, und sobald der Knoten zurück ist, läuft der nächste Durchlauf einfach durch.

Migrationen im Release

Da mix im Release fehlt, laufen Ecto-Migrationen über eval auf dem Web-Knoten (der ML-Knoten hat keine DB):

bin/finance_web eval "Finance.Release.migrate()"

eval startet die Runtime-Config (also muss DATABASE_URL gesetzt sein), bootet aber nicht den Supervision-Tree — die Migration läuft gegen eine Einmal-Verbindung, die danach abgebaut wird.

Die Bilanz: was das kostet und was nicht

Was dieses Setup an „verteiltem System" kostet, ist überschaubar und steht fast vollständig in diesem Artikel:

  • ein paar Zeilen Rollen-Logik im Supervision-Tree,
  • runtime: false auf drei Deps plus zwei Release-Definitionen,
  • ein catch für den unerreichbaren Knoten,
  • eine Handvoll Env-Variablen (Cookie, Distribution, Cluster-Hosts).

Was es nicht kostet: keine API-Definition, keine Serialisierungsschicht, kein Service-Mesh, keine zweite Sprache, kein zweites Repo. Der aufrufende Code (Nx.Serving.batched_run/2) ändert sich um exakt null Zeilen zwischen „alles lokal" und „Modell auf dem Mac".

Die ehrlichen Grenzen:

  • Cross-Arch-Builds sind manuell. Jedes Release muss auf seiner Ziel-Architektur gebaut werden. Es gibt keine CI-Pipeline, die das abstrahiert — bewusst, für ein Projekt dieser Größe.
  • Statische Topologie. FINANCE_CLUSTER_HOSTS listet feste Knotennamen. Für ein dynamisch skalierendes Cluster (Kubernetes etc.) wäre eine andere libcluster-Strategie (DNS, Gossip) die richtige Wahl — hier wäre sie Ballast.
  • Ein einzelner ML-Knoten. Es gibt kein Load-Balancing über mehrere ML-Knoten. Nx.Serving würde das über die Prozessgruppe von sich aus unterstützen, aber das Setup braucht es (noch) nicht.

Der eigentliche Punkt: Verteilte ML-Inferenz gilt in vielen Stacks als eigenständiges Infrastruktur-Projekt. Auf der BEAM ist „lass das woanders rechnen" ein Baustein der Plattform, der schon da ist, bevor man anfängt — Fehlertoleranz, Verteilung und Nebenläufigkeit inklusive. Man bezahlt die Trennung nicht mit Architektur, sondern mit Konfiguration.

Kommentare

Beliebte Posts aus diesem Blog

Splitting an ML model and a web app across two BEAM nodes — the technical blueprint

Jido in Practice: Agents in Elixir as Composable Actions

A Foundation Model in the BEAM: On-Chain Anomalies with Google TimesFM in Elixir