TypeScript voor Python ML-engineers: bouw een agent service
Automatische vertaling
Dit artikel is automatisch vertaald vanuit de oorspronkelijke Engelse versie.
Deze gids is voor ervaren Python ML- en backend-engineers die een AI-service in TypeScript en Node moeten bouwen. Hij vertrekt vanuit Python-servicebegrippen, niet vanuit basisprogrammeren of browsercode. Aan het eind kun je een Python-servicestack vertalen naar TypeScript, de Python-gewoonten herkennen die JavaScript-bugs veroorzaken, en een streaming agent service draaien en aanpassen.
De voorbeeldservice volgt een request door validatie, tool-uitvoering, HTTP-streaming en opslag. Hij beantwoordt drie vragen: wat gebeurt er als tool-argumenten in fragmenten binnenkomen, als een stream faalt na HTTP 200, of als een worker dezelfde job twee keer claimt? Het bereik is alleen backend- en AI-werk: geen React en geen browserbundler.
De code staat in slavadubrov/typescript-agent-service. Die bevat twee implementaties van de agent loop, Postgres-runhistorie, een worker en een MCP-server. Je kunt de offline demo zonder API-sleutel draaien en daarna de Python-naar-TypeScript-kaart hieronder gebruiken om de service te lezen en aan te passen. Zware numerieke berekeningen blijven in Python; deze gids behandelt orchestration, HTTP en streaming.
Draai eerst de companion
Installeer Node 24 en pnpm via de officiële pnpm-installatie-instructies. Kloon daarna de companion-repository en draai:
pnpm install
pnpm demo
pnpm check
pnpm demo laat de HTTP-handler, de agent loop en de SSE-stream draaien met een
gescript model. Het roept ook de runSweep-berekening van de worker aan. Het
start het worker-proces en de bijbehorende databasequeue niet. De demo heeft
geen API-sleutel, database of Docker nodig. pnpm check draait de type checker,
de linter, de formattercontrole en de tests.
De demo toont de SSE-frames van één chatrequest en daarna de sweep van de worker. Ingekort:
event: tool_call
data: {"type":"tool_call","callId":"call_1","toolName":"estimate_kv_cache","args":{"model":"llama-3.1-8b","seqLen":32768,"batchSize":16}}
event: tool_result
data: {"type":"tool_result","callId":"call_1","toolName":"estimate_kv_cache","ok":true,"result":{"model":"llama-3.1-8b","seqLen":32768,"batchSize":16,"kvCacheGiB":64},"durationMs":0}
event: done
data: {"type":"done","stopReason":"stop","steps":2,"text":"Llama-3.1-8B at 32k context and batch 16 needs 64 GiB of KV cache."}
--- worker: capacity sweep ---
batch= 1 kv_cache=4.0 GiB
batch= 16 kv_cache=64.0 GiB
Elk event:/data:-blok is één SSE-frame: het model vraagt om een tool, de
tool geeft een resultaat terug en de run eindigt met done. Het laatste deel is
de sweep over batchgroottes van de worker.
Zo draai je de echte API en worker:
cp .env.example .env # add OPENAI_API_KEY or an OpenAI-compatible URL
pnpm db:up # start Postgres in Docker
pnpm db:push # create the database schema
pnpm dev:api # API at http://localhost:8080
pnpm dev:worker # run this in a second terminal
Stream een agent-run. Het antwoord is een reeks SSE-frames zoals in de demo-uitvoer hierboven:
curl -N -X POST localhost:8080/v1/chat \
-H 'content-type: application/json' \
-d '{"message":"How much KV cache for llama-3.1-70b at 32k context, batch 8?"}'
Zet een lange job in de queue en poll hem daarna. De eerste aanroep geeft 202
terug met {"id":"...","status":"queued"}. De tweede geeft de opgeslagen rij
terug, met het resultaat zodra de worker klaar is:
curl -X POST localhost:8080/v1/sweeps \
-H 'content-type: application/json' \
-d '{"model":"llama-3.1-70b","seqLen":32768,"batchSizes":[1,4,16]}'
curl localhost:8080/v1/runs/<id>
De pnpm-scripts hierboven zijn de commando’s die ik in de rest van dit
artikel gebruik. De repository zet de lagere Node- en TypeScript-commando’s
achter benoemde pnpm-scripts, zoals een Python-project uv run-commando’s
achter make-targets kan zetten. Meng geen npm install in deze
pnpm-repository. Gebruik pnpm install, zodat pnpm-lock.yaml het enige
lockfile blijft.
Wat Node, npm, pnpm, TypeScript en tsx doen
De bijna gelijke namen verbergen aparte taken:
- JavaScript is de taal.
- Node.js is de runtime, ongeveer CPython voor JavaScript. Installeer Node 24 voor dit project.
- Het npm-register is de pakketindex, ongeveer PyPI. Het
npm-commando wordt met Node meegeleverd en kan pakketten uit dat register installeren. - pnpm is de package manager die deze repository heeft gekozen. Hij
installeert vanuit het npm-register, beheert de monorepo-workspace en draait
de commando’s die in
package.jsonstaan. package.jsonis het projectmanifest, het dichtst bijpyproject.toml. De sectiescriptsgeeft namen alsdemo,checkendev:apiaan langere commando’s.- TypeScript is JavaScript met statische types. Het
tsc-commando controleert die types. De repository draait het viapnpm checkofpnpm typecheck. - tsx draait
.ts-bestanden zonder aparte build. De development-scripts gebruiken dewatch-modus om de API of worker opnieuw te starten na een wijziging in de broncode. In deze gids roep je het niet rechtstreeks aan.
Voor deze repository draai je de pnpm-scripts. Node is de runtime binnen die
scripts, en de meegeleverde Dockerfile regelt productie.
Wat elk script uitvoert:
pnpm demodraaittsx scripts/demo.ts.pnpm dev:apidraaittsx watch.pnpm checkdraaittsc, Biome (de linter) en Vitest (de testrunner).- De Docker-
CMDdraaitnode --import tsx/esm.
Dit alles draait op Node 24. In pnpm check lezen tsc en Biome alleen
bestanden; Vitest draait de tests op Node.
De stack, vertaald vanuit Python
Zod, Hono, Drizzle, Vitest en Biome dekken een groot deel van hetzelfde terrein als pydantic, FastAPI, SQLAlchemy, pytest en Ruff.
De meeste paren zijn directe vervangingen. Drie niet:
| Onderdeel | Python | TypeScript | Waarom het geen directe vervanging is |
|---|---|---|---|
| Validatie | pydantic | zod | Het schema is de bron. Het type wordt eruit afgeleid, niet andersom |
| Typecontrole | mypy | TypeScript (pnpm typecheck) | Types worden verwijderd voordat de code draait. Geen enkele library kan ze lezen, dus runtime-validatie heeft een apart schema nodig (Zod) |
| Jobqueue | celery + Redis | bullmq (queue op Redis-basis) of SQL | Postgres kan een at-least-once queue implementeren. Een broker heb je misschien niet nodig |
De companion gebruikt vier libraries die uitleg verdienen.
Hono voor de HTTP-laag
Express en Fastify zijn op Node gerichte alternatieven. Hono gebruikt de
webstandaard Request- en Response-API’s
en levert adapters voor Node en serverless runtimes. Die portabiliteit is nuttig
voor deze kleine streaming-API, dus koos ik Hono. Kies Fastify als de service
alleen op Node draait en je de plugins en de schemagebaseerde
responsserialisatie wilt.
Drizzle voor SQL
Drizzle houdt het schema in TypeScript en heeft geen stap nodig om een client te genereren. Het maakt ook rauwe SQL beschikbaar wanneer de query builder een Postgres-clausule niet netjes kan uitdrukken. Ik zou in plaats daarvan Prisma kiezen als de gegenereerde client en de omliggende tooling beter bij het team passen.
Biome voor linting en formatting
Biome doet linting, formatting en het sorteren van imports met één binary en één configuratiebestand. Houd ESLint als het project afhangt van eigen regels die Biome niet biedt.
Vitest voor tests
Vitest draait de .ts-tests van de companion
zonder aparte transformatieconfiguratie. Houd Jest in een repository die al een
Jest-configuratie en eigen matchers heeft.
Lees de TypeScript-syntaxis die hieronder wordt gebruikt
Houd deze tabel naast de servicevoorbeelden als naslag.
| TypeScript | Python / opmerking |
|---|---|
(x) => expression | anonieme functie met een expressie als body, vergelijkbaar met lambda x: expression |
(x) => { statements } | anonieme functie met een body van statements |
async (x) => { statements } | async anonieme functie |
const { model, seqLen } = request | haalt de properties model en seqLen uit request |
const [first] = xs | first = xs[0]. Bij een lege lijst geeft het undefined, geen IndexError |
{ type: "error", message } | {"type": "error", "message": message}. Een kale naam wordt dat veld |
`text ${x}` | f-string |
cond ? a : b | a if cond else b |
const / let | Beide binden een naam. const verbiedt opnieuw binden, let staat het toe |
export | maakt een naam importeerbaar |
switch / case | match, behalve dat cases doorvallen tenzij ze eindigen op break of return |
for await | itereren over een async generator |
i++ | verhoogt en geeft de oude waarde terug |
/^https?$/ | een regex-literal, geen re.compile nodig |
T[], Map<K, V> | list[T], dict[K, V] |
Gebruik const tenzij de binding moet veranderen. Gebruik let voor een teller,
een accumulator of een andere binding die je opnieuw toewijst.
Een korte enum-vertaling
Voor stringwaardige statussen gebruikt deze repository een object plus een afgeleid string-uniontype:
const Status = { Queued: "queued", Running: "running" } as const;
type Status = (typeof Status)[keyof typeof Status]; // "queued" | "running"
Het object levert Status.Queued terwijl het programma draait. De type-regel
staat tijdens het typecontroleren alleen "queued" of "running" toe. Samen
vervullen ze de twee rollen van deze Python-declaratie:
from enum import Enum
class Status(str, Enum):
QUEUED = "queued"
RUNNING = "running"
Je hoeft het patroon alleen te herkennen. as const houdt de waarden van het
object de exacte strings, in plaats van ze te verbreden tot een willekeurige
string.
De zeven semantische verschillen die tijd kosten
De syntaxistabel brengt je door de voorbeelden. Bij deze semantische verschillen veroorzaken Python-gewoonten bugs.
1. Lege arrays en objecten zijn truthy
Het Python-principe “een lege container is falsy” is de gewoonte die het slechtst
overdraagt. if (results) is waar voor een lege array. Schrijf
if (results.length).
2. null en undefined zijn verschillend
null markeert meestal een bewuste afwezigheid. undefined betekent meestal dat
een waarde ontbreekt of niet is toegewezen, al kan code het ook expliciet
toewijzen. Librarycode geeft voortdurend undefined terug. Het verschil wordt
zichtbaar zodra je een standaardwaarde schrijft. || vervangt de linkerkant
telkens als die falsy is. Dat omvat 0, "" en false. ?? vervangt alleen
bij null en undefined. Dus 0 || 10 is 10, terwijl 0 ?? 10 0 is. Zo
wordt een batchgrootte van nul ongemerkt tien.
3. Een catch-blok ontvangt unknown
Er is geen except ValueError:. Eén catch-blok krijgt alles. Omdat JavaScript
toestaat een string, een getal of null te gooien, typeert TypeScript de
opgevangen waarde als unknown, het type “kan letterlijk alles zijn” onder
strict. De companion zet strict aan, en nieuwe projecten zouden dat meestal
moeten doen. Om de fout te inspecteren, vernauw je eerst de waarde:
try {
await risky();
} catch (error) {
// `error` is `unknown` until you prove otherwise. This line is the
// TypeScript equivalent of `except Exception as e:` and it is not
// optional.
const message = error instanceof Error ? error.message : String(error);
}
4. Promises starten meteen
Een async-functie aanroepen start de uitvoering van de body en geeft een
promise terug. Een Python-coroutine-object doet niets totdat je het awaitet of
inplant. Promise.all lijkt op asyncio.gather. Promise.allSettled lijkt op
gather(..., return_exceptions=True), behalve dat elk resultaat wordt verpakt
als { status, value } of { status, reason }.
Node beheert de runtime-planning. Het houdt het proces in leven zolang er nog
actieve handles of requests zijn, zoals timers en sockets. Een gewone openstaande
promise houdt Node niet in leven. Je verpakt het programma niet in
asyncio.run. In een ES-module kun je await op het hoogste niveau gebruiken
wanneer het opstarten op een async operatie moet wachten.
5. JavaScript heeft één gewoon numeriek type
Het number-type van JavaScript slaat waarden op als 64-bit
floating-pointgetallen, ongeveer hetzelfde als de float van Python. De
technische standaard voor dit formaat heet IEEE 754. Decimale waarden zijn bij
benadering, dus 0.1 + 0.2 is niet precies 0.3, en gehele getallen blijven
alleen exact tot 2**53 - 1, oftewel 9,007,199,254,740,991.
Houd 64-bit-ID’s als strings op servicegrenzen. Een Postgres-bigint omzetten
naar een JavaScript-number kan afronden. Voor grotere exacte gehele getallen
biedt JavaScript het aparte type BigInt, dat zich niet met gewone getallen
laat mengen.
6. Gebruik Map als je een dictionary in Python-stijl nodig hebt
In JavaScript maakt {} een object. Objecten stellen meestal records met
benoemde velden voor:
const request = { model: "gpt-5", seqLen: 4096 };
console.log(request.model);
Een object is geen schone sleutel-waardetabel zoals een Python-dict. Het erft
sommige namen van JavaScript zelf. Dat kan een verrassend resultaat geven:
const tools: Record<string, unknown> = {};
tools["constructor"]; // a built-in function, not a missing value
Object.hasOwn(tools, "constructor"); // false
Als een externe string een veld uit een object kiest, roep dan Object.hasOwn
aan voordat je het leest. Als je een algemene dictionary nodig hebt, gebruik dan
Map. Map lijkt meer op de dict van Python: een sleutel bestaat alleen als
je code hem toevoegt.
const tools = new Map<string, unknown>();
tools.get("constructor"); // undefined
7. Neem de extensie op in relatieve imports
Een JavaScript-bronbestand dat code met andere bestanden deelt heet een
module. Dit project gebruikt het moderne moduleformaat, ES modules, meestal
afgekort tot ESM. ES staat voor ECMAScript, de formele naam van de taal
JavaScript. In de praktijk is ESM de import- en export-syntaxis die in dit
hele project wordt gebruikt.
Voor een relatieve import eist Node de exacte bestandsnaam. Het raadt niet of
./env ./env.ts of ./env.js betekent:
import { loadEnv } from "./env.ts";
Imports uit geïnstalleerde pakketten of workspace-pakketten gebruiken nog steeds de pakketnaam, zonder bestandsextensie:
import { z } from "zod";
import { runAgent } from "@agent/core";
Zod: declareer het schema, leid het type af
In pydantic declareer je een klasse en krijg je een validator. In Zod declareer je een validator en leid je het type ervan af. Dezelfde ene bron van waarheid, in de tegenovergestelde richting.
De figuur toont dezelfde PORT-instelling in beide talen, en wat er van elke
declaratie overblijft terwijl de code draait.
Uit 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> leest een statisch type terug uit het runtime-schema.
z.coerce.number() vangt op dat elke gedefinieerde waarde in process.env (de
os.environ van Node) een string is. Het vervult dezelfde rol als de
numerieke-instellingencoercie van pydantic, al verschillen de exacte strings die
elk accepteert. Dit volgt het pydantic-settings-patroon en draait één keer bij
het opstarten. Een ongeldige omgeving geeft dan een leesbare opstartfout in
plaats van een TypeError in een handler.
Een kale z.url() accepteert localhost:8000. De URL-standaard behandelt alles
vóór de eerste dubbele punt als het schema. Daardoor leest hij localhost: als
een protocol met de naam “localhost” en accepteert hij de string. De waarde komt
dan bij de HTTP-client en faalt met minder context. Schemavalidatie verplaatst
fouten naar eerder, maar dwingt wel een ruim schema af als je dat hebt
geschreven.
Zod 4 levert ook z.toJSONSchema, dus dit project heeft de dependency
zod-to-json-schema uit oudere tutorials niet nodig. Dat is van belang zodra één
schema drie afnemers moet voeden, waar de sectie “Eén tool, drie afnemers”
hieronder over gaat.
De service
De bovenste rij is gedeelde code in packages/. Daaronder is elke app in apps/
een apart proces. Alleen de API en de worker raken Postgres, en de MCP-server
praat met zijn client via stdin en stdout.
De demoservice bepaalt de grootte van LLM-deployments. Eén tool zoekt de architectuurconstanten van een model op. De andere schat het geheugenverbruik van de KV cache: het GPU-geheugen dat de attention keys en values van lopende requests vasthoudt. Beide tools zijn bewust saaie rekenkunde. Ze hebben geen netwerk nodig en geven elke keer hetzelfde antwoord. Daardoor is de service testbaar zonder API-sleutel. De schatter van de KV cache wordt ook gepubliceerd via het Model Context Protocol (MCP), zodat andere AI-clients hem kunnen aanroepen.
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 is het bestand dat de workspace declareert. Interne pakketten
krijgen een scoped naam zoals @agent/core, waarbij het voorvoegsel @agent/ een
naamgevingsconventie is en geen taalfunctie. Elk pakket declareert zijn publieke
ingang in package.json. Die pakketgrens hangt niet af van het commando dat de
applicatie start.
Deze private workspace laat die ingangen naar .ts-bron wijzen, omdat elke
afnemer deel uitmaakt van dezelfde repository. Publieke npm-pakketten publiceren
normaal JavaScript plus .d.ts-typedeclaraties, zodat gewone Node-afnemers de
TypeScript-runner of de buildconfiguratie van de pakketauteur niet nodig hebben.
Schrijf de toolloop één keer met de hand
De linkerkolom is één modelstap. De rechterkolom is wat de loop levert: op een exception na die de loop verlaat, zijn die events alles wat de HTTP-route en de tests zien.
Frameworks voor agents met tool calling verpakken dezelfde basisloop:
- Roep het model aan met tooldefinities.
- Valideer en voer de gevraagde tools uit.
- Voeg de resultaten toe aan de berichten.
- Roep het model opnieuw aan.
Als je de loop één keer schrijft, worden de verborgen faalpaden zichtbaar.
De loop is een async function*, een async generator, precies de vorm van
Python’s async def met yield. De HTTP-route itereert over die generator, zet
elk event om in een Server-Sent Events-frame en verzamelt de tekst. Nadat de
stream is afgelopen, roept de route eenmalig storage.createRun aan met de
eindtekst. Tests roepen de loop apart aan en verzamelen de events in een array.
Een SSE-frame is één brok van een langlopend HTTP-antwoord. De sectie hieronder
legt het formaat uit.
De loop heeft twee normale uitgangen. Een antwoord zonder tool calls levert
done op met stopReason: "stop". Als het model tot maxSteps tools blijft
aanvragen, voltooit de generator toch en levert done op met
stopReason: "max_steps". Behandel dat resultaat als voltooid maar afgekapt: de
verzamelde tekst kan onvolledig zijn.
Elke waarde die de loop oplevert is een AgentEvent, een tagged union. Ingekort
uit
packages/schemas/src/events.ts:
export const AgentEventSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("text"), delta: z.string() }),
z.object({
type: z.literal("tool_call"),
callId: z.string(),
toolName: z.string(),
args: z.unknown(),
}),
z.object({
type: z.literal("tool_result"),
callId: z.string(),
ok: z.boolean(),
result: z.unknown() /* ... */,
}),
z.object({
type: z.literal("done"),
stopReason: z.enum(["stop", "max_steps"]),
steps: z.number(),
text: z.string(),
}),
z.object({ type: z.literal("error"), message: z.string() }),
]);
export type AgentEvent = z.infer<typeof AgentEventSchema>;
z.discriminatedUnion kiest de variant via het veld type, zoals een match op getagde dataclasses. Binnen if (event.type === "text") weet de compiler dat event.delta bestaat. Een variant toevoegen laat een onvolledige switch niet automatisch mislukken. Voor een compilerfout bij een ontbrekende variant controleer je de overgebleven waarde tegen never in een default-tak. Een lintregel voor volledige gevalsafhandeling kan ontbrekende gevallen ook melden. De HTTP-route en de AI SDK-versie hieronder gebruiken beide de vernauwde eventtypen.
De loop-body streamt tekst-events en zet tool calls uit fragmenten in elkaar.
Uit
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);
}
}
De loop-lokale variabele text bevat één modelstap. De loop gebruikt hem in het
assistentbericht voor de volgende stap of in het uiteindelijke done-event. Het
is niet de accumulator op routeniveau die later wordt opgeslagen.
De partial-map is het deel dat frameworks verbergen. De SDK geeft stringfragmenten
van de functieargumenten door, en die kunnen de geserialiseerde JSON op willekeurige
posities splitsen. Meerdere parallelle calls kunnen ook door elkaar lopen. De
repository heeft een test die {"model":"llama-3.1-8b",...} over vier chunks
verdeelt.
De figuur volgt die vier chunks naar de map-entry voor index 0.
Het tweede wat je zelf het best schrijft, is wat er gebeurt als validatie faalt:
// `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,
};
}
Vóór de uitvoering kan de lookup de tool missen, kan JSON.parse de argumenten
afwijzen of kan Zod hun vorm afwijzen. Elke fout wordt een bericht dat het model
leest. Tijdens de uitvoering wordt ook een verwachte ToolError een tool result,
zodat het model zijn call kan corrigeren. Een onverwachte exception loopt door
naar het HTTP-foutpad in plaats van als domeinfout te worden gepresenteerd.
z.prettifyError zet Zods issue-boom om in een bericht waar het model iets mee
kan, in plaats van een stack trace.
strict: true op een OpenAI-functiedefinitie
vraagt de provider de decoding tot het schema te beperken. Het heeft niets te
maken met de strict-vlag van TypeScript in tsconfig. Het lijkt op vLLM’s
guided decoding, al verschillen de ondersteunde schema’s en de details van de
afdwinging. Het neemt één faalmodus weg, maar een zelf gehost endpoint kan de
vlag negeren. De argumenten moeten bovendien JSON.parse doorstaan.
De loop roept /chat/completions aan, omdat de companion op OpenAI-compatibele
servers mikt. vLLM,
SGLang en
Ollama documenteren dat
endpoint, dus OPENAI_BASE_URL kan dezelfde client op elk ervan richten. Hun
dekking van de Responses API verschilt en verandert per release. Als je beide
kanten beheert, controleer dan de actuele compatibiliteitspagina van de server
voordat je tussen de twee API’s kiest.
Stap daarna over op de AI SDK, en weet wat je inruilt
De companion implementeert dezelfde agent ook met de Vercel AI SDK, zodat je beide kunt vergelijken. Beide versies leveren AgentEvent-waarden, maar hun gedrag bij terminale fouten verschilt momenteel. De optionele SDK-adapter levert een error-event met String(part.error) en keert terug zonder done. De huidige HTTP-route stuurt dat detail door en slaat de run op als succeeded, omdat de generator zonder exception eindigt. Behandel error of een stream die zonder done sluit als een mislukte run voordat je de standaardloop door deze adapter vervangt. Schoon foutmeldingen voor clients op en test de opgeslagen status samen met het terminale event.
De companion gebruikt AI SDK 7. De implementatie met het framework staat in
packages/agent-core/src/loop-ai-sdk.ts:
const result = streamText({
model: provider.chatModel(options.model),
prompt: options.message,
tools: aiSdkTools, // Zod schemas passed straight through
stopWhen: stepCountIs(options.maxSteps ?? 6),
});
for await (const part of result.fullStream) {
switch (part.type) {
case "text-delta": yield { type: "text", delta: part.text }; break;
case "tool-call": yield { type: "tool_call", callId: part.toolCallId, /* ... */ }; break;
// ...
}
}
De SDK haalt vijf stukken applicatiecode weg:
- de fragment-accumulator
JSON.parseen het bijbehorende foutpad- de aanroep die Zod over de geparseerde argumenten laat lopen
- providerspecifieke berichtopbouw
- de stapteller
stopWhen accepteert meerdere voorwaarden, waaronder een staplimiet of een
specifieke tool call. De for await-loop verandert niet als het stopbeleid
verandert.
Wat je opgeeft is directe controle over validatiefouten. De met de hand
geschreven loop bepaalt wat het model ziet na een afgewezen call. De companion
zet de optie repairToolCall
van de SDK niet, dus gebruikt hij de standaard: als er meer dan één stap is
toegestaan, stuurt AI SDK 7 de mislukte call in de volgende stap terug naar het
model. Geef een repairToolCall-callback mee om de call zelf te herstellen,
bijvoorbeeld met een tweede modelaanroep.
De ruil werkt ook andersom. In de SDK-versie blijft een providerwissel beperkt tot de provideradapter. Je hebt nog steeds het bijbehorende providerpakket, credentials, configuratie en integratietests nodig. In de ruwe loop is de providerspecifieke afhandeling van requests en streams jouw code om te wijzigen.
Begin voor een nieuwe service met de SDK, tenzij je die faalpaden moet beheersen. De met de hand geschreven versie geeft je een kleine implementatie om te inspecteren en met die keuze te vergelijken.
Streaming over HTTP: Hono en SSE
Hono-routes lezen als FastAPI-routes. De enige toevoeging is zValidator, dat
doet wat FastAPI gratis krijgt uit de typeannotaties op de signatuur van een
handler. De c in de handler hieronder is de requestcontext van Hono, het object
dat FastAPI over je parameters verdeelt. deps is een bundel dependencies waarmee
de app wordt opgebouwd in plaats van ze rechtstreeks te importeren. runAgent is
er een van, en de testsectie laat zien wat dat oplevert.
Ingekort uit 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 = "";
let done: Extract<AgentEvent, { type: "done" }> | undefined;
try {
// The full route wraps this loop in a `withSpan` tracing span.
for await (const event of deps.runAgent({
message: body.message,
maxSteps: body.maxSteps,
})) {
if (event.type === "text") text += event.delta;
if (event.type === "done") done = event;
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:
done?.stopReason === "max_steps" ? "truncated" : "succeeded",
input: { message: body.message },
output: { text },
});
});
});
De route houdt het laatste done-event vast. Een loop die bij max_steps stopt,
voltooit normaal, dus de route slaat hem op als truncated, niet als
succeeded.
zValidator valideert de body en geeft c.req.valid("json") het type dat het
schema voortbrengt. Sla het over en de body is getypeerd als any, de
uitstapoptie van TypeScript, waarbij elke property-toegang compileert en niets
wordt gecontroleerd. Dat schakelt het voordeel van typeveiligheid van het schema
uit.
Deze route gebruikt Server-Sent Events in plaats van WebSockets. De server houdt
een HTTP-antwoord open terwijl hij event: <name>- en data: <json>-frames
schrijft, en sluit het na het laatste event. Het verkeer loopt van server naar
client, wat bij deze agent-stream past. Een WebSocket zou bidirectionele
berichten en een protocol-upgrade toevoegen die deze route niet nodig heeft.
Bij de standaardloop verandert een exception tijdens de stream de HTTP-foutmelding. Zodra het eerste frame met status 200 is verstuurd, kan de server die response niet vervangen door een 500. Het catch-blok logt de opgevangen fout, stuurt een constant foutevent naar de client en keert terug. Daardoor wordt storage.createRun overgeslagen: een fout die de generator gooit, laat geen rij achter. Deze catch draait niet wanneer de optionele SDK-adapter een error-event levert en terugkeert. Schrijf voor debugging of evals een failed-rij met de gedeeltelijke tekst in het catch-blok als je mislukte runs wilt bewaren.
De figuur volgt één request naar zijn drie eindes. De statuscode is in alle drie de gevallen gelijk, dus alleen het laatste frame onderscheidt ze.
Een test dekt dit pad. Een generator levert één tekstdelta op en gooit dan een
fout. Het antwoord blijft 200 en het laatste frame is een error-event met het
constante bericht Agent run failed. De logger bewaart de opgevangen fout voor
diagnose aan de serverkant. Elke client die alleen de statuscode controleert,
meldt succes bij een mislukte run.
app.ts maakt twee kleinere keuzes die uitleg verdienen. Het behandelt
/healthz als liveness-endpoint, dus die route raakt Postgres bewust niet. Een
liveness-fout tijdens een database-storing kan elke replica herstarten zonder de
afhankelijkheid te herstellen. Voeg een aparte readiness-check toe wanneer de
orchestrator het verkeer moet stoppen naar een instantie die Postgres niet kan
bereiken. De foutpaden loggen de opgevangen fout maar geven een constante string
terug. error.message in een responsbody teruggeven is de manier waarop
connection strings in andermans browser terechtkomen.
Het Celery-achtige deel, zonder Celery
Lange jobs horen niet in een request handler. POST /v1/sweeps valideert een
model, een sequentielengte en een lijst batchgroottes, voegt een queued-rij in
en geeft 202 terug met de run-id. De worker claimt de rij, berekent de
grootte van de KV cache voor elke batchgrootte en slaat het resultaat op.
GET /v1/runs/:id leest het.
De bovenste helft toont de statussen waar een sweep-rij doorheen gaat. De tijdlijn toont de reclaim van een verouderde lock, hieronder uitgelegd: na vijf minuten claimt een tweede worker een job die nog draait.
Hier is geen Redis en geen BullMQ. PostgreSQL documenteert SKIP LOCKED voor
meerdere consumers van een queue-achtige tabel.
De clausule geeft deze kleine service een at-least-once queue in één tabel. Hij is
transactioneel met de rest van je schrijfacties, en het is één service minder in
docker-compose.yml.
De claimquery in
packages/agent-core/src/db/storage.ts
is:
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()` adds a locking clause; `{ skipLocked: true }` adds SKIP LOCKED.
.for("update", { skipLocked: true });
De rij is voor de transactie gelockt, en elke gelijktijdige worker die dezelfde
query draait, slaat hem over in plaats van te blokkeren. Twee gelijktijdige
claims krijgen dus niet dezelfde niet-verouderde rij. Een integratietest vuurt
twee claims tegelijk af via Promise.all en controleert dat ze verschillende
rijen teruggeven. De naïeve versie, SELECT ... LIMIT 1 gevolgd door UPDATE,
faalt die test: beide transacties lezen dezelfde rij voordat een van beide
schrijft, dus beide starten dezelfde job.
Het bovenste paneel is de race die de test vangt; het onderste paneel is de claimquery hierboven.
Dit is at-least-once-uitvoering: een job kan meer dan eens draaien. De volledige
query claimt ook een running-rij opnieuw als zijn lock ouder is dan vijf
minuten, en niets verlengt die lease. Een actieve job die langer dan vijf minuten
draait, kan dus twee keer worden geclaimd. Maak jobs idempotent. Voeg voor
langlopend werk een lease-heartbeat toe, of zet de drempel voor verouderde locks
boven de maximale looptijd.
Voeg BullMQ toe als je vertraagde jobs, terugkerende schema’s, prioriteiten, rate limits of een dashboard nodig hebt. De equivalente stap in Python is van een databasetabel naar Celery. Tot dan is Redis nog een service om te draaien en te monitoren.
De worker valideert wat hij uit jsonb leest opnieuw:
// 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);
De testsuite voert de worker ook een sweep-rij waarvan seqLen de string
"lots" is. De worker laat de run falen en blijft pollen, in plaats van te
crashen en dezelfde ongeldige rij eindeloos opnieuw te proberen.
CPU-werk legt een andere Node-beperking bloot. Een synchrone callback draait op
de thread van de event loop en wordt niet onderbroken. Een for-loop die twee
seconden rekent, blokkeert elk request, elke timer en elke liveness-check van dat
proces twee seconden lang. Een strakke loop binnen een async def blokkeert
asyncio op dezelfde manier. Beide runtimes vereisen dat je CPU-werk expliciet
uitbesteedt.
await setTimeout(0) uit node:timers/promises (het voorvoegsel node: betekent
standaardbibliotheek, dus node:timers is voor Node wat os voor Python is) is
await asyncio.sleep(0). De sweep geeft na elke batchgrootte de controle af,
zodat het worker-proces timers en andere callbacks kan afhandelen. De controle
afgeven maakt CPU-werk niet parallel. Node
worker_threads kunnen JavaScript
parallel uitvoeren. Gebruik voor puur Python-CPU-werk onder de gebruikelijke
CPython-build met GIL een process pool in plaats van een thread pool. Deze
service gebruikt geen van beide. Houd zware numerieke berekeningen in Python,
waar de ondersteunende libraries al staan, en haal ze van de event loop van de
API af.
Eén tool, drie afnemers
EstimateKvCacheInput heeft drie afnemers:
- De met de hand geschreven loop zet hem om met
z.toJSONSchema. - De AI SDK ontvangt hem ongewijzigd.
- De MCP-server publiceert zijn vorm.
Dat hergebruik is waarom packages/schemas bestaat.
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());
Twee details zijn van belang voordat je een client verbindt. Ten eerste: een
server die zo wordt gestart, gebruikt zijn eigen stdin en stdout om met de client
te communiceren. Elke regel is
een JSON-RPC-bericht.
Een verdwaalde console.log, het JavaScript-equivalent van print, beschadigt
dan een bericht. De client verbreekt de verbinding met een parsefout die geen
bestand noemt. Stuur in plaats daarvan alle diagnostiek naar stderr.
Ten tweede: een domeinfout moet isError: true teruggeven met een bericht. Het
aanroepende model kan dan de call corrigeren, net zoals het dat kan na ongeldige
tool-argumenten in de agent loop.
Je kunt de server aansturen met printf en een pipe, wat de moeite waard is om
één keer te doen voordat je een echte client erop richt:
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
De probe komt overeen met de protocolversie die de README van de companion gebruikt. Gebruik voor een echte client de SDK in plaats van JSON-RPC-berichten met de hand te onderhouden.
Een agent testen zonder API-sleutel
Vitest vervult de rol van pytest, maar de structuur is anders. describe groepeert
gerelateerde tests. it en test definiëren elk één testcase. test.each lijkt
op parametrize, beforeEach levert de setup per test, vi.fn() maakt een
mockfunctie en describe.skipIf slaat een groep voorwaardelijk over.
Agenttests hangen af van één beslissing: runAgent neemt een OpenAI-client als
parameter in plaats van er zelf een te bouwen. De fake is een object met een
chat.completions.create-methode die een gescripte async iterable teruggeeft:
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
// cast through `unknown` first. This disables type checking for that
// value, so keep it to test code.
} as unknown as OpenAI;
}
De tests splitsen één JSON-argumentstring over chunks en verwerken twee tool
calls in één antwoord. Ze dekken ook een ongeldige batchSize, misvormde JSON,
onbekende toolnamen en een model dat tools blijft aanroepen tot maxSteps het
stopt. Het testbestand draait ruim binnen een seconde, zonder netwerk en zonder
sleutel.
Integratietests tegen Postgres gebruiken
describe.skipIf(!process.env.DATABASE_URL), zodat pnpm test werkt op een
verse clone zonder draaiende Postgres, en CI ze aanzet door de variabele mee te
geven. De repository heeft 42 tests. Achtendertig daarvan draaien zonder Postgres
of API-sleutel.
Log gestructureerde events met Pino
Pino vervult dezelfde rol als structlog: één JSON-object per regel, child
loggers met gebonden velden en expliciet afschermen van gevoelige gegevens. De companion configureert het
in packages/observability/src/logger.ts:
const log = pino({
redact: {
paths: [
"req.headers.authorization",
"apiKey",
"OPENAI_API_KEY",
"*.apiKey",
],
censor: "[redacted]",
},
});
Zonder die afscherming kan log.info({ req }, "...") een Authorization-header naar de
logbackend kopiëren.
Trace applicatiewerk met handmatige spans
De companion gebruikt OpenTelemetry voor drie spans op applicatieniveau:
agent.run, agent.tool en worker.sweep. Hij installeert geen automatische
HTTP- of Postgres-instrumentatie. startTracing() in
packages/observability/src/tracing.ts
maakt een NodeSDK met een OTLP-traceexporter. Als
OTEL_EXPORTER_OTLP_ENDPOINT ontbreekt, laat het tracing uit.
Het werk zelf wordt omhuld door withSpan() uit hetzelfde bestand:
return tracer.startActiveSpan(name, { attributes }, async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
});
JavaScript heeft geen context manager-syntaxis in Python-stijl. Hier is de callback het blok dat een Python-context manager zou omsluiten. De volledige helper registreert ook exceptions en zet de spanstatus voordat hij opnieuw gooit.
Automatische HTTP- en databasespans zijn een aparte functie. Ze vereisen de bijbehorende instrumentatiepakketten en initialisatie voordat de geïnstrumenteerde modules laden. Voeg dat alleen toe wanneer die spans nuttig zijn, en volg dan de setup van de OpenTelemetry Node SDK voor de exacte pakketversies die je uitrolt.
Lever de monorepo uit in Docker
Gebruik de meegeleverde
Dockerfile.
De container start de API met de tsx-loader. Je hoeft geen TypeScript-runner te
kiezen of aan te roepen wanneer je hem uitrolt.
De build gebruikt pnpm fetch, zodat gedownloade dependencies in de cache blijven
tot het lockfile verandert. Daarna gebruikt hij pnpm deploy om de API en zijn
productiedependencies naar een zelfstandige map te kopiëren. De runtime-stage
draait als de niet-root-gebruiker node, en zijn CMD in exec-vorm laat de API
SIGTERM rechtstreeks ontvangen voor een nette shutdown.
Waarom de Dockerfile tsx laadt
Node 24 kan een beperkte subset van TypeScript draaien door typeannotaties te
verwijderen. Het controleert de types van de code niet en voert de
transformaties niet uit die een volledige TypeScript-runner ondersteunt. De
package-scripts van de repository verbergen dat detail. pnpm check voert de
aparte statische controle uit.
De container laat een andere beperking zien. pnpm deploy kopieert de
workspace-pakketten onder node_modules, en Node weigert daar bewust
TypeScript te strippen (Node TypeScript-documentatie).
Zonder loader faalt de container bij het opstarten met
ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING.
De Dockerfile laadt tsx, dat die .ts-bestanden verwerkt voordat Node ze
uitvoert. Een team dat alleen .js-bestanden in zijn runtime-image wil, kan in
plaats daarvan een compilatiestap toevoegen. Dat is een alternatief
productieontwerp, geen extra stap die nodig is om deze companion te draaien.
Een pad van drie weken
Ervaren Python-engineers kunnen materiaal overslaan dat variabelen en loops uitlegt. Deze volgorde richt zich op de onderdelen die van Python verschillen. Doe in elke rij de bouwtaak; het leesmateriaal ondersteunt die.
| Week | Lees | Bouw |
|---|---|---|
| 1 | javascript.info: alleen modules, promises en objecten. Houd MDN’s JS Guide als naslag. | Herschrijf één Python-CLI in TypeScript. Voeg een package.json-script toe. Draai het script en pnpm typecheck. |
| 1-2 | Lees TypeScripts tsconfig-referentie, de gratis Total TypeScript-tutorials en de Zod-docs. | Bouw een met Zod gevalideerde configmodule en één tagged union. Na controle van de tag weet de compiler welke variant het blok bevat. |
| 2 | Lees de documentatie van Hono, Drizzle, Vitest en Biome. | Bouw een streaming proxy naar een OpenAI-compatibel endpoint met een log op basis van Drizzle. |
| 3 | Lees de documentatie van de AI SDK en de MCP TypeScript SDK. | Bouw een agent met tool calling. Bouw daarna een MCP-server die een van zijn tools beschikbaar maakt. |
Begin met de gratis Total TypeScript-tutorials. Betaal pas voor het gevorderde materiaal wanneer je met generics en conditional types op bibliotheekniveau werkt. Sla elke cursus “intro tot JavaScript” over, en sla alles over wat naar React neigt, tenzij het product het vereist.
Voor een overzicht van productieconventies is goldbergyoni/nodebestpractices een brede, door de community onderhouden checklist. Controleer advies dat het runtimegedrag of de beveiliging raakt aan de actuele Node-documentatie.
Voordat je de service aanpast
De offline tests oefenen de besturingsstroom van de service uit met gescripte modelantwoorden. Ze stellen niet vast hoe betrouwbaar een live model jouw taken voltooit. Houd dat onderscheid in gedachten wanneer je de demotools door echte vervangt.
Valideer elke waarde die van buiten binnenkomt: de omgeving, de HTTP-body, de
tool-argumenten van het model en rijen die uit jsonb worden gelezen.
| Waarde | Gecontroleerd door | Bij een fout |
|---|---|---|
process.env | EnvSchema, in loadEnv() bij het opstarten | opstartfout; het proces stopt |
| HTTP-body | ChatRequestSchema, via zValidator | 400; de handler draait nooit |
| Tool-argumenten | tool.parse, na JSON.parse | tool-fout die naar het model gaat |
runs.input | SweepRequestSchema, in de worker bij elke claim | run gemarkeerd als failed; de worker blijft pollen |
Leg daarna het afrondingsbeleid vast: bepaal of je applicatie truncated-runs
accepteert, opnieuw probeert of laat beoordelen, en test het event en de
opgeslagen status samen. Als jobs langer kunnen draaien dan de drempel voor
verouderde locks, voeg dan een lease-heartbeat toe voordat je side effects
toevoegt.
Zodra die gedragingen expliciet zijn, voeg je de failed-rij toe die in de
streamingsectie is beschreven, en gebruik je mislukte en afgekapte runs om een
aparte evaluatiesuite voor agents
te bouwen. Daar test je of taken worden voltooid; de offline suite blijft de
snellere controle op kapot streaming-, validatie- en opslaggedrag.
Referenties
Demorepository
- slavadubrov/typescript-agent-service - de monorepo die in dit artikel wordt gebruikt: Hono-API met SSE, twee implementaties van de agent loop, Drizzle-opslag, worker, MCP-server en 42 tests
Runtime en taal
- TypeScript rechtstreeks in Node.js uitvoeren - de beperkte TypeScript-ondersteuning van Node en de beperking voor
node_modules - Compileropties van TypeScript -
stricten de andere controles die de companion configureert - JavaScript-gids van MDN - het taalnaslagwerk dat je open moet houden
- javascript.info - moderne JavaScript-tutorial. Lees de hoofdstukken over modules en promises.
Tooling
- pnpm en Installatie van pnpm - package manager, workspaces en setup
- Biome - lint, format en het sorteren van imports in één binary
- Vitest - testrunner die geen transformatieconfiguratie nodig heeft
- Total TypeScript - gratis tutorials plus een betaald traject voor geavanceerde types
Libraries
- Zod - schemavalidatie en typeafleiding. Versie 4 bevat
z.toJSONSchema. - Hono - HTTP-framework op webstandaarden
- Drizzle ORM - SQL-first TypeScript-ORM met
drizzle-kit-migraties - SELECT-documentatie van PostgreSQL - de lockclausule
FOR UPDATE ... SKIP LOCKED - BullMQ - queue op Redis-basis voor als de databasetabel niet genoeg is
AI en agents
- Vercel AI SDK -
streamText,tool,stopWhenen provideradapters - openai/openai-node - de officiële TypeScript-client
- MCP TypeScript SDK en de MCP-specificatie - servers en clients bouwen
Conventies
- goldbergyoni/nodebestpractices - door de community onderhouden checklist voor productieconventies