도움말 응답 스트리밍

JavaScriptBeginner
지금 연습하기

소개

AI 모델이 긴 답변을 준비할 때 전체 결과를 기다리면 애플리케이션이 멈춘 것처럼 느껴질 수 있습니다. 스트리밍을 사용하면 준비된 작은 조각을 애플리케이션이 즉시 받을 수 있습니다. 발신자가 아직 입력하는 동안 메시지를 읽는 것과 비슷합니다. 전체 답변을 생성하는 데 필요한 작업량은 대체로 같지만, 유용한 텍스트를 더 일찍 볼 수 있습니다.

이 실습에서는 하나의 HTTP 응답으로 일련의 이벤트를 보내는 텍스트 형식인 **Server-Sent Events(SSE)**를 사용합니다. Workers AI 이벤트는 모두 data:로 시작합니다. 텍스트 이벤트에는 생성된 응답의 일부가 들어 있고, 마지막 data: [DONE] 이벤트는 스트림이 정상적으로 완료되었음을 나타냅니다. 이벤트 사이에 잠시 지연되는 것은 모델이 계속 작업 중이라는 의미일 뿐입니다. 완료 신호가 없으면 애플리케이션은 느린 응답과 영원히 끝나지 않을 연결을 구분할 수 없습니다.

POST /help를 만들고, Cloudflare 에서 실행되는 Llama 응답을 제공된 명령줄 클라이언트로 스트리밍합니다. 그런 다음 두 가지 종료 방식을 확인합니다. 정상 완료와 첫 번째 유용한 조각을 받은 뒤 의도적으로 취소하는 방식입니다. 취소는 클라이언트가 남은 답변을 더 이상 필요로 하지 않아 사용하지 않는 연결을 열어 두지 않고 작업을 종료하는 것을 의미합니다. 또한 결정론적 테스트를 사용해 모델 시작 실패가 제한된 시간 내 오류로 바뀌는지, 중단된 스트림이 멈추지 않고 종료되는지도 검증합니다.

이 실습은 과정의 두 번째 실습입니다. Cloudflare Worker 가 Cloudflare 네트워크에서 실행되는 애플리케이션 코드이며, AI 바인딩이 Workers AI 를 env.AI로 제공한다는 사실을 알고 있다고 가정합니다. 과정을 바로 시작했다면 먼저 Connect LabEx to Your Cloudflare Account를 완료하세요. 이 실습을 통해 VM 터미널 사용, Wrangler 인증, 학습 계정 확인 및 계정 ID 저장 방법을 익힐 수 있습니다.

이 실습에서는 @cf/meta/llama-3.3-70b-instruct-fp8-fast 모델과 작은 합성 질문을 사용합니다. 현재 Workers Free 계정에는 매일 10,000 Neurons 의 공유 할당량이 제공됩니다. 로컬 추론도 Cloudflare 에 연결되며 이 할당량을 사용합니다. 할당량이 소진되었거나 모델에 용량이 부족하면 요청을 반복해서 보내지 말고 중지하세요. 애플리케이션은 멈추는 대신 종료 오류를 보고해야 합니다.

설정 과정에서 Node.js 22.22.0 과 프로젝트 전용 Wrangler 4.132.0 을 /home/labex/project/help-stream에 설치합니다. SSE 클라이언트, 결정론적 픽스처 및 독립적인 검사 스크립트도 제공합니다. 설정 과정에서는 로그인하거나 모델을 호출하거나 Worker 를 배포하거나 클라우드 리소스를 생성하지 않습니다. 임시 Worker 를 삭제하고 로그아웃을 확인할 때까지 이 VM 을 열어 두세요.

VM 인증 및 스트리밍 Worker 구성

이 단계에서는 새 VM 을 인증하고 임시 스트리밍 Worker 를 구성합니다. 이미 Dashboard 에 로그인했더라도 터미널 명령이 학습 계정을 관리할 권한을 자동으로 얻는 것은 아닙니다.

준비된 프로젝트로 이동한 뒤 고정된 Wrangler 버전을 확인합니다.

cd /home/labex/project/help-stream
npx wrangler --version

