Lewati ke konten utama
Semua artikel
MenengahAI Backend

Integrasi LLM API untuk Backend Engineer

Pola streaming response, cost control, caching, dan rate limiting saat backend kamu memanggil API LLM seperti OpenAI atau Anthropic.

17 menit baca

"Memanggil LLM API itu satu baris kode. Membuatnya aman dipanggil 10.000 kali sehari tanpa membakar budget atau membuat server kamu menggantung — itu pekerjaan backend engineer yang sesungguhnya."


Tentang Artikel Ini

Kalau kamu baru pertama kali menyambungkan backend ke OpenAI atau Anthropic, kemungkinan besar kodenya persis seperti contoh di dokumentasi resmi: satu fungsi, satu request, satu response. Itu jalan sempurna waktu testing. Masalahnya baru muncul begitu fitur itu dipakai user sungguhan — response yang lama bikin request timeout, tagihan API yang melonjak karena satu endpoint dipanggil berkali-kali untuk pertanyaan yang sama, atau satu user iseng bikin 50 request bersamaan sampai server kehabisan koneksi.

LLM API berbeda dari API biasa yang selama ini kamu integrasikan (payment gateway, email provider, dll) dalam tiga hal yang langsung berdampak ke arsitektur: latensinya jauh lebih tinggi (detik, bukan milidetik), biayanya proporsional ke jumlah token bukan flat per-request, dan outputnya tidak deterministik — endpoint yang sama, input yang sama, bisa menghasilkan response yang berbeda tiap kali dipanggil.

Kita akan sambungkan toko-api (project yang sama dari artikel-artikel sebelumnya) ke OpenAI dan Anthropic untuk dua fitur yang realistis: generate deskripsi produk otomatis, dan asisten tanya-jawab produk untuk pembeli. Di sepanjang jalan, tiap pola yang kita tambahkan menutup satu celah production yang biasanya baru ketahuan setelah tagihan atau insiden pertama datang. Setelah selesai, kamu akan bisa:

  • Memanggil OpenAI dan Anthropic API dari Node.js dengan pola yang seragam
  • Mengimplementasikan streaming response supaya user tidak menatap loading spinner selama 8 detik
  • Menghitung dan membatasi biaya token sebelum tagihan membengkak
  • Meng-cache response supaya pertanyaan yang sama tidak dibayar dua kali
  • Menerapkan rate limit dan concurrency guard yang sesuai karakter LLM (bukan rate limit REST API biasa)
  • Menangani error, retry, dan fallback antar-provider dengan aman
  • Memvalidasi input user sebelum masuk ke prompt

Prasyarat: Sudah menyelesaikan artikel #02 (REST API dengan Node.js & Express) — kita lanjutkan toko-api dari situ. Sudah punya API key OpenAI dan/atau Anthropic (cukup salah satu untuk ikuti artikel ini, tapi pola fallback di Bab 8 butuh keduanya).


Daftar Isi

  1. Kenapa Integrasi LLM Beda dari Integrasi API Biasa
  2. Setup: SDK OpenAI dan Anthropic di Node.js
  3. Endpoint Pertama: Generate Deskripsi Produk
  4. Streaming Response ke Client
  5. Cost Control: Hitung Token Sebelum Kena Tagihan
  6. Caching: Jangan Bayar Dua Kali untuk Pertanyaan yang Sama
  7. Rate Limiting dan Concurrency Guard
  8. Error Handling: Retry, Timeout, dan Fallback Provider
  9. Validasi Input yang Masuk ke Prompt

Bab 1: Kenapa Integrasi LLM Beda dari Integrasi API Biasa

Tiga Perbedaan yang Mengubah Arsitektur

Sebelum menulis kode, ada baiknya menyamakan model mental dulu — supaya keputusan di bab-bab berikutnya terasa masuk akal, bukan sekadar "ikuti saja langkahnya".

Latensi. Payment gateway atau email provider biasanya merespons dalam ratusan milidetik. LLM API bisa makan 2–15 detik untuk response yang panjang, tergantung model dan panjang output. Kalau kamu perlakukan seperti REST call biasa — request masuk, tunggu, balas — user akan menatap layar kosong cukup lama untuk berpikir aplikasinya hang.

Biaya proporsional ke token. API lain biasanya kamu bayar flat per-request atau per-bulan. LLM API dihitung per token — gabungan input (prompt) dan output (response). Satu endpoint yang "cuma dipanggil sekali" bisa jadi mahal kalau prompt-nya menyertakan seluruh riwayat percakapan atau dokumen panjang setiap kali dipanggil ulang.

