Automatische Übersetzung Dieser Artikel wurde automatisch aus der englischen Originalversion übersetzt.

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

Dies ist ein kompakter Onboarding-Leitfaden für erfahrene Python-Engineers, die AI-Services mit TypeScript und Node ausliefern müssen. Er richtet sich an ML-Engineers, Data Scientists und Backend-Entwickler, die keinen JavaScript-Einsteigerkurs benötigen.

Ich habe dieses Onboarding in den vergangenen Monaten selbst durchlaufen, nachdem ich mit Python und Java gearbeitet hatte. Die meisten Anleitungen, die ich fand, begannen mit grundlegender Programmierung oder Frontend-DOM-Arbeit. Dieser Artikel startet mit Konzepten aus Python-Services. Am Ende kannst du einen Python-Service-Stack auf TypeScript abbilden und die Python-Gewohnheiten erkennen, die JavaScript-Bugs verursachen. Das durchgehende Beispiel folgt einem Streaming-Agent-Service – vom Schema bis zum Deployment.

Zusammenfassung: Installiere Node 24 und pnpm. Verwende anschließend die pnpm-Befehle des Repositorys. Führe pnpm demo für das Offline-Beispiel, pnpm dev:api und pnpm dev:worker für die Entwicklung sowie pnpm check vor einem Commit aus. node, tsx und den TypeScript-Checker musst du nicht selbst ausführen. Die Package-Scripts erledigen das.

Der Service verwendet Zod, Hono, Drizzle, Vitest und Biome. Sie decken weitgehend dieselben Aufgaben ab wie pydantic, FastAPI, SQLAlchemy, pytest und Ruff. Umfangreiche numerische Berechnungen gehören nach Python. Verwende diesen TypeScript-Service für Orchestration, HTTP und Streaming.

Alles hier ist eine Datei aus slavadubrov/typescript-agent-service, dem begleitenden Repository zu diesem Artikel. Es enthält eine HTTP API, zwei Versionen desselben Agent Loops, Postgres Run History, einen Worker und einen MCP Server. pnpm install && pnpm demo führt den Offline-HTTP/SSE-Agent-Pfad und die Sweep-Berechnung des Workers ohne API-Key aus.

Ich behandle ausschließlich Backend- und AI-Themen. Kein React. Auch einen Browser Bundler gibt es nicht.


Zuerst das begleitende Repository ausführen

Installiere Node 24 und pnpm gemäß den offiziellen pnpm-Installationsanweisungen. Klone anschließend das begleitende Repository und führe Folgendes aus:

pnpm install
pnpm demo
pnpm check

pnpm demo testet den HTTP-Handler, den Agent Loop und den SSE-Stream mit einem scripted Model. Außerdem wird die runSweep-Berechnung des Workers aufgerufen. Der Worker-Prozess und seine Database Queue werden nicht gestartet. Die Demo benötigt keinen API-Key, keine Datenbank und kein Docker. pnpm check führt den Type Checker, Linter, Formatter-Check und die Tests aus.

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

Das sind die Befehle, die ich im weiteren Verlauf dieses Artikels verwende. Das Repository kapselt die niedrigeren Node- und TypeScript-Befehle hinter benannten pnpm-Scripts – ähnlich wie ein Python-Projekt uv run-Befehle hinter make-Targets kapseln könnte. Füge npm install nicht in dieses pnpm-Repository ein. Verwende pnpm install, damit pnpm-lock.yaml die einzige Lockfile bleibt.


Was Node, npm, pnpm, TypeScript und tsx tun

Die ähnlichen Namen verbergen getrennte Aufgaben:

Führe für dieses Repository die pnpm-Scripts aus. Node ist die Runtime innerhalb dieser Scripts; das bereitgestellte Dockerfile übernimmt Production.


Der Stack, von Python aus abgebildet

Zwei Spalten bilden jede Aufgabe in einem Python-AI-Service auf ihren TypeScript-Ersatz ab. Die Zeilen behandeln Projektmetadaten, Packages, Validierung, HTTP, SQL, Queues, Tests, Linting und Type Checks. Drei Zeilen sind keine direkten Ersetzungen.

Der größte Teil der Zuordnung ist unspektakulär – und das ist die gute Nachricht. Drei Zeilen sind es nicht:

AufgabePythonTypeScriptWarum es keine direkte Ersetzung ist
ValidierungpydanticzodDas Schema ist die Quelle. Der Typ wird daraus erzeugt, nicht umgekehrt
Type CheckmypyTypeScript (pnpm typecheck)Beide prüfen Quellcode, validieren aber keine Daten, die zur Laufzeit eintreffen
Job Queuecelery + Redisbullmq (Redis-backed Queue) oder SQLPostgres kann eine At-Least-Once-Queue implementieren. Einen Broker benötigst du eventuell nicht

