LLM이 응답을 스트리밍하는 방법

게시일: 2025년 1월 21일

스트리밍된 LLM 응답은 증분식으로 지속적으로 방출되는 데이터로 구성됩니다. 스트리밍 데이터는 서버와 클라이언트에서 다르게 표시됩니다.

서버에서

스트리밍된 대답이 어떤 모습인지 알아보기 위해 명령줄 도구 curl를 사용하여 Gemini에게 긴 농담을 해 달라고 요청했습니다. Gemini API에 대한 다음 호출을 고려해 보세요. 사용해 보려면 URL의 {GOOGLE_API_KEY}를 Gemini API 키로 바꾸세요.

$ curl "https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:streamGenerateContent?alt=sse&key={GOOGLE_API_KEY}" \
      -H 'Content-Type: application/json' \
      --no-buffer \
      -d '{ "contents":[{"parts":[{"text": "Tell me a long T-rex joke, please."}]}]}'

이 요청은 이벤트 스트림 형식으로 다음과 같은 (잘린) 출력을 로깅합니다. 각 줄은 data:로 시작하고 그 뒤에 메시지 페이로드가 옵니다. 구체적인 형식은 실제로 중요하지 않으며 텍스트 청크가 중요합니다.

//
data: {"candidates":[{"content": {"parts": [{"text": "A T-Rex"}],"role": "model"},
  "finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}],
  "usageMetadata": {"promptTokenCount": 11,"candidatesTokenCount": 4,"totalTokenCount": 15}}

data: {"candidates": [{"content": {"parts": [{ "text": " walks into a bar and orders a drink. As he sits there, he notices a" }], "role": "model"},
  "finishReason": "STOP","index": 0,"safetyRatings": [{"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HATE_SPEECH","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_HARASSMENT","probability": "NEGLIGIBLE"},{"category": "HARM_CATEGORY_DANGEROUS_CONTENT","probability": "NEGLIGIBLE"}]}],
  "usageMetadata": {"promptTokenCount": 11,"candidatesTokenCount": 21,"totalTokenCount": 32}}
명령어를 실행하면 결과 청크가 스트리밍됩니다.

첫 번째 페이로드는 JSON입니다. 강조 표시된 candidates[0].content.parts[0].text를 자세히 살펴보세요.

{
  "candidates": [
    {
      "content": {
        "parts": [
          {
            "text": "A T-Rex"
          }
        ],
        "role": "model"
      },
      "finishReason": "STOP",
      "index": 0,
      "safetyRatings": [
        {
          "category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
          "probability": "NEGLIGIBLE"
        },
        {
          "category": "HARM_CATEGORY_HATE_SPEECH",
          "probability": "NEGLIGIBLE"
        },
        {
          "category": "HARM_CATEGORY_HARASSMENT",
          "probability": "NEGLIGIBLE"
        },
        {
          "category": "HARM_CATEGORY_DANGEROUS_CONTENT",
          "probability": "NEGLIGIBLE"
        }
      ]
    }
  ],
  "usageMetadata": {
    "promptTokenCount": 11,
    "candidatesTokenCount": 4,
    "totalTokenCount": 15
  }
}

첫 번째 text 항목은 Gemini의 대답의 시작 부분입니다. text 항목을 더 많이 추출하면 응답이 줄바꿈으로 구분됩니다.

다음 스니펫은 여러 text 항목을 보여주며, 이는 모델의 최종 응답을 보여줍니다.

"A T-Rex"

" was walking through the prehistoric jungle when he came across a group of Triceratops. "

"\n\n\"Hey, Triceratops!\" the T-Rex roared. \"What are"

" you guys doing?\"\n\nThe Triceratops, a bit nervous, mumbled,
\"Just... just hanging out, you know? Relaxing.\"\n\n\"Well, you"

" guys look pretty relaxed,\" the T-Rex said, eyeing them with a sly grin.
\"Maybe you could give me a hand with something.\"\n\n\"A hand?\""

...

하지만 T-rex 농담 대신 모델에 약간 더 복잡한 것을 요청하면 어떻게 될까요? 예를 들어 Gemini에게 숫자가 짝수인지 홀수인지 확인하는 JavaScript 함수를 만들어 달라고 요청합니다. text: 청크가 약간 다르게 보입니다.

이제 출력에 JavaScript 코드 블록으로 시작하는 마크다운 형식이 포함됩니다. 다음 샘플에는 이전과 동일한 전처리 단계가 포함되어 있습니다.

"```javascript\nfunction"

" isEven(number) {\n  // Check if the number is an integer.\n"

"  if (Number.isInteger(number)) {\n  // Use the modulo operator"

" (%) to check if the remainder after dividing by 2 is 0.\n  return number % 2 === 0; \n  } else {\n  "
"// Return false if the number is not an integer.\n    return false;\n }\n}\n\n// Example usage:\nconsole.log(isEven("

"4)); // Output: true\nconsole.log(isEven(7)); // Output: false\nconsole.log(isEven(3.5)); // Output: false\n```\n\n**Explanation:**\n\n1. **`isEven("

"number)` function:**\n   - Takes a single argument `number` representing the number to be checked.\n   - Checks if the `number` is an integer using `Number.isInteger()`.\n   - If it's an"

...

더욱 어려운 점은 마크업된 항목 중 일부가 한 청크에서 시작하여 다른 청크에서 끝난다는 것입니다. 일부 마크업은 중첩되어 있습니다. 다음 예에서 강조 표시된 함수는 **isEven(number) function:**의 두 줄로 분할됩니다. 결합된 출력은 **isEven("number) function:**입니다. 즉, 서식이 지정된 마크다운을 출력하려면 마크다운 파서로 각 청크를 개별적으로 처리할 수 없습니다.

클라이언트에서

MediaPipe LLM과 같은 프레임워크를 사용하여 클라이언트에서 Gemma와 같은 모델을 실행하는 경우 스트리밍 데이터는 콜백 함수를 통해 제공됩니다.

예를 들면 다음과 같습니다.

llmInference.generateResponse(
  inputPrompt,
  (chunk, done) => {
     console.log(chunk);
});

Prompt API를 사용하면 ReadableStream를 반복하여 스트리밍 데이터를 청크로 가져올 수 있습니다.

const languageModel = await LanguageModel.create();
const stream = languageModel.promptStreaming(inputPrompt);
for await (const chunk of stream) {
  console.log(chunk);
}

다음 단계

스트리밍된 데이터를 성능이 우수하고 안전하게 렌더링하는 방법을 알고 싶으신가요? LLM 응답을 렌더링하기 위한 권장사항을 읽어보세요.