TypeScript für Python-ML-Engineers: einen Agent-Service bauen

Automatische Übersetzung

Dieser Artikel wurde automatisch aus der englischen Originalversion übersetzt.

Dieser Leitfaden richtet sich an erfahrene Python-ML- und Backend-Engineers, die einen AI-Service in TypeScript und Node bauen müssen. Er geht von Python-Service-Konzepten aus, nicht von Programmiergrundlagen oder Browser-Code. Am Ende kannst du einen Python-Service-Stack auf TypeScript abbilden, die Python-Gewohnheiten erkennen, die in JavaScript zu Bugs führen, und einen Streaming-Agent-Service ausführen und ändern.

Der Beispiel-Service verfolgt eine Anfrage durch Validierung, Tool-Ausführung, HTTP-Streaming und Persistenz. Er beantwortet drei Fragen: Was passiert, wenn Tool-Argumente in Fragmenten ankommen, ein Stream nach HTTP 200 fehlschlägt oder ein Worker denselben Job zweimal übernimmt? Der Umfang beschränkt sich auf Backend- und AI-Arbeit: kein React und kein Browser-Bundler.

Der Code liegt in slavadubrov/typescript-agent-service. Er enthält zwei Agent-Loop-Implementierungen, Postgres-Run-History, einen Worker und einen MCP-Server. Du kannst die Offline-Demo ohne API-Schlüssel ausführen und dann mit der Python-zu-TypeScript-Übersicht unten den Service lesen und ändern. Rechenintensive Numerik bleibt in Python; dieser Leitfaden behandelt Orchestration, HTTP und Streaming.

Zuerst das Begleit-Repository ausführen

Installiere Node 24 und pnpm gemäß der offiziellen pnpm-Installationsanleitung. Klone dann das Begleit-Repository und führe aus:

pnpm install
pnpm demo
pnpm check

pnpm demo testet den HTTP-Handler, den Agent Loop und den SSE-Stream mit einem geskripteten Model. Es ruft auch die runSweep-Berechnung des Workers auf. Es startet weder den Worker-Prozess noch dessen Datenbank-Queue. Die Demo braucht keinen API-Schlüssel, keine Datenbank und kein Docker. pnpm check führt den Type Checker, den Linter, die Formatierungsprüfung und die Tests aus.

Die Demo gibt die SSE-Frames einer Chat-Anfrage aus, danach den Sweep des Workers. Gekürzt:

event: tool_call
data: {"type":"tool_call","callId":"call_1","toolName":"estimate_kv_cache","args":{"model":"llama-3.1-8b","seqLen":32768,"batchSize":16}}

event: tool_result
data: {"type":"tool_result","callId":"call_1","toolName":"estimate_kv_cache","ok":true,"result":{"model":"llama-3.1-8b","seqLen":32768,"batchSize":16,"kvCacheGiB":64},"durationMs":0}

event: done
data: {"type":"done","stopReason":"stop","steps":2,"text":"Llama-3.1-8B at 32k context and batch 16 needs 64 GiB of KV cache."}

--- worker: capacity sweep ---
batch=  1  kv_cache=4.0 GiB
batch= 16  kv_cache=64.0 GiB

Jeder event:/data:-Block ist ein SSE-Frame: Das Model fordert ein Tool an, das Tool liefert sein Ergebnis, und der Run endet mit done. Der letzte Teil ist der Batch-Size-Sweep des Workers.

So startest du die echte API und den Worker:

cp .env.example .env          # add OPENAI_API_KEY or an OpenAI-compatible URL
pnpm db:up                    # start Postgres in Docker
pnpm db:push                  # create the database schema
pnpm dev:api                  # API at http://localhost:8080
pnpm dev:worker               # run this in a second terminal

Streame einen Agent-Run. Die Antwort besteht aus einer Reihe von SSE-Frames wie in der Demo-Ausgabe oben:

curl -N -X POST localhost:8080/v1/chat \
  -H 'content-type: application/json' \
  -d '{"message":"How much KV cache for llama-3.1-70b at 32k context, batch 8?"}'

Stelle einen langen Job in die Queue und frage ihn dann ab. Der erste Aufruf liefert 202 mit {"id":"...","status":"queued"}. Der zweite liefert die gespeicherte Zeile, mit dem Ergebnis, sobald der Worker fertig ist:

curl -X POST localhost:8080/v1/sweeps \
  -H 'content-type: application/json' \
  -d '{"model":"llama-3.1-70b","seqLen":32768,"batchSizes":[1,4,16]}'

curl localhost:8080/v1/runs/<id>

Die obigen pnpm-Skripte sind die Befehle, die ich im Rest des Artikels verwende. Das Repository legt die systemnäheren Node- und TypeScript-Befehle hinter benannte pnpm-Skripte, ähnlich wie ein Python-Projekt uv run-Befehle hinter make-Targets legen kann. Mische kein npm install in dieses pnpm-Repository. Nutze pnpm install, damit pnpm-lock.yaml die einzige Lockfile bleibt.


Was Node, npm, pnpm, TypeScript und tsx tun

Die ähnlichen Namen verbergen getrennte Aufgaben:

  • JavaScript ist die Sprache.
  • Node.js ist die Runtime, grob CPython für JavaScript. Installiere für dieses Projekt Node 24.
  • Die npm-Registry ist der Paketindex, grob PyPI. Der Befehl npm wird mit Node installiert und kann Pakete aus dieser Registry installieren.
  • pnpm ist der Paketmanager, den dieses Repository gewählt hat. Er installiert aus der npm-Registry, verwaltet den Monorepo-Workspace und führt die in package.json deklarierten Befehle aus.
  • package.json ist das Projektmanifest, am ehesten vergleichbar mit pyproject.toml. Der Abschnitt scripts gibt längeren Befehlen Namen wie demo, check und dev:api.
  • TypeScript ist JavaScript mit statischen Typen. Der Befehl tsc prüft diese Typen. Das Repository führt ihn über pnpm check oder pnpm typecheck aus.
  • tsx führt .ts-Dateien ohne separaten Build aus. Die Entwicklungsskripte nutzen seinen watch-Modus, um API oder Worker nach einer Änderung im Quellcode neu zu starten. In diesem Leitfaden rufst du ihn nicht direkt auf.

Führe in diesem Repository die pnpm-Skripte aus. Node ist die Runtime in diesen Skripten, und das mitgelieferte Dockerfile übernimmt die Produktion.

Was jedes Skript ausführt:

  • pnpm demo führt tsx scripts/demo.ts aus.
  • pnpm dev:api führt tsx watch aus.
  • pnpm check führt tsc, Biome (den Linter) und Vitest (den Test Runner) aus.
  • Das Docker-CMD führt node --import tsx/esm aus.

Alle laufen auf Node 24. In pnpm check lesen tsc und Biome nur Dateien; Vitest führt die Tests auf Node aus.


Der Stack, abgebildet von Python

Zod, Hono, Drizzle, Vitest und Biome decken einen großen Teil dessen ab, was pydantic, FastAPI, SQLAlchemy, pytest und Ruff abdecken.

Die meisten Paare sind direkte Ersetzungen. Drei sind es nicht:

AspektPythonTypeScriptWarum es kein einfacher Austausch ist
ValidierungpydanticzodDas Schema ist die Quelle. Der Typ wird daraus erzeugt, nicht umgekehrt
TypprüfungmypyTypeScript (pnpm typecheck)Typen werden entfernt, bevor der Code läuft. Keine Bibliothek kann sie lesen, daher braucht die Validierung zur Laufzeit ein separates Schema (Zod)
Job-Queuecelery + Redisbullmq (Redis-basierte Queue) oder SQLPostgres kann eine At-least-once-Queue umsetzen. Möglicherweise brauchst du keinen Broker

