Tradução automática Este artigo foi traduzido automaticamente a partir da versão original em inglês.

TypeScript para engenheiros de ML Python: criar um serviço de agentes

Este é um guia rápido de integração para engenheiros Python experientes que precisam de colocar serviços de IA em produção com TypeScript e Node. Destina-se a engenheiros de ML, cientistas de dados e developers backend que não precisam de um curso introdutório de JavaScript.

Fiz esta integração nos últimos meses, depois de trabalhar com Python e Java. A maioria dos guias que encontrei começava pela programação básica ou pelo trabalho de front-end com o DOM. Este artigo parte dos conceitos de serviços Python. No final, conseguirá estabelecer o correspondente entre uma stack de serviços Python e TypeScript e reconhecer os hábitos de Python que causam bugs em JavaScript. O exemplo acompanha um serviço de agentes com streaming, desde o schema até ao deployment.

Resumo: Instale Node 24 e pnpm. Depois, use os comandos pnpm do repositório. Execute pnpm demo para o exemplo offline, pnpm dev:api e pnpm dev:worker para desenvolvimento, e pnpm check antes de fazer um commit. Não precisa de executar node, tsx nem o verificador de TypeScript manualmente. Os scripts do package tratam disso.

O serviço usa Zod, Hono, Drizzle, Vitest e Biome. Estas ferramentas cobrem grande parte do mesmo espaço que pydantic, FastAPI, SQLAlchemy, pytest e Ruff. Mantenha os cálculos numéricos pesados em Python. Use este serviço TypeScript para orquestração, HTTP e streaming.

Tudo o que é apresentado aqui corresponde a um ficheiro em slavadubrov/typescript-agent-service, o repositório complementar publicado com este artigo. Contém uma API HTTP, duas versões do mesmo loop de agentes, histórico de execuções em Postgres, um worker e um servidor MCP. pnpm install && pnpm demo executa o percurso offline do agente HTTP/SSE e o cálculo de sweep do worker sem uma API key.

Abordo apenas trabalho de backend e IA. Não há React. Também não existe um bundler de browser.


Execute primeiro o repositório complementar

Instale Node 24 e pnpm seguindo as instruções oficiais de instalação do pnpm. Depois, clone o repositório complementar e execute:

pnpm install
pnpm demo
pnpm check

pnpm demo testa o handler HTTP, o loop de agentes e o stream SSE com um modelo scripted. Também chama o cálculo runSweep do worker. Não inicia o processo do worker nem a respetiva fila na base de dados. A demonstração não precisa de API key, base de dados nem Docker. pnpm check executa o verificador de tipos, o linter, a verificação do formatter e os testes.

Para executar a API e o worker reais:

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

Estes são os comandos que uso no resto do artigo. O repositório coloca os comandos de nível inferior do Node e do TypeScript atrás de scripts pnpm com nomes, tal como um projeto Python pode colocar comandos uv run atrás de targets make. Não misture npm install neste repositório pnpm. Use pnpm install para que pnpm-lock.yaml continue a ser o único lockfile.


O papel de Node, npm, pnpm, TypeScript e tsx

Os nomes semelhantes escondem funções distintas:

Neste repositório, execute os scripts pnpm. O Node é o runtime dentro desses scripts e o Dockerfile fornecido trata da produção.


A stack, mapeada a partir de Python

Duas colunas mapeiam cada preocupação de um serviço de IA em Python para o seu equivalente em TypeScript. As linhas abrangem metadados do projeto, pacotes, validação, HTTP, SQL, filas, testes, linting e verificações de tipos. Três linhas não são substituições diretas.

A maior parte do mapeamento é pouco interessante, o que é uma boa notícia. Há três exceções:

PreocupaçãoPythonTypeScriptPor que não é uma substituição direta
ValidaçãopydanticzodO schema é a fonte de verdade. O tipo é gerado a partir dele, e não o contrário
Verificação de tiposmypyTypeScript (pnpm typecheck)Ambos verificam o código-fonte sem validarem os dados que chegam em runtime
Fila de jobscelery + Redisbullmq (fila suportada por Redis) ou SQLO Postgres pode implementar uma fila at-least-once. Talvez não precise de um broker

O projeto complementar utiliza quatro bibliotecas que vale a pena explicar.

Hono para a camada HTTP

Express e Fastify são alternativas centradas no Node. O Hono utiliza as APIs Web-standard Request e Response e fornece adapters para Node e runtimes serverless. Essa portabilidade é útil para esta pequena API de streaming, por isso escolhi o Hono.

Drizzle para SQL

Drizzle mantém o schema em TypeScript e não requer um passo de geração de cliente. Também disponibiliza SQL raw quando o query builder não consegue expressar corretamente uma cláusula de Postgres. Eu escolheria o Prisma quando o cliente gerado e o tooling envolvente se adequassem melhor à equipa.

Biome para linting e formatação

