AI 답변을 모든 화면에 스트리밍
챗봇, 코파일럿, AI 에이전트: 쓰는 대로 나타나고, 모든 탭과 기기에 전달되며, 새로고침 후에도 이어지는 답변.
챗봇, 코파일럿, AI 에이전트가 빠르게 느껴지는 건 모델이 쓰는 대로 답변이 화면에 나타날 때입니다. 대부분의 앱은 질문을 보낸 하나의 HTTP 응답으로 스트리밍합니다. 사용자가 페이지를 새로고침하거나, 두 번째 탭을 열거나, 휴대폰으로 옮기거나, 동료가 같은 답변을 보고 싶어 하면 이것만으로는 부족합니다. 긴 답변은 서버리스 함수의 실행 시간 제한을 넘기기도 합니다. 답변을 채널에 발행하면 모델의 작업과 브라우저의 연결을 분리할 수 있습니다. 구독 중인 모든 화면이 같은 텍스트를 보고, 연결이 끊긴 화면은 채널 기록에서 따라잡습니다. 흔히 쓰는 기술 스택에는 묶기, 순서 맞추기, 따라잡기를 맡는 오픈 소스 패키지가 있어서 직접 짜지 않고 몇 줄만 추가하면 됩니다.
사용자가 얻는 것
- 전체 답변을 기다리지 않고 단어 단위로 나타나는 답변.
- 모든 탭과 모든 기기, 공유 채팅의 모든 사람에게 같은 답변.
- 새로고침하거나 연결이 끊겨도 처음부터 다시 하지 않고 빠진 부분만 채우며 이어지는 답변.
- '중지' 버튼, 그리고 탭을 닫아도 계속 써지는 답변. 돌아오면 결과가 준비되어 있습니다.

