Traduction automatique Cet article a été traduit automatiquement depuis la version originale en anglais.

TypeScript pour les ingénieurs ML Python : créer un service d’agent

Voici un guide d’intégration rapide destiné aux ingénieurs Python expérimentés qui doivent déployer des services d’IA en TypeScript et Node. Il s’adresse aux ingénieurs ML, aux data scientists et aux développeurs backend qui n’ont pas besoin d’un cours d’introduction à JavaScript.

J’ai moi-même suivi cette phase d’intégration ces derniers mois, après avoir travaillé en Python et en Java. La plupart des guides que j’ai trouvés commençaient par les bases de la programmation ou par le travail sur le DOM côté front-end. Cet article part des concepts des services Python. À la fin, vous saurez faire correspondre une stack de services Python à TypeScript et reconnaître les habitudes Python qui provoquent des bugs JavaScript. L’exemple fil rouge suit un service d’agent en streaming, du schéma au déploiement.

Résumé : installez Node 24 et pnpm. Utilisez ensuite les commandes pnpm du dépôt. Exécutez pnpm demo pour l’exemple hors ligne, pnpm dev:api et pnpm dev:worker pour le développement, puis pnpm check avant un commit. Vous n’avez pas besoin d’exécuter vous-même node, tsx ou le vérificateur TypeScript. Les scripts du package s’en chargent.

Le service utilise Zod, Hono, Drizzle, Vitest et Biome. Ils couvrent une grande partie des mêmes besoins que pydantic, FastAPI, SQLAlchemy, pytest et Ruff. Gardez les calculs numériques lourds en Python. Utilisez ce service TypeScript pour l’orchestration, HTTP et le streaming.

Tout ce qui est présenté ici se trouve dans le dépôt slavadubrov/typescript-agent-service, le dépôt compagnon publié avec cet article. Il contient une API HTTP, deux versions de la même boucle d’agent, l’historique des exécutions dans Postgres, un worker et un serveur MCP. pnpm install && pnpm demo exécute le chemin d’agent HTTP/SSE hors ligne ainsi que le calcul de balayage du worker, sans clé d’API.

Je couvre uniquement le backend et l’IA. Pas de React. Il n’y a pas non plus de bundler pour navigateur.


Commencez par exécuter le dépôt compagnon

Installez Node 24 et pnpm en suivant les instructions officielles d’installation de pnpm. Clonez ensuite le dépôt compagnon et exécutez :

pnpm install
pnpm demo
pnpm check

pnpm demo teste le gestionnaire HTTP, la boucle d’agent et le flux SSE avec un modèle scripté. Il appelle également le calcul runSweep du worker. Il ne démarre ni le processus du worker ni sa file d’attente en base de données. La démo ne nécessite ni clé d’API, ni base de données, ni Docker. pnpm check exécute le vérificateur de types, le linter, la vérification du formatage et les tests.

Pour exécuter l’API et le worker réels :

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

Ce sont les commandes que j’utilise dans la suite de cet article. Le dépôt place les commandes Node et TypeScript de niveau inférieur derrière des scripts pnpm nommés, un peu comme un projet Python pourrait placer des commandes uv run derrière des cibles make. Ne mélangez pas npm install dans ce dépôt pnpm. Utilisez pnpm install afin que pnpm-lock.yaml reste l’unique lockfile.


Rôle de Node, npm, pnpm, TypeScript et tsx

Des noms similaires masquent des rôles distincts :

Pour ce repository, exécutez les scripts pnpm. Node est le runtime utilisé par ces scripts, et le Dockerfile fourni gère la production.


La stack, mise en correspondance avec Python

Deux colonnes mettent en correspondance chaque préoccupation d’un service d’IA Python avec son équivalent TypeScript. Les lignes couvrent les métadonnées du projet, les packages, la validation, HTTP, SQL, les files d’attente, les tests, le linting et les vérifications de types. Trois lignes ne sont pas des remplacements directs.

La plupart des correspondances sont sans intérêt particulier, ce qui est une bonne nouvelle. Trois lignes ne le sont pas :

PréoccupationPythonTypeScriptPourquoi ce n’est pas un remplacement direct
ValidationpydanticzodLe schéma est la source de vérité. Le type est généré à partir de celui-ci, et non l’inverse
Vérification de typesmypyTypeScript (pnpm typecheck)Les deux vérifient le code source sans valider les données reçues à l’exécution
File d’attente de jobscelery + Redisbullmq (file d’attente basée sur Redis) ou SQLPostgres peut implémenter une file d’attente at-least-once. Vous n’avez peut-être pas besoin d’un broker

