Langsung ke konten

← Kembali ke Perpustakaan

L4 · 9 Okt 2026

Cara kami membuat voice agent bisa diinterupsi

  • voice-ai
  • azure-openai-realtime
  • latency
  • evaluation
  • case-study

Penelepon sudah menyela, tetapi voice agent kami masih butuh sekitar dua detik untuk merespons. Ini terjadi saat agent sedang mencari jawaban di knowledge base. Kalau sedang menelepon, dua detik terasa lama.

Saya memimpin pengembangan voice agent realtime untuk contact center ini di Metrodata. Ada dua bug barge-in yang saya perbaiki. Pada jalur pencarian knowledge, A/B terkontrol kemudian mencatat sekitar 0,2 detik.

Sampelnya hanya n=4 panggilan per arm. Hasil itu menunjukkan perbedaan pada jalur yang diuji, bukan berarti masalah turn-taking sudah selesai. Eksperimen berikutnya malah tidak berhasil menurunkan tingkat cut-off di lingkungan live.

Apa arti barge-in untuk agent speech-to-speech

Agent ini memakai Azure OpenAI Realtime. Audio penelepon masuk sebagai stream, lalu model membalas dengan stream audio. Pada mode default, tidak ada tahap speech-to-text dan text-to-speech yang terpisah.

Backend Python FastAPI menjadi penghubung media channel dan model. Backend ini juga menjalankan tool, termasuk pencarian knowledge lewat Azure AI Search, serta mengalihkan panggilan ke operator manusia jika diperlukan.

Barge-in terjadi saat penelepon mulai bicara ketika agent sedang bicara, atau baru akan bicara. Penanganannya bukan sekadar cancel response:

  1. Menghentikan audio yang sedang didengar penelepon.
  2. Berhenti menghasilkan sisa jawaban.
  3. Menyamakan ingatannya tentang percakapan dengan apa yang benar-benar didengar penelepon.

Langkah ketiga mudah terlewat. Model bisa sudah menghasilkan satu paragraf, sementara penelepon baru mendengar separuhnya. Tanpa koreksi conversation history, jawaban berikutnya menganggap sisa paragraf itu sudah didengar.

Masalah pertama: selesai bagi model, masih diputar bagi penelepon

Logika cancel kami langsung return begitu model melaporkan response selesai. Sekilas masuk akal. Masalahnya, selesai menghasilkan audio tidak sama dengan selesai memutarnya.

Media channel, waktu itu Azure Communication Services, masih bisa menyimpan antrean audio. Model sudah selesai, tetapi penelepon masih mendengar agent bicara. Kalau menyela pada saat itu, tidak ada yang menghentikan playback.

Posisi truncate kami juga keliru. Kami menghitung dari jumlah byte audio yang sudah diteruskan ke channel, bukan audio yang sudah didengar. Akibatnya, audio yang masih tertahan di buffer ikut dianggap sudah tersampaikan.

Saya mengubahnya menjadi barge-in yang playout-aware. Tracker kecil memperkirakan berapa milidetik dari response saat ini yang sudah diputar ke penelepon. Berikut sketsa sederhananya, bukan kode produksi:

on caller speech start:
    heard_ms = playout.heard_ms()
    channel.stop_audio()      # drop queued audio
    realtime.cancel()         # if still generating
    realtime.truncate(item_id, heard_ms)

Urutannya penting: hentikan playback dulu, batalkan generation jika masih berjalan, lalu truncate conversation item pada posisi yang sudah didengar. Penelepon mendapat hening lebih dulu, dan history model berhenti di titik yang sama dengan audio yang didengar.

Saya tidak punya pengukuran sebelum dan sesudah khusus untuk perbaikan ini. Perbaikannya dirilis sebagai salah satu prioritas tertinggi dari architecture review internal. A/B di bawah mengukur jalur kode yang berbeda.

Masalah kedua: agent tidak bisa mendengar saat sedang mencari

Delay sekitar dua detik tadi berasal dari tool pencarian knowledge. Tool ini berjalan inline di receive loop provider, coroutine yang membaca setiap event dari model realtime.

Selama menunggu hasil pencarian, loop tidak bisa membaca event lain, termasuk sinyal bahwa penelepon mulai bicara. Bagian yang seharusnya terus mendengarkan malah kami buat menunggu I/O.

Saya memindahkan setiap tool call ke asyncio task tersendiri yang bisa dibatalkan, dengan satu batas waktu 2,5 detik. Perubahan ini juga menambahkan satu response gate agar hanya ada satu response yang berjalan pada satu waktu, serta membuat hand-off ke operator manusia menjadi non-blocking. Sketsa sederhananya:

task = asyncio.create_task(
    asyncio.wait_for(search(query), timeout=2.5))
# the receive loop keeps reading events
# on barge-in: task.cancel()

