Uma resposta do modelo pode trazer várias tool calls, mas isso não significa que o seu serviço consiga executá-las todas ao mesmo tempo. Uma API externa pode ter poucas conexões disponíveis, uma consulta pode disputar o mesmo recurso ou um agente em background pode ocupar os workers de uma requisição interativa.

O limite precisa ficar no runtime, antes do handler da tool começar. O guia sobre quando permitir ou bloquear parallel tool calls resolve a pergunta anterior: as chamadas são independentes? Este artigo resolve a seguinte: depois que o lote foi considerado seguro, quantas execuções podem consumir capacidade agora?

O código abaixo usa um semáforo em TypeScript para limitar execuções globais e pools por tool. Ele é uma implementação local e ilustrativa. Não coordena réplicas diferentes, não substitui rate limiting e não torna uma escrita idempotente.

Diagrama mostra um lote de tool calls passando por uma admissão com slots globais e um pool por tool.

Resposta curta

  • Separe a permissão para o modelo emitir várias chamadas do limite de execução no seu processo.
  • Reserve o slot antes de chamar a tool e libere-o em finally, inclusive quando houver erro ou cancelamento.
  • Use um limite global para a capacidade do worker e pools menores para dependências sensíveis.
  • Faça a espera observar um AbortSignal; um agente cancelado não deve deixar um waiter preso na fila.
  • Teste o maior número de execuções ativas e o caminho de cancelamento. Contar apenas o resultado final não prova que o limite funcionou.

Concorrência não é o mesmo que paralelismo do modelo

parallel_tool_calls e opções parecidas dizem se o provedor pode devolver várias chamadas em uma resposta. O Agents SDK para JavaScript também documenta toolExecution.maxFunctionToolConcurrency como um limite de execução local por turno. São camadas diferentes: uma controla a emissão do modelo; a outra controla o dispatcher do SDK.

O Agents SDK para Python deixa essa separação explícita: parallel_tool_calls controla o que o modelo pode emitir, enquanto max_function_tool_concurrency limita quantas function tools locais rodam ao mesmo tempo. Mesmo assim, uma configuração do SDK pode ser insuficiente quando várias execuções compartilham um cliente HTTP, uma fila, uma API ou um banco que vive fora daquela instância.

Pense em quatro limites diferentes:

Limite Pergunta que responde Exemplo
Emissão O modelo pode pedir várias tools no mesmo turno? parallel_tool_calls
Concorrência Quantas execuções estão ativas agora? semáforo com 4 slots
Frequência Quantas chamadas podem começar durante um intervalo? 10 por segundo
Orçamento Quantas chamadas ou tokens o run pode consumir no total? 20 calls por run

Desligar a emissão paralela pode ser uma política válida para tools com estado compartilhado, mas isso não limita dois runs diferentes. Um orçamento total também não impede que cinco chamadas comecem no mesmo milissegundo. Use o controle que corresponde ao recurso que pode saturar.

Reserve capacidade antes do handler

O semáforo abaixo mantém três invariantes: o contador nunca passa do limite, cada aquisição tem uma liberação correspondente e um waiter cancelado sai da fila sem consumir um slot.

type Waiter = {
  resolve: (release: () => void) => void;
  reject: (error: unknown) => void;
  onAbort?: () => void;
};

export class Semaphore {
  private active = 0;
  private readonly waiters: Waiter[] = [];

  constructor(private readonly limit: number) {
    if (!Number.isInteger(limit) || limit < 1) {
      throw new Error("limit must be a positive integer");
    }
  }

  acquire(signal?: AbortSignal): Promise<() => void> {
    if (signal?.aborted) {
      return Promise.reject(signal.reason ?? new Error("aborted"));
    }

    if (this.active < this.limit) {
      this.active += 1;
      return Promise.resolve(() => this.release());
    }

    return new Promise((resolve, reject) => {
      const waiter: Waiter = {
        resolve: (release) => {
          signal?.removeEventListener("abort", waiter.onAbort!);
          this.active += 1;
          resolve(release);
        },
        reject,
      };

      waiter.onAbort = () => {
        const index = this.waiters.indexOf(waiter);
        if (index === -1) return;
        this.waiters.splice(index, 1);
        reject(signal?.reason ?? new Error("aborted"));
      };

      signal?.addEventListener("abort", waiter.onAbort, { once: true });
      this.waiters.push(waiter);
    });
  }

