소개
사람이 읽는 AI 답변은 표현이 달라도 문제가 되지 않을 수 있습니다. 하지만 애플리케이션 코드는 더 엄격한 형식을 요구합니다. 예를 들어 티켓 라우팅 서비스는 category와 priority 같은 이름이 지정된 필드와 미리 정의된 값이 필요합니다. 구조화된 출력을 사용하면 모델이 자유 형식의 문장 대신 기계가 읽을 수 있는 데이터를 반환하도록 요청할 수 있습니다.
이 실습에서는 JSON Mode와 JSON Schema를 사용합니다. JSON 은 데이터 형식이고, 스키마는 어떤 필드가 필요한지, 어떤 값 형식을 허용하는지, 예상하지 못한 필드를 금지할지 설명하는 계약입니다. 모델에 스키마를 따르도록 요청하면 응답 형태가 개선되지만, 이것이 신뢰 경계가 되는 것은 아닙니다. 모델 출력은 여전히 외부 데이터이므로 누락되거나 잘못된 형식이거나 애플리케이션과 호환되지 않을 수 있습니다.
POST /extract를 구현합니다. Worker 는 Cloudflare 에서 호스팅하는 Llama 모델에 작은 테스트용 지원 티켓 하나를 보내고, 카테고리, 우선순위, 짧은 요약, 후속 조치 여부라는 네 가지 필드를 요청합니다. 그런 다음 Worker 가 응답을 반환하기 전에 Ajv 를 사용해 동일한 스키마를 독립적으로 검사합니다. 결정적 테스트 픽스처는 잘못된 모델 출력을 주입하므로, 잘못된 데이터가 승인된 응답에 들어가지 않고 오류 경로를 따르는지 확인할 수 있습니다.
이 실습은 과정의 세 번째 실습입니다. Cloudflare Worker 가 HTTP 요청을 처리하고 AI 바인딩이 Workers AI 를 env.AI로 제공한다는 내용을 알고 있다고 가정합니다. 과정에 바로 들어왔다면 먼저 LabEx 를 Cloudflare 계정에 연결을 완료하세요. 이 실습을 통해 VM 터미널 사용법, Wrangler 인증, 학습 계정 확인, 계정 ID 저장 방법을 익힐 수 있습니다.
이 실습에서는 JSON Mode 를 지원하는 @cf/meta/llama-3.3-70b-instruct-fp8-fast를 사용하며, 모든 프롬프트와 결과를 작게 유지합니다. Workers Free 계정은 현재 공유 일일 할당량으로 10,000 Neurons 를 제공하므로, 무료 할당량이 남아 있는 동안에는 Workers Paid 가 필요하지 않습니다. 로컬 추론도 Cloudflare 에 연결되며 해당 할당량을 사용합니다. 모델이나 할당량을 사용할 수 없다면 반복해서 요청하지 말고 중지하세요.
설정 과정에서 /home/labex/project/ticket-fields에 Node.js 22.22.0, 프로젝트 로컬 Wrangler 4.132.0, Ajv 8.17.1 을 설치합니다. 결정적 테스트와 독립적인 검사를 제공합니다. 설정 과정에서는 로그인하거나 모델을 호출하거나 Worker 를 배포하거나 클라우드 리소스를 생성하지 않습니다. 삭제용 Worker 를 삭제하고 로그아웃을 확인할 때까지 이 VM 을 열어 두세요.
VM 인증 및 추출 Worker 구성
이 단계에서는 새 VM 을 인증하고 삭제할 임시 Worker 하나를 구성합니다. Dashboard 로그인은 브라우저에 적용되지만, 새 VM 의 Wrangler 가 학습 계정을 관리하려면 별도의 제한된 인증이 필요합니다.
준비된 프로젝트 디렉터리로 이동하고 고정된 Wrangler 버전을 확인합니다.
cd /home/labex/project/ticket-fields
npx wrangler --version
4.132.0이 출력되어야 합니다. 이전 Workers AI 실습에서 사용한 것과 동일한 범위의 권한을 요청합니다. Wrangler 4.132.0 은 Worker 를 삭제할 때 KV 종속 항목을 확인하므로, 이 실습에서 KV 데이터를 만들지 않더라도 workers_kv:write를 포함해야 관련 없는 정리 오류를 방지할 수 있습니다.
npx wrangler login --device --browser=false --scopes account:read user:read workers_scripts:write workers_kv:write ai:write
표시된 링크를 열고 현재 디바이스 코드를 입력합니다. 계정과 권한을 확인한 다음 학습 계정을 인증합니다. 터미널로 돌아와 구조화된 ID 데이터를 확인합니다.
npx wrangler whoami --json
loggedIn: true인지 확인하고 사용할 계정의 name과 id를 읽습니다. 삭제할 Worker 에 사용할 고유한 이름을 생성합니다.
RUN="labex-c07-a03-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"
YOUR_ACCOUNT_ID를 해당 계정의 실제 ID 로 바꿉니다.
cat > wrangler.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "YOUR_ACCOUNT_ID",
"main": "src/index.js",
"compatibility_date": "2026-09-16",
"workers_dev": true,
"preview_urls": false,
"observability": {
"enabled": true,
"head_sampling_rate": 1
},
"ai": {
"binding": "AI",
"remote": true
}
}
JSON
AI 바인딩은 env.AI로 사용할 수 있습니다. remote: true는 로컬 Worker 프로세스가 실제 계정에 연결된 모델을 계속 호출한다는 뜻입니다. Observability 를 활성화하면 배포 후 확인할 작은 생명주기 이벤트가 저장됩니다. 아직 추론이나 배포는 수행되지 않았습니다.
구조화된 출력 계약 확인
이 단계에서는 애플리케이션을 보호하는 두 계층을 확인합니다. JSON Mode 는 모델 요청에 스키마를 함께 보냅니다. Ajv 는 Worker 내부에서 반환된 값을 해당 스키마와 비교합니다. 첫 번째 계층은 생성을 안내하고, 두 번째 계층은 해당 값을 안전하게 수락할지 결정합니다.
Worker 의 환경 타입을 생성합니다.
npx wrangler types
grep -A4 'interface __BaseEnv_Env' worker-configuration.d.ts
AI: Ai를 찾습니다. 이것은 소스 코드에 저장한 모델 API 키가 아니라 플랫폼이 제공하는 바인딩입니다.
레코드는 다음 네 필드로 구성됩니다.
category: billing | account | upload | other
priority: low | medium | high
summary: nonempty text, at most 160 characters
needs_follow_up: true or false
JSON Schema 에서 type은 값의 종류를 지정하고, enum은 값을 미리 정의된 목록으로 제한하며, required는 반드시 존재해야 하는 필드를 지정합니다. additionalProperties: false는 예상하지 못한 필드를 거부합니다. 이 마지막 규칙이 중요합니다. 그렇지 않으면 모델이 추가한 필드가 눈치채지 못한 채 통과할 수 있습니다. 스키마는 구조를 설명할 뿐, 모델의 해석이 객관적으로 정확한지는 판단하지 않습니다. 사람이나 이후의 비즈니스 규칙이 승인된 필드를 다시 검토할 수도 있습니다.
결정적 테스트에 제공된 잘못된 픽스처를 확인합니다.
grep -nE 'security|priority: 1|internal_note|not-an-object' test/worker.test.mjs
이 픽스처는 Neurons 를 사용하지 않습니다. 반복적인 실시간 프롬프트로는 의도적으로 만들기 어려운 사례를 테스트에서 안정적으로 확인할 수 있습니다.
검증된 추출 엔드포인트 구현
이 단계에서는 스키마, 모델 요청, 애플리케이션 측 검증을 구현합니다. Ajv 검사를 통과한 분기만 record를 반환합니다.
Worker 진입점을 생성합니다.
cat > src/index.js <<'JS'
import Ajv from "ajv";
const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast";
const MAX_TICKET = 1200;
export const TICKET_SCHEMA = {
type: "object",
properties: {
category: { type: "string", enum: ["billing", "account", "upload", "other"] },
priority: { type: "string", enum: ["low", "medium", "high"] },
summary: { type: "string", minLength: 1, maxLength: 160 },
needs_follow_up: { type: "boolean" }
},
required: ["category", "priority", "summary", "needs_follow_up"],
additionalProperties: false
};
const ajv = new Ajv({ allErrors: true });
const isTicketRecord = ajv.compile(TICKET_SCHEMA);
function json(data, status = 200) {
return Response.json(data, { status });
}
async function readTicket(request) {
const contentType = request.headers.get("content-type") || "";
if (!contentType.toLowerCase().includes("application/json")) {
return { error: json({ error: "json_required" }, 415) };
}
const raw = await request.text();
if (raw.length > 2048) {
return { error: json({ error: "ticket_too_large" }, 413) };
}
let body;
try {
body = JSON.parse(raw);
} catch {
return { error: json({ error: "invalid_json" }, 400) };
}
const ticket = typeof body?.ticket === "string" ? body.ticket.trim() : "";
if (!ticket) return { error: json({ error: "invalid_ticket" }, 400) };
if (ticket.length > MAX_TICKET) {
return { error: json({ error: "ticket_too_large" }, 413) };
}
return { ticket };
}
async function extractTicket(request, env) {
const parsed = await readTicket(request);
if (parsed.error) return parsed.error;
const requestId = crypto.randomUUID();
const details = { requestId, model: MODEL };
let result;
try {
result = await env.AI.run(MODEL, {
messages: [
{
role: "system",
content: "Extract support-ticket fields. Use only evidence in the ticket. Keep the summary short and do not add fields."
},
{ role: "user", content: parsed.ticket }
],
response_format: {
type: "json_schema",
json_schema: TICKET_SCHEMA
},
max_tokens: 160,
temperature: 0
});
} catch {
console.error(JSON.stringify({ event: "ticket_extraction_failed", ...details }));
return json({ error: "model_unavailable", requestId }, 502);
}
const candidate = result?.response;
if (!isTicketRecord(candidate)) {
console.error(JSON.stringify({ event: "ticket_output_rejected", ...details }));
return json({ error: "invalid_model_output", requestId }, 502);
}
console.log(JSON.stringify({ event: "ticket_output_accepted", ...details }));
return json({ record: candidate, requestId });
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/health") {
return json({ status: "ok" });
}
if (request.method === "POST" && url.pathname === "/extract") {
return extractTicket(request, env);
}
return json({ error: "not_found" }, 404);
}
};
JS
Worker 는 티켓 내용이나 반환된 필드를 로그에 기록하지 않습니다. 요청 ID 를 사용하면 지원 내용 자체를 Observability 데이터에 복사하지 않고도 클라이언트 응답과 승인, 거부 또는 실패한 생명주기 이벤트를 연결할 수 있습니다. Ajv 오류 세부 정보도 클라이언트 응답에 포함하지 않습니다. 이러한 정보는 내부 검증 설계를 노출할 수 있기 때문입니다. 클라이언트에는 안정적인 invalid_model_output 계약만 반환합니다.
결정적 테스트를 실행합니다.
node --test test/worker.test.mjs
5 개의 테스트가 모두 통과해야 합니다. 한 테스트는 가짜 AI 바인딩을 통해 잘못된 후보 7 개를 주입하고, 모든 응답에 record가 포함되지 않아야 하는지 확인합니다. 그런 다음 배포하지 않고 실제 Worker 를 번들링합니다.
npx wrangler deploy --dry-run
픽스처를 사용하면 모델 출력의 변동에 의존하지 않고 거부 동작을 확인할 수 있습니다. 드라이 런을 통해 소스 코드, Ajv 종속 항목, Worker 설정이 함께 번들링되는지 확인할 수 있습니다. 다음 단계에서는 실제 구조화된 추론을 한 번 실행합니다.
실제 구조화된 결과 한 번 실행
이 단계에서는 VM 에서 Worker 를 실행하고 실제 JSON Mode 요청을 한 번 보냅니다. 여기서“로컬”은 요청 처리기를 뜻합니다. AI 바인딩은 계속 선택한 Cloudflare 계정을 사용하며 일일 할당량 일부를 소비합니다.
Wrangler 를 백그라운드에서 시작하고 프로세스 ID 를 저장합니다.
npx wrangler dev --port 8787 > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
AI 를 사용하지 않는 health 경로가 응답할 때까지 기다립니다.
for attempt in $(seq 1 30); do
if curl --silent --fail http://127.0.0.1:8787/health; then
break
fi
sleep 1
done
명확한 테스트용 티켓을 하나 전송합니다.
curl --silent --show-error http://127.0.0.1:8787/extract \
--header 'Content-Type: application/json' \
--data '{"ticket":"Customer cannot upload a PDF and needs help before today’s deadline."}'
record와 requestId가 포함된 JSON 응답이 반환되어야 합니다. 정확한 카테고리, 우선순위, 요약 문구, 후속 조치 여부는 달라질 수 있습니다. 중요한 점은 record가 정확히 네 필드를 포함하고 모든 값이 스키마를 만족하는지입니다.
이제 잘못된 애플리케이션 요청이 모델을 호출하기 전에 거부되는지 확인합니다.
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' \
http://127.0.0.1:8787/extract \
--header 'Content-Type: application/json' \
--data '{"ticket":""}'
{"error":"invalid_ticket"}와 HTTP 400이 반환되어야 합니다. 입력 검증은 모델 호출을 보호하고, 출력 검증은 애플리케이션 레코드를 보호합니다. 두 검증은 서로 다른 경계입니다.
배포하고 승인된 출력 확인
이 단계에서는 동일한 검증된 엔드포인트를 배포하고 Dashboard 에 표시되는 상태와 런타임 결과를 연결합니다. 먼저 저장된 개발 프로세스만 중지합니다.
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
Worker 를 배포합니다.
npx wrangler deploy
Wrangler 가 출력한 정확한 workers.dev URL 을 저장합니다.
WORKER_URL="https://YOUR_WORKER_URL"
제한된 공개 요청을 하나 보냅니다.
curl --silent --show-error "$WORKER_URL/extract" \
--header 'Content-Type: application/json' \
--data '{"ticket":"Customer cannot upload a PDF and needs help before today’s deadline."}'
공개 응답의 record 아래에 스키마의 필드가 정확히 네 개 포함되는지 다시 확인합니다. HTTP 성공 상태만으로는 충분하지 않습니다. 독립적인 검사에서는 반환된 모든 필드와 배포된 AI 바인딩도 검증합니다.
Cloudflare Dashboard 를 열고 Workers & Pages → Overview → labex-c07-a03-... Worker로 이동합니다. 바인딩을 확인한 다음 Observability → Logs를 엽니다. ticket_output_accepted를 검색하고 이벤트를 펼친 뒤 model, requestId, 이벤트 이름을 확인합니다. 로그에는 의도적으로 티켓 내용과 추출된 레코드를 포함하지 않습니다.
아래 바인딩 화면은 한 번의 삭제용 디버그 실행에서 가져온 것입니다. 다이어그램과 표는 모두 AI라는 이름이 Workers AI 에 연결되어 있음을 보여 줍니다. 이는 Worker 의 env.AI에 대응하는 Dashboard 화면입니다. 고유한 Worker 이름은 다르게 표시됩니다.

