TypeScript pour les ingénieurs ML Python : créer un service agentic
Traduction automatique Cet article a été traduit automatiquement depuis la version originale en anglais.
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 avec 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’initiation à JavaScript.
J’ai moi-même suivi cette intégration au cours des derniers mois, après avoir travaillé en Python et en Java. La plupart des guides que j’ai trouvés commençaient par la programmation de base ou le développement DOM côté frontend. Cet article part des concepts des services Python. À la fin, vous saurez faire correspondre une stack de service 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 sweep 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 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 handler 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 de tâches 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’utiliserai dans la suite de cet article. Le dépôt regroupe les
commandes Node et TypeScript de bas niveau derrière des scripts pnpm nommés,
un peu comme un projet Python peut regrouper les commandes uv run derrière des
cibles make. N’intégrez 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
La similarité des noms masque des rôles distincts :
- JavaScript est le langage.
- Node.js est le runtime, comparable à CPython pour JavaScript. Installez Node 24 pour ce projet.
- Le registre npm est l’index des paquets, comparable à PyPI. La commande
npmest fournie avec Node et permet d’installer des paquets depuis ce registre. - pnpm est le gestionnaire de paquets choisi par ce dépôt. Il installe les paquets depuis le
registre npm, gère l’espace de travail 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 dépôt 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 dépôt, exécutez les scripts pnpm. Node est le runtime utilisé par ces
scripts, et le Dockerfile fourni gère la production.
La stack, transposée depuis Python
La plupart des correspondances sont triviales, ce qui est une bonne nouvelle. Trois lignes ne le sont pas :
| Responsabilité | Python | TypeScript | Pourquoi ce n’est pas une substitution directe |
|---|---|---|---|
| Validation | pydantic | zod | Le schéma est la source de vérité. Le type en est généré, et non l’inverse |
| Vérification des types | mypy | TypeScript (pnpm typecheck) | Les deux vérifient le code source sans valider les données reçues au runtime |
| File de tâches | celery + Redis | bullmq (file adossée à Redis) ou SQL | Postgres peut implémenter une file « au moins une fois ». Un broker n’est peut-être pas nécessaire |
Le projet compagnon utilise quatre bibliothèques qui méritent quelques explications.
Hono pour la couche HTTP
Express et Fastify sont des alternatives centrées sur Node. Hono utilise les API
Request et Response standard du Web et
fournit des adaptateurs 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 peut pas 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
Gardez ce tableau à côté des exemples de services comme 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 | extrait les propriétés model et seqLen de request |
const [first] = xs | first = xs[0]. Produit 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 lient un nom. const interdit une nouvelle affectation, tandis que let l’autorise |
export | rend un nom importable |
switch / case | match, sauf que l’exécution passe au cas suivant si les branches ne se terminent pas par break ou return |
for await | itération sur un générateur asynchrone |
i++ | incrémente puis 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 brève traduction des enums
Pour les états représentés par des chaînes, ce dépôt utilise un objet associé à 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, ces éléments remplissent
les deux rôles de cette déclaration Python :
from enum import Enum
class Status(str, Enum):
QUEUED = "queued"
RUNNING = "running"
Il suffit de reconnaître le pattern. as const conserve les valeurs de l’objet sous forme
de chaînes exactes, au lieu de les élargir à un quelconque string.
Les sept différences sémantiques qui font perdre du temps
!!! byte « Byte says »
J’ai transposé directement une valeur par défaut `or` de Python vers `||`. Une taille de batch configurée à `0` est devenue `10`, et l’exécution semblait parfaitement normale. `??` est celui qui n’intercepte que `null` et `undefined`.
Le tableau de syntaxe permet de suivre les exemples. Ce sont ces différences sémantiques qui transforment les habitudes Python en bugs.
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) est true pour un tableau vide. Écrivez
if (results.length).
2. null et undefined sont différents
null indique généralement une absence délibérée. undefined signifie généralement qu’une
valeur est manquante ou non assignée, même si le code peut lui attribuer une valeur explicitement. 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 le membre 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 ainsi
qu’une taille de batch nulle devient silencieusement dix.
3. Un bloc catch reçoit unknown
Il n’existe pas de except ValueError:. Un seul bloc catch reçoit tout. Comme
JavaScript permet de lever une chaîne, un nombre ou null, TypeScript type la valeur capturée comme unknown, son
type « littéralement n’importe quoi » avec strict. Le compagnon active
strict, ce qui est généralement recommandé dans les nouveaux projets. Pour inspecter l’erreur, réduisez d’abord 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 à exécuter son corps et renvoie une promise. Un
objet coroutine Python ne fait rien tant qu’il n’est pas awaité 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 au runtime. Il maintient le processus en vie tant que des handles ou
des requêtes actifs, comme des timers et des sockets, existent encore. Il n’est pas nécessaire d’encapsuler 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 ne possède qu’un 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 float de Python. La norme 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 mélange 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 propre comme un dict Python. Il hérite de certains
noms de JavaScript lui-même, ce qui 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 d’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 de 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 dans l’autre sens
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 un 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. Cela joue le même rôle que la coercition
des paramètres numériques de pydantic, même si les chaînes exactes acceptées diffèrent. Cela suit le
pattern pydantic-settings et s’exécute une seule fois au démarrage. Un environnement
invalide produit alors une erreur de démarrage lisible plutôt qu’une TypeError dans un
handler.
Un z.url() nu accepte que 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 et échoue avec moins de contexte. La validation du schéma fait
remonter les échecs plus tôt, mais elle appliquera un schéma permissif si c’est
celui que vous avez défini.
Zod 4 fournit également z.toJSONSchema, ce qui évite à ce projet 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 qu’explique la section « Un outil, trois consommateurs » ci-dessous.
Le service
Le service de démonstration dimensionne des déploiements de LLM. Un outil recherche 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. Ces 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 scopé tel que @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é pointe ces entrées vers le code source .ts, car tous les
consommateurs appartiennent au même dépôt. Les packages npm publics publient
normalement du JavaScript ainsi que des 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 qui utilisent le tool calling encapsulent tous la même boucle de base :
- 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, dont la forme exacte
correspond à celle de async def en Python, avec yield. La route HTTP parcourt
ce générateur, transforme chaque événement en frame 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 rassemblent ses
événements dans un tableau. Une frame SSE est un fragment d’une réponse HTTP de
longue durée. La section suivante explique ce format.
La boucle comporte deux sorties normales. Une réponse sans appel d’outil produit done avec
stopReason: "stop". Si le modèle continue à demander des outils jusqu’à maxSteps,
le générateur termine tout de même et produit done avec stopReason: "max_steps".
Considérez ce résultat comme terminé mais tronqué : le texte accumulé peut être
partiel.
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 du niveau de routage qui sera ensuite
persisté.
La map partial est la partie que les frameworks dissimulent. Le SDK expose des fragments
de chaînes correspondant aux arguments de 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.
Le deuxième élément qu’il vaut la peine d’écrire soi-même est le comportement en cas d’échec de la validation :
// `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 lu par le
modèle. Pendant l’exécution, une exception attendue ToolError 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 d’erreurs de Zod en un message que le modèle peut exploiter,
plutôt qu’en une 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
l’option strict de TypeScript dans tsconfig. Cette approche ressemble au guided decoding
de vLLM, même si les schémas pris en charge et les détails d’application diffèrent. Elle élimine
un mode d’échec, mais un endpoint auto-hébergé peut ignorer cette option. Les arguments doivent
également passer JSON.parse.
La boucle appelle /chat/completions, car le projet compagnon cible des 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. La
prise en charge de l’API Responses diffère selon les serveurs et évolue au fil des versions. Si
vous contrôlez les deux côtés, consultez la page de compatibilité actuelle du serveur avant de
choisir entre les deux API.
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 ; la couche HTTP ne peut donc pas les distinguer.
Le projet compagnon verrouille AI SDK 7.0.42 dans
packages/agent-core/package.json.
Son implémentation du 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 stratégie d’arrêt évolue.
Ce que vous perdez, c’est le contrôle direct sur l’échec de validation. La boucle écrite manuellement décide de ce que le modèle reçoit après un appel rejeté. Le SDK expose l’option expérimentale experimental_repairToolCall pour fournir un callback de correction personnalisé. Le companion actuel ne définit pas cette option et s’appuie donc sur la gestion par défaut des appels invalides du SDK.
Le compromis fonctionne aussi dans l’autre sens. Avec le SDK, un changement de provider se limite à l’adaptateur du provider. Il faut néanmoins le package du provider correspondant, les identifiants, la configuration et les tests d’intégration associés. Dans la boucle bas niveau, la gestion des requêtes et des streams spécifique au provider fait partie du code à modifier.
J’écris la boucle manuellement sur le premier projet, puis j’utilise le SDK sur les suivants. Vous ne payez le prix de cette leçon qu’une seule fois. L’alternative consiste à découvrir pour la première fois les internals d’un framework pendant qu’il tombe en panne en production.
Streaming HTTP : Hono et SSE
Les routes Hono ressemblent aux routes FastAPI. La seule addition est zValidator, qui fournit 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, au lieu de les importer directement. runAgent en fait partie, et la section consacrée aux tests montre ce que cela permet.
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 },
});
});
});
La route traite actuellement de la même manière tout générateur qui se termine normalement. Elle persiste status: "succeeded" après tout générateur arrivé à son terme, y compris celui dont l’événement final possède stopReason: "max_steps". Le résultat plafond devient ainsi terminé mais tronqué ou partiel, plutôt qu’un échec déjà persisté. En production, le code devrait inspecter l’événement done et appliquer une stratégie explicite. Il pourrait, par exemple, utiliser un statut distinct pour les résultats tronqués ou passer par un workflow de revue/réessai avant de signaler le succès.
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, dans lequel tous les accès aux propriétés sont compilés sans aucun contrôle. Cela désactive l’avantage de type safety apporté par le schéma.
Cette route utilise des Server-Sent Events plutôt que des WebSockets. Le serveur maintient une réponse HTTP ouverte pendant qu’il écrit les trames event: <name> et data: <json>, puis la ferme après l’événement final. 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 du protocole dont cette route n’a pas besoin.
Une erreur en cours de stream modifie la gestion des erreurs HTTP. Une fois la première trame envoyée avec le statut 200, le serveur ne peut plus remplacer cette réponse par une 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 générateur qui émet done, y compris max_steps, constitue un stream terminé normalement et atteint bien cette écriture.
Un test couvre ce chemin. Un générateur produit un delta de texte, puis lève une exception. La réponse reste en 200, et sa dernière trame est un événement error dont le message constant est Agent run failed. Le logger conserve l’erreur interceptée pour le diagnostic côté serveur. Tout client qui vérifie uniquement le code d’état signale comme réussi un run qui a pourtant échoué.
app.ts prend deux décisions secondaires qui méritent d’être expliquées. Il traite /healthz comme un endpoint de liveness ; cette route ne touche donc délibérément pas à Postgres. Une défaillance du liveness pendant une panne de la base de données pourrait redémarrer chaque replica 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 joindre 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 voir des chaînes de connexion finir dans le navigateur de quelqu’un d’autre.
La partie inspirée de Celery, sans Celery
Les traitements longs n’ont pas leur place dans un gestionnaire de requêtes. L’API insère une ligne et renvoie 202. Un worker s’approprie ensuite cette ligne.
Il n’y a ici ni Redis ni BullMQ. PostgreSQL documente SKIP LOCKED pour les consommateurs multiples d’une table jouant le rôle d’une 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 retire un service de plus de docker-compose.yml.
La requête de claim 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 la saute au lieu de rester bloqué. Deux claims simultanés ne récupèrent donc pas la même ligne non obsolète. Un test d’intégration lance deux claims simultanément via Promise.all et vérifie qu’ils 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 ; le worker de démonstration ne renouvelle pas ce lease. Un job actif qui s’exécute plus de cinq minutes peut donc être récupéré deux fois. Rendez les jobs idempotents. Pour les traitements longs, ajoutez un heartbeat du lease ou définissez le seuil d’obsolescence au-delà 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. Avant cela, 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 seqLen est une chaîne de caractères. Le worker fait échouer le run et continue à sonder la file, au lieu de planter et de réessayer indéfiniment la même ligne empoisonnée.
Le travail CPU met en évidence une autre contrainte de Node. Un callback synchrone s’exécute sur le thread de la boucle d’événements et n’est pas préempté. Une boucle for qui effectue des calculs arithmétiques pendant deux secondes bloque pendant deux secondes toutes les requêtes, tous les timers et tous les contrôles de vivacité de ce processus. Une boucle serrée dans un async def bloque asyncio de la même manière. Les deux runtimes exigent donc que vous déportiez explicitement le travail CPU.
await setTimeout(0) depuis node:timers/promises (le préfixe node: signifie bibliothèque standard, donc node:timers joue pour Node le même rôle que os pour Python) est await asyncio.sleep(0). Le balayage 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 travail CPU parallèle. Node
worker_threads peut exécuter
du JavaScript en parallèle. Pour du travail CPU en Python pur avec une build CPython habituelle 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 lourds en Python, là où se trouvent déjà les bibliothèques de support, et déportez-les hors de la boucle d’événements 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 cette réutilisation qui explique l’existence de packages/schemas.
Depuis 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 simple console.log, l’équivalent JavaScript de print, suffit alors à corrompre un message. Le client se déconnecte avec une erreur d’analyse qui n’indique aucun fichier. Envoyez plutôt 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 il peut le faire après des arguments d’outil invalides dans la boucle de l’agent.
Vous pouvez piloter le serveur avec printf et un pipe, ce qu’il est utile de faire une fois avant de lui connecter un véritable client :
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 compagnon. 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 propre à 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 entre plusieurs blocs et gèrent deux appels d’outils dans une même réponse. Ils couvrent également batchSize invalide, le JSON mal formé, les 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 vierge sans instance Postgres en cours d’exécution, tandis que la CI les active en fournissant la variable.
Le dépôt compte 40 tests. Trente-six s’exécutent sans Postgres ni clé d’API.
Journaliser les événements structurés avec Pino
Pino joue 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 exporter de traces OTLP. Si
OTEL_EXPORTER_OTLP_ENDPOINT est absente, le tracing reste désactivé.
Le travail lui-même est encapsulé par withSpan() du 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 similaire à celle de Python. Ici, le callback correspond au bloc qu’un gestionnaire de contexte Python entourerait. Le helper complet enregistre également les exceptions et définit le statut du span avant de relancer l’exception.
Les spans automatiques pour HTTP et les bases 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 du SDK OpenTelemetry pour Node pour connaître les versions exactes des packages que vous déployez.
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 ni 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 runtime s’exécute sous l’utilisateur non root node, et son CMD au format exec permet à l’API de recevoir directement SIGTERM pour effectuer 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 dépôt masquent ce détail.
pnpm check effectue la vérification statique séparément.
Le conteneur révèle 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 que Node ne les exécute. 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
compagnon.
Un parcours sur trois semaines
Les ingénieurs Python expérimentés peuvent ignorer les sections qui enseignent les variables et les 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 cet objectif.
| Semaine | Read | Build |
|---|---|---|
| 1 | javascript.info : modules, promises et objets uniquement. Conservez le guide JS de MDN comme référence. | Réécrivez un CLI Python en TypeScript. Ajoutez un script package.json. Exécutez le script et pnpm typecheck. |
| 1-2 | Lisez la référence de tsconfig de TypeScript, les tutoriels gratuits de Total TypeScript et la documentation de Zod. | Construisez un module de configuration validé par Zod et une tagged union. Après avoir vérifié le tag, le compilateur sait quelle variante le bloc contient. |
| 2 | Lisez la documentation de Hono, Drizzle, Vitest et Biome. | Construisez un proxy de streaming vers un endpoint compatible avec OpenAI, avec un journal stocké via Drizzle. |
| 3 | Lisez la documentation de l’AI SDK et du MCP TypeScript SDK. | Construisez un agent avec tool calling. Construisez ensuite 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 de niveau bibliothèque et des conditional types. Ignorez tous les cours d’« introduction à JavaScript » ainsi que tout ce qui est orienté React, sauf si le produit l’exige.
Pour un aperçu des conventions de production, goldbergyoni/nodebestpractices propose une checklist communautaire complète et maintenue. Vérifiez les conseils qui affectent le comportement à l’exécution ou la sécurité dans la documentation actuelle de Node.
Arbitrages
Conserver les calculs numériques en Python
Node convient bien à l’orchestration, au service HTTP et au streaming. Des calculs soutenus et fortement consommateurs de CPU bloquent son thread de boucle d’événements principal. Conservez vLLM et le code d’entraînement en Python, sauf si une charge mesurée justifie 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 le modèle ou une ligne lue depuis jsonb. Chaque frontière nécessite un schéma runtime.
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 vers AI SDK 6). Le module compagnon appelle directement streamText avec 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 erreurs de validation, commencez avec 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 plus 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 runtime et dérivez le type TypeScript avec
z.infer. - La stack correspond globalement bien : pnpm pour uv, Hono pour FastAPI, Drizzle pour SQLAlchemy, Vitest pour pytest et Biome pour Ruff. Trois lignes ne sont toutefois pas de simples équivalences : la validation, la vérification des types et la file de jobs.
- Écrivez une boucle d’agent à la main si vous devez comprendre ou personnaliser les chemins implicites : accumulation des fragments, validation et retour des erreurs d’outil.
- 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é API. - Postgres peut fournir une file au moins une fois. Rendez les handlers idempotents et renouvelez le lease, ou dimensionnez-le au-delà de la durée d’exécution maximale pour les jobs longs. Ajoutez BullMQ si vous avez besoin de délais, de priorités ou de planifications.
- Utilisez le Dockerfile fourni en production. Il package 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
- Exécuter TypeScript nativement dans Node.js - la prise en charge limitée de TypeScript par Node et sa restriction
node_modules - Options du compilateur TypeScript -
strictet les autres vérifications configurées par le compagnon - Guide JavaScript de MDN - la référence du langage à garder ouverte
- javascript.info - tutoriel JavaScript moderne. Lisez les chapitres sur les modules et les promises.
Outils
- pnpm et installation de pnpm - gestionnaire de paquets, workspaces et configuration
- Biome - linting, formatage et tri des imports dans un seul binaire
- Vitest - test runner qui ne nécessite 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 standard Web
- Drizzle ORM - ORM TypeScript orienté SQL 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 des adaptateurs de fournisseurs - openai/openai-node - le 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