TypeScript dla inżynierów ML pracujących w Pythonie: budowa serwisu agenta

Tłumaczenie automatyczne Ten artykuł został automatycznie przetłumaczony z angielskiego oryginału.

Większość przewodników po TypeScript zaczyna od podstaw programowania albo pracy z DOM-em po stronie front-endu. Ten przewodnik zaczyna od koncepcji usług w Pythonie, które można przenieść, oraz od zachowania środowiska uruchomieniowego, którego nie można przenieść, ponieważ celem jest backendowy serwis AI.

Przykładem jest strumieniowy serwis agenta przeznaczony dla doświadczonych inżynierów ML i backendu pracujących w Pythonie. Żądanie przechodzi przez walidację, wykonanie narzędzia, strumieniowanie HTTP i zapis danych. Konkretne pytania są praktyczne: co się dzieje, gdy argumenty narzędzia przychodzą we fragmentach, strumień kończy się błędem po HTTP 200 albo worker dwa razy pobiera to samo zadanie?

Kod znajduje się w repozytorium slavadubrov/typescript-agent-service. Zawiera dwie implementacje pętli agenta, historię uruchomień w Postgresie, workera oraz serwer MCP. Możesz uruchomić jego tryb offline bez klucza API, a następnie użyć poniższej mapy Python–TypeScript do czytania i modyfikowania serwisu. Intensywne obliczenia numeryczne pozostają w Pythonie; ten przewodnik dotyczy orkiestracji, HTTP i strumieniowania.

Najpierw uruchom dołączony przykład

Zainstaluj Node 24 oraz pnpm zgodnie z oficjalną instrukcją instalacji pnpm. Następnie sklonuj dołączone repozytorium i uruchom:

pnpm install
pnpm demo
pnpm check

pnpm demo ćwiczy handler HTTP, pętlę agenta i strumień SSE za pomocą modelu sterowanego skryptem. Wywołuje także obliczenia runSweep workera. Nie uruchamia procesu workera ani jego kolejki bazodanowej. Demo nie wymaga klucza API, bazy danych ani Dockera. pnpm check uruchamia checker typów, linter, sprawdzanie formatowania oraz testy.

Aby uruchomić właściwe API i workera:

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

To polecenia, których używam w dalszej części artykułu. Repozytorium ukrywa niskopoziomowe polecenia Node i TypeScript za nazwanymi skryptami pnpm, podobnie jak projekt w Pythonie może ukrywać polecenia uv run za targetami make. Nie mieszaj npm install z tym repozytorium pnpm. Używaj pnpm install, aby pnpm-lock.yaml pozostał jedynym plikiem lockfile.


Rola Node, npm, pnpm, TypeScript i tsx

Podobne nazwy kryją różne zadania:

  • JavaScript to język.
  • Node.js to środowisko uruchomieniowe, w przybliżeniu odpowiednik CPython dla JavaScriptu. W tym projekcie zainstaluj Node 24.
  • Rejestr npm to indeks pakietów, w przybliżeniu odpowiednik PyPI. Polecenie npm jest dostarczane z Node i może instalować pakiety z tego rejestru.
  • pnpm to menedżer pakietów wybrany dla tego repozytorium. Instaluje pakiety z rejestru npm, zarządza workspace’em monorepo i uruchamia polecenia zadeklarowane w package.json.
  • package.json to manifest projektu, najbliższy pyproject.toml. Jego sekcja scripts nadaje dłuższym poleceniom nazwy, takie jak demo, check i dev:api.
  • TypeScript to JavaScript z typami statycznymi. Polecenie tsc sprawdza te typy. Repozytorium uruchamia je przez pnpm check lub pnpm typecheck.
  • tsx uruchamia pliki .ts bez osobnego builda. Skrypty deweloperskie używają jego trybu watch, aby restartować API lub workera po zmianie źródła. W tym przewodniku nie wywołujesz go bezpośrednio.

W tym repozytorium uruchamiaj skrypty pnpm. Node jest środowiskiem uruchomieniowym używanym wewnątrz tych skryptów, a dostarczony Dockerfile obsługuje produkcję.


Stos odwzorowany z Pythona

Dwie kolumny mapują każdy obszar pythonowego serwisu AI na jego odpowiednik w TypeScript. Sześć zamian zajmuje tę samą pozycję, a wyróżniony panel pokazuje różnice semantyczne w walidacji, kolejkach i sprawdzaniu typów.Dwie kolumny mapują każdy obszar pythonowego serwisu AI na jego odpowiednik w TypeScript. Sześć zamian zajmuje tę samą pozycję, a wyróżniony panel pokazuje różnice semantyczne w walidacji, kolejkach i sprawdzaniu typów.

Większość mapowania jest banalna — i to dobra wiadomość. Trzy wiersze nie są:

ObszarPythonTypeScriptDlaczego to nie jest prosta zamiana
WalidacjapydanticzodSchemat jest źródłem prawdy. Typ jest z niego generowany, a nie odwrotnie
Sprawdzanie typówmypyTypeScript (pnpm typecheck)Oba narzędzia sprawdzają kod źródłowy, ale nie walidują danych przychodzących w runtime
Kolejka zadańcelery + Redisbullmq (kolejka oparta na Redisie) lub SQLPostgres może implementować kolejkę typu at-least-once. Broker może nie być potrzebny