Non-determinisme. GET /products/1 dipanggil 100 kali menghasilkan response yang identik. Prompt yang sama ke LLM API bisa menghasilkan 100 variasi jawaban berbeda — bahkan dengan temperature rendah, tidak ada jaminan output byte-identik. Ini artinya kamu tidak bisa mengandalkan pola testing yang sama seperti API deterministik biasa, dan kamu perlu berpikir soal fallback untuk kasus output yang di luar ekspektasi (format salah, jawaban tidak relevan, dll).

Tiga hal ini yang membentuk seluruh isi artikel: streaming menjawab masalah latensi, cost control dan caching menjawab masalah biaya, dan validasi + fallback menjawab masalah non-determinisme.


Bab 2: Setup: SDK OpenAI dan Anthropic di Node.js

Install SDK

npm install openai @anthropic-ai/sdk

Kedua provider punya SDK resmi untuk Node.js, dan keduanya punya bentuk API yang cukup mirip — client object, method create(), pesan berbentuk array { role, content }. Kita akan bungkus keduanya di balik satu interface yang seragam, supaya kode di endpoint tidak perlu tahu provider mana yang sedang dipakai.

# .env — tambahkan di samping variable yang sudah ada dari artikel #02
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...

Jangan pernah kirim API key ini ke client (browser/mobile). Semua panggilan ke OpenAI/Anthropic wajib lewat backend kamu. Kalau key ini bocor ke frontend, siapa pun bisa memakainya dan tagihan jadi tanggung jawabmu.

Wrapper Seragam untuk Kedua Provider

// src/services/llm.js
import OpenAI from 'openai';
import Anthropic from '@anthropic-ai/sdk';
 
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const anthropic = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
 
// Cek dokumentasi resmi masing-masing provider untuk model & pricing terbaru —
// daftar model berubah cukup sering, jangan hardcode tanpa cek ulang berkala.
const OPENAI_MODEL = process.env.OPENAI_MODEL ?? 'gpt-4.1-mini';
const ANTHROPIC_MODEL = process.env.ANTHROPIC_MODEL ?? 'claude-sonnet-5';
 
export async function generateText({ system, prompt, provider = 'openai' }) {
  if (provider === 'anthropic') {
    const res = await anthropic.messages.create({
      model: ANTHROPIC_MODEL,
      max_tokens: 1024,
      system,
      messages: [{ role: 'user', content: prompt }],
    });
    return {
      text: res.content[0].text,
      usage: { inputTokens: res.usage.input_tokens, outputTokens: res.usage.output_tokens },
    };
  }
 
  const res = await openai.chat.completions.create({
    model: OPENAI_MODEL,
    messages: [
      { role: 'system', content: system },
      { role: 'user', content: prompt },
    ],
  });
  return {
    text: res.choices[0].message.content,
    usage: {
      inputTokens: res.usage.prompt_tokens,
      outputTokens: res.usage.completion_tokens,
    },
  };
}

Perhatikan usage di kedua cabang — ini bukan detail kosmetik. Setiap response dari kedua provider menyertakan jumlah token asli yang terpakai, dan itu yang akan kita simpan di Bab 5 untuk cost tracking. Jangan pernah mengandalkan estimasi token sendiri sebagai angka final ketika response aslinya sudah memberi angka pasti.


Bab 3: Endpoint Pertama: Generate Deskripsi Produk

Kasus: Deskripsi Produk Otomatis dari Spesifikasi

Tim toko-api sering punya ratusan produk baru masuk tanpa deskripsi marketing yang layak — cuma spesifikasi teknis mentah dari supplier. Ini kandidat bagus untuk LLM: input terstruktur (nama, kategori, spesifikasi), output teks yang bisa langsung dipakai atau diedit tipis oleh tim konten.

// src/routes/products.js
import { generateText } from '../services/llm.js';
 
router.post('/:id/generate-description', async (req, res, next) => {
  try {
    const product = await getProductById(req.params.id); // sudah ada dari artikel #02
    if (!product) return res.status(404).json({ error: 'Produk tidak ditemukan' });
 
    const system = 'Kamu adalah copywriter e-commerce. Tulis deskripsi produk yang ringkas, ' +
      'jujur berdasarkan spesifikasi yang diberikan, maksimal 3 paragraf, bahasa Indonesia santai.';
    const prompt = `Nama produk: ${product.name}\nKategori: ${product.category}\n` +
      `Spesifikasi: ${JSON.stringify(product.specs)}`;
 
    const { text, usage } = await generateText({ system, prompt });
 
    await updateProductDescription(product.id, text); // simpan ke DB, review manual sebelum publish
 
    res.json({ description: text, usage });
  } catch (err) {
    next(err);
  }
});

Endpoint ini sengaja tidak langsung mem-publish hasilnya — deskripsi disimpan sebagai draft, tim konten yang review dan approve. LLM bagus untuk mempercepat draft pertama, tapi untuk konten yang tampil ke pembeli, tetap ada manusia yang jadi gatekeeper terakhir sebelum publish. Ini bukan soal tidak percaya modelnya — ini soal siapa yang bertanggung jawab kalau ada klaim produk yang keliru.

Endpoint ini juga contoh kasus di mana non-streaming masih tepat: hasilnya perlu direview utuh sebelum disimpan, jadi tidak ada gunanya menampilkan kata per kata ke user. Streaming baru masuk akal untuk kasus berikutnya — di mana user menunggu jawaban secara real-time.


Bab 4: Streaming Response ke Client

Kasus: Asisten Tanya-Jawab Produk

Fitur kedua: pembeli bisa bertanya langsung soal produk ("bahan celana ini apa, bisa dicuci mesin?") dan dapat jawaban berbasis spesifikasi produk. Di sinilah latensi jadi masalah nyata — jawaban yang panjang bisa makan waktu 5–8 detik kalau ditunggu utuh. Streaming mengirim token ke client sesegera model menghasilkannya, jadi user melihat jawaban "mengetik" secara real-time daripada menatap layar kosong.

// src/routes/products.js
router.post('/:id/ask', async (req, res, next) => {
  const { question } = req.body;
  if (!question || typeof question !== 'string') {
    return res.status(400).json({ error: 'Pertanyaan wajib diisi' });
  }
 
  const product = await getProductById(req.params.id);
  if (!product) return res.status(404).json({ error: 'Produk tidak ditemukan' });
 
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('Connection', 'keep-alive');
 
  try {
    const stream = await openai.chat.completions.create({
      model: OPENAI_MODEL,
      stream: true,
      messages: [
        {
          role: 'system',
          content: `Jawab pertanyaan pembeli hanya berdasarkan spesifikasi produk berikut: ` +
            `${JSON.stringify(product.specs)}. Kalau tidak ada di spesifikasi, bilang tidak tahu, jangan mengarang.`,
        },
        { role: 'user', content: question },
      ],
    });
 
    for await (const chunk of stream) {
      const delta = chunk.choices[0]?.delta?.content;
      if (delta) res.write(`data: ${JSON.stringify({ delta })}\n\n`);
    }
    res.write('data: [DONE]\n\n');
  } catch (err) {
    res.write(`data: ${JSON.stringify({ error: 'Gagal memproses pertanyaan' })}\n\n`);
  } finally {
    res.end();
  }
});

Perhatikan instruksi di system prompt: jawab hanya dari spesifikasi produk, kalau tidak ada bilang tidak tahu. Ini pagar sederhana tapi penting — tanpa itu, model cenderung "membantu" dengan mengarang jawaban yang terdengar meyakinkan untuk hal yang sebenarnya tidak ada di data produk. Untuk kasus jawaban yang perlu mengambil dari basis pengetahuan yang lebih luas (bukan cuma satu produk), pola ini akan berkembang jadi retrieval — dibahas tuntas di artikel berikutnya, RAG & Vector Database Praktis.

Di Sisi Client

// Contoh konsumsi di frontend (fetch + ReadableStream)
const res = await fetch(`/api/products/${id}/ask`, {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ question }),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let answer = '';
 
while (true) {
  const { done, value } = await reader.read();
  if (done) break;
  const chunk = decoder.decode(value);
  for (const line of chunk.split('\n\n')) {
    if (!line.startsWith('data: ') || line.includes('[DONE]')) continue;
    const { delta } = JSON.parse(line.slice(6));
    if (delta) {
      answer += delta;
      renderPartialAnswer(answer); // update UI tiap chunk masuk
    }
  }
}

usage tidak selalu tersedia di response streaming secara default. Untuk OpenAI, kamu perlu menambahkan stream_options: { include_usage: true } supaya chunk terakhir menyertakan angka token. Kalau kamu butuh cost tracking yang akurat untuk endpoint streaming (dan biasanya memang butuh — Bab 5), cek dokumentasi resmi untuk opsi setara di provider yang kamu pakai, jangan asumsikan formatnya sama seperti response non-streaming.


Bab 5: Cost Control: Hitung Token Sebelum Kena Tagihan

Kenapa Rate Limit Saja Tidak Cukup

Rate limit membatasi jumlah request. Tapi dua request bisa punya biaya yang jauh berbeda — satu pertanyaan singkat vs satu prompt yang menyertakan seluruh katalog produk. Kalau kamu cuma membatasi jumlah request per menit, satu user masih bisa membakar budget bulanan lewat segelintir request yang masing-masing mahal.

Simpan Setiap Pemakaian Token

-- migrations/010_create_llm_usage_log.sql
CREATE TABLE llm_usage_log (
  id SERIAL PRIMARY KEY,
  endpoint VARCHAR(100) NOT NULL,
  provider VARCHAR(20) NOT NULL,
  input_tokens INT NOT NULL,
  output_tokens INT NOT NULL,
  estimated_cost_usd NUMERIC(10, 6) NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);
 
CREATE INDEX idx_llm_usage_created_at ON llm_usage_log (created_at);
// src/services/llm.js — tambahkan setelah generateText berhasil
const PRICING_PER_1M_TOKENS = {
  // Angka contoh — ganti sesuai pricing aktif dari dashboard provider,
  // ini berubah tanpa pemberitahuan dan beda per model.
  openai: { input: 0.15, output: 0.60 },
  anthropic: { input: 3.0, output: 15.0 },
};
 
function estimateCost(provider, inputTokens, outputTokens) {
  const rate = PRICING_PER_1M_TOKENS[provider];
  return (inputTokens * rate.input + outputTokens * rate.output) / 1_000_000;
}
 
export async function logUsage({ endpoint, provider, usage }) {
  const cost = estimateCost(provider, usage.inputTokens, usage.outputTokens);
  await db.query(
    `INSERT INTO llm_usage_log (endpoint, provider, input_tokens, output_tokens, estimated_cost_usd)
     VALUES ($1, $2, $3, $4, $5)`,
    [endpoint, provider, usage.inputTokens, usage.outputTokens, cost]
  );
  return cost;
}

Budget Guard Harian

// src/middleware/budgetGuard.js
const DAILY_BUDGET_USD = Number(process.env.LLM_DAILY_BUDGET_USD ?? 20);
 
export async function budgetGuard(req, res, next) {
  const { rows } = await db.query(
    `SELECT COALESCE(SUM(estimated_cost_usd), 0) AS total
     FROM llm_usage_log WHERE created_at > CURRENT_DATE`
  );
  if (Number(rows[0].total) >= DAILY_BUDGET_USD) {
    return res.status(503).json({ error: 'Fitur AI sedang mencapai batas budget harian, coba lagi besok.' });
  }
  next();
}

Pasang budgetGuard sebelum handler yang memanggil LLM. Ini jaring pengaman terakhir — bukan pengganti monitoring aktif. Tetap cek dashboard billing provider secara berkala; budget guard cuma mencegah lonjakan tak terduga jadi tagihan yang mengejutkan di akhir bulan, bukan alat kontrol biaya utama.

Kalau kamu belum sempat mengukur, jangan tebak angka DAILY_BUDGET_USD. Jalankan dulu tanpa guard selama beberapa hari dengan traffic wajar, lihat total biaya aktual dari llm_usage_log, baru tentukan batas yang masuk akal — 2–3x rata-rata harian biasanya titik awal yang aman.


Bab 6: Caching: Jangan Bayar Dua Kali untuk Pertanyaan yang Sama

Banyak pertanyaan produk berulang — "bisa dicuci mesin?", "ada garansi?" — dengan spesifikasi produk yang sama, jawabannya nyaris selalu identik. Memanggil LLM ulang untuk pertanyaan yang secara efektif sama adalah biaya yang bisa dihindari sepenuhnya.

// src/services/cache.js — in-memory, cukup untuk traffic sedang di satu instance
import crypto from 'crypto';
 
const cache = new Map();
const TTL_MS = 60 * 60 * 1000; // 1 jam
 
function cacheKey(productId, question) {
  const normalized = question.trim().toLowerCase();
  return crypto.createHash('sha256').update(`${productId}:${normalized}`).digest('hex');
}
 
export function getCached(productId, question) {
  const entry = cache.get(cacheKey(productId, question));
  if (!entry || Date.now() > entry.expiresAt) return null;
  return entry.value;
}
 
export function setCached(productId, question, value) {
  cache.set(cacheKey(productId, question), { value, expiresAt: Date.now() + TTL_MS });
}
// dipakai di endpoint /ask sebelum memanggil LLM
const cached = getCached(product.id, question);
if (cached) {
  res.write(`data: ${JSON.stringify({ delta: cached })}\n\n`);
  return res.end();
}
// ...panggil LLM seperti biasa, lalu setCached(product.id, question, fullAnswer) setelah selesai

Map in-memory ini reset tiap kali server restart, dan tidak dibagi antar-instance kalau kamu jalankan PM2 cluster mode (lihat artikel #07 — Deploy VPS) atau beberapa container. Untuk cache yang perlu bertahan dan dibagi antar-proses, pindahkan ke Redis — sudah dibahas caranya di artikel Redis & Caching Strategy, polanya sama persis, tinggal ganti Map dengan SETEX/GET.

Normalisasi pertanyaan (trim().toLowerCase()) di atas sengaja sederhana — dua pertanyaan yang mirip secara makna tapi beda kata tetap dianggap berbeda. Caching berbasis kemiripan makna (bukan cuma string identik) butuh embedding dan pencarian vektor, yang jadi topik utama artikel selanjutnya.


Bab 7: Rate Limiting dan Concurrency Guard

Rate Limit Per-Request Saja Belum Cukup

// src/middleware/llmRateLimit.js
import rateLimit from 'express-rate-limit';
 
export const llmRateLimit = rateLimit({
  windowMs: 60_000,
  max: 10, // jauh lebih ketat dari endpoint REST biasa
  message: { error: 'Terlalu banyak permintaan ke asisten AI, coba lagi sebentar.' },
});

Angka max: 10 sengaja jauh lebih rendah dibanding rate limit endpoint REST biasa (yang bisa ratusan per menit) — satu request LLM jauh lebih mahal dan lama dibanding satu request REST biasa, jadi ambang batasnya juga harus proporsional.

Kenapa Masih Butuh Concurrency Guard Terpisah

Rate limit membatasi jumlah request dalam rentang waktu. Tapi request LLM streaming bisa tetap terbuka selama beberapa detik — kalau 10 user membuka stream bersamaan dalam window yang sama, semuanya lolos rate limit tapi server tetap menahan 10 koneksi terbuka sekaligus, masing-masing menunggu token dari provider. Untuk endpoint yang panggilannya lama seperti ini, batasi juga berapa banyak yang boleh berjalan bersamaan, terpisah dari rate limit per-menit:

// src/middleware/concurrencyGuard.js
let activeRequests = 0;
const MAX_CONCURRENT = Number(process.env.LLM_MAX_CONCURRENT ?? 20);
 
export function concurrencyGuard(req, res, next) {
  if (activeRequests >= MAX_CONCURRENT) {
    return res.status(503).json({ error: 'Server sedang sibuk, coba lagi sebentar.' });
  }
  activeRequests++;
  res.on('finish', () => activeRequests--);
  res.on('close', () => activeRequests--);
  next();
}

res.on('close', ...) penting di sini — kalau user menutup tab di tengah streaming, finish tidak selalu terpanggil, tapi close terpanggil. Tanpa itu, counter activeRequests bisa "bocor" naik terus tanpa pernah turun kalau banyak user menutup koneksi lebih cepat dari yang diperkirakan.


Bab 8: Error Handling: Retry, Timeout, dan Fallback Provider

Retry dengan Backoff untuk Error Sementara

Error 429 (rate limited oleh provider) atau 5xx biasanya sementara — percobaan ulang setelah jeda singkat sering berhasil. Error 4xx lain (API key salah, request tidak valid) tidak akan berhasil walau diulang seratus kali — jangan di-retry.

// src/services/llm.js
async function withRetry(fn, { maxRetries = 2 } = {}) {
  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await fn();
    } catch (err) {
      const retriable = err.status === 429 || (err.status >= 500 && err.status < 600);
      if (!retriable || attempt === maxRetries) throw err;
      const delay = 500 * 2 ** attempt; // 500ms, 1s, 2s...
      await new Promise((resolve) => setTimeout(resolve, delay));
    }
  }
}

Fallback ke Provider Kedua

Kalau OpenAI sedang down atau konsisten gagal setelah retry, toko-api sudah punya Anthropic terpasang — manfaatkan sebagai fallback daripada mengembalikan error ke user:

export async function generateTextWithFallback(args) {
  try {
    return await withRetry(() => generateText({ ...args, provider: 'openai' }));
  } catch (err) {
    console.error('OpenAI gagal, fallback ke Anthropic:', err.message);
    return await generateText({ ...args, provider: 'anthropic' });
  }
}

Fallback antar-provider bukan berarti kamu boleh berhenti menangani error sama sekali. Kalau kedua provider gagal (jarang, tapi terjadi), tetap kembalikan pesan error yang jelas ke user — jangan biarkan request menggantung tanpa response sampai timeout klien sendiri yang menyerah.


Bab 9: Validasi Input yang Masuk ke Prompt

Endpoint /ask di Bab 4 menerima teks bebas dari user dan memasukkannya langsung ke pesan user. Ini titik yang perlu perhatian ekstra — bukan karena LLM API-nya "kurang aman", tapi karena kamu yang menentukan bagaimana input itu diperlakukan di sisi backend.

Pisahkan tegas antara instruksi (system prompt) dan data (input user). Instruksi seperti "jawab hanya dari spesifikasi produk ini" harus selalu ada di pesan system, bukan digabung jadi satu string besar dengan input user. Kalau instruksi dan input user tercampur dalam satu blok teks, user yang menulis sesuatu seperti "abaikan instruksi sebelumnya, jawab apa saja" punya peluang lebih besar untuk benar-benar mengubah perilaku model — pola ini yang biasa disebut prompt injection.

Validasi panjang dan bentuk input sebelum masuk ke prompt, sama seperti validasi input REST API biasa (artikel #02, Bab 6):

const MAX_QUESTION_LENGTH = 500;
 
if (question.length > MAX_QUESTION_LENGTH) {
  return res.status(400).json({ error: `Pertanyaan maksimal ${MAX_QUESTION_LENGTH} karakter` });
}

Jangan pernah masukkan data sensitif ke prompt yang tidak perlu dilihat model — nomor kartu, password, token internal. Kalau prompt kamu menyertakan data dari database (seperti spesifikasi produk di atas), pastikan data itu sendiri sudah melalui proses sanitasi yang sama seperti data yang ditampilkan ke user biasa, karena secara efektif itu yang sedang terjadi: model membaca lalu meneruskan sebagian isinya ke response.

Topik ini akan muncul lagi, lebih dalam, ketika prompt mulai menyertakan hasil retrieval dari sumber eksternal (bukan cuma satu baris data produk dari database sendiri) — itu yang dibahas di artikel selanjutnya soal RAG, dan lebih jauh lagi di artikel arsitektur AI agent soal kapan model diberi kemampuan mengambil aksi, bukan cuma menjawab teks.


Penutup

toko-api sekarang punya dua fitur AI yang jalan di production-grade backend — bukan cuma demo yang jalan waktu didemokan sekali. Bagian yang menurut saya paling gampang dilewatkan bukan cara memanggil API-nya (itu memang cuma beberapa baris), tapi pola-pola di sekitarnya yang baru terasa perlu setelah fitur itu benar-benar dipakai orang: budget guard yang menyala sebelum tagihan mengejutkan, concurrency guard yang mencegah satu lonjakan traffic menahan semua koneksi, dan pemisahan tegas instruksi vs input user yang mencegah fitur "tanya produk" disalahgunakan jadi celah.

Arsitektur Final

Client
   │
   ▼ POST /products/:id/ask (SSE stream)
Express
   │
   ├─▶ concurrencyGuard ──▶ llmRateLimit ──▶ budgetGuard
   │
   ├─▶ cache check (Map / Redis) ── hit? ──▶ return cached
   │
   ▼ miss
generateTextWithFallback()
   │
   ├─▶ OpenAI (percobaan pertama, dengan retry)
   └─▶ Anthropic (fallback kalau OpenAI gagal)
   │
   ▼
logUsage() ──▶ llm_usage_log (PostgreSQL)

Production Checklist

Langkah selanjutnya:

  1. RAG & Vector Database Praktis — endpoint /ask di atas cuma tahu satu produk pada satu waktu. Untuk jawaban yang perlu mengambil dari basis pengetahuan yang lebih luas (seluruh katalog, dokumen kebijakan toko, dll), kamu butuh retrieval berbasis embedding — itu topik artikel berikutnya.
  2. Membangun MCP Server — begitu model perlu memanggil fungsi/tools (bukan cuma membaca teks yang kamu suapkan), MCP jadi cara standar untuk menyambungkan itu.

Lanjutkan ke

RAG & Vector Database PraktisSegera
Membangun MCP ServerSegera