4.132.0이 표시되어야 합니다. 이제 필요한 권한만 요청합니다. Workers Scripts 쓰기 권한은 임시 Worker 를 관리하고, Workers AI 쓰기 권한은 바인딩이 모델을 호출하도록 합니다. 두 개의 읽기 범위는 선택한 계정을 식별하는 데 사용됩니다. Wrangler 4.132.0 은 Worker 삭제 중 KV 바인딩 종속성도 검사하므로, 이 실습에서 KV 데이터를 만들지 않더라도 좁은 범위의 KV 쓰기 권한이 정리 명령을 완료하도록 해 줍니다.

npx wrangler login --device --browser=false --scopes account:read user:read workers_scripts:write workers_kv:write ai:write

표시된 링크를 열고 현재 장치 코드를 입력한 다음 계정과 권한을 확인하고 학습 계정을 인증합니다. 브라우저를 닫은 뒤에도 Wrangler 가 계속 실행되므로 Background Access 가 표시될 수 있습니다. 터미널로 돌아와 성공 메시지가 나타날 때까지 기다린 다음 구조화된 ID 데이터를 확인합니다.

npx wrangler whoami --json

loggedIn: true인지 확인하고 대상 계정의 nameid를 읽습니다. 고유한 임시 Worker 이름을 생성합니다.

RUN="labex-c07-a02-$(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",
  "compatibility_flags": ["enable_request_signal"],
  "workers_dev": true,
  "preview_urls": false,
  "observability": {
    "enabled": true,
    "head_sampling_rate": 1
  },
  "ai": {
    "binding": "AI",
    "remote": true
  }
}
JSON

AI 바인딩은 env.AI가 됩니다. remote: true는 로컬 개발에서도 실제 클라우드 모델을 사용한다는 뜻입니다. enable_request_signal은 클라이언트가 연결을 끊을 때 request.signal이 이를 알리도록 하며, Worker 는 이 취소 신호를 기록하고 처리할 수 있습니다. Observability 설정은 나중에 확인할 수 있도록 수명 주기 이벤트를 저장합니다. 아직 Worker 를 배포하지 않았고 추론도 실행하지 않았습니다.

AI 바인딩 및 SSE 클라이언트 확인

이 단계에서는 Worker 를 작성하기 전에 AI 바인딩을 제공된 SSE 클라이언트에 연결합니다. 모델은 생산자이고, Worker 는 바이트를 전달하며, client.mjs는 소비자입니다. 역할을 분리하면 어떤 구성 요소가 작업을 종료해야 하는지 명확해집니다.

Worker 의 환경 타입을 생성합니다.

npx wrangler types
grep -A4 'interface __BaseEnv_Env' worker-configuration.d.ts

AI: Ai를 찾습니다. 이는 구성된 바인딩을 핸들러에서 env.AI로 사용할 수 있다는 뜻이며, 소스 코드에 저장된 API 키가 아닙니다.

이제 제공된 클라이언트의 터미널 결과를 확인합니다.

grep -nE 'chunk:|complete chunks=|cancelled after|stream_error:' client.mjs

클라이언트는 응답을 한 번에 한 조각씩 읽습니다. chunk: 줄에는 새로 생성된 텍스트가 표시됩니다. completedata: [DONE]을 받은 뒤에만 나타납니다. 취소 모드에서는 비어 있지 않은 첫 번째 조각을 받은 뒤 리더를 닫습니다. stream_error는 45 초 시간 제한을 포함한 최종 실패를 나타냅니다. 이 시간 제한은 안전을 위한 경계이며 모든 모델 응답이 그만큼 오래 걸린다는 뜻은 아닙니다.

SSE 이벤트는 빈 줄로 구분된 일반 텍스트입니다. 정상적으로 완료되는 스트림은 일반적으로 다음과 같습니다.

data: {"response":"First piece"}

data: {"response":" and another piece."}

data: [DONE]

청크 경계는 전송 방식에 따른 세부 사항입니다. 하나의 이벤트에 단어 하나, 구두점 또는 더 긴 조각이 들어갈 수 있습니다. 애플리케이션 로직은 response 문자열을 합치고 [DONE]을 기다려야 하며, 청크의 개수나 크기가 항상 일정하다고 가정하면 안 됩니다.