Biome trata do linting, da formatação e da ordenação de imports com um único binário e um único ficheiro de configuração. Mantenha o ESLint quando o projeto depender de regras personalizadas que o Biome não disponibiliza.

Vitest para testes

Vitest executa os testes .ts do companion sem configuração de transformação separada.


Leia a sintaxe TypeScript usada abaixo

Mantenha esta tabela junto aos exemplos de serviços para referência.

TypeScriptPython / nota
(x) => expressionfunção anónima com um corpo baseado numa expressão, semelhante a lambda x: expression
(x) => { statements }função anónima com um corpo baseado numa instrução
async (x) => { statements }função anónima assíncrona
const { model, seqLen } = requestextrai as propriedades model e seqLen de request
const [first] = xsfirst = xs[0]. Produz undefined, e não IndexError, quando está vazio
{ type: "error", message }{"type": "error", "message": message}. Um nome simples torna-se esse campo
text ${x}f-string
cond ? a : ba if cond else b
const / letAmbos associam um nome. const impede a reatribuição, enquanto let a permite
exporttorna um nome importável
switch / casematch, exceto que os casos prosseguem para o seguinte, a menos que terminem em break ou return
for awaititeração sobre um gerador assíncrono
i++incrementa e devolve o valor antigo
/^https?$/literal de expressão regular, sem necessidade de re.compile
T[], Map<K, V>list[T], dict[K, V]

Use const, exceto quando a binding tiver de mudar. Use let para um contador, acumulador ou outra binding que vá reatribuir.

Uma tradução breve de enum

Para estados representados por strings, este repositório utiliza um objeto e um tipo de união de strings inferido:

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

O objeto fornece Status.Queued durante a execução do programa. A linha type permite apenas "queued" ou "running" durante a verificação de tipos. Em conjunto, preenchem as duas funções desta declaração Python:

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

Só precisa de reconhecer o padrão. as const mantém os valores do objeto como strings exatas, em vez de os alargar para qualquer string.


As sete diferenças semânticas que fazem perder tempo

A tabela de sintaxe ajuda a ultrapassar os exemplos. É nestas diferenças semânticas que os hábitos de Python causam erros.

1. Arrays e objetos vazios são truthy

A ideia de Python de que um “contentor vazio é falsy” é o hábito que pior se transfere. if (results) é true para um array vazio. Escreva if (results.length).

2. null e undefined são diferentes

null normalmente assinala uma ausência intencional. undefined normalmente significa que um valor está em falta ou não foi atribuído, embora o código possa atribuí-lo explicitamente. O código de bibliotecas devolve undefined constantemente. A diferença torna-se problemática quando define um valor predefinido. || substitui o lado esquerdo sempre que este é falsy. Isso inclui 0, "" e false. ?? substitui apenas null e undefined. Assim, 0 || 10 é 10, enquanto 0 ?? 10 é 0. É essa diferença que faz com que um tamanho de batch igual a zero se transforme silenciosamente em dez.

3. Um bloco catch recebe unknown

Não existe except ValueError:. Um único bloco catch recebe tudo. Como JavaScript permite lançar uma string, um número ou null, o TypeScript tipa o valor capturado como unknown, o seu tipo “pode ser literalmente qualquer coisa” sob strict. O complemento permite strict, e os projetos novos devem geralmente ativá-lo. Para inspecionar o erro, restrinja primeiro o tipo do valor:

try {
    await risky();
} catch (error) {
    // `error` is `unknown` until you prove otherwise. This line is the
    // TypeScript equivalent of `except ValueError as e:` and it is not
    // optional.
    const message = error instanceof Error ? error.message : String(error);
}

4. As Promises começam imediatamente

Chamar uma função async começa a executar o seu corpo e devolve uma promise. Um objeto coroutine de Python não faz nada até ser aguardado ou agendado. Promise.all é semelhante a asyncio.gather. Promise.allSettled é semelhante a gather(..., return_exceptions=True), exceto pelo facto de cada resultado ser encapsulado como { status, value } ou { status, reason }.

O Node gere o agendamento em runtime. Mantém o processo ativo enquanto ainda existirem handles ou pedidos ativos, como timers e sockets. Uma promise pendente comum, por si só, não mantém o Node ativo. Não envolve o programa em asyncio.run. Num módulo ES, pode usar await no nível superior quando o arranque tiver de aguardar uma operação assíncrona.

5. JavaScript tem um único tipo numérico comum

O tipo number de JavaScript armazena valores como números de vírgula flutuante de 64 bits, aproximadamente como o float do Python. A norma técnica deste formato chama-se IEEE 754. Os valores decimais são aproximados, por isso 0.1 + 0.2 não é exatamente 0.3, e os inteiros só permanecem exatos até 2**53 - 1, ou 9,007,199,254,740,991.

