본문 바로가기
C.W.K.
Stream
Lesson 03 of 04 · published

fetch로 TypeScript streaming

~22 min · streaming, typescript

Level 0Downloader
0 XP0/41 lessons0/11 achievements
0/120 XP to next level120 XP to go0% complete

Async generator 패턴

Node 20+랑 요즘 브라우저에서 Ollama NDJSON을 받아먹는 정석은 이 셋의 조합이야. fetch().body가 주는 ReadableStream, TextDecoder, 그리고 직접 굴리는 newline buffer. 이걸 async function* generator로 감싸두면 바깥에서는 for await 한 줄로 stream을 돌 수 있어.

감싸는 게 왜 남는 장사냐면, 소비하는 쪽이 HTTP도 buffer도 몰라도 되기 때문이야. 터미널에 찍든, DOM 노드에 붙이든, 다른 stream으로 넘기든 for await는 똑같아. Python 쪽 iter_lines() 패턴이랑 정확히 같은 자리에 서는 조각이고, 그래서 두 언어의 client가 같은 모양으로 생기게 돼.

Buffer 패턴 (절대 skip 금지)

Network chunk는 JSON 줄 경계랑 안 맞아. read() 한 번이 이렇게 돌아올 수 있어:

  • '{"a":1,"b":2}\n{"c":3,' — 마지막 줄 미완성
  • '4}\n{"e":5}\n' — 첫 부분이 이전 줄 완성

그래서 순서가 정해져 있어. Buffer에 쌓고, \n로 자르고, 완성된 줄만 전부 parse하고, 잘린 tail은 buffer에 남겨둬. 네 단계 중 마지막 하나를 빼먹는 게 제일 흔한 실수야. 짧은 답변으로 test하면 read가 한 번에 끝나서 tail이 안 생기고, 그래서 버그가 멀쩡히 통과해. 그러다 답이 길어지는 순간 토큰이 조용히 사라지지. TypeScript Ollama client에서 버그가 제일 많이 나는 자리가 정확히 여기야.

Code

Buffer 제대로 처리하는 async generator·typescript
type Msg = { role: "system" | "user" | "assistant" | "tool"; content: string };

async function* streamChat(
  model: string,
  messages: Msg[],
  baseUrl = "http://localhost:11434",
): AsyncGenerator<{ content?: string; done?: boolean; raw?: any }> {
  const r = await fetch(`${baseUrl}/api/chat`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ model, messages, stream: true }),
  });
  if (!r.ok || !r.body) throw new Error(`Ollama ${r.status}`);

  const reader = r.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split("\n");
    buffer = lines.pop() ?? ""; // 미완성 tail 유지

    for (const line of lines) {
      if (!line.trim()) continue;
      const chunk = JSON.parse(line);
      if (chunk.done) {
        yield { done: true, raw: chunk };
        return;
      }
      yield { content: chunk.message?.content ?? "", raw: chunk };
    }
  }
}

// 사용
for await (const tok of streamChat(
  "qwen2.5:7b",
  [{ role: "user", content: "Explain GGUF in 3 sentences." }],
)) {
  if (tok.done) {
    const r = tok.raw;
    const tps = r.eval_count / (r.eval_duration / 1e9);
    console.log(`\n[${r.eval_count} tokens @ ${tps.toFixed(1)} tok/s]`);
    break;
  }
  process.stdout.write(tok.content ?? "");
}
브라우저 버전 (같은 골격)·typescript
// CORS 설정했거나 자체 backend 통해 proxy하면 같은 코드 브라우저에서 동작.
// 차이점: process.stdout 대신 textContent 쓰고 DOM 노드에 append.

async function streamIntoDOM(target: HTMLElement, model: string, messages: Msg[]) {
  for await (const tok of streamChat(model, messages)) {
    if (tok.done) break;
    target.textContent += tok.content ?? "";
  }
}

External links

Exercise

Buffer 제대로 처리한 streamChat(model, messages) TypeScript 구현. 두 시나리오로 test: 짧은 답변 (network read 한 번에 stream 전체 커버)이랑 긴 답변 (read 여러 번). 둘 다 동작하고 부분 줄 손실 없는지 확인.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.