Dołączony przykład korzysta z czterech bibliotek, które warto wyjaśnić.

Hono jako warstwa HTTP

Express i Fastify to alternatywy skoncentrowane na Node. Hono korzysta ze standardowych dla Web API Request i Response oraz udostępnia adaptery dla Node i środowisk serverless. Ta przenośność jest przydatna dla tego niewielkiego strumieniowego API, dlatego wybrałem Hono.

Drizzle do SQL

Drizzle przechowuje schemat w TypeScript i nie wymaga etapu generowania klienta. Udostępnia także raw SQL, gdy query builder nie potrafi elegancko wyrazić klauzuli Postgresa. Wybrałbym Prisma, gdyby jego generowany klient i otaczające go narzędzia lepiej pasowały do zespołu.

Biome do lintingu i formatowania

Biome obsługuje linting, formatowanie i sortowanie importów za pomocą jednego binarnego narzędzia i jednego pliku konfiguracyjnego. Pozostań przy ESLint, jeśli projekt zależy od niestandardowych reguł, których Biome nie udostępnia.

Vitest do testów

Vitest uruchamia testy .ts dołączonego przykładu bez osobnej konfiguracji transformacji.


Składnia TypeScript użyta poniżej

Trzymaj tę tabelę obok przykładów serwisu.

TypeScriptPython / uwaga
(x) => expressionfunkcja anonimowa z ciałem będącym wyrażeniem, podobna do lambda x: expression
(x) => { statements }funkcja anonimowa z ciałem zawierającym instrukcje
async (x) => { statements }asynchroniczna funkcja anonimowa
const { model, seqLen } = requestwyodrębnia właściwości model i seqLen z request
const [first] = xsfirst = xs[0]. Daje undefined, a nie IndexError, gdy jest puste
{ type: "error", message }{"type": "error", "message": message}. Sama nazwa staje się tym polem
text ${x}f-string
cond ? a : ba if cond else b
const / letOba wiążą nazwę. const zabrania ponownego przypisania, a let na nie pozwala
exportsprawia, że nazwa może być importowana
switch / casematch, z tym że przypadki przechodzą dalej, jeśli nie kończą się na break lub return
for awaititerowanie po generatorze asynchronicznym
i++zwiększa wartość i zwraca poprzednią
/^https?$/literał wyrażenia regularnego, bez potrzeby używania re.compile
T[], Map<K, V>list[T], dict[K, V]

Używaj const, chyba że przypisanie musi się zmienić. Używaj let dla licznika, akumulatora lub innego przypisania, któremu ponownie nadajesz wartość.

Krótkie odwzorowanie enum

Dla stanów reprezentowanych przez stringi to repozytorium używa obiektu oraz wyinferowanego typu unii stringów:

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

Obiekt udostępnia Status.Queued podczas działania programu. Wiersz type pozwala podczas sprawdzania typów tylko na "queued" lub "running". Razem pełnią dwie role tej deklaracji w Pythonie:

from enum import Enum

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

Wystarczy rozpoznawać ten wzorzec. as const zachowuje wartości obiektu jako dokładne stringi, zamiast rozszerzać je do dowolnego string.


Siedem różnic semantycznych, które zabierają czas

Tabela składni pozwala przejść przez przykłady. To właśnie te różnice semantyczne powodują błędy wynikające z nawyków z Pythona.

1. Puste tablice i obiekty są truthy

Pythonowy nawyk „pusty kontener jest falsy” przenosi się najgorzej. if (results) ma wartość true dla pustej tablicy. Zapisz if (results.length).

2. null i undefined to różne wartości

null zwykle oznacza celowy brak wartości. undefined zwykle oznacza brakującą lub nieprzypisaną wartość, chociaż kod może przypisać ją jawnie. Kod bibliotek nieustannie zwraca undefined. Różnica ujawnia się przy ustawianiu wartości domyślnej. || podstawia prawą stronę, gdy lewa jest falsy. Dotyczy to także 0, "" i false. ?? podstawia wartość tylko dla null i undefined. Dlatego 0 || 10 jest równe 10, a 0 ?? 10 jest równe 0. To właśnie ta różnica sprawia, że rozmiar batcha równy zero po cichu zmienia się w dziesięć.

3. Blok catch otrzymuje unknown

Nie istnieje except ValueError:. Jeden blok catch obsługuje wszystko. Ponieważ JavaScript pozwala rzucać stringiem, liczbą lub null, TypeScript typuje przechwyconą wartość jako unknown — typ „dosłownie cokolwiek” w trybie strict. Dołączony przykład włącza strict, co powinny robić także nowe projekty. Aby zbadać błąd, najpierw zawęź typ wartości:

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. Promises uruchamiają się natychmiast

Wywołanie funkcji async rozpoczyna wykonywanie jej ciała i zwraca promise. Obiekt korutyny w Pythonie nic nie robi, dopóki nie wykonasz await ani go nie zaplanujesz. Promise.all jest zbliżone do asyncio.gather. Promise.allSettled jest zbliżone do gather(..., return_exceptions=True), z tym że każdy wynik jest opakowany jako { status, value } lub { status, reason }.

