DISPONÍVEL PARA VAGAS FULL-TIME ABERTO A CONTRATOS FREELANCE AI FULLSTACK ENGINEER · AGENTIC AI AGENT-READY · HUMANS WELCOME AI AGENTS · LLM TOOLING · EVALS NEXT.JS · TYPESCRIPT · REACT EM LISBOA · REMOTO PARA O MUNDO
← Todos os posts

Streaming de 2,85M de mensagens: o encanamento de um chat de agente em produção

Fui o segundo committer mais ativo no pacote de chat open-source de uma plataforma de agentes em produção, a frente de streaming de um runtime de IA que transformava specs OpenAPI em ferramentas e conduzia agentes on-chain entre NEAR e EVM. Código real do BitteProtocol/chat: o loop do useChat, os estados de tool-call, o contrato de operationId do OpenAPI, e como o histórico é reconstruído.

Imagem de capa — Streaming de 2,85M de mensagens: o encanamento de um chat de agente em produção

O runtime em que trabalhei rodava loops de streaming de agentes que transformavam specs OpenAPI em ferramentas e conduziam agentes que agem on-chain entre NEAR e EVM. Quando saí, em outubro de 2025, ele tinha servido mais de 2,85M de mensagens em 344K chats. Fui o segundo committer mais ativo no pacote de chat open-source dele, @bitte-ai/chat (github.com/BitteProtocol/chat), com 115 commits. Esse pacote é a frente de streaming do sistema, e é construído sobre o Vercel AI SDK, ai@4.1.2.

Este post é o encanamento que eu realmente posso mostrar. Cada snippet abaixo é código real desse repo, com o caminho onde ele vive. O lado do servidor do runtime é fechado, então não vou citá-lo. O que é aberto é a parte que decidia se os usuários confiavam na coisa: o loop de stream, os estados de tool-call, e o momento em que o dinheiro se move.

O loop inteiro é um hook

O chat é um componente React. O loop inteiro de agente, streaming, tool calls, retries, mora dentro de uma chamada de useChat em src/components/chat/ChatContent.tsx:

const {
  messages,
  input,
  handleInputChange,
  isLoading: isInProgress,
  handleSubmit,
  reload,
  addToolResult,
  append,
  error,
} = useChat({
  maxSteps: 7,
  id: chatId,
  api: apiUrl,
  onToolCall: async ({ toolCall }): Promise<BitteToolResult | undefined> => {
    const localAgent = options?.localAgent;
    if (!localAgent) return undefined;

    try {
      return await executeLocalToolCall({
        localAgent,
        toolCall,
        metadata: { accountId, evmAddress, chainId },
      });
    } catch (error) {
      const errorMessage =
        error instanceof Error ? error.message : "Unknown error";
      console.error("Error executing tool call:", errorMessage);
      return { error: errorMessage };
    }
  },
  sendExtraMessageFields: true,
  initialMessages,
  headers: { Authorization: `Bearer ${apiKey}` },
  body: {
    id: chatId,
    config: { mode: AssistantsMode.DEBUG, agentId },
    accountId: accountId || "",
    evmAddress: evmAddress as Hex,
    chainId,
    localAgent: options?.localAgent,
  } satisfies ChatRequestBody,
});

Três decisões nesse bloco carregaram o produto.

Primeiro, maxSteps: 7. O loop do agente é server-authoritative, o modelo chama uma tool, lê o resultado, decide a próxima chamada, mas o cliente limita a cadeia inteira a sete passos. Um agente com acesso de escrita à carteira de um usuário, rodando em loop sem bound, é um gerador de conta com side effects. Sete foi suficiente para “checar saldos, montar o swap, revisar a transação” e pequeno o suficiente para que um modelo confuso ficasse sem estrada antes de esgotar a paciência do usuário.

Segundo, a carteira viaja em toda requisição. accountId, evmAddress e chainId vão no body de toda chamada, então o modelo nunca precisa perguntar “qual é o seu endereço”. O contexto viaja com o stream em vez de ocupá-lo.

Terceiro, onToolCall intercepta ferramentas específicas e as roda no navegador. Um localAgent é uma spec OpenAPI mais uma base URL, e suas ferramentas executam client-side, contra localhost se for lá que o agente roda. O mesmo stream de mensagens comanda tanto as ferramentas server-side quanto as locais, e o modelo não consegue notar a diferença.