Das begleitende Repository verwendet vier Libraries, die eine Erklärung verdienen.

Hono für die HTTP-Schicht

Express und Fastify sind Node-orientierte Alternativen. Hono verwendet die Web-standardisierten Request- und Response-APIs und bietet Adapter für Node und serverless Runtimes. Diese Portabilität ist für diese kleine Streaming-API nützlich, daher habe ich Hono gewählt.

Drizzle für SQL

Drizzle hält das Schema in TypeScript und benötigt keinen Schritt zur Client-Generierung. Außerdem ist Raw SQL verfügbar, wenn der Query Builder eine Postgres-Klausel nicht sauber ausdrücken kann. Prisma würde ich wählen, wenn der generierte Client und die umgebenden Tools besser zum Team passen.

Biome für Linting und Formatting

Biome übernimmt Linting, Formatting und das Sortieren von Imports mit einem Binary und einer Konfigurationsdatei. Verwende ESLint weiter, wenn das Projekt von Custom Rules abhängt, die Biome nicht bereitstellt.

Vitest für Tests

Vitest führt die .ts-Tests des begleitenden Repositorys ohne separate Transform-Konfiguration aus.


Die weiter unten verwendete TypeScript-Syntax lesen

Halte diese Tabelle während der Arbeit mit den Service-Beispielen als Referenz bereit.

TypeScriptPython / Hinweis
(x) => expressionanonyme Funktion mit Expression-Body, ähnlich wie lambda x: expression
(x) => { statements }anonyme Funktion mit Statement-Body
async (x) => { statements }async anonyme Funktion
const { model, seqLen } = requestextrahiert die Properties model und seqLen aus request
const [first] = xsfirst = xs[0]. Bei leerem Ergebnis liefert es undefined, nicht IndexError
{ type: "error", message }{"type": "error", "message": message}. Ein unqualifizierter Name wird zu diesem Feld
text ${x}f-string
cond ? a : ba if cond else b
const / letBeide binden einen Namen. const verbietet eine erneute Zuweisung, let erlaubt sie
exportmacht einen Namen importierbar
switch / casematch, außer dass Fälle weiterlaufen, wenn sie nicht mit break oder return enden
for awaitIteration über einen async Generator
i++inkrementiert und gibt den alten Wert zurück
/^https?$/ein Regex-Literal, kein re.compile erforderlich
T[], Map<K, V>list[T], dict[K, V]

Verwende const, außer wenn sich das Binding ändern muss. Verwende let für einen Counter, Accumulator oder ein anderes Binding, das du neu zuweisen wirst.

Eine kurze Enum-Übertragung

Für String-basierte Zustände verwendet dieses Repository ein Object plus einen abgeleiteten String-Union-Typ:

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

Das Object stellt Status.Queued während der Laufzeit bereit. Die Zeile type erlaubt beim Type Checking nur "queued" oder "running". Zusammen füllen sie die beiden Rollen dieser Python-Deklaration:

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

Du musst nur das Muster erkennen. as const bewahrt die Werte des Objects als exakte Strings, statt sie auf irgendeinen string-Typ zu erweitern.


Die sieben semantischen Unterschiede, die Zeit kosten

Die Syntax-Tabelle bringt dich durch die Beispiele. Diese semantischen Unterschiede sind die Stellen, an denen Python-Gewohnheiten Bugs verursachen.

1. Leere Arrays und Objects sind truthy

Die Python-Gewohnheit „leerer Container ist falsy“ wird am schlechtesten übertragen. if (results) ist bei einem leeren Array true. Schreibe if (results.length).

2. null und undefined sind unterschiedlich

null kennzeichnet normalerweise bewusstes Fehlen. undefined bedeutet normalerweise, dass ein Wert fehlt oder nicht zugewiesen wurde, obwohl Code ihn explizit setzen kann. Library-Code liefert ständig undefined zurück. Der Unterschied wird relevant, wenn du einen Default schreibst. || ersetzt die rechte Seite immer dann, wenn die linke Seite falsy ist. Dazu gehören 0, "" und false. ?? ersetzt dagegen nur bei null und undefined. Daher ist 0 || 10 gleich 10, während 0 ?? 10 gleich 0 ist. So wird eine Batch-Größe von null stillschweigend zu zehn.

3. Ein Catch-Block erhält unknown

Es gibt kein except ValueError:. Ein catch-Block übernimmt alles. Da JavaScript das Werfen eines Strings, einer Zahl oder von null erlaubt, typisiert TypeScript den gefangenen Wert als unknown – den Typ „könnte buchstäblich alles sein“ unter strict. Das begleitende Repository aktiviert strict; neue Projekte sollten das im Allgemeinen ebenfalls tun. Um den Fehler zu untersuchen, schränke den Wert zuerst ein:

try {
    await risky();
} catch (error) {
    // `error` is `unknown` until you prove otherwise. This line is the
    // TypeScript equivalent of `except ValueError 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 startet die Ausführung ihres Bodys und gibt ein Promise zurück. Ein Python-Coroutine-Objekt tut nichts, bis du es awaitest oder einplanst. Promise.all entspricht ungefähr asyncio.gather. Promise.allSettled entspricht ungefähr gather(..., return_exceptions=True), außer dass jedes Ergebnis als { status, value } oder { status, reason } verpackt wird.

Node übernimmt die Scheduling-Logik der Runtime. Der Prozess bleibt aktiv, solange aktive Handles oder Requests wie Timer und Sockets existieren. Ein gewöhnliches ausstehendes Promise allein hält Node nicht am Leben. Du umschließt das Programm nicht mit asyncio.run. In einem ES Module kannst du auf oberster Ebene await verwenden, wenn der Start auf eine async Operation warten muss.

5. JavaScript besitzt einen gewöhnlichen numerischen Typ

Der JavaScript-Typ number speichert Werte als 64-Bit-Gleitkommazahlen, ungefähr 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 Ganzzahlen bleiben nur bis 2**53 - 1 beziehungsweise 9,007,199,254,740,991 exakt.

Halte 64-Bit-IDs an Service-Grenzen als Strings. Die Konvertierung eines Postgres- bigint in einen JavaScript-number kann zu Rundungsfehlern führen. Für größere exakte Ganzzahlen stellt JavaScript den separaten Typ BigInt bereit, der sich nicht mit gewöhnlichen Zahlen mischen lässt.

6. Verwende Map für ein Python-ähnliches Dictionary

In JavaScript erzeugt {} ein Object. Objects repräsentieren normalerweise Records mit benannten Feldern:

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

Ein Object ist keine saubere Key-Value-Tabelle wie ein Python-dict. Es erbt einige Namen aus JavaScript selbst. Das kann zu einem überraschenden Ergebnis führen:

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 aus einem Object auswählt, rufe vor dem Lesen Object.hasOwn auf. Wenn du ein allgemeines Dictionary benötigst, verwende Map. Map entspricht eher Pythons dict: Ein Key existiert nur, wenn dein Code ihn hinzufügt.

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

7. Die Extension in relativen Imports angeben

Eine JavaScript-Quelldatei, die Code mit anderen Dateien teilt, heißt Module. Dieses Projekt verwendet das moderne Modulformat ES Modules, meist zu ESM verkürzt. ES steht für ECMAScript, den formalen Namen der JavaScript-Sprache. In der Praxis ist ESM die im gesamten Projekt verwendete import- und export-Syntax.

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

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

Imports aus installierten oder Workspace-Packages verwenden weiterhin den Package-Namen ohne Dateiendung:

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

Zod ist pydantic mit umgekehrter Richtung

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

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 Runtime-Schema heraus. z.coerce.number() behandelt die Tatsache, dass jeder definierte Wert in process.env (Nodes os.environ) ein String ist. Das erfüllt dieselbe Rolle wie pydantics Coercion numerischer Settings, auch wenn sich die akzeptierten exakten Strings unterscheiden. Das folgt dem Muster pydantic-settings und läuft einmal beim Startup. Eine ungültige Umgebung erzeugt dann einen verständlichen Startup-Fehler statt eines TypeError innerhalb eines Handlers.

Ein unverändertes z.url() akzeptiert localhost:8000. Der URL-Standard behandelt alles vor dem ersten Doppelpunkt als Scheme. Daher liest er localhost: als Protokoll namens „localhost“ und akzeptiert den String. Der Wert erreicht anschließend den HTTP-Client und schlägt mit weniger Kontext fehl. Schema-Validierung verschiebt Fehler nach vorne, erzwingt aber ein permissives Schema, wenn du genau dieses geschrieben hast.

Zod 4 liefert außerdem z.toJSONSchema mit, sodass dieses Projekt die in älteren Tutorials verbreitete Dependency zod-to-json-schema nicht benötigt. Das ist wichtig, sobald ein Schema drei Consumers versorgen muss – genau darum geht es im Abschnitt „Ein Tool, drei Consumers“.


Der Service

Ein pnpm-Workspace enthält Packages und Apps. Die Packages enthalten Schemas, Agent-Code und Observability. Ein Schema versorgt den handgeschriebenen Loop, das Vercel AI SDK und den MCP-Server. Die Apps enthalten eine Hono-API, einen Worker und einen MCP-Server. API und Worker teilen sich eine Postgres-Runs-Tabelle.

Der Demo-Service dimensioniert LLM-Deployments. Ein Tool schlägt Architekturkonstanten eines Models nach. Das andere schätzt seinen KV-cache-Footprint: den GPU-Speicher, der für Attention-Keys und -Values von In-Flight-Requests benötigt wird. Beide Tools führen absichtlich nur einfache Arithmetik aus. Sie benötigen kein Netzwerk und liefern jedes Mal dasselbe Ergebnis. Dadurch lässt sich der Service ohne API-Key testen. Der KV-cache estimator 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 Packages erhalten einen Scoped Name wie @agent/core, wobei das Präfix @agent/ eine Namenskonvention und kein Sprachfeature ist. Jedes Package deklariert seinen öffentlichen Entry Point in package.json. Diese Package-Grenze hängt nicht davon ab, welcher Befehl die Anwendung startet.

Dieser private Workspace verweist bei diesen Entries auf .ts-Quellcode, weil jeder Consumer Teil desselben Repositorys ist. Öffentliche npm-Packages veröffentlichen normalerweise JavaScript plus .d.ts-Typdeklarationen, sodass gewöhnliche Node-Consumer weder den TypeScript Runner noch das Build-Setup des Package-Autors benötigen.


Den Tool Loop einmal von Hand schreiben

Ein Schritt eines Agent Loops. Das Model streamt Chunks. Der Loop setzt Tool Calls anhand ihres Index zusammen, parst ihr JSON und validiert sie mit Zod. Gültige Calls führen das Tool aus. Ungültige Calls erzeugen einen Fehler, den das Model liest, bevor der Loop wiederholt wird. Der Loop liefert typisierte AgentEvent-Werte. Die HTTP-Route gibt pro Event einen SSE-Frame aus, akkumuliert Text und ruft nach dem Stream den Storage auf. Tests konsumieren den Generator separat.

Tool-Calling-Agent-Frameworks kapseln denselben grundlegenden Loop:

  1. Rufe das Model mit Tool-Definitionen auf.
  2. Validiere die angeforderten Tools und führe sie aus.
  3. Hänge die Ergebnisse an die Messages an.
  4. Rufe das Model erneut auf.

Schreibe diesen Loop einmal selbst. Dann ist das Verhalten eines Frameworks eine Engineering-Entscheidung, die du begründen kannst.

Der Loop ist ein async function*, also ein async Generator – exakt die Form von Pythons async def mit yield. Die HTTP-Route iteriert über diesen Generator, wandelt jedes Event in einen Server-Sent-Events-Frame um und akkumuliert den Text. Nach Ende des Streams ruft die Route storage.createRun einmal mit dem finalen Text auf. Tests rufen den Loop separat auf und sammeln seine Events in einem Array. Ein SSE-Frame ist ein Chunk einer langlebigen HTTP-Response. Der folgende Abschnitt erklärt das Format.

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 lokale Variable text enthält einen Model-Schritt. Der Loop verwendet sie entweder in der Assistant Message für den nächsten Schritt oder im finalen done-Event. Sie ist nicht der Accumulator auf Routenebene, der später persistiert wird.

Die Map partial ist der Teil, den Frameworks verbergen. Das SDK stellt String- Fragmente der Function-Argumente bereit, die das serialisierte JSON an beliebigen Positionen teilen können. Mehrere parallele Calls können sich außerdem verschachteln. Das Repository enthält einen Test, der {"model":"llama-3.1-8b",...} in vier Chunks aufteilt.

Der zweite Teil, den du selbst schreiben solltest, ist das Verhalten bei fehlgeschlagener Validierung:

// `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 verfehlen, JSON.parse kann die Argumente ablehnen oder Zod kann ihre Struktur zurückweisen. Jeder Fehler wird zu einer Message, die das Model liest. Während der Ausführung wird auch ein erwarteter ToolError zu einem Tool Result, damit das Model seinen Call korrigieren kann. Eine unerwartete Exception propagiert dagegen in den HTTP-Error-Pfad, statt als fachlicher Fehler präsentiert zu werden. z.prettifyError verwandelt Zods Issue Tree in eine Message, mit der das Model arbeiten kann, statt in einen Stack Trace.

strict: true in einer OpenAI-Function-Definition fordert den Provider auf, das Decoding auf das Schema zu beschränken. Das hat keinen Bezug zu TypeScripts strict-tsconfig-Flag. Es ähnelt vLLMs Guided Decoding, auch wenn sich unterstützte Schemas und Details der Durchsetzung unterscheiden. Dadurch entfällt ein Fehlerpfad, aber ein Self-Hosted-Endpoint kann das Flag ignorieren. Die Argumente müssen außerdem JSON.parse überstehen.

Der Loop ruft /chat/completions auf, weil das begleitende Repository OpenAI-kompatible Server anvisiert. vLLM, SGLang und Ollama dokumentieren diesen Endpoint, sodass OPENAI_BASE_URL denselben Client auf jeden davon richten kann. Ihr Support für die Responses API unterscheidet sich und ändert sich je nach Release. Wenn du beide Seiten kontrollierst, prüfe vor der Wahl zwischen den APIs die aktuelle Kompatibilitätsseite des Servers.


Anschließend zum AI SDK wechseln – und wissen, was du eintauschst

Für spätere Projekte würde ich das Vercel AI SDK verwenden. Das begleitende Repository implementiert denselben Agent zweimal, damit der Trade-off sichtbar wird. Beide Versionen liefern denselben AgentEvent-Stream, daher kann die HTTP-Schicht sie nicht unterscheiden.

Das begleitende Repository pinnt AI SDK 7.0.42 in packages/agent-core/package.json. 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 entfernt fünf Teile Application Code:

stopWhen akzeptiert mehrere Bedingungen, darunter ein Step-Limit oder einen bestimmten Tool Call. Der for await-Loop ändert sich nicht, wenn sich die Stop-Policy ändert.

Was du aufgibst, ist die direkte Kontrolle über Validierungsfehler. Der handgeschriebene Loop entscheidet, was das Model nach einem abgelehnten Call sieht. In der SDK-Version konfigurierst du dieses Verhalten über repairToolCall. Der Trade-off wirkt auch in die andere Richtung: In der SDK-Version ist ein Provider-Wechsel im Provider-Adapter gekapselt. Das passende Provider-Package, Credentials, Konfiguration und Integrationstests werden trotzdem benötigt. Im Raw Loop sind Provider-spezifisches Request- und Stream-Handling dein eigener Code, den du ändern musst.

Im ersten Projekt schreibe ich den Loop selbst und bei späteren Projekten verwende ich das SDK. Für diese Lektion zahlst du nur einmal. Die Alternative wäre, erstmals die Internals eines Frameworks zu lesen, während es in Production ausfällt.


Streaming über HTTP: Hono und SSE

Hono-Routen sehen wie FastAPI-Routen aus. Die eine Ergänzung ist zValidator: Sie übernimmt die Aufgabe, die FastAPI aus den Type Annotations der Handler-Signatur automatisch erhält. Das c im folgenden Handler ist Hono Request Context – das Object, das FastAPI auf deine Parameter verteilt. deps ist ein Dependency-Bag, mit dem die App konstruiert wird, statt Dependencies direkt zu importieren. runAgent ist eine davon; der Testabschnitt zeigt, was dir das bringt.

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 = "";
        try {
            await withSpan(
                "agent.run",
                { "agent.max_steps": body.maxSteps },
                async () => {
                    for await (const event of deps.runAgent({
                        message: body.message,
                        maxSteps: body.maxSteps,
                    })) {
                        if (event.type === "text") text += event.delta;

                        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: "succeeded",
            input: { message: body.message },
            output: { text },
        });
    });
});

