Void AI: 라우트 핸들러에서 LLM을 타입 안전하게 호출하기

앱에 AI 기능을 하나 붙이려고 마음먹으면, 정작 모델을 부르는 코드보다 그 주변을 차리는 일이 더 번거롭습니다. 어느 제공사를 쓸지 정하고, SDK를 설치하고, API 키를 환경변수로 빼고, 스트리밍 응답을 직접 파싱하고, 사용량이 얼마나 나왔는지 따로 추적하고… 정작 “프롬프트를 보내고 답을 받는” 본질은 한 줄인데 말이죠.
Void는 이 주변 작업을 void/ai라는 클라이언트로 미리 차려둡니다. Cloudflare의 AI 게이트웨이 위에서 동작하는 타입 안전한 클라이언트인데, Void 라우팅에서 본 라우트 핸들러 안에서 모델을 바로 부를 수 있어요. 이번 글에서는 추론을 한 번 돌려보는 것부터 스트리밍, 외부 API 연동, 사용량 계량까지 차근차근 살펴보겠습니다.
2026년 9월에 나온 Void 0.20.3 기준으로 고쳐 썼습니다. 0.20부터는 기본 배포 대상이 본인 Cloudflare 계정으로 바뀌면서, AI 사용량도 Void 요금제가 아니라 여러분의 Cloudflare 계정에 잡힙니다.
ai.run으로 추론 한 번
가장 기본은 ai.run()입니다. 모델 이름과 입력 객체를 넘기면 추론 결과를 돌려줘요. 라우트 핸들러 안에서 이렇게 씁니다.
import { defineHandler } from "void";
import { ai } from "void/ai";
export const POST = defineHandler(async (c) => {
const { prompt } = await c.req.json();
const result = await ai.run("@cf/meta/llama-3.1-8b-instruct", {
messages: [{ role: "user", content: prompt }],
});
return result; // 객체는 자동으로 JSON 응답이 됩니다
});
Void 라우팅에서 익힌 defineHandler 패턴 그대로예요. 요청 본문에서 prompt를 꺼내고, ai.run()에 모델과 메시지를 넘기고, 결과를 그대로 반환하면 끝입니다. 객체를 반환하면 Void가 알아서 JSON 응답으로 바꿔주니, 별도의 직렬화 코드도 필요 없고요.
여기서 눈여겨볼 점은 API 키가 어디에도 없다는 겁니다. 보통 OpenAI나 다른 제공사를 쓰면 키를 발급받아 환경변수로 넣어야 하는데, void/ai는 Cloudflare의 AI 바인딩을 쓰기 때문에 우리가 키를 들고 다닐 일이 없어요. 앱이 void/ai를 import하면 빌드할 때 Void가 이를 감지해 Workers AI 바인딩(env.AI)을 알아서 추가해주니, wrangler.jsonc에 바인딩을 적을 필요도 없습니다.
messages 배열은 OpenAI와 같은 형식이라, 익숙한 system, user, assistant 역할을 그대로 씁니다. 특히 맨 앞에 system 메시지를 두면 모델의 말투나 역할을 고정할 수 있어요.
await ai.run("@cf/meta/llama-3.1-8b-instruct", {
messages: [
{ role: "system", content: "너는 간결하게 답하는 한국어 비서야." },
{ role: "user", content: prompt },
],
});
이전 대화를 assistant 메시지로 함께 넘기면 맥락을 이어가는 멀티턴 대화도 만들 수 있고요. 결국 어떤 모델을 부르든 “메시지 배열을 채워 보낸다”는 한 가지 방식으로 수렴합니다.
두 갈래 모델: Workers AI와 게이트웨이
ai.run()에 넘기는 모델 이름은 두 종류로 나뉩니다. 하나는 Cloudflare가 자기 엣지에서 직접 돌리는 Workers AI 모델이에요. 앞 예제의 @cf/meta/llama-3.1-8b-instruct처럼 @cf/로 시작하죠.
다른 하나는 게이트웨이를 거쳐 외부 제공사 모델로 가는 경우입니다. 이때는 제공사/모델 형태로 적어요.
// Google Gemini
await ai.run("google/gemini-2.5-flash", {
messages: [{ role: "user", content: prompt }],
});
// OpenAI GPT
await ai.run("openai/gpt-6-luna", {
messages: [{ role: "user", content: prompt }],
});
같은 ai.run() 한 가지로 엣지 모델과 외부 모델을 모두 부르는 거죠. 모델 이름만 바꾸면 되니, “일단 가벼운 라마로 만들어보고 나중에 Gemini로 갈아끼우는” 식의 실험이 쉽습니다.
여기서도 키는 필요 없습니다. 외부 제공사 모델이라도 Cloudflare가 관리하는 자격증명으로 호출되기 때문에, @cf/ 모델과 똑같이 우리가 키를 들고 다닐 일이 없어요. 직접 키를 써야 하는 경우는 뒤에서 다룰 ai.provider()뿐입니다. 다만 Gemini는 OpenAI식 messages 대신 contents 배열을 받는 식으로, 입력 모양은 모델의 원래 스키마를 따릅니다.
게다가 입력과 출력에 타입이 붙습니다. Void가 Cloudflare의 모델 스키마를 그대로 따라가기 때문에, 모델마다 받는 입력 모양이 다르면 그게 타입 단계에서 드러나요. 오타 난 파라미터나 빠뜨린 필드를 런타임이 아니라 편집기에서 먼저 잡는 셈입니다.
그럼 둘 중 무엇을 골라야 할까요? 엣지에서 도는 Workers AI 모델은 응답이 빠르고 뉴런도 적게 들어서, 분류나 요약, 짧은 응답처럼 가벼운 작업에 어울립니다. 반면 복잡한 추론이나 긴 문맥이 필요하면 Gemini나 GPT 같은 게이트웨이 모델이 낫고요. 같은 ai.run()이라 둘을 섞기도 쉬운데, 예를 들어 들어온 질문을 가벼운 라마로 먼저 분류하고 정말 어려운 것만 큰 모델로 넘기면 비용과 품질을 동시에 챙길 수 있습니다.
스트리밍은 ai.stream으로
챗봇처럼 답이 한 글자씩 흘러나오는 경험을 만들려면 스트리밍이 필요하죠. ai.run() 대신 ai.stream()을 쓰면 됩니다.
import { defineHandler } from "void";
import { ai } from "void/ai";
export const POST = defineHandler(async (c) => {
const { prompt } = await c.req.json();
return ai.stream("@cf/meta/llama-3.1-8b-instruct", {
messages: [{ role: "user", content: prompt }],
});
});
ai.stream()은 Server-Sent Events 형식의 응답을 적절한 헤더와 함께 그대로 돌려줍니다. 우리가 ReadableStream을 직접 만들거나 text/event-stream 헤더를 손으로 붙일 필요가 없어요. 핸들러는 ai.stream()이 만든 응답을 반환만 하면 되고, 클라이언트는 그 SSE 스트림을 받아 토큰이 도착하는 대로 화면에 그리면 됩니다.
모델 찾기와 문서 변환
쓸 수 있는 모델이 뭐가 있는지 코드에서 직접 조회할 수도 있습니다. ai.models()는 사용 가능한 모델 목록을 돌려주고, 작업 종류로 걸러낼 수 있어요.
const models = await ai.models({ task: "Text Generation" });
이미지 분류나 임베딩 등 작업별로 어떤 모델이 있는지 모를 때, 문서를 뒤지지 않고 코드로 확인할 수 있어 편합니다.
또 하나 쏠쏠한 도구가 ai.toMarkdown()입니다. PDF나 워드 같은 문서를 마크다운으로 변환해줘요.
const result = await ai.toMarkdown([{ name: "document.pdf", blob: pdfBytes }]);
언뜻 사소해 보이지만, 이건 검색 증강 생성(RAG)을 만들 때 첫 단추가 되는 작업입니다. 외부 문서를 LLM이 다루기 좋은 깔끔한 텍스트로 바꿔야 그다음 임베딩이든 요약이든 이어갈 수 있으니까요.
외부 API를 직접 부르기
게이트웨이가 표준화해주는 ai.run()으로 충분하지 않을 때도 있습니다. 특정 제공사의 고유한 엔드포인트나 파라미터를 그대로 써야 하는 경우죠. 이럴 땐 ai.provider()로 제공사의 API를 네이티브하게 호출합니다.
const res = await ai.provider("openai").fetch("/chat/completions", {
body: {
model: "gpt-6.1-sol",
messages: [{ role: "user", content: prompt }],
max_tokens: 512,
},
});
const result = await res.json();
fetch와 거의 같은 모양이라, 제공사의 공식 문서에 적힌 경로와 본문을 그대로 옮기면 됩니다. 본문은 객체로 넘기면 Void가 직렬화해줘요. OpenAI와 Google AI Studio는 물론 Anthropic, Groq, Mistral, OpenRouter 등 20개 가까운 제공사를 지원하고, 목록에 없는 제공사도 키 이름과 헤더를 지정해서 붙일 수 있습니다.
다만 이 경로는 제공사 API 키가 필요합니다. 그리고 그 키는 코드에 적으면 안 되겠죠. 제공사 이름에서 키 이름이 정해지는데, openai라면 OPENAI_API_KEY, google-ai-studio라면 GOOGLE_API_KEY예요. Void 환경변수에서 다룬 것처럼 프로덕션에서는 시크릿으로, 로컬에서는 .env에 넣어두면 ai.provider()가 알아서 그 키를 실어 보냅니다.
bunx void secret put OPENAI_API_KEY
본인 Cloudflare 계정으로 직접 배포한다면 한 가지가 더 필요합니다. 제공사 요청은 여러분 계정의 AI Gateway를 거쳐 나가기 때문에, void.json에 그 게이트웨이의 ID를 적어둬야 해요. 이 설정이 없으면 ai.provider().fetch()는 501을 돌려줍니다.
{
"ai": {
"gateway": "my-gateway"
}
}
덕분에 제공사 호출 로그와 비용도 내 계정의 AI Gateway 분석 화면에서 그대로 볼 수 있습니다.
사용량은 누구 계정에 잡힐까
AI 호출은 공짜가 아니니, 얼마나 썼는지 가늠하는 기준이 필요합니다. Workers AI 사용량은 “뉴런(neuron)“이라는 단위로 계량되는데요. 0.20에서는 이 사용량이 어디에 잡히는지가 배포 대상에 따라 갈립니다.
본인 Cloudflare 계정으로 직접 배포하면 사용량은 온전히 그 계정의 몫입니다. Workers AI는 계정의 무료 허용량과 요금제를 따르고, ai.provider()로 부른 외부 제공사 요청은 여러분의 제공사 키로 청구되죠. 예전 버전처럼 Void가 정한 Free, Solo, Pro 요금제로 뉴런 한도를 나누던 방식은 더 이상 쓰지 않습니다.
반면 팀이 운영하는 Void 플랫폼에 배포하면, 요청이 플랫폼의 프록시를 거치면서 플랫폼 계정에 사용량이 쌓입니다. 관리자는 사용자별 할당량 프로필을 정할 수 있는데, 새로 설치한 플랫폼의 기본값은 한도가 없는 custom 프로필이에요. 사내 플랫폼을 운영한다면 AI 비용이 한 계정으로 몰린다는 점을 염두에 두고 할당량부터 챙기는 게 좋겠습니다.
로컬에서 돌려보려면
마지막으로 로컬 개발 환경입니다. D1이나 KV와 달리 Workers AI에는 로컬 시뮬레이터가 없습니다. 그래서 void/ai를 import한 앱은 개발 서버에서도 실제 Cloudflare의 AI 바인딩을 원격으로 부르고, 그러려면 배포 대상이 먼저 연결돼 있어야 해요.
bunx void connect --platform cloudflare # Cloudflare 계정 연결
bun run dev
void init에서 이미 Cloudflare를 골랐다면 이 단계는 끝나 있습니다. 개발 서버는 CLI와 같은 키체인 기반 Cloudflare 로그인을 쓰고, CI 같은 자동화 환경에서는 CLOUDFLARE_API_TOKEN을 씁니다. 로컬에서 부른 호출도 계정의 허용량을 소모한다는 점은 기억해두세요. 팀 플랫폼을 쓴다면 void connect <플랫폼 URL>로 연결하고 void project link로 프로젝트를 이어주면 됩니다.
Void 마이그레이션에서도 봤듯이 Void는 대시보드 없이 CLI로 모든 걸 다루는데, AI도 예외가 아니에요. 한 번 연결해두면 로컬 개발 서버에서 부른 ai.run()도 프로덕션과 같은 모델로 동작합니다.
마치며
Void의 AI는 “모델을 부르는 일”을 라우트 핸들러 안의 한 줄로 줄여줍니다. ai.run()으로 추론하고, ai.stream()으로 토큰을 흘려보내고, ai.provider()로 제공사 API를 직접 두드리는 것까지, 키 발급도 SDK 설치도 사용량 추적 코드도 없이 Void 라우팅에서 익힌 핸들러 패턴 그대로 AI를 얹는 거죠. 엣지 모델과 외부 모델을 같은 인터페이스로 다루니, 모델을 갈아끼우며 실험하기도 좋고요.
여기서 한 걸음 더 나아가 문서를 임베딩하고 검색해 답을 만드는 RAG를 구축하려면, ai.toMarkdown()으로 전처리한 텍스트를 Cloudflare Vectorize 같은 벡터 데이터베이스에 넣는 흐름으로 이어집니다. AI까지 더해지면서 화면, 데이터, 인증, 그리고 지능까지 Void 하나로 엮이는 셈이에요.
더 자세한 내용은 Void AI 가이드를 참고하세요.
This work is licensed under CC BY 4.0