Os nomes das ferramentas são operationIds do OpenAPI

O truque central do runtime era que as ferramentas não eram funções que alguém registrava à mão. Eram operações OpenAPI. O nome da ferramenta que o modelo emite é o operationId da spec do agente, e a execução é uma busca de volta nessa spec. Aqui está o resolver real de src/lib/local-agent.ts:

export const findToolPathAndMethod = (
  localAgent: LocalAgent,
  toolName: string
): { toolPath?: string; httpMethod?: string } => {
  let toolPath: string | undefined;
  let httpMethod: string | undefined;

  Object.entries(localAgent.spec.paths).forEach(
    ([path, pathObj]: [string, any]) => {
      Object.entries(pathObj).forEach(([method, methodObj]: [string, any]) => {
        if (methodObj.operationId === toolName) {
          toolPath = path;
          httpMethod = method.toUpperCase();
        }
      });
    }
  );

  return { toolPath, httpMethod };
};

Depois que o path é encontrado, buildUrlWithParams substitui os path parameters e se recusa a seguir se algum estiver faltando:

url = url.replace(/\{(\w+)\}/g, (_, key) => {
  if (remainingArgs[key] === undefined) {
    throw new Error(`Missing required path parameter: ${key}`);
  }
  const value = remainingArgs[key];
  delete remainingArgs[key];
  return encodeURIComponent(String(value));
});

Depois buildRequestOptions anexa o contexto da carteira como um header mb-metadata, e requisições GET têm seus args restantes serializados como query params por handleQueryParams, que descarta silenciosamente valores null e undefined. Os argumentos do modelo vão direto para o wire.

Esse design tem uma propriedade que só passei a valorizar depois: o failure mode é legível. Quando uma chamada quebra, você está depurando uma requisição HTTP comum para um endpoint documentado, não uma abstração de framework. A spec é o contrato, a tool call é a spec se resolvendo, e todo agente no registry falava o mesmo contrato.

Os estados das ferramentas são o produto

O AI SDK v4 modela uma tool call em andamento como um ToolInvocation com um state que migra de call para result. Esse único campo guiou toda a UI de transação. De src/components/chat/MessageGroup.tsx:

for (const invocation of message.toolInvocations) {
  const { toolName, toolCallId, state, args } = invocation;
  const result = state === "result" ? invocation.result : null;

  if (state !== "result") {
    if (toolName === BittePrimitiveName.SIGN_MESSAGE) {
      const { message, nonce, recipient, callbackUrl } = args;
      return (
        <ReviewSignMessage
          key={`${toolCallId}-${index}`}
          chatId={chatId}
          message={message}
          nonce={nonce}
          recipient={recipient}
          callbackUrl={callbackUrl}
          toolCallId={toolCallId}
          addToolResult={(result) =>
            addToolResult({ toolCallId: toolCallId, result })
          }
          // ...
        />
      );
    }
    return null;
  }

  if (
    toolName === BittePrimitiveName.GENERATE_TRANSACTION ||
    toolName === BittePrimitiveName.TRANSFER_FT ||
    toolName === BittePrimitiveName.GENERATE_EVM_TX
  ) {
    const transactions = result?.data?.transactions || [];
    const evmSignRequest = result?.data?.evmSignRequest;
    // renders <EvmTxCard> or <ReviewTransaction> with warnings
  }
}

Leia o control flow, porque ele é o security model. Uma tool call de sign-message que ainda não produziu um resultado não renderiza um spinner. Ela renderiza um card de aprovação com a mensagem exata, o nonce e o recipient, e o loop fica pausado até o usuário assinar ou recusar. A decisão dele volta para o stream através do addToolResult, que é como o modelo descobre o que o humano decidiu. Ferramentas de transação concluídas renderizam EvmTxCard ou ReviewTransaction com os payloads e quaisquer warnings que a ferramenta retornou.

Os primitivos eram um enum, não magic strings, em src/lib/constants.ts: generate-transaction, generate-evm-tx, sign-message, generate-image, render-chart, create-drop, transfer-ft. Cada um tinha um componente dedicado. A regra em que chegamos: se a UI renderiza algo como componente, isso nunca viaja como texto. Um plano de transação é dado, e o usuário aprova dado, não prosa que se afirma ser dado.

Erros seguiam o mesmo caminho. BitteToolResult é uma discriminated union, { data } ou { error: string }, e uma ferramenta que falhava renderizava sua string de erro em um CodeBlock em vez de fingir sucesso. Quando o próprio stream falhava, o estado de error do useChat renderizava um botão de Retry ligado ao reload(). Não elegante. Debugável às 2 da manhã, que é a métrica que importa.

O histórico é reconstruído, não replayed

Chats eram persistidos server-side como arrays de CoreMessage, o formato de wire do AI SDK. Carregar um de volta era uma rehydration em dois passos em src/components/BitteAiChat.tsx: fetchChatHistory(chatId, historyApiUrl) puxava as mensagens armazenadas, e convertToUIMessages de src/lib/chat.ts reconstruía o estado da UI, incluindo as tool invocations:

function addToolMessageToChat({
  toolMessage,
  messages,
}: {
  toolMessage: CoreToolMessage;
  messages: Array<Message & { agentId?: string }>;
}): Array<Message> {
  return messages.map((message) => {
    if (message.toolInvocations) {
      return {
        ...message,
        toolInvocations: message.toolInvocations.map((toolInvocation) => {
          const toolResult = toolMessage.content.find(
            (tool) => tool.toolCallId === toolInvocation.toolCallId
          );

          if (toolResult) {
            return {
              ...toolInvocation,
              state: "result",
              result: toolResult.result,
            };
          }
          return toolInvocation;
        }),
      };
    }
    return message;
  });
}

O histórico armazenado separa o conteúdo tool-call do assistente da mensagem posterior com role tool, que carrega o resultado. A reconstrução casa os dois pelo toolCallId e troca o invocation para state: "result", então um chat recarregado mostra transações finalizadas como finalizadas, com seus cards intactos, em vez de uma parede de JSON cru. sendExtraMessageFields: true no hook significava que o histórico anotado completo, e não uma versão reduzida, voltava para o servidor a cada turno, e foi isso que permitiu a um cliente reconectado continuar uma conversa como se a aba nunca tivesse sido fechada.

Uma função pequena pagava aluguel todo dia: getAgentIdFromMessage lia o agente das annotations da mensagem, e formatAgentId removia o sufixo .vercel.app para exibição. Chats multi-agent renderizavam quem disse o quê sem um join contra o registry.

O que a escala realmente me ensinou

As lições são menos glamorosas que a arquitetura.

Sete passos é uma decisão de produto, não técnica. Cada aumento de maxSteps era debatido, porque cada passo é mais uma chance de o modelo fazer algo caro. O cap é onde UX e safety se encontram.

O stream é uma state machine, não texto. Texto era o fallback. Tudo que era estruturado, transações, imagens, charts, sign requests, chegava como uma tool invocation tipada com um state, e a UI reagia a partir do state. A primeira versão deixava a UI parsear intent do texto em stream. Ela durou até o primeiro blob malformado renderizar como um code block quebrado em produção.

Aprovação é um stream event. A pausa antes de addToolResult disparar é o momento mais importante do produto, o usuário lendo exatamente o que vai acontecer com o dinheiro dele. Streamar os argumentos em um card de revisão acabou sendo uma feature de confiança, não decoração.

Contratos chatos vencem encanamento esperto. OperationId igual a nome da ferramenta, mb-metadata carrega a carteira, erros são strings em uma union. Nunca precisei explicar essas regras duas vezes para um novo autor de agente.

Nada disso exigiu inventar um protocolo de streaming. O Vercel AI SDK nos deu o loop, os states e os primitives de continuação. O trabalho foi decidir o que viaja no stream, o que espera por um humano, e o que a UI faz enquanto o modelo pensa. Essa é a diferença entre uma demo e um runtime, e a maior parte disso mora em um switch statement sobre toolInvocation.state.

Se você roda agentes em streaming em produção e tem sua própria lista de falhas chatas, eu gostaria de lê-la. Minha inbox está aberta.