Node zarządza planowaniem w runtime. Utrzymuje proces przy życiu, gdy istnieją aktywne handlery lub żądania, takie jak timery i sockety. Sam oczekujący promise nie utrzymuje Node przy życiu. Nie opakowujesz programu w asyncio.run. W module ES możesz użyć await na poziomie top-level, gdy start musi zaczekać na operację asynchroniczną.

5. JavaScript ma jeden zwykły typ liczbowy

Typ number w JavaScripcie przechowuje wartości jako 64-bitowe liczby zmiennoprzecinkowe, mniej więcej odpowiednik pythonowego float. Standard techniczny tego formatu nazywa się IEEE 754. Wartości dziesiętne są przybliżone, dlatego 0.1 + 0.2 nie jest dokładnie równe 0.3, a liczby całkowite pozostają dokładne tylko do 2**53 - 1, czyli 9,007,199,254,740,991.

64-bitowe identyfikatory przechowuj jako stringi na granicach serwisu. Konwersja postgresowego bigint na javascriptowe number może spowodować zaokrąglenie. Dla większych dokładnych liczb całkowitych JavaScript udostępnia osobny typ BigInt, który nie miesza się ze zwykłymi liczbami.

6. Używaj Map, gdy potrzebujesz słownika w stylu Pythona

W JavaScripcie {} tworzy obiekt. Obiekty zwykle reprezentują rekordy z nazwanymi polami:

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

Obiekt nie jest czystą tabelą klucz–wartość, taką jak pythonowy dict. Dziedziczy niektóre nazwy z samego JavaScriptu. Może to dać zaskakujący wynik:

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

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

Jeśli zewnętrzny string wybiera pole obiektu, przed odczytem wywołaj Object.hasOwn. Jeśli potrzebujesz ogólnego słownika, użyj Map. Map jest bliższe pythonowemu dict: klucz istnieje tylko wtedy, gdy doda go twój kod.

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

7. Dołączaj rozszerzenie w importach względnych

Plik źródłowy JavaScriptu współdzielący kod z innymi plikami nazywa się modulem. Ten projekt używa nowoczesnego formatu modułów, modułów ES, zwykle skracanego do ESM. ES oznacza ECMAScript, formalną nazwę języka JavaScript. W praktyce ESM to składnia import i export używana w całym projekcie.

W imporcie względnym Node wymaga dokładnej nazwy pliku. Nie zgadnie, czy ./env oznacza ./env.ts czy ./env.js:

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

Importy z zainstalowanych pakietów lub pakietów workspace’u nadal używają nazwy pakietu, bez rozszerzenia pliku:

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

Zod to pydantic z odwróconym kierunkiem

W pydantic deklarujesz klasę i otrzymujesz walidator. W Zod deklarujesz walidator i wyprowadzasz z niego typ. To samo single source of truth, ale kierunek jest odwrotny.

Z 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> odczytuje typ statyczny ze schematu runtime. z.coerce.number() obsługuje fakt, że każda zdefiniowana wartość w process.env (node’owym os.environ) jest stringiem. Pełni tę samą rolę co konwersja ustawień numerycznych w pydantic, choć dokładne akceptowane stringi są różne. Stosuje wzorzec pydantic-settings i wykonuje się raz podczas startu. Nieprawidłowe środowisko daje wtedy czytelny błąd startowy zamiast TypeError wewnątrz handlera.

Samo z.url() akceptuje localhost:8000. Standard URL traktuje wszystko przed pierwszym dwukropkiem jako schemat. Odczytuje więc localhost: jako protokół o nazwie „localhost” i akceptuje ten string. Wartość dociera potem do klienta HTTP i kończy się błędem z mniejszą ilością kontekstu. Walidacja schematu przesuwa błędy wcześniej, ale wymusi liberalny schemat, jeśli taki zapiszesz.

Zod 4 dostarcza także z.toJSONSchema, więc ten projekt nie potrzebuje zależności zod-to-json-schema, często spotykanej w starszych tutorialach. Ma to znaczenie, gdy jeden schemat musi zasilać trzech konsumentów — o tym jest poniższa sekcja „Jedno narzędzie, trzech konsumentów”.


Serwis

Jeden schemat Zod zasila ręcznie napisaną pętlę, Vercel AI SDK i publisher MCP. Dwa adaptery pętli obsługują API Hono, publisher obsługuje proces MCP, a API i worker współdzielą tabelę uruchomień w Postgresie.Jeden schemat Zod zasila ręcznie napisaną pętlę, Vercel AI SDK i publisher MCP. Dwa adaptery pętli obsługują API Hono, publisher obsługuje proces MCP, a API i worker współdzielą tabelę uruchomień w Postgresie.

Przykładowy serwis dobiera rozmiary wdrożeń LLM. Jedno narzędzie wyszukuje stałe architektury modelu. Drugie szacuje zajętość KV cache: pamięć GPU używaną do przechowywania kluczy i wartości attention dla żądań obsługiwanych w locie. Oba narzędzia wykonują celowo banalne obliczenia arytmetyczne. Nie potrzebują sieci i za każdym razem zwracają tę samą odpowiedź. Dzięki temu serwis można testować bez klucza API. Estymator KV cache jest również publikowany przez Model Context Protocol (MCP), więc inne klienty AI mogą go wywoływać.

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 to plik deklarujący workspace. Pakiety wewnętrzne otrzymują nazwę ze scope’em, taką jak @agent/core, gdzie prefiks @agent/ jest konwencją nazewniczą, a nie cechą języka. Każdy pakiet deklaruje publiczny punkt wejścia w package.json. Ta granica pakietu nie zależy od tego, które polecenie uruchamia aplikację.