Mantenha os IDs de 64 bits como strings nas fronteiras dos serviços. Converter um bigint do Postgres para um number do JavaScript pode arredondá-lo. Para inteiros exatos maiores, o JavaScript disponibiliza o tipo separado BigInt, que não se combina com os números comuns.

6. Use Map quando precisar de um dicionário ao estilo do Python

Em JavaScript, {} cria um objeto. Os objetos representam normalmente registos com campos nomeados:

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

Um objeto não é uma tabela limpa de chave-valor como um dict do Python. Herda alguns nomes do próprio JavaScript. Isto pode produzir um resultado surpreendente:

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

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

Se uma string externa selecionar um campo de um objeto, chame Object.hasOwn antes de o ler. Se precisar de um dicionário de uso geral, utilize Map. Map é mais próximo de dict do Python: uma chave só existe quando o seu código a adiciona.

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

7. Inclua a extensão nas importações relativas

Um ficheiro de código-fonte JavaScript que partilha código com outros ficheiros chama-se módulo. Este projeto utiliza o formato moderno de módulos, os módulos ES, normalmente abreviado como ESM. ES significa ECMAScript, o nome formal da linguagem JavaScript. Na prática, ESM é a sintaxe import e export utilizada em todo o projeto.

Numa importação relativa, o Node requer o nome de ficheiro exato. Não tenta adivinhar se ./env significa ./env.ts ou ./env.js:

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

As importações de pacotes instalados ou do workspace continuam a utilizar o nome do pacote, sem extensão de ficheiro:

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

Zod é pydantic com a seta invertida

No pydantic, declara uma classe e obtém um validador. No Zod, declara um validador e deriva dele o tipo. A mesma fonte única de verdade, mas na direção oposta.

De packages/schemas/src/env.ts:

import { z } from "zod"; // `z` is Zod's whole API, the way `pd` is pandas

const EnvSchema = z.object({
    PORT: z.coerce.number().int().positive().default(8080),
    // See the note below: a bare z.url() would accept "localhost:8000".
    OPENAI_BASE_URL: z
        .url({ protocol: /^https?$/ })
        .default("https://api.openai.com/v1"),
    DATABASE_URL: z.string().optional(),
});

export type Env = z.infer<typeof EnvSchema>;

z.infer<typeof EnvSchema> extrai o tipo estático do esquema em tempo de execução. z.coerce.number() trata do facto de cada valor definido em process.env (o os.environ do Node) ser uma string. Desempenha o mesmo papel que a coerção das definições numéricas do pydantic, embora as strings exatas aceites por cada um sejam diferentes. Isto segue o padrão pydantic-settings e é executado uma vez no arranque. Um ambiente inválido produz então um erro de arranque legível, em vez de um TypeError dentro de um handler.

Um z.url() simples aceita localhost:8000. O standard de URL trata tudo o que aparece antes dos dois pontos como o esquema. Por isso, interpreta localhost: como um protocolo chamado “localhost” e aceita a cadeia. O valor chega depois ao cliente HTTP e falha com menos contexto. A validação do esquema antecipa as falhas, mas aplicará um esquema permissivo se for isso que tiver escrito.

O Zod 4 também inclui z.toJSONSchema, pelo que este projeto não precisa da dependência zod-to-json-schema, comum em tutoriais mais antigos. Isto é importante quando um esquema tem de alimentar três consumidores, que é precisamente o tema da secção “Uma ferramenta, três consumidores” abaixo.


O serviço

Um workspace pnpm tem packages e apps. Os packages contêm esquemas, código de agentes e observabilidade. Um esquema alimenta o ciclo escrito à mão, o Vercel AI SDK e o servidor MCP. As apps contêm uma API Hono, um worker e um servidor MCP. A API e o worker partilham uma tabela de execuções em Postgres.

O serviço de demonstração dimensiona deployments de LLM. Uma ferramenta consulta as constantes da arquitetura de um modelo. A outra estima a sua ocupação de KV-cache: a memória da GPU usada para guardar as chaves e os valores de atenção dos pedidos em curso. Ambas as ferramentas fazem, deliberadamente, aritmética simples. Não precisam de rede e dão sempre o mesmo resultado. Isto torna o serviço testável sem uma chave de API. O estimador de KV-cache também é disponibilizado através do Model Context Protocol (MCP), para que outros clientes de IA o possam chamar.

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 é o ficheiro que declara o workspace. Os packages internos recebem um nome com scope, como @agent/core, em que o prefixo @agent/ é uma convenção de nomenclatura, não uma funcionalidade da linguagem. Cada package declara o seu ponto de entrada público em package.json. Essa fronteira entre packages não depende do comando que inicia a aplicação.

Este workspace privado aponta essas entradas para o código-fonte em .ts, porque todos os consumidores fazem parte do mesmo repositório. Os packages npm públicos normalmente publicam JavaScript e declarações de tipos .d.ts, para que os consumidores Node comuns não precisem do executor de TypeScript nem da configuração de build do autor do package.


