Автоматический перевод Эта статья была автоматически переведена с оригинальной английской версии.
TypeScript для Python ML-инженеров: создаём агентный сервис
Это краткое руководство по быстрому онбордингу для опытных Python-инженеров, которым нужно выводить AI-сервисы на TypeScript и Node. Оно рассчитано на ML-инженеров, датасаентистов и бэкенд-разработчиков, которым не нужен вводный курс по JavaScript.
За последние несколько месяцев я сам прошёл такой онбординг после работы с Python и Java. Большинство найденных мной руководств начинались с базового программирования или работы с DOM во фронтенде. Эта статья отталкивается от концепций Python-сервисов. К концу вы сможете сопоставлять стек Python-сервиса с TypeScript и распознавать привычки Python, из-за которых возникают баги в JavaScript. Сквозной пример — потоковый агентный сервис: от схемы до деплоя.
Кратко: установите Node 24 и pnpm. Затем используйте команды pnpm
из репозитория. Запускайте pnpm demo для офлайн-примера, pnpm dev:api и pnpm dev:worker
для разработки, а перед коммитом — pnpm check. Вам не нужно самостоятельно запускать
node, tsx или проверку TypeScript. Всё это делают package scripts.
Сервис использует Zod, Hono, Drizzle, Vitest и Biome. Они покрывают во многом те же задачи, что pydantic, FastAPI, SQLAlchemy, pytest и Ruff. Тяжёлые численные вычисления оставляйте в Python. Используйте этот TypeScript-сервис для оркестрации, HTTP и стриминга.
Всё описанное здесь — файлы репозитория
slavadubrov/typescript-agent-service,
опубликованного вместе со статьёй. В нём есть HTTP API,
две версии одного и того же агентного цикла, история запусков в Postgres, воркер
и MCP-сервер. Команда pnpm install && pnpm demo запускает офлайн-путь агента через HTTP/SSE
и вычисление sweep в воркере без API-ключа.
Я рассматриваю только бэкенд и AI-разработку. React здесь нет, как нет и браузерного бандлера.
Сначала запустите companion-репозиторий
Установите Node 24 и pnpm по официальной инструкции по установке pnpm. Затем склонируйте companion-репозиторий и выполните:
pnpm install
pnpm demo
pnpm check
pnpm demo проверяет HTTP-хендлер, агентный цикл и SSE-стрим со скриптовой моделью.
Он также вызывает вычисление runSweep воркера. Сам процесс воркера и его очередь
в базе данных не запускаются. Для демо не нужны API-ключ, база данных или Docker.
pnpm check запускает проверку типов, линтер, проверку форматирования и тесты.
Чтобы запустить настоящий API и воркер:
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
Именно эти команды я использую в остальной статье. Репозиторий прячет низкоуровневые
команды Node и TypeScript за именованными скриптами pnpm — примерно так же,
как Python-проект может прятать команды uv run за целями make.
Не добавляйте npm install в этот pnpm-репозиторий. Используйте pnpm install,
чтобы pnpm-lock.yaml оставался единственным lockfile.
Что делают Node, npm, pnpm, TypeScript и tsx
Похожие названия скрывают разные задачи:
- JavaScript — язык программирования.
- Node.js — рантайм, примерно как CPython для JavaScript. Для этого проекта установите Node 24.
- npm registry — индекс пакетов, примерно как PyPI. Команда
npmпоставляется вместе с Node и умеет устанавливать пакеты из этого registry. - pnpm — выбранный репозиторием пакетный менеджер. Он устанавливает пакеты из
npm registry, управляет workspace монорепозитория и запускает команды, объявленные в
package.json. package.json— манифест проекта, ближайший аналогpyproject.toml. Его секцияscriptsзадаёт имена вродеdemo,checkиdev:apiдля более длинных команд.- TypeScript — JavaScript со статическими типами. Его команда
tscпроверяет эти типы. Репозиторий запускает её черезpnpm checkилиpnpm typecheck. - tsx запускает файлы
.tsбез отдельной сборки. Скрипты разработки используют его режимwatch, чтобы перезапускать API или воркер после изменения исходников. В этом руководстве вы не вызываете его напрямую.
В этом репозитории запускайте скрипты pnpm. Node — рантайм внутри этих
скриптов, а поставляемый Dockerfile занимается production-сборкой.
Стек в сопоставлении с Python
Большая часть сопоставления довольно скучная — и это хорошая новость. Исключений три:
| Задача | Python | TypeScript | Почему это не прямая замена |
|---|---|---|---|
| Валидация | pydantic | zod | Источником является схема. Тип генерируется из неё, а не наоборот |
| Проверка типов | mypy | TypeScript (pnpm typecheck) | Оба инструмента проверяют исходники, но не валидируют данные, пришедшие в рантайме |
| Очередь задач | celery + Redis | bullmq (очередь на Redis) или SQL | Postgres может реализовать очередь с гарантией at-least-once. Брокер может быть не нужен |
В companion-репозитории используются четыре библиотеки, о которых стоит рассказать.
Hono для HTTP-слоя
Express и Fastify — альтернативы, ориентированные на Node. Hono использует стандартные
для Web API Request и Response
и предоставляет адаптеры для Node и serverless-рантаймов. Для этого небольшого потокового
API такая переносимость полезна, поэтому я выбрал Hono.
Drizzle для SQL
Drizzle хранит схему в TypeScript и не требует шага генерации клиента. Он также даёт доступ к raw SQL, когда query builder не может аккуратно выразить конструкцию Postgres. Я бы выбрал Prisma, если его сгенерированный клиент и окружающий tooling лучше подходят команде.
Biome для линтинга и форматирования
Biome выполняет линтинг, форматирование и сортировку импортов одним бинарником и одним конфигурационным файлом. Оставляйте ESLint, если проект зависит от кастомных правил, которых нет в Biome.
Vitest для тестов
Vitest запускает тесты .ts в companion-репозитории
без отдельной конфигурации трансформаций.
Читайте синтаксис TypeScript, используемый ниже
Держите эту таблицу рядом с примерами сервиса для справки.
| TypeScript | Python / примечание |
|---|---|
(x) => expression | анонимная функция с телом-выражением, похожая на lambda x: expression |
(x) => { statements } | анонимная функция с телом-инструкцией |
async (x) => { statements } | асинхронная анонимная функция |
const { model, seqLen } = request | извлечь свойства model и seqLen из request |
const [first] = xs | first = xs[0]. При пустом значении возвращает undefined, а не IndexError |
{ type: "error", message } | {"type": "error", "message": message}. Отдельное имя становится этим полем |
text ${x} | f-string |
cond ? a : b | a if cond else b |
const / let | Оба связывают имя. const запрещает переназначение, а let его допускает |
export | делает имя доступным для импорта |
switch / case | match, но ветки проваливаются дальше, если не заканчиваются break или return |
for await | итерация по асинхронному генератору |
i++ | увеличивает значение и возвращает старое |
/^https?$/ | regex-литерал, re.compile не нужен |
T[], Map<K, V> | list[T], dict[K, V] |
Используйте const, если binding не должен меняться. Для счётчика,
аккумулятора или другого binding, который вы будете переназначать, используйте let.
Краткий перевод enum
Для состояний со строковыми значениями этот репозиторий использует объект и выведенный тип string union:
const Status = { Queued: "queued", Running: "running" } as const;
type Status = (typeof Status)[keyof typeof Status]; // "queued" | "running"
Объект предоставляет Status.Queued во время выполнения программы. Строка type
разрешает только "queued" или "running" при проверке типов. Вместе они
выполняют две роли следующего объявления Python:
class Status(str, Enum):
QUEUED = "queued"
RUNNING = "running"
Достаточно распознавать этот паттерн. as const сохраняет значения объекта
как точные строки, не расширяя их до какого-либо string.
Семь семантических различий, которые отнимают время
Таблица синтаксиса поможет разобраться с примерами. Именно эти семантические различия превращают привычки Python в баги.
1. Пустые массивы и объекты — truthy
Привычка Python «пустой контейнер — falsy» переносится хуже всего. if (results)
равно true для пустого массива. Пишите if (results.length).
2. null и undefined — разные значения
null обычно обозначает намеренное отсутствие. undefined обычно означает,
что значение отсутствует или не присвоено, хотя код может присвоить его явно. Библиотечный
код постоянно возвращает undefined. Различие проявляется при задании значения
по умолчанию. || подставляет правую часть, когда левая falsy. Сюда
относятся 0, "" и false. ?? подставляет
значение только для null и undefined. Поэтому 0 || 10 — это 10,
а 0 ?? 10 — 0. Именно так размер батча, равный нулю, незаметно
превращается в десять.
3. catch получает unknown
Отдельного except ValueError: нет. Один блок catch получает всё. Поскольку
JavaScript позволяет выбрасывать строку, число или null, TypeScript типизирует
перехваченное значение как unknown — тип «буквально что угодно» при strict.
Companion-репозиторий включает strict, и в новых проектах это обычно следует
делать. Чтобы изучить ошибку, сначала сузьте тип значения:
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 запускаются сразу
Вызов функции async начинает выполнять её тело и возвращает promise. Объект
корутины Python ничего не делает, пока вы не вызовете await или не запланируете его.
Promise.all близок к asyncio.gather. Promise.allSettled близок к gather(..., return_exceptions=True),
за исключением того, что каждый результат обёрнут в { status, value } или { status, reason }.
Планированием рантайма управляет Node. Процесс остаётся активным, пока существуют активные
handles или запросы, например таймеры и сокеты. Обычный ожидающий promise сам по себе
не удерживает Node активным. Не нужно оборачивать программу в asyncio.run.
В ES module можно использовать await на верхнем уровне, если запуск должен
дождаться асинхронной операции.
5. В JavaScript один обычный числовой тип
Тип JavaScript number хранит значения как 64-битные числа с плавающей точкой —
примерно как float в Python. Технический стандарт этого формата называется
IEEE 754. Десятичные значения приблизительны, поэтому 0.1 + 0.2 не равно точно
0.3, а целые числа остаются точными только до 2**53 - 1, то есть
9,007,199,254,740,991.
Храните 64-битные ID в виде строк на границах сервиса. Преобразование Postgres bigint
в JavaScript number может привести к округлению. Для больших точных целых
JavaScript предоставляет отдельный тип BigInt, который нельзя смешивать
с обычными числами.
6. Используйте Map, если нужен словарь в стиле Python
В JavaScript {} создаёт object. Объекты обычно представляют записи
с именованными полями:
const request = { model: "gpt-5", seqLen: 4096 };
console.log(request.model);
Объект — не такая чистая таблица ключ-значение, как Python dict. Он наследует
некоторые имена из самого JavaScript. Это может привести к неожиданному результату:
const tools: Record<string, unknown> = {};
tools["constructor"]; // a built-in function, not a missing value
Object.hasOwn(tools, "constructor"); // false
Если внешняя строка выбирает поле объекта, перед чтением вызовите Object.hasOwn.
Если нужен универсальный словарь, используйте Map. Map ближе
к Python dict: ключ существует только после того, как его добавил ваш код.
const tools = new Map<string, unknown>();
tools.get("constructor"); // undefined
7. Добавляйте расширение в относительные импорты
Файл исходников JavaScript, который делит код с другими файлами, называется module.
Этот проект использует современный формат модулей — ES modules, обычно сокращённо
ESM. ES означает ECMAScript, официальное название языка JavaScript. На практике ESM —
это синтаксис import и export, используемый во всём проекте.
Для относительного импорта Node требует точное имя файла. Он не угадывает, означает ли
./env файл ./env.ts или ./env.js:
import { loadEnv } from "./env.ts";
Импорты установленных или workspace-пакетов по-прежнему используют имя пакета, без расширения файла:
import { z } from "zod";
import { runAgent } from "@agent/core";
Zod — это pydantic с обратным направлением стрелки
В pydantic вы объявляете класс и получаете валидатор. В Zod вы объявляете валидатор и выводите из него тип. Единый источник истины тот же, направление обратное.
Из 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> извлекает статический тип из runtime-схемы. z.coerce.number() учитывает,
что каждое определённое значение в process.env — это строка (аналог os.environ в Node).
Он выполняет ту же роль, что числовое приведение настроек в pydantic, хотя конкретные
принимаемые строки различаются. Это паттерн pydantic-settings, который выполняется один
раз при старте. Некорректное окружение тогда приводит к понятной ошибке запуска,
а не к TypeError внутри хендлера.
Обычный z.url() принимает localhost:8000. Стандарт URL считает всё до первой
двоеточия схемой. Поэтому он воспринимает localhost: как протокол с именем
«localhost» и принимает строку. Затем значение доходит до HTTP-клиента и завершается
ошибкой с меньшим объёмом контекста. Валидация схемой переносит ошибки на более ранний
этап, но будет применять permissive-схему, если именно такую вы написали.
В Zod 4 также есть z.toJSONSchema, поэтому этому проекту не нужна зависимость
zod-to-json-schema, распространённая в старых руководствах. Это важно, когда одна схема
должна обслуживать трёх потребителей, о чём рассказывает следующий раздел
«Один инструмент, три потребителя».
Сервис
Демо-сервис оценивает конфигурации для LLM. Один инструмент ищет константы архитектуры модели. Другой оценивает объём KV cache: память GPU, используемую для хранения ключей и значений аттеншна у выполняющихся запросов. Оба инструмента намеренно выполняют простую арифметику. Им не нужна сеть, и каждый раз они возвращают один и тот же результат. Благодаря этому сервис можно тестировать без API-ключа. Оценка KV cache также опубликована через Model Context Protocol (MCP), поэтому её могут вызывать другие AI-клиенты.
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 — файл, объявляющий workspace. Внутренние пакеты получают scoped-имя вроде
@agent/core, где префикс @agent/ — соглашение об именовании, а не
возможность языка. Каждый пакет объявляет публичную точку входа в package.json.
Граница пакета не зависит от того, какая команда запускает приложение.
Этот приватный workspace направляет эти точки входа в исходники .ts,
поскольку каждый потребитель находится в том же репозитории. Публичные npm-пакеты обычно
публикуют JavaScript вместе с объявлениями типов .d.ts, чтобы обычным
Node-потребителям не требовались TypeScript-рантайм или setup сборки автора пакета.
Один раз напишите цикл инструментов вручную
Фреймворки для агентов с tool calling оборачивают один и тот же базовый цикл:
- Вызвать модель с определениями инструментов.
- Валидировать и запустить запрошенные инструменты.
- Добавить результаты в messages.
- Снова вызвать модель.
Напишите этот цикл один раз. Тогда поведение фреймворка станет инженерным выбором, который можно обосновать.
Цикл — это async function*, асинхронный генератор, точная форма Python-ового
async def с yield. HTTP-роут итерирует этот генератор, превращает
каждое событие во фрейм Server-Sent Events и накапливает текст. После завершения стрима
роут один раз вызывает storage.createRun с итоговым текстом. Тесты вызывают цикл отдельно
и собирают его события в массив. SSE-фрейм — это один чанк долгоживущего HTTP-ответа.
Формат описан в следующем разделе.
Из 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);
}
}
Локальная для цикла переменная text содержит один шаг модели. Цикл использует
её в assistant message для следующего шага или в финальном событии done.
Это не аккумулятор на уровне роута, который позже сохраняется.
Map partial — это часть, которую скрывают фреймворки. SDK выдаёт строковые
фрагменты аргументов функции, причём они могут разделить сериализованный JSON в произвольных
местах. Несколько параллельных вызовов также могут перемешиваться. В репозитории есть тест,
который разделяет {"model":"llama-3.1-8b",...} на четыре чанка.
Второе, что стоит написать самостоятельно, — обработка ошибки валидации:
// `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,
};
}
До выполнения lookup может не найти инструмент, JSON.parse может отклонить аргументы,
а Zod — их форму. Каждая такая ошибка становится сообщением, которое читает модель.
Во время выполнения ожидаемая ToolError также превращается в результат инструмента,
чтобы модель могла скорректировать вызов. Неожиданное исключение уходит в HTTP error path,
а не представляется как доменная ошибка.
z.prettifyError превращает дерево issues Zod в сообщение, с которым модель может
что-то сделать, вместо stack trace.
strict: true в определении функции OpenAI
просит провайдера ограничить decoding схемой. Это не связано с флагом strict
в tsconfig TypeScript. Механизм похож на guided decoding в vLLM, хотя поддерживаемые схемы
и детали enforcement различаются. Он убирает один класс ошибок, но self-hosted endpoint
может проигнорировать флаг. Аргументы также должны пройти JSON.parse.
Цикл вызывает /chat/completions, поскольку companion-репозиторий рассчитан на
OpenAI-compatible серверы. vLLM,
SGLang и
Ollama документируют этот endpoint, поэтому
OPENAI_BASE_URL может направить один и тот же клиент на любой из них. Покрытие
Responses API у них различается и меняется от релиза к релизу. Если вы контролируете
обе стороны, перед выбором одной из двух API проверьте актуальную страницу совместимости сервера.
Затем переключитесь на AI SDK и поймите, чем вы за это платите
Для следующих проектов я бы использовал Vercel AI SDK. Companion-репозиторий реализует
одного и того же агента двумя способами, чтобы trade-off был виден. Обе версии выдают
одинаковый поток AgentEvent, поэтому HTTP-слой не может их различить.
Companion-репозиторий закрепляет AI SDK 7.0.42 в
packages/agent-core/package.json.
Реализация на фреймворке находится в
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 убирает пять частей прикладного кода:
- аккумулятор фрагментов
JSON.parseи его error path- вызов, который прогоняет Zod по распарсенным аргументам
- сборку сообщений, специфичную для провайдера
- счётчик шагов
stopWhen принимает несколько условий, включая лимит шагов или конкретный
tool call. Цикл for await не меняется при изменении stop policy.
Вы отдаёте прямой контроль над ошибками валидации. Написанный вручную цикл сам решает,
что увидит модель после отклонённого вызова. В версии на SDK это поведение настраивается
через repairToolCall.
Trade-off работает и в обратную сторону. В версии на SDK смена провайдера локализована
в адаптере провайдера. При этом всё равно потребуются соответствующий provider package,
credentials, configuration и integration tests. В raw loop обработка provider-specific
запросов и стрима — ваш код, который нужно менять.
В первом проекте я пишу цикл вручную, а в последующих использую SDK. Этот урок достаточно оплатить один раз. Альтернатива — впервые разбираться во внутренностях фреймворка уже в момент production-сбоя.
Стриминг по HTTP: Hono и SSE
Роуты Hono выглядят как роуты FastAPI. Единственное добавление — zValidator,
который выполняет работу, автоматически доступную FastAPI благодаря type annotations
в сигнатуре хендлера. c в хендлере ниже — это request context Hono,
объект, который FastAPI распределяет по вашим параметрам. deps — набор
зависимостей, с которыми создаётся приложение, вместо прямого импорта. runAgent —
одна из них, и в разделе о тестировании показано, что это даёт.
app.post("/v1/chat", zValidator("json", ChatRequestSchema), (c) => {
const body = c.req.valid("json");
const log = deps.logger.child({ route: "chat" });
return streamSSE(c, async (stream) => {
let text = "";
try {
await withSpan(
"agent.run",
{ "agent.max_steps": body.maxSteps },
async () => {
for await (const event of deps.runAgent({
message: body.message,
maxSteps: body.maxSteps,
})) {
if (event.type === "text") text += event.delta;
await stream.writeSSE({
event: event.type,
data: JSON.stringify(event),
});
}
},
);
} catch (error) {
log.error({ err: error }, "agent run failed");
await stream.writeSSE({
event: "error",
data: JSON.stringify({
type: "error",
message: "Agent run failed",
}),
});
return;
}
await deps.storage.createRun({
kind: "chat",
status: "succeeded",
input: { message: body.message },
output: { text },
});
});
});
zValidator валидирует body и присваивает c.req.valid("json") тип, который выдаёт
схема. Если его пропустить, body получает тип any — режим TypeScript,
отключающий проверки, при котором обращение к любому свойству компилируется. Это
отключает преимущество type safety схемы.
Этот роут использует Server-Sent Events вместо WebSockets. Сервер держит HTTP-ответ открытым,
пока отправляет фреймы event: <name> и data: <json>, а затем закрывает его
после финального события. Трафик идёт от сервера к клиенту, что соответствует этому
агентному стриму. WebSocket добавил бы двунаправленные сообщения и protocol upgrade,
которые этому роуту не нужны.
Ошибка в середине стрима меняет обработку HTTP-ошибок. После отправки первого фрейма со
статусом 200 сервер не может заменить этот ответ на 500. Блок catch
логирует перехваченную ошибку, отправляет клиенту постоянное error-событие и возвращает управление.
Этот return важен: только успешно завершённый стрим доходит до storage.createRun.
Этот путь покрыт тестом. Генератор выдаёт один text delta, а затем выбрасывает исключение.
Ответ остаётся со статусом 200, а его последний фрейм — событие error
с постоянным сообщением Agent run failed. Логгер сохраняет перехваченную ошибку
для серверной диагностики. Любой клиент, который проверяет только status code, сообщит
об успехе завершившегося с ошибкой запуска.
app.ts принимает ещё два небольших, но важных решения. Он считает /healthz
liveness endpoint, поэтому этот роут намеренно не обращается к Postgres. Сбой liveness
во время недоступности базы может перезапустить все реплики, не восстановив зависимость.
Добавьте отдельную readiness-проверку, если оркестратор должен перестать направлять трафик
на инстанс, который не может подключиться к Postgres. В error paths ошибка логируется,
но клиенту возвращается постоянная строка. Echoing error.message в body ответа —
так строки подключения оказываются в чужом браузере.
Часть в форме Celery, но без Celery
Долгие задачи не должны выполняться в request handler. API вставляет строку и возвращает 202. Воркер забирает эту строку.
Здесь нет Redis и BullMQ. PostgreSQL документирует SKIP LOCKED для
нескольких потребителей таблицы, похожей на очередь.
Эта конструкция даёт небольшому сервису очередь at-least-once в одной таблице.
Она транзакционна вместе с остальными записями и убирает один сервис из docker-compose.yml.
Query для claim в
packages/agent-core/src/db/storage.ts
выглядит так:
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 });
Строка блокируется на время транзакции, а любой конкурентный воркер, выполняющий тот
же query, пропускает её вместо блокировки. Поэтому два одновременных claim не получают
одну и ту же неустаревшую строку. Integration test выполняет два claim одновременно
через Promise.all и проверяет, что они возвращают разные строки. Наивная версия,
SELECT ... LIMIT 1, а затем UPDATE, этот тест проваливает: обе транзакции
читают одну строку до того, как какая-либо из них успевает записать изменения, поэтому
обе запускают одну и ту же задачу.
Это выполнение at-least-once, а не exactly-once. Полный query также повторно забирает
строку running, если её lock старше пяти минут, а демо-воркер не продлевает
эту lease. Поэтому активная задача, выполняющаяся дольше пяти минут, может быть забрана
дважды. Делайте задачи идемпотентными. Для долгих операций добавьте heartbeat lease
или задайте порог stale-lock выше максимального runtime.
Добавьте BullMQ, если нужны отложенные задачи, повторяемые расписания, приоритеты, rate limits или dashboard. В Python я бы аналогично перешёл от таблицы в базе к Celery. До этого Redis — ещё один сервис, который нужно запускать, мониторить и объяснять дежурному инженеру.
Воркер повторно валидирует прочитанные из 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);
Тесты также передают воркеру строку, у которой seqLen — строка. Воркер
завершает run с ошибкой и продолжает polling, вместо того чтобы упасть и бесконечно
повторять ту же poison row.
CPU-работа выявляет ещё одно ограничение Node. Синхронный callback выполняется в потоке
event loop и не вытесняется. Цикл for, который две секунды занят
арифметикой, блокирует на эти две секунды каждый запрос, таймер и liveness check процесса.
Плотный цикл внутри async def аналогично блокирует asyncio. В обоих рантаймах
CPU-работу нужно явно выносить отдельно.
await setTimeout(0) из node:timers/promises (префикс node: означает
standard library, поэтому node:timers для Node — то же, что os
для Python) — это await asyncio.sleep(0). Sweep делает yield после каждого batch size,
чтобы процесс воркера мог обслуживать таймеры и другие callbacks. Yielding не делает
CPU-работу параллельной. Node
worker_threads умеет выполнять JavaScript
параллельно. Для чистой CPU-работы на Python при обычной сборке CPython с включённым GIL
используйте process pool, а не thread pool. Этот сервис не использует ни тот, ни другой.
Тяжёлые численные вычисления оставляйте в Python, где уже есть нужные библиотеки,
и выносите их за пределы event loop API.
Один инструмент, три потребителя
EstimateKvCacheInput имеет трёх потребителей:
- Написанный вручную цикл преобразует его через
z.toJSONSchema. - AI SDK получает его без изменений.
- MCP-сервер публикует его форму.
Именно поэтому существует packages/schemas.
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());
Перед подключением клиента важны две детали. Во-первых, сервер, запущенный таким
образом, использует собственные stdin и stdout для общения с клиентом. Каждая строка —
сообщение JSON-RPC.
Случайный console.log, эквивалент print в JavaScript, испортит
сообщение. Клиент отключится с parse error, в котором не будет указано имя файла.
Все диагностические сообщения отправляйте в stderr.
Во-вторых, доменная ошибка должна возвращать isError: true с сообщением.
Вызывающая модель сможет скорректировать вызов — так же, как после некорректных
аргументов инструмента в агентном цикле.
Сервер можно проверить через printf и pipe. Это стоит сделать один раз,
прежде чем направлять к нему реальный клиент:
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
Проверка соответствует версии протокола из README companion-репозитория. Для настоящего клиента используйте SDK, а не поддерживайте JSON-RPC-сообщения вручную.
Тестирование агента без API-ключа
Vitest выполняет роль pytest, но структура отличается. describe группирует
связанные тесты. it и test задают по одному тест-кейсу.
test.each близок к parametrize, beforeEach отвечает за setup
перед тестом, vi.fn() создаёт mock-функцию, а describe.skipIf условно
пропускает группу.
Тесты агента зависят от одного решения: runAgent принимает OpenAI-клиент
параметром, а не создаёт его внутри. Fake — это объект с методом chat.completions.create,
возвращающим scripted async iterable:
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;
}
Тесты разделяют одну строку JSON-аргумента на чанки и обрабатывают два tool calls
в одном ответе. Также проверяются некорректный batchSize, malformed JSON,
неизвестные имена инструментов и модель, которая продолжает вызывать инструменты, пока
maxSteps её не остановит. Файл тестов выполняется значительно быстрее
секунды, без сети и ключа.
Integration tests с Postgres используют describe.skipIf(!process.env.DATABASE_URL), поэтому pnpm test
работает на свежем clone без запущенного Postgres, а CI включает их, передавая переменную.
В репозитории 40 тестов. Тридцать шесть запускаются без Postgres и API-ключа.
Логируйте структурированные события с Pino
Pino выполняет ту же роль, что structlog: один JSON-объект в строке,
child loggers с привязанными полями и явная redaction. Companion-репозиторий настраивает
его в packages/observability/src/logger.ts:
const log = pino({
redact: {
paths: [
"req.headers.authorization",
"apiKey",
"OPENAI_API_KEY",
"*.apiKey",
],
censor: "[redacted]",
},
});
Без redaction log.info({ req }, "...") может скопировать Authorization header в log backend.
Трейсьте работу приложения с ручными спанами
Companion-репозиторий использует OpenTelemetry для трёх application-level спанов:
agent.run, agent.tool и worker.sweep. Автоматическая instrumentation
для HTTP или Postgres не устанавливается. startTracing() в
packages/observability/src/tracing.ts
создаёт NodeSDK с OTLP trace exporter. Если
OTEL_EXPORTER_OTLP_ENDPOINT отсутствует, трейсинг отключён.
Сама работа оборачивается в withSpan() из того же файла:
return tracer.startActiveSpan(name, { attributes }, async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
});
В JavaScript нет синтаксиса context manager в стиле Python. Здесь callback — это блок, который в Python окружал бы context manager. Полный helper также записывает исключения и устанавливает status спана перед повторным выбросом ошибки.
Автоматические HTTP- и database-спаны — отдельная возможность. Для них нужны подходящие instrumentation packages и инициализация до загрузки инструментируемых модулей. Добавляйте это только тогда, когда такие спаны действительно полезны, а точные версии пакетов, которые вы деплоите, сверяйте с настройкой OpenTelemetry Node SDK.
Деплойте монорепозиторий в Docker
Используйте поставляемый
Dockerfile.
Контейнер запускает API с loader tsx. При деплое вам не нужно выбирать
или вызывать TypeScript runner.
Сборка использует pnpm fetch, поэтому загрузки зависимостей остаются в кэше,
пока не изменится lockfile. Затем pnpm deploy копирует API и его production-зависимости
в self-contained directory. Runtime-stage запускается от имени non-root пользователя
node, а exec-form CMD позволяет API напрямую получать
SIGTERM для graceful shutdown.
Почему Dockerfile загружает tsx
Node 24 умеет запускать ограниченное подмножество TypeScript, удаляя аннотации типов.
Он не проверяет типы и не выполняет трансформации, которые поддерживает полноценный
TypeScript runner. Package scripts репозитория скрывают эту деталь.
pnpm check выполняет отдельную статическую проверку.
Контейнер выявляет ещё одно ограничение. pnpm deploy копирует workspace-пакеты
в node_modules, а Node намеренно отказывается удалять TypeScript оттуда
(документация Node по TypeScript). Первая версия образа
завершалась с ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Это работает.
Я был разочарован.
Dockerfile исправляет проблему, загружая tsx, который обрабатывает эти
.ts-файлы до их выполнения Node. Команда, которая хочет оставить в
runtime image только .js-файлы, может вместо этого добавить шаг компиляции.
Это альтернативный production-дизайн, а не дополнительный шаг, необходимый для запуска
этого companion-репозитория.
План на три недели
Опытные Python-инженеры могут пропустить материал о переменных и циклах. Эта последовательность сосредоточена на отличиях от Python. Колонка Build показывает цель каждой строки, а чтение поддерживает её.
| Неделя | Читать | Создать |
|---|---|---|
| 1 | javascript.info: только modules, promises и objects. Держите MDN’s JS Guide как справочник. | Перепишите один Python CLI на TypeScript. Добавьте скрипт package.json. Запустите скрипт и pnpm typecheck. |
| 1–2 | Прочитайте справочник tsconfig TypeScript, бесплатные руководства Total TypeScript и документацию Zod. | Создайте модуль конфигурации с валидацией Zod и один tagged union. После проверки tag компилятор знает, какой вариант находится в блоке. |
| 2 | Прочитайте документацию Hono, Drizzle, Vitest и Biome. | Создайте streaming proxy к OpenAI-compatible endpoint с логом на Drizzle. |
| 3 | Прочитайте документацию AI SDK и MCP TypeScript SDK. | Создайте tool-calling агента. Затем создайте MCP-сервер, который предоставляет один из его инструментов. |
Начните с бесплатных руководств Total TypeScript. Переходите к продвинутым материалам только при работе с generics уровня библиотек и conditional types. Пропускайте любые курсы «введение в JavaScript», а также всё, что связано с React, если этого не требует продукт.
Для обзора production-конвенций goldbergyoni/nodebestpractices представляет собой широкий чеклист, который поддерживает сообщество. Советы, влияющие на поведение рантайма или безопасность, проверяйте по актуальной документации Node.
Компромиссы
Оставляйте численные вычисления в Python
Node хорошо подходит для оркестрации, HTTP-сервинга и стриминга. Длительные CPU-bound вычисления блокируют его основной поток event loop. Оставляйте vLLM и код тренировки в Python, если только измерения не оправдывают перенос.
Валидируйте каждую границу в рантайме
Аннотация TypeScript не проверяет HTTP body, переменную окружения,
сгенерированный моделью аргумент инструмента или строку, прочитанную из jsonb.
Для каждой границы нужна runtime-схема.
Изолируйте churn SDK
AI SDK 6 заменил Experimental_Agent на ToolLoopAgent и переименовал
настройку агента system в instructions
(migration guide AI SDK 6). Companion-репозиторий напрямую
вызывает streamText в AI SDK 7 и предоставляет собственный поток
AgentEvent. Эта граница оставляет HTTP-роут неизменным при изменениях SDK.
Пропускайте написанный вручную цикл, если важнее срок
Написание цикла один раз показывает, каким поведением управляет SDK. Если нужно сначала выпустить сервис и нет причин кастомизировать ошибки валидации, начинайте с SDK.
Ключевые выводы
- Установите Node 24 и pnpm. Затем используйте скрипты репозитория:
pnpm demo,pnpm dev:api,pnpm dev:workerиpnpm check. Скрипты скрывают низкоуровневые команды рантайма и проверки типов. - Runtime-валидация структурно необходима. В этом проекте выбран Zod. Статические типы
не проверяют HTTP body, переменные окружения, вывод модели или строки из базы данных.
С Zod объявляйте runtime-схему и выводите тип TypeScript с помощью
z.infer. - Стек в основном сопоставляется напрямую: pnpm для uv, Hono для FastAPI, Drizzle для SQLAlchemy, Vitest для pytest, Biome для Ruff. Три строки не являются заменами: валидация, проверка типов и очередь задач.
- Напишите один агентный цикл вручную, если нужно изучить или кастомизировать скрытые пути: накопление фрагментов, валидацию и feedback от ошибок инструментов.
- Сделайте цикл асинхронным генератором. HTTP-роут потребляет его значения
AgentEvent, отправляет SSE-фреймы, накапливает текст и сохраняет его после стрима. Тесты отдельно потребляют генератор без сети и API-ключа. - Postgres может предоставить очередь at-least-once. Делайте хендлеры идемпотентными, а для долгих задач продлевайте lease или задавайте её срок выше максимального runtime. Добавляйте BullMQ, если нужны задержки, приоритеты или расписания.
- Используйте поставляемый Dockerfile для production. Он упаковывает выбранное приложение,
загружает TypeScript через
tsx, запускается от non-root пользователя и передаёт shutdown-сигналы процессу API.
Ссылки
Демо-репозиторий
- slavadubrov/typescript-agent-service — монорепозиторий, используемый во всей статье: Hono API с SSE, две реализации агентного цикла, storage на Drizzle, воркер, MCP-сервер и 40 тестов
Рантайм и язык
- Нативный запуск TypeScript в Node.js — ограниченная поддержка TypeScript в Node и ограничение
node_modules - Опции компилятора TypeScript —
strictи другие проверки, настроенные в companion-репозитории - MDN JavaScript Guide — справочник по языку, который стоит держать открытым
- javascript.info — современный учебник по JavaScript. Прочитайте главы о modules и promises.
Инструменты
- pnpm и установка pnpm — пакетный менеджер, workspaces и настройка
- Biome — lint, format и сортировка импортов одним бинарником
- Vitest — test runner, которому не нужна конфигурация трансформаций
- Total TypeScript — бесплатные руководства и платный трек по продвинутым типам
Библиотеки
- Zod — валидация схем и вывод типов. В версии 4 есть
z.toJSONSchema. - Hono — HTTP-фреймворк на Web-стандартах
- Drizzle ORM — TypeScript ORM с SQL-first подходом и миграциями
drizzle-kit - Документация PostgreSQL SELECT — locking clause
FOR UPDATE ... SKIP LOCKED - BullMQ — очередь на Redis для случаев, когда таблицы в базе уже недостаточно
AI и агенты
- Vercel AI SDK —
streamText,tool,stopWhenи адаптеры провайдеров - openai/openai-node — официальный TypeScript-клиент
- MCP TypeScript SDK и спецификация MCP — создание серверов и клиентов
Конвенции
- goldbergyoni/nodebestpractices — поддерживаемый сообществом чеклист production-конвенций