AI Classification w Critical Path: Kiedy Milisekundy Mają Znaczenie Biznesowe

Jakiś czas temu wdrażaliśmy system automatycznego dzwonienia z inteligentną detekcją poczty głosowej. Brzmi prosto: zadzwoń, sprawdź czy to człowiek czy automat, jeśli automat – zostaw wiadomość dokładnie po sygnale. Problem w tym, że „sprawdź” wymaga klasyfikacji AI, a ta zwykle zajmuje 200-500ms. Tymczasem beep pojawia się w losowym momencie, a wiadomość trzeba odtworzyć z precyzją do kilkudziesięciu milisekund. Za późno – nagranie ucięte. Za wcześnie – poczta głosowa nie nagrywa.

To właśnie oznacza „AI w critical path”. Nie chodzi o kolejny feature, który „fajnie by było mieć”. Chodzi o decyzję architektoniczną, która determinuje czy system w ogóle działa. I tu zaczyna się prawdziwa zabawa z trade-offami.

Dlaczego Gemini w Pętli Detekcji To Był Zły Pomysł

Pierwsza wersja wyglądała sensownie na papierze. Deepgram streamuje transkrypcję audio w czasie rzeczywistym przez WebSocket. Każdy interim transcript – często to tylko kilka słów – ląduje w promcie do Gemini przez OpenRouter: „czy to człowiek czy automat?”. Gemini odpowiada, system podejmuje decyzję.

W praktyce interim transcript przychodzi co 300-500ms. Każde wywołanie Gemini to kolejne 150-300ms. Przy trzech-czterech interim przed potencjalnym beepem dochodzimy do 1-2 sekund łącznego opóźnienia w procesie detekcji. Tymczasem beep w systemie poczty głosowej trwa zwykle 0.5-1 sekundę. Jeśli go przegapimy, możemy równie dobrze rozłączyć się od razu.

Pierwsze produkcyjne testy pokazały dokładnie to. System wykrywał poczty głosowe poprawnie w około 85% przypadków, ale wiadomość była ucięta w 40% z nich. Logi mówiły jasno: klasyfikacja zakończyła się 200ms po wykryciu beep. Za późno. Zbudowaliśmy wolną i nieskuteczną maszynę do irytowania ludzi urywanymi nagraniami.

Problem nie był w Gemini jako takim. Problem był w umieszczeniu synchronicznego API call w krytycznej ścieżce czasowej. Każdy millisekund liczył się dosłownie – nie w sensie „UX będzie lepsze”, ale „system jest bezużyteczny jeśli to trwa dłużej”.

Trzy Detektory i Problem Synchronizacji

Rozwiązanie wydawało się oczywiste: uruchom wiele mechanizmów detekcji równolegle. Pierwszy który zadziała, wygrywa. Zaimplementowaliśmy trzy:

W teorii eleganckie. W praktyce natychmiast wpadliśmy w klasyczny race condition.

Połączenie trafia do poczty głosowej. BeepDetector wykrywa charakterystyczną częstotliwość i wywołuje „playAudio()”. 50ms później webhook od Telnyx z eventem „call.machine.detection.ended” – i jego handler też wywołuje „playAudio()”. Efekt: wiadomość odtworzona dwukrotnie. Albo – co gorsza – połączenie już się zakończyło (user rozłączył się), ale BeepDetector wciąż przetwarza bufor audio i próbuje odtworzyć nagranie do nieistniejącego call.

Początkowo próbowaliśmy prostego flaga „playbackTriggered = true”. Nie zadziałało. Node.js nie gwarantuje atomowości odczytu-sprawdzenia-zapisu w środowisku asynchronicznym. Przy kilku detektorach wykonujących się niemal jednocześnie, dwa mogły wejść do bloku „if (!playbackTriggered)” zanim którykolwiek zdążył ustawić flagę.

Ostatecznie użyliśmy Redis „SETNX” dla atomowego „first write wins”:

async function triggerPlayback(callId: string, source: string): Promise {
  const key = "call:${callId}:playback";
  const acquired = await redis.set(key, source, { NX: true, EX: 300 });
  if (!acquired) {
    logger.info("Playback already triggered for ${callId} by another detector");
    return false;
  }
  // Cleanup innych detektorów
  await callStateManager.stopAllDetectors(callId);
  // Odtwórz
  await telnyx.calls.playAudio(callId, voicemailUrl);
  return true;
}

Redis gwarantuje atomowość na poziomie operacji. Pierwszy detektor który zawoła „SETNX”, dostanie true. Reszta dostaje false i po prostu ignoruje swój wynik. Problem rozwiązany, ale to oznaczało dodanie Redis jako hard dependency w krytycznej ścieżce. Każda dodatkowa latencja – w naszym przypadku średnio 2-3ms do lokalnego Redis – to koszt w budżecie czasowym.

Deepgram Backpressure i Degraded Mode

Przez pierwsze dwa tygodnie w produkcji wszystko działało gładko. Potem przyszedł dzień z „większą kampanią” – zamiast przeciętnych 3 równoległych połączeń, nagle mieliśmy 5. Przy piątym kanale system zaczął spowalniać. Logi pokazywały opóźnienia w transkrypcji Deepgram – interim transcript docierał 800ms po wysłaniu audio.

Co się działo? Deepgram ma swoje limity concurrent streams i throughput na API key. Przekroczenie ich nie skutkuje hard error – po prostu processing spowalnia. Dla nas to oznaczało, że klasyfikacja była opóźniona, beep detection spóźniony, a wiadomości ucięte.

Typowy błąd: unbounded buffer na WebSocket. Audio z Telnyx leci w czasie rzeczywistym, stały bitrate. Jeśli Deepgram nie nadąża, bufor rośnie. W Node.js to oznacza rosnące zużycie pamięci, aż w końcu OOM. My tego nie mieliśmy od początku – zaimplementowaliśmy circular buffer – ale problem opóźnienia pozostał.

Rozwiązanie: circuit breaker z automatycznym przełączeniem na „degraded mode”. Jeśli Deepgram zaczyna spowalniać (mierzymy czas od wysłania audio chunk do otrzymania interim), system przełącza się na lokalną detekcję:

class DeepgramCircuitBreaker {
  private state: "closed" | "open" | "half-open" = "closed";
  private recentLatencies: number[] = [];
  recordLatency(ms: number) {
    this.recentLatencies.push(ms);
    if (this.recentLatencies.length > 10) this.recentLatencies.shift();
    const avgLatency = this.recentLatencies.reduce((a, b) => a + b) / this.recentLatencies.length;
    if (avgLatency > 500 && this.state === "closed") {
      this.state = "open";
      logger.warn("Deepgram circuit breaker opened, switching to local-only detection");
      metrics.increment("deepgram.circuit_breaker.opened");
    }
  }
  shouldUseDeepgram(): boolean {
    return this.state === "closed";
  }
}

W degraded mode polegamy tylko na BeepDetector (FFT) i Telnyx AMD. Tracimy transcript-based classification, ale zachowujemy funkcjonalność systemu. Dokładność spada z ~92% do ~85%, ale to lepsze niż całkowite zablokowanie połączeń.

To typowy trade-off w systemach z AI w critical path: musisz mieć fallback który nie używa AI. Inaczej każda awaria zewnętrznego API to downtime.

„Większość race conditionów wygląda jak problem „raz na jakiś czas”. Potem produkcja przypomina ci, że „raz na jakiś czas” przy 10 tysiącach eventów dziennie oznacza mniej więcej co 14 minut.”

Gemini Poza Critical Path - Ale Nie Do Końca

Ostatecznie przesunęliśmy klasyfikację AI poza ścisłą pętlę detekcji. Zamiast czekać na odpowiedź Gemini przed decyzją o playback, uruchamiamy klasyfikację w tle i używamy jej wyniku tylko jako jednego z sygnałów w hierarchii zaufania:

To znaczy, że Gemini nie blokuje decyzji, ale wciąż wpływa na accuracy. W praktyce zdąża odpowiedzieć w ~60% przypadków przed finalną decyzją o playback. W tych przypadkach podnosi accuracy o około 5 punktów procentowych. W pozostałych 40% decyzja opiera się na lokalnych detektorach.