Escreva o ciclo da ferramenta manualmente, uma vez

Uma etapa de um ciclo de agente. O modelo transmite chunks. O ciclo compõe as chamadas de ferramentas por índice, analisa o respetivo JSON e valida-as com Zod. As chamadas válidas executam a ferramenta. As chamadas inválidas produzem um erro que o modelo lê antes de o ciclo se repetir. O ciclo produz valores AgentEvent tipados. A rota HTTP emite uma frame SSE por evento, acumula texto e chama o armazenamento depois do stream. Os testes consomem o generator separadamente.

Os frameworks de agentes com tool calling encapsulam o mesmo ciclo básico:

  1. Chame o modelo com as definições das ferramentas.
  2. Valide e execute as ferramentas solicitadas.
  3. Acrescente os resultados às mensagens.
  4. Volte a chamar o modelo.

Escreva este ciclo uma vez. O comportamento do framework passa então a ser uma decisão de engenharia que pode justificar.

O loop é um async function*, um gerador assíncrono, com a forma exata de um async def do Python com yield. A rota HTTP itera esse gerador, transforma cada evento numa frame de Server-Sent Events e acumula o texto. Depois de o stream terminar, a rota chama storage.createRun uma vez com o texto final. Os testes invocam o loop separadamente e recolhem os respetivos eventos num array. Uma frame SSE é um fragmento de uma resposta HTTP de longa duração. A secção abaixo explica o formato.

De packages/agent-core/src/loop.ts:

let text = ""; // the assistant text produced during this model step
// Keyed by the `index` field, because a streamed response interleaves
// fragments of several parallel tool calls and only `index` is present
// on every fragment. `id` and `name` arrive once, `arguments` arrives
// in pieces. `a?.b` below reads `b` only if `a` exists, and gives back
// `undefined` instead of throwing if it doesn't.
const partial = new Map<number, PartialToolCall>();

for await (const chunk of stream) {
    const choice = chunk.choices[0];
    if (!choice) continue;

    if (choice.delta.content) {
        text += choice.delta.content;
        yield { type: "text", delta: choice.delta.content };
    }

    for (const fragment of choice.delta.tool_calls ?? []) {
        const slot = partial.get(fragment.index) ?? { id: "", name: "", args: "" };
        if (fragment.id) slot.id = fragment.id;
        if (fragment.function?.name) slot.name = fragment.function.name;
        if (fragment.function?.arguments) slot.args += fragment.function.arguments;
        partial.set(fragment.index, slot);
    }
}

A variável local text do loop contém um passo do modelo. O loop usa-a na mensagem do assistant para o passo seguinte ou no evento final done. Não é o acumulador ao nível da rota que é persistido mais tarde.

O mapa partial é a parte que os frameworks ocultam. O SDK expõe fragmentos de strings dos argumentos da função, que podem dividir o JSON serializado em posições arbitrárias. Várias chamadas paralelas também podem intercalar-se. O repositório tem um teste que divide {"model":"llama-3.1-8b",...} em quatro chunks.

A segunda coisa que vale a pena escrever é o que acontece quando a validação falha:

// `tool.parse` is this repo's wrapper, not Zod's. Zod's own `.parse` throws and
// `.safeParse` returns `{ success, data, error }`; this returns
// `{ ok: true, value }` on success and `{ ok: false, error }` on failure, so no
// caller has to catch.
const parsed = tool.parse(raw);
if (!parsed.ok) {
    return {
        ok: false,
        value: { error: `Invalid arguments: ${parsed.error}` },
        parsedArgs: raw,
    };
}

Antes da execução, a pesquisa pode não encontrar a tool, JSON.parse pode rejeitar os argumentos ou o Zod pode rejeitar a respetiva estrutura. Cada falha transforma-se numa mensagem que o modelo lê. Durante a execução, um ToolError esperado também se transforma num resultado da tool, para que o modelo possa corrigir a chamada. Uma exceção inesperada propaga-se para o caminho de erro HTTP, em vez de ser apresentada como uma falha do domínio. z.prettifyError transforma a árvore de problemas do Zod numa mensagem que o modelo pode usar, em vez de numa stack trace.

strict: true numa definição de função OpenAI pede ao provider que limite a descodificação ao schema. Não tem qualquer relação com a flag strict do tsconfig do TypeScript. Isto é semelhante ao guided decoding do vLLM, embora os schemas suportados e os detalhes da aplicação das restrições sejam diferentes. Remove um modo de falha, mas um endpoint self-hosted pode ignorar a flag. Os argumentos têm também de sobreviver a JSON.parse.

O loop chama /chat/completions porque o projeto complementar tem como alvo servidores compatíveis com OpenAI. vLLM, SGLang e Ollama documentam esse endpoint, pelo que OPENAI_BASE_URL pode apontar o mesmo cliente para qualquer um deles. A cobertura da Responses API é diferente e muda entre releases. Se controlar ambos os lados, consulte a página de compatibilidade atual do servidor antes de escolher entre as duas APIs.