Le projet compagnon utilise quatre bibliothèques qui méritent quelques explications.

Hono pour la couche HTTP

Express et Fastify sont des alternatives orientées Node. Hono utilise les API Request et Response standard du Web et fournit des adapters pour Node et les runtimes serverless. Cette portabilité est utile pour cette petite API de streaming ; c’est pourquoi j’ai choisi Hono.

Drizzle pour SQL

Drizzle conserve le schéma en TypeScript et ne nécessite pas d’étape de génération de client. Il permet également d’utiliser du SQL brut lorsque le query builder ne permet pas d’exprimer proprement une clause Postgres. Je choisirais plutôt Prisma lorsque son client généré et son outillage associé conviennent mieux à l’équipe.

Biome pour le linting et le formatage

Biome gère le linting, le formatage et le tri des imports avec un seul binaire et un seul fichier de configuration. Conservez ESLint lorsque le projet dépend de règles personnalisées que Biome ne fournit pas.

Vitest pour les tests

Vitest exécute les tests .ts du companion sans configuration de transformation distincte.


Lire la syntaxe TypeScript utilisée ci-dessous

Conservez ce tableau à côté des exemples de services pour référence.

TypeScriptPython / remarque
(x) => expressionfonction anonyme avec un corps constitué d’une expression, similaire à lambda x: expression
(x) => { statements }fonction anonyme avec un corps constitué d’instructions
async (x) => { statements }fonction anonyme asynchrone
const { model, seqLen } = requestextraire les propriétés model et seqLen de request
const [first] = xsfirst = xs[0]. Renvoie undefined, et non IndexError, lorsqu’il est vide
{ type: "error", message }{"type": "error", "message": message}. Un nom nu devient ce champ
text ${x}f-string
cond ? a : ba if cond else b
const / letLes deux associent un nom. const interdit la réaffectation, tandis que let l’autorise
exportrend un nom importable
switch / casematch, sauf que les cas continuent leur exécution s’ils ne se terminent pas par break ou return
for awaititérer sur un générateur asynchrone
i++incrémente et renvoie l’ancienne valeur
/^https?$/littéral d’expression régulière, sans re.compile nécessaire
T[], Map<K, V>list[T], dict[K, V]

Utilisez const sauf si la liaison doit changer. Utilisez let pour un compteur, un accumulateur ou une autre liaison que vous réaffecterez.

Une courte traduction des enums

Pour les états représentés par des chaînes, ce dépôt utilise un objet ainsi qu’un type d’union de chaînes inféré :

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

L’objet fournit Status.Queued pendant l’exécution du programme. La ligne type n’autorise que "queued" ou "running" lors de la vérification des types. Ensemble, ils remplissent les deux rôles de cette déclaration Python :

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

Il suffit de reconnaître le pattern. as const conserve les valeurs de l’objet comme chaînes exactes, au lieu de les élargir à un quelconque string.


Les sept différences sémantiques qui font perdre du temps

Le tableau de syntaxe permet de parcourir les exemples. Ce sont ces différences sémantiques qui provoquent des bugs lorsqu’on applique des habitudes Python.

1. Les tableaux et objets vides sont truthy

L’habitude Python selon laquelle un « conteneur vide est falsy » est celle qui se transpose le moins bien. if (results) vaut true pour un tableau vide. Écrivez if (results.length).

2. null et undefined sont différents

null signale généralement une absence intentionnelle. undefined signifie généralement qu’une valeur est manquante ou n’a pas été affectée, même si le code peut lui affecter explicitement une valeur. Le code des bibliothèques renvoie constamment undefined. La différence devient problématique lorsqu’on définit une valeur par défaut. || remplace le membre de droite chaque fois que celui de gauche est falsy. Cela inclut 0, "" et false. ?? ne remplace que null et undefined. Ainsi, 0 || 10 vaut 10, tandis que 0 ?? 10 vaut 0. C’est cette différence qui transforme silencieusement une taille de batch de zéro en dix.

3. Un bloc catch reçoit unknown

Il n’existe pas de except ValueError:. Un seul bloc catch intercepte tout. Comme JavaScript permet de lever une chaîne, un nombre ou null, TypeScript type la valeur interceptée comme unknown, le type « cela peut être littéralement n’importe quoi » sous strict. L’option associée active strict, ce qui est généralement recommandé pour les nouveaux projets. Pour inspecter l’erreur, commencez par affiner le type de la valeur :

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. Les Promises démarrent immédiatement

L’appel d’une fonction async commence l’exécution de son corps et renvoie une Promise. Un objet coroutine Python ne fait rien tant qu’il n’est pas attendu ou planifié. Promise.all est proche de asyncio.gather. Promise.allSettled est proche de gather(..., return_exceptions=True), sauf que chaque résultat est encapsulé dans { status, value } ou { status, reason }.