Ten prywatny workspace wskazuje te entry pointy na źródła .ts, ponieważ każdy konsument należy do tego samego repozytorium. Publiczne pakiety npm zwykle publikują JavaScript oraz deklaracje typów .d.ts, aby zwykli konsumenci Node nie potrzebowali runnera TypeScript ani konfiguracji builda autora pakietu.


Raz napisz pętlę narzędzia ręcznie

Ręcznie napisana pętla agenta składa strumieniowe wywołania narzędzi, waliduje je, uruchamia poprawne narzędzia, a niepoprawne dane zwraca do historii wiadomości jako błąd narzędzia. Jej typowany strumień AgentEvent zasila trasę HTTP i testy; trasa zapisuje końcowy wynik po zakończeniu strumienia.Ręcznie napisana pętla agenta składa strumieniowe wywołania narzędzi, waliduje je, uruchamia poprawne narzędzia, a niepoprawne dane zwraca do historii wiadomości jako błąd narzędzia. Jej typowany strumień AgentEvent zasila trasę HTTP i testy; trasa zapisuje końcowy wynik po zakończeniu strumienia.

Frameworki agentów korzystające z tool callingu opakowują tę samą podstawową pętlę:

  1. Wywołaj model z definicjami narzędzi.
  2. Zweryfikuj i uruchom żądane narzędzia.
  3. Dodaj wyniki do wiadomości.
  4. Wywołaj model ponownie.

Jednorazowe napisanie pętli uwidacznia ukryte ścieżki błędów. Jeśli już je rozumiesz i musisz dostarczyć rozwiązanie, przejdź do opisanej niżej wersji z SDK.

Pętla jest async function*, czyli generatorem asynchronicznym — dokładnie odpowiednikiem pythonowego async def z yield. Trasa HTTP iteruje po tym generatorze, zamienia każde zdarzenie na ramkę Server-Sent Events i akumuluje tekst. Po zakończeniu strumienia trasa wywołuje storage.createRun raz, z końcowym tekstem. Testy wywołują pętlę osobno i zbierają jej zdarzenia do tablicy. Ramka SSE to jeden fragment długotrwałej odpowiedzi HTTP. Poniższa sekcja wyjaśnia ten format.

Pętla ma dwa normalne zakończenia. Odpowiedź bez wywołań narzędzi daje done z stopReason: "stop". Jeśli model nadal żąda narzędzi po osiągnięciu maxSteps, generator także kończy działanie i daje done z stopReason: "max_steps". Traktuj ten wynik jako zakończony, ale ucięty: zgromadzony tekst może być niepełny.

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

Lokalna dla pętli zmienna text przechowuje jeden krok modelu. Pętla używa jej w wiadomości asystenta dla następnego kroku albo w końcowym zdarzeniu done. Nie jest to akumulator na poziomie trasy, który zostanie później zapisany.

Mapa partial to część ukrywana przez frameworki. SDK udostępnia stringowe fragmenty argumentów funkcji, które mogą dzielić serializowany JSON w dowolnych miejscach. Kilka równoległych wywołań może się także przeplatać. Repozytorium zawiera test dzielący {"model":"llama-3.1-8b",...} na cztery fragmenty.

Drugą rzeczą, którą warto napisać samodzielnie, jest obsługa nieudanej walidacji:

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

Przed wykonaniem wyszukiwanie może nie znaleźć narzędzia, JSON.parse może odrzucić argumenty albo Zod może odrzucić ich kształt. Każda porażka staje się wiadomością odczytywaną przez model. Podczas wykonywania oczekiwany ToolError także staje się wynikiem narzędzia, aby model mógł skorygować wywołanie. Nieoczekiwany wyjątek przechodzi do ścieżki błędu HTTP zamiast być przedstawiony jako błąd domenowy. z.prettifyError zamienia drzewo problemów Zod na wiadomość, na którą model może zareagować, zamiast na stack trace.

strict: true w definicji funkcji OpenAI prosi providera o ograniczenie dekodowania do schematu. Nie ma to związku z flagą strict tsconfigu TypeScriptu. Przypomina to guided decoding w vLLM, choć obsługiwane schematy i szczegóły egzekwowania różnią się. Eliminuje jeden typ błędu, ale endpoint self-hosted może zignorować tę flagę. Argumenty muszą także przetrwać JSON.parse.

Pętla wywołuje /chat/completions, ponieważ dołączony przykład jest kierowany na serwery kompatybilne z OpenAI. vLLM, SGLang oraz Ollama dokumentują ten endpoint, więc OPENAI_BASE_URL może wskazywać tego samego klienta na dowolny z nich. Zakres obsługi Responses API różni się między nimi i zmienia się wraz z wydaniami. Jeśli kontrolujesz obie strony, przed wyborem jednego z API sprawdź aktualną stronę kompatybilności serwera.


Następnie przełącz się na AI SDK i poznaj koszt tej zamiany