Kluczowe: nie synchronizujemy na promise z API call. Uruchamiamy klasyfikację, zapisujemy pending promise w Map, a arbiter decyzyjny sprawdza czy już jest wynik. Jeśli tak – uwzględnia. Jeśli nie – idzie dalej bez niej.

class DetectionArbiter {
  private pendingClassifications = new Map>();
  async decide(callId: string, signals: DetectionSignal[]): Promise {
    // Sprawdź czy klasyfikacja AI jest gotowa (bez czekania)
    const aiPromise = this.pendingClassifications.get(callId);
    const aiResult = aiPromise ? await Promise.race([
      aiPromise,
      new Promise(resolve => setTimeout(() => resolve(null), 50)) // 50ms timeout
    ]) : null;
    if (aiResult) {
      signals.push({ source: "gemini", classification: aiResult });
    }
    // Reszta logiki decyzyjnej...
  }
}

„Promise.race” z timeoutem to trick – dajemy AI 50ms na odpowiedź. Jeśli zdąży, super. Jeśli nie, nie blokujemy.

FFT Processing i Event Loop Bottleneck

BeepDetector to prosty koncept: analizuj audio w paśmie 900-1100 Hz, szukaj ciągłego tonu. W praktyce FFT (Fast Fourier Transform) to operacja CPU-intensive. Przy 8kHz sample rate i 20ms ramkach, to 50 operacji FFT na sekundę na kanał. Pięć równoległych kanałów = 250 FFT/s.

Node.js ma single-threaded event loop. Synchroniczne FFT na głównym wątku blokowało wszystko inne – webhooks od Telnyx spóźniały się o 100-200ms, co powodowało missed events i desynchronizację stanu połączeń.

Pierwsze rozwiązanie: worker threads. Każdy BeepDetector w osobnym workerze. Problem: overhead tworzenia worker thread per połączenie był zbyt duży. Tworzyliśmy pool 4 workerów i round-robin scheduling. Zadziałało, ale dodało kolejne 5-10ms latency per analysis (message passing między main thread a worker).

Lepsze rozwiązanie: Goertzel algorithm zamiast pełnego FFT. Goertzel to O(N) dla pojedynczej częstotliwości vs O(N log N) dla pełnego spektrum. Dla naszego use case (interesuje nas tylko ~1000 Hz) to wystarczy. Zaimplementowaliśmy Goertzel w C++ jako native addon dla Node.js. Latency spadła z ~15ms do ~2ms per analysis, a obciążenie CPU o połowę.

Ale nadal: przy skalowaniu do 20+ kanałów, Node.js będzie bottleneck. Długoterminowe rozwiązanie prawdopodobnie wymaga wydzielenia BeepDetector jako osobny microservice w Go lub Rust, z komunikacją przez Unix socket lub shared memory. Na razie przy 5 kanałach wystarcza, ale to technical debt który czeka.

Cache Jako Ratowanie Kosztów API

Gemini przez OpenRouter to ~$0.0001 per token. Interim transcript to średnio 20-30 tokenów. Cztery interim per połączenie = ~0.01¢ per call. Przy 10,000 połączeń dziennie to $10/dzień samego API. Brzmi niewiele, ale 90% tych requestów było duplikatami.

Dlaczego? Bo poczty głosowe mają standardowe nagrane wiadomości. „You have reached the voicemail box of… Please leave a message after the tone” to dosłownie setki identycznych transkryptów. Pierwsza optymalizacja: LRU cache z hashem transkryptu jako kluczem.

class ClassificationCache {
  private cache = new LRUCache({ max: 1000, ttl: 3600_000 });
  get(transcript: string): Classification | undefined {
    const normalized = transcript.toLowerCase().trim();
    return this.cache.get(normalized);
  }
  set(transcript: string, classification: Classification) {
    const normalized = transcript.toLowerCase().trim();
    this.cache.set(normalized, classification);
  }
}