zValidator validiert den Body und gibt c.req.valid("json") den Typ, den das Schema erzeugt. Ohne diese Funktion ist der Body als any typisiert, TypeScripts Opt-out, bei dem jeder Property-Zugriff kompiliert wird und nichts geprüft wird. Damit wird der Type-Safety-Vorteil des Schemas deaktiviert.

Diese Route verwendet Server-Sent Events statt WebSockets. Der Server hält eine HTTP-Response offen, schreibt event: <name>- und data: <json>-Frames und schließt sie nach dem letzten Event. Der Datenverkehr läuft vom Server zum Client, was zu diesem Agent Stream passt. Ein WebSocket würde bidirektionales Messaging und ein Protocol Upgrade hinzufügen, das diese Route nicht benötigt.

Ein Fehler mitten im Stream verändert die HTTP-Fehlerberichterstattung. Sobald der erste Frame mit Status 200 gesendet wurde, kann der Server diese Response nicht mehr durch eine 500 ersetzen. Der catch-Block loggt den gefangenen Fehler, sendet ein konstantes Error Event an den Client und kehrt zurück. Dieses Return ist wichtig: Nur ein erfolgreich abgeschlossener Stream erreicht storage.createRun.

Ein Test deckt diesen Pfad ab. Ein Generator liefert ein Text-Delta und wirft anschließend eine Exception. Die Response bleibt 200, und ihr letzter Frame ist ein error-Event mit der konstanten Message Agent run failed. Der Logger behält den gefangenen Fehler für die serverseitige Diagnose. 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 absichtlich nicht auf Postgres zu. Ein Liveness-Fehler während eines Database Outages könnte jede Replica neu starten, ohne die Dependency zu reparieren. Füge einen separaten Readiness-Check hinzu, wenn der Orchestrator den Traffic von einer Instanz abziehen muss, die Postgres nicht erreichen kann. Die Error Paths loggen den gefangenen Fehler, geben aber einen konstanten String zurück. Wenn du error.message in einen Response Body spiegelst, landen Connection Strings im Browser eines anderen Nutzers.