사용하는 기능
- 비공개 채널: 대화에 참여할 수 있는 사람만 답변을 읽을 수 있습니다. 확인은 여러분의 인증 엔드포인트에서 합니다.
- 채널 기록(
pusher:rewind, Hobby 이상 요금제, 앱마다 설정에서 켬): 다시 연결된 화면이 놓친 이벤트를 받습니다. - Hibira 패키지: Vercel AI SDK, React, Vue, Nuxt, Laravel용. 토큰을 Free 한도 4 KB보다 작은 이벤트로 묶고, 번호를 붙이고, 순서대로 다시 맞춥니다.
- 여러분의 모델과 API 키: 텍스트를 스트리밍하는 모델이면 무엇이든 됩니다. Hibira는 텍스트를 사용자에게 전달할 뿐입니다.
기술 스택에 맞는 패키지 고르기
| 사용 중인 것 | 패키지 | 브라우저 |
|---|---|---|
Vercel AI SDK useChat | @hibira-io/ai-sdk | HibiraChatTransport |
| Anthropic, OpenAI, Gemini SDK와 React 또는 Vue | @hibira-io/react, @hibira-io/vue | useStream() |
| Laravel AI SDK 에이전트 | hibira-io/laravel | useAgentStream() |
| 그 밖의 언어나 프레임워크 | 아무 Pusher 서버 라이브러리 | pusher-js |
Vercel AI SDK: useChat용 트랜스포트
useChat()은 그대로 씁니다. 답변이 HTTP 응답 대신 채널로 올 뿐입니다. 브라우저에서는:
import { useChat } from '@ai-sdk/react';
import { HibiraChatTransport } from '@hibira-io/ai-sdk';
const transport = new HibiraChatTransport({ pusher, api: '/api/chat' });
export function Chat({ id, initialMessages }) {
const { messages, sendMessage, status } = useChat({ id, messages: initialMessages, transport, resume: true });
// render messages as usual
}API 라우트는 바로 응답하고, 모델의 스트림은 백그라운드에서 발행합니다:
import { after } from 'next/server';
import { streamText, convertToModelMessages } from 'ai';
import { publishUIMessageStream } from '@hibira-io/ai-sdk/server';
export async function POST(req: Request) {
const { id, messages, runId } = await req.json();
const result = streamText({ model, messages: await convertToModelMessages(messages) });
after(() =>
publishUIMessageStream(pusher, {
chatId: id,
runId,
stream: result.toUIMessageStream({ originalMessages: messages, onFinish: ({ messages }) => saveChat(id, messages) }),
}),
);
return Response.json({ runId }, { status: 202 });
}트랜스포트는 private-ai-chat-{chatId}를 구독하므로, 인증 엔드포인트에서 채팅 소유자에게만 이 채널을 허용하세요. resume: true를 주면 새로고침한 페이지가 아직 쓰는 중인 답변을 이어받습니다.
React 또는 Vue와 아무 모델 SDK
streamToChannel()은 Anthropic, OpenAI, Gemini SDK가 돌려주는 스트림을 그대로 받습니다:
import { streamToChannel } from '@hibira-io/react/server';
export async function POST(req) {
const { conversationId, question } = await req.json();
const stream = anthropic.messages.stream({ model, max_tokens: 1024, messages: [{ role: 'user', content: question }] });
const id = crypto.randomUUID();
streamToChannel(pusher, `private-conversation-${conversationId}`, stream, { id }).catch(console.error);
return Response.json({ id });
}import { useStream } from '@hibira-io/react';
function Answer({ conversationId }) {
const { text, done, error } = useStream(`private-conversation-${conversationId}`, { rewind: true });
return <p aria-busy={!done}>{text}{error && <em> ({error})</em>}</p>;
}@hibira-io/vue에도 같은 useStream()이 있고, Nuxt에서는 자동으로 가져옵니다. 중지 버튼을 넣으려면 AbortController의 signal을 streamToChannel()에 넘기고 중지 라우트에서 abort하세요.
Laravel AI SDK 에이전트
에이전트를 큐에 넣고 그 이벤트를 사용자의 비공개 채널로 브로드캐스트합니다. 텍스트, 추론, 도구 호출, 승인 요청이 순서대로 도착하고, 요금제의 이벤트 크기보다 큰 것은 나뉘어 옵니다:
use Hibira\Laravel\Ai\AgentChannel;
(new SalesCoach)->broadcastOnQueue($request->input('prompt'), AgentChannel::user($request->user()));const { text, status, toolCalls, approvals } = useAgentStream(`private-hibira-ai.user.${user.id}`, { rewind: true });status는 streaming, awaiting_approval, done, error 중 하나이므로 에이전트가 무엇을 하는지 페이지에 보여 줄 수 있습니다.
에이전트 단계와 승인
에이전트 작업의 각 단계를 보여 주거나, 위험한 도구를 실행하기 전에 사람에게 묻거나, MCP 클라이언트 안의 에이전트가 진행 상황을 알리게 하려면 AI 에이전트: 실시간 진행 단계, 승인, 알림을 보세요. 같은 채널을 쓰므로 스트리밍 중인 답변과 에이전트의 단계를 한 페이지에 함께 보여 줄 수 있습니다.
패키지 없이
아무 Pusher 서버 라이브러리에서든 모델이 쓴 내용을 100 ms마다 보내고 묶음마다 번호를 붙이면, 브라우저가 순서대로 맞추고 빠진 묶음을 알아챌 수 있습니다:
seq, buffer, last = 0, "", time.monotonic()
for delta in model_stream:
buffer += delta
if time.monotonic() - last >= 0.1 or len(buffer) >= 1000:
pusher_client.trigger(f"private-chat-{chat_id}", "ai-delta", {"id": answer_id, "seq": seq, "text": buffer})
seq, buffer, last = seq + 1, "", time.monotonic()
pusher_client.trigger(f"private-chat-{chat_id}", "ai-done", {"id": answer_id, "seq": seq, "text": buffer})완성된 답변은 데이터베이스에 저장하고, 페이지를 열거나 다시 연결될 때 한 번 불러오세요. 큐 워커로 같은 패턴을 쓰는 방법은 오래 걸리는 작업의 진행률을 참고하세요.
팁
- 몇 개의 화면이 받든 한 번의 발행은 메시지 1건입니다. 초당 50토큰이라면 20초짜리 답변은 토큰마다 보내면 약 1,000건, 100 ms 묶음이면 약 200건입니다.
- 각 이벤트는 요금제의 이벤트 크기 한도보다 작게 유지하세요(Free는 4 KB, 요금 참고). 패키지는 3,500바이트 이하로 맞추고 더 큰 것은 나눕니다.
- 채널 기록은 Hobby 이상 요금제에서 쓸 수 있습니다. 꺼져 있어도 패키지는 스트리밍하며, 답변 일부를 놓친 화면은 깨진 내용 대신 '불완전'으로 표시합니다.
- 새로고침하지 않는 탭 하나뿐이라면 일반 HTTP 스트리밍으로 충분합니다. 답변이 새로고침을 견뎌야 하거나, 여러 기기나 여러 사람에게 가야 하거나, 백그라운드 작업에서 나올 때 채널을 쓰세요.
- 긴 답변이나 에이전트 작업이 끝나기 전에 사용자가 앱을 닫았다면 푸시 알림 가이드로 푸시를 보내세요.