A maioria dos SDKs LLM não fornece disjuntor integrado para loops de chamada de ferramenta em nível de agente. Este tutorial percorre a construção de um loop de agente TypeScript independente de provedor, protegido por quatro proteções distintas: limites de tentativa por ferramenta, tempos limite de execução, uma proteção de orçamento de token de melhor esforço e um substituto do lado do aplicativo para o estado de rascunho.
- Definir suas ferramentas com esquemas Zod e um ambiente compartilhado
LLMClientinterface para execução independente de provedor. - Enrolar cada ferramenta com um limite de tentativa por ferramenta usando um
Maprastreador para interromper tempestades de novas tentativas. - Definir um tempo limite de execução via
AbortControlleresetTimeoutpara capturar chamadas externas suspensas. - Acompanhar gasto de token após cada conclusão usando uma proteção de orçamento com constantes de preços por modelo.
- Retornar um estruturado
DraftResultquando qualquer guarda-corpo é acionado, preservando saídas parciais e histórico de conversas. - Compor todas as quatro proteções em um único loop de agente com ordem de verificação em camadas: orçamento antes de cada chamada LLM, novas tentativas durante a execução, tempo limite encerrando todo o loop.
- Teste cada guarda-corpo de forma independente, simulando falhas de ferramentas, tempos limite curtos e orçamentos mínimos.
Índice
O ciclo do agente em fuga de US$ 2.000
Em abril de 2026, um desenvolvedor do r/cursor do Reddit descreveu um agente que ficou preso em um loop e cobraram mais de US$ 2.000 em menos de duas horas enquanto estavam longe do teclado. O agente não era malicioso. Ele continuou fazendo o que lhe foi pedido e nada no sistema lhe disse para parar.
Este é o problema central. A maioria dos SDKs LLM não fornece disjuntor integrado para loops de chamada de ferramenta em nível de agente. (Alguns SDKs mais recentes, como o Agents SDK da OpenAI, agora incluem limites de giro configuráveis, mas isso ainda não é universal.) O modelo decide quando parar de chamar ferramentas, e se as ferramentas continuarem retornando erros ou o modelo continuar alucinando argumentos, o loop continua indefinidamente, queimando tokens e dólares a cada iteração.
Este tutorial percorre a construção de um loop de agente TypeScript independente de provedor, protegido por quatro proteções distintas: limites de tentativa por ferramenta, tempos limite de execução, uma proteção de orçamento de token de melhor esforço e um substituto do lado do aplicativo para o estado de rascunho. Os pré-requisitos incluem conhecimento prático de TypeScript, conforto com async/await e familiaridade com o envio de solicitações de conclusão de chat e como lidar com respostas de chamadas de ferramenta.
O padrão de loop básico
O loop de agente canônico segue um ciclo previsível: enviar um histórico de conversação para o LLM, receber uma resposta que pode conter uma ou mais chamadas de ferramenta, executar essas ferramentas, anexar os resultados de volta à conversa e repetir até que o modelo retorne uma resposta de texto final sem chamadas de ferramenta. Esse padrão é consistente em alto nível no OpenAI, Anthropic, Google e outros provedores, embora os esquemas de mensagens dos provedores sejam diferentes. Este artigo utiliza uma abstração normalizada; consulte a referência da API de cada provedor para obter os formatos exatos.
Nada limita o loop por padrão. O modelo sozinho decide quando parar. Se uma ferramenta retornar um erro consistentemente, o modelo poderá tentar novamente. Se uma API externa for instável, o modelo não terá como distinguir entre falhas transitórias e permanentes. O loop simplesmente continua.
Três modos de falha que causam loops descontrolados
Tempestades de novas tentativas acontecem quando uma ferramenta comete erros consistentemente e o modelo continua tentando novamente a mesma chamada com argumentos idênticos ou ligeiramente variados. Depois, há loops semânticos, onde o modelo alterna entre duas ou mais ferramentas sem convergir para uma resposta, e a saída de cada ferramenta aciona uma chamada para a outra. O terceiro modo de falha é o token blowout: cada iteração acrescenta resultados de chamadas de ferramenta e respostas de modelo ao contexto da conversa, inflando o tamanho e o custo do prompt linearmente, mesmo que o loop acabe terminando.
Pré-requisitos
- Node.js ≥18 (para nativos
fetch,AbortControllerestructuredClone) - TypeScript ≥5,0
- zod pacote:
npm install zod - Uma chave de API do provedor LLM e o SDK correspondente (por exemplo,
npm install openaipara OpenAI)
Tipos Compartilhados
O código deste artigo depende de um conjunto compartilhado de interfaces. Crie um types.ts arquivo com as seguintes definições:
export interface ChatMessage {
role: "user" | "assistant" | "tool" | "system";
content: string;
toolCalls?: ToolCall();
toolCallId?: string;
}
export interface ToolCall {
id: string;
name: string;
arguments: unknown;
}
export interface LLMClient {
chat(
messages: ChatMessage(),
tools: Map<string, Tool>,
signal?: AbortSignal
): Promise<{
content: string;
toolCalls?: ToolCall();
usage?: { prompt_tokens: number; completion_tokens: number };
}>;
}
export interface Tool {
name: string;
description: string;
parameters: import("zod").ZodSchema;
execute: (args: unknown) => Promise<string>;
}
export interface CompleteResult {
kind: "complete";
content: string;
}
export interface DraftResult {
kind: "draft";
reason: "retry_exhausted" | "timeout" | "budget_exceeded";
partialOutputs: Array<{ tool: string; output: string }>;
conversationSnapshot: ChatMessage();
}
export type AgentResult = CompleteResult | DraftResult;
Inicialização do projeto
O tsconfig.json deve habilitar o modo estrito e direcionar ES2022 ou posterior para habilitar a saída de sintaxe moderna. A espera de nível superior requer "module": "Node16" ou "ES2022". AbortController é uma API de tempo de execução Node.js (≥15) independente de target.
{
"compilerOptions": {
"strict": true,
"target": "ES2022",
"module": "Node16",
"moduleResolution": "node16",
"esModuleInterop": true,
"outDir": "./dist"
}
}
Observação: Se você estiver construindo com Vite, esbuild ou um empacotador semelhante, você pode usar "moduleResolution": "bundler" em vez de. Para simples tsc compilação, uso "node16" como mostrado acima.
Instalar zod como a biblioteca de validação de esquema:
npm install zod
O cliente LLM é representado como o genérico LLMClient interface definida em types.ts. Os leitores podem trocar em OpenAI’s openai pacote, Antrópico @anthropic-ai/sdkou qualquer cliente compatível. As proteções operam no formato da resposta, não no provedor.
O circuito nu e desprotegido
Aqui está o loop simples e desprotegido que o restante do tutorial irá fortalecer progressivamente:
import { type ChatMessage, type LLMClient, type ToolCall, type Tool } from "./types";
async function runAgentLoop(
client: LLMClient,
tools: Map<string, Tool>,
messages: ChatMessage()
): Promise<string> {
while (true) {
const response = await client.chat(messages, tools);
if (!response.toolCalls || response.toolCalls.length === 0) {
return response.content;
}
messages.push({
role: "assistant",
content: response.content,
toolCalls: response.toolCalls,
});
for (const tc of response.toolCalls) {
const tool = tools.get(tc.name);
if (!tool) {
messages.push({ role: "tool", toolCallId: tc.id, content: "Unknown tool" });
continue;
}
const result = await tool.execute(tc.arguments);
messages.push({ role: "tool", toolCallId: tc.id, content: result });
}
}
}
Esta é a estrutura que a maioria dos tutoriais apresenta. Funciona até que não funcione.
Definindo Ferramentas com Esquemas Zod
Cada ferramenta é definida com um nome, uma descrição, um esquema Zod para seus parâmetros e um execute função. A validação do argumento acontece dentro do wrapper de execução via safeParsecapturando argumentos alucinados antes que eles alcancem a lógica do aplicativo.
import { z } from "zod";
import { type Tool } from "./types";
const lookupWeather: Tool = {
name: "lookupWeather",
description: "Get current weather for a given city",
parameters: z.object({ city: z.string() }),
execute: async (args: unknown) => {
const parsed = lookupWeather.parameters.safeParse(args);
if (!parsed.success) {
return JSON.stringify({ error: "Invalid arguments", details: parsed.error.flatten() });
}
return JSON.stringify({ city: parsed.data.city, tempC: 22, condition: "sunny" });
},
};
const createDraftPR: Tool = {
name: "createDraftPR",
description: "Open a draft pull request with the given title and body",
parameters: z.object({ title: z.string(), body: z.string(), branch: z.string() }),
execute: async (args: unknown) => {
const parsed = createDraftPR.parameters.safeParse(args);
if (!parsed.success) {
return JSON.stringify({ error: "Invalid arguments", details: parsed.error.flatten() });
}
return JSON.stringify({ prNumber: 42, status: "draft", branch: parsed.data.branch });
},
};
O createDraftPR a ferramenta está diretamente ligada ao cenário do Reddit. Esse padrão reflete uma resolução comum para tais cenários: parar e abrir um rascunho de PR em vez de deixar o agente fazer um loop indefinido. Este tutorial codifica esse padrão como um mecanismo genérico de fallback.
Por que as tampas por ferramenta diferem das tampas de loop
Um limite de tentativas por ferramenta rastreia quantas vezes uma ferramenta específica foi invocada em uma única execução de agente. Isso é diferente de um limite geral de iteração de loop. Um agente que chama três ferramentas diferentes, uma vez que cada uma iterou três vezes, mas não teve nenhuma nova tentativa. Enquanto isso, um agente que chama a mesma ferramenta cinco vezes seguidas porque ela continua falhando tem cinco tentativas em uma ferramenta. O limite por ferramenta capta isso. Ele interrompe tempestades de novas tentativas e ainda permite que o agente use outras ferramentas livremente.
O guarda rastreia as tentativas em um Mapcom escopo para uma única execução.
Implementação
class ToolAttemptExhaustedError extends Error {
constructor(public toolName: string, public maxAttempts: number) {
super(`Tool "${toolName}" exhausted ${maxAttempts} attempts`);
this.name = "ToolAttemptExhaustedError";
}
}
function withMaxAttempts(
tool: Tool,
maxAttempts: number,
attemptMap: Map<string, number>
): Tool {
return {
...tool,
execute: async (args: unknown) => {
const current = attemptMap.get(tool.name) ?? 0;
if (current >= maxAttempts) {
throw new ToolAttemptExhaustedError(tool.name, maxAttempts);
}
attemptMap.set(tool.name, current + 1);
return tool.execute(args);
},
};
}
Quando o erro é acionado dentro do loop do agente, o loop o captura e injeta uma mensagem informando ao modelo que a ferramenta está indisponível para o restante desta execução. Isso evita que o modelo continue solicitando uma ferramenta que já provou que não terá sucesso, interrompendo o ciclo de novas tentativas sem encerrar toda a execução.
Guardrail 2: Tempo limite de execução
Correndo no circuito contra um prazo
Loops de agente de longa duração podem travar em chamadas de API externas que nunca são resolvidas. Um AbortController emparelhado com setTimeout cria um prazo de nível de execução e Promise.race garante que o prazo seja vencido mesmo quando o trabalho nunca é concluído. O AbortSignal é passado para o cliente LLM para que um cliente bem comportado possa cancelar sua solicitação em andamento.
Importante: O AbortSignal apenas encerra o loop entre as iterações, a menos que seu LLMClient implementação passa para o cliente HTTP subjacente (por exemplo, OpenAI SDK’s signal opção). Verifique se o seu cliente propaga o sinal para realmente cancelar solicitações HTTP em andamento.
Implementação
import { type AgentResult } from "./types";
interface AgentTimeoutResult {
kind: "timeout";
elapsedMs: number;
}
function withTimeout<T>(
fn: (signal: AbortSignal) => Promise<T>,
timeoutMs: number
): Promise<T | AgentTimeoutResult> {
const controller = new AbortController();
const start = Date.now();
let timer: ReturnType<typeof setTimeout> | undefined;
const deadline = new Promise<AgentTimeoutResult>((resolve) => {
timer = setTimeout(() => {
controller.abort();
resolve({ kind: "timeout", elapsedMs: Date.now() - start });
}, timeoutMs);
});
const work = (async () => fn(controller.signal))().catch((err) => {
if (controller.signal.aborted) {
return { kind: "timeout" as const, elapsedMs: Date.now() - start };
}
throw err;
});
return Promise.race((work, deadline)).finally(() => clearTimeout(timer));
}
O tempo limite produz uma estrutura AgentTimeoutResult em vez de lançar tempos limite causados por aborto. Erros não abortados ainda são lançados e devem ser detectados pelo chamador. O chamador inspeciona o resultado kind campo para determinar se ocorreu um tempo limite.
Guardrail 3: Guarda Orçamentária de Token de Melhor Esforço
Por que “melhor esforço”?
O custo exato do token não pode ser conhecido antes do retorno da conclusão. A estratégia funciona em regime de trailing: após cada conclusão, o guarda acumula usage.prompt_tokens e usage.completion_tokens da resposta, multiplica pelas constantes de preço por token e compara o total em execução com um limite em dólares por execução. Como avalia após cada chamada, ele interrompe a próxima iteração, não a atual. É por isso que a guarda é rotulada de “melhor esforço”. Uma única conclusão cara pode ultrapassar o orçamento antes que o guarda dispare.
Para uma aplicação rigorosa do orçamento, defina budgetUsd bem abaixo do seu limite real (por exemplo, 20% do seu teto) para absorver o excesso de chamada única no pior caso. O guarda não pode impedir que uma única chamada ultrapasse o orçamento.
Tabela de pesquisa de preços
Um simples Record mapeia identificadores de modelo para custos por token em dólares. Os leitores devem manter isso sincronizado com a página de preços do fornecedor, à medida que os preços mudam.
Implementação
const MODEL_PRICING: Record<string, { input: number; output: number }> = {
"example-large-model": { input: 3.00 / 1_000_000, output: 15.00 / 1_000_000 },
"example-small-model": { input: 0.20 / 1_000_000, output: 0.80 / 1_000_000 },
"default": { input: 5.00 / 1_000_000, output: 15.00 / 1_000_000 },
};
class BudgetTracker {
private spent = 0;
constructor(private readonly budgetUsd: number) {}
record(usage: { prompt_tokens: number; completion_tokens: number }, model: string): void {
const pricing = MODEL_PRICING(model);
if (!pricing) {
console.warn(`Unknown model "${model}", using default pricing. Budget tracking may be inaccurate.`);
}
const effectivePricing = pricing ?? MODEL_PRICING("default");
this.spent +=
usage.prompt_tokens * effectivePricing.input +
usage.completion_tokens * effectivePricing.output;
}
hasExceeded(): boolean {
return this.spent >= this.budgetUsd;
}
get currentSpend(): number {
return this.spent;
}
}
A integração disso ao loop do agente requer duas chamadas: budget.record(response.usage, model) após cada chamada LLM, e budget.hasExceeded() antes da próxima iteração. Quando o orçamento falha, o ciclo sai para o projecto de reserva em vez de continuar.
Guardrail 4: Fallback do lado do aplicativo para o estado de rascunho
Falhar graciosamente em vez de falhar silenciosamente
Quando qualquer guarda-corpo é acionado, o agente não deve simplesmente lançar um erro não tratado ou retornar uma string vazia. Ambos os resultados perdem o trabalho parcial que o agente concluiu antes do disparo do guardrail. O padrão draft-state captura tudo o que foi coletado até o momento e sinaliza o resultado como incompleto. Concretamente, ele preserva todas as saídas da ferramenta acumuladas durante a execução e o histórico completo da conversa, para que o aplicativo chamador possa inspecionar o que aconteceu, mostrá-lo a um humano ou tentar novamente com parâmetros ajustados.
Implementação
interface DraftResult {
kind: "draft";
reason: "retry_exhausted" | "timeout" | "budget_exceeded";
partialOutputs: Array<{ tool: string; output: string }>;
conversationSnapshot: ChatMessage();
}
interface CompleteResult {
kind: "complete";
content: string;
}
type AgentResult = CompleteResult | DraftResult;
A lógica de saída do loop do agente usa uma verificação direta: se o loop terminar normalmente, retorna um CompleteResult. Se um guardrail disparar, construa um DraftResult com as saídas parciais acumuladas da ferramenta, o motivo da parada e um instantâneo da conversa. Isso reflete o padrão comum de parar e abrir um rascunho de PR, codificado como um padrão genérico que qualquer aplicativo de chamada pode manipular, seja isso significando salvar um rascunho, notificar um humano ou enfileirar uma nova tentativa com parâmetros diferentes.
Juntando tudo: o loop completo do agente protegido
Listagem completa da fonte
import {
type ChatMessage,
type LLMClient,
type Tool,
type AgentResult,
type DraftResult,
} from "./types";
async function runAgentLoop(
client: LLMClient,
toolDefs: Tool(),
userPrompt: string,
options: { maxAttempts: number; timeoutMs: number; budgetUsd: number; model: string }
): Promise<AgentResult> {
const messages: ChatMessage() = ({ role: "user", content: userPrompt });
const attemptMap = new Map<string, number>();
const budget = new BudgetTracker(options.budgetUsd);
const partialOutputs: Array<{ tool: string; output: string }> = ();
const guardedTools = new Map(
toolDefs.map((t) => (t.name, withMaxAttempts(t, options.maxAttempts, attemptMap)))
);
const result = await withTimeout(async (signal: AbortSignal) => {
while (true) {
if (budget.hasExceeded()) {
return {
kind: "draft" as const,
reason: "budget_exceeded" as const,
partialOutputs,
conversationSnapshot: (...messages),
};
}
const response = await client.chat(messages, guardedTools, signal);
if (response.usage) {
budget.record(response.usage, options.model);
}
if (!response.toolCalls || response.toolCalls.length === 0) {
return { kind: "complete" as const, content: response.content };
}
const toolResults: ChatMessage() = ();
for (const tc of response.toolCalls) {
const tool = guardedTools.get(tc.name);
if (!tool) {
toolResults.push({ role: "tool", toolCallId: tc.id, content: "Unknown tool" });
continue;
}
try {
const output = await tool.execute(tc.arguments);
partialOutputs.push({ tool: tc.name, output });
toolResults.push({ role: "tool", toolCallId: tc.id, content: output });
} catch (err) {
if (err instanceof ToolAttemptExhaustedError) {
messages.push({
role: "assistant",
content: response.content,
toolCalls: response.toolCalls,
});
messages.push({
role: "tool",
toolCallId: tc.id,
content: `Tool "${tc.name}" is unavailable (max attempts reached).`,
});
return {
kind: "draft" as const,
reason: "retry_exhausted" as const,
partialOutputs,
conversationSnapshot: (...messages),
};
}
console.error("Tool execution error:", { toolName: tc.name });
toolResults.push({
role: "tool",
toolCallId: tc.id,
content: `Tool "${tc.name}" returned an error.`,
});
}
}
messages.push({
role: "assistant",
content: response.content,
toolCalls: response.toolCalls,
});
messages.push(...toolResults);
}
}, options.timeoutMs);
if (result && typeof result === "object" && "kind" in result) {
if (result.kind === "timeout") {
return {
kind: "draft",
reason: "timeout",
partialOutputs,
conversationSnapshot: (...messages),
};
}
return result as AgentResult;
}
throw new Error(
`runAgentLoop: unexpected result shape from withTimeout: ${JSON.stringify(result)}`
);
}
Executando o exemplo
async function main() {
const client: LLMClient = createYourLLMClient();
const tools = (lookupWeather, createDraftPR);
const result = await runAgentLoop(client, tools, "What's the weather in Sydney?", {
maxAttempts: 3,
timeoutMs: 120_000,
budgetUsd: 0.50,
model: "example-large-model",
});
if (result.kind === "complete") {
console.log("Agent completed:", result.content);
} else {
console.warn(`Agent stopped early (${result.reason}).`);
console.warn("Partial outputs:", result.partialOutputs);
console.warn("Conversation length:", result.conversationSnapshot.length, "messages");
}
}
main();
A ordem de verificação da guarda é importante. A verificação do orçamento é realizada antes de cada chamada do LLM (a operação mais cara). O limite de novas tentativas por ferramenta é acionado durante a execução da ferramenta. O tempo limite envolve todo o loop como a camada mais externa. Porque ele usa Promise.raceele retornará um rascunho no prazo, mesmo que o cliente LLM ignore o sinal de aborto, embora a solicitação desse cliente continue sendo executada em segundo plano, a menos que você passe signal para a camada HTTP.
Testando suas proteções
Simulando Falhas
Três testes manuais validam cada guarda-corpo de forma independente. Promova-os para testes automatizados usando Vitest, Jest ou qualquer executor de testes de sua escolha.
Tente novamente o teste de limite. Substitua uma ferramenta execute funcionar com aquele que sempre lança. Execute o loop do agente com maxAttempts definido como 2. O resultado deve ser um DraftResult com reason definido como "retry_exhausted" depois de exatamente duas invocações dessa ferramenta.
Teste de tempo limite. Definir timeoutMs para 1.000. Substitua uma ferramenta execute com uma função que aguarda 5 segundos setTimeout promessa. O resultado deverá ser um DraftResult com reason definido como "timeout" após cerca de um segundo. Passe o AbortSignal também para o seu cliente HTTP, de modo que a solicitação em andamento seja cancelada em vez de deixada em execução após o retorno do rascunho.
Teste de orçamento. Definir budgetUsd para 0,001. Execute o agente com qualquer prompt que acione pelo menos uma chamada de ferramenta. O guarda orçamentário deverá disparar após a primeira conclusão, retornando um DraftResult com reason definido como "budget_exceeded".
Promova-os para testes automatizados em um pipeline de CI assim que as proteções estiverem integradas a uma base de código de produção. Cada teste exerce um único modo de falha isoladamente, facilitando o diagnóstico das falhas.
O que vem a seguir
Os limites de tentativas por ferramenta impedem tempestades de novas tentativas. Os tempos limite de execução capturam chamadas externas suspensas. A guarda orçamental limita a exposição financeira e o projecto de reserva preserva o trabalho parcial em vez de o descartar. Juntas, essas quatro camadas cobrem a lacuna crítica que a maioria dos SDKs LLM deixa em aberto.
Loops descontrolados como o acima não são cenários inventados. Eles acontecem quando um sistema não tem grades de proteção e o modelo continua tentando, exatamente como foi projetado. Nos sistemas de agentes de produção, as proteções são um requisito.
Para as próximas etapas, considere integrar o registro estruturado em cada evento de proteção para alimentar painéis de observabilidade. Adicionar back-off exponencial com atraso ao wrapper de nova tentativa reduziria a carga em APIs externas instáveis. Sem demora, novas tentativas rápidas em APIs com taxa limitada geram erros 429, piorando o comportamento do loop que essas proteções pretendem evitar. A transmissão de conclusões com acompanhamento incremental do orçamento poderia diminuir a lacuna de acompanhamento do guarda orçamentário (passando da verificação pós-chamada para a verificação intermediária). SitePoints Texto datilografado e Desenvolvimento de IA os recursos cobrem tópicos adjacentes para engenheiros que desenvolvem essas bases.
Source link