Der Begleit-Service nutzt vier Bibliotheken, die eine Erklärung verdienen.

Hono für die HTTP-Schicht

Express und Fastify sind auf Node ausgerichtete Alternativen. Hono nutzt die Web-Standard-APIs Request und Response und bietet Adapter für Node und Serverless-Runtimes. Diese Portabilität ist für diese kleine Streaming-API nützlich, deshalb habe ich Hono gewählt. Wähle Fastify, wenn der Service nur auf Node läuft und du sein Plugin-Angebot und die schemabasierte Serialisierung von Antworten willst.

Drizzle für SQL

Drizzle hält das Schema in TypeScript und braucht keinen Schritt zur Client-Generierung. Außerdem steht rohes SQL zur Verfügung, wenn der Query Builder eine Postgres-Klausel nicht sauber ausdrücken kann. Ich würde stattdessen Prisma wählen, wenn sein generierter Client und das umgebende Tooling besser zum Team passen.

Biome für Linting und Formatierung

Biome übernimmt Linting, Formatierung und Import-Sortierung mit einer Binärdatei und einer Konfigurationsdatei. Behalte ESLint, wenn das Projekt von eigenen Regeln abhängt, die Biome nicht bietet.

Vitest für Tests

Vitest führt die .ts-Tests des Begleit-Repositorys ohne separate Transform-Konfiguration aus. Behalte Jest in einem Repository, das bereits eine Jest-Konfiguration und eigene Matcher hat.


Die verwendete TypeScript-Syntax lesen

Lege diese Tabelle als Referenz neben die Service-Beispiele.

TypeScriptPython / Hinweis
(x) => expressionanonyme Funktion mit Ausdruck als Rumpf, ähnlich lambda x: expression
(x) => { statements }anonyme Funktion mit Anweisungen als Rumpf
async (x) => { statements }asynchrone anonyme Funktion
const { model, seqLen } = requestliest die Eigenschaften model und seqLen aus request
const [first] = xsfirst = xs[0]. Bei einem leeren Array ergibt es undefined, keinen IndexError
{ type: "error", message }{"type": "error", "message": message}. Ein bloßer Name wird zu diesem Feld
`text ${x}`f-String
cond ? a : ba if cond else b
const / letBeide binden einen Namen. const verbietet Neubindung, let erlaubt sie
exportmacht einen Namen importierbar
switch / casematch, nur dass Fälle durchfallen, sofern sie nicht mit break oder return enden
for awaitIteration über einen asynchronen Generator
i++erhöht und gibt den alten Wert zurück
/^https?$/ein Regex-Literal, kein re.compile nötig
T[], Map<K, V>list[T], dict[K, V]

Nutze const, solange sich die Bindung nicht ändern muss. Nutze let für einen Zähler, einen Akkumulator oder eine andere Bindung, die du neu zuweist.

Eine kurze Enum-Übersetzung

Für Zustände mit String-Werten nutzt dieses Repository ein Objekt plus einen abgeleiteten String-Union-Typ:

const Status = { Queued: "queued", Running: "running" } as const;
type Status = (typeof Status)[keyof typeof Status]; // "queued" | "running"

Das Objekt liefert Status.Queued, während das Programm läuft. Die type-Zeile erlaubt bei der Typprüfung nur "queued" oder "running". Zusammen erfüllen sie die zwei Rollen dieser Python-Deklaration:

from enum import Enum

class Status(str, Enum):
    QUEUED = "queued"
    RUNNING = "running"

Du musst das Muster nur wiedererkennen. as const behält die Werte des Objekts als exakte Strings, statt sie zu einem beliebigen string zu erweitern.


Die sieben semantischen Unterschiede, die Zeit kosten

Die Syntax-Tabelle bringt dich durch die Beispiele. An diesen semantischen Unterschieden führen Python-Gewohnheiten zu Bugs.

1. Leere Arrays und Objekte sind truthy

Pythons Regel “ein leerer Container ist falsy” ist die Gewohnheit, die sich am schlechtesten übertragen lässt. if (results) ist für ein leeres Array wahr. Schreibe if (results.length).

2. null und undefined sind verschieden

null markiert meist ein bewusstes Fehlen. undefined bedeutet meist, dass ein Wert fehlt oder nicht zugewiesen ist, auch wenn Code es ausdrücklich zuweisen kann. Bibliothekscode gibt ständig undefined zurück. Der Unterschied zeigt sich, wenn du einen Default schreibst. || setzt die rechte Seite ein, sobald die linke falsy ist. Das schließt 0, "" und false ein. ?? setzt sie nur für null und undefined ein. So ist 0 || 10 gleich 10, während 0 ?? 10 gleich 0 ist. Auf diese Weise wird aus einer Batch Size von null stillschweigend zehn.

3. Ein catch-Block erhält unknown

Es gibt kein except ValueError:. Ein einziger catch-Block bekommt alles. Weil JavaScript erlaubt, einen String, eine Zahl oder null zu werfen, typisiert TypeScript den gefangenen Wert unter strict als unknown, seinen Typ für “kann buchstäblich alles sein”. Das Begleit-Repository aktiviert strict, und neue Projekte sollten das im Allgemeinen auch tun. Um den Fehler zu untersuchen, grenze den Wert zuerst ein:

try {
    await risky();
} catch (error) {
    // `error` is `unknown` until you prove otherwise. This line is the
    // TypeScript equivalent of `except Exception as e:` and it is not
    // optional.
    const message = error instanceof Error ? error.message : String(error);
}

4. Promises starten sofort

Der Aufruf einer async-Funktion führt ihren Rumpf sofort aus und gibt ein Promise zurück. Ein Python-Coroutine-Objekt tut nichts, bis du es mit await abwartest oder einplanst. Promise.all entspricht ungefähr asyncio.gather. Promise.allSettled entspricht ungefähr gather(..., return_exceptions=True), nur dass jedes Ergebnis als { status, value } oder { status, reason } verpackt wird.

Node übernimmt das Scheduling zur Laufzeit. Es hält den Prozess am Leben, solange aktive Handles oder Anfragen wie Timer und Sockets existieren. Ein gewöhnliches ausstehendes Promise allein hält Node nicht am Leben. Du verpackst das Programm nicht in asyncio.run. In einem ES-Modul kannst du auf oberster Ebene await verwenden, wenn der Start auf eine asynchrone Operation warten muss.

5. JavaScript hat einen gewöhnlichen numerischen Typ

Der Typ number in JavaScript speichert Werte als 64-Bit-Gleitkommazahlen, etwa wie Pythons float. Der technische Standard für dieses Format heißt IEEE 754. Dezimalwerte sind Näherungen, daher ist 0.1 + 0.2 nicht exakt 0.3, und ganze Zahlen bleiben nur bis 2**53 - 1 exakt, also 9,007,199,254,740,991.

Halte 64-Bit-IDs an Service-Grenzen als Strings. Wird ein Postgres-bigint in ein JavaScript-number umgewandelt, kann er gerundet werden. Für größere exakte ganze Zahlen bietet JavaScript den separaten Typ BigInt, der sich nicht mit gewöhnlichen Zahlen mischen lässt.

6. Nutze Map, wenn du ein Dictionary im Python-Stil brauchst

In JavaScript erzeugt {} ein Objekt. Objekte stellen meist Records mit benannten Feldern dar:

const request = { model: "gpt-5", seqLen: 4096 };
console.log(request.model);

Ein Objekt ist keine saubere Key-Value-Tabelle wie ein Python-dict. Es erbt einige Namen von JavaScript selbst. Das kann ein überraschendes Ergebnis liefern:

const tools: Record<string, unknown> = {};

tools["constructor"]; // a built-in function, not a missing value
Object.hasOwn(tools, "constructor"); // false