Skutek: 85% cache hit rate. Koszty API spadły do $1.50/dzień. Ale pojawił się inny problem: interim transcripts są incrementalne. Pierwszy: „you have reached”, drugi: „you have reached the voicemail”, trzeci: „you have reached the voicemail box of”. Exact match cache nie łapał partial matches.

Rozwiązanie: fuzzy matching z Levenshtein distance dla transkryptów poniżej 50 znaków. Jeśli similarity > 0.85 do cached entry, użyj cached classification. Cache hit rate wzrósł do 92%.

Drugie podejście: local heuristics przed API call. Lista regex patterns dla oczywistych przypadków:

const OBVIOUS_VOICEMAIL_PATTERNS = [
  /please leave (a |your )?message/i,
  /after the (tone|beep)/i,
  /mailbox (is full|has not been set up)/i,
  /not available to take your call/i,
  /press \d+ to leave a message/i,
];
function quickCheck(transcript: string): Classification | null {
  for (const pattern of OBVIOUS_VOICEMAIL_PATTERNS) {
    if (pattern.test(transcript)) {
      return { type: "VOICEMAIL", confidence: 0.95, source: "heuristic" };
    }
  }
  return null;
}

Jeśli lokalna heurystyka zwróci high-confidence result, pomijamy API całkowicie. Coverage: około 40% przypadków. Łączny efekt: zamiast 40,000 API calls/day, mamy 3,000. Koszty spadły 13x.

Webhooks i Idempotency Hell

Telnyx wysyła webhooks o eventach połączeń: „call.initiated”, „call.answered”, „call.hangup”. W teorii webhook przychodzi raz per event. W praktyce: może nie dojść, może dojść zduplikowany (retry po timeout), może dojść out-of-order (webhook „hangup” przed „answered” bo networking).

Pierwsze produkcyjne symptomy: „phantom calls” w statystykach. Połączenie zakończyło się 10 minut temu, ale w Redis wciąż figuruje jako active. Debugowanie pokazało: webhook „call.hangup” nigdy nie dotarł. Po 30 minutach Telnyx spróbował retry, ale nasz handler był synchroniczny i long-running – timeout, więc Telnyx spróbował ponownie. Drugi raz handler zadziałał, ale nasze logi pokazywały błąd „call not found” (bo w międzyczasie manual cleanup).

Rozwiązanie w trzech warstwach:

1. Idempotency key
Każdy webhook Telnyx ma „data.id” – unique event ID. Używamy „SETNX” w Redis z TTL 1h:

app.post("/webhook/telnyx", async (req, res) => {
  const eventId = req.body.data.id;
  const isNew = await redis.set("webhook:${eventId}", "1", { NX: true, EX: 3600 });
  if (!isNew) {
    logger.info("Duplicate webhook ${eventId}, ignoring");
    return res.status(200).send("ok");
  }
  // Natychmiast odpowiedz 200 (Telnyx wymaga szybkiej odpowiedzi)
  res.status(200).send("accepted");
  // Przetwarzaj asynchronicznie
  await queue.add("process-webhook", req.body);
});

2. Asynchroniczne przetwarzanie
Handler HTTP natychmiast odpowiada 200 i wrzuca event do BullMQ. Telnyx dostaje ACK w <10ms, więc nie robi retry. Faktyczne przetwarzanie dzieje się w worker process.

3. Reconciliation
Co 60 sekund job porównuje „nasze active calls” (Redis SET) z „Telnyx active calls” (API query). Rozbieżności = cleanup:

@Cron("*/60 * * * * *")
async reconcileActiveCalls() {
  const ourCalls = await redis.smembers("active_calls");
  const telnyxCalls = await telnyx.calls.list({ status: "active" });
  const telnyxIds = new Set(telnyxCalls.map(c => c.id));
  for (const callId of ourCalls) {
    if (!telnyxIds.has(callId)) {
      logger.warn("Orphaned call ${callId}, cleaning up");
      await this.forceCleanup(callId);
    }
  }
}

To nie rozwiązuje problemu missed webhooks całkowicie, ale ogranicza okno niespójności do 60 sekund.

