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:
- JavaScript ist die Sprache.
- Node.js ist die Runtime, ungefähr CPython für JavaScript. Installiere für dieses Projekt Node 24.
- Die npm Registry ist der Package Index, ungefähr PyPI. Der Befehl
npmwird mit Node installiert und kann Packages aus dieser Registry installieren. - pnpm ist der Package Manager, für den sich dieses Repository entschieden hat. Er installiert aus der npm Registry, verwaltet den Monorepo-Workspace und führt die in
package.jsondefinierten Befehle aus. package.jsonist das Projektmanifest und entspricht am ehestenpyproject.toml. Sein Abschnittscriptsgibt längeren Befehlen Namen wiedemo,checkunddev:api.- TypeScript ist JavaScript mit statischen Typen. Sein Befehl
tscprüft diese Typen. Das Repository führt ihn überpnpm checkoderpnpm typecheckaus. - tsx führt
.ts-Dateien ohne separaten Build aus. Die Development-Scripts verwenden den Moduswatch, um API oder Worker nach einer Quellcodeänderung neu zu starten. Du rufst ihn in diesem Leitfaden nicht direkt auf.
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
Der größte Teil der Zuordnung ist unspektakulär – und das ist die gute Nachricht. Drei Zeilen sind es nicht:
| Aufgabe | Python | TypeScript | Warum es keine direkte Ersetzung ist |
|---|---|---|---|
| Validierung | pydantic | zod | Das Schema ist die Quelle. Der Typ wird daraus erzeugt, nicht umgekehrt |
| Type Check | mypy | TypeScript (pnpm typecheck) | Beide prüfen Quellcode, validieren aber keine Daten, die zur Laufzeit eintreffen |
| Job Queue | celery + Redis | bullmq (Redis-backed Queue) oder SQL | Postgres 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.
| TypeScript | Python / Hinweis |
|---|---|
(x) => expression | anonyme 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 } = request | extrahiert die Properties model und seqLen aus request |
const [first] = xs | first = 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 : b | a if cond else b |
const / let | Beide binden einen Namen. const verbietet eine erneute Zuweisung, let erlaubt sie |
export | macht einen Namen importierbar |
switch / case | match, außer dass Fälle weiterlaufen, wenn sie nicht mit break oder return enden |
for await | Iteration ü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
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
Tool-Calling-Agent-Frameworks kapseln denselben grundlegenden Loop:
- Rufe das Model mit Tool-Definitionen auf.
- Validiere die angeforderten Tools und führe sie aus.
- Hänge die Ergebnisse an die Messages an.
- 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:
- den Fragment-Accumulator
JSON.parseund seinen Error-Pfad- den Aufruf, der Zod auf die geparsten Argumente anwendet
- Provider-spezifisches Message-Assembling
- den Step-Counter
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:
- Der handgeschriebene Loop konvertiert es mit
z.toJSONSchema. - Das AI SDK erhält es unverändert.
- Der MCP Server veröffentlicht seine Struktur.
Diese Wiederverwendung ist der Grund, warum packages/schemas existiert.
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.
| Woche | Lesen | Bauen |
|---|---|---|
| 1 | javascript.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–2 | Lies 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. |
| 2 | Lies die Dokumentation von Hono, Drizzle, Vitest und Biome. | Baue einen Streaming-Proxy zu einem OpenAI-kompatiblen Endpoint mit einem Drizzle-basierten Log. |
| 3 | Lies 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
- Installiere Node 24 und pnpm. Verwende anschließend die Repository-Scripts:
pnpm demo,pnpm dev:api,pnpm dev:workerundpnpm check. Die Scripts verbergen die niedrigeren Runtime- und Type-Checker-Befehle. - 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.inferab. - 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.
- Schreibe einen Agent Loop von Hand, wenn du die verborgenen Pfade verstehen oder anpassen musst: Fragment-Akkumulation, Validierung und Tool-Error-Feedback.
- 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. - 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.
- 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
- slavadubrov/typescript-agent-service – das im gesamten Artikel verwendete Monorepo: Hono-API mit SSE, zwei Agent-Loop-Implementierungen, Drizzle-Storage, Worker, MCP-Server und 40 Tests
Runtime und Sprache
- Running TypeScript natively in Node.js – Nodes eingeschränkter TypeScript-Support und seine
node_modules-Einschränkung - TypeScript compiler options –
strictund die übrigen vom begleitenden Repository konfigurierten Checks - MDN JavaScript Guide – die Sprachreferenz, die du geöffnet halten solltest
- javascript.info – modernes JavaScript-Tutorial. Lies die Kapitel zu Modulen und Promises.
Tooling
- pnpm und pnpm installation – Package Manager, Workspaces und Setup
- Biome – Linting, Formatting und Import-Sortierung in einem Binary
- Vitest – Test Runner ohne Transform-Konfiguration
- Total TypeScript – kostenlose Tutorials plus kostenpflichtiger Kurs für fortgeschrittene Typen
Libraries
- Zod – Schema-Validierung und Type Inference. Version 4 enthält
z.toJSONSchema. - Hono – Web-standardisiertes HTTP-Framework
- Drizzle ORM – SQL-first TypeScript ORM mit
drizzle-kit-Migrations - PostgreSQL SELECT documentation – die
FOR UPDATE ... SKIP LOCKED-Locking-Klausel - BullMQ – Redis-backed Queue, wenn eine Database Table nicht ausreicht
AI und Agents
- Vercel AI SDK –
streamText,tool,stopWhenund Provider-Adapter - openai/openai-node – der offizielle TypeScript-Client
- MCP TypeScript SDK und die MCP-Spezifikation – Server und Clients bauen
Konventionen
- goldbergyoni/nodebestpractices – von der Community gepflegte Checkliste für Production-Konventionen