Wenn ein externer String ein Feld eines Objekts auswählt, rufe Object.hasOwn auf, bevor du es liest. Wenn du ein allgemeines Dictionary brauchst, nutze Map. Map kommt Pythons dict näher: Ein Schlüssel existiert nur, wenn dein Code ihn hinzufügt.

const tools = new Map<string, unknown>();
tools.get("constructor"); // undefined

7. Die Dateiendung in relativen Imports angeben

Eine JavaScript-Quelldatei, die Code mit anderen Dateien teilt, heißt Modul. Dieses Projekt nutzt das moderne Modulformat, ES-Module, meist ESM abgekürzt. ES steht für ECMAScript, den formalen Namen der Sprache JavaScript. In der Praxis ist ESM die Syntax mit import und export, die dieses Projekt durchgehend verwendet.

Bei einem relativen Import verlangt Node den exakten Dateinamen. Es rät nicht, ob ./env für ./env.ts oder ./env.js steht:

import { loadEnv } from "./env.ts";

Imports aus installierten Paketen oder Workspace-Paketen verwenden weiterhin den Paketnamen, ohne Dateiendung:

import { z } from "zod";
import { runAgent } from "@agent/core";

Zod: Schema deklarieren, Typ ableiten

In pydantic deklarierst du eine Klasse und bekommst einen Validator. In Zod deklarierst du einen Validator und leitest den Typ daraus ab. Dieselbe Single Source of Truth, nur in die entgegengesetzte Richtung.

Die Abbildung zeigt dieselbe Einstellung PORT in beiden Varianten und was von jeder Deklaration übrig bleibt, während der Code läuft.

Zwei Bänder vergleichen eine PORT-Einstellung. In Python deklariert class Env(BaseSettings) PORT: int = 8080; mypy prüft gegen die Annotation, die Klasse behält sie zur Laufzeit, und pydantic baut daraus den Validator. In TypeScript deklariert EnvSchema PORT mit z.coerce.number(), und z.infer baut daraus type Env bei der Typprüfung. Während der Code läuft, prüft EnvSchema process.env, und type Env ist verschwunden, weil Typen vor der Ausführung entfernt werden.Zwei Bänder vergleichen eine PORT-Einstellung. In Python deklariert class Env(BaseSettings) PORT: int = 8080; mypy prüft gegen die Annotation, die Klasse behält sie zur Laufzeit, und pydantic baut daraus den Validator. In TypeScript deklariert EnvSchema PORT mit z.coerce.number(), und z.infer baut daraus type Env bei der Typprüfung. Während der Code läuft, prüft EnvSchema process.env, und type Env ist verschwunden, weil Typen vor der Ausführung entfernt werden.

Aus packages/schemas/src/env.ts:

import { z } from "zod"; // `z` is Zod's whole API, the way `pd` is pandas

const EnvSchema = z.object({
    PORT: z.coerce.number().int().positive().default(8080),
    // See the note below: a bare z.url() would accept "localhost:8000".
    OPENAI_BASE_URL: z
        .url({ protocol: /^https?$/ })
        .default("https://api.openai.com/v1"),
    DATABASE_URL: z.string().optional(),
});

export type Env = z.infer<typeof EnvSchema>;

z.infer<typeof EnvSchema> liest einen statischen Typ aus dem Laufzeit-Schema zurück. z.coerce.number() löst das Problem, dass jeder definierte Wert in process.env (Nodes os.environ) ein String ist. Es erfüllt dieselbe Rolle wie die Umwandlung numerischer Einstellungen in pydantic, auch wenn sich die exakten Strings unterscheiden, die beide akzeptieren. Das folgt dem Muster von pydantic-settings und läuft einmal beim Start. Eine ungültige Umgebung führt dann zu einem lesbaren Startfehler statt zu einem TypeError in einem Handler.

Ein bloßes z.url() akzeptiert localhost:8000. Der URL-Standard behandelt alles vor dem ersten Doppelpunkt als Schema. Er liest localhost: daher als Protokoll namens “localhost” und akzeptiert den String. Der Wert erreicht dann den HTTP-Client und schlägt dort mit weniger Kontext fehl. Schema-Validierung verlagert Fehler nach vorn, setzt aber auch ein zu lockeres Schema durch, wenn du ein solches geschrieben hast.

Zod 4 liefert außerdem z.toJSONSchema mit, sodass dieses Projekt die in älteren Tutorials verbreitete Abhängigkeit zod-to-json-schema nicht braucht. Das zählt, sobald ein Schema drei Verbraucher bedienen muss, worum es im Abschnitt “Ein Tool, drei Verbraucher” unten geht.


Der Service

Drei gemeinsame Pakete oben: @agent/schemas, @agent/core und @agent/observability. Darunter drei Prozesse. Die Hono-API nimmt HTTP-Anfragen eines Clients wie curl entgegen und antwortet mit SSE-Frames oder JSON, ruft einen OpenAI-kompatiblen Model-Server unter /chat/completions auf und fügt Runs in die Postgres-Tabelle runs ein und liest sie daraus. Der Worker übernimmt eingereihte Zeilen aus dieser Tabelle und schreibt Ergebnisse. Der MCP-Server spricht mit einem AI-Client per JSON-RPC über stdin und stdout und hat weder eine HTTP-Route noch eine Postgres-Verbindung.Drei gemeinsame Pakete oben: @agent/schemas, @agent/core und @agent/observability. Darunter drei Prozesse. Die Hono-API nimmt HTTP-Anfragen eines Clients wie curl entgegen und antwortet mit SSE-Frames oder JSON, ruft einen OpenAI-kompatiblen Model-Server unter /chat/completions auf und fügt Runs in die Postgres-Tabelle runs ein und liest sie daraus. Der Worker übernimmt eingereihte Zeilen aus dieser Tabelle und schreibt Ergebnisse. Der MCP-Server spricht mit einem AI-Client per JSON-RPC über stdin und stdout und hat weder eine HTTP-Route noch eine Postgres-Verbindung.

Die obere Zeile ist gemeinsamer Code in packages/. Darunter ist jede App in apps/ ein eigener Prozess. Nur die API und der Worker greifen auf Postgres zu, und der MCP-Server spricht mit seinem Client über stdin und stdout.

Der Demo-Service dimensioniert LLM-Deployments. Ein Tool schlägt die Architekturkonstanten für ein Model nach. Das andere schätzt dessen Speicherbedarf für KV cache: den GPU-Speicher, der Attention Keys und Values für laufende Anfragen hält. Beide Tools sind bewusst schlichte Arithmetik. Sie brauchen kein Netzwerk und liefern jedes Mal dieselbe Antwort. Dadurch lässt sich der Service ohne API-Schlüssel testen. Der Schätzer für KV cache wird außerdem über das Model Context Protocol (MCP) veröffentlicht, sodass andere AI-Clients ihn aufrufen können.

typescript-agent-service/
├── apps/
│   ├── api/           @agent/api: Hono API, streams Server-Sent Events (below)
│   ├── worker/        polls Postgres for long-running jobs
│   └── mcp/           MCP server: exposes one tool to outside AI clients
├── packages/
│   ├── schemas/       package name @agent/schemas: env, API, and tool schemas
│   ├── agent-core/    package name @agent/core: the loop (twice), tools, storage
│   └── observability/ Pino logging, OpenTelemetry tracing
├── pnpm-workspace.yaml
└── package.json

pnpm-workspace.yaml ist die Datei, die den Workspace deklariert. Interne Pakete erhalten einen Scoped-Namen wie @agent/core, wobei das Präfix @agent/ eine Namenskonvention ist, kein Sprachmerkmal. Jedes Paket deklariert seinen öffentlichen Einstiegspunkt in package.json. Diese Paketgrenze hängt nicht davon ab, welcher Befehl die Anwendung startet.

