소개
AI 엔드포인트는 JavaScript 코드만으로 동작하지 않습니다. 학습자가 잘못된 입력을 보낼 수도 있고, 선택한 모델이 요청을 거부할 수도 있습니다. 계정의 할당량이나 속도 제한에 도달하거나, 처리 용량을 일시적으로 사용할 수 없거나, 애플리케이션 코드 자체에서 오류가 발생할 수도 있습니다. 이러한 상황에는 서로 다른 대응이 필요합니다. 모든 상황을 단순히“AI 실패”로 처리하면 애플리케이션을 운영하기 어려워지고 불필요한 재시도를 유발할 수 있습니다.
이 실습에서는 POST /draft-reply를 구축합니다. Cloudflare 에서 호스팅하는 Llama 모델이 짧은 고객 지원 답변 하나를 작성합니다. Worker 는 추론을 시작하기 전에 잘못된 입력을 거부하고, 문서화된 모델 및 제한 관련 오류를 식별하며, 일시적인 장애는 최대 한 번만 재시도합니다. 또한 모델 응답을 검증하고 애플리케이션 결함은 별도로 보고합니다. 제한된 재시도란 추가 시도 횟수의 최대값을 미리 정해 두는 방식입니다. 계정의 무료 할당량이 소진될 때까지 반복할 수 없습니다.
대부분의 실패 경로는 결정론적 픽스처로 검증합니다. 픽스처는 지정한 결과나 오류를 반환하는 제어된 대체 요소입니다. 따라서 실제로 할당량을 의도적으로 소모하거나 장애를 발생시키지 않고도 할당량 초과 및 서비스 중단 동작을 테스트할 수 있습니다. 실제 모델을 사용하는 요청은 짧은 로컬 요청 한 번과 배포 후 요청 한 번뿐입니다.
이 실습은 과정의 여섯 번째 가이드 실습입니다. 이 페이지로 바로 이동했다면 먼저 LabEx 를 Cloudflare 계정에 연결을 완료하세요. VM 터미널 사용 방법, Wrangler 인증 방법, 학습 계정 확인 방법 및 계정 ID 설정 방법을 익힐 수 있습니다.
선택한 @cf/meta/llama-3.3-70b-instruct-fp8-fast 모델은 표준 Workers AI 할당량으로 사용할 수 있습니다. 현재 Workers Free 에는 하루 10,000 Neurons 가 포함됩니다. 무료 할당량이 남아 있는 동안에는 Workers Paid 가 필요하지 않습니다. 화면에 표시되는 실습과 독립 검사는 로컬과 배포 후에 각각 짧고 정상적인 요청을 한 번씩 실행합니다. 로컬 추론도 Cloudflare 에 연결되어 계정 사용량을 소모하므로, 실제 장애가 발생했을 때 반복해서 재시도하지 마세요.
설정 과정에서는 Node.js 22.22.0 과 프로젝트 로컬 Wrangler 4.132.0 을 /home/labex/project/resilient-ai-reply에 설치합니다. 결정론적 픽스처와 독립 검사도 제공합니다. 설정 과정에서는 Wrangler 인증, Worker 소스 생성, 모델 호출, 배포 또는 클라우드 리소스 생성은 수행하지 않습니다.
VM 인증 및 복원력 있는 Worker 구성
이 단계에서는 새 VM 을 인증하고 일회용 Worker 하나를 구성합니다. Cloudflare 브라우저 로그인만으로는 새 LabEx VM 내부의 Wrangler 가 자동으로 인증되지 않습니다.
준비된 프로젝트 디렉터리로 이동하고 고정된 CLI 버전을 확인합니다.
cd /home/labex/project/resilient-ai-reply
npx wrangler --version
디바이스 인증 흐름을 실행합니다.
npx wrangler login --device --browser=false --scopes \
account:read user:read workers_scripts:write workers_kv:write ai:write
브라우저에서 표시된 인증 URL 을 열고, 사용할 학습 계정을 확인한 다음 표시된 접근 권한을 승인합니다. 이 Wrangler 버전에서는 Worker 를 삭제할 때 KV 호환성 범위가 필요합니다. 이 실습에서는 KV 데이터를 생성하거나 변경하지 않습니다.
구조화된 출력으로 인증 상태를 확인합니다.
npx wrangler whoami --json
"loggedIn": true인지 확인하고 계정 이름을 확인합니다. 그런 다음 해당 계정의 실제 ID 를 복사하여 다음 구성에 입력합니다. 고유한 이름을 생성하고 wrangler.jsonc를 만듭니다.
RUN="labex-c07-a06-$(openssl rand -hex 6)"
printf 'Worker name: %s\n' "$RUN"
cat > wrangler.jsonc <<EOF
{
"name": "$RUN",
"main": "src/index.js",
"compatibility_date": "2026-09-16",
"account_id": "PASTE_YOUR_ACCOUNT_ID_HERE",
"workers_dev": true,
"preview_urls": false,
"observability": {
"enabled": true,
"head_sampling_rate": 1
},
"ai": {
"binding": "AI",
"remote": true
}
}
EOF
AI 바인딩을 사용하면 Worker 코드에서 계정에 연결된 env.AI 인터페이스에 접근할 수 있습니다. remote: true를 설정하면 로컬 Wrangler 요청도 실제 Workers AI 서비스로 전송되며 공유 할당량에서 사용량이 차감됩니다.
장애 범주 분리
이 단계에서는 복구 코드를 작성하기 전에 서로 다른 여러 장애 원인을 작은 공개 계약으로 정리합니다.
HTTP 상태 코드는 클라이언트에 어떤 종류의 결과가 발생했는지 알려 줍니다. 원시 공급자 메시지, 계정 정보 또는 스택 트레이스를 외부에 노출해서는 안 됩니다. 이 실습에서는 다음 다섯 가지 경계를 사용합니다.
400 invalid_request: 학습자의 입력이 없거나 허용된 크기를 벗어났으므로 추론을 시작하지 않습니다.502 model_incompatible또는incompatible_model_response: 선택한 모델 또는 반환된 응답 형태가 애플리케이션 계약과 일치하지 않습니다. 같은 요청을 반복해도 호환성 문제는 해결되지 않습니다.503 model_quota_exhausted또는model_rate_limited: 계정 또는 모델 제한이 중지해야 함을 나타냅니다. 즉시 자동 재시도하면 요청을 하나 더 사용하고 부하를 늘리게 됩니다.503 model_temporarily_unavailable: 타임아웃 또는 일시적인 처리 용량 부족이 두 번 발생했습니다. 응답에는Retry-After가 포함되므로 클라이언트는 이후 요청 전에 기다릴 수 있습니다.500 application_failure: 모델 추론은 사용할 수 있는 데이터를 반환했지만 애플리케이션 자체의 형식 지정 단계에서 실패했습니다.
Cloudflare 는 일일 무료 할당량 소진에 내부 코드 3036, 일시적인 처리 용량 부족에 3040, 타임아웃에 3007, Workers Paid 가 필요한 모델에 5035를 사용한다고 문서화하고 있습니다. 애플리케이션은 알려진 신호를 안정적인 공개 오류로 매핑하고, 범주·시도 횟수·추적 ID 만 기록합니다.
TypeScript 선언을 생성하고 AI 바인딩을 확인합니다.
npx wrangler types
grep -nE 'interface Env|AI: Ai' worker-configuration.d.ts
생성된 선언을 통해 Worker 에서 env.AI를 사용할 수 있음을 확인할 수 있습니다. 그러나 이것만으로 모델 호출 성공이 보장되지는 않습니다. 인증, 할당량, 모델 호환성 및 서비스 상태는 런타임 조건입니다.
제한된 복구 구현
이 단계에서는 오류 분류, 한 번의 재시도 제한, 모델 응답과 애플리케이션 경계를 분리하는 로직을 구현합니다.
Worker 진입점을 만듭니다.
cat > src/index.js <<'WORKER'
const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast";
const MAX_MESSAGE = 500;
const RETRY_DELAY_MS = 25;
const RETRY_AFTER_SECONDS = 30;
function json(data, status = 200, headers = {}) {
return Response.json(data, { status, headers });
}
async function readMessage(request) {
if (request.method !== "POST") return { error: json({ error: "method_not_allowed" }, 405) };
let body;
try { body = await request.json(); }
catch { return { error: json({ error: "invalid_request" }, 400) }; }
if (typeof body?.message !== "string") return { error: json({ error: "invalid_request" }, 400) };
const message = body.message.trim();
if (!message || message.length > MAX_MESSAGE) return { error: json({ error: "invalid_request" }, 400) };
return { message };
}
function numeric(value) {
const number = Number(value);
return Number.isFinite(number) ? number : undefined;
}
export function classifyModelError(error) {
const code = numeric(error?.code ?? error?.cause?.code);
const status = numeric(error?.status ?? error?.cause?.status);
if ([5004, 5005, 5007, 5016, 5018, 5035, 3042].includes(code) ||
[400, 403, 404, 405, 413].includes(status)) {
return { kind: "model_incompatible", status: 502, retryable: false };
}
if (code === 3036) return { kind: "model_quota_exhausted", status: 503, retryable: false };
if (code === 3040 || code === 3007 || status >= 500) {
return { kind: "model_temporarily_unavailable", status: 503, retryable: true };
}
if (status === 429) return { kind: "model_rate_limited", status: 503, retryable: false };
return { kind: "model_unavailable", status: 503, retryable: false };
}
export async function runWithBoundedRecovery(run, input, traceId, sleep) {
for (let attempt = 1; attempt <= 2; attempt += 1) {
try {
return { result: await run(input), attempts: attempt };
} catch (error) {
const failure = classifyModelError(error);
if (failure.retryable && attempt === 1) {
console.log(JSON.stringify({
event: "model_retry_scheduled",
kind: failure.kind,
attempt,
traceId
}));
await sleep(RETRY_DELAY_MS);
continue;
}
return { failure, attempts: attempt };
}
}
}
function formatReply(reply) {
return reply.trim();
}
export async function handleDraftReply(request, env, options = {}) {
const parsed = await readMessage(request);
if (parsed.error) return parsed.error;
const traceId = crypto.randomUUID();
const run = options.run ?? (input => env.AI.run(MODEL, input));
const sleep = options.sleep ?? (ms => new Promise(resolve => setTimeout(resolve, ms)));
const outcome = await runWithBoundedRecovery(run, {
messages: [
{ role: "system", content: "Draft one concise support reply under 80 words. Do not invent account actions." },
{ role: "user", content: parsed.message }
],
max_tokens: 120
}, traceId, sleep);
if (outcome.failure) {
console.log(JSON.stringify({
event: "model_request_failed",
kind: outcome.failure.kind,
attempts: outcome.attempts,
retryable: outcome.failure.retryable,
traceId
}));
const headers = outcome.failure.retryable ? { "retry-after": String(RETRY_AFTER_SECONDS) } : {};
return json({ error: outcome.failure.kind, retryable: outcome.failure.retryable },
outcome.failure.status, headers);
}
if (typeof outcome.result?.response !== "string" ||
!outcome.result.response.trim() ||
outcome.result.response.length > 1200) {
console.log(JSON.stringify({
event: "model_response_rejected",
attempts: outcome.attempts,
traceId
}));
return json({ error: "incompatible_model_response", retryable: false }, 502);
}
let reply;
try {
reply = (options.format ?? formatReply)(outcome.result.response);
} catch {
console.log(JSON.stringify({ event: "application_failure", traceId }));
return json({ error: "application_failure", retryable: false }, 500);
}
console.log(JSON.stringify({
event: "reply_generated",
model: MODEL,
attempts: outcome.attempts,
traceId
}));
return json({ model: MODEL, reply, attempts: outcome.attempts, traceId });
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (url.pathname === "/health") return json({ ok: true });
if (url.pathname === "/draft-reply") return handleDraftReply(request, env);
return json({ error: "not_found" }, 404);
}
};
WORKER
재시도 루프는 전체 시도를 두 번까지 허용합니다. 즉, 최초 호출과 알려진 일시적 장애 범주에 한해 추가 호출 한 번입니다. 할당량 초과, 속도 제한 및 호환성 오류가 발생하면 즉시 중지합니다. 또한 모델 호출, 응답 검증 및 애플리케이션 형식 지정을 서로 분리했습니다. 이를 통해 운영자는 공급자 문제와 애플리케이션 결함을 구분할 수 있습니다.
공개 응답에는 원시 예외가 포함되지 않습니다. 로그에서도 고객 지원 메시지와 생성된 답변을 제외하고, 장애 범위를 조사하는 데 필요한 수명 주기 메타데이터만 기록합니다.
할당량을 사용하지 않고 장애 매트릭스 검증
이 단계에서는 실제 모델 요청을 보내기 전에 제어된 픽스처를 사용하여 모든 장애 범주를 실행해 봅니다.
결정론적 테스트 모음을 실행합니다.
node --test test/worker.test.mjs
아홉 가지 테스트는 실제 추론 대신 픽스처를 사용합니다. 잘못된 입력은 모델 호출을 0 회 발생시키고, 할당량 및 속도 제한 오류는 1 회 호출하며, 일시적인 처리 용량 부족은 최대 2 회 호출하는지 확인합니다. 잘못된 형식의 출력은 호환성 오류가 되고, 형식 지정 결함은 애플리케이션 오류가 되는지도 확인합니다.
이제 정확한 Worker 를 번들링합니다.
npx wrangler deploy --dry-run --outdir /tmp/a06-dry-run
dry run 을 실행하면 Wrangler 가 모듈을 번들링할 수 있는지 확인하고 AI 바인딩을 표시해야 합니다. Worker 를 배포하거나 모델을 호출하지는 않습니다.
정상적인 추론 실행 및 증거 확인
이 단계에서는 로컬에서 정상 요청 한 번과 배포 후 정상 요청 한 번을 실행한 다음, 결과를 Cloudflare Dashboard 의 읽기 전용 증거와 연결합니다.
백그라운드에서 로컬 Wrangler 를 시작하고 AI 를 사용하지 않는 health 경로가 응답할 때까지 기다립니다. 제한된 루프를 사용하므로 무한히 기다리지 않습니다.
npx wrangler dev --port 8787 > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
curl --silent --fail http://127.0.0.1:8787/health >/dev/null && break
sleep 1
done
curl --silent --show-error http://127.0.0.1:8787/draft-reply \
-H 'content-type: application/json' \
--data '{"message":"My keyboard stopped working after the latest update."}'
응답에는 비어 있지 않은 reply, 정확한 모델, 추적 ID 가 포함되어야 하며, 일반적인 정상 상황에서는 attempts 값이 1 이어야 합니다. 값이 2 라면 일시적인 장애 한 번을 제한된 재시도 안에서 복구했다는 의미입니다.
로컬 독립 검사를 실행하고 저장된 프로세스를 중지한 다음 배포합니다.
./.labex/verify.py local
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npx wrangler deploy
배포 출력에서 정확한 workers.dev URL 을 복사하여 공개 엔드포인트를 테스트합니다.
WORKER_URL="https://YOUR_WORKER_URL"
curl --silent --show-error "$WORKER_URL/draft-reply" \
-H 'content-type: application/json' \
--data '{"message":"My keyboard stopped working after the latest update."}'
curl --silent --show-error --include "$WORKER_URL/draft-reply" \
-H 'content-type: application/json' \
--data '{"message":""}'
./.labex/verify.py deployed
빈 메시지는 추론 전에 HTTP 400 을 반환해야 합니다. 이는 모델 요청을 추가로 사용하지 않고 입력을 보호한다는 것을 증명합니다.
Workers & Pages를 열고 정확한 Worker 이름을 선택한 다음 Bindings를 확인합니다. 바인딩은 API 키를 저장하지 않고 Worker 코드가 다른 Cloudflare 서비스에 접근하도록 연결해 주는 이름이 지정된 연결입니다. AI라는 이름의 Workers AI 연결이 하나 있는지 확인합니다. 아래 예시의 Worker 이름은 테스트 실행에 사용된 이름이며, 사용자의 이름에는 다른 무작위 접미사가 포함됩니다.