Node gère la planification du runtime. Il maintient le processus actif tant que des handles ou requêtes actifs, tels que des timers et des sockets, existent encore. Une Promise en attente ordinaire ne maintient pas à elle seule Node actif. Il n’est pas nécessaire d’englober le programme dans asyncio.run. Dans un module ES, vous pouvez utiliser await au niveau supérieur lorsque le démarrage doit attendre une opération asynchrone.

5. JavaScript possède un seul type numérique ordinaire

Le type number de JavaScript stocke les valeurs sous forme de nombres à virgule flottante sur 64 bits, à peu près comme le type float de Python. Le standard technique de ce format s’appelle IEEE 754. Les valeurs décimales sont approximatives : 0.1 + 0.2 n’est donc pas exactement égal à 0.3, et les entiers ne restent exacts que jusqu’à 2**53 - 1, soit 9,007,199,254,740,991.

Conservez les identifiants sur 64 bits sous forme de chaînes aux frontières des services. Convertir un bigint de Postgres en number JavaScript peut l’arrondir. Pour les entiers exacts plus grands, JavaScript fournit le type distinct BigInt, qui ne se combine pas avec les nombres ordinaires.

6. Utilisez Map lorsque vous avez besoin d’un dictionnaire à la Python

En JavaScript, {} crée un objet. Les objets représentent généralement des enregistrements avec des champs nommés :

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

Un objet n’est pas une table clé-valeur nette comme un dict Python. Il hérite de certains noms de JavaScript lui-même. Cela peut produire un résultat surprenant :

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

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

Si une chaîne externe sélectionne un champ dans un objet, appelez Object.hasOwn avant de le lire. Si vous avez besoin d’un dictionnaire généraliste, utilisez Map. Map est plus proche du dict de Python : une clé n’existe que lorsque votre code l’ajoute.

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

7. Incluez l’extension dans les imports relatifs

Un fichier source JavaScript qui partage du code avec d’autres fichiers est appelé un module. Ce projet utilise le format de modules moderne, les modules ES, généralement abrégés en ESM. ES signifie ECMAScript, le nom officiel du langage JavaScript. En pratique, ESM correspond à la syntaxe import et export utilisée dans tout le projet.

Pour un import relatif, Node exige le nom de fichier exact. Il ne devinera pas si ./env signifie ./env.ts ou ./env.js :

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

Les imports depuis des packages installés ou des packages du workspace utilisent toujours le nom du package, sans extension de fichier :

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

Zod, c’est pydantic avec la flèche inversée

Avec pydantic, vous déclarez une classe et obtenez un validateur. Avec Zod, vous déclarez un validateur et en déduisez le type. Une même source de vérité unique, mais dans le sens opposé.

Extrait de 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> extrait le type statique du schéma d’exécution. z.coerce.number() gère le fait que chaque valeur définie dans process.env (le os.environ de Node) est une chaîne. Il joue le même rôle que la coercition des paramètres numériques de pydantic, même si les chaînes acceptées diffèrent. Cela suit le pattern pydantic-settings et ne s’exécute qu’une fois au démarrage. Un environnement invalide produit alors une erreur de démarrage lisible, plutôt qu’un TypeError dans un handler.

Un z.url() brut accepte localhost:8000. La norme des URL considère tout ce qui précède le premier deux-points comme le schéma. Elle interprète donc localhost: comme un protocole nommé « localhost » et accepte la chaîne. La valeur parvient ensuite au client HTTP, qui échoue avec moins de contexte. La validation du schéma permet de détecter les erreurs plus tôt, mais elle appliquera un schéma permissif si c’est celui que vous avez écrit.

Zod 4 fournit également z.toJSONSchema ; ce projet n’a donc pas besoin de la dépendance zod-to-json-schema, courante dans les anciens tutoriels. Cela devient important lorsqu’un même schéma doit alimenter trois consommateurs, ce qui est l’objet de la section « Un outil, trois consommateurs » ci-dessous.


Le service

Un workspace pnpm contient des packages et des applications. Les packages contiennent les schémas, le code de l’agent et l’observabilité. Un même schéma alimente la boucle écrite à la main, le Vercel AI SDK et le serveur MCP. Les applications contiennent une API Hono, un worker et un serveur MCP. L’API et le worker partagent une table Postgres des exécutions.