Der Celery-ähnliche Teil – ohne Celery

Lange Jobs gehören nicht in einen Request Handler. Die API fügt eine Row ein und gibt 202 zurück. Ein Worker übernimmt die Row.

Hier gibt es weder Redis noch BullMQ. PostgreSQL dokumentiert SKIP LOCKED für mehrere Consumer einer Queue-ähnlichen Tabelle. Die Klausel gibt diesem kleinen Service eine At-Least-Once-Queue in einer einzigen Tabelle. Sie ist mit deinen übrigen Writes transaktional, und docker-compose.yml enthält einen Service weniger.

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()` exists but is undocumented; SKIP LOCKED rides in its second
    // argument.
    .for("update", { skipLocked: true });

Die Row wird für die Transaction gesperrt, und ein paralleler Worker mit derselben Query überspringt sie, statt zu blockieren. Zwei simultane Claims erhalten daher nicht dieselbe nicht-veraltete Row. Ein Integrationstest führt über Promise.all zwei Claims gleichzeitig aus und stellt fest, dass sie unterschiedliche Rows zurückgeben. Die naive Variante – SELECT ... LIMIT 1 gefolgt von UPDATE – scheitert an diesem Test: Beide Transactions lesen dieselbe Row, bevor eine von ihnen schreibt, und starten daher denselben Job.

Das ist At-Least-Once Execution, nicht Exactly-Once Execution. Die vollständige Query übernimmt außerdem eine running-Row erneut, wenn ihre Sperre älter als fünf Minuten ist; der Demo-Worker erneuert diese Lease nicht. Ein laufender Job, der länger als fünf Minuten dauert, kann daher zweimal übernommen werden. Mache Jobs idempotent. Füge für lange Arbeiten einen Lease-Heartbeat hinzu oder setze den Stale-Lock-Schwellenwert über die maximale Laufzeit.

Füge BullMQ hinzu, wenn du verzögerte Jobs, wiederholbare Schedules, Prioritäten, Rate Limits oder ein Dashboard benötigst. In Python würde ich denselben Schritt von einer Database Table zu Celery machen. Vorher ist Redis ein weiterer Service, den du betreiben, überwachen und der jeweils diensthabenden Person erklären 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 Testsuite gibt dem Worker außerdem eine Row, deren seqLen ein String ist. Der Worker schlägt den Run fehl und pollt weiter, statt abzustürzen und dieselbe Poison Row endlos erneut zu versuchen.

CPU-Arbeit zeigt eine weitere Node-Einschränkung. Ein synchroner Callback läuft auf dem Event-Loop-Thread und wird nicht preempted. Eine for-Schleife, die zwei Sekunden lang Arithmetik ausführt, blockiert für zwei Sekunden jeden Request, Timer und Liveness-Check dieses Prozesses. Eine enge Schleife innerhalb eines async def blockiert asyncio auf dieselbe Weise. Beide Runtimes verlangen, dass CPU-Arbeit explizit ausgelagert wird.

await setTimeout(0) aus node:timers/promises (das Präfix node: bedeutet Standard Library; node:timers ist für Node, was os für Python ist) ist await asyncio.sleep(0). Der Sweep gibt nach jeder Batch-Größe die Kontrolle zurück, damit der Worker-Prozess Timer und andere Callbacks bedienen kann. Yielding macht CPU-Arbeit nicht parallel. Node worker_threads kann JavaScript parallel ausführen. Für reine Python-CPU-Arbeit in einem üblichen GIL-aktivierten CPython-Build verwendest du statt eines Thread Pools einen Process Pool. Dieser Service nutzt keines von beiden. Umfangreiche numerische Berechnungen gehören nach Python, wo die unterstützenden Libraries bereits vorhanden sind, und müssen vom API Event Loop entkoppelt werden.


Ein Tool, drei Consumers

EstimateKvCacheInput hat drei Consumers:

Diese Wiederverwendung ist der Grund, warum packages/schemas existiert.

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());

Bevor du einen Client verbindest, sind zwei Details wichtig. Erstens verwendet ein auf diese Weise gestarteter Server sein eigenes stdin und stdout zur Kommunikation mit dem Client. Jede Zeile ist eine JSON-RPC-Message. Ein unerwartetes console.log – das JavaScript-Äquivalent zu print – beschädigt dann eine Message. Der Client trennt die Verbindung mit einem Parse-Fehler, der keine Datei nennt. Sende sämtliche Diagnostics stattdessen an stderr.

Zweitens sollte ein fachlicher Fehler isError: true mit einer Message zurückgeben. Das aufrufende Model kann den Call dann korrigieren, genau wie bei ungültigen Tool-Argumenten im Agent Loop.

Du kannst den Server mit printf und einer Pipe ansteuern. Das solltest du einmal tun, 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 entspricht der Protocol-Version aus der README des begleitenden Repositorys. Für einen echten Client solltest du das SDK verwenden, statt JSON-RPC-Messages von Hand zu pflegen.


Einen Agent ohne API-Key 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 ähnelt parametrize, beforeEach stellt Setup pro Test bereit, vi.fn() erzeugt eine Mock Function und describe.skipIf überspringt eine Gruppe bedingt.

Agent Tests hängen von einer Entscheidung ab: runAgent erhält einen OpenAI-Client als Parameter, statt selbst einen zu erzeugen. Der Fake ist ein Object mit einer chat.completions.create-Methode, die ein gescriptetes async 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
        // launder it through `unknown` first. A lie to the compiler, confined
        // to one line in a test file, which is the only place it belongs.
    } as unknown as OpenAI;
}

Die Tests teilen einen JSON-Argument-String über mehrere Chunks auf und verarbeiten zwei Tool Calls in einer Response. Außerdem decken sie 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 deutlich unter einer Sekunde, ohne Netzwerk und ohne Key.

Integrationstests gegen Postgres verwenden describe.skipIf(!process.env.DATABASE_URL), sodass pnpm test bei einem frischen Clone ohne laufendes Postgres funktioniert; in CI werden sie durch Setzen der Variable aktiviert. Das Repository enthält 40 Tests. 36 davon laufen ohne Postgres und ohne API-Key.


Strukturierte Events mit Pino loggen

Pino übernimmt dieselbe Rolle wie structlog: ein JSON-Objekt pro Zeile, Child Logger mit gebundenen Feldern und explizites Redaction. Das begleitende 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 Redaction kann log.info({ req }, "...") einen Authorization-Header in das Log-Backend kopieren.


Application Work mit manuellen Spans tracen

Das begleitende Repository verwendet OpenTelemetry für drei Spans auf Application-Ebene: agent.run, agent.tool und worker.sweep. Automatische HTTP- oder Postgres-Instrumentierung wird nicht installiert. startTracing() in packages/observability/src/tracing.ts erzeugt einen NodeSDK mit einem OTLP-Trace-Exporter. Wenn OTEL_EXPORTER_OTLP_ENDPOINT fehlt, bleibt Tracing deaktiviert.

Die eigentliche Arbeit wird mit withSpan() aus derselben Datei umschlossen:

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

JavaScript besitzt keine Python-ähnliche Context-Manager-Syntax. Hier ist der Callback der Block, den ein Python Context Manager umschließen würde. Der vollständige Helper erfasst außerdem Exceptions und setzt den Span-Status, bevor er die Exception erneut wirft.

Automatische HTTP- und Database-Spans sind ein separates Feature. Sie benötigen die passenden Instrumentation-Packages sowie eine Initialisierung, bevor die instrumentierten Module geladen werden. Füge sie nur hinzu, wenn diese Spans nützlich sind, und folge dann dem OpenTelemetry Node SDK Setup für die exakten Package-Versionen, die du deployest.


Das Monorepo in Docker ausliefern

Verwende das bereitgestellte Dockerfile. Der Container startet die API mit dem tsx-Loader. Beim Deployment musst du keinen TypeScript Runner auswählen oder aufrufen.

Der Build verwendet pnpm fetch, damit Dependency-Downloads bis zu einer Änderung der Lockfile gecacht bleiben. Anschließend wird mit pnpm deploy die API samt Production Dependencies in ein eigenständiges Verzeichnis kopiert. Die Runtime Stage läuft als nicht-privilegierter Benutzer node, und ihr Exec-Form-CMD ermöglicht es der API, SIGTERM direkt für einen Graceful Shutdown zu empfangen.

Warum das Dockerfile tsx lädt

Node 24 kann ein begrenztes TypeScript-Subset ausführen, indem Type Annotations entfernt werden. Es prüft den Code jedoch nicht und führt auch nicht die Transformationen durch, die ein vollständiger TypeScript Runner unterstützt. Die Package-Scripts des Repositorys verbergen dieses Detail. pnpm check führt die separate statische Prüfung aus.

Der Container besitzt eine weitere Einschränkung. pnpm deploy kopiert die Workspace-Packages unter node_modules, und Node verweigert dort absichtlich das Entfernen von TypeScript (Node-TypeScript-Dokumentation). Die erste Version des Images stürzte mit ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING ab. Sie funktioniert. Ich war entmutigt.

Das Dockerfile behebt das Problem, indem es tsx lädt, das diese .ts-Dateien verarbeitet, bevor Node sie ausführt. Ein Team, das in seinem Runtime-Image ausschließlich .js-Dateien verwenden möchte, kann stattdessen einen Compile-Schritt hinzufügen. Das ist ein alternatives Production-Design und kein zusätzlicher Schritt, der zum Ausführen dieses begleitenden Repositorys erforderlich ist.


Ein Drei-Wochen-Pfad

Erfahrene Python-Engineers können Material zu Variablen und Schleifen überspringen. Diese Abfolge konzentriert sich auf die Unterschiede zu Python. Die Build-Spalte ist der Zweck jeder Zeile; die Lektüre unterstützt ihn.

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

Beginne mit den kostenlosen Total-TypeScript-Tutorials. Bezahle erst dann für das fortgeschrittene Material, wenn du mit Library-Grade-Generics und Conditional Types arbeitest. Überspringe jeden „Intro to JavaScript“-Kurs und alles React-ähnliche, sofern das Produkt es nicht erfordert.

Einen Überblick über Production-Konventionen bietet goldbergyoni/nodebestpractices, eine umfassende, von der Community gepflegte Checkliste. Prüfe Hinweise, die sich auf Runtime-Verhalten oder Security auswirken, anhand der aktuellen Node-Dokumentation.


Trade-offs

Numerische Berechnungen in Python behalten

Node eignet sich gut für Orchestration, HTTP Serving und Streaming. Dauerhafte CPU-bound-Berechnungen blockieren seinen Main-Event-Loop-Thread. Belasse vLLM und Training in Python, sofern ein gemessener Workload die Verlagerung nicht rechtfertigt.

Jede Grenze zur Laufzeit validieren

Eine TypeScript-Annotation prüft weder einen HTTP-Body noch eine Environment Variable, ein Model-generiertes Tool-Argument oder eine aus jsonb gelesene Row. Jede Grenze benötigt ein Runtime-Schema.

SDK-Änderungen kapseln

AI SDK 6 ersetzte Experimental_Agent durch ToolLoopAgent und benannte die Agent-Einstellung system in instructions um (AI-SDK-6-Migrationsleitfaden). Das begleitende Repository ruft streamText direkt auf AI SDK 7 auf und stellt seinen eigenen AgentEvent-Stream bereit. Diese Grenze hält die HTTP-Route unverändert, wenn sich der SDK-Code ändert.

Den handgeschriebenen Loop überspringen, wenn die Deadline wichtiger ist

Der einmalige Bau des Loops zeigt dir, welches Verhalten das SDK übernimmt. Wenn du zuerst ausliefern musst und keinen Grund hast, Validierungsfehler anzupassen, starte direkt mit dem SDK.


Die wichtigsten Erkenntnisse

  1. Installiere Node 24 und pnpm. Verwende anschließend die Repository-Scripts: pnpm demo, pnpm dev:api, pnpm dev:worker und pnpm check. Die Scripts verbergen die niedrigeren Runtime- und Type-Checker-Befehle.
  2. Runtime-Validierung ist strukturell notwendig. Zod ist der gewählte Validator dieses Projekts. Statische Typen prüfen weder HTTP-Bodies, Environment Variables, Model-Output noch Database Rows. Mit Zod deklarierst du ein Runtime-Schema und leitest den TypeScript-Typ mit z.infer ab.
  3. Der Stack lässt sich größtenteils sauber abbilden: pnpm für uv, Hono für FastAPI, Drizzle für SQLAlchemy, Vitest für pytest und Biome für Ruff. Drei Zeilen sind keine direkten Ersetzungen: Validierung, Type Checking und Job Queue.
  4. Schreibe einen Agent Loop von Hand, wenn du die verborgenen Pfade verstehen oder anpassen musst: Fragment-Akkumulation, Validierung und Tool-Error-Feedback.
  5. Mache den Loop zu einem async Generator. Die HTTP-Route konsumiert seine AgentEvent-Werte, gibt SSE-Frames aus, akkumuliert den Text und persistiert ihn nach dem Stream. Tests konsumieren den Generator separat, ohne Netzwerk und ohne API-Key.
  6. Postgres kann eine At-Least-Once-Queue bereitstellen. Mache Handler idempotent und erneuere die Lease oder dimensioniere sie über die maximale Laufzeit für lange Jobs. Füge BullMQ hinzu, wenn du Delays, Prioritäten oder Schedules benötigst.
  7. Verwende das bereitgestellte Dockerfile für Production. Es paketiert die ausgewählte App, lädt TypeScript mit tsx, läuft als nicht-root User und leitet Shutdown-Signale an den API-Prozess weiter.

Referenzen

Demo-Repository

Runtime und Sprache

Tooling

Libraries

AI und Agents

Konventionen