같은 실행에서 공개 요청과 독립적인 검사를 수행한 뒤 3 Success와 0 Errors가 기록되었습니다. 이 수치는 예시일 뿐이며 동일한 개수를 요구하지 않습니다. 중요한 점은 선택한 Worker 가 화면에 표시된 /extract 요청을 성공적으로 처리했다는 관계입니다.

ticket_output_accepted로 필터링한 후 애플리케이션 이벤트를 펼치면 정확한 Llama 모델, 요청 ID, 승인된 이벤트 이름이 표시됩니다. 테스트용 티켓이나 추출된 레코드는 포함되지 않습니다. 이를 통해 개인정보 경계를 확인할 수 있습니다. 단, 로그 한 줄만으로 스키마 검증 통과를 증명할 수는 없습니다. 런타임 응답과 독립적인 검사가 이를 증명합니다.

그런 다음 Workers AI를 열고 오늘의 모델 사용량을 확인합니다. Llama 3.3 모델을 찾아 제한된 실습이 10,000-Neuron Workers Free 할당량 안에 있는지 확인합니다. Dashboard 업데이트가 지연될 수 있으므로 그래프나 로그를 강제로 갱신하려고 추론을 반복하지 말고 잠시 기다리세요.
예시 계정에는 Llama 모델에 대해 261.63/10k Neurons 가 표시되었습니다. 이 합계에는 동일한 학습 계정에서 이전 과정 제작 실습에 사용한 양도 포함되어 있으므로 이 실습만의 비용은 아니며, 여러분에게 표시되는 값도 다릅니다. 예시 수치와 일치하는 것이 아니라 Free 할당량 안에 남아 있는지가 확인 지점입니다.