Dieser private Workspace lässt diese Einstiegspunkte auf .ts-Quellcode zeigen, weil jeder Verbraucher Teil desselben Repositorys ist. Öffentliche npm-Pakete veröffentlichen üblicherweise JavaScript plus .d.ts-Typdeklarationen, damit gewöhnliche Node-Verbraucher weder den TypeScript-Runner noch das Build-Setup des Paketautors brauchen.


Den Tool-Loop einmal von Hand schreiben

Ein Schritt von runAgent. Er ruft das Model auf, liest den Stream und liefert pro Delta ein text-Event. Ohne Tool Calls liefert er done mit stopReason stop. Andernfalls durchläuft jeder Aufruf lookup, JSON.parse, Zod-parse und execute; eine fehlgeschlagene Prüfung oder ein ToolError wird zu einem Tool Result mit ok false und Fehlertext, und jede andere Exception verlässt runAgent. Der Loop liefert tool_call und tool_result, hängt die Nachrichten an und startet den nächsten Schritt, und nach maxSteps Schritten liefert er done mit stopReason max_steps.Ein Schritt von runAgent. Er ruft das Model auf, liest den Stream und liefert pro Delta ein text-Event. Ohne Tool Calls liefert er done mit stopReason stop. Andernfalls durchläuft jeder Aufruf lookup, JSON.parse, Zod-parse und execute; eine fehlgeschlagene Prüfung oder ein ToolError wird zu einem Tool Result mit ok false und Fehlertext, und jede andere Exception verlässt runAgent. Der Loop liefert tool_call und tool_result, hängt die Nachrichten an und startet den nächsten Schritt, und nach maxSteps Schritten liefert er done mit stopReason max_steps.

Die linke Spalte ist ein Model-Schritt. Die rechte Spalte zeigt, was der Loop liefert: Abgesehen von einer Exception, die den Loop verlässt, sind diese Events alles, was die HTTP-Route und die Tests sehen.

Agent-Frameworks mit Tool Calling umhüllen denselben Grundloop:

  1. Das Model mit Tool-Definitionen aufrufen.
  2. Die angeforderten Tools validieren und ausführen.
  3. Die Ergebnisse an die Nachrichten anhängen.
  4. Das Model erneut aufrufen.

Wer den Loop einmal selbst schreibt, macht seine verborgenen Fehlerpfade sichtbar.

Der Loop ist eine async function*, ein asynchroner Generator, exakt die Form von Pythons async def mit yield. Die HTTP-Route iteriert über diesen Generator, macht aus jedem Event einen Server-Sent-Events-Frame und sammelt den Text. Nach dem Ende des Streams ruft die Route einmal storage.createRun mit dem finalen Text auf. Tests rufen den Loop separat auf und sammeln seine Events in einem Array. Ein SSE-Frame ist ein Abschnitt einer langlebigen HTTP-Antwort. Der Abschnitt unten erklärt das Format.

Der Loop hat zwei normale Ausgänge. Eine Antwort ohne Tool Calls liefert done mit stopReason: "stop". Fordert das Model bis maxSteps weiter Tools an, endet der Generator trotzdem und liefert done mit stopReason: "max_steps". Behandle dieses Ergebnis als abgeschlossen, aber abgeschnitten: Der gesammelte Text kann unvollständig sein.

Jeder Wert, den der Loop liefert, ist ein AgentEvent, eine getaggte Union. Gekürzt aus packages/schemas/src/events.ts:

export const AgentEventSchema = z.discriminatedUnion("type", [
    z.object({ type: z.literal("text"), delta: z.string() }),
    z.object({
        type: z.literal("tool_call"),
        callId: z.string(),
        toolName: z.string(),
        args: z.unknown(),
    }),
    z.object({
        type: z.literal("tool_result"),
        callId: z.string(),
        ok: z.boolean(),
        result: z.unknown() /* ... */,
    }),
    z.object({
        type: z.literal("done"),
        stopReason: z.enum(["stop", "max_steps"]),
        steps: z.number(),
        text: z.string(),
    }),
    z.object({ type: z.literal("error"), message: z.string() }),
]);

export type AgentEvent = z.infer<typeof AgentEventSchema>;

z.discriminatedUnion wählt die Variante über das Feld type, wie ein match auf markierten Dataclasses. In if (event.type === "text") weiß der Compiler, dass event.delta existiert. Eine zusätzliche Variante führt nicht automatisch dazu, dass ein unvollständiger switch fehlschlägt. Für einen Compilerfehler bei einer fehlenden Variante prüfst du den verbleibenden Wert in einem default-Zweig gegen never. Eine Lint-Regel für vollständige Fallbehandlung kann fehlende Fälle ebenfalls melden. Die HTTP-Route und die AI SDK-Version unten nutzen beide die eingegrenzten Event-Typen.

Der Loop-Rumpf streamt Text-Events und setzt Tool Calls aus Fragmenten zusammen. Aus packages/agent-core/src/loop.ts:

let text = ""; // the assistant text produced during this model step
// Keyed by the `index` field, because a streamed response interleaves
// fragments of several parallel tool calls and only `index` is present
// on every fragment. `id` and `name` arrive once, `arguments` arrives
// in pieces. `a?.b` below reads `b` only if `a` exists, and gives back
// `undefined` instead of throwing if it doesn't.
const partial = new Map<number, PartialToolCall>();

for await (const chunk of stream) {
    const choice = chunk.choices[0];
    if (!choice) continue;

    if (choice.delta.content) {
        text += choice.delta.content;
        yield { type: "text", delta: choice.delta.content };
    }

    for (const fragment of choice.delta.tool_calls ?? []) {
        const slot = partial.get(fragment.index) ?? { id: "", name: "", args: "" };
        if (fragment.id) slot.id = fragment.id;
        if (fragment.function?.name) slot.name = fragment.function.name;
        if (fragment.function?.arguments) slot.args += fragment.function.arguments;
        partial.set(fragment.index, slot);
    }
}

Die loop-lokale Variable text hält einen Model-Schritt. Der Loop nutzt sie in der Assistant-Nachricht für den nächsten Schritt oder im finalen done-Event. Es ist nicht der Akkumulator auf Routen-Ebene, der später persistiert wird.

Die Map partial ist der Teil, den Frameworks verbergen. Das SDK liefert String-Fragmente der Funktionsargumente, und diese können das serialisierte JSON an beliebigen Stellen teilen. Mehrere parallele Aufrufe können sich außerdem verschränken. Das Repository hat einen Test, der {"model":"llama-3.1-8b",...} auf vier Chunks aufteilt.

Die Abbildung folgt diesen vier Chunks bis zum Map-Eintrag für Index 0.

Vier gestreamte Chunks, alle mit index 0. Der erste trägt id c1 und name estimate_kv_cache; die nächsten drei tragen Teile des arguments-Strings, und der erste Schnitt liegt mitten im Wert llama-3.1-8b. Der Map-Eintrag fügt die Teile zusammen, und erst nach dem Ende des Streams macht JSON.parse aus dem String model llama-3.1-8b, seqLen 4096, batchSize 1.Vier gestreamte Chunks, alle mit index 0. Der erste trägt id c1 und name estimate_kv_cache; die nächsten drei tragen Teile des arguments-Strings, und der erste Schnitt liegt mitten im Wert llama-3.1-8b. Der Map-Eintrag fügt die Teile zusammen, und erst nach dem Ende des Streams macht JSON.parse aus dem String model llama-3.1-8b, seqLen 4096, batchSize 1.

Das Zweite, das du selbst schreiben solltest, ist, was bei fehlgeschlagener Validierung passiert:

// `tool.parse` is this repo's wrapper, not Zod's. Zod's own `.parse` throws and
// `.safeParse` returns `{ success, data, error }`; this returns
// `{ ok: true, value }` on success and `{ ok: false, error }` on failure, so no
// caller has to catch.
const parsed = tool.parse(raw);
if (!parsed.ok) {
    return {
        ok: false,
        value: { error: `Invalid arguments: ${parsed.error}` },
        parsedArgs: raw,
    };
}

Vor der Ausführung kann der Lookup das Tool nicht finden, JSON.parse kann die Argumente ablehnen oder Zod kann ihre Form ablehnen. Jeder dieser Fehler wird zu einer Nachricht, die das Model liest. Auch während der Ausführung wird ein erwarteter ToolError zu einem Tool Result, damit das Model seinen Aufruf korrigieren kann. Eine unerwartete Exception wird an den HTTP-Fehlerpfad weitergereicht, statt als fachlicher Fehler dargestellt zu werden. z.prettifyError macht aus Zods Issue-Baum eine Nachricht, mit der das Model etwas anfangen kann, statt eines Stack Traces.

strict: true in einer OpenAI-Funktionsdefinition bittet den Provider, Constrained Decoding anhand des Schemas anzuwenden. Es hat nichts mit dem tsconfig-Flag strict von TypeScript zu tun. Das ähnelt dem Guided Decoding von vLLM, auch wenn unterstützte Schemas und Details der Durchsetzung abweichen. Es beseitigt eine Fehlerart, aber ein selbst gehosteter Endpoint kann das Flag ignorieren. Die Argumente müssen außerdem JSON.parse überstehen.

Der Loop ruft /chat/completions auf, weil das Begleit-Repository OpenAI-kompatible Server anspricht. vLLM, SGLang und Ollama dokumentieren diesen Endpoint, sodass OPENAI_BASE_URL denselben Client auf jeden von ihnen richten kann. Ihre Abdeckung der Responses API unterscheidet sich und ändert sich je nach Release. Wenn du beide Seiten kontrollierst, prüfe die aktuelle Kompatibilitätsseite des Servers, bevor du dich zwischen den beiden APIs entscheidest.


Dann auf das AI SDK wechseln und wissen, was du eintauschst

Das Begleit-Repository implementiert denselben Agent auch mit dem Vercel AI SDK, damit du beide vergleichen kannst. Beide Versionen liefern AgentEvent-Werte, aber ihr Verhalten bei abschließenden Fehlern unterscheidet sich derzeit. Der optionale SDK-Adapter liefert ein error-Event mit String(part.error) und kehrt ohne done zurück. Die aktuelle HTTP-Route leitet diese Details weiter und speichert den Run als succeeded, weil der Generator ohne Exception beendet wurde. Bevor du den standardmäßigen rohen Loop durch diesen Adapter ersetzt, behandle error oder einen Stream, der ohne done endet, als fehlgeschlagenen Run. Bereinige Fehlermeldungen für Clients und teste den gespeicherten Status zusammen mit dem abschließenden Event.

Das Begleit-Repository nutzt AI SDK 7. Die Framework-Implementierung liegt in packages/agent-core/src/loop-ai-sdk.ts:

const result = streamText({
    model: provider.chatModel(options.model),
    prompt: options.message,
    tools: aiSdkTools,          // Zod schemas passed straight through
    stopWhen: stepCountIs(options.maxSteps ?? 6),
});

for await (const part of result.fullStream) {
    switch (part.type) {
        case "text-delta": yield { type: "text", delta: part.text }; break;
        case "tool-call":  yield { type: "tool_call", callId: part.toolCallId, /* ... */ }; break;
        // ...
    }
}

Das SDK nimmt dir fünf Teile Anwendungscode ab:

  • den Fragment-Akkumulator
  • JSON.parse und seinen Fehlerpfad
  • den Aufruf, der Zod über die geparsten Argumente laufen lässt
  • die providerspezifische Zusammensetzung der Nachrichten
  • den Schrittzähler

stopWhen akzeptiert mehrere Bedingungen, darunter ein Schrittlimit oder einen bestimmten Tool Call. Die for await-Schleife ändert sich nicht, wenn sich die Stopp-Regel ändert.

Was du aufgibst, ist die direkte Kontrolle über Validierungsfehler. Der handgeschriebene Loop entscheidet, was das Model nach einem abgelehnten Aufruf sieht. Das Begleit-Repository setzt die Option repairToolCall des SDK nicht und nutzt daher den Default: Sind mehr als ein Schritt erlaubt, schickt AI SDK 7 den fehlgeschlagenen Aufruf im nächsten Schritt an das Model zurück. Übergib einen repairToolCall-Callback, um den Aufruf selbst zu reparieren, zum Beispiel mit einem zweiten Model-Aufruf.

Der Tausch gilt auch in die andere Richtung. In der SDK-Version bleibt ein Providerwechsel auf den Provider-Adapter beschränkt. Er erfordert trotzdem das passende Provider-Paket, Zugangsdaten, Konfiguration und Integrationstests. Im rohen Loop ist die providerspezifische Behandlung von Anfragen und Streams dein eigener Code, den du ändern musst.

Für einen neuen Service fang mit dem SDK an, sofern du diese Fehlerpfade nicht kontrollieren musst. Die handgeschriebene Version gibt dir eine kleine Implementierung zum Untersuchen und zum Vergleich mit dieser Wahl.


Streaming über HTTP: Hono und SSE

Hono-Routen lesen sich wie FastAPI-Routen. Die einzige Ergänzung ist zValidator, das die Aufgabe übernimmt, die FastAPI durch die Typannotationen der Handler-Signatur gratis erhält. Das c im Handler unten ist Honos Request-Kontext, das Objekt, das FastAPI auf deine Parameter verteilt. deps ist ein Bündel von Abhängigkeiten, mit dem die App konstruiert wird, statt sie direkt zu importieren. runAgent ist eine davon, und der Abschnitt zum Testen zeigt, was dir das bringt.

Gekürzt aus apps/api/src/app.ts:

app.post("/v1/chat", zValidator("json", ChatRequestSchema), (c) => {
    const body = c.req.valid("json");
    const log = deps.logger.child({ route: "chat" });

    return streamSSE(c, async (stream) => {
        let text = "";
        let done: Extract<AgentEvent, { type: "done" }> | undefined;
        try {
            // The full route wraps this loop in a `withSpan` tracing span.
            for await (const event of deps.runAgent({
                message: body.message,
                maxSteps: body.maxSteps,
            })) {
                if (event.type === "text") text += event.delta;
                if (event.type === "done") done = event;

                await stream.writeSSE({
                    event: event.type,
                    data: JSON.stringify(event),
                });
            }
        } catch (error) {
            log.error({ err: error }, "agent run failed");
            await stream.writeSSE({
                event: "error",
                data: JSON.stringify({
                    type: "error",
                    message: "Agent run failed",
                }),
            });
            return;
        }

        await deps.storage.createRun({
            kind: "chat",
            status:
                done?.stopReason === "max_steps" ? "truncated" : "succeeded",
            input: { message: body.message },
            output: { text },
        });
    });
});

Die Route behält das finale done-Event. Ein Loop, der bei max_steps stoppt, endet normal, daher speichert die Route ihn als truncated, nicht als succeeded.

zValidator validiert den Body und gibt c.req.valid("json") den Typ, den das Schema erzeugt. Ohne es ist der Body als any typisiert, TypeScripts Ausstieg, bei dem jeder Property-Zugriff kompiliert und nichts geprüft wird. Das hebt den Typsicherheitsgewinn des Schemas auf.