Le service de démonstration dimensionne des déploiements de LLM. Un outil récupère les constantes d’architecture d’un modèle. L’autre estime l’empreinte de son KV-cache : la mémoire GPU utilisée pour conserver les clés et valeurs d’attention des requêtes en cours d’exécution. Les deux outils effectuent volontairement des calculs arithmétiques simples. Ils n’ont besoin d’aucun accès réseau et renvoient toujours le même résultat. Le service peut ainsi être testé sans clé d’API. L’estimateur de KV-cache est aussi publié via le Model Context Protocol (MCP), afin que d’autres clients d’IA puissent l’appeler.

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 est le fichier qui déclare le workspace. Les packages internes reçoivent un nom avec scope, comme @agent/core, où le préfixe @agent/ est une convention de nommage et non une fonctionnalité du langage. Chaque package déclare son point d’entrée public dans package.json. Cette frontière entre packages ne dépend pas de la commande qui démarre l’application.

Ce workspace privé fait pointer ces entrées vers le code source .ts, car tous les consommateurs appartiennent au même dépôt. Les packages npm publics publient généralement du JavaScript accompagné de déclarations de types .d.ts, afin que les consommateurs Node ordinaires n’aient pas besoin du runner TypeScript ni de la configuration de build de l’auteur du package.


Écrire la boucle d’outils à la main, une seule fois

Une étape d’une boucle d’agent. Le modèle diffuse des chunks. La boucle assemble les appels d’outils par index, analyse leur JSON et les valide avec Zod. Les appels valides exécutent l’outil. Les appels invalides produisent une erreur que le modèle lit avant que la boucle ne recommence. La boucle génère des valeurs AgentEvent typées. La route HTTP émet une trame SSE par événement, accumule le texte et appelle le stockage après le stream. Les tests consomment le générateur séparément.

Les frameworks d’agents avec tool calling encapsulent la même boucle élémentaire :

  1. Appeler le modèle avec les définitions des outils.
  2. Valider et exécuter les outils demandés.
  3. Ajouter les résultats aux messages.
  4. Rappeler le modèle.

Écrivez cette boucle une seule fois. Le comportement du framework devient alors un choix d’ingénierie que vous pouvez justifier.

La boucle est un async function*, c’est-à-dire un générateur asynchrone, correspondant exactement à la forme Python de async def avec yield. La route HTTP parcourt ce générateur, transforme chaque événement en trame Server-Sent Events et accumule le texte. Une fois le flux terminé, la route appelle storage.createRun une seule fois avec le texte final. Les tests invoquent la boucle séparément et collectent ses événements dans un tableau. Une trame SSE est un fragment d’une réponse HTTP de longue durée. La section ci-dessous en explique le format.

Depuis 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);
    }
}

La variable locale à la boucle, text, contient une étape du modèle. La boucle l’utilise dans le message de l’assistant pour l’étape suivante ou dans l’événement final done. Il ne s’agit pas de l’accumulateur au niveau de la route, qui sera ensuite persisté.

La map partial correspond à la partie que les frameworks masquent. Le SDK expose des fragments de chaînes correspondant aux arguments de la fonction, qui peuvent découper le JSON sérialisé à des positions arbitraires. Plusieurs appels parallèles peuvent également s’entrelacer. Le dépôt contient un test qui répartit {"model":"llama-3.1-8b",...} sur quatre fragments.

La deuxième chose qu’il vaut la peine d’écrire soi-même est ce qui se passe lorsque la validation échoue :

// `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,
    };
}

Avant l’exécution, la recherche peut ne trouver aucun outil, JSON.parse peut rejeter les arguments, ou Zod peut rejeter leur structure. Chaque échec devient un message que le modèle peut lire. Pendant l’exécution, une ToolError attendue devient également un résultat d’outil afin que le modèle puisse corriger son appel. Une exception inattendue remonte vers le chemin d’erreur HTTP au lieu d’être présentée comme un échec métier. z.prettifyError transforme l’arbre des problèmes de Zod en un message sur lequel le modèle peut agir, plutôt qu’en stack trace.

strict: true dans une définition de fonction OpenAI demande au fournisseur de contraindre le décodage au schéma. Cela n’a aucun rapport avec le flag strict de tsconfig TypeScript. Cette approche ressemble au guided decoding de vLLM, même si les schémas pris en charge et les détails de l’application des contraintes diffèrent. Elle élimine un mode d’échec, mais un endpoint auto-hébergé peut ignorer ce flag. Les arguments doivent également survivre à JSON.parse.

La boucle appelle /chat/completions, car le projet compagnon cible les serveurs compatibles avec OpenAI. vLLM, SGLang et Ollama documentent cet endpoint ; OPENAI_BASE_URL peut donc pointer le même client vers n’importe lequel d’entre eux. Leur couverture de la Responses API diffère et évolue selon les versions. Si vous contrôlez les deux côtés, consultez la page de compatibilité actuelle du serveur avant de choisir entre les deux APIs.


