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:
- JavaScript é a linguagem.
- Node.js é o runtime, aproximadamente o equivalente ao CPython para JavaScript. Instale o Node 24 para este projeto.
- O npm registry é o índice de pacotes, aproximadamente o equivalente ao PyPI. O comando
npmvem com o Node e pode instalar pacotes a partir desse registry. - pnpm é o package manager escolhido por este repositório. Instala pacotes 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:api, a 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 execute diretamente neste guia.
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
A maior parte do mapeamento é pouco interessante, o que é uma boa notícia. Há três exceções:
| Preocupação | Python | TypeScript | Por que não é uma substituição direta |
|---|---|---|---|
| Validação | pydantic | zod | O schema é a fonte de verdade. 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 validarem os dados que chegam em runtime |
| Fila de jobs | celery + Redis | bullmq (fila suportada por Redis) ou SQL | O 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.
| TypeScript | Python / nota |
|---|---|
(x) => expression | funçã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 } = 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 simples torna-se esse campo |
text ${x} | f-string |
cond ? a : b | a if cond else b |
const / let | Ambos associam um nome. const impede a reatribuição, enquanto let a permite |
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 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
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
Os frameworks de agentes com tool calling encapsulam o mesmo ciclo básico:
- Chame o modelo com as definições das ferramentas.
- Valide e execute as ferramentas solicitadas.
- Acrescente os resultados às mensagens.
- 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:
- o acumulador de fragmentos
JSON.parsee o respetivo caminho de erro- a chamada que executa o 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 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.
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:
- O loop escrito manualmente converte-o com
z.toJSONSchema. - O AI SDK recebe-o sem alterações.
- O servidor MCP publica o seu esquema.
É por isso que packages/schemas existe.
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.
| Semana | Ler | Construir |
|---|---|---|
| 1 | javascript.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-2 | Leia 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. |
| 2 | Leia 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. |
| 3 | Leia 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
- Instale Node 24 e pnpm. Depois, utilize os scripts do repositório:
pnpm demo,pnpm dev:api,pnpm dev:workerepnpm check. Os scripts ocultam os comandos de runtime e de verificação de tipos de nível inferior. - 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. - 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.
- 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.
- 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. - 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.
- 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
- slavadubrov/typescript-agent-service - o monorepo utilizado ao longo deste artigo: API Hono com SSE, duas implementações de ciclos de agentes, armazenamento com Drizzle, worker, servidor MCP e 40 testes
Runtime e linguagem
- Running TypeScript natively in Node.js - o suporte limitado do Node para TypeScript e a sua restrição
node_modules - TypeScript compiler options -
stricte as outras verificações configuradas pelo companion - 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 instalação do pnpm — gestor de pacotes, workspaces e configuração
- Biome — linting, formatação e ordenação de imports num único binário
- Vitest — test runner que não requer configuração de transformação
- Total TypeScript — tutoriais gratuitos e um percurso pago 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 padrões Web
- Drizzle ORM — ORM de TypeScript SQL-first com migrações
drizzle-kit - Documentação de SELECT do PostgreSQL — a cláusula de locking
FOR UPDATE ... SKIP LOCKED - BullMQ — fila suportada por Redis para quando a tabela da base de dados não chega
IA e agentes
- Vercel AI SDK —
streamText,tool,stopWhene adaptadores de providers - openai/openai-node — o cliente oficial de TypeScript
- MCP TypeScript SDK e a especificação MCP — para criar servidores e clientes
Convenções
- goldbergyoni/nodebestpractices — checklist mantida pela comunidade para convenções em produção