W kolejnych projektach używaj Vercel AI SDK. Dołączony przykład implementuje tego samego agenta dwa razy, aby kompromis był widoczny. Obie wersje emitują ten sam strumień AgentEvent, więc warstwa HTTP nie potrafi ich odróżnić.

Dołączony przykład przypina AI SDK 7.0.42 w packages/agent-core/package.json. Implementacja frameworkowa znajduje się w 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;
        // ...
    }
}

SDK usuwa pięć elementów kodu aplikacyjnego:

  • akumulator fragmentów
  • JSON.parse i jego ścieżkę błędu
  • wywołanie uruchamiające Zod na sparsowanych argumentach
  • składanie wiadomości specyficzne dla providera
  • licznik kroków

stopWhen akceptuje kilka warunków, w tym limit kroków lub konkretne wywołanie narzędzia. Pętla for await nie zmienia się przy zmianie polityki zatrzymywania.

Tracisz natomiast bezpośrednią kontrolę nad błędami walidacji. Ręcznie napisana pętla decyduje, co model zobaczy po odrzuconym wywołaniu. SDK udostępnia eksperymentalną opcję experimental_repairToolCall dla niestandardowego callbacku naprawczego. Obecny dołączony przykład nie ustawia tej opcji, więc korzysta z domyślnej obsługi niepoprawnych wywołań w SDK.

Kompromis działa także w drugą stronę. W wersji opartej na SDK zmiana providera jest lokalna dla adaptera providera. Nadal wymaga odpowiedniego pakietu providera, poświadczeń, konfiguracji i testów integracyjnych. W surowej pętli obsługa żądań i strumieni specyficzna dla providera jest częścią kodu, który musisz zmienić.

W nowym serwisie zacznij od SDK, chyba że musisz kontrolować te ścieżki błędów. Wersja ręczna daje małą implementację, którą można przeanalizować i porównać z tym wyborem.


Strumieniowanie przez HTTP: Hono i SSE

Trasy Hono przypominają trasy FastAPI. Jedynym dodatkiem jest zValidator, które wykonuje pracę zapewnianą FastAPI automatycznie przez adnotacje typów w sygnaturze handlera. c w poniższym handlerze to kontekst żądania Hono, czyli obiekt, który FastAPI rozdziela na parametry. deps to zbiór zależności, z którymi konstruowana jest aplikacja, zamiast importowania ich bezpośrednio. runAgent jest jedną z nich, a sekcja o testowaniu pokazuje, co dzięki temu zyskujesz.

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

Trasa obecnie traktuje każdy generator kończący się normalnie tak samo. Zapisuje status: "succeeded" po każdym generatorze, który dociera do końca, także po takim, którego końcowe zdarzenie ma stopReason: "max_steps". Oznacza to, że wynik graniczny jest zakończony, ale ucięty lub częściowy, a nie oznaczony jako wcześniej zapisany błąd. Kod produkcyjny powinien przeanalizować zdarzenie done i zastosować jawną politykę. Na przykład może używać osobnego statusu „ucięty” albo ścieżki przeglądu/ponowienia, zanim zgłosi sukces.

zValidator waliduje body i nadaje c.req.valid("json") typ wynikający ze schematu. Jeśli je pominiesz, body ma typ any, czyli wyłączenie kontroli w TypeScripcie, w którym każdy dostęp do właściwości się kompiluje i nic nie jest sprawdzane. Wyłącza to korzyść bezpieczeństwa typów zapewnianą przez schemat.

Ta trasa używa Server-Sent Events zamiast WebSockets. Serwer utrzymuje otwartą odpowiedź HTTP, zapisując ramki event: <name> i data: <json>, a następnie zamyka ją po końcowym zdarzeniu. Ruch płynie od serwera do klienta, co pasuje do tego strumienia agenta. WebSocket dodałby komunikację dwukierunkową i upgrade protokołu, których ta trasa nie potrzebuje.

Błąd w środku strumienia zmienia sposób raportowania błędów HTTP. Po wysłaniu pierwszej ramki ze statusem 200 serwer nie może zastąpić tej odpowiedzi statusem 500. Blok catch loguje przechwycony błąd, wysyła klientowi stałe zdarzenie błędu i kończy działanie. Ten return ma znaczenie: tylko poprawnie zakończony strumień dociera do storage.createRun. Generator emitujący done, w tym max_steps, jest normalnie zakończonym strumieniem i dociera do tego zapisu.

Test obejmuje tę ścieżkę. Generator zwraca jeden delta tekstowy, a następnie rzuca wyjątek. Odpowiedź pozostaje 200, a jej ostatnią ramką jest zdarzenie error ze stałym komunikatem Agent run failed. Logger zachowuje przechwycony błąd na potrzeby diagnostyki po stronie serwera. Każdy klient, który sprawdza wyłącznie kod statusu, zgłosi sukces nieudanego uruchomienia.

app.ts podejmuje dwie mniejsze decyzje, które warto wyjaśnić. Traktuje /healthz jako endpoint liveness, więc ta trasa celowo nie dotyka Postgresa. Awaria liveness podczas niedostępności bazy mogłaby zrestartować każdą replikę bez naprawienia zależności. Dodaj osobny readiness check, gdy orkiestrator musi przestać kierować ruch do instancji, która nie może połączyć się z Postgressem. Ścieżki błędów logują przechwycony błąd, ale zwracają stały string. Wstawienie error.message do body odpowiedzi to sposób, w jaki connection stringi trafiają do cudzej przeglądarki.