Passez ensuite à l’AI SDK, en sachant ce que vous échangez

Pour les projets à venir, j’utiliserais le Vercel AI SDK. Le projet compagnon implémente le même agent deux fois afin de rendre le compromis visible. Les deux versions émettent le même flux AgentEvent, si bien que la couche HTTP ne peut pas les différencier.

Le projet compagnon verrouille AI SDK 7.0.42 dans packages/agent-core/package.json. Son implémentation pour le framework se trouve dans 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;
        // ...
    }
}

Le SDK supprime cinq éléments de code applicatif :

stopWhen accepte plusieurs conditions, notamment une limite d’étapes ou un appel d’outil spécifique. La boucle for await ne change pas lorsque la politique d’arrêt évolue.

Ce que vous abandonnez, c’est le contrôle direct sur les échecs de validation. La boucle écrite manuellement décide de ce que le modèle voit après le rejet d’un appel. Dans la version avec le SDK, vous configurez ce comportement via repairToolCall. Le compromis fonctionne aussi dans l’autre sens. Dans la version avec le SDK, un changement de provider reste localisé dans l’adaptateur du provider. Il nécessite tout de même le package du provider correspondant, les identifiants, la configuration et les tests d’intégration adéquats. Dans la boucle brute, la gestion des requêtes et des streams propre à chaque provider fait partie du code à modifier.

J’écris la boucle manuellement pour le premier projet, puis j’utilise le SDK pour les suivants. Vous ne payez le prix de cette leçon qu’une fois. L’autre option consiste à découvrir les composants internes d’un framework au moment où il tombe en panne en production.


Streaming sur HTTP : Hono et SSE

Les routes Hono ressemblent aux routes FastAPI. La seule addition est zValidator, qui fait ce que FastAPI obtient gratuitement grâce aux annotations de type de la signature d’un handler. Le c du handler ci-dessous est le contexte de requête de Hono, l’objet que FastAPI répartit entre vos paramètres. deps est un ensemble de dépendances avec lesquelles l’application est construite, plutôt que des dépendances importées directement. runAgent en fait partie, et la section consacrée aux tests montre ce que cela vous apporte.

Depuis 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 valide le body et donne à c.req.valid("json") le type produit par le schéma. Si vous l’omettez, le body est typé any, le mécanisme d’opt-out de TypeScript, où chaque accès à une propriété est compilé sans aucune vérification. Cela désactive le bénéfice de type-safety du schéma.

Cette route utilise des Server-Sent Events plutôt que des WebSockets. Le serveur maintient une réponse HTTP ouverte tout en écrivant des frames event: <name> et data: <json>, puis la ferme après le dernier événement. Le trafic circule du serveur vers le client, ce qui correspond à ce stream d’agent. Un WebSocket ajouterait une communication bidirectionnelle et une mise à niveau de protocole dont cette route n’a pas besoin.

Une erreur survenant au milieu du stream modifie la gestion des erreurs HTTP. Une fois la première frame envoyée avec le statut 200, le serveur ne peut plus remplacer cette réponse par une erreur 500. Le bloc catch journalise l’erreur interceptée, envoie au client un événement d’erreur constant, puis retourne. Ce retour est important : seul un stream terminé avec succès atteint storage.createRun.

Un test couvre ce chemin. Un générateur produit un delta de texte, puis lève une exception. La réponse reste 200, et sa dernière frame est un événement error contenant le message constant Agent run failed. Le logger conserve l’erreur interceptée pour le diagnostic côté serveur. Tout client qui vérifie uniquement le code de statut signale une réussite alors que l’exécution a échoué.

app.ts prend deux décisions secondaires qui méritent d’être expliquées. Il considère /healthz comme un endpoint de liveness, de sorte que cette route ne touche délibérément pas à Postgres. Une défaillance du liveness pendant une panne de la base de données pourrait redémarrer tous les replicas sans réparer la dépendance. Ajoutez un contrôle de readiness distinct lorsque l’orchestrateur doit cesser d’acheminer du trafic vers une instance qui ne peut pas atteindre Postgres. Les chemins d’erreur journalisent l’erreur interceptée, mais renvoient une chaîne constante. Renvoyer error.message dans le corps de la réponse est le meilleur moyen de faire apparaître des chaînes de connexion dans le navigateur de quelqu’un d’autre.


La partie inspirée de Celery, sans Celery

Les tâches longues n’ont pas leur place dans un gestionnaire de requêtes. L’API insère une ligne et renvoie 202. Un worker récupère la ligne.

