TypeScript para engenheiros de ML de Python: criar um serviço de agentes
Tradução automática Este artigo foi traduzido automaticamente a partir da versão original em inglês.
Este é um guia rápido de integração para engenheiros Python experientes que precisam de colocar serviços de AI em produção com TypeScript e Node. Destina-se a engenheiros de ML, cientistas de dados e developers de backend que não precisam de um curso introdutório de JavaScript.
Fiz essa integração pessoalmente 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 com o DOM no frontend. Este artigo parte dos conceitos de serviços Python. No final, conseguirá mapear uma stack de serviços Python para 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é à implementação.
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 um commit. Não precisa de executar
node, tsx nem o verificador de TypeScript manualmente. Os scripts do package fazem-no.
O serviço utiliza Zod, Hono, Drizzle, Vitest e Biome. Estes cobrem grande parte do mesmo espaço funcional que pydantic, FastAPI, SQLAlchemy, pytest e Ruff. Mantenha os cálculos numéricos pesados em Python. Utilize 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 agent loop, histórico de execuções em Postgres, um worker e um
servidor MCP. pnpm install && pnpm demo executa o percurso offline de HTTP/SSE do agente e o
cálculo de sweep do worker sem uma API key.
Abordo apenas trabalho de backend e AI. 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 exercita o handler HTTP, o agent loop 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 fila de base de dados. A demonstração não precisa de API key, base de dados
nem Docker. pnpm check executa o type checker, 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 utilizo no resto do artigo. O repositório coloca os comandos de
baixo nível de Node e TypeScript atrás de scripts pnpm com nomes,
à semelhança do que um projeto Python poderia fazer com comandos uv run
atrás de targets make. Não misture npm install neste
repositório pnpm. Utilize 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:
- JavaScript é a linguagem.
- Node.js é o runtime, aproximadamente o CPython do JavaScript. Instale Node 24 para este projeto.
- O npm registry é o índice de packages, aproximadamente o PyPI. O comando
npmvem com Node e pode instalar packages desse registry. - pnpm é o package manager escolhido por este repositório. Instala a partir do
npm registry, gere o workspace do monorepo e executa os comandos declarados em
package.json. package.jsoné o manifesto do projeto, o equivalente mais próximo depyproject.toml. A sua secçãoscriptsatribui nomes comodemo,checkedev:apia comandos mais longos.- TypeScript é JavaScript com tipos estáticos. O comando
tscverifica esses tipos. O repositório executa-o através depnpm checkoupnpm typecheck. - tsx executa ficheiros
.tssem uma build separada. Os scripts de desenvolvimento utilizam o seu modowatchpara reiniciar a API ou o worker depois de uma alteração no código-fonte. Não o invocará diretamente neste guia.
Neste repositório, execute os scripts pnpm. Node é o runtime dentro desses
scripts, e o Dockerfile fornecido trata da produção.
A stack, mapeada a partir de Python
A maior parte do mapa é pouco interessante, o que é uma boa notícia. Há três linhas que não são:
| Responsabilidade | Python | TypeScript | Porque não é uma substituição direta |
|---|---|---|---|
| Validação | pydantic | zod | O schema é a fonte. O tipo é gerado a partir dele, e não o contrário |
| Verificação de tipos | mypy | TypeScript (pnpm typecheck) | Ambos verificam o código-fonte sem validar dados recebidos em runtime |
| Fila de jobs | celery + Redis | bullmq (fila suportada por Redis) ou SQL | 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 focadas em Node. Hono utiliza as APIs
Request e Response, padrão da Web
e fornece adapters para runtimes Node e serverless. Essa portabilidade é útil para esta pequena
API com streaming, por isso escolhi 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 claramente uma cláusula de Postgres. Escolheria Prisma quando o cliente gerado e o ecossistema envolvente se ajustassem 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 ESLint quando o projeto depender de regras personalizadas que o Biome não disponibiliza.
Vitest para testes
Vitest executa os testes .ts do projeto
complementar sem configuração de transform separada.
Leia a sintaxe TypeScript utilizada abaixo
Mantenha esta tabela junto aos exemplos do serviço para consulta.
| TypeScript | Python / nota |
|---|---|
(x) => expression | função anónima com corpo constituído por uma expressão, semelhante a lambda x: expression |
(x) => { statements } | função anónima com corpo constituído por instruções |
async (x) => { statements } | função anónima assíncrona |
const { model, seqLen } = request | extrai as propriedades model e seqLen de request |
const [first] = xs | first = xs[0]. Produz undefined, e não IndexError, quando está vazio |
{ type: "error", message } | {"type": "error", "message": message}. Um nome isolado torna-se esse campo |
text ${x} | f-string |
cond ? a : b | a if cond else b |
const / let | Ambos associam um nome. const proíbe nova associação, enquanto let permite-a |
export | torna um nome importável |
switch / case | match, exceto que os casos prosseguem para o seguinte, a menos que terminem em break ou return |
for await | iteração sobre um gerador assíncrono |
i++ | incrementa e devolve o valor anterior |
/^https?$/ | um literal de regex, sem necessidade de re.compile |
T[], Map<K, V> | list[T], dict[K, V] |
Utilize const a menos que a associação tenha de mudar. Utilize let para
um contador, acumulador ou outra associação que irá reatribuir.
Uma tradução curta de enum
Para estados com valores string, este repositório utiliza um objeto e um tipo string-union inferido:
const Status = { Queued: "queued", Running: "running" } as const;
type Status = (typeof Status)[keyof typeof Status]; // "queued" | "running"
O objeto disponibiliza 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:
from enum import Enum
class Status(str, Enum):
QUEUED = "queued"
RUNNING = "running"
Basta 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 consomem tempo
A tabela de sintaxe permite-lhe atravessar os exemplos. Estas diferenças semânticas são o ponto em que os hábitos de Python causam bugs.
1. Arrays e objetos vazios são truthy
O hábito de Python de considerar um “contentor vazio como falsy” é o 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 deliberada. undefined normalmente
significa que falta um valor ou que este ainda 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 se define um valor por defeito. ||
substitui o lado direito sempre que o lado esquerdo for falsy. Isso inclui 0,
"" e false. ?? substitui apenas null e
undefined. Assim, 0 || 10 é 10, enquanto 0 ?? 10 é
0. É assim que um tamanho de batch igual a zero se transforma 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, TypeScript tipa o
valor capturado como unknown, o tipo “pode ser literalmente qualquer coisa” sob
strict. O projeto complementar ativa strict, e os projetos novos
deveriam fazê-lo em geral. Para inspecionar o erro, restrinja primeiro o tipo:
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 awaited ou agendado.
Promise.all é próximo de asyncio.gather. Promise.allSettled é próximo de
gather(..., return_exceptions=True), exceto que cada resultado é encapsulado como { status, value } ou
{ status, reason }.
Node controla o agendamento do runtime. Mantém o processo ativo enquanto existirem handles ou
requests ativos, como timers e sockets. Uma promise pendente normal, por si só, não mantém Node
ativo. Não envolva o programa em asyncio.run. Num ES module, pode utilizar
await no nível superior quando o arranque tiver de aguardar uma operação assíncrona.
5. JavaScript tem um único tipo numérico normal
O tipo number do JavaScript armazena valores como números de vírgula flutuante
de 64 bits, aproximadamente o equivalente a float de 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 IDs de 64 bits como strings nos limites do serviço. Converter um bigint de
Postgres para um number JavaScript pode arredondá-lo. Para inteiros exatos maiores,
JavaScript disponibiliza o tipo separado BigInt, que não pode ser misturado com números
normais.
6. Utilize Map quando precisar de um dicionário ao estilo 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 chave-valor limpa como um dict de Python. Herda alguns
nomes do próprio JavaScript, o que 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 do dict de 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 nos imports relativos
Um ficheiro de código-fonte JavaScript que partilha código com outros ficheiros chama-se um
módulo. Este projeto utiliza o formato moderno de módulos, os ES modules, normalmente
abreviados como ESM. ES significa ECMAScript, o nome formal da linguagem JavaScript. Na prática,
ESM corresponde à sintaxe import e export utilizada em todo o projeto.
Num import relativo, Node exige o nome de ficheiro exato. Não adivinha se
./env significa ./env.ts ou ./env.js:
import { loadEnv } from "./env.ts";
Os imports de packages instalados ou do workspace continuam a utilizar o nome do package, sem extensão de ficheiro:
import { z } from "zod";
import { runAgent } from "@agent/core";
Zod é pydantic com a seta invertida
Em pydantic, declara uma classe e obtém um validador. Em Zod, declara um validador e deriva o tipo a partir dele. A mesma fonte única de verdade, mas na direção oposta.
A partir 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> obtém um tipo estático a partir do schema de runtime.
z.coerce.number() trata do facto de cada valor definido em process.env
(o os.environ de Node) ser uma string. Desempenha a mesma função da coerção de
configurações numéricas do pydantic, embora as strings exatas aceites sejam diferentes. Isto segue o
padrão pydantic-settings e é executado uma vez no arranque. Um ambiente inválido produz
assim um erro de arranque legível, em vez de um TypeError dentro de um handler.
Um z.url() isolado aceita localhost:8000. A norma de URLs trata tudo antes
dos dois pontos como o scheme. Por isso, interpreta localhost: como um protocolo
chamado “localhost” e aceita a string. O valor chega depois ao cliente HTTP e falha com menos
contexto. A validação do schema antecipa as falhas, mas aplicará um schema permissivo se for isso
que escreveu.
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 schema tem de
alimentar três consumidores, que é o tema da secção “Uma ferramenta, três consumidores” abaixo.
O serviço
O serviço de demonstração dimensiona deployments de LLM. Uma ferramenta consulta as constantes da arquitetura de um modelo. A outra estima a memória ocupada pela KV cache: a memória da GPU utilizada para manter as keys e values de attention de requests em curso. Ambas as ferramentas fazem deliberadamente aritmética simples. Não precisam de rede e devolvem sempre a mesma resposta. Isto torna o serviço testável sem uma API key. O estimador de KV cache também é publicado através do Model Context Protocol (MCP), para que outros clientes de AI 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 do package não depende do comando que inicia
a aplicação.
Este workspace privado aponta esses pontos de entrada para o código-fonte .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 consumidores Node
normais não precisem do TypeScript runner nem da configuração de build do autor do package.
Escreva o tool loop manualmente, uma vez
Os frameworks de agentes com tool calling encapsulam o mesmo loop básico:
- Chamar o modelo com definições das ferramentas.
- Validar e executar as ferramentas solicitadas.
- Acrescentar os resultados às mensagens.
- Voltar a chamar o modelo.
Escreva este loop uma vez. O comportamento do framework passa então a ser uma escolha de engenharia que consegue justificar.
O loop é um async function*, um gerador assíncrono, exatamente a forma do
async def de Python com yield. A rota HTTP itera esse gerador,
transforma cada evento num 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 eventos num array. Um frame SSE é um fragmento de uma
resposta HTTP de longa duração. A secção abaixo explica o formato.
O loop tem duas saídas normais. Uma resposta sem tool calls produz done com
stopReason: "stop". Se o modelo continuar a pedir ferramentas até maxSteps,
o gerador também termina e produz done com stopReason: "max_steps". Trate esse
resultado como concluído mas truncado: o texto acumulado pode estar incompleto.
A partir 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 contém um passo do modelo. O loop utiliza-a na mensagem
do assistant para o passo seguinte ou no evento done final. Não é o acumulador
ao nível da rota que será posteriormente persistido.
O mapa partial é a parte que os frameworks escondem. O SDK expõe fragmentos string
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 inclui um teste que divide
{"model":"llama-3.1-8b",...} em quatro chunks.
A segunda parte que vale a pena escrever manualmente é 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 ferramenta, JSON.parse pode rejeitar os
argumentos ou o Zod pode rejeitar a sua forma. Cada falha transforma-se numa mensagem que o modelo
lê. Durante a execução, um ToolError esperado também se torna um tool result 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 de domínio. z.prettifyError transforma a árvore de
issues do Zod numa mensagem sobre a qual o modelo pode agir, em vez de num stack trace.
strict: true numa definição de função OpenAI pede ao provider
que restrinja a decodificação ao schema. Não tem qualquer relação com a flag strict
do tsconfig de TypeScript. É semelhante ao guided decoding do vLLM, embora os schemas suportados e
os detalhes de enforcement sejam diferentes. Remove um modo de falha, mas um endpoint self-hosted
pode ignorar a flag. Os argumentos também têm de sobreviver a JSON.parse.
O loop chama /chat/completions porque o projeto complementar visa 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 varia e muda com cada
release. 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, utilizaria o Vercel AI SDK. O projeto complementar implementa o mesmo
agente duas vezes, para tornar visível a troca. Ambas as versões emitem o mesmo stream
AgentEvent, pelo que a camada HTTP não as consegue distinguir.
O projeto complementar fixa o AI SDK 7.0.42 em
packages/agent-core/package.json.
A implementação do framework encontra-se 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 do código da aplicação:
- o acumulador de fragments
JSON.parsee o seu caminho de erro- a chamada que executa Zod sobre os argumentos analisados
- a montagem de mensagens específica do provider
- o contador de passos
stopWhen aceita várias condições, incluindo um limite de passos ou um tool call
específico. O loop for await não muda quando a política de paragem muda.
O que perde é o controlo direto sobre falhas de validação. O loop escrito à mão decide o que o
modelo vê depois de uma chamada rejeitada. O SDK expõe a opção experimental experimental_repairToolCall
para um callback de reparação personalizado. O projeto complementar atual não define essa opção,
pelo que depende do tratamento predefinido de chamadas inválidas do SDK.
A troca também funciona no sentido inverso. Na versão com o SDK, uma alteração de provider fica localizada no adapter do provider. Continua a exigir o package do provider correspondente, credenciais, configuração e testes de integração. No loop raw, o tratamento de requests e streams específico do provider é código seu e tem de ser alterado por si.
Escrevo o loop à mão no primeiro projeto e utilizo o SDK nos seguintes. Paga essa aprendizagem uma vez. A alternativa é ler pela primeira vez os internals de um framework enquanto este falha em produção.
Streaming sobre HTTP: Hono e SSE
As rotas Hono parecem-se com rotas FastAPI. A única adição é zValidator, que
faz o trabalho que o FastAPI obtém gratuitamente das type annotations da assinatura de um handler.
O c no handler abaixo é o request context do Hono, o objeto que o FastAPI
distribui pelos seus parâmetros. deps é um conjunto de dependências com que a
app é construída, em vez de serem importadas diretamente. runAgent é uma delas,
e a secção de testes mostra o que isso permite.
A partir 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 },
});
});
});
Atualmente, a rota trata todos os geradores que terminam normalmente da mesma forma. Persiste
status: "succeeded" depois de qualquer gerador que chegue ao fim, incluindo um cujo evento
final tenha stopReason: "max_steps". Assim, o resultado de limite é concluído mas truncado ou
parcial, e não uma falha já persistida. O código de produção deve inspecionar o evento
done e aplicar uma política explícita. Por exemplo, poderia utilizar um estado
truncated distinto ou um caminho de revisão/retry antes de reportar sucesso.
zValidator valida o body e atribui a c.req.valid("json") o tipo produzido pelo
schema. Se o omitir, o body fica tipado como any, o opt-out do TypeScript,
em que qualquer acesso a propriedades compila e nada é verificado. Isso desativa o benefício de
type-safety do schema.
Esta rota utiliza Server-Sent Events em vez de WebSockets. O servidor mantém uma resposta HTTP
aberta 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 de
agente. Um WebSocket acrescentaria mensagens bidirecionais e um protocol upgrade 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 status 200, o servidor já não pode substituir essa resposta por um 500. O bloco
catch regista o erro capturado, envia um evento de erro constante para o cliente
e retorna. Esse return é importante: só um stream concluído com sucesso chega a
storage.createRun. Um gerador que emita done, incluindo max_steps,
é um stream concluído normalmente e chega efetivamente a essa escrita.
Um teste cobre este caminho. Um gerador produz um delta de texto e depois lança uma exceção. A
resposta mantém o status 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 status reportará 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 deliberadamente não toca no 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 alcançar o Postgres. Os caminhos
de erro registam o erro capturado, mas devolvem uma string constante. Repetir error.message
no body da resposta é assim que as connection strings acabam no browser de outra pessoa.
A parte com formato Celery, sem Celery
Jobs longos não pertencem a um request handler. A API insere uma linha e devolve 202. Um worker reclama essa linha.
Aqui não há Redis nem BullMQ. O PostgreSQL documenta SKIP LOCKED para
vários consumidores de uma tabela semelhante a uma fila.
A cláusula dá a este pequeno serviço uma fila at-least-once numa única tabela. É transacional
com o resto das suas escritas e representa mais um serviço a menos 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 para a transação e qualquer worker concorrente que execute a mesma query
salta-a em vez de ficar bloqueado. Assim, duas claims simultâneas não recebem a mesma linha
não stale. Um teste de integração dispara duas claims em simultâneo através de Promise.all
e confirma 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 job.
Isto é execução at-least-once, não execução exactly-once. A query completa também recupera uma
linha running quando o seu lock tem mais de cinco minutos, e o worker de demonstração
não renova essa lease. Assim, um job ativo que demore mais de cinco minutos pode ser reclamado duas
vezes. Torne os jobs idempotentes. Para trabalho de longa duração, acrescente um heartbeat da lease
ou defina o limite de stale acima do runtime máximo.
Adicione BullMQ quando precisar de jobs atrasados, schedules repetíveis, prioridades, rate limits ou um dashboard. 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 crashar e repetir indefinidamente a
mesma linha poison.
O trabalho de CPU expõe outra restrição de Node. Um callback síncrono corre na thread do event loop
e não é preemptado. Um loop for a fazer cálculos durante dois segundos bloqueia
todas as requests, timers e verificações de liveness desse processo durante dois segundos. Um loop
apertado dentro de um async def bloqueia asyncio da mesma forma. Ambos os runtimes
exigem que o trabalho de CPU seja descarregado explicitamente.
await setTimeout(0) de node:timers/promises (o prefixo node: significa
standard library, pelo que node:timers está para Node como os está
para Python) é await asyncio.sleep(0). O sweep produz um yield após cada tamanho de batch para
que o processo worker possa tratar timers e outros callbacks. Yielding não torna o trabalho de
CPU paralelo. Node worker_threads consegue executar
JavaScript em paralelo. Para trabalho de CPU puro em Python sob uma build habitual de CPython com
GIL ativo, utilize um process pool em vez de um thread pool. Este serviço não utiliza nenhum dos
dois. Mantenha os cálculos numéricos pesados em Python, onde as bibliotecas de suporte já existem,
e retire-os do event loop da API.
Uma ferramenta, três consumidores
EstimateKvCacheInput tem três consumidores:
- O loop escrito à mão converte-o com
z.toJSONSchema. - O AI SDK recebe-o sem alterações.
- O servidor MCP publica a sua forma.
É por isso que packages/schemas existe.
A partir 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());
Antes de ligar um cliente, há dois detalhes importantes. Primeiro, um servidor iniciado desta
forma utiliza o seu próprio stdin e stdout para comunicar com o cliente. Cada linha é
uma mensagem JSON-RPC.
Um console.log perdido, o equivalente JavaScript de print, corrompe
depois uma mensagem. O cliente desliga-se com um erro de parsing que não identifica nenhum ficheiro.
Envie todos os diagnósticos para stderr.
Segundo, uma falha de 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 de ferramenta
inválidos no agent loop.
Pode conduzir o servidor com printf e um pipe. Vale a pena fazê-lo 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 sonda corresponde à versão do protocolo utilizada pelo README do projeto complementar. Para um cliente real, utilize o SDK em vez de manter mensagens JSON-RPC manualmente.
Testar um agente sem API key
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 é próximo de parametrize, beforeEach fornece setup por teste,
vi.fn() cria uma função mock e describe.skipIf faz skip condicional a um grupo.
Os testes do agente dependem de uma decisão: runAgent recebe um cliente OpenAI como
parâmetro em vez de o construir. O fake é um objeto com um método chat.completions.create que
devolve um iterable assíncrono scripted:
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 tool calls numa
única resposta. Também cobrem batchSize inválido, JSON malformado, nomes de ferramentas
desconhecidos e um modelo que continua a chamar ferramentas até maxSteps o parar.
O ficheiro de testes é executado em bem menos de um segundo, sem rede nem key.
Os testes de integração com Postgres utilizam describe.skipIf(!process.env.DATABASE_URL), pelo que pnpm test
funciona num clone novo 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 API key.
Registar eventos estruturados com Pino
Pino desempenha o mesmo papel que structlog: um objeto JSON por linha, child
loggers com campos associados e redaction explícita. O projeto complementar configura-o em
packages/observability/src/logger.ts:
const log = pino({
redact: {
paths: [
"req.headers.authorization",
"apiKey",
"OPENAI_API_KEY",
"*.apiKey",
],
censor: "[redacted]",
},
});
Sem redaction, log.info({ req }, "...") pode copiar um header Authorization para o backend de logs.
Fazer trace do trabalho da aplicação com spans manuais
O projeto complementar 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 ou Postgres. startTracing() em
packages/observability/src/tracing.ts cria um NodeSDK com um
exporter de traces OTLP. Se OTEL_EXPORTER_OTLP_ENDPOINT estiver ausente, deixa o tracing desligado.
O próprio trabalho é envolvido por withSpan() do mesmo ficheiro:
return tracer.startActiveSpan(name, { attributes }, async (span) => {
try {
return await fn(span);
} finally {
span.end();
}
});
JavaScript não tem uma sintaxe de context manager ao estilo de Python. Aqui, o callback é o bloco que um context manager Python envolveria. O helper completo também regista exceções e define o status do span antes de voltar a lançá-las.
Os spans automáticos de HTTP e base de dados são uma funcionalidade separada. Exigem os packages de instrumentação correspondentes e inicialização antes de os módulos instrumentados serem carregados. Adicione-os apenas quando esses spans forem úteis e siga a configuração do OpenTelemetry Node SDK para obter as versões exatas dos packages que implementa.
Colocar o monorepo em produção com Docker
Utilize o Dockerfile fornecido.
O container inicia a API com o loader tsx. Não precisa de escolher nem invocar
um TypeScript runner quando fizer o deployment.
A build utiliza pnpm fetch para que os downloads de dependências permaneçam em cache
até o lockfile mudar. Em seguida, utiliza pnpm deploy para copiar a API e as suas
dependências de produção para um diretório autónomo. A fase de runtime corre como o utilizador
não-root node, e o CMD em formato exec permite que a API
receba diretamente SIGTERM para um shutdown gracioso.
Porque é que o Dockerfile carrega tsx
Node 24 consegue executar um subconjunto limitado de TypeScript, removendo type annotations. Não
faz type-check nem executa as transformações suportadas por um TypeScript runner completo. Os
scripts do package escondem esse detalhe. pnpm check realiza a verificação estática
separadamente.
O container expõe outra limitação. pnpm deploy copia os packages do workspace para
node_modules, e Node recusa deliberadamente remover TypeScript nessa localização
(documentação de TypeScript do Node). A primeira versão da imagem
crashou com ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING. Funciona. Fiquei desanimado.
O Dockerfile corrige o problema carregando tsx, que trata esses ficheiros
.ts antes de Node os executar. Uma equipa que queira apenas ficheiros
.js na imagem de runtime pode acrescentar um passo de compilação. Esse é um
design de produção alternativo, não um passo adicional necessário para executar este projeto
complementar.
Um percurso de três semanas
Engenheiros Python experientes podem saltar o material que ensina variáveis e loops. Esta sequência foca-se nas partes que diferem de Python. A coluna “Build” é o objetivo de cada linha. A leitura serve de suporte.
| Semana | Ler | Criar |
|---|---|---|
| 1 | javascript.info: apenas módulos, promises e objetos. Mantenha o guia de JS da MDN como referência. | Reescreva um CLI Python em TypeScript. Adicione um script package.json. Execute o script e pnpm typecheck. |
| 1-2 | Leia a referência de tsconfig do TypeScript, os tutoriais gratuitos de Total TypeScript e a documentação do Zod. | Crie um módulo de configuração validado por Zod e uma tagged union. Depois de verificar a tag, o compilador sabe qual variante o bloco contém. |
| 2 | Leia a documentação de Hono, Drizzle, Vitest e Biome. | Crie um proxy com streaming para um endpoint compatível com OpenAI, com um log suportado por Drizzle. |
| 3 | Leia a documentação do AI SDK e do MCP TypeScript SDK. | Crie um agente com tool calling. Depois, crie um servidor MCP que exponha uma das suas ferramentas. |
Comece pelos tutoriais gratuitos de Total TypeScript. Pague pelo material avançado apenas quando estiver a trabalhar com generics ao nível de bibliotecas e conditional types. Ignore todos os cursos de “introdução a JavaScript” e tudo o que tenha formato React, a menos que o produto o exija.
Para uma visão geral das convenções de produção, goldbergyoni/nodebestpractices é uma checklist ampla, mantida pela comunidade. Verifique as recomendações que afetam o comportamento do runtime ou a segurança na documentação atual do Node.
Trade-offs
Mantenha os cálculos numéricos em Python
Node funciona bem para orquestração, serving HTTP e streaming. Cálculos sustentados limitados pela CPU bloqueiam a sua thread principal do event loop. Mantenha vLLM e o código de training em Python, salvo quando uma carga de trabalho medida justificar a sua migração.
Valide todos os limites em runtime
Uma anotação TypeScript não verifica um body HTTP, uma variável de ambiente, argumentos
de ferramentas gerados pelo modelo ou uma linha lida de jsonb. Cada limite
precisa de um schema de runtime.
Isole a churn do SDK
AI SDK 6 substituiu Experimental_Agent por ToolLoopAgent e renomeou a
configuração do agente system para instructions (guia de migração do
AI SDK 6). O projeto complementar chama diretamente streamText
no AI SDK 7 e expõe o seu próprio stream AgentEvent. Essa fronteira mantém a rota
HTTP inalterada quando o código do SDK muda.
Ignore o loop escrito à mão quando o prazo for mais importante
Escrever o loop uma vez ensina-lhe que comportamento é gerido pelo SDK. Se precisar de implementar primeiro e não tiver motivo para personalizar falhas de validação, comece pelo SDK.
Principais conclusões
- Instale Node 24 e pnpm. Depois utilize os scripts do repositório:
pnpm demo,pnpm dev:api,pnpm dev:workerepnpm check. Os scripts escondem os comandos de runtime e do type checker de baixo nível. - A validação em runtime é estruturalmente necessária. Zod é o validador escolhido neste projeto.
Os tipos estáticos não inspecionam bodies HTTP, variáveis de ambiente, output do modelo ou linhas
da base de dados. Com Zod, declare um schema de runtime e derive o tipo TypeScript com
z.infer. - 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. Três linhas não são substituições: validação, verificação de tipos e fila de jobs.
- Escreva um agent loop à mão se precisar de aprender ou personalizar os caminhos ocultos: acumulação de fragments, validação e feedback de erros de ferramentas.
- Transforme o loop 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 API key. - Postgres pode fornecer uma fila at-least-once. Torne os handlers idempotentes e renove a lease ou dimensione-a acima do runtime máximo para jobs longos. Adicione BullMQ quando precisar de atrasos, prioridades ou schedules.
- Utilize o Dockerfile fornecido em produção. Este empacota a app selecionada, carrega TypeScript
com
tsx, corre como utilizador não-root e encaminha os sinais de shutdown para o processo da API.
Referências
Repositório de demonstração
- slavadubrov/typescript-agent-service — o monorepo utilizado ao longo deste artigo: API Hono com SSE, duas implementações de agent loop, armazenamento Drizzle, worker, servidor MCP e 40 testes
Runtime e linguagem
- Running TypeScript natively in Node.js — o suporte limitado de TypeScript do Node e a sua restrição
node_modules - TypeScript compiler options —
stricte as restantes verificações configuradas pelo projeto complementar - MDN JavaScript Guide — a referência da linguagem que vale a pena manter aberta
- javascript.info — tutorial de JavaScript moderno. Leia os capítulos sobre módulos e promises.
Ferramentas
- pnpm e pnpm installation — package manager, workspaces e configuração
- Biome — lint, formatação e ordenação de imports num único binário
- Vitest — test runner que não precisa de configuração de transform
- Total TypeScript — tutoriais gratuitos e uma vertente paga para tipos avançados
Bibliotecas
- Zod — validação de schemas e inferência de tipos. A versão 4 inclui
z.toJSONSchema. - Hono — framework HTTP baseado nos standards da Web
- Drizzle ORM — ORM TypeScript SQL-first com migrações
drizzle-kit - PostgreSQL SELECT documentation — a cláusula de locking
FOR UPDATE ... SKIP LOCKED - BullMQ — fila suportada por Redis para quando a tabela da base de dados já não é suficiente
AI e agentes
- Vercel AI SDK —
streamText,tool,stopWhene adapters de providers - openai/openai-node — o cliente TypeScript oficial
- MCP TypeScript SDK e a especificação MCP — criar servidores e clientes
Convenções
- goldbergyoni/nodebestpractices — checklist mantida pela comunidade para convenções de produção