Receive loop sekarang tetap mendengarkan selama pencarian berjalan. Saat terjadi barge-in, task pencarian dibatalkan. Saya memilih satu batas waktu daripada timeout bertumpuk supaya hanya ada satu limit yang perlu dipahami. Kalau waktunya habis, agent melanjutkan percakapan, bukan macet.

Cara saya mengukurnya

Saya ingin membandingkan jalur kode, bukan dua environment yang berbeda. Voice evaluation harness kami sudah punya media emulator yang memasukkan rekaman audio penelepon ke backend seperti panggilan sungguhan, golden question set, dan LLM judge terkalibrasi untuk menilai kualitas jawaban. Saya menambahkan A/B jalur lama dan baru, dengan legacy adapter untuk menjalankan jalur lama.

Model deployment, search index, dan audio penelepon sama untuk kedua arm. Pengujian utama berjalan dari runner sekali pakai di Azure Container Instances agar jaringan laptop saya tidak memengaruhi hasil. Run dari laptop hanya disimpan sebagai lampiran.

Hasil pada giliran yang memakai knowledge base:

  • Latency barge-in pada jalur lama: 1.875 sampai 2.140 ms.
  • Latency barge-in pada jalur baru: 93 sampai 220 ms.
  • Jalur baru menjawab pertanyaan knowledge di 4 dari 4 panggilan. Jalur lama mengalihkan ke manusia di 4 dari 4 panggilan.

Dengan n=4 panggilan per arm, selisih sekitar sepuluh kali lipat terlihat berulang. Sampel ini tetap tidak cukup untuk menghitung persentil atau confidence interval.

Ini panggilan dari harness di runner eksperimen, bukan traffic pelanggan. Saya tidak punya angka barge-in dari produksi untuk dikutip. Cakupannya juga hanya knowledge turn, bukan percakapan biasa.

Yang tidak didukung oleh data

Belakangan, kami memindahkan voice channel ke LiveKit self-hosted. Muncul masalah berbeda: agent bicara menimpa penelepon. Tingkat cut-off di lingkungan live tercatat 53,3%.

Awalnya saya menduga sinyal berhenti terlambat sampai ke media layer. Saya membuat reproduksi LiveKit lokal dengan trace berbasis monotonic clock, lalu mengujinya dengan model palsu dan model sungguhan. Aturan verdict sudah saya tulis sebelum pengujian.

Trace itu sebagian besar membantah dugaan saya: hanya sekitar 80 ms audio yang terdengar setelah clear. Penyebab sebenarnya adalah provider meng-commit giliran penelepon saat jeda alami, serta stale response yang tidak pernah dibatalkan.

Saya merilis start-hold dan stale-response cancel, lalu mengukur ulang di lingkungan live. Tingkat cut-off menjadi 60,0%. Tidak turun.

Catatan saya tidak menyimpan jumlah turn di balik kedua persentase tersebut, jadi angkanya hanya bisa dibaca sebagai indikasi arah. Yang jelas, hasilnya tidak menunjukkan perbaikan. Saya mencatatnya sebagai hasil negatif, bukan menyatakan masalah sudah beres.

Rangkaian eksperimen itu juga menguji acknowledgement clip saat pencarian knowledge dan aturan agar model mengucapkan kalimat pertama yang pendek lebih awal. Keduanya ditolak oleh verdict yang sudah ditetapkan sebelum pengujian. Penyajian hasil prefetch tidak bisa diuji dengan bersih, jadi saya melaporkannya sebagai blocked.

Beberapa hari sebelumnya, pengujian lain dengan verdict yang ditetapkan di awal menolak dua usulan perubahan default latency, baik masing-masing maupun gabungan keduanya. Default tetap seperti semula.

Tidak ada eksperimen yang ditolak atau blocked itu yang dinyalakan. Lever yang ditolak eksperimen cut-off kemudian saya hapus dari codebase, bukan saya biarkan sebagai switch yang tidak dipakai.

Yang akan saya cek lebih dulu

Saat debugging barge-in, bedakan audio yang dihasilkan model, yang dikirim backend, dan yang didengar penelepon. Posisi truncate harus mengikuti yang sudah didengar. Hentikan playback sebelum mengurus generation dan context.

Lalu periksa receive loop. Jangan taruh I/O lambat di sana. Jalankan tool sebagai task yang bisa dibatalkan dengan satu batas waktu. Bandingkan jalur lama dan baru dalam kondisi yang sama, jalankan di luar laptop, dan cantumkan ukuran sampelnya.

Setiap perbaikan di atas berawal dari spec tertulis. Saya mengarahkan AI coding agent untuk sebagian besar implementasi, lalu me-review dan melakukan merge. Tetapi spec dan PR yang sudah di-merge bukan bukti bahwa perbaikannya berhasil. Tulis aturan verdict sebelum pengujian, terima hasil negatif, dan hapus yang ditolak data. Masalah cut-off kami masih belum selesai.