Il n’y a ici ni Redis ni BullMQ. PostgreSQL documente SKIP LOCKED pour plusieurs consommateurs d’une table faisant office de file. Cette clause fournit à ce petit service une file at-least-once dans une seule table. Elle est transactionnelle avec le reste de vos écritures et ajoute un service de moins à docker-compose.yml.

La requête de prise en charge dans packages/agent-core/src/db/storage.ts est la suivante :

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

La ligne est verrouillée pour la transaction, et tout worker concurrent exécutant la même requête l’ignore au lieu de rester bloqué. Deux prises en charge simultanées ne reçoivent donc pas la même ligne non obsolète. Un test d’intégration lance deux prises en charge simultanément via Promise.all et vérifie qu’elles renvoient des lignes différentes. La version naïve, SELECT ... LIMIT 1 suivie de UPDATE, échoue à ce test : les deux transactions lisent la même ligne avant que l’une ou l’autre n’écrive, et lancent donc toutes deux le même job.

Il s’agit d’une exécution at-least-once, et non d’une exécution exactly-once. La requête complète récupère également une ligne running lorsque son verrou date de plus de cinq minutes, et le worker de démonstration ne renouvelle pas ce lease. Un job actif qui s’exécute pendant plus de cinq minutes peut donc être pris en charge deux fois. Rendez les jobs idempotents. Pour les traitements de longue durée, ajoutez un heartbeat du lease ou définissez le seuil d’obsolescence au-dessus de la durée d’exécution maximale.

Ajoutez BullMQ lorsque vous avez besoin de jobs différés, de planifications répétables, de priorités, de limites de débit ou d’un dashboard. Je ferais le même passage d’une table de base de données à Celery en Python. Auparavant, Redis est un service supplémentaire à exécuter, superviser et expliquer à la personne d’astreinte.

Le worker revalide ce qu’il lit depuis jsonb :

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

La suite de tests fournit également au worker une ligne dont le seqLen est une chaîne. Le worker échoue l’exécution et continue à interroger la file, au lieu de planter et de réessayer indéfiniment la même ligne empoisonnée.

Le traitement CPU met en évidence une autre contrainte de Node. Un callback synchrone s’exécute sur le thread de l’event loop et n’est pas préempté. Une boucle for qui effectue des calculs arithmétiques pendant deux secondes bloque pendant ces deux secondes toutes les requêtes, les temporisateurs et les contrôles de liveness de ce processus. Une boucle serrée dans un async def bloque asyncio de la même manière. Les deux runtimes vous obligent à déporter explicitement le travail CPU.

await setTimeout(0) de node:timers/promises (le préfixe node: indique la bibliothèque standard ; ainsi, node:timers joue pour Node le même rôle que os pour Python) est await asyncio.sleep(0). Le sweep cède la main après chaque taille de batch afin que le processus worker puisse traiter les timers et les autres callbacks. Céder la main ne rend pas le calcul CPU parallèle. Node worker_threads peut exécuter du JavaScript en parallèle. Pour les calculs CPU purs en Python avec le build CPython habituel activant le GIL, utilisez un process pool plutôt qu’un thread pool. Ce service n’utilise ni l’un ni l’autre. Conservez les calculs numériques lourds en Python, là où les bibliothèques nécessaires sont déjà disponibles, et déportez-les hors de l’event loop de l’API.


Un outil, trois consommateurs

EstimateKvCacheInput a trois consommateurs :

C’est la raison d’être de packages/schemas.

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

Deux détails sont importants avant de connecter un client. Premièrement, un serveur démarré de cette manière utilise ses propres stdin et stdout pour communiquer avec le client. Chaque ligne est un message JSON-RPC. Un console.log parasite, l’équivalent JavaScript de print, corrompt alors un message. Le client se déconnecte avec une erreur d’analyse qui n’indique aucun fichier. Envoyez tous les diagnostics vers stderr.

Deuxièmement, une erreur métier doit renvoyer isError: true avec un message. Le modèle appelant peut alors corriger l’appel, comme après des arguments d’outil invalides dans la boucle de l’agent.

Vous pouvez piloter le serveur avec printf et un pipe ; cela vaut la peine de le faire une fois avant de lui connecter un client réel :

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

La sonde correspond à la version du protocole utilisée par le README associé. Pour un client réel, utilisez le SDK plutôt que de gérer manuellement les messages JSON-RPC.


Tester un agent sans clé API