다음으로 Observability를 엽니다. 예시 실행에서는 성공 이벤트 6 개와 오류 0 개가 생성되었습니다. 하나의 요청이 호출 기록과 애플리케이션 로그를 모두 만들 수 있고, 저장된 로그가 응답 후 도착할 수도 있으므로 실제 개수는 다를 수 있습니다.

여기 표시되는 파란색 무료 요금제 안내는 AI 추론 사용량이 아니라 Workers Logs 이벤트 할당량을 설명합니다. reply_generated를 검색하고 결과 하나를 확장합니다. 예시에는 성공한 일치 항목 두 개와 애플리케이션이 의도적으로 제한한 필드가 표시됩니다. 여기에는 한 번의 시도, 추적 ID 및 정확한 모델이 포함됩니다. 전체 이벤트에는 event: "reply_generated"도 포함되지만, 애플리케이션은 고객 지원 메시지, 생성된 답변 또는 원시 공급자 오류를 기록하지 않습니다.

마지막으로 AI > Workers AI를 열고 Neurons 탭을 선택한 상태로 둡니다. Neuron 은 Cloudflare 에서 AI 계산량을 나타내는 단위입니다. 공유 예시 계정에서는 해당 날짜에 428.59/10k Neurons 를 사용했으며, 이 중 427.82는 Llama 모델에, 0.77은 이전 임베딩 실습에 할당되었습니다. 이 합계에는 다른 과정 실습도 포함되며 일정 시간 후에 갱신될 수 있습니다. 따라서 단일 요청의 비용을 의미하지 않습니다.

