Skip to content
Konsultasi

CTA_FLAG Terbelah di Tengah Stream SSE

Adityo Guni Waluyo

Regex strip per delta nggak bisa melihat marker yang terbelah dua potongan. Pelajaran boundary matching di streaming SSE.

Ringkasan

Bug ini terjadi karena regex pembersih CTA_FLAG: YES dijalankan per delta, sementara marker sering terbelah di dua potongan stream sehingga lolos tersimpan ke cache Redis. Perbaikannya strip ulang di string gabungan setelah stream selesai. Pelajarannya: cek invariant di output akhir, dan gunakan structured output JSON untuk solusi kanonis.

# CTA_FLAG yang Nyelip di Jawaban Tersimpan

Replay chat di "Tanya Adityo", dan di ujung jawaban yang harusnya bersih ada satu baris nyeleneh: CTA_FLAG: YES, kadang polos, kadang dibungkus bintang kayak markup markdown. Parahnya, ini bukan cuma sekali. Tiap replay dari cache Redis, marker itu muncul lagi, kayak jamur di kamar mandi. Udah di-strip kok pas streaming? Ternyata enggak semudah itu.

Dugaan pertama gue salah total. Gue curiga regexnya kebanyakan mikir, case-sensitivity, atau model tiba-tiba nulis markernya dengan format baru. Gue buka log, grep manual, dan marker-nya persis kayak pola yang udah gue definisikan. Regex-nya sehat. Masalahnya di tempat lain.

Regex per delta itu buta sama boundary

Setup-nya begini: jawaban dari LLM di-stream ke client lewat SSE, dan di tengah jalan gue nyelipin marker teks biasa, CTA_FLAG: YES, buat nandain kapan jawaban perlu dikasih call-to-action. Di sisi backend, marker ini di-strip sebelum jawaban disimpen ke cache Redis buat replay. Kode lama-nya ngecek dan nge-strip per delta:

if any(_ASK_CTA_RE.search(d) for d in answer_parts):
    stripped = [_ASK_CTA_RE.sub(' ', d).strip() for d in answer_parts]
    yield AskEvent('cta_stripped', {'had_cta': has_cta_source})
    answer_parts = stripped

Intuisinya masuk akal: strip di tiap potongan, beres. Tapi re.sub bekerja pada satu string, mengganti kemunculan pattern yang non-overlapping di string itu, dan dia nggak punya cara lihat pola yang terbentang di dua string berbeda [2]. Nah, ternyata model sering banget nulis markernya terbelah. Satu delta berisi CTA, delta berikutnya _FLAG: YES. Regex di-delta pertama nggak match, di-delta kedua juga nggak match. Marker lolos utuh, masuk ke jawaban gabungan, dan tersimpan permanen di cache.

Ini bukan keanehan model aja. Di level transport, satu network chunk bukan satu event. TCP ngasih byte, bukan pesan; satu chunk bisa bawa tiga event utuh plus separuh event keempat, dan delimiter frame SSE itu blank line, bukan chunk boundary [5]. Jadi nggak ada jaminan potongan yang gue terima di loop streaming akan jatuh di tempat yang rapi. Malah sebaliknya, pecah di tengah marker itu hal yang wajar.

Perbaikannya dua lapis

Lapis pertama di use case: substitusi tetap per delta, tapi cuma di delta yang match, tanpa event tambahan.

if any(_ASK_CTA_RE.search(d) for d in answer_parts):
    answer_parts = [_ASK_CTA_RE.sub(' ', d) for d in answer_parts]

Ini tetep nangkep kasus marker utuh di satu delta. Tapi jujur, lapis ini doang nggak nutup kasus terbelah. Makanya lapis kedua: setelah stream selesai, jawaban digabung dulu di routes.py lewat _extract_cta_answer, dan strip dijalankan lagi di string gabungan sebelum di-split bagian PENJELASAN dan di-store ke cache. Di string yang udah digabung, regex bisa lihat CTA_FLAG: YES sebagai satu string utuh, dan match. Gue verifikasi live: joined stream dan tiap delta satuan sekarang bersih dari marker.

Ada detail kecil yang gue buang sekalian: event cta_stripped yang dulu di-yield. Client nggak pernah pakai buat apa pun selain logging, jadi matiin aja. Diffnya cuma 2 tambahan, 6 penghapusan, dan itu udah cukup.

Pelajaran yang lebih umum dari kasus ini

Yang bikin bug ini nyelip lame bukan karena regex-nya susah, tapi karena cara berpikirnya. Nge-eksekusi matcher per-potongan secara prinsip nggak bisa nemuin pola yang membentang dua potongan. Kalau kamu punya invariant "output akhir nggak boleh mengandung X", cek invariant itu di output akhir, bukan di perjalanan. Perjalanan bisa pecah di mana aja, dan server SSE emang dipush data kapan aja server mau [4], pesan dipisah sepasang newline, dan pesan tanpa field event diterima client sebagai message event biasa [1]. Semua itu ketebalan protokol yang nggak perlu kamu pedulikan kalau verifikasinya di titik akhir.

Opini gue yang paling tegas ada di sini: marker teks di jawaban LLM itu hack, dan hack begini selalu bocor di boundary. Pendekatan kanonis buat data terstruktur kayak flag CTA adalah structured output berbasis JSON Schema, di mana flag-nya jadi field beneran, bukan teks yang harus di-strip manual [3]. Gue tau kebanyakan orang masih pake marker karena gampang dan nggak perlu ubah prompt flow, dan gue juga masih pake itu. Tapi commit ini adalah tagihan yang harus gue bayar buat kemalasan itu.

Kalau suatu hari marker ini nambah atau formatnya makin manyun, migrasi ke structured output bakal jadi langkah pertama, bukan regex baru yang lebih jeli.

Sumber

Artikel ini ditulis dari lima sumber berikut, diakses 1 September 2026: Using server-sent events di MDN; re, regular expression operations di dokumentasi Python; Structured Outputs dari OpenAI; Server-sent events di MDN; dan Streaming Responses: SSE, Chunks and Backpressure dari multigrid.ai.

Baca juga artikel terkait: Satu Stream SSE, Dua Versi Jawaban dan Marker CTA_FLAG: YES yang Bocor ke Chat.

Artikel terkait