Vitest joue le rôle de pytest, mais sa structure est différente. describe regroupe les tests associés. it et test définissent chacun un cas de test. test.each est proche de parametrize, beforeEach fournit la configuration de chaque test, vi.fn() crée une fonction mock, et describe.skipIf ignore conditionnellement un groupe.

Les tests de l’agent reposent sur une décision importante : runAgent reçoit un client OpenAI en paramètre au lieu d’en construire un. Le fake est un objet doté d’une méthode chat.completions.create qui renvoie un itérable asynchrone scripté :

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;
}

Les tests répartissent une chaîne d’arguments JSON sur plusieurs chunks et gèrent deux tool calls dans une même réponse. Ils couvrent également un batchSize invalide, un JSON mal formé, des noms d’outils inconnus et un modèle qui continue d’appeler des outils jusqu’à ce que maxSteps l’arrête. Le fichier de test s’exécute en bien moins d’une seconde, sans réseau ni clé.

Les tests d’intégration avec Postgres utilisent describe.skipIf(!process.env.DATABASE_URL), de sorte que pnpm test fonctionne sur un clone fraîchement créé sans Postgres en cours d’exécution, tandis que la CI les active en fournissant la variable. Le dépôt contient 40 tests. Trente-six s’exécutent sans Postgres ni clé API.


Journaliser des événements structurés avec Pino

Pino remplit le même rôle que structlog : un objet JSON par ligne, des loggers enfants avec des champs liés et une redaction explicite. Le companion le configure dans packages/observability/src/logger.ts :

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

Sans redaction, log.info({ req }, "...") peut copier un en-tête Authorization dans le backend de logs.


Tracer le travail applicatif avec des spans manuels

Le companion utilise OpenTelemetry pour trois spans au niveau applicatif : agent.run, agent.tool et worker.sweep. Il n’installe pas d’instrumentation automatique pour HTTP ou Postgres. startTracing() dans packages/observability/src/tracing.ts crée un NodeSDK avec un exporteur de traces OTLP. Si OTEL_EXPORTER_OTLP_ENDPOINT est absent, le tracing reste désactivé.

Le travail lui-même est encapsulé par withSpan() dans le même fichier :

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

JavaScript ne dispose pas d’une syntaxe de gestionnaire de contexte comme Python. Ici, le callback correspond au bloc qu’entourerait un gestionnaire de contexte Python. Le helper complet enregistre également les exceptions et définit le statut du span avant de relancer l’exception.

Les spans automatiques HTTP et base de données constituent une fonctionnalité distincte. Ils nécessitent les packages d’instrumentation correspondants ainsi qu’une initialisation avant le chargement des modules instrumentés. Ajoutez-les uniquement lorsque ces spans sont utiles, puis suivez la configuration de l’OpenTelemetry Node SDK pour connaître les versions exactes des packages déployées.


Déployer le monorepo avec Docker

Utilisez le Dockerfile fourni. Le conteneur démarre l’API avec le loader tsx. Vous n’avez pas besoin de choisir ou d’invoquer un runner TypeScript lors du déploiement.

Le build utilise pnpm fetch afin que les téléchargements de dépendances restent en cache jusqu’à la modification du lockfile. Il utilise ensuite pnpm deploy pour copier l’API et ses dépendances de production dans un répertoire autonome. Le stage d’exécution s’exécute sous l’utilisateur non privilégié node, et son CMD en forme exec permet à l’API de recevoir SIGTERM directement pour un arrêt propre.

Pourquoi le Dockerfile charge tsx

Node 24 peut exécuter un sous-ensemble limité de TypeScript en supprimant les annotations de type. Il ne vérifie pas les types et n’effectue pas les transformations prises en charge par un runner TypeScript complet. Les scripts du repository masquent ce détail. pnpm check effectue la vérification statique séparément.

Le conteneur expose une autre limite. pnpm deploy copie les packages du workspace sous node_modules, et Node refuse délibérément d’y supprimer TypeScript (documentation TypeScript de Node). La première version de l’image plantait avec ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Ça fonctionne. J’étais découragé.

Le Dockerfile corrige le problème en chargeant tsx, qui traite ces fichiers .ts avant leur exécution par Node. Une équipe qui souhaite uniquement des fichiers .js dans son image d’exécution peut ajouter une étape de compilation. Il s’agit d’une autre conception de production, et non d’une étape supplémentaire nécessaire pour exécuter ce companion.


Un parcours de trois semaines

Les ingénieurs Python expérimentés peuvent ignorer les sections consacrées aux variables et aux boucles. Cette séquence se concentre sur les aspects qui diffèrent de Python. La colonne « Build » indique l’objectif de chaque ligne. Les lectures servent à le préparer.

