TypeScript para ingenieros de ML de Python: crea un servicio de agentes
Traducción automática Este artículo se tradujo automáticamente a partir de la versión original en inglés.
Esta es una guía rápida de incorporación para ingenieros de Python con experiencia que necesitan desplegar servicios de AI en TypeScript y Node. Está escrita para ingenieros de ML, científicos de datos y desarrolladores backend que no necesitan un curso introductorio de JavaScript.
Yo mismo hice esa incorporación durante los últimos meses, después de trabajar con Python y Java. La mayoría de las guías que encontré empezaban con programación básica o trabajo con el DOM del frontend. Este artículo parte de conceptos de servicios de Python. Al final, podrás relacionar un stack de servicios de Python con TypeScript y reconocer los hábitos de Python que provocan errores en JavaScript. El ejemplo conductor sigue un servicio de agentes con streaming, desde el esquema hasta el despliegue.
Resumen: Instala Node 24 y pnpm. Después, usa los comandos pnpm
del repositorio. Ejecuta pnpm demo para el ejemplo offline, pnpm dev:api y pnpm dev:worker
para desarrollo, y pnpm check antes de hacer un commit. No necesitas ejecutar
node, tsx ni el comprobador de TypeScript por tu cuenta. Los scripts del paquete lo hacen.
El servicio usa Zod, Hono, Drizzle, Vitest y Biome. Cubren buena parte del mismo terreno que pydantic, FastAPI, SQLAlchemy, pytest y Ruff. Mantén los cálculos numéricos pesados en Python. Usa este servicio de TypeScript para la orquestación, HTTP y streaming.
Todo lo que aparece aquí es un archivo de
slavadubrov/typescript-agent-service,
el repositorio complementario publicado con este artículo. Contiene una API HTTP,
dos versiones del mismo agent loop, historial de ejecuciones en Postgres, un worker y
un servidor MCP. pnpm install && pnpm demo ejecuta la ruta offline de agente HTTP/SSE y el
cálculo de barrido del worker sin necesidad de una clave de API.
Aquí solo cubro backend y trabajo con AI. No hay React. Tampoco hay un bundler para el navegador.
Ejecuta primero el repositorio complementario
Instala Node 24 y pnpm siguiendo las instrucciones oficiales de instalación de pnpm. Después, clona el repositorio complementario y ejecuta:
pnpm install
pnpm demo
pnpm check
pnpm demo prueba el handler HTTP, el agent loop y el stream SSE con un
modelo simulado. También llama al cálculo runSweep del worker. No inicia
el proceso del worker ni su cola de base de datos. La demo no necesita clave de API,
base de datos ni Docker. pnpm check ejecuta el comprobador de tipos, el linter,
el formateador y los tests.
Para ejecutar la API y el worker reales:
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
Esos son los comandos que uso en el resto del artículo. El repositorio oculta los comandos
de nivel inferior de Node y TypeScript tras scripts pnpm con nombre,
igual que un proyecto de Python podría ocultar comandos uv run tras objetivos
make. No mezcles npm install en este repositorio de pnpm.
Usa pnpm install para que pnpm-lock.yaml siga siendo el único lockfile.
Qué hacen Node, npm, pnpm, TypeScript y tsx
Los nombres parecidos ocultan funciones distintas:
- JavaScript es el lenguaje.
- Node.js es el runtime, aproximadamente el CPython de JavaScript. Instala Node 24 para este proyecto.
- El registro de npm es el índice de paquetes, aproximadamente PyPI. El comando
npmviene con Node y puede instalar paquetes desde ese registro. - pnpm es el package manager elegido por este repositorio. Instala paquetes desde el
registro de npm, gestiona el workspace del monorepo y ejecuta los comandos declarados
en
package.json. package.jsones el manifiesto del proyecto, lo más parecido apyproject.toml. Su secciónscriptsasigna nombres comodemo,checkydev:apia comandos más largos.- TypeScript es JavaScript con tipos estáticos. Su comando
tsccomprueba esos tipos. El repositorio lo ejecuta mediantepnpm checkopnpm typecheck. - tsx ejecuta archivos
.tssin una compilación independiente. Los scripts de desarrollo usan su modowatchpara reiniciar la API o el worker cuando cambia el código fuente. En esta guía no lo invocas directamente.
En este repositorio, ejecuta los scripts pnpm. Node es el runtime dentro
de esos scripts, y el Dockerfile proporcionado se encarga de producción.
El stack, relacionado con Python
La mayor parte de la relación es aburrida, lo cual es una buena noticia. Hay tres filas que no lo son:
| Aspecto | Python | TypeScript | Por qué no es un reemplazo directo |
|---|---|---|---|
| Validación | pydantic | zod | El esquema es la fuente de verdad. El tipo se genera a partir de él, no al revés |
| Comprobación de tipos | mypy | TypeScript (pnpm typecheck) | Ambos comprueban el código fuente sin validar los datos que llegan en runtime |
| Cola de trabajos | celery + Redis | bullmq (cola respaldada por Redis) o SQL | Postgres puede implementar una cola at-least-once. Quizá no necesites un broker |
El repositorio complementario utiliza cuatro bibliotecas que merece la pena explicar.
Hono para la capa HTTP
Express y Fastify son alternativas centradas en Node. Hono usa las APIs
Request y Response, estándar de la Web
y proporciona adaptadores para runtimes de Node y serverless. Esa portabilidad resulta útil
para esta pequeña API con streaming, por lo que elegí Hono.
Drizzle para SQL
Drizzle mantiene el esquema en TypeScript y no requiere un paso de generación de cliente. También permite usar SQL directamente cuando el query builder no puede expresar limpiamente una cláusula de Postgres. Elegiría Prisma cuando su cliente generado y las herramientas de su entorno encajen mejor con el equipo.
Biome para linting y formateo
Biome se encarga del linting, el formateo y la ordenación de imports con un único binario y un único archivo de configuración. Mantén ESLint si el proyecto depende de reglas personalizadas que Biome no ofrece.
Vitest para los tests
Vitest ejecuta los tests
.ts del repositorio complementario sin una configuración de transformación independiente.
Lee la sintaxis de TypeScript utilizada a continuación
Mantén esta tabla junto a los ejemplos del servicio como referencia.
| TypeScript | Python / nota |
|---|---|
(x) => expression | función anónima con cuerpo de expresión, similar a lambda x: expression |
(x) => { statements } | función anónima con cuerpo de sentencias |
async (x) => { statements } | función anónima asíncrona |
const { model, seqLen } = request | extrae las propiedades model y seqLen de request |
const [first] = xs | first = xs[0]. Produce undefined, no IndexError, cuando está vacío |
{ type: "error", message } | {"type": "error", "message": message}. Un nombre sin más se convierte en ese campo |
text ${x} | f-string |
cond ? a : b | a if cond else b |
const / let | Ambos vinculan un nombre. const impide reasignarlo, mientras que let lo permite |
export | hace que un nombre se pueda importar |
switch / case | match, salvo que los casos continúen si no terminan en break o return |
for await | iterar sobre un generador asíncrono |
i++ | incrementa y devuelve el valor anterior |
/^https?$/ | un literal de expresión regular; no hace falta re.compile |
T[], Map<K, V> | list[T], dict[K, V] |
Usa const salvo que el binding tenga que cambiar. Usa let
para un contador, acumulador u otro binding que vayas a reasignar.
Una traducción breve de enum
Para estados representados por strings, este repositorio utiliza un objeto y un tipo unión de strings inferido:
const Status = { Queued: "queued", Running: "running" } as const;
type Status = (typeof Status)[keyof typeof Status]; // "queued" | "running"
El objeto proporciona Status.Queued mientras se ejecuta el programa. La línea
type permite únicamente "queued" o "running"
durante la comprobación de tipos. Juntos desempeñan las dos funciones de esta declaración de Python:
from enum import Enum
class Status(str, Enum):
QUEUED = "queued"
RUNNING = "running"
Solo necesitas reconocer el patrón. as const conserva los valores del objeto como
strings exactos, en lugar de ampliarlos a cualquier string.
Las siete diferencias semánticas que hacen perder tiempo
La tabla de sintaxis te permite seguir los ejemplos. Estas diferencias semánticas son las que convierten los hábitos de Python en errores.
1. Los arrays y objetos vacíos son truthy
La idea de Python de que «un contenedor vacío es falsy» es el hábito
que peor se traslada. if (results) es true para un array vacío. Escribe
if (results.length).
2. null y undefined son diferentes
null suele indicar una ausencia intencionada.
undefined suele significar que falta un valor o que no se ha asignado,
aunque el código puede asignarlo explícitamente. El código de las bibliotecas devuelve
undefined constantemente. La diferencia importa cuando escribes un valor
predeterminado. || sustituye el lado derecho siempre que el izquierdo
sea falsy. Esto incluye 0, "" y false.
?? solo sustituye null y undefined.
Por tanto, 0 || 10 es 10, mientras que 0 ?? 10
es 0. Esa diferencia es la razón por la que un tamaño de lote de
cero se convierte silenciosamente en diez.
3. Un bloque catch recibe unknown
No existe except ValueError:. Un único bloque catch
recibe cualquier cosa. Como JavaScript permite lanzar un string, un número o null,
TypeScript tipa el valor capturado como unknown, su tipo «podría ser literalmente
cualquier cosa» bajo strict. El repositorio complementario activa
strict, y los proyectos nuevos deberían hacerlo en general. Para inspeccionar
el error, acota primero el valor:
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. Las Promises empiezan inmediatamente
Llamar a una función async empieza a ejecutar su cuerpo y devuelve una promise.
Un objeto coroutine de Python no hace nada hasta que lo esperas o lo programas.
Promise.all se parece a asyncio.gather. Promise.allSettled se parece a
gather(..., return_exceptions=True), salvo que cada resultado está envuelto como { status, value }
o { status, reason }.
Node gestiona la planificación del runtime. Mantiene el proceso activo mientras existan
handles activos o peticiones, como timers y sockets. Una promise pendiente ordinaria por sí
sola no mantiene vivo Node. No envuelvas el programa en asyncio.run. En un módulo
ES, puedes usar await en el nivel superior cuando el arranque deba esperar
una operación asíncrona.
5. JavaScript tiene un único tipo numérico ordinario
El tipo number de JavaScript almacena valores como números de coma flotante
de 64 bits, aproximadamente igual que el float de Python. El estándar técnico
de este formato se denomina IEEE 754. Los valores decimales son aproximados, por lo que
0.1 + 0.2 no es exactamente 0.3, y los enteros solo son exactos
hasta 2**53 - 1, es decir, 9,007,199,254,740,991.
Mantén los IDs de 64 bits como strings en los límites del servicio. Convertir un bigint
de Postgres a un number de JavaScript puede redondearlo. Para enteros exactos
más grandes, JavaScript proporciona el tipo separado BigInt, que no se puede
mezclar con los números ordinarios.
6. Usa Map cuando necesites un diccionario al estilo de Python
En JavaScript, {} crea un objeto. Los objetos suelen representar
registros con campos con nombre:
const request = { model: "gpt-5", seqLen: 4096 };
console.log(request.model);
Un objeto no es una tabla limpia de clave-valor como un dict de Python.
Hereda algunos nombres del propio JavaScript. Esto puede producir un resultado inesperado:
const tools: Record<string, unknown> = {};
tools["constructor"]; // a built-in function, not a missing value
Object.hasOwn(tools, "constructor"); // false
Si un string externo selecciona un campo de un objeto, llama a Object.hasOwn antes
de leerlo. Si necesitas un diccionario de propósito general, usa Map.
Map se parece más al dict de Python: una clave solo
existe cuando tu código la añade.
const tools = new Map<string, unknown>();
tools.get("constructor"); // undefined
7. Incluye la extensión en los imports relativos
Un archivo de código JavaScript que comparte código con otros archivos se denomina
módulo. Este proyecto utiliza el formato de módulos moderno, ES modules, normalmente
abreviado como ESM. ES significa ECMAScript, el nombre formal del lenguaje JavaScript.
En la práctica, ESM es la sintaxis import y export utilizada
en todo el proyecto.
En un import relativo, Node requiere el nombre de archivo exacto. No adivina si
./env significa ./env.ts o ./env.js:
import { loadEnv } from "./env.ts";
Los imports desde paquetes instalados o del workspace siguen utilizando el nombre del paquete, sin extensión de archivo:
import { z } from "zod";
import { runAgent } from "@agent/core";
Zod es pydantic con la flecha invertida
En pydantic declaras una clase y obtienes un validador. En Zod declaras un validador y deduces el tipo a partir de él. La misma fuente única de verdad, pero en dirección opuesta.
A partir 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> extrae un tipo estático del esquema en runtime. z.coerce.number()
resuelve el hecho de que cada valor definido en process.env (el os.environ
de Node) sea un string. Cumple el mismo papel que la coerción de configuración numérica de
pydantic, aunque los strings exactos que acepta cada uno difieren. Sigue el patrón
pydantic-settings y se ejecuta una vez durante el arranque. Un entorno no válido
produce así un error de arranque legible en lugar de un TypeError dentro de un handler.
Un z.url() sin más acepta localhost:8000. El estándar de URL trata
todo lo que precede a los dos puntos como el esquema. Por tanto, interpreta localhost:
como un protocolo llamado «localhost» y acepta el string. El valor llega después al cliente HTTP
y falla con menos contexto. La validación del esquema adelanta los fallos, pero aplicará un
esquema permisivo si eso es lo que has escrito.
Zod 4 también incluye z.toJSONSchema, por lo que este proyecto no necesita la
dependencia zod-to-json-schema habitual en tutoriales antiguos. Esto importa cuando
un esquema tiene que alimentar a tres consumidores, que es el tema de la sección «Una
herramienta, tres consumidores».
El servicio
El servicio de demostración dimensiona despliegues de LLM. Una herramienta consulta las constantes de arquitectura de un modelo. La otra estima su huella de KV cache: la memoria de la GPU utilizada para almacenar las keys y values de atención de las peticiones en curso. Ambas herramientas realizan deliberadamente una aritmética sencilla. No necesitan red y siempre devuelven la misma respuesta. Esto hace que el servicio se pueda probar sin una clave de API. El estimador de KV cache también se publica mediante el Model Context Protocol (MCP), para que otros clientes de AI puedan llamarlo.
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 es el archivo que declara el workspace. Los paquetes internos reciben
un nombre con scope, como @agent/core, donde el prefijo @agent/
es una convención de nombres, no una característica del lenguaje. Cada paquete declara su
punto de entrada público en package.json. Ese límite de paquete no depende del
comando que inicie la aplicación.
Este workspace privado dirige esas entradas al código fuente de .ts porque
todos los consumidores forman parte del mismo repositorio. Los paquetes npm públicos normalmente
publican JavaScript junto con declaraciones de tipos .d.ts, para que los
consumidores habituales de Node no necesiten el runner de TypeScript ni la configuración de
build del autor del paquete.
Escribe el loop de herramientas a mano, una vez
Los frameworks de agentes con tool calling encapsulan el mismo loop básico:
- Llama al modelo con las definiciones de las herramientas.
- Valida y ejecuta las herramientas solicitadas.
- Añade los resultados a los mensajes.
- Vuelve a llamar al modelo.
Escribe este loop una vez. Así podrás defender el comportamiento del framework como una decisión de ingeniería.
El loop es un async function*, un generador asíncrono, exactamente la forma de un
async def de Python con yield. La ruta HTTP itera ese generador,
convierte cada evento en un frame de Server-Sent Events y acumula el texto. Cuando termina
el stream, la ruta llama una vez a storage.createRun con el texto final. Los tests
invocan el loop por separado y recopilan sus eventos en un array. Un frame SSE es un fragmento
de una respuesta HTTP de larga duración. La sección siguiente explica el formato.
El loop tiene dos salidas normales. Una respuesta sin tool calls produce done
con stopReason: "stop". Si el modelo sigue solicitando herramientas hasta
maxSteps, el generador también termina y produce done con
stopReason: "max_steps". Trata ese resultado como completado pero truncado: el texto acumulado
puede estar incompleto.
A partir de 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 local del loop text contiene un paso del modelo. El loop la
utiliza en el mensaje del assistant para el siguiente paso o en el evento final
done. No es el acumulador de nivel de ruta que se persiste después.
El mapa partial es la parte que ocultan los frameworks. El SDK expone
fragmentos string de los argumentos de la función, que pueden dividir el JSON serializado
en posiciones arbitrarias. Además, varias llamadas paralelas pueden intercalarse. El
repositorio tiene un test que divide {"model":"llama-3.1-8b",...} en cuatro chunks.
Lo segundo que merece la pena escribir por tu cuenta es qué ocurre cuando falla la validación:
// `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,
};
}
Antes de ejecutar, la búsqueda puede no encontrar la herramienta, JSON.parse puede
rechazar los argumentos o Zod puede rechazar su forma. Cada fallo se convierte en un mensaje
que el modelo puede leer. Durante la ejecución, un ToolError esperado también se
convierte en un resultado de herramienta para que el modelo pueda corregir la llamada. Una
excepción inesperada se propaga a la ruta de error HTTP en lugar de presentarse como un fallo
de dominio. z.prettifyError convierte el árbol de incidencias de Zod en un mensaje
sobre el que el modelo puede actuar, en vez de en un stack trace.
strict: true en una definición de función de OpenAI
pide al proveedor que limite la decodificación al esquema. No tiene relación con la opción
strict del tsconfig de TypeScript. Se parece al guided decoding de vLLM,
aunque los esquemas compatibles y los detalles de enforcement difieren. Elimina un modo de
fallo, pero un endpoint self-hosted puede ignorar la opción. Los argumentos también deben
superar JSON.parse.
El loop llama a /chat/completions porque el repositorio complementario apunta a
servidores compatibles con OpenAI. vLLM,
SGLang y Ollama documentan ese endpoint,
por lo que OPENAI_BASE_URL puede dirigir el mismo cliente a cualquiera de ellos.
La cobertura de Responses API difiere entre ellos y cambia según la release. Si controlas
ambos lados, consulta la página de compatibilidad actual del servidor antes de elegir entre
las dos APIs.
Después cambia al AI SDK y entiende qué has intercambiado
Para proyectos posteriores, usaría el Vercel AI SDK. El repositorio complementario
implementa el mismo agente dos veces para que el intercambio sea visible. Ambas versiones
emiten el mismo stream AgentEvent, por lo que la capa HTTP no puede distinguirlas.
El repositorio complementario fija AI SDK 7.0.42 en
packages/agent-core/package.json.
La implementación del framework está en
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;
// ...
}
}
El SDK elimina cinco fragmentos de código de la aplicación:
- el acumulador de fragmentos
JSON.parsey su ruta de error- la llamada que ejecuta Zod sobre los argumentos parseados
- el ensamblado de mensajes específico del proveedor
- el contador de pasos
stopWhen acepta varias condiciones, incluido un límite de pasos o un tool call
específico. El loop for await no cambia cuando cambia la política de parada.
Lo que pierdes es el control directo sobre los fallos de validación. El loop escrito a mano
decide qué ve el modelo después de rechazar una llamada. El SDK expone la opción experimental
experimental_repairToolCall para un callback de reparación personalizado. El repositorio
complementario actual no configura esa opción, por lo que depende del tratamiento
predeterminado del SDK para las llamadas no válidas.
El intercambio también funciona en la otra dirección. En la versión con el SDK, un cambio de proveedor queda localizado en el adaptador del proveedor. Aun así, requiere el paquete del proveedor correspondiente, credenciales, configuración y tests de integración. En el loop directo, el tratamiento de las peticiones y del stream específico del proveedor es código tuyo que debes modificar.
Yo escribo el loop a mano en el primer proyecto y uso el SDK en los siguientes. Esa lección se paga una sola vez. La alternativa es leer por primera vez los internals de un framework mientras falla en producción.
Streaming sobre HTTP: Hono y SSE
Las rutas de Hono se parecen a las de FastAPI. La única adición es zValidator,
que hace el trabajo que FastAPI obtiene gratis de las anotaciones de tipos de la firma de
un handler. El c del handler siguiente es el contexto de petición de
Hono, el objeto que FastAPI distribuye entre tus parámetros. deps es un
conjunto de dependencias con las que se construye la aplicación, en lugar de importarlas
directamente. runAgent es una de ellas, y la sección de tests muestra qué
ventajas aporta.
A partir de 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 ruta trata actualmente todos los generadores que terminan con normalidad de la misma
manera. Persiste status: "succeeded" después de cualquier generador que llegue al final,
incluido uno cuyo evento final tenga stopReason: "max_steps". Por tanto, el resultado de
alcanzar el límite se marca como completado pero truncado o parcial, no como un fallo ya
persistido. El código de producción debería inspeccionar el evento done
y aplicar una política explícita. Por ejemplo, podría usar un estado truncado distinto o una
ruta de revisión/reintento antes de comunicar el éxito.
zValidator valida el body y proporciona a c.req.valid("json") el tipo que
produce el esquema. Si lo omites, el body queda tipado como any, la
opción de escape de TypeScript, donde cualquier acceso a propiedades compila y no se comprueba.
Eso desactiva la ventaja de seguridad de tipos del esquema.
Esta ruta usa Server-Sent Events en lugar de WebSockets. El servidor mantiene abierta una
respuesta HTTP mientras escribe frames event: <name> y data: <json>,
y después la cierra tras el evento final. El tráfico va del servidor al cliente, lo que encaja
con este stream del agente. Un WebSocket añadiría mensajería bidireccional y una actualización
de protocolo que esta ruta no necesita.
Un fallo a mitad del stream cambia la notificación de errores HTTP. Una vez enviado el primer
frame con estado 200, el servidor no puede sustituir esa respuesta por un 500. El bloque
catch registra el error capturado, envía al cliente un evento de error constante
y termina. Ese return es importante: solo un stream completado correctamente llega a
storage.createRun. Un generador que emite done, incluido
max_steps, es un stream completado con normalidad y sí llega a esa escritura.
Un test cubre esta ruta. Un generador produce un delta de texto y después lanza una excepción.
La respuesta sigue siendo 200 y su último frame es un evento error con el
mensaje constante Agent run failed. El logger conserva el error capturado para el
diagnóstico en el servidor. Cualquier cliente que solo compruebe el código de estado informará
de éxito en una ejecución fallida.
app.ts toma dos decisiones menores que merece la pena explicar. Trata
/healthz como un endpoint de liveness, por lo que esa ruta no toca Postgres
deliberadamente. Un fallo de liveness durante una caída de la base de datos podría reiniciar
todas las réplicas sin reparar la dependencia. Añade una comprobación de readiness separada
cuando el orquestador deba dejar de enviar tráfico a una instancia que no puede alcanzar
Postgres. Las rutas de error registran el error capturado, pero devuelven un string constante.
Devolver error.message en el body es la forma en que las cadenas de conexión acaban
en el navegador de otra persona.
La parte con forma de Celery, sin Celery
Los trabajos largos no pertenecen a un handler de peticiones. La API inserta una fila y devuelve 202. Un worker reclama la fila.
Aquí no hay Redis ni BullMQ. PostgreSQL documenta SKIP LOCKED para
múltiples consumidores de una tabla similar a una cola.
La cláusula proporciona a este pequeño servicio una cola at-least-once en una sola tabla.
Es transaccional con el resto de tus escrituras y supone un servicio menos en
docker-compose.yml.
La query de reclamación de
packages/agent-core/src/db/storage.ts
es:
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 fila queda bloqueada durante la transacción, y cualquier worker concurrente que ejecute
la misma query la omite en lugar de bloquearse. Por tanto, dos reclamaciones simultáneas no
reciben la misma fila no obsoleta. Un test de integración realiza dos reclamaciones a la vez
mediante Promise.all y comprueba que devuelven filas distintas. La versión ingenua,
SELECT ... LIMIT 1 seguida de UPDATE, falla ese test: ambas transacciones
leen la misma fila antes de que ninguna escriba, por lo que las dos inician el mismo trabajo.
Esto es ejecución at-least-once, no ejecución exactly-once. La query completa también vuelve a
reclamar una fila running cuando su lock tiene más de cinco minutos, y el
worker de demostración no renueva esa lease. Por tanto, un trabajo activo que dure más de cinco
minutos puede reclamarse dos veces. Haz que los trabajos sean idempotentes. Para trabajos de
larga duración, añade un heartbeat de la lease o establece el umbral de obsolescencia por
encima del tiempo de ejecución máximo.
Añade BullMQ cuando necesites trabajos diferidos, schedules repetibles, prioridades, rate limits o un dashboard. En Python haría el mismo cambio de una tabla de base de datos a Celery. Antes de eso, Redis es un servicio más que ejecutar, monitorizar y explicar a quien esté de guardia.
El worker vuelve a validar lo que lee de 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 también proporciona al worker una fila cuyo seqLen es un
string. El worker marca la ejecución como fallida y sigue haciendo polling, en lugar de
caerse y reintentar eternamente la misma fila envenenada.
El trabajo de CPU expone otra limitación de Node. Un callback síncrono se ejecuta en el hilo
del event loop y no se puede interrumpir. Un loop for que procese
aritmética durante dos segundos bloquea durante dos segundos todas las peticiones, timers
y comprobaciones de liveness de ese proceso. Un loop ajustado dentro de un
async def bloquea asyncio de la misma manera. Ambos runtimes requieren que
descargues explícitamente el trabajo de CPU.
await setTimeout(0) de node:timers/promises (el prefijo node: significa
biblioteca estándar, por lo que node:timers es para Node lo que os
es para Python) es await asyncio.sleep(0). El sweep cede el control después de cada tamaño
de lote para que el proceso del worker pueda atender timers y otros callbacks. Ceder el
control no paraleliza el trabajo de CPU. Node
worker_threads puede ejecutar JavaScript en paralelo.
Para trabajo de CPU puro en Python bajo una build habitual de CPython con GIL, usa un process
pool en lugar de un thread pool. Este servicio no utiliza ninguno de los dos. Mantén los
cálculos numéricos pesados en Python, donde ya residen las bibliotecas de soporte, y sácalos
del event loop de la API.
Una herramienta, tres consumidores
EstimateKvCacheInput tiene tres consumidores:
- El loop escrito a mano lo convierte mediante
z.toJSONSchema. - El AI SDK lo recibe sin cambios.
- El servidor MCP publica su forma.
Por eso existe packages/schemas.
A partir 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());
Hay dos detalles importantes antes de conectar un cliente. Primero, un servidor iniciado
de esta forma utiliza su propio stdin y stdout para comunicarse con el cliente. Cada línea
es un mensaje JSON-RPC. Un console.log suelto, el
equivalente en JavaScript de print, corrompe entonces un mensaje. El cliente
se desconecta con un error de parseo que no indica ningún archivo. Envía todos los diagnósticos
a stderr.
Segundo, un fallo de dominio debería devolver isError: true con un mensaje. El modelo
que realiza la llamada puede corregirla, igual que después de recibir argumentos de herramienta
no válidos en el agent loop.
Puedes controlar el servidor con printf y un pipe. Merece la pena hacerlo una
vez antes de conectarlo a un cliente real:
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 prueba coincide con la versión del protocolo utilizada por el README del repositorio complementario. Para un cliente real, utiliza el SDK en lugar de mantener mensajes JSON-RPC a mano.
Prueba un agente sin clave de API
Vitest cumple el papel de pytest, pero la estructura es diferente.
describe agrupa tests relacionados. it y test
definen cada uno un caso de test. test.each se parece a parametrize,
beforeEach proporciona la configuración por test, vi.fn() crea una
función mock y describe.skipIf omite condicionalmente un grupo.
Los tests del agente dependen de una decisión: runAgent recibe un cliente de
OpenAI como parámetro en lugar de construirlo. El fake es un objeto con un método
chat.completions.create que devuelve un iterable asíncrono guionizado:
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;
}
Los tests dividen un string de argumentos JSON entre varios chunks y gestionan dos tool calls
en una misma respuesta. También cubren batchSize no válido, JSON malformado,
nombres de herramientas desconocidos y un modelo que sigue llamando a herramientas hasta que
maxSteps lo detiene. El archivo de tests se ejecuta en mucho menos de un segundo,
sin red ni clave.
Los tests de integración contra Postgres utilizan
describe.skipIf(!process.env.DATABASE_URL), por lo que pnpm test funciona en un clon nuevo
sin Postgres en ejecución, y CI los activa proporcionando la variable. El repositorio tiene
40 tests. Treinta y seis se ejecutan sin Postgres ni clave de API.
Registra eventos estructurados con Pino
Pino cumple el mismo papel que structlog: un objeto JSON por línea,
loggers hijos con campos vinculados y redacción explícita. El repositorio complementario
lo configura en packages/observability/src/logger.ts:
const log = pino({
redact: {
paths: [
"req.headers.authorization",
"apiKey",
"OPENAI_API_KEY",
"*.apiKey",
],
censor: "[redacted]",
},
});
Sin redacción, log.info({ req }, "...") puede copiar una cabecera Authorization al backend de logs.
Traza el trabajo de la aplicación con spans manuales
El repositorio complementario utiliza OpenTelemetry para tres spans a nivel de aplicación:
agent.run, agent.tool y worker.sweep. No instala
instrumentación automática para HTTP o Postgres. startTracing() en
packages/observability/src/tracing.ts crea un NodeSDK con
un exporter de trazas OTLP. Si falta OTEL_EXPORTER_OTLP_ENDPOINT, deja la trazabilidad desactivada.
El trabajo en sí se envuelve con withSpan() del mismo archivo:
return tracer.startActiveSpan(name, { attributes }, async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
});
JavaScript no tiene una sintaxis de context manager al estilo de Python. Aquí, el callback es el bloque que rodearía un context manager de Python. El helper completo también registra excepciones y establece el estado del span antes de relanzarlas.
Los spans automáticos de HTTP y base de datos son una funcionalidad independiente. Requieren los paquetes de instrumentación correspondientes y una inicialización anterior a la carga de los módulos instrumentados. Añádelos solo cuando esos spans sean útiles y sigue la configuración del OpenTelemetry Node SDK para consultar las versiones exactas de los paquetes que despliegues.
Despliega el monorepo en Docker
Usa el
Dockerfile proporcionado.
El contenedor inicia la API con el loader tsx. No necesitas elegir ni
invocar un runner de TypeScript al desplegarlo.
El build utiliza pnpm fetch para que las descargas de dependencias permanezcan
en caché hasta que cambie el lockfile. Después usa pnpm deploy para copiar la API
y sus dependencias de producción a un directorio autocontenido. La fase de runtime se ejecuta
con el usuario sin privilegios node, y su CMD en
formato exec permite que la API reciba SIGTERM directamente para un apagado
ordenado.
Por qué el Dockerfile carga tsx
Node 24 puede ejecutar un subconjunto limitado de TypeScript eliminando las anotaciones de
tipos. No comprueba los tipos ni realiza las transformaciones que admite un runner completo de
TypeScript. Los scripts del paquete del repositorio ocultan ese detalle.
pnpm check realiza la comprobación estática por separado.
El contenedor expone otra limitación. pnpm deploy copia los paquetes del workspace
bajo node_modules, y Node se niega deliberadamente a eliminar TypeScript allí
(documentación de TypeScript de Node). La primera versión de la
imagen fallaba con ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Funciona.
Me desanimé.
El Dockerfile soluciona el problema cargando tsx, que procesa esos archivos
.ts antes de que Node los ejecute. Un equipo que quiera incluir únicamente
archivos .js en su imagen de runtime puede añadir un paso de compilación.
Es un diseño de producción alternativo, no un paso adicional necesario para ejecutar este
repositorio complementario.
Un itinerario de tres semanas
Los ingenieros de Python con experiencia pueden saltarse el material que enseña variables y bucles. Esta secuencia se centra en las partes que difieren de Python. La columna de construcción es el objetivo de cada fila. Las lecturas sirven de apoyo.
| Semana | Leer | Construir |
|---|---|---|
| 1 | javascript.info: solo módulos, promises y objetos. Conserva la guía de JS de MDN como referencia. | Reescribe una CLI de Python en TypeScript. Añade un script package.json. Ejecuta el script y pnpm typecheck. |
| 1-2 | Lee la referencia de tsconfig de TypeScript, los tutoriales gratuitos de Total TypeScript y la documentación de Zod. | Crea un módulo de configuración validado con Zod y una tagged union. Tras comprobar el tag, el compilador sabe qué variante contiene el bloque. |
| 2 | Lee la documentación de Hono, Drizzle, Vitest y Biome. | Crea un proxy con streaming a un endpoint compatible con OpenAI y un log respaldado por Drizzle. |
| 3 | Lee la documentación del AI SDK y del MCP TypeScript SDK. | Crea un agente con tool calling. Después crea un servidor MCP que exponga una de sus herramientas. |
Empieza por los tutoriales gratuitos de Total TypeScript. Paga por el material avanzado solo cuando trabajes con generics y conditional types propios de bibliotecas. Sáltate todos los cursos de «introducción a JavaScript» y cualquier contenido centrado en React, salvo que el producto lo requiera.
Para consultar convenciones de producción, goldbergyoni/nodebestpractices ofrece una checklist amplia mantenida por la comunidad. Verifica en la documentación actual de Node cualquier recomendación que afecte al comportamiento del runtime o a la seguridad.
Compromisos
Mantén los cálculos numéricos en Python
Node funciona bien para la orquestación, el servicio HTTP y el streaming. Los cálculos sostenidos limitados por CPU bloquean su hilo principal del event loop. Mantén vLLM y el código de entrenamiento en Python, salvo que un workload medido justifique moverlos.
Valida cada frontera en runtime
Una anotación de TypeScript no comprueba un body HTTP, una variable de entorno, los argumentos
de una herramienta generados por el modelo ni una fila leída de jsonb. Cada
frontera necesita un esquema en runtime.
Aísla la inestabilidad del SDK
AI SDK 6 sustituyó Experimental_Agent por ToolLoopAgent y cambió el nombre
de la configuración del agente de system a instructions
(guía de migración de AI SDK 6). El repositorio complementario
llama directamente a streamText en AI SDK 7 y expone su propio stream
AgentEvent. Esa frontera mantiene la ruta HTTP sin cambios cuando cambia el código
del SDK.
Omite el loop escrito a mano cuando el plazo sea más importante
Escribir el loop una vez te enseña qué comportamiento gestiona el SDK. Si necesitas desplegar primero y no tienes motivos para personalizar los fallos de validación, empieza con el SDK.
Ideas clave
- Instala Node 24 y pnpm. Después usa los scripts del repositorio:
pnpm demo,pnpm dev:api,pnpm dev:workerypnpm check. Los scripts ocultan los comandos de nivel inferior del runtime y del comprobador de tipos. - La validación en runtime es estructuralmente necesaria. Zod es el validador elegido por
este proyecto. Los tipos estáticos no inspeccionan bodies HTTP, variables de entorno, salida
del modelo ni filas de la base de datos. Con Zod, declara un esquema en runtime y deriva el
tipo de TypeScript mediante
z.infer. - El stack se relaciona en su mayor parte limpiamente: pnpm para uv, Hono para FastAPI, Drizzle para SQLAlchemy, Vitest para pytest y Biome para Ruff. Tres filas no son reemplazos directos: validación, comprobación de tipos y cola de trabajos.
- Escribe un agent loop a mano si necesitas aprender o personalizar las rutas ocultas: acumulación de fragmentos, validación y feedback de errores de herramientas.
- Convierte el loop en un generador asíncrono. La ruta HTTP consume sus valores
AgentEvent, emite frames SSE, acumula el texto y lo persiste después del stream. Los tests consumen el generador por separado, sin red ni clave de API. - Postgres puede proporcionar una cola at-least-once. Haz que los handlers sean idempotentes y renueva la lease o dimensiona su duración por encima del tiempo máximo de ejecución para trabajos largos. Añade BullMQ cuando necesites retrasos, prioridades o schedules.
- Usa el Dockerfile proporcionado en producción. Empaqueta la aplicación seleccionada, carga
TypeScript con
tsx, se ejecuta con un usuario sin privilegios y reenvía las señales de apagado al proceso de la API.
Referencias
Repositorio de demostración
- slavadubrov/typescript-agent-service: el monorepo utilizado en todo el artículo: API de Hono con SSE, dos implementaciones de agent loop, almacenamiento con Drizzle, worker, servidor MCP y 40 tests
Runtime y lenguaje
- Ejecución nativa de TypeScript en Node.js: el soporte limitado de TypeScript de Node y su restricción
node_modules - Opciones del compilador de TypeScript:
stricty las demás comprobaciones configuradas por el repositorio complementario - Guía de JavaScript de MDN: la referencia del lenguaje que merece la pena tener abierta
- javascript.info: tutorial moderno de JavaScript. Lee los capítulos sobre módulos y promises.
Herramientas
- pnpm y instalación de pnpm: package manager, workspaces y configuración
- Biome: lint, formateo y ordenación de imports en un único binario
- Vitest: runner de tests que no necesita configuración de transformación
- Total TypeScript: tutoriales gratuitos y un itinerario de pago para tipos avanzados
Bibliotecas
- Zod: validación de esquemas e inferencia de tipos. La versión 4 incluye
z.toJSONSchema. - Hono: framework HTTP estándar de la Web
- Drizzle ORM: ORM de TypeScript orientado a SQL con migraciones
drizzle-kit - Documentación de SELECT de PostgreSQL: la cláusula de locking
FOR UPDATE ... SKIP LOCKED - BullMQ: cola respaldada por Redis para cuando la tabla de la base de datos no sea suficiente
AI y agentes
- Vercel AI SDK:
streamText,tool,stopWheny adaptadores de proveedores - openai/openai-node: el cliente oficial de TypeScript
- MCP TypeScript SDK y la especificación MCP: creación de servidores y clientes
Convenciones
- goldbergyoni/nodebestpractices: checklist mantenida por la comunidad sobre convenciones de producción