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 :
- JavaScript est le langage.
- Node.js est le runtime, l’équivalent approximatif de CPython pour JavaScript. Installez Node 24 pour ce projet.
- Le npm registry est l’index des packages, l’équivalent approximatif de PyPI. La commande
npmest fournie avec Node et permet d’installer des packages depuis cet index. - pnpm est le package manager choisi par ce repository. Il installe depuis le npm registry, gère le workspace du monorepo et exécute les commandes déclarées
dans
package.json. package.jsonest le manifeste du projet, l’équivalent le plus proche depyproject.toml. Sa sectionscriptsassocie des noms tels quedemo,checketdev:apià des commandes plus longues.- TypeScript est JavaScript avec des types statiques. Sa commande
tscvérifie ces types. Le repository l’exécute viapnpm checkoupnpm typecheck. - tsx exécute les fichiers
.tssans build séparé. Les scripts de développement utilisent son modewatchpour redémarrer l’API ou le worker après une modification du code source. Vous ne l’invoquez pas directement dans ce guide.
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
La plupart des correspondances sont sans intérêt particulier, ce qui est une bonne nouvelle. Trois lignes ne le sont pas :
| Préoccupation | Python | TypeScript | Pourquoi ce n’est pas un remplacement direct |
|---|---|---|---|
| Validation | pydantic | zod | Le 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 types | mypy | TypeScript (pnpm typecheck) | Les deux vérifient le code source sans valider les données reçues à l’exécution |
| File d’attente de jobs | celery + Redis | bullmq (file d’attente basée sur Redis) ou SQL | Postgres 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.
| TypeScript | Python / remarque |
|---|---|
(x) => expression | fonction 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 } = request | extraire les propriétés model et seqLen de request |
const [first] = xs | first = 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 : b | a if cond else b |
const / let | Les deux associent un nom. const interdit la réaffectation, tandis que let l’autorise |
export | rend un nom importable |
switch / case | match, sauf que les cas continuent leur exécution s’ils ne se terminent pas par break ou return |
for await | ité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
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
Les frameworks d’agents avec tool calling encapsulent la même boucle élémentaire :
- Appeler le modèle avec les définitions des outils.
- Valider et exécuter les outils demandés.
- Ajouter les résultats aux messages.
- 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 :
- l’accumulateur de fragments
JSON.parseet son chemin d’erreur- l’appel qui exécute Zod sur les arguments analysés
- l’assemblage des messages spécifique au fournisseur
- le compteur d’étapes
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 :
- La boucle écrite manuellement le convertit avec
z.toJSONSchema. - L’AI SDK le reçoit sans modification.
- Le serveur MCP en publie la structure.
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 |
|---|---|---|
| 1 | javascript.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-2 | Lire 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. |
| 2 | Lire 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. |
| 3 | Lire 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
- Installez Node 24 et pnpm. Utilisez ensuite les scripts du dépôt :
pnpm demo,pnpm dev:api,pnpm dev:workeretpnpm check. Ces scripts masquent les commandes de bas niveau du runtime et du vérificateur de types. - 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. - 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.
- É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.
- 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. - 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.
- 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
- slavadubrov/typescript-agent-service - le monorepo utilisé tout au long de cet article : API Hono avec SSE, deux implémentations de boucle d’agent, stockage Drizzle, worker, serveur MCP et 40 tests
Runtime et langage
- Running TypeScript natively in Node.js - la prise en charge limitée de TypeScript par Node et sa restriction
node_modules - TypeScript compiler options -
strictet les autres vérifications configurées par le projet compagnon - MDN JavaScript Guide - la référence du langage à garder ouverte
- javascript.info - tutoriel moderne sur JavaScript. Lisez les chapitres sur les modules et les promesses.
Outillage
- pnpm et installation de pnpm - gestionnaire de packages, workspaces et configuration
- Biome - linting, formatage et tri des imports dans un seul binaire
- Vitest - test runner ne nécessitant aucune configuration de transformation
- Total TypeScript - tutoriels gratuits et parcours payant consacré aux types avancés
Bibliothèques
- Zod - validation de schémas et inférence de types. La version 4 inclut
z.toJSONSchema. - Hono - framework HTTP conforme aux standards du Web
- Drizzle ORM - ORM TypeScript SQL-first avec des migrations
drizzle-kit - Documentation PostgreSQL sur SELECT - la clause de verrouillage
FOR UPDATE ... SKIP LOCKED - BullMQ - file d’attente basée sur Redis lorsque la table de la base de données ne suffit plus
IA et agents
- Vercel AI SDK -
streamText,tool,stopWhenet adaptateurs de providers - openai/openai-node - client TypeScript officiel
- MCP TypeScript SDK et la spécification MCP - création de serveurs et de clients
Conventions
- goldbergyoni/nodebestpractices - checklist maintenue par la communauté pour les conventions de production