Depois mude para o AI SDK e saiba o que trocou

Para projetos futuros, eu usaria o Vercel AI SDK. O projeto complementar implementa o mesmo agente duas vezes, para tornar explícito o compromisso. Ambas as versões emitem o mesmo stream AgentEvent, pelo que a camada HTTP não consegue distingui-las.

O projeto complementar fixa o AI SDK 7.0.42 em packages/agent-core/package.json. A sua implementação para o framework está em 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;
        // ...
    }
}

O SDK elimina cinco partes de código da aplicação:

stopWhen aceita várias condições, incluindo um limite de passos ou uma chamada específica a uma ferramenta. O ciclo for await não muda quando a política de paragem muda.

O que se perde é o controlo direto sobre falhas de validação. O ciclo escrito manualmente decide o que o modelo vê depois de uma chamada rejeitada. Na versão com o SDK, configura esse comportamento através de repairToolCall. A troca também funciona no sentido inverso. Na versão com o SDK, uma alteração de fornecedor fica localizada no adaptador do fornecedor. Continua a exigir o pacote do fornecedor correspondente, credenciais, configuração e testes de integração. No ciclo bruto, o tratamento de pedidos e streams específico do fornecedor é código seu que terá de alterar.

Escrevo manualmente o ciclo no primeiro projeto e uso o SDK nos seguintes. Paga-se essa aprendizagem uma vez. A alternativa é ler pela primeira vez os detalhes internos de um framework enquanto este falha em produção.


Streaming por HTTP: Hono e SSE

As rotas do Hono são semelhantes às rotas do FastAPI. A única adição é zValidator, que faz o trabalho que o FastAPI obtém gratuitamente a partir das anotações de tipo na assinatura de um handler. O c no handler abaixo é o contexto de pedido do Hono, o objeto que o FastAPI distribui pelos seus parâmetros. deps é um conjunto de dependências com que a aplicação é construída, em vez de serem importadas diretamente. runAgent é uma delas, e a secção de testes mostra o que isso permite.

De apps/api/src/app.ts:

app.post("/v1/chat", zValidator("json", ChatRequestSchema), (c) => {
    const body = c.req.valid("json");
    const log = deps.logger.child({ route: "chat" });

    return streamSSE(c, async (stream) => {
        let text = "";
        try {
            await withSpan(
                "agent.run",
                { "agent.max_steps": body.maxSteps },
                async () => {
                    for await (const event of deps.runAgent({
                        message: body.message,
                        maxSteps: body.maxSteps,
                    })) {
                        if (event.type === "text") text += event.delta;

                        await stream.writeSSE({
                            event: event.type,
                            data: JSON.stringify(event),
                        });
                    }
                },
            );
        } catch (error) {
            log.error({ err: error }, "agent run failed");
            await stream.writeSSE({
                event: "error",
                data: JSON.stringify({
                    type: "error",
                    message: "Agent run failed",
                }),
            });
            return;
        }

        await deps.storage.createRun({
            kind: "chat",
            status: "succeeded",
            input: { message: body.message },
            output: { text },
        });
    });
});

zValidator valida o corpo e fornece a c.req.valid("json") o tipo produzido pelo schema. Se o omitir, o corpo fica tipado como any, a opção de escape do TypeScript, em que qualquer acesso a propriedades compila e nada é verificado. Isto desativa o benefício de segurança de tipos do schema.

Esta rota usa Server-Sent Events em vez de WebSockets. O servidor mantém aberta uma resposta HTTP enquanto escreve frames event: <name> e data: <json>, e fecha-a depois do evento final. O tráfego flui do servidor para o cliente, o que corresponde a este stream do agente. Um WebSocket acrescentaria mensagens bidirecionais e uma atualização de protocolo de que esta rota não precisa.

Uma falha a meio do stream altera o tratamento de erros HTTP. Depois de enviado o primeiro frame com o estado 200, o servidor já não pode substituir essa resposta por um 500. O bloco catch regista o erro capturado, envia ao cliente um evento de erro constante e termina. Esse retorno é importante: apenas um stream concluído com sucesso chega a storage.createRun.

Um teste cobre este caminho. Um generator produz um delta de texto e depois lança uma exceção. A resposta permanece 200, e o seu último frame é um evento error com a mensagem constante Agent run failed. O logger conserva o erro capturado para diagnóstico no servidor. Qualquer cliente que verifique apenas o código de estado comunica sucesso numa execução que falhou.