사용량이 사용 가능한 일일 할당량 안에 있는지만 확인합니다. Dashboard 화면은 구성, 트래픽 및 사용량을 명령줄 결과와 연결하는 데 도움이 되지만, 런타임 응답과 독립 검사가 최종 기준입니다. 차트의 수치를 바꾸기 위해 추론을 반복하지 마세요.
Worker 삭제 및 로그아웃
이 단계에서는 인증이 유지되는 동안 일회용 엔드포인트를 삭제한 다음 VM 에서 해당 인증을 제거합니다.
wrangler.jsonc에 기록된 일회용 Worker 만 삭제합니다.
npx wrangler delete --force
Wrangler 가 아직 인증된 상태에서 인증된 리소스가 없는지 확인합니다.
./.labex/verify.py deleted
이제 이 VM 에 저장된 인증을 제거합니다.
npx wrangler logout
npx wrangler whoami --json
"loggedIn": false인지 확인한 다음 최종 검사를 실행합니다.
./.labex/verify.py logout
Worker 를 삭제하면 클라우드 리소스가 제거되고, 로그아웃하면 이 VM 에서 인증 정보가 제거됩니다. 두 작업은 서로 별개의 정리 작업입니다.
요약
다음 기능을 갖춘 Workers AI 엔드포인트를 구축했습니다.
- 추론 전에 잘못된 입력을 거부합니다.
- 호환성, 할당량, 속도 제한, 일시적 장애 및 애플리케이션 장애를 서로 구분합니다.
- 알려진 일시적 장애를 최대 한 번 재시도합니다.
- 애플리케이션 형식 지정 전에 모델 출력을 검증합니다.
- 원시 공급자 세부 정보를 노출하지 않고 안정적인 공개 오류를 반환합니다.
- 개인정보 노출을 제한한 수명 주기 메타데이터를 기록합니다.
- 할당량을 낭비하지 않고 결정론적 픽스처로 장애 동작을 검증합니다.
- Workers Free 에서 로컬 및 배포 후의 정상 추론을 확인합니다.
- 일회용 Worker 를 삭제하고 VM 에서 로그아웃합니다.
중요한 운영 습관은“모든 AI 오류를 재시도하는 것”이 아닙니다. 오류가 발생한 경계를 식별하고, 실제로 일시적인 조건만 정해진 제한 안에서 재시도하며, 클라이언트가 다음에 취할 수 있는 조치를 알 수 있도록 응답하는 것이 핵심입니다.