모니터링 기능이 있는 스트리밍 엔드포인트 작성

이 단계에서는 스트리밍 엔드포인트와 수명 주기 모니터링을 작성합니다. Worker 는 stream: true로 모델에 스트림을 요청한 뒤 동일한 SSE 프로토콜을 클라이언트에 제공합니다. 전체 답변을 먼저 메모리에 모으지 않습니다. 작은 래퍼가 스트림 수명 주기를 감시합니다. 정상 완료 시에는 정상적으로 닫고, 취소 시에는 업스트림 리더를 취소하며, 스트림 오류가 발생하면 응답을 종료합니다.

Worker 진입점을 생성합니다.

cat > src/index.js <<'JS'
const MODEL = "@cf/meta/llama-3.3-70b-instruct-fp8-fast";
const MAX_QUESTION = 800;

function json(data, status = 200) {
  return Response.json(data, { status });
}

async function readQuestion(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: "question_too_large" }, 413) };
  }

  let body;
  try {
    body = JSON.parse(raw);
  } catch {
    return { error: json({ error: "invalid_json" }, 400) };
  }

  const question = typeof body?.question === "string" ? body.question.trim() : "";
  if (!question) {
    return { error: json({ error: "invalid_question" }, 400) };
  }
  if (question.length > MAX_QUESTION) {
    return { error: json({ error: "question_too_large" }, 413) };
  }
  return { question };
}

function monitor(upstream, details) {
  const reader = upstream.getReader();
  let terminal = false;

  return new ReadableStream({
    async pull(controller) {
      try {
        const { done, value } = await reader.read();
        if (done) {
          terminal = true;
          console.log(JSON.stringify({ event: "help_stream_completed", ...details }));
          controller.close();
          return;
        }
        controller.enqueue(value);
      } catch {
        terminal = true;
        console.error(JSON.stringify({ event: "help_stream_failed", ...details }));
        controller.error(new Error("model stream interrupted"));
      }
    },
    async cancel(reason) {
      if (!terminal) {
        terminal = true;
        console.log(JSON.stringify({ event: "help_stream_cancelled", ...details }));
      }
      await reader.cancel(reason);
    }
  });
}

async function streamHelp(request, env) {
  const parsed = await readQuestion(request);
  if (parsed.error) return parsed.error;

  const requestId = crypto.randomUUID();
  const details = { requestId, model: MODEL };
  request.signal.addEventListener("abort", () => {
    console.log(JSON.stringify({ event: "help_client_disconnected", ...details }));
  }, { once: true });

  try {
    const upstream = await env.AI.run(MODEL, {
      messages: [
        {
          role: "system",
          content: "Answer the support question in at most four short sentences. Give safe, practical steps and do not invent account details."
        },
        { role: "user", content: parsed.question }
      ],
      stream: true,
      max_tokens: 160,
      temperature: 0.2
    });

    if (!(upstream instanceof ReadableStream)) {
      throw new Error("stream unavailable");
    }

    console.log(JSON.stringify({ event: "help_stream_started", ...details }));
    return new Response(monitor(upstream, details), {
      headers: {
        "content-type": "text/event-stream; charset=utf-8",
        "cache-control": "no-store",
        "x-request-id": requestId
      }
    });
  } catch {
    console.error(JSON.stringify({ event: "help_stream_start_failed", ...details }));
    return json({ error: "model_unavailable", requestId }, 502);
  }
}

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 === "/help") {
      return streamHelp(request, env);
    }
    return json({ error: "not_found" }, 404);
  }
};
JS

코드는 요청 ID 와 수명 주기 이벤트를 기록하지만 질문이나 생성된 답변은 기록하지 않습니다. 요청 ID는 한 클라이언트 응답과 하나의 로그 항목을 연결하며, 지원 콘텐츠를 Observability 데이터에 복사하지 않습니다. 오류 응답은 내부 제공업체 정보도 숨깁니다. 운영자는 수명 주기 로그를 확인하고, 클라이언트에는 안정적인 model_unavailable 계약이 전달됩니다.

결정론적 테스트를 실행합니다. 테스트의 가짜 AI 바인딩은 제어된 이벤트를 생성하고, 스트리밍 전에 실패하며, 취소를 지원하고, Neurons 를 사용하지 않고 하나의 스트림을 중단합니다.

