AI Internals - Observabilidade
Introduction
Harness entregou estado durável: log append-only de eventos, compactação, retomada, sandbox. Isso mantém a sessão viva. Não diz qual estágio está queimando dinheiro, qual query de retrieval voltou lixo, ou por que o p95 de um tenant dobrou depois do deploy de terça.
Observabilidade em apps com LLM é a camada de instrumentação por cima: traces que seguem um request por classify → retrieve → tool → synthesize, campos de custo amarrados a cada chamada ao provedor, e artefatos suficientes para replay de uma resposta ruim sem adivinhar só pelo texto do chat.
Orquestração já listou os campos por estágio que valem log. Este artigo é como ligá-los a algo que você consegue buscar, grafar e alertar — Langfuse, exportadores OpenTelemetry, ou uma tabela Postgres sua. A forma importa mais que o vendor.
Por que observabilidade de LLM não é APM comum
Um handler REST que devolve 200 em geral fez o mesmo trabalho toda vez. Um caminho com LLM pode devolver 200 com explicação errada de reembolso, loop de doze tool calls, ou completion de US$ 4 porque alguém colou um PDF de 90k tokens no retrieval.
Três diferenças aparecem em todo incidente de produção:
- A unidade cara é token, não milissegundo de CPU. Request "lento" pode ser barato; um rápido pode ser ruinoso.
- A falha costuma ser semântica, não stack trace. HTTP status ok; os chunks recuperados estavam errados.
- Um turn do usuário é uma árvore, não um span. Chamada de classifier, busca por embedding, duas rodadas de tool, retry de synthesis — tudo um "request" na UI do produto.
Você ainda quer histogramas de latência e taxa de erro. Também precisa de registros de generation com model id, contagem de tokens, finish reason, e os inputs que de fato chegaram ao modelo depois de projection e compactação.
Trace, span, generation
Ferramentas como Langfuse popularizaram uma hierarquia que encaixa bem em orquestração e turns do harness:
- Trace — uma unidade visível no produto: resposta de suporte, turn de agent, run de pipeline. Carrega
trace_id,session_id,user_id, ambiente, versão do release. - Span — um passo dentro do trace: retrieval, handler de tool, checagem de policy. Tem nome, início/fim, status, atributos.
- Generation (ou observation type
generation) — uma chamada LLM ao provedor. Guarda model, parâmetros, messages de input/output (ou hashes),usage, latência,finish_reason.
Um trace único costuma ter várias generations. Loop de agent com quatro chamadas ao modelo é um trace com quatro filhos generation, não quatro traces soltos.
Mantenha IDs estáveis entre o event log do harness e o trace. Quando o harness grava seq 42 tool_result, o span correspondente deve carregar event_seq=42. Retomada após crash não deve bifurcar identidade de observabilidade.
O conjunto mínimo de atributos
Se você só instrumentar seis coisas, que sejam estas:
| Campo | Por que existe |
|---|---|
trace_id | Amarra todo span de um outcome visível ao usuário |
session_id | Segue comportamento multi-turn e compactação |
stage | classify, retrieve, tool, synthesize, agent_turn |
model | Fixa comportamento entre deploys e testes A/B |
usage.prompt_tokens / usage.completion_tokens | Custo e pressão de contexto |
status | ok, error, cancelled, fallback |
No span de retrieve, acrescente query (ou hash), nome do índice, top-k chunk IDs, scores, latência do reranker. Em spans de tool: nome da tool, hash dos args validados (não secrets crus), idempotency key, HTTP status downstream.
O motivo de fallback da orquestração vai na raiz do trace quando synthesis falha no quality gate — senão você só vê "resposta vazia" na UI.
Passando um trace pelo pipeline
Abaixo um esqueleto que envolve o pipeline de orquestração da parte 7. Sem mágica de framework: abre trace, abre spans, registra generations onde o client HTTP retorna.
type Tracer struct {
backend Backend // Langfuse, OTel, seu DB
}
type Trace struct {
ID string
SessionID string
backend Backend
}
type Span struct {
TraceID string
Name string
backend Backend
start time.Time
}
func (t *Tracer) StartTrace(sessionID string) *Trace {
id := uuid.NewString()
t.backend.CreateTrace(id, sessionID)
return &Trace{ID: id, SessionID: sessionID, backend: t.backend}
}
func (tr *Trace) Span(name string) *Span {
return &Span{TraceID: tr.ID, Name: name, backend: tr.backend, start: time.Now()}
}
func (s *Span) End(status string, attrs map[string]any) {
s.backend.EndSpan(s.TraceID, s.Name, s.start, status, attrs)
}
func (s *Span) RecordGeneration(model string, usage Usage, finish string, ms int64) {
s.backend.RecordGeneration(s.TraceID, s.Name, model, usage, finish, ms)
}
func RunPipeline(tr *Trace, state *State) error {
s := tr.Span("classify")
state.Intent = classifyIntent(state.UserInput)
s.End("ok", map[string]any{"intent": state.Intent})
if needsRetrieval(state.Intent) {
r := tr.Span("retrieve")
state.Evidence, state.ChunkIDs = retrieveEvidence(state.UserInput)
r.End("ok", map[string]any{
"chunk_ids": state.ChunkIDs,
"k": len(state.ChunkIDs),
})
}
g := tr.Span("synthesize")
resp, err := callChatCompletion(buildMessages(state))
if err != nil {
g.End("error", map[string]any{"err": err.Error()})
return err
}
g.RecordGeneration(resp.Model, resp.Usage, resp.FinishReason, resp.LatencyMs)
state.FinalAnswer = resp.Text
g.End("ok", nil)
return nil
}O span de retrieve termina antes da synthesis de propósito. Quando a resposta alucina uma cláusula de policy, você quer chunk IDs num span irmão — não enterrados num blob de generation.
Loops de agent e turns do harness
Um turn de agent da parte 6 é um trace (ou trace filho sob uma raiz de sessão) com padrão repetido:
trace: agent_turn_17
span: project_messages
generation: model (tool_calls=[grep, read_file])
span: tool grep
span: tool read_file
generation: model (final text)Cada generation registra usage separado para somar custo por turn e achar turns em que o modelo chamou tools à toa. O evento interrupted do harness deve fechar o span do trace com status=cancelled e ainda persistir usage parcial se o provedor já cobrou tokens streamados.
Eventos de compactação ficam em metadata, não como substituto de histórico nos traces. Guarde compaction_through_seq no trace para saber que o modelo viu um resumo em vez dos turns 1–40 ao debugar regressão.
Subagents da parte 8 devem ser traces aninhados ligados por parent_trace_id. O pai só precisa do span de resultado do subagent mais totais de token — não trinta mil tokens de output de grep duplicados em dois lugares.
Custo: de onde vem a conta de fato
A parte 2 introduziu usage.prompt_tokens e usage.completion_tokens. Observabilidade é onde esses campos viram contabilidade.
Em todo registro de generation:
- Model id (incluindo model pinado da sessão no harness)
- Tokens de prompt, completion e total
- Cached prompt tokens quando o provedor expõe (cache hits de prefixo da parte 8)
- USD estimado de tabela de preço por model — calcule no exportador, não chute no dashboard
Agregue em três níveis:
- Por trace — quanto esta resposta custou ao negócio
- Por sessão — quanto custou aquela hora de coding agent
- Por tenant / feature flag — quem limitar ou upsell
Alerte em taxa de mudança, não só absolutos. Salto de 40% na média de prompt tokens por trace depois de mudança de template de prompt é bug de retrieval ou histórico muito antes de finance perceber.
Fique de olho em completion tokens em decode runaway. finish_reason: "length" com max_tokens alto é agent que nunca aprendeu a parar — barato de ver em agregados, caro de perder.
Latência: prefill, decode e o resto
Latência do provedor num span de generation deve separar quando der:
- Time to first token (TTFT) — em geral prefill em prompts longos; pico quando dumps de RAG crescem
- Tempo total de generation — prefill mais decode; cresce com tamanho de output
- Tempo fora do model — retrieval, embedding, rerank, HTTP de tool, execução na sandbox
Coloque latência de retrieval e tool em spans próprios. Senão p95 de "latência LLM" esconde vector search de 4s.
Para UI com streaming, registre quando o primeiro token chegou no span de generation. Usuário reclama de "lento" quando TTFT está ruim mesmo com tempo total ok.
Workflows de debug que funcionam
Três perguntas cobrem a maior parte dos plantões:
Por que esta resposta estava errada? Abra o trace. Veja chunk IDs e scores no span de retrieve — retrieval vazio ou score baixo explica a maior parte das falhas de groundedness. Compare hash das messages projetadas no span de generation com o que você achava que mandou. Se diferir, o bug é montagem ou compactação, não o model.
Por que estava lento? Ordene spans por duração dentro do trace. Se retrieve domina, conserte índice ou reduza k. Se TTFT domina, encolha prompt ou corrija envenenamento de prefix cache. Se spans de tool dominam, o model está fazendo trabalho demais por turn.
Por que estava caro? Some usage das generations no trace. Loops multi-call aparecem como quatro generations empilhadas. Compare prompt tokens trace a trace para o mesmo intent — salto significa histórico ou RAG crescendo.
Tenha um link de "trace ruim dourado" no runbook: uma falha conhecida com anotações. Engenheiros novos aprendem a UI mais rápido com exemplo concreto que com tour de dashboard.
Redação, sampling e retenção
Traces tentam guardar prompts completos. É também onde moram PII, secrets e conteúdo de página raspada.
Redija na captura em outputs de tool e leituras de ambiente — a mesma fronteira da sandbox do harness. Guarde SHA-256 de payloads de message para dedup e "mandamos a mesma coisa duas vezes"; texto completo só em buckets restritos com TTL menor.
Estratégias de sampling:
| Tráfego | Abordagem |
|---|---|
| Prod de baixo volume | Sample 100%, retenha 30 dias |
| Q&A alto volume | Sample 10–20%, sempre guarde erros e traces acima de limiar de custo |
| Abuso de free tier | Sample agressivo, sempre registre totais de token |
Sempre grave 100% dos traces que baterem fallback, falha de quality gate, ou negação de approval. São os que você precisa para fixes e para datasets de eval no próximo artigo.
Logs, traces e evals
As camadas empilham; não se substituem.
| Camada | Granularidade | Melhor para |
|---|---|---|
| Logs estruturados | Eventos linha a linha, baratos em volume | Correlação de deploy, auth, rate limits |
| Traces | Árvore de request com metadata LLM | Latência, custo, debug de retrieval |
| Runs de eval | Conjuntos rotulados + scoring | Provar que mudança de prompt ajudou |
Logs sozinhos raramente respondem "quais chunks foram recuperados pro ticket 8842". Traces sozinhos raramente provam regressão em mil perguntas rotuladas — isso é Evals.
Exporte trace IDs em linhas de falha de eval quando um humano marca resposta ruim. Você ganha link direto do caso de teste falho aos registros exatos de retrieval e generation.
Failure modes
- Um span pro pipeline inteiro. Você vê latência total, não qual estágio quebrou.
- Logar só texto final. Respostas erradas sem chunk IDs não são debugáveis.
session_idausente nos traces. Bugs de custo multi-turn e compactação parecem ruído isolado.- Args crus de tool com secrets. Trace vira vazamento de credencial.
- Ignorar campos de cached token. Ganhos de prefix cache somem dos gráficos de custo.
- Trace novo por chamada ao model num loop de agent. Loops ficam impossíveis de agregar.
- Retenção 100% de prompt completo para sempre. Incidente de compliance esperando acontecer.
- Dashboards sem alerta de drift de token. Finance acha o problema antes de engineering.
Conclusion
Observabilidade transforma estágios de orquestração e eventos do harness em algo mensurável: traces pro caminho, generations pra economia de tokens, spans irmãos pra retrieval e tools. Ligue IDs uma vez, redija na fronteira, e guarde metadata suficiente pra replay de respostas ruins sem SSH em produção.
No próximo vem Evals — datasets rotulados, scoring, e o loop que prova que uma mudança realmente ajudou antes de shippar pra todo mundo.