app.ts toma duas decisões menores que vale a pena explicar. Trata /healthz como um endpoint de liveness, pelo que essa rota não acede deliberadamente ao Postgres. Uma falha de liveness durante uma indisponibilidade da base de dados poderia reiniciar todas as réplicas sem reparar a dependência. Acrescente uma verificação de readiness separada quando o orquestrador tiver de deixar de encaminhar tráfego para uma instância que não consegue aceder ao Postgres. Os caminhos de erro registam o erro capturado, mas devolvem uma string constante. Repetir error.message no corpo da resposta é uma forma de fazer com que as connection strings acabem no browser de outra pessoa.


A parte com formato de Celery, sem Celery

Os trabalhos longos não pertencem a um handler de pedidos. A API insere uma linha e devolve 202. Um worker assume a linha.

Aqui não há Redis nem BullMQ. O PostgreSQL documenta SKIP LOCKED para múltiplos consumidores de uma tabela semelhante a uma fila. A cláusula dá a este pequeno serviço uma fila com semântica at-least-once numa única tabela. É transacional com o resto das suas escritas e corresponde a menos um serviço em docker-compose.yml.

A query de claim em 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 });

A linha fica bloqueada durante a transação e qualquer worker concorrente que execute a mesma query ignora-a, em vez de ficar bloqueado. Assim, dois claims simultâneos não recebem a mesma linha não obsoleta. Um teste de integração executa dois claims em simultâneo através de Promise.all e verifica que devolvem linhas diferentes. A versão ingénua, SELECT ... LIMIT 1 seguida de UPDATE, falha esse teste: ambas as transações leem a mesma linha antes de qualquer uma escrever, pelo que ambas iniciam o mesmo trabalho.

Isto é execução at-least-once, não execução exactly-once. A query completa também recupera uma linha running quando o respetivo lock tem mais de cinco minutos, e o worker de demonstração não renova essa lease. Por conseguinte, um trabalho ativo que dure mais de cinco minutos pode ser atribuído duas vezes. Torne os trabalhos idempotentes. Para trabalho de longa duração, acrescente um heartbeat da lease ou defina o limiar de lock obsoleto acima do tempo máximo de execução.

Adicione BullMQ quando precisar de trabalhos agendados para mais tarde, agendamentos repetíveis, prioridades, limites de taxa ou um dashboard. Eu faria a mesma transição de uma tabela de base de dados para Celery em Python. Antes disso, o Redis é mais um serviço para executar, monitorizar e explicar a quem estiver de prevenção.

O worker valida novamente o que lê de jsonb:

// The row was validated on the way in, but it has been through a database.
// A stored row can outlive the schema version that accepted it.
// The job is a batch-size sweep. Zod's own `.parse` throws; the poll loop
// catches that and marks the run failed.
const input = SweepRequestSchema.parse(run.input);

A suite de testes também fornece ao worker uma linha cujo seqLen é uma string. O worker falha a execução e continua a fazer polling, em vez de falhar e voltar a tentar a mesma linha problemática para sempre.

O trabalho de CPU expõe outra limitação do Node. Um callback síncrono é executado na thread do event loop e não é preemptado. Um ciclo for que faça cálculos durante dois segundos bloqueia todos os pedidos, temporizadores e verificações de liveness desse processo durante dois segundos. Um ciclo apertado dentro de um async def bloqueia asyncio da mesma forma. Ambos os runtimes exigem que faça explicitamente o offload do trabalho de CPU.

await setTimeout(0) de node:timers/promises (o prefixo node: significa biblioteca padrão, por isso node:timers está para Node como os está para Python) é await asyncio.sleep(0). O varrimento cede o controlo depois de cada lote, para que o processo worker possa tratar temporizadores e outros callbacks. Ceder o controlo não torna o trabalho de CPU paralelo. O Node worker_threads consegue executar JavaScript em paralelo. Para trabalho de CPU puro em Python, numa compilação habitual do CPython com GIL, use um pool de processos em vez de um pool de threads. Este serviço não usa nenhum dos dois. Mantenha os cálculos numéricos pesados em Python, onde já residem as bibliotecas de suporte, e retire-os do event loop da API.


Uma ferramenta, três consumidores

EstimateKvCacheInput tem três consumidores:

É por isso que packages/schemas existe.

De apps/mcp/src/index.ts:

server.registerTool(
    "estimate_kv_cache",
    {
        description: "Estimate KV-cache VRAM in GiB for a served model...",
        // A Zod object schema keeps the map of fields you passed in on
        // `.shape`. This SDK wants that map, not the schema wrapped around it.
        inputSchema: EstimateKvCacheInput.shape,
    },
    async ({ model, seqLen, batchSize }) => {
        /* ... */
    },
);

await server.connect(new StdioServerTransport());

Há dois detalhes importantes antes de ligar um cliente. Primeiro, um servidor iniciado desta forma usa o seu próprio stdin e stdout para comunicar com o cliente. Cada linha é uma mensagem JSON-RPC. Um console.log perdido, o equivalente em JavaScript a print, corrompe então uma mensagem. O cliente desliga-se com um erro de análise que não identifica nenhum ficheiro. Envie todos os diagnósticos para stderr.

