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:
- Lokalny BeepDetector - analiza FFT surowego audio w paśmie 900-1100 Hz
- Telnyx call.detect API - wbudowana w operatora detekcja AMD (Answering Machine Detection)
- Silence-based fallback - jeśli cisza powyżej 2 sekund, uznaj za pocztę głosową
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:
- Highest priority: Explicit phrases w transkrypcie - jeśli Deepgram złapał "please leave a message after the beep", to oczywista poczta głosowa. Nie czekamy na AI.
- High priority: BeepDetector wykrył ton 1000 Hz przez 0.5s - charakterystyczny beep. Odtwarzaj.
- Medium priority: Gemini classification z confidence > 0.9 - jeśli AI jest pewne (i zdążyło odpowiedzieć), uwzględniamy.
- Low priority: Telnyx AMD - najmniej wiarygodne, ale najszybsze.
- Fallback: Po 3 sekundach ciszy bez innych sygnałów, uznaj za pocztę i odtwórz (ryzyko błędu, ale lepsze niż pominięcie).
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:
- CPU (BeepDetector FFT): Przy 50 kanałach to 2500 FFT operations/s. Nawet z Goertzel optimization, to za dużo dla pojedynczego VPS. Rozwiązanie: wydzielenie BeepDetector jako osobny service, horizontal scaling.
- Deepgram connections: 50 concurrent WebSockets. Sprawdziliśmy limit planu - do 100 OK, ale trzeba upgrade. Cost implications.
- Gemini API rate limits: OpenRouter ma per-minute limits. 50 kanałów x 4 requests/call x 2 calls/min = 400 req/min. To akurat w limicie, ale przy większym scale będzie problem. Rozwiązanie: własny model (Whisper lokalnie + mniejszy classifier model), ale to duży effort.
- Redis memory: State per call to ~5KB (audio buffers, transcript history, detector state). 50 calls x 5KB = 250KB. Trivial. Ale przy 500 równoczesnych połączeń (większy deployment) to 2.5MB - nadal OK. Redis memory nie jest bottleneck do ~10,000 concurrent calls.
- PostgreSQL connections: 50 concurrent writes. Wymaga connection pooling (PgBouncer) i tuning "max_connections". Standardowa konfiguracja.
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:
- Podnosi ceny 3x (a my mamy kontrakty z klientami po starych cenach)
- Degraduje jakość AMD (już się zdarzyło - dlatego zbudowaliśmy własne)
- Banuje nas za "zbyt agresywne dialowanie" (autodialer regulations są skomplikowane)
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:
- Usunięcie AI z synchronicznej ścieżki detekcji. Gemini w tle + local heuristics jako primary to właściwa decyzja. Accuracy ~92%, latency <100ms.
- Worker threads dla FFT + późniejszy Goertzel optimization. CPU bottleneck rozwiązany przy obecnej skali.
- Redis atomic operations dla race conditions. "SETNX" + Lua scripting to game changer dla distributed coordination.
- PostgreSQL as source of truth. Redis failures nie zabijają kampanii.
Nie zadziałało / wymaga poprawy:
- Timezone resolution nadal niedoskonałe. ~97% accuracy brzmi dobrze, ale 3% to nadal compliance risk. Potrzebujemy lepszych danych (może integracja z bazą Census dla zip code -> timezone).
- Deepgram backpressure handling jest reactive (circuit breaker), nie predictive. Chcemy load-based shedding zanim backpressure się pojawi.
- Brak proper distributed tracing. Debugging multi-detector race conditions wymaga ręcznego korelowania logów. Potrzebujemy OpenTelemetry + Jaeger.
Co bym zmienił od początku:
- Architektura event-driven od dnia 1. Teraz mamy hybrydę REST callbacks (webhooks) + event emitters + BullMQ jobs. Spójny event bus (np. NATS) uprościłby flow.
- Więcej investment w local ML. Dependencja na Gemini API to krótkoterminowo OK, ale długoterminowo chcemy własny distilled model. Fine-tuned DistilBERT dla binary classification (human vs machine) byłby szybszy i tańszy.
- Staging environment z realistic load. Większość problemów (race conditions, backpressure) pokazała się dopiero w produkcji przy 5 równoczesnych kanałach. Syntetyczny load testing z chaos engineering (losowe killing connections, delayed webhooks) wykryłby to wcześniej.
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:
- Przewidzieć degraded mode bez AI. Lokalny fallback który daje 80% accuracy jest lepszy niż 95% accuracy z 500ms opóźnieniem, jeśli to opóźnienie zabija UX.
- Mierzyć wszystko w percentylach, nie średnich. Średnie API latency 200ms wygląda OK. P99 latency 800ms zabija system. Production load ma long tail.
- Zakładać że external API failures będą częste. Nie "czy Deepgram spowalnia", ale "co robimy kiedy spowalnia". Circuit breakers, timeouts, retries - to nie opcje, to requirement.
- Cache agresywnie, ale z sensem. 92% hit rate na klasyfikację to różnica między $150/mies a $2000/mies w kosztach API. Ale fuzzy matching wymaga thought - zbyt liberalny threshold daje false hits i błędy klasyfikacji.
- Distributed coordination jest trudna. Redis "SETNX" rozwiązuje wiele, ale nie wszystko. Race conditions będą się zdarzać. Reconciliation jobs są koniecznością, nie nice-to-have.
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.