"AI poza critical path brzmi jak kompromis, dopóki nie odkryjesz, że kompromis też ma latency, cache, timeouty i własny mały system nerwowy. Czyli klasycznie: usunęliśmy problem z jednej pętli, żeby dać mu biurko obok."

Redis Failure i Source of Truth

BullMQ używa Redis do przechowywania stanów jobów. Kampania dzwonienia ma tysiące numerów, każdy musi być processed exactly-once. Co się dzieje gdy Redis crashuje w środku kampanii?

Naiwne podejście: restart Redis, restart workerów, „jakoś to będzie”. W praktyce: część numerów zadwoniona podwójnie (bo state „już wybrane” zniknął), część pominięta (bo job był w stanie „active” i nie został resumed).

Rozwiązanie: PostgreSQL jako source of truth, Redis jako performance cache. Każde wykonane połączenie jest najpierw zapisywane w PostgreSQL, następnie cache”owane w Redis:

async function markCallCompleted(campaignId: string, phoneNumber: string, result: CallResult) {
  // 1. Durable write
  await db.campaignCalls.upsert({
    campaignId,
    phoneNumber,
    result,
    completedAt: new Date(),
  });
  // 2. Cache update (best effort)
  await redis.sadd("campaign:${campaignId}:completed", phoneNumber).catch(err => {
    logger.error("Redis cache update failed, continuing", err);
  });
}

Przy starcie workera po awarii Redis:

async function recoverCampaignState(campaignId: string) {
  logger.info("Recovering state for campaign ${campaignId} from PostgreSQL");
  const completed = await db.campaignCalls.findMany({
    where: { campaignId, completedAt: { not: null } }
  });
  // Rebuild Redis cache
  if (completed.length > 0) {
    await redis.sadd("campaign:${campaignId}:completed", ...completed.map(c => c.phoneNumber));
  }
  logger.info("Recovered ${completed.length} completed calls");
}

Cost: każdy call wymaga database write, co dodaje ~5-10ms latency. Benefit: pełna durability. Trade-off który ma sens dla systemu gdzie „exact number of calls” ma znaczenie biznesowe (i prawne – TCPA compliance).

Timezone Hell i Regulatory Compliance

USA ma 350+ area codes. System musi wywnioskować strefę czasową z numeru telefonu, żeby nie dzwonić o 6 rano czasu lokalnego. Problem: niektóre area codes pokrywają wiele stref czasowych. Kod 859 (Kentucky) to większość Eastern Time, ale kawałek Central Time.

Pierwsza implementacja: statyczna mapa area code -> timezone. W praktyce: około 3% błędnych klasyfikacji. To brzmi niewiele, ale przy 10,000 połączeń dziennie to 300 potencjalnych naruszeń TCPA (Telephone Consumer Protection Act). Kara: do $1,500 per violation. 300 x $1,500 = $450,000 potencjalnej ekspozycji.

Rozwiązanie w dwóch częściach:

1. Conservative fallback
Jeśli area code jest ambiguous, użyj „późniejszej” strefy czasowej. Tzn. jeśli kod obejmuje Eastern (UTC-5) i Central (UTC-6), klasyfikuj jako Central. Efekt: dzwonimy o 9am Central = 10am Eastern. Bezpieczniejsze.

2. Manual override database
Dla klientów którzy „znają swoje listy”, pozwalamy na upload CSV z explicit timezone per number. To nadpisuje area code logic:

class TimezoneResolver {
  async resolve(phoneNumber: string): Promise {
    // 1. Explicit override
    const override = await db.timezoneOverrides.findUnique({ 
      where: { phoneNumber } 
    });
    if (override) return override.timezone;
    // 2. Area code mapping
    const areaCode = phoneNumber.substring(0, 3);
    const mapping = AREA_CODE_MAP[areaCode];
    if (!mapping) {
      throw new Error("Unknown area code ${areaCode}");
    }
    // 3. Conservative choice for ambiguous codes
    return mapping.ambiguous ? mapping.conservativeTimezone : mapping.timezone;
  }
}