Segundo, uma falha do domínio deve devolver isError: true com uma mensagem. O modelo chamador pode então corrigir a chamada, tal como pode fazê-lo depois de argumentos inválidos de uma ferramenta no loop do agente.

Pode controlar o servidor com printf e um pipe, o que vale a pena fazer uma vez antes de apontar para ele um cliente real:

printf '%s\n' \
  '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' \
  '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
  '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"estimate_kv_cache","arguments":{"model":"llama-3.1-70b","seqLen":8192,"batchSize":4}}}' \
  | pnpm -s mcp   # -s suppresses pnpm's own output so only JSON-RPC comes back

A verificação é compatível com a versão do protocolo usada pelo README que o acompanha. Para um cliente real, use o SDK em vez de manter mensagens JSON-RPC manualmente.


Testar um agente sem uma chave de API

O Vitest desempenha o papel do pytest, mas a estrutura é diferente. describe agrupa testes relacionados. it e test definem cada um um caso de teste. test.each é semelhante a parametrize, beforeEach fornece configuração por teste, vi.fn() cria uma função mock e describe.skipIf ignora condicionalmente um grupo.

Os testes do agente dependem de uma decisão: runAgent recebe um cliente OpenAI como parâmetro, em vez de criar um. O fake é um objeto com um método chat.completions.create que devolve um iterável assíncrono definido por um guião:

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

Os testes dividem uma string de argumentos JSON por vários chunks e tratam duas chamadas de ferramentas numa só resposta. Também abrangem batchSize inválido, JSON malformado, nomes de ferramentas desconhecidos e um modelo que continua a chamar ferramentas até maxSteps o interromper. O ficheiro de testes é executado em bem menos de um segundo, sem rede nem chave.

Os testes de integração com o Postgres usam describe.skipIf(!process.env.DATABASE_URL), pelo que pnpm test funciona num clone acabado de criar sem Postgres em execução, e a CI ativa-os fornecendo a variável. O repositório tem 40 testes. Trinta e seis são executados sem Postgres nem chave de API.


Registar eventos estruturados com Pino

O Pino desempenha a mesma função que structlog: um objeto JSON por linha, loggers-filhos com campos associados e redação explícita. O companion configura-o em packages/observability/src/logger.ts:

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

Sem redação, log.info({ req }, "...") pode copiar um cabeçalho Authorization para o backend de logs.


Rastrear o trabalho da aplicação com spans manuais

O companion utiliza OpenTelemetry para três spans ao nível da aplicação: agent.run, agent.tool e worker.sweep. Não instala instrumentação automática de HTTP nem de Postgres. startTracing() em packages/observability/src/tracing.ts cria um NodeSDK com um exportador de traces OTLP. Se OTEL_EXPORTER_OTLP_ENDPOINT estiver ausente, o tracing fica desativado.

O trabalho propriamente dito é envolvido por withSpan() do mesmo ficheiro:

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

O JavaScript não tem uma sintaxe de gestor de contexto ao estilo do Python. Aqui, o callback é o bloco que um gestor de contexto Python envolveria. O helper completo também regista exceções e define o estado do span antes de voltar a lançá-las.

Os spans automáticos de HTTP e de base de dados são uma funcionalidade separada. Exigem os pacotes de instrumentação correspondentes e a inicialização antes de os módulos instrumentados serem carregados. Adicione-os apenas quando esses spans forem úteis e, em seguida, siga a configuração do OpenTelemetry Node SDK para consultar as versões exatas dos pacotes que vai colocar em produção.


Distribuir o monorepo com Docker

Utilize o Dockerfile fornecido. O contentor inicia a API com o loader tsx. Não precisa de escolher nem invocar um executor de TypeScript quando faz o deployment.

A build utiliza pnpm fetch para manter as transferências de dependências em cache até o lockfile mudar. Em seguida, utiliza pnpm deploy para copiar a API e as respetivas dependências de produção para um diretório autónomo. A fase de runtime é executada como o utilizador não-root node, e o CMD em formato exec permite que a API receba SIGTERM diretamente para efetuar um encerramento gracioso.

Porque é que o Dockerfile carrega o tsx

O Node 24 consegue executar um subconjunto limitado de TypeScript, removendo as anotações de tipos. Não verifica os tipos nem executa as transformações suportadas por um executor completo de TypeScript. Os scripts de pacote do repositório ocultam esse detalhe. pnpm check executa a verificação estática separadamente.

O contentor expõe outra limitação. pnpm deploy copia os pacotes do workspace para node_modules, e o Node recusa deliberadamente remover TypeScript nesse local (documentação de TypeScript do Node). A primeira versão da imagem falhava com ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Funciona. Fiquei desencorajado.

O Dockerfile corrige o problema carregando tsx, que trata esses ficheiros .ts antes de o Node os executar. Uma equipa que queira apenas ficheiros .js na imagem de runtime pode adicionar um passo de compilação. Esse é um design de produção alternativo, não um passo adicional necessário para executar este companion.