node --test test/worker.test.mjs

6 개의 테스트가 모두 통과해야 합니다. 그런 다음 배포하지 않고 실제 Worker 를 번들링합니다.

npx wrangler deploy --dry-run

테스트는 제어된 타이밍에서 애플리케이션 동작을 검증합니다. 드라이 런은 소스와 구성이 함께 번들링되는지 검증합니다. 어느 쪽도 현재 클라우드 모델을 사용할 수 있다는 사실을 증명하지는 않습니다. 다음 단계에서 실제 스트림 하나를 실행합니다.

실제 로컬 스트림 관찰

이 단계에서는 VM 에서 실행되는 Worker 프로세스를 통해 실제 스트림 하나를 관찰합니다. 여기서“로컬”이라는 말은 요청 핸들러를 가리킵니다. 모델 추론은 여전히 선택한 계정에서 실행되며 일일 할당량을 사용합니다.

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

이제 제공된 클라이언트로 작은 합성 질문을 하나 보냅니다.

node client.mjs http://127.0.0.1:8787 \
  "How can I safely retry an invoice upload without creating a duplicate ticket?"

하나 이상의 chunk: 줄 뒤에 다음과 비슷한 최종 줄이 표시되어야 합니다.

complete chunks=18 chars=238

텍스트, 청크 개수 및 문자 수는 달라집니다. 중요한 증거는 비어 있지 않은 증분 콘텐츠가 표시된 뒤 [DONE]이 전송되고, 클라이언트가 이를 complete로 변환하는 것입니다. stream_error가 표시되면 .labex/dev.log를 확인합니다. 할당량, 인증 또는 용량 문제는 추론 실패이므로 무한정 기다릴 이유가 아닙니다.

마지막으로 추론 전에 애플리케이션 검증이 수행되는지 확인합니다.

curl --silent --show-error --write-out '\nHTTP %{http_code}\n' \
  http://127.0.0.1:8787/help \
  --header 'Content-Type: application/json' \
  --data '{"question":""}'

{"error":"invalid_question"}과 HTTP 400이 표시되어야 합니다. 일반 요청이 경계 검사를 통과한 뒤에만 스트림을 사용할 수 있습니다.

스트림 배포, 완료 및 취소

이 단계에서는 Worker 를 배포하고, 스트림 하나를 완료한 뒤 다른 스트림은 첫 번째 유용한 조각을 받은 후 취소합니다. 먼저 저장해 둔 개발 프로세스만 중지하고 종료될 때까지 기다립니다.

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"

먼저 정상 완료를 확인합니다.

node client.mjs "$WORKER_URL" \
  "How can I safely retry an invoice upload without creating a duplicate ticket?"

마지막 complete 줄은 모델이 [DONE]을 보냈다는 뜻입니다. 첫 번째 조각만 받은 것으로는 응답이 완료되었다고 증명할 수 없습니다.

이제 두 번째 스트림을 시작하고, 비어 있지 않은 첫 번째 조각을 받은 직후 의도적으로 중지합니다.

node client.mjs "$WORKER_URL" \
  "Explain four checks to make before retrying a failed file upload." \
  --cancel-after-first

chunk: 줄 하나와 그 뒤의 cancelled after 1 chunk가 표시되어야 합니다. 취소는 모델 오류가 아닙니다. 클라이언트가 더 이상 나머지 응답을 필요로 하지 않아 의도적으로 결정한 결과입니다. 리더를 닫으면 업스트림 스트림으로 취소가 전달되고, 수신 요청의 signal 을 통해 Worker 는 연결 끊김을 기록할 수 있습니다.

Cloudflare Dashboard 를 열고 Workers & Pages → Overview → 해당 labex-c07-a02-... Worker → Observability → Logs로 이동합니다. 최근 요청을 찾습니다. 아래 개요는 임시 수락 실행 한 번의 결과입니다. 14 Success0 Errors는 Worker 가 health 확인, 완료된 스트림 및 의도적인 연결 끊김을 오류 없이 처리했다는 뜻입니다. 실제 합계와 타임스탬프는 다릅니다.

오류 없이 성공한 스트리밍 Worker 호출