Dashboard 값은 이 삭제용 실행에 해당합니다. 학습 목표는 정확한 Worker ID, 해당 AI 바인딩, 개인정보를 제한한 승인 이벤트, Free 할당량 사용량입니다. Dashboard 표시가 지연되더라도 CLI, API, 런타임 검사가 기준이 됩니다.
Worker 삭제 및 로그아웃
이 단계에서는 삭제용 Worker 를 삭제한 다음 이 VM 의 인증을 제거합니다. Workers AI 사용량은 계정 수준의 기록이므로 Worker 를 삭제해도 엔드포인트만 제거될 뿐 사용량 기록이 삭제되거나 계정 요금제가 변경되지는 않습니다.
wrangler.jsonc에 지정된 정확한 Worker 를 삭제합니다.
npx wrangler delete
Wrangler 에 이 실습에서 만든 고유한 labex-c07-a03-... 이름이 표시될 때만 확인합니다. 명령이 Successfully deleted로 끝나야 합니다. Workers & Pages → Overview를 새로 고치고 해당 이름이 사라졌는지 확인합니다.
VM 이 아직 인증된 상태에서 독립적인 관리 검사를 실행합니다.
python3 .labex/verify.py deleted
PASS: deleted가 보고된 후에만 VM 에 저장된 인증 정보를 제거합니다.
npx wrangler logout
npx wrangler whoami --json
loggedIn: false인지 확인합니다. 로컬 파일이 없거나 브라우저 탭을 닫았거나 네트워크 오류가 발생했다는 사실만으로는 클라우드에서 Worker 가 삭제되었거나 로그아웃되었다고 증명할 수 없습니다.
요약
JSON Mode 와 JSON Schema 를 사용해 구조화된 티켓 필드를 요청하는 Workers AI 엔드포인트를 구현했습니다. 요청한 형태가 신뢰할 수 있는 데이터와 같지 않은 이유를 이해하고, Ajv 를 독립적인 애플리케이션 경계로 사용했으며, 잘못된 모델 출력이 승인된 레코드가 되지 않는다는 사실을 잘못된 픽스처로 확인했습니다. Workers Free 에서 로컬 및 배포된 실제 결과를 각각 한 번 실행하고, 승인 이벤트를 Dashboard Observability 와 연결했으며, 삭제용 Worker 를 제거하고 새 VM 에서 로그아웃했습니다.



