챗봇을 만들다 보면 이런 상황이 온다. 답변이 한참 길게 나오는 중인데 사용자가 "아, 이거 아닌데" 하고 멈추고 싶어 한다. 아니면 답변 받는 도중에 다른 페이지로 넘어가 버린다.
문제는 여기서 스트리밍을 안 끊으면 서버 쪽 LLM 호출이 계속 돌아간다는 거다. 사용자는 화면을 떠났는데 토큰은 계속 소비되고 있다. 개인 프로젝트에서 BYOK로 붙였을 때 이게 그대로 비용으로 돌아온다.
클라이언트: stop() 한 줄
useChat은 stop 함수를 그냥 준다. 스트리밍 중일 때 이걸 호출하면 연결을 끊는다.
'use client';
import { useChat } from '@ai-sdk/react';
export function Chat() {
const { messages, sendMessage, status, stop } = useChat();
return (
<div>
{status === 'streaming' && (
<button onClick={stop}>■ 멈추기</button>
)}
</div>
);
}
status가 streaming일 때만 멈춤 버튼을 보여주는 게 자연스럽다. 여기까지만 하면 화면상으로는 답변이 딱 멈춘다.
서버: 여기서 진짜로 끊어야 한다
근데 클라이언트에서 연결을 끊어도, 서버 라우트가 그걸 감지하고 LLM 호출을 취소하지 않으면 반쪽짜리다. 연결이 끊긴 신호(req.signal)를 streamText의 abortSignal로 넘겨줘야 한다.
// app/api/chat/route.ts
import { anthropic } from '@ai-sdk/anthropic';
import { streamText, convertToModelMessages } from 'ai';
export async function POST(req: Request) {
const { messages } = await req.json();
const result = streamText({
model: anthropic('claude-sonnet-5'),
messages: convertToModelMessages(messages),
abortSignal: req.signal,
});
return result.toUIMessageStreamResponse();
}
req.signal은 클라이언트 연결이 끊기면 자동으로 abort 상태가 된다. 이걸 abortSignal에 물려주면 실제 모델 호출까지 취소된다. 이거 안 해두면 화면만 멈추고 뒤에서는 끝까지 다 돌아간다.
중간까지 온 답변은 저장하고 싶다면
멈춰도 그때까지 스트리밍된 텍스트는 남기고 싶을 때가 있다. onAbort 콜백에서 처리하면 된다.
const result = streamText({
model: anthropic('claude-sonnet-5'),
messages: convertToModelMessages(messages),
abortSignal: req.signal,
onAbort: ({ steps }) => {
// 중단 시점까지의 steps로 부분 응답 저장
console.log('중단됨, 지금까지 단계:', steps.length);
},
});
onFinish는 abort될 때 안 불린다. 그래서 중단 케이스는 onAbort에서 따로 잡아줘야 한다는 걸 알아두면 좋다.
정리하면 stop()으로 화면을 멈추고, abortSignal: req.signal로 서버 호출까지 끊고, 부분 저장이 필요하면 onAbort를 쓴다. 클라이언트만 끊고 끝내면 비용이 새니까, 서버 쪽 abortSignal을 빠뜨리지 않는 게 핵심이다.