SemaineÀ lireÀ construire
1javascript.info : uniquement les modules, les promises et les objets. Gardez le guide JS de MDN comme référence.Réécrire un CLI Python en TypeScript. Ajouter un script package.json. Exécuter le script et pnpm typecheck.
1-2Lire la référence tsconfig de TypeScript, les tutoriels gratuits de Total TypeScript et la documentation de Zod.Construire un module de configuration validé par Zod et une tagged union. Après avoir vérifié le tag, le compilateur sait quelle variante contient le bloc.
2Lire la documentation de Hono, Drizzle, Vitest et Biome.Construire un proxy de streaming vers un endpoint compatible avec l’API OpenAI, avec un journal géré par Drizzle.
3Lire la documentation de l’AI SDK et du MCP TypeScript SDK.Construire un agent avec tool calling. Puis construire un serveur MCP qui expose l’un de ses outils.

Commencez par les tutoriels gratuits de Total TypeScript. Ne payez pour le contenu avancé que lorsque vous travaillez avec des generics et des conditional types de niveau bibliothèque. Ignorez tous les cours d’« introduction à JavaScript » ainsi que tout ce qui est orienté React, sauf si le produit l’exige.

Pour avoir une vue d’ensemble des conventions de production, goldbergyoni/nodebestpractices propose une checklist communautaire complète et maintenue. Vérifiez les recommandations qui affectent le comportement à l’exécution ou la sécurité dans la documentation actuelle de Node.


Compromis

Garder les traitements numériques en Python

Node convient bien à l’orchestration, au service HTTP et au streaming. Des calculs intensifs en CPU exécutés de manière prolongée bloquent son thread principal de boucle d’événements. Gardez vLLM et le code d’entraînement en Python, sauf si des mesures sur la charge justifient leur migration.

Valider chaque frontière à l’exécution

Une annotation TypeScript ne valide pas un corps HTTP, une variable d’environnement, un argument d’outil généré par un modèle ou une ligne lue depuis jsonb. Chaque frontière nécessite un schéma d’exécution.

Isoler les évolutions du SDK

AI SDK 6 a remplacé Experimental_Agent par ToolLoopAgent et renommé le paramètre d’agent system en instructions (guide de migration d’AI SDK 6). La fonction compagnon appelle directement streamText dans AI SDK 7 et expose son propre flux AgentEvent. Cette frontière permet de conserver la route HTTP inchangée lorsque le code du SDK évolue.

Éviter la boucle écrite à la main lorsque le délai est prioritaire

Écrire la boucle une fois permet de comprendre quels comportements sont pris en charge par le SDK. Si vous devez livrer rapidement et n’avez aucune raison de personnaliser les échecs de validation, commencez par le SDK.


Points clés

  1. Installez Node 24 et pnpm. Utilisez ensuite les scripts du dépôt : pnpm demo, pnpm dev:api, pnpm dev:worker et pnpm check. Ces scripts masquent les commandes de bas niveau du runtime et du vérificateur de types.
  2. La validation à l’exécution est structurellement nécessaire. Zod est le validateur choisi pour ce projet. Les types statiques n’inspectent ni les corps HTTP, ni les variables d’environnement, ni la sortie du modèle, ni les lignes de la base de données. Avec Zod, déclarez un schéma d’exécution et dérivez le type TypeScript avec z.infer.
  3. La correspondance entre les composants est globalement claire : pnpm pour uv, Hono pour FastAPI, Drizzle pour SQLAlchemy, Vitest pour pytest et Biome pour Ruff. Trois lignes ne sont pas de simples équivalences : la validation, la vérification des types et la file d’attente de tâches.
  4. Écrivez une boucle d’agent à la main si vous devez comprendre ou personnaliser les chemins implicites : accumulation des fragments, validation et retour d’erreur des outils.
  5. Faites de la boucle un générateur asynchrone. La route HTTP consomme ses valeurs AgentEvent, émet des trames SSE, accumule le texte et le persiste après le flux. Les tests consomment le générateur séparément, sans réseau ni clé d’API.
  6. Postgres peut fournir une file d’attente « au moins une fois ». Rendez les gestionnaires idempotents et renouvelez le bail ou définissez sa durée au-delà du temps d’exécution maximal pour les tâches longues. Ajoutez BullMQ lorsque vous avez besoin de délais, de priorités ou de planifications.
  7. Utilisez le Dockerfile fourni pour la production. Il empaquette l’application sélectionnée, charge TypeScript avec tsx, s’exécute avec un utilisateur non root et transmet les signaux d’arrêt au processus de l’API.

Références

Dépôt de démonstration

Runtime et langage

Outillage

Bibliothèques

IA et agents

Conventions