help_stream_completed를 검색하고 결과 하나를 펼친 다음 model, requestId, event를 확인합니다. 요청 ID 는 안전한 상관관계 값입니다. 학습자의 질문이나 생성된 답변을 저장하지 않고도 운영자가 수명 주기 기록을 연결할 수 있게 합니다.

모델과 요청 ID 가 포함된 완료된 스트림 수명 주기 기록

의도적으로 취소한 공개 요청의 경우 help_client_disconnected를 검색합니다. enable_request_signal을 사용하면 이 이벤트가 들어오는 클라이언트가 연결을 끊었다는 직접적인 증거가 됩니다. Step 4 의 결정론적 테스트에서는 별도로 downstream cancel()이 모델 픽스처에 도달하고 help_stream_cancelled를 기록하는지 검증합니다. 실제 네트워크 타이밍에 따라 클라우드에서 확인할 수 있는 기록은 request-signal 이벤트일 수 있습니다. 저장된 로그는 응답보다 늦게 도착할 수 있으므로 잠시 기다리고, 필요한 경우 제한된 취소 요청을 최대 한 번만 추가로 보냅니다.

취소된 스트림의 클라이언트 연결 끊김 수명 주기 기록

그런 다음 Workers AI를 열고 오늘의 모델 사용량을 확인합니다. Llama 3.3 모델을 찾아 작은 실습들이 10,000-Neuron Free 할당량 안에 있는지 확인합니다. 아래 예에서는 학습 계정의 모든 작업이 158.03/10k Neurons 를 사용했습니다. 여기에는 해당 계정의 다른 실습도 포함되므로 실제 수치는 다릅니다. 계정이 Free 할당량 안에 있는 동안에는 이 실습에 Workers Paid 플랜이 필요하지 않습니다. 사용량 반영이 지연될 수 있으므로 그래프를 업데이트하려고 추론을 반복하지 마세요.

Llama 3.3 모델의 Workers AI 일일 Neuron 사용량

이 스크린샷의 Worker 이름, 요청 ID, 타임스탬프 및 사용량은 임시 실행에서 가져온 예시입니다. 학습 대상은 정확한 값이 아니라 이벤트 이름과 수명 주기 간의 관계입니다.

Worker 삭제 및 로그아웃

이 단계에서는 임시 Worker 를 삭제한 다음 이 VM 에서 로그아웃합니다. Workers AI 사용량 기록은 계정 수준의 기록이므로 Worker 를 삭제해도 공개 엔드포인트만 제거됩니다. 과거 기록이 삭제되거나 계정 플랜이 변경되지는 않습니다.

wrangler.jsonc에 지정된 정확한 Worker 를 삭제합니다.

npx wrangler delete

Wrangler 에 이 실습의 고유한 labex-c07-a02-... 이름이 표시될 때만 확인합니다. 명령은 Successfully deleted로 끝나야 합니다. Dashboard 에서 Workers & Pages → Overview를 새로 고치고 해당 이름이 없는지 확인합니다.

VM 이 아직 인증된 상태에서 독립적인 관리 검사를 실행합니다.

python3 .labex/verify.py deleted

PASS: deleted가 보고된 뒤에만 이 VM 에 저장된 인증 정보를 삭제합니다.

npx wrangler logout
npx wrangler whoami --json

loggedIn: false인지 확인합니다. 로컬 파일이 사라졌거나 브라우저 탭이 닫혔거나 네트워크 오류가 발생했다는 사실만으로는 클라우드에서 Worker 가 삭제되었거나 로그아웃되었다고 증명할 수 없습니다.

요약

완성된 답변을 버퍼링하지 않고 증분 SSE 출력을 전달하는 Workers AI 엔드포인트를 만들었습니다. 클라이언트에 명시적인 [DONE] 신호가 필요한 이유, 시간 제한이 무기한 대기를 방지하는 방법, 의도적인 취소와 실패의 차이, 스트림 오류를 정상적으로 종료하는 방법을 배웠습니다. 실제 로컬 및 배포된 추론을 검증하고, 수명 주기 이벤트를 Dashboard Observability 에 연결했으며, 임시 Worker 를 삭제하고 새 VM 에서 로그아웃했습니다.