  private release(): void {
    const next = this.waiters.shift();
    if (next) {
      next.resolve(() => this.release());
      return;
    }
    this.active -= 1;
  }
}

O incremento acontece quando a aquisição é entregue, não quando a Promise entra na fila. Quando um waiter é cancelado, a fila perde a referência e o contador não muda. O finally do chamador deve ser o único lugar que libera o slot; liberar também em um callback de erro cria contagem negativa ou capacidade fantasma.

Combine um limite global com pools por tool

Um limite global protege a capacidade do processo. Um pool por tool impede que uma dependência específica consuma todos os slots. O trpc-agent-go documenta grupos globais e por tool e também alerta que limites por invocação não protegem uma capacidade compartilhada entre runs ou réplicas.

type ToolCall<T> = {
  name: string;
  run: (signal: AbortSignal) => Promise<T>;
};

const globalPool = new Semaphore(4);
const toolPools = new Map([
  ["search", new Semaphore(1)],
  ["read_customer", new Semaphore(2)],
]);

export async function executeTool<T>(
  call: ToolCall<T>,
  signal: AbortSignal,
): Promise<T> {
  const releaseGlobal = await globalPool.acquire(signal);
  const specificPool = toolPools.get(call.name);
  let releaseSpecific: (() => void) | undefined;

  try {
    releaseSpecific = specificPool
      ? await specificPool.acquire(signal)
      : undefined;
    return await call.run(signal);
  } finally {
    releaseSpecific?.();
    releaseGlobal();
  }
}

Essa ordem evita uma classe de deadlock: toda execução pega primeiro o limite global e depois o pool específico. O custo é que um slot global pode ficar reservado enquanto uma chamada espera pelo pool da tool. Se isso for relevante, use um scheduler que admita o par de recursos atomically ou defina um limite global por grupo, em vez de empilhar semáforos sem medir a fila.

Não deixe o nome da tool ser a única configuração. O registro também precisa dizer se a chamada lê ou escreve, qual recurso compartilha e se exige ordem. A classificação de independência continua sendo a decisão do artigo sobre parallel tool calls; o pool só controla capacidade depois dessa decisão.

Escolha entre esperar e falhar rápido

Quando não há slot, o runtime pode enfileirar a chamada ou devolver um erro estruturado ao agente. A escolha depende do recurso:

Situação Política Motivo
Consulta idempotente e curta Esperar com deadline A fila absorve um pico pequeno sem repetir a decisão do modelo.
Mutação com aprovação expirada Falhar rápido Esperar pode executar uma aprovação que já não corresponde ao estado atual.
Tool de baixa prioridade Fila com limite Um run de background não deve ocupar memória indefinidamente.
Serviço externo com quota por janela Rate limiter separado Um semáforo controla simultaneidade, não frequência.
Requisição cancelada Remover o waiter O cliente não quer que a execução comece depois.

O Microsoft Agent Framework explica que limites de chamadas e duração são best effort e podem ser verificados depois de um lote pedido pelo modelo. Isso é diferente de rejeitar uma execução antes do handler. Se a proteção precisa ser exata, faça a admissão no seu próprio runtime ou na dependência que possui a capacidade.

Não transforme uma rejeição em retry automático. Um erro concurrency_limit pode voltar ao modelo como resultado de tool, pode encerrar o turno ou pode ser colocado numa fila durável. O runtime deve registrar runId, nome da tool, política aplicada, tempo de espera e motivo da rejeição sem copiar argumentos sensíveis. A decisão sobre tentar de novo pertence à política do run e à idempotência do efeito.

Teste a capacidade, não só a resposta

Um teste útil controla a duração das tools para observar quantas estão ativas. O exemplo a seguir pode ser executado com tsx ou adaptado para JavaScript. Ele não chama um modelo nem uma API externa.

import assert from "node:assert/strict";

const gate = new Semaphore(2);
let active = 0;
let maximum = 0;

