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

앱에 AI 기능을 하나 붙이려고 마음먹으면, 정작 모델을 부르는 코드보다 그 주변을 차리는 일이 더 번거롭습니다. 어느 제공사를 쓸지 정하고, SDK를 설치하고, API 키를 환경변수로 빼고, 스트리밍 응답을 직접 파싱하고, 사용량이 얼마나 나왔는지 따로 추적하고… 정작 “프롬프트를 보내고 답을 받는” 본질은 한 줄인데 말이죠.
Void는 이 주변 작업을 void/ai라는 클라이언트로 미리 차려둡니다. Cloudflare의 AI 게이트웨이 위에서 동작하는 타입 안전한 클라이언트인데, Void 라우팅에서 본 라우트 핸들러 안에서 모델을 바로 부를 수 있어요. 이번 글에서는 추론을 한 번 돌려보는 것부터 스트리밍, 외부 API 연동, 사용량 계량까지 차근차근 살펴보겠습니다.
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가 계량하기 때문에, 우리가 키를 들고 다닐 일이 없어요.
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-4.1-mini", {
messages: [{ role: "user", content: prompt }],
});
같은 ai.run() 한 가지로 엣지 모델과 외부 모델을 모두 부르는 거죠. 모델 이름만 바꾸면 되니, “일단 가벼운 라마로 만들어보고 나중에 Gemini로 갈아끼우는” 식의 실험이 쉽습니다.
여기서도 키는 필요 없습니다. 외부 제공사 모델이라도 Void가 Cloudflare가 관리하는 자격증명으로 호출하고 사용량을 뉴런으로 계량하기 때문에, @cf/ 모델과 똑같이 우리가 키를 들고 다닐 일이 없어요. 직접 키를 써야 하는 경우는 뒤에서 다룰 ai.provider()뿐입니다.
게다가 입력과 출력에 타입이 붙습니다. 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 markdown = await ai.toMarkdown(file);
언뜻 사소해 보이지만, 이건 검색 증강 생성(RAG)을 만들 때 첫 단추가 되는 작업입니다. 외부 문서를 LLM이 다루기 좋은 깔끔한 텍스트로 바꿔야 그다음 임베딩이든 요약이든 이어갈 수 있으니까요.
외부 API를 직접 부르기
게이트웨이가 표준화해주는 ai.run()으로 충분하지 않을 때도 있습니다. 특정 제공사의 고유한 엔드포인트나 파라미터를 그대로 써야 하는 경우죠. 이럴 땐 ai.provider()로 제공사의 API를 네이티브하게 호출합니다.
const res = await ai.provider("openai").fetch("/v1/chat/completions", {
method: "POST",
body: JSON.stringify({
model: "gpt-4.1-mini",
messages: [{ role: "user", content: prompt }],
}),
});
fetch와 똑같은 모양이라, 제공사의 공식 문서에 적힌 경로와 본문을 거의 그대로 옮기면 됩니다. OpenAI와 Google AI Studio를 지원하고, 커스텀 제공사도 붙일 수 있어요.
다만 이 경로는 제공사 API 키가 필요합니다. 그리고 그 키는 코드에 적으면 안 되겠죠. Void 환경변수에서 다룬 것처럼 프로젝트 시크릿으로 등록해두면, ai.provider()가 알아서 그 키를 실어 보냅니다. 결국 AI 호출도 시크릿 관리와 한 흐름으로 이어지는 셈이에요.
사용량은 뉴런으로 계량됩니다
AI 호출은 공짜가 아니니, 얼마나 썼는지 가늠하는 기준이 필요합니다. Void는 Cloudflare와 마찬가지로 사용량을 “뉴런(neuron)“이라는 단위로 계량하고, 요금제마다 월 허용량이 다릅니다.
- Free: 월 100,000 뉴런. 한도에 도달하면 요청이 차단됩니다.
- Solo: 월 300,000 뉴런. 초과분은 추가 과금됩니다.
- Pro: 월 500,000 뉴런. 초과분은 추가 과금됩니다.
여기서 요금제의 성격 차이가 드러나요. Free는 한도에 닿으면 더 이상 호출이 안 되니 비용이 절대 새지 않지만, 갑자기 트래픽이 몰리면 기능이 멈춥니다. 반대로 Solo와 Pro는 초과분을 과금해서라도 서비스를 계속 돌리고요. 사이드 프로젝트라면 Free의 “딱 거기까지” 안전장치가 마음 편하고, 실서비스라면 초과 과금을 감수하더라도 멈추지 않는 쪽이 낫겠죠.
로컬에서 돌려보려면
마지막으로 로컬 개발 환경입니다. void/ai는 Cloudflare 게이트웨이를 거치고 사용량을 Void가 계량하기 때문에, 로컬에서 테스트하더라도 Void 프로젝트에 연결돼 있어야 동작해요. 그래서 두 가지를 먼저 해둬야 합니다.
bunx void auth login # Void 계정 로그인
bunx void project link # 현재 디렉토리를 Void 프로젝트에 연결
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