Diese Route nutzt Server-Sent Events statt WebSockets. Der Server hält eine HTTP-Antwort offen, während er Frames der Form event: <name> und data: <json> schreibt, und schließt sie nach dem letzten Event. Der Verkehr fließt vom Server zum Client, was zu diesem Agent-Stream passt. Ein WebSocket würde bidirektionale Nachrichten und ein Protokoll-Upgrade hinzufügen, die diese Route nicht braucht.

Beim standardmäßigen rohen Loop ändert eine Exception während des Streams die HTTP-Fehlermeldung. Sobald der erste Frame mit Status 200 gesendet wurde, kann der Server diese Antwort nicht durch einen 500 ersetzen. Der catch-Block protokolliert den abgefangenen Fehler, sendet ein konstantes Fehler-Event an den Client und kehrt zurück. Dadurch wird storage.createRun übersprungen: Ein geworfener Generatorfehler hinterlässt keine Zeile. Dieser catch läuft nicht, wenn der optionale SDK-Adapter ein error-Event liefert und zurückkehrt. Wenn du fehlgeschlagene Runs für Debugging oder evals brauchst, schreibe im catch-Block eine failed-Zeile mit dem Teiltext.

Die Antwort auf POST /v1/chat: Status 200 geht mit dem ersten Frame raus, danach text-, tool_call- und tool_result-Frames. Es folgen drei Enden: done mit stopReason stop speichert den Run als succeeded; done mit stopReason max_steps speichert ihn als truncated; ein werfender Generator sendet ein error-Event mit Agent run failed und speichert keine Zeile. Der Client sieht in allen drei Fällen Status 200.Die Antwort auf POST /v1/chat: Status 200 geht mit dem ersten Frame raus, danach text-, tool_call- und tool_result-Frames. Es folgen drei Enden: done mit stopReason stop speichert den Run als succeeded; done mit stopReason max_steps speichert ihn als truncated; ein werfender Generator sendet ein error-Event mit Agent run failed und speichert keine Zeile. Der Client sieht in allen drei Fällen Status 200.

Die Abbildung verfolgt eine Anfrage bis zu ihren drei Enden. Der Statuscode ist in allen drei Fällen derselbe, nur der letzte Frame unterscheidet sie.

Ein Test deckt diesen Pfad ab. Ein Generator liefert ein Text-Delta und wirft dann eine Exception. Die Antwort bleibt 200, und ihr letzter Frame ist ein error-Event mit der konstanten Meldung Agent run failed. Der Logger behält den gefangenen Fehler für die Diagnose auf dem Server. Jeder Client, der nur den Statuscode prüft, meldet bei einem fehlgeschlagenen Run Erfolg.

app.ts trifft zwei kleinere Entscheidungen, die eine Erklärung verdienen. Es behandelt /healthz als Liveness-Endpoint, daher greift diese Route bewusst nicht auf Postgres zu. Ein Liveness-Fehler bei einem Datenbankausfall könnte jedes Replica neu starten, ohne die Abhängigkeit zu reparieren. Füge eine separate Readiness-Prüfung hinzu, wenn der Orchestrator keinen Verkehr mehr an eine Instanz leiten soll, die Postgres nicht erreicht. Die Fehlerpfade protokollieren den gefangenen Fehler, geben aber einen konstanten String zurück. error.message in einen Response-Body zu schreiben, ist der Weg, auf dem Connection Strings im Browser eines Fremden landen.


Der Celery-förmige Teil, ohne Celery

Lange Jobs gehören nicht in einen Request-Handler. POST /v1/sweeps validiert ein Model, eine Sequenzlänge und eine Liste von Batch Sizes, fügt eine Zeile mit queued ein und gibt 202 mit der Run-ID zurück. Der Worker übernimmt die Zeile, berechnet die Größe von KV cache für jede Batch Size und speichert das Ergebnis. GET /v1/runs/:id liest es.

POST /v1/sweeps fügt eine Zeile mit queued ein und gibt 202 zurück. Ein Worker übernimmt sie und setzt sie auf running, mit lockedAt auf der Übernahmezeit; sie endet als succeeded oder failed. Eine Zeitleiste darunter zeigt Worker A, der den Sweep bei Minute 5 noch ausführt, wenn der Lock als veraltet gilt und Worker B dieselbe Zeile erneut übernimmt.POST /v1/sweeps fügt eine Zeile mit queued ein und gibt 202 zurück. Ein Worker übernimmt sie und setzt sie auf running, mit lockedAt auf der Übernahmezeit; sie endet als succeeded oder failed. Eine Zeitleiste darunter zeigt Worker A, der den Sweep bei Minute 5 noch ausführt, wenn der Lock als veraltet gilt und Worker B dieselbe Zeile erneut übernimmt.

Die obere Hälfte zeigt die Zustände, die eine Sweep-Zeile durchläuft. Die Zeitleiste zeigt die unten erklärte Übernahme eines veralteten Locks: Nach fünf Minuten übernimmt ein zweiter Worker einen Job, der noch läuft.

Hier gibt es kein Redis und kein BullMQ. PostgreSQL dokumentiert SKIP LOCKED für mehrere Verbraucher einer queue-artigen Tabelle. Die Klausel gibt diesem kleinen Service eine At-least-once-Queue in einer Tabelle. Sie ist transaktional mit deinen übrigen Schreibvorgängen, und es ist ein Service weniger in docker-compose.yml.

Die Claim-Query in packages/agent-core/src/db/storage.ts lautet:

const [candidate] = await tx
    .select({ id: runs.id })
    .from(runs)
    // The real query also picks up rows whose lock went stale; trimmed here.
    .where(and(eq(runs.kind, kind), eq(runs.status, "queued")))
    .orderBy(runs.createdAt)
    .limit(1)
    // `.for()` adds a locking clause; `{ skipLocked: true }` adds SKIP LOCKED.
    .for("update", { skipLocked: true });

Die Zeile ist für die Transaktion gesperrt, und jeder gleichzeitige Worker, der dieselbe Query ausführt, überspringt sie, statt zu blockieren. Zwei gleichzeitige Claims erhalten daher nicht dieselbe nicht veraltete Zeile. Ein Integrationstest feuert zwei Claims gleichzeitig über Promise.all ab und prüft, dass sie verschiedene Zeilen liefern. Die naive Version, SELECT ... LIMIT 1 gefolgt von UPDATE, fällt bei diesem Test durch: Beide Transaktionen lesen dieselbe Zeile, bevor eine von ihnen schreibt, daher starten beide denselben Job.

Zwei Sequenz-Felder mit Bahnen für Worker A, Zeile 7, Zeile 8 und Worker B. Beim naiven Claim wählen beide Worker Zeile 7 aus, solange sie noch queued ist, setzen sie dann beide auf running und führen denselben Job aus. Mit FOR UPDATE SKIP LOCKED sperrt Worker A Zeile 7, Worker B überspringt sie und sperrt Zeile 8, und jeder führt einen anderen Job aus.Zwei Sequenz-Felder mit Bahnen für Worker A, Zeile 7, Zeile 8 und Worker B. Beim naiven Claim wählen beide Worker Zeile 7 aus, solange sie noch queued ist, setzen sie dann beide auf running und führen denselben Job aus. Mit FOR UPDATE SKIP LOCKED sperrt Worker A Zeile 7, Worker B überspringt sie und sperrt Zeile 8, und jeder führt einen anderen Job aus.

Das obere Feld ist die Race Condition, die der Test findet; das untere Feld ist die Claim-Query oben.

Das ist At-least-once-Ausführung: Ein Job kann mehr als einmal laufen. Die vollständige Query übernimmt außerdem eine running-Zeile erneut, wenn ihr Lock älter als fünf Minuten ist, und nichts verlängert diese Lease. Ein laufender Job, der länger als fünf Minuten läuft, kann daher zweimal übernommen werden. Mache Jobs idempotent. Bei lang laufender Arbeit füge einen Lease-Heartbeat hinzu oder setze die Schwelle für veraltete Locks über die maximale Laufzeit.