Dodatkowo: opt-out detection w real-time. Jeśli transcript zawiera „stop calling me” lub podobne, natychmiast hangup i dodaj do suppress list:

@OnEvent("transcript.interim")
async checkOptOut(callId: string, transcript: string) {
  const optOut = this.optOutDetector.detect(transcript);
  if (optOut) {
    logger.warn("Opt-out detected for ${callId}: "${optOut.phrase}"");
    await telnyx.calls.hangup(callId);
    await db.suppressList.create({
      phoneNumber: callState.phoneNumber,
      reason: "opt_out_verbal",
      detectedPhrase: optOut.phrase,
    });
    metrics.increment("compliance.opt_out.verbal");
  }
}

To nie łapie 100% przypadków (ludzie mówią „quit bothering me” w różnych wariantach), ale pokrywa najbardziej oczywiste. Regex patterns trzeba aktualizować na podstawie manual review próbek transkryptów.

Skalowanie Poza Pojedynczy VPS

System obecnie działa na dedykowanym VPS w Ashburn (blisko infrastruktury Telnyx). Ograniczenie: 5 równoległych kanałów. Co jeśli klient chce 20? Albo 50?

Bottlenecki:

Architektura dla skali:

┌─────────────────────────────────────────────────┐
│         Load Balancer (webhooks)                │
└───────────┬─────────────────────────────────────┘
            │
    ┌───────┴────────┐
    │                │
┌───▼────┐      ┌───▼────┐      ┌────────┐
│ VPS 1  │      │ VPS 2  │      │ VPS N  │
│ (5 ch) │      │ (5 ch) │      │ (5 ch) │
└───┬────┘      └───┬────┘      └───┬────┘
    │               │               │
    └───────┬───────┴───────┬───────┘
            │               │
      ┌─────▼─────┐   ┌─────▼─────┐
      │   Redis   │   │ PostgreSQL│
      │  Cluster  │   │           │
      └───────────┘   └───────────┘

Kluczowe: każdy VPS musi koordynować przez Redis, żeby nie przekroczyć globalnego limitu kanałów. Distributed locking per campaign:

class DistributedChannelManager {
  async acquireChannel(campaignId: string): Promise {
    const script = "
      local active = redis.call("scard", KEYS[1])
      if active < tonumber(ARGV[1]) then
        local channelId = ARGV[2]
        redis.call("sadd", KEYS[1], channelId)
        return channelId
      end
      return nil
    ";
    const channelId = uuidv4();
    const result = await redis.eval(
      script,
      ["campaign:${campaignId}:active_channels"],
      [MAX_CHANNELS_PER_CAMPAIGN, channelId]
    );
    return result;
  }
}

Lua script w Redis zapewnia atomowość check-and-set. Każdy VPS próbuje acquire channel; jeśli limit przekroczony, czeka lub przechodzi do następnej kampanii.

Vendor Lock-in i Długoterminowe Ryzyko

System jest głęboko związany z Telnyx: media forking (raw audio stream), TeXML (call control), webhooks, AMD API. Migracja do innego providera (Twilio, Plivo) to przepisanie ~40% codebase.

To jest OK dla MVP i wczesnego product-market fit. Ale długoterminowo to ryzyko. Co jeśli Telnyx:

Rozwiązanie: abstraction layer. Nie wywołujemy „telnyx.calls.playAudio()” bezpośrednio, tylko „telephonyProvider.playAudio()”:

interface TelephonyProvider {
  makeCall(params: CallParams): Promise;
  playAudio(callId: string, audioUrl: string): Promise;
  streamMedia(callId: string): AsyncIterable;
  hangup(callId: string): Promise;
}
class TelnyxProvider implements TelephonyProvider {
  async playAudio(callId: string, audioUrl: string): Promise {
    await this.client.calls.playAudio(callId, { audio_url: audioUrl });
  }
  // ...
}
class TwilioProvider implements TelephonyProvider {
  async playAudio(callId: string, audioUrl: string): Promise {
    await this.client.calls(callId).update({ twiml: "${audioUrl}" });
  }
  // ...
}

