TypeScript для Python ML-инженеров: создаём агентный сервис
Автоматический перевод Эта статья была автоматически переведена с оригинальной английской версии.
Это краткое руководство по быстрому онбордингу для опытных Python-инженеров, которым нужно выпускать AI-сервисы на TypeScript и Node. Оно рассчитано на ML-инженеров, дата-сайентистов и бэкенд-разработчиков, которым не нужен вводный курс по JavaScript.
За последние несколько месяцев я сам прошёл такой онбординг после работы с Python и Java. Большинство найденных мной руководств начинались с базового программирования или работы с DOM во фронтенде. Эта статья отталкивается от концепций Python-сервисов. К концу вы сможете сопоставлять стек Python-сервиса со стеком TypeScript и распознавать привычки из Python, которые приводят к багам в JavaScript. Сквозной пример — стриминговый агентный сервис: от схемы до деплоя.
из репозитория. Запускайте `pnpm demo` для офлайн-примера, `pnpm dev:api` и `pnpm dev:worker` для разработки, а `pnpm check` — перед коммитом. Вам не нужно самостоятельно запускать `node`, `tsx` или проверку TypeScript. Это делают скрипты пакета.Сервис использует Zod, Hono, Drizzle, Vitest и Biome. Они покрывают значительную часть задач, которые в Python решают 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 здесь нет. Браузерного бандлера тоже нет.
Сначала запустите репозиторий-компаньон
Установите Node 24 и pnpm согласно официальной инструкции по установке pnpm. Затем склонируйте репозиторий-компаньон и выполните:
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 и умеет устанавливать пакеты из этого реестра. - 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. Брокер может не понадобиться |
Упомянуты четыре библиотеки, которые стоит объяснить.
Hono для HTTP-слоя
Express и Fastify — альтернативы, ориентированные на Node. Hono использует Web-стандартные
Request и Response API и
предоставляет адаптеры для Node и serverless-рантаймов. Для небольшого стримингового API
такая переносимость полезна, поэтому я выбрал Hono.
Drizzle для SQL
Drizzle хранит схему в TypeScript и не требует шага генерации клиента. При этом он позволяет использовать raw SQL, когда query builder не может аккуратно выразить конструкцию Postgres. Prisma я бы выбрал в случае, если сгенерированный клиент и окружающий туллинг лучше подходят команде.
Biome для линтинга и форматирования
Biome выполняет линтинг, форматирование и сортировку импортов одним бинарником и одним конфигурационным файлом. Оставляйте ESLint, если проект зависит от кастомных правил, которых нет в Biome.
Vitest для тестов
Vitest запускает тесты .ts
репозитория-компаньона без отдельной конфигурации трансформаций.
Читаем синтаксис 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:
from enum import Enum
class Status(str, Enum):
QUEUED = "queued"
RUNNING = "running"
Достаточно распознавать этот паттерн. as const сохраняет значения объекта как
точные строки, не расширяя их до произвольного string.
Семь семантических различий, которые отнимают время
Таблицы синтаксиса достаточно для чтения примеров. Именно семантические различия становятся источником багов при переносе привычек из Python.
1. Пустые массивы и объекты truthy
Привычка из Python считать пустой контейнер falsy переносится хуже всего. if (results)
истинно для пустого массива. Пишите 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.
Репозиторий-компаньон включает 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. Promise запускается сразу
Вызов функции async начинает выполнять её тело и возвращает promise.
Объект-корутина Python ничего не делает, пока вы не вызовете await или не запланируете её.
Promise.all близок к asyncio.gather. Promise.allSettled близок к gather(..., return_exceptions=True),
но каждый результат обёрнут в { status, value } или { status, reason }.
Node управляет планированием рантайма. Он поддерживает процесс, пока существуют активные
handle или запросы — например, таймеры и сокеты. Один обычный ожидающий promise не удерживает
Node живым. Оборачивать программу в asyncio.run не нужно. В ES-модуле можно
использовать 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 {} создаёт объект. Объекты обычно представляют записи
с именованными полями:
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, который делит код с другими файлами, называется модулем.
Этот проект использует современный формат модулей — 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-клиента и завершается ошибкой с меньшим
объёмом контекста. Валидация схемой сдвигает ошибки ближе к источнику, но разрешительная схема
будет разрешать всё, что вы в ней указали.
В Zod 4 также есть z.toJSONSchema, поэтому этому проекту не нужна зависимость
zod-to-json-schema, часто встречающаяся в старых руководствах. Это важно, когда одна
схема должна обслуживать трёх потребителей, о чём рассказывается в разделе «Один инструмент,
три потребителя».
Сервис
Демо-сервис оценивает конфигурации LLM-деплоя. Один инструмент ищет константы архитектуры модели. Другой оценивает объём KV cache — память GPU, используемую для хранения ключей и значений attention у запросов, находящихся в работе. Оба инструмента намеренно сводятся к простой арифметике. Им не нужна сеть, и они каждый раз выдают один и тот же результат. Благодаря этому сервис можно тестировать без 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-раннер или конфигурация сборки автора пакета.
Напишите агентный цикл вручную — один раз
Фреймворки для tool-calling агентов оборачивают один и тот же базовый цикл:
- Вызвать модель с описаниями инструментов.
- Валидировать и запустить запрошенные инструменты.
- Добавить результаты в сообщения.
- Снова вызвать модель.
Напишите этот цикл один раз. Тогда поведение фреймворка станет инженерным выбором, который можно обосновать.
Цикл — это async function*, асинхронный генератор, точный аналог
async def в Python с yield. HTTP-роут итерирует этот генератор,
превращает каждое событие в Server-Sent Events-фрейм и накапливает текст. После завершения
стрима роут один раз вызывает storage.createRun с финальным текстом. Тесты вызывают цикл
отдельно и собирают его события в массив. SSE-фрейм — это один чанк долгоживущего HTTP-ответа.
Формат описан в следующем разделе.
У цикла есть два штатных выхода. Ответ без tool calls выдаёт done с
stopReason: "stop". Если модель продолжает запрашивать инструменты до maxSteps,
генератор всё равно завершается и выдаёт done с stopReason: "max_steps".
Считайте такой результат завершённым, но усечённым: накопленный текст может быть неполным.
Из 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 на следующем шаге или в финальном событии 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,
};
}
До выполнения поиск может не найти инструмент, JSON.parse может отклонить аргументы,
а Zod — их структуру. Каждая такая ошибка превращается в сообщение, которое читает модель.
Во время выполнения ожидаемый ToolError также становится результатом инструмента,
чтобы модель могла исправить вызов. Неожиданное исключение передаётся в HTTP error path,
а не представляется как доменная ошибка.
z.prettifyError превращает дерево ошибок Zod в сообщение, с которым модель может работать,
вместо stack trace.
strict: true в определении функции OpenAI
просит провайдера ограничить decoding схемой. Это не связано с флагом strict в
tsconfig TypeScript. Механизм похож на guided decoding в vLLM, хотя поддерживаемые схемы и
детали enforcement различаются. Он устраняет один класс ошибок, но self-hosted endpoint может
проигнорировать флаг. Аргументы также должны пройти через JSON.parse.
Цикл вызывает /chat/completions, поскольку репозиторий-компаньон ориентирован на
OpenAI-compatible серверы. vLLM,
SGLang и
Ollama документируют этот
endpoint, поэтому OPENAI_BASE_URL может направить одного и того же клиента к любому из
них. Поддержка Responses API различается и меняется от релиза к релизу. Если вы контролируете
обе стороны, проверьте текущую страницу совместимости сервера, прежде чем выбирать между
двумя API.
Затем перейдите на AI SDK — и поймите, чем пожертвовали
В следующих проектах я бы использовал Vercel AI SDK. В репозитории-компаньоне один
и тот же агент реализован двумя способами, чтобы компромисс был виден. Обе версии выдают
одинаковый поток AgentEvent, поэтому HTTP-слой не видит между ними разницы.
В репозитории-компаньоне 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и обработку его ошибок- вызов, запускающий Zod для разобранных аргументов
- сборку сообщений, специфичную для провайдера
- счётчик шагов
stopWhen принимает несколько условий, включая лимит шагов или конкретный
tool call. Цикл for await не меняется при изменении политики остановки.
Цена — меньший прямой контроль над ошибками валидации. Написанный вручную цикл решает,
что увидит модель после отклонённого вызова. SDK предоставляет экспериментальную опцию
experimental_repairToolCall для кастомного callback исправления.
Текущий репозиторий-компаньон эту опцию не задаёт и полагается на стандартную обработку
некорректных вызовов в SDK.
Компромисс работает и в другую сторону. В версии на SDK смена провайдера локализована в адаптере провайдера. Но всё равно потребуются соответствующий пакет провайдера, учётные данные, конфигурация и интеграционные тесты. В raw loop код обработки запросов и стриминга, специфичный для провайдера, менять придётся вам.
В первом проекте я пишу цикл вручную, а в последующих использую 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 },
});
});
});
Сейчас роут одинаково обрабатывает любой генератор, завершившийся штатно. Он сохраняет
status: "succeeded" после любого генератора, дошедшего до конца, в том числе если его
финальное событие содержит stopReason: "max_steps". Поэтому результат на предельном числе шагов
считается завершённым, но усечённым или частичным, а не уже сохранённой ошибкой. В production-коде
следует проверять событие done и применять явную политику. Например, можно
использовать отдельный статус truncated или путь на ревью/повтор перед сообщением об успехе.
zValidator валидирует body и даёт c.req.valid("json") тип, который производит
схема. Если пропустить этот шаг, body получает тип any — режим отказа от
проверок в TypeScript, при котором любой доступ к свойству компилируется без проверки. Это
отключает преимущество типобезопасности схемы.
В этом роуте используются Server-Sent Events, а не WebSockets. Сервер держит HTTP-ответ
открытым, пока записывает фреймы event: <name> и data: <json>, а затем
закрывает его после финального события. Трафик идёт от сервера к клиенту, что соответствует
этому агентному стриму. WebSocket добавил бы двусторонний обмен сообщениями и protocol
upgrade, которые этому роуту не нужны.
Ошибка в середине стрима меняет обработку HTTP-ошибок. После отправки первого фрейма со статусом
200 сервер не может заменить этот ответ на 500. Блок catch логирует пойманную
ошибку, отправляет клиенту постоянное error-событие и возвращает управление. Этот return важен:
только успешно завершившийся стрим доходит до storage.createRun. Генератор, который выдаёт
done, включая max_steps, считается штатно завершённым и доходит до
этой записи.
Этот путь покрыт тестом. Генератор выдаёт один text delta, а затем выбрасывает исключение.
Ответ остаётся со статусом 200, а его последний фрейм — событие error с
постоянным сообщением Agent run failed. Логгер сохраняет пойманную ошибку для диагностики
на сервере. Клиент, который проверяет только статус-код, сообщит об успешном завершении
неудачного запуска.
app.ts принимает ещё два небольших, но важных решения. Он считает /healthz
liveness endpoint, поэтому этот роут намеренно не обращается к Postgres. Сбой liveness во время
аварии базы данных мог бы перезапустить все реплики, не восстановив зависимость. Добавьте
отдельную readiness-проверку, если оркестратору нужно прекратить направлять трафик на
инстанс, который не может достучаться до Postgres. В error path ошибка логируется, но клиенту
возвращается постоянная строка. Эхоирование error.message в тело ответа — так строки
подключения оказываются в чужом браузере.
Часть в форме Celery, но без Celery
Долгие задачи не должны выполняться в request handler. API вставляет строку и возвращает 202. Воркер забирает строку.
Здесь нет Redis и нет BullMQ. PostgreSQL документирует SKIP LOCKED для
нескольких потребителей таблицы, играющей роль очереди.
Этот оператор даёт небольшому сервису очередь at-least-once в одной таблице. Она
транзакционна с остальными записями и добавляет на одну службу меньше в docker-compose.yml.
Запрос 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 });
Строка блокируется на время транзакции, а параллельный воркер с тем же запросом пропускает
её вместо блокировки. Поэтому два одновременных claim не получают одну и ту же актуальную
строку. Интеграционный тест одновременно запускает два claim через Promise.all и
проверяет, что они возвращают разные строки. Наивная версия — SELECT ... LIMIT 1, а затем
UPDATE — этот тест не проходит: обе транзакции читают одну строку до того,
как любая из них успевает её обновить, и обе запускают одну задачу.
Это выполнение at-least-once, а не exactly-once. Полный запрос также повторно забирает строку
со статусом running, если её блокировка старше пяти минут; демо-воркер не
продлевает этот lease. Поэтому живая задача, работающая дольше пяти минут, может быть забрана
дважды. Делайте задачи идемпотентными. Для долгих задач добавьте heartbeat lease или задайте
порог устаревания выше максимального времени выполнения.
Добавьте BullMQ, если нужны отложенные задачи, повторяемые расписания, приоритеты, rate limits или дашборд. В 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 — строка. Воркер завершает
запуск с ошибкой и продолжает polling, вместо того чтобы падать и бесконечно повторять ту же
poison row.
CPU-работа показывает ещё одно ограничение Node. Синхронный callback выполняется в потоке
event loop и не прерывается принудительно. Цикл for, который две секунды
занят арифметикой, блокирует на эти две секунды каждый запрос, таймер и liveness-проверку
процесса. Плотный цикл внутри async def точно так же блокирует asyncio. Оба
рантайма требуют явно выносить CPU-работу.
await setTimeout(0) из node:timers/promises (префикс node: означает
standard library, поэтому node:timers для Node — то же, что os
для Python) — это await asyncio.sleep(0). Sweep уступает управление после каждого размера
батча, чтобы процесс воркера мог обслужить таймеры и другие callback. Yield не делает
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 — повредит
сообщение. Клиент отключится с ошибкой парсинга, в которой не будет имени файла. Все
диагностические сообщения отправляйте в 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 репозитория-компаньона. Для реального клиента используйте 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,
возвращающим скриптовый 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
не остановит её. Файл тестов выполняется заметно быстрее секунды, без сети и ключа.
Интеграционные тесты с Postgres используют describe.skipIf(!process.env.DATABASE_URL), поэтому pnpm test
работает в свежем клоне без запущенного Postgres, а CI включает их, передавая переменную.
В репозитории 40 тестов. Тридцать шесть запускаются без Postgres и API-ключа.
Логируем структурированные события через Pino
Pino выполняет ту же роль, что и structlog: один JSON-объект на строку,
дочерние логгеры с привязанными полями и явная редактировка чувствительных данных.
Репозиторий-компаньон настраивает его в
packages/observability/src/logger.ts:
const log = pino({
redact: {
paths: [
"req.headers.authorization",
"apiKey",
"OPENAI_API_KEY",
"*.apiKey",
],
censor: "[redacted]",
},
});
Без редактирования log.info({ req }, "...") может скопировать Authorization header
в бэкенд логирования.
Трассируем работу приложения ручными спанами
Репозиторий-компаньон использует OpenTelemetry для трёх спанов уровня приложения:
agent.run, agent.tool и worker.sweep. Автоматическая
инструментация 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 нет синтаксиса контекстного менеджера в стиле Python. Здесь callback — это блок, который в Python окружал бы контекстный менеджер. Полный helper также записывает исключения и устанавливает статус спана перед повторным выбрасыванием ошибки.
Автоматические спаны HTTP и базы данных — отдельная возможность. Для них нужны соответствующие пакеты инструментации и инициализация до загрузки инструментированных модулей. Добавляйте это только когда такие спаны действительно полезны, а точные версии пакетов сверяйте с настройкой OpenTelemetry Node SDK, используемой при деплое.
Деплойте монорепозиторий в Docker
Используйте поставляемый
Dockerfile.
Контейнер запускает API с loader tsx. При деплое не нужно выбирать
или вручную вызывать TypeScript-раннер.
Сборка использует pnpm fetch, поэтому загрузки зависимостей остаются в кэше,
пока не изменится lockfile. Затем pnpm deploy копирует API и его production-зависимости
в самодостаточный каталог. Runtime-stage запускается от имени пользователя без root-прав
node, а exec-форма CMD позволяет API напрямую получать
SIGTERM для graceful shutdown.
Зачем Dockerfile загружает tsx
Node 24 умеет выполнять ограниченное подмножество TypeScript, удаляя аннотации типов.
Он не проверяет типы и не выполняет преобразования, которые поддерживает полноценный
TypeScript-раннер. Скрипты пакета скрывают эту деталь.
pnpm check выполняет отдельную статическую проверку.
У контейнера есть ещё одно ограничение. pnpm deploy копирует workspace-пакеты в
node_modules, а Node намеренно отказывается удалять TypeScript из таких файлов
(документация Node по TypeScript). Первая версия
образа падала с ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Она работает.
Я был обескуражен.
Dockerfile исправляет проблему, загружая tsx, который обрабатывает эти
.ts-файлы до их выполнения Node. Команда, которая хочет иметь в runtime-образе
только .js-файлы, может вместо этого добавить шаг компиляции. Это
альтернативный production-дизайн, а не дополнительный шаг, необходимый для запуска
этого репозитория-компаньона.
План на три недели
Опытные Python-инженеры могут пропустить материал о переменных и циклах. Эта последовательность сосредоточена на отличиях от Python. Колонка Build показывает цель каждой недели, а чтение поддерживает практику.
| Неделя | Читать | Собрать |
|---|---|---|
| 1 | javascript.info: только модули, promises и объекты. JS Guide на MDN держите как справочник. | Перепишите один Python CLI на TypeScript. Добавьте скрипт package.json. Запустите скрипт и pnpm typecheck. |
| 1–2 | Прочитайте справочник tsconfig TypeScript, бесплатные уроки Total TypeScript и документацию Zod. | Создайте модуль конфигурации с валидацией Zod и один tagged union. После проверки tag компилятор знает, какой вариант находится в блоке. |
| 2 | Прочитайте документацию Hono, Drizzle, Vitest и Biome. | Создайте стриминговый прокси к OpenAI-compatible endpoint с логированием через Drizzle. |
| 3 | Прочитайте документацию AI SDK и MCP TypeScript SDK. | Создайте tool-calling агента. Затем создайте MCP-сервер, публикующий один из его инструментов. |
Начните с бесплатных уроков Total TypeScript. Платите за продвинутые материалы только при работе с library-grade generics и conditional types. Пропускайте все курсы «введение в JavaScript» и всё, связанное с React, если этого не требует продукт.
Для обзора production-соглашений используйте goldbergyoni/nodebestpractices — широкий community-maintained checklist. Советы, влияющие на поведение рантайма или безопасность, проверяйте по актуальной документации 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 (руководство по миграции
AI SDK 6). Репозиторий-компаньон напрямую вызывает 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, переменные окружения, output модели или строки из базы данных.
В Zod объявите runtime-схему и выведите TypeScript-тип через
z.infer. - Стек в основном сопоставляется напрямую: pnpm вместо uv, Hono вместо FastAPI, Drizzle вместо SQLAlchemy, Vitest вместо pytest, Biome вместо Ruff. Три строки не являются прямыми заменами: валидация, проверка типов и очередь задач.
- Напишите один агентный цикл вручную, если нужно изучить или кастомизировать скрытые пути: накопление фрагментов, валидацию и обратную связь через ошибки инструментов.
- Сделайте цикл асинхронным генератором. HTTP-роут потребляет его значения
AgentEvent, выдаёт SSE-фреймы, накапливает текст и сохраняет его после стрима. Тесты потребляют генератор отдельно, без сети и API-ключа. - Postgres может предоставить очередь at-least-once. Делайте обработчики идемпотентными, а lease продлевайте или задавайте его длительность выше максимального времени выполнения для долгих задач. Добавляйте BullMQ, когда нужны задержки, приоритеты или расписания.
- Используйте поставляемый Dockerfile для production. Он пакует выбранное приложение,
загружает TypeScript через
tsx, запускается от имени пользователя без root-прав и передаёт сигналы завершения процессу API.
Ссылки
Репозиторий демо
- slavadubrov/typescript-agent-service — монорепозиторий, используемый во всей статье: Hono API с SSE, две реализации агентного цикла, хранилище на Drizzle, воркер, MCP-сервер и 40 тестов
Рантайм и язык
- Нативный запуск TypeScript в Node.js — ограниченная поддержка TypeScript в Node и ограничение
node_modules - Опции компилятора TypeScript —
strictи другие проверки, настроенные в репозитории-компаньоне - JavaScript Guide на MDN — справочник по языку, который стоит держать открытым
- javascript.info — современный учебник по JavaScript. Прочитайте главы о модулях и promises.
Туллинг
- pnpm и установка pnpm — пакетный менеджер, workspaces и настройка
- Biome — линтинг, форматирование и сортировка импортов одним бинарником
- Vitest — тестовый раннер без конфигурации трансформаций
- Total TypeScript — бесплатные уроки и платный трек по продвинутым типам
Библиотеки
- Zod — валидация схем и вывод типов. В версии 4 есть
z.toJSONSchema. - Hono — HTTP-фреймворк на Web-стандартах
- Drizzle ORM — TypeScript ORM с SQL-first-подходом и миграциями
drizzle-kit - Документация PostgreSQL SELECT — оператор блокировки
FOR UPDATE ... SKIP LOCKED - BullMQ — очередь на Redis, когда таблицы базы данных уже недостаточно
AI и агенты
- Vercel AI SDK —
streamText,tool,stopWhenи адаптеры провайдеров - openai/openai-node — официальный TypeScript-клиент
- MCP TypeScript SDK и спецификация MCP — создание серверов и клиентов
Соглашения
- goldbergyoni/nodebestpractices — community-maintained checklist production-соглашений