Füge BullMQ hinzu, wenn du verzögerte Jobs, wiederkehrende Zeitpläne, Prioritäten, Rate Limits oder ein Dashboard brauchst. Der entsprechende Schritt in Python ist der von einer Datenbanktabelle zu Celery. Davor ist Redis ein weiterer Service, den du betreiben und überwachen musst.

Der Worker validiert, was er aus jsonb liest, erneut:

// The row was validated on the way in, but it has been through a database.
// A stored row can outlive the schema version that accepted it.
// The job is a batch-size sweep. Zod's own `.parse` throws; the poll loop
// catches that and marks the run failed.
const input = SweepRequestSchema.parse(run.input);

Die Test-Suite übergibt dem Worker außerdem eine Sweep-Zeile, deren seqLen der String "lots" ist. Der Worker lässt den Run fehlschlagen und pollt weiter, statt abzustürzen und dieselbe ungültige Zeile endlos erneut zu versuchen.

CPU-Arbeit zeigt eine weitere Node-Einschränkung. Ein synchroner Callback läuft auf dem Thread der Event Loop und wird nicht unterbrochen. Eine for-Schleife, die zwei Sekunden lang rechnet, blockiert jede Anfrage, jeden Timer und jede Liveness-Prüfung dieses Prozesses für zwei Sekunden. Eine enge Schleife in einer async def blockiert asyncio auf dieselbe Weise. Beide Runtimes verlangen, dass du CPU-Arbeit ausdrücklich auslagerst.

await setTimeout(0) aus node:timers/promises (das Präfix node: steht für die Standardbibliothek, also verhält sich node:timers zu Node wie os zu Python) entspricht await asyncio.sleep(0). Der Sweep gibt nach jeder Batch Size die Kontrolle ab, damit der Worker-Prozess Timer und andere Callbacks bedienen kann. Das Abgeben der Kontrolle macht CPU-Arbeit nicht parallel. Node worker_threads können JavaScript parallel ausführen. Für reine Python-CPU-Arbeit unter dem üblichen CPython-Build mit GIL nutze einen Process Pool statt eines Thread Pools. Dieser Service nutzt keines von beiden. Behalte rechenintensive Numerik in Python, wo die unterstützenden Bibliotheken schon liegen, und verlagere sie weg von der Event Loop der API.


Ein Tool, drei Verbraucher

EstimateKvCacheInput hat drei Verbraucher:

  • Der handgeschriebene Loop wandelt es mit z.toJSONSchema um.
  • Das AI SDK erhält es unverändert.
  • Der MCP-Server veröffentlicht seine Form.

Diese Wiederverwendung ist der Grund, warum es packages/schemas gibt.

Aus apps/mcp/src/index.ts:

server.registerTool(
    "estimate_kv_cache",
    {
        description: "Estimate KV-cache VRAM in GiB for a served model...",
        // A Zod object schema keeps the map of fields you passed in on
        // `.shape`. This SDK wants that map, not the schema wrapped around it.
        inputSchema: EstimateKvCacheInput.shape,
    },
    async ({ model, seqLen, batchSize }) => {
        /* ... */
    },
);

await server.connect(new StdioServerTransport());

Zwei Details zählen, bevor du einen Client verbindest. Erstens: Ein so gestarteter Server nutzt sein eigenes stdin und stdout, um mit dem Client zu kommunizieren. Jede Zeile ist eine JSON-RPC-Nachricht. Ein verirrtes console.log, das JavaScript-Äquivalent von print, beschädigt dann eine Nachricht. Der Client trennt die Verbindung mit einem Parse-Fehler, der keine Datei nennt. Sende stattdessen alle Diagnosen an stderr.

Zweitens: Ein fachlicher Fehler sollte isError: true mit einer Nachricht zurückgeben. Das aufrufende Model kann den Aufruf dann korrigieren, genau wie nach ungültigen Tool-Argumenten im Agent Loop.

Du kannst den Server mit printf und einer Pipe ansteuern, was sich einmal lohnt, bevor du einen echten Client darauf richtest:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"estimate_kv_cache","arguments":{"model":"llama-3.1-70b","seqLen":8192,"batchSize":4}}}' \
  | pnpm -s mcp   # -s suppresses pnpm's own output so only JSON-RPC comes back

Die Probe nutzt dieselbe Protokollversion wie die README des Begleit-Repositorys. Nutze für einen echten Client das SDK, statt JSON-RPC-Nachrichten von Hand zu pflegen.


Einen Agent ohne API-Schlüssel testen

Vitest übernimmt die Rolle von pytest, aber die Struktur ist anders. describe gruppiert zusammengehörige Tests. it und test definieren jeweils einen Testfall. test.each entspricht ungefähr parametrize, beforeEach liefert das Setup pro Test, vi.fn() erzeugt eine Mock-Funktion, und describe.skipIf überspringt eine Gruppe unter einer Bedingung.

Agent-Tests hängen an einer Entscheidung: runAgent nimmt einen OpenAI-Client als Parameter, statt selbst einen zu konstruieren. Der Fake ist ein Objekt mit einer Methode chat.completions.create, die ein geskriptetes asynchrones Iterable zurückgibt:

function fakeClient(scripts: Chunk[][]): OpenAI {
    let call = 0;
    return {
        chat: {
            completions: {
                create: async () => {
                    const script = scripts[call++] ?? [];
                    // Defines an async generator and calls it on the same
                    // line, so `create` hands back something you can
                    // `for await` over, which is the shape a real streaming
                    // response has.
                    return (async function* () {
                        for (const chunk of script) yield chunk;
                    })();
                },
            },
        },
        // TypeScript refuses a direct cast between unrelated shapes, so you
        // cast through `unknown` first. This disables type checking for that
        // value, so keep it to test code.
    } as unknown as OpenAI;
}

Die Tests teilen einen JSON-Argument-String auf mehrere Chunks auf und behandeln zwei Tool Calls in einer Antwort. Sie decken außerdem ein ungültiges batchSize, fehlerhaftes JSON, unbekannte Tool-Namen und ein Model ab, das so lange Tools aufruft, bis maxSteps es stoppt. Die Testdatei läuft in deutlich unter einer Sekunde, ohne Netzwerk und ohne Schlüssel.

Integrationstests gegen Postgres nutzen describe.skipIf(!process.env.DATABASE_URL), sodass pnpm test auf einem frischen Clone ohne laufendes Postgres funktioniert und die CI sie durch das Setzen der Variable einschaltet. Das Repository hat 42 Tests. Achtunddreißig laufen ohne Postgres oder API-Schlüssel.


Strukturierte Events mit Pino protokollieren

Pino erfüllt dieselbe Rolle wie structlog: ein JSON-Objekt pro Zeile, Child-Logger mit gebundenen Feldern und explizite Schwärzung. Das Begleit-Repository konfiguriert es in packages/observability/src/logger.ts:

const log = pino({
    redact: {
        paths: [
            "req.headers.authorization",
            "apiKey",
            "OPENAI_API_KEY",
            "*.apiKey",
        ],
        censor: "[redacted]",
    },
});

Ohne Schwärzung kann log.info({ req }, "...") einen Authorization-Header in das Log-Backend kopieren.


Anwendungsarbeit mit manuellen Spans tracen

Das Begleit-Repository nutzt OpenTelemetry für drei Spans auf Anwendungsebene: agent.run, agent.tool und worker.sweep. Es installiert keine automatische HTTP- oder Postgres-Instrumentierung. startTracing() in packages/observability/src/tracing.ts erzeugt ein NodeSDK mit einem OTLP-Trace-Exporter. Fehlt OTEL_EXPORTER_OTLP_ENDPOINT, bleibt Tracing ausgeschaltet.

