Fiz rollback de 2.724 linhas depois que uma mudança no áudio de IA quebrou a produção
Fiz um push do que deveria ser uma melhoria no sistema de voz da minha plataforma de podcast. Seis dias e vários commits depois, deletei 2.724 linhas de código e fiz rollback para o que funcionava. Aqui está o que aconteceu e o que aprendi sobre testar mudanças de IA em produção.
Eu queria ter escrito este post antes. Mas a situação toda levou mais tempo para desenrolar do que eu esperava, e eu queria ter certeza de que a história estava certa antes de compartilhar.
Em 28 de abril de 2026, fiz o deploy do que eu achava ser uma melhoria no sistema de text-to-speech do DIALØGUE. Um modelo de voz diferente parecia promissor no papel. Eu tinha ouvido dizer -- e constatado por mim mesmo -- que as vozes do modelo atual sofriam drift em podcasts mais longos. Então eu fiz a troca.
Em 30 de abril, eu já tinha deletado 2.724 linhas de código e feito rollback para o caminho estável de 22 de abril.
O podcast de um usuário real estava travado. Não conseguia gerar áudio. O sistema tinha marcado como AUDIO_FAILED e depois lançou um 409 Conflict na retry, o que no começo parecia ser o bug real. Acabou sendo um sintoma -- o podcast já estava em estado de falha quando a retry aconteceu. O problema real era mais simples e pior: um segmento fez timeout na nova configuração. O fallback também fez timeout. E o sistema simplesmente desistiu.
Para piorar, era um script que caminhava para 65 minutos de áudio falado. A mediana dos podcasts na plataforma é de 26 minutos. Eu aceitei um script com mais do dobro desse tamanho em um sistema que nunca tinha gerado com sucesso nada nem perto disso. Esse era o risco real -- e a mudança de modelo foi o que o expôs.
Tenho que admitir, esse não foi meu melhor momento como desenvolvedor. Fiz uma mudança de configuração sem o tipo de stress testing que um sistema de produção merece. Aqui está o que aconteceu, o que quebrou e o que eu aprendi.
O que o DIALØGUE faz
Se vocês ainda não viram, o DIALØGUE é um gerador de podcasts com IA. Você dá a ele um tema, um PDF ou um episódio de programa, e ele produz um podcast conversacional com dois apresentadores. Tudo roda em cima da síntese text-to-speech Gemini do Google.
O pipeline de áudio funciona assim:
- Gerar um outline a partir do material fonte
- Expandir o outline em um script completo
- Dividir o script em segmentos
- Sintetizar áudio para cada segmento usando Gemini TTS
- Juntar os segmentos, normalizar o volume e subir a MP3 final
O default de produção era um modelo Gemini TTS que funcionava bem para a maioria dos casos. Mas quanto mais longo o script, mais você percebia a voz sofrer drift entre segmentos. O Apresentador A no segmento um não soava exatamente igual ao Apresentador A no segmento seis. Para um podcast de 25 minutos, é perceptível mas você se acostuma. Para algo se aproximando de 40 minutos, fica bem ruim.
Decidi tentar uma configuração de modelo diferente. A ideia era melhorar a consistência de voz em podcasts mais longos.
O que eu construí
Ao longo de cerca de uma semana no final de abril, fiz uma série de commits para tentar uma configuração de modelo diferente. Não foi uma mudança casual de uma linha. Construí uma mudança de arquitetura inteira:
- Troquei o modelo TTS default pela nova configuração
- Adicionei uma cadeia de fallback -- se o modelo principal fizesse timeout, fallback para o modelo estável de produção
- Construí um sistema de QA em nível de chunk: dividir transcrições em unidades menores, sintetizar cada uma, validar a qualidade de áudio com análise do ffmpeg
- Adicionei tracking de progresso do workflow para que a UI pudesse mostrar o estado de síntese por segmento
- Reforcei a lógica de retry -- três tentativas por chunk, backoff exponencial
- Adicionei um gate de qualidade de áudio longo que bloquearia
COMPLETEa menos que a MP3 final montada passasse pelo QA de áudio
A ideia era sair de "gerar áudio por segmento e torcer para soar consistente" para "fazer chunk das transcrições, QA em cada chunk, retry nas falhas, validar o arquivo final."
Em 28 de abril, o código estava em produção. Os testes unitários passaram. Os testes de integração passaram. O que eu não tinha -- e deveria ter -- era um load test rodando uma exportação completa de 50+ minutos contra a nova configuração. Pulei o único teste que realmente importava.
O que quebrou
Quase imediatamente, um podcast de produção falhou.
Era um longo -- uma análise de capex de Big Tech que caminhava para 65 minutos de áudio falado. O sistema chegou na etapa de áudio e retornou AUDIO_FAILED.
No início, vi o 409 Conflict e pensei que o orchestrator tinha um bug. Acontece que o 409 era um sintoma secundário -- uma tentativa de retry depois que o podcast já estava marcado como falho. A primeira falha era muito mais simples.
O segmento 0 fez timeout na nova configuração do modelo. O timeout estava configurado em 60 segundos. O modelo fallback -- que era o mesmo modelo que o caminho estável usa -- também fez timeout. O sistema marcou o podcast como falho e seguiu em frente.
O fallback deveria ter funcionado. Mas o fallback foi invocado com o mesmo budget de timeout de 60 segundos, no mesmo segmento grande, no mesmo contexto de request que já tinha consumido tempo na chamada primária falha. O caminho estável, por outro lado, chama esse mesmo modelo do zero com o timeout completo disponível e processa segmentos individualmente. Mesmo modelo, condições diferentes -- por isso a versão estável lida bem com isso mas o fallback não.
Desativei o sistema de chunk QA em produção com uma mudança de uma linha. Isso não consertou o timeout. A nova configuração continuava fazendo timeout em segmentos que o modelo estável lidava sem problemas.
Bem, esse é o tipo de momento em que você tem que escolher: continuar debugando o novo caminho, ou voltar para o que funciona.
O rollback
Em 30 de abril, tomei a decisão.
O commit de rollback (4a5bfc8) deletou 2.724 linhas de código:
- O módulo TTS chunker inteiro
- Gates de análise de qualidade de áudio
- Tracking de progresso do workflow
- Os test suites de retry de chunk QA
- A configuração de cadeia de modelo fallback
E adicionou 321 linhas para preservar as partes que importavam -- frases de prompt de voz localizadas, compatibilidade com o frontend e coverage de regressão para o caminho estável.
São muitas linhas para deletar. Sinceramente, pareceu que estava admitindo uma derrota. Mas não era. Era escolher o caminho correto de longo prazo em vez de algo que parecia progresso mas não estava pronto.
Depois do rollback:
- Reconstruí a imagem Docker base compartilhada
- Redeploiei o serviço
generate-speech - Resetei o podcast falho de volta para aprovação de script
- Cliquei em "Generate Audio" pela UI normal
O resultado:
- Modelo: Gemini TTS (configuração estável de produção)
- Segmentos: 6 de 6 completados
- Duração: 1.527 segundos -- cerca de 25 minutos
- MP3 final: 30,5 MB
- Status:
COMPLETE
O podcast que estava travado há dois dias terminou em cerca de 11 minutos depois do rollback. O episódio do usuário foi entregue -- dois dias depois do planejado, mas completo.
Uma coisa sobre o número de 65 minutos: essa era a duração estimada com base no tamanho do script bruto, não no output final. Depois do reset, o script passou pelo pipeline de encurtamento normal, e o áudio final montado ficou em cerca de 25 minutos. O script original teria sido ainda mais longo -- o que é parte do motivo pelo qual estava falhando.
Os dados
Antes de decidir o que fazer em seguida, eu queria números reais. Não sensações -- dados. Consultei o banco de dados de produção para informações sobre duração de podcasts.
| Coorte | Quantidade | Duração Mediana | p90 | Máx |
|---|---|---|---|---|
| Últimos 30 dias | 8 | 26 min | 31 min | 34 min |
| Últimos 90 dias | 72 | 27 min | 36 min | 45 min |
| Todo o período | 120 | 26 min | 30 min | 45 min |
Então a mediana dos podcasts é de cerca de 26 minutos. O p90 fica em torno de 34-36 minutos. O podcast completado mais longo de todos os tempos foi de 45 minutos.
E aqui estão os dados mais duros -- podcasts que chegaram à etapa de áudio, divididos por duração estimada:
| Duração Estimada | Chegou ao Áudio | Completados | Falhos | Em Progresso |
|---|---|---|---|---|
| Menos de 15 min | 18 | 17 | 0 | 1 |
| 15-30 min | 36 | 31 | 2 | 3 |
| 30-40 min | 24 | 23 | 0 | 1 |
| 40-50 min | 3 | 1 | 2 | 0 |
| 50+ min | 2 | 0 | 2 | 0 |
"Em Progresso" cobre podcasts que foram iniciados mas nunca completados -- abandonados pelo usuário, cancelados durante a geração, ou deixados em estado pending. Dois podcasts tentados com 50+ minutos. Ambos falharam na etapa de áudio. Zero completados.
Nenhum podcast de produção completado jamais chegou a 50 minutos. O sistema funciona bem para a faixa de 15-40 minutos. Acima de 40 minutos, o risco sobe drasticamente. E acima de 50 minutos, é essencialmente um território não testado.
O que eu aprendi
1. Mudanças de configuração precisam de stress testing em nível de produção
Tenho que ser honesto sobre isso. A nova configuração do modelo poderia ter funcionado bem em prompts de teste curtos. Mas em produção, com scripts reais e restrições de timeout reais, falhou em segmentos que a configuração estável lida sem problema.
Não fiz o benchmark corretamente antes de fazer o deploy. Um prompt de teste curto vai funcionar. Um podcast de 65 minutos com múltiplos segmentos sob um timeout de 60 segundos é um contexto completamente diferente. O gap entre esses dois cenários é onde vivem os incidentes de produção.
Posso estar errado sobre a configuração em si -- talvez só precisasse de mais ajuste ou um timeout mais longo. Mas o ponto se mantém: mudei uma dependência central de produção sem o tipo de stress testing que um sistema em que as pessoas realmente confiam merece.
2. Inconsistência de voz é o problema real, não timeouts
O timeout foi a falha visível. Mas o motivo pelo qual eu estava tentando uma configuração diferente em primeiro lugar era a inconsistência de voz. Mesmo no modelo estável, as vozes mudam entre chamadas de síntese. O Apresentador A no segmento um não soa exatamente igual ao Apresentador A no segmento seis.
Para podcasts curtos, isso é quase imperceptível. Para os mais longos, acumula. E para podcasts de 50+ minutos -- que, de novo, ninguém completou em produção -- provavelmente seria bem óbvio.
A abordagem de chunks tentava resolver isso tornando cada chunk menor e mais controlado. Essa é a direção certa, acho. A implementação só não estava pronta para produção ainda.
3. Eu precisava de telemetria antes do incidente, não depois
Quando a falha aconteceu, não consegui mapear custo ou performance de TTS para IDs de podcast específicos. Os logs não tinham entradas úteis. Os eventos de workflow não tinham registros de geração de TTS.
Tive que diagnosticar a falha a partir do campo de status do podcast, um erro 409 confuso, e reprodução local do comportamento de timeout. Funcionou no final, mas não foi o tipo de experiência de debugging que quero ter de novo.
Adicionei telemetria de custo de TTS depois -- modelo usado, modelo fallback, contagem de retries, status por tentativa, caracteres de transcrição, bytes de áudio de saída, duração do áudio. Isso deveria ter existido antes do incidente. Pela minha experiência, é sempre assim. Você constrói a observabilidade depois do incêndio, não antes.
4. Fazer rollback não é fracasso
Deletar 2.724 linhas de código foi ruim. Não vou fingir que não. Você passa uma semana construindo algo, fica orgulhoso da arquitetura, e depois demoli tudo porque não está pronto.
Mas foi a decisão certa. O sistema de chunk QA era um bom design. Ele vai voltar -- como uma mudança menor e isolada com validação canary apropriada. Só que não como parte de uma mudança de configuração de modelo. E não enquanto a nova configuração continua fazendo timeout em segmentos que o caminho estável lida sem problema.
5. O episódio de 50 minutos é um produto diferente
Esse me surpreendeu. Eu presumi que o sistema lidaria com qualquer tamanho de script. Os dados dizem o contrário.
Se alguém genuinamente quer um podcast de 50 minutos, esse é um perfil de geração diferente. Provavelmente precisa de um script que seja reduzido para 45 minutos ou menos antes da geração de áudio. Um gate de revisão manual antes do TTS. Budgets de timeout por segmento mais fortes. Talvez até uma estratégia de síntese completamente diferente.
Otimizar o caminho default para a faixa de 15-40 minutos é a decisão certa. O episódio de 50+ minutos deve ser tratado como um caminho excepcional, não como a norma. Acho que essa é uma decisão de produto, não só de engenharia.
Onde as coisas estão agora
A produção está de volta na configuração estável de TTS. Não é perfeita. A inconsistência de voz em podcasts mais longos ainda está lá, e eu consigo ouvir. Mas é estável o suficiente para que a plataforma funcione na grande maioria dos casos.
Se quiser ouvir o que o DIALOGUE produz, pode tentar você mesmo em https://podcast.chandlernguyen.com.
O relatório do incidente está comitado no repositório. O rollback está documentado com o formato exato do SQL para resetar um podcast falho. A telemetria está no lugar agora para a próxima tentativa.
E a lição é mais clara do que eu gostaria:
Em IA de produção, o custo de mudar dependências centrais sem o stress testing adequado não é um experimento fracassado. É uma experiência de usuário fracassada.
Vou tentar de novo com configurações de modelo diferentes. Mas da próxima vez, com melhor telemetria, um caminho de deployment canary, e um stress test que rode uma exportação completa de 50+ minutos antes de qualquer coisa tocar a produção.
Antes de mudar dependências de IA em produção, aqui está a checklist que eu deveria ter seguido:
- Stress test do pior caso. Rodar o workload mais longo e pesado que vocês esperam -- não só um prompt de teste curto. Se o sistema de vocês lida com podcasts de 40 minutos, testem com um de 50.
- Log de telemetria por tentativa desde o dia um. Nome do modelo, modelo fallback, contagem de retries, timeout, caracteres de transcrição, bytes de áudio de saída, duração do áudio. Isso deveria ter existido antes do incidente. Sempre é assim.
- Canary em um job real. Antes de virar a chave para todo mundo, rodem um único job real de produção end to end na nova config e verifiquem o output.
- Verificar o fallback com o mesmo budget de timeout. Um fallback que compartilha o mesmo timeout do primário não é um fallback -- é uma segunda chance de falhar nas mesmas condições.
- Definir o SQL de rollback e o runbook antes de fazer o deploy. Saber exatamente como resetar um job travado. Documentar os comandos. Se não tiverem isso, vão improvisar durante o incidente.
Se vocês constroem sistemas de IA em produção, qual é o pattern de vocês para testar mudanças de configuração de modelo sem quebrar a experiência dos usuários? Genuinamente gostaria de saber. Vocês já tiveram um momento similar de "mudei uma coisa e quebrou tudo"?
Abraços,
Chandler