Część przypominająca Celery, ale bez Celery

Długie zadania nie należą do handlera żądania. API wstawia wiersz i zwraca 202. Worker pobiera ten wiersz.

Nie ma tu Redisa ani BullMQ. PostgreSQL dokumentuje SKIP LOCKED dla wielu konsumentów tabeli przypominającej kolejkę. Klauzula daje temu niewielkiemu serwisowi kolejkę typu at-least-once w jednej tabeli. Jest transakcyjna względem pozostałych zapisów i oznacza o jedną usługę mniej w docker-compose.yml.

Zapytanie pobierające wiersz w packages/agent-core/src/db/storage.ts wygląda tak:

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

Wiersz jest blokowany na czas transakcji, a równoległy worker wykonujący to samo zapytanie pomija go zamiast czekać. Dwa jednoczesne pobrania nie otrzymają więc tego samego, nieprzestarzałego wiersza. Test integracyjny uruchamia dwa pobrania naraz przez Promise.all i sprawdza, że zwracają różne wiersze. Naiwna wersja, SELECT ... LIMIT 1, a następnie UPDATE, nie przechodzi testu: obie transakcje odczytują ten sam wiersz, zanim którakolwiek z nich zapisze zmianę, więc obie uruchamiają to samo zadanie.

To wykonanie typu at-least-once: zadanie może uruchomić się więcej niż raz. Pełne zapytanie ponownie przejmuje także wiersz w stanie running, gdy jego blokada trwa ponad pięć minut, a demonstracyjny worker nie odnawia tego lease’u. Aktywne zadanie działające dłużej niż pięć minut może więc zostać pobrane dwa razy. Zadania muszą być idempotentne. W przypadku długich prac dodaj heartbeat lease’u albo ustaw próg nieaktualnej blokady powyżej maksymalnego czasu działania.

Dodaj BullMQ, gdy potrzebujesz opóźnionych zadań, harmonogramów cyklicznych, priorytetów, limitów szybkości lub dashboardu. Odpowiednikiem w Pythonie jest przejście z tabeli w bazie do Celery. Wcześniej Redis jest kolejną usługą do uruchomienia i monitorowania.

Worker ponownie waliduje dane odczytane z 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);

Zestaw testów przekazuje workerowi także wiersz, którego seqLen jest stringiem. Worker kończy uruchomienie błędem i nadal odpytuje kolejkę, zamiast się wywrócić i wiecznie ponawiać tę samą zatrutą wiadomość.

Praca CPU ujawnia kolejne ograniczenie Node. Synchroniczny callback wykonuje się w wątku event loop i nie jest wywłaszczany. Pętla for wykonująca arytmetykę przez dwie sekundy blokuje każde żądanie, timer i check liveness tego procesu przez dwie sekundy. Ścisła pętla wewnątrz async def blokuje asyncio w taki sam sposób. Oba środowiska wymagają jawnego oddelegowania pracy CPU.

await setTimeout(0) z node:timers/promises (prefiks node: oznacza standardową bibliotekę, więc node:timers jest dla Node tym, czym os dla Pythona) to await asyncio.sleep(0). Przegląd ustępuje po każdym rozmiarze batcha, aby proces workera mógł obsługiwać timery i inne callbacki. Ustąpienie nie sprawia, że praca CPU staje się równoległa. Node worker_threads może wykonywać JavaScript równolegle. W przypadku czystej pracy CPU w Pythonie, przy typowej kompilacji CPython z włączonym GIL, używaj puli procesów zamiast puli wątków. Ten serwis nie używa żadnej z nich. Ciężkie obliczenia numeryczne pozostaw w Pythonie, gdzie potrzebne biblioteki już istnieją, i przenieś je poza event loop API.


Jedno narzędzie, trzech konsumentów

EstimateKvCacheInput ma trzech konsumentów:

  • Ręcznie napisana pętla konwertuje je za pomocą z.toJSONSchema.
  • AI SDK otrzymuje je bez zmian.
  • Serwer MCP publikuje jego kształt.

Dlatego istnieje packages/schemas.

Z 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());

Przed podłączeniem klienta ważne są dwa szczegóły. Po pierwsze, serwer uruchomiony w ten sposób używa własnego stdin i stdout do komunikacji z klientem. Każdy wiersz jest wiadomością JSON-RPC. Pojedynczy console.log, odpowiednik pythonowego print, uszkodzi wtedy wiadomość. Klient rozłączy się z błędem parsowania, który nie wskaże żadnego pliku. Wszystkie komunikaty diagnostyczne wysyłaj na stderr.

Po drugie, błąd domenowy powinien zwracać isError: true z komunikatem. Wywołujący model może wtedy skorygować wywołanie, podobnie jak po niepoprawnych argumentach narzędzia w pętli agenta.

Możesz sterować serwerem za pomocą printf i potoku, co warto zrobić raz przed skierowaniem na niego prawdziwego klienta:

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

Probe odpowiada wersji protokołu używanej w README dołączonego przykładu. W przypadku prawdziwego klienta użyj SDK zamiast ręcznie utrzymywać wiadomości JSON-RPC.


