OpenTelemetry é o SDK do Langfuse pra Go
🇬🇧 Read it in English
EVAL PASSED - gate model "qwen2.5:3b": acc 95.1%, answer 95.7%
Fiquei uma semana olhando pra essa linha me achando o cara. Daí eu mudei um prompt, rodei de novo, deu 94,6%, e veio a pergunta óbvia: isso é pior que ontem, ou é ruído?
Subi o scroll pra conferir. O meu terminal tinha comido o ontem.
É isso. Esse é o motivo inteiro de eu ter colocado o Langfuse na frente de um agente em Go. Não porque eu queria um dashboard, mas porque um número que você não consegue comparar com o da semana passada não é medição, é achismo.
O agente é a Ava, uma PoC de pesquisa em Go: um voice agent que aplica pesquisas de opinião em voz alta e sabe a hora de desligar. Ela existe porque um time aqui tinha um voice agent que não sabia quando a conversa tinha acabado, então eu construí a PoC mais burra que funcionava em vez de escrever uma spec. Uma tarde, zero documento.
Este post é o que aconteceu depois daquela tarde, quando “funciona” parou de ser suficiente e eu quis saber se aquilo continuava funcionando.
E é em Go que aparece a primeira parede, porque o Langfuse tem SDK pra Python e pra JS e nada pra gente. Acontece que tudo bem. O que vem a seguir é o caminho inteiro: o que é um eval, o eval porco que eu escrevi primeiro, e como ele chegou no Langfuse com nada além do SDK padrão do OpenTelemetry e do net/http.
Primeiro: como você sabe se uma aplicação de LLM presta?
Se você escreve uma função que soma dois números, o teste é óbvio:
if Add(2, 2) != 4 {
t.Fatal("math is broken")
}
Agora escreve esse teste pra um modelo. Mesma entrada, roda duas vezes, saem duas frases diferentes. As duas certas. O != não te serve pra nada.
Essa é a parte que enrola quem vem de software normal: o modelo não é a unidade sob teste, o comportamento é. Você não está afirmando que a saída é uma string específica. Você está afirmando que a saída tem uma propriedade que te interessa. Então o trabalho todo vira: escolher a propriedade e achar um jeito de dar nota pra ela.
A Chip Huyen cataloga as formas de fazer isso em AI Engineering. Eu acabei usando três delas, e são essas três que eu aprenderia antes de encostar em qualquer plataforma.
1. Corretude funcional
O sistema fez a coisa? Não “o texto ficou bonitinho” - ele funcionou?
Esse é o tipo mais forte de eval e sempre o primeiro a buscar, porque não tem interpretação envolvida. Se você pede pro modelo escrever gcd(a, b), você não dá nota pro código, você roda e checa que gcd(15, 20) devolve 5. É assim que o LeetCode te avalia e é assim que o HumanEval avalia modelos.
A versão da Ava: a cada resposta, um classificador decide o que a conversa faz em seguida - avançar, reler a pergunta, pedir esclarecimento ou desligar. Essa decisão tem uma resposta certa, então o eval é uma comparação:
turn, err := classifier.ClassifyTurn(ctx, c.q, c.reply)
if turn.Intent == c.want {
report.correct++
}
Chato. == num label. Isso é uma feature. A saída do classificador é restrita o bastante pra ser checada exatamente, e tudo que vem depois dessa decisão é uma máquina de estados que eu testo como qualquer outro código Go. Quando dá pra empurrar uma coisa difusa pra um conjunto pequeno de labels, empurre - você recupera o seu ==.
2. Similaridade contra dados de referência
Tem coisa que não tem uma resposta certa só. A frase de encerramento da Ava é um retorno personalizado ao que a pessoa falou. Existem mil boas.
Aqui você compara a saída contra dados de referência: um corpus rotulado de pares (entrada, esperado), onde esperado é uma resposta de referência em vez de um label. E “comparar” se divide em dois.
Similaridade léxica trabalha nas palavras em si. Sobreposição, distância de edição, BLEU, ROUGE. Barato, determinístico, sem modelo no meio, e não faz a menor ideia de que “caro” e “salgado” significam a mesma coisa.
Similaridade semântica trabalha no significado. Você joga os dois textos em vetores e mede o ângulo entre eles, então paráfrase pontua alto. Custa uma chamada de embedding por comparação, e devolve um float pro qual você agora tem que escolher um limiar.
Em Go as duas são funções do mesmo formato:
// Léxica: palavras em comum, sem modelo envolvido.
func WordOverlap(got, reference string) float64
// Semântica: gera os embeddings e mede o ângulo entre os vetores.
func CosineSimilarity(ctx context.Context, e Embedder, got, reference string) (float64, error)
Daí você dá nota pro caso com a que couber, contra um limiar que é seu:
score := WordOverlap(got, c.reference)
if score < 0.6 {
report.miss(c, score)
}
Eu fui de léxica, e não foi porque eu benchmarkei nada. Eu precisava de uma propriedade só: o agente falou de coisas que a pessoa realmente disse? Essa pergunta é sobre quais palavras apareceram, então significado nunca foi o eixo.
Uma dúzia de linhas de strings.Fields e um map[string]bool responderam. O UnsupportedWords(line, answers) me dá as palavras do encerramento que não aparecem nem nas respostas da pessoa nem no repertório do próprio agente. Uma checagem barata de groundedness: ela não pergunta se a frase está certa, só se tem alguma coisa ali que foi inventada.
E pegou uma de verdade. Eu coloquei um exemplo bonitinho de frase de encerramento no prompt, e o modelo de 3B copiou ele literalmente nos 13 casos, contando animadamente sobre lavanda pra uma pessoa que só tinha dito “Baunilha.”. Todo score de match exato continuava marcando 100%, porque nenhum deles estava checando se as palavras tinham sido conquistadas.
Tem um preço, e ele é especificamente o preço da léxica: uma paráfrase legítima (“aroma” no lugar de “cheiro”) cai naquela lista como violação. A semântica perdoaria isso, ao custo de uma chamada de embedding e de um limiar que eu teria que defender. De um jeito ou de outro eu reporto o número e nunca travo nada nele. Ele me diz onde ir olhar, e nada além disso.
3. IA como juiz
E aí tem o tipo de coisa que comparação de string nenhuma vai alcançar. A Ava fala um reconhecimento curto antes da próxima pergunta pra não parecer um formulário. “Lavanda, boa escolha” é um ack bom? Você sabe na hora. O seu código não faz ideia.
Então você pergunta pra um modelo, restringe ele a {"good": bool, "reason": string} e faz o parse do JSON. Em Go isso é uma chamada de API e um json.Unmarshal, não tem nada de esperto aí.
Duas regras que eu passaria pra qualquer um fazendo isso. Fixe um único modelo juiz pra todos os modelos que você avalia, ou os seus scores param de ser comparáveis entre si. E nunca deixe o juiz quebrar o seu build - ele é uma dependência paga e não determinística, e queda de juiz não pode deixar o CI vermelho.
Custo e alcance sobem conforme você desce essa lista. Confiança vai no sentido contrário. Então trave o build no primeiro tipo, e apenas acompanhe os outros dois.
O eval porco: um slice, um loop e um exit code
Essa é a parte que eu queria que alguém tivesse me falado antes: um eval não é uma plataforma. São três coisas.
Um dataset:
type evalCase struct {
q string
reply string
want llm.Intent
clarity llm.Clarity
}
var dataset = []evalCase{
{"What's your favorite scent?", "Vanilla, definitely.", llm.IntentAnswer, clear},
{"What could we do better?", "Nothing that comes to my mind actually.", llm.IntentAnswer, clear},
{"How do you like it?", "(coughing)", llm.IntentUnintellig, na},
// ~80 desses, rotulados na mão
}
Um scorer - o loop lá de cima, num worker pool. E um limiar:
minAcc := flag.Float64("min-acc", 0.90, "minimum overall intent accuracy to pass")
minAns := flag.Float64("min-answer", 0.95, "minimum valid-answer acceptance to pass")
// ...
if gate.acc() >= *minAcc && gate.ansRate() >= *minAns {
fmt.Printf("\nEVAL PASSED - gate model %q: acc %.1f%%, answer %.1f%%\n", ...)
return
}
os.Exit(1)
go run ./cmd/eval, e um exit code diferente de zero quando o comportamento do agente regride. Isso é um eval de verdade. Zero dependências, e pegou bug de verdade.
NOTA: os dois limiares são deliberadamente diferentes. Ler errado uma resposta e classificar como outra coisa perde uma resposta real, então isso trava forte. Um reconhecimento sem graça é cosmético, então é reportado e nunca bloqueia. Trave no que perde dado.
Eu não estou te mostrando a versão porca porque ela é charmosa. Estou mostrando porque tudo que o Langfuse me deu depois é uma visão em cima exatamente dessas três peças. O scorer é descartável. O dataset é o ativo.
Quando você enxerga isso, a plataforma passa a ser armazenamento em vez de mágica. Que é tudo que eu sempre quis dela, já que o meu problema real era que a run de ontem não existia mais.
Duas portas pro Langfuse, e nenhuma delas é um SDK
O Langfuse não tem SDK pra Go. O que ele tem são duas superfícies HTTP, e juntas elas cobrem tudo:
- Um endpoint OTLP em
/api/public/otel/v1/traces. Esse é o caminho oficialmente suportado pra qualquer linguagem sem SDK - a documentação de OpenTelemetry deles fala isso na lata: “For other languages, use the native OpenTelemetry API for your language and export spans to Langfuse.” Você aponta o SDK padrão de OpenTelemetry pra Go pra lá. Isso carrega os traces. - Uma API REST em
/api/public/*pras coisas que o OpenTelemetry não tem conceito: datasets, runs de experimento e scores.net/httppuro. A documentação da Public API cobre auth e convenções; a referência completa da API é a página que você vai deixar aberta de verdade.
Essa é a arquitetura inteira. O internal/obs no meu projeto são dois arquivos, um por porta.
Porta 1: traces via OTLP
A configuração inteira é um exporter:
func Init(ctx context.Context) (shutdown func(context.Context) error, enabled bool, err error) {
noop := func(context.Context) error { return nil }
pk := strings.TrimSpace(os.Getenv("LANGFUSE_PUBLIC_KEY"))
sk := strings.TrimSpace(os.Getenv("LANGFUSE_SECRET_KEY"))
if pk == "" || sk == "" {
return noop, false, nil // sem credencial: tracing desligado, tudo vira no-op
}
auth := base64.StdEncoding.EncodeToString([]byte(pk + ":" + sk))
exp, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpointURL(Host()+"/api/public/otel/v1/traces"),
otlptracehttp.WithHeaders(map[string]string{
"Authorization": "Basic " + auth,
"x-langfuse-ingestion-version": "4",
}),
)
if err != nil {
return noop, false, err
}
res, err := sdkresource.Merge(sdkresource.Default(),
sdkresource.NewSchemaless(semconv.ServiceName("voicesurvey")))
if err != nil {
return noop, false, err
}
tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp), sdktrace.WithResource(res))
otel.SetTracerProvider(tp)
return tp.Shutdown, true, nil
}
Três coisas ali valem mais que o resto.
A auth é Basic auth com o seu par de chaves. Chave pública como usuário, chave secreta como senha, base64, pronto. Sem dança de token.
O x-langfuse-ingestion-version: 4 é o contrato de ingestão atual, e a documentação deles é seca sobre isso: mande ele “so that new data appears in real time.” Mande. Sem ele o endpoint cai num mapeamento antigo e os seus spans chegam sutilmente errados.
NewSchemaless, e não NewWithAttributes. Essa me custou uma tarde. Se você fixa a sua própria URL de schema semconv no resource, ela conflita com a que o resource padrão do SDK já carrega, e o Merge quebra o seu init inteiro num schema mismatch. Atributos schemaless fazem merge limpo e sobrevivem a upgrade de SDK.
E repare no que acontece sem credencial: o tracer global continua um noop, então toda chamada instrumentada no código custa zero. A PoC continua rodando 100% offline, o que não é pouca coisa quando o seu gate model é local.
Instrumentando: embrulhe, não edite
Agora, o que é que entra no trace? Eu não saí espalhando span pelo agente. Todo chamador de LLM no projeto é uma interface, então tracing é um decorator:
func TraceClassifier(inner llm.Classifier, model string) llm.Classifier {
return &tracedClassifier{inner: inner, model: model}
}
func (t *tracedClassifier) ClassifyTurn(ctx context.Context, question, reply string) (llm.Turn, error) {
ctx, span := otel.Tracer("voicesurvey").Start(ctx, "classify_turn")
defer span.End()
input, _ := json.Marshal(map[string]string{"question": question, "reply": reply})
span.SetAttributes(
attribute.String("gen_ai.request.model", t.model),
attribute.String("langfuse.observation.type", "generation"),
attribute.String("langfuse.observation.input", string(input)),
attribute.String("langfuse.trace.name", "classify_turn"),
attribute.String("langfuse.trace.metadata.prompt_version", llm.ClassifyPromptVersion()),
)
turn, err := t.inner.ClassifyTurn(ctx, question, reply)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return turn, err
}
output, _ := json.Marshal(turn)
span.SetAttributes(attribute.String("langfuse.observation.output", string(output)))
return turn, nil
}
Uma linha no call site liga tudo: cl = obs.TraceClassifier(cl, name). O wrapper é pass-through puro, e nunca encosta no Turn nem no erro. Um wrapper que consegue alterar um resultado é um bug que você vai acabar culpando o modelo por.
O prefixo de atributo langfuse.* é a parte que você não adivinha pela documentação do OTel. Aquilo são convenções do próprio Langfuse: o observation.type faz o span renderizar como uma generation, com painel de input e output em vez de uma barra de tempo pelada, e qualquer chave langfuse.trace.metadata.* vira um campo filtrável na UI.
Eu carimbo o prompt_version em toda chamada, sem exceção. Sem ele uma edição de prompt fica invisível, porque saída velha e saída nova caem na mesma pilha sem eixo nenhum pra separar.
É assim que esses atributos ficam quando chegam lá. Um trace de classify_turn, 0,31s, saindo de um programa Go sem SDK nenhum:

Todo campo daquele screenshot veio de uma chamada de SetAttributes. Nada foi configurado na UI. E já que você está aí, repare na barra lateral esquerda, porque ela resolve a confusão que eu vou contar mais pra frente: Evaluation e Prompt Management são duas seções separadas. Nenhuma depende da outra.
Por que traces, e não só números
Aqui está o que eu subestimei. Eu fui pro Langfuse atrás de histórico de runs e ganhei uma coisa que eu não tinha pedido.
Uma conversa por voz não é uma chamada de modelo. É transcrever, classificar, escrever o reconhecimento, sintetizar, e de novo, vinte turnos adentro. Quando uma conversa sai estranha, o número agregado de acurácia não serve pra nada. Ele me diz com que frequência alguma coisa quebrou e nunca o que aconteceu.
Um trace é o turno inteiro como um objeto aninhado: entradas, saídas, latência por etapa, em ordem, marcados com o id da sessão. Dar nota pra uma conversa acaba sendo muito mais fácil do que dar nota pra uma frase, porque a frase finalmente tem contexto em volta. A resposta anterior da pessoa está ali em cima, logo acima da decisão que leu ela errado.
E a latência para de se esconder. Numa ligação, o tempo de TTS e de transcrição é silêncio morto na linha, e ele nunca aparece nos spans de LLM. Então esses também entram no trace, com um helper minúsculo pras etapas que não são chamada de LLM:
op := obs.StartOp(ctx, "tts")
op.In(text).Bytes("audio.bytes", len(pcm))
defer op.End()
Esses spans carregam o id da sessão, que é o que transforma vinte traces separados de volta numa conversa legível:

24 traces pra uma pesquisa só. É esse número que me convenceu de que um dashboard de médias nunca ia ser suficiente.
O que eu entendi errado: evals vivem em cima dos traces
Agora a confusão que me impediu de começar, que é o motivo real de eu ter querido escrever este post.
Eu entrei nessa achando que, pra rodar evals no Langfuse, eu teria que mover os meus prompts pra dentro do Langfuse antes. Adotar o prompt management deles, versionar prompt na UI deles, deixar o runner deles executar. Isso é uma migração de verdade, e é o tipo de preço que faz você fechar a aba e decidir que o terminal está ótimo, obrigado.
Não é verdade. Avaliação no Langfuse pendura nos traces. A unidade é um Score, e o modelo de dados de score é a página que resolveu isso pra mim: um nome, um valor, um tipo de dado e exatamente um sujeito - um trace, uma observation, uma sessão ou uma run de dataset. Trace está listado como o caso comum. Nada num score se importa com onde o seu prompt mora. Você emite o trace do Go, pendura scores nele, e pronto, você está fazendo eval.
Você não entrega a receita pro crítico de restaurante. Ele come o prato.
O prompt management só vira pré-requisito pra uma coisa específica: pedir pro Langfuse executar um prompt ele mesmo contra um dataset. Eu nunca quis isso. O meu prompt mora em Go, do lado do código que depende dele, e é lá que ele fica.
Porta 2: scores via REST
Um score é um POST pequeno. O cliente inteiro é net/http:
// Score é um julgamento. Exatamente UM sujeito precisa estar setado.
type Score struct {
Name string
Value float64
DataType string // NUMERIC ou BOOLEAN
Comment string
TraceID string
DatasetRunID string
ID string // informe e a escrita vira idempotente
}
Pendurando um veredito por caso no trace que produziu ele:
c.Score(ctx, obs.Score{
ID: obs.StableID(runName, itemID(cr.c), "intent_correct"),
Name: "intent_correct",
Value: correct,
DataType: "BOOLEAN",
TraceID: cr.traceID,
Comment: fmt.Sprintf("want %s, got %s", cr.c.want, cr.got.Intent),
})
Essa chamada sozinha é o que transformou o meu terminal com scroll em algo usável. Na UI eu filtro por intent_correct = 0 e leio só os erros, cada um clicável direto pra conversa inteira que produziu ele.
Pra capturar o id do trace, o wrapper de tracing joga ele numa ref carregada no contexto:
var ref obs.TraceRef
ctx = obs.WithTraceRef(ctx, &ref)
turn, err := cl.ClassifyTurn(ctx, c.q, c.reply)
outcomes[i] = caseResult{c: c, got: turn, err: err, traceID: ref.ID()}
E o lado offline - o meu dataset escrito na mão - mapeia nos dataset experiments do Langfuse do mesmo jeito. O corpus vira um dataset, a passada de cada modelo vira uma run, cada caso vira um run item que liga o item do dataset ao trace que ele produziu. Métricas agregadas penduram na run em vez de num trace:
c.Score(ctx, obs.Score{
ID: obs.StableID(runName, "intent_accuracy"),
Name: "intent_accuracy",
Value: r.acc(),
DataType: "NUMERIC",
DatasetRunID: runID,
})
O que deixa o ponto concreto: até uma run offline de eval passa por dentro do tracing, porque um run item exige um id de trace. Não existe caminho pra dentro do Langfuse que pule os traces.
E esse é o retorno do exercício inteiro, a coisa que o meu terminal nunca ia conseguir me dar:

Esse é o eval de encerramento lá de cima, o mesmo do UnsupportedWords. Quatro runs dos mesmos 13 casos, cada linha marcada com o hash do prompt que a produziu, enfileiradas de um jeito que uma mudança é uma coisa que eu leio em vez de uma coisa que eu lembro. A linha do baseline e a linha depois da correção continuam as duas ali semanas depois, que é exatamente o que o meu terminal falhou em fazer.
E é isso que finalmente responde a pergunta que eu abri, coisa que armazenamento sozinho nunca ia responder. 94,6% é pior que ontem, ou é ruído? são na verdade duas perguntas, e a que importa é se o prompt mexeu.
Duas linhas carregando o mesmo fingerprint e scores diferentes: isso é ruído, e a resposta honesta é aumentar o dataset ou parar de ler aquela casa decimal. Duas linhas com fingerprints diferentes e scores diferentes: isso é a sua mudança, e agora dá pra discutir.
O histórico é o que me deixou fazer a pergunta na semana seguinte. O fingerprint é o que tornou ela respondível.
Quatro pegadinhas que vão te pegar
Dê flush antes de ligar as coisas. O exporter OTLP em batch é assíncrono, e o endpoint de run item rejeita um trace que ele ainda não ingeriu. Então force um flush antes, e trate 404 como retentável:
if err := obs.Flush(ctx); err != nil {
return fmt.Errorf("flush traces: %w", err)
}
Faça retry do 404, e só do 404. Um 404 ali significa que nada foi criado, então retentar é seguro. Fora isso aquele endpoint não é idempotente - o servidor é quem cunha o id - então nenhum outro status vale um retry.
Aprenda quais endpoints fazem upsert. Dataset faz upsert por nome, item de dataset faz upsert por id, score faz upsert pelo id que você informa. Então eu derivo os ids de um hash de conteúdo e reenvio o corpus inteiro a cada run sem criar uma única duplicata:
func itemID(c evalCase) string {
return obs.StableID("turn-classifier", c.q, c.reply)
}
Edite o dataset.go e só os casos que realmente mudaram mudam lá em cima. Run items são a exceção, então eles são postados exatamente uma vez e nunca retentados às cegas.
Agora o versionamento de prompt é seu. Essa é a conta por manter os prompts em Go, e ela chega quietinha. O prompt management do Langfuse teria versionado eles pra mim. Os meus prompts são constantes de string em Go, então ninguém versiona eles além de mim, e um score que eu não consigo atribuir a um prompt volta a ser achismo.
O meu primeiro instinto foi uma constante pra incrementar na mão. Ideia terrível: eu ia editar o prompt, esquecer de incrementar, e atribuir silenciosamente a saída nova à versão velha. Pior que não ter versão nenhuma, porque parece confiável.
Então a versão é derivada do prompt:
// ClassifyPromptVersion é um fingerprint curto e estável das instruções do
// classificador: o system prompt mais as âncoras de few-shot. Endereçado por
// conteúdo, então não tem como sair de sincronia como um número incrementado na mão.
func ClassifyPromptVersion() string {
_, shots := classifyPrompt("", "")
h := sha256.New()
h.Write([]byte(classifySystem))
for _, m := range shots {
h.Write([]byte(m.Role))
h.Write([]byte(m.Content))
}
return hex.EncodeToString(h.Sum(nil))[:12]
}
Doze caracteres hex, e é esse o 79bec7b42725 do screenshot de trace lá em cima. Mude uma única palavra do prompt e o fingerprint muda junto, tendo eu lembrado de pensar nisso ou não.
Dois detalhes que valem roubar. Inclua os exemplos de few-shot no hash, não só o system prompt, porque um exemplo editado muda comportamento tanto quanto uma instrução editada. E o teste afirma só que o valor é estável entre chamadas e tem 12 caracteres, nunca qual é o valor - um teste fixando o hash falharia a cada edição legítima de prompt, que é a forma mais rápida de você se ensinar a ignorar teste vermelho.
A troca é real e eu faria de novo: um 79bec7b42725 opaco é menos legível que um v3, e ele não consegue mentir pra mim.
O que eu diria pra um amigo começando isso em Go
A ausência de um SDK parecia um bloqueio e era um incômodo de dois arquivos. internal/obs: um arquivo pros traces OTLP, um pro cliente REST. Essa é a integração inteira.
Então, na ordem:
- Escreva o dataset primeiro. Vinte casos rotulados num
[]struct{}ganham de qualquer ferramenta que você ainda não escolheu. Os casos são o ativo; todo o resto vem depois deles. - Pontue exatamente onde der. Empurre saída difusa pra conjuntos pequenos de label e recupere o seu
==. Vá de similaridade e de juiz só pro que resiste de verdade a isso, e não trave o build neles. - Aponte o SDK padrão do OTel pra
/api/public/otel/v1/traces. Basic auth,x-langfuse-ingestion-version: 4,NewSchemaless. Você terminou em 30 linhas. - Instrumente embrulhando interfaces, nunca editando call site. E mantenha o wrapper pass-through puro.
- Os seus prompts podem continuar em Go. Scores penduram em traces. Gere você mesmo o fingerprint do prompt e carimbe ele em todo span, e você fica com o prompt e com o histórico dele onde o código está.
Os números da Ava continuam saindo no terminal, exatamente como antes. A diferença é que eles não somem mais quando eu fecho ele.
Por hoje é só.
Recomendação de livro: AI Engineering, da Chip Huyen - os capítulos de avaliação são o tratamento mais claro disso que eu já encontrei, e a taxonomia da primeira metade deste post vem de lá.