Чому вам потрібен gateway
Перший раз, коли команда викликає openai.chat.completions.create напряму з коду застосунку, все гаразд. Другий раз — інший сервіс, можливо інший провайдер, можливо кешована версія — усе починає дрейфувати. До десятого call site у вас є:
- Немає єдиного вигляду того, що витрачається на AI
- Немає способу перемкнути провайдерів без зміни коду
- Rate limits, що б'ють окремі сервіси непередбачувано
- Немає кешування — ви платите за те саме completion двічі
- Observability, що живе в дашборді провайдера, а не у вашому
- Немає guardrails на границі
LLM gateway — це та сама відповідь, що її API gateways дали HTTP-розповзанню п'ятнадцять років тому: єдиний спільний шар, через який проходить кожен вихідний виклик моделі. У 2026 для інженерних команд, які не хочуть писати власне з нуля, домінують два інструменти: Portkey для routing, fallback та кешування і Langfuse для observability, evaluation та cost attribution. Вони гарно компонуються, і обидва нормально працюють self-hosted.
Ця стаття — архітектура, яку ми рекомендуємо, і TypeScript, потрібний, щоб зв'язати це докупи.
Що gateway насправді має робити
Корисний gateway має сім робіт. Не кожна фіча — day-one, але всі сім мають бути у roadmap.
- Уніфікований інтерфейс. Один SDK чи HTTP-контракт, який викликає ваш застосунок, незалежно від того, який провайдер за ним.
- Routing провайдерів. Направляти до OpenAI, Anthropic, Google, Mistral або self-hosted vLLM-ендпоінту на основі назви моделі, потреб фічі чи вартості.
- Fallback та retry. Якщо primary провайдер деградував — автоматично переключатись.
- Кешування. Exact-match та семантичне кешування для усунення дублікатів викликів.
- Rate limiting та бюджети. Per-team, per-customer, per-environment.
- Observability. Кожен виклик логується з промптами, відповідями, токенами, latency, cost та user context.
- Guardrails. Очищення входів, фільтрація виходів, PII redaction, детекція prompt injection.
Референсна архітектура
Ось форма, яку ми розгортаємо для середніх команд:
+----------------------+
Client app -----> | LLM Gateway API | -----> OpenAI
| (TypeScript / Bun) | Anthropic
| + Portkey core | Google
+----------+-----------+ Self-hosted vLLM
|
| async traces
v
+--------------+
| Langfuse |
| (self-host) |
+--------------+
|
v
Cost dashboards, evals,
per-customer attribution
Сам gateway — це stateless сервіс; ми запускаємо його як Kubernetes Deployment з horizontal autoscaler. Redis обробляє кеш і rate limits. Langfuse працює як власний стек (web, worker, Postgres, ClickHouse, S3-compatible сховище) або self-hosted, або через Langfuse Cloud.
Core client interface
Почнімо з погляду застосунку. Застосунки ніколи не повинні викликати провайдерські SDK напряму — вони викликають gateway через тонкий клієнт, що примушує контракт.
// packages/llm-client/src/types.ts
export interface LLMRequest {
readonly model: string;
readonly messages: readonly {
readonly role: "system" | "user" | "assistant";
readonly content: string;
}[];
readonly maxTokens?: number;
readonly temperature?: number;
readonly metadata: {
readonly team: string;
readonly customer?: string;
readonly feature: string;
readonly traceId?: string;
};
}
export interface LLMResponse {
readonly text: string;
readonly model: string;
readonly provider: string;
readonly inputTokens: number;
readonly outputTokens: number;
readonly costUsd: number;
readonly latencyMs: number;
readonly cached: boolean;
}
export interface LLMClient {
complete(req: LLMRequest): Promise<LLMResponse>;
}
Кілька свідомих виборів. Metadata обов'язкова, не опціональна — кожен виклик має бути атрибутований до команди і фічі. Cost та provider повертаються явно, щоб викликачі могли їх логувати. І прапорець cache hit експонується, бо він з'являється у evals та billing reconciliation.
Реалізація gateway
Gateway — це Bun-сервіс, що використовує Portkey SDK як core routing engine і Langfuse для tracing.
// apps/llm-gateway/src/gateway.ts
import Portkey from "portkey-ai";
import { Langfuse } from "langfuse";
import type { LLMRequest, LLMResponse } from "@example/llm-client";
export interface GatewayConfig {
readonly portkeyApiKey: string;
readonly langfusePublicKey: string;
readonly langfuseSecretKey: string;
readonly langfuseHost: string;
}
export class LLMGateway {
private readonly portkey: Portkey;
private readonly langfuse: Langfuse;
constructor(cfg: GatewayConfig) {
this.portkey = new Portkey({
apiKey: cfg.portkeyApiKey,
config: "pc-main-router",
});
this.langfuse = new Langfuse({
publicKey: cfg.langfusePublicKey,
secretKey: cfg.langfuseSecretKey,
baseUrl: cfg.langfuseHost,
});
}
async complete(req: LLMRequest): Promise<LLMResponse> {
const trace = this.langfuse.trace({
name: req.metadata.feature,
userId: req.metadata.customer,
metadata: {
team: req.metadata.team,
upstream_trace: req.metadata.traceId,
},
});
const generation = trace.generation({
name: "llm.complete",
model: req.model,
input: req.messages,
modelParameters: {
temperature: req.temperature ?? 0.7,
maxTokens: req.maxTokens ?? 1024,
},
});
const started = Date.now();
try {
const result = await this.portkey.chat.completions.create({
model: req.model,
messages: req.messages as { role: string; content: string }[],
max_tokens: req.maxTokens ?? 1024,
temperature: req.temperature ?? 0.7,
metadata: {
team: req.metadata.team,
customer: req.metadata.customer ?? "unknown",
feature: req.metadata.feature,
},
});
const choice = result.choices[0];
const text = choice?.message?.content ?? "";
const usage = result.usage ?? { prompt_tokens: 0, completion_tokens: 0 };
const latency = Date.now() - started;
const cached = (result as { cached?: boolean }).cached ?? false;
generation.end({
output: text,
usage: {
promptTokens: usage.prompt_tokens,
completionTokens: usage.completion_tokens,
},
});
return {
text,
model: result.model,
provider: (result as { provider?: string }).provider ?? "unknown",
inputTokens: usage.prompt_tokens,
outputTokens: usage.completion_tokens,
costUsd: estimateCost(result.model, usage),
latencyMs: latency,
cached,
};
} catch (err) {
generation.end({ output: null, level: "ERROR", statusMessage: String(err) });
throw err;
} finally {
await this.langfuse.flushAsync();
}
}
}
function estimateCost(
model: string,
usage: { prompt_tokens: number; completion_tokens: number },
): number {
const rates: Record<string, { input: number; output: number }> = {
"gpt-4.1": { input: 2.5e-6, output: 10e-6 },
"claude-sonnet-4-6-20260115": { input: 3e-6, output: 15e-6 },
"gemini-2.5-pro": { input: 1.25e-6, output: 5e-6 },
};
const rate = rates[model] ?? { input: 0, output: 0 };
return usage.prompt_tokens * rate.input + usage.completion_tokens * rate.output;
}
Конфіг Portkey, на який посилається pc-main-router, живе у консолі Portkey (або у self-hosted config-файлі). Саме там декларуються правила routing, fallback і кешування — не в коді.
Routing та fallback як конфіг
Portkey використовує JSON-конфіг для опису стратегій routing. Реалістичний конфіг з primary, fallback, семантичним кешуванням і canary для нової моделі:
{
"strategy": { "mode": "fallback" },
"targets": [
{
"strategy": { "mode": "loadbalance" },
"targets": [
{
"virtual_key": "openai-prod",
"override_params": { "model": "gpt-4.1" },
"weight": 0.9
},
{
"virtual_key": "openai-prod",
"override_params": { "model": "gpt-4.1-mini" },
"weight": 0.1
}
]
},
{
"virtual_key": "anthropic-prod",
"override_params": { "model": "claude-sonnet-4-6-20260115" }
}
],
"cache": {
"mode": "semantic",
"max_age": 3600
},
"retry": {
"attempts": 3,
"on_status_codes": [429, 500, 502, 503, 504]
}
}
Читаючи зверху вниз: 90% запитів йдуть на GPT-4.1, 10% — на mini canary, і якщо OpenAI target повністю провалюється, Portkey робить fallback на Claude. Семантичний кеш тримається годину. Retries обробляють transient помилки.
Зміна routing тепер не вимагає зміни коду, деплою чи навіть рестарту. Це головна причина, чому ви хочете gateway з самого початку.
Семантичне кешування
Exact-match кешування тривіальне — хешуй промпт, використовуй Redis. Семантичне кешування цікавіше: embed промпт, знайдіть схожі промпти у векторному сховищі, поверніть кешовану відповідь, якщо схожість перевищує поріг. Portkey надсилає це з коробки; прапорець — "cache": { "mode": "semantic" } вище.
Коли використовувати:
- Так: FAQ-стилі support-боти, Q&A по документації, відомі шаблонні відповіді.
- Обережно: агенти, що тягнуть свіжі дані — застарілі відповіді нашкодять.
- Ні: усе з персоналізацією чи real-time входами.
Cache hit rates, які ми бачимо у продакшні, сильно варіюються за use case: 30–50% для внутрішніх docs Q&A, 5–15% для customer-facing асистентів, близько до нуля для code generation. Виміряйте свій, перш ніж припускати економію.
Per-Customer бюджети та rate limits
Бюджети не дають одному клієнту спалити місячний рахунок. Gateway примушує їх з Redis перед викликом провайдера.
// apps/llm-gateway/src/budget.ts
import Redis from "ioredis";
export class BudgetEnforcer {
constructor(private readonly redis: Redis) {}
async tryConsume(customer: string, usd: number): Promise<void> {
const key = `budget:${customer}:${this.currentMonth()}`;
const limitKey = `budget-limit:${customer}`;
const [currentRaw, limitRaw] = await this.redis.mget(key, limitKey);
const current = currentRaw ? Number(currentRaw) : 0;
const limit = limitRaw ? Number(limitRaw) : 100;
if (current + usd > limit) {
throw new BudgetExceededError(customer, current, limit);
}
await this.redis.incrbyfloat(key, usd);
await this.redis.expire(key, 60 * 60 * 24 * 40);
}
private currentMonth(): string {
const now = new Date();
return `${now.getUTCFullYear()}-${String(now.getUTCMonth() + 1).padStart(2, "0")}`;
}
}
export class BudgetExceededError extends Error {
constructor(
public readonly customer: string,
public readonly spent: number,
public readonly limit: number,
) {
super(`budget exceeded for ${customer}: $${spent.toFixed(2)} / $${limit.toFixed(2)}`);
}
}
Бюджети нараховуються після виклику реальною вартістю, але pre-flight оцінка уникає удару по провайдеру взагалі, якщо клієнт уже перебрав. На практиці ми робимо обидва.
Observability з Langfuse
Langfuse дає вам traces, generations, scores та evaluations. Він гарно працює, бо ставиться до LLM-виклику як до first-class об'єкта з input, output, model, parameters та cost — а не до звичайного log line.
Речі, які треба підключити в перший день:
- Traces, згруповані за сесією користувача і фічею.
- Generations для кожного виклику моделі.
- Scores, прикріплені до generations — палець вгору/вниз від користувача, результати offline eval, виводи safety-класифікатора.
- Datasets для регресійного тестування — зберігайте відомо-хороші взаємодії і відтворюйте їх при кожному оновленні моделі.
SDK займає три рядки на виклик (ми їх уже показали вище). Цінність, яку ви отримуєте — значна: реальна вартість per customer, реальна latency per feature і можливість подивитися на інцидент минулого вівторка і побачити точний промпт, що його спричинив.
Розгортання
Gateway — це невеликий сервіс і має працювати як такий.
apiVersion: apps/v1
kind: Deployment
metadata:
name: llm-gateway
namespace: ai-platform
spec:
replicas: 3
selector:
matchLabels: { app: llm-gateway }
template:
metadata:
labels: { app: llm-gateway }
spec:
containers:
- name: gateway
image: ghcr.io/example/llm-gateway:1.4.2
ports:
- containerPort: 8080
env:
- name: PORTKEY_API_KEY
valueFrom: { secretKeyRef: { name: portkey, key: api-key } }
- name: LANGFUSE_PUBLIC_KEY
valueFrom: { secretKeyRef: { name: langfuse, key: public } }
- name: LANGFUSE_SECRET_KEY
valueFrom: { secretKeyRef: { name: langfuse, key: secret } }
- name: LANGFUSE_HOST
value: https://langfuse.ai.example.com
- name: REDIS_URL
valueFrom: { secretKeyRef: { name: redis, key: url } }
resources:
requests: { cpu: 200m, memory: 256Mi }
limits: { memory: 512Mi }
readinessProbe:
httpGet: { path: /ready, port: 8080 }
livenessProbe:
httpGet: { path: /live, port: 8080 }
---
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: llm-gateway
namespace: ai-platform
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: llm-gateway
minReplicas: 3
maxReplicas: 30
metrics:
- type: Resource
resource:
name: cpu
target: { type: Utilization, averageUtilization: 60 }
Запускайте мінімум 3 репліки — gateway знаходиться на critical path кожної AI-фічі продукту, і single-pod outage неприйнятний.
Canary-деплої нових моделей
Коли з'являється нова версія моделі (скажімо, OpenAI випускає gpt-4.2), ви хочете викотити її так само, як викотили б нову версію свого сервісу. Portkey load-balance конфіг, який ми показали вище, — це саме це: направити 10% трафіку на нову модель, спостерігати за якістю і latency у Langfuse, а потім розширити.
Визначте критерії успіху явно перед canary. Ми зазвичай дивимось на:
- User satisfaction score (thumbs up/down) — не повинен регресувати.
- Task completion rate (чи користувач отримує те, що просив?) — не повинен регресувати.
- p95 latency — не повинна суттєво регресувати.
- Cost per interaction — не повинна регресувати більш ніж на 20%.
Якщо щось із цього ламається — відкочуйтесь, перемкнувши значення конфіга. Без redeploy.
Що не варто класти в gateway
Feature creep — це ворог. Речі, які належать деінде:
- Prompt templates. Вони живуть з кодом фічі. Gateway не повинен знати, які у вас промпти.
- Agent orchestration. Використовуйте Temporal, LangGraph або подібне. Gateway викликає одну модель за раз.
- Бізнес-логіка. Gateway робить LLM-виклик і повертається. Він не вирішує, чи його робити.
Тримайте gateway нудним і вузьким. Вузькі речі не ламаються.
Наступні кроки
LLM gateway — це перший шматок AI platform інфраструктури, який більшість команд мають побудувати після своєї першої продакшн-фічі. Він окупає себе протягом кварталу через економію вартості і дозволяє вам міняти провайдерів, додавати guardrails і дебажити продакшн-проблеми без зміни коду. Portkey плюс Langfuse проводить вас більшість шляху з двома днями інтеграційної роботи. Якщо хочете допомоги з проєктуванням gateway для вашого стеку або міграцією існуючих прямих викликів, напишіть нам.