Testowanie agenta bez klucza API

Vitest pełni rolę podobną do pytest, ale struktura jest inna. describe grupuje powiązane testy. it i test definiują po jednym przypadku testowym. test.each jest zbliżone do parametrize, beforeEach udostępnia konfigurację per test, vi.fn() tworzy funkcję mockującą, a describe.skipIf warunkowo pomija grupę.

Testy agenta zależą od jednej decyzji: runAgent przyjmuje klienta OpenAI jako parametr zamiast go konstruować. Fake jest obiektem z metodą chat.completions.create zwracającą asynchroniczny iterable sterowany skryptem:

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

Testy dzielą jeden string argumentu JSON na fragmenty i obsługują dwa wywołania narzędzi w jednej odpowiedzi. Obejmują także niepoprawne batchSize, uszkodzony JSON, nieznane nazwy narzędzi oraz model, który nadal wywołuje narzędzia, dopóki maxSteps go nie zatrzyma. Plik testowy wykonuje się znacznie poniżej sekundy, bez sieci i klucza.

Testy integracyjne z Postgressem używają describe.skipIf(!process.env.DATABASE_URL), więc pnpm test działa na świeżo sklonowanym repozytorium bez uruchomionego Postgresa, a CI włącza je przez dostarczenie zmiennej. Repozytorium ma 40 testów. Trzydzieści sześć działa bez Postgresa i klucza API.


Logowanie ustrukturyzowanych zdarzeń za pomocą Pino

Pino pełni tę samą rolę co structlog: jeden obiekt JSON na wiersz, loggeri potomne z przypisanymi polami oraz jawna redakcja. Dołączony przykład konfiguruje je w packages/observability/src/logger.ts:

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

Bez redakcji log.info({ req }, "...") może skopiować nagłówek Authorization do backendu logów.


Śledzenie pracy aplikacji za pomocą ręcznych spanów

Dołączony przykład używa OpenTelemetry dla trzech spanów na poziomie aplikacji: agent.run, agent.tool i worker.sweep. Nie instaluje automatycznej instrumentacji HTTP ani Postgresa. startTracing() w packages/observability/src/tracing.ts tworzy NodeSDK z eksporterem śladów OTLP. Jeśli brakuje OTEL_EXPORTER_OTLP_ENDPOINT, śledzenie pozostaje wyłączone.

Sama praca jest opakowana przez withSpan() z tego samego pliku:

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

JavaScript nie ma składni context managera w stylu Pythona. Tutaj callback jest blokiem, który w Pythonie zostałby objęty przez context manager. Pełny helper rejestruje także wyjątki i ustawia status spana przed ponownym rzuceniem wyjątku.

Automatyczne spany HTTP i bazy danych to osobna funkcja. Wymagają odpowiednich pakietów instrumentacji oraz inicjalizacji przed załadowaniem instrumentowanych modułów. Dodaj je tylko wtedy, gdy te spany są przydatne, a następnie zastosuj się do konfiguracji OpenTelemetry Node SDK dla dokładnych wersji pakietów wdrażanych w produkcji.


Dostarczanie monorepo w Dockerze

Użyj dostarczonego Dockerfile. Kontener uruchamia API za pomocą loadera tsx. Przy wdrażaniu nie musisz wybierać ani wywoływać runnera TypeScript.

Build używa pnpm fetch, aby pobieranie zależności pozostawało w cache do czasu zmiany lockfile. Następnie używa pnpm deploy do skopiowania API i jego zależności produkcyjnych do samowystarczalnego katalogu. Etap runtime działa jako użytkownik non-root node, a jego zapisany w formie exec CMD pozwala API bezpośrednio odbierać SIGTERM w celu kontrolowanego zamknięcia.

Dlaczego Dockerfile ładuje tsx

Node 24 może uruchamiać ograniczony podzbiór TypeScriptu przez usuwanie adnotacji typów. Nie sprawdza jednak typów ani nie wykonuje transformacji obsługiwanych przez pełny runner TypeScriptu. Skrypty pakietu repozytorium ukrywają ten szczegół. pnpm check wykonuje osobne sprawdzanie statyczne.

Kontener ujawnia kolejne ograniczenie. pnpm deploy kopiuje pakiety workspace’u pod node_modules, a Node celowo odmawia usuwania TypeScriptu w tym miejscu (dokumentacja TypeScriptu w Node). Pierwsza wersja obrazu kończyła się błędem ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Błąd występuje przed uruchomieniem API.

Dockerfile rozwiązuje problem przez ładowanie tsx, które obsługuje te pliki .ts, zanim Node je wykona. Zespół, który chce mieć w obrazie runtime wyłącznie pliki .js, może zamiast tego dodać etap kompilacji. To alternatywny projekt produkcyjny, a nie dodatkowy krok wymagany do uruchomienia tego przykładu.


Plan na trzy tygodnie

Doświadczeni inżynierowie Pythona mogą pominąć materiał uczący zmiennych i pętli. Ta sekwencja koncentruje się na elementach różniących się od Pythona. Kolumna „Buduj” wskazuje cel każdego wiersza. Lektury zapewniają wsparcie.