Um percurso de três semanas

Engenheiros Python experientes podem saltar o material sobre variáveis e ciclos. Esta sequência centra-se nas partes que diferem do Python. A coluna de construção é o objetivo de cada linha. A leitura serve de apoio.

SemanaLerConstruir
1javascript.info: apenas módulos, promises e objetos. Mantenha o JS Guide da MDN como referência.Reescreva uma CLI Python em TypeScript. Adicione um script package.json. Execute o script e pnpm typecheck.
1-2Leia a referência do tsconfig do TypeScript, os tutoriais gratuitos do Total TypeScript e a documentação do Zod.Crie um módulo de configuração validado pelo Zod e uma tagged union. Depois de verificar a tag, o compilador sabe qual a variante contida no bloco.
2Leia a documentação do Hono, do Drizzle, do Vitest e do Biome.Crie um proxy de streaming para um endpoint compatível com a OpenAI, com um log suportado pelo Drizzle.
3Leia a documentação do AI SDK e do MCP TypeScript SDK.Crie um agente com tool calling. Em seguida, crie um servidor MCP que exponha uma das suas tools.

Comece pelos tutoriais gratuitos do Total TypeScript. Pague pelo material avançado apenas quando estiver a trabalhar com generics e conditional types ao nível de bibliotecas. Ignore todos os cursos de «introdução a JavaScript» e tudo o que tenha a ver com React, a menos que o produto o exija.

Para uma visão geral das convenções de produção, goldbergyoni/nodebestpractices é uma checklist abrangente, mantida pela comunidade. Confirme as recomendações que afetam o comportamento em runtime ou a segurança na documentação atual do Node.


Compromissos

Mantenha os cálculos numéricos em Python

O Node funciona bem para orquestração, disponibilização de HTTP e streaming. Cálculos intensivos e prolongados bloqueiam a sua thread principal do event loop. Mantenha o vLLM e o código de treino em Python, salvo se uma carga de trabalho medida justificar a sua migração.

Valide todos os limites em runtime

Uma anotação TypeScript não valida um corpo HTTP, uma variável de ambiente, um argumento de tool gerado pelo modelo ou uma linha lida de jsonb. Cada limite precisa de um schema de runtime.

Isolar a evolução do SDK

O AI SDK 6 substituiu Experimental_Agent por ToolLoopAgent e mudou o nome da definição do agente system para instructions (guia de migração do AI SDK 6). O chamador associado invoca streamText diretamente no AI SDK 7 e expõe o seu próprio stream AgentEvent. Esta fronteira mantém a rota HTTP inalterada quando o código do SDK muda.

Evitar o ciclo escrito à mão quando o prazo for mais importante

Escrever o ciclo uma vez permite perceber que comportamento pertence ao SDK. Se precisar de entregar primeiro e não tiver motivos para personalizar falhas de validação, comece pelo SDK.


Principais conclusões

  1. Instale Node 24 e pnpm. Depois, utilize os scripts do repositório: pnpm demo, pnpm dev:api, pnpm dev:worker e pnpm check. Os scripts ocultam os comandos de runtime e de verificação de tipos de nível inferior.
  2. A validação em runtime é estruturalmente necessária. Zod é o validador escolhido neste projeto. Os tipos estáticos não inspecionam corpos HTTP, variáveis de ambiente, resultados de modelos ou linhas da base de dados. Com Zod, declare um schema de runtime e derive o tipo TypeScript com z.infer.
  3. A stack mapeia-se, na sua maioria, de forma simples: pnpm para uv, Hono para FastAPI, Drizzle para SQLAlchemy, Vitest para pytest e Biome para Ruff. Há três linhas que não são substituições diretas: validação, verificação de tipos e a fila de jobs.
  4. Escreva um ciclo de agente à mão se precisar de compreender ou personalizar os caminhos ocultos: acumulação de fragmentos, validação e feedback de erros das ferramentas.
  5. Transforme o ciclo num gerador assíncrono. A rota HTTP consome os seus valores AgentEvent, emite frames SSE, acumula o texto e persiste-o depois do stream. Os testes consomem o gerador separadamente, sem rede nem chave de API.
  6. O Postgres pode fornecer uma fila com entrega pelo menos uma vez. Torne os handlers idempotentes e renove o lease ou dimensione-o acima do tempo máximo de execução para jobs longos. Adicione BullMQ quando precisar de atrasos, prioridades ou agendamentos.
  7. Utilize o Dockerfile fornecido em produção. Este empacota a aplicação selecionada, carrega TypeScript com tsx, é executado como um utilizador não root e encaminha os sinais de encerramento para o processo da API.

Referências

Repositório de demonstração

Runtime e linguagem

Ferramentas

Bibliotecas

IA e agentes

Convenções