GitHub avatar

Fox's Blog

NEW

Sabaki vs System One: David vs Goliath, the smallest decision model in the world

20KB vs 421M parameters. Sabaki drops 7 points on typed-decisions but goes from 25s CPU to under 1ms offline. Anatomy of an organizational MoE.

Introduction

If you hang out on Hacker News or Twitter since September, you definitely saw Jev fly by.

On September 15, 2026, TypeSafe ships Jev, their first "System One". The pitch fits in one sentence: stop asking an LLM to generate JSON to pick a label. Hand it a state plus typed questions, it hands you back typed decisions with probabilities. One pass, zero tokens to sample, nothing to parse after.

It blew up. Record first-day adoption on Vercel AI Gateway, five Elixir customers in a few days, everybody benchmarking Jev against their own thing. And honestly the hype makes sense. For years we tinkered with answer ONLY with {"choice": ...} prompts while praying the model would not invent an option, break the JSON, or hallucinate a field. Jev named that need and shipped it as a clean API: POST /v1/systemone, choice / score / noul questions, answers with probabilities + confidence.

I loved the idea. The eco model, less -- and to be clear, I never paid for Jev.

Because behind it, Jev stays a closed API. Unknown weights, no self-host, every decision = a network round-trip + a meter running. And the open alternatives that followed land around 400M parameters with a mandatory GPU.

So I built the other extreme: Sabaki. A keyword router + a logistic regression in pure TypeScript that speaks the same contract as Jev (same choice / score / noul shapes, same /v1/systemone endpoint), but runs in under 1ms with 20 kilobytes of JSON. Zero dependencies in nano mode, offline, $0.

To put it in perspective: Laya is 843 MB of safetensors to load. OpenDecider-nano is 2 GB of RAM. Sabaki nano invoices is 20 KB. The weight ratio sits around 40,000.

If you want the square, numbers version, there is the October 2026 v1 preprint on the site: Sabaki: An Organizational Mixture-of-Experts for Typed Decisions on CPU (PDF). Design, measurements, tradeoffs, it is all there. This post is the readable version, with the open code next to it.

I did not write it in my corner in paper-benchmark mode. I contribute to azalio/doomLaya (my fork: fox3000foxy/doomLaya), a harness where Laya and Jev really play FreeDoom with model-owned decisions, per-tick telemetry and videos. I saw Goliath play for real, and as soon as the network coughs, he coughs too.

We will open the engines one by one. As usual, with the real code.

The ring everyone agrees to fight in

There is one dataset Jev, Laya and OpenDecider all cite: LocalLLaMA/typed-decisions. 4 synthetic workflows (support, invoices, security, agent traces), 5 questions per case, 1200 train cases, 400 test, 2000 decisions.

Model Params Test score Latency Where it runs
OpenDecider-nano ~400M 0.796 17ms GPU GPU, 2 GB RAM
Laya typed-decisions 421M 0.766 ~25s/state CPU, ~830ms/case MLX GPU
Jev 1.13 closed ~0.73-0.75 ~710ms API API only
Sabaki mini 22M int8 0.69 ~20ms CPU CPU, 22 MB
Sabaki nano ~0 0.61 <1ms everywhere
Laya base zero-shot 421M 0.362 same GPU

Chance is 0.318, majority is 0.461. So base Laya at 0.362 with no fine-tune is "I answer at random while staying polite" level. Keep that in mind, it will matter.

7 points separate Sabaki from the best. 1000x of latency separate them the other way when you have no GPU. That is the whole post in two sentences, the rest is here so you get why.

Still, be careful: behind the same choice / score / noul plug, the kitchens have nothing in common. Jev = closed API. Laya = fine-tuned ModernBERT-large. OpenDecider = distilled Ettin-encoder. Sabaki = word counting + int8 MiniLM. Same contract, incomparable budgets.

Jev, the hit everyone saw coming

Give TypeSafe what belongs to TypeSafe: the System One contract is a genuinely good idea.

Before, to triage a ticket, you did this: you asked an LLM "pick between billing, technical and spam, answer in JSON", you parsed the answer, you handled the cases where it answers "I would say billing! :)" or invents a 4th category. Every question cost tokens, every answer could break your pipeline.

Jev changes the deal: you send the text + questions with their criteria, it scores each question in parallel and hands you a typed value with its distribution.

curl https://api.typesafe.ai/v1/systemone \
 -H "Authorization: Bearer $KEY" \
 -H 'content-type: application/json' \
 -d '{"state":"charge double ce mois-ci","questions":{"dept":{"type":"choice","instructions":"Route ce message.","criteria":{"billing":"paiements","technical":"bugs"}}}}}'

Answer: { choice, probabilities, confidence }. No free text. Your code can branch on it directly.

That is why it went viral: Python + JS SDK, Claude Code plugin, LangChain integration, everybody could plug their agents into it in an afternoon. The TypeSafe docs even push the healthy pattern: Jev for fast little judgments, deterministic code around it to compose the real decision, slow LLM or human as soon as confidence runs low.

The problem is everything else. Closed weights, offline impossible, per-call pricing. For an agent making 100 decisions per run, the tax stings. For Doom at 35 ticks/s, it is dead: in doomLaya, Jev clears MAP01 seed 48 in 68.97s with an HTTP p50 of 357ms. The run reports $0.0046 in API cost -- not my bill, the benchmark's. It works, the run is clean, no death. But 357ms at 35 ticks/s means 12 ticks where the game keeps going while you wait on the network.

Sabaki stole exactly that contract -- same questions, same answers, same /v1/systemone endpoint served by serve() -- while dropping everything that needs a datacenter.

Laya, the big open one that proved you could do it without an API

Laya by Convai Innovations, Apache-2.0. Under the hood: ModernBERT-large, 421M, with 1 [MASK] token per option. The hidden state of each marker goes through a small MLP, softmax over the question options. One pass per question, like Jev.

pipe = pipeline("text-classification", model="convaiinnovations/laya-typed-decisions")

The 0.766 deserves a pause. It is the checkpoint fine-tuned on the benchmark train set: 1200 cases, RLCD with gaussian noise on the logits, log + spherical reward. The same model without that fit drops to 0.362. In other words, it guesses. So the Laya score means "big encoder + training on the domain", not "magic big encoder".

On CPU, it is a brick. The measured ONNX port runs around 25s per state. In MLX FP16, count 830ms per case on average with a p99 at 5.3s. And you have to load 843 MB before you answer anything.

In doomLaya, stock Laya fails MAP01 in 180s. The adapted v3 (azalio/laya-doom-v3), retrained on Doom, clears it in 61.43s with a 108ms p50 on MPS, grabbing the shotgun on the way. So yes, it plays -- after dedicated training, on an Apple GPU, with a scripted executor doing the real work (A*, doors with a 15-tick latch, auto-aim).

My summary: with no training on your task, Laya is a 421M clipboard for you. With it, it is very strong, but you need the GPU and the data to go with it.

OpenDecider, the one that won the leaderboard

OpenDecider is Manjunath deciding to come after everybody on their own benchmark. Result: 0.796 with a fully fine-tuned Ettin-encoder-400m + MLP head, KL loss on soft targets distilled from Qwen3-235B + DeepSeek.

pip install opendecider
# 17ms/question on L40S, 18ms on M4 Max, ~9ms batched

At 50% coverage, selective accuracy climbs to 0.943 vs 0.882 for Jev. On paper, the best of both worlds: open, calibrated, fast.

In practice: 2 GB of RAM to load nano, GPU required to hold the 17ms, phishing at 0.63 vs 0.90 for Jev (OOD wipes it out), and like Laya, trained on the benchmark train set. The test was never seen, but the domain was.

It wins the arm-wrestling match, but it is still 400M with a GPU behind it. You do not stuff it into a Cloudflare Worker or a game loop on a laptop.

The 150Ms, and the gap below

openJev-verdict-2.0 (151M), julia-1 (144M multilingual), Open Jev (150M), Largha-150M (144M en+zh), Vela (0.3B-9B). All ModernBERT or mmBERT-small, all able to run on CPU, all fun to train (Open Jev claims 30min on a Colab T4). Self-reported numbers, different protocols, so take them with a grain of salt, but the family exists.

The Sabaki generalist is 22.7M. About 6.5x smaller than the smallest of that bunch. Nobody else goes under 100M to answer runtime-defined questions.

That is the "smallest in the world" bit: not the best score, the smallest envelope that reads your instructions + criteria and decides when no specialist knows the answer.

Sabaki, the dumb router and its disposable experts

OK, enough about the big guys. Let us get to David, because that is where it gets fun.

Sabaki does not try to be a big smart model that understands the world. It is an organizational MoE: 1 sparse router at system level, independent experts, one active per input. Each plugin is a separate npm package, with its manifest, its weights, its version. You only install what you route to.

The sabaki-js org: 13 repos, the sabaki core (129 commits), 6 @sabaki-js/* plugins, the weights, the Cloudflare worker, the template to write your own.

The core package.json already tells the story:

"dependencies": { "@typesafe-ai/sdk": "^0.7.0" },
"optionalDependencies": { "onnxruntime-node": "^1.22.0" }

In nano mode, zero runtime dependencies. Just compiled TypeScript, nothing to install, nothing that can break between two ONNX versions.

The router, or how to decide in a few lines

The router lives in src/index.ts, and tokenization looks like this (src/index.ts:129):

function tokens(s: string): string[] {
  return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .split(/[^a-z0-9]+/).filter((w) => w.length > 3);
}

Lowercase, accents stripped, 4-letter minimum words. That filter kills all the noise ("the", "and", "ok"). On support tickets, it changes everything because your matching runs on words that actually mean something.

Scoring itself sits in route() (src/index.ts:251), and that is where it all plays out:

const hits = kws.filter((k) =>
  words.some((w) => w.includes(k) || k.includes(w))).length;
const score = hits / Math.sqrt(kws.length);

Loose matching both ways: billing catches billed, invoice catches invoicing. Dividing by the square root avoids punishing big keyword lists, otherwise the mail plugin with its 600 words would crush everybody as soon as a mail contains "hello".

Each plugin has its threshold, default 0.15 stored in src/index.ts:139, mail at 0.03 because subjects are short. Below:

return { domain: "unknown", decision: null, confidence: 0, plugin: null };

Unknown domain = unknown, never an invented guess to please you. That is dumb on purpose, and that is exactly what makes the automate > 0.8 thresholding usable later: when Sabaki answers, it means a specialist really matched.

There is a bonus language axis via langid: a plugin declaring languages only competes for those languages. Handy to split latin vs CJK phishing. Text too short = no filter, everybody competes.

Total cost: a few microseconds per decision. No matrix to multiply, no network to call, just string comparisons and one division.

Nano, my favorite file in the repo

src/logreg.ts:28, tokenization:

function tokens(s: string): string[] {
  const words = s.toLowerCase().split(/[^a-z0-9]+/)
    .filter((w) => /^[a-z]{3,}$/.test(w));
  const out = [...words];
  for (let i = 0; i < words.length - 1; i++)
    out.push(`${words[i]} ${words[i + 1]}`);
  return out;
}

Unigrams + bigrams. charged twice gives charged, twice, and the charged twice pair. That is all you need to catch "double billing" without pulling out a 400M transformer.

src/logreg.ts:46, vectorization, where TF-IDF does its job:

const tf = 1 + Math.log(c);  // sublinear TF
const val = tf * w.idf[i]!;  // sklearn-style smooth IDF

The 10th occurrence of a word counts less than the first, rare words weigh more than "please". Exactly the sklearn recipe, exact parity checked on 20 questions.

And the comment that made me smile while rereading:

// Only touch present (sparse) terms instead of scanning 3000 dims.
for (const [t, c] of counts) {

Instead of scanning 3000 dimensions per ticket, you only touch the twenty-ish words present. That is why it answers in under 1ms. The kind of invisible detail that changes everything in prod, because complexity follows ticket size, not vocabulary size.

src/logreg.ts:86, inference, in three lines:

// binary: 1 coef row + sigmoid
const pPos = 1 / (1 + Math.exp(-s));
// multiclass: dot product + softmax, argmax

One dot product, one sigmoid or softmax depending on class count. One pass and you are done. Zero tokens generated, so zero broken JSON to patch up behind you with retries and parsing heuristics.

train.py recipe: 300 train / 100 test per workflow, deterministic split (1 item in 5 in test to avoid temporal bias), 3000-6000 vocab, coefs rounded to 1e-4 with zero measured loss. JSON export { vocab, idf, questions }.

Weights: 20 KB for invoices (duplicate 0.89) to 650 KB for mail (bucket 0.889). The kind of size that fits in a frontend bundle.

The detail that floored me: on mail, MiniLM ties TF-IDF within +/-0.02. So nano ships as is. Paying for a GPU to triage mail, no thanks. Agent_trace sticks instead (needs_review 0.59 in nano): observability text is thin, no word really carries the decision.

The factory turning weights into a plugin, in src/plugin.ts:52:

export function nanoClassifier(manifest, weights, opts = {}) {
  return definePlugin({ manifest,
    classify: (input) => {
      const r = predict(`${from}${input.state}`, weights, q);
      return { decision: r.decision, confidence: r.confidence, domain: manifest.domain };
    }});
}

decide() stays sync nano, decideAsync() unlocks ONNX for mini. Two paths, one simple rule: if you need async it means you want mini, otherwise nano is enough. Easy to remember when you code, easy to debug when it breaks.

To show you this is not just theory, here is what real usage looks like:

import { Sabaki } from "sabaki";
import customerService from "@sabaki-js/customer-service";

const sabaki = new Sabaki().register(customerService);
sabaki.decide({ state: "charged twice, refund please" });
// { decision: "billing", confidence: 0.31, domain: "customer_service" }

Mini, 22 MB that change everything on hard cases

Nano has limits, you saw it on agent_trace right above. MiniLM-L6 picks up the torch: one shared encoder for the whole domain, plus one linear head per question. End-to-end fine-tune over 8-12 epochs, lr 2e-5, on CUDA GPU for training (inference stays CPU, that is what counts).

The numbers that settled it, measured on the same splits:

  • frozen encoder + MLP: 0.65
  • full fine-tune: 0.68
  • 400M distillation to frozen embeddings: tried, tossed, worse than gold labels

ONNX export via dynamo, exact parity checked, then int8 dynamic quant: 90 MB -> 22 MB, -0.004 accuracy on customer_service. ECE unchanged or better. Every export gets re-measured, otherwise the optimization means nothing.

Since 1.0.2, weights ship inside the package (models/<workflow>-int8/ + exported modelDir). npm i is enough, airplane mode OK. Updating = publishing a patch, signed via trusted publishing.

Concrete: needs_review goes from 0.59 to 0.82. disposition 0.73 -> 0.76. ECE < 0.14 everywhere, with healthy OOD doubt (0.2-0.5 on dragons, recipes and empty strings). That doubt is what allows aggressive thresholding in prod.

Generalist, the catch-all that makes Sabaki truly System One

Jev and the others answer everything: any question, any options, defined at runtime. That is the System One contract that made Jev viral -- you drop your instructions + criteria with your options, it decides.

Sabaki specialists cannot do that. They know their questions (category, duplicate, bucket) and nothing else. With no safety net, Sabaki would return unknown as soon as you go off script, where Jev always answers something.

The generalist is that net. A cross-encoder reading question + option + state and outputting a score, one pass per option, softmax, argmax. 22.7M int8 MiniLM -- 6.5x smaller than the 144-151M crowd. But its job is not score, it is compatibility: with it plugged in as fallback, POST /v1/systemone accepts any state and any questions, like Jev. The model field says who really answered (@sabaki-js/[email protected]+mini, sabaki-generalist-v1...), so on the caller side you change nothing.

And most of all, it is a fallback, not a boss. Golden rule in decideAsync(): if a specialist wins routing, it answers. The generalist only speaks on unknown. Never the reverse. That keeps an average generalist from stomping a good specialist on its own turf.

The loader lives in src/generalist.ts, with a per-folder cache so you do not reload the ONNX session on every call:

export function loadGeneralist(modelDir: string) {
  // cache per folder, ONNX session reused
  // tokenizer.json -> Unigram or wordpiece, auto-detected
  const sess = await ort.InferenceSession.create(
    path.join(modelDir, "generalist.onnx"),
    { intraOpNumThreads: 2 },
  );
}

You point SABAKI_GENERALIST_DIR at a folder with generalist.onnx + tokenizer/, the model is swappable. The id comes from config.json, otherwise "sabaki-generalist". The Map cache avoids reloading the session on every call.

The input format is classic cross-encoder: question, option and state concatenated with separators, one line per option to score:

const texts = options.map(
  (o) => `${input.question ?? "decision"} [SEP] ${o} [SEP] ${input.state}`,
);

Question + option + state, separated by [SEP], one line per option. Options capped at 8 (slice(0, 8)), default ["yes", "no"] if you give nothing. Beyond that, split on the caller side -- 8 is already wide for a typed decision.

Then comes the smart batch, the kind of optimization that looks modest but divides latency by the option count. Instead of one session.run per option, everything goes in a single forward:

// A single batched forward for all options
// (padded to max length) instead of one run per option.
const L = Math.max(...encs.map((e) => e.ids.length));
// ids + attention_mask in [options.length, L], padding with padId
const out = await sess.run({ input_ids, attention_mask });
const scores = Array.from(out["score"].data).map(Number);

Each text gets encoded (BERT-style wordpiece or SentencePiece unigram depending on tokenizer.json), padded to the batch max length, stacked into an [nb_options, L] tensor. One sess.run, you get one score per option. On CPU that changes everything: 20-36ms instead of 8x that time.

Then softmax + argmax, with full probabilities for each option, exactly in the format Jev made popular:

const pr = softmax(scores);
// bi = index of the max
const confidence = Math.round(pr[bi] * 1000) / 1000;
const probs = Object.fromEntries(
  options.map((o, i) => [o, Math.round(pr[i] * 1000) / 1000]),
);

And the detail that makes it usable in prod instead of a demo, abstainAt:

if (input.abstainAt !== undefined && confidence < input.abstainAt) {
  return { decision: null, confidence, domain: "generalist", probabilities: probs };
}

Opt-in, no abstention by default. You pass abstainAt: 0.6-0.7 and everything soft comes back null instead of a guess. Since softmax spits out 1.00 even when it is wrong out-of-distribution, you need it. The GUIDE recommends it for any autonomous use: below threshold, you ask for clarification instead of self-resolving.

To finish, here is the same setup with the generalist fallback: when no specialist matches, it takes over instead of returning unknown.

import { Sabaki, definePlugin, loadGeneralist } from "sabaki";
import mail from "@sabaki-js/mail";

const backend = loadGeneralist(process.env.SABAKI_GENERALIST_DIR!);
const generalist = definePlugin({
  manifest: { name: "@sabaki-js/generalist", domain: "generalist",
    version: "0.1.0", questions: ["decision"], keywords: [] },
  classify: (input) => backend.decide(input),
});

const sabaki = new Sabaki().register(mail).setFallback(generalist);

await sabaki.decideAsync({
  state: "le client dit que l'app a supprimé ses données",
  question: "c'est urgent ?",
  options: ["yes", "no"],
  abstainAt: 0.65,
});
// mail specialist if routing matches, otherwise generalist as Jev-style catch-all

Generalist generations, all measured the same on PhishNChips (2000 mails) and on the typed-decisions test to compare like with like:

  • v4: 0.29 test, R=0.026 phishing. Toss it, it answered beside the point.
  • A (22M 80k): 0.546, R=0.406. Starting to look like something.
  • mini v1 served: 0.536, R=0.616. The one running on the site. Decent phishing recall, average test.
  • B (144M scratch): 0.568, R=0.062. Bigger, barely better on test, useless on phishing.

The 144M from scratch that does not transfer better really cooled me on the "throw more params to generalize" idea. At equal task, big costs more without guessing better. Even as a catch-all, small does the job. And it stays EN-only (all-MiniLM-L6-v2), EN routing, FR out-of-distribution. For FR, go through a specialist trained on it, not the generalist.

Doom, the only test that really matters to me

doomLaya is Laya and Jev really playing FreeDoom. The model picks command / target / weapon, the executor traces the A* path, opens doors, aims, clicks. Per-tick telemetry, videos, run SHAs. I contribute to it, so I am not talking about a leaderboard read from far away.

v0.1.0, MAP01 seed 48, skill 3, 35 ticks/s, same executor:

Model Result p50 HTTP API cost
Laya stock fail 180s 106ms $0
Laya v3 adapted exit 61.43s 108ms $0
Jev 1.13 exit 68.97s 357ms $0.0046

Two clean exits, no death, with Laya grabbing the shotgun on the way because the executor handles pickup by itself. Honesty first: Laya v3 was trained on Doom, Jev was not. One seed, one map, it proves nothing general. The project README says it in black and white, and to its credit it does not oversell the result.

Sabaki side (plugin-doom): Python ViZDoom calling a JS server every tick. Nano ~18k req/s, mini ~71 req/s on one i5 thread. The 35Hz tick clears easily, even on an average laptop.

With an exit compass, mini clears MAP01 in 2-4 runs out of 6, nano 0/6. Makes sense: text loses space, doomLaya states cap around 0.57 whether MiniLM or ModernBERT-base. When spatial info is missing, no text classifier reinvents it.

Neighbors reran the match with equal data: doomgemma sticks a 27min LoRA on an RTX 4070 and brings Gemma 4 E2B to Laya v3 level in game (6/6 vs 6/6) and above on isolated decisions. At equal size, a decoder runs as fast as Laya (270M: 22ms vs 23ms). At equal result, Laya stays ~3x faster because it gets there with a smaller model.

My take: training on the task matters more than architecture. A small trained specialist beats a big generalist discovering it. Exactly the Sabaki bet. The follow-up paper, System Switch (arXiv:2610.09683), pushes the logic all the way: fast small model playing, big reasoning model taking back over when a gate opens. Sabaki pre-triage (automate > 0.85 else escalate) applied to the game.

What got sacrificed

Every missing feature funds another one, that is the rule when you target CPU. The honest list, like for Bareiron where every line of C was a tradeoff:

Sabaki tells no stories. It picks between options you give it, with a probability. For an explanation, a summary, free text, grab an LLM.

French stays out-of-distribution for the generalist. EN routing (all-MiniLM-L6-v2), 1/4 FR vs 3/4 EN probe on similar cases. The mail plugin comes from a mostly FR shop but anonymized: 0 PII, -0.024 accuracy for a vocab with no personal terms. Anonymization costs, but it was non-negotiable.

Softmax acts too sure outside its zone. Some 1.00 misses observed, yes. Hence the pre-triage recommended in the GUIDE: abstainAt: 0.6-0.7 to return null under threshold, then confidence > 0.85 gate to automate, human otherwise. Measured reference: 0.62 phishing recall at 0.87 precision. Enough to cut the queue in half, not enough to fire reviewers.

Nano struggles on agent traces. 0.34 to 0.59 depending on the question. Mini lifts needs_review to 0.82, but risk / urgency / action stay hard. Observability text is just poor in lexical signals.

Text Doom caps out. Around 0.57, MiniLM like ModernBERT-base. As long as you stay on text with no pixels, MAP02 blocks you. The credible path to a real player is vision + RL (tiny-doom-defender, 1.1M params from pixels), not a better text classifier.

The benchmark drifts by batches. typed-decisions is generated in batches, the temporal split is really a batch split. drift_tool.py outputs 0.82 on customer_service, 0.67 on agent_trace (0.5 = indistinguishable). Measured consequence: published weights score 0.733 mixed vs 0.61 on the official split. So the official number is conservative. Platt / temperature+bias calibration does not transfer across batches, so we only ship temperature scaling calibrated on recent batches. House rule: drift_tool.py first, warn above 0.6, block above 0.8. Nobody else publishes that, everybody should.

The final result, numbers in hand

On one side a laptop i5, on the other an API billing per call. Here is what each mode gives you in practice:

  • nano: <1ms, ~18k req/s, 20-650 KB, 0 dep
  • mini: ~20ms, ~71 req/s, 22 MB int8, optional onnxruntime
  • generalist: 20-36ms, 22.7M, the smallest open generalist I know

Several loaded domains: ~0.5 GB RAM in lazy-load per domain instead of ~2 GB for everything. Deterministic: 100/100 identical inputs give the same output. Per-question thresholds, gate on confidence, unknown when nothing matches.

On Bareiron, running 3+ players lagged because each player cost a 200ms chunk. Here running 3+ domains passes without flinching, because each expert is tiny and loaded on demand instead of loading everything at once. Same "small is enough" philosophy, applied to different hardware.

The philosophy

p2r3 said: "I just love the idea that this tiny 1-buck chip can run something as advanced as Minecraft."

Same trip here: I love the idea that 20 KB of JSON and a hundred lines of TS replace a GPU call to pick a label. It obviously does not replace everything, do not kid yourself. It replaces the annoying case where you want a label in milliseconds, locally, with a confidence you can reasonably threshold instead of crossing your fingers.

Tradeoffs, written in black and white:

  • encoder -> word counting: 7 points down, latency divided by 1000, zero infra
  • single big model -> npm plugins: coarser gate, infinitely simpler ops
  • generation -> typed decision: done with breaking parsing and invented options
  • raw confidence -> pre-triage: abstain first, automation above 0.85, human for the rest

The 3 things to remember:

  1. 7 points down, 1000x faster -- 0.61/0.69 vs 0.75-0.80, but <1ms/20ms CPU offline vs 25s CPU or paid API. A score with no cost means nothing.
  2. Simple router + separate experts -- hits / sqrt(keywords), threshold, unknown. Each plugin trains, versions and ships alone. On mail, TF-IDF ties MiniLM: the GPU is useless here.
  3. Offline-first with honest calibration -- npm i, bundled weights, 0 GPU, ECE < 0.14, abstain, drift tool. The only stack in the comparison holding a 35Hz game loop with no GPU.

If the project interests you, everything is MIT on github.com/sabaki-js. The academic version with measurements and bibtex is the v1 preprint. And if you want to see Goliath really play Doom, head to azalio/doomLaya xD

NEW

Sabaki vs System One : David contre Goliath, le plus petit modèle de décision du monde

20KB contre 421M paramètres. Sabaki perd 7 points sur typed-decisions mais passe de 25s CPU à moins d'1ms offline. Anatomie d'un MoE organisationnel.

Introduction

Si tu traînes sur Hacker News ou Twitter depuis septembre, t'as forcément vu passer Jev.

Le 15 septembre 2026, TypeSafe sort Jev, leur premier "System One". Le pitch tient en une phrase : arrêtez de demander à un LLM de générer du JSON pour choisir un label. Filez-lui un state plus des questions typées, il vous rend des décisions typées avec des probas. Une passe, zéro token à sampler, rien à parser derrière.

Ça a explosé. Premier jour d'adoption record sur Vercel AI Gateway, cinq clients Elixir en quelques jours, tout le monde qui benchmarke Jev contre son truc. Et honnêtement la hype se comprend. Ça faisait des années qu'on bricolait des prompts réponds UNIQUEMENT avec {"choice": ...} en priant pour que le modèle invente pas une option, casse pas le JSON, hallucine pas un champ. Jev a mis un nom sur ce besoin et l'a ship en API propre : POST /v1/systemone, questions choice / score / noul, réponses avec probabilities + confidence.

J'ai adoré l'idée. Le modèle éco, moins -- et pour être clair, j'ai jamais payé pour Jev.

Parce que derrière, Jev reste une API fermée. Poids inconnus, pas de self-host, chaque décision = un round-trip réseau + un compteur qui tourne. Et les alternatives open qui ont suivi tapent dans les 400M de paramètres avec un GPU obligatoire.

Alors j'ai construit l'autre extrême : Sabaki. Un routeur à mots-clés + une régression logistique en TypeScript pur qui parle le même contrat que Jev (mêmes shapes choice / score / noul, même endpoint /v1/systemone), mais qui tourne en moins d'1ms avec 20 kilooctets de JSON. Zéro dépendance en mode nano, offline, 0€.

Pour situer : Laya c'est 843 Mo de safetensors à loader. OpenDecider-nano c'est 2 Go de RAM. Sabaki nano invoices c'est 20 Ko. Le rapport en poids tourne autour de 40 000.

Si tu veux la version carrée et chiffrée, y'a le preprint v1 d'octobre 2026 sur le site : Sabaki: An Organizational Mixture-of-Experts for Typed Decisions on CPU (PDF). Design, mesures, tradeoffs, tout y est. Cet article c'est la version lisible, avec le code ouvert à côté.

Je l'ai pas écrit dans mon coin en mode benchmark-papier. Je contribue à azalio/doomLaya (mon fork : fox3000foxy/doomLaya), un harness où Laya et Jev jouent vraiment à FreeDoom avec décisions model-owned, télémétrie par tick et vidéos. J'ai vu Goliath jouer en vrai, et dès que le réseau tousse, lui aussi.

On va ouvrir les moteurs un par un. Comme d'hab, avec le vrai code.

Le ring où tout le monde accepte de se battre

Y'a un seul dataset que Jev, Laya et OpenDecider citent tous : LocalLLaMA/typed-decisions. 4 workflows synthétiques (support, factures, sécu, traces d'agents), 5 questions par cas, 1200 cas de train, 400 de test, 2000 décisions.

Modèle Params Score test Latence Où ça tourne
OpenDecider-nano ~400M 0.796 17ms GPU GPU, 2 Go RAM
Laya typed-decisions 421M 0.766 ~25s/state CPU, ~830ms/cas MLX GPU
Jev 1.13 fermé ~0.73-0.75 ~710ms API API only
Sabaki mini 22M int8 0.69 ~20ms CPU CPU, 22 Mo
Sabaki nano ~0 0.61 <1ms partout
Laya base zéro-shot 421M 0.362 pareil GPU

Le hasard c'est 0.318, la majorité 0.461. Donc Laya base à 0.362 sans fine-tune, c'est le niveau "je réponds au pif en étant poli". Retiens ça, ça va servir.

7 points séparent Sabaki du meilleur. 1000x de latence les séparent dans l'autre sens quand t'as pas de GPU. Voilà tout l'article en deux phrases, le reste c'est pour comprendre pourquoi.

Attention quand même : derrière la même prise choice / score / noul, les cuisines n'ont rien à voir. Jev = API fermée. Laya = ModernBERT-large fine-tuné. OpenDecider = Ettin-encoder distillé. Sabaki = comptage de mots + MiniLM int8. Même contrat, budgets incomparables.

Jev, la claque que tout le monde a vue passer

Faut rendre à TypeSafe ce qui est à TypeSafe : le contrat System One, c'est une vraie bonne idée.

Avant, pour trier un ticket, tu faisais ça : tu demandais à un LLM "choisis entre billing, technique et spam, réponds en JSON", tu parsais la réponse, tu gérais les cas où il répond "Je dirais billing ! 😊" ou invente une 4ème catégorie. Chaque question coûtait des tokens, chaque réponse pouvait casser ton pipeline.

Jev change le deal : tu envoies le texte + des questions avec leurs critères, il évalue chaque question en parallèle et te rend une valeur typée avec sa distribution.

curl https://api.typesafe.ai/v1/systemone \
 -H "Authorization: Bearer $KEY" \
 -H 'content-type: application/json' \
 -d '{"state":"charge double ce mois-ci","questions":{"dept":{"type":"choice","instructions":"Route ce message.","criteria":{"billing":"paiements","technical":"bugs"}}}}}'

Réponse : { choice, probabilities, confidence }. Pas de texte libre. Ton code peut brancher dessus direct.

C'est pour ça que c'est devenu viral : SDK Python + JS, plugin Claude Code, intégration LangChain, tout le monde pouvait brancher ses agents dessus en une aprem. La doc TypeSafe pousse même le pattern sain : Jev pour les petits jugements rapides, code déterministe autour pour composer la vraie décision, LLM lent ou humain dès que la confiance est basse.

Le souci c'est le reste. Poids fermés, offline impossible, prix au call. Pour un agent qui prend 100 décisions par run, la taxe pique. Pour Doom à 35 ticks/s, c'est mort : dans doomLaya, Jev sort MAP01 seed 48 en 68.97s avec un p50 HTTP à 357ms. Le run rapporte $0.0046 de coût API -- pas ma facture, celle du benchmark. Ça marche, le run est propre, sans mort. Mais 357ms à 35 ticks/s, ça fait 12 ticks où le jeu continue pendant que t'attends le réseau.

Sabaki a piqué exactement ce contrat -- mêmes questions, mêmes réponses, même endpoint /v1/systemone servi par serve() -- en virant tout ce qui demande un datacenter.

Laya, le gros open qui a montré que c'était faisable sans API

Laya par Convai Innovations, Apache-2.0. Sous le capot : ModernBERT-large, 421M, avec 1 token [MASK] par option. Le hidden state de chaque marqueur passe dans un petit MLP, softmax sur les options de la question. Une passe par question, comme Jev.

pipe = pipeline("text-classification", model="convaiinnovations/laya-typed-decisions")

Le 0.766 mérite qu'on s'y arrête. C'est le checkpoint fine-tuné sur le train du benchmark : 1200 cas, RLCD avec bruit gaussien sur les logits, reward log + spherical. Le même modèle sans ce fit tombe à 0.362. Autant dire qu'il devine. Donc le score Laya, c'est "gros encodeur + entraînement sur le domaine", pas "gros encodeur magique".

Sur CPU, c'est un parpaing. Le port ONNX mesuré tourne autour de 25s par state. En MLX FP16, compte 830ms par cas en moyenne avec un p99 à 5.3s. Et faut loader 843 Mo avant de répondre quoi que ce soit.

Dans doomLaya, la Laya stock échoue MAP01 en 180s. La v3 adaptée (azalio/laya-doom-v3), ré-entraînée sur du Doom, sort en 61.43s avec p50 108ms sur MPS, en ramassant le shotgun au passage. Donc oui, ça joue -- après entraînement dédié, sur GPU Apple, avec un exécuteur scripté qui fait le vrai boulot (A*, portes avec latch 15 ticks, visée auto).

Mon résumé : sans entraînement sur ta tâche, Laya te sert de presse-papier à 421M. Avec, c'est très fort, mais faut le GPU et les datas qui vont avec.

OpenDecider, celui qui a gagné le leaderboard

OpenDecider, c'est Manjunath qui a décidé d'aller chercher tout le monde sur leur propre benchmark. Résultat : 0.796 avec un Ettin-encoder-400m full fine-tune + tête MLP, loss KL sur des soft targets distillées de Qwen3-235B + DeepSeek.

pip install opendecider
# 17ms/question sur L40S, 18ms sur M4 Max, ~9ms batché

À 50% de coverage, la selective accuracy monte à 0.943 contre 0.882 pour Jev. Sur le papier, le meilleur des deux mondes : open, calibré, rapide.

En pratique : 2 Go de RAM pour charger nano, GPU requis pour tenir les 17ms, phishing à 0.63 contre 0.90 pour Jev (l'OOD le lave), et comme Laya, entraîné sur le train du benchmark. Le test n'a jamais été vu, mais le domaine oui.

Il gagne le bras de fer, mais ça reste 400M avec un GPU derrière. Tu le colles pas dans un Worker Cloudflare ou dans une boucle de jeu sur un laptop.

Les 150M, et le trou en dessous

openJev-verdict-2.0 (151M), julia-1 (144M multilingue), Open Jev (150M), Largha-150M (144M en+zh), Vela (0.3B-9B). Tous des ModernBERT ou mmBERT-small, tous capables de tourner sur CPU, tous sympas à entraîner (Open Jev annonce 30min sur un T4 Colab). Chiffres self-reported, protocoles différents, donc à prendre avec des pincettes, mais la famille existe.

Le generalist Sabaki fait 22.7M. Environ 6.5x plus petit que le plus petit de cette bande. Personne d'autre ne descend sous les 100M pour répondre à des questions définies à runtime.

C'est ça, "le plus petit du monde" : pas le meilleur score, la plus petite enveloppe qui lit tes instructions + criteria et tranche quand aucun spécialiste ne sait répondre.

Sabaki, le routeur débile et ses experts jetables

Bon, assez parlé des gros. Passons à David, parce que c'est là que ça devient marrant.

Sabaki n'essaie pas d'être un gros modèle malin qui comprend le monde. C'est un MoE organisationnel : 1 routeur sparse au niveau système, des experts indépendants, un seul actif par input. Chaque plugin est un package npm séparé, avec son manifest, ses poids, sa version. Tu installes que ce que tu routes.

L'org sabaki-js : 13 repos, le core sabaki (129 commits), 6 plugins @sabaki-js/*, les poids, le worker Cloudflare, le template pour écrire le tien.

Le package.json du core raconte déjà l'histoire :

"dependencies": { "@typesafe-ai/sdk": "^0.7.0" },
"optionalDependencies": { "onnxruntime-node": "^1.22.0" }

En mode nano, zéro dépendance runtime. Que du TypeScript compilé, rien à installer, rien qui peut casser entre deux versions d'ONNX.

Le routeur, ou comment décider en quelques lignes

Le routeur vit dans src/index.ts, et la tokenization ressemble à ça (src/index.ts:129) :

function tokens(s: string): string[] {
  return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .split(/[^a-z0-9]+/).filter((w) => w.length > 3);
}

Minuscules, accents virés, mots de 4 lettres minimum. Ce filtre vire tout le bruit ("le", "de", "ok"). Sur du ticket support, ça change tout parce que ton matching se fait sur des mots qui portent un sens.

Le scoring lui-même est dans route() (src/index.ts:251), et c'est là que tout se joue :

const hits = kws.filter((k) =>
  words.some((w) => w.includes(k) || k.includes(w))).length;
const score = hits / Math.sqrt(kws.length);

Matching lâche dans les deux sens : billing attrape billed, invoice attrape invoicing. La division par la racine évite de pénaliser les grosses listes de keywords, sinon le plugin mail avec ses 600 mots écraserait tout le monde dès qu'un mail contient "hello".

Chaque plugin a son seuil, défaut 0.15 stocké dans src/index.ts:139, mail à 0.03 parce que les sujets sont courts. En dessous :

return { domain: "unknown", decision: null, confidence: 0, plugin: null };

Domaine inconnu = unknown, jamais un guess inventé pour faire plaisir. C'est volontairement bête comme approche, et c'est exactement ce qui rend le seuillage automate > 0.8 utilisable plus tard : quand Sabaki répond, c'est qu'un spécialiste a vraiment matché.

Y'a un axe langue en prime via langid : un plugin qui déclare languages ne concourt que pour ses langues. Pratique pour séparer phishing latin et CJK. Texte trop court = pas de filtre, tout le monde concourt.

Coût total : quelques microsecondes par décision. Aucune matrice à multiplier, aucun réseau à appeler, juste des comparaisons de strings et une division.

Nano, mon fichier préféré du repo

src/logreg.ts:28, la tokenization :

function tokens(s: string): string[] {
  const words = s.toLowerCase().split(/[^a-z0-9]+/)
    .filter((w) => /^[a-z]{3,}$/.test(w));
  const out = [...words];
  for (let i = 0; i < words.length - 1; i++)
    out.push(`${words[i]} ${words[i + 1]}`);
  return out;
}

Unigrammes + bigrammes. charged twice donne charged, twice, et la paire charged twice. C'est tout ce qu'il faut pour capter "double facturation" sans sortir un transformer de 400M.

src/logreg.ts:46, la vectorisation, là où le TF-IDF fait son taf :

const tf = 1 + Math.log(c);  // TF sublinéaire
const val = tf * w.idf[i]!;  // IDF smooth façon sklearn

La 10ème occurrence d'un mot compte moins que la première, les mots rares pèsent plus que "please". Exactement la recette sklearn, parité exacte vérifiée sur 20 questions.

Et le commentaire qui m'a fait sourire en relisant :

// Touche seulement les termes presents (creux) au lieu de balayer 3000 dims.
for (const [t, c] of counts) {

Au lieu de balayer 3000 dimensions à chaque ticket, tu touches uniquement la vingtaine de mots présents. Voilà pourquoi ça répond en moins d'1ms. C'est le genre de détail invisible qui change tout en prod, parce que la complexité suit la taille du ticket et pas celle du vocabulaire.

src/logreg.ts:86, l'inférence, qui tient en trois lignes :

// binaire : 1 ligne de coefs + sigmoïde
const pPos = 1 / (1 + Math.exp(-s));
// multiclasse : produit scalaire + softmax, argmax

Un produit scalaire, une sigmoïde ou un softmax selon le nombre de classes. Une passe et c'est fini. Zéro token généré, donc zéro JSON cassé à réparer derrière avec des retry et des heuristiques de parsing.

Recette train.py : 300 train / 100 test par workflow, split déterministe (1 item sur 5 en test pour éviter le biais temporel), vocab 3000-6000, coefs arrondis à 1e-4 avec zéro perte mesurée. Export JSON { vocab, idf, questions }.

Poids : 20 Ko pour invoices (duplicate 0.89) à 650 Ko pour mail (bucket 0.889). Le genre de taille qui passe dans un bundle frontend.

Le détail qui m'a scotché : sur mail, MiniLM colle TF-IDF à +/-0.02. Donc nano shippe tel quel. Payer un GPU pour trier des mails, très peu pour moi. En revanche agent_trace coince (needs_review 0.59 en nano) : le texte d'observabilité est pauvre, aucun mot ne porte vraiment la décision.

La fabrique qui transforme des poids en plugin, dans src/plugin.ts:52 :

export function nanoClassifier(manifest, weights, opts = {}) {
  return definePlugin({ manifest,
    classify: (input) => {
      const r = predict(`${from}${input.state}`, weights, q);
      return { decision: r.decision, confidence: r.confidence, domain: manifest.domain };
    }});
}

decide() reste synchrone nano, decideAsync() débloque ONNX pour mini. Deux chemins, une règle simple : si t'as besoin d'async c'est que tu veux mini, sinon nano suffit. Facile à retenir quand tu codes, facile à debugger quand ça casse.

Pour te montrer que c'est pas juste de la théorie, voilà à quoi ressemble un usage réel :

import { Sabaki } from "sabaki";
import customerService from "@sabaki-js/customer-service";

const sabaki = new Sabaki().register(customerService);
sabaki.decide({ state: "charged twice, refund please" });
// { decision: "billing", confidence: 0.31, domain: "customer_service" }

Mini, 22 Mo qui changent la donne sur les cas durs

Nano a ses limites, tu l'as vu sur agent_trace juste au-dessus. MiniLM-L6 reprend le flambeau : un encodeur partagé pour tout le domaine, plus une tête linéaire par question. Fine-tune end-to-end pendant 8-12 epochs, lr 2e-5, sur GPU CUDA pour l'entraînement (l'inférence reste CPU, c'est ça qui compte).

Les chiffres qui ont tranché, mesurés sur les mêmes splits :

  • encodeur gelé + MLP : 0.65
  • full fine-tune : 0.68
  • distillation 400M vers embeddings gelés : testée, jetée, moins bonne que les gold labels

Export ONNX via dynamo, parité exacte vérifiée, puis quantif dynamique int8 : 90 Mo -> 22 Mo, -0.004 accuracy sur customer_service. ECE inchangée ou meilleure. Chaque export est re-mesuré, sinon l'optimisation ne veut rien dire.

Depuis la 1.0.2, les poids sont embarqués dans le package (models/<workflow>-int8/ + modelDir exporté). npm i suffit, mode avion OK. Mettre à jour = publier un patch, signé via trusted publishing.

Concret : needs_review passe de 0.59 à 0.82. disposition 0.73 -> 0.76. ECE < 0.14 partout, avec un doute OOD sain (0.2-0.5 sur dragons, recettes et strings vides). C'est ce doute qui autorise le seuillage agressif en prod.

Generalist, le catch-all qui rend Sabaki vraiment System One

Jev et les autres répondent à tout : n'importe quelle question, n'importe quelles options, définies à runtime. C'est ça le contrat System One qui a rendu Jev viral -- tu poses tes instructions + criteria avec tes options, il tranche.

Les spécialistes Sabaki savent pas faire ça. Ils connaissent leurs questions (category, duplicate, bucket) et rien d'autre. Sans filet, Sabaki renverrait unknown dès que tu sors du script, là où Jev répond toujours quelque chose.

Le generalist c'est ce filet. Un cross-encoder qui lit question + option + state et sort un score, une passe par option, softmax, argmax. 22.7M MiniLM int8 -- 6.5x plus petit que les 144-151M de la bande. Mais son job c'est pas le score, c'est la compatibilité : avec lui branché en fallback, POST /v1/systemone accepte n'importe quel state et n'importe quelles questions, comme Jev. Le champ model dit qui a vraiment répondu (@sabaki-js/[email protected]+mini, sabaki-generalist-v1...), donc côté appelant tu changes rien.

Et surtout, c'est un fallback, pas un chef. Règle d'or dans decideAsync() : si un spécialiste gagne le routage, il répond. Le generalist ne parle que sur unknown. Jamais l'inverse. Ça évite qu'un généraliste moyen vienne écraser un spécialiste bon sur son domaine.

Le loader vit dans src/generalist.ts, avec un cache par dossier pour ne pas recharger la session ONNX à chaque call :

export function loadGeneralist(modelDir: string) {
  // cache par dossier, session ONNX réutilisée
  // tokenizer.json -> Unigram ou wordpiece, détecté auto
  const sess = await ort.InferenceSession.create(
    path.join(modelDir, "generalist.onnx"),
    { intraOpNumThreads: 2 },
  );
}

Tu pointes SABAKI_GENERALIST_DIR vers un dossier avec generalist.onnx + tokenizer/, le modèle est swappable. L'id vient de config.json, sinon "sabaki-generalist". Le cache Map évite de reloader la session à chaque call.

Le format d'entrée, c'est du cross-encoder classique : question, option et state concaténés avec des séparateurs, une ligne par option à scorer :

const texts = options.map(
  (o) => `${input.question ?? "decision"} [SEP] ${o} [SEP] ${input.state}`,
);

Question + option + state, séparés par [SEP], une ligne par option. Options capées à 8 (slice(0, 8)), défaut ["yes", "no"] si tu donnes rien. Au-delà, faut découper côté appelant -- 8 c'est déjà large pour une décision typée.

Ensuite vient le batch malin, le genre d'optimisation qui ne paie pas de mine mais qui divise la latence par le nombre d'options. Au lieu d'un session.run par option, tout part en un seul forward :

// Un seul forward batché pour toutes les options
// (rembourré à la longueur max) au lieu d'un run par option.
const L = Math.max(...encs.map((e) => e.ids.length));
// ids + attention_mask en [options.length, L], padding au padId
const out = await sess.run({ input_ids, attention_mask });
const scores = Array.from(out["score"].data).map(Number);

Chaque texte est encodé (wordpiece façon BERT ou unigram SentencePiece selon tokenizer.json), paddé à la longueur max du batch, empilé en tenseur [nb_options, L]. Un seul sess.run, tu récupères un score par option. Sur CPU ça change tout : 20-36ms au lieu de 8x ce temps.

Puis vient le softmax + argmax, avec les probas complètes pour chaque option, exactement dans le format que Jev a popularisé :

const pr = softmax(scores);
// bi = index du max
const confidence = Math.round(pr[bi] * 1000) / 1000;
const probs = Object.fromEntries(
  options.map((o, i) => [o, Math.round(pr[i] * 1000) / 1000]),
);

Et le détail qui rend le truc utilisable en prod au lieu de rester une démo, c'est abstainAt :

if (input.abstainAt !== undefined && confidence < input.abstainAt) {
  return { decision: null, confidence, domain: "generalist", probabilities: probs };
}

Opt-in, pas d'abstention par défaut. Tu passes abstainAt: 0.6-0.7 et tout ce qui est mou revient null au lieu d'un guess. Vu que le softmax sort des 1.00 même quand il a tort hors-distrib, c'est pas du luxe. Le GUIDE le recommande pour tout usage autonome : en dessous du seuil, tu demandes une clarification au lieu d'auto-résoudre.

Pour finir, voilà le même montage avec le fallback generalist : quand aucun spécialiste ne matche, c'est lui qui prend le relais au lieu de rendre unknown.

import { Sabaki, definePlugin, loadGeneralist } from "sabaki";
import mail from "@sabaki-js/mail";

const backend = loadGeneralist(process.env.SABAKI_GENERALIST_DIR!);
const generalist = definePlugin({
  manifest: { name: "@sabaki-js/generalist", domain: "generalist",
    version: "0.1.0", questions: ["decision"], keywords: [] },
  classify: (input) => backend.decide(input),
});

const sabaki = new Sabaki().register(mail).setFallback(generalist);

await sabaki.decideAsync({
  state: "le client dit que l'app a supprimé ses données",
  question: "c'est urgent ?",
  options: ["yes", "no"],
  abstainAt: 0.65,
});
// spécialiste mail si le routing matche, sinon generalist en catch-all façon Jev

Les générations du generalist, toutes mesurées pareil sur PhishNChips (2000 mails) et sur le test typed-decisions pour comparer ce qui est comparable :

  • v4 : 0.29 test, R=0.026 phishing. À jeter, ça répondait à côté.
  • A (22M 80k) : 0.546, R=0.406. Ça commence à ressembler à quelque chose.
  • mini v1 servie : 0.536, R=0.616. C'est celle qui tourne sur le site. Recall phishing correct, test moyen.
  • B (144M scratch) : 0.568, R=0.062. Plus gros, à peine meilleur en test, nul en phishing.

Le 144M from scratch qui transfère pas mieux, ça m'a bien calmé sur l'idée de "mettre plus de params pour généraliser". À tâche égale, le gros coûte plus cher sans deviner mieux. Même comme catch-all, le petit fait le taf. Et il reste EN-only (all-MiniLM-L6-v2), routage EN, FR hors-distrib. Pour du FR, faut passer par un spécialiste entraîné dessus, pas par le generalist.

Doom, le seul test qui compte vraiment pour moi

doomLaya, c'est Laya et Jev qui jouent à FreeDoom pour de vrai. Le modèle choisit command / target / weapon, l'exécuteur trace le chemin A*, ouvre les portes, vise, clique. Télémétrie par tick, vidéos, SHA des runs. J'y contribue, donc je parle pas d'un leaderboard lu de loin.

v0.1.0, MAP01 seed 48, skill 3, 35 ticks/s, même exécuteur :

Modèle Résultat p50 HTTP Coût API
Laya stock échec 180s 106ms $0
Laya v3 adaptée sortie 61.43s 108ms $0
Jev 1.13 sortie 68.97s 357ms $0.0046

Deux sorties propres, sans mort, avec Laya qui ramasse le shotgun au passage parce que l'exécuteur gère le pickup tout seul. Honnêteté oblige : Laya v3 a été entraînée sur Doom, Jev non. Un seed, une map, ça prouve rien de général. Le README du projet le dit noir sur blanc, et c'est à son honneur de pas survendre le résultat.

Côté Sabaki (plugin-doom) : ViZDoom en Python qui appelle un serveur JS à chaque tick. Nano ~18k req/s, mini ~71 req/s sur un thread i5. Le tick 35Hz passe large, même sur un laptop moyen.

Avec boussole de sortie, mini sort MAP01 en 2-4 runs sur 6, nano 0/6. Logique : le texte perd le spatial, les états doomLaya plafonnent vers 0.57 que ce soit MiniLM ou ModernBERT-base. Quand l'info spatiale manque, aucun classifieur texte ne la réinvente.

Les voisins ont refait le match à données égales : doomgemma colle une LoRA 27min sur une RTX 4070 et amène Gemma 4 E2B au niveau Laya v3 en jeu (6/6 vs 6/6) et au-dessus en décisions isolées. À taille égale, un décodeur va aussi vite que Laya (270M : 22ms vs 23ms). À résultat égal, Laya reste ~3x plus rapide parce qu'elle y arrive avec un plus petit modèle.

Ma lecture : l'entraînement sur la tâche compte plus que l'architecture. Un petit spécialiste entraîné bat un gros généraliste qui découvre. Exactement le pari Sabaki. Le papier qui suit, System Switch (arXiv:2610.09683), pousse la logique jusqu'au bout : petit modèle rapide qui joue, gros modèle raisonneur qui reprend la main quand une gate s'ouvre. Le pre-triage Sabaki (automate > 0.85 sinon escalade) appliqué au jeu.

Ce qui a été sacrifié

Chaque feature absente finance une autre, c'est la règle du jeu quand tu vises le CPU. La liste honnête, comme pour Bareiron où chaque ligne de C était un tradeoff :

Sabaki raconte rien. Il tranche entre des options que tu lui donnes, avec une proba. Pour une explication, un résumé, du texte libre, prends un LLM.

Le français reste hors-distrib pour le generalist. Routage EN (all-MiniLM-L6-v2), sonde 1/4 FR vs 3/4 EN sur cas similaires. Le plugin mail vient d'une boîte majoritairement FR mais anonymisée : 0 PII, -0.024 accuracy pour un vocab sans termes personnels. L'anonymisation se paie, mais c'était non négociable.

Le softmax se montre trop sûr quand il sort de sa zone. Des faux à 1.00 observés, oui. D'où le pre-triage recommandé dans le GUIDE : abstainAt: 0.6-0.7 pour rendre null sous le seuil, puis gate confidence > 0.85 pour automatiser, humain sinon. Référence mesurée : recall phishing 0.62 à précision 0.87. De quoi couper la file en deux, pas de quoi virer les reviewers.

Nano galère sur les traces d'agents. 0.34 à 0.59 selon la question. Mini remonte needs_review à 0.82, mais risk / urgency / action restent durs. Le texte d'observabilité est juste pauvre en signaux lexicaux.

Doom en texte plafonne. Vers 0.57, MiniLM comme ModernBERT-base. Tant que tu restes sur du texte sans pixels, MAP02 te bloque. La voie crédible pour un vrai joueur, c'est vision + RL (tiny-doom-defender, 1.1M params depuis pixels), pas un meilleur classifieur texte.

Le benchmark dérive par batches. typed-decisions est généré par fournées, le split temporel est en fait un split de batches. drift_tool.py sort 0.82 sur customer_service, 0.67 sur agent_trace (0.5 = indistinguable). Conséquence mesurée : les poids publiés font 0.733 en mixte vs 0.61 sur split officiel. Le chiffre officiel est donc conservateur. La calibration Platt / temperature+bias ne transfère pas entre batches, donc on shippe juste le temperature scaling calibré sur batches récents. Règle maison : drift_tool.py d'abord, warn au-dessus de 0.6, block au-dessus de 0.8. Personne d'autre ne publie ça, tout le monde devrait.

Le résultat final, chiffres en main

D'un côté un i5 de laptop, de l'autre une API qui facture au call. Voilà ce que chaque mode donne concrètement :

  • nano : <1ms, ~18k req/s, 20-650 Ko, 0 dep
  • mini : ~20ms, ~71 req/s, 22 Mo int8, onnxruntime en optionnel
  • generalist : 20-36ms, 22.7M, le plus petit généraliste open que je connaisse

Plusieurs domaines chargés : ~0.5 Go RAM en lazy-load par domaine au lieu de ~2 Go pour tout. Déterministe : 100/100 inputs identiques donnent le même output. Seuils par question, gate sur confidence, unknown quand rien ne matche.

Sur Bareiron, faire tourner 3+ joueurs ça ramait parce que chaque joueur coûtait un chunk à 200ms. Ici faire tourner 3+ domaines ça passe sans broncher, parce que chaque expert est minuscule et chargé à la demande au lieu de tout loader d'un coup. Même philosophie du "petit qui suffit", appliquée à un autre hardware.

La philo

p2r3 disait : "J'aime juste l'idée que cette toute petite puce à 1 balles puisse faire tourner quelque chose d'aussi avancé que Minecraft."

Même délire ici : j'aime l'idée que 20 Ko de JSON et une centaine de lignes de TS remplacent un appel GPU pour choisir un label. Ça remplace évidemment pas tout, faut pas déconner. Ça remplace le cas relou où tu veux un label en millisecondes, en local, avec une confiance que tu peux raisonnablement seuiller au lieu de croiser les doigts.

Les tradeoffs, écrits noir sur blanc :

  • encodeur -> comptage de mots : 7 points en moins, latence divisée par 1000, infra à zéro
  • gros modèle unique -> plugins npm : gate plus grossière, ops infiniment plus simples
  • génération -> décision typée : fini le parsing qui casse et les options inventées
  • confiance brute -> pre-triage : abstain d'abord, automatisation au-dessus de 0.85, humain pour le reste

Les 3 trucs à retenir :

  1. 7 points en moins, 1000x plus rapide -- 0.61/0.69 vs 0.75-0.80, mais <1ms/20ms CPU offline contre 25s CPU ou API payante. Un score sans son coût ne veut rien dire.
  2. Routeur simple + experts séparés -- hits / sqrt(keywords), threshold, unknown. Chaque plugin s'entraîne, se versionne et se shippe seul. Sur mail, TF-IDF colle MiniLM : le GPU ne sert à rien ici.
  3. Offline-first avec calibration honnête -- npm i, poids embarqués, 0 GPU, ECE < 0.14, abstain, drift tool. Le seul stack du comparo qui tient une game loop à 35Hz sans GPU.

Si le projet t'intéresse, tout est en MIT sur github.com/sabaki-js. La version académique avec mesures et bibtex, c'est le preprint v1. Et si tu veux voir Goliath jouer à Doom pour de vrai, direction azalio/doomLaya xD

NEW

Sabaki vs System One:大卫对阵歌利亚,全球最小的决策模型

20KB 对 421M 参数。Sabaki 在 typed-decisions 上丢了 7 个点,延迟却从 25 秒 CPU 降到离线 1 毫秒以内。一个组织型 MoE 的解剖。

引言

如果你从九月开始就混 Hacker News 或者 Twitter,你肯定刷到过 Jev。

2026 年 9 月 15 日,TypeSafe 发布了 Jev,他们的第一个“System One”。卖点一句话就能讲清:别再让 LLM 生成 JSON 来选标签了。给他一个 state 加上类型化的问题,他直接返回带概率的类型化决策。一次 forward,零 token 采样,后面什么都不用解析。

直接爆了。Vercel AI Gateway 上线首日采用量破纪录,几天之内冒出五个 Elixir 客户端,人人都在拿 Jev 跟自己的东西做 benchmark。说实话这热度不难理解。我们之前多少年都在手搓 只准回 {"choice": ...} 这种 prompt,然后祈祷模型别自创一个选项,别把 JSON 写崩,别幻觉出一个字段。Jev 给这个需求起了个名字,还 ship 了一个干净的 API:POST /v1/systemone,choice / score / noul 三种问题,回答带 probabilities 加 confidence。

我很喜欢这个想法。除了省钱那个模型,其他都喜欢。说清楚一点,我从来没给 Jev 付过钱。

因为 Jev 骨子里还是个封闭 API。权重未知,不能自托管,每个决策都是一次网络往返加一次计费。后来跟进的开源替代,个个都是 400M 参数起步,还强制要 GPU。

所以我造了另一个极端:Sabaki。一个关键词路由器加一个纯 TypeScript 逻辑回归,说的跟 Jev 是同一份契约(同样的 choice / score / noul shape,同样的 /v1/systemone endpoint),但跑在1 毫秒以内,只要 20KB 的 JSON。nano 模式零依赖,离线,0 块钱。

给你个 concept:Laya 要加载 843MB 的 safetensors。OpenDecider-nano 要 2GB 内存。Sabaki nano invoices 只要 20KB。重量比大概是 40000 倍。

想要方方正正带数字的版本,网站上有 2026 年 10 月的 preprint v1:Sabaki: An Organizational Mixture-of-Experts for Typed Decisions on CPU(PDF)。设计,测量,tradeoff,全在里面。这篇文章是可读版,代码开着放在旁边。

我不是关起门来写了篇 benchmark 论文。我在给 azalio/doomLaya 做贡献(我的 fork:fox3000foxy/doomLaya),一个真的让 Laya 和 Jev 进 FreeDoom 里打怪的 harness,决策 model-owned,每 tick 遥测,还有录像。Goliath 实战打成什么样我亲眼见过,网络一咳嗽,它也一样咳嗽。

咱们把引擎一个一个拆开。老规矩,带着真代码拆。

所有人都在同一个擂台上打

Jev、Laya、OpenDecider 认的只有同一个数据集:LocalLLaMA/typed-decisions。4 个合成 workflow(客服,发票,安全,agent 轨迹),每个 case 5 个问题,1200 个训练 case,400 个测试 case,2000 个决策。

模型 参数 测试分数 延迟 运行环境
OpenDecider-nano ~400M 0.796 17ms GPU GPU,2 Go 内存
Laya typed-decisions 421M 0.766 ~25s/state CPU,~830ms/用例 MLX GPU
Jev 1.13 闭源 ~0.73-0.75 ~710ms API 仅 API
Sabaki mini 22M int8 0.69 ~20ms CPU CPU,22 Mo
Sabaki nano ~0 0.61 <1ms 到处都能跑
Laya base 零样本 421M 0.362 相同 GPU

随机猜是 0.318,多数类是 0.461。所以 Laya base 不微调只有 0.362,基本就是“礼貌地瞎猜”。记住这个,后面有用。

Sabaki 跟第一名差 7 个点。没 GPU 的时候,延迟反过来差 1000 倍。整篇文章其实就这两句话,剩下的都是解释为什么。

先打个预防针:同一个 choice / score / noul 接口后面,厨房完全不是一回事。Jev 是封闭 API。Laya 是微调过的 ModernBERT-large。OpenDecider 是蒸馏出来的 Ettin-encoder。Sabaki 是数词加 MiniLM int8。契约一样,预算完全不是一个量级。

Jev,所有人都见过的那记耳光

该给 TypeSafe 的 credit 得给:System One 这份契约,真的是个好想法。

以前给工单分类,你得这么干:让 LLM“在 billing、技术、spam 里选一个,只回 JSON”,然后解析回答,还要处理它回你“ 我觉得是 billing!😊”或者自创第四个分类的情况。每个问题都要烧 token,每个回答都可能搞崩你的 pipeline。

Jev 把 deal 改了:你发文本加带标准的问题,他把每个问题并行评估一遍,还你一个带分布的类型化值。

curl https://api.typesafe.ai/v1/systemone \
 -H "Authorization: Bearer $KEY" \
 -H 'content-type: application/json' \
 -d '{"state":"charge double ce mois-ci","questions":{"dept":{"type":"choice","instructions":"Route ce message.","criteria":{"billing":"paiements","technical":"bugs"}}}}}'

回答:{ choice, probabilities, confidence }。没有自由文本。你的代码可以直接接着写分支。

这就是它病毒式传播的原因:Python 加 JS 的 SDK,Claude Code 插件,LangChain 集成,一下午谁都能把自己的 agent 接上去。TypeSafe 的文档推的也是健康用法:Jev 管快速小判断,外面围一圈确定性代码拼出真正的决策,置信度一低就走慢速 LLM 或者人工。

问题在剩下的部分。权重封闭,离线没门,按调用收费。一个 run 要做 100 个决策的 agent,这税就很肉了。Doom 35 ticks/s 的节奏更没戏:在 doomLaya 里,Jev 打 MAP01 seed 48 用了 68.97 秒,p50 HTTP 357ms。这次 run 的 API 账单是 $0.0046,不是我的账单,是 benchmark 的账单。run 本身是干净的,零死亡通关。但 35 ticks/s 的节奏下 357ms,等于游戏往前跑了 12 个 tick,你还在等网络。

Sabaki 偷的就是这份契约,同样的 questions,同样的 answers,同样的 /v1/systemone(由 serve() serve 出来),只不过把需要数据中心的部分全砍了。

Laya,不用 API 也能搞定的开源大块头

Laya 出自 Convai Innovations,Apache-2.0。盖子底下:ModernBERT-large,421M,每个选项 1 个 [MASK] token。每个 marker 的 hidden state 过一个小 MLP,在问题的选项上 softmax。跟 Jev 一样,每个问题一次 forward。

pipe = pipeline("text-classification", model="convaiinnovations/laya-typed-decisions")

0.766 这个数值得停下来说两句。这是在 benchmark 训练集上微调过的 checkpoint:1200 个 case,RLCD 加 logits 高斯噪声,log 加 spherical reward。同一个模型不做这个 fit,直接掉到 0.362。基本就是瞎猜。所以 Laya 的分数,意思是“大 encoder 加领域内训练”,不是“大 encoder 有魔法”。

在 CPU 上,它就是块砖。测过的 ONNX 移植版每个 state 要 25 秒左右。MLX FP16 平均每个 case 830ms,p99 到 5.3 秒。而且张嘴回答之前,先加载 843MB。

在 doomLaya 里,原版 Laya 180 秒没走出 MAP01。适配过的 v3(azalio/laya-doom-v3,在 Doom 数据上重训过)61.43 秒通关,MPS 上 p50 108ms,顺手还捡了把 shotgun。所以是的,能打,但在专用训练之后,在苹果 GPU 上,而且真正干活的是外面那个脚本执行器(A*,15 tick latch 的门,自动瞄准)。

我的总结:不在你的任务上训,Laya 就是个 421M 的镇纸。训了,很强,但 GPU 和数据都得备齐。

OpenDecider,登顶榜首的那个

OpenDecider,是 Manjunath 决定在所有人的 benchmark 上把所有人赢一遍。结果:Ettin-encoder-400m 全量微调加 MLP 头,KL loss 吃 Qwen3-235B 加 DeepSeek 蒸出来的软目标,干到 0.796。

pip install opendecider
# L40S 上 17ms/问题,M4 Max 上 18ms,batch 之后约 9ms

50% coverage 下 selective accuracy 到 0.943,Jev 是 0.882。纸面上两全其美:开源,校准好,速度快。

实际上:加载 nano 要 2GB 内存,想稳住 17ms 就得上 GPU,phishing 只有 0.63(Jev 是 0.90,OOD 直接把它洗了),而且跟 Laya 一样,在 benchmark 的训练集上训过。测试集是没见过,领域见过。

力气比赢了,但还是 400M 身后站着 GPU。你塞不进 Cloudflare Worker,也塞不进笔记本上的游戏循环。

150M 档,以及下面的真空地带

openJev-verdict-2.0(151M),julia-1(144M 多语言),Open Jev(150M),Largha-150M(144M 英加中),Vela(0.3B 到 9B)。全是 ModernBERT 或者 mmBERT-small,全能在 CPU 上跑,训起来都很友好(Open Jev 号称 Colab T4 上 30 分钟)。数字都是 self-reported,协议各不相同,所以看看就好,但这个家族确实存在。

通用 Sabaki 只有 22.7M。比这一档里最小的还小 6.5 倍左右。100M 以下,能回答运行时定义的问题,除了我们没别人。

这就是“世界最小”的意思:不是分数最高,是能读你的 instructions 加 criteria、能在没有专家会答的时候兜底的最小外壳。

Sabaki,笨路由器和它的一次性专家们

行,大的说完了。轮到大卫了,好玩的部分在这里。

Sabaki 不装聪明大模型,不假装理解世界。它是个组织型 MoE:系统层 1 个稀疏路由器,一堆独立专家,每次输入只激活一个。每个插件都是独立 npm 包,带自己的 manifest、权重、版本。你路由到谁,才装谁。

sabaki-js 这个 org:13 个仓库,core sabaki(129 个 commit),6 个 @sabaki-js/* 插件,权重,Cloudflare worker,还有教你写自己插件的模板。

core 的 package.json 已经把故事讲完了:

"dependencies": { "@typesafe-ai/sdk": "^0.7.0" },
"optionalDependencies": { "onnxruntime-node": "^1.22.0" }

nano 模式零运行时依赖。全是编译好的 TypeScript,不用装东西,也就没有什么东西会在两个 ONNX 版本之间碎掉。

路由器,或者说几行代码怎么做决策

路由器住在 src/index.ts,分词长这样(src/index.ts:129):

function tokens(s: string): string[] {
  return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .split(/[^a-z0-9]+/).filter((w) => w.length > 3);
}

小写,去 accents,4 个字母起收。这个过滤干掉了所有噪音(“le”,“de”,“ok”)。在客服工单上效果是质变,因为你的匹配全落在有实义的词上。

打分本身在 route() 里(src/index.ts:251),胜负手就在这里:

const hits = kws.filter((k) =>
  words.some((w) => w.includes(k) || k.includes(w))).length;
const score = hits / Math.sqrt(kws.length);

双向宽松匹配:billing 能抓住 billed,invoice 能抓住 invoicing。除以词表长度的平方根,是为了不惩罚关键词多的大插件,不然 mail 插件带着 600 个词,邮件里有个“hello”就把所有人都压死了。

每个插件有自己的阈值,默认 0.15 存在 src/index.ts:139,mail 是 0.03 因为标题都很短。低于阈值:

return { domain: "unknown", decision: null, confidence: 0, plugin: null };

未知领域就回 unknown,绝不为了讨好你现编一个猜测。方法蠢得理直气壮,后面 automate > 0.8 的阈值能用,全靠这个:Sabaki 敢回答,就说明真有个专家命中了。

额外还有个语言轴,靠 langid:声明了 languages 的插件只参与自己语言的竞争。拉丁 phishing 和 CJK 分流就靠它。文本太短就不过滤,所有人都下场。

总成本:每次决策几微秒。不乘矩阵,不调网络,就是字符串比较加一次除法。

Nano,我最喜欢的仓库文件

src/logreg.ts:28,分词:

function tokens(s: string): string[] {
  const words = s.toLowerCase().split(/[^a-z0-9]+/)
    .filter((w) => /^[a-z]{3,}$/.test(w));
  const out = [...words];
  for (let i = 0; i < words.length - 1; i++)
    out.push(`${words[i]} ${words[i + 1]}`);
  return out;
}

unigram 加 bigram。charged twice 给出 charged、twice,再加一个 charged twice。想抓住“重复收费”,这些就够了,不用请 400M 的 transformer 出山。

src/logreg.ts:46,向量化,TF-IDF 干活的地方:

const tf = 1 + Math.log(c);  // 次线性 TF
const val = tf * w.idf[i]!;  // sklearn 风格的平滑 IDF

一个词出现第 10 次,重要性远不如第 1 次,稀有词比“please”值钱。标准 sklearn 配方,20 个问题上验证过数值完全一致。

还有重读时让我笑出来的注释:

// 只碰出现的词(稀疏),而不是扫 3000 个维度。
for (const [t, c] of counts) {

每个工单只碰实际出现的二十来个词,而不是每次扫 3000 维。这就是 1 毫秒以内回答的原因。prod 里 invisible 的细节决定一切,因为复杂度跟着工单长度走,不跟着词表走。

src/logreg.ts:86,推理,三行写完:

// 二分类:1 行系数 + sigmoid
const pPos = 1 / (1 + Math.exp(-s));
// 多分类:点积 + softmax,argmax

一次点积,按类别数走 sigmoid 或者 softmax。一遍 forward,结束。零 token 生成,所以后面没有碎掉的 JSON 等着你写 retry 和解析 heuristic 来擦屁股。

train.py 配方:每个 workflow 300 训练加 100 测试,确定性切分(每 5 个取 1 个进测试,防止时间偏置),词表 3000 到 6000,系数四舍五入到 1e-4,实测零精度损失。导出 JSON { vocab, idf, questions }。

权重:invoices 的 20KB(duplicate 0.89)到 mail 的 650KB(bucket 0.889)。这种体型塞前端 bundle 都没压力。

让我惊讶的细节:在 mail 上,MiniLM 和 TF-IDF 差 ±0.02。所以 nano 直接 ship。为邮件分类买 GPU,我拒绝。但 agent_trace 卡住了(nano 下 needs_review 0.59):可观测文本太贫,几乎没有词真的携带决策信号。

把权重变成插件的工厂,在 src/plugin.ts:52:

export function nanoClassifier(manifest, weights, opts = {}) {
  return definePlugin({ manifest,
    classify: (input) => {
      const r = predict(`${from}${input.state}`, weights, q);
      return { decision: r.decision, confidence: r.confidence, domain: manifest.domain };
    }});
}

decide() 走同步 nano,decideAsync() 解锁 ONNX 给 mini。两条路,一条规矩:要 async 说明你要 mini,不然 nano 就够。写代码好记,挂了好查。

证明不只是纸上谈兵,给你看真实用法长什么样:

import { Sabaki } from "sabaki";
import customerService from "@sabaki-js/customer-service";

const sabaki = new Sabaki().register(customerService);
sabaki.decide({ state: "charged twice, refund please" });
// { decision: "billing", confidence: 0.31, domain: "customer_service" }

Mini,22MB,在硬骨头上翻盘

nano 的上限,你在上面 agent_trace 那里已经看到了。MiniLM-L6 接棒:全领域共享一个 encoder,每个问题一个线性头。端到端微调 8 到 12 个 epoch,lr 2e-5,训练在 CUDA GPU 上(推理还是 CPU,这才是重点)。

拍板用的数字,同样切分上测的:

  • encoder 冻结加 MLP:0.65
  • 全量微调:0.68
  • 400M 向冻结 embedding 蒸馏:试过,扔了,不如 gold label

经 dynamo 导出 ONNX,数值一致性验证过,再动态 int8 量化:90MB 压到 22MB,在 customer_service 上 accuracy 只掉 0.004。ECE 不变甚至更好。每次导出都重测,不测的优化等于没说。

从 1.0.2 开始权重直接进包(models/<workflow>-int8/ 加 modelDir 导出)。npm i 就行,断网 OK。更新等于发 patch,走 trusted publishing 签名。

具体效果:needs_review 从 0.59 涨到 0.82。disposition 0.73 到 0.76。ECE 全线 < 0.14,OOD 还有健康的怀疑(dragons、菜谱、空字符串上 0.2 到 0.5)。prod 里敢做激进阈值,全靠这份怀疑。

Generalist,让 Sabaki 真正成为 System One 的兜底网

Jev 它们什么都答:运行时定义的任意问题、任意选项。这是 System One 让 Jev 爆了的契约,你把 instructions 加 criteria 和选项递过去,它就拍板。

Sabaki 的专家不会这个。它们只认识自己的问题(category,duplicate,bucket),别的都不会。没有兜底,你一出脚本 Sabaki 就回 unknown,而 Jev 永远有话说。

generalist 就是这张网。一个 cross-encoder,读问题加选项加 state,吐一个分数,每个选项一次 forward,softmax,argmax。22.7M MiniLM int8,比 144 到 151M 那一档小 6.5 倍。但它的 job 不是刷分,是兼容:接上 fallback,POST /v1/systemone 就跟 Jev 一样,什么 state、什么问题都接。model 字段写明到底谁答的(@sabaki-js/[email protected]+mini,sabaki-generalist-v1...),调用方什么都不用改。

而且重点,它是 fallback,不是老大。decideAsync() 里的铁律:专家路由赢了,专家答。只有 unknown 的时候 generalist 才开口。绝不反过来。这样才不会让一个平庸的通才压住自己地盘上的优秀专家。

loader 住在 src/generalist.ts,按目录缓存,别每次调用都重载 ONNX session:

export function loadGeneralist(modelDir: string) {
  // 按目录缓存,复用 ONNX session
  // tokenizer.json -> Unigram 或 wordpiece,自动检测
  const sess = await ort.InferenceSession.create(
    path.join(modelDir, "generalist.onnx"),
    { intraOpNumThreads: 2 },
  );
}

把 SABAKI_GENERALIST_DIR 指向一个装着 generalist.onnx 加 tokenizer/ 的目录,模型随便换。id 来自 config.json,没有就叫 "sabaki-generalist"。Map 缓存保证每次调用不重载 session。

输入格式,经典 cross-encoder:问题、选项、state 用分隔符拼起来,每个待打分选项一行:

const texts = options.map(
  (o) => `${input.question ?? "decision"} [SEP] ${o} [SEP] ${input.state}`,
);

问题加选项加 state,[SEP] 隔开,每个选项一行。选项上限 8 个(slice(0, 8)),不给就默认 ["yes", "no"]。再多调用方自己切,类型化决策 8 个已经很宽了。

然后是 batch 的小聪明,看着不起眼,延迟直接除以选项数。每个选项一次 session.run 改成全部一次 forward:

// 所有选项一次 batch forward
// (pad 到最大长度),而不是每个选项跑一次。
const L = Math.max(...encs.map((e) => e.ids.length));
// ids + attention_mask 形状 [options.length, L],按 padId 做 padding
const out = await sess.run({ input_ids, attention_mask });
const scores = Array.from(out["score"].data).map(Number);

每段文本编码(按 tokenizer.json 走 BERT 式 wordpiece 或者 SentencePiece unigram),pad 到 batch 里最长,堆成 [选项数, L] 张量。一次 sess.run,每个选项拿一个分数。在 CPU 上这就是一切:20 到 36ms,而不是 8 倍的时间。

然后 softmax 加 argmax,每个选项完整概率,就是 Jev 带火的那个格式:

const pr = softmax(scores);
// bi = 最大值的下标
const confidence = Math.round(pr[bi] * 1000) / 1000;
const probs = Object.fromEntries(
  options.map((o, i) => [o, Math.round(pr[i] * 1000) / 1000]),
);

让这东西从 demo 变成 prod 可用的细节,是 abstainAt:

if (input.abstainAt !== undefined && confidence < input.abstainAt) {
  return { decision: null, confidence, domain: "generalist", probabilities: probs };
}

opt-in,默认不弃权。你传 abstainAt: 0.6-0.7,拿不准的就回 null 而不是瞎猜。softmax 分布外也敢吐 1.00,不加这个不行。GUIDE 给所有自主用法都推荐它:低于阈值就去问人澄清,别自己硬解。

最后,带 generalist fallback 的完整接法:专家都没命中,它接管而不是回 unknown。

import { Sabaki, definePlugin, loadGeneralist } from "sabaki";
import mail from "@sabaki-js/mail";

const backend = loadGeneralist(process.env.SABAKI_GENERALIST_DIR!);
const generalist = definePlugin({
  manifest: { name: "@sabaki-js/generalist", domain: "generalist",
    version: "0.1.0", questions: ["decision"], keywords: [] },
  classify: (input) => backend.decide(input),
});

const sabaki = new Sabaki().register(mail).setFallback(generalist);

await sabaki.decideAsync({
  state: "le client dit que l'app a supprimé ses données",
  question: "c'est urgent ?",
  options: ["yes", "no"],
  abstainAt: 0.65,
});
// 路由命中就走 mail 专家,否则 generalist 像 Jev 一样兜底

generalist 每一代,都在 PhishNChips(2000 封邮件)和 typed-decisions 测试集上同样测过,能对齐的都对齐:

  • v4:测试 0.29,phishing R=0.026。扔掉,答的都是隔壁的题。
  • A(22M 80k):0.546,R=0.406。开始像人话了。
  • 线上 mini v1:0.536,R=0.616。网站上跑的就是这个。phishing recall 还行,测试一般。
  • B(144M 从零训):0.568,R=0.062。更大,测试好一丁点,phishing 拉胯。

144M 从零训还没迁移好,这一下把我“堆参数换泛化”的念头浇灭了。同一个任务,更大的更贵,猜得还没更好。连兜底这个活,小的也干得了。而且它是纯英文(all-MiniLM-L6-v2),路由英文,法语是分布外。法语请走专门训过的专家,别找 generalist。

Doom,对我来说唯一算数的测试

doomLaya,就是让 Laya 和 Jev 真进 FreeDoom 里打。模型选 command / target / weapon,执行器管 A* 寻路、开门、瞄准、开火。每 tick 遥测,录像,run 的 SHA。我是 contributor,不是远处读了个榜单就来聊。

v0.1.0,MAP01 seed 48,skill 3,35 ticks/s,同一个执行器:

模型 结果 p50 HTTP API 成本
Laya 原版 通关失败 180s 106ms $0
Laya v3 适配版 通关 61.43s 108ms $0
Jev 1.13 通关 68.97s 357ms $0.0046

两次干净通关,零死亡,Laya 顺手捡了 shotgun,因为执行器自己管 pickup。诚实一点:Laya v3 在 Doom 上训过,Jev 没有。一个 seed,一张图,证明不了通用结论。项目 README 白纸黑字写了,不吹,这点值得尊敬。

Sabaki 这边(plugin-doom):Python 版 ViZDoom 每 tick 调一次 JS 服务器。nano 约 18k req/s,i5 单线程 mini 约 71 req/s。35Hz 的 tick,中端笔记本都轻松拿下。

带出口罗盘,mini 6 次跑里通关 2 到 4 次,nano 0/6。合理:文本丢了空间信息,doomLaya 的 state 在 0.57 左右封顶,MiniLM 和 ModernBERT-base 都一样。空间信息没了,什么文本分类器都变不出来。

邻居们用同样数据重打了一遍:doomgemma 在 RTX 4070 上花 27 分钟打个 LoRA,把 Gemma 4 E2B 奶到游戏里跟 Laya v3 同级(6/6 对 6/6),孤立决策上还更高。同体量下 decoder 跟 Laya 一样快(270M:22ms 对 23ms)。同效果下 Laya 还是快 3 倍左右,因为它用更小的模型办到了。

我的解读:任务上训练过,比架构重要。训过的专家 beating 没见过的通才。这正是 Sabaki 的赌注。后续那篇 System Switch(arXiv:2610.09683)把这个逻辑推到底:小模型快速打,gate 一开大模型接管推理。Sabaki 的预分拣(automate > 0.85 否则升级)用在游戏里,就是这个味。

被牺牲掉的东西

每个缺掉的 feature,都在给另一个 feature 买单,想跑 CPU 就得认这个规矩。诚实清单,跟 Bareiron 一样,每行 C 都是 tradeoff:

Sabaki 不讲故事。 它在你给的选项里拍板,附一个概率。想要解释、总结、自由文本,请出门找 LLM。

generalist 的法语还是分布外。 路由英文(all-MiniLM-L6-v2),相似 case 上法语探针只有英语的 1/4 对 3/4。mail 插件来自一个 mostly 法语但匿名化过的邮箱:零 PII,代价是 accuracy 掉 0.024,词表里没有个人词。匿名化是要花钱的,但这条没得谈。

softmax 出了舒适区还敢装自信。 见过错到 1.00 的,是的。所以 GUIDE 里推荐预分拣:abstainAt: 0.6-0.7 先把拿不准的变成 null,再用 confidence > 0.85 的 gate 做自动,剩下来给人。实测参考:phishing recall 0.62,precision 0.87。队列砍一半可以,指望它开掉 reviewer 不行。

nano 在 agent 轨迹上吃力。 按问题 0.34 到 0.59。mini 把 needs_review 拉到 0.82,但 risk / urgency / action 还是硬。可观测文本的词汇信号就是贫。

文本 Doom 到顶了。 0.57 左右,MiniLM 和 ModernBERT-base 都一样。只用文本不要像素,MAP02 就卡住。真想做个玩家,可信的路是视觉加 RL(tiny-doom-defender,从像素起 1.1M 参数),不是更好的文本分类器。

benchmark 按 batch 漂。 typed-decisions 按批生成,时间切分其实是 batch 切分。drift_tool.py 在 customer_service 上打出 0.82,agent_trace 上 0.67(0.5 等于不可区分)。实测后果:发布的权重混合 0.733,官方切分 0.61。所以官方数字是保守的。Platt 校准和 temperature 加 bias 在 batch 之间迁不过去,所以只 ship 在最近 batch 上校准的 temperature scaling。家规:先跑 drift_tool.py,过 0.6 警告,过 0.8 拉闸。别人都不发这个,人人都该发。

最终结果,数字说话

一边是笔记本 i5,一边是按调用收费的 API。每种模式实际长这样:

  • nano:<1ms,约 18k req/s,20 到 650KB,0 依赖
  • mini:约 20ms,约 71 req/s,22MB int8,onnxruntime 可选
  • generalist:20 到 36ms,22.7M,我知道的最小开源通才

多领域全装:lazy-load 每个领域约 0.5GB 内存,而不是全量 2GB。确定性:100 个相同输入,100 个相同输出。按问题设阈值,confidence 上做 gate,谁都不认识就回 unknown。

Bareiron 上 3 个以上玩家就卡,因为每个玩家都是一个 200ms 的 chunk。这里 3 个以上领域随便跑,因为每个专家都小,还按需加载而不是全堆进内存。同一个“小而够用”的哲学,换了块硬件而已。

哲学

p2r3 说过:“我就喜欢这种感觉,一块 1 块钱的小芯片,能跑起 Minecraft 这么高级的东西。”

一样的感觉:我喜欢 20KB JSON 加一百行 TS 就顶掉一次 GPU 调用的感觉。当然不是什么都能顶,别闹。顶的是那种烦人的 case:你要一个 label,要毫秒级,要本地,要置信度能拿来切阈值而不是闭眼祈祷。

tradeoff,白纸黑字:

  • encoder 换成数词:少 7 个点,延迟除以 1000,infra 归零
  • 单一大模型换成 npm 插件:gate 粗一点,ops 简单一万倍
  • 生成换成类型化决策:碎解析和自创选项成为历史
  • 原始置信换成预分拣:先弃权,0.85 以上自动,剩下给人

三句话记住:

  1. 少 7 个点,快 1000 倍,0.61/0.69 对 0.75 到 0.80,但 CPU 离线 <1ms/20ms,对面是 25 秒 CPU 或者付费 API。脱离成本谈分数,毫无意义。
  2. 简单路由器加独立专家,hits / sqrt(keywords),阈值,unknown。每个插件独立训练、独立版本、独立发布。在 mail 上 TF-IDF 追平 MiniLM:这里 GPU 就是没用。
  3. 离线优先,校准诚实,npm i,权重进包,零 GPU,ECE < 0.14,弃权,drift 工具。整个对比里唯一能在 35Hz 游戏循环里活下来还不用 GPU 的 stack。

项目有兴趣,MIT 全开源在 github.com/sabaki-js。带测量和 bibtex 的学术版是 preprint v1。想看 Goliath 真进 Doom 里打,去 azalio/doomLaya xD

NEW

Sabaki vs System One: ダビデ対ゴリアテ、世界最小の意思決定モデル

20KB対421Mパラメータ。Sabakiはtyped-decisionsで7ポイント落とす代わりに、CPU 25秒から1ms未満のオフラインに落とした。組織型MoEの中身。

はじめに

9月以降Hacker NewsやTwitterを見てたら、Jevを絶対目にしてるはずだ。

2026年9月15日、TypeSafeがJevを出した。初の「System One」だ。売り文句は一言で終わる。ラベルを選ぶためにLLMにJSONを生成させるのはもうやめろ。stateと型付きの質問を渡せば、型付きの決定が確率つきで返ってくる。1パス、サンプルするトークンゼロ、後段のパースなし。

これが爆発した。Vercel AI Gatewayで初日の採用記録、数日でElixirクライアントが5つ、みんな自分のやつとJevをベンチマークし始めた。正直、盛り上がる理由はわかる。何年も「{"choice": ...}だけで答えてくれ」と祈りながらプロンプトを書いてきた。モデルが選択肢を捏造したり、JSONを壊したり、存在しないフィールドを幻覚したりしないように祈る作業だ。Jevはその需要に名前をつけて、きれいなAPIで出した。POST /v1/systemone、質問はchoice / score / noul、答えはprobabilitiesとconfidenceつき。

アイデアは大好きだ。エコなモデルはもっと好きだ。はっきり言うと、自分はJevに課金したことはない。

理由は単純で、Jevは中身が閉じたAPIのままだからだ。重みは非公開、セルフホスト不可、1回の決定ごとにネットワークの往復と課金メーターが回る。後追いのオープンな代替も400Mパラメータ級でGPU必須だった。

だから逆の極端を作った。Sabakiだ。キーワードルーターとTypeScript純正のロジスティック回帰で、Jevと同じ契約をしゃべる(同じchoice / score / noulの形、同じ/v1/systemoneエンドポイント)。でも1ms未満で動いて、重みは20キロバイトのJSONだ。nanoモードは依存ゼロ、オフライン、0円。

サイズ感で言うと、Layaは843MBのsafetensorsをロードする。OpenDecider-nanoはRAMを2GB食う。Sabaki nano invoicesは20KBだ。重さの比率はだいたい40000倍違う。

きっちり数字で見たい人向けに、2026年10月のpreprint v1をサイトに置いてある。Sabaki: An Organizational Mixture-of-Experts for Typed Decisions on CPU (PDF)だ。設計も測定もトレードオフも全部そこにある。この記事は読み物版で、横にオープンなコードつきだ。

机上のベンチマーク論文みたいに一人で書いたわけじゃない。自分はazalio/doomLaya(自分のフォークはfox3000foxy/doomLaya)にコントリビュートしてる。LayaとJevがFreeDoomを実際にプレイするハーネスで、決定はモデル持ち、tick単位のテレメトリと動画つきだ。ゴリアテが実際にプレイするところを見たし、ネットワークが咳き込むとそいつも咳き込むのを見た。

エンジンを一つずつ開けていこう。いつも通り、本物のコードつきで。

みんなが認める土俵

JevもLayaもOpenDeciderも引用するデータセットは一つだけだ。LocalLLaMA/typed-decisions。合成ワークフロー4種(サポート、請求書、セキュリティ、エージェントのトレース)、ケースごとに質問5つ、train 1200ケース、test 400ケース、決定2000件。

モデル パラメータ テストスコア レイテンシ 動作環境
OpenDecider-nano ~400M 0.796 17ms GPU GPU、RAM 2GB
Laya typed-decisions 421M 0.766 ~25s/state CPU、~830ms/件 MLX GPU
Jev 1.13 非公開 ~0.73-0.75 ~710ms API APIのみ
Sabaki mini 22M int8 0.69 ~20ms CPU CPU、22MB
Sabaki nano ~0 0.61 <1ms どこでも
Laya base ゼロショット 421M 0.362 同じ GPU

ランダムは0.318、多数決は0.461だ。つまりファインチューンなしのLaya baseの0.362は「丁寧に当てずっぽう」レベルってことだ。覚えておいてほしい、後で効いてくる。

トップとSabakiの差は7ポイント。GPUがないときのレイテンシは逆方向に1000倍違う。この記事の全部はこの2文に尽きる。残りはなぜそうなるかを理解するためだ。

ただし注意がある。同じchoice / score / noulの口に見えて、中の台所は全然違う。Jevは閉じたAPI、LayaはファインチューンしたModernBERT-large、OpenDeciderは蒸留したEttin-encoder、Sabakiは単語カウントとMiniLM int8だ。契約は同じ、予算が比較にならない。

Jev、みんなが食らった衝撃

TypeSafeのものはTypeSafeに返そう。System Oneの契約は本当にいいアイデアだった。

昔、チケットを振り分けるにはこうしてた。LLMに「billing、technical、spamから選んでJSONで答えて」と頼んで、返答をパースして、「billingだと思うよ! 😊」と返してきたり4つ目のカテゴリを捏造したりするケースに対処する。質問1つごとにトークン課金、返答1つごとにパイプラインが壊れる可能性があった。

Jevは条件を変えた。テキストと基準つきの質問を送ると、各質問を並列に評価して、分布つきの型付き値を返してくる。

curl https://api.typesafe.ai/v1/systemone \
 -H "Authorization: Bearer $KEY" \
 -H 'content-type: application/json' \
 -d '{"state":"charge double ce mois-ci","questions":{"dept":{"type":"choice","instructions":"Route ce message.","criteria":{"billing":"paiements","technical":"bugs"}}}}}'

返答は{ choice, probabilities, confidence }だ。自由テキストなし。コードはそのまま分岐できる。

バズった理由はここだ。PythonとJSのSDK、Claude Codeプラグイン、LangChain統合、みんな午後のうちにエージェントに繋げた。TypeSafeのドキュメントも健全なパターンを推してる。速い小さな判断はJev、本当の決定の合成は周りの決定的コード、低速LLMや人間は信頼度が低いときだ。

問題はそれ以外だ。重みは非公開、オフライン不可、従量課金。1回のrunで100回決定するエージェントだと税金が痛い。35 ticks/sのDoomだと死ぬ。doomLayaでは、JevはMAP01 seed 48を68.97秒で抜けて、HTTP p50は357msだ。runのコストは$0.0046と報告されてる。自分の請求じゃなくてベンチマークの分だ。動くし、runはきれいで死なない。でも35 ticks/sで357msは、ネットワーク待ちの間にゲームが12 tick進むってことだ。

Sabakiはこの契約だけをそのままもらった。同じ質問、同じ答え、同じ/v1/systemoneをserve()で served して、データセンターが必要な部分を全部捨てた。

Laya、APIなしでもいけることを見せたデカいオープン勢

LayaはConvai Innovations製、Apache-2.0だ。中身はModernBERT-largeの421Mで、選択肢ごとに[MASK]トークンを1つ置く。各マーカーのhidden stateを小さなMLPに通して、質問の選択肢にsoftmaxする。Jevと同じで質問ごとに1パスだ。

pipe = pipeline("text-classification", model="convaiinnovations/laya-typed-decisions")

0.766はちゃんと見たほうがいい。これはベンチマークのtrainにファインチューンしたチェックポイントの数字だ。1200ケース、logitsにガウスノイズを載せたRLCD、log + sphericalリワードだ。同じモデルでもこのフィットなしだと0.362に落ちる。ほぼ当てずっぽうだ。つまりLayaのスコアは「でかいエンコーダとドメイン学習」であって「魔法のでかいエンコーダ」じゃない。

CPUだと文鎮だ。ONNX移植の実測で1 stateあたり約25秒。MLX FP16だと1件平均830ms、p99で5.3秒だ。何か答える前に843MBをロードする。

doomLayaだと、素のLayaはMAP01を180秒で失敗する。Doom用に再学習したv3 (azalio/laya-doom-v3)は61.43秒で抜けて、MPSでp50 108ms、ついでにショットガンを拾ってくる。つまり、ちゃんと遊べる。専用学習の後で、Apple GPUの上で、実際に動く部分(A*、15 tickラッチのドア、自動照準)を書いたスクリプト実行部があってこそだ。

自分のまとめはこうだ。タスク学習なしのLayaは421Mのクリップボードだ。ありならめちゃ強い。でもGPUとデータが必要だ。

OpenDecider、リーダーボードを獲ったやつ

OpenDeciderは、Manjunathがみんなの土俵に乗り込んで獲りにいったやつだ。結果は0.796。Ettin-encoder-400mのフルファインチューンとMLPヘッド、Qwen3-235BとDeepSeekから蒸留したソフトターゲットへのKL損失だ。

pip install opendecider
# L40Sで17ms/question、M4 Maxで18ms、バッチ化で約9ms

カバレッジ50%だとselective accuracyは0.943で、Jevの0.882を上回る。紙の上ではいいとこ取りだ。オープンで、キャリブレーションされていて、速い。

実際はこうだ。nanoのロードにRAM 2GB、17msを保つにはGPU必須、phishingは0.63でJevの0.90に洗われる(OODに弱い)。Layaと同じでベンチマークのtrainで学習してる。テストは見てないがドメインは見てる。

腕相撲には勝った。でもやっぱりGPU背負いの400Mだ。Cloudflare Workerにも、ノートPCのゲームループにも突っ込めない。

150M勢と、その下の空白

openJev-verdict-2.0 (151M)、julia-1 (144M多言語)、Open Jev (150M)、Largha-150M (144M en+zh)、Vela (0.3B-9B)。全部ModernBERTかmmBERT-smallで、全部CPUで回せて、学習も軽い(Open JevはColabのT4で30分と言ってる)。数字は自己申告でプロトコルもバラバラだから割り引いて見るべきだが、ファミリーは存在する。

汎用のSabaki generalistは22.7Mだ。この中で一番小さいやつの約6.5分の1になる。ランタイム定義の質問に答える用途で100Mを切るやつは他にいない。

「世界最小」の意味はここだ。トップのスコアじゃない。instructionsとcriteriaを読んで、スペシャリストが誰もわからないときに決める、一番小さい入れ物の話だ。

Sabaki、バカ正直なルーターと使い捨てエキスパート

よし、デカい連中の話は終わり。ダビデに行こう。面白いのはここからだ。

Sabakiは世界を理解する賢いデカいモデルになろうとしない。組織型MoEだ。システムレベルのスパースルーターが1つ、独立したエキスパートが複数、入力ごとに動くのは1つだけ。各プラグインは別々のnpmパッケージで、マニフェストと重みとバージョンを持つ。ルーティングする分だけ入れればいい。

sabaki-js orgは13リポジトリ。coreのsabaki(129コミット)、@sabaki-js/*のプラグイン6つ、重み、Cloudflare worker、自分のやつを書くテンプレートだ。

coreのpackage.jsonがもう物語ってる。

"dependencies": { "@typesafe-ai/sdk": "^0.7.0" },
"optionalDependencies": { "onnxruntime-node": "^1.22.0" }

nanoモードはランタイム依存ゼロだ。コンパイルされたTypeScriptだけで、インストールするものなし、ONNXのバージョン間で壊れるものなし。

ルーター、数行で決める仕組み

ルーターはsrc/index.tsに住んでて、トークン化はこんな感じ(src/index.ts:129)。

function tokens(s: string): string[] {
  return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .split(/[^a-z0-9]+/).filter((w) => w.length > 3);
}

小文字化、アクセント除去、4文字以上の単語だけ残す。このフィルタでノイズ(「le」とか「de」とか「ok」)が消える。サポートチケットだとここが効く。マッチングが意味を持つ単語同士になるからだ。

スコアリング自体はroute()の中(src/index.ts:251)。勝負はここで決まる。

const hits = kws.filter((k) =>
  words.some((w) => w.includes(k) || k.includes(w))).length;
const score = hits / Math.sqrt(kws.length);

両方向のゆるいマッチだ。billingはbilledを拾うし、invoiceはinvoicingを拾う。ルート割りでキーワード数が多いリストを不利にしない。じゃないと600語持ちのmailプラグインが「hello」が入っただけで全部持っていく。

各プラグインにしきい値があって、デフォルトはsrc/index.ts:139の0.15、mailは件名が短いから0.03だ。それ以下だとこうなる。

return { domain: "unknown", decision: null, confidence: 0, plugin: null };

未知ドメインはunknownで、喜ばせるための捏造-guessは返さない。わざとバカにしてある。後でautomate > 0.8のしきい値が使えるのはこのおかげだ。Sabakiが答えるときは、本当にマッチしたスペシャリストがいるってことだ。

おまけでlangidの言語軸もある。languagesを宣言したプラグインは自分の言語のときだけ出場する。ラテン系phishingとCJKの分離に便利だ。テキストが短すぎるときはフィルタなしで全員出場だ。

合計コストは1決定あたり数マイクロ秒だ。掛ける行列なし、呼ぶネットワークなし、文字列比較と割り算だけだ。

Nano、リポジトリで一番好きなファイル

src/logreg.ts:28、トークン化はこうだ。

function tokens(s: string): string[] {
  const words = s.toLowerCase().split(/[^a-z0-9]+/)
    .filter((w) => /^[a-z]{3,}$/.test(w));
  const out = [...words];
  for (let i = 0; i < words.length - 1; i++)
    out.push(`${words[i]} ${words[i + 1]}`);
  return out;
}

ユニグラムとバイグラムだ。charged twiceはchargedとtwiceとペアのcharged twiceになる。400Mのtransformerを出さずに「二重請求」を掴むにはこれで十分だ。

src/logreg.ts:46のベクトル化、TF-IDFが働く場所だ。

const tf = 1 + Math.log(c);  // TFは対数で抑える
const val = tf * w.idf[i]!;  // IDFはsklearn風のスムージング

同じ単語の10回目は1回目より軽いし、レアな単語は「please」より重い。sklearnのレシピそのままで、20問で完全一致を確認した。

読み返して笑ったコメントがこれだ。

// 3000次元を全部舐めず、存在する語だけ触る(スパース)
for (const [t, c] of counts) {

チケットごとに3000次元を全部舐めず、入ってる20語くらいだけ触る。1ms未満の理由はここだ。本番だと効くタイプの見えない工夫で、計算量が語彙サイズじゃなくてチケット長さにしか依存しない。

src/logreg.ts:86の推論、3行で終わる。

// バイナリ: 係数1行 + シグモイド
const pPos = 1 / (1 + Math.exp(-s));
// マルチクラス: 内積 + softmax、argmax

内積を1回、クラス数でシグモイドかsoftmaxかを使い分ける。1パスで終わりだ。生成トークンゼロだから、壊れたJSONをリトライとパース職人芸で直す作業もゼロだ。

train.pyのレシピはこうだ。ワークフローごとにtrain 300、test 100、決定的split(時間バイアス避けに5件に1件をtest)、語彙3000から6000、係数は1e-4に丸めて測定上の劣化ゼロだ。JSONで{ vocab, idf, questions }を吐く。

重みはinvoicesの20KB (duplicate 0.89)からmailの650KB (bucket 0.889)までだ。フロントのバンドルに入るサイズ感だ。

痺れた細部がある。mailだとMiniLMとTF-IDFがプラスマイナス0.02で張り合う。だからnanoはそのまま出す。メール振り分けにGPU課金とかごめんだ。逆にagent_traceは詰まる(nanoだとneeds_review 0.59)。観測テキストが貧しくて、決定を担う単語がほぼないからだ。

重みをプラグインに変える工場はsrc/plugin.ts:52にある。

export function nanoClassifier(manifest, weights, opts = {}) {
  return definePlugin({ manifest,
    classify: (input) => {
      const r = predict(`${from}${input.state}`, weights, q);
      return { decision: r.decision, confidence: r.confidence, domain: manifest.domain };
    }});
}

decide()はnanoのまま同期、decideAsync()でONNXのminiが解禁される。2経路でルールは単純だ。asyncが必要ならminiが要る、要らないならnanoで足りる。書くときに覚えやすいし、壊れたときにデバッグしやすい。

理論だけじゃないのを見せるために、実際の使い方を貼る。

import { Sabaki } from "sabaki";
import customerService from "@sabaki-js/customer-service";

const sabaki = new Sabaki().register(customerService);
sabaki.decide({ state: "charged twice, refund please" });
// { decision: "billing", confidence: 0.31, domain: "customer_service" }

Mini、難しいケースを変える22MB

nanoの限界はさっきのagent_traceで見た通りだ。MiniLM-L6が引き継ぐ。ドメイン全体で共有のエンコーダ1つと、質問ごとの線形ヘッドだ。8から12 epochでend-to-endファインチューン、lr 2e-5、学習はCUDA GPUでやる(推論はCPUのまま、重要なのはこっちだ)。

同じsplitで測って結論が出た数字だ。

  • 凍結エンコーダとMLP: 0.65
  • フルファインチューン: 0.68
  • 400Mから凍結embeddingへの蒸留: 試して捨てた、goldラベルより悪かった

dynamo経由でONNX化して完全一致を確認、それから動的int8量子化だ。90MBから22MBへ、customer_serviceでaccuracy マイナス0.004だ。ECEは同じか改善だ。各exportは測り直す。測らない最適化に意味はない。

1.0.2から重みはパッケージ内蔵だ(models/<workflow>-int8/とexportされたmodelDir)。npm iで終わり、機内モードOKだ。更新はパッチ公開で、trusted publishingで署名する。

具体的に言うと、needs_reviewが0.59から0.82になる。dispositionは0.73から0.76だ。ECEは全部0.14未満で、OODだと健全に迷う(dragonsやレシピや空文字で0.2から0.5)。この迷いがあるから本番の強気なしきい値が許される。

Generalist、Sabakiを本当にSystem Oneにするフォールバック

Jevたちは何にでも答える。どんな質問でも、どんな選択肢でも、ランタイム定義でだ。これがJevをバズらせたSystem Oneの契約で、instructionsとcriteriaと選択肢を渡せば切り分けてくれる。

Sabakiのスペシャリストはそれができない。知ってるのは自分の質問(categoryとかduplicateとかbucket)だけだ。網がなければ台本から外れた瞬間にunknownを返して、Jevなら何か返すところで止まる。

generalistはその網だ。質問と選択肢とstateを読んでスコアを出すcross-encoderで、選択肢ごとに1パス、softmaxしてargmaxだ。22.7MのMiniLM int8で、150M前後の連中の6.5分の1だ。でも仕事はスコアじゃない、互換性だ。これをフォールバックに繋ぐと、POST /v1/systemoneがJevと同じで任意のstateと任意の質問を受け付ける。modelフィールドが本当に答えたやつを言う(@sabaki-js/[email protected]+miniとかsabaki-generalist-v1とか)から、呼ぶ側は変えなくていい。

大事なのは、こいつはフォールバックであってボスじゃないってことだ。decideAsync()の鉄則はこうだ。スペシャリストがルーティングに勝ったらそいつが答える。generalistがしゃべるのはunknownのときだけだ。逆はやらない。中の上のジェネラリストが、得意領域の優秀なスペシャリストを潰さないためだ。

ローダーはsrc/generalist.tsにいて、ONNXセッションを毎回ロードしないようにフォルダ単位でキャッシュする。

export function loadGeneralist(modelDir: string) {
  // フォルダ単位でキャッシュ、ONNXセッションを使い回す
  // tokenizer.jsonからUnigramかwordpieceかを自動判定
  const sess = await ort.InferenceSession.create(
    path.join(modelDir, "generalist.onnx"),
    { intraOpNumThreads: 2 },
  );
}

SABAKI_GENERALIST_DIRをgeneralist.onnxとtokenizer/が入ったフォルダに向ければ、モデルは差し替え可能だ。idはconfig.jsonから来て、なければ"sabaki-generalist"になる。Mapのキャッシュで毎callの再ロードを避ける。

入力形式は古典的なcross-encoderだ。質問と選択肢とstateをセパレータで繋いで、スコア付けたい選択肢の数だけ行を作る。

const texts = options.map(
  (o) => `${input.question ?? "decision"} [SEP] ${o} [SEP] ${input.state}`,
);

質問と選択肢とstateを[SEP]で区切って、選択肢ごと1行だ。選択肢は8個で頭打ち(slice(0, 8))、何もなければ["yes", "no"]だ。それ以上は呼ぶ側で刻んでくれ。型付き決定に8枠はもう広い。

次は地味に効くバッチ化だ。見た目は地味だがレイテンシを選択肢数で割る。選択肢ごとにsession.runせず、全部1 forwardで流す。

// 全オプションを1回のバッチforwardで処理
// (最大長までパディング) オプション毎にrunしない
const L = Math.max(...encs.map((e) => e.ids.length));
// ids + attention_maskは[options.length, L]、padIdでパディング
const out = await sess.run({ input_ids, attention_mask });
const scores = Array.from(out["score"].data).map(Number);

各テキストをエンコードして(BERT風wordpieceか、tokenizer.json次第でSentencePiece unigram)、バッチの最大長までパディングして、[選択肢数, L]のテンソルに積む。sess.runは1回で、選択肢ごとのスコアが返る。CPUだとこれが全部だ。20から36msが、その8倍にならずに済む。

それからsoftmaxとargmaxで、Jevが広めた形式そのままで全選択肢の確率を出す。

const pr = softmax(scores);
// bi = 最大値のインデックス
const confidence = Math.round(pr[bi] * 1000) / 1000;
const probs = Object.fromEntries(
  options.map((o, i) => [o, Math.round(pr[i] * 1000) / 1000]),
);

デモで終わらせず本番で使えるようにする細部がabstainAtだ。

if (input.abstainAt !== undefined && confidence < input.abstainAt) {
  return { decision: null, confidence, domain: "generalist", probabilities: probs };
}

オプトインで、デフォルトの棄権なしだ。abstainAt: 0.6-0.7を渡すと、ふわっとしたやつはguessじゃなくてnullで返る。分布外だと間違ってるときに1.00を出すsoftmaxだから、これは贅沢品じゃない。GUIDEは自律用途の全部に推奨してる。しきい値以下は自動解決じゃなくて確認を聞けってことだ。

最後に、generalistフォールバックありの同じ組み立てだ。スペシャリストが誰もマッチしないとき、こいつがunknownの代わりに受ける。

import { Sabaki, definePlugin, loadGeneralist } from "sabaki";
import mail from "@sabaki-js/mail";

const backend = loadGeneralist(process.env.SABAKI_GENERALIST_DIR!);
const generalist = definePlugin({
  manifest: { name: "@sabaki-js/generalist", domain: "generalist",
    version: "0.1.0", questions: ["decision"], keywords: [] },
  classify: (input) => backend.decide(input),
});

const sabaki = new Sabaki().register(mail).setFallback(generalist);

await sabaki.decideAsync({
  state: "le client dit que l'app a supprimé ses données",
  question: "c'est urgent ?",
  options: ["yes", "no"],
  abstainAt: 0.65,
});
// mailスペシャリストがマッチすればそちら、ダメならgeneralistがJev風のcatch-allで受ける

generalistの世代は全部同じ測り方だ。PhishNChips (2000通)と、比較できる分はtyped-decisionsのtestで測ってる。

  • v4: test 0.29、phishing R=0.026。外してた、捨てだ。
  • A (22M 80k): 0.546、R=0.406。形になってきた。
  • mini v1 served: 0.536、R=0.616。サイトで動いてるのはこれだ。phishingのrecallはまとも、testは並だ。
  • B (144M scratch): 0.568、R=0.062。でかくてtestはちょい上、phishingはダメだ。

144Mのscratchが転移で勝てないのを見て「パラメータを積めば汎化する」の夢は冷めた。同じタスクだと、デカいやつは金がかかって当てない。catch-allとしても小さいやつで足りる。あとこいつはEN専用(all-MiniLM-L6-v2)で、ルーティングもEN、FRは分布外だ。FRはgeneralistじゃなくて、そこで学習したスペシャリストに回してくれ。

Doom、自分にとって本当に大事なテスト

doomLayaは、LayaとJevがFreeDoomを本気で遊ぶ場所だ。モデルがcommand / target / weaponを選んで、実行部がA*で寄せてドアを開けて狙って撃つ。tick単位のテレメトリ、動画、runのSHAつきだ。自分も貢献してるから、遠くのリーダーボードを眺めた話じゃない。

v0.1.0、MAP01 seed 48、skill 3、35 ticks/s、同じ実行部だ。

モデル 結果 p50 HTTP APIコスト
Laya stock 失敗 180s 106ms $0
Laya v3 adapted 脱出 61.43s 108ms $0
Jev 1.13 脱出 68.97s 357ms $0.0046

2つともきれいな脱出で死なない。Layaは実行部がpickupをやるからショットガンを拾ってくる。正直に言うと、Laya v3はDoomで学習してるしJevはしてない。1 seedの1 mapじゃ一般的なことは言えない。プロジェクトのREADMEも黒白はっきり書いてて、盛らないのは立派だ。

Sabaki側 (plugin-doom)はこうだ。PythonのViZDoomが毎tick JSサーバーを叩く。nanoは約18k req/s、miniはi5の1スレッドで約71 req/sだ。35Hzのtickは平均的なノートでも余裕で回る。

出口コンパスありだと、miniは6回中2から4回脱出、nanoは0/6だ。道理だ。テキスト化で空間情報が落ちるし、doomLayaの状態文はMiniLMでもModernBERT-baseでも0.57あたりで頭打ちになる。空間情報がないのにテキスト分類器がそれを発明するわけがない。

隣の連中も同じデータで殴り返してきた。doomgemmaはRTX 4070で27分のLoRAを当てて、Gemma 4 E2BをゲームではLaya v3並み(6/6対6/6)、単体決定だと上まで持っていった。同じサイズだとデコーダはLayaと同じ速さだ(270Mで22ms対23ms)。同じ結果なら小さいモデルで届くLayaが約3倍速い。

自分の読みはこうだ。アーキテクチャよりタスク学習が効く。学習済みの小さいスペシャリストは、初見のデカいジェネラリストに勝つ。Sabakiの賭けそのものだ。後追いの論文System Switch (arXiv:2610.09683)はこの理屈を最後まで押してる。速い小さいモデルが遊んで、ゲートが開いたらデカい推論モデルが引き継ぐ。Sabakiのpre-triage (automate > 0.85、ダメならエスカレ)のゲーム版だ。

犠牲にしたもの

ない機能が別の機能を払う。これがCPU狙いのルールだ。正直リストを置く。BareironでCの1行1行がトレードオフだったのと同じだ。

**Sabakiは語らない。**渡された選択肢から確率つきで切り分けるだけだ。説明も要約も自由文も要るならLLMを呼んでくれ。

**フランス語はgeneralistの分布外のままだ。**ルーティングはEN (all-MiniLM-L6-v2)で、似たケースの1/4 FR対3/4 ENのプローブ通りだ。mailプラグインはほぼFRの会社のものだが匿名化してる。PIIゼロ、その代わり個人語彙なしでaccuracyマイナス0.024だ。匿名化のコストは払う。ここは譲れない。

**softmaxは縄張りの外だと自信満々になる。**外れなのに1.00を出すのを見た、ある。だからGUIDEのpre-triage推奨だ。abstainAt: 0.6-0.7でしきい値以下をnullにして、自動化はconfidence > 0.85のゲート、人間は残りだ。実測の参照値はphishingで精度0.87のときrecall 0.62だ。列を半分にする用であって、レビューアを消す用じゃない。

**nanoはエージェントのトレースが苦手だ。**質問によって0.34から0.59だ。miniでneeds_reviewは0.82まで戻るが、risk / urgency / actionは難しいままだ。観測テキストが語彙シグナルとして貧しいだけだ。

**テキストのDoomは頭打ちだ。**MiniLMもModernBERT-baseも0.57あたりだ。ピクセルなしのテキストのままだとMAP02で詰まる。本物のプレイヤーへのまっとうな道はvisionとRL (tiny-doom-defender、ピクセルからの1.1M params)であって、もっといいテキスト分類器じゃない。

**ベンチマークはバッチでドリフトする。**typed-decisionsはロット生成で、時間splitは実質バッチsplitだ。drift_tool.pyはcustomer_serviceで0.82、agent_traceで0.67を出す(0.5が区別不能)。実測の帰結として、公開重みは公式splitの0.61に対して混合だと0.733になる。公式数字は保守的ってことだ。Plattやtemperature+biasのキャリブレーションはバッチ間で転移しない。だから最近バッチで取ったtemperature scalingだけ出す。社内ルールはこうだ。まずdrift_tool.py、0.6超えで警告、0.8超えで停止だ。他の誰も出してないが、全員出すべきだ。

最終結果、数字で見る

片方にノートのi5、片方に従量課金のAPIだ。各モードの実際はこうなる。

  • nano: 1ms未満、約18k req/s、20から650KB、依存0
  • mini: 約20ms、約71 req/s、22MB int8、onnxruntimeは任意
  • generalist: 20から36ms、22.7M、知る限り最小のオープンな汎用器だ

複数ドメイン積みだと、全部載せの約2GBじゃなくてドメインごとに約0.5GBの遅延ロードだ。決定的で、同じ入力100個は同じ出力を返す。質問ごとのしきい値、confidenceのゲート、何も刺さらないときのunknownだ。

Bareironだとプレイヤー1人ごとに200msのchunkがかかって、3人以上で詰まった。ここだとドメイン3つ以上が軽く回る。エキスパートが tiny で必要分だけ載るからで、全部一気載せじゃない。「足りる小さいやつ」の思想は同じで、相手のハードが違うだけだ。

思想

p2r3の言葉だ。「1ドルくらいのちっちゃいチップでMinecraftみたいなのが動くって発想が好きなんだ。」

ここも同じノリだ。20KBのJSONと100行ちょいのTSが、ラベル1つのためにGPU呼び出しを置き換えるって発想が好きなんだ。もちろん全部は置き換えない、ふざけるなって話だ。置き換えるのはあの面倒なやつだ。ミリ秒で、ローカルで、祈る代わりにしきい値で扱える信頼度つきのラベルが欲しいやつだ。

トレードオフを黒白はっきり書く。

  • エンコーダから単語カウントへ: 7ポイント減、レイテンシ1000分の1、インフラゼロ
  • 単体のデカいモデルからnpmプラグインへ: ゲートは粗い、運用は無限に楽
  • 生成から型付き決定へ: 壊れるパースと捏造選択肢は終わり
  • 素の信頼度からpre-triageへ: まず棄権、0.85超えで自動化、残りは人間

覚えることは3つだけだ。

  1. 7ポイント減、1000倍速い : 0.61/0.69対0.75-0.80だが、従量APIやCPU 25秒に対してCPUで1ms未満/20msのオフラインだ。コストなしのスコアに意味はない。
  2. 単純ルーターと分離エキスパート : hits / sqrt(keywords)としきい値とunknownだ。各プラグインは単独で学習して版管理して出す。mailだとTF-IDFがMiniLMに張りつく。ここにGPUは要らない。
  3. オフライン優先と正直なキャリブレーション : npm i、重み内蔵、GPUゼロ、ECE 0.14未満、棄権、drift toolだ。35HzのゲームループをGPUなしで回せる比較対象はこの構成だけだ。

興味が出たら、全部MITでgithub.com/sabaki-jsにある。測定とbibtexつきの学術版はpreprint v1だ。ゴリアテがDoomを本気で遊ぶのを見たかったらazalio/doomLayaへ xD

NEW

Sabaki vs System One : 다윗 대 골리앗, 세상에서 가장 작은 결정 모델

20KB 대 421M 파라미터. Sabaki는 typed-decisions에서 7점을 내주지만 CPU 25초를 오프라인 1ms 미만으로 바꿔요. 조직형 MoE 해부기.

소개

9월부터 Hacker News나 트위터 좀 봤다면 Jev를 무조건 봤을 거예요.

2026년 9월 15일, TypeSafe가 Jev를 내놔요. 자기들의 첫 번째 "System One"이죠. 피치는 한 문장이에요. 라벨 고른다고 LLM한테 JSON 생성시키지 마세요. state에 타입 달린 질문들을 넘기면 확률 딸린 타입 결정으로 돌려줘요. 한 번에, 샘플링할 토큰 제로, 뒤에 파싱할 것도 없음.

터졌어요. Vercel AI Gateway에서 첫날 도입 기록, 며칠 만에 Elixir 클라이언트 다섯 개, 다들 자기 거랑 Jev 붙여서 벤치마크 돌리기. 사실 hype은 이해돼요. 몇 년 동안 {"choice": ...}로만 답해 같은 프롬프트 쓰면서 모델이 옵션 날조하지 않게, JSON 깨먹지 않게, 필드 환각하지 않게 빌었잖아요. Jev가 그 수요에 이름 붙여서 깔끔한 API로 ship했어요. POST /v1/systemone, choice / score / noul 질문, probabilities + confidence 딸린 응답.

아이디어는 진짜 좋았어요. 에코 모델은 덜 좋았고요. 분명히 말하면, Jev에 돈 낸 적은 없어요.

왜냐면 Jev는 결국 닫힌 API거든요. 가중치 비공개, 셀프호스트 불가, 결정 한 번이 네트워크 왕복 한 번 + 과금 한 번. 그리고 뒤따라 나온 오픈 대안들은 400M 파라미터에 GPU 필수예요.

그래서 반대쪽 극단으로 만들었어요. Sabaki. 키워드 라우터 + 순수 TypeScript 로지스틱 회귀인데 Jev랑 컨트랙트가 같아요 (같은 choice / score / noul 셰이프, serve()가 서빙하는 같은 /v1/systemone 엔드포인트). 근데 1ms 미만에 돌아가고 20킬로바이트 JSON이면 돼요. nano 모드에서는 의존성 제로, 오프라인, 0원.

감 잡으시라고요. Laya는 safetensors 843MB를 로드해야 해요. OpenDecider-nano는 RAM 2GB예요. Sabaki nano invoices는 20KB예요. 무게비가 대충 40,000배예요.

제대로 된 숫자 버전 보고 싶으면 사이트에 있는 2026년 10월 preprint v1이 있어요. Sabaki: An Organizational Mixture-of-Experts for Typed Decisions on CPU (PDF). 설계, 측정, 트레이드오프 다 들어있어요. 이 글은 읽기 쉬운 버전이고, 코드는 옆에 열어뒀어요.

혼자 방에서 벤치마크 종이 쓰듯 만든 것도 아니에요. 저는 azalio/doomLaya (제 포크는 fox3000foxy/doomLaya)에 기여해요. Laya랑 Jev가 FreeDoom을 진짜로 플레이하는 하네스예요. 모델이 직접 결정하고, 틱마다 텔레메트리 뽑고, 영상도 남아요. 골리앗이 실전에서 뛰는 걸 직접 봤고, 네트워크가 기침만 해도 같이 기침하는 것도 봤어요.

이제 엔진을 하나씩 열어볼게요. 늘 그렇듯, 진짜 코드랑 같이요.

모두가 싸우기로 합의한 링

Jev, Laya, OpenDecider가 다 같이 인용하는 데이터셋은 하나예요. LocalLLaMA/typed-decisions. 합성 워크플로 4개 (support, factures, sécu, agent traces), 케이스당 질문 5개, train 1200 케이스, test 400 케이스, 결정 2000개.

모델 파라미터 테스트 점수 레이턴시 실행 환경
OpenDecider-nano ~400M 0.796 17ms GPU GPU, RAM 2GB
Laya typed-decisions 421M 0.766 ~25s/state CPU, ~830ms/케이스 MLX GPU
Jev 1.13 비공개 ~0.73-0.75 ~710ms API API 전용
Sabaki mini 22M int8 0.69 ~20ms CPU CPU, 22MB
Sabaki nano ~0 0.61 <1ms 어디서든
Laya base 제로샷 421M 0.362 동일 GPU

랜덤이 0.318, majority가 0.461이에요. 그러니까 파인튜닝 없는 Laya base 0.362는 "예의 바르게 찍기" 수준이에요. 기억해두세요, 뒤에서 써먹어요.

Sabaki랑 1등 사이에는 7점이 있어요. GPU 없을 때는 반대 방향으로 1000배 레이턴시가 있어요. 이 글 전체를 두 문장으로 줄이면 그게 다예요. 나머지는 왜 그런지 이해하려는 거예요.

하나 주의할 점. 같은 choice / score / noul 소켓 뒤에 주방은 완전히 달라요. Jev는 닫힌 API, Laya는 파인튜닝한 ModernBERT-large, OpenDecider는 증류한 Ettin-encoder, Sabaki는 단어 세기 + int8 MiniLM이에요. 컨트랙트는 같고, 예산은 비교가 안 돼요.

Jev, 모두가 본 한 방

TypeSafe한테 돌릴 건 돌려줘야 해요. System One 컨트랙트, 진짜 좋은 아이디어예요.

예전에는 티켓 분류하려면 이렇게 했어요. LLM한테 "billing, technique, spam 중에 골라, JSON으로 답해"라고 시키고, 응답 파싱하고, 얘가 "제 생각엔 billing이에요! 😊"라고 답하거나 4번째 카테고리 날조하는 경우를 처리했어요. 질문 하나가 토큰 몇 개, 응답 하나가 파이프라인 터질 리스크였어요.

Jev는 거래를 바꿨어요. 텍스트 + 기준 딸린 질문들을 보내면, 각 질문을 병렬로 평가해서 타입 있는 값이랑 분포를 돌려줘요.

curl https://api.typesafe.ai/v1/systemone \
 -H "Authorization: Bearer $KEY" \
 -H 'content-type: application/json' \
 -d '{"state":"charge double ce mois-ci","questions":{"dept":{"type":"choice","instructions":"Route ce message.","criteria":{"billing":"paiements","technical":"bugs"}}}}}'

응답은 { choice, probabilities, confidence }예요. 자유 텍스트 없음. 코드에서 바로 분기 탈 수 있어요.

바이럴 난 이유가 여기예요. Python + JS SDK, Claude Code 플러그인, LangChain 연동, 다들 오후 한나절이면 에이전트에 붙일 수 있었어요. TypeSafe 문서도 건강한 패턴을 밀어요. 빠른 작은 판단은 Jev, 진짜 결정 조합은 주변 결정적 코드, 신뢰도 낮으면 느린 LLM이나 사람.

문제는 나머지예요. 가중치 비공개, 오프라인 불가, 호출당 과금. 한 번 도는데 결정 100번 내리는 에이전트면 세금이 아파요. 35틱/s Doom이면 끝이에요. doomLaya에서 Jev는 MAP01 seed 48을 68.97초에 깨고 p50 HTTP가 357ms예요. 그 런에서 API 비용 $0.0046이 찍혀요. 제 청구서가 아니라 벤치마크 청구서예요. 돌아는 가요, 런도 깨끗하고 죽지도 않아요. 근데 35틱/s에 357ms면 게임은 계속 돌아가는데 12틱 동안 네트워크 기다리는 거예요.

Sabaki는 이 컨트랙트를 그대로 베꼈어요. 같은 질문, 같은 응답, serve()가 서빙하는 같은 /v1/systemone 엔드포인트. 데이터센터 필요한 부분은 다 떼고요.

Laya, API 없이 된다는 걸 보여준 큰 오픈 모델

Convai Innovations의 Laya, Apache-2.0이에요. 보닛 밑에는 ModernBERT-large 421M, 옵션당 [MASK] 토큰 1개. 각 마커의 hidden state가 작은 MLP를 거쳐서 그 질문 옵션들에 softmax. Jev처럼 질문당 한 패스예요.

pipe = pipeline("text-classification", model="convaiinnovations/laya-typed-decisions")

0.766은 뜯어볼 가치가 있어요. 이건 벤치마크 train에 파인튜닝한 체크포인트예요. 1200 케이스, 로짓에 가우시안 노이즈 넣은 RLCD, log + spherical 리워드. 같은 모델도 이 핏 빼면 0.362로 떨어져요. 그냥 찍는 거나 다름없어요. 그러니까 Laya 점수는 "큰 인코더 + 도메인 학습"이지 "마법의 큰 인코더"가 아니에요.

CPU에서는 벽돌이에요. 측정한 ONNX 포트는 state당 한 25초예요. MLX FP16으로는 케이스당 평균 830ms, p99는 5.3초예요. 그리고 뭔가 답하기 전에 843MB를 먼저 로드해야 해요.

doomLaya에서는 스톡 Laya가 MAP01을 180초 안에 못 깨요. Doom 데이터로 다시 학습한 v3 (azalio/laya-doom-v3)은 MPS에서 p50 108ms로 61.43초에 깨고, 가는 길에 샷건도 주워요. 그러니까 돼요, 돼는 건 맞아요. 전용 학습 하고, Apple GPU 얹고, 진짜 일은 스크립트 실행기(A*, 15틱 latch 문 열기, 자동 조준)가 다 해줄 때요.

제 요약은 이래요. 자기 태스크 학습 없으면 Laya는 421M짜리 문진이에요. 학습 있으면 엄청 세요. 근데 GPU랑 데이터가 있어야 해요.

OpenDecider, 리더보드를 가져간 애

OpenDecider는 Manjunath가 걔들 벤치마크 위에서 걔들을 이기기로 마음먹은 프로젝트예요. 결과는 Ettin-encoder-400m 풀 파인튜닝 + MLP 헤드, Qwen3-235B + DeepSeek에서 증류한 soft target에 KL loss로 0.796이에요.

pip install opendecider
# L40S에서 질문당 17ms, M4 Max에서 18ms, 배치로 약 9ms

coverage 50%에서는 selective accuracy가 0.943까지 올라가고 Jev는 0.882예요. 종이 위에서는 둘 다 잡은 거예요. 오픈, 캘리브레이션, 빠름.

현실에서는요. nano 로드에 RAM 2GB, 17ms 뽑으려면 GPU 필수, 피싱은 0.63 대 Jev 0.90 (OOD에서 털려요), 그리고 Laya처럼 벤치마크 train에서 학습했어요. test는 안 봤지만 도메인은 본 거죠.

힘싸움은 이겼어요. 그래도 뒤에 GPU 달린 400M이에요. Cloudflare Worker나 노트북 게임 루프에 갖다 박을 물건은 아니에요.

150M급, 그리고 그 아래 빈 공간

openJev-verdict-2.0 (151M), julia-1 (144M 다국어), Open Jev (150M), Largha-150M (144M en+zh), Vela (0.3B-9B). 다 ModernBERT나 mmBERT-small, 다 CPU에서 돌아가고, 학습도 만만해요 (Open Jev는 Colab T4로 30분이라 그래요). 숫자는 self-reported에 프로토콜도 제각각이라 조심해서 봐야 하지만, 이 가족은 존재해요.

제너럴리스트 Sabaki는 22.7M이에요. 이 라인에서 제일 작은 애보다 한 6.5배 작아요. 런타임에 정의된 질문에 답하면서 100M 밑으로 내려가는 애는 달리 없어요.

"세상에서 제일 작다"가 그 뜻이에요. 점수 1등이 아니라, instructions + criteria 읽고 스페셜리스트가 모를 때 결정을 내리는 제일 작은 봉투예요.

Sabaki, 멍청한 라우터와 일회용 엑스퍼트들

자, 큰 애들 얘기는 됐고요. 다윗으로 가요. 여기서부터 재밌어져요.

Sabaki는 세상을 이해하는 똑똑한 큰 모델이 되려고 안 해요. 조직형 MoE예요. 시스템 레벨 sparse 라우터 1개, 독립된 엑스퍼트들, 인풋당 1개만 활성화. 각 플러그인은 npm 패키지 하나씩 따로, manifest랑 가중치랑 버전 들고 있어요. 라우팅되는 것만 설치하면 돼요.

org sabaki-js 상황은요. 리포 13개, 코어 sabaki (129 커밋), 플러그인 6개 @sabaki-js/*, 가중치, Cloudflare worker, 직접 만드는 법 템플릿.

코어 package.json이 이미 다 말해줘요.

"dependencies": { "@typesafe-ai/sdk": "^0.7.0" },
"optionalDependencies": { "onnxruntime-node": "^1.22.0" }

nano 모드에서는 런타임 의존성 제로예요. 컴파일된 TypeScript만, 설치할 거 없음, ONNX 버전 사이에 깨질 거 없음.

라우터, 몇 줄로 결정하는 법

라우터는 src/index.ts에 살아 있고, 토크나이저는 이렇게 생겼어요 (src/index.ts:129).

function tokens(s: string): string[] {
  return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .split(/[^a-z0-9]+/).filter((w) => w.length > 3);
}

소문자, 악센트 제거, 4글자 이상 단어만. 이 필터가 노이즈("le", "de", "ok")를 다 날려요. 지원 티켓에서는 매칭이 의미 있는 단어 위주로 걸리니까 판이 바뀌어요.

스코어링 자체는 route() 안에 있어요 (src/index.ts:251). 승부는 여기서 나요.

const hits = kws.filter((k) =>
  words.some((w) => w.includes(k) || k.includes(w))).length;
const score = hits / Math.sqrt(kws.length);

양방향 느슨한 매칭이에요. billing이 billed를 잡고, invoice가 invoicing을 잡아요. 루트로 나누는 건 키워드 리스트 큰 애 패널티 안 주려고요. 안 그러면 키워드 600개짜리 mail 플러그인이 "hello" 들어간 메일마다 다 먹어버려요.

플러그인마다 임계값이 있고, 기본 0.15는 src/index.ts:139에 있어요. mail은 제목이 짧아서 0.03이에요. 그 밑이면요.

return { domain: "unknown", decision: null, confidence: 0, plugin: null };

모르는 도메인은 unknown이지, 기분 좋으라고 찍어내는 guess가 아니에요. 일부러 멍청하게 짠 거고, 나중에 automate > 0.8 스레시홀딩이 쓸만한 이유가 정확히 이거예요. Sabaki가 답하면 진짜 스페셜리스트가 매칭된 거예요.

보너스로 langid 언어 축도 있어요. languages 선언한 플러그인은 자기 언어에서만 경쟁해요. 라틴 피싱이랑 CJK 나누기 좋아요. 텍스트 너무 짧으면 필터 없이 다 붙어요.

총비용은 결정당 몇 마이크로초예요. 곱할 행렬도 없고, 호출할 네트워크도 없고, 문자열 비교 몇 개랑 나눗셈 하나면 끝.

Nano, 리포에서 제일 좋아하는 파일

src/logreg.ts:28의 토크나이저예요.

function tokens(s: string): string[] {
  const words = s.toLowerCase().split(/[^a-z0-9]+/)
    .filter((w) => /^[a-z]{3,}$/.test(w));
  const out = [...words];
  for (let i = 0; i < words.length - 1; i++)
    out.push(`${words[i]} ${words[i + 1]}`);
  return out;
}

유니그램 + 바이그램이에요. charged twice가 charged, twice, 그리고 페어 charged twice로 나와요. 400M짜리 트랜스포머 없이 "이중 청구" 잡는 데 이 정도면 충분해요.

src/logreg.ts:46의 벡터화, TF-IDF가 일하는 자리예요.

const tf = 1 + Math.log(c);  // 서브리니어 TF
const val = tf * w.idf[i]!;  // sklearn식 스무스 IDF

단어 10번째 등장은 처음보다 덜 세고, 희귀 단어가 "please"보다 무거워요. sklearn 레시피 그대로예요. 20개 질문에서 완전 동등성 검증했어요.

다시 읽다가 웃은 주석이 있어요.

// 3000차원 다 훑지 않고 있는 텀(희소)만 건드림
for (const [t, c] of counts) {

티켓마다 3000차원을 다 훑는 대신 그 티켓에 있는 스무 개 단어만 건드려요. 1ms 미만이 나오는 이유가 여기예요. 프로덕션에서는 이런 안 보이는 디테일이 다 바꿔요. 복잡도가 어휘 크기가 아니라 티켓 크기를 따라가거든요.

src/logreg.ts:86의 추론, 세 줄이면 끝이에요.

// 이진: 계수 1줄 + 시그모이드
const pPos = 1 / (1 + Math.exp(-s));
// 다중클래스: 내적 + 소프트맥스, argmax

내적 한 번, 클래스 수에 따라 시그모이드나 소프트맥스 하나. 한 패스면 끝. 생성 토큰 제로라서 뒤에서 retry랑 파싱 휴리스틱으로 고칠 깨진 JSON도 제로예요.

train.py 레시피는요. 워크플로당 train 300개 / test 100개, 결정적 스플릿 (시간 편향 피하려고 5개 중 1개를 test로), 어휘 3000-6000, 계수는 1e-4로 반올림해도 측정 손실 제로. JSON { vocab, idf, questions }로 익스포트.

가중치는 invoices 20KB (duplicate 0.89)부터 mail 650KB (bucket 0.889)까지예요. 프론트엔드 번들에 들어갈 크기죠.

눌러앉게 만든 디테일이 있어요. mail에서는 MiniLM이 TF-IDF랑 +-0.02로 붙어있어요. 그래서 nano 그대로 ship해요. 메일 분류하자고 GPU 돈 쓸 생각 없어요. 반대로 agent_trace는 막혀요 (nano에서 needs_review 0.59). 관측 텍스트가 빈약해서 결정을 먹여줄 단어가 없어요.

가중치를 플러그인으로 바꾸는 공장, src/plugin.ts:52에 있어요.

export function nanoClassifier(manifest, weights, opts = {}) {
  return definePlugin({ manifest,
    classify: (input) => {
      const r = predict(`${from}${input.state}`, weights, q);
      return { decision: r.decision, confidence: r.confidence, domain: manifest.domain };
    }});
}

decide()는 nano라서 동기 유지, decideAsync()는 mini용으로 ONNX를 풀어요. 경로 두 개, 규칙 하나. async 필요하면 mini 원한다는 뜻, 아니면 nano면 돼요. 코드 짤 때 외우기 쉽고, 깨졌을 때 디버그하기 쉬워요.

이론만 떠드는 거 아니라는 거 보여주려고 실제 사용 모습 가져왔어요.

import { Sabaki } from "sabaki";
import customerService from "@sabaki-js/customer-service";

const sabaki = new Sabaki().register(customerService);
sabaki.decide({ state: "charged twice, refund please" });
// { decision: "billing", confidence: 0.31, domain: "customer_service" }

Mini, 어려운 케이스를 바꾸는 22MB

nano 한계는 위에서 agent_trace로 봤죠. MiniLM-L6이 이어받아요. 도메인 전체에서 공유하는 인코더 하나 + 질문별 선형 헤드 하나. 8-12 에폭 end-to-end 파인튜닝, lr 2e-5, 학습은 CUDA GPU에서 (추론은 CPU로 남는 게 포인트예요).

판가름 낸 숫자, 같은 스플릿에서 잰 거예요.

  • 인코더 고정 + MLP : 0.65
  • 풀 파인튜닝 : 0.68
  • 400M에서 고정 임베딩으로 증류 : 테스트해보고 버림, gold label보다 못함

dynamo로 ONNX 익스포트, 완전 동등성 검증, 그 다음 동적 int8 양자화. 90MB에서 22MB로, customer_service에서 accuracy -0.004예요. ECE는 그대로거나 더 좋아요. 각 익스포트는 다시 측정해요. 안 그러면 최적화가 무슨 의미인지 모르거든요.

1.0.2부터 가중치는 패키지 안에 들어있어요 (models/<workflow>-int8/ + modelDir 익스포트). npm i면 끝, 비행기 모드 OK. 업데이트는 trusted publishing으로 서명한 패치 발행이에요.

구체적으로요. needs_review가 0.59에서 0.82로 가요. disposition은 0.73에서 0.76으로. ECE는 전부 0.14 미만, OOD에서는 건강한 의심이 담긴 (dragons, 레시피, 빈 문자열에 0.2-0.5). 이 의심 덕분에 프로덕션에서 공격적인 스레시홀딩이 가능해요.

Generalist, Sabaki를 진짜 System One으로 만드는 catch-all

Jev랑 다른 애들은 뭐든 답해요. 어떤 질문, 어떤 옵션이든 런타임에 정의하면 돼요. Jev를 바이럴 나게 한 System One 컨트랙트가 그거예요. instructions + criteria랑 옵션 주면 잘라줘요.

Sabaki 스페셜리스트는 그걸 못 해요. 자기 질문(category, duplicate, bucket)만 알고 그 외는 몰라요. 망이 없으면 스크립트 벗어나는 순간 Sabaki는 unknown을 뱉고, Jev는 뭐라도 답해요.

generalist가 그 망이에요. 질문 + 옵션 + state를 읽고 점수를 내는 크로스 인코더, 옵션당 한 패스, softmax, argmax. 22.7M MiniLM int8이라 그 라인 144-151M보다 6.5배 작아요. 근데 얘 일은 점수가 아니라 호환성이에요. fallback으로 물려두면 POST /v1/systemone이 Jev처럼 어떤 state, 어떤 질문이든 받아요. model 필드가 진짜 답한 애를 말해줘요 (@sabaki-js/[email protected]+mini, sabaki-generalist-v1...). 호출 쪽에서는 바꿀 게 없어요.

그리고 중요한 건, 얘는 fallback이지 보스가 아니에요. decideAsync()의 철칙은요. 스페셜리스트가 라우팅을 이기면 걔가 답해요. generalist는 unknown일 때만 말해요. 반대는 절대 없어요. 그래야 자기 도메인에서 잘하는 스페셜리스트를 평균적인 제너럴리스트가 덮어쓰는 일이 없어요.

로더는 src/generalist.ts에 있고, ONNX 세션을 매번 다시 로드하지 않게 폴더별 캐시를 써요.

export function loadGeneralist(modelDir: string) {
  // 폴더별 캐시, ONNX 세션 재사용
  // tokenizer.json -> Unigram 또는 wordpiece, 자동 감지
  const sess = await ort.InferenceSession.create(
    path.join(modelDir, "generalist.onnx"),
    { intraOpNumThreads: 2 },
  );
}

SABAKI_GENERALIST_DIR을 generalist.onnx + tokenizer/ 들어있는 폴더로 가리키면 돼요. 모델은 교체 가능해요. id는 config.json에서 오고, 없으면 "sabaki-generalist"예요. Map 캐시 덕분에 호출마다 세션을 다시 안 올려요.

입력 포맷은 클래식 크로스 인코더예요. 질문, 옵션이랑 state를 구분자로 이어붙이고, 점수 매길 옵션마다 한 줄씩요.

const texts = options.map(
  (o) => `${input.question ?? "decision"} [SEP] ${o} [SEP] ${input.state}`,
);

질문 + 옵션 + state를 [SEP]으로 나누고 옵션당 한 줄이에요. 옵션은 8개까지 잘라요 (slice(0, 8)). 아무것도 안 주면 기본 ["yes", "no"]예요. 그 이상은 호출 쪽에서 쪼개야 해요. 타입 결정치고 8개면 이미 넓어요.

다음은 얌전해 보여서 그렇지 옵션 수만큼 레이턴시를 나누는 배치 트릭이에요. 옵션당 session.run 한 번 대신 다 묶어서 포워드 한 번으로 보내요.

// 모든 옵션 한 번에 배치 포워드 한 번
// (최대 길이에 패딩) 옵션당 한 번씩 돌리는 대신.
const L = Math.max(...encs.map((e) => e.ids.length));
// ids + attention_mask를 [options.length, L]로, padId로 패딩
const out = await sess.run({ input_ids, attention_mask });
const scores = Array.from(out["score"].data).map(Number);

각 텍스트를 인코딩하고 (tokenizer.json에 따라 BERT식 wordpiece나 SentencePiece unigram), 배치에서 제일 긴 길이에 맞춰 패딩하고, [옵션 수, L] 텐서로 쌓아요. sess.run 한 번에 옵션당 점수 하나씩 받아와요. CPU에서는 이게 다예요. 8배 시간 대신 20-36ms예요.

그 다음은 softmax + argmax, 옵션별 전체 확률이 담긴, Jev가 유행시킨 포맷 그대로예요.

const pr = softmax(scores);
// bi = 최대값 인덱스
const confidence = Math.round(pr[bi] * 1000) / 1000;
const probs = Object.fromEntries(
  options.map((o, i) => [o, Math.round(pr[i] * 1000) / 1000]),
);

프로덕션에서 쓸 수 있게 만드는 디테일은 데모로 안 끝나게 해주는 abstainAt이에요.

if (input.abstainAt !== undefined && confidence < input.abstainAt) {
  return { decision: null, confidence, domain: "generalist", probabilities: probs };
}

Opt-in이고 기본 abstention은 없어요. abstainAt: 0.6-0.7을 넘기면 무른 건 다 guess 대신 null로 돌아와요. 분포 밖에서는 틀리고도 softmax가 1.00을 뱉는 걸 봤으니 사치가 아니에요. GUIDE는 자율 운용에 다 권장해요. 임계값 밑이면 자동 해결 말고 해명을 물어보라고요.

마지막으로 generalist fallback 붙인 같은 구성이에요. 스페셜리스트 매칭이 없으면 unknown 대신 얘가 이어받아요.

import { Sabaki, definePlugin, loadGeneralist } from "sabaki";
import mail from "@sabaki-js/mail";

const backend = loadGeneralist(process.env.SABAKI_GENERALIST_DIR!);
const generalist = definePlugin({
  manifest: { name: "@sabaki-js/generalist", domain: "generalist",
    version: "0.1.0", questions: ["decision"], keywords: [] },
  classify: (input) => backend.decide(input),
});

const sabaki = new Sabaki().register(mail).setFallback(generalist);

await sabaki.decideAsync({
  state: "le client dit que l'app a supprimé ses données",
  question: "c'est urgent ?",
  options: ["yes", "no"],
  abstainAt: 0.65,
});
// 라우팅 맞으면 mail 스페셜리스트, 아니면 Jev식 catch-all로 generalist

generalist 세대들, 다 같은 잣대로 쟀어요. PhishNChips (메일 2000개)랑 비교 가능한 부분은 typed-decisions test에서요.

  • v4 : test 0.29, R=0.026 피싱. 버릴 거예요, 옆으로 답했어요.
  • A (22M 80k) : 0.546, R=0.406. 뭔가 모양이 나오기 시작해요.
  • mini v1 서빙 중 : 0.536, R=0.616. 사이트에서 도는 게 이거예요. 피싱 리콜 괜찮고 test는 중간.
  • B (144M 스크래치) : 0.568, R=0.062. 더 크고 test는 살짝 좋고 피싱은 망.

144M을 스크래치로 키워도 전이가 안 좋다는 게 "파라미터 늘리면 일반화된다"는 생각을 접게 했어요. 같은 태스크에서 큰 애가 돈만 더 들고 더 잘 맞히지도 않아요. catch-all로도 작은 애가 일을 해요. 그리고 EN 전용으로 남아있어요 (all-MiniLM-L6-v2), 라우팅 EN, FR은 분포 밖. FR이면 generalist 말고 그쪽 학습한 스페셜리스트로 가야 해요.

Doom, 저한테 진짜로 중요한 유일한 테스트

doomLaya는 Laya랑 Jev가 FreeDoom을 진짜로 플레이하는 거예요. 모델이 command / target / weapon을 고르고, 실행기가 A* 경로 짜고, 문 열고, 조준하고, 클릭해요. 틱마다 텔레메트리, 영상, 런 SHA. 기여하는 입장이라 멀리서 리더보드 읽고 말하는 게 아니에요.

v0.1.0, MAP01 seed 48, skill 3, 35틱/s, 같은 실행기예요.

모델 결과 p50 HTTP API 비용
Laya stock 실패 180초 106ms $0
Laya v3 적응형 클리어 61.43초 108ms $0
Jev 1.13 클리어 68.97초 357ms $0.0046

두 번 다 깨끗하게 죽지 않고 클리어했고, Laya는 실행기가 픽업을 알아서 해서 가는 길에 샷건도 주웠어요. 정직하게 말하면요. Laya v3는 Doom으로 학습했고 Jev는 아니에요. 시드 하나, 맵 하나로는 일반화 증명이 안 돼요. 프로젝트 README도 그걸 대놓고 말하고, 결과 부풀리기를 안 하는 건 훌륭해요.

Sabaki 쪽은요 (plugin-doom). Python ViZDoom이 매 틱 JS 서버를 호출해요. i5 한 스레드에서 nano는 초당 약 18k 요청, mini는 약 71 요청. 35Hz 틱은 평균 노트북에서도 널널해요.

출구 나침반이 있으면 mini는 6번 중 2-4번 MAP01을 깨고 nano는 0/6이에요. 당연해요. 텍스트로 공간 정보가 날아가고, doomLaya 상태는 MiniLM이든 ModernBERT-base든 한 0.57에서 막혀요. 공간 정보가 없으면 텍스트 분류기가 그걸 발명하지는 못해요.

이웃들이 같은 데이터로 리매치를 했어요. doomgemma는 RTX 4070에서 LoRA 27분 돌리고 Gemma 4 E2B를 게임에서는 Laya v3급(6/6 대 6/6), 분리 결정에서는 위로 올려놨어요. 크기 같으면 디코더도 Laya만큼 빨라요 (270M: 22ms 대 23ms). 결과 같으면 더 작은 모델로 해내는 Laya가 한 3배 빨라요.

제 해석은요. 아키텍처보다 태스크 학습이 세요. 학습한 작은 스페셜리스트가 처음 보는 큰 제너럴리스트를 이겨요. Sabaki 베팅 그대로예요. 뒤따라온 페이퍼 System Switch (arXiv:2610.09683)는 이 논리를 끝까지 밀어요. 빨리 뛰는 작은 모델, 게이트 열리면 큰 추론 모델이 이어받기. Sabaki pre-triage (automate > 0.85 아니면 에스컬레이션)를 게임에 적용한 거예요.

희생한 것들

CPU를 노리면 빠진 기능 하나가 다른 기능 값을 치러요. 게임 규칙이에요. Bareiron에서 C 한 줄이 트레이드오프였듯 정직한 목록이에요.

Sabaki는 이야기를 안 해요. 주는 옵션 중에서 확률이 담긴로 잘라줘요. 설명, 요약, 자유 텍스트는 LLM 가져가세요.

generalist한테 프랑스어는 분포 밖이에요. 라우팅 EN (all-MiniLM-L6-v2), 비슷한 케이스에 FR 1/4 대 EN 3/4 프로브. mail 플러그인은 FR 비중 높은 메일함에서 왔지만 익명화했어요. PII 제로, 개인 용어 없는 어휘라 accuracy -0.024. 익명화 비용은 내지만, 이건 협상 불가였어요.

softmax는 자기 구역 벗어나면 너무 확신해요. 1.00 찍고 틀리는 거 봤어요, 네. 그래서 GUIDE에서 권장하는 pre-triage가 있어요. abstainAt: 0.6-0.7로 임계값 밑은 null로 돌리고, 그 다음 confidence > 0.85 게이트로 자동화하고, 나머지는 사람. 측정한 기준점은 피싱 리콜 0.62에 정밀도 0.87이에요. 줄을 둘로 나누는 용도지 리뷰어 없애는 용도가 아니에요.

nano는 에이전트 traces에서 헤매요. 질문 따라 0.34에서 0.59예요. mini가 needs_review를 0.82까지 올리지만 risk / urgency / action은 여전히 어려워요. 관측 텍스트에 어휘 신호가 빈약해요.

텍스트 Doom은 천장이 있어요. MiniLM이든 ModernBERT-base든 한 0.57. 텍스트에 픽셀 없이 있으면 MAP02가 막아요. 진짜 플레이어로 가는 믿을 길은 vision + RL (픽셀에서 시작하는 1.1M짜리 tiny-doom-defender)이지 더 좋은 텍스트 분류기가 아니에요.

벤치마크는 배치로 드리프트해요. typed-decisions는 배치 단위로 생성돼서 시간 스플릿이 사실은 배치 스플릿이에요. drift_tool.py가 customer_service에서 0.82, agent_trace에서 0.67을 뱉어요 (0.5가 구분 불가). 측정한 결과는요. 공개 가중치가 공식 스플릿 0.61 대신 믹스에서 0.733이에요. 공식 숫자는 보수적인 거예요. Platt / temperature+bias 캘리브레이션은 배치 사이 전이가 안 돼서 최근 배치로 맞춘 temperature scaling만 ship해요. 사내 규칙은요. drift_tool.py 먼저, 0.6 넘으면 경고, 0.8 넘으면 블록. 남들은 아무도 공개 안 하지만 다 해야 해요.

최종 결과, 숫자 놓고 보면

한쪽은 노트북 i5, 다른 쪽은 호출당 과금 API. 각 모드가 실제로 내는 건 이거예요.

  • nano : 1ms 미만, 초당 약 18k 요청, 20-650KB, 의존성 0개
  • mini : 약 20ms, 초당 약 71 요청, 22MB int8, onnxruntime은 선택
  • generalist : 20-36ms, 22.7M, 제가 아는 제일 작은 오픈 제너럴리스트

도메인 여러 개 올리면요. 전부 올리는 2GB 대신 도메인당 lazy-load로 약 0.5GB RAM. 결정적이에요. 같은 인풋 100개면 같은 아웃풋 100개. 질문별 임계값, confidence 게이트, 매칭 없으면 unknown.

Bareiron에서는 플레이어 하나가 200ms 청크를 먹어서 3명 넘게 돌리면 버벅였어요. 여기서는 도메인 3개 넘게 올려도 끄떡없어요. 엑스퍼트가 다 작고 필요할 때 로드되니까 한 번에 다 올리는 게 아니거든요. "작은 게 충분하다"는 같은 철학, 다른 하드웨어에 적용한 거예요.

철학

p2r3가 말했어요. "1달러짜리 조그만 칩에서 Minecraft 같은 게 돌아간다는 생각 자체가 좋아요."

같은 느낌이에요. GPU 호출을 20KB JSON이랑 TypeScript 백 줄로 대신한다는 생각이 좋아요. 물론 다 대신하지는 않아요, 장난 아니고요. 대신하는 건 로컬에서 밀리초 안에 라벨 하나, 손으로 기도하는 대신 그럭저럭 임계값 걸 수 있는 신뢰도로 받고 싶을 때 그 짜증나는 케이스예요.

트레이드오프, 까놓고 쓰면요.

  • 인코더에서 단어 세기로 : 7점 내주고, 레이턴시 1000분의 1, 인프라 제로
  • 큰 모델 하나에서 npm 플러그인들로 : 게이트는 투박해지고, 운영은 무한히 쉬워짐
  • 생성에서 타입 결정으로 : 깨지는 파싱이랑 날조 옵션이 끝남
  • 날 신뢰도에서 pre-triage로 : 먼저 abstain, 0.85 넘기면 자동화, 나머지는 사람

기억할 3가지예요.

  1. 7점 내주고 1000배 빠름 : 0.75-0.80 대비 0.61/0.69지만, CPU 25초나 유료 API 대비 오프라인 1ms 미만/20ms. 비용 없는 점수는 아무 의미 없어요.
  2. 단순 라우터 + 분리된 엑스퍼트 : hits / sqrt(keywords), 임계값, unknown. 플러그인마다 따로 학습하고 버전 붙이고 ship해요. mail에서는 TF-IDF가 MiniLM에 붙어요. 여기서는 GPU가 필요 없어요.
  3. 캘리브레이션 정직한 오프라인 우선 : npm i, 내장 가중치, GPU 0개, ECE 0.14 미만, abstain, drift tool. 이 비교표에서 GPU 없이 35Hz 게임 루프 버티는 스택은 이거 하나예요.

프로젝트 관심 있으면 MIT로 다 열려있어요. github.com/sabaki-js요. 측정값이랑 bibtex 딸린 학술 버전은 preprint v1이고요. 골리앗이 Doom 하는 거 직접 보고 싶으면 azalio/doomLaya예요 xD

NEW

Sabaki vs System One: Davut Golyat'a karşı, dünyanın en küçük karar modeli

20KB'ye karşı 421M parametre. Sabaki typed-decisions'ta 7 puan bırakıyor ama 25sn CPU süresinden 1ms altı offline hıza iniyor. Örgütsel bir MoE'nin anatomisi.

Giriş

Eylül ayından beri Hacker News ya da Twitter'da takılıyorsan Jev'i görmemen imkansız.

15 Eylül 2026'da TypeSafe Jev'i çıkardı, ilk "System One"ları. Pitch tek cümle: bir label seçmek için LLM'e JSON ürettirmeyi bırakın. Ona bir state ver, üstüne tipli sorular ver, sana tipli kararları probalarıyla versin. Tek pass, sample edilecek sıfır token, arkada parse edilecek hiçbir şey yok.

Patladı gitti. Vercel AI Gateway'de ilk gün rekor benimseme, birkaç günde beş Elixir müşterisi, herkes Jev'i kendi şeyiyle benchmarkliyor. Ve açık konuşayım hype anlaşılır. Yıllardır SADECE {"choice": ...} ile cevap ver promptlarıyla dua ediyorduk, model bir seçenek uydurmasın, JSON'u bozmasın, alan halüsinasyonu yapmasın diye. Jev bu ihtiyaca isim koydu ve temiz bir API olarak ship etti: POST /v1/systemone, choice / score / noul sorular, cevapta probabilities + confidence.

Fikre bayıldım. Eko modeli daha az sevdim, ve net olayım, Jev'e hiç para ödemedim.

Çünkü arkada Jev kapalı bir API. Ağırlıklar bilinmiyor, self-host yok, her karar eşittir bir network round-trip artı dönen bir sayaç. Sonra gelen açık alternatifler de 400M parametre bandında ve zorunlu GPU istiyor.

O yüzden ben diğer ucu inşa ettim: Sabaki. Keyword router artı saf TypeScript ile yazılmış bir lojistik regresyon, Jev ile aynı kontratı konuşuyor (aynı choice / score / noul shape'leri, serve() ile sunulan aynı /v1/systemone endpoint'i), ama 1ms altında dönüyor, 20 kilobayt JSON ile. Nano modda sıfır bağımlılık, offline, 0€.

Ölçek için: Laya 843 MB safetensors yükletiyor. OpenDecider-nano 2 GB RAM istiyor. Sabaki nano invoices 20 KB. Ağırlık oranı 40.000 civarı.

Kareli, rakamlı versiyonu istersen sitede Ekim 2026 preprint v1 var: Sabaki: An Organizational Mixture-of-Experts for Typed Decisions on CPU (PDF). Tasarım, ölçümler, tradeoff'lar, hepsi içinde. Bu yazı okunabilir versiyon, yanında açık kodla.

Bunu köşemde kağıt-benchmark modunda yazmadım. azalio/doomLaya'ya katkı yapıyorum (benim fork: fox3000foxy/doomLaya), Laya ve Jev'in model-owned kararlarla, tick başına telemetriyle ve videolarla gerçekten FreeDoom oynadığı bir harness. Golyat'ı sahada oynarken gördüm, network öksürünce o da öksürüyor.

Motorları tek tek açalım. Her zamanki gibi, gerçek kodla.

Herkesin dövüşmeyi kabul ettiği ring

Jev, Laya ve OpenDecider'ın üçünün de alıntıladığı tek bir dataset var: LocalLLaMA/typed-decisions. 4 sentetik workflow (destek, faturalar, güvenlik, ajan izleri), vaka başına 5 soru, 1200 train vakası, 400 test, 2000 karar.

Model Params Test skoru Gecikme Nerede çalışıyor
OpenDecider-nano ~400M 0.796 17ms GPU GPU, 2 Go RAM
Laya typed-decisions 421M 0.766 ~25s/state CPU, ~830ms/vaka MLX GPU
Jev 1.13 kapalı ~0.73-0.75 ~710ms API sadece API
Sabaki mini 22M int8 0.69 ~20ms CPU CPU, 22 Mo
Sabaki nano ~0 0.61 <1ms her yerde
Laya base zero-shot 421M 0.362 aynı GPU

Şans 0.318, çoğunluk 0.461. Yani fine-tune'suz 0.362'deki Laya base, "kibar kibar rastgele sallıyorum" seviyesi. Bunu aklında tut, lazım olacak.

Sabaki ile en iyi arasında 7 puan var. GPU'n yoksa diğer yönde 1000x gecikme farkı var. Bütün yazı iki cümlede bu, gerisi nedenini anlamak için.

Yine de dikkat: aynı choice / score / noul prizinin arkasında mutfaklar bambaşka. Jev eşittir kapalı API. Laya eşittir fine-tune'lu ModernBERT-large. OpenDecider eşittir distile Ettin-encoder. Sabaki eşittir kelime sayma artı int8 MiniLM. Aynı kontrat, kıyaslanamaz bütçeler.

Jev, herkesin gördüğü tokat

Hakkını verelim, TypeSafe'e ait olan TypeSafe'in: System One kontratı gerçekten iyi bir fikir.

Eskiden bir ticket'ı ayıklamak için şunu yapıyordun: LLM'e "billing, teknik ve spam arasından seç, JSON cevap ver" diyordun, cevabı parse ediyordun, "Bence billing! 😊" diye cevap verdiği ya da 4. kategori uydurduğu durumlarla uğraşıyordun. Her soru token yakıyordu, her cevap pipeline'ını patlatabilirdi.

Jev anlaşmayı değiştirdi: metni artı kriterli soruları yolluyorsun, her soruyu paralel değerlendirip sana tipli bir değer ve dağılımıyla dönüyor.

curl https://api.typesafe.ai/v1/systemone \
 -H "Authorization: Bearer $KEY" \
 -H 'content-type: application/json' \
 -d '{"state":"charge double ce mois-ci","questions":{"dept":{"type":"choice","instructions":"Route ce message.","criteria":{"billing":"paiements","technical":"bugs"}}}}}'

Cevap: { choice, probabilities, confidence }. Serbest metin yok. Kodun üstüne direkt dallanabiliyor.

Viral olmasının sebebi bu: Python + JS SDK, Claude Code eklentisi, LangChain entegrasyonu, herkes ajanlarını bir öğleden sonra bağlayabiliyordu. TypeSafe dokümantasyonu sağlıklı pattern'i bile dayatıyor: hızlı küçük yargılar için Jev, gerçek kararı kompoze etmek için etrafında deterministik kod, güven düşükse yavaş LLM ya da insan.

Sorun geri kalanı. Ağırlıklar kapalı, offline imkansız, call başına fiyat. Run başına 100 karar alan bir ajan için vergi acıtıyor. 35 tick/s ile Doom için zaten ölü: doomLaya'da Jev MAP01 seed 48'i 68.97sn'de çıkıyor, p50 HTTP 357ms. Run 0.0046$ API maliyeti raporluyor, bu benim faturam değil, benchmark'ın faturası. Çalışıyor, run temiz, ölüm yok. Ama 35 tick/s'de 357ms, sen network beklerken oyunun 12 tick ilerlemesi demek.

Sabaki tam olarak bu kontratı çaldı, aynı sorular, aynı cevaplar, serve() ile sunulan aynı /v1/systemone endpoint'i, ama datacenter isteyen her şeyi çöpe attı.

Laya, APIsiz de olacağını gösteren koca açık model

Convai Innovations'tan Laya, Apache-2.0. Kaputun altında: ModernBERT-large, 421M, seçenek başına 1 tane [MASK] token. Her işaretçinin hidden state'i küçük bir MLP'den geçiyor, sorunun seçenekleri üstünde softmax. Jev gibi soru başına tek pass.

pipe = pipeline("text-classification", model="convaiinnovations/laya-typed-decisions")

0.766 üstünde durmayı hak ediyor. Bu checkpoint benchmark train'ine fine-tune'lu: 1200 vaka, logitler üstünde gauss gürültülü RLCD, log + spherical reward. Aynı model bu fit olmadan 0.362'ye düşüyor. Yani resmen sallıyor. O yüzden Laya skoru "sihirli koca encoder" değil, "koca encoder artı domain üstünde eğitim" demek.

CPU'da resmen külçe. Ölçülen ONNX portu state başına 25sn civarı. MLX FP16'da vaka başına ortalama 830ms, p99 5.3sn say. Ve bir şey cevaplamadan önce 843 MB yüklemen lazım.

doomLaya'da stok Laya MAP01'de 180sn'de patlıyor. Uyarlanmış v3 (azalio/laya-doom-v3), Doom üstünde yeniden eğitilmiş, MPS'te p50 108ms ile 61.43sn'de çıkıyor, arada shotgun'ı da kapıyor. Yani evet oynuyor, ama özel eğitimden sonra, Apple GPU üstünde, gerçek işi yapan scriptli bir executor ile (A*, 15 tick latch'li kapılar, oto aim).

Özetim: kendi task'inde eğitmeden Laya sana 421M'lık pano görevi görüyor. Eğitirsen çok güçlü, ama GPU ve ona uygun datalar lazım.

OpenDecider, leaderboard'u kazanan

OpenDecider, Manjunath'ın herkesi kendi benchmark'ında avlamaya karar vermesi. Sonuç: full fine-tune Ettin-encoder-400m artı MLP kafa ile 0.796, Qwen3-235B + DeepSeek'ten distile soft target'lar üstünde KL loss.

pip install opendecider
# L40S'te 17ms/soru, M4 Max'te 18ms, batch'li ~9ms

%50 coverage'da selective accuracy Jev'in 0.882'sine karşı 0.943'e çıkıyor. Kağıt üstünde iki dünyanın en iyisi: açık, kalibre, hızlı.

Pratikte: nano'yu yüklemek için 2 GB RAM, 17ms'yi tutmak için zorunlu GPU, phishing'de Jev'in 0.90'ına karşı 0.63 (OOD onu dağıtıyor), ve Laya gibi benchmark train'inde eğitimli. Test hiç görülmedi ama domain görüldü.

Bilek güreşini kazanıyor ama arkada yine GPU'lu 400M var. Bunu Cloudflare Worker'a ya da laptopta oyun döngüsüne sokamazsın.

150M'ler ve altındaki boşluk

openJev-verdict-2.0 (151M), julia-1 (144M çok dilli), Open Jev (150M), Largha-150M (144M en+zh), Vela (0.3B-9B). Hepsi ModernBERT ya da mmBERT-small, hepsi CPU'da dönebiliyor, hepsi eğitmesi keyifli (Open Jev Colab T4'te 30dk diyor). Rakamlar self-reported, protokoller farklı, o yüzden cımbızla al, ama aile mevcut.

Generalist Sabaki 22.7M. Bu tayfanın en küçüğünden yaklaşık 6.5x daha küçük. Runtime'da tanımlı sorulara cevap verip 100M altına inen başka kimse yok.

"dünyanın en küçüğü" bu demek: en iyi skor değil, senin instructions + criteria metnini okuyup hiçbir uzman cevap veremeyince kararı veren en küçük zarf.

Sabaki, salak router ve atılabilir uzmanları

Tamam, büyüklerden bu kadar. Davut'a geçelim, çünkü işin eğlencesi burada başlıyor.

Sabaki dünyayı anlayan akıllı koca bir model olmaya çalışmıyor. Örgütsel bir MoE: sistem seviyesinde 1 sparse router, bağımsız uzmanlar, input başına tek aktif. Her plugin ayrı bir npm paketi, manifest'i, ağırlıkları, versiyonu ile. Sadece route ettiğin şeyi kuruyorsun.

sabaki-js org'u: 13 repo, core sabaki (129 commit), 6 @sabaki-js/* plugin'i, ağırlıklar, Cloudflare worker, kendi plugin'ini yazman için template.

Core'un package.json'ı hikayeyi zaten anlatıyor:

"dependencies": { "@typesafe-ai/sdk": "^0.7.0" },
"optionalDependencies": { "onnxruntime-node": "^1.22.0" }

Nano modda sıfır runtime bağımlılığı. Sadece derlenmiş TypeScript, kurulacak bir şey yok, iki ONNX versiyonu arasında bozulabilecek bir şey yok.

Router, ya da birkaç satırda karar vermek

Router src/index.ts içinde yaşıyor, tokenization şuna benziyor (src/index.ts:129):

function tokens(s: string): string[] {
  return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .split(/[^a-z0-9]+/).filter((w) => w.length > 3);
}

Küçük harf, aksanları at, minimum 4 harfli kelimeler. Bu filtre bütün gürültüyü atıyor ("le", "de", "ok"). Destek ticket'ında her şeyi değiştiriyor çünkü eşleşmen anlam taşıyan kelimeler üstünden oluyor.

Skorun kendisi route() içinde (src/index.ts:251), ve her şey burada kopuyor:

const hits = kws.filter((k) =>
  words.some((w) => w.includes(k) || k.includes(w))).length;
const score = hits / Math.sqrt(kws.length);

İki yöne gevşek eşleşme: billing billed'i yakalıyor, invoice invoicing'i yakalıyor. Karekök bölmesi büyük keyword listelerini cezalandırmaman için, yoksa 600 kelimelik mail plugin'i içinde "hello" geçen her mailde herkesi ezerdi.

Her plugin'in kendi eşiği var, varsayılan 0.15 src/index.ts:139 içinde, mail 0.03 çünkü konular kısa. Altında:

return { domain: "unknown", decision: null, confidence: 0, plugin: null };

Bilinmeyen domain eşittir unknown, asla seni mutlu etmek için uydurma tahmin yok. Bilerek salak bir yaklaşım, ve automate > 0.8 eşiklemesini sonra kullanılabilir yapan tam olarak bu: Sabaki cevap veriyorsa gerçekten bir uzman eşleşmiş demektir.

Üstüne langid ile dil ekseni var: languages bildiren bir plugin sadece kendi dillerinde yarışıyor. Latin phishing ile CJK'yi ayırmak için pratik. Metin çok kısaysa filtre yok, herkes yarışıyor.

Toplam maliyet: karar başına birkaç mikrosaniye. Çarpılacak matris yok, aranacak network yok, sadece string karşılaştırma ve bir bölme.

Nano, repo'daki favori dosyam

src/logreg.ts:28, tokenization:

function tokens(s: string): string[] {
  const words = s.toLowerCase().split(/[^a-z0-9]+/)
    .filter((w) => /^[a-z]{3,}$/.test(w));
  const out = [...words];
  for (let i = 0; i < words.length - 1; i++)
    out.push(`${words[i]} ${words[i + 1]}`);
  return out;
}

Unigram artı bigram. charged twice sana charged, twice ve charged twice çiftini veriyor. 400M'lik transformer çıkarmadan "çifte faturalandırma" yakalamak için gereken her şey bu.

src/logreg.ts:46, vektörizasyon, TF-IDF'nin iş yaptığı yer:

const tf = 1 + Math.log(c);  // sublineer TF
const val = tf * w.idf[i]!;  // sklearn tarzı smooth IDF

Bir kelimenin 10. geçişi ilkinden az sayıyor, nadir kelimeler "please"ten ağır basıyor. Birebir sklearn tarifi, 20 soruda tam parite doğrulandı.

Ve tekrar okurken beni güldüren yorum:

// 3000 boyutu taramak yerine sadece geçen terimlere dokun (seyrek).
for (const [t, c] of counts) {

Her ticket'ta 3000 boyut taramak yerine sadece geçen yirmi kelimeye dokunuyorsun. 1ms altında cevap vermesinin sebebi bu. Prod'da her şeyi değiştiren görünmez detaylardan, çünkü karmaşıklık kelime haznesini değil ticket boyutunu takip ediyor.

src/logreg.ts:86, çıkarım, üç satırda bitiyor:

// ikili: 1 satır katsayı + sigmoid
const pPos = 1 / (1 + Math.exp(-s));
// çok sınıflı: nokta çarpım + softmax, argmax

Sınıf sayısına göre bir nokta çarpım, bir sigmoid ya da softmax. Tek pass ve bitti. Üretilen sıfır token, yani arkada retry ve parsing sezgiselleriyle tamir edilecek bozulmuş sıfır JSON.

train.py tarifi: workflow başına 300 train / 100 test, deterministik split (zamansal yanlılığı önlemek için 5'te 1 test), 3000-6000 kelime haznesi, ölçülen sıfır kayıpla 1e-4'e yuvarlanmış katsayılar. JSON export { vocab, idf, questions }.

Ağırlıklar: invoices için 20 KB (duplicate 0.89) mail için 650 KB (bucket 0.889). Frontend bundle'a sığacak boyutlar.

Beni çarpan detay: mail'de MiniLM TF-IDF'yi +-0.02 ile takip ediyor. Yani nano aynen ship ediliyor. Mail ayıklamak için GPU'ya para ödemek bana göre değil. Ama agent_trace takılıyor (nano'da needs_review 0.59): gözlemlenebilirlik metni fakir, hiçbir kelime kararı gerçekten taşımıyor.

Ağırlıkları plugin'e çeviren fabrika, src/plugin.ts:52 içinde:

export function nanoClassifier(manifest, weights, opts = {}) {
  return definePlugin({ manifest,
    classify: (input) => {
      const r = predict(`${from}${input.state}`, weights, q);
      return { decision: r.decision, confidence: r.confidence, domain: manifest.domain };
    }});
}

decide() nano'da senkron kalıyor, decideAsync() mini için ONNX'in kilidini açıyor. İki yol, tek basit kural: async gerekiyorsa mini istiyorsun demek, yoksa nano yeter. Kod yazarken akılda tutması kolay, patlayınca debug etmesi kolay.

Bunun sadece teori olmadığını göstereyim, gerçek kullanım şöyle görünüyor:

import { Sabaki } from "sabaki";
import customerService from "@sabaki-js/customer-service";

const sabaki = new Sabaki().register(customerService);
sabaki.decide({ state: "charged twice, refund please" });
// { decision: "billing", confidence: 0.31, domain: "customer_service" }

Mini, zor vakalarda oyunu değiştiren 22 MB

Nano'nun limitleri var, az üstte agent_trace'te gördün. MiniLM-L6 bayrağı devralıyor: bütün domain için paylaşılan bir encoder, artı soru başına bir lineer kafa. 8-12 epoch end-to-end fine-tune, lr 2e-5, eğitim için CUDA GPU (çıkarım CPU kalıyor, önemli olan bu).

Kararı veren rakamlar, aynı splitlerde ölçüldü:

  • donmuş encoder artı MLP: 0.65
  • full fine-tune: 0.68
  • 400M'den donmuş embedding'lere distilasyon: denendi, çöpe atıldı, gold label'lardan daha kötü

Dynamo ile ONNX export, tam parite doğrulandı, sonra dinamik int8 quant: 90 MB'den 22 MB'ye, customer_service üstünde -0.004 accuracy. ECE aynı ya da daha iyi. Her export yeniden ölçülüyor, yoksa optimizasyon lafta kalır.

1.0.2'den beri ağırlıklar pakete gömülü (models/<workflow>-int8/ artı export edilen modelDir). npm i yetiyor, uçak modu OK. Güncellemek eşittir patch yayınlamak, trusted publishing ile imzalı.

Somut: needs_review 0.59'dan 0.82'ye çıkıyor. disposition 0.73'ten 0.76'ya. Her yerde ECE < 0.14, sağlıklı OOD şüphesiyle (ejderhalar, tarifler ve boş stringlerde 0.2-0.5). Prod'da agresif eşiklemeye izin veren bu şüphe.

Generalist, Sabaki'yi gerçekten System One yapan catch-all

Jev ve diğerleri her şeye cevap veriyor: runtime'da tanımlı her soru, her seçenek. Jev'i viral yapan System One kontratı bu, instructions + criteria ile sorularını ve seçeneklerini koyuyorsun, o kesip atıyor.

Sabaki uzmanları bunu bilmiyor. Kendi sorularını biliyorlar (category, duplicate, bucket) ve başka bir şey bilmiyorlar. Filesiz Sabaki script dışına çıkınca unknown dönerdi, Jev'in hep bir şey cevap verdiği yerde.

Generalist bu file. Soru + seçenek + state okuyup skor basan bir cross-encoder, seçenek başına bir pass, softmax, argmax. 22.7M int8 MiniLM, tayfanın 144-151M'inden 6.5x küçük. Ama işi skor değil, uyumluluk: fallback olarak bağlıyken POST /v1/systemone Jev gibi her state'i ve her soruyu kabul ediyor. model alanı gerçekten kimin cevap verdiğini söylüyor (@sabaki-js/[email protected]+mini, sabaki-generalist-v1...), yani arayan tarafta hiçbir şey değiştirmiyorsun.

Ve en önemlisi, bir şef değil fallback. decideAsync() içinde altın kural: bir uzman routing'i kazanırsa o cevap veriyor. Generalist sadece unknown durumunda konuşuyor. Asla tersi. Böylece vasat bir generalist kendi domain'inde iyi bir uzmanı ezmiyor.

Loader src/generalist.ts içinde, her call'da ONNX session'ı yeniden yüklememek için klasör başına cache ile:

export function loadGeneralist(modelDir: string) {
  // klasör başına cache, ONNX session yeniden kullanılıyor
  // tokenizer.json -> Unigram ya da wordpiece, oto algılanıyor
  const sess = await ort.InferenceSession.create(
    path.join(modelDir, "generalist.onnx"),
    { intraOpNumThreads: 2 },
  );
}

SABAKI_GENERALIST_DIR'i içinde generalist.onnx + tokenizer/ olan bir klasöre doğrultuyorsun, model değiştirilebilir. Id config.json'dan geliyor, yoksa "sabaki-generalist". Map cache her call'da session reload'u önlüyor.

Girdi formatı klasik cross-encoder: soru, seçenek ve state ayraçlarla birleştirilmiş, skorlanacak seçenek başına bir satır:

const texts = options.map(
  (o) => `${input.question ?? "decision"} [SEP] ${o} [SEP] ${input.state}`,
);

Soru + seçenek + state, [SEP] ile ayrılmış, seçenek başına bir satır. Seçenekler 8 ile kaplı (slice(0, 8)), bir şey vermezsen varsayılan ["yes", "no"]. Ötesi arayan tarafta bölünecek, 8 zaten tipli bir karar için geniş.

Sonra akıllı batch geliyor, gösterişsiz durup gecikmeyi seçenek sayısına bölen türden optimizasyon. Seçenek başına bir session.run yerine her şey tek forward'da gidiyor:

// bütün seçenekler için tek batch forward
// (max uzunluğa pad'li), seçenek başına run yerine.
const L = Math.max(...encs.map((e) => e.ids.length));
// [seçenek sayısı, L] içinde ids + attention_mask, padId ile padding
const out = await sess.run({ input_ids, attention_mask });
const scores = Array.from(out["score"].data).map(Number);

Her metin encode ediliyor (BERT tarzı wordpiece ya da tokenizer.json'a göre unigram SentencePiece), batch'in max uzunluğuna pad'leniyor, [seçenek_sayısı, L] tensöründe üst üste konuyor. Tek sess.run, seçenek başına bir skor alıyorsun. CPU'da her şeyi değiştiriyor: 8 katı süre yerine 20-36ms.

Sonra softmax + argmax geliyor, Jev'in popülerleştirdiği formatta her seçenek için tam probalarla:

const pr = softmax(scores);
// bi = max indeksi
const confidence = Math.round(pr[bi] * 1000) / 1000;
const probs = Object.fromEntries(
  options.map((o, i) => [o, Math.round(pr[i] * 1000) / 1000]),
);

Ve bunu demoda bırakmayıp prod'da kullanılabilir yapan detay abstainAt:

if (input.abstainAt !== undefined && confidence < input.abstainAt) {
  return { decision: null, confidence, domain: "generalist", probabilities: probs };
}

Opt-in, varsayılan çekimserlik yok. abstainAt: 0.6-0.7 geçiyorsun ve yumuşak kalan her şey tahmin yerine null dönüyor. Softmax dağılım dışında bile 1.00 basabildiği için bu lüks değil. GUIDE otonom her kullanım için öneriyor: eşiğin altında kalırsan oto çözmek yerine açıklama istiyorsun.

Bitirmek için generalist fallback'li aynı kurulum: hiçbir uzman eşleşmeyince unknown dönmek yerine bayrağı o devralıyor.

import { Sabaki, definePlugin, loadGeneralist } from "sabaki";
import mail from "@sabaki-js/mail";

const backend = loadGeneralist(process.env.SABAKI_GENERALIST_DIR!);
const generalist = definePlugin({
  manifest: { name: "@sabaki-js/generalist", domain: "generalist",
    version: "0.1.0", questions: ["decision"], keywords: [] },
  classify: (input) => backend.decide(input),
});

const sabaki = new Sabaki().register(mail).setFallback(generalist);

await sabaki.decideAsync({
  state: "le client dit que l'app a supprimé ses données",
  question: "c'est urgent ?",
  options: ["yes", "no"],
  abstainAt: 0.65,
});
// routing tutarsa mail uzmanı, yoksa Jev tarzı catch-all olarak generalist

Generalist nesilleri, hepsi aynı şekilde ölçüldü, PhishNChips üstünde (2000 mail) ve kıyaslanabilir olan için typed-decisions testinde:

  • v4: test 0.29, R=0.026 phishing. Çöplük, konuyla alakasız cevap veriyordu.
  • A (22M 80k): 0.546, R=0.406. Bir şeye benzemeye başlıyor.
  • servisteki mini v1: 0.536, R=0.616. Sitede dönen bu. Düzgün phishing recall, vasat test.
  • B (144M scratch): 0.568, R=0.062. Daha koca, testte azıcık iyi, phishing'de sıfır.

Sıfırdan 144M'in daha iyi transfer olmaması "genellemek için parametre basarım" fikrine beni iyi soğuttu. Eşit işte büyük olan daha pahalıya aynı sallamayı yapıyor. Catch-all olarak bile küçük olan işi görüyor. Ve hala EN-only (all-MiniLM-L6-v2), EN routing, FR dağılım dışı. FR için generalist ile değil, üstünde eğitilmiş bir uzmanla geçeceksin.

Doom, benim için gerçekten sayılan tek test

doomLaya, Laya ve Jev'in gerçekten FreeDoom oynaması. Model command / target / weapon seçiyor, executor A* ile yol çiziyor, kapıları açıyor, nişan alıp tıklıyor. Tick başına telemetri, videolar, run SHA'ları. Katkı yapıyorum, yani uzaktan okuduğum bir leaderboard anlatmıyorum.

v0.1.0, MAP01 seed 48, skill 3, 35 tick/s, aynı executor:

Model Sonuç p50 HTTP API maliyeti
Laya stock başarısız 180s 106ms $0
Laya v3 uyarlanmış çıkış 61.43s 108ms $0
Jev 1.13 çıkış 68.97s 357ms $0.0046

Ölmeden iki temiz çıkış, Laya arada shotgun'ı kapıyor çünkü executor pickup'ı tek başına yönetiyor. Dürüst olalım: Laya v3 Doom üstünde eğitildi, Jev eğitilmedi. Tek seed, tek map, genel bir şey kanıtlamaz. Projenin README'si bunu siyah üstüne beyaz yazıyor, sonucu şişirmemesi de onun şerefine.

Sabaki tarafı (plugin-doom): Python'da ViZDoom, her tick'te bir JS sunucusunu çağırıyor. Nano ~18k req/s, mini i5 tek thread'de ~71 req/s. 35Hz tick vasat bir laptopta bile rahat geçiyor.

Çıkış pusulasıyla mini MAP01'i 6'da 2-4 run çıkarıyor, nano 0/6. Mantıklı: metin uzamsal bilgiyi kaybediyor, doomLaya stateleri MiniLM de olsa ModernBERT-base de olsa 0.57 civarına çakılıyor. Uzamsal bilgi eksikken hiçbir metin sınıflandırıcı onu sihirle yaratmıyor.

Komşular maçı eşit datayla tekrarladı: doomgemma RTX 4070'te 27dk'lık bir LoRA çakıyor ve Gemma 4 E2B'yi oyunda Laya v3 seviyesine (6/6'ya 6/6) izole kararlarda üstüne taşıyor. Eşit boyutta decoder Laya kadar hızlı (270M: 22ms'ye 23ms). Eşit sonuçta Laya ~3x hızlı kalıyor çünkü oraya daha küçük modelle varıyor.

Benim okumam: task üstünde eğitim mimariden ağır basıyor. Eğitilmiş küçük uzman, keşfeden koca generalisti dövüyor. Sabaki'nin iddiası birebir bu. Sonraki makale, System Switch (arXiv:2610.09683), mantığı sona götürüyor: oynayan hızlı küçük model, bir kapı açılınca devralan akıl yürüten büyük model. Oyuna uygulanmış Sabaki pre-triage (automate > 0.85 yoksa yükselt).

Feda edilenler

Eksik her özellik bir başkasını finanse ediyor, CPU hedeflerken oyunun kuralı bu. Bareiron'da her C satırı bir tradeoff'tu, burada da dürüst liste:

Sabaki hikaye anlatmıyor. Verdiğin seçenekler arasından probasıyla kesip atıyor. Açıklama, özet, serbest metin için bir LLM al.

Generalist için Fransızca hala dağılım dışı. EN routing (all-MiniLM-L6-v2), benzer vakalarda 3/4 EN'e karşı 1/4 FR sonda. Mail plugin'i çoğunluğu FR bir kutudan ama anonimleştirilmiş geliyor: 0 PII, kişisel terimsiz vocab için -0.024 accuracy. Anonimleştirme paralı, ama pazarlık dışıydı.

Softmax bölgesinden çıkınca fazla özgüvenli. 1.00 basan yanlışlar görüldü, evet. O yüzden GUIDE'daki pre-triage öneriliyor: eşik altı null için abstainAt: 0.6-0.7, sonra otomatik için confidence > 0.85 kapısı, kalanı insan. Ölçülmüş referans: 0.87 kesinlikte 0.62 phishing recall. Kuyruğu ikiye bölersin, reviewer'ları kovamazsın.

Nano ajan izlerinde zorlanıyor. Soruya göre 0.34 ile 0.59. Mini needs_review'u 0.82'ye çıkarıyor ama risk / urgency / action zor kalıyor. Gözlemlenebilirlik metni leksik sinyal olarak zaten fakir.

Metinle Doom tıkanıyor. MiniLM de ModernBERT-base de 0.57 civarı. Piksel olmadan metinde kaldığın sürece MAP02 seni kilitliyor. Gerçek bir oyuncu için kredibil yol vision + RL (piksellerden 1.1M parametreli tiny-doom-defender), daha iyi bir metin sınıflandırıcı değil.

Benchmark batch'lerle kayıyor. typed-decisions desteler halinde üretiliyor, zamansal split aslında batch split'i. drift_tool.py customer_service'te 0.82, agent_trace'te 0.67 basıyor (0.5 eşittir ayırt edilemez). Ölçülmüş sonuç: yayınlanan ağırlıklar resmi split'te 0.61'e karşı karışıkta 0.733 yapıyor. Yani resmi rakam muhafazakar. Platt / temperature+bias kalibrasyonu batch'ler arası transfer olmuyor, o yüzden sadece güncel batch'lerde kalibreli temperature scaling ship ediyoruz. Ev kuralı: önce drift_tool.py, 0.6 üstü uyar, 0.8 üstü blokla. Bunu başka kimse yayınlamıyor, herkes yayınlamalı.

Final sonuç, rakamlarla

Bir yanda laptop i5'i, diğer yanda call başına fatura kesen bir API. Her mod somut olarak şunu veriyor:

  • nano: <1ms, ~18k req/s, 20-650 KB, 0 bağımlılık
  • mini: ~20ms, ~71 req/s, 22 MB int8, opsiyonel onnxruntime
  • generalist: 20-36ms, 22.7M, bildiğim en küçük açık generalist

Yüklü birkaç domain: hepsi için ~2 GB yerine domain başına lazy-load ile ~0.5 GB RAM. Deterministik: 100/100 aynı input aynı output veriyor. Soru başına eşikler, confidence üstünde kapı, hiçbir şey eşleşmeyince unknown.

Bareiron'da 3+ oyuncu çalıştırmak kasıyordu çünkü her oyuncu 200ms'lik bir chunk'a mal oluyordu. Burada 3+ domain çalıştırmak tık demiyor, çünkü her uzman minicik ve hepsini bir anda yüklemek yerine istek üstüne yükleniyor. "Yeten küçük" felsefesi, başka donanıma uygulanmış hali.

Felsefe

p2r3 demişti: "Şu 1 dolarlık minicik çipin Minecraft gibi gelişmiş bir şeyi çalıştırabilmesi fikrini seviyorum."

Burada da aynı kafa: 20 KB JSON ve yüz satır TS'in bir label için GPU çağrısının yerini alması fikrini seviyorum. Belli ki her şeyin yerini almıyor, saçmalamayalım. Sadece milisaniyede, lokalde, parmak çaprazlamak yerine makulce eşikleyebildiğin bir güvenle bir label istediğin gıcık durumun yerini alıyor.

Tradeoff'lar, siyah üstüne beyaz:

  • encoder yerine kelime sayma: 7 puan eksik, 1000'de 1 gecikme, sıfır infra
  • koca tek model yerine npm plugin'leri: daha kaba kapı, sonsuz basit ops
  • üretim yerine tipli karar: bozulan parsing ve uydurulan seçenekler bitti
  • ham güven yerine pre-triage: önce çekimser kal, 0.85 üstü otomatikleştir, kalanı insan

Aklında kalacak 3 şey:

  1. 7 puan eksik, 1000x hızlı - 0.75-0.80'e karşı 0.61/0.69, ama 25sn CPU ya da paralı API'ye karşı <1ms/20ms offline CPU. Maliyetsiz skor bir şey anlatmaz.
  2. Basit router artı ayrı uzmanlar - hits / sqrt(keywords), eşik, unknown. Her plugin tek başına eğitiliyor, versiyonlanıyor, ship ediliyor. Mail'de TF-IDF MiniLM'e yapışıyor: GPU burada çöp.
  3. Kalibrasyonu dürüst offline-first - npm i, gömülü ağırlıklar, 0 GPU, ECE < 0.14, çekimserlik, drift tool. Karşılaştırmada GPU'suz 35Hz oyun döngüsünü tutan tek stack.

Proje ilgini çekerse hepsi MIT ile github.com/sabaki-js adresinde. Ölçümlü ve bibtex'li akademik versiyon preprint v1. Golyat'ı Doom oynarken gerçekten görmek istersen azalio/doomLaya xD

NEW

Sabaki vs System One: Davide contro Golia, il più piccolo modello decisionale al mondo

20KB contro 421M parametri. Sabaki perde 7 punti su typed-decisions ma passa da 25s CPU a meno di 1ms offline. Anatomia di un MoE organizzativo.

Introduzione

Se bazzichi Hacker News o Twitter da settembre, hai visto Jev per forza.

Il 15 settembre 2026, TypeSafe tira fuori Jev, il loro primo "System One". Il pitch sta in una frase: smettila di chiedere a un LLM di generare JSON per scegliere una label. Gli passi uno state più domande tipizzate, lui ti restituisce decisioni tipizzate con probabilità. Un passaggio, zero token da campionare, niente da parsare dopo.

È esploso. Primo giorno da record di adozione su Vercel AI Gateway, cinque client Elixir in pochi giorni, tutti a benchmarkare Jev contro la loro roba. E onestamente l'hype si capisce. Erano anni che rattoppavamo prompt tipo rispondi SOLO con {"choice": ...} pregando che il modello non inventasse un'opzione, non rompesse il JSON, non allucinasse un campo. Jev ha dato un nome a questo bisogno e lo ha shippato in un'API pulita: POST /v1/systemone, domande choice / score / noul, risposte con probabilities + confidence.

Ho adorato l'idea. Il modello economico, meno -- e per essere chiaro, non ho mai pagato per Jev.

Perché dietro, Jev resta un'API chiusa. Pesi sconosciuti, niente self-host, ogni decisione = un round-trip di rete + un contatore che gira. E le alternative open arrivate dopo viaggiano sui 400M di parametri con GPU obbligatoria.

Allora ho costruito l'altro estremo: Sabaki. Un router a keyword + una regressione logistica in TypeScript puro che parla lo stesso contratto di Jev (stesse shape choice / score / noul, stesso endpoint /v1/systemone), ma gira in meno di 1ms con 20 kilobyte di JSON. Zero dipendenze in modalità nano, offline, 0€.

Per capirci: Laya sono 843 MB di safetensors da caricare. OpenDecider-nano sono 2 GB di RAM. Sabaki nano invoices sono 20 KB. Il rapporto di peso sta intorno a 40.000.

Se vuoi la versione quadrata e piena di numeri, c'è il preprint v1 di ottobre 2026 sul sito: Sabaki: An Organizational Mixture-of-Experts for Typed Decisions on CPU (PDF). Design, misure, tradeoff, c'è tutto. Questo articolo è la versione leggibile, con il codice aperto a fianco.

Non l'ho scritto nel mio angolo in modalità benchmark di carta. Contribuisco a azalio/doomLaya (il mio fork: fox3000foxy/doomLaya), un harness dove Laya e Jev giocano davvero a FreeDoom con decisioni model-owned, telemetria per tick e video. Ho visto Golia giocare dal vivo, e appena la rete tossisce, tossisce anche lui.

Apriamo i motori uno a uno. Come al solito, con il codice vero.

Il ring dove tutti accettano di combattere

C'è un solo dataset che Jev, Laya e OpenDecider citano tutti: LocalLLaMA/typed-decisions. 4 workflow sintetici (supporto, fatture, sicurezza, tracce di agenti), 5 domande per caso, 1200 casi di train, 400 di test, 2000 decisioni.

Modello Params Punteggio test Latenza Dove gira
OpenDecider-nano ~400M 0.796 17ms GPU GPU, 2 GB di RAM
Laya typed-decisions 421M 0.766 ~25s/state CPU, ~830ms/caso MLX GPU
Jev 1.13 chiuso ~0.73-0.75 ~710ms API API only
Sabaki mini 22M int8 0.69 ~20ms CPU CPU, 22 MB
Sabaki nano ~0 0.61 <1ms ovunque
Laya base zero-shot 421M 0.362 uguale GPU

Il caso è 0.318, la maggioranza 0.461. Quindi Laya base a 0.362 senza fine-tune è il livello "rispondo a caso ma con educazione". Tienilo a mente, servirà.

7 punti separano Sabaki dal migliore. 1000x di latenza li separano nell'altro senso quando non hai una GPU. Ecco tutto l'articolo in due frasi, il resto serve a capire perché.

Attenzione però: dietro la stessa presa choice / score / noul, le cucine non c'entrano niente tra loro. Jev = API chiusa. Laya = ModernBERT-large fine-tunato. OpenDecider = Ettin-encoder distillato. Sabaki = conteggio di parole + MiniLM int8. Stesso contratto, budget incomparabili.

Jev, lo schiaffo che hanno visto tutti

Bisogna dare a TypeSafe quel che è di TypeSafe: il contratto System One è davvero una bella idea.

Prima, per smistare un ticket, facevi così: chiedevi a un LLM "scegli tra billing, tecnico e spam, rispondi in JSON", parsavi la risposta, gestivi i casi in cui risponde "Direi billing! 😊" o inventa una quarta categoria. Ogni domanda costava token, ogni risposta poteva rompere la tua pipeline.

Jev cambia il deal: mandi il testo + domande con i loro criteri, lui valuta ogni domanda in parallelo e ti restituisce un valore tipizzato con la sua distribuzione.

curl https://api.typesafe.ai/v1/systemone \
 -H "Authorization: Bearer $KEY" \
 -H 'content-type: application/json' \
 -d '{"state":"charge double ce mois-ci","questions":{"dept":{"type":"choice","instructions":"Route ce message.","criteria":{"billing":"paiements","technical":"bugs"}}}}}'

Risposta: { choice, probabilities, confidence }. Niente testo libero. Il tuo codice ci si attacca sopra diretto.

Ed è per questo che è diventato virale: SDK Python + JS, plugin Claude Code, integrazione LangChain, tutti potevano attaccarci i loro agenti in un pomeriggio. La doc TypeSafe spinge pure il pattern sano: Jev per i piccoli giudizi rapidi, codice deterministico intorno per comporre la vera decisione, LLM lento o umano appena la confidenza è bassa.

Il problema è il resto. Pesi chiusi, offline impossibile, prezzo a chiamata. Per un agente che prende 100 decisioni per run, la tassa punge. Per Doom a 35 tick/s è morto: in doomLaya, Jev esce da MAP01 seed 48 in 68.97s con un p50 HTTP a 357ms. Il run riporta $0.0046 di costo API -- non la mia fattura, quella del benchmark. Funziona, il run è pulito, senza morti. Ma 357ms a 35 tick/s fanno 12 tick in cui il gioco va avanti mentre tu aspetti la rete.

Sabaki ha rubato esattamente questo contratto -- stesse domande, stesse risposte, stesso endpoint /v1/systemone servito da serve() -- buttando via tutto quello che chiede un datacenter.

Laya, il gigante open che ha mostrato che si poteva fare senza API

Laya di Convai Innovations, Apache-2.0. Sotto il cofano: ModernBERT-large, 421M, con 1 token [MASK] per opzione. L'hidden state di ogni marcatore passa in un piccolo MLP, softmax sulle opzioni della domanda. Un passaggio per domanda, come Jev.

pipe = pipeline("text-classification", model="convaiinnovations/laya-typed-decisions")

Lo 0.766 merita che ci fermiamo un attimo. È il checkpoint fine-tunato sul train del benchmark: 1200 casi, RLCD con rumore gaussiano sui logit, reward log + spherical. Lo stesso modello senza quel fit crolla a 0.362. Praticamente tira a indovinare. Quindi il punteggio Laya è "grande encoder + allenamento sul dominio", non "grande encoder magico".

Su CPU è un mattone. Il port ONNX misurato gira intorno ai 25s per state. In MLX FP16, conta 830ms per caso in media con un p99 a 5.3s. E devi caricare 843 MB prima di rispondere a qualsiasi cosa.

In doomLaya, Laya stock fallisce MAP01 in 180s. La v3 adattata (azalio/laya-doom-v3), ri-allenata su Doom, esce in 61.43s con p50 108ms su MPS, raccogliendo lo shotgun al passaggio. Quindi sì, gioca -- dopo allenamento dedicato, su GPU Apple, con un esecutore scriptato che fa il vero lavoro (A*, porte con latch 15 tick, mira automatica).

Il mio riassunto: senza allenamento sul tuo task, Laya ti serve da fermacarte da 421M. Con quello, è molto forte, ma servono la GPU e i dati che vanno con lei.

OpenDecider, quello che ha vinto la leaderboard

OpenDecider: Manjunath ha deciso di andare a prendere tutti sul loro stesso benchmark. Risultato: 0.796 con un Ettin-encoder-400m full fine-tune + testa MLP, loss KL su soft target distillati da Qwen3-235B + DeepSeek.

pip install opendecider
# 17ms/domanda su L40S, 18ms su M4 Max, ~9ms in batch

Al 50% di coverage, la selective accuracy sale a 0.943 contro 0.882 per Jev. Sulla carta, il meglio dei due mondi: open, calibrato, veloce.

In pratica: 2 GB di RAM per caricare nano, GPU richiesta per tenere i 17ms, phishing a 0.63 contro 0.90 per Jev (l'OOD lo asfalta), e come Laya, allenato sul train del benchmark. Il test non l'ha mai visto, ma il dominio sì.

Vince il braccio di ferro, ma restano 400M con una GPU dietro. Non lo infili in un Worker Cloudflare o in un game loop su un laptop.

I 150M, e il buco sotto

openJev-verdict-2.0 (151M), julia-1 (144M multilingue), Open Jev (150M), Largha-150M (144M en+zh), Vela (0.3B-9B). Tutti ModernBERT o mmBERT-small, tutti capaci di girare su CPU, tutti simpatici da allenare (Open Jev dichiara 30min su un T4 Colab). Numeri self-reported, protocolli diversi, quindi da prendere con le pinze, ma la famiglia esiste.

Il generalist Sabaki fa 22.7M. Circa 6.5x più piccolo del più piccolo della banda. Nessun altro scende sotto i 100M per rispondere a domande definite a runtime.

Ecco cosa vuol dire "il più piccolo al mondo": non il miglior punteggio, ma il guscio più piccolo che legge le tue instructions + criteria e decide quando nessuno specialista sa rispondere.

Sabaki, il router scemo e i suoi esperti usa e getta

Ok, basta parlare dei grossi. Passiamo a Davide, perché è qui che diventa divertente.

Sabaki non prova a essere un grande modello furbo che capisce il mondo. È un MoE organizzativo: 1 router sparso a livello di sistema, esperti indipendenti, uno solo attivo per input. Ogni plugin è un package npm separato, con il suo manifest, i suoi pesi, la sua versione. Installi solo quello che routi.

L'org sabaki-js: 13 repo, il core sabaki (129 commit), 6 plugin @sabaki-js/*, i pesi, il worker Cloudflare, il template per scrivere il tuo.

Il package.json del core racconta già la storia:

"dependencies": { "@typesafe-ai/sdk": "^0.7.0" },
"optionalDependencies": { "onnxruntime-node": "^1.22.0" }

In modalità nano, zero dipendenze runtime. Solo TypeScript compilato, niente da installare, niente che può rompersi tra due versioni di ONNX.

Il router, o come decidere in poche righe

Il router vive in src/index.ts, e la tokenizzazione assomiglia a questo (src/index.ts:129):

function tokens(s: string): string[] {
  return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .split(/[^a-z0-9]+/).filter((w) => w.length > 3);
}

Minuscole, accenti via, parole da 4 lettere minimo. Questo filtro butta via tutto il rumore ("le", "de", "ok"). Su ticket di supporto cambia tutto perché il tuo matching avviene su parole che portano un senso.

Lo scoring vero sta in route() (src/index.ts:251), ed è lì che si gioca tutto:

const hits = kws.filter((k) =>
  words.some((w) => w.includes(k) || k.includes(w))).length;
const score = hits / Math.sqrt(kws.length);

Matching lasco in entrambi i sensi: billing becca billed, invoice becca invoicing. La divisione per la radice evita di penalizzare le grosse liste di keyword, altrimenti il plugin mail con le sue 600 parole schiaccerebbe tutti appena una mail contiene "hello".

Ogni plugin ha la sua soglia, default 0.15 in src/index.ts:139, mail a 0.03 perché gli oggetti sono corti. Sotto:

return { domain: "unknown", decision: null, confidence: 0, plugin: null };

Dominio sconosciuto = unknown, mai un guess inventato per far piacere. È un approccio volutamente stupido, ed è esattamente quello che rende la soglia automate > 0.8 usabile dopo: quando Sabaki risponde, è perché uno specialista ha matchato davvero.

C'è un asse lingua in più via langid: un plugin che dichiara languages concorre solo per le sue lingue. Comodo per separare phishing latino e CJK. Testo troppo corto = niente filtro, concorrono tutti.

Costo totale: qualche microsecondo per decisione. Nessuna matrice da moltiplicare, nessuna rete da chiamare, solo confronti tra stringhe e una divisione.

Nano, il mio file preferito del repo

src/logreg.ts:28, la tokenizzazione:

function tokens(s: string): string[] {
  const words = s.toLowerCase().split(/[^a-z0-9]+/)
    .filter((w) => /^[a-z]{3,}$/.test(w));
  const out = [...words];
  for (let i = 0; i < words.length - 1; i++)
    out.push(`${words[i]} ${words[i + 1]}`);
  return out;
}

Unigrammi + bigrammi. charged twice dà charged, twice, e la coppia charged twice. È tutto quel che serve per beccare "doppia fatturazione" senza tirare fuori un transformer da 400M.

src/logreg.ts:46, la vettorizzazione, dove il TF-IDF fa il suo lavoro:

const tf = 1 + Math.log(c);  // TF sublineare
const val = tf * w.idf[i]!;  // IDF smooth alla sklearn

La decima occorrenza di una parola conta meno della prima, le parole rare pesano più di "please". Esattamente la ricetta sklearn, parità esatta verificata su 20 domande.

E il commento che mi ha fatto sorridere rileggendo:

// Tocca solo i termini presenti (sparsi) invece di scansionare 3000 dimensioni.
for (const [t, c] of counts) {

Invece di scansionare 3000 dimensioni a ogni ticket, tocchi solo la ventina di parole presenti. Ecco perché risponde in meno di 1ms. È il tipo di dettaglio invisibile che cambia tutto in prod, perché la complessità segue la dimensione del ticket e non quella del vocabolario.

src/logreg.ts:86, l'inferenza, che sta in tre righe:

// binario: 1 riga di coefficienti + sigmoide
const pPos = 1 / (1 + Math.exp(-s));
// multiclasse: prodotto scalare + softmax, argmax

Un prodotto scalare, una sigmoide o un softmax secondo il numero di classi. Un passaggio ed è finita. Zero token generati, quindi zero JSON rotto da riparare dopo con retry ed euristiche di parsing.

Ricetta train.py: 300 train / 100 test per workflow, split deterministico (1 item su 5 in test per evitare il bias temporale), vocab 3000-6000, coefficienti arrotondati a 1e-4 con zero perdita misurata. Export JSON { vocab, idf, questions }.

Pesi: 20 KB per invoices (duplicate 0.89) fino a 650 KB per mail (bucket 0.889). Il tipo di dimensione che ci sta in un bundle frontend.

Il dettaglio che mi ha steso: su mail, MiniLM incolla TF-IDF a +/-0.02. Quindi nano shippa così com'è. Pagare una GPU per smistare mail, proprio no. Invece agent_trace si inceppa (needs_review 0.59 in nano): il testo di osservabilità è povero, nessuna parola porta davvero la decisione.

La factory che trasforma pesi in plugin, in src/plugin.ts:52:

export function nanoClassifier(manifest, weights, opts = {}) {
  return definePlugin({ manifest,
    classify: (input) => {
      const r = predict(`${from}${input.state}`, weights, q);
      return { decision: r.decision, confidence: r.confidence, domain: manifest.domain };
    }});
}

decide() resta sincrono nano, decideAsync() sblocca ONNX per mini. Due strade, una regola semplice: se ti serve async è perché vuoi mini, altrimenti nano basta. Facile da ricordare quando codici, facile da debuggare quando si rompe.

Per mostrarti che non è solo teoria, ecco a cosa assomiglia un uso reale:

import { Sabaki } from "sabaki";
import customerService from "@sabaki-js/customer-service";

const sabaki = new Sabaki().register(customerService);
sabaki.decide({ state: "charged twice, refund please" });
// { decision: "billing", confidence: 0.31, domain: "customer_service" }

Mini, 22 MB che cambiano le carte sui casi duri

Nano ha i suoi limiti, l'hai visto su agent_trace qui sopra. MiniLM-L6 riprende il testimone: un encoder condiviso per tutto il dominio, più una testa lineare per domanda. Fine-tune end-to-end per 8-12 epoche, lr 2e-5, su GPU CUDA per l'allenamento (l'inferenza resta CPU, è questo che conta).

I numeri che hanno deciso, misurati sugli stessi split:

  • encoder congelato + MLP: 0.65
  • full fine-tune: 0.68
  • distillazione 400M verso embedding congelati: testata, buttata, peggio delle gold label

Export ONNX via dynamo, parità esatta verificata, poi quantizzazione dinamica int8: 90 MB -> 22 MB, -0.004 accuracy su customer_service. ECE invariata o migliore. Ogni export viene ri-misurato, altrimenti l'ottimizzazione non vuol dire niente.

Dalla 1.0.2, i pesi sono dentro il package (models/<workflow>-int8/ + modelDir esportato). Basta npm i, modalità aereo OK. Aggiornare = pubblicare una patch, firmata via trusted publishing.

Concreto: needs_review passa da 0.59 a 0.82. disposition 0.73 -> 0.76. ECE < 0.14 ovunque, con un dubbio OOD sano (0.2-0.5 su draghi, ricette e stringhe vuote). È questo dubbio che autorizza soglie aggressive in prod.

Generalist, il catch-all che rende Sabaki davvero System One

Jev e gli altri rispondono a tutto: qualsiasi domanda, qualsiasi opzione, definita a runtime. Questo è il contratto System One che ha reso Jev virale -- tu posi le tue instructions + criteria con le tue opzioni, lui decide.

Gli specialisti Sabaki non sanno farlo. Conoscono le loro domande (category, duplicate, bucket) e niente altro. Senza rete, Sabaki restituirebbe unknown appena esci dal copione, dove Jev risponde sempre qualcosa.

Il generalist è questa rete. Un cross-encoder che legge domanda + opzione + state e tira fuori uno score, un passaggio per opzione, softmax, argmax. 22.7M MiniLM int8 -- 6.5x più piccolo dei 144-151M della banda. Ma il suo lavoro non è il punteggio, è la compatibilità: con lui attaccato come fallback, POST /v1/systemone accetta qualsiasi state e qualsiasi domanda, come Jev. Il campo model dice chi ha risposto davvero (@sabaki-js/[email protected]+mini, sabaki-generalist-v1...), quindi lato chiamante non cambi niente.

E soprattutto, è un fallback, non un capo. Regola d'oro in decideAsync(): se uno specialista vince il routing, risponde lui. Il generalist parla solo su unknown. Mai il contrario. Così eviti che un generalista medio schiacci uno specialista bravo sul suo dominio.

Il loader vive in src/generalist.ts, con una cache per cartella per non ricaricare la sessione ONNX a ogni chiamata:

export function loadGeneralist(modelDir: string) {
  // cache per cartella, sessione ONNX riutilizzata
  // tokenizer.json -> Unigram o wordpiece, rilevato in automatico
  const sess = await ort.InferenceSession.create(
    path.join(modelDir, "generalist.onnx"),
    { intraOpNumThreads: 2 },
  );
}

Punti SABAKI_GENERALIST_DIR verso una cartella con generalist.onnx + tokenizer/, il modello è swappable. L'id viene da config.json, altrimenti "sabaki-generalist". La cache Map evita di ricaricare la sessione a ogni chiamata.

Il formato di input è cross-encoder classico: domanda, opzione e state concatenati con separatori, una riga per opzione da scorare:

const texts = options.map(
  (o) => `${input.question ?? "decision"} [SEP] ${o} [SEP] ${input.state}`,
);

Domanda + opzione + state, separati da [SEP], una riga per opzione. Opzioni cappate a 8 (slice(0, 8)), default ["yes", "no"] se non dai niente. Oltre, devi spezzare lato chiamante -- 8 è già largo per una decisione tipizzata.

Poi arriva il batch furbo, il tipo di ottimizzazione che sembra niente ma divide la latenza per il numero di opzioni. Invece di un session.run per opzione, tutto parte in un solo forward:

// Un solo forward in batch per tutte le opzioni
// (paddato alla lunghezza max) invece di un run per opzione.
const L = Math.max(...encs.map((e) => e.ids.length));
// ids + attention_mask in [options.length, L], padding al padId
const out = await sess.run({ input_ids, attention_mask });
const scores = Array.from(out["score"].data).map(Number);

Ogni testo è encodato (wordpiece alla BERT o unigram SentencePiece secondo tokenizer.json), paddato alla lunghezza max del batch, impilato in un tensore [nb_options, L]. Un solo sess.run, e recuperi uno score per opzione. Su CPU cambia tutto: 20-36ms invece di 8x quel tempo.

Poi arriva il softmax + argmax, con le probabilità complete per ogni opzione, esattamente nel formato che Jev ha reso popolare:

const pr = softmax(scores);
// bi = indice del max
const confidence = Math.round(pr[bi] * 1000) / 1000;
const probs = Object.fromEntries(
  options.map((o, i) => [o, Math.round(pr[i] * 1000) / 1000]),
);

E il dettaglio che rende la cosa usabile in prod invece di restare una demo è abstainAt:

if (input.abstainAt !== undefined && confidence < input.abstainAt) {
  return { decision: null, confidence, domain: "generalist", probabilities: probs };
}

Opt-in, nessuna astensione di default. Passi abstainAt: 0.6-0.7 e tutto ciò che è molle torna null invece di un guess. Visto che il softmax sputa 1.00 anche quando ha torto fuori distribuzione, non è un lusso. Il GUIDE lo raccomanda per ogni uso autonomo: sotto la soglia, chiedi un chiarimento invece di auto-risolvere.

Per finire, ecco lo stesso montaggio con il fallback generalist: quando nessuno specialista matcha, tocca a lui invece di restituire unknown.

import { Sabaki, definePlugin, loadGeneralist } from "sabaki";
import mail from "@sabaki-js/mail";

const backend = loadGeneralist(process.env.SABAKI_GENERALIST_DIR!);
const generalist = definePlugin({
  manifest: { name: "@sabaki-js/generalist", domain: "generalist",
    version: "0.1.0", questions: ["decision"], keywords: [] },
  classify: (input) => backend.decide(input),
});

const sabaki = new Sabaki().register(mail).setFallback(generalist);

await sabaki.decideAsync({
  state: "le client dit que l'app a supprimé ses données",
  question: "c'est urgent ?",
  options: ["yes", "no"],
  abstainAt: 0.65,
});
// specialista mail se il routing matcha, altrimenti generalist come catch-all alla Jev

Le generazioni del generalist, tutte misurate uguali su PhishNChips (2000 mail) e sul test typed-decisions per confrontare ciò che è confrontabile:

  • v4: 0.29 test, R=0.026 phishing. Da buttare, rispondeva a lato.
  • A (22M 80k): 0.546, R=0.406. Comincia ad assomigliare a qualcosa.
  • mini v1 servita: 0.536, R=0.616. È quella che gira sul sito. Recall phishing corretta, test medio.
  • B (144M scratch): 0.568, R=0.062. Più grosso, appena meglio in test, nullo in phishing.

Il 144M from scratch che non trasferisce meglio mi ha calmato parecchio sull'idea di "mettere più parametri per generalizzare". A parità di task, il grosso costa di più senza indovinare meglio. Anche come catch-all, il piccolo fa il lavoro. E resta EN-only (all-MiniLM-L6-v2), routing EN, FR fuori distribuzione. Per il FR devi passare da uno specialista allenato sopra, non dal generalist.

Doom, l'unico test che conta davvero per me

doomLaya sono Laya e Jev che giocano davvero a FreeDoom. Il modello sceglie command / target / weapon, l'esecutore traccia il percorso A*, apre le porte, mira, clicca. Telemetria per tick, video, SHA dei run. Ci contribuisco, quindi non parlo di una leaderboard letta da lontano.

v0.1.0, MAP01 seed 48, skill 3, 35 tick/s, stesso esecutore:

Modello Risultato p50 HTTP Costo API
Laya stock fallimento 180s 106ms $0
Laya v3 adattata uscita 61.43s 108ms $0
Jev 1.13 uscita 68.97s 357ms $0.0046

Due uscite pulite, senza morti, con Laya che raccoglie lo shotgun al passaggio perché l'esecutore gestisce il pickup da solo. Onestà vuole: Laya v3 è stata allenata su Doom, Jev no. Un seed, una mappa, non prova niente di generale. Il README del progetto lo dice nero su bianco, e gli fa onore non gonfiare il risultato.

Lato Sabaki (plugin-doom): ViZDoom in Python che chiama un server JS a ogni tick. Nano ~18k req/s, mini ~71 req/s su un thread i5. Il tick 35Hz passa largo, anche su un laptop medio.

Con bussola di uscita, mini esce da MAP01 in 2-4 run su 6, nano 0/6. Logico: il testo perde lo spaziale, gli stati doomLaya plafonano verso 0.57 sia con MiniLM che con ModernBERT-base. Quando manca l'info spaziale, nessun classificatore testuale la reinventa.

I vicini hanno rifatto il match a dati pari: doomgemma incolla una LoRA da 27min su una RTX 4070 e porta Gemma 4 E2B al livello di Laya v3 in gioco (6/6 vs 6/6) e sopra in decisioni isolate. A parità di taglia, un decoder va veloce come Laya (270M: 22ms vs 23ms). A parità di risultato, Laya resta ~3x più veloce perché ci arriva con un modello più piccolo.

La mia lettura: l'allenamento sul task conta più dell'architettura. Un piccolo specialista allenato batte un grosso generalista che scopre. Esattamente la scommessa Sabaki. Il paper che segue, System Switch (arXiv:2610.09683), spinge la logica fino in fondo: modello piccolo e veloce che gioca, modello grande ragionatore che riprende in mano quando una gate si apre. Il pre-triage Sabaki (automate > 0.85 altrimenti escala) applicato al gioco.

Cosa è stato sacrificato

Ogni feature assente ne finanzia un'altra, è la regola del gioco quando miri alla CPU. La lista onesta, come per Bareiron dove ogni riga di C era un tradeoff:

Sabaki non racconta niente. Decide tra opzioni che gli dai, con una probabilità. Per una spiegazione, un riassunto, testo libero, prendi un LLM.

Il francese resta fuori distribuzione per il generalist. Routing EN (all-MiniLM-L6-v2), sonda 1/4 FR vs 3/4 EN su casi simili. Il plugin mail viene da una casella perlopiù FR ma anonimizzata: 0 PII, -0.024 accuracy per un vocab senza termini personali. L'anonimizzazione si paga, ma non era negoziabile.

Il softmax si mostra troppo sicuro quando esce dalla sua zona. Falsi a 1.00 osservati, sì. Da qui il pre-triage raccomandato nel GUIDE: abstainAt: 0.6-0.7 per restituire null sotto la soglia, poi gate confidence > 0.85 per automatizzare, umano altrimenti. Riferimento misurato: recall phishing 0.62 a precisione 0.87. Abbastanza per tagliare la coda in due, non per licenziare i revisori.

Nano fatica sulle tracce di agenti. 0.34 a 0.59 secondo la domanda. Mini riporta needs_review a 0.82, ma risk / urgency / action restano duri. Il testo di osservabilità è semplicemente povero di segnali lessicali.

Doom in testo plafona. Verso 0.57, MiniLM come ModernBERT-base. Finché resti su testo senza pixel, MAP02 ti blocca. La via credibile per un vero giocatore è vision + RL (tiny-doom-defender, 1.1M parametri da pixel), non un miglior classificatore testuale.

Il benchmark deriva per batch. typed-decisions è generato a infornate, lo split temporale è in realtà uno split di batch. drift_tool.py sputa 0.82 su customer_service, 0.67 su agent_trace (0.5 = indistinguibile). Conseguenza misurata: i pesi pubblicati fanno 0.733 in misto vs 0.61 sullo split ufficiale. Il numero ufficiale è quindi conservativo. La calibrazione Platt / temperature+bias non trasferisce tra batch, quindi shippiamo solo il temperature scaling calibrato su batch recenti. Regola di casa: prima drift_tool.py, warn sopra 0.6, block sopra 0.8. Nessun altro pubblica questa roba, tutti dovrebbero.

Il risultato finale, numeri alla mano

Da un lato un i5 da laptop, dall'altro un'API che fattura a chiamata. Ecco cosa dà concretamente ogni modalità:

  • nano: <1ms, ~18k req/s, 20-650 KB, 0 dipendenze
  • mini: ~20ms, ~71 req/s, 22 MB int8, onnxruntime opzionale
  • generalist: 20-36ms, 22.7M, il più piccolo generalista open che conosco

Più domini caricati: ~0.5 GB RAM in lazy-load per dominio invece di ~2 GB per tutto. Deterministico: 100/100 input identici danno lo stesso output. Soglie per domanda, gate su confidence, unknown quando niente matcha.

Su Bareiron, far girare 3+ giocatori arrancava perché ogni giocatore costava un chunk a 200ms. Qui far girare 3+ domini passa senza fiatare, perché ogni esperto è minuscolo e caricato su richiesta invece di caricare tutto in una volta. Stessa filosofia del "piccolo che basta", applicata a un altro hardware.

La filosofia

p2r3 diceva: "Amo e basta l'idea che questo piccolissimo chip da 1 dollaro possa far girare qualcosa di avanzato come Minecraft."

Stesso trip qui: amo l'idea che 20 KB di JSON e un centinaio di righe di TS sostituiscano una chiamata GPU per scegliere una label. Ovviamente non sostituisce tutto, non prendiamoci in giro. Sostituisce il caso noioso in cui vuoi una label in millisecondi, in locale, con una confidenza che puoi ragionevolmente sogliare invece di incrociare le dita.

I tradeoff, scritti nero su bianco:

  • encoder -> conteggio di parole: 7 punti in meno, latenza divisa per 1000, infra a zero
  • modello unico grosso -> plugin npm: gate più grossolana, ops infinitamente più semplici
  • generazione -> decisione tipizzata: basta parsing che si rompe e opzioni inventate
  • confidenza grezza -> pre-triage: prima abstain, automazione sopra 0.85, umano per il resto

Le 3 cose da ricordare:

  1. 7 punti in meno, 1000x più veloce -- 0.61/0.69 vs 0.75-0.80, ma <1ms/20ms CPU offline contro 25s CPU o API a pagamento. Un punteggio senza il suo costo non vuol dire niente.
  2. Router semplice + esperti separati -- hits / sqrt(keywords), threshold, unknown. Ogni plugin si allena, si versiona e shippa da solo. Su mail, TF-IDF incolla MiniLM: la GPU non serve a niente qui.
  3. Offline-first con calibrazione onesta -- npm i, pesi inclusi, 0 GPU, ECE < 0.14, abstain, drift tool. L'unico stack del confronto che regge un game loop a 35Hz senza GPU.

Se il progetto ti interessa, è tutto in MIT su github.com/sabaki-js. La versione accademica con misure e bibtex è il preprint v1. E se vuoi vedere Golia giocare a Doom per davvero, vai su azalio/doomLaya xD

NEW

Sabaki vs System One: David gegen Goliath, das kleinste Entscheidungsmodell der Welt

20KB gegen 421M Parameter. Sabaki verliert 7 Punkte auf typed-decisions, geht aber von 25s CPU auf unter 1ms offline. Anatomie eines organisationalen MoE.

Einleitung

Wenn du seit September auf Hacker News oder Twitter rumhängst, bist du an Jev garantiert nicht vorbeigekommen.

Am 15. September 2026 haut TypeSafe mit Jev raus, ihrem ersten "System One". Der Pitch passt in einen Satz: Hört auf, einen LLM nach JSON für ein Label zu fragen. Gib ihm einen state plus typisierte Fragen, er gibt dir typisierte Entscheidungen mit Wahrscheinlichkeiten zurück. Ein Durchlauf, null Tokens zum Samplen, nichts mehr zu parsen danach.

Das Ding ist explodiert. Rekordadoption am ersten Tag auf dem Vercel AI Gateway, fünf Elixir-Clients in wenigen Tagen, alle benchen Jev gegen ihr eigenes Zeug. Und ehrlich, der Hype ist verständlich. Jahrelang haben wir Prompts vom Typ antworte NUR mit {"choice": ...} gebastelt und gebetet, dass das Modell keine Option erfindet, das JSON nicht zerschießt, kein Feld halluziniert. Jev hat diesem Bedarf einen Namen gegeben und als saubere API versendet: POST /v1/systemone, Fragen vom Typ choice / score / noul, Antworten mit probabilities + confidence.

Ich fand die Idee super. Nur das Modell dahinter weniger -- und um das klar zu sagen, ich habe nie für Jev bezahlt.

Weil dahinter bleibt Jev eine geschlossene API. Unbekannte Gewichte, kein Self-Hosting, jede Entscheidung = ein Netzwerk-Roundtrip + ein Zähler, der mitläuft. Und die offenen Alternativen, die danach kamen, liegen bei 400M Parametern mit GPU-Pflicht.

Also habe ich das andere Extrem gebaut: Sabaki. Ein Keyword-Router + eine logistische Regression in purem TypeScript, der denselben Vertrag spricht wie Jev (dieselben choice / score / noul-Shapes, derselbe Endpoint /v1/systemone, serviert von serve()), aber in unter 1ms läuft mit 20 Kilobyte JSON. Null Dependencies im Nano-Modus, offline, 0€.

Zur Einordnung: Laya sind 843 MB Safetensors zum Laden. OpenDecider-nano sind 2 GB RAM. Sabaki nano invoices sind 20 KB. Das Gewichtsverhältnis liegt bei etwa 40.000.

Wenn du die eckige, mit Zahlen belegte Version willst, gibt es das Preprint v1 vom Oktober 2026 auf der Seite: Sabaki: An Organizational Mixture-of-Experts for Typed Decisions on CPU (PDF). Design, Messungen, Tradeoffs, alles drin. Dieser Artikel hier ist die lesbare Version, mit offenem Code daneben.

Ich habe das nicht in meiner Ecke als Benchmark-Papier geschrieben. Ich arbeite mit an azalio/doomLaya (mein Fork: fox3000foxy/doomLaya), einem Harness, in dem Laya und Jev wirklich FreeDoom spielen mit model-owned Entscheidungen, Telemetrie pro Tick und Videos. Ich habe Goliath live spielen sehen, und sobald das Netzwerk hustet, hustet er mit.

Wir machen jetzt die Motoren einzeln auf. Wie immer, mit dem echten Code.

Der Ring, auf den sich alle einigen

Es gibt genau ein Dataset, das Jev, Laya und OpenDecider alle zitieren: LocalLLaMA/typed-decisions. 4 synthetische Workflows (Support, Rechnungen, Security, Agent-Traces), 5 Fragen pro Fall, 1200 Train-Fälle, 400 Test-Fälle, 2000 Entscheidungen.

Modell Params Test-Score Latenz Wo es läuft
OpenDecider-nano ~400M 0.796 17ms GPU GPU, 2 GB RAM
Laya typed-decisions 421M 0.766 ~25s/state CPU, ~830ms/Fall MLX GPU
Jev 1.13 geschlossen ~0.73-0.75 ~710ms API nur API
Sabaki mini 22M int8 0.69 ~20ms CPU CPU, 22 MB
Sabaki nano ~0 0.61 <1ms überall
Laya Basis Zero-Shot 421M 0.362 gleich GPU

Zufall liegt bei 0.318, Mehrheit bei 0.461. Laya Basis mit 0.362 ohne Fine-Tune heißt also: "ich rate höflich drauflos". Merk dir das, du brauchst es noch.

7 Punkte trennen Sabaki vom Besten. 1000x Latenz trennen sie in die andere Richtung, wenn du keine GPU hast. Das ist der ganze Artikel in zwei Sätzen, der Rest erklärt dir, warum.

Aber Vorsicht: Hinter demselben choice / score / noul-Anschluss stecken komplett verschiedene Küchen. Jev = geschlossene API. Laya = fine-getunter ModernBERT-large. OpenDecider = destillierter Ettin-Encoder. Sabaki = Wortzählung + MiniLM int8. Gleicher Vertrag, unvergleichbare Budgets.

Jev, die Klatsche, die jeder mitbekommen hat

Man muss TypeSafe lassen, was TypeSafe gehört: Der System-One-Vertrag ist echt eine gute Idee.

Früher hast du zum Sortieren eines Tickets das hier gemacht: du fragst einen LLM "wähl zwischen Billing, Technik und Spam, antworte in JSON", du parst die Antwort, du handelst die Fälle ab, in denen er "Ich würde sagen Billing! 😊" antwortet oder eine vierte Kategorie erfindet. Jede Frage kostete Tokens, jede Antwort konnte deine Pipeline sprengen.

Jev dreht den Deal um: du schickst den Text + Fragen mit ihren Kriterien, er wertet jede Frage parallel aus und gibt dir einen typisierten Wert mit seiner Verteilung zurück.

curl https://api.typesafe.ai/v1/systemone \
 -H "Authorization: Bearer $KEY" \
 -H 'content-type: application/json' \
 -d '{"state":"charge double ce mois-ci","questions":{"dept":{"type":"choice","instructions":"Route ce message.","criteria":{"billing":"paiements","technical":"bugs"}}}}}'

Antwort: { choice, probabilities, confidence }. Kein freier Text. Dein Code kann direkt darauf verzweigen.

Darum ging das viral: Python- + JS-SDK, Claude-Code-Plugin, LangChain-Integration, jeder konnte seine Agenten an einem Nachmittag anschließen. Die TypeSafe-Doku pusht sogar das gesunde Pattern: Jev für kleine schnelle Urteile, deterministischer Code drumherum für die echte zusammengesetzte Entscheidung, langsamer LLM oder Mensch sobald die Confidence niedrig ist.

Das Problem ist der Rest. Geschlossene Gewichte, offline unmöglich, Preis pro Call. Für einen Agenten, der 100 Entscheidungen pro Run trifft, tut die Steuer weh. Für Doom mit 35 Ticks/s ist es tot: in doomLaya schafft Jev MAP01 Seed 48 in 68.97s mit p50 HTTP von 357ms. Der Run meldet $0.0046 API-Kosten -- nicht meine Rechnung, die vom Benchmark. Es läuft, der Run ist sauber, ohne Tod. Aber 357ms bei 35 Ticks/s, das sind 12 Ticks, in denen das Spiel weiterläuft, während du aufs Netzwerk wartest.

Sabaki hat genau diesen Vertrag geklaut -- dieselben Fragen, dieselben Antworten, derselbe Endpoint /v1/systemone, serviert von serve() -- und alles rausgeworfen, was ein Datacenter braucht.

Laya, der dicke Offene, der gezeigt hat, dass es ohne API geht

Laya von Convai Innovations, Apache-2.0. Unter der Haube: ModernBERT-large, 421M, mit 1 [MASK]-Token pro Option. Der Hidden State jedes Markers geht durch ein kleines MLP, Softmax über die Optionen der Frage. Ein Durchlauf pro Frage, wie bei Jev.

pipe = pipeline("text-classification", model="convaiinnovations/laya-typed-decisions")

Die 0.766 verdienen einen genaueren Blick. Das ist der Checkpoint, fine-getunt auf dem Train-Split des Benchmarks: 1200 Fälle, RLCD mit Gauß-Rauschen auf den Logits, Log- + Spherical-Reward. Dasselbe Modell ohne diesen Fit fällt auf 0.362. Sprich: es rät. Der Laya-Score heißt also "großer Encoder + Training auf der Domäne", nicht "großer magischer Encoder".

Auf CPU ist das ein Ziegelstein. Der gemessene ONNX-Port liegt bei rund 25s pro State. In MLX FP16 rechne mit 830ms pro Fall im Schnitt mit p99 bei 5.3s. Und du musst erst 843 MB laden, bevor irgendwas antwortet.

In doomLaya scheitert die Stock-Laya an MAP01 in 180s. Die angepasste v3 (azalio/laya-doom-v3), neu trainiert auf Doom, kommt in 61.43s raus mit p50 108ms auf MPS, und sammelt nebenbei die Shotgun ein. Also ja, das spielt -- nach dediziertem Training, auf Apple-GPU, mit einem gescripteten Executor, der die echte Arbeit macht (A*, Türen mit 15-Tick-Latch, Auto-Aim).

Mein Fazit: ohne Training auf deine Aufgabe ist Laya ein 421M-Briefbeschwerer. Mit Training ist es sehr stark, aber du brauchst die GPU und die passenden Daten dazu.

OpenDecider, der das Leaderboard gewonnen hat

OpenDecider, das ist Manjunath, der beschlossen hat, alle auf ihrem eigenen Benchmark zu jagen. Ergebnis: 0.796 mit einem voll fine-getunten Ettin-Encoder-400m + MLP-Head, KL-Loss auf Soft Targets destilliert aus Qwen3-235B + DeepSeek.

pip install opendecider
# 17ms/Frage auf L40S, 18ms auf M4 Max, ~9ms gebatcht

Bei 50% Coverage steigt die selektive Accuracy auf 0.943 gegen 0.882 für Jev. Auf dem Papier das Beste aus beiden Welten: offen, kalibriert, schnell.

In der Praxis: 2 GB RAM zum Laden von Nano, GPU-Pflicht für die 17ms, Phishing bei 0.63 gegen 0.90 für Jev (OOD zieht ihn ab), und wie Laya trainiert auf dem Train-Split des Benchmarks. Der Test wurde nie gesehen, aber die Domäne schon.

Er gewinnt das Armdrücken, aber es bleiben 400M mit einer GPU dahinter. Das klebst du nicht in einen Cloudflare-Worker oder in eine Game-Loop auf einem Laptop.

Die 150M-Klasse, und das Loch darunter

openJev-verdict-2.0 (151M), julia-1 (144M mehrsprachig), Open Jev (150M), Largha-150M (144M en+zh), Vela (0.3B-9B). Alles ModernBERT oder mmBERT-small, alle CPU-fähig, alle nett zu trainieren (Open Jev meldet 30min auf einer T4 auf Colab). Selbst berichtete Zahlen, verschiedene Protokolle, also mit Vorsicht zu genießen, aber die Familie existiert.

Der Sabaki-Generalist macht 22.7M. Etwa 6.5x kleiner als der kleinste aus dieser Truppe. Kein anderer geht unter 100M runter, um zur Runtime definierte Fragen zu beantworten.

Das ist es, "der kleinste der Welt": nicht der beste Score, sondern die kleinste Hülle, die deine instructions + criteria liest und entscheidet, wenn kein Spezialist Bescheid weiß.

Sabaki, der dumme Router und seine Wegwerf-Experten

Gut, genug über die Großen geredet. Kommen wir zu David, weil es hier lustig wird.

Sabaki versucht nicht, ein großes schlaues Modell zu sein, das die Welt versteht. Es ist ein organisationales MoE: 1 sparser Router auf Systemebene, unabhängige Experten, einer aktiv pro Input. Jedes Plugin ist ein eigenes npm-Package, mit seinem Manifest, seinen Gewichten, seiner Version. Du installierst nur, was du routest.

Die Org sabaki-js: 13 Repos, der Core sabaki (129 Commits), 6 @sabaki-js/*-Plugins, die Gewichte, der Cloudflare-Worker, das Template für dein eigenes.

Die package.json vom Core erzählt die Geschichte schon:

"dependencies": { "@typesafe-ai/sdk": "^0.7.0" },
"optionalDependencies": { "onnxruntime-node": "^1.22.0" }

Im Nano-Modus null Runtime-Dependencies. Nur kompiliertes TypeScript, nichts zu installieren, nichts, was zwischen zwei ONNX-Versionen brechen kann.

Der Router, oder wie man in wenigen Zeilen entscheidet

Der Router lebt in src/index.ts, und die Tokenisierung sieht so aus (src/index.ts:129):

function tokens(s: string): string[] {
  return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .split(/[^a-z0-9]+/).filter((w) => w.length > 3);
}

Kleinbuchstaben, Akzente raus, Wörter mit mindestens 4 Buchstaben. Dieser Filter wirft den ganzen Rausch raus ("le", "de", "ok"). Bei Support-Tickets ändert das alles, weil dein Matching auf Wörtern läuft, die Bedeutung tragen.

Das Scoring selbst steckt in route() (src/index.ts:251), und hier entscheidet sich alles:

const hits = kws.filter((k) =>
  words.some((w) => w.includes(k) || k.includes(w))).length;
const score = hits / Math.sqrt(kws.length);

Lockeres Matching in beide Richtungen: billing fängt billed, invoice fängt invoicing. Die Division durch die Wurzel bestraft große Keyword-Listen nicht, sonst würde das Mail-Plugin mit seinen 600 Wörtern alles plattmachen, sobald eine Mail "hello" enthält.

Jedes Plugin hat seine Schwelle, Standard 0.15 gespeichert in src/index.ts:139, Mail bei 0.03 weil Betreffs kurz sind. Darunter:

return { domain: "unknown", decision: null, confidence: 0, plugin: null };

Unbekannte Domäne = unknown, nie ein erfundener Guess, nur um nett zu sein. Absichtlich doof, und genau das macht das Thresholding automate > 0.8 später benutzbar: wenn Sabaki antwortet, dann weil ein Spezialist wirklich gematcht hat.

Obendrauf gibt es eine Sprachachse über langid: ein Plugin, das languages deklariert, tritt nur für seine Sprachen an. Praktisch, um lateinisches Phishing und CJK zu trennen. Text zu kurz = kein Filter, alle treten an.

Gesamtkosten: ein paar Mikrosekunden pro Entscheidung. Keine Matrix zu multiplizieren, kein Netzwerk anzurufen, nur String-Vergleiche und eine Division.

Nano, meine Lieblingsdatei im Repo

src/logreg.ts:28, die Tokenisierung:

function tokens(s: string): string[] {
  const words = s.toLowerCase().split(/[^a-z0-9]+/)
    .filter((w) => /^[a-z]{3,}$/.test(w));
  const out = [...words];
  for (let i = 0; i < words.length - 1; i++)
    out.push(`${words[i]} ${words[i + 1]}`);
  return out;
}

Unigramme + Bigramme. charged twice gibt charged, twice, und das Paar charged twice. Das reicht, um "doppelte Abrechnung" zu erkennen, ohne einen 400M-Transformer rauszuholen.

src/logreg.ts:46, die Vektorisierung, wo TF-IDF seine Arbeit macht:

const tf = 1 + Math.log(c);  // sublineares TF
const val = tf * w.idf[i]!;  // smoothes IDF wie bei sklearn

Das zehnte Vorkommen eines Wortes zählt weniger als das erste, seltene Wörter wiegen mehr als "please". Genau das sklearn-Rezept, exakte Parität auf 20 Fragen verifiziert.

Und der Kommentar, bei dem ich beim Wiederlesen grinsen musste:

// berührt nur vorhandene Terme (sparse) statt 3000 Dims zu scannen.
for (const [t, c] of counts) {

Statt bei jedem Ticket 3000 Dimensionen zu scannen, fasst du nur die rund zwanzig vorhandenen Wörter an. Darum antwortet das in unter 1ms. Das ist so ein unsichtbares Detail, das in Prod alles ändert, weil die Komplexität der Ticketgröße folgt und nicht der Vokabulargröße.

src/logreg.ts:86, die Inferenz, die in drei Zeilen passt:

// binär: 1 Zeile Coefs + Sigmoid
const pPos = 1 / (1 + Math.exp(-s));
// multiklasse: Skalarprodukt + Softmax, Argmax

Ein Skalarprodukt, ein Sigmoid oder ein Softmax je nach Klassenzahl. Ein Durchlauf und fertig. Null Tokens generiert, also null kaputtes JSON, das du danach mit Retries und Parsing-Heuristiken flicken musst.

Rezept train.py: 300 Train / 100 Test pro Workflow, deterministischer Split (jedes fünfte Item in Test, um Zeitbias zu vermeiden), Vocab 3000-6000, Coefs auf 1e-4 gerundet bei null gemessenem Verlust. Export als JSON { vocab, idf, questions }.

Gewichte: 20 KB für invoices (duplicate 0.89) bis 650 KB für Mail (bucket 0.889). Eine Größe, die in ein Frontend-Bundle passt.

Das Detail, das mich umgehauen hat: auf Mail klebt MiniLM mit +/-0.02 an TF-IDF. Also shippt Nano so wie es ist. Eine GPU zum Mailsortieren bezahlen, nee danke. Dafür klemmt agent_trace (needs_review 0.59 in Nano): Observability-Text ist arm, kein Wort trägt die Entscheidung wirklich.

Die Fabrik, die aus Gewichten ein Plugin macht, in src/plugin.ts:52:

export function nanoClassifier(manifest, weights, opts = {}) {
  return definePlugin({ manifest,
    classify: (input) => {
      const r = predict(`${from}${input.state}`, weights, q);
      return { decision: r.decision, confidence: r.confidence, domain: manifest.domain };
    }});
}

decide() bleibt synchron für Nano, decideAsync() schaltet ONNX für Mini frei. Zwei Wege, eine simple Regel: wenn du Async brauchst, willst du Mini, sonst reicht Nano. Leicht zu merken beim Coden, leicht zu debuggen wenn es kracht.

Damit du siehst, dass das nicht nur Theorie ist, hier echte Nutzung:

import { Sabaki } from "sabaki";
import customerService from "@sabaki-js/customer-service";

const sabaki = new Sabaki().register(customerService);
sabaki.decide({ state: "charged twice, refund please" });
// { decision: "billing", confidence: 0.31, domain: "customer_service" }

Mini, 22 MB, die bei schweren Fällen alles ändern

Nano hat seine Grenzen, du hast es oben bei agent_trace gesehen. MiniLM-L6 übernimmt: ein geteilter Encoder für die ganze Domäne, plus ein linearer Head pro Frage. End-to-end Fine-Tune über 8-12 Epochen, lr 2e-5, auf CUDA-GPU fürs Training (Inferenz bleibt CPU, darauf kommt es an).

Die Zahlen, die entschieden haben, gemessen auf denselben Splits:

  • eingefrorener Encoder + MLP: 0.65
  • volles Fine-Tune: 0.68
  • Distillation 400M auf eingefrorene Embeddings: getestet, verworfen, schlechter als die Gold-Labels

ONNX-Export via Dynamo, exakte Parität verifiziert, dann dynamische int8-Quantisierung: 90 MB -> 22 MB, -0.004 Accuracy auf customer_service. ECE gleich oder besser. Jeder Export wird neu vermessen, sonst heißt die Optimierung nichts.

Seit 1.0.2 stecken die Gewichte im Package (models/<workflow>-int8/ + exportiertes modelDir). npm i reicht, Flugmodus OK. Updaten = Patch publishen, signiert via Trusted Publishing.

Konkret: needs_review geht von 0.59 auf 0.82. disposition 0.73 -> 0.76. ECE < 0.14 überall, mit gesundem OOD-Zweifel (0.2-0.5 auf Drachen, Rezepten und leeren Strings). Genau dieser Zweifel erlaubt aggressives Thresholding in Prod.

Generalist, das Catch-all, das Sabaki erst wirklich zu System One macht

Jev und die anderen antworten auf alles: jede Frage, jede Option, zur Runtime definiert. Das ist der System-One-Vertrag, der Jev viral gemacht hat -- du stellst deine instructions + criteria mit deinen Optionen, er entscheidet.

Die Sabaki-Spezialisten können das nicht. Sie kennen ihre Fragen (category, duplicate, bucket) und sonst nichts. Ohne Netz würde Sabaki unknown zurückgeben, sobald du vom Skript abweichst, wo Jev immer irgendwas antwortet.

Der Generalist ist dieses Netz. Ein Cross-Encoder, der Frage + Option + State liest und einen Score ausspuckt, ein Durchlauf pro Option, Softmax, Argmax. 22.7M MiniLM int8 -- 6.5x kleiner als die 144-151M der Truppe. Aber sein Job ist nicht der Score, sondern Kompatibilität: mit ihm als Fallback angeschlossen akzeptiert POST /v1/systemone jeden State und jede Frage, wie Jev. Das Feld model sagt, wer wirklich geantwortet hat (@sabaki-js/[email protected]+mini, sabaki-generalist-v1...), also änderst du aufruferseitig nichts.

Und vor allem ist es ein Fallback, kein Chef. Goldene Regel in decideAsync(): wenn ein Spezialist das Routing gewinnt, antwortet er. Der Generalist redet nur bei unknown. Nie umgekehrt. So wischt kein mittelmäßiger Generalist einen guten Spezialisten auf dessen Domäne weg.

Der Loader lebt in src/generalist.ts, mit Cache pro Ordner, damit du die ONNX-Session nicht bei jedem Call neu lädst:

export function loadGeneralist(modelDir: string) {
  // Cache pro Ordner, ONNX-Session wird wiederverwendet
  // tokenizer.json -> Unigram oder Wordpiece, auto erkannt
  const sess = await ort.InferenceSession.create(
    path.join(modelDir, "generalist.onnx"),
    { intraOpNumThreads: 2 },
  );
}

Du zeigst SABAKI_GENERALIST_DIR auf einen Ordner mit generalist.onnx + tokenizer/, das Modell ist austauschbar. Die ID kommt aus config.json, sonst "sabaki-generalist". Der Map-Cache erspart das Neuladen der Session bei jedem Call.

Das Input-Format ist klassischer Cross-Encoder: Frage, Option und State verkettet mit Trennern, eine Zeile pro zu scorender Option:

const texts = options.map(
  (o) => `${input.question ?? "decision"} [SEP] ${o} [SEP] ${input.state}`,
);

Frage + Option + State, getrennt durch [SEP], eine Zeile pro Option. Optionen gedeckelt auf 8 (slice(0, 8)), Default ["yes", "no"] wenn du nichts gibst. Darüber hinaus musst du aufruferseitig stückeln -- 8 ist schon breit für eine typisierte Entscheidung.

Dann kommt das schlaue Batching, so eine Optimierung, die unscheinbar aussieht, aber die Latenz durch die Optionszahl teilt. Statt einem session.run pro Option geht alles in einem Forward raus:

// Ein einziger gebatchter Forward für alle Optionen
// (auf Max-Länge aufgefüllt) statt ein Run pro Option.
const L = Math.max(...encs.map((e) => e.ids.length));
// ids + attention_mask als [options.length, L], Padding mit padId
const out = await sess.run({ input_ids, attention_mask });
const scores = Array.from(out["score"].data).map(Number);

Jeder Text wird encodiert (BERT-Wordpiece oder SentencePiece-Unigram je nach tokenizer.json), auf die Max-Länge des Batches gepaddet, zu einem Tensor [anz_options, L] gestapelt. Ein einziges sess.run, du kriegst einen Score pro Option. Auf CPU ändert das alles: 20-36ms statt 8x so viel.

Dann kommt Softmax + Argmax, mit den kompletten Probs für jede Option, genau in dem Format, das Jev populär gemacht hat:

const pr = softmax(scores);
// bi = Index des Maximums
const confidence = Math.round(pr[bi] * 1000) / 1000;
const probs = Object.fromEntries(
  options.map((o, i) => [o, Math.round(pr[i] * 1000) / 1000]),
);

Und das Detail, das das Ding in Prod benutzbar macht statt nur eine Demo zu bleiben, ist abstainAt:

if (input.abstainAt !== undefined && confidence < input.abstainAt) {
  return { decision: null, confidence, domain: "generalist", probabilities: probs };
}

Opt-in, keine Enthaltung per Default. Du gibst abstainAt: 0.6-0.7 mit und alles Wackelige kommt als null zurück statt als Guess. Weil der Softmax auch bei Fehlschüssen außerhalb der Verteilung 1.00 ausspuckt, ist das kein Luxus. Der GUIDE empfiehlt es für jede autonome Nutzung: unter der Schwelle fragst du nach statt automatisch aufzulösen.

Zum Schluss noch dasselbe Setup mit Generalist-Fallback: wenn kein Spezialist matcht, springt er ein statt unknown zu liefern.

import { Sabaki, definePlugin, loadGeneralist } from "sabaki";
import mail from "@sabaki-js/mail";

const backend = loadGeneralist(process.env.SABAKI_GENERALIST_DIR!);
const generalist = definePlugin({
  manifest: { name: "@sabaki-js/generalist", domain: "generalist",
    version: "0.1.0", questions: ["decision"], keywords: [] },
  classify: (input) => backend.decide(input),
});

const sabaki = new Sabaki().register(mail).setFallback(generalist);

await sabaki.decideAsync({
  state: "le client dit que l'app a supprimé ses données",
  question: "c'est urgent ?",
  options: ["yes", "no"],
  abstainAt: 0.65,
});
// Spezialist für Mail wenn das Routing matcht, sonst Generalist als Catch-all wie bei Jev

Die Generationen des Generalisten, alle gleich gemessen auf PhishNChips (2000 Mails) und auf dem typed-decisions-Test, um Vergleichbares zu vergleichen:

  • v4: 0.29 Test, R=0.026 Phishing. Wegwerfen, lag daneben.
  • A (22M 80k): 0.546, R=0.406. Fängt an, nach was auszusehen.
  • mini v1 serviert: 0.536, R=0.616. Die läuft auf der Seite. Ordentlicher Phishing-Recall, mittlerer Test.
  • B (144M from scratch): 0.568, R=0.062. Größer, kaum besser im Test, mies im Phishing.

Der 144M-from-scratch, der nicht besser transferiert, hat mich bei der Idee "mehr Params für Generalisierung" ziemlich runtergeholt. Bei gleicher Aufgabe kostet der Große mehr, ohne besser zu raten. Selbst als Catch-all macht der Kleine den Job. Und er bleibt EN-only (all-MiniLM-L6-v2), EN-Routing, FR out-of-distrib. Für FR nimmst du einen darauf trainierten Spezialisten, nicht den Generalisten.

Doom, der einzige Test, der für mich wirklich zählt

doomLaya, das ist Laya und Jev, die wirklich FreeDoom spielen. Das Modell wählt command / target / weapon, der Executor plant den Weg per A*, öffnet Türen, zielt, klickt. Telemetrie pro Tick, Videos, SHAs der Runs. Ich arbeite da mit, ich rede also nicht über ein Leaderboard aus der Ferne.

v0.1.0, MAP01 Seed 48, Skill 3, 35 Ticks/s, derselbe Executor:

Modell Ergebnis p50 HTTP API-Kosten
Laya Stock Fehlschlag 180s 106ms $0
Laya v3 angepasst Ausgang 61.43s 108ms $0
Jev 1.13 Ausgang 68.97s 357ms $0.0046

Zwei saubere Ausgänge, ohne Tod, Laya schnappt unterwegs die Shotgun auf, weil der Executor das Pickup allein handelt. Ehrlichkeitspflicht: Laya v3 wurde auf Doom trainiert, Jev nicht. Ein Seed, eine Map, das beweist nichts Allgemeines. Das README vom Projekt sagt das schwarz auf weiß, und es ehrt das Projekt, das Ergebnis nicht zu überverkaufen.

Auf Sabaki-Seite (plugin-doom): ViZDoom in Python ruft pro Tick einen JS-Server auf. Nano ~18k req/s, Mini ~71 req/s auf einem i5-Thread. Der 35Hz-Tick geht locker durch, selbst auf einem mittleren Laptop.

Mit Ausgangskompass schafft Mini MAP01 in 2-4 von 6 Runs, Nano 0/6. Logisch: der Text verliert das Räumliche, die Doom-States deckeln bei etwa 0.57, ob MiniLM oder ModernBERT-base. Wenn die räumliche Info fehlt, erfindet sie kein Textklassifizierer neu.

Die Nachbarn haben das Rematch mit gleichen Daten gemacht: doomgemma klebt eine 27min-LoRA auf einer RTX 4070 drauf und bringt Gemma 4 E2B im Spiel auf Laya-v3-Niveau (6/6 vs 6/6) und darüber bei isolierten Entscheidungen. Bei gleicher Größe ist ein Decoder so schnell wie Laya (270M: 22ms vs 23ms). Bei gleichem Ergebnis bleibt Laya etwa 3x schneller, weil sie es mit einem kleineren Modell schafft.

Meine Lesart: Training auf der Aufgabe zählt mehr als Architektur. Ein kleiner trainierter Spezialist schlägt einen großen Generalisten, der Neuland betritt. Genau die Sabaki-Wette. Das Folge-Paper, System Switch (arXiv:2610.09683), zieht die Logik bis zum Ende durch: kleines schnelles Modell, das spielt, großes Reasoner-Modell, das übernimmt, wenn ein Gate aufgeht. Das Sabaki-Pre-Triage (automate > 0.85 sonst Eskalation), angewandt aufs Spiel.

Was geopfert wurde

Jedes fehlende Feature finanziert ein anderes, das ist die Spielregel, wenn du auf CPU zielst. Die ehrliche Liste, wie bei Bareiron, wo jede C-Zeile ein Tradeoff war:

Sabaki erzählt nichts. Es entscheidet zwischen Optionen, die du ihm gibst, mit einer Proba. Für Erklärungen, Zusammenfassungen, freien Text, nimm einen LLM.

Französisch bleibt out-of-distrib für den Generalisten. EN-Routing (all-MiniLM-L6-v2), Sonde 1/4 FR vs 3/4 EN auf ähnlichen Fällen. Das Mail-Plugin kommt aus einer Firma mit Mehrheit FR, aber anonymisiert: 0 PII, -0.024 Accuracy für ein Vocab ohne persönliche Begriffe. Anonymisierung kostet, war aber nicht verhandelbar.

Der Softmax zeigt sich zu sicher außerhalb seiner Zone. Falsche mit 1.00 gesehen, ja. Darum das empfohlene Pre-Triage aus dem GUIDE: abstainAt: 0.6-0.7, um unter der Schwelle null zu liefern, dann Gate confidence > 0.85 zum Automatisieren, Mensch für den Rest. Gemessene Referenz: Phishing-Recall 0.62 bei Präzision 0.87. Genug, um die Schlange zu halbieren, nicht genug, um Reviewer rauszuwerfen.

Nano kämpft mit Agent-Traces. 0.34 bis 0.59 je nach Frage. Mini hebt needs_review auf 0.82, aber risk / urgency / action bleiben hart. Observability-Text ist einfach arm an lexikalischen Signalen.

Doom in Text deckelt. Bei etwa 0.57, MiniLM wie ModernBERT-base. Solange du auf Text ohne Pixel bleibst, blockt dich MAP02. Der glaubwürdige Weg zu einem echten Spieler ist Vision + RL (tiny-doom-defender, 1.1M Params aus Pixeln), kein besserer Textklassifizierer.

Der Benchmark driftet in Batches. typed-decisions wird in Chargen generiert, der zeitliche Split ist eigentlich ein Batch-Split. drift_tool.py meldet 0.82 auf customer_service, 0.67 auf agent_trace (0.5 = ununterscheidbar). Gemessene Folge: die publizierten Gewichte machen 0.733 gemischt vs 0.61 auf offiziellem Split. Die offizielle Zahl ist also konservativ. Platt-Kalibrierung / Temperature+Bias transferiert nicht zwischen Batches, also shippen wir nur Temperature-Scaling kalibriert auf frischen Batches. Hausregel: erst drift_tool.py, Warnung über 0.6, Block über 0.8. Kein anderer publiziert das, alle sollten es.

Das Endergebnis, mit Zahlen in der Hand

Auf der einen Seite ein Laptop-i5, auf der anderen eine API, die pro Call abrechnet. So sieht jeder Modus konkret aus:

  • Nano: <1ms, ~18k req/s, 20-650 KB, 0 Deps
  • Mini: ~20ms, ~71 req/s, 22 MB int8, onnxruntime optional
  • Generalist: 20-36ms, 22.7M, der kleinste offene Generalist, den ich kenne

Mehrere Domänen geladen: ~0.5 GB RAM im Lazy-Load pro Domäne statt ~2 GB für alles. Deterministisch: 100/100 identische Inputs geben denselben Output. Schwellen pro Frage, Gate auf confidence, unknown wenn nichts matcht.

Auf Bareiron haben 3+ Spieler geruckelt, weil jeder Spieler einen Chunk für 200ms kostete. Hier laufen 3+ Domänen locker durch, weil jeder Experte winzig ist und bei Bedarf geladen wird, statt alles auf einmal zu laden. Dieselbe Philo vom "Kleinen, das reicht", angewandt auf andere Hardware.

Die Philo

p2r3 sagte: "Ich mag einfach die Idee, dass dieser winzige 1-Euro-Chip etwas so Fortgeschrittenes wie Minecraft laufen lassen kann."

Gleicher Trip hier: ich mag die Idee, dass 20 KB JSON und hundert Zeilen TS einen GPU-Call ersetzen, um ein Label zu wählen. Das ersetzt natürlich nicht alles, muss man nicht diskutieren. Es ersetzt den nervigen Fall, wo du ein Label in Millisekunden willst, lokal, mit einer Confidence, die du halbwegs sauber threshholden kannst statt Daumen zu drücken.

Die Tradeoffs, schwarz auf weiß:

  • Encoder -> Wortzählung: 7 Punkte weniger, Latenz durch 1000 geteilt, Infra auf null
  • einzelnes großes Modell -> npm-Plugins: gröberes Gate, unendlich einfachere Ops
  • Generierung -> typisierte Entscheidung: Schluss mit kaputtem Parsing und erfundenen Optionen
  • rohe Confidence -> Pre-Triage: erst Enthaltung, Automatisierung über 0.85, Mensch für den Rest

Die 3 Dinger zum Mitnehmen:

  1. 7 Punkte weniger, 1000x schneller -- 0.61/0.69 vs 0.75-0.80, aber <1ms/20ms CPU offline gegen 25s CPU oder Bezahl-API. Ein Score ohne seine Kosten heißt nichts.
  2. Simpler Router + getrennte Experten -- hits / sqrt(keywords), Threshold, unknown. Jedes Plugin trainiert, versioniert und shippt allein. Auf Mail klebt TF-IDF an MiniLM: die GPU bringt hier nichts.
  3. Offline-first mit ehrlicher Kalibrierung -- npm i, Gewichte eingebettet, 0 GPU, ECE < 0.14, Enthaltung, Drift-Tool. Der einzige Stack im Vergleich, der eine Game-Loop mit 35Hz ohne GPU hält.

Wenn dich das Projekt interessiert, alles ist MIT auf github.com/sabaki-js. Die akademische Version mit Messungen und Bibtex ist das Preprint v1. Und wenn du Goliath live Doom spielen sehen willst, ab zu azalio/doomLaya xD

NEW

Sabaki vs System One : Давид против Голиафа, самая маленькая decision-модель в мире

20КБ против 421M параметров. Sabaki теряет 7 пунктов на typed-decisions, но уходит с 25s CPU до меньше 1ms в офлайне. Анатомия организационного MoE.

Введение

Если ты с сентября сидишь на Hacker News или в Twitter, ты точно видел Jev.

15 сентября 2026 TypeSafe выпускает Jev, свой первый "System One". Питч укладывается в одну фразу : хватит просить LLM сгенерировать JSON, чтобы выбрать лейбл. Даёшь ему state плюс типизированные вопросы, он возвращает тебе типизированные решения с вероятностями. Один проход, ноль токенов на семплирование, ничего парсить на выходе.

Это взлетело. Рекордный первый день адопшена на Vercel AI Gateway, пять клиентов на Elixir за несколько дней, все бенчмаркают Jev против своей штуки. И честно, хайп понятен. Мы годами колхозили промпты отвечай ТОЛЬКО {"choice": ...} и молились, чтобы модель не выдумала опцию, не сломала JSON, не галлюцинировала поле. Jev дал этой потребности имя и зашипил чистое API : POST /v1/systemone, вопросы choice / score / noul, ответы с probabilities + confidence.

Мне идея зашла. Эко-модель, меньше -- и чтобы было ясно, я за Jev никогда не платил.

Потому что под капотом Jev всё равно закрытое API. Веса неизвестны, селф-хоста нет, каждое решение = сетевой round-trip + счётчик, который крутится. А опенсорсные альтернативы, которые вышли следом, весят под 400M параметров с обязательным GPU.

Поэтому я построил другую крайность : Sabaki. Роутер по ключевым словам + логистическая регрессия на чистом TypeScript, которые говорят на том же контракте, что Jev (те же шейпы choice / score / noul, тот же эндпоинт /v1/systemone), но крутятся за меньше 1ms с JSON в 20 килобайт. Ноль зависимостей в nano-режиме, офлайн, 0€.

Для масштаба : Laya это 843 Мб safetensors под загрузку. OpenDecider-nano это 2 Гб RAM. Sabaki nano invoices это 20 Кб. Разница в весе где-то в 40 000 раз.

Если хочешь строгую версию с цифрами, есть препринт v1 от октября 2026 на сайте : Sabaki: An Organizational Mixture-of-Experts for Typed Decisions on CPU (PDF). Дизайн, замеры, трейдоффы, всё там. А эта статья -- читаемая версия, с открытым кодом рядом.

Я писал это не в углу в режиме бумажного бенчмарка. Я контрибьючу в azalio/doomLaya (мой форк : fox3000foxy/doomLaya), харнесс, где Laya и Jev реально играют во FreeDoom с model-owned решениями, телеметрией по тикам и видосами. Я видел Голиафа в игре вживую, и как только сеть чихает, он тоже.

Давай вскрывать движки по одному. Как обычно, с настоящим кодом.

Ринг, на котором согласны драться все

Есть один датасет, который цитируют Jev, Laya и OpenDecider : LocalLLaMA/typed-decisions. 4 синтетических воркфлоу (поддержка, счета, секьюрность, трейсы агентов), 5 вопросов на кейс, 1200 кейсов в трейне, 400 в тесте, 2000 решений.

Модель Параметры Скор на тесте Латентность Где крутится
OpenDecider-nano ~400M 0.796 17ms GPU GPU, 2 Гб RAM
Laya typed-decisions 421M 0.766 ~25s/state CPU, ~830ms/кейс MLX GPU
Jev 1.13 закрыт ~0.73-0.75 ~710ms API только API
Sabaki mini 22M int8 0.69 ~20ms CPU CPU, 22 Мб
Sabaki nano ~0 0.61 <1ms везде
Laya base zero-shot 421M 0.362 так же GPU

Рандом это 0.318, мажорити 0.461. Так что Laya base с 0.362 без файн-тюна, это уровень "отвечаю наугад, но вежливо". Запомни это, пригодится.

7 пунктов отделяют Sabaki от лучшего. 1000x латентности отделяют их в другую сторону, когда у тебя нет GPU. Вот тебе вся статья в двух фразах, остальное -- чтобы понять почему.

Только осторожно : за одинаковой розеткой choice / score / noul кухни совершенно разные. Jev = закрытое API. Laya = дообученный ModernBERT-large. OpenDecider = дистиллированный Ettin-encoder. Sabaki = подсчёт слов + MiniLM int8. Один контракт, несравнимые бюджеты.

Jev, пощёчина, которую видели все

Отдадим TypeSafe должное : контракт System One, это реально хорошая идея.

Раньше, чтобы рассортировать тикет, ты делал так : просил LLM "выбери между billing, technical и spam, ответь в JSON", парсил ответ, разгревал случаи, когда он отвечает "Я бы сказал billing! 😊" или выдумывает 4-ю категорию. Каждый вопрос стоил токенов, каждый ответ мог сломать пайплайн.

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

curl https://api.typesafe.ai/v1/systemone \
 -H "Authorization: Bearer $KEY" \
 -H 'content-type: application/json' \
 -d '{"state":"charge double ce mois-ci","questions":{"dept":{"type":"choice","instructions":"Route ce message.","criteria":{"billing":"paiements","technical":"bugs"}}}}}'

Ответ : { choice, probabilities, confidence }. Никакого свободного текста. Твой код может ветвиться прямо по нему.

Поэтому это и стало вирусным : SDK на Python + JS, плагин Claude Code, интеграция LangChain, все могли посадить своих агентов на него за полдня. Дока TypeSafe даже толкает здоровый паттерн : Jev для мелких быстрых суждений, детерминированный код вокруг для сборки настоящего решения, медленный LLM или человек как только уверенность низкая.

Беда в остальном. Веса закрыты, офлайн невозможен, цена за вызов. Для агента, который принимает 100 решений за ран, налог кусается. Для Doom на 35 тиков/с это смерть : в doomLaya Jev проходит MAP01 seed 48 за 68.97s с p50 HTTP 357ms. Ран приносит $0.0046 цены API -- не мой счёт, а счёт бенчмарка. Это работает, ран чистый, без смертей. Но 357ms при 35 тиках/с, это 12 тиков, пока игра идёт дальше, а ты ждёшь сеть.

Sabaki спёр ровно этот контракт -- те же вопросы, те же ответы, тот же эндпоинт /v1/systemone, который отдаёт serve() -- выкинув всё, что требует дата-центр.

Laya, жирный опенсорс, который показал, что можно без API

Laya от Convai Innovations, Apache-2.0. Под капотом : ModernBERT-large, 421M, с 1 токеном [MASK] на опцию. Хиден-стейт каждого маркера идёт в маленький MLP, софтмакс по опциям вопроса. Один проход на вопрос, как у Jev.

pipe = pipeline("text-classification", model="convaiinnovations/laya-typed-decisions")

На 0.766 стоит остановиться. Это чекпоинт дообученный на трейне бенчмарка : 1200 кейсов, RLCD с гауссовым шумом на логитах, reward log + spherical. Та же модель без этого фита падает до 0.362. Считай, гадает. Так что скор Laya, это "жирный энкодер + обучение на домене", а не "жирный энкодер-магия".

На CPU это кирпич. Замеренный ONNX-порт крутится около 25s на state. На MLX FP16 считай 830ms на кейс в среднем с p99 5.3s. И надо загрузить 843 Мб прежде чем вообще что-то ответить.

В doomLaya стоковая Laya валит MAP01 за 180s. Адаптированная v3 (azalio/laya-doom-v3), переобученная на Doom, выходит за 61.43s с p50 108ms на MPS, по пути подбирая шотган. Так что да, это играет -- после dedicated-обучения, на Apple GPU, со скриптованным экзекьютором, который делает настоящую работу (A*, двери с латчем 15 тиков, автонаведение).

Мой итог : без обучения на твоей задаче Laya служит тебе буфером обмена за 421M. С ним -- очень сильно, но давай GPU и данные в комплекте.

OpenDecider, тот, кто выиграл лидерборд

OpenDecider, это Manjunath, который решил забрать всех на их же бенчмарке. Итог : 0.796 с Ettin-encoder-400m в полном файн-тюне + MLP-голова, KL-лосс на софт-таргетах, дистиллированных из Qwen3-235B + DeepSeek.

pip install opendecider
# 17ms/вопрос на L40S, 18ms на M4 Max, ~9ms в батче

При 50% coverage selective accuracy растёт до 0.943 против 0.882 у Jev. На бумаге лучшее из двух миров : опен, откалиброван, быстр.

На практике : 2 Гб RAM чтобы загрузить nano, GPU обязателен чтобы держать 17ms, фишинг 0.63 против 0.90 у Jev (OOD его умывает), и как Laya, обучен на трейне бенчмарка. Тест никто не видел, но домен -- да.

Армрестлинг он выигрывает, но это всё равно 400M с GPU за спиной. Ты его не засунешь в Cloudflare Worker или в игровой цикл на ноуте.

150M, и дыра под ними

openJev-verdict-2.0 (151M), julia-1 (144M мультиязычная), Open Jev (150M), Largha-150M (144M en+zh), Vela (0.3B-9B). Все ModernBERT или mmBERT-small, все умеют крутиться на CPU, все приятно обучать (Open Jev заявляет 30 мин на T4 в Colab). Цифры self-reported, протоколы разные, так что бери с щипцами, но семейка существует.

Дженералист Sabaki весит 22.7M. Примерно в 6.5x меньше самого мелкого в этой банде. Никто другой не спускается ниже 100M чтобы отвечать на вопросы, заданные в рантайме.

Вот что такое "самый мелкий в мире" : не лучший скор, а самая маленькая упаковка, которая читает твои instructions + criteria и решает, когда ни один специалист ответить не может.

Sabaki, тупой роутер и его одноразовые эксперты

Так, хорош о жирных. Переходим к Давиду, потому что тут начинается веселье.

Sabaki не пытается быть жирной умной моделью, которая понимает мир. Это организационный MoE : 1 разреженный роутер на уровне системы, независимые эксперты, один активный на инпут. Каждый плагин -- отдельный npm-пакет, со своим манифестом, весами, версией. Ставишь только то, что роутишь.

Орга sabaki-js : 13 репо, ядро sabaki (129 коммитов), 6 плагинов @sabaki-js/*, веса, Cloudflare-воркер, шаблон чтобы написать свой.

package.json ядра уже рассказывает историю :

"dependencies": { "@typesafe-ai/sdk": "^0.7.0" },
"optionalDependencies": { "onnxruntime-node": "^1.22.0" }

В nano-режиме ноль рантайм-зависимостей. Только скомпилированный TypeScript, ставить нечего, ломаться между версиями ONNX нечему.

Роутер, или как решать в несколько строк

Роутер живёт в src/index.ts, а токенизация выглядит так (src/index.ts:129) :

function tokens(s: string): string[] {
  return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .split(/[^a-z0-9]+/).filter((w) => w.length > 3);
}

Нижний регистр, акценты снесены, слова от 4 букв. Этот фильтр выкидывает весь шум ("le", "de", "ok"). На саппорт-тикетах это всё меняет, потому что матчишься по словам, которые несут смысл.

Сам скоринг лежит в route() (src/index.ts:251), и тут решается всё :

const hits = kws.filter((k) =>
  words.some((w) => w.includes(k) || k.includes(w))).length;
const score = hits / Math.sqrt(kws.length);

Матчинг свободный в обе стороны : billing цепляет billed, invoice цепляет invoicing. Деление на корень не даёт задавить большим спискам кейвордов, иначе мейл-плагин со своими 600 словами выжирал бы всех, как только в письме есть "hello".

У каждого плагина свой порог, дефолт 0.15 лежит в src/index.ts:139, у мейла 0.03 потому что темы короткие. Ниже :

return { domain: "unknown", decision: null, confidence: 0, plugin: null };

Неизвестный домен = unknown, никогда никакого выдуманного гесса чтобы порадовать. Подход нарочно тупой, и ровно поэтому потом работает порог automate > 0.8 : когда Sabaki отвечает, значит реально сматчился специалист.

Впридачу есть языковая ось через langid : плагин, который заявляет languages, конкурирует только за свои языки. Удобно разводить латинский фишинг и CJK. Текст слишком короткий = без фильтра, конкурируют все.

Итоговая цена : несколько микросекунд на решение. Никаких матриц умножать, никаких сетей дёргать, только сравнения строк и одно деление.

Nano, мой любимый файл в репо

src/logreg.ts:28, токенизация :

function tokens(s: string): string[] {
  const words = s.toLowerCase().split(/[^a-z0-9]+/)
    .filter((w) => /^[a-z]{3,}$/.test(w));
  const out = [...words];
  for (let i = 0; i < words.length - 1; i++)
    out.push(`${words[i]} ${words[i + 1]}`);
  return out;
}

Униграммы + биграммы. charged twice даёт charged, twice, и пару charged twice. Этого хватает чтобы поймать "двойное списание" без транса на 400M.

src/logreg.ts:46, векторизация, где TF-IDF делает работу :

const tf = 1 + Math.log(c);  // сублинейный TF
const val = tf * w.idf[i]!;  // сглаженный IDF как в sklearn

10-е вхождение слова весит меньше первого, редкие слова весят больше чем "please". Ровно рецепт sklearn, точная паритетность проверена на 20 вопросах.

И коммент, который заставил меня улыбнуться при перечитывании :

// Трогаем только присутствующие термы (разреженно) вместо прогона по 3000 dims.
for (const [t, c] of counts) {

Вместо прогона 3000 дименшнов на каждый тикет, ты трогаешь только пару десятков присутствующих слов. Вот почему это отвечает за меньше 1ms. Та самая невидимая деталь, которая всё меняет в проде, потому что сложность идёт от размера тикета, а не словаря.

src/logreg.ts:86, инференс, который укладывается в три строки :

// бинарник: 1 строка коэфов + сигмоида
const pPos = 1 / (1 + Math.exp(-s));
// мультикласс: скалярное произведение + softmax, argmax

Одно скалярное произведение, сигмоида или софтмакс в зависимости от числа классов. Один проход и всё. Ноль сгенерированных токенов, значит ноль сломанного JSON, который потом чинить ретраями и эвристиками парсинга.

Рецепт train.py : 300 трейн / 100 тест на воркфлоу, детерминированный сплит (каждый 5-й айтем в тест чтобы убрать временной байас), словарь 3000-6000, коэфы округлены до 1e-4 с нулевой замеренной потерей. Экспорт в JSON { vocab, idf, questions }.

Веса : 20 Кб для invoices (duplicate 0.89) до 650 Кб для mail (bucket 0.889). Размер, который лезет во фронтенд-бандл.

Деталь, которая меня прибила : на мейле MiniLM жмётся к TF-IDF в +/-0.02. Так что nano шипается как есть. Платить за GPU чтобы сортировать почту, это без меня. А вот agent_trace буксует (needs_review 0.59 в nano) : текст обсервебилити бедный, ни одно слово реально не тянет решение.

Фабрика, которая превращает веса в плагин, в src/plugin.ts:52 :

export function nanoClassifier(manifest, weights, opts = {}) {
  return definePlugin({ manifest,
    classify: (input) => {
      const r = predict(`${from}${input.state}`, weights, q);
      return { decision: r.decision, confidence: r.confidence, domain: manifest.domain };
    }});
}

decide() остаётся синхронным для nano, decideAsync() открывает ONNX для mini. Два пути, одно простое правило : если тебе нужен async, значит хочешь mini, иначе nano хватает. Легко запомнить когда кодишь, легко дебажить когда ломается.

Чтобы показать, что это не просто теория, вот как выглядит реальное использование :

import { Sabaki } from "sabaki";
import customerService from "@sabaki-js/customer-service";

const sabaki = new Sabaki().register(customerService);
sabaki.decide({ state: "charged twice, refund please" });
// { decision: "billing", confidence: 0.31, domain: "customer_service" }

Mini, 22 Мб, которые меняют игру на сложных кейсах

У Nano есть пределы, ты видел их на agent_trace чуть выше. MiniLM-L6 подхватывает флаг : один shared-энкодер на весь домен, плюс линейная голова на вопрос. Файн-тюн end-to-end 8-12 эпох, lr 2e-5, на GPU CUDA для обучения (инференс всё равно CPU, это и важно).

Цифры, которые всё решили, замерены на тех же сплитах :

  • замороженный энкодер + MLP : 0.65
  • полный файн-тюн : 0.68
  • дистилляция 400M в замороженные эмбеддинги : попробовали, выкинули, хуже чем gold-лейблы

Экспорт в ONNX через dynamo, точный паритет проверен, потом динамическая квантизация int8 : 90 Мб -> 22 Мб, -0.004 accuracy на customer_service. ECE неизменна или лучше. Каждый экспорт перемеряется, иначе оптимизация ничего не значит.

С 1.0.2 веса лежат в пакете (models/<workflow>-int8/ + экспортированный modelDir). Хватает npm i, самолётный режим ок. Обновить = опубликовать патч, подписанный через trusted publishing.

Конкретно : needs_review прыгает с 0.59 до 0.82. disposition 0.73 -> 0.76. ECE < 0.14 везде, с честным OOD-сомнением (0.2-0.5 на драконах, рецептах и пустых строках). Это сомнение и разрешает агрессивное порогование в проде.

Generalist, catch-all, который делает Sabaki настоящим System One

Jev и остальные отвечают на всё : любой вопрос, любые опции, заданные в рантайме. Это и есть контракт System One, который сделал Jev вирусным, -- ты задаёшь свои instructions + criteria со своими опциями, он решает.

Специалисты Sabaki так не умеют. Они знают свои вопросы (category, duplicate, bucket) и больше ничего. Без страховки Sabaki возвращал бы unknown как только ты уходишь со скрипта, там где Jev всегда что-то отвечает.

Дженералист это и есть страховка. Кросс-энкодер, который читает вопрос + опцию + state и выдаёт скор, один проход на опцию, софтмакс, argmax. 22.7M MiniLM int8 -- в 6.5x меньше чем 144-151M у банды. Но его работа не скор, а совместимость : с ним в фолбэке POST /v1/systemone принимает любой state и любые вопросы, как Jev. Поле model говорит, кто реально ответил (@sabaki-js/[email protected]+mini, sabaki-generalist-v1...), так что на вызывающей стороне ты ничего не меняешь.

И главное, это фолбэк, а не босс. Золотое правило в decideAsync() : если выигрывает роутинг специалиста, отвечает он. Дженералист говорит только на unknown. Никогда наоборот. Это не даёт среднему дженералисту затоптать хорошего специалиста на его поле.

Лоадер живёт в src/generalist.ts, с кэшем по папке чтобы не перезагружать ONNX-сессию на каждый вызов :

export function loadGeneralist(modelDir: string) {
  // кэш по папке, ONNX-сессия переиспользуется
  // tokenizer.json -> Unigram или wordpiece, автоопределение
  const sess = await ort.InferenceSession.create(
    path.join(modelDir, "generalist.onnx"),
    { intraOpNumThreads: 2 },
  );
}

Ты указываешь SABAKI_GENERALIST_DIR на папку с generalist.onnx + tokenizer/, модель swappable. Id берётся из config.json, иначе "sabaki-generalist". Кэш на Map спасает от перезагрузки сессии на каждый вызов.

Формат входа, это классический кросс-энкодер : вопрос, опция и state склеены с разделителями, по строке на опцию для скоринга :

const texts = options.map(
  (o) => `${input.question ?? "decision"} [SEP] ${o} [SEP] ${input.state}`,
);

Вопрос + опция + state, через [SEP], по строке на опцию. Опции капаны на 8 (slice(0, 8)), дефолт ["yes", "no"] если ты ничего не дал. Дальше режь на вызывающей стороне, -- 8 уже широко для типизированного решения.

Дальше идёт умный батч, та оптимизация, которая выглядит скромно, но делит латентность на число опций. Вместо одного session.run на опцию, всё летит одним форвардом :

// Один батчевый форвард на все опции
// (добито до макс. длины) вместо run на опцию.
const L = Math.max(...encs.map((e) => e.ids.length));
// ids + attention_mask в [options.length, L], паддинг до padId
const out = await sess.run({ input_ids, attention_mask });
const scores = Array.from(out["score"].data).map(Number);

Каждый текст кодируется (wordpiece в стиле BERT или unigram SentencePiece по tokenizer.json), добивается до макс. длины батча, складывается в тензор [nb_options, L]. Один sess.run, забираешь скор на опцию. На CPU это всё меняет : 20-36ms вместо 8x этого времени.

Потом софтмакс + argmax, с полными пробами на каждую опцию, ровно в том формате, который популяризовал Jev :

const pr = softmax(scores);
// bi = индекс макса
const confidence = Math.round(pr[bi] * 1000) / 1000;
const probs = Object.fromEntries(
  options.map((o, i) => [o, Math.round(pr[i] * 1000) / 1000]),
);

А деталь, которая делает штуку пригодной в проде, а не демкой, это abstainAt :

if (input.abstainAt !== undefined && confidence < input.abstainAt) {
  return { decision: null, confidence, domain: "generalist", probabilities: probs };
}

Opt-in, без abstention по дефолту. Передаёшь abstainAt: 0.6-0.7 и всё вялое возвращается null вместо гесса. Учитывая, что софтмакс выдаёт 1.00 даже когда неправ вне распределения, это не роскошь. GUIDE рекомендует его для любого автономного использования : ниже порога просишь уточнение вместо авторезолва.

В конце вот та же сборка с фолбэк-дженералистом : когда ни один специалист не матчится, подхватывает он вместо возврата unknown.

import { Sabaki, definePlugin, loadGeneralist } from "sabaki";
import mail from "@sabaki-js/mail";

const backend = loadGeneralist(process.env.SABAKI_GENERALIST_DIR!);
const generalist = definePlugin({
  manifest: { name: "@sabaki-js/generalist", domain: "generalist",
    version: "0.1.0", questions: ["decision"], keywords: [] },
  classify: (input) => backend.decide(input),
});

const sabaki = new Sabaki().register(mail).setFallback(generalist);

await sabaki.decideAsync({
  state: "le client dit que l'app a supprimé ses données",
  question: "c'est urgent ?",
  options: ["yes", "no"],
  abstainAt: 0.65,
});
// специалист mail если роутинг сматчился, иначе generalist как catch-all в стиле Jev

Генерации дженералиста, все замерены одинаково на PhishNChips (2000 писем) и на тесте typed-decisions чтобы сравнивать сравнимое :

  • v4 : 0.29 тест, R=0.026 фишинг. В помойку, отвечал мимо.
  • A (22M 80k) : 0.546, R=0.406. Уже начинает на что-то походить.
  • mini v1 на проде : 0.536, R=0.616. Это та, что крутится на сайте. Вменяемый recall фишинга, средний тест.
  • B (144M scratch) : 0.568, R=0.062. Больше, чуть лучше в тесте, никакой в фишинге.

144M со скретча, который не трансферит лучше, хорошо остудил меня насчёт "накинуть параметров чтобы обобщить". При равной задаче жирный стоит дороже, а гадает не лучше. Даже как catch-all мелкий тащит. И он остаётся EN-only (all-MiniLM-L6-v2), роутинг EN, FR вне распределения. Для FR иди через специалиста, обученного на нём, а не через дженералиста.

Doom, единственный тест, который мне реально важен

doomLaya, это Laya и Jev, которые играют во FreeDoom по-настоящему. Модель выбирает command / target / weapon, экзекьютор ведёт по A*, открывает двери, целится, кликает. Телеметрия по тикам, видосы, SHA ранов. Я туда контрибьючу, так что говорю не про лидерборд, вычитанный издалека.

v0.1.0, MAP01 seed 48, skill 3, 35 тиков/с, тот же экзекьютор :

Модель Результат p50 HTTP Цена API
Laya stock провал 180s 106ms $0
Laya v3 адаптированная выход 61.43s 108ms $0
Jev 1.13 выход 68.97s 357ms $0.0046

Два чистых выхода, без смертей, Laya по пути подбирает шотган потому что экзекьютор сам рулит подбор. Честно : Laya v3 обучена на Doom, Jev нет. Один сид, одна карта, это ничего общего не доказывает. README проекта так и говорит чёрным по белому, и это делает ему честь, что результат не oversellится.

Со стороны Sabaki (plugin-doom) : ViZDoom на Python, который дёргает JS-сервер каждый тик. Nano ~18k req/s, mini ~71 req/s на одном потоке i5. Тик 35Hz проходит с запасом, даже на среднем ноуте.

С компасом выхода mini проходит MAP01 в 2-4 ранах из 6, nano 0/6. Логично : текст теряет spatial, состояния doomLaya упираются где-то в 0.57 что на MiniLM, что на ModernBERT-base. Когда пространственной инфы нет, никакой текстовый классификатор её не выдумает.

Соседи пересняли матч на равных данных : doomgemma вешает LoRA на 27 мин на RTX 4070 и доводит Gemma 4 E2B до уровня Laya v3 в игре (6/6 vs 6/6) и выше в изолированных решениях. При равном размере декодер идёт так же быстро как Laya (270M : 22ms vs 23ms). При равном результате Laya остаётся ~3x быстрее потому что добирается туда меньшей моделью.

Мой вывод : обучение на задаче важнее архитектуры. Мелкий специалист, обученный под задачу, бьёт жирного дженералиста, который видит её впервые. Ровно ставка Sabaki. Следующая бумага, System Switch (arXiv:2610.09683), дожимает логику до конца : быстрая мелкая модель играет, жирный рассуждающий подхватывает когда открывается gate. Sabaki pre-triage (automate > 0.85 иначе эскалация), применённый к игре.

Чем пожертвовали

Каждая отсутствующая фича оплачивает другую, таковы правила когда целишься в CPU. Честный список, как для Bareiron где каждая строка C была трейдоффом :

Sabaki ничего не рассказывает. Он выбирает между опциями, которые ты ему даёшь, с пробой. За объяснением, саммари, свободным текстом иди к LLM.

Французский остаётся вне распределения для дженералиста. Роутинг EN (all-MiniLM-L6-v2), зонд 1/4 FR vs 3/4 EN на похожих кейсах. Мейл-плагин приехал из в основном FR-ящика, но анонимизированного : 0 PII, -0.024 accuracy за словарь без персональных термов. Анонимизация стоит денег, но это было non negotiable.

Софтмакс слишком уверен когда выходит из своей зоны. Ложняки с 1.00 видали, да. Отсюда рекомендованный pre-triage в GUIDE : abstainAt: 0.6-0.7 чтобы отдавать null под порогом, потом gate confidence > 0.85 на автомат, человек на остальное. Замеренный референс : recall фишинга 0.62 при precision 0.87. Хватает чтобы разрезать очередь пополам, не хватает чтобы уволить ревьюеров.

Nano буксует на трейсах агентов. 0.34 до 0.59 в зависимости от вопроса. Mini поднимает needs_review до 0.82, но risk / urgency / action остаются тяжёлыми. Текст обсервебилити просто беден на лексические сигналы.

Doom в тексте упирается. Где-то в 0.57, что MiniLM, что ModernBERT-base. Пока сидишь на тексте без пикселей, MAP02 тебя стопает. Вменяемый путь к реально играющему -- vision + RL (tiny-doom-defender, 1.1M параметров с пикселей), а не лучший текстовый классификатор.

Бенчмарк дрейфует батчами. typed-decisions сгенерирован пачками, временной сплит по факту сплит батчей. drift_tool.py выдаёт 0.82 на customer_service, 0.67 на agent_trace (0.5 = неразличимо). Замеренное следствие : опубликованные веса дают 0.733 в миксе vs 0.61 на официальном сплите. Так что официальная цифра консервативна. Калибрация Platt / temperature+bias не трансферится между батчами, поэтому шипаем только temperature scaling, откалиброванный на свежих батчах. Домашнее правило : сначала drift_tool.py, warn выше 0.6, block выше 0.8. Никто другой это не публикует, а должны бы все.

Финальный результат, с цифрами на руках

С одной стороны ноутбучный i5, с другой API со счётчиком за вызов. Вот что каждый режим даёт конкретно :

  • nano : <1ms, ~18k req/s, 20-650 Кб, 0 зависимостей
  • mini : ~20ms, ~71 req/s, 22 Мб int8, onnxruntime опционально
  • generalist : 20-36ms, 22.7M, самый мелкий опен-дженералист что я знаю

Несколько доменов загружены : ~0.5 Гб RAM в lazy-load по домену вместо ~2 Гб за всё. Детерминированно : 100/100 одинаковых инпутов дают одинаковый аутпут. Пороги на вопрос, gate на confidence, unknown когда ничего не матчится.

На Bareiron 3+ игроков тормозили потому что каждый игрок стоил чанк в 200ms. Тут 3+ домена проходят не напрягаясь, потому что каждый эксперт крошечный и грузится по требованию вместо загрузки всего сразу. Та же философия "мелкого, которого хватает", на другом железе.

Философия

p2r3 говорил : "Мне просто нравится идея, что эта крошечная чипуха за 1 бакс может крутить что-то настолько продвинутое как Minecraft."

Тот же вайб тут : мне нравится идея, что 20 Кб JSON и сотня строк TS заменяют вызов GPU чтобы выбрать лейбл. Это очевидно заменяет не всё, не надо гнать. Это заменяет душный кейс где тебе нужен лейбл за миллисекунды, локально, с уверенностью, которую можно вменяемо пороговать вместо скрещенных пальцев.

Трейдоффы, чёрным по белому :

  • энкодер -> подсчёт слов : минус 7 пунктов, латентность делить на 1000, инфры ноль
  • жирная уникальная модель -> npm-плагины : gate грубее, опсы бесконечно проще
  • генерация -> типизированное решение : конец ломающемуся парсингу и выдуманным опциям
  • сырая уверенность -> pre-triage : сначала abstain, автоматизация выше 0.85, человек на остальное

3 вещи для запоминания :

  1. 7 пунктов меньше, в 1000x быстрее -- 0.61/0.69 vs 0.75-0.80, но <1ms/20ms CPU в офлайне против 25s CPU или платного API. Скор без его цены ничего не значит.
  2. Простой роутер + отдельные эксперты -- hits / sqrt(keywords), threshold, unknown. Каждый плагин тренируется, версионируется и шипается отдельно. На мейле TF-IDF жмётся к MiniLM : GPU тут ни к чему.
  3. Offline-first с честной калибровкой -- npm i, веса в комплекте, 0 GPU, ECE < 0.14, abstain, drift tool. Единственный стек из сравнения, который держит игровой цикл на 35Hz без GPU.

Если проект интересен, всё в MIT на github.com/sabaki-js. Академическая версия с замерами и bibtex, это препринт v1. А если хочешь увидеть Голиафа в Doom вживую, тебе в azalio/doomLaya xD

NEW

Sabaki vs System One: David contra Goliat, el modelo de decisión más pequeño del mundo

20KB contra 421M parámetros. Sabaki pierde 7 puntos en typed-decisions pero pasa de 25s en CPU a menos de 1ms offline. Anatomía de un MoE organizacional.

Introducción

Si te pasas por Hacker News o Twitter desde septiembre, seguro que has visto a Jev.

El 15 de septiembre de 2026, TypeSafe saca Jev, su primer "System One". El pitch cabe en una frase: deja de pedirle a un LLM que genere JSON para elegir una etiqueta. Pásale un state más unas preguntas tipadas, y te devuelve decisiones tipadas con sus probas. Una pasada, cero tokens que muestrear, nada que parsear después.

Lo petó. Primer día de adopción récord en Vercel AI Gateway, cinco clientes Elixir en pocos días, todo el mundo bencheando Jev contra su invento. Y la verdad es que el hype se entiende. Llevábamos años apañándonos con prompts de responde SOLO con {"choice": ...} rezando para que el modelo no se inventara una opción, no rompiera el JSON, no alucinara un campo. Jev le puso nombre a esa necesidad y lo sirvió en una API limpia: POST /v1/systemone, preguntas choice / score / noul, respuestas con probabilities + confidence.

Me encantó la idea. El modelo eco, menos -- y para que quede claro, nunca pagué por Jev.

Porque por detrás, Jev sigue siendo una API cerrada. Pesos desconocidos, sin self-host, cada decisión = un viaje de ida y vuelta por red + un contador que corre. Y las alternativas open que llegaron después se van a los 400M de parámetros con GPU obligatoria.

Así que construí el otro extremo: Sabaki. Un router por palabras clave + una regresión logística en TypeScript puro que habla el mismo contrato que Jev (mismas formas choice / score / noul, mismo endpoint /v1/systemone), pero que corre en menos de 1ms con 20 kilobytes de JSON. Cero dependencias en modo nano, offline, 0€.

Para ubicarte: Laya son 843 MB de safetensors que cargar. OpenDecider-nano son 2 GB de RAM. Sabaki nano invoices son 20 KB. La relación en peso ronda los 40 000.

Si quieres la versión seria y con números, tienes el preprint v1 de octubre de 2026 en el sitio: Sabaki: An Organizational Mixture-of-Experts for Typed Decisions on CPU (PDF). Diseño, medidas, tradeoffs, todo está ahí. Este artículo es la versión legible, con el código abierto al lado.

No lo escribí en mi rincón en plan benchmark de papel. Contribuyo a azalio/doomLaya (mi fork: fox3000foxy/doomLaya), un harness donde Laya y Jev juegan de verdad a FreeDoom con decisiones model-owned, telemetría por tick y vídeos. Vi jugar a Goliat en vivo, y en cuanto la red tose, él también.

Vamos a abrir los motores uno por uno. Como siempre, con el código de verdad.

El ring donde todos aceptan pelear

Hay un solo dataset que citan Jev, Laya y OpenDecider: LocalLLaMA/typed-decisions. 4 workflows sintéticos (soporte, facturas, seguridad, trazas de agentes), 5 preguntas por caso, 1200 casos de train, 400 de test, 2000 decisiones.

Modelo Params Score test Latencia Dónde corre
OpenDecider-nano ~400M 0.796 17ms GPU GPU, 2 GB RAM
Laya typed-decisions 421M 0.766 ~25s/state CPU, ~830ms/caso MLX GPU
Jev 1.13 cerrado ~0.73-0.75 ~710ms API solo API
Sabaki mini 22M int8 0.69 ~20ms CPU CPU, 22 MB
Sabaki nano ~0 0.61 <1ms en todas partes
Laya base cero-shot 421M 0.362 igual GPU

El azar es 0.318, la mayoría 0.461. O sea que Laya base con 0.362 sin fine-tune está al nivel de "respondo al azar siendo educado". Quédate con eso, te va a servir.

7 puntos separan a Sabaki del mejor. 1000x de latencia los separan en la otra dirección cuando no tienes GPU. Todo el artículo en dos frases, el resto es para entender por qué.

Ojo de todos modos: detrás de la misma toma choice / score / noul, las cocinas no tienen nada que ver. Jev = API cerrada. Laya = ModernBERT-large fine-tuneado. OpenDecider = Ettin-encoder destilado. Sabaki = conteo de palabras + MiniLM int8. Mismo contrato, presupuestos incomparables.

Jev, la bofetada que todos vieron pasar

Hay que darle a TypeSafe lo que es de TypeSafe: el contrato System One es una idea muy buena de verdad.

Antes, para clasificar un ticket, hacías esto: le pedías a un LLM "elige entre billing, technical y spam, responde en JSON", parseabas la respuesta, gestionabas los casos en los que responde "¡Yo diría billing! 😊" o se inventa una cuarta categoría. Cada pregunta costaba tokens, cada respuesta podía romperte el pipeline.

Jev cambia el trato: envías el texto + unas preguntas con sus criterios, evalúa cada pregunta en paralelo y te devuelve un valor tipado con su distribución.

curl https://api.typesafe.ai/v1/systemone \
 -H "Authorization: Bearer $KEY" \
 -H 'content-type: application/json' \
 -d '{"state":"charge double ce mois-ci","questions":{"dept":{"type":"choice","instructions":"Route ce message.","criteria":{"billing":"paiements","technical":"bugs"}}}}}'

Respuesta: { choice, probabilities, confidence }. Nada de texto libre. Tu código puede engancharse ahí directo.

Por eso se hizo viral: SDK Python + JS, plugin de Claude Code, integración con LangChain, todo el mundo podía conectar sus agentes en una tarde. La doc de TypeSafe incluso empuja el patrón sano: Jev para los juicios rápidos pequeños, código determinista alrededor para componer la decisión de verdad, LLM lento o humano en cuanto la confianza baja.

El problema es lo demás. Pesos cerrados, offline imposible, precio por llamada. Para un agente que toma 100 decisiones por run, el impuesto pica. Para Doom a 35 ticks/s, está muerto: en doomLaya, Jev termina MAP01 seed 48 en 68.97s con un p50 HTTP de 357ms. El run reporta $0.0046 de coste API -- no es mi factura, es la del benchmark. Funciona, el run sale limpio, sin muertes. Pero 357ms a 35 ticks/s son 12 ticks en los que el juego sigue mientras esperas la red.

Sabaki copió exactamente ese contrato -- mismas preguntas, mismas respuestas, mismo endpoint /v1/systemone servido por serve() -- quitando todo lo que pide un datacenter.

Laya, el grande open que demostró que se podía sin API

Laya por Convai Innovations, Apache-2.0. Bajo el capó: ModernBERT-large, 421M, con 1 token [MASK] por opción. El hidden state de cada marcador pasa por un pequeño MLP, softmax sobre las opciones de la pregunta. Una pasada por pregunta, como Jev.

pipe = pipeline("text-classification", model="convaiinnovations/laya-typed-decisions")

El 0.766 merece que nos paremos. Es el checkpoint fine-tuneado sobre el train del benchmark: 1200 casos, RLCD con ruido gaussiano sobre los logits, reward log + spherical. El mismo modelo sin ese ajuste cae a 0.362. O sea que adivina. Así que el score de Laya es "encoder grande + entrenamiento en el dominio", no "encoder grande mágico".

En CPU, es un ladrillo. El port ONNX medido ronda los 25s por state. En MLX FP16, cuenta unos 830ms por caso de media con un p99 de 5.3s. Y hay que cargar 843 MB antes de responder nada.

En doomLaya, la Laya stock falla MAP01 en 180s. La v3 adaptada (azalio/laya-doom-v3), reentrenada con Doom, sale en 61.43s con p50 de 108ms en MPS, recogiendo la escopeta por el camino. O sea que sí, juega -- después de un entrenamiento dedicado, sobre GPU de Apple, con un ejecutor scripteado que hace el trabajo de verdad (A*, puertas con latch de 15 ticks, apuntado auto).

Mi resumen: sin entrenamiento en tu tarea, Laya te sirve de pisapapeles de 421M. Con él, es muy fuerte, pero necesitas la GPU y los datos que van con ella.

OpenDecider, el que ganó el leaderboard

OpenDecider, es Manjunath que decidió ir a por todos en su propio benchmark. Resultado: 0.796 con un Ettin-encoder-400m full fine-tune + cabeza MLP, loss KL sobre soft targets destiladas de Qwen3-235B + DeepSeek.

pip install opendecider
# 17ms/pregunta en L40S, 18ms en M4 Max, ~9ms en batch

Con 50% de coverage, la selective accuracy sube a 0.943 contra 0.882 para Jev. Sobre el papel, lo mejor de los dos mundos: open, calibrado, rápido.

En la práctica: 2 GB de RAM para cargar nano, GPU obligatoria para aguantar los 17ms, phishing en 0.63 contra 0.90 para Jev (el OOD lo barre), y como Laya, entrenado sobre el train del benchmark. El test nunca se vio, pero el dominio sí.

Gana el pulso, pero siguen siendo 400M con una GPU detrás. No lo metes en un Worker de Cloudflare ni en un game loop en un portátil.

Los 150M, y el hueco por debajo

openJev-verdict-2.0 (151M), julia-1 (144M multilingüe), Open Jev (150M), Largha-150M (144M en+zh), Vela (0.3B-9B). Todos ModernBERT o mmBERT-small, todos capaces de correr en CPU, todos majos de entrenar (Open Jev anuncia 30min en un T4 de Colab). Cifras self-reported, protocolos distintos, así que tómalas con pinzas, pero la familia existe.

El generalist de Sabaki hace 22.7M. Unas 6.5x más pequeño que el más chico de esa banda. Nadie más baja de los 100M para responder preguntas definidas en runtime.

Eso es "el más pequeño del mundo": no la mejor nota, la envoltura más pequeña que lee tus instructions + criteria y decide cuando ningún especialista sabe responder.

Sabaki, el router tonto y sus expertos desechables

Vale, ya hablamos bastante de los grandes. Pasemos a David, porque aquí es donde se pone divertido.

Sabaki no intenta ser un modelo grande y listo que entiende el mundo. Es un MoE organizacional: 1 router sparse a nivel sistema, expertos independientes, uno solo activo por input. Cada plugin es un paquete npm separado, con su manifest, sus pesos, su versión. Solo instalas lo que ruteas.

La org sabaki-js: 13 repos, el core sabaki (129 commits), 6 plugins @sabaki-js/*, los pesos, el worker de Cloudflare, la plantilla para escribir el tuyo.

El package.json del core ya cuenta la historia:

"dependencies": { "@typesafe-ai/sdk": "^0.7.0" },
"optionalDependencies": { "onnxruntime-node": "^1.22.0" }

En modo nano, cero dependencias runtime. Solo TypeScript compilado, nada que instalar, nada que pueda romperse entre dos versiones de ONNX.

El router, o cómo decidir en pocas líneas

El router vive en src/index.ts, y la tokenización se ve así (src/index.ts:129):

function tokens(s: string): string[] {
  return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .split(/[^a-z0-9]+/).filter((w) => w.length > 3);
}

Minúsculas, acentos fuera, palabras de 4 letras mínimo. Ese filtro quita todo el ruido ("le", "de", "ok"). En tickets de soporte, lo cambia todo porque tu matching se hace sobre palabras que aportan significado.

El scoring en sí está en route() (src/index.ts:251), y ahí es donde se juega todo:

const hits = kws.filter((k) =>
  words.some((w) => w.includes(k) || k.includes(w))).length;
const score = hits / Math.sqrt(kws.length);

Matching laxo en ambos sentidos: billing caza billed, invoice caza invoicing. Dividir por la raíz evita penalizar las listas grandes de keywords, si no el plugin de mail con sus 600 palabras aplastaría a todos en cuanto un mail contenga "hello".

Cada plugin tiene su umbral, 0.15 por defecto guardado en src/index.ts:139, mail en 0.03 porque los asuntos son cortos. Por debajo:

return { domain: "unknown", decision: null, confidence: 0, plugin: null };

Dominio desconocido = unknown, nunca un invento para quedar bien. Es tonto a propósito, y justo eso hace que el umbral automate > 0.8 sea usable después: cuando Sabaki responde, es porque un especialista de verdad hizo match.

Hay un eje de idioma extra vía langid: un plugin que declara languages solo compite para sus idiomas. Práctico para separar phishing latino y CJK. Texto demasiado corto = sin filtro, compiten todos.

Coste total: unos microsegundos por decisión. Ninguna matriz que multiplicar, ninguna red que llamar, solo comparaciones de strings y una división.

Nano, mi archivo favorito del repo

src/logreg.ts:28, la tokenización:

function tokens(s: string): string[] {
  const words = s.toLowerCase().split(/[^a-z0-9]+/)
    .filter((w) => /^[a-z]{3,}$/.test(w));
  const out = [...words];
  for (let i = 0; i < words.length - 1; i++)
    out.push(`${words[i]} ${words[i + 1]}`);
  return out;
}

Unigramas + bigramas. charged twice da charged, twice, y el par charged twice. Justo lo que hace falta para cazar "doble cobro" sin sacar un transformer de 400M.

src/logreg.ts:46, la vectorización, donde el TF-IDF hace su trabajo:

const tf = 1 + Math.log(c);  // TF sublineal
const val = tf * w.idf[i]!;  // IDF suavizado estilo sklearn

La décima aparición de una palabra cuenta menos que la primera, las palabras raras pesan más que "please". Exactamente la receta de sklearn, paridad exacta verificada en 20 preguntas.

Y el comentario que me hizo sonreír releyendo:

// Toca solo los términos presentes (disperso) en vez de barrer 3000 dims.
for (const [t, c] of counts) {

En vez de barrer 3000 dimensiones en cada ticket, solo tocas la veintena de palabras presentes. Por eso responde en menos de 1ms. Es el tipo de detalle invisible que lo cambia todo en prod, porque la complejidad sigue el tamaño del ticket y no el del vocabulario.

src/logreg.ts:86, la inferencia, que cabe en tres líneas:

// binario: 1 línea de coefs + sigmoide
const pPos = 1 / (1 + Math.exp(-s));
// multiclase: producto escalar + softmax, argmax

Un producto escalar, una sigmoide o un softmax según el número de clases. Una pasada y listo. Cero tokens generados, o sea cero JSON roto que arreglar después con reintentos y heurísticas de parseo.

Receta train.py: 300 train / 100 test por workflow, split determinista (1 de cada 5 ítems en test para evitar sesgo temporal), vocab 3000-6000, coefs redondeados a 1e-4 con cero pérdida medida. Export JSON { vocab, idf, questions }.

Pesos: 20 KB para invoices (duplicate 0.89) hasta 650 KB para mail (bucket 0.889). El tipo de tamaño que entra en un bundle frontend.

El detalle que me dejó loco: en mail, MiniLM empata con TF-IDF en +/-0.02. O sea que nano se sirve tal cual. Pagar una GPU para clasificar mails, paso. En cambio agent_trace se atasca (needs_review 0.59 en nano): el texto de observabilidad es pobre, ninguna palabra sostiene de verdad la decisión.

La fábrica que convierte pesos en plugin, en src/plugin.ts:52:

export function nanoClassifier(manifest, weights, opts = {}) {
  return definePlugin({ manifest,
    classify: (input) => {
      const r = predict(`${from}${input.state}`, weights, q);
      return { decision: r.decision, confidence: r.confidence, domain: manifest.domain };
    }});
}

decide() sigue síncrono en nano, decideAsync() desbloquea ONNX para mini. Dos caminos, una regla simple: si necesitas async es que quieres mini, si no nano basta. Fácil de recordar cuando programas, fácil de depurar cuando se rompe.

Para enseñarte que no es solo teoría, así se ve un uso real:

import { Sabaki } from "sabaki";
import customerService from "@sabaki-js/customer-service";

const sabaki = new Sabaki().register(customerService);
sabaki.decide({ state: "charged twice, refund please" });
// { decision: "billing", confidence: 0.31, domain: "customer_service" }

Mini, 22 MB que cambian el juego en los casos duros

Nano tiene sus límites, lo viste con agent_trace justo arriba. MiniLM-L6 toma el relevo: un encoder compartido para todo el dominio, más una cabeza lineal por pregunta. Fine-tune end-to-end durante 8-12 epochs, lr 2e-5, en GPU CUDA para el entrenamiento (la inferencia sigue en CPU, que es lo que cuenta).

Las cifras que decidieron, medidas sobre los mismos splits:

  • encoder congelado + MLP: 0.65
  • full fine-tune: 0.68
  • destilación 400M hacia embeddings congelados: probada, descartada, peor que las gold labels

Export ONNX vía dynamo, paridad exacta verificada, luego cuantif dinámica int8: 90 MB -> 22 MB, -0.004 accuracy en customer_service. ECE igual o mejor. Cada export se vuelve a medir, si no la optimización no significa nada.

Desde la 1.0.2, los pesos vienen dentro del paquete (models/<workflow>-int8/ + modelDir exportado). Basta con npm i, modo avión OK. Actualizar = publicar un patch, firmado vía trusted publishing.

En concreto: needs_review pasa de 0.59 a 0.82. disposition 0.73 -> 0.76. ECE < 0.14 en todas partes, con una duda OOD sana (0.2-0.5 en dragons, recetas y strings vacíos). Esa duda es la que permite el umbral agresivo en prod.

Generalist, el catch-all que hace que Sabaki sea de verdad System One

Jev y los demás responden a todo: cualquier pregunta, cualquier opción, definidas en runtime. Ese es el contrato System One que hizo viral a Jev, pones tus instructions + criteria con tus opciones, y decide.

Los especialistas Sabaki no saben hacer eso. Conocen sus preguntas (category, duplicate, bucket) y nada más. Sin red, Sabaki devolvería unknown en cuanto te sales del guion, donde Jev siempre responde algo.

El generalist es esa red. Un cross-encoder que lee pregunta + opción + state y saca una nota, una pasada por opción, softmax, argmax. 22.7M MiniLM int8, 6.5x más pequeño que los 144-151M de la banda. Pero su trabajo no es la nota, es la compatibilidad: con él conectado en fallback, POST /v1/systemone acepta cualquier state y cualquier pregunta, como Jev. El campo model dice quién respondió de verdad (@sabaki-js/[email protected]+mini, sabaki-generalist-v1...), así que del lado del llamante no cambias nada.

Y sobre todo, es un fallback, no un jefe. Regla de oro en decideAsync(): si un especialista gana el ruteo, responde él. El generalist solo habla en unknown. Nunca al revés. Así evitas que un generalista mediocre aplaste a un especialista bueno en su dominio.

El loader vive en src/generalist.ts, con caché por carpeta para no recargar la sesión ONNX en cada llamada:

export function loadGeneralist(modelDir: string) {
  // caché por carpeta, sesión ONNX reutilizada
  // tokenizer.json -> Unigram o wordpiece, detectado auto
  const sess = await ort.InferenceSession.create(
    path.join(modelDir, "generalist.onnx"),
    { intraOpNumThreads: 2 },
  );
}

Apuntas SABAKI_GENERALIST_DIR a una carpeta con generalist.onnx + tokenizer/, el modelo es intercambiable. El id viene de config.json, si no "sabaki-generalist". La caché Map evita recargar la sesión en cada llamada.

El formato de entrada es cross-encoder clásico: pregunta, opción y state concatenados con separadores, una línea por opción a puntuar:

const texts = options.map(
  (o) => `${input.question ?? "decision"} [SEP] ${o} [SEP] ${input.state}`,
);

Pregunta + opción + state, separados por [SEP], una línea por opción. Opciones capadas a 8 (slice(0, 8)), por defecto ["yes", "no"] si no das nada. Más allá, toca trocear del lado del llamante, 8 ya es amplio para una decisión tipada.

Luego viene el batch listo, el tipo de optimización que parece poca cosa pero divide la latencia por el número de opciones. En vez de un session.run por opción, todo sale en un solo forward:

// Un solo forward en batch para todas las opciones
// (rellenado a la longitud máxima) en vez de un run por opción.
const L = Math.max(...encs.map((e) => e.ids.length));
// ids + attention_mask en [options.length, L], padding con padId
const out = await sess.run({ input_ids, attention_mask });
const scores = Array.from(out["score"].data).map(Number);

Cada texto se codifica (wordpiece estilo BERT o unigram SentencePiece según tokenizer.json), se rellena a la longitud máxima del batch, se apila en un tensor [nb_opciones, L]. Un solo sess.run, recuperas una nota por opción. En CPU lo cambia todo: 20-36ms en vez de 8x ese tiempo.

Después viene el softmax + argmax, con las probas completas para cada opción, exactamente en el formato que popularizó Jev:

const pr = softmax(scores);
// bi = índice del máximo
const confidence = Math.round(pr[bi] * 1000) / 1000;
const probs = Object.fromEntries(
  options.map((o, i) => [o, Math.round(pr[i] * 1000) / 1000]),
);

Y el detalle que hace que esto sirva en prod en vez de quedarse en demo es abstainAt:

if (input.abstainAt !== undefined && confidence < input.abstainAt) {
  return { decision: null, confidence, domain: "generalist", probabilities: probs };
}

Opt-in, sin abstención por defecto. Pasas abstainAt: 0.6-0.7 y todo lo flojo devuelve null en vez de una adivinanza. Como el softmax escupe 1.00 incluso cuando falla fuera de distribución, no es un lujo. La GUÍA lo recomienda para todo uso autónomo: bajo el umbral, pides una aclaración en vez de autoresolver.

Para terminar, el mismo montaje con el fallback generalist: cuando ningún especialista hace match, él toma el relevo en vez de devolver unknown.

import { Sabaki, definePlugin, loadGeneralist } from "sabaki";
import mail from "@sabaki-js/mail";

const backend = loadGeneralist(process.env.SABAKI_GENERALIST_DIR!);
const generalist = definePlugin({
  manifest: { name: "@sabaki-js/generalist", domain: "generalist",
    version: "0.1.0", questions: ["decision"], keywords: [] },
  classify: (input) => backend.decide(input),
});

const sabaki = new Sabaki().register(mail).setFallback(generalist);

await sabaki.decideAsync({
  state: "le client dit que l'app a supprimé ses données",
  question: "c'est urgent ?",
  options: ["yes", "no"],
  abstainAt: 0.65,
});
// especialista mail si el ruteo hace match, si no generalist en catch-all estilo Jev

Las generaciones del generalist, todas medidas igual en PhishNChips (2000 mails) y en el test typed-decisions para comparar lo comparable:

  • v4: 0.29 test, R=0.026 phishing. Para tirar, respondía al lado.
  • A (22M 80k): 0.546, R=0.406. Empieza a parecerse a algo.
  • mini v1 servida: 0.536, R=0.616. Es la que corre en el sitio. Recall de phishing correcto, test medio.
  • B (144M scratch): 0.568, R=0.062. Más grande, apenas mejor en test, nulo en phishing.

El 144M from scratch que no transfiere mejor me curó la idea de "meter más params para generalizar". A igual tarea, el grande cuesta más sin adivinar mejor. Hasta como catch-all, el pequeño hace el trabajo. Y sigue EN-only (all-MiniLM-L6-v2), ruteo EN, FR fuera de distribución. Para FR, toca pasar por un especialista entrenado en eso, no por el generalist.

Doom, el único test que de verdad me importa

doomLaya es Laya y Jev jugando a FreeDoom de verdad. El modelo elige command / target / weapon, el ejecutor traza el camino A*, abre las puertas, apunta, clickea. Telemetría por tick, vídeos, SHA de los runs. Contribuyo ahí, así que no hablo de un leaderboard leído de lejos.

v0.1.0, MAP01 seed 48, skill 3, 35 ticks/s, mismo ejecutor:

Modelo Resultado p50 HTTP Coste API
Laya stock fallo 180s 106ms $0
Laya v3 adaptada salida 61.43s 108ms $0
Jev 1.13 salida 68.97s 357ms $0.0046

Dos salidas limpias, sin muertes, con Laya recogiendo la escopeta por el camino porque el ejecutor gestiona el pickup solo. Honestidad obliga: Laya v3 se entrenó con Doom, Jev no. Una seed, un mapa, no prueba nada general. El README del proyecto lo dice en negro sobre blanco, y es de agradecer que no infle el resultado.

Lado Sabaki (plugin-doom): ViZDoom en Python que llama a un servidor JS en cada tick. Nano ~18k req/s, mini ~71 req/s en un hilo i5. El tick a 35Hz pasa sobrado, hasta en un portátil medio.

Con brújula de salida, mini sale de MAP01 en 2-4 runs de 6, nano 0/6. Lógico: el texto pierde lo espacial, los estados doomLaya se estancan hacia 0.57 ya sea MiniLM o ModernBERT-base. Cuando falta la info espacial, ningún clasificador de texto la reinventa.

Los vecinos repitieron el partido con los mismos datos: doomgemma mete una LoRA de 27min en una RTX 4070 y lleva a Gemma 4 E2B al nivel de Laya v3 en juego (6/6 vs 6/6) y por encima en decisiones aisladas. A igual tamaño, un decoder va tan rápido como Laya (270M: 22ms vs 23ms). A igual resultado, Laya sigue ~3x más rápida porque lo logra con un modelo más pequeño.

Mi lectura: el entrenamiento en la tarea cuenta más que la arquitectura. Un especialista pequeño entrenado le gana a un generalista grande que descubre. Exactamente la apuesta de Sabaki. El paper que sigue, System Switch (arXiv:2610.09683), lleva la lógica hasta el final: modelo pequeño y rápido que juega, modelo grande razonador que retoma cuando se abre una gate. El pre-triage de Sabaki (automate > 0.85 si no escala) aplicado al juego.

Lo que se sacrificó

Cada feature ausente financia otra, esa es la regla del juego cuando apuntas a CPU. La lista honesta, como para Bareiron donde cada línea de C era un tradeoff:

Sabaki no cuenta historias. Decide entre opciones que tú le das, con una proba. Para una explicación, un resumen, texto libre, coge un LLM.

El francés sigue fuera de distribución para el generalist. Ruteo EN (all-MiniLM-L6-v2), sonda 1/4 FR vs 3/4 EN en casos similares. El plugin mail viene de una empresa mayormente FR pero anonimizada: 0 PII, -0.024 accuracy por un vocab sin términos personales. La anonimización se paga, pero no era negociable.

El softmax se muestra demasiado seguro cuando sale de su zona. Falsos en 1.00 observados, sí. De ahí el pre-triage recomendado en la GUÍA: abstainAt: 0.6-0.7 para devolver null bajo el umbral, luego gate confidence > 0.85 para automatizar, humano si no. Referencia medida: recall de phishing 0.62 con precisión 0.87. Para partir la cola en dos, no para despedir reviewers.

Nano sufre con las trazas de agentes. 0.34 a 0.59 según la pregunta. Mini sube needs_review a 0.82, pero risk / urgency / action siguen duros. El texto de observabilidad es simplemente pobre en señales léxicas.

Doom en texto se estanca. Hacia 0.57, MiniLM como ModernBERT-base. Mientras sigas en texto sin píxeles, MAP02 te frena. La vía creíble para un jugador de verdad es visión + RL (tiny-doom-defender, 1.1M params desde píxeles), no un mejor clasificador de texto.

El benchmark deriva por batches. typed-decisions se genera por hornadas, el split temporal es en realidad un split de batches. drift_tool.py saca 0.82 en customer_service, 0.67 en agent_trace (0.5 = indistinguible). Consecuencia medida: los pesos publicados hacen 0.733 en mixto vs 0.61 en split oficial. La cifra oficial es por tanto conservadora. La calibración Platt / temperature+bias no transfiere entre batches, así que solo servimos el temperature scaling calibrado en batches recientes. Regla de la casa: drift_tool.py primero, warn por encima de 0.6, block por encima de 0.8. Nadie más publica esto, todos deberían.

El resultado final, números en mano

De un lado un i5 de portátil, del otro una API que cobra por llamada. Esto es lo que da cada modo en concreto:

  • nano: <1ms, ~18k req/s, 20-650 KB, 0 dep
  • mini: ~20ms, ~71 req/s, 22 MB int8, onnxruntime en opcional
  • generalist: 20-36ms, 22.7M, el generalista open más pequeño que conozco

Varios dominios cargados: ~0.5 GB RAM en lazy-load por dominio en vez de ~2 GB para todo. Determinista: 100/100 inputs idénticos dan el mismo output. Umbrales por pregunta, gate sobre confidence, unknown cuando nada hace match.

En Bareiron, correr 3+ jugadores se arrastraba porque cada jugador costaba un chunk a 200ms. Aquí correr 3+ dominios pasa sin despeinarse, porque cada experto es minúsculo y se carga bajo demanda en vez de cargarlo todo de golpe. Misma filosofía del "pequeño que basta", aplicada a otro hardware.

La filo

p2r3 decía: "Me encanta la idea de que ese chip diminuto de 1 dólar pueda correr algo tan avanzado como Minecraft."

Mismo rollo aquí: me encanta la idea de que 20 KB de JSON y un centenar de líneas de TS reemplacen una llamada GPU para elegir una etiqueta. Obvio que no reemplaza todo, no nos flipemos. Reemplaza ese caso pesado donde quieres una etiqueta en milisegundos, en local, con una confianza que puedes umbralizar de forma razonable en vez de cruzar los dedos.

Los tradeoffs, escritos en negro sobre blanco:

  • encoder -> conteo de palabras: 7 puntos menos, latencia dividida por 1000, infra a cero
  • modelo grande único -> plugins npm: gate más gruesa, ops infinitamente más simples
  • generación -> decisión tipada: se acabó el parseo que se rompe y las opciones inventadas
  • confianza bruta -> pre-triage: abstain primero, automatización por encima de 0.85, humano para el resto

Las 3 cosas que recordar:

  1. 7 puntos menos, 1000x más rápido -- 0.61/0.69 vs 0.75-0.80, pero <1ms/20ms CPU offline contra 25s CPU o API de pago. Un score sin su coste no significa nada.
  2. Router simple + expertos separados -- hits / sqrt(keywords), threshold, unknown. Cada plugin se entrena, se versiona y se sirve solo. En mail, TF-IDF empata con MiniLM: la GPU no pinta nada aquí.
  3. Offline-first con calibración honesta -- npm i, pesos incluidos, 0 GPU, ECE < 0.14, abstain, drift tool. El único stack de la comparativa que aguanta un game loop a 35Hz sin GPU.

Si el proyecto te interesa, todo está en MIT en github.com/sabaki-js. La versión académica con medidas y bibtex es el preprint v1. Y si quieres ver jugar a Goliat en Doom de verdad, rumbo a azalio/doomLaya xD

NEW

Sabaki vs System One : Davi contra Golias, o menor modelo de decisão do mundo

20KB contra 421M parâmetros. Sabaki perde 7 pontos no typed-decisions mas sai de 25s CPU para menos de 1ms offline. Anatomia de um MoE organizacional.

Introdução

Se você anda pelo Hacker News ou Twitter desde setembro, com certeza já trombou com o Jev.

No dia 15 de setembro de 2026, a TypeSafe lançou o Jev, o primeiro "System One" deles. O pitch cabe numa frase : parem de pedir pra um LLM gerar JSON pra escolher um label. Você passa um state mais umas perguntas tipadas, ele devolve decisões tipadas com probas. Um passe, zero token pra amostrar, nada pra parsear depois.

E explodiu. Dia um com adoção recorde no Vercel AI Gateway, cinco clientes Elixir em poucos dias, todo mundo comparando o Jev com a própria solução em benchmark. E, sinceramente, o hype faz sentido. A gente passou anos gambiarreando prompts do tipo responda SOMENTE com {"choice": ...} rezando pro modelo não inventar uma opção, não quebrar o JSON, não alucinar um campo. O Jev deu nome pra essa necessidade e entregou numa API limpa : POST /v1/systemone, perguntas choice / score / noul, respostas com probabilities + confidence.

Adorei a ideia. O modelo econômico, menos -- e pra deixar claro, nunca paguei pelo Jev.

Porque por trás, o Jev continua sendo uma API fechada. Pesos desconhecidos, sem self-host, cada decisão = um round-trip de rede + um contador girando. E as alternativas open que vieram depois batem nos 400M de parâmetros com GPU obrigatória.

Então construí o outro extremo : Sabaki. Um roteador por palavras-chave + uma regressão logística em TypeScript puro que fala o mesmo contrato do Jev (mesmos shapes choice / score / noul, mesmo endpoint /v1/systemone), mas que roda em menos de 1ms com 20 kilobytes de JSON. Zero dependência no modo nano, offline, grátis.

Pra situar : Laya são 843 MB de safetensors pra carregar. OpenDecider-nano são 2 GB de RAM. Sabaki nano invoices são 20 KB. A proporção de peso gira em torno de 40 000.

Se você quer a versão quadrada e com números, tem o preprint v1 de outubro de 2026 no site : Sabaki: An Organizational Mixture-of-Experts for Typed Decisions on CPU (PDF). Design, medições, tradeoffs, tudo lá. Este artigo é a versão legível, com o código aberto do lado.

Não escrevi isso no meu canto em modo benchmark de papel. Eu contribuo pro azalio/doomLaya (meu fork : fox3000foxy/doomLaya), um harness onde Laya e Jev jogam FreeDoom de verdade com decisões model-owned, telemetria por tick e vídeos. Vi o Golias jogar ao vivo, e quando a rede tosse, ele tosse junto.

Vamos abrir os motores um por um. Como sempre, com o código de verdade.

O ringue onde todo mundo aceita lutar

Tem um único dataset que Jev, Laya e OpenDecider citam todos : LocalLLaMA/typed-decisions. 4 workflows sintéticos (suporte, faturas, segurança, traces de agentes), 5 perguntas por caso, 1200 casos de treino, 400 de teste, 2000 decisões.

Modelo Params Score teste Latência Onde roda
OpenDecider-nano ~400M 0.796 17ms GPU GPU, 2 GB RAM
Laya typed-decisions 421M 0.766 ~25s/state CPU, ~830ms/caso MLX GPU
Jev 1.13 fechado ~0.73-0.75 ~710ms API só API
Sabaki mini 22M int8 0.69 ~20ms CPU CPU, 22 MB
Sabaki nano ~0 0.61 <1ms em todo lugar
Laya base zero-shot 421M 0.362 igual GPU

O acaso é 0.318, a maioria é 0.461. Então Laya base com 0.362 sem fine-tune é nível "respondo no chute sendo educado". Guarda isso, vai ser útil.

7 pontos separam o Sabaki do melhor. 1000x de latência separam no sentido contrário quando você não tem GPU. Aí está o artigo inteiro em duas frases, o resto é pra entender o porquê.

Mas atenção : por trás da mesma tomada choice / score / noul, as cozinhas não têm nada a ver. Jev = API fechada. Laya = ModernBERT-large com fine-tune. OpenDecider = Ettin-encoder destilado. Sabaki = contagem de palavras + MiniLM int8. Mesmo contrato, orçamentos incomparáveis.

Jev, o tapa que todo mundo viu passar

Tem que dar pra TypeSafe o que é da TypeSafe : o contrato System One é uma ideia realmente boa.

Antes, pra triar um ticket, você fazia isso : pedia pra um LLM "escolhe entre billing, técnico e spam, responde em JSON", parseava a resposta, lidava com os casos onde ele responde "Eu diria billing ! 😊" ou inventa uma 4ª categoria. Cada pergunta custava tokens, cada resposta podia quebrar seu pipeline.

O Jev muda o jogo : você manda o texto + perguntas com seus critérios, ele avalia cada pergunta em paralelo e devolve um valor tipado com sua distribuição.

curl https://api.typesafe.ai/v1/systemone \
 -H "Authorization: Bearer $KEY" \
 -H 'content-type: application/json' \
 -d '{"state":"charge double ce mois-ci","questions":{"dept":{"type":"choice","instructions":"Route ce message.","criteria":{"billing":"paiements","technical":"bugs"}}}}}'

Resposta : { choice, probabilities, confidence }. Sem texto livre. Seu código pode plugar direto em cima.

É por isso que viralizou : SDK Python + JS, plugin Claude Code, integração LangChain, todo mundo conseguia plugar seus agentes numa tarde. A doc da TypeSafe empurra até o padrão saudável : Jev pros julgamentos pequenos e rápidos, código determinístico em volta pra compor a decisão de verdade, LLM lento ou humano assim que a confiança cai.

O problema é o resto. Pesos fechados, offline impossível, preço por chamada. Pra um agente que toma 100 decisões por run, a taxa dói. Pra Doom a 35 ticks/s, é morte : no doomLaya, o Jev sai da MAP01 seed 48 em 68.97s com p50 HTTP de 357ms. O run dá $0.0046 de custo de API -- não é minha fatura, é a do benchmark. Funciona, o run é limpo, sem morte. Mas 357ms a 35 ticks/s dá 12 ticks onde o jogo continua enquanto você espera a rede.

O Sabaki roubou exatamente esse contrato -- mesmas perguntas, mesmas respostas, mesmo endpoint /v1/systemone servido por serve() -- tirando tudo que pede um datacenter.

Laya, o grandão open que mostrou que dava pra fazer sem API

Laya pela Convai Innovations, Apache-2.0. Debaixo do capô : ModernBERT-large, 421M, com 1 token [MASK] por opção. O hidden state de cada marcador passa num MLP pequeno, softmax nas opções da pergunta. Um passe por pergunta, igual ao Jev.

pipe = pipeline("text-classification", model="convaiinnovations/laya-typed-decisions")

O 0.766 merece uma pausa. É o checkpoint com fine-tune no treino do benchmark : 1200 casos, RLCD com ruído gaussiano nos logits, reward log + spherical. O mesmo modelo sem esse ajuste cai pra 0.362. Ou seja, ele chuta. Então o score da Laya é "encoder grandão + treino no domínio", não "encoder grandão mágico".

Em CPU, é um tijolo. A porta ONNX medida gira em torno de 25s por state. Em MLX FP16, conta 830ms por caso na média com p99 de 5.3s. E tem que carregar 843 MB antes de responder qualquer coisa.

No doomLaya, a Laya stock falha a MAP01 em 180s. A v3 adaptada (azalio/laya-doom-v3), retreinada em Doom, sai em 61.43s com p50 de 108ms em MPS, pegando a shotgun no caminho. Então sim, joga -- depois de treino dedicado, em GPU Apple, com um executor com script que faz o trabalho de verdade (A*, portas com latch de 15 ticks, mira auto).

Meu resumo : sem treino na sua tarefa, a Laya serve de peso de papel de 421M. Com treino, é muito forte, mas precisa da GPU e dos dados que acompanham.

OpenDecider, o que venceu o leaderboard

OpenDecider, é o Manjunath que resolveu buscar todo mundo no próprio benchmark deles. Resultado : 0.796 com Ettin-encoder-400m full fine-tune + cabeça MLP, loss KL em soft targets destilados de Qwen3-235B + DeepSeek.

pip install opendecider
# 17ms/pergunta na L40S, 18ms na M4 Max, ~9ms em batch

Com 50% de coverage, a selective accuracy sobe pra 0.943 contra 0.882 do Jev. No papel, o melhor dos dois mundos : open, calibrado, rápido.

Na prática : 2 GB de RAM pra carregar o nano, GPU obrigatória pra segurar os 17ms, phishing em 0.63 contra 0.90 do Jev (o OOD lava ele), e igual à Laya, treinado no treino do benchmark. O teste nunca foi visto, mas o domínio sim.

Ele vence o braço de ferro, mas continua sendo 400M com uma GPU atrás. Você não enfia isso num Worker Cloudflare nem num loop de jogo no laptop.

Os 150M, e o buraco embaixo

openJev-verdict-2.0 (151M), julia-1 (144M multilíngue), Open Jev (150M), Largha-150M (144M en+zh), Vela (0.3B-9B). Todos ModernBERT ou mmBERT-small, todos capazes de rodar em CPU, todos tranquilos de treinar (Open Jev anuncia 30min num T4 Colab). Números self-reported, protocolos diferentes, então pega com pinça, mas a família existe.

O generalist Sabaki tem 22.7M. Cerca de 6.5x menor que o menor dessa turma. Ninguém mais desce abaixo dos 100M pra responder perguntas definidas em runtime.

É isso, "o menor do mundo" : não o melhor score, o menor envelope que lê suas instructions + criteria e decide quando nenhum especialista sabe responder.

Sabaki, o roteador burro e seus especialistas descartáveis

Bom, chega de falar dos grandes. Vamos pro Davi, porque é aqui que fica divertido.

O Sabaki não tenta ser um modelão esperto que entende o mundo. É um MoE organizacional : 1 roteador esparso no nível do sistema, especialistas independentes, um só ativo por input. Cada plugin é um pacote npm separado, com seu manifest, seus pesos, sua versão. Você instala só o que roteia.

A org sabaki-js : 13 repos, o core sabaki (129 commits), 6 plugins @sabaki-js/*, os pesos, o worker Cloudflare, o template pra escrever o seu.

O package.json do core já conta a história :

"dependencies": { "@typesafe-ai/sdk": "^0.7.0" },
"optionalDependencies": { "onnxruntime-node": "^1.22.0" }

No modo nano, zero dependência de runtime. Só TypeScript compilado, nada pra instalar, nada que pode quebrar entre duas versões do ONNX.

O roteador, ou como decidir em poucas linhas

O roteador mora em src/index.ts, e a tokenização é assim (src/index.ts:129) :

function tokens(s: string): string[] {
  return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .split(/[^a-z0-9]+/).filter((w) => w.length > 3);
}

Minúsculas, acentos fora, palavras de 4 letras no mínimo. Esse filtro tira todo o ruído ("le", "de", "ok"). Em ticket de suporte, isso muda tudo porque seu matching acontece em palavras que carregam sentido.

O scoring em si está em route() (src/index.ts:251), e é aí que tudo se decide :

const hits = kws.filter((k) =>
  words.some((w) => w.includes(k) || k.includes(w))).length;
const score = hits / Math.sqrt(kws.length);

Matching frouxo nos dois sentidos : billing pega billed, invoice pega invoicing. A divisão pela raiz evita penalizar listas grandes de keywords, senão o plugin mail com suas 600 palavras esmagaria todo mundo assim que um email contém "hello".

Cada plugin tem seu limiar, padrão 0.15 guardado em src/index.ts:139, mail em 0.03 porque os assuntos são curtos. Abaixo disso :

return { domain: "unknown", decision: null, confidence: 0, plugin: null };

Domínio desconhecido = unknown, nunca um chute inventado pra agradar. É uma abordagem propositalmente burra, e é exatamente isso que torna o limiar automate > 0.8 usável depois : quando o Sabaki responde, é porque um especialista realmente bateu.

Tem um eixo de idioma de brinde via langid : um plugin que declara languages só concorre pros seus idiomas. Prático pra separar phishing latino e CJK. Texto curto demais = sem filtro, todo mundo concorre.

Custo total : alguns microssegundos por decisão. Nenhuma matriz pra multiplicar, nenhuma rede pra chamar, só comparações de strings e uma divisão.

Nano, meu arquivo favorito do repo

src/logreg.ts:28, a tokenização :

function tokens(s: string): string[] {
  const words = s.toLowerCase().split(/[^a-z0-9]+/)
    .filter((w) => /^[a-z]{3,}$/.test(w));
  const out = [...words];
  for (let i = 0; i < words.length - 1; i++)
    out.push(`${words[i]} ${words[i + 1]}`);
  return out;
}

Unigramas + bigramas. charged twice dá charged, twice, e o par charged twice. É tudo que precisa pra captar "cobrança dupla" sem tirar um transformer de 400M da cartola.

src/logreg.ts:46, a vetorização, onde o TF-IDF faz seu trabalho :

const tf = 1 + Math.log(c);  // TF sublinear
const val = tf * w.idf[i]!;  // IDF suave estilo sklearn

A 10ª ocorrência de uma palavra conta menos que a primeira, palavras raras pesam mais que "please". Exatamente a receita do sklearn, paridade exata verificada em 20 perguntas.

E o comentário que me fez sorrir relendo :

// Toca só nos termos presentes (esparso) em vez de varrer 3000 dims.
for (const [t, c] of counts) {

Em vez de varrer 3000 dimensões a cada ticket, você toca só na vintena de palavras presentes. É por isso que responde em menos de 1ms. É o tipo de detalhe invisível que muda tudo em prod, porque a complexidade segue o tamanho do ticket e não o do vocabulário.

src/logreg.ts:86, a inferência, que cabe em três linhas :

// binário : 1 linha de coefs + sigmoide
const pPos = 1 / (1 + Math.exp(-s));
// multiclasse : produto escalar + softmax, argmax

Um produto escalar, uma sigmoide ou um softmax conforme o número de classes. Um passe e acabou. Zero token gerado, então zero JSON quebrado pra consertar depois com retry e heurística de parsing.

Receita train.py : 300 treino / 100 teste por workflow, split determinístico (1 item em 5 no teste pra evitar viés temporal), vocab 3000-6000, coefs arredondados pra 1e-4 com zero perda medida. Export JSON { vocab, idf, questions }.

Pesos : 20 KB pra invoices (duplicate 0.89) até 650 KB pra mail (bucket 0.889). Do tipo de tamanho que passa num bundle frontend.

O detalhe que me grudou na cadeira : em mail, o MiniLM cola no TF-IDF a +/-0.02. Então o nano shippa como está. Pagar GPU pra triar email, conta comigo não. Já agent_trace trava (needs_review 0.59 no nano) : o texto de observabilidade é pobre, nenhuma palavra carrega a decisão de verdade.

A fábrica que transforma pesos em plugin, em src/plugin.ts:52 :

export function nanoClassifier(manifest, weights, opts = {}) {
  return definePlugin({ manifest,
    classify: (input) => {
      const r = predict(`${from}${input.state}`, weights, q);
      return { decision: r.decision, confidence: r.confidence, domain: manifest.domain };
    }});
}

decide() continua síncrono nano, decideAsync() libera ONNX pra mini. Dois caminhos, uma regra simples : se você precisa de async é porque quer mini, senão nano basta. Fácil de lembrar quando você programa, fácil de debugar quando quebra.

Pra mostrar que não é só teoria, olha como é um uso real :

import { Sabaki } from "sabaki";
import customerService from "@sabaki-js/customer-service";

const sabaki = new Sabaki().register(customerService);
sabaki.decide({ state: "charged twice, refund please" });
// { decision: "billing", confidence: 0.31, domain: "customer_service" }

Mini, 22 MB que mudam o jogo nos casos duros

O Nano tem seus limites, você viu em agent_trace logo acima. O MiniLM-L6 pega o bastão : um encoder compartilhado pra todo o domínio, mais uma cabeça linear por pergunta. Fine-tune end-to-end por 8-12 epochs, lr 2e-5, em GPU CUDA pro treino (a inferência continua em CPU, é isso que conta).

Os números que decidiram, medidos nos mesmos splits :

  • encoder congelado + MLP : 0.65
  • full fine-tune : 0.68
  • destilação 400M pra embeddings congelados : testada, descartada, pior que os gold labels

Export ONNX via dynamo, paridade exata verificada, depois quant dinâmica int8 : 90 MB -> 22 MB, -0.004 accuracy em customer_service. ECE igual ou melhor. Cada export é remedido, senão a otimização não quer dizer nada.

Desde a 1.0.2, os pesos vêm embutidos no pacote (models/<workflow>-int8/ + modelDir exportado). npm i basta, modo avião OK. Atualizar = publicar um patch, assinado via trusted publishing.

Concreto : needs_review vai de 0.59 pra 0.82. disposition 0.73 -> 0.76. ECE < 0.14 em todo lugar, com uma dúvida OOD saudável (0.2-0.5 em dragões, receitas e strings vazias). É essa dúvida que permite o limiar agressivo em prod.

Generalist, o catch-all que faz o Sabaki ser System One de verdade

O Jev e os outros respondem tudo : qualquer pergunta, quaisquer opções, definidas em runtime. É esse o contrato System One que fez o Jev viralizar -- você coloca suas instructions + criteria com suas opções, ele decide.

Os especialistas Sabaki não sabem fazer isso. Eles conhecem suas perguntas (category, duplicate, bucket) e nada mais. Sem rede de proteção, o Sabaki devolveria unknown assim que você sai do roteiro, onde o Jev sempre responde alguma coisa.

O generalist é essa rede. Um cross-encoder que lê pergunta + opção + state e solta um score, um passe por opção, softmax, argmax. 22.7M MiniLM int8 -- 6.5x menor que os 144-151M da turma. Mas o trabalho dele não é o score, é a compatibilidade : com ele plugado como fallback, o POST /v1/systemone aceita qualquer state e quaisquer perguntas, igual ao Jev. O campo model diz quem respondeu de verdade (@sabaki-js/[email protected]+mini, sabaki-generalist-v1...), então do lado de quem chama você não muda nada.

E acima de tudo, é um fallback, não um chefe. Regra de ouro em decideAsync() : se um especialista vence o roteamento, ele responde. O generalist só fala no unknown. Nunca o contrário. Isso evita que um generalista mediano esmague um especialista bom no próprio domínio.

O loader mora em src/generalist.ts, com cache por pasta pra não recarregar a sessão ONNX a cada chamada :

export function loadGeneralist(modelDir: string) {
  // cache por pasta, sessão ONNX reutilizada
  // tokenizer.json -> Unigram ou wordpiece, detectado auto
  const sess = await ort.InferenceSession.create(
    path.join(modelDir, "generalist.onnx"),
    { intraOpNumThreads: 2 },
  );
}

Você aponta SABAKI_GENERALIST_DIR pra uma pasta com generalist.onnx + tokenizer/, o modelo é trocável. O id vem de config.json, senão "sabaki-generalist". O cache Map evita recarregar a sessão a cada chamada.

O formato de entrada é cross-encoder clássico : pergunta, opção e state concatenados com separadores, uma linha por opção pra pontuar :

const texts = options.map(
  (o) => `${input.question ?? "decision"} [SEP] ${o} [SEP] ${input.state}`,
);

Pergunta + opção + state, separados por [SEP], uma linha por opção. Opções limitadas a 8 (slice(0, 8)), padrão ["yes", "no"] se você não passa nada. Acima disso, tem que fatiar do lado de quem chama -- 8 já é largo pra uma decisão tipada.

Depois vem o batch esperto, aquele tipo de otimização que parece boba mas divide a latência pelo número de opções. Em vez de um session.run por opção, tudo vai num único forward :

// Um único forward em batch para todas as opções
// (preenchido até o comprimento máximo) em vez de um run por opção.
const L = Math.max(...encs.map((e) => e.ids.length));
// ids + attention_mask em [options.length, L], padding no padId
const out = await sess.run({ input_ids, attention_mask });
const scores = Array.from(out["score"].data).map(Number);

Cada texto é codificado (wordpiece estilo BERT ou unigram SentencePiece conforme tokenizer.json), preenchido até o comprimento máximo do batch, empilhado num tensor [nb_options, L]. Um único sess.run, você recupera um score por opção. Em CPU isso muda tudo : 20-36ms em vez de 8x esse tempo.

Aí vem o softmax + argmax, com as probas completas pra cada opção, exatamente no formato que o Jev popularizou :

const pr = softmax(scores);
// bi = índice do max
const confidence = Math.round(pr[bi] * 1000) / 1000;
const probs = Object.fromEntries(
  options.map((o, i) => [o, Math.round(pr[i] * 1000) / 1000]),
);

E o detalhe que torna o troço usável em prod em vez de ficar demo é o abstainAt :

if (input.abstainAt !== undefined && confidence < input.abstainAt) {
  return { decision: null, confidence, domain: "generalist", probabilities: probs };
}

Opt-in, sem abstenção por padrão. Você passa abstainAt: 0.6-0.7 e tudo que está mole volta null em vez de um chute. Como o softmax solta uns 1.00 mesmo quando erra fora da distribuição, não é luxo. O GUIDE recomenda pra todo uso autônomo : abaixo do limiar, você pede clarificação em vez de auto-resolver.

Pra fechar, aí está a mesma montagem com o fallback generalist : quando nenhum especialista bate, é ele que assume em vez de devolver unknown.

import { Sabaki, definePlugin, loadGeneralist } from "sabaki";
import mail from "@sabaki-js/mail";

const backend = loadGeneralist(process.env.SABAKI_GENERALIST_DIR!);
const generalist = definePlugin({
  manifest: { name: "@sabaki-js/generalist", domain: "generalist",
    version: "0.1.0", questions: ["decision"], keywords: [] },
  classify: (input) => backend.decide(input),
});

const sabaki = new Sabaki().register(mail).setFallback(generalist);

await sabaki.decideAsync({
  state: "le client dit que l'app a supprimé ses données",
  question: "c'est urgent ?",
  options: ["yes", "no"],
  abstainAt: 0.65,
});
// especialista mail se o roteamento bater, senão generalist como catch-all estilo Jev

As gerações do generalist, todas medidas igual no PhishNChips (2000 emails) e no teste typed-decisions pra comparar o comparável :

  • v4 : 0.29 teste, R=0.026 phishing. Pra jogar fora, respondia do lado.
  • A (22M 80k) : 0.546, R=0.406. Começa a parecer alguma coisa.
  • mini v1 servida : 0.536, R=0.616. É a que roda no site. Recall phishing ok, teste médio.
  • B (144M scratch) : 0.568, R=0.062. Maior, mal melhor no teste, nulo em phishing.

O 144M from scratch que não transfere melhor me acalmou bem na ideia de "colocar mais params pra generalizar". Em tarefa igual, o grande custa mais caro sem chutar melhor. Mesmo como catch-all, o pequeno faz o trabalho. E ele continua EN-only (all-MiniLM-L6-v2), roteamento EN, FR fora da distribuição. Pra FR, tem que passar por um especialista treinado nisso, não pelo generalist.

Doom, o único teste que importa de verdade pra mim

doomLaya é Laya e Jev jogando FreeDoom pra valer. O modelo escolhe command / target / weapon, o executor traça o caminho A*, abre as portas, mira, clica. Telemetria por tick, vídeos, SHA dos runs. Eu contribuo pra isso, então não estou falando de um leaderboard lido de longe.

v0.1.0, MAP01 seed 48, skill 3, 35 ticks/s, mesmo executor :

Modelo Resultado p50 HTTP Custo API
Laya stock falha 180s 106ms $0
Laya v3 adaptada saída 61.43s 108ms $0
Jev 1.13 saída 68.97s 357ms $0.0046

Duas saídas limpas, sem morte, com a Laya pegando a shotgun no caminho porque o executor cuida do pickup sozinho. Honestidade obriga : a Laya v3 foi treinada em Doom, o Jev não. Um seed, um mapa, não prova nada geral. O README do projeto diz preto no branco, e é mérito dele não supervender o resultado.

Do lado Sabaki (plugin-doom) : ViZDoom em Python que chama um servidor JS a cada tick. Nano ~18k req/s, mini ~71 req/s numa thread i5. O tick 35Hz passa folgado, mesmo num laptop mediano.

Com bússola de saída, a mini sai da MAP01 em 2-4 runs de 6, nano 0/6. Lógico : o texto perde o espacial, os estados doomLaya batem no teto perto de 0.57 seja MiniLM ou ModernBERT-base. Quando falta info espacial, nenhum classificador de texto reinventa ela.

Os vizinhos refizeram a partida com dados iguais : doomgemma cola uma LoRA de 27min numa RTX 4070 e leva o Gemma 4 E2B ao nível da Laya v3 em jogo (6/6 vs 6/6) e acima em decisões isoladas. Em tamanho igual, um decoder vai tão rápido quanto a Laya (270M : 22ms vs 23ms). Em resultado igual, a Laya continua ~3x mais rápida porque chega lá com um modelo menor.

Minha leitura : o treino na tarefa conta mais que a arquitetura. Um especialista pequeno treinado bate um generalista grandão que está descobrindo. Exatamente a aposta do Sabaki. O paper que vem depois, System Switch (arXiv:2610.09683), leva a lógica até o fim : modelo pequeno e rápido que joga, modelo grandão que raciocina e retoma quando um gate abre. O pré-triage Sabaki (automate > 0.85 senão escala) aplicado ao jogo.

O que foi sacrificado

Cada feature ausente financia outra, é a regra do jogo quando você mira CPU. A lista honesta, igual no Bareiron onde cada linha de C era um tradeoff :

Sabaki não conta história. Ele decide entre opções que você dá, com uma proba. Pra explicação, resumo, texto livre, pega um LLM.

O francês continua fora da distribuição pro generalist. Roteamento EN (all-MiniLM-L6-v2), sonda 1/4 FR vs 3/4 EN em casos parecidos. O plugin mail veio de uma caixa majoritariamente FR mas anonimizada : 0 PII, -0.024 accuracy por um vocab sem termos pessoais. A anonimização se paga, mas era inegociável.

O softmax se mostra confiante demais quando sai da sua área. Falsos a 1.00 observados, sim. Daí o pré-triage recomendado no GUIDE : abstainAt: 0.6-0.7 pra devolver null abaixo do limiar, depois gate confidence > 0.85 pra automatizar, humano senão. Referência medida : recall phishing 0.62 com precisão 0.87. Dá pra cortar a fila em dois, não dá pra mandar os revisores embora.

O Nano sofre nos traces de agentes. 0.34 a 0.59 conforme a pergunta. A mini sobe needs_review pra 0.82, mas risk / urgency / action continuam duros. O texto de observabilidade é só pobre em sinais lexicais.

Doom em texto bate no teto. Perto de 0.57, MiniLM igual a ModernBERT-base. Enquanto você fica no texto sem pixels, a MAP02 te trava. O caminho crível pra um jogador de verdade é visão + RL (tiny-doom-defender, 1.1M params a partir de pixels), não um classificador de texto melhor.

O benchmark deriva por batches. O typed-decisions é gerado por fornadas, o split temporal é na prática um split de batches. drift_tool.py dá 0.82 em customer_service, 0.67 em agent_trace (0.5 = indistinguível). Consequência medida : os pesos publicados fazem 0.733 no misto vs 0.61 no split oficial. O número oficial é então conservador. A calibração Platt / temperature+bias não transfere entre batches, então a gente shippa só o temperature scaling calibrado em batches recentes. Regra da casa : drift_tool.py primeiro, warn acima de 0.6, block acima de 0.8. Ninguém mais publica isso, todo mundo devia.

O resultado final, números na mão

De um lado um i5 de laptop, do outro uma API que cobra por chamada. Olha o que cada modo entrega na prática :

  • nano : <1ms, ~18k req/s, 20-650 KB, 0 dep
  • mini : ~20ms, ~71 req/s, 22 MB int8, onnxruntime opcional
  • generalist : 20-36ms, 22.7M, o menor generalista open que conheço

Vários domínios carregados : ~0.5 GB RAM em lazy-load por domínio em vez de ~2 GB pra tudo. Determinístico : 100/100 inputs idênticos dão o mesmo output. Limiares por pergunta, gate em confidence, unknown quando nada bate.

No Bareiron, rodar 3+ jogadores engasgava porque cada jogador custava um chunk a 200ms. Aqui rodar 3+ domínios passa sem chiar, porque cada especialista é minúsculo e carregado sob demanda em vez de carregar tudo de uma vez. Mesma filosofia do "pequeno que basta", aplicada a outro hardware.

A filosofia

O p2r3 dizia : "Só gosto da ideia de que esse chipzinho de 1 dólar consegue rodar algo tão avançado quanto Minecraft."

Mesma vibe aqui : adoro a ideia de que 20 KB de JSON e uma centena de linhas de TS substituem uma chamada GPU pra escolher um label. Óbvio que não substitui tudo, não vamos viajar. Substitui aquele caso chato onde você quer um label em milissegundos, local, com uma confiança que dá pra cortar num limiar em vez de cruzar os dedos.

Os tradeoffs, escritos preto no branco :

  • encoder -> contagem de palavras : 7 pontos a menos, latência dividida por 1000, infra zerada
  • modelão único -> plugins npm : gate mais grosso, ops infinitamente mais simples
  • geração -> decisão tipada : chega de parsing que quebra e opções inventadas
  • confiança bruta -> pré-triage : abstain primeiro, automação acima de 0.85, humano pro resto

As 3 coisas pra levar :

  1. 7 pontos a menos, 1000x mais rápido -- 0.61/0.69 vs 0.75-0.80, mas <1ms/20ms CPU offline contra 25s CPU ou API paga. Um score sem seu custo não quer dizer nada.
  2. Roteador simples + especialistas separados -- hits / sqrt(keywords), threshold, unknown. Cada plugin treina, versiona e shippa sozinho. Em mail, TF-IDF cola no MiniLM : GPU não serve pra nada aqui.
  3. Offline-first com calibração honesta -- npm i, pesos embutidos, 0 GPU, ECE < 0.14, abstain, drift tool. A única stack do comparativo que segura um game loop a 35Hz sem GPU.

Se o projeto te interessa, está tudo em MIT em github.com/sabaki-js. A versão acadêmica com medições e bibtex é o preprint v1. E se você quer ver o Golias jogando Doom de verdade, vai pra azalio/doomLaya xD

NEW

Sabaki vs System One : Daud melawan Goliath, model keputusan terkecil di dunia

20KB melawan 421M parameter. Sabaki kalah 7 poin di typed-decisions tapi turun dari 25 detik CPU ke kurang dari 1ms secara offline. Anatomi sebuah MoE organisasional.

Pengantar

Kalau kamu nongkrong di Hacker News atau Twitter sejak September, kamu pasti sudah lihat Jev lewat.

Pada 15 September 2026, TypeSafe merilis Jev, "System One" pertama mereka. Pitch-nya muat dalam satu kalimat: berhenti minta LLM menghasilkan JSON untuk memilih label. Kasih dia state plus pertanyaan bertipe, dia mengembalikan keputusan bertipe dengan probabilitas. Satu pass, nol token yang di-sample, tidak ada yang perlu di-parse sesudahnya.

Langsung meledak. Hari pertama adopsi rekor di Vercel AI Gateway, lima klien Elixir dalam beberapa hari, semua orang membenchmark Jev melawan jagoannya masing-masing. Dan jujur hype-nya masuk akal. Bertahun-tahun kita mengakali prompt jawab HANYA dengan {"choice": ...} sambil berdoa model tidak mengarang opsi, tidak merusak JSON, tidak menghalusinasi field. Jev memberi nama untuk kebutuhan itu dan mengirimkannya sebagai API yang rapi: POST /v1/systemone, pertanyaan choice / score / noul, jawaban dengan probabilities + confidence.

Aku suka idenya. Model bisnisnya, kurang. Dan biar jelas, aku tidak pernah bayar untuk Jev.

Karena di balik itu, Jev tetap API tertutup. Bobot tidak diketahui, tidak bisa self-host, tiap keputusan = satu round-trip jaringan + meteran yang jalan. Dan alternatif open yang muncul setelahnya main di 400M parameter dengan GPU wajib.

Jadi aku membangun ekstrem sebaliknya: Sabaki. Sebuah router keyword + regresi logistik dalam TypeScript murni yang bicara kontrak yang sama seperti Jev (shape choice / score / noul yang sama, endpoint /v1/systemone yang sama), tapi jalan dalam kurang dari 1ms dengan JSON 20 kilobyte. Nol dependensi dalam mode nano, offline, gratis.

Buat gambaran: Laya itu 843 MB safetensors yang harus di-load. OpenDecider-nano itu 2 GB RAM. Sabaki nano invoices itu 20 KB. Rasio beratnya sekitar 40.000.

Kalau kamu mau versi yang rapi dan berangka, ada preprint v1 Oktober 2026 di situs: Sabaki: An Organizational Mixture-of-Experts for Typed Decisions on CPU (PDF). Desain, pengukuran, tradeoff, semua ada di sana. Artikel ini versi yang enak dibaca, dengan kode terbuka di sebelahnya.

Aku tidak menulisnya sendirian dengan gaya benchmark kertas. Aku berkontribusi ke azalio/doomLaya (fork-ku: fox3000foxy/doomLaya), sebuah harness tempat Laya dan Jev benar-benar main FreeDoom dengan keputusan model-owned, telemetri per tick dan video. Aku sudah lihat Goliath main secara langsung, dan begitu jaringan batuk, dia ikut batuk juga.

Kita bedah mesinnya satu per satu. Seperti biasa, dengan kode aslinya.

Ring tempat semua orang setuju bertarung

Hanya ada satu dataset yang dikutip Jev, Laya dan OpenDecider semuanya: LocalLLaMA/typed-decisions. 4 workflow sintetis (support, invoice, keamanan, trace agen), 5 pertanyaan per kasus, 1200 kasus train, 400 test, 2000 keputusan.

Model Params Skor test Latensi Jalan di mana
OpenDecider-nano ~400M 0.796 17ms GPU GPU, 2 GB RAM
Laya typed-decisions 421M 0.766 ~25s/state CPU, ~830ms/kasus MLX GPU
Jev 1.13 tertutup ~0.73-0.75 ~710ms API hanya API
Sabaki mini 22M int8 0.69 ~20ms CPU CPU, 22 MB
Sabaki nano ~0 0.61 <1ms di mana saja
Laya base zero-shot 421M 0.362 sama GPU

Acak itu 0.318, mayoritas 0.461. Jadi Laya base di 0.362 tanpa fine-tune, itu level "jawab ngasal tapi sopan". Ingat itu, nanti kepakai.

7 poin memisahkan Sabaki dari yang terbaik. 1000x latensi memisahkan mereka ke arah sebaliknya kalau kamu tidak punya GPU. Seluruh artikel ini dalam dua kalimat, sisanya buat memahami kenapanya.

Tapi hati-hati: di balik soket choice / score / noul yang sama, dapurnya beda total. Jev = API tertutup. Laya = ModernBERT-large yang di-fine-tune. OpenDecider = Ettin-encoder hasil distilasi. Sabaki = hitung kata + MiniLM int8. Kontrak sama, budget tidak sebanding.

Jev, tamparan yang dilihat semua orang

Kasih kredit ke TypeSafe: kontrak System One itu memang ide yang bagus.

Dulu, buat memilah tiket, kamu melakukannya seperti ini: kamu minta LLM "pilih antara billing, teknis dan spam, jawab dalam JSON", kamu parse jawabannya, kamu menangani kasus saat dia menjawab "Kurasa billing! 😊" atau mengarang kategori ke-4. Tiap pertanyaan makan token, tiap jawaban bisa merusak pipeline-mu.

Jev mengubah kesepakatan: kamu kirim teks + pertanyaan dengan kriterianya, dia mengevaluasi tiap pertanyaan secara paralel dan mengembalikan nilai bertipe dengan distribusinya.

curl https://api.typesafe.ai/v1/systemone \
 -H "Authorization: Bearer $KEY" \
 -H 'content-type: application/json' \
 -d '{"state":"charge double ce mois-ci","questions":{"dept":{"type":"choice","instructions":"Route ce message.","criteria":{"billing":"paiements","technical":"bugs"}}}}}'

Jawabannya: { choice, probabilities, confidence }. Tanpa teks bebas. Kodemu bisa langsung mencabangkannya.

Itu sebabnya ini jadi viral: SDK Python + JS, plugin Claude Code, integrasi LangChain, semua orang bisa mencolokkan agennya dalam satu sore. Dok TypeSafe bahkan mendorong pola yang sehat: Jev untuk penilaian kecil yang cepat, kode deterministik di sekitarnya untuk menyusun keputusan sebenarnya, LLM lambat atau manusia begitu confidence rendah.

Masalahnya sisanya. Bobot tertutup, offline mustahil, harga per call. Untuk agen yang mengambil 100 keputusan per run, pajaknya terasa. Untuk Doom pada 35 tick/s, itu mati: di doomLaya, Jev menyelesaikan MAP01 seed 48 dalam 68.97 detik dengan p50 HTTP 357ms. Run itu mencatat biaya API $0.0046, bukan tagihanku, tagihan benchmark-nya. Itu jalan, run-nya bersih, tanpa mati. Tapi 357ms pada 35 tick/s, itu 12 tick di mana game terus jalan selagi kamu menunggu jaringan.

Sabaki mencuri tepat kontrak itu, pertanyaan sama, jawaban sama, endpoint /v1/systemone yang sama dilayani oleh serve(), sambil membuang semua yang butuh datacenter.

Laya, si raksasa open yang membuktikan ini bisa jalan tanpa API

Laya oleh Convai Innovations, Apache-2.0. Di balik kap: ModernBERT-large, 421M, dengan 1 token [MASK] per opsi. Hidden state tiap marker masuk ke MLP kecil, softmax di atas opsi pertanyaan. Satu pass per pertanyaan, seperti Jev.

pipe = pipeline("text-classification", model="convaiinnovations/laya-typed-decisions")

Angka 0.766 layak dibahas. Itu checkpoint yang di-fine-tune di train benchmark: 1200 kasus, RLCD dengan noise Gaussian pada logit, reward log + spherical. Model yang sama tanpa fitting itu jatuh ke 0.362. Intinya dia menebak. Jadi skor Laya itu "encoder besar + latihan di domain", bukan "encoder besar yang ajaib".

Di CPU, ini batu bata. Port ONNX yang diukur jalan sekitar 25 detik per state. Di MLX FP16, hitung 830ms per kasus rata-rata dengan p99 5.3 detik. Dan kamu harus me-load 843 MB sebelum menjawab apa pun.

Di doomLaya, Laya standar gagal di MAP01 dalam 180 detik. Versi v3 yang diadaptasi (azalio/laya-doom-v3), dilatih ulang di Doom, keluar dalam 61.43 detik dengan p50 108ms di MPS, sambil memungut shotgun karena eksekutor menangani pickup sendiri. Jadi ya, itu main, setelah latihan khusus, di GPU Apple, dengan eksekutor script yang mengerjakan kerjaan sebenarnya (A*, pintu dengan latch 15 tick, bidik otomatis).

Ringkasanku: tanpa latihan di tugasmu, Laya berguna sebagai tatakan kertas 421M. Dengan latihan, itu kuat banget, tapi kamu butuh GPU dan data yang cocok.

OpenDecider, yang memenangkan leaderboard

OpenDecider, itu Manjunath yang memutuskan mengejar semua orang di benchmark mereka sendiri. Hasil: 0.796 dengan Ettin-encoder-400m full fine-tune + kepala MLP, loss KL di soft target distilasi dari Qwen3-235B + DeepSeek.

pip install opendecider
# 17ms/pertanyaan di L40S, 18ms di M4 Max, ~9ms secara batch

Pada coverage 50%, selective accuracy naik ke 0.943 melawan 0.882 untuk Jev. Di atas kertas, terbaik dari dua dunia: open, terkalibrasi, cepat.

Praktiknya: 2 GB RAM buat me-load nano, GPU wajib buat menahan 17ms, phishing di 0.63 melawan 0.90 untuk Jev (OOD mencucinya bersih), dan seperti Laya, dilatih di train benchmark. Test tidak pernah dilihat, tapi domainnya sudah.

Dia menang adu panco, tapi tetap 400M dengan GPU di belakang. Kamu tidak akan menempelkannya di Worker Cloudflare atau di game loop laptop.

Kelas 150M, dan lubang di bawahnya

openJev-verdict-2.0 (151M), julia-1 (144M multilingual), Open Jev (150M), Largha-150M (144M en+zh), Vela (0.3B-9B). Semua ModernBERT atau mmBERT-small, semua bisa jalan di CPU, semua enak dilatih (Open Jev mengklaim 30 menit di T4 Colab). Angka self-reported, protokol beda-beda, jadi ambil dengan sejumput garam, tapi keluarganya ada.

Generalist Sabaki itu 22.7M. Sekitar 6.5x lebih kecil dari yang terkecil di kelompok itu. Tidak ada orang lain yang turun di bawah 100M untuk menjawab pertanyaan yang didefinisikan saat runtime.

Itu maksud "terkecil di dunia": bukan skor terbaik, amplop terkecil yang membaca instructions + criteria-mu dan memotong saat tidak ada spesialis yang tahu jawabannya.

Sabaki, router bego dan ahli sekali pakainya

Oke, cukup soal yang besar-besar. Lanjut ke Daud, karena di sinilah jadi seru.

Sabaki tidak mencoba jadi model besar pintar yang memahami dunia. Ini MoE organisasional: 1 router sparse level sistem, ahli yang independen, satu yang aktif per input. Tiap plugin adalah package npm terpisah, dengan manifest, bobot, versinya sendiri. Kamu install hanya yang kamu route.

Org sabaki-js: 13 repo, core sabaki (129 commit), 6 plugin @sabaki-js/*, bobot, worker Cloudflare, template buat menulis milikmu sendiri.

package.json core sudah menceritakan kisahnya:

"dependencies": { "@typesafe-ai/sdk": "^0.7.0" },
"optionalDependencies": { "onnxruntime-node": "^1.22.0" }

Dalam mode nano, nol dependensi runtime. Hanya TypeScript yang dikompilasi, tidak ada yang perlu di-install, tidak ada yang bisa rusak di antara dua versi ONNX.

Router, atau cara memutuskan dalam beberapa baris

Router tinggal di src/index.ts, dan tokenisasi terlihat seperti ini (src/index.ts:129):

function tokens(s: string): string[] {
  return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .split(/[^a-z0-9]+/).filter((w) => w.length > 3);
}

Huruf kecil, aksen dibuang, kata minimal 4 huruf. Filter ini membuang semua noise ("le", "de", "ok"). Di tiket support, ini mengubah segalanya karena matching-mu terjadi pada kata yang membawa makna.

Scoring-nya sendiri ada di route() (src/index.ts:251), dan di sinilah semuanya dimainkan:

const hits = kws.filter((k) =>
  words.some((w) => w.includes(k) || k.includes(w))).length;
const score = hits / Math.sqrt(kws.length);

Matching longgar dua arah: billing menangkap billed, invoice menangkap invoicing. Pembagian dengan akar menghindari menghukum daftar keyword besar, kalau tidak plugin mail dengan 600 katanya akan menghancurkan semua orang begitu sebuah mail berisi "hello".

Tiap plugin punya threshold-nya, default 0.15 disimpan di src/index.ts:139, mail di 0.03 karena subjeknya pendek. Di bawahnya:

return { domain: "unknown", decision: null, confidence: 0, plugin: null };

Domain tidak dikenal = unknown, tidak pernah tebakan ngarang biar terlihat senang. Ini sengaja dibuat bego, dan justru itu yang bikin threshold automate > 0.8 bisa dipakai nanti: saat Sabaki menjawab, itu karena spesialis benar-benar cocok.

Ada bonus sumbu bahasa via langid: plugin yang mendeklarasikan languages hanya bersaing untuk bahasanya. Praktis buat memisahkan phishing latin dan CJK. Teks terlalu pendek = tanpa filter, semua ikut bersaing.

Total biaya: beberapa mikrodetik per keputusan. Tidak ada matriks yang dikalikan, tidak ada jaringan yang dipanggil, hanya perbandingan string dan satu pembagian.

Nano, file favoritku di repo ini

src/logreg.ts:28, tokenisasinya:

function tokens(s: string): string[] {
  const words = s.toLowerCase().split(/[^a-z0-9]+/)
    .filter((w) => /^[a-z]{3,}$/.test(w));
  const out = [...words];
  for (let i = 0; i < words.length - 1; i++)
    out.push(`${words[i]} ${words[i + 1]}`);
  return out;
}

Unigram + bigram. charged twice memberi charged, twice, dan pasangan charged twice. Itu saja cukup buat menangkap "tagihan ganda" tanpa mengeluarkan transformer 400M.

src/logreg.ts:46, vektorisasi, tempat TF-IDF bekerja:

const tf = 1 + Math.log(c);  // TF sublinear
const val = tf * w.idf[i]!;  // IDF smooth ala sklearn

Kemunculan kata ke-10 bobotnya lebih kecil dari yang pertama, kata langka lebih berat dari "please". Tepat resep sklearn, paritas eksak terverifikasi pada 20 pertanyaan.

Dan komentar yang bikin aku senyum saat baca ulang:

// Hanya sentuh term yang muncul (sparse) daripada menyapu 3000 dim.
for (const [t, c] of counts) {

Daripada menyapu 3000 dimensi tiap tiket, kamu hanya menyentuh sekitar dua puluh kata yang muncul. Itu kenapa jawabannya kurang dari 1ms. Ini jenis detail tak terlihat yang mengubah segalanya di prod, karena kompleksitas mengikuti ukuran tiket bukan ukuran kosakata.

src/logreg.ts:86, inferensi, yang muat dalam tiga baris:

// biner: 1 baris koef + sigmoid
const pPos = 1 / (1 + Math.exp(-s));
// multikelas: dot product + softmax, argmax

Satu dot product, satu sigmoid atau softmax tergantung jumlah kelas. Satu pass dan selesai. Nol token yang di-generate, jadi nol JSON rusak yang harus diperbaiki di belakang dengan retry dan heuristik parsing.

Resep train.py: 300 train / 100 test per workflow, split deterministik (1 item dari 5 masuk test biar tidak bias temporal), vocab 3000-6000, koef dibulatkan ke 1e-4 dengan nol loss terukur. Ekspor JSON { vocab, idf, questions }.

Bobot: 20 KB untuk invoices (duplicate 0.89) sampai 650 KB untuk mail (bucket 0.889). Jenis ukuran yang muat dalam bundle frontend.

Detail yang bikin aku kaget: di mail, MiniLM menempel TF-IDF dalam +/-0.02. Jadi nano dikirim apa adanya. Bayar GPU buat memilah mail, ogah banget. Sebaliknya agent_trace mentok (needs_review 0.59 di nano): teks observabilitas miskin, tidak ada kata yang benar-benar membawa keputusan.

Pabrik yang mengubah bobot jadi plugin, di src/plugin.ts:52:

export function nanoClassifier(manifest, weights, opts = {}) {
  return definePlugin({ manifest,
    classify: (input) => {
      const r = predict(`${from}${input.state}`, weights, q);
      return { decision: r.decision, confidence: r.confidence, domain: manifest.domain };
    }});
}

decide() tetap sinkron untuk nano, decideAsync() membuka ONNX untuk mini. Dua jalur, satu aturan simpel: kalau kamu butuh async berarti kamu mau mini, kalau tidak nano cukup. Gampang diingat saat kamu ngoding, gampang di-debug saat rusak.

Biar kamu lihat ini bukan sekadar teori, ini contoh pemakaian nyata:

import { Sabaki } from "sabaki";
import customerService from "@sabaki-js/customer-service";

const sabaki = new Sabaki().register(customerService);
sabaki.decide({ state: "charged twice, refund please" });
// { decision: "billing", confidence: 0.31, domain: "customer_service" }

Mini, 22 MB yang mengubah permainan di kasus sulit

Nano punya batas, kamu sudah lihat di agent_trace barusan. MiniLM-L6 mengambil alih: satu encoder bersama untuk seluruh domain, plus satu kepala linear per pertanyaan. Fine-tune end-to-end selama 8-12 epoch, lr 2e-5, di GPU CUDA untuk training (inferensi tetap CPU, itu yang penting).

Angka yang menentukan, diukur pada split yang sama:

  • encoder beku + MLP: 0.65
  • full fine-tune: 0.68
  • distilasi 400M ke embedding beku: dicoba, dibuang, lebih jelek dari gold label

Ekspor ONNX via dynamo, paritas eksak terverifikasi, lalu kuantisasi dinamis int8: 90 MB -> 22 MB, -0.004 accuracy di customer_service. ECE tidak berubah atau lebih baik. Tiap ekspor diukur ulang, kalau tidak optimasi tidak ada artinya.

Sejak 1.0.2, bobot disertakan dalam package (models/<workflow>-int8/ + modelDir yang diekspor). npm i cukup, mode pesawat OK. Update = terbitkan patch, ditandatangani via trusted publishing.

Konkret: needs_review naik dari 0.59 ke 0.82. disposition 0.73 -> 0.76. ECE < 0.14 di mana-mana, dengan keraguan OOD yang sehat (0.2-0.5 pada dragon, resep dan string kosong). Keraguan inilah yang membolehkan threshold agresif di prod.

Generalist, catch-all yang bikin Sabaki benar-benar System One

Jev dan yang lain menjawab segalanya: pertanyaan apa pun, opsi apa pun, didefinisikan saat runtime. Itu kontrak System One yang bikin Jev viral, kamu menaruh instructions + criteria dengan opsimu, dia memotong.

Spesialis Sabaki tidak bisa begitu. Mereka tahu pertanyaannya (category, duplicate, bucket) dan tidak yang lain. Tanpa jaring pengaman, Sabaki akan mengembalikan unknown begitu kamu keluar naskah, di mana Jev selalu menjawab sesuatu.

Generalist adalah jaring itu. Sebuah cross-encoder yang membaca pertanyaan + opsi + state dan mengeluarkan skor, satu pass per opsi, softmax, argmax. 22.7M MiniLM int8, 6.5x lebih kecil dari 144-151M di kelompok itu. Tapi tugasnya bukan skor, tapi kompatibilitas: dengan dia dicolok sebagai fallback, POST /v1/systemone menerima state apa pun dan pertanyaan apa pun, seperti Jev. Field model bilang siapa yang benar-benar menjawab (@sabaki-js/[email protected]+mini, sabaki-generalist-v1...), jadi di sisi pemanggil kamu tidak mengubah apa pun.

Dan yang penting, ini fallback, bukan bos. Aturan emas di decideAsync(): kalau spesialis menang routing, dia yang menjawab. Generalist hanya bicara saat unknown. Tidak pernah sebaliknya. Ini mencegah generalis yang pas-pasan menindih spesialis yang bagus di domainnya.

Loader tinggal di src/generalist.ts, dengan cache per folder biar tidak me-load ulang sesi ONNX tiap call:

export function loadGeneralist(modelDir: string) {
  // cache per folder, sesi ONNX dipakai ulang
  // tokenizer.json -> Unigram atau wordpiece, terdeteksi otomatis
  const sess = await ort.InferenceSession.create(
    path.join(modelDir, "generalist.onnx"),
    { intraOpNumThreads: 2 },
  );
}

Kamu arahkan SABAKI_GENERALIST_DIR ke folder berisi generalist.onnx + tokenizer/, model bisa ditukar. Id berasal dari config.json, kalau tidak ada "sabaki-generalist". Cache Map menghindari reload sesi tiap call.

Format input, itu cross-encoder klasik: pertanyaan, opsi dan state digabung dengan separator, satu baris per opsi yang di-skor:

const texts = options.map(
  (o) => `${input.question ?? "decision"} [SEP] ${o} [SEP] ${input.state}`,
);

Pertanyaan + opsi + state, dipisah [SEP], satu baris per opsi. Opsi dibatasi 8 (slice(0, 8)), default ["yes", "no"] kalau kamu tidak memberi apa pun. Di atas itu, potong di sisi pemanggil, 8 sudah lega untuk keputusan bertipe.

Lalu datang batch yang pintar, jenis optimasi yang terlihat sepele tapi membagi latensi dengan jumlah opsi. Bukan satu session.run per opsi, semua berangkat dalam satu forward:

// Satu forward batch untuk semua opsi
// (dipadding ke panjang maks) bukan satu run per opsi.
const L = Math.max(...encs.map((e) => e.ids.length));
// ids + attention_mask dalam [options.length, L], padding ke padId
const out = await sess.run({ input_ids, attention_mask });
const scores = Array.from(out["score"].data).map(Number);

Tiap teks di-encode (wordpiece ala BERT atau unigram SentencePiece tergantung tokenizer.json), di-padding ke panjang maks batch, ditumpuk jadi tensor [nb_opsi, L]. Satu sess.run, kamu dapat satu skor per opsi. Di CPU ini mengubah segalanya: 20-36ms bukan 8x waktu itu.

Lalu softmax + argmax, dengan proba lengkap untuk tiap opsi, tepat dalam format yang dipopulerkan Jev:

const pr = softmax(scores);
// bi = indeks nilai maks
const confidence = Math.round(pr[bi] * 1000) / 1000;
const probs = Object.fromEntries(
  options.map((o, i) => [o, Math.round(pr[i] * 1000) / 1000]),
);

Dan detail yang bikin ini bisa dipakai di prod bukan sekadar demo, itu abstainAt:

if (input.abstainAt !== undefined && confidence < input.abstainAt) {
  return { decision: null, confidence, domain: "generalist", probabilities: probs };
}

Opt-in, tanpa abstain default. Kamu oper abstainAt: 0.6-0.7 dan semua yang lembek kembali null bukan tebakan. Karena softmax mengeluarkan 1.00 bahkan saat salah di luar distribusi, ini bukan kemewahan. GUIDE merekomendasikannya untuk semua pemakaian otonom: di bawah threshold, kamu minta klarifikasi daripada auto-resolve.

Terakhir, ini rakitan yang sama dengan fallback generalist: saat tidak ada spesialis yang cocok, dia yang mengambil alih bukan mengembalikan unknown.

import { Sabaki, definePlugin, loadGeneralist } from "sabaki";
import mail from "@sabaki-js/mail";

const backend = loadGeneralist(process.env.SABAKI_GENERALIST_DIR!);
const generalist = definePlugin({
  manifest: { name: "@sabaki-js/generalist", domain: "generalist",
    version: "0.1.0", questions: ["decision"], keywords: [] },
  classify: (input) => backend.decide(input),
});

const sabaki = new Sabaki().register(mail).setFallback(generalist);

await sabaki.decideAsync({
  state: "le client dit que l'app a supprimé ses données",
  question: "c'est urgent ?",
  options: ["yes", "no"],
  abstainAt: 0.65,
});
// spesialis mail kalau routing cocok, kalau tidak generalist jadi catch-all ala Jev

Generasi generalist, semua diukur sama di PhishNChips (2000 mail) dan di test typed-decisions biar yang sebanding bisa dibandingkan:

  • v4: 0.29 test, R=0.026 phishing. Buang, jawabnya ke mana-mana.
  • A (22M 80k): 0.546, R=0.406. Mulai mirip sesuatu.
  • mini v1 yang diserve: 0.536, R=0.616. Ini yang jalan di situs. Recall phishing lumayan, test sedang.
  • B (144M scratch): 0.568, R=0.062. Lebih besar, sedikit lebih baik di test, nol di phishing.

Si 144M from scratch yang tidak transfer lebih baik, itu menyadarkanku soal ide "tambah parameter biar generalisasi". Di tugas yang sama, yang besar bayar lebih mahal tanpa menebak lebih baik. Bahkan sebagai catch-all, yang kecil mengerjakan tugasnya. Dan dia tetap EN-only (all-MiniLM-L6-v2), routing EN, FR di luar distribusi. Untuk FR, lewat spesialis yang dilatih di sana, bukan via generalist.

Doom, satu-satunya tes yang benar-benar penting buatku

doomLaya, itu Laya dan Jev yang benar-benar main FreeDoom. Model memilih command / target / weapon, eksekutor menelusuri jalur A*, membuka pintu, membidik, mengklik. Telemetri per tick, video, SHA run. Aku berkontribusi di sana, jadi aku tidak bicara soal leaderboard yang dibaca dari jauh.

v0.1.0, MAP01 seed 48, skill 3, 35 tick/s, eksekutor sama:

Model Hasil p50 HTTP Biaya API
Laya standar gagal 180 detik 106ms $0
Laya v3 adaptasi keluar 61.43 detik 108ms $0
Jev 1.13 keluar 68.97 detik 357ms $0.0046

Dua exit bersih, tanpa mati, dengan Laya memungut shotgun karena eksekutor menangani pickup sendiri. Jujur: Laya v3 dilatih di Doom, Jev tidak. Satu seed, satu map, tidak membuktikan hal umum. README proyek bilang hitam di atas putih, dan itu terhormat karena tidak oversell hasil.

Sisi Sabaki (plugin-doom): ViZDoom dalam Python yang memanggil server JS tiap tick. Nano ~18k req/s, mini ~71 req/s pada satu thread i5. Tick 35Hz lewat lega, bahkan di laptop sedang.

Dengan kompas exit, mini keluar MAP01 dalam 2-4 run dari 6, nano 0/6. Logis: teks kehilangan spasial, state doomLaya mentok sekitar 0.57 baik MiniLM maupun ModernBERT-base. Saat info spasial hilang, tidak ada pengklasifikasi teks yang menciptakannya ulang.

Tetangga mengulang pertandingan dengan data setara: doomgemma menempel LoRA 27 menit di RTX 4070 dan membawa Gemma 4 E2B ke level Laya v3 dalam game (6/6 vs 6/6) dan di atasnya dalam keputusan terisolasi. Pada ukuran setara, decoder secepat Laya (270M: 22ms vs 23ms). Pada hasil setara, Laya tetap ~3x lebih cepat karena mencapai itu dengan model lebih kecil.

Bacaanku: latihan di tugas lebih penting dari arsitektur. Spesialis kecil yang dilatih mengalahkan generalis besar yang baru kenal. Tepat taruhan Sabaki. Paper berikutnya, System Switch (arXiv:2610.09683), mendorong logika sampai habis: model kecil cepat yang main, model penalar besar yang mengambil alih saat gate terbuka. Pre-triage Sabaki (automate > 0.85 kalau tidak eskalasi) diterapkan ke game.

Yang dikorbankan

Tiap fitur yang hilang membiayai yang lain, itu aturan main saat kamu membidik CPU. Daftar jujur, seperti untuk Bareiron di mana tiap baris C adalah tradeoff:

Sabaki tidak bercerita. Dia memotong di antara opsi yang kamu beri, dengan proba. Untuk penjelasan, ringkasan, teks bebas, ambil LLM.

Bahasa Prancis tetap di luar distribusi untuk generalist. Routing EN (all-MiniLM-L6-v2), probe 1/4 FR vs 3/4 EN pada kasus mirip. Plugin mail berasal dari perusahaan yang mayoritas FR tapi dianonimkan: 0 PII, -0.024 accuracy untuk vocab tanpa term personal. Anonimisasi ada harganya, tapi itu tidak bisa ditawar.

Softmax tampil terlalu yakin saat keluar zonanya. False dengan 1.00 terlihat, ya. Makanya pre-triage yang direkomendasikan di GUIDE: abstainAt: 0.6-0.7 buat mengembalikan null di bawah threshold, lalu gate confidence > 0.85 buat otomatisasi, manusia untuk sisanya. Referensi terukur: recall phishing 0.62 pada presisi 0.87. Cukup buat membelah antrean jadi dua, tidak cukup buat memecat reviewer.

Nano kesulitan di trace agen. 0.34 sampai 0.59 tergantung pertanyaan. Mini menaikkan needs_review ke 0.82, tapi risk / urgency / action tetap keras. Teks observabilitas memang miskin sinyal leksikal.

Doom dalam teks mentok. Sekitar 0.57, MiniLM maupun ModernBERT-base. Selama kamu tetap di teks tanpa piksel, MAP02 memblokirmu. Jalur kredibel untuk pemain beneran, itu vision + RL (tiny-doom-defender, 1.1M param dari piksel), bukan pengklasifikasi teks yang lebih baik.

Benchmark bergeser per batch. typed-decisions dibuat per batch, split temporal sebenarnya split batch. drift_tool.py keluar 0.82 di customer_service, 0.67 di agent_trace (0.5 = tak terbedakan). Konsekuensi terukur: bobot yang dipublikasikan 0.733 campuran vs 0.61 pada split resmi. Angka resmi jadi konservatif. Kalibrasi Platt / temperature+bias tidak transfer antar batch, jadi kita hanya kirim temperature scaling yang dikalibrasi pada batch terbaru. Aturan rumah: drift_tool.py dulu, warn di atas 0.6, block di atas 0.8. Tidak ada orang lain yang mempublikasi ini, semua orang seharusnya.

Hasil akhir, dengan angka di tangan

Di satu sisi laptop i5, di sisi lain API yang menagih per call. Ini yang diberikan tiap mode secara konkret:

  • nano: <1ms, ~18k req/s, 20-650 KB, 0 dep
  • mini: ~20ms, ~71 req/s, 22 MB int8, onnxruntime opsional
  • generalist: 20-36ms, 22.7M, generalis open terkecil yang kukenal

Beberapa domain dimuat: ~0.5 GB RAM dalam lazy-load per domain bukan ~2 GB untuk semua. Deterministik: 100/100 input identik memberi output sama. Threshold per pertanyaan, gate pada confidence, unknown saat tidak ada yang cocok.

Di Bareiron, menjalankan 3+ pemain tersendat karena tiap pemain makan satu chunk 200ms. Di sini menjalankan 3+ domain lewat tanpa ngos-ngosan, karena tiap ahli mungil dan dimuat saat diminta bukan me-load semuanya sekaligus. Filosofi "kecil yang cukup" yang sama, diterapkan ke hardware lain.

Filosofinya

p2r3 bilang: "Aku suka ide chip kecil seharga 1 dolar ini bisa menjalankan sesuatu sekeren Minecraft."

Vibe yang sama di sini: aku suka ide 20 KB JSON dan seratus baris TS menggantikan panggilan GPU buat memilih label. Itu jelas tidak menggantikan segalanya, jangan ngaco. Itu menggantikan kasus menyebalkan saat kamu mau label dalam milidetik, lokal, dengan confidence yang bisa kamu threshold dengan masuk akal bukan sekadar berdoa.

Tradeoff, ditulis hitam di atas putih:

  • encoder -> hitung kata: 7 poin hilang, latensi dibagi 1000, infra nol
  • model besar tunggal -> plugin npm: gate lebih kasar, ops jauh lebih simpel
  • generasi -> keputusan bertipe: selesai parsing yang rusak dan opsi yang dikarang
  • confidence mentah -> pre-triage: abstain dulu, otomatisasi di atas 0.85, manusia untuk sisanya

3 hal yang perlu diingat:

  1. 7 poin hilang, 1000x lebih cepat - 0.61/0.69 vs 0.75-0.80, tapi <1ms/20ms CPU offline melawan 25 detik CPU atau API berbayar. Skor tanpa biayanya tidak ada artinya.
  2. Router sederhana + ahli terpisah - hits / sqrt(keywords), threshold, unknown. Tiap plugin dilatih, diberi versi dan dikirim sendiri. Di mail, TF-IDF menempel MiniLM: GPU tidak berguna di sini.
  3. Offline-first dengan kalibrasi jujur - npm i, bobot disertakan, 0 GPU, ECE < 0.14, abstain, drift tool. Satu-satunya stack di perbandingan yang menahan game loop 35Hz tanpa GPU.

Kalau proyek ini menarik buatmu, semua ada dalam MIT di github.com/sabaki-js. Versi akademis dengan pengukuran dan bibtex, itu preprint v1. Dan kalau kamu mau lihat Goliath main Doom beneran, ke azalio/doomLaya xD

NEW

Sabaki vs System One : डेविड बनाम गोलियत, दुनिया का सबसे छोटा डिसीजन मॉडल

20KB बनाम 421M पैरामीटर। Sabaki typed-decisions पर 7 पॉइंट हारता है लेकिन 25s CPU से <1ms offline पर आ जाता है। एक ऑर्गनाइजेशनल MoE की चीरफाड़।

परिचय

अगर तुम सितंबर से Hacker News या Twitter पर घूम रहे हो, तो Jev तुम्हारी नजर से बचा नहीं होगा।

15 सितंबर 2026 को TypeSafe ने Jev निकाला, उनका पहला "System One"। पिच एक लाइन में निपट जाता है : LLM से JSON उगलवाकर label चुनना बंद करो। उसे एक state दो और typed सवाल दो, वह typed decision लौटाएगा probas के साथ। एक pass, sample करने के लिए zero token, पीछे कुछ parse नहीं करना।

यह फट पड़ा। Vercel AI Gateway पर पहले दिन रिकॉर्ड adoption, कुछ दिनों में पांच Elixir client, हर कोई Jev को अपनी चीज से benchmark कर रहा। और ईमानदारी से hype समझ आता है। सालों से हम सिर्फ {"choice": ...} के साथ जवाब दो वाले prompt जुगाड़ रहे थे और मन्नत मांग रहे थे कि मॉडल कोई option गढ़ न ले, JSON न तोड़े, कोई field hallucinate न करे। Jev ने इस जरूरत को नाम दिया और clean API में ship कर दिया : POST /v1/systemone, सवाल choice / score / noul, जवाब probabilities + confidence के साथ।

मुझे idea बहुत पसंद आया। eco मॉडल, कम खर्च, और साफ कहूं तो मैंने Jev के लिए कभी पैसे नहीं दिए।

क्योंकि पीछे Jev अब भी closed API है। weights अज्ञात, self-host नामुमकिन, हर decision मतलब एक network round-trip और एक मीटर जो घूमता रहता है। और जो open alternative पीछे आए वे GPU अनिवार्य के साथ 400M पैरामीटर पर खेल रहे हैं।

तो मैंने दूसरा extreme बना दिया : Sabaki। एक keyword router और pure TypeScript में logistic regression जो Jev वाला contract बोलता है (वही choice / score / noul shapes, वही /v1/systemone endpoint), लेकिन जो चलता है <1ms में 20 kilobyte JSON के साथ। nano mode में zero dependency, offline, 0€।

scale समझने के लिए : Laya मतलब 843 MB safetensors load करना। OpenDecider-nano मतलब 2 GB RAM। Sabaki nano invoices मतलब 20 KB। वजन का ratio करीब 40 000 बैठता है।

अगर तुम्हें नपी-तुली version चाहिए, तो साइट पर अक्टूबर 2026 का preprint v1 है : Sabaki: An Organizational Mixture-of-Experts for Typed Decisions on CPU (PDF)। Design, माप, tradeoff, सब कुछ उसमें है। यह article वही readable version है, साथ में खुला code।

मैंने इसे कोने में बैठकर paper-benchmark mode में नहीं लिखा। मैं contribute करता हूं azalio/doomLaya पर (मेरा fork : fox3000foxy/doomLaya), एक harness जहां Laya और Jev सच में FreeDoom खेलते हैं, model-owned decision, per-tick telemetry और video के साथ। मैंने Goliath को सच में खेलते देखा है, और network खांसते ही वह भी अटकता है।

चलो engine एक-एक करके खोलते हैं। हमेशा की तरह, असली code के साथ।

वह ring जहां सब लड़ने को तैयार हैं

एक ही dataset है जिसे Jev, Laya और OpenDecider तीनों quote करते हैं : LocalLLaMA/typed-decisions। 4 synthetic workflow (support, invoice, security, agent trace), हर case पर 5 सवाल, 1200 train case, 400 test, 2000 decision।

मॉडल पैरामीटर टेस्ट स्कोर लेटेंसी कहां चलता है
OpenDecider-nano ~400M 0.796 17ms GPU GPU, 2 Go RAM
Laya typed-decisions 421M 0.766 ~25s/state CPU, ~830ms/cas MLX GPU
Jev 1.13 बंद ~0.73-0.75 ~710ms API API only
Sabaki mini 22M int8 0.69 ~20ms CPU CPU, 22 Mo
Sabaki nano ~0 0.61 <1ms हर जगह
Laya base zéro-shot 421M 0.362 वही GPU

random 0.318 है, majority 0.461। तो fine-tune के बिना 0.362 पर Laya base मतलब "शराफत से तुक्का मार रहा हूं" वाला level। इसे याद रखो, आगे काम आएगा।

Sabaki को best से 7 पॉइंट अलग करते हैं। जब GPU नहीं है तो latency में 1000x का फासला दूसरी तरफ अलग करता है। पूरा article इन्हीं दो लाइनों में है, बाकी सब यह समझने के लिए है कि क्यों।

फिर भी ध्यान रहे : एक ही choice / score / noul socket के पीछे kitchen बिल्कुल अलग है। Jev मतलब closed API। Laya मतलब fine-tuned ModernBERT-large। OpenDecider मतलब distilled Ettin-encoder। Sabaki मतलब word count और int8 MiniLM। एक जैसा contract, कहीं अलग budget।

Jev, वह थप्पड़ जो सबने देखा

TypeSafe का हक TypeSafe को देना पड़ेगा : System One contract सच में अच्छा idea है।

पहले ticket sort करने के लिए तुम यह करते थे : LLM से कहते "billing, technique और spam में चुनो, JSON में जवाब दो", जवाब parse करते, उन case को संभालते जहां वह बोलता "मेरे हिसाब से billing ! 😊" या चौथी category गढ़ लेता। हर सवाल token खर्च करता, हर जवाब तुम्हारी pipeline तोड़ सकता था।

Jev deal बदल देता है : तुम text और criteria वाले सवाल भेजते हो, वह हर सवाल को parallel में score करता है और typed value लौटाता है उसके distribution के साथ।

curl https://api.typesafe.ai/v1/systemone \
 -H "Authorization: Bearer $KEY" \
 -H 'content-type: application/json' \
 -d '{"state":"charge double ce mois-ci","questions":{"dept":{"type":"choice","instructions":"Route ce message.","criteria":{"billing":"paiements","technical":"bugs"}}}}}'

जवाब : { choice, probabilities, confidence }। कोई free text नहीं। तुम्हारा code सीधे इस पर branch कर सकता है।

इसीलिए यह viral हुआ : Python और JS SDK, Claude Code plugin, LangChain integration, सब अपने agent एक दोपहर में जोड़ सकते थे। TypeSafe की doc healthy pattern भी धकेलती है : तेज छोटे judgment के लिए Jev, असली decision compose करने के लिए आसपास deterministic code, confidence low होते ही slow LLM या human।

दिक्कत बाकी चीज है। weights बंद, offline नामुमकिन, हर call पर कीमत। एक agent जो हर run में 100 decision लेता है, वहां tax चुभता है। 35 ticks/s वाले Doom के लिए तो dead है : doomLaya में Jev MAP01 seed 48 को 68.97s में निकालता है, p50 HTTP 357ms के साथ। run $0.0046 API cost दिखाता है, यह मेरा bill नहीं, benchmark का bill है। यह चलता है, run clean है, बिना मौत। लेकिन 35 ticks/s पर 357ms मतलब 12 tick जब game चलता रहता है और तुम network का wait करते हो।

Sabaki ने ठीक यही contract उठाया, वही सवाल, वही जवाब, वही /v1/systemone endpoint जो serve() serve करता है, और datacenter मांगने वाली हर चीज हटा दी।

Laya, बड़ा open जिसने दिखाया कि बिना API भी हो सकता है

Laya, Convai Innovations से, Apache-2.0। hood के नीचे : ModernBERT-large, 421M, हर option के लिए 1 [MASK] token। हर marker का hidden state छोटे MLP में जाता है, सवाल के option पर softmax। Jev की तरह हर सवाल पर एक pass।

pipe = pipeline("text-classification", model="convaiinnovations/laya-typed-decisions")

0.766 पर रुकना बनता है। यह benchmark के train पर fine-tuned checkpoint है : 1200 case, logits पर Gaussian noise के साथ RLCD, log और spherical reward। वही मॉडल इस fit के बिना 0.362 पर गिरता है। सीधे कहें तो अंदाजा लगाता है। तो Laya स्कोर मतलब "बड़ा encoder और domain पर training", "जादुई बड़ा encoder" नहीं।

CPU पर यह ईंट है। नापा हुआ ONNX port हर state पर करीब 25s घूमता है। MLX FP16 में हर case पर औसत 830ms गिनो, p99 5.3s के साथ। और कुछ जवाब देने से पहले 843 MB load करना पड़ता है।

doomLaya में stock Laya MAP01 में 180s में fail होती है। adapted v3 (azalio/laya-doom-v3), Doom पर फिर से trained, 61.43s में p50 108ms MPS पर निकालती है, रास्ते में shotgun उठाते हुए। तो हां, यह खेलती है, dedicated training के बाद, Apple GPU पर, scripted executor के साथ जो असली काम करता है (A*, 15 tick latch वाले दरवाजे, auto aim)।

मेरा सार : तुम्हारी task पर training के बिना Laya 421M का paperweight है। training के साथ बहुत strong है, लेकिन GPU और साथ का data चाहिए।

OpenDecider, जिसने leaderboard जीता

OpenDecider, यानी Manjunath ने सबको उनके अपने benchmark पर लेने की ठानी। नतीजा : 0.796, full fine-tune Ettin-encoder-400m और MLP head के साथ, Qwen3-235B और DeepSeek से distilled soft target पर KL loss।

pip install opendecider
# L40S पर 17ms/question, M4 Max पर 18ms, batch में ~9ms

50% coverage पर selective accuracy 0.943 तक जाती है, Jev के 0.882 के खिलाफ। कागज पर दोनों दुनिया का best : open, calibrated, तेज।

practically : nano load करने के लिए 2 GB RAM, 17ms पकड़ने के लिए GPU जरूरी, phishing पर 0.63 बनाम Jev का 0.90 (OOD में धुल जाता है), और Laya की तरह benchmark के train पर trained। test कभी देखा नहीं, लेकिन domain हां।

यह arm-wrestling जीतता है, लेकिन पीछे अब भी GPU वाला 400M है। इसे Cloudflare Worker में या laptop पर game loop में नहीं घुसाओगे।

150M वाले, और नीचे खाली गड्ढा

openJev-verdict-2.0 (151M), julia-1 (144M multilingual), Open Jev (150M), Largha-150M (144M en+zh), Vela (0.3B-9B)। सब ModernBERT या mmBERT-small, सब CPU पर चलने लायक, सब train करने में मस्त (Open Jev Colab T4 पर 30min बताता है)। आंकड़े self-reported, protocol अलग-अलग, तो चुटकी भर नमक के साथ लो, लेकिन family मौजूद है।

generalist Sabaki 22.7M है। इस पट्टी के सबसे छोटे से करीब 6.5x छोटा। runtime में define हुए सवालों का जवाब देने के लिए 100M से नीचे और कोई नहीं उतरता।

"दुनिया का सबसे छोटा" का यही मतलब है : best स्कोर नहीं, सबसे छोटा envelope जो तुम्हारे instructions और criteria पढ़ता है और फैसला करता है जब कोई specialist जवाब नहीं जानता।

Sabaki, बेवकूफ router और उसके फेंकने लायक expert

चलो, बड़ों की बात बहुत हुई। David पर आते हैं, क्योंकि मजा यहीं शुरू होता है।

Sabaki दुनिया समझने वाला smart बड़ा मॉडल बनने की कोशिश नहीं करता। यह organizational MoE है : system level पर 1 sparse router, independent expert, हर input पर एक ही active। हर plugin अलग npm package है, अपना manifest, अपने weight, अपना version। जो route होता है वही install करते हो।

org sabaki-js : 13 repo, core sabaki (129 commit), 6 @sabaki-js/* plugin, weight, Cloudflare worker, अपना वाला लिखने का template।

core का package.json पहले ही कहानी कह देता है :

"dependencies": { "@typesafe-ai/sdk": "^0.7.0" },
"optionalDependencies": { "onnxruntime-node": "^1.22.0" }

nano mode में zero runtime dependency। सिर्फ compiled TypeScript, install करने को कुछ नहीं, दो ONNX version के बीच टूटने को कुछ नहीं।

Router, या कुछ लाइनों में decide करना

router src/index.ts में रहता है, और tokenization ऐसी दिखती है (src/index.ts:129) :

function tokens(s: string): string[] {
  return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .split(/[^a-z0-9]+/).filter((w) => w.length > 3);
}

lowercase, accent साफ, कम से कम 4 letter वाले शब्द। यह filter सारा noise उड़ा देता है ("le", "de", "ok")। support ticket पर इससे सब बदल जाता है क्योंकि matching meaningful शब्दों पर होती है।

scoring खुद route() में है (src/index.ts:251), और खेल यहीं निपटता है :

const hits = kws.filter((k) =>
  words.some((w) => w.includes(k) || k.includes(w))).length;
const score = hits / Math.sqrt(kws.length);

दोनों तरफ loose matching : billing पकड़ लेता है billed को, invoice पकड़ लेता है invoicing को। root से भाग देने से बड़ी keyword list penalize नहीं होती, वरना 600 शब्द वाला mail plugin हर mail में "hello" देखते ही सबको कुचल देता।

हर plugin का अपना threshold है, default 0.15 src/index.ts:139 में रखा है, mail 0.03 पर क्योंकि subject छोटे होते हैं। इसके नीचे :

return { domain: "unknown", decision: null, confidence: 0, plugin: null };

unknown domain मतलब unknown, खुश करने के लिए गढ़ा हुआ guess कभी नहीं। यह जानबूझकर बेवकूफ approach है, और ठीक इसी से बाद में automate > 0.8 thresholding usable बनती है : जब Sabaki जवाब देता है, मतलब सच में किसी specialist ने match किया है।

ऊपर से bonus language axis langid से : जो plugin languages declare करता है वह सिर्फ अपनी भाषा के लिए दौड़ता है। Latin बनाम CJK phishing अलग करने में handy। बहुत छोटा text मतलब कोई filter नहीं, सब दौड़ते हैं।

कुल cost : हर decision पर कुछ microsecond। multiply करने को कोई matrix नहीं, call करने को कोई network नहीं, सिर्फ string compare और एक division।

Nano, repo में मेरी पसंदीदा file

src/logreg.ts:28, tokenization :

function tokens(s: string): string[] {
  const words = s.toLowerCase().split(/[^a-z0-9]+/)
    .filter((w) => /^[a-z]{3,}$/.test(w));
  const out = [...words];
  for (let i = 0; i < words.length - 1; i++)
    out.push(`${words[i]} ${words[i + 1]}`);
  return out;
}

unigram और bigram। charged twice देता है charged, twice, और जोड़ी charged twice। 400M transformer निकाले बिना "double billing" पकड़ने के लिए बस इतना काफी है।

src/logreg.ts:46, vectorization, जहां TF-IDF असली काम करता है :

const tf = 1 + Math.log(c);  // सबलीनियर TF
const val = tf * w.idf[i]!;  // sklearn जैसा स्मूथ IDF

किसी शब्द की 10वीं बार मौजूदगी पहली से कम गिनती है, rare शब्द "please" से भारी हैं। बिल्कुल sklearn recipe, 20 सवालों पर exact parity verified।

और वह comment जिसने दोबारा पढ़ते हुए हंसाया :

// 3000 डाइमेंशन घुमाने के बजाय सिर्फ मौजूद टर्म छूता है (स्पार्स)।
for (const [t, c] of counts) {

हर ticket पर 3000 dimension घुमाने के बजाय तुम सिर्फ मौजूद बीस शब्द छूते हो। इसीलिए जवाब <1ms में आता है। ऐसा invisible detail जो prod में सब बदल देता है, क्योंकि complexity vocab size से नहीं ticket size से चलती है।

src/logreg.ts:86, inference, जो तीन लाइन में निपट जाता है :

// बाइनरी : कोएफ की 1 लाइन और सिग्मॉइड
const pPos = 1 / (1 + Math.exp(-s));
// मल्टीक्लास : डॉट प्रोडक्ट और सॉफ्टमैक्स, argmax

class की संख्या के हिसाब से एक dot product, sigmoid या softmax। एक pass और खत्म। zero token generate, तो पीछे retry और parsing heuristic से ठीक करने को कोई टूटा JSON नहीं।

train.py recipe : हर workflow पर 300 train / 100 test, deterministic split (temporal bias से बचने के लिए हर 5 में 1 item test में), vocab 3000-6000, coef 1e-4 पर round, नपा हुआ zero loss। JSON export { vocab, idf, questions }।

weight : invoices के लिए 20 KB (duplicate 0.89) से mail के लिए 650 KB (bucket 0.889) तक। ऐसा size जो frontend bundle में घुस जाता है।

वह detail जिसने मुझे चौंकाया : mail पर MiniLM TF-IDF से +/-0.02 पर चिपका है। तो nano ऐसे ही ship होता है। mail sort करने के लिए GPU का पैसा, मुझे नहीं देना। उधर agent_trace अटकता है (needs_review nano में 0.59) : observability text गरीब है, कोई शब्द सच में decision नहीं उठाता।

वह factory जो weight को plugin बनाती है, src/plugin.ts:52 में :

export function nanoClassifier(manifest, weights, opts = {}) {
  return definePlugin({ manifest,
    classify: (input) => {
      const r = predict(`${from}${input.state}`, weights, q);
      return { decision: r.decision, confidence: r.confidence, domain: manifest.domain };
    }});
}

decide() nano में sync रहता है, decideAsync() mini के लिए ONNX खोलता है। दो रास्ते, एक simple नियम : async चाहिए मतलब mini चाहिए, वरना nano काफी है। code लिखते हुए याद रखना आसान, टूटने पर debug करना आसान।

यह दिखाने के लिए कि यह सिर्फ theory नहीं है, real usage ऐसा दिखता है :

import { Sabaki } from "sabaki";
import customerService from "@sabaki-js/customer-service";

const sabaki = new Sabaki().register(customerService);
sabaki.decide({ state: "charged twice, refund please" });
// { decision: "billing", confidence: 0.31, domain: "customer_service" }

Mini, 22 MB जो मुश्किल case पलट देता है

Nano की limit है, agent_trace पर ऊपर देख ही लिया। MiniLM-L6 आगे संभालता है : पूरे domain के लिए shared encoder, हर सवाल पर linear head। 8-12 epoch end-to-end fine-tune, lr 2e-5, training के लिए CUDA GPU (inference CPU ही रहता है, यही मायने रखता है)।

वही split पर नपे आंकड़े जिन्होंने फैसला किया :

  • frozen encoder और MLP : 0.65
  • full fine-tune : 0.68
  • 400M से frozen embedding तक distillation : try करके फेंका, gold label से खराब

dynamo से ONNX export, exact parity verified, फिर dynamic int8 quant : 90 MB से 22 MB, customer_service पर -0.004 accuracy। ECE same या better। हर export फिर से नापा जाता है, वरना optimization का मतलब नहीं।

1.0.2 से weight package में बंडल हैं (models/<workflow>-int8/ और exported modelDir)। npm i काफी है, airplane mode OK। update करना मतलब patch publish करना, trusted publishing से signed।

concrete : needs_review 0.59 से 0.82 जाता है। disposition 0.73 से 0.76। हर जगह ECE < 0.14, OOD पर healthy doubt के साथ (dragon, recipe और empty string पर 0.2-0.5)। यही doubt prod में aggressive thresholding की इजाजत देता है।

Generalist, वह catch-all जो Sabaki को सच में System One बनाता है

Jev और बाकी सब हर चीज का जवाब देते हैं : कोई भी सवाल, कोई भी option, runtime में define हुए। यही System One contract है जिसने Jev को viral किया, तुम अपने option के साथ instructions और criteria रखते हो, वह काट देता है।

Sabaki specialist यह नहीं जानते। वे अपने सवाल जानते हैं (category, duplicate, bucket) और कुछ नहीं। net के बिना Sabaki script से बाहर निकलते ही unknown लौटाता, जहां Jev हमेशा कुछ न कुछ बोलता है।

generalist यही net है। एक cross-encoder जो सवाल और option और state पढ़ता है और score निकालता है, हर option पर एक pass, softmax, argmax। 22.7M int8 MiniLM, पट्टी के 144-151M से 6.5x छोटा। लेकिन उसका काम स्कोर नहीं, compatibility है : उसके fallback में लगे होने से POST /v1/systemone कोई भी state और कोई भी सवाल मानता है, Jev की तरह। model field बताता है किसने सच में जवाब दिया (@sabaki-js/[email protected]+mini, sabaki-generalist-v1...), तो caller तरफ कुछ नहीं बदलना।

और सबसे जरूरी, यह fallback है, boss नहीं। decideAsync() में golden rule : अगर specialist routing जीतता है, वही बोलता है। generalist सिर्फ unknown पर बोलता है। कभी उल्टा नहीं। इससे बचा जाता है कि average generalist अपने domain में अच्छे specialist को कुचल दे।

loader src/generalist.ts में रहता है, हर call पर ONNX session दोबारा load न हो इसलिए folder-wise cache के साथ :

export function loadGeneralist(modelDir: string) {
  // हर फोल्डर के लिए कैश, ONNX सेशन दोबारा इस्तेमाल होता है
  // tokenizer.json -> Unigram या wordpiece, ऑटो डिटेक्ट
  const sess = await ort.InferenceSession.create(
    path.join(modelDir, "generalist.onnx"),
    { intraOpNumThreads: 2 },
  );
}

तुम SABAKI_GENERALIST_DIR को generalist.onnx और tokenizer/ वाले folder पर point करते हो, मॉडल swappable है। id config.json से आती है, वरना "sabaki-generalist"। Map cache हर call पर session reload से बचाता है।

input format classic cross-encoder है : सवाल, option और state separator के साथ जोड़े हुए, score करने को हर option पर एक लाइन :

const texts = options.map(
  (o) => `${input.question ?? "decision"} [SEP] ${o} [SEP] ${input.state}`,
);

सवाल और option और state, [SEP] से अलग, हर option पर एक लाइन। option 8 पर capped (slice(0, 8)), कुछ न दो तो default ["yes", "no"]। इससे आगे caller तरफ काटना पड़ेगा, typed decision के लिए 8 पहले ही काफी बड़ा है।

फिर smart batch आता है, ऐसा optimization जो दिखता छोटा है लेकिन latency को option की संख्या से भाग दे देता है। हर option पर एक session.run के बजाय सब एक forward में जाता है :

// हर ऑप्शन पर एक रन के बजाय सभी ऑप्शन के लिए एक बैच्ड फॉरवर्ड
// (अधिकतम लंबाई तक पैड किया हुआ)।
const L = Math.max(...encs.map((e) => e.ids.length));
// [options.length, L] में ids और attention_mask, padId से पैडिंग
const out = await sess.run({ input_ids, attention_mask });
const scores = Array.from(out["score"].data).map(Number);

हर text encode होता है (tokenizer.json के हिसाब से BERT जैसा wordpiece या SentencePiece unigram), batch की max length तक pad होता है, [nb_options, L] tensor में stack होता है। एक sess.run, हर option पर एक score। CPU पर इससे सब बदल जाता है : 8x समय के बजाय 20-36ms।

फिर softmax और argmax आता है, हर option के लिए पूरी proba के साथ, ठीक उसी format में जिसे Jev ने popular किया :

const pr = softmax(scores);
// bi = अधिकतम का इंडेक्स
const confidence = Math.round(pr[bi] * 1000) / 1000;
const probs = Object.fromEntries(
  options.map((o, i) => [o, Math.round(pr[i] * 1000) / 1000]),
);

और वह detail जो इसे demo से उठाकर prod में usable बनाता है, वह abstainAt है :

if (input.abstainAt !== undefined && confidence < input.abstainAt) {
  return { decision: null, confidence, domain: "generalist", probabilities: probs };
}

Opt-in, default में कोई abstention नहीं। तुम abstainAt: 0.6-0.7 देते हो और नरम हर चीज guess के बजाय null लौटती है। softmax out-of-distrib पर गलत होकर भी 1.00 निकालता है, इसलिए यह luxury नहीं है। GUIDE autonomous use में यही recommend करता है : threshold के नीचे clarification मांगो, auto-resolve मत करो।

आखिर में, fallback generalist वाला वही setup : जब कोई specialist match नहीं करता, unknown देने के बजाय यही संभालता है।

import { Sabaki, definePlugin, loadGeneralist } from "sabaki";
import mail from "@sabaki-js/mail";

const backend = loadGeneralist(process.env.SABAKI_GENERALIST_DIR!);
const generalist = definePlugin({
  manifest: { name: "@sabaki-js/generalist", domain: "generalist",
    version: "0.1.0", questions: ["decision"], keywords: [] },
  classify: (input) => backend.decide(input),
});

const sabaki = new Sabaki().register(mail).setFallback(generalist);

await sabaki.decideAsync({
  state: "le client dit que l'app a supprimé ses données",
  question: "c'est urgent ?",
  options: ["yes", "no"],
  abstainAt: 0.65,
});
// रूटिंग मैच करे तो मेल स्पेशलिस्ट, वरना Jev जैसा catch-all generalist

generalist की generation, सब एक जैसे नापी हुई PhishNChips (2000 mail) पर और compare लायक चीज के लिए typed-decisions test पर :

  • v4 : 0.29 test, R=0.026 phishing। फेंकने लायक, बगल में जवाब दे रहा था।
  • A (22M 80k) : 0.546, R=0.406। अब कुछ शक्ल बनने लगी है।
  • mini v1 served : 0.536, R=0.616। यही साइट पर चलती है। phishing recall ठीक, test औसत।
  • B (144M scratch) : 0.568, R=0.062। बड़ा, test में बस थोड़ा better, phishing में zero।

144M scratch जो better transfer नहीं करता, इसने "generalize करने के लिए ज्यादा param डालो" वाला idea ठंडा कर दिया। बराबर task पर बड़ा ज्यादा खर्च करता है बिना better अंदाजा लगाए। catch-all में भी छोटा काम चला लेता है। और यह EN-only ही रहता है (all-MiniLM-L6-v2), EN routing, मिलते-जुलते case पर FR 1/4 बनाम EN 3/4 probe। FR के लिए trained specialist से जाओ, generalist से नहीं।

Doom, मेरे लिए इकलौता test जो सच में मायने रखता है

doomLaya मतलब Laya और Jev सच में FreeDoom खेलते हैं। मॉडल चुनता है command / target / weapon, executor A* path बनाता है, दरवाजे खोलता है, aim करता है, click करता है। per-tick telemetry, video, run का SHA। मैं इसमें contribute करता हूं, तो दूर से पढ़े leaderboard की बात नहीं कर रहा।

v0.1.0, MAP01 seed 48, skill 3, 35 ticks/s, same executor :

मॉडल नतीजा p50 HTTP API लागत
Laya stock असफल 180s 106ms $0
Laya v3 adapted निकास 61.43s 108ms $0
Jev 1.13 निकास 68.97s 357ms $0.0046

दो clean निकास, बिना मौत, Laya रास्ते में shotgun उठाती है क्योंकि executor pickup खुद संभालता है। ईमानदारी से : Laya v3 Doom पर trained थी, Jev नहीं। एक seed, एक map, general कुछ साबित नहीं करता। project का README यह काला-सफेद कहता है, और नतीजा न बेचना उसके सम्मान में है।

Sabaki तरफ (plugin-doom) : Python में ViZDoom जो हर tick पर JS server call करता है। i5 के एक thread पर nano ~18k req/s, mini ~71 req/s। 35Hz tick आराम से निकलता है, औसत laptop पर भी।

exit compass के साथ mini 6 में 2-4 run में MAP01 निकालता है, nano 0/6। logical है : text में spatial खो जाता है, doomLaya state MiniLM हो या ModernBERT-base 0.57 के आसपास cap होते हैं। जब spatial info missing है, कोई text classifier उसे दोबारा नहीं गढ़ता।

पड़ोसियों ने बराबर data पर rematch किया : doomgemma RTX 4070 पर 27min LoRA चिपकाता है और Gemma 4 E2B को game में Laya v3 के level पर लाता है (6/6 बनाम 6/6) और isolated decision में ऊपर। बराबर size पर decoder Laya जितना तेज है (270M : 22ms बनाम 23ms)। बराबर नतीजे पर Laya ~3x तेज रहती है क्योंकि वह छोटे मॉडल से वहां पहुंचती है।

मेरी पढ़त : task पर training architecture से ज्यादा मायने रखती है। trained छोटा specialist खोज रहे बड़े generalist को हराता है। बिल्कुल Sabaki वाला दांव। पीछे आया paper, System Switch (arXiv:2610.09683), logic को आखिर तक ले जाता है : तेज छोटा मॉडल जो खेलता है, बड़ा reasoner मॉडल जो gate खुलते ही संभालता है। game पर लगाया Sabaki pre-triage (automate > 0.85 वरना escalate)।

क्या कुर्बान किया

हर missing feature दूसरी feature का पैसा देती है, CPU निशाने पर हो तो यही नियम है। ईमानदार list, Bareiron की तरह जहां C की हर लाइन tradeoff थी :

Sabaki कहानी नहीं सुनाता। वह तुम्हारे दिए option के बीच काटता है, proba के साथ। explanation, summary, free text के लिए LLM लो।

generalist के लिए French अब भी out-of-distrib है। EN routing (all-MiniLM-L6-v2), मिलते-जुलते case पर 3/4 EN बनाम 1/4 FR probe। mail plugin ज्यादातर FR कंपनी से आया लेकिन anonymized : 0 PII, personal term बिना vocab के लिए -0.024 accuracy। anonymization की कीमत है, लेकिन यह non-negotiable था।

softmax zone से बाहर निकलकर ज्यादा sure दिखता है। 1.00 पर false देखे हैं, हां। इसीलिए GUIDE में recommended pre-triage : threshold के नीचे null लौटाने के लिए abstainAt: 0.6-0.7, फिर automate करने के लिए confidence > 0.85 gate, वरना human। नपा reference : 0.87 precision पर 0.62 phishing recall। लाइन दो हिस्सों में काटने लायक, reviewer हटाने लायक नहीं।

Nano agent trace पर जूझता है। सवाल के हिसाब से 0.34 से 0.59। Mini needs_review को 0.82 तक उठाता है, लेकिन risk / urgency / action मुश्किल रहते हैं। observability text में lexical signal बस गरीब है।

Text में Doom cap होता है। MiniLM हो या ModernBERT-base, 0.57 के आसपास। pixel के बिना text पर रहोगे तो MAP02 रोक देगा। असली player का credible रास्ता vision और RL है (pixel से 1.1M param वाला tiny-doom-defender), better text classifier नहीं।

benchmark batch से drift करता है। typed-decisions batch में generate होता है, temporal split असल में batch split है। drift_tool.py customer_service पर 0.82 निकालता है, agent_trace पर 0.67 (0.5 मतलब अलग नहीं कर सकते)। नपा नतीजा : published weight official split पर 0.61 बनाम mixed में 0.733 करते हैं। तो official आंकड़ा conservative है। Platt / temperature+bias calibration batch के बीच transfer नहीं होती, इसलिए recent batch पर calibrated temperature scaling ही ship होता है। घर का नियम : पहले drift_tool.py, 0.6 के ऊपर warn, 0.8 के ऊपर block। यह और कोई publish नहीं करता, सबको करना चाहिए।

आखिरी नतीजा, आंकड़े हाथ में

एक तरफ laptop का i5, दूसरी तरफ हर call पर bill बनाने वाली API। हर mode concretely यह देता है :

  • nano : <1ms, ~18k req/s, 20-650 KB, 0 dep
  • mini : ~20ms, ~71 req/s, 22 MB int8, optional onnxruntime
  • generalist : 20-36ms, 22.7M, सबसे छोटा open generalist जिसे मैं जानता हूं

कई domain load : सबके लिए ~2 GB के बजाय lazy-load में domain-wise ~0.5 GB RAM। deterministic : 100/100 same input वही output देते हैं। हर सवाल पर threshold, confidence पर gate, कुछ match न हो तो unknown।

Bareiron पर 3+ player चलाना अटकता था क्योंकि हर player 200ms chunk खर्च करता था। यहां 3+ domain चलाना बिना खांसे निकलता है, क्योंकि हर expert छोटा है और सब एक साथ load करने के बजाय मांग पर load होता है। "छोटा जो काफी है" वाली वही philosophy, दूसरे hardware पर लगाई हुई।

फलसफा

p2r3 कहता था : "मुझे बस यही idea पसंद है कि 1 डॉलर वाली वह नन्ही चिप Minecraft जितनी advanced चीज चला सकती है।"

वही धुन यहां : मुझे यह idea पसंद है कि 20 KB JSON और सौ लाइन TS label चुनने के लिए GPU call हटा देते हैं। यह जाहिर है सब कुछ replace नहीं करता, बकवास नहीं करना चाहिए। यह उस खिजाने वाले case को replace करता है जहां label millisecond में चाहिए, local में, ऐसे confidence के साथ जिसे आंख बंद करने के बजाय ठीक-ठाक threshold कर सको।

tradeoff, काले पर सफेद :

  • encoder से word count : 7 पॉइंट कम, latency 1000 से भाग, infra zero
  • अकेले बड़े मॉडल से npm plugin : मोटी gate, infinitaly simple ops
  • generation से typed decision : टूटने वाला parsing और गढ़े option खत्म
  • कच्चे भरोसे से pre-triage : पहले abstain, 0.85 के ऊपर automate, बाकी के लिए human

याद रखने की 3 चीजें :

  1. 7 पॉइंट कम, 1000x तेज : 0.61/0.69 बनाम 0.75-0.80, लेकिन 25s CPU या paid API के खिलाफ <1ms/20ms CPU offline। cost के बिना स्कोर का मतलब नहीं।
  2. simple router और अलग expert : hits / sqrt(keywords), threshold, unknown। हर plugin अकेला train, version और ship होता है। mail पर TF-IDF MiniLM से चिपका है : यहां GPU बेकार है।
  3. offline-first ईमानदार calibration के साथ : npm i, बंडल weight, 0 GPU, ECE < 0.14, abstain, drift tool। compare में इकलौता stack जो बिना GPU 35Hz game loop पकड़ता है।

अगर project में दिलचस्पी है, सब MIT में है github.com/sabaki-js पर। नाप और bibtex वाली academic version preprint v1 है। और अगर Goliath को सच में Doom खेलते देखना है, तो azalio/doomLaya की तरफ जाओ xD

NEW

Sabaki ضد System One: داود ضد جالوت، أصغر نموذج قرار في العالم

20KB مقابل 421M بارامتر. Sabaki يخسر 7 نقاط على typed-decisions لكنه ينزل من 25 ثانية CPU إلى أقل من 1ms أوفلاين. تشريح MoE تنظيمي.

مقدمة

لو تتسكع على Hacker News أو تويتر من سبتمبر، أكيد شفت اسم Jev يمر قدامك.

في 15 سبتمبر 2026، TypeSafe تطلق Jev، أول System One عندها. الفكرة كلها في جملة واحدة: بطلوا تطلبوا من LLM يولد JSON عشان يختار label. اعطوه state مع أسئلة مكتوبة بأنواع، وهو يرجع لكم قرارات مكتوبة بأنواع مع احتمالات. تمريرة واحدة، صفر توكن يتولد، ولا شيء يتفكك وراها.

وانفجرت. أول يوم تبني قياسي على Vercel AI Gateway، خمسة عملاء Elixir في كم يوم، والكل يقارن Jev مع شغله. وبصراحة الضجة مفهومة. صار لنا سنين نرقع ببرومبتات جاوب فقط بهذا الشكل {"choice": ...} وندعي أن الموديل ما يخترع خيار، وما يكسر JSON، وما يهلوس حقل. Jev سمى الحاجة باسمها ونزلها API نظيفة: POST /v1/systemone، أسئلة choice / score / noul، وأجوبة مع probabilities زائد confidence.

عجبتني الفكرة. النموذج الاقتصادي أقل، وللتوضيح، عمري ما دفعت لـ Jev.

لأن ورا الكواليس، Jev يبقى API مقفلة. أوزان مجهولة، ما في self-host، كل قرار يعني رحلة شبكة زائد عداد يشتغل. والبدائل المفتوحة اللي جت بعده تضرب في 400M بارامتر مع GPU إجباري.

فعملت الطرف الآخر تماما: Sabaki. راوتر كلمات مفتاحية زائد انحدار لوجستي بـ TypeScript صافي يتكلم نفس عقد Jev (نفس أشكال choice / score / noul، نفس endpoint في /v1/systemone مخدوم بـ serve())، لكنه يشتغل في أقل من 1ms مع 20 كيلوبايت JSON. صفر اعتماد في وضع nano، أوفلاين، 0 يورو.

عشان تتصور: Laya هي 843 ميغا safetensors تتحمل. OpenDecider-nano هي 2 غيغا RAM. Sabaki nano invoices هي 20 كيلو. نسبة الوزن تدور حول 40 000.

لو تبغى النسخة الجادة بالأرقام، فيه preprint v1 لأكتوبر 2026 على الموقع: Sabaki: An Organizational Mixture-of-Experts for Typed Decisions on CPU (PDF). التصميم، القياسات، المقايضات، كله هناك. هذا المقال هو النسخة المقروءة، مع الكود مفتوح جنبه.

ما كتبته لحالي على طريقة ورقة بنشمارك. أنا أساهم في azalio/doomLaya (الفورك حقي: fox3000foxy/doomLaya)، harness فيه Laya وJev يلعبون FreeDoom فعلا بقرارات model-owned، وتليمتري لكل tick وفيديوهات. شفت جالوت يلعب حقيقي، وشفت أنه أول ما الشبكة تكح، هو يكح معها.

حنفك المحركات واحد واحد. وكالعادة، بالكود الحقيقي.

الحلبة اللي الكل متفق يتقاتل فيها

يوجد dataset واحد يستشهد به Jev وLaya وOpenDecider كلهم: LocalLLaMA/typed-decisions. 4 workflows مصنعة (دعم، فواتير، أمن، آثار agents)، 5 أسئلة لكل حالة، 1200 حالة تدريب، 400 اختبار، 2000 قرار.

الموديل البارامترات نتيجة الاختبار التأخير أين يشتغل
OpenDecider-nano ~400M 0.796 17ms GPU GPU، 2 غيغا RAM
Laya typed-decisions 421M 0.766 ~25s/state CPU، ~830ms/حالة MLX GPU
Jev 1.13 مقفل ~0.73-0.75 ~710ms API API فقط
Sabaki mini 22M int8 0.69 ~20ms CPU CPU، 22 ميغا
Sabaki nano ~0 0.61 <1ms في كل مكان
Laya base صفر شوت 421M 0.362 نفسه GPU

العشوائي هو 0.318، والأغلبية 0.461. يعني Laya base على 0.362 بدون fine-tune، هذا مستوى "أجاوب عشوائي بأدب". احفظها، حتحتاجها بعدين.

7 نقاط تفصل Sabaki عن الأفضل. 1000x تأخير يفصلهم بالاتجاه المعاكس لما ما عندك GPU. هذا المقال كله في جملتين، والباقي عشان نفهم ليش.

لكن انتبه: ورا نفس الواجهة choice / score / noul، المطابخ ما لها علاقة ببعض. Jev يعني API مقفلة. Laya يعني ModernBERT-large مضروب fine-tune. OpenDecider يعني Ettin-encoder مقطر. Sabaki يعني عد كلمات زائد MiniLM int8. نفس العقد، وميزانيات لا تقارن.

Jev، الصفعة اللي الكل شافها تمر

لازم نعطي TypeSafe حقها: عقد System One، فكرة ممتازة فعلا.

قبل، عشان تفرز تذكرة، كنت تعمل كذا: تطلب من LLM "اختار بين billing وتقني وسبام، وجاوب JSON"، وتفك الرد، وتتعامل مع الحالات اللي يقول فيها "أظن billing ! 😊" أو يخترع فئة رابعة. كل سؤال يكلف توكنات، وكل جواب ممكن يكسر pipeline حقك.

Jev يغير الصفقة: ترسل النص زائد أسئلة مع معاييرها، وهو يقيم كل سؤال بالتوازي ويرجع لك قيمة مكتوبة بنوع مع توزيعها.

curl https://api.typesafe.ai/v1/systemone \
 -H "Authorization: Bearer $KEY" \
 -H 'content-type: application/json' \
 -d '{"state":"charge double ce mois-ci","questions":{"dept":{"type":"choice","instructions":"Route ce message.","criteria":{"billing":"paiements","technical":"bugs"}}}}}'

الجواب: { choice, probabilities, confidence }. ما في نص حر. كودك يقدر يتشعب عليه مباشرة.

عشان كذا صار فيروسي: SDK بايثون زائد JS، إضافة Claude Code، تكامل LangChain، الكل قدر يشبك agents عليه في بعد ظهر واحد. وثائق TypeSafe نفسها تدفع النمط السليم: Jev للأحكام الصغيرة السريعة، وكود حتمي حوله يركب القرار الحقيقي، وLLM بطيء أو إنسان أول ما الثقة تنزل.

المشكلة في الباقي. أوزان مقفلة، أوفلاين مستحيل، سعر لكل call. لـ agent ياخذ 100 قرار في كل run، الضريبة توجع. لـ Doom على 35 ticks/s، ميت: في doomLaya، Jev يخلص MAP01 seed 48 في 68.97s مع p50 HTTP على 357ms. الـ run يبلغ $0.0046 تكلفة API، مو فاتورتي، فاتورة البنشمارك. يشتغل، والـ run نظيف، بدون موت. لكن 357ms على 35 ticks/s، يعني 12 ticks اللعبة تكمل فيها وأنت تنتظر الشبكة.

Sabaki سرق نفس العقد بالضبط، نفس الأسئلة، نفس الأجوبة، نفس endpoint في /v1/systemone مخدوم بـ serve()، وشال كل شيء يحتاج datacenter.

Laya، الكبير المفتوح اللي أثبت أنها ممكنة بدون API

Laya من Convai Innovations، Apache-2.0. تحت الغطاء: ModernBERT-large، 421M، مع توكن [MASK] واحد لكل خيار. الـ hidden state لكل علامة يمر في MLP صغير، softmax على خيارات السؤال. تمريرة واحدة لكل سؤال، مثل Jev.

pipe = pipeline("text-classification", model="convaiinnovations/laya-typed-decisions")

الـ 0.766 تستاهل نوقف عندها. هذا checkpoint مضروب fine-tune على train البنشمارك: 1200 حالة، RLCD مع ضجيج غاوسي على logits، مكافأة log زائد spherical. نفس الموديل بدون هذا الضبط ينزل لـ 0.362. يعني يخمن. فسكور Laya هو "مشفر كبير زائد تدريب على الدومين"، مو "مشفر كبير سحري".

على CPU، هو طابوقة. منفذ ONNX المقاس يدور حول 25 ثانية لكل state. على MLX FP16، احسب 830ms لكل حالة كمعدل مع p99 على 5.3s. ولازم تحمل 843 ميغا قبل ما تجاوب على أي شيء.

في doomLaya، الـ Laya stock تفشل MAP01 في 180s. النسخة v3 المعدلة (azalio/laya-doom-v3)، المعاد تدريبها على Doom، تخلص في 61.43s مع p50 على 108ms على MPS، وتلقط الشوتغن في الطريق. يعني أيوه، تلعب، لكن بعد تدريب مخصص، على GPU آبل، مع منفذ مسكربت يعمل الشغل الحقيقي (A*، أبواب مع latch على 15 ticks، تصويب تلقائي).

خلاصتي: بدون تدريب على مهمتك، Laya تصلح ثقالة ورق بـ 421M. معه، هي قوية جدا، لكن لازم GPU والداتا اللي تروح معه.

OpenDecider، اللي فاز بالليدربورد

OpenDecider، هذا Manjunath قرر يجيب الكل على البنشمارك حقهم. النتيجة: 0.796 مع Ettin-encoder-400m مضروب full fine-tune زائد رأس MLP، خسارة KL على soft targets مقطرة من Qwen3-235B زائد DeepSeek.

pip install opendecider
# 17ms/سؤال على L40S، 18ms على M4 Max، ~9ms بالدفعات

عند 50% تغطية، الدقة الانتقائية تطلع لـ 0.943 مقابل 0.882 لـ Jev. على الورق، أفضل العالمين: مفتوح، معاير، سريع.

في الواقع: 2 غيغا RAM لتحميل nano، GPU لازم عشان تمسك 17ms، تصيد على 0.63 مقابل 0.90 لـ Jev (الـ OOD يمسح فيه)، ومثل Laya، مدرب على train البنشمارك. الاختبار ما انشاف، لكن الدومين أيوه.

يفوز في المصارعة، لكنه يبقى 400M مع GPU وراه. ما تلزقه في Worker على Cloudflare ولا في حلقة لعبة على لابتوب.

الـ 150M، والفراغ اللي تحتها

openJev-verdict-2.0 (151M)، julia-1 (144M متعدد اللغات)، Open Jev (150M)، Largha-150M (144M en+zh)، Vela (0.3B-9B). كلها ModernBERT أو mmBERT-small، كلها قادرة تشتغل على CPU، وكلها لطيفة في التدريب (Open Jev يعلن 30 دقيقة على T4 Colab). أرقام self-reported، بروتوكولات مختلفة، فخذها بملقط، لكن العائلة موجودة.

الـ generalist حق Sabaki يعمل 22.7M. حوالي 6.5x أصغر من أصغر واحد في هذه الشلة. ولا أحد ثاني ينزل تحت 100M عشان يجاوب على أسئلة تتعرف في runtime.

هذا هو "الأصغر في العالم": مو أفضل سكور، أصغر ظرف يقرأ instructions زائد criteria حقك ويحسم لما ولا مختص يعرف يجاوب.

Sabaki، الراوتر الغبي وخبراؤه المرمى

طيب، كفاية كلام عن الكبار. نروح لداود، لأن هنا تصير ممتعة.

Sabaki لا يحاول يكون موديل كبير ذكي يفهم العالم. هو MoE تنظيمي: راوتر sparse واحد على مستوى النظام، خبراء مستقلون، واحد نشط فقط لكل input. كل plugin هو حزمة npm منفصلة، مع manifest وأوزان ونسخة. تثبت بس اللي توجه له.

المنظمة sabaki-js: 13 ريبو، الـ core اسمه sabaki (129 commits)، 6 plugins من نوع @sabaki-js/*، الأوزان، worker على Cloudflare، والقالب عشان تكتب حقك.

الـ package.json للـ core يحكي القصة لحاله:

"dependencies": { "@typesafe-ai/sdk": "^0.7.0" },
"optionalDependencies": { "onnxruntime-node": "^1.22.0" }

في وضع nano، صفر اعتماد runtime. TypeScript مترجم فقط، لا شيء يتثبت، لا شيء ينكسر بين نسختين من ONNX.

الراوتر، أو كيف تقرر في كم سطر

الراوتر يعيش في src/index.ts، والـ tokenization تشبه هذا (src/index.ts:129):

function tokens(s: string): string[] {
  return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .split(/[^a-z0-9]+/).filter((w) => w.length > 3);
}

أحرف صغيرة، لهجات مشالة، كلمات 4 أحرف على الأقل. هذا الفلتر يشيل كل الضجيج ("le"، "de"، "ok"). على تذاكر الدعم، يغير كل شيء لأن المطابقة حقك تصير على كلمات تحمل معنى.

السكور نفسه في route() (src/index.ts:251)، وهنا ينلعب كل شيء:

const hits = kws.filter((k) =>
  words.some((w) => w.includes(k) || k.includes(w))).length;
const score = hits / Math.sqrt(kws.length);

مطابقة مرخية بالاتجاهين: billing تلقط billed، وinvoice تلقط invoicing. القسمة على الجذر تمنع معاقبة قوائم keywords الكبيرة، وإلا plugin الميل مع 600 كلمة حقته يسحق الكل أول ما إيميل يحتوي "hello".

كل plugin له عتبته، الافتراضي 0.15 مخزن في src/index.ts:139، والميل على 0.03 لأن المواضيع قصيرة. تحتها:

return { domain: "unknown", decision: null, confidence: 0, plugin: null };

دومين مجهول يعني unknown، أبدا تخمين مخترع عشان يفرحك. مقصودة غبية بهذا الشكل، وهذا بالضبط اللي يخلي العتبة automate > 0.8 صالحة للاستعمال بعدين: لما Sabaki يجاوب، فهذا لأن مختص فعلا طابق.

يوجد محور لغة إضافي عبر langid: plugin يعلن languages لا ينافس إلا على لغاته. عملي لفصل تصيد لاتيني عن CJK. نص قصير جدا يعني لا فلتر، الكل ينافس.

التكلفة الكلية: كم ميكروثانية لكل قرار. لا مصفوفة تنضرب، لا شبكة تندعى، فقط مقارنات strings وقسمة.

Nano، ملفي المفضل في الريبو

src/logreg.ts:28، الـ tokenization:

function tokens(s: string): string[] {
  const words = s.toLowerCase().split(/[^a-z0-9]+/)
    .filter((w) => /^[a-z]{3,}$/.test(w));
  const out = [...words];
  for (let i = 0; i < words.length - 1; i++)
    out.push(`${words[i]} ${words[i + 1]}`);
  return out;
}

Unigrammes زائد bigrammes. charged twice تعطي charged، وtwice، والزوج charged twice. هذا كل ما تحتاجه عشان تلقط "فواترة مزدوجة" بدون ما تطلع transformer بـ 400M.

src/logreg.ts:46، التحويل لمتجهات، هنا TF-IDF يشتغل شغله:

const tf = 1 + Math.log(c);  // TF تحت خطي
const val = tf * w.idf[i]!;  // IDF ملساء على طريقة sklearn

المرة العاشرة لكلمة تحسب أقل من الأولى، والكلمات النادرة توزن أكثر من "please". بالضبط وصفة sklearn، تطابق تام متحقق عليه على 20 سؤال.

والتعليق اللي ضحكني وأنا أراجع:

// يلمس فقط المصطلحات الموجودة (متناثرة) بدل مسح 3000 بعد.
for (const [t, c] of counts) {

بدل ما تمسح 3000 بعد في كل تذكرة، تلمس فقط عشرين كلمة موجودة. عشان كذا يجاوب في أقل من 1ms. هذا النوع من التفاصيل الخفية اللي يغير كل شيء في الإنتاج، لأن التعقيد يتبع حجم التذكرة مو حجم المفردات.

src/logreg.ts:86، الاستدلال، اللي ينحشر في ثلاثة أسطر:

// ثنائي: سطر معاملات واحد زائد سيغمويد
const pPos = 1 / (1 + Math.exp(-s));
// متعدد الفئات: جداء نقطي زائد softmax، argmax

جداء نقطي واحد، سيغمويد أو softmax حسب عدد الفئات. تمريرة واحدة وخلاص. صفر توكن متولد، يعني صفر JSON مكسور تصلحه وراها بـ retry وحيل تفكيك.

وصفة train.py: 300 تدريب / 100 اختبار لكل workflow، تقسيم حتمي (1 عنصر من 5 للاختبار عشان تتفادى الانحياز الزمني)، مفردات 3000-6000، معاملات مقربة لـ 1e-4 مع صفر خسارة مقاسة. تصدير JSON بصيغة { vocab, idf, questions }.

الأوزان: 20 كيلو لـ invoices (الـ duplicate على 0.89) إلى 650 كيلو للميل (الـ bucket على 0.889). النوع من الأحجام اللي يدخل في bundle واجهة.

التفصيل اللي صدمني: على الميل، MiniLM يلصق TF-IDF بفارق +/-0.02. يعني nano يتشحن كما هو. أدفع GPU عشان أفرز إيميلات، لا شكرا. بالمقابل agent_trace يعلق (الـ needs_review على 0.59 في nano): نص المراقبية فقير، ولا كلمة تحمل القرار فعلا.

المصنع اللي يحول الأوزان لـ plugin، في src/plugin.ts:52:

export function nanoClassifier(manifest, weights, opts = {}) {
  return definePlugin({ manifest,
    classify: (input) => {
      const r = predict(`${from}${input.state}`, weights, q);
      return { decision: r.decision, confidence: r.confidence, domain: manifest.domain };
    }});
}

الـ decide() تبقى متزامنة في nano، والـ decideAsync() تفتح ONNX لـ mini. مساران، وقاعدة بسيطة: لو تحتاج async فهذا لأنك تبغى mini، وإلا nano تكفي. سهلة تتذكر وأنت تكود، وسهلة تتصلح لما تنكسر.

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

import { Sabaki } from "sabaki";
import customerService from "@sabaki-js/customer-service";

const sabaki = new Sabaki().register(customerService);
sabaki.decide({ state: "charged twice, refund please" });
// { decision: "billing", confidence: 0.31, domain: "customer_service" }

Mini، 22 ميغا تغير اللعبة على الحالات الصعبة

Nano لها حدود، شفتها على agent_trace فوق. MiniLM-L6 يستلم الشعلة: مشفر مشترك لكل الدومين، زائد رأس خطي لكل سؤال. Fine-tune من أوله لآخره على 8-12 epochs، lr على 2e-5، على GPU CUDA للتدريب (الاستدلال يبقى CPU، هذا اللي يهم).

الأرقام اللي حسمت، مقاسة على نفس التقسيمات:

  • مشفر مجمد زائد MLP: 0.65
  • full fine-tune: 0.68
  • تقطير 400M نحو embeddings مجمدة: تجربت، وانرمت، أسوأ من gold labels

تصدير ONNX عبر dynamo، تطابق تام متحقق، ثم تكميم ديناميكي int8: من 90 ميغا إلى 22 ميغا، -0.004 دقة على customer_service. ECE ثابتة أو أفضل. كل تصدير يعاد قياسه، وإلا التحسين ما يعني شيئا.

من 1.0.2، الأوزان مضمنة في الحزمة (models/<workflow>-int8/ زائد modelDir مصدر). npm i تكفي، وضع طيران OK. التحديث يعني نشر patch، موقع عبر trusted publishing.

عمليا: الـ needs_review تطلع من 0.59 إلى 0.82. الـ disposition من 0.73 إلى 0.76. ECE تحت 0.14 في كل مكان، مع شك OOD صحي (0.2-0.5 على تنانين ووصفات وstrings فارغة). هذا الشك هو اللي يسمح بالعتبة العدوانية في الإنتاج.

Generalist، الشبكة اللي تخلي Sabaki فعلا System One

Jev وغيره يجاوبون على كل شيء: أي سؤال، أي خيارات، تتعرف في runtime. هذا هو عقد System One اللي خلى Jev فيروسي، تطرح instructions زائد criteria مع خياراتك، وهو يحسم.

مختصو Sabaki ما يعرفون يعملون هذا. يعرفون أسئلتهم (category، duplicate، bucket) ولا شيء غيرها. بدون شبكة، Sabaki يرجع unknown أول ما تطلع عن النص، بينما Jev يجاوب دائما شيئا.

الـ generalist هو هذه الشبكة. cross-encoder يقرأ سؤال زائد خيار زائد state ويطلع سكور، تمريرة لكل خيار، softmax، argmax. 22.7M MiniLM int8، أصغر 6.5x من 144-151M حق الشلة. لكن شغله مو السكور، شغله التوافق: معه موصول كـ fallback، الـ POST /v1/systemone يقبل أي state وأي أسئلة، مثل Jev. حقل model يقول مين جاوب فعلا (@sabaki-js/[email protected]+mini، sabaki-generalist-v1...)، فمن جهة المنادي ما تغير شيئا.

والأهم، هو fallback، مو رئيس. القاعدة الذهبية في decideAsync(): لو مختص كسب التوجيه، هو يجاوب. الـ generalist لا يتكلم إلا على unknown. أبدا العكس. هذا يمنع جنرالست متوسط يسحق مختص ممتاز في دومينه.

الـ loader يعيش في src/generalist.ts، مع cache لكل مجلد عشان لا يعيد تحميل جلسة ONNX في كل call:

export function loadGeneralist(modelDir: string) {
  // كاش لكل مجلد، جلسة ONNX يعاد استعمالها
  // tokenizer.json -> Unigram أو wordpiece، يكتشف تلقائيا
  const sess = await ort.InferenceSession.create(
    path.join(modelDir, "generalist.onnx"),
    { intraOpNumThreads: 2 },
  );
}

توجه SABAKI_GENERALIST_DIR نحو مجلد فيه generalist.onnx زائد tokenizer/، والموديل قابل للتبديل. الـ id يجي من config.json، وإلا "sabaki-generalist". الـ cache من نوع Map يمنع إعادة تحميل الجلسة في كل call.

صيغة الدخل، cross-encoder كلاسيكي: سؤال وخيار وstate ملزقة بفواصل، سطر لكل خيار يتقيم:

const texts = options.map(
  (o) => `${input.question ?? "decision"} [SEP] ${o} [SEP] ${input.state}`,
);

سؤال زائد خيار زائد state، مفصولة بـ [SEP]، سطر لكل خيار. الخيارات مسقوفة عند 8 (slice(0, 8))، والافتراضي ["yes", "no"] لو ما تعطي شيئا. فوقها، لازم تقطع من جهة المنادي، 8 واسعة أصلا لقرار مكتوب بنوع.

ثم تجي الدفعة الذكية، نوع التحسين اللي ما يدفع شكله لكنه يقسم التأخير على عدد الخيارات. بدل session.run لكل خيار، كله ينطلق في forward واحد:

// forward واحد مدفع لكل الخيارات
// (مبطنة للطول الأقصى) بدل run لكل خيار.
const L = Math.max(...encs.map((e) => e.ids.length));
// ids زائد attention_mask في [options.length, L]، حشو عند padId
const out = await sess.run({ input_ids, attention_mask });
const scores = Array.from(out["score"].data).map(Number);

كل نص يتشفر (wordpiece على طريقة BERT أو unigram SentencePiece حسب tokenizer.json)، يتبطن لأقصى طول في الدفعة، يترص في تنسور [nb_options, L]. sess.run واحد، وتسترجع سكور لكل خيار. على CPU هذا يغير كل شيء: 20-36ms بدل 8x هذا الوقت.

ثم يجي softmax زائد argmax، مع الاحتمالات الكاملة لكل خيار، بالضبط بالصيغة اللي نشرها Jev:

const pr = softmax(scores);
// bi = مؤشر الأقصى
const confidence = Math.round(pr[bi] * 1000) / 1000;
const probs = Object.fromEntries(
  options.map((o, i) => [o, Math.round(pr[i] * 1000) / 1000]),
);

والتفصيل اللي يخلي الشيء صالحا للإنتاج بدل ما يبقى ديمو، هو abstainAt:

if (input.abstainAt !== undefined && confidence < input.abstainAt) {
  return { decision: null, confidence, domain: "generalist", probabilities: probs };
}

Opt-in، لا امتناع افتراضيا. تمرر abstainAt: 0.6-0.7 وكل ما هو طري يرجع null بدل تخمين. وبما أن softmax يطلع 1.00 حتى لما يغلط خارج التوزيع، فهذا مو رفاهية. الـ GUIDE يوصي به لكل استعمال ذاتي: تحت العتبة، تطلب توضيحا بدل الحل التلقائي.

للختام، هذا نفس التركيب مع fallback الـ generalist: لما ولا مختص يطابق، هو يستلم بدل ما يرجع unknown.

import { Sabaki, definePlugin, loadGeneralist } from "sabaki";
import mail from "@sabaki-js/mail";

const backend = loadGeneralist(process.env.SABAKI_GENERALIST_DIR!);
const generalist = definePlugin({
  manifest: { name: "@sabaki-js/generalist", domain: "generalist",
    version: "0.1.0", questions: ["decision"], keywords: [] },
  classify: (input) => backend.decide(input),
});

const sabaki = new Sabaki().register(mail).setFallback(generalist);

await sabaki.decideAsync({
  state: "le client dit que l'app a supprimé ses données",
  question: "c'est urgent ?",
  options: ["yes", "no"],
  abstainAt: 0.65,
});
// مختص الميل لو التوجيه طابق، وإلا generalist كشبكة على طريقة Jev

أجيال الـ generalist، كلها مقاسة بنفس الطريقة على PhishNChips (2000 إيميل) وعلى اختبار typed-decisions عشان نقارن ما يقارن:

  • v4: 0.29 اختبار، R=0.026 تصيد. تترمي، كانت تجاوب جنب الموضوع.
  • A (22M 80k): 0.546، R=0.406. بدأت تشبه شيئا.
  • mini v1 المخدومة: 0.536، R=0.616. هذه اللي شغالة على الموقع. recall تصيد سليم، اختبار متوسط.
  • B (144M scratch): 0.568، R=0.062. أكبر، أفضل بشوية في الاختبار، ولا شيء في التصيد.

الـ 144M from scratch اللي ما ينقل أفضل، هذا هداني كثير عن فكرة "نرمي بارامترات أكثر عشان نعمم". على نفس المهمة، الكبير يكلف أكثر بدون تخمين أفضل. حتى كشبكة، الصغير يعمل الشغل. ويبقى EN فقط (all-MiniLM-L6-v2)، توجيه EN، وFR خارج التوزيع. للفرنسي، لازم تمر بمختص مدرب عليه، مو بالـ generalist.

Doom، الاختبار الوحيد اللي يهمني فعلا

doomLaya، هي Laya وJev يلعبون FreeDoom حقيقي. الموديل يختار command / target / weapon، والمنفذ يرسم طريق A*، يفتح الأبواب، يصوب، ينقر. تليمتري لكل tick، فيديوهات، SHA للـ runs. أساهم فيه، فما أتكلم عن ليدربورد مقروء من بعيد.

v0.1.0، MAP01 seed 48، skill 3، 35 ticks/s، نفس المنفذ:

الموديل النتيجة p50 HTTP تكلفة API
Laya stock فشل 180s 106ms $0
Laya v3 المعدلة خروج 61.43s 108ms $0
Jev 1.13 خروج 68.97s 357ms $0.0046

خروجان نظيفان، بدون موت، مع Laya تلقط الشوتغن في الطريق لأن المنفذ يدير الالتقاط لحاله. للأمانة: Laya v3 تدربت على Doom، وJev لا. seed واحد، خريطة واحدة، لا يثبت شيئا عاما. README المشروع يقولها أسود على أبيض، وهذا من شرفه أنه لا يبالغ في النتيجة.

جهة Sabaki (plugin-doom): ViZDoom ببايثون ينادي سيرفر JS في كل tick. Nano حوالي 18k req/s، وmini حوالي 71 req/s على thread i5 واحد. الـ tick على 35Hz يمر مرتاح، حتى على لابتوب متوسط.

مع بوصلة خروج، mini تخلص MAP01 في 2-4 runs من 6، وnano صفر من 6. منطقي: النص يضيع المكاني، وحالات doomLaya النصية تسقف نحو 0.57 سواء MiniLM أو ModernBERT-base. لما المعلومة المكانية ناقصة، ولا مصنف نصي يخترعها.

الجيران أعادوا المباراة ببيانات متساوية: doomgemma يلصق LoRA في 27 دقيقة على RTX 4070 ويوصل Gemma 4 E2B لمستوى Laya v3 في اللعب (6/6 مقابل 6/6) وفوقها في القرارات المعزولة. بحجم متساو، مفكك يمشي بنفس سرعة Laya (270M: 22ms مقابل 23ms). بنتيجة متساوية، Laya تبقى أسرع 3x تقريبا لأنها توصل بموديل أصغر.

قراءتي: التدريب على المهمة يهم أكثر من المعمارية. مختص صغير مدرب يغلب جنرالست كبير يكتشف. بالضبط رهان Sabaki. الورقة اللي تليه، System Switch (arXiv:2610.09683)، تدفع المنطق للآخر: موديل صغير سريع يلعب، وموديل مفكر كبير يستلم لما بوابة تنفتح. الفرز الأولي حق Sabaki (الـ automate > 0.85 وإلا تصعيد) مطبق على اللعب.

ما تم التضحية به

كل ميزة غائبة تمول أخرى، هذه قاعدة اللعبة لما تستهدف CPU. القائمة الصادقة، مثل Bareiron حيث كل سطر C كان مقايضة:

Sabaki لا يحكي. هو يحسم بين خيارات تعطيها له، مع احتمال. لشرح، أو ملخص، أو نص حر، خذ LLM.

الفرنسية تبقى خارج التوزيع للـ generalist. توجيه EN (الـ all-MiniLM-L6-v2)، مسبار 1/4 فرنسي مقابل 3/4 إنجليزي على حالات متشابهة. plugin الميل جاء من شركة أغلبها فرنسية لكن مجهولة: صفر PII، -0.024 دقة مقابل مفردات بدون مصطلحات شخصية. إخفاء الهوية يندفع ثمنه، لكنه كان غير قابل للتفاوض.

الـ softmax يظهر واثقا جدا لما يخرج من منطقته. شوهدت أخطاء على 1.00، أيوه. عشان كذا الفرز الأولي الموصى به في GUIDE: الـ abstainAt: 0.6-0.7 ليرجع null تحت العتبة، ثم بوابة confidence > 0.85 للأتمتة، وإنسان للباقي. مرجع مقاس: recall تصيد 0.62 بدقة 0.87. يكفي يقص الطابور نصفين، مو يكفي يطرد المراجعين.

Nano يتعثر على آثار agents. من 0.34 إلى 0.59 حسب السؤال. Mini ترفع الـ needs_review إلى 0.82، لكن الـ risk / urgency / action تبقى صعبة. نص المراقبية فقير في الإشارات المعجمية فقط.

Doom نصيا يسقف. نحو 0.57، MiniLM مثل ModernBERT-base. طول ما تبقى على نص بدون بكسلات، MAP02 توقفك. الطريق الموثوق للاعب حقيقي، هو رؤية زائد RL (tiny-doom-defender، 1.1M بارامتر من بكسلات)، مو مصنف نصي أفضل.

البنشمارك ينجرف بالدفعات. typed-decisions متولد بدفعات، والتقسيم الزمني هو في الحقيقة تقسيم دفعات. الـ drift_tool.py يطلع 0.82 على customer_service، و0.67 على agent_trace (0.5 يعني لا يميز). النتيجة المقاسة: الأوزان المنشورة تعمل 0.733 مختلط مقابل 0.61 على التقسيم الرسمي. فالرقم الرسمي محافظ إذن. المعايرة Platt / temperature+bias لا تنتقل بين الدفعات، فنشحن فقط temperature scaling معاير على دفعات حديثة. قاعدة البيت: الـ drift_tool.py أولا، تحذير فوق 0.6، ومنع فوق 0.8. ولا أحد ثاني ينشر هذا، والكل لازم.

النتيجة النهائية، بالأرقام في اليد

من جهة لابتوب i5، ومن جهة API تحاسب لكل call. هذا ما يعطيه كل وضع عمليا:

  • nano: أقل من 1ms، حوالي 18k req/s، 20-650 كيلو، صفر اعتماد
  • mini: حوالي 20ms، حوالي 71 req/s، 22 ميغا int8، onnxruntime اختياري
  • generalist: 20-36ms، 22.7M، أصغر جنرالست مفتوح أعرفه

عدة دومينات محملة: حوالي 0.5 غيغا RAM في lazy-load لكل دومين بدل حوالي 2 غيغا للكل. حتمي: 100/100 inputs متطابقة تعطي نفس المخرج. عتبات لكل سؤال، بوابة على confidence، وunknown لما لا شيء يطابق.

على Bareiron، تشغيل 3 لاعبين فأكثر كان يعلق لأن كل لاعب يكلف chunk على 200ms. هنا تشغيل 3 دومينات فأكثر يمر بدون ما يتنحنح، لأن كل خبير صغير جدا ويتحمل عند الطلب بدل تحميل الكل دفعة واحدة. نفس فلسفة "الصغير يكفي"، مطبقة على عتاد آخر.

الفلسفة

p2r3 قال: "أحب فقط فكرة أن هذه الشريحة الصغيرة جدا بألف تكلفة تقدر تشغل شيئا متقدما مثل Minecraft."

نفس الجو هنا: أحب فكرة أن 20 كيلو JSON ومئة سطر TS تعوض call على GPU عشان تختار label. واضح ما تعوض كل شيء، لا نبالغ. تعوض الحالة المزعجة حيث تبغى label في مللي ثانية، محليا، مع ثقة تقدر تعتبها بشكل معقول بدل ما تصلب أصابعك.

المقايضات، مكتوبة أسود على أبيض:

  • مشفر نحو عد كلمات: 7 نقاط أقل، تأخير مقسوم على 1000، بنية صفر
  • موديل كبير واحد نحو plugins على npm: بوابة أخشن، عمليات أبسط بلا حدود
  • توليد نحو قرار مكتوب بنوع: خلاص تفكيك يتكسر وخيارات مخترعة
  • ثقة خام نحو فرز أولي: امتناع أولا، أتمتة فوق 0.85، وإنسان للباقي

الـ 3 أشياء تتذكرها:

  1. 7 نقاط أقل، أسرع 1000x، 0.61/0.69 مقابل 0.75-0.80، لكن أقل من 1ms/20ms على CPU أوفلاين مقابل 25 ثانية CPU أو API مدفوعة. سكور بدون تكلفته لا يعني شيئا.
  2. راوتر بسيط زائد خبراء منفصلون، hits / sqrt(keywords)، عتبة، unknown. كل plugin يتدرب ويتنسخ ويتشحن لحاله. على الميل، TF-IDF يلصق MiniLM: الـ GPU لا فائدة منه هنا.
  3. أوفلاين أولا مع معايرة صادقة، npm i، أوزان مضمنة، صفر GPU، ECE تحت 0.14، امتناع، أداة drift. الـ stack الوحيد في المقارنة اللي يمسك حلقة لعب على 35Hz بدون GPU.

لو المشروع يهمك، كله MIT على github.com/sabaki-js. النسخة الأكاديمية مع القياسات وbibtex، هي preprint v1. ولو تبغى تشوف جالوت يلعب Doom حقيقي، على azalio/doomLaya xD

NEW

Sabaki vs System One: David đấu Goliath, mô hình ra quyết định nhỏ nhất thế giới

20KB đấu 421M tham số. Sabaki thua 7 điểm trên typed-decisions nhưng từ 25s CPU xuống dưới 1ms offline. Mổ xẻ một MoE kiểu tổ chức.

Giới thiệu

Nếu bạn hay lướt Hacker News hay Twitter từ tháng 9, chắc chắn bạn đã thấy cái tên Jev lướt qua.

Ngày 15 tháng 9 năm 2026, TypeSafe tung ra Jev, con "System One" đầu tiên của họ. Pitch gói trong một câu: đừng bắt LLM sinh JSON để chọn label nữa. Ném cho nó một state cộng vài câu hỏi có kiểu, nó trả lại quyết định có kiểu kèm xác suất. Một pass duy nhất, zero token phải sample, không có gì để parse sau đó.

Nó nổ tung thật. Ngày đầu đạt adoption kỷ lục trên Vercel AI Gateway, vài ngày có ngay năm khách Elixir, ai cũng lôi Jev ra benchmark với đồ nhà mình. Và nói thật hype này dễ hiểu. Mấy năm trời tụi mình toàn chế prompt kiểu chỉ trả lời đúng {"choice": ...} rồi cầu nguyện cho model đừng bịa thêm option, đừng làm vỡ JSON, đừng hallucinate thêm field. Jev gọi tên đúng cái nhu cầu đó rồi ship thành API sạch: POST /v1/systemone, câu hỏi choice / score / noul, câu trả lời kèm probabilities + confidence.

Mình thích ý tưởng này thật. Còn cái mô hình kinh tế thì thích ít hơn, và nói cho rõ, mình chưa bao giờ trả tiền cho Jev.

Vì đằng sau Jev vẫn là API đóng. Trọng số mù tịt, không self-host được, mỗi quyết định là một round-trip mạng cộng một cái đồng hồ tính tiền đang chạy. Còn mấy bản open đi theo sau thì toàn cỡ 400M tham số, bắt buộc phải có GPU.

Thế nên mình build cực còn lại: Sabaki. Một router keyword cộng hồi quy logistic bằng TypeScript thuần, nói đúng cùng contract với Jev (cùng shape choice / score / noul, cùng endpoint /v1/systemone), nhưng chạy trong dưới 1ms với file JSON 20 kilobyte. Chế độ nano thì zero dependency, offline, 0€.

Để bạn dễ hình dung: Laya là 843 MB safetensors phải load. OpenDecider-nano là 2 GB RAM. Sabaki nano invoices là 20 KB. Tỉ lệ cân nặng cỡ 40 000.

Nếu bạn muốn bản vuông vắn đầy số liệu, có preprint v1 tháng 10 năm 2026 trên site: Sabaki: An Organizational Mixture-of-Experts for Typed Decisions on CPU (PDF). Thiết kế, đo đạc, tradeoff, đủ hết. Còn bài này là bản dễ đọc, kèm code mở bên cạnh.

Mình không viết nó một mình kiểu benchmark giấy. Mình có contribute cho azalio/doomLaya (fork của mình: fox3000foxy/doomLaya), một harness nơi Laya và Jev chơi FreeDoom thật với quyết định do model cầm, telemetry theo từng tick và video. Mình đã thấy Goliath chơi ngoài đời thật, và cứ mạng lag một cái là nó cũng chịu.

Giờ mình mở từng cái động cơ ra một. Như mọi khi, với code thật.

Cái sàn đấu mà ai cũng chịu chơi

Chỉ có một dataset mà Jev, Laya và OpenDecider đều trích: LocalLLaMA/typed-decisions. 4 workflow tổng hợp (support, hóa đơn, bảo mật, trace agent), 5 câu hỏi mỗi case, 1200 case train, 400 case test, 2000 quyết định.

Modèle Params Score test Latence Où ça tourne
OpenDecider-nano ~400M 0.796 17ms GPU GPU, 2 Go RAM
Laya typed-decisions 421M 0.766 ~25s/state CPU, ~830ms/cas MLX GPU
Jev 1.13 fermé ~0.73-0.75 ~710ms API API only
Sabaki mini 22M int8 0.69 ~20ms CPU CPU, 22 Mo
Sabaki nano ~0 0.61 <1ms partout
Laya base zéro-shot 421M 0.362 pareil GPU

Random là 0.318, majority là 0.461. Nên Laya base 0.362 mà không fine-tune thì đúng tầm "trả lời bừa nhưng lịch sự". Nhớ kỹ chỗ này, lát nó sẽ có ích.

7 điểm ngăn cách Sabaki với con top nhất. 1000x độ trễ ngăn theo chiều ngược lại khi bạn không có GPU. Cả bài gói trong hai câu đó, phần còn lại là để hiểu tại sao.

Nhưng cẩn thận: đằng sau cùng một ổ cắm choice / score / noul, nồi niêu bên trong chẳng liên quan gì nhau. Jev = API đóng. Laya = ModernBERT-large fine-tune. OpenDecider = Ettin-encoder distill. Sabaki = đếm từ + MiniLM int8. Cùng contract, ngân sách khác một trời một vực.

Jev, cú tát mà ai cũng thấy

Cái gì của TypeSafe thì trả cho TypeSafe: contract System One đúng là một ý tưởng hay thật.

Trước đây, để phân loại một ticket, bạn làm thế này: bạn hỏi LLM "chọn giữa billing, kỹ thuật và spam, trả lời bằng JSON", rồi parse câu trả lời, rồi xử lý mấy ca nó trả lời "Mình nghĩ là billing ! 😊" hay bịa thêm category thứ 4. Mỗi câu hỏi tốn token, mỗi câu trả lời đều có thể làm sập pipeline.

Jev đổi kèo: bạn gửi text + câu hỏi kèm tiêu chí, nó đánh giá từng câu hỏi song song rồi trả về giá trị có kiểu kèm phân phối của nó.

curl https://api.typesafe.ai/v1/systemone \
 -H "Authorization: Bearer $KEY" \
 -H 'content-type: application/json' \
 -d '{"state":"charge double ce mois-ci","questions":{"dept":{"type":"choice","instructions":"Route ce message.","criteria":{"billing":"paiements","technical":"bugs"}}}}}'

Trả lời: { choice, probabilities, confidence }. Không text tự do. Code của bạn cắm vào xài ngay được.

Đó là lý do nó thành viral: SDK Python + JS, plugin Claude Code, tích hợp LangChain, ai cũng cắm agent của mình vào trong một buổi chiều. Doc TypeSafe còn đẩy đúng pattern lành mạnh: Jev cho mấy phán đoán nhỏ cần nhanh, code deterministic bọc quanh để ráp quyết định thật, LLM chậm hoặc con người nhảy vào khi confidence thấp.

Vấn đề là phần còn lại. Trọng số đóng, offline bất khả thi, tính tiền theo call. Với một agent quất 100 quyết định mỗi run, tiền thuế này đau. Với Doom chạy 35 ticks/s thì chết hẳn: trong doomLaya, Jev clear MAP01 seed 48 trong 68.97s với p50 HTTP 357ms. Run đó tốn $0.0046 tiền API, không phải hóa đơn của mình, mà là của benchmark. Nó chạy được, run sạch, không chết mạng nào. Nhưng 357ms ở 35 ticks/s thì tức là 12 tick game vẫn chạy trong lúc bạn đứng chờ mạng.

Sabaki chôm đúng contract đó, cùng câu hỏi, cùng câu trả lời, cùng endpoint /v1/systemone do serve() phục vụ, nhưng vứt hết mọi thứ cần datacenter.

Laya, con open bự chứng minh làm được mà khỏi cần API

Laya của Convai Innovations, Apache-2.0. Dưới nắp capo: ModernBERT-large, 421M, với 1 token [MASK] mỗi option. Hidden state của từng marker chạy qua một MLP nhỏ, softmax trên các option của câu hỏi. Một pass mỗi câu hỏi, giống Jev.

pipe = pipeline("text-classification", model="convaiinnovations/laya-typed-decisions")

Con 0.766 đáng để dừng lại mổ xẻ. Đó là checkpoint fine-tune trên tập train của benchmark: 1200 case, RLCD với nhiễu Gauss trên logit, reward log + spherical. Cùng model mà không fit kiểu đó thì rớt xuống 0.362. Nói thẳng là đoán mò. Nên điểm Laya là "encoder bự + train đúng domain", chứ không phải "encoder bự thần kỳ".

Trên CPU, nó nặng như cục gạch. Bản port ONNX đo được cỡ 25s mỗi state. Trên MLX FP16, tính trung bình 830ms mỗi case với p99 5.3s. Mà phải load 843 MB trước khi trả lời bất cứ thứ gì.

Trong doomLaya, Laya stock fail MAP01 trong 180s. Bản v3 adapt (azalio/laya-doom-v3), train lại trên data Doom, clear trong 61.43s với p50 108ms trên MPS, tiện tay lụm luôn khẩu shotgun. Nên đúng, nó chơi được, sau khi train riêng, trên GPU Apple, với một executor scripted gánh việc thật (A*, cửa có latch 15 tick, ngắm tự động).

Mình tóm gọn: không train trên task của bạn, Laya chỉ là cái kẹp giấy 421M. Có train thì rất mạnh, nhưng bạn phải có GPU và data đi kèm.

OpenDecider, kẻ thắng leaderboard

OpenDecider, là lúc Manjunath quyết định đi gõ đầu từng người trên chính benchmark của họ. Kết quả: 0.796 với Ettin-encoder-400m full fine-tune + đầu MLP, loss KL trên soft target distill từ Qwen3-235B + DeepSeek.

pip install opendecider
# 17ms/question sur L40S, 18ms sur M4 Max, ~9ms batché

Ở 50% coverage, selective accuracy lên tới 0.943 so với 0.882 của Jev. Trên giấy, best cả hai thế giới: open, calibré, nhanh.

Thực tế: load nano tốn 2 GB RAM, muốn giữ 17ms thì bắt buộc GPU, phishing 0.63 so với 0.90 của Jev (gặp OOD là bị vả), và giống Laya, nó train trên tập train của benchmark. Tập test thì chưa thấy bao giờ, nhưng domain thì thấy rồi.

Nó thắng trận vật tay, nhưng vẫn là 400M với GPU chống lưng. Bạn không nhét nó vào Worker Cloudflare hay vòng lặp game trên laptop được.

Lũ 150M, và cái hố bên dưới

openJev-verdict-2.0 (151M), julia-1 (144M đa ngữ), Open Jev (150M), Largha-150M (144M en+zh), Vela (0.3B-9B). Toàn ModernBERT hay mmBERT-small, con nào cũng chạy CPU được, con nào cũng dễ train (Open Jev báo 30 phút trên T4 Colab). Số liệu tự công bố, protocol mỗi nơi một kiểu, nên đọc với nhúm muối, nhưng cái họ này tồn tại thật.

Con generalist Sabaki chỉ 22.7M. Nhỏ hơn khoảng 6.5x so với con bé nhất đám kia. Không ai khác chui xuống dưới 100M để trả lời câu hỏi định nghĩa lúc runtime.

Đó chính là nghĩa của "nhỏ nhất thế giới": không phải điểm cao nhất, mà là cái vỏ nhỏ nhất mà vẫn đọc được instructions + criteria của bạn rồi chốt khi không chuyên gia nào biết trả lời.

Sabaki, con router ngố và dàn expert xài một lần

Rồi, nói về mấy con bự đủ rồi. Qua David đi, vì đây mới là chỗ vui.

Sabaki không cố làm model bự thông minh hiểu cả thế giới. Nó là MoE kiểu tổ chức: 1 router thưa ở tầng hệ thống, các expert độc lập, mỗi input chỉ một expert chạy. Mỗi plugin là một package npm riêng, có manifest, trọng số, version riêng. Bạn cài đúng cái bạn route tới.

Org sabaki-js: 13 repo, core sabaki (129 commit), 6 plugin @sabaki-js/*, trọng số, worker Cloudflare, template để viết plugin của riêng bạn.

File package.json của core đã kể hết câu chuyện:

"dependencies": { "@typesafe-ai/sdk": "^0.7.0" },
"optionalDependencies": { "onnxruntime-node": "^1.22.0" }

Ở chế độ nano, zero dependency runtime. Chỉ TypeScript biên dịch sẵn, không có gì để cài, không có gì vỡ giữa hai bản ONNX.

Con router, hay cách quyết định trong vài dòng

Router nằm trong src/index.ts, và đoạn tokenize trông thế này (src/index.ts:129):

function tokens(s: string): string[] {
  return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .split(/[^a-z0-9]+/).filter((w) => w.length > 3);
}

Chữ thường, bỏ dấu, từ tối thiểu 4 chữ. Cái lọc này đá hết nhiễu ("le", "de", "ok"). Trên ticket support, nó thay đổi tất cả vì matching của bạn đánh trên mấy từ thật sự có nghĩa.

Còn scoring nằm trong route() (src/index.ts:251), và mọi thứ quyết định ở đây:

const hits = kws.filter((k) =>
  words.some((w) => w.includes(k) || k.includes(w))).length;
const score = hits / Math.sqrt(kws.length);

Matching lỏng cả hai chiều: billing bắt được billed, invoice bắt được invoicing. Chia cho căn bậc hai để khỏi phạt mấy list keyword dài, nếu không plugin mail với 600 từ của nó đè bẹp tất cả chỉ vì một mail có chữ "hello".

Mỗi plugin có ngưỡng riêng, mặc định 0.15 nằm ở src/index.ts:139, mail là 0.03 vì subject ngắn. Dưới ngưỡng:

return { domain: "unknown", decision: null, confidence: 0, plugin: null };

Domain lạ = unknown, không bao giờ bịa guess cho vui. Ngố một cách cố ý, và đó đúng là thứ khiến ngưỡng automate > 0.8 xài được về sau: khi Sabaki trả lời, tức là một chuyên gia đã match thật.

Khuyến mãi thêm trục ngôn ngữ qua langid: plugin nào khai languages thì chỉ đua trong ngôn ngữ đó. Tiện để tách phishing Latin với CJK. Text quá ngắn = khỏi lọc, ai cũng được đua.

Tổng chi phí: vài micro giây mỗi quyết định. Không ma trận nào phải nhân, không mạng nào phải gọi, chỉ so string với một phép chia.

Nano, file mình thích nhất repo

src/logreg.ts:28, đoạn tokenize:

function tokens(s: string): string[] {
  const words = s.toLowerCase().split(/[^a-z0-9]+/)
    .filter((w) => /^[a-z]{3,}$/.test(w));
  const out = [...words];
  for (let i = 0; i < words.length - 1; i++)
    out.push(`${words[i]} ${words[i + 1]}`);
  return out;
}

Unigram + bigram. charged twice cho ra charged, twice, và cặp charged twice. Chỉ cần vậy là đủ bắt "bị tính tiền hai lần" mà khỏi lôi transformer 400M ra.

src/logreg.ts:46, đoạn vector hóa, nơi TF-IDF làm việc của nó:

const tf = 1 + Math.log(c);  // TF dưới tuyến
const val = tf * w.idf[i]!;  // IDF mượt kiểu sklearn

Lần xuất hiện thứ 10 của một từ ít giá trị hơn lần đầu, từ hiếm nặng ký hơn "please". Đúng công thức sklearn, parity chuẩn đã verify trên 20 câu hỏi.

Và cái comment làm mình bật cười khi đọc lại:

// Chỉ chạm vào term có mặt (thưa) thay vì quét 3000 chiều.
for (const [t, c] of counts) {

Thay vì quét 3000 chiều mỗi ticket, bạn chỉ chạm vào hai chục từ đang có mặt. Đó là lý do nó trả lời dưới 1ms. Kiểu chi tiết vô hình nhưng lên prod thì đổi hết cục diện, vì độ phức tạp ăn theo độ dài ticket chứ không ăn theo cỡ vocab.

src/logreg.ts:86, đoạn suy luận, gói trong ba dòng:

// nhị phân: 1 dòng coef + sigmoid
const pPos = 1 / (1 + Math.exp(-s));
// đa lớp: tích vô hướng + softmax, argmax

Một tích vô hướng, một sigmoid hay softmax tùy số lớp. Một pass là xong. Zero token sinh ra, nên zero JSON vỡ phải vá bằng retry và mẹo parse.

Công thức train.py: 300 train / 100 test mỗi workflow, split deterministic (1 item trên 5 vào test để tránh bias thời gian), vocab 3000-6000, coef làm tròn tới 1e-4 mà không mất accuracy đo được. Export JSON { vocab, idf, questions }.

Trọng số: 20 KB cho invoices (duplicate 0.89) tới 650 KB cho mail (bucket 0.889). Cỡ này nhét vào bundle frontend ngon lành.

Chi tiết làm mình choáng: trên mail, MiniLM bám TF-IDF trong +-0.02. Nên nano ship luôn. Bắt trả tiền GPU để phân loại mail thì thôi, mình xin kiếu. Ngược lại agent_trace kẹt (needs_review 0.59 ở nano): text observability nghèo, không từ nào thật sự gánh quyết định.

Cái factory biến trọng số thành plugin, trong src/plugin.ts:52:

export function nanoClassifier(manifest, weights, opts = {}) {
  return definePlugin({ manifest,
    classify: (input) => {
      const r = predict(`${from}${input.state}`, weights, q);
      return { decision: r.decision, confidence: r.confidence, domain: manifest.domain };
    }});
}

decide() giữ đồng bộ cho nano, decideAsync() mở ONNX cho mini. Hai đường, một luật đơn giản: cần async tức là bạn muốn mini, còn không nano đủ xài. Dễ nhớ khi code, dễ debug khi hỏng.

Để cho bạn thấy đây không chỉ là lý thuyết, đây là lúc xài thật trông ra sao:

import { Sabaki } from "sabaki";
import customerService from "@sabaki-js/customer-service";

const sabaki = new Sabaki().register(customerService);
sabaki.decide({ state: "charged twice, refund please" });
// { decision: "billing", confidence: 0.31, domain: "customer_service" }

Mini, 22 MB đổi cục diện trên case khó

Nano có giới hạn, bạn thấy trên agent_trace ngay bên trên. MiniLM-L6 lên tiếp sức: một encoder chung cho cả domain, cộng một đầu tuyến tính mỗi câu hỏi. Fine-tune end-to-end 8-12 epoch, lr 2e-5, trên GPU CUDA lúc train (lúc suy luận vẫn CPU, cái đó mới quan trọng).

Mấy con số đã chốt hạ, đo trên cùng split:

  • encoder đông cứng + MLP: 0.65
  • full fine-tune: 0.68
  • distill 400M về embedding đông cứng: test rồi, vứt, thua gold label

Export ONNX qua dynamo, parity chuẩn đã verify, rồi quant int8 động: 90 MB xuống 22 MB, -0.004 accuracy trên customer_service. ECE giữ nguyên hoặc tốt hơn. Mỗi bản export đều đo lại, không thì optimize chẳng nghĩa lý gì.

Từ bản 1.0.2, trọng số nhúng luôn trong package (models/<workflow>-int8/ + modelDir export sẵn). npm i là xong, bật chế độ máy bay vẫn OK. Update = publish patch, ký qua trusted publishing.

Cụ thể: needs_review từ 0.59 lên 0.82. disposition 0.73 lên 0.76. ECE < 0.14 mọi nơi, với hoài nghi OOD lành mạnh (0.2-0.5 trên rồng, công thức nấu ăn và string rỗng). Chính cái hoài nghi này cho phép ngưỡng gắt ở prod.

Generalist, lưới hứng biến Sabaki thành System One thật

Jev và đám còn lại trả lời mọi thứ: bất kỳ câu hỏi nào, bất kỳ option nào, định nghĩa lúc runtime. Đó là contract System One làm Jev viral, bạn quăng instructions + criteria với option của bạn vào, nó chốt.

Mấy chuyên gia Sabaki không biết làm vậy. Chúng chỉ biết câu hỏi của mình (category, duplicate, bucket) và hết. Không có lưới hứng, Sabaki sẽ trả unknown ngay khi bạn ra khỏi kịch bản, chỗ Jev vẫn trả lời gì đó.

Generalist là cái lưới đó. Một cross-encoder đọc câu hỏi + option + state rồi nhả điểm, một pass mỗi option, softmax, argmax. 22.7M MiniLM int8, nhỏ hơn 6.5x so với đám 144-151M. Nhưng job của nó không phải điểm số, mà là tương thích: cắm nó làm fallback, POST /v1/systemone nhận bất kỳ state và bất kỳ câu hỏi nào, giống Jev. Field model nói ai đã trả lời thật (@sabaki-js/[email protected]+mini, sabaki-generalist-v1...), nên phía gọi bạn chẳng đổi gì.

Và quan trọng, nó là fallback, không phải sếp. Luật vàng trong decideAsync(): chuyên gia thắng routing thì chuyên gia trả lời. Generalist chỉ mở miệng khi unknown. Không bao giờ ngược lại. Vậy mới khỏi chuyện generalist tầm tầm đè đầu chuyên gia giỏi đúng sân nhà.

Loader nằm trong src/generalist.ts, có cache theo thư mục để khỏi load lại session ONNX mỗi call:

export function loadGeneralist(modelDir: string) {
  // cache theo thư mục, tái dùng session ONNX
  // tokenizer.json -> Unigram hay wordpiece, tự phát hiện
  const sess = await ort.InferenceSession.create(
    path.join(modelDir, "generalist.onnx"),
    { intraOpNumThreads: 2 },
  );
}

Bạn trỏ SABAKI_GENERALIST_DIR tới thư mục có generalist.onnx + tokenizer/, model thay nóng được. Id lấy từ config.json, không thì "sabaki-generalist". Cache Map khỏi reload session mỗi call.

Format input là cross-encoder cổ điển: câu hỏi, option và state nối nhau bằng separator, mỗi option cần chấm điểm một dòng:

const texts = options.map(
  (o) => `${input.question ?? "decision"} [SEP] ${o} [SEP] ${input.state}`,
);

Câu hỏi + option + state, ngăn bằng [SEP], mỗi option một dòng. Option chặn ở 8 (slice(0, 8)), mặc định ["yes", "no"] nếu bạn không đưa gì. Hơn nữa thì cắt phía bên gọi, 8 là rộng rồi cho một quyết định có kiểu.

Rồi tới batch khôn, kiểu optimize nhìn không ra gì nhưng chia độ trễ theo số option. Thay vì một session.run mỗi option, tất cả đi trong một forward:

// Một forward batch duy nhất cho mọi option
// (độn tới độ dài max) thay vì một run mỗi option.
const L = Math.max(...encs.map((e) => e.ids.length));
// ids + attention_mask thành [options.length, L], padding bằng padId
const out = await sess.run({ input_ids, attention_mask });
const scores = Array.from(out["score"].data).map(Number);

Mỗi text được encode (wordpiece kiểu BERT hay unigram SentencePiece tùy tokenizer.json), pad tới độ dài max của batch, xếp thành tensor [số_option, L]. Một sess.run duy nhất, bạn lấy một điểm mỗi option. Trên CPU nó đổi hết: 20-36ms thay vì gấp 8 lần đó.

Rồi tới softmax + argmax, với proba đầy đủ cho từng option, đúng format Jev đã popularize:

const pr = softmax(scores);
// bi = index của max
const confidence = Math.round(pr[bi] * 1000) / 1000;
const probs = Object.fromEntries(
  options.map((o, i) => [o, Math.round(pr[i] * 1000) / 1000]),
);

Và chi tiết biến đồ chơi demo thành đồ xài prod được là abstainAt:

if (input.abstainAt !== undefined && confidence < input.abstainAt) {
  return { decision: null, confidence, domain: "generalist", probabilities: probs };
}

Opt-in, mặc định không từ chối. Bạn truyền abstainAt: 0.6-0.7 và mọi thứ yếu trả về null thay vì guess bừa. Vì softmax nhả 1.00 ngay cả khi sai ngoài phân phối, thiếu cái này là mệt. GUIDE khuyên dùng cho mọi tác vụ tự động: dưới ngưỡng thì hỏi lại thay vì tự chốt.

Cuối cùng, đây là cùng dàn đó với fallback generalist: khi không chuyên gia nào match, nó đỡ thay vì trả unknown.

import { Sabaki, definePlugin, loadGeneralist } from "sabaki";
import mail from "@sabaki-js/mail";

const backend = loadGeneralist(process.env.SABAKI_GENERALIST_DIR!);
const generalist = definePlugin({
  manifest: { name: "@sabaki-js/generalist", domain: "generalist",
    version: "0.1.0", questions: ["decision"], keywords: [] },
  classify: (input) => backend.decide(input),
});

const sabaki = new Sabaki().register(mail).setFallback(generalist);

await sabaki.decideAsync({
  state: "le client dit que l'app a supprimé ses données",
  question: "c'est urgent ?",
  options: ["yes", "no"],
  abstainAt: 0.65,
});
// chuyên gia mail nếu routing match, không thì generalist đỡ kiểu Jev

Các đời generalist, đều đo như nhau trên PhishNChips (2000 mail) và trên test typed-decisions để so cái so được:

  • v4: 0.29 test, R=0.026 phishing. Vứt, trả lời trật lất.
  • A (22M 80k): 0.546, R=0.406. Bắt đầu ra hình người.
  • mini v1 đang serve: 0.536, R=0.616. Bản đang chạy trên site. Recall phishing ổn, test trung bình.
  • B (144M scratch): 0.568, R=0.062. Bự hơn, test nhỉnh hơn tí, phishing dở tệ.

Con 144M train từ đầu mà transfer không khá hơn làm mình tỉnh hẳn vụ "đắp thêm param để tổng quát hơn". Cùng task, con bự tốn tiền hơn mà đoán có giỏi hơn đâu. Ngay cả làm lưới hứng, con nhỏ vẫn gánh được. Và nó vẫn EN-only (all-MiniLM-L6-v2), routing EN, FR là ngoài phân phối. Muốn FR thì đi qua chuyên gia train FR, đừng qua generalist.

Doom, bài test duy nhất mình quan tâm thật

doomLaya là nơi Laya và Jev chơi FreeDoom thật. Model chọn command / target / weapon, executor vẽ đường A*, mở cửa, ngắm, bấm. Telemetry từng tick, video, SHA của run. Mình có contribute nên mình không nói về leaderboard đọc từ xa.

v0.1.0, MAP01 seed 48, skill 3, 35 ticks/s, cùng executor:

Modèle Résultat p50 HTTP Coût API
Laya stock échec 180s 106ms $0
Laya v3 adaptée sortie 61.43s 108ms $0
Jev 1.13 sortie 68.97s 357ms $0.0046

Hai pha clear sạch, không chết, Laya tiện tay lụm khẩu shotgun vì executor tự lo pickup. Trung thực mà nói: Laya v3 đã train trên Doom, Jev thì không. Một seed, một map, chẳng chứng minh gì tổng quát. README dự án nói trắng ra, và điểm đáng nể là nó không nổ kết quả.

Phía Sabaki (plugin-doom): ViZDoom bằng Python gọi server JS mỗi tick. Nano ~18k req/s, mini ~71 req/s trên một thread i5. Tick 35Hz qua nhẹ, ngay cả laptop tầm trung.

Có la bàn cửa ra, mini clear MAP01 trong 2-4 run trên 6, nano 0/6. Hợp lý: text mất thông tin không gian, state doomLaya kẹt quanh 0.57 dù MiniLM hay ModernBERT-base. Khi thông tin không gian thiếu, không classifier text nào bịa ra được.

Mấy nhà bên đã đá lại trận đó trên cùng data: doomgemma đắp LoRA 27 phút trên RTX 4070 rồi đưa Gemma 4 E2B ngang Laya v3 lúc chơi (6/6 vs 6/6) và hơn lúc quyết định lẻ. Cùng cỡ, decoder chạy nhanh ngang Laya (270M: 22ms vs 23ms). Cùng kết quả, Laya vẫn nhanh hơn ~3x vì nó làm được với model nhỏ hơn.

Mình đọc thế này: train đúng task quan trọng hơn kiến trúc. Chuyên gia nhỏ được train ăn đứt generalist bự lần đầu gặp việc. Đúng kèo Sabaki cược. Paper đi sau, System Switch (arXiv:2610.09683), đẩy logic tới cùng: model nhỏ nhanh chơi, model reasoner bự giành lại tay lái khi cổng mở. Pre-triage Sabaki (automate > 0.85 không thì leo thang) áp vào game.

Cái đã hy sinh

Mỗi feature vắng mặt nuôi một feature khác, đó là luật chơi khi bạn nhắm CPU. List trung thực, giống Bareiron nơi mỗi dòng C là một tradeoff:

Sabaki không kể chuyện. Nó chốt giữa option bạn đưa, kèm proba. Muốn giải thích, tóm tắt, text tự do thì lấy LLM.

Tiếng Pháp vẫn ngoài phân phối với generalist. Routing EN (all-MiniLM-L6-v2), probe 1/4 FR vs 3/4 EN trên case tương tự. Plugin mail từ một hộp thư chủ yếu FR nhưng đã ẩn danh: 0 PII, -0.024 accuracy để lấy vocab không term cá nhân. Ẩn danh phải trả giá, nhưng chỗ này không thương lượng được.

Softmax tỏ ra chắc cú quá khi ra khỏi sân nhà. Đã thấy sai mà 1.00, có. Nên GUIDE khuyên pre-triage: abstainAt: 0.6-0.7 để trả null dưới ngưỡng, rồi gate confidence > 0.85 mới tự động, còn lại người xử. Tham chiếu đã đo: recall phishing 0.62 ở precision 0.87. Đủ để chẻ hàng đợi làm đôi, chưa đủ để đuổi reviewer.

Nano vật vã trên trace agent. 0.34 tới 0.59 tùy câu hỏi. Mini kéo needs_review lên 0.82, nhưng risk / urgency / action vẫn khoai. Text observability nghèo tín hiệu từ vựng thật.

Doom text kẹt trần. Quanh 0.57, MiniLM hay ModernBERT-base cũng vậy. Chừng nào còn text không pixel, MAP02 chặn bạn. Đường khả thi cho tay chơi thật là vision + RL (tiny-doom-defender, 1.1M param từ pixel), không phải classifier text ngon hơn.

Benchmark trôi theo batch. typed-decisions sinh theo mẻ, split thời gian thực ra là split mẻ. drift_tool.py nhả 0.82 trên customer_service, 0.67 trên agent_trace (0.5 = không phân biệt được). Hệ quả đã đo: trọng số public đạt 0.733 ở mix so với 0.61 trên split chính thức. Nên số chính thức thiên về thận trọng. Calib Platt / temperature+bias không transfer giữa batch, nên chỉ ship temperature scaling calib trên batch gần nhất. Luật nhà: chạy drift_tool.py trước, warn trên 0.6, block trên 0.8. Chẳng ai khác public cái này, mà ai cũng nên làm.

Kết quả cuối, số liệu trong tay

Một bên là i5 laptop, bên kia là API tính tiền theo call. Đây là thứ mỗi chế độ cho ra cụ thể:

  • nano: <1ms, ~18k req/s, 20-650 KB, 0 dep
  • mini: ~20ms, ~71 req/s, 22 MB int8, onnxruntime optional
  • generalist: 20-36ms, 22.7M, generalist open nhỏ nhất mình biết

Nhiều domain cùng load: ~0.5 GB RAM lazy-load theo domain thay vì ~2 GB cho tất cả. Deterministic: 100/100 input giống nhau cho output giống nhau. Ngưỡng theo câu hỏi, gate trên confidence, unknown khi không gì match.

Trên Bareiron, chạy 3+ người chơi là lag vì mỗi người tốn một chunk 200ms. Ở đây chạy 3+ domain vẫn mượt, vì mỗi expert tí hon và load khi cần thay vì load hết một cục. Cùng triết lý "nhỏ mà đủ", áp qua phần cứng khác.

Triết lý

p2r3 từng nói: "J'aime juste l'idée que cette toute petite puce à 1 balles puisse faire tourner quelque chose d'aussi avancé que Minecraft."

Cùng mood ở đây: mình thích ý tưởng 20 KB JSON với trăm dòng TS thay một cú gọi GPU để chọn label. Dĩ nhiên nó không thay mọi thứ, đừng đùa. Nó thay đúng ca khó chịu nơi bạn muốn một label trong mili giây, local, với confidence đủ tử tế để đặt ngưỡng thay vì nhắm mắt cầu may.

Tradeoff, viết trắng mực đen:

  • encoder thành đếm từ: kém 7 điểm, độ trễ chia 1000, infra về zero
  • model bự duy nhất thành plugin npm: gate thô hơn, ops dễ hơn vô hạn
  • sinh text thành quyết định có kiểu: hết parse vỡ và option bịa
  • confidence thô thành pre-triage: abstain trước, tự động trên 0.85, còn lại người lo

3 thứ cần nhớ:

  1. Kém 7 điểm, nhanh hơn 1000x - 0.61/0.69 vs 0.75-0.80, nhưng <1ms/20ms CPU offline so với 25s CPU hay API trả tiền. Điểm mà không kèm chi phí thì vô nghĩa.
  2. Router đơn giản + expert tách rời - hits / sqrt(keywords), threshold, unknown. Mỗi plugin train, version và ship riêng. Trên mail, TF-IDF bám MiniLM: GPU ở đây vô dụng.
  3. Offline-first với calib trung thực - npm i, trọng số nhúng sẵn, 0 GPU, ECE < 0.14, abstain, drift tool. Stack duy nhất trong kèo này giữ được game loop 35Hz mà không cần GPU.

Nếu bạn hứng thú, tất cả MIT trên github.com/sabaki-js. Bản học thuật với đo đạc và bibtex là preprint v1. Còn muốn xem Goliath chơi Doom thật thì qua azalio/doomLaya xD

NEW

>-

>-

บทนำ

ถ้าคุณสิงอยู่บน Hacker News หรือ Twitter มาตั้งแต่เดือนกันยา คุณต้องเคยเห็นชื่อ Jev ผ่านตาแน่ๆ

วันที่ 15 กันยายน 2026 TypeSafe เปิดตัว Jev ซึ่งเป็น "System One" ตัวแรกของพวกเขา Pitch จบในประโยคเดียว : เลิกให้ LLM ปั่น JSON เพื่อเลือก label ได้แล้ว โยน state กับคำถามแบบ typed ให้มัน แล้วมันจะคืนคำตัดสินใจแบบ typed พร้อมความน่าจะเป็นมาให้ รอบเดียว ไม่ต้อง sample token สักตัว ไม่ต้อง parse อะไรต่อข้างหลัง

มันระเบิดเลย วันแรกยอดใช้บน Vercel AI Gateway ทำสถิติ ลูกค้า Elixir โผล่มาห้ารายในไม่กี่วัน ทุกคนเอา Jev ไป benchmark เทียบของตัวเอง แล้วพูดตรงๆ กระแสนี้เข้าใจได้ เราทนเขียน prompt แบบ ตอบกลับมา ONLY ด้วย {"choice": ...} มาหลายปีแล้ว พร้อมภาวนาว่าโมเดลจะไม่มโน option เพิ่ม จะไม่ทำ JSON พัง จะไม่หลอน field ขึ้นมา Jev ตั้งชื่อให้ความต้องการนี้แล้ว ship เป็น API สะอาดๆ : POST /v1/systemone คำถามแบบ choice / score / noul คำตอบมี probabilities + confidence

ผมชอบไอเดียนี้มาก ไอเดียประหยัดน่ะ ชอบน้อยกว่าหน่อย และพูดให้ชัด ผมไม่เคยจ่ายเงินให้ Jev เลย

เพราะข้างหลัง Jev ยังเป็น API ปิด น้ำหนักไม่เปิด ไม่มี self-host ทุกคำตัดสินใจคือ network round-trip หนึ่งรอบบวกมิเตอร์ที่หมุนตลอด แล้วทางเลือก open ที่ตามมาก็ซัดไปราว 400M พารามิเตอร์ แถมบังคับใช้ GPU

ผมเลยสร้างขั้วตรงข้าม : Sabaki เราเตอร์ keyword บวก logistic regression ใน TypeScript ล้วนๆ ที่พูด contract เดียวกับ Jev (shape choice / score / noul เหมือนกัน endpoint /v1/systemone เหมือนกัน) แต่รันใน ต่ำกว่า 1ms ด้วย JSON 20 กิโลไบต์ โหมด nano ไม่มี dependency เลย ออฟไลน์ 0 บาท

เทียบให้เห็นภาพ : Laya คือ safetensors 843 MB ที่ต้องโหลด OpenDecider-nano คือ RAM 2 GB Sabaki nano invoices คือ 20 KB อัตราส่วนน้ำหนักราวๆ 40,000 เท่า

ถ้าอยากได้เวอร์ชันแน่นๆ เป็นตัวเลข มี preprint v1 เดือนตุลา 2026 บนเว็บ : Sabaki: An Organizational Mixture-of-Experts for Typed Decisions on CPU (PDF) ดีไซน์ ตัวเลขวัด tradeoff ครบหมด บทความนี้คือเวอร์ชันอ่านง่าย พร้อมโค้ดเปิดข้างๆ

ผมไม่ได้เขียนมันเงียบๆ คนเดียวแบบ benchmark กระดาษ ผม contribute ให้ azalio/doomLaya (fork ผมเอง : fox3000foxy/doomLaya) ซึ่งเป็น harness ที่ Laya กับ Jev เล่น FreeDoom กันจริงๆ แบบ decisions เป็นของโมเดล มี telemetry ทุก tick มีวิดีโอ ผมเห็นโกลิอัทเล่นจริงมาแล้ว และพอเน็ตกระตุก มันก็ร่วงเหมือนกัน

เราจะแงะเครื่องทีละตัว เช่นเคย เอาโค้ดจริงมาดูกัน

สังเวียนที่ทุกคนยอมขึ้นชก

มี dataset เดียวที่ Jev, Laya และ OpenDecider อ้างถึงเหมือนกันหมด : LocalLLaMA/typed-decisions 4 workflow สังเคราะห์ (support, invoices, security, agent traces) เคสละ 5 คำถาม train 1200 เคส test 400 เคส รวม 2000 คำตัดสินใจ

โมเดล พารามิเตอร์ คะแนน test Latency รันที่ไหน
OpenDecider-nano ~400M 0.796 17ms GPU GPU, RAM 2 GB
Laya typed-decisions 421M 0.766 ~25s/state CPU, ~830ms/เคส MLX GPU
Jev 1.13 ปิด ~0.73-0.75 ~710ms API API only
Sabaki mini 22M int8 0.69 ~20ms CPU CPU, 22 MB
Sabaki nano ~0 0.61 <1ms ทุกที่
Laya base zero-shot 421M 0.362 เท่ากัน GPU

สุ่มมั่วได้ 0.318 ตอบเสียงข้างมากได้ 0.461 ฉะนั้น Laya base ที่ 0.362 แบบไม่ fine-tune คือระดับ "ตอบมั่วแบบสุภาพ" จำไว้ เดี๋ยวได้ใช้

7 แต้มห่างระหว่าง Sabaki กับที่หนึ่ง แต่ latency ห่างกัน 1000 เท่าในทิศกลับกันเวลาคุณไม่มี GPU บทความนี้สรุปได้ในสองประโยค ที่เหลือคืออธิบายว่าทำไม

แต่ระวัง : หลังปลั๊ก choice / score / noul อันเดียวกัน ครัวข้างในคนละเรื่องเลย Jev คือ API ปิด Laya คือ ModernBERT-large fine-tune OpenDecider คือ Ettin-encoder แบบ distill Sabaki คือการนับคำบวก MiniLM int8 contract เดียวกัน งบคนละจักรวาล

Jev หมัดที่ทุกคนเห็น

ต้องให้เครดิต TypeSafe ตรงนี้ : contract แบบ System One เป็นไอเดียที่ดีจริงๆ

ก่อนหน้านี้จะแยก ticket สักใบ คุณต้องทำแบบนี้ : ขอ LLM ว่า "เลือกระหว่าง billing, technique กับ spam ตอบเป็น JSON เท่านั้น" แล้วมา parse คำตอบ แล้วยังต้องรับมือเวลามันตอบว่า "ผมว่า billing นะ! 😊" หรือมโนหมวดที่สี่ขึ้นมา ทุกคำถามเสีย token ทุกคำตอบพัง pipeline คุณได้

Jev เปลี่ยนดีล : คุณส่งข้อความบวกคำถามพร้อมเกณฑ์ไป มันประเมินแต่ละคำถามขนานกัน แล้วคืนค่าแบบ typed มาพร้อม distribution

curl https://api.typesafe.ai/v1/systemone \
 -H "Authorization: Bearer $KEY" \
 -H 'content-type: application/json' \
 -d '{"state":"charge double ce mois-ci","questions":{"dept":{"type":"choice","instructions":"Route ce message.","criteria":{"billing":"paiements","technical":"bugs"}}}}}'

คำตอบ : { choice, probabilities, confidence } ไม่มี free text โค้ดคุณเอาไปต่อได้เลย

นี่คือเหตุผลที่มันไวรัล : SDK Python + JS, ปลั๊กอิน Claude Code, เชื่อม LangChain ทุกคนเสียบ agent ตัวเองได้ในบ่ายเดียว Doc ของ TypeSafe ยังดัน pattern ที่ดีด้วย : Jev ไว้ตัดสินเล็กๆ เร็วๆ โค้ด deterministic ล้อมรอบเพื่อประกอบคำตัดสินใจจริง LLM ตัวช้าหรือคนจริงมาเสียบตอน confidence ต่ำ

ปัญหาคือส่วนที่เหลือ น้ำหนักปิด ออฟไลน์หมดสิทธิ์ จ่ายราย call agent ที่ตัดสิน 100 ครั้งต่อ run โดนภาษีอ่วม สำหรับ Doom ที่ 35 ticks/s คือตายสนิท : ใน doomLaya Jev จบ MAP01 seed 48 ใน 68.97 วินาที ด้วย p50 HTTP 357ms run นี้มีค่า API $0.0046 ไม่ใช่บิลผม เป็นบิลของ benchmark มันรันได้ run สะอาด ไม่ตายเลย แต่ 357ms ที่ 35 ticks/s คือเกมเดินไป 12 ticks ระหว่างที่คุณรอเน็ต

Sabaki ลอก contract นี้มาทั้งดุ้น คำถามเหมือน คำตอบเหมือน endpoint /v1/systemone ที่เสิร์ฟโดย serve() เหมือนกัน แค่ตัดทุกอย่างที่ต้องใช้ datacenter ออก

Laya ยักษ์ open ที่พิสูจน์ว่าทำได้โดยไม่ต้องพึ่ง API

Laya โดย Convai Innovations, Apache-2.0 ใต้ฝา : ModernBERT-large 421M มี 1 token [MASK] ต่อหนึ่ง option hidden state ของแต่ละ marker วิ่งผ่าน MLP เล็กๆ แล้ว softmax เหนือ option ของคำถาม รอบเดียวต่อคำถามเหมือน Jev

pipe = pipeline("text-classification", model="convaiinnovations/laya-typed-decisions")

เลข 0.766 ควรหยุดดูหน่อย นี่คือ checkpoint fine-tune บน train ของ benchmark : 1200 เคส RLCD ใส่ gaussian noise บน logits reward แบบ log + spherical โมเดลตัวเดียวกันไม่ fit แบบนี้ร่วงไป 0.362 พูดง่ายๆ คือเดา ฉะนั้นคะแนน Laya คือ "encoder ตัวใหญ่บวกเทรนตรงโดเมน" ไม่ใช่ "encoder ตัวใหญ่แบบเวทมนตร์"

บน CPU มันคืออิฐก้อน Port ONNX ที่วัดได้วิ่งราว 25 วินาทีต่อ state บน MLX FP16 เฉลี่ย 830ms ต่อเคส p99 ล่อไป 5.3 วินาที แล้วยังต้องโหลด 843 MB ก่อนจะตอบอะไรสักคำ

ใน doomLaya Laya ตัว stock ล้มเหลว MAP01 ใน 180 วินาที ตัว v3 ที่ปรับแล้ว (azalio/laya-doom-v3) ซึ่งเทรนใหม่บน Doom โดยเฉพาะ จบใน 61.43 วินาที ด้วย p50 108ms บน MPS แถมเก็บ shotgun ระหว่างทาง ฉะนั้นใช่ มันเล่นได้ แต่ต้องเทรนเฉพาะทาง บน GPU Apple แถมมี executor แบบ script ทำของจริง (A*, ประตูแบบ latch 15 ticks, เล็งอัตโนมัติ)

สรุปของผม : ไม่เทรนตรงงานของคุณ Laya ก็แค่ที่ทับกระดาษ 421M เทรนแล้วมันโหดมาก แต่ต้องมี GPU กับ data ที่เข้าคู่กัน

OpenDecider ตัวที่ชนะ leaderboard

OpenDecider คือ Manjunath ที่ตัดสินใจมาเก็บทุกคนบน benchmark ของพวกเขาเอง ผลลัพธ์ : 0.796 ด้วย Ettin-encoder-400m full fine-tune บวกหัว MLP, loss แบบ KL บน soft target ที่ distill จาก Qwen3-235B + DeepSeek

pip install opendecider
# 17ms/คำถามบน L40S, 18ms บน M4 Max, ~9ms แบบ batch

ที่ coverage 50% selective accuracy ขึ้นไป 0.943 เทียบกับ Jev ที่ 0.882 บนกระดาษคือดีที่สุดทั้งสองโลก : open, calibrate มาดี, เร็ว

ของจริง : โหลด nano ต้องใช้ RAM 2 GB อยากได้ 17ms ต้องมี GPU, phishing ได้ 0.63 เทียบกับ Jev ที่ 0.90 (เจอ OOD แล้วโดนล้าง) และเหมือน Laya คือเทรนบน train ของ benchmark test ไม่เคยเห็น แต่โดเมนเคยเห็นแล้ว

มันชนะตอนวัดกล้าม แต่ก็ยังเป็น 400M ที่มี GPU อยู่ข้างหลัง คุณยัดมันลง Worker Cloudflare ไม่ได้ หรือยัดใน game loop บน laptop ไม่ได้

พวก 150M กับหลุมข้างล่าง

openJev-verdict-2.0 (151M), julia-1 (144M หลายภาษา), Open Jev (150M), Largha-150M (144M en+zh), Vela (0.3B-9B) ทั้งหมดคือ ModernBERT หรือ mmBERT-small รันบน CPU ได้หมด เทรนง่าย (Open Jev เคลมว่า 30 นาทีบน T4 Colab) ตัวเลข self-reported โปรโตคอลคนละแบบ ฟังหูไว้หู แต่ตระกูลนี้มีอยู่จริง

generalist ของ Sabaki ทำได้ 22.7M เล็กกว่าตัวเล็กสุดในกลุ่มนี้ราว 6.5 เท่า ไม่มีใครลงต่ำกว่า 100M เพื่อตอบคำถามที่กำหนดตอน runtime

นี่แหละความหมายของ "เล็กที่สุดในโลก" : ไม่ใช่คะแนนดีสุด แต่เป็นซองเล็กสุดที่อ่าน instructions + criteria ของคุณแล้วกล้าตัดสินตอนที่ไม่มี specialist คนไหนตอบได้

Sabaki เราเตอร์โง่ๆ กับผู้เชี่ยวชาญแบบใช้แล้วทิ้ง

พอเรื่องพวกยักษ์ มาดูฝั่งดาวิดบ้าง เพราะตรงนี้แหละสนุก

Sabaki ไม่พยายามเป็นโมเดลใหญ่ฉลาดที่เข้าใจโลก มันคือ MoE เชิงองค์กร : 1 sparse router ระดับระบบ ผู้เชี่ยวชาญอิสระ ทำงานจริงทีละตัวต่อ input แต่ละปลั๊กอินคือ package npm แยกกัน มี manifest น้ำหนัก และเวอร์ชันของตัวเอง คุณติดตั้งเฉพาะทางที่คุณ route ไป

org sabaki-js : 13 repo, core sabaki (129 commits), 6 ปลั๊กอิน @sabaki-js/*, น้ำหนัก, Cloudflare worker, template สำหรับเขียนของตัวเอง

package.json ของ core เล่าเรื่องหมดแล้ว :

"dependencies": { "@typesafe-ai/sdk": "^0.7.0" },
"optionalDependencies": { "onnxruntime-node": "^1.22.0" }

โหมด nano ไม่มี dependency ตอนรันเลย มีแค่ TypeScript คอมไพล์ล้วนๆ ไม่มีอะไรให้ติดตั้ง ไม่มีอะไรพังระหว่างเวอร์ชัน ONNX

เราเตอร์ หรือวิธีตัดสินใจในไม่กี่บรรทัด

เราเตอร์อยู่ใน src/index.ts และการ tokenize หน้าตาแบบนี้ (src/index.ts:129) :

function tokens(s: string): string[] {
  return s.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "")
    .split(/[^a-z0-9]+/).filter((w) => w.length > 3);
}

ตัวเล็กหมด ตัด accent ทิ้ง เอาเฉพาะคำยาวเกิน 4 ตัวอักษร ฟิลเตอร์นี้ตัด noise ออกหมด ("le", "de", "ok") บน ticket support เรื่องนี้เปลี่ยนเกม เพราะ matching ของคุณวิ่งบนคำที่มีความหมายจริงๆ

ตัว scoring อยู่ใน route() (src/index.ts:251) และทั้งหมดตัดสินกันตรงนี้ :

const hits = kws.filter((k) =>
  words.some((w) => w.includes(k) || k.includes(w))).length;
const score = hits / Math.sqrt(kws.length);

matching แบบหลวมๆ ทั้งสองทาง : billing จับ billed ได้ invoice จับ invoicing ได้ หารด้วยรากที่สองเพื่อไม่ลงโทษลิสต์ keyword ใหญ่ๆ ไม่งั้นปลั๊กอินเมลที่มี 600 คำจะเหมาหมดทุกครั้งที่เมลมีคำว่า "hello"

แต่ละปลั๊กอินมี threshold ของตัวเอง ค่าเริ่มต้น 0.15 เก็บใน src/index.ts:139 เมลใช้ 0.03 เพราะหัวข้อสั้น ต่ำกว่านั้น :

return { domain: "unknown", decision: null, confidence: 0, plugin: null };

โดเมนไม่รู้จักคือ unknown ไม่เคยมโนคำตอบเอาใจ เป็นความโง่โดยตั้งใจ และนี่แหละที่ทำให้ threshold automate > 0.8 ใช้ได้จริงทีหลัง : เวลา Sabaki ตอบ คือ specialist ตัวจริง match เจอแล้ว

มีแกนภาษาแถมผ่าน langid : ปลั๊กอินที่ประกาศ languages จะแข่งเฉพาะภาษาของตัวเอง สะดวกเวลาจะแยก phishing ละตินกับ CJK ข้อความสั้นเกินไปคือไม่กรอง ทุกคนแข่งหมด

ต้นทุนรวม : ไม่กี่ไมโครวินาทีต่อคำตัดสินใจ ไม่มี matrix ให้คูณ ไม่มีเน็ตให้เรียก มีแค่เทียบ string กับหารเลข

Nano ไฟล์โปรดของผมใน repo

src/logreg.ts:28 ส่วน tokenize :

function tokens(s: string): string[] {
  const words = s.toLowerCase().split(/[^a-z0-9]+/)
    .filter((w) => /^[a-z]{3,}$/.test(w));
  const out = [...words];
  for (let i = 0; i < words.length - 1; i++)
    out.push(`${words[i]} ${words[i + 1]}`);
  return out;
}

unigram บวก bigram charged twice ได้ charged, twice และคู่ charged twice แค่นี้พอจับ "เก็บเงินซ้ำ" ได้โดยไม่ต้องงัด transformer 400M

src/logreg.ts:46 ส่วน vectorize ตรงที่ TF-IDF ทำงาน :

const tf = 1 + Math.log(c);  // TF แบบ sublinear
const val = tf * w.idf[i]!;  // IDF แบบ smooth สไตล์ sklearn

คำที่โผล่ครั้งที่สิบน้ำหนักน้อยกว่าครั้งแรก คำหายากน้ำหนักมากว่า "please" สูตร sklearn เป๊ะๆ ตรวจ parity แล้วบน 20 คำถาม

แล้วคอมเมนต์ที่ทำให้ผมยิ้มตอนกลับมาอ่าน :

// แตะเฉพาะเทอมที่มีอยู่ (sparse) แทนการกวาด 3000 มิติ
for (const [t, c] of counts) {

แทนที่จะกวาด 3000 มิติทุก ticket คุณแตะแค่ยี่สิบกว่าคำที่มีอยู่จริง นี่คือเหตุผลที่ตอบในต่ำกว่า 1ms รายละเอียดเล็กๆ ที่มองไม่เห็นแต่เปลี่ยนทุกอย่างใน prod เพราะ complexity วิ่งตามขนาด ticket ไม่ใช่ขนาด vocab

src/logreg.ts:86 ส่วน inference จบในสามบรรทัด :

// ไบนารี : coef บรรทัดเดียว + sigmoid
const pPos = 1 / (1 + Math.exp(-s));
// หลายคลาส : dot product + softmax แล้ว argmax

dot product หนึ่งครั้ง sigmoid หรือ softmax แล้วแต่จำนวนคลาส รอบเดียวจบ ไม่สร้าง token สักตัว ฉะนั้นไม่มี JSON พังให้ต้องซ่อมด้วย retry กับ heuristic parse

สูตร train.py : workflow ละ 300 train / 100 test split แบบ deterministic (test 1 ชิ้นทุก 5 ชิ้นเพื่อเลี่ยง bias เวลา) vocab 3000-6000 ปัด coef ที่ 1e-4 วัดแล้วไม่เสียอะไรเลย export เป็น JSON { vocab, idf, questions }

น้ำหนัก : 20 KB สำหรับ invoices (duplicate 0.89) ถึง 650 KB สำหรับเมล (bucket 0.889) ขนาดที่ยัดลง bundle frontend ได้สบายๆ

รายละเอียดที่ผมอึ้ง : บนเมล MiniLM เสมอกับ TF-IDF ที่ +/-0.02 ฉะนั้น nano ship ตามนั้น ใครจะจ่าย GPU เพื่อแยกเมล ผมขอบาย แต่ agent_trace ติด (needs_review 0.59 ใน nano) : ข้อความ observability มันจืด ไม่มีคำไหนแบกคำตัดสินใจได้จริง

โรงงานที่เปลี่ยนน้ำหนักเป็นปลั๊กอินอยู่ใน src/plugin.ts:52 :

export function nanoClassifier(manifest, weights, opts = {}) {
  return definePlugin({ manifest,
    classify: (input) => {
      const r = predict(`${from}${input.state}`, weights, q);
      return { decision: r.decision, confidence: r.confidence, domain: manifest.domain };
    }});
}

decide() ยัง sync ใน nano decideAsync() ปลดล็อก ONNX ให้ mini สองทาง กฎง่ายๆ : ถ้าต้อง async แปลว่าคุณอยากได้ mini ไม่งั้น nano พอ จำง่ายตอนโค้ด debug ง่ายตอนพัง

ให้ดูว่าไม่ใช่แค่ทฤษฎี นี่คือหน้าตาการใช้จริง :

import { Sabaki } from "sabaki";
import customerService from "@sabaki-js/customer-service";

const sabaki = new Sabaki().register(customerService);
sabaki.decide({ state: "charged twice, refund please" });
// { decision: "billing", confidence: 0.31, domain: "customer_service" }

Mini 22 MB ที่เปลี่ยนเกมบนเคสยาก

Nano มีขีดจำกัด คุณเห็นแล้วบน agent_trace ข้างบน MiniLM-L6 มารับช่วงต่อ : encoder กลางตัวเดียวทั้งโดเมน บวกหัว linear ต่อคำถาม Fine-tune end-to-end 8-12 epochs, lr 2e-5 บน GPU CUDA ตอนเทรน (ตอน inference ยัง CPU นี่คือประเด็น)

ตัวเลขที่ตัดสิน วัดบน split เดียวกัน :

  • encoder แช่แข็ง + MLP : 0.65
  • full fine-tune : 0.68
  • distill 400M ลง embeddings แช่แข็ง : ลองแล้ว โยนทิ้ง แพ้ gold label

export ONNX ผ่าน dynamo ตรวจ parity เป๊ะ แล้ว quant int8 แบบ dynamic : 90 MB เหลือ 22 MB เสีย accuracy 0.004 บน customer_service ECE เท่าเดิมหรือดีกว่า ทุก export วัดใหม่หมด ไม่งั้น optimize ไปก็ไร้ความหมาย

ตั้งแต่ 1.0.2 น้ำหนักฝังใน package (models/<workflow>-int8/ + export modelDir) npm i จบ โหมดเครื่องบินก็รอด อัปเดตคือปล่อย patch เซ็นผ่าน trusted publishing

ของจริง : needs_review จาก 0.59 ขึ้นไป 0.82 disposition 0.73 ไป 0.76 ECE ต่ำกว่า 0.14 ทุกที่ พร้อมความลังเลแบบ OOD ที่ดี (0.2-0.5 บนมังกร สูตรอาหาร และ string ว่าง) ความลังเลนี่แหละที่ทำให้ threshold โหดๆ ใน prod เป็นไปได้

Generalist ตาข่ายกันตกที่ทำให้ Sabaki เป็น System One จริงๆ

Jev กับตัวอื่นตอบได้ทุกอย่าง : คำถามอะไรก็ได้ option อะไรก็ได้ กำหนดตอน runtime ได้หมด นี่คือ contract System One ที่ทำให้ Jev ไวรัล คุณวาง instructions + criteria กับ option ของคุณเอง มันตัดสินให้

specialist ของ Sabaki ทำแบบนั้นไม่ได้ มันรู้จักแค่คำถามของตัวเอง (category, duplicate, bucket) อย่างอื่นไม่รู้ ไม่มีตาข่าย Sabaki จะคืน unknown ทันทีที่หลุดสคริปต์ ตรงที่ Jev ยังตอบอะไรสักอย่างเสมอ

generalist คือตาข่ายนั้น cross-encoder ที่อ่านคำถาม + option + state แล้วพ่น score รอบเดียวต่อ option softmax แล้ว argmax 22.7M MiniLM int8 เล็กกว่าพวก 144-151M ราว 6.5 เท่า แต่งานมันไม่ใช่คะแนน งานมันคือความเข้ากันได้ : เสียบมันเป็น fallback แล้ว POST /v1/systemone จะรับ state กับคำถามอะไรก็ได้เหมือน Jev field model บอกว่าใครตอบจริง (@sabaki-js/[email protected]+mini, sabaki-generalist-v1...) ฝั่งคนเรียกไม่ต้องเปลี่ยนอะไร

และสำคัญสุด มันคือ fallback ไม่ใช่หัวหน้า กฎทองใน decideAsync() : ถ้า specialist ชนะ routing มันตอบ generalist พูดเฉพาะตอน unknown เท่านั้น ไม่มีกลับกัน กันไม่ให้ generalist กลางๆ มาทับ specialist เก่งๆ ในบ้านมันเอง

loader อยู่ใน src/generalist.ts มี cache ต่อโฟลเดอร์เพื่อไม่โหลด session ONNX ใหม่ทุก call :

export function loadGeneralist(modelDir: string) {
  // cache ตามโฟลเดอร์ ใช้ session ONNX เดิมซ้ำ
  // tokenizer.json แบบ Unigram หรือ wordpiece ตรวจอัตโนมัติ
  const sess = await ort.InferenceSession.create(
    path.join(modelDir, "generalist.onnx"),
    { intraOpNumThreads: 2 },
  );
}

คุณชี้ SABAKI_GENERALIST_DIR ไปโฟลเดอร์ที่มี generalist.onnx + tokenizer/ โมเดลสลับได้ id มาจาก config.json ไม่งั้นคือ "sabaki-generalist" cache แบบ Map กันโหลด session ซ้ำทุก call

ฟอร์แมต input คือ cross-encoder คลาสสิก : คำถาม option และ state ต่อกันด้วย separator บรรทัดละ option ที่จะ score :

const texts = options.map(
  (o) => `${input.question ?? "decision"} [SEP] ${o} [SEP] ${input.state}`,
);

คำถาม + option + state คั่นด้วย [SEP] บรรทัดละ option option ตัดที่ 8 (slice(0, 8)) ไม่ให้มาก็ใช้ default ["yes", "no"] เกินนั้นไปตัดฝั่งคนเรียก 8 ก็กว้างพอสำหรับคำตัดสินใจแบบ typed แล้ว

แล้วก็ batch แบบฉลาดเล็กๆ ที่ดูไม่แพงแต่หาร latency ด้วยจำนวน option แทนที่จะ session.run ทีละ option ส่งหมดใน forward เดียว :

// forward เดียวแบบ batch ครบทุก option
// (pad ให้ยาวเท่าตัวที่ยาวสุด) แทน run ทีละ option
const L = Math.max(...encs.map((e) => e.ids.length));
// ids + attention_mask เป็น [options.length, L] pad ด้วย padId
const out = await sess.run({ input_ids, attention_mask });
const scores = Array.from(out["score"].data).map(Number);

แต่ละข้อความถูก encode (wordpiece สไตล์ BERT หรือ unigram SentencePiece แล้วแต่ tokenizer.json) pad ให้ยาวเท่าตัวที่ยาวสุดใน batch ซ้อนเป็น tensor [จำนวน_option, L] sess.run ครั้งเดียว ได้ score ต่อ option บน CPU เรื่องนี้เปลี่ยนโลก : 20-36ms แทนที่จะคูณแปด

แล้วก็ softmax + argmax พร้อม proba ครบทุก option ในฟอร์แมตเดียวกับที่ Jev ทำให้ดัง :

const pr = softmax(scores);
// bi คือ index ของค่ามากสุด
const confidence = Math.round(pr[bi] * 1000) / 1000;
const probs = Object.fromEntries(
  options.map((o, i) => [o, Math.round(pr[i] * 1000) / 1000]),
);

และรายละเอียดที่ทำให้ใช้ใน prod ได้จริง ไม่ใช่แค่เดโม คือ abstainAt :

if (input.abstainAt !== undefined && confidence < input.abstainAt) {
  return { decision: null, confidence, domain: "generalist", probabilities: probs };
}

opt-in ไม่ abstain เป็น default คุณส่ง abstainAt: 0.6-0.7 แล้วทุกอย่างที่แกว่งจะคืน null แทนเดา เพราะ softmax พ่น 1.00 ออกมาแม้ตอบผิดนอก distribution ไม่ใช่ของฟุ่มเฟือย GUIDE แนะนำสำหรับงาน autonomous ทุกแบบ : ต่ำกว่า threshold ให้ถามเพิ่มแทน auto-resolve

สุดท้าย นี่คือโครงเดียวกันแบบเสียบ fallback generalist : พอไม่มี specialist match มันรับช่วงแทนคืน unknown

import { Sabaki, definePlugin, loadGeneralist } from "sabaki";
import mail from "@sabaki-js/mail";

const backend = loadGeneralist(process.env.SABAKI_GENERALIST_DIR!);
const generalist = definePlugin({
  manifest: { name: "@sabaki-js/generalist", domain: "generalist",
    version: "0.1.0", questions: ["decision"], keywords: [] },
  classify: (input) => backend.decide(input),
});

const sabaki = new Sabaki().register(mail).setFallback(generalist);

await sabaki.decideAsync({
  state: "le client dit que l'app a supprimé ses données",
  question: "c'est urgent ?",
  options: ["yes", "no"],
  abstainAt: 0.65,
});
// specialist เมลถ้า routing เข้า ไม่งั้น generalist รับแบบ catch-all สไตล์ Jev

รุ่น generalist วัดเหมือนกันหมดบน PhishNChips (เมล 2000 ฉบับ) กับ test typed-decisions เพื่อเทียบที่เทียบได้ :

  • v4 : test 0.29, R=0.026 phishing โยนทิ้งได้เลย ตอบคนละเรื่อง
  • A (22M 80k) : 0.546, R=0.406 เริ่มเป็นรูปเป็นร่าง
  • mini v1 ที่เสิร์ฟอยู่ : 0.536, R=0.616 ตัวที่รันบนเว็บ Recall phishing ใช้ได้ test กลางๆ
  • B (144M scratch) : 0.568, R=0.062 ใหญ่กว่า test ดีกว่านิดเดียว phishing กาก

ตัว 144M from scratch ที่ transfer ไม่ดีกว่า ทำผมเลิกคิดว่า "ใส่ param เพิ่มแล้วจะ generalize ดี" งานเท่ากัน ตัวใหญ่แพงกว่าโดยไม่ได้เดาเก่งกว่า แม้เป็น catch-all ตัวเล็กก็เอาอยู่ และมันยัง EN-only (all-MiniLM-L6-v2) routing EN, FR คือนอก distribution งาน FR ต้องผ่าน specialist ที่เทรนตรง ไม่ใช่ generalist

Doom บทสอบเดียวที่มีความหมายจริงๆ สำหรับผม

doomLaya คือ Laya กับ Jev เล่น FreeDoom กันจริงๆ โมเดลเลือก command / target / weapon executor วาง path A* เปิดประตู เล็ง ยิง telemetry ทุก tick มีวิดีโอ มี SHA ของ run ผม contribute อยู่ ฉะนั้นไม่ได้พูดจาก leaderboard ที่อ่านผ่านๆ

v0.1.0, MAP01 seed 48, skill 3, 35 ticks/s, executor เดียวกัน :

โมเดล ผลลัพธ์ p50 HTTP ค่า API
Laya stock ล้มเหลว 180s 106ms $0
Laya v3 แบบปรับแล้ว จบ 61.43s 108ms $0
Jev 1.13 จบ 68.97s 357ms $0.0046

จบสวยสอง run ไม่ตาย Laya เก็บ shotgun ระหว่างทางเพราะ executor จัด pickup เอง ซื่อสัตย์หน่อย : Laya v3 เทรนบน Doom มา Jev ไม่ได้เทรน seed เดียว map เดียว พิสูจน์อะไรกว้างๆ ไม่ได้ README ของโปรเจกต์เขียนไว้ตรงๆ และน่านับถือที่ไม่ขายเกินจริง

ฝั่ง Sabaki (plugin-doom) : ViZDoom ใน Python เรียกเซิร์ฟเวอร์ JS ทุก tick nano ราว 18k req/s, mini ราว 71 req/s บน thread i5 เดียว tick 35Hz สบายๆ แม้บน laptop กลางๆ

ใส่เข็มทิศทางออก mini จบ MAP01 ได้ 2-4 run จาก 6, nano 0/6 สมเหตุสมผล : ข้อความเสียมิติ spatial state แบบ doomLaya ตันราว 0.57 ทั้ง MiniLM และ ModernBERT-base พอข้อมูล spatial หาย ไม่มี classifier ข้อความตัวไหนเสกมันกลับมาได้

เพื่อนบ้านแข่งซ้ำด้วยข้อมูลเท่ากัน : doomgemma ใส่ LoRA 27 นาทีบน RTX 4070 แล้วพา Gemma 4 E2B ขึ้นชั้น Laya v3 ตอนเล่น (6/6 เทียบ 6/6) และเหนือกว่าตอนตัดสินเดี่ยวๆ ขนาดเท่ากัน decoder เร็วพอๆ กับ Laya (270M : 22ms เทียบ 23ms) ผลเท่ากัน Laya ยังเร็วกว่าราว 3 เท่าเพราะทำได้ด้วยโมเดลเล็กกว่า

มุมมองผม : เทรนตรงงานสำคัญกว่าสถาปัตยกรรม specialist ตัวเล็กที่เทรนมาตรง ชนะ generalist ตัวใหญ่ที่เพิ่งมาเจอ นี่คือเดิมพันของ Sabaki เปเปอร์ตามมา System Switch (arXiv:2610.09683) ดันตรรกะนี้สุดทาง : โมเดลเล็กเร็วเล่นไป โมเดลใหญ่สาย reason เสียบตอน gate เปิด pre-triage ของ Sabaki (automate > 0.85 ไม่งั้น escalate) เอามาใช้กับเกมนั่นเอง

สิ่งที่ยอมทิ้ง

ทุกฟีเจอร์ที่หายไปจ่ายให้อีกอย่าง นี่คือกฎตอนเล็ง CPU ลิสต์แบบซื่อสัตย์ เหมือนตอน Bareiron ที่ C ทุกบรรทัดคือ tradeoff :

Sabaki ไม่เล่าเรื่อง มันเลือกตัวเลือกที่คุณยื่นให้ พร้อม proba อยากได้คำอธิบาย สรุป หรือ free text ไปเอา LLM

ภาษาฝรั่งเศสยังนอก distribution ของ generalist routing EN (all-MiniLM-L6-v2) probe ได้ FR 1/4 เทียบ EN 3/4 บนเคสคล้ายกัน ปลั๊กอินเมลมาจากกล่องที่ส่วนใหญ่เป็น FR แต่ anonymize แล้ว : 0 PII เสีย accuracy ไป 0.024 เพื่อแลก vocab ที่ไม่มีคำส่วนบุคคล anonymize มีราคา แต่ต่อรองไม่ได้

softmax มั่นเกินตอนหลุดโซน เห็น false ที่ 1.00 มาแล้ว ใช่ เลยต้องมี pre-triage ตาม GUIDE : abstainAt: 0.6-0.7 เพื่อคืน null ใต้ threshold แล้ว gate confidence > 0.85 ค่อย automate นอกนั้นส่งคน จุดอ้างอิงที่วัดแล้ว : recall phishing 0.62 ที่ precision 0.87 มากพอผ่าคิวเป็นสองกอง ไม่พอไล่ reviewer ออก

Nano ลำบากบน agent traces 0.34 ถึง 0.59 แล้วแต่คำถาม Mini ดัน needs_review ไป 0.82 ได้ แต่ risk / urgency / action ยังหิน ข้อความ observability มันจืด สัญญาณ lexical น้อย

Doom แบบข้อความตัน ราว 0.57 ทั้ง MiniLM และ ModernBERT-base ตราบใดที่อยู่บนข้อความไม่มี pixel MAP02 บล็อกคุณแน่ ทางที่เชื่อได้สำหรับผู้เล่นจริงคือ vision + RL (tiny-doom-defender 1.1M param จาก pixel) ไม่ใช่ classifier ข้อความที่ดีกว่า

benchmark ดริฟต์เป็น batch typed-decisions สร้างเป็นล็อต split เวลาเลยกลายเป็น split ของ batch จริงๆ drift_tool.py ได้ 0.82 บน customer_service, 0.67 บน agent_trace (0.5 คือแยกไม่ออก) ผลที่วัดได้ : น้ำหนักที่ปล่อยทำ 0.733 แบบผสม เทียบ 0.61 บน split ทางการ เลขทางการเลยค่อนข้าง conservative การ calibrate แบบ Platt / temperature+bias ไม่ย้ายข้าม batch ฉะนั้น ship แค่ temperature scaling ที่ calibrate บน batch ล่าสุด กฎบ้าน : รัน drift_tool.py ก่อน เตือนเหนือ 0.6 บล็อกเหนือ 0.8 ไม่มีใครปล่อยของแบบนี้ แต่ทุกคนควรทำ

ผลสุดท้าย ตัวเลขในมือ

ฝั่งหนึ่งคือ i5 บน laptop อีกฝั่งคือ API ที่คิดเงินราย call แต่ละโหมดให้ของจริงแบบนี้ :

  • nano : <1ms, ราว 18k req/s, 20-650 KB, 0 dep
  • mini : ~20ms, ราว 71 req/s, 22 MB int8, onnxruntime แบบ optional
  • generalist : 20-36ms, 22.7M, generalist open เล็กสุดที่ผมรู้จัก

โหลดหลายโดเมน : RAM ราว 0.5 GB แบบ lazy-load รายโดเมน แทน 2 GB แบบเหมาหมด deterministic : input เดียวกัน 100/100 ให้ output เดิม threshold รายคำถาม gate บน confidence คืน unknown ตอนไม่มีอะไร match

บน Bareiron รันผู้เล่น 3 คนขึ้นไปแล้วอืด เพราะผู้เล่นหนึ่งคนกิน chunk ละ 200ms ที่นี่รัน 3 โดเมนขึ้นไปสบาย เพราะผู้เชี่ยวชาญแต่ละตัวจิ๋วและโหลดตามต้องการ แทนที่จะโหลดตูมเดียว ปรัชญา "เล็กแต่พอ" ตัวเดียว เอามาใช้กับ hardware อีกแบบ

ปรัชญา

p2r3 เคยว่าไว้ : "ผมแค่ชอบไอเดียที่ว่าชิปจิ๋วราคา 1 ดอลลาร์รันอะไรล้ำๆ อย่าง Minecraft ได้"

อารมณ์เดียวกันตรงนี้ : ผมชอบไอเดียที่ JSON 20 KB กับ TS ร้อยกว่าบรรทัดเสียบแทน call GPU เพื่อเลือก label ได้สักใบ แน่นอนมันแทนทุกอย่างไม่ได้ อย่ามั่ว มันแทนเคสน่ารำคาญที่คุณอยากได้ label ในไม่กี่ ms แบบ local พร้อม confidence ที่ threshold ได้จริงๆ แทนที่จะหลับตาลุ้น

tradeoff เขียนไว้ขาวดำ :

  • encoder ไปนับคำ : เสีย 7 แต้ม latency หารพัน infra เป็นศูนย์
  • โมเดลใหญ่ตัวเดียวไปปลั๊กอิน npm : gate หยาบกว่า ops ง่ายกว่าเยอะ
  • generation ไปคำตัดสินใจแบบ typed : จบเรื่อง parse พังกับ option มโน
  • confidence ดิบไป pre-triage : abstain ก่อน automate เหนือ 0.85 ที่เหลือนส่งคน

3 อย่างที่อยากให้จำ :

  1. เสีย 7 แต้ม เร็วพันเท่า : 0.61/0.69 เทียบ 0.75-0.80 แต่ <1ms/20ms CPU ออฟไลน์ เทียบ 25 วินาทีบน CPU หรือ API จ่ายเงิน คะแนนที่ไม่มีต้นทุนกำกับไม่มีความหมาย
  2. เราเตอร์ง่ายๆ บวกผู้เชี่ยวชาญแยกกัน : hits / sqrt(keywords) threshold unknown แต่ละปลั๊กอินเทรน version และ ship เดี่ยวๆ บนเมล TF-IDF เสมอ MiniLM : ตรงนี้ GPU ไม่ช่วยอะไร
  3. ออฟไลน์ก่อน พร้อม calibrate แบบซื่อสัตย์ : npm i น้ำหนักฝังมา 0 GPU ECE ต่ำกว่า 0.14 มี abstain มี drift tool stack เดียวในกลุ่มเทียบที่ยืน game loop 35Hz ได้โดยไม่ต้องมี GPU

ถ้าโปรเจกต์โดนใจ ทุกอย่าง MIT บน github.com/sabaki-js เวอร์ชันวิชาการพร้อมตัวเลขและ bibtex คือ preprint v1 ถ้าอยากเห็นโกลิอัทเล่น Doom จริงๆ ไปที่ azalio/doomLaya xD

Related Articles