TydzieńCzytajBuduj
1javascript.info: tylko moduły, promises i obiekty. Miej pod ręką MDN JS Guide jako referencję.Przepisz jedno pythonowe CLI na TypeScript. Dodaj skrypt package.json. Uruchom skrypt i pnpm typecheck.
1–2Przeczytaj referencję tsconfigu TypeScriptu, bezpłatne tutoriale Total TypeScript oraz dokumentację Zod.Zbuduj moduł konfiguracji walidowany przez Zod oraz jedną tagged union. Po sprawdzeniu taga kompilator wie, który wariant zawiera blok.
2Przeczytaj dokumentację Hono, Drizzle, Vitest i Biome.Zbuduj strumieniowy proxy do endpointu kompatybilnego z OpenAI, z logiem opartym na Drizzle.
3Przeczytaj dokumentację AI SDK i MCP TypeScript SDK.Zbuduj agenta korzystającego z narzędzi. Następnie zbuduj serwer MCP udostępniający jedno z jego narzędzi.

Zacznij od bezpłatnych tutoriali Total TypeScript. Zaawansowane materiały opłacaj dopiero wtedy, gdy pracujesz z generykami i typami warunkowymi na poziomie bibliotek. Pomiń każdy kurs „wprowadzenie do JavaScriptu” oraz wszystko o kształcie Reacta, chyba że wymaga tego produkt.

Przegląd konwencji produkcyjnych znajdziesz w goldbergyoni/nodebestpractices — to szeroka, utrzymywana przez społeczność checklista. Porady wpływające na zachowanie runtime’u lub bezpieczeństwo weryfikuj w aktualnej dokumentacji Node.


Kompromisy

Obliczenia numeryczne pozostaw w Pythonie

Node dobrze sprawdza się w orkiestracji, obsłudze HTTP i strumieniowaniu. Długotrwałe obliczenia obciążające CPU blokują główny wątek event loop. Pozostaw vLLM i kod treningowy w Pythonie, chyba że pomiary uzasadniają przeniesienie ich gdzie indziej.

Waliduj każdą zewnętrzną wartość w runtime

Adnotacja TypeScriptu nie sprawdza body HTTP, zmiennej środowiskowej, wygenerowanego przez model argumentu narzędzia ani wiersza odczytanego z jsonb. Każdą z tych wartości waliduj za pomocą schematu runtime.

Izoluj zmiany SDK

AI SDK 6 zastąpiło Experimental_Agent przez ToolLoopAgent i zmieniło nazwę ustawienia agenta z system na instructions (przewodnik migracji AI SDK 6). Dołączony przykład wywołuje streamText bezpośrednio w AI SDK 7 i udostępnia własny strumień AgentEvent. Ten interfejs utrzymuje trasę HTTP bez zmian przy modyfikacjach kodu SDK.

Pomiń ręcznie napisaną pętlę, jeśli ważniejszy jest termin

Jednorazowe napisanie pętli uczy, jakie zachowanie przejmuje SDK. Jeśli musisz najpierw dostarczyć działające rozwiązanie i nie masz powodu dostosowywać błędów walidacji, zacznij od SDK.


Zanim dostosujesz serwis

Testy offline ćwiczą przepływ sterowania serwisu za pomocą sterowanych skryptem odpowiedzi modelu. Nie pokazują, jak niezawodnie działający model będzie realizował twoje zadania. Pamiętaj o tym rozróżnieniu, gdy zastąpisz narzędzia demonstracyjne prawdziwymi.

Najpierw ustal politykę ukończenia. Trasa obecnie zapisuje succeeded nawet wtedy, gdy pętla zatrzymuje się na max_steps. Zdecyduj, czy aplikacja powinna zaakceptować, ponowić lub skierować do przeglądu taki wynik, a następnie testuj zdarzenie i zapisany status razem. W przypadku zadań mogących trwać ponad pięć minut zajmij się zachowaniem związanym z nieaktualną blokadą workera, zanim dodasz efekty uboczne.

Gdy te zachowania będą jawne, użyj prawdziwych nieudanych uruchomień do zbudowania osobnego zestawu ewaluacyjnego agenta. To tam należy testować wykonanie zadań; zestaw offline pozostaje szybszym sprawdzeniem uszkodzonego strumieniowania, walidacji i zapisu danych.


Materiały referencyjne

Repozytorium demonstracyjne

Runtime i język

Narzędzia

  • pnpm i instalacja pnpm — menedżer pakietów, workspace’y i konfiguracja
  • Biome — lint, formatowanie i sortowanie importów w jednym narzędziu binarnym
  • Vitest — runner testów niewymagający konfiguracji transformacji
  • Total TypeScript — bezpłatne tutoriale oraz płatna ścieżka dotycząca zaawansowanych typów

Biblioteki

  • Zod — walidacja schematów i inferencja typów. Wersja 4 zawiera z.toJSONSchema.
  • Hono — framework HTTP zgodny ze standardami Web
  • Drizzle ORM — ORM TypeScript oparty na SQL, z migracjami drizzle-kit
  • Dokumentacja PostgreSQL SELECT — klauzula blokowania FOR UPDATE ... SKIP LOCKED
  • BullMQ — kolejka oparta na Redisie, gdy tabela w bazie przestaje wystarczać

AI i agenci

Konwencje