Die eigentliche Arbeit wird von withSpan() aus derselben Datei umhüllt:

return tracer.startActiveSpan(name, { attributes }, async (span) => {
    try {
        return await fn(span);
    } finally {
        span.end();
    }
});

JavaScript hat keine Context-Manager-Syntax im Python-Stil. Hier ist der Callback der Block, den ein Python-Context-Manager umschließen würde. Der vollständige Helper zeichnet außerdem Exceptions auf und setzt den Span-Status, bevor er sie erneut wirft.

Automatische HTTP- und Datenbank-Spans sind ein eigenes Feature. Sie erfordern die passenden Instrumentierungspakete und eine Initialisierung, bevor die instrumentierten Module geladen werden. Füge das nur hinzu, wenn diese Spans nützlich sind, und folge dann dem Setup des OpenTelemetry Node SDK für die exakten Paketversionen, die du auslieferst.


Das Monorepo in Docker ausliefern

Nutze das mitgelieferte Dockerfile. Der Container startet die API mit dem tsx-Loader. Du musst beim Deployment keinen TypeScript-Runner auswählen oder aufrufen.

Der Build nutzt pnpm fetch, damit Dependency-Downloads bis zur nächsten Änderung der Lockfile im Cache bleiben. Danach kopiert pnpm deploy die API und ihre Produktionsabhängigkeiten in ein eigenständiges Verzeichnis. Die Runtime-Stage läuft als Nicht-Root-Nutzer node, und ihr CMD in Exec-Form lässt die API SIGTERM direkt für ein sauberes Herunterfahren empfangen.

Warum das Dockerfile tsx lädt

Node 24 kann eine begrenzte TypeScript-Teilmenge ausführen, indem es Typannotationen entfernt. Es prüft den Code nicht auf Typen und führt die Transformationen nicht aus, die ein vollständiger TypeScript-Runner unterstützt. Die Paketskripte des Repositorys verbergen dieses Detail. pnpm check führt die separate statische Prüfung aus.

Der Container zeigt eine weitere Grenze. pnpm deploy kopiert die Workspace-Pakete unter node_modules, und Node weigert sich dort bewusst, TypeScript zu entfernen (Node-TypeScript-Dokumentation). Ohne Loader schlägt der Container beim Start mit ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING fehl.

Das Dockerfile lädt tsx, das diese .ts-Dateien verarbeitet, bevor Node sie ausführt. Ein Team, das nur .js-Dateien in seinem Runtime-Image will, kann stattdessen einen Compile-Schritt hinzufügen. Das ist ein alternatives Produktionsdesign, kein zusätzlicher Schritt, der nötig wäre, um dieses Begleit-Repository auszuführen.


Ein Drei-Wochen-Plan

Erfahrene Python-Engineers können Material überspringen, das Variablen und Schleifen lehrt. Diese Abfolge konzentriert sich auf die Teile, die sich von Python unterscheiden. Erledige in jeder Zeile die Bauaufgabe; die Lektüre unterstützt sie.

WocheLesenBauen
1javascript.info: nur Module, Promises und Objekte. Behalte den MDN JS Guide als Referenz.Schreibe ein Python-CLI in TypeScript neu. Füge ein package.json-Skript hinzu. Führe das Skript und pnpm typecheck aus.
1-2Lies die tsconfig-Referenz von TypeScript, die kostenlosen Total-TypeScript-Tutorials und die Zod-Dokumentation.Baue ein Zod-validiertes Konfigurationsmodul und eine getaggte Union. Nach der Prüfung des Tags weiß der Compiler, welche Variante der Block enthält.
2Lies die Dokumentation von Hono, Drizzle, Vitest und Biome.Baue einen Streaming-Proxy zu einem OpenAI-kompatiblen Endpoint mit einem Log auf Drizzle-Basis.
3Lies die Dokumentation des AI SDK und des MCP TypeScript SDK.Baue einen Agent mit Tool Calling. Baue dann einen MCP-Server, der eines seiner Tools bereitstellt.

Beginne mit den kostenlosen Total-TypeScript-Tutorials. Zahle für das fortgeschrittene Material nur, wenn du mit Generics und Conditional Types auf Bibliotheksniveau arbeitest. Überspringe jeden “Einführung in JavaScript”-Kurs und alles, was nach React aussieht, sofern das Produkt es nicht verlangt.

Für einen Überblick über Produktionskonventionen ist goldbergyoni/nodebestpractices eine breite, von der Community gepflegte Checkliste. Prüfe Ratschläge, die Laufzeitverhalten oder Sicherheit betreffen, gegen die aktuelle Node-Dokumentation.


Bevor du den Service anpasst

Die Offline-Tests prüfen den Kontrollfluss des Service mit geskripteten Model-Antworten. Sie sagen nichts darüber aus, wie zuverlässig ein Live-Model deine Aufgaben erledigt. Behalte diese Unterscheidung im Blick, wenn du die Demo-Tools durch echte ersetzt.

Validiere jeden Wert, der von außen hereinkommt: die Umgebung, den HTTP-Body, Tool-Argumente vom Model und aus jsonb gelesene Zeilen.

WertGeprüft vonBei einem Fehler
process.envEnvSchema, in loadEnv() beim StartStartfehler; der Prozess stoppt
HTTP-BodyChatRequestSchema, über zValidator400; der Handler läuft nie
Tool-Argumentetool.parse, nach JSON.parseTool-Fehler, der an das Model gesendet wird
runs.inputSweepRequestSchema, im Worker bei jeder ÜbernahmeRun als failed markiert; der Worker pollt weiter

Lege als Nächstes die Abschlussregel fest: Entscheide, ob deine Anwendung truncated-Runs akzeptiert, wiederholt oder prüft, und teste das Event und den gespeicherten Status gemeinsam. Wenn Jobs länger laufen können als die Schwelle für veraltete Locks, füge einen Lease-Heartbeat hinzu, bevor du Seiteneffekte einbaust.

Sobald diese Verhaltensweisen explizit sind, füge die im Streaming-Abschnitt beschriebene failed-Zeile hinzu und nutze fehlgeschlagene und abgeschnittene Runs, um eine eigene Agent-Evaluierungs-Suite aufzubauen. Dort testest du den Aufgabenabschluss; die Offline-Suite bleibt die schnellere Prüfung auf kaputtes Streaming, kaputte Validierung und kaputte Persistenz.


Referenzen

Demo-Repository

  • slavadubrov/typescript-agent-service - das Monorepo, das in diesem Artikel durchgehend verwendet wird: Hono-API mit SSE, zwei Agent-Loop-Implementierungen, Drizzle-Storage, Worker, MCP-Server und 42 Tests

Runtime und Sprache

Tooling

  • pnpm und Installation von pnpm - Paketmanager, Workspaces und Setup
  • Biome - Lint, Format und Import-Sortierung in einer Binärdatei
  • Vitest - Test Runner, der keine Transform-Konfiguration braucht
  • Total TypeScript - kostenlose Tutorials plus ein kostenpflichtiger Track für fortgeschrittene Typen

Bibliotheken

  • Zod - Schema-Validierung und Typinferenz. Version 4 enthält z.toJSONSchema.
  • Hono - HTTP-Framework auf Web-Standards
  • Drizzle ORM - SQL-first-TypeScript-ORM mit drizzle-kit-Migrationen
  • SELECT-Dokumentation von PostgreSQL - die Locking-Klausel FOR UPDATE ... SKIP LOCKED
  • BullMQ - Redis-basierte Queue für den Fall, dass die Datenbanktabelle nicht ausreicht

AI und Agents

Konventionen