async function fakeTool(signal: AbortSignal): Promise<void> {
  const release = await gate.acquire(signal);
  active += 1;
  maximum = Math.max(maximum, active);

  try {
    await new Promise((resolve) => setTimeout(resolve, 20));
  } finally {
    active -= 1;
    release();
  }
}

await Promise.all(
  Array.from({ length: 6 }, () => fakeTool(new AbortController().signal)),
);

assert.equal(maximum, 2);

Adicione um segundo caso com um AbortController: ocupe o único slot, ponha outra chamada na fila, aborte o signal e confirme que a segunda tool nunca entrou no corpo do handler. Depois faça uma chamada falhar e verifique que o slot volta a ficar disponível. Por fim, execute dois runs ao mesmo tempo. Um semáforo criado dentro do run testa apenas o run; um pool criado fora dele testa a capacidade compartilhada do processo.

O teste ainda não prova uma política distribuída. Com duas réplicas, cada processo pode aceitar quatro chamadas e o serviço externo observar oito. Para essa fronteira, mova a admissão para um gateway, uma fila ou o próprio recurso compartilhado. O post sobre testar um worker de fila sem duplicar efeitos ajuda a testar o comportamento quando a execução sai do processo local.

Falhas que o limite não corrige

Confundir concorrência com rate limit. Quatro slots podem iniciar quatro requisições em um instante e ainda violar uma quota de duas por segundo. Use um token bucket ou política equivalente quando o limite for temporal.

Aplicar o limite somente ao modelo. O provedor pode emitir uma chamada, um retry ou um worker pode iniciar outra depois. A fronteira precisa envolver o executor que chama a dependência.

Usar uma contagem sem reserva. O padrão “ler ativos, comparar, iniciar” permite que várias Promises passem pela mesma verificação antes que qualquer uma incremente o contador. A aquisição do semáforo precisa ser uma operação indivisível dentro do event loop ou de um coordenador compartilhado.

Prender slots em uma tool que ignora cancelamento. O AbortSignal precisa chegar à operação que espera I/O. Um timeout do chamador pode liberar o slot local enquanto o upstream continua trabalhando, então classifique esse efeito como incerto quando houver escrita.

Criar um pool por run e chamá-lo de proteção global. Isso limita uma execução, não todas as execuções. Documente o escopo de cada pool e teste o cenário que realmente disputa o recurso.

Checklist antes de liberar tools concorrentes

  • A decisão de independência foi tomada antes do scheduler?
  • Existe um limite global para a capacidade do worker?
  • Alguma dependência precisa de um pool menor ou de serialização por recurso?
  • A aquisição acontece antes do handler e a liberação está em finally?
  • Cancelar um waiter remove a fila sem consumir capacidade?
  • A espera tem deadline e uma política para rejeição?
  • Rate limit, orçamento total e idempotência foram tratados separadamente?
  • O teste mede o máximo de execuções ativas, falha, cancelamento e dois runs concorrentes?
  • O trace identifica admissão, rejeição, espera e efeito sem vazar argumentos?

Conclusão

O modelo pode pedir várias tools, mas o runtime deve decidir quantas começam. Primeiro classifique dependências e estado compartilhado. Depois coloque a admissão no executor, combine um limite global com pools específicos, propague cancelamento e teste o maior número de execuções ativas.

Esse limite é uma peça de capacidade. Ele não substitui rate limiting, aprovação, idempotência, timeout ou coordenação entre réplicas. Trate cada proteção como uma pergunta diferente e deixe o teste mostrar qual fronteira está realmente coberta.

Para manter contexto entre sessões longas de desenvolvimento, uso o RemoteCode como ferramenta do autor. Ele não coordena slots de produção, não substitui o scheduler e não é evidência de throughput.

Como este artigo foi produzido

Samuel Fajreldines é o autor responsável por este artigo. A pesquisa comparou as referências atuais do OpenAI Agents SDK, do Microsoft Agent Framework e do trpc-agent-go com discussões públicas recentes e o cluster existente do site. O semáforo e o teste são uma implementação local de referência. O código foi verificado em Node.js v25.5.0 sem chamar um provedor de modelo, uma API externa ou um ambiente multi-réplica. A assistência de IA apoiou descoberta, organização, redação, localização e revisão de consistência; não forneceu experiência de produção nem mediu latência, custo ou throughput.

Fontes consultadas