블로그 목록
AI

Vercel AI SDK로 추론 모델 다루기 — extractReasoningMiddleware로 <think> 태그 분리하기

추론(reasoning) 모델을 앱에 붙였다가 한 번 당황했다. 질문을 던졌는데 응답 텍스트 맨 앞에 <think> 어쩌고 하는 장문의 혼잣말이 그대로 딸려 나왔다. 모델이 답을 내기 전에 생각하는 과정인데, 그게 최종 답변과 한 덩어리로 스트리밍되니 화면에 사고 과정이 통째로 노출됐다.

DeepSeek R1 같은 모델은 사고 과정을 <think>...</think> 태그로 감싸서 일반 텍스트에 섞어 보낸다. 그냥 두면 text에 사고 과정과 답이 뒤섞여 들어온다. 직접 정규식으로 잘라낼 수도 있지만, 스트리밍 중에 태그가 청크 경계에 걸리면 파싱이 꼬인다.

Vercel AI SDK의 extractReasoningMiddleware가 이걸 대신 해준다. 지정한 태그를 스트림에서 분리해서 reasoning과 text로 나눠준다.

미들웨어 씌우기

wrapLanguageModel로 모델을 감싸고, 어떤 태그를 추론으로 볼지 지정한다.

// lib/reasoning-model.ts
import { wrapLanguageModel, extractReasoningMiddleware } from 'ai';
import { deepseek } from '@ai-sdk/deepseek';

export const reasoningModel = wrapLanguageModel({
  model: deepseek('deepseek-reasoner'),
  middleware: extractReasoningMiddleware({ tagName: 'think' }),
});

tagName에 여는/닫는 태그 안쪽 이름만 넣으면 된다. <think>면 'think'.

사고 과정과 답 따로 받기

이제 결과에서 reasoning과 text가 깔끔하게 갈린다. text에는 <think> 블록이 빠진 최종 답만 남는다.

import { generateText } from 'ai';
import { reasoningModel } from '@/lib/reasoning-model';

const { text, reasoning } = await generateText({
  model: reasoningModel,
  prompt: '사과 3개를 5명이 똑같이 나누면 한 명당 몇 개?',
});

console.log(reasoning); // 모델의 사고 과정
console.log(text);      // 0.6개 — 최종 답만

스트리밍에서 접어두기

스트리밍할 때도 파트가 나뉘어 온다. 사고 과정은 접이식 영역에 흘리고, 답만 본문에 보여주는 UI가 자연스럽다.

const result = streamText({ model: reasoningModel, prompt });

for await (const part of result.fullStream) {
  if (part.type === 'reasoning-delta') {
    process.stdout.write(`[생각] ${part.text}`);
  } else if (part.type === 'text-delta') {
    process.stdout.write(part.text);
  }
}

클라이언트 useChat에서는 메시지의 parts 배열에 reasoning 타입 파트가 따로 들어오니, <details>로 감싸 "생각 과정 보기" 토글을 붙이면 된다.


핵심은 사고 과정을 직접 잘라내려 애쓰지 말고 미들웨어에 맡기는 거다. 태그가 청크 경계에 걸려도 알아서 처리해주고, 호출부는 reasoning과 text만 구분해서 쓰면 된다. 추론 모델을 UI에 붙일 때 가장 먼저 깔아두면 편한 한 겹이다.