To nie eliminuje lock-in całkowicie. Każdy provider ma unique capabilities (Telnyx media forking to nie ma direct equivalent w Twilio). Ale redukuje koszt migracji z „przepisz 40%” do „zaimplementuj adapter + przetestuj różnice w behavior”.

"Najpierw Redis był kolejką, potem cache’em, potem lock managerem, a przez chwilę prawie religią. Dopiero Postgres musiał przypomnieć wszystkim, że source of truth powinien przeżyć więcej niż chwilowy kaprys procesu."

Co Zadziałało, Co Nie, i Co Bym Zmienił

Zadziałało:

Nie zadziałało / wymaga poprawy:

Co bym zmienił od początku:

Praktyczne Wnioski

Umieszczenie AI w critical path to trade-off między accuracy a latency. W większości przypadków możesz mieć jedno lub drugie, nie oba. Jeśli timing ma znaczenie biznesowe (a w telco zawsze ma), musisz:

Największa lekcja: AI to narzędzie, nie architektura. Decyzja „używamy GPT-4 do X” mówi tyle samo o systemie, co „używamy PostgreSQL do Y”. Interesujące są constraints, trade-offy i failure modes. Jakie są SLA external API? Co się dzieje przy timeout? Jak synchronizujemy competing signals? Jak fallbackujemy przy degradacji?

Te pytania determinują czy system zadziała w produkcji, nie wybór modelu.

Podsumowanie

AI w critical path brzmi świetnie na etapie architektury. Diagram wygląda nowocześnie, strzałki płyną elegancko między WebSocketami, transkrypcją i klasyfikacją, a wszyscy przez chwilę udają, że 300ms latency „to przecież praktycznie realtime”. Potem przychodzi pierwszy production load i nagle okazuje się, że beep trwa krócej niż odpowiedź modelu, Deepgram dostaje zadyszki przy piątym równoległym callu, a trzy różne detektory próbują jednocześnie odtworzyć tę samą wiadomość. System oczywiście nadal działał. Po prostu czasami zostawiał voicemail dwa razy albo próbował rozmawiać z połączeniem, które zakończyło się kilka sekund wcześniej.

Najbardziej ironiczne w całym systemie było to, że im więcej dokładaliśmy „inteligencji”, tym więcej infrastruktury musiało istnieć tylko po to, żeby ograniczać skutki tej inteligencji. Circuit breakery dla AI. Timeouty dla timeoutów. Redis używany głównie do pilnowania, żeby dwa komponenty nie zrobiły jednocześnie dokładnie tego samego. Reconciliation job sprawdzający, które połączenia jeszcze istnieją w rzeczywistości. W pewnym momencie lokalny regex „please leave a message” okazał się bardziej stabilnym elementem architektury niż pół stacku AI dookoła niego.

Production traffic ma też wyjątkowy talent do znajdowania wszystkich scenariuszy, które podczas developmentu wydają się absurdalnie mało prawdopodobne. Webhook „hangup” przed „answered”. FFT blokujące event loop dokładnie w momencie playbacku. Redis failover akurat między „SETNX” a cleanupem calla. I oczywiście wszystko dzieje się równocześnie, bo produkcja najwyraźniej uważa chaos engineering za sport zespołowy. Finalnie system działa bardzo dobrze. Trzeba tylko zaakceptować, że połowa architektury istnieje głównie po to, żeby przekonywać resztę systemu, że sytuacja nadal jest pod kontrolą.

Zobacz powiązane case studies i analizy

Procesy, architektura i workflow powiązane z tematami poruszanymi w tym materiale – od integracji i realtime systems po automatyzacje operacyjne.

Inne

Pozostałe artykuły

Webhook Reliability Patterns: czego nauczyłem się debugując phantom calls w systemie VoIP
Distributed state w systemach realtime działa poprawnie tylko do momentu, w którym różne warstwy infrastruktury zaczynają posiadać sprzeczne informacje o tym samym połączeniu.
Jak dostarczyć MVP w 14 dni bez tworzenia architektonicznej katastrofy, która zablokuje rozwój produktu po pierwszym sukcesie?