ADR-0009: Streaming do grafo de execução do agente para a UI
- Status: Accepted
- Data: 2026-05-21
- Responsáveis pela decisão: Waldemar Szemat
Contexto e definição do problema
Seção intitulada “Contexto e definição do problema”O aplicativo de página única (SPA) da demo renderiza um Grafo de Execução do Agente ao vivo: um pipeline da esquerda para a direita que acende nó a nó conforme o LangGraph do agente é executado, e que codifica visualmente a latência por nó e o desfecho do turno. Esta ADR registra a única decisão arquiteturalmente significativa que esse grafo impõe.
A rota de requisição/resposta de POST /chat invoca o grafo LangGraph
compilado, aguarda o turno inteiro terminar e retorna um único documento
JSON ChatResponse. O painel de rastreamento de backend do aplicativo
renderiza esse documento depois que o turno é concluído. Essa rota sozinha
não oferece ao navegador forma de saber que guardrail_pre terminou e que
retrieve_context começou enquanto o turno ainda está em execução. Uma
visualização nó a nó precisa exatamente disso: eventos de progresso por nó
entregues ao navegador conforme acontecem.
O agente já produz os dados subjacentes por nó. A decisão de observabilidade (ADR-0006) instrumentou spans do OpenTelemetry ao redor de cada nó do grafo, cada chamada de LLM, cada recuperação e cada decisão de guardrail, cada um carregando tempo e atributos. A medição por nó existe. O que não existe é um caminho que emita eventos por nó para um navegador conforme o turno é executado. O arcabouço LangGraph escolhido na ADR-0001 fornece a fonte para esse caminho: o grafo compilado expõe uma API de streaming que produz um evento por etapa do ciclo de vida do nó, ao lado da invocação não-streaming que o código de requisição/resposta usa.
A superfície da API de streaming do LangGraph evoluiu durante 2026: a API que a
implementação usa é a API de streaming do LangGraph recomendada à época da
implementação. A decisão aqui registrada, um transporte SSE
negociado por meio do cabeçalho Accept e opcional sobre os endpoints existentes,
é independente de qual API de streaming específica do LangGraph acabe
por alimentá-la; a implementação seleciona a API recomendada vigente à época.
Duas restrições limitam a resposta. Primeiro, o contrato JSON existente de POST /chat e
POST /chat/resume não pode quebrar: chamadores programáticos
e os próprios testes do projeto do caminho JSON precisam continuar funcionando
byte a byte. O arcabouço de avaliação não está entre os chamadores afetados - ele
conduz o agente por meio do construtor de grafo e da invocação não-streaming
diretamente e nunca chama o endpoint HTTP -, mas consumidores gerais da
API HTTP estão. Segundo, o aplicativo de página única é livre de arcabouço e
de dependências por construção (ADR-0007); seja qual for o transporte escolhido,
o lado do navegador precisa consumi-lo com JavaScript puro e sem biblioteca.
Como entregamos eventos de execução do agente por nó ao navegador em tempo
real, para que o aplicativo possa renderizar um grafo de execução ao vivo, sem quebrar o
contrato JSON de /chat existente e sem adicionar uma dependência
no lado do cliente?
Fatores da decisão
Seção intitulada “Fatores da decisão”- Fluxo unidirecional, do servidor para o cliente. O navegador precisa
receber uma sequência de eventos de nó. Ele não precisa enviar nada
no meio do turno; o turno já está totalmente especificado pelo corpo inicial do
POST /chat. O transporte deve corresponder a esse formato e não carregar o custo de uma capacidade que o recurso não usa. - Compatibilidade retroativa do contrato JSON de
/chat. Um chamador programático existente, e todo teste existente do caminho JSON, precisa receber oChatResponseinalterado. O comportamento de streaming precisa ser opcional, não uma mudança que quebre a resposta padrão. - Nenhuma nova dependência no lado do cliente. O aplicativo é livre de arcabouço; o navegador precisa consumir o stream com uma API embutida e JavaScript puro. Nenhuma biblioteca de cliente pode ser introduzida.
- Reutilizar o grafo existente e os dados por nó existentes. O caminho de streaming precisa partir do grafo compilado já construído no lifespan do FastAPI e dos spans por nó que a ADR-0006 já instrumentou. Ele não pode construir um segundo grafo nem reimplementar a medição por nó.
- Implantabilidade no host de demo. O transporte precisa funcionar através do Google Cloud Run como uma única instância com escala a zero (ADR-0007) a cerca de $0, e através do restritivo proxy do Hugging Face Spaces com SDK Docker no alvo secundário.
- Legibilidade de engenharia. Uma superfície de streaming limpa conduzida a partir de uma API de streaming do LangGraph sobre o grafo compilado deve se ler como uma escolha de transporte deliberada, não incidental.
Opções consideradas
Seção intitulada “Opções consideradas”- Server-sent events (SSE), opcional por negociação de conteúdo via cabeçalho
Accept(escolhida):POST /chatePOST /chat/resumeretornam um corpotext/event-streamquando a requisição carregaAccept: text/event-stream, e o JSONChatResponseinalterado caso contrário. - WebSocket: um novo endpoint bidirecional
ws://carregando os eventos por nó. - Polling do cliente: o navegador requisita repetidamente um endpoint de status do turno até que o turno seja concluído.
- Sem streaming; renderizar o grafo pós-turno: manter a invocação
não-streaming, desenhar o caminho final dos nós uma vez a partir do
ChatResponse.
Dentro da opção SSE, o próprio esquema de eventos foi uma subdecisão. O AG-UI, um protocolo de eventos agente-para-UI emergente de 2026, foi considerado como o esquema de fio para os eventos por nó. Para esta demo autocontida o projeto usa, em vez disso, um pequeno esquema de eventos SSE sob medida (seis eventos, enumerados no Resultado da decisão abaixo) que é suficiente para o Grafo de Execução do Agente e não carrega superfície de protocolo externo. A nomenclatura de eventos compatível com AG-UI é registrada como possível alinhamento futuro se o projeto algum dia precisar interoperar com um cliente AG-UI.
Resultado da decisão
Seção intitulada “Resultado da decisão”Opção escolhida: server-sent events, opcional por meio de negociação de conteúdo
via cabeçalho Accept nos endpoints /chat e /chat/resume
existentes. Quando uma requisição carrega Accept: text/event-stream, o
endpoint retorna um stream SSE de seis tipos de evento: um evento de abertura
graph_topology carregando o conjunto real de nós e arestas do grafo compilado,
depois eventos node_started, node_completed, paused e error
conforme o turno é executado, e um turn_completed terminal que carrega o
ChatResponse completo. O backend conduz esse stream a partir de uma API de
streaming do LangGraph sobre o grafo compilado já mantido no estado da aplicação.
Quando uma requisição carrega qualquer outro Accept, o endpoint se comporta exatamente como
o caminho de requisição/resposta: ele invoca o grafo e retorna o
JSON ChatResponse.
O SSE é a escolha estrutural porque o fluxo de dados é estritamente
unidirecional. O navegador recebe um stream de eventos de nó; ele nunca
precisa enviar um frame de volta no meio do turno. O SSE é um protocolo
unidirecional do servidor para o cliente e é, portanto, um encaixe exato, onde o WebSocket
acrescentaria um canal full-duplex, um endpoint separado não-HTTP e
tratamento de ciclo de vida de conexão para um canal de retorno que o recurso nunca usa.
O SSE também é consumido sem nenhuma biblioteca de cliente, o que mantém a
linha de zero dependências do aplicativo. A API EventSource embutida do navegador não pode ser
usada aqui: o EventSource emite um GET e não pode enviar um corpo de requisição ou
cabeçalhos personalizados, ao passo que /chat e /chat/resume são endpoints POST
que recebem um corpo JSON e precisam carregar Accept: text/event-stream. O aplicativo
consome o stream, em vez disso, com a API fetch que ele já usa para
/chat: ele lê o corpo da resposta em streaming através de
response.body.getReader(), decodifica os bytes com TextDecoder e
analisa ele próprio os frames text/event-stream. Isso é uma pequena quantidade de
JavaScript puro e não adiciona dependência. E o SSE trafega sobre HTTP simples, então ele
atravessa qualquer proxy HTTP - incluindo o restritivo proxy de alvo secundário
do Hugging Face Spaces - sem um upgrade de protocolo.
Tornar o streaming opcional por meio de negociação de conteúdo é a segunda
escolha estrutural. O cabeçalho Accept é o próprio mecanismo do HTTP para um
cliente declarar qual representação ele deseja. Atrelar o comportamento de streaming
a Accept: text/event-stream significa que o contrato JSON fica intocado para todo chamador
que não solicita o stream: mesmo caminho de endpoint, mesmo método, mesmo esquema
ChatResponse, mesmos códigos de status. O modo de streaming é puramente aditivo. Isso
preserva o valor de opção da superfície /chat: o caminho de streaming e o caminho JSON coexistem em um
endpoint, e uma mudança futura em qualquer um deles não perturba o outro.
O caminho de streaming adiciona uma camada de emissão, não um novo agente. Ele usa uma API de streaming do LangGraph sobre o mesmo grafo compilado que o lifespan já constrói; o construtor de grafo permanece inalterado, o código dos nós permanece inalterado, os spans por nó do OpenTelemetry da ADR-0006 permanecem inalterados. A decisão é fazer streaming do que já é executado, não rearquitetar o agente.
As opções não-streaming foram rejeitadas. O WebSocket compra bidirecionalidade
que o recurso não precisa, ao custo de um segundo endpoint e mais
código de ciclo de vida. O polling produz uma serra de requisições, não consegue entregar um
evento de nó no instante em que ele acontece e, em uma instância com escala a zero, desperdiça o
orçamento de requisições por sessão que a ADR-0007 limita por taxa. Renderizar o grafo apenas
pós-turno a partir do ChatResponse é o fallback honesto (e é o caminho de
degradação do aplicativo), mas como experiência principal ele descarta todo o efeito
ao vivo, nó a nó, que é o objetivo do recurso.
Contrato de compatibilidade retroativa
Seção intitulada “Contrato de compatibilidade retroativa”O modo de streaming é aditivo e opcional. O contrato é:
- Uma requisição
POST /chatouPOST /chat/resumecomAccept: text/event-streamrecebe uma respostatext/event-stream. - Uma requisição com qualquer outro valor de
Accept- incluindoapplication/json,*/*e um cabeçalhoAcceptausente - recebe o JSONChatResponse: esquema, nomes de campos e códigos de status idênticos. - O evento terminal
turn_completeddo stream SSE carrega o payload completo doChatResponse, então um cliente de streaming encerra o turno com exatamente os dados que um cliente JSON recebe. - No stream de
/chat/resume, o eventoturn_completedtambém carrega uma mediçãohuman_wait_ms. Esse valor vive no envelope do evento SSE, não dentro do objetoChatResponse: o esquema doChatResponsepermanece byte a byte idêntico tanto no caminho JSON quanto no de streaming, ehuman_wait_mssó é observável por um cliente de retomada em streaming. - O arcabouço de avaliação não é afetado: ele invoca o grafo por meio do construtor de
grafo diretamente e nunca emite uma requisição HTTP
/chat. A negociação de conteúdo existe para consumidores gerais da API HTTP, não para o arcabouço.
Nota de implantação: o proxy de implantação não pode bufferizar o stream
Seção intitulada “Nota de implantação: o proxy de implantação não pode bufferizar o stream”O SSE depende de cada evento chegar ao navegador conforme ele é descarregado. Um proxy
reverso que bufferiza o corpo da resposta anula o recurso: o navegador
receberia todos os eventos de nó de uma vez, ao fim do turno. O alvo secundário do
Hugging Face Spaces com SDK Docker (ADR-0007) fica atrás de tal proxy, e um proxy
de plataforma gerenciada também pode bufferizar. A
resposta de streaming irá, portanto, definir X-Accel-Buffering: no e
Cache-Control: no-cache, e o manipulador de streaming irá descarregar cada registro SSE
conforme a API de streaming do LangGraph o produz. X-Accel-Buffering: no
é uma dica específica do nginx, no entanto, e não é garantido que todo proxy
a honre: o cabeçalho é necessário, mas não comprovadamente
suficiente. A implementação precisa, portanto, verificar no serviço ao vivo
que os eventos de fato chegam de forma incremental (por exemplo, uma verificação curl -N
contra o endpoint implantado observando os eventos chegarem um a um, em vez de
como um único bloco de fim de turno), e o runbook de implantação registra tanto o
cabeçalho quanto essa etapa de verificação. A postura de instância única
registrada na ADR-0007 é compatível com SSE: uma resposta SSE é uma
resposta HTTP de longa duração na instância, não estado compartilhado.
Confirmação
Seção intitulada “Confirmação”A decisão foi implementada e confirmada em conformidade com os seguintes critérios:
- Os endpoints
/chate/chat/resumeramificam conforme o cabeçalhoAcceptda requisição:text/event-streamretorna uma resposta em streaming, todo outro valor retorna o JSONChatResponse. Ambos os ramos são cobertos por testes, incluindo um teste de regressão afirmando que o caminho JSON está byte a byte inalterado. - O ramo de streaming parte de uma API de streaming do LangGraph sobre o grafo compilado no estado da aplicação; o construtor de grafo permanece inalterado, o que os testes unitários inalterados do grafo do agente continuam a verificar.
- Um teste de streaming afirma a sequência de eventos SSE (a abertura
graph_topology, depois os eventos por nó e terminais) para um turno normal, um turno recusado (com umretrieve_contextpulado), um turno pausado por HITL e um turno que falha no meio do stream, contra o esquema de eventos documentado. - A suíte de gate de qualidade e o arcabouço de avaliação passam, confirmando que a adição de streaming não perturbou o caminho de execução não-HTTP.
- A documentação de implantação registra o requisito de não bufferização do proxy e a etapa de verificação de entrega incremental no serviço ao vivo.
Consequências
Seção intitulada “Consequências”Positivas
Seção intitulada “Positivas”- O navegador recebe eventos de execução por nó em tempo real, que é a capacidade habilitadora do Grafo de Execução do Agente ao vivo.
- O contrato JSON de
/chatfica intocado: o streaming é opcional por meio deAccept, então nenhum chamador programático existente e nenhum teste existente do caminho JSON muda de comportamento. - Nenhuma dependência no lado do cliente é adicionada; o aplicativo permanece livre de arcabouço e
consome o corpo
text/event-streamcom JavaScript puro. - O caminho de streaming reutiliza o grafo compilado e os spans por nó que o projeto já constrói; é uma camada de emissão, não um segundo agente.
- O SSE trafega sobre HTTP simples, então ele implanta através de qualquer proxy HTTP - incluindo o alvo secundário do Hugging Face Spaces - sem upgrade de protocolo e permanece dentro da postura de cerca de $0.
- Uma superfície limpa de streaming-do-LangGraph-para-SSE é uma demonstração concreta de engenharia de agentes com streaming.
Negativas
Seção intitulada “Negativas”- Os manipuladores
/chate/chat/resumeganham um segundo caminho de código; os endpoints agora têm um ramo JSON e um ramo de streaming a manter em sincronia, o que é mais superfície a testar e manter. - A entrega SSE depende de nenhum proxy bufferizar a resposta; um proxy
reverso mal configurado degrada silenciosamente o efeito ao vivo para um despejo pós-turno. O
cabeçalho
X-Accel-Buffering: noé uma dica específica do nginx e não é garantido ser honrado por todo proxy (o alvo secundário do Hugging Face Spaces em particular), então ele é necessário, mas não comprovadamente suficiente; a implementação precisa verificar a entrega incremental no serviço ao vivo (veja a nota de implantação). Esta é uma dependência operacional que não existia antes. - Uma resposta SSE de longa duração ocupa uma conexão pela duração do turno; na única instância com escala a zero isso limita a concorrência, consistente com a postura existente de instância única, mas digno de menção.
- Uma resposta SSE de longa duração precisa ser cancelada quando o cliente se desconecta. Se o navegador fecha a conexão (aba fechada, navegação para fora) e o manipulador não percebe, a execução do grafo continua executando e a única instância permanece ocupada com trabalho que ninguém vai ler, vazando a única instância que a demo tem. O manipulador de streaming precisa detectar a desconexão do cliente e abortar a execução do grafo em andamento, em vez de deixá-la rodar até a conclusão sem ser observada.
- A robustez do SSE através de proxies ociosos exige cuidado explícito. O stream precisa
emitir um campo
idpor evento e linhas de comentário de heartbeat periódicas (linhas keep-alive:) para que um intermediário ocioso não trate uma conexão quieta como morta e a descarte. Isso importa mais durante uma pausa de HITL: entre o eventopausede a retomada do humano, o stream pode ficar ocioso por muito tempo, e sem heartbeats essa janela de ociosidade é exatamente quando um proxy tem mais probabilidade de fechar a conexão. - O SSE é unidirecional por concepção: se um recurso futuro algum dia precisar de uma mensagem cliente-para-servidor no meio do turno, o SSE não pode carregá-la e esse recurso precisaria de um transporte diferente. Este é um limite aceito, não um custo atual.
Neutras
Seção intitulada “Neutras”- O caminho de execução do agente permanece inalterado: o construtor de grafo, o código dos nós, os guardrails, o caminho de RAG e os spans do OpenTelemetry estão todos como estavam. A decisão adiciona emissão, não comportamento.
- O esquema
ChatResponsepermanece inalterado; ele agora também é carregado como odatado evento SSE terminalturn_completed. - O esquema de eventos SSE torna-se uma nova superfície de contrato público: seis eventos
(
graph_topology,node_started,node_completed,paused,error,turn_completed), documentados e versionados com o projeto. - Uma API de streaming do LangGraph torna-se uma parte usada da dependência LangGraph
já fixada; nenhum novo pacote entra no lockfile. Qual API de streaming específica
é usada é uma escolha de implementação (a superfície de streaming do LangGraph
evoluiu durante 2026) e não muda esta
decisão, que é sobre o transporte SSE e a opção negociada por
Accept.
Prós e contras das opções
Seção intitulada “Prós e contras das opções”SSE, opcional por negociação de conteúdo via cabeçalho Accept
Seção intitulada “SSE, opcional por negociação de conteúdo via cabeçalho Accept”- Bom, porque o SSE é unidirecional do servidor para o cliente, o que corresponde exatamente a um feed de eventos de nó que não precisa de canal de retorno do cliente.
- Bom, porque a negociação de conteúdo mantém o contrato JSON do
ChatResponsebyte a byte intacto para todo chamador não-streaming. - Bom, porque o navegador consome
text/event-streamcom a APIfetchembutida e um leitor de corpo em streaming, sem biblioteca de cliente, mantendo a linha de zero dependências. - Bom, porque o SSE trafega sobre HTTP simples e atravessa qualquer proxy HTTP - incluindo o alvo secundário do Hugging Face Spaces - sem upgrade de protocolo.
- Bom, porque o stream é conduzido a partir de uma API de streaming do LangGraph sobre o grafo compilado existente; sem segundo grafo, sem novo código de medição.
- Ruim, porque os manipuladores
/chatganham um segundo caminho de código a manter em sincronia com o caminho JSON. - Ruim, porque a entrega correta depende de nenhum proxy bufferizar o corpo da resposta.
WebSocket
Seção intitulada “WebSocket”- Bom, porque é um transporte em tempo real maduro e amplamente suportado.
- Bom, porque poderia carregar uma futura mensagem cliente-para-servidor no meio do turno sem uma mudança de transporte.
- Ruim, porque o fluxo de dados do recurso é estritamente unidirecional; full-duplex é uma capacidade paga e nunca usada.
- Ruim, porque exige um endpoint separado não-HTTP e tratamento explícito de ciclo de vida de conexão, mais superfície que o SSE para o mesmo resultado.
- Ruim, porque um upgrade
ws://é um caminho menos trivial através do proxy de alvo secundário do Hugging Face Spaces do que uma resposta de streaming em HTTP simples.
Polling do cliente
Seção intitulada “Polling do cliente”- Bom, porque não precisa de primitiva de streaming em nenhum dos lados e é trivial de implementar.
- Ruim, porque não consegue entregar um evento de nó no instante em que o nó começa; o efeito ao vivo, nó a nó, degrada para uma aproximação grosseira, quantizada pelo intervalo de polling.
- Ruim, porque produz uma rajada de requisições HTTP por turno, desperdiçando o orçamento de requisições por sessão que a ADR-0007 limita por taxa na demo.
Sem streaming; renderizar o grafo pós-turno
Seção intitulada “Sem streaming; renderizar o grafo pós-turno”- Bom, porque não adiciona nenhum novo comportamento de endpoint e é o caminho honesto de degradação quando o SSE está indisponível.
- Ruim, porque como experiência principal ele descarta toda a visualização ao vivo, nó a nó, que é o objetivo do recurso; o grafo só mostraria um turno já concluído.
Mais informações
Seção intitulada “Mais informações”- ADR-0001: Arcabouço de orquestração - LangGraph, a fonte da API de streaming e do grafo compilado de onde o stream é conduzido.
- ADR-0006: Pilha de observabilidade - os spans por nó do OpenTelemetry que já medem o tempo dos nós.
- ADR-0007: Alvo de implantação - Google Cloud Run (com Hugging Face Spaces como alvo secundário), a postura de instância única e o proxy que o stream SSE precisa atravessar sem bufferização.
- Especificação de server-sent events do WHATWG HTML: https://html.spec.whatwg.org/multipage/server-sent-events.html
- Documentação de streaming do LangGraph: https://langchain-ai.github.io/langgraph/how-tos/streaming/
- Protocolo de eventos agente-para-UI AG-UI (considerado para o esquema de eventos, registrado como possível alinhamento futuro): https://ag-ui.com/
- MADR 4.0.0: https://adr.github.io/madr/