AI ajanlarına dost API tasarımı
Bir insan geliştirici hatalı bir API yanıtı aldığında tarayıcıda DevTools'u açar, payload'ı inceler, belgeleri okur ve sorunun ne olduğunu tahmin eder. Bir LLM ajanının böyle bir lüksü yok. Ajan, dönen HTTP durum koduna ve yanıt gövdesindeki yapısal bilgiye bakarak kararını verir. Eğer hata yanıtı belirsizse, ajan ya gereksiz yere yeniden dener ya da kullanıcıya anlamsız bir mesaj iletir. Yani bir API'nin ajan tarafından doğru kullanılabilmesi, API'nin insan tarafından kolay kullanılmasından farklı bir tasarım baskısı yaratır.
Bu yazıda, LLM ajanlarının API'leri doğru tüketebilmesi için dikkat edilmesi gereken üç eksen var: OpenAPI şemasının ajan tarafından okunabilirliği, hata yanıtlarının deterministik yapısı ve idempotency garantileri.
OpenAPI şeması ajanın "belgesi"dir
Bir ajan, API'yi çağırmadan önce OpenAPI (veya benzeri) bir şemayı okuyarak hangi endpoint'in ne beklediğini öğrenir. Bu, MCP sunucuları için tool tanımlarının nasıl çalıştığına benzer: araç tanımı ne kadar net ise, LLM o aracı o kadar doğru kullanır.
Pratik olarak şu iki şey fark yaratır:
Açıklama alanlarını makine için yazın
OpenAPI'deki description alanları genellikle insan okuyucu için yazılır. Ajan tüketicisi düşünüldüğünde, her alanın ne zaman zorunlu olduğu, hangi formatta beklendiği ve sınır değerlerinin ne olduğu açıklama metninde yer almalı.
# openapi.yaml
paths:
/orders:
post:
operationId: createOrder
summary: Yeni sipariş oluşturur
description: |
Tek bir sipariş oluşturur. Aynı idempotency_key ile tekrar çağrılırsa
yeni kayıt oluşturmaz, mevcut siparişi döner.
quantity 1-100 arasında tam sayı olmalıdır.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [product_id, quantity, idempotency_key]
properties:
product_id:
type: string
format: uuid
description: Ürün UUID'si. /products endpoint'inden alınır.
quantity:
type: integer
minimum: 1
maximum: 100
idempotency_key:
type: string
format: uuid
description: |
İstemci tarafından üretilen UUID v4.
Aynı key ile yapılan tekrar istekler idempotent davranır.Burada operationId değeri de önemli. Birçok ajan framework'ü (OpenAI function calling, Claude Agent SDK, LangChain) operationId'yi fonksiyon adı olarak kullanır. post_api_v1_orders gibi otomatik üretilmiş isimler yerine createOrder gibi anlamlı isimler, LLM'in doğru aracı seçme olasılığını artırır.
Enum ve const değerlerini şemada tanımlayın
Bir ajanın serbest metin yazmak yerine sabit değerler arasından seçim yapması hata oranını düşürür. status alanı string yerine enum olmalı:
status:
type: string
enum: [pending, confirmed, shipped, cancelled]
description: Sipariş durumu. Yeni siparişler her zaman 'pending' ile başlar.FastAPI ile bu doğal olarak Python enum'larına eşlenir:
from enum import Enum
from pydantic import BaseModel
class OrderStatus(str, Enum):
pending = "pending"
confirmed = "confirmed"
shipped = "shipped"
cancelled = "cancelled"
class OrderResponse(BaseModel):
id: str
status: OrderStatus
quantity: intFastAPI bu modeli otomatik olarak OpenAPI şemasına yansıtır, yani enum değerleri şemada görünür.
Deterministik hata yanıtları
Ajan tüketicisinin en çok zorlandığı alan hata durumları. İnsan bir 400 Bad Request alıp gövdedeki İngilizce mesajdan sorunu anlayabilir. Ama LLM'in tutarlı karar verebilmesi için hata yanıtlarının yapısal ve öngörülebilir olması gerekir.
Makine tarafından okunabilir hata kodu
HTTP durum kodu tek başına yeterli değil. 422 Unprocessable Entity aldığınızda bunun "stok yetersiz" mi yoksa "geçersiz miktar" mı olduğunu ayırt edemezsiniz. RFC 7807 (Problem Details for HTTP APIs) bu sorunu çözmek için tasarlandı, ama ajanlar için biraz daha katı bir yaklaşım gerekiyor.
Her hata yanıtında sabit bir error_code alanı kullanılmalı:
{
"error_code": "INSUFFICIENT_STOCK",
"message": "Ürün için yeterli stok bulunmuyor. Mevcut: 3, istenen: 10.",
"field": "quantity",
"retry": false
}error_code değeri enum benzeri sabit bir string. retry alanı ise ajana "bu isteği tekrar denemek mantıklı mı" bilgisini verir. Bu iki alan, bir ajanın hata sonrası davranışını programatik olarak belirlemesine yeter.
FastAPI'de bunu şöyle yapılandırabiliriz:
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
app = FastAPI()
class APIError(BaseModel):
error_code: str
message: str
field: str | None = None
retry: bool = False
@app.exception_handler(HTTPException)
async def structured_error_handler(request, exc):
detail = exc.detail if isinstance(exc.detail, dict) else {"message": str(exc.detail)}
error = APIError(
error_code=detail.get("error_code", "UNKNOWN_ERROR"),
message=detail.get("message", "Bilinmeyen hata"),
field=detail.get("field"),
retry=detail.get("retry", False),
)
return JSONResponse(status_code=exc.status_code, content=error.model_dump())
@app.post("/orders")
async def create_order(product_id: str, quantity: int):
available = 3 # veritabanından geldiğini varsayın
if quantity > available:
raise HTTPException(
status_code=422,
detail={
"error_code": "INSUFFICIENT_STOCK",
"message": f"Mevcut: {available}, istenen: {quantity}",
"field": "quantity",
"retry": False,
},
)
# ... sipariş oluşturTekrar denenebilirlik sinyalleri
Hata yanıtındaki retry: true/false alanı basit ama etkili. Bunu biraz daha detaylandırmak istiyorsanız, Retry-After header'ı rate-limit durumlarında ajanın ne kadar beklemesi gerektiğini saniye cinsinden belirtir. HTTP 429 yanıtında bu header olmadan dönen bir API, ajanın rastgele aralıklarla tekrar denemesine yol açar.
from fastapi.responses import JSONResponse
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request, exc):
return JSONResponse(
status_code=429,
headers={"Retry-After": "30"},
content={
"error_code": "RATE_LIMIT_EXCEEDED",
"message": "Dakikada en fazla 60 istek gönderilebilir.",
"retry": True,
},
)Buradaki kural basit: 4xx hatalarından yalnızca 429 tekrar denenebilir. 400, 422, 403 gibi hatalar genellikle istemci hatasıdır, tekrar denemek sonucu değiştirmez. 5xx hatalarında ise 503 (Service Unavailable) tekrar denenebilir, 500 ise genellikle bir bug'dır.
Idempotency: ajanlar tekrar dener
Ağ hatası, timeout, LLM'in "yanıt alamadım, tekrar dene" kararı. Bunların hepsi aynı isteğin birden fazla gönderilmesine neden olur. Eğer API idempotent değilse, bir sipariş iki kez oluşur ya da bir ödeme iki kez alınır.
Idempotency key yaklaşımı Stripe API'sinin popülerleştirdiği bir kalıp. İstemci her mutasyon isteğinde benzersiz bir key gönderir. Sunucu bu key'i ilk gördüğünde işlemi yapar ve sonucu kaydeder. Aynı key ile tekrar istek geldiğinde işlemi tekrarlamaz, kayıtlı sonucu döner.
Uygulama
import hashlib
import json
from fastapi import FastAPI, Header, HTTPException
from fastapi.responses import JSONResponse
app = FastAPI()
# Gerçek uygulamada Redis veya veritabanı kullanılır
idempotency_store: dict[str, dict] = {}
@app.post("/orders")
async def create_order(
product_id: str,
quantity: int,
idempotency_key: str = Header(alias="Idempotency-Key"),
):
# Key daha önce kullanıldı mı?
if idempotency_key in idempotency_store:
stored = idempotency_store[idempotency_key]
return JSONResponse(
status_code=stored["status_code"],
content=stored["body"],
headers={"Idempotency-Replayed": "true"},
)
# İşlemi gerçekleştir
order = {"id": "ord_abc123", "product_id": product_id, "quantity": quantity}
response_body = order
status_code = 201
# Sonucu kaydet
idempotency_store[idempotency_key] = {
"status_code": status_code,
"body": response_body,
}
return JSONResponse(status_code=status_code, content=response_body)Burada Idempotency-Replayed: true header'ı, ajanın (veya insanın) yanıtın cache'den mi geldiğini anlamasını sağlar. Bu header Stripe'ın da kullandığı bir konvansiyon.
Birkaç dikkat noktası var. Idempotency key'leri sonsuza kadar saklamak gereksiz. 24-48 saat gibi bir TTL yeterli. Key'in scope'u da düşünülmeli: aynı key farklı endpoint'lerde farklı anlamlara mı gelmeli? Genelde key, endpoint ve HTTP metodu kombinasyonuna bağlanır.
OpenAPI şemasında idempotency
Idempotency key'ini şemada tanımlamak, ajanın bu header'ı otomatik olarak göndermesini sağlar:
parameters:
- name: Idempotency-Key
in: header
required: true
schema:
type: string
format: uuid
description: |
İstemci tarafından üretilen UUID v4. Aynı key ile tekrar
gönderilen istekler yeni kayıt oluşturmaz.Yanıt tutarlılığı: zarf yapısı
Ajanların yanıtları parse etmesi için tutarlı bir zarf yapısı gerekir. Her endpoint farklı bir yapıda yanıt dönerse, LLM'in her seferinde farklı bir parse stratejisi uygulaması gerekir. Bu hem token maliyetini artırır hem de hata olasılığını yükseltir.
Basit bir zarf:
{
"data": { "id": "ord_abc123", "status": "pending" },
"meta": { "request_id": "req_xyz", "timestamp": "2025-07-27T10:00:00Z" }
}Hata durumunda:
{
"error": {
"error_code": "INSUFFICIENT_STOCK",
"message": "Mevcut: 3, istenen: 10",
"retry": false
},
"meta": { "request_id": "req_xyz", "timestamp": "2025-07-27T10:00:01Z" }
}Burada kural şu: başarılı yanıtta data alanı var, error yok. Hatalı yanıtta error var, data yok. meta her zaman var. Bu kadar basit bir kural bile ajanın yanıtı doğru kategorize etmesine yeter.
Versiyon ve deprecation sinyalleri
Bir ajanın tool tanımları genellikle deploy anında sabitlenir. API'nin bir endpoint'i kaldırması veya davranışını değiştirmesi, ajanın sessizce bozulmasına neden olur. Sunset ve Deprecation header'ları (IETF draft-ietf-httpapi-deprecation-header) bu riski azaltır:
Deprecation: true
Sunset: Sat, 01 Nov 2025 00:00:00 GMT
Link: </docs/migration-v3>; rel="successor-version"Bir ajan bu header'ları görüp otomatik olarak yeni versiyona geçebilir mi? Bugün çoğu ajan framework'ü bu header'ları işlemiyor. Ama loglamak ve uyarı üretmek için altyapıyı hazırlamak, API tarafında maliyetsiz bir yatırım. Claude Code'un skill sistemi gibi yapılar, ajanların kullandığı araçları güncelleyebildiği bir katman sunuyor; deprecation sinyali bu katmanı tetikleyebilir.
Hono ile minimal bir örnek
Hono.js, OpenAPI şeması üretmek için @hono/zod-openapi paketini sağlıyor. Idempotency ve yapısal hata yanıtlarını bir arada gösteren kısa bir örnek:
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
const ErrorSchema = z.object({
error_code: z.string(),
message: z.string(),
retry: z.boolean(),
});
const OrderSchema = z.object({
id: z.string(),
product_id: z.string().uuid(),
quantity: z.number().int().min(1).max(100),
});
const route = createRoute({
method: "post",
path: "/orders",
request: {
headers: z.object({
"idempotency-key": z.string().uuid(),
}),
body: {
content: {
"application/json": {
schema: OrderSchema.omit({ id: true }),
},
},
},
},
responses: {
201: {
content: { "application/json": { schema: OrderSchema } },
description: "Sipariş oluşturuldu",
},
422: {
content: { "application/json": { schema: ErrorSchema } },
description: "Doğrulama hatası",
},
},
});
const app = new OpenAPIHono();
const store = new Map<string, object>();
app.openapi(route, (c) => {
const key = c.req.header("idempotency-key")!;
if (store.has(key)) {
c.header("Idempotency-Replayed", "true");
return c.json(store.get(key) as z.infer<typeof OrderSchema>, 201);
}
const body = c.req.valid("json");
const order = { id: crypto.randomUUID(), ...body };
store.set(key, order);
return c.json(order, 201);
});
app.doc("/openapi.json", { openapi: "3.1.0", info: { title: "Orders API", version: "1" } });
export default app;Bu örnek, /openapi.json endpoint'inden ajan tarafından okunabilir bir şema üretir, idempotency key kontrolü yapar ve hata yanıtlarını sabit bir yapıda döner. Hono + Cloudflare Workers + D1 ile bunu edge'e deploy etmek de birkaç satır eklemekle mümkün.
API tasarımında "ajanlar için" düşünmek, aslında iyi API tasarımının zaten gerektirdiği şeyleri daha katı uygulamak demek. Tutarlı hata yapısı, idempotency, açık şema tanımları. Bunların hiçbiri yeni fikirler değil. Fark şu: insan geliştiriciler belirsizliğe tahammül edebilir, LLM ajanları edemez. API'yi bir ajanın doğru kullanabildiği noktaya getirdiğinizde, insan geliştiriciler için de daha iyi bir API elde etmiş olursunuz.