跳到主要內容

LLM 串流輸出:SSE、逐字渲染、串流 JSON 解析

從伺服器代理、SSE 事件解析、不會閃爍的 markdown 漸進渲染,到部分 JSON 的容錯解析與工具呼叫參數的即時累積。

約 10 分鐘 · llm-streaming.md

1. 架構:一定要有代理層

瀏覽器  ←SSE─  你的 /api/chat  ←stream─  模型 API
                     ↑
               金鑰只在這裡

前端直連模型 API 等於公開你的金鑰,沒有例外。代理層順便做:身分驗證、速率限制、限制可用模型與參數、記錄用量。

2. 伺服器:把上游的串流轉出去

在 Cloudflare Workers / Edge runtime 上,最省事的做法是直接把上游的 ReadableStream 接出去:

export async function POST({ request, fetch }) {
  const upstream = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'content-type': 'application/json',
      'x-api-key': ANTHROPIC_API_KEY,
      'anthropic-version': '2023-06-01'
    },
    body: JSON.stringify({ ...body, stream: true })
  });

  if (!upstream.ok || !upstream.body) {
    return new Response('upstream error', { status: 502 });
  }

  return new Response(upstream.body, {
    headers: {
      'content-type': 'text/event-stream',
      'cache-control': 'no-cache, no-transform',
      connection: 'keep-alive'
    }
  });
}

no-transform 很重要:沒有它,某些 CDN 或壓縮層會緩衝你的串流,於是使用者盯著空白畫面五秒,然後整段答案一次跳出來。

3. 前端:不要用 EventSource

EventSource 只能發 GET、不能帶自訂標頭、不能送 body。用 fetch + ReadableStream

const response = await fetch('/api/chat', {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ messages }),
  signal: controller.signal   // 使用者要能按「停止」
});

const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';

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

  // stream: true 是關鍵:一個 UTF-8 字元可能被切在兩個 chunk 之間,
  // 少了它,中文和 emoji 會變成問號。
  buffer += decoder.decode(value, { stream: true });

  // SSE 事件以空行分隔。最後一段可能不完整,留在 buffer 裡等下一輪。
  const events = buffer.split('\n\n');
  buffer = events.pop() ?? '';

  for (const event of events) handleEvent(event);
}

三個最常見的 bug,全部在上面這段裡:

  1. 沒有 { stream: true } → 中文變亂碼
  2. 沒有把最後一段留在 buffer → 隨機掉字
  3. 沒有 AbortController → 使用者按停止但費用繼續產生

4. 解析 SSE 事件

function handleEvent(raw: string) {
  const lines = raw.split('\n');
  let eventName = 'message';
  const dataLines: string[] = [];

  for (const line of lines) {
    if (line.startsWith('event:')) eventName = line.slice(6).trim();
    else if (line.startsWith('data:')) dataLines.push(line.slice(5).trim());
    else if (line.startsWith(':')) continue;      // 註解/心跳
  }

  const data = dataLines.join('\n');
  if (!data || data === '[DONE]') return;

  // 上游偶爾會送出不完整的 JSON。丟掉那一筆,不要讓整個串流死掉。
  try {
    dispatch(eventName, JSON.parse(data));
  } catch {
    return;
  }
}

5. 逐字渲染 markdown 不閃爍

問題:模型輸出到一半的 markdown 是壞掉的 markdown。當它輸出了 ``` 但還沒輸出結尾,parser 會把後面全部當成程式碼區塊 —— 於是答案「塌陷」,等結尾到了又「彈回來」。這是 LLM 聊天介面最明顯的視覺瑕疵。

解法:解析前先把沒關的東西補起來。

export function closeOpenMarkdown(partial: string): string {
  let out = partial;

  // 1. 沒關的 code fence
  const fences = out.match(/^```/gm)?.length ?? 0;
  if (fences % 2 === 1) out += '\n```';

  // 2. 沒關的行內 code(要先排除 fence 內部)
  const withoutFences = out.replace(/```[\s\S]*?```/g, '');
  const ticks = withoutFences.match(/`/g)?.length ?? 0;
  if (ticks % 2 === 1) out += '`';

  // 3. 打到一半的連結 "[文字](htt" —— 先藏起來
  out = out.replace(/\[[^\]]*\]\([^)]*$/, '');

  // 4. 結尾懸空的粗體/斜體符號
  const trailing = out.match(/(\*{1,2}|_{1,2})$/);
  if (trailing) out = out.slice(0, -trailing[0].length);

  return out;
}

然後 renderUntrusted(closeOpenMarkdown(partial))

模型輸出一律走 untrusted 路徑,因為它可能被使用者的輸入誘導產生 <script>。詳見 markdown-parser

6. 串流 JSON:部分解析

當你要求模型輸出結構化資料,你會希望欄位一填好就顯示,而不是等整個 JSON 完成。

JSON.parse 對半個 JSON 會直接丟錯,所以要先把它補完整:

/** 把不完整的 JSON 補成可解析的形狀。無法補救時回 null。 */
export function parsePartialJson<T = unknown>(input: string): T | null {
  const text = input.trim();
  if (!text) return null;

  try {
    return JSON.parse(text) as T;
  } catch {
    // 繼續往下補
  }

  const stack: string[] = [];
  let inString = false;
  let escaped = false;

  for (const char of text) {
    if (escaped) { escaped = false; continue; }
    if (char === '\\') { escaped = true; continue; }
    if (char === '"') { inString = !inString; continue; }
    if (inString) continue;

    if (char === '{') stack.push('}');
    else if (char === '[') stack.push(']');
    else if (char === '}' || char === ']') stack.pop();
  }

  let patched = text;
  if (escaped) patched = patched.slice(0, -1);        // 結尾是落單的反斜線
  if (inString) patched += '"';                        // 字串沒收尾
  patched = patched.replace(/,\s*$/, '');              // 結尾多一個逗號
  patched = patched.replace(/:\s*$/, ': null');        // key 後面還沒有值
  while (stack.length) patched += stack.pop();         // 補上所有沒關的括號

  try {
    return JSON.parse(patched) as T;
  } catch {
    return null;   // 這一幀還補不起來,下一個 chunk 再說
  }
}

用法:每收到一個 chunk 就試著解析,成功就更新 UI,失敗就等下一個。不要 throw,串流過程中失敗是正常的。

工具呼叫的參數

工具參數是以字串片段串流過來的,要自己累積:

const toolCalls = new Map<number, { name: string; args: string }>();

// input_json_delta 事件
const call = toolCalls.get(index)!;
call.args += delta.partial_json;

// 想即時顯示「它正在準備呼叫什麼」:
const preview = parsePartialJson(call.args);

7. 捲動行為

只有當使用者本來就在最底部時,才跟著捲。

function isAtBottom(el: HTMLElement) {
  return el.scrollHeight - el.scrollTop - el.clientHeight < 40;
}

使用者往上捲去看前面的內容,畫面卻一直把他拉回底部 —— 這是聊天介面最惹人厭的行為。40px 的容差是必要的,因為 subpixel。

8. 游標

游標要接在最後一個文字節點後面,用 inline 元素:

.cursor {
  display: inline-block;
  width: 0.5em;
  height: 1em;
  vertical-align: text-bottom;
  background: var(--ds-accent);
  animation: blink 1s steps(2) infinite;
}

固定在容器右下角的游標,每次換行都會離文字越來越遠。

9. 錯誤與中斷

  • 串流中斷 → 保留已經收到的內容,在下面顯示「連線中斷」+ 重試。把已產生的字丟掉是最糟的處理
  • 使用者按停止 → controller.abort(),並把那則訊息標記為未完成
  • 上游回 429 → 顯示「現在有點忙」,不要顯示原始的 rate limit 訊息

10. 檢查清單

  • 金鑰只在伺服器
  • TextDecoder{ stream: true }
  • SSE buffer 保留最後一段不完整的事件
  • AbortController 且 UI 有停止按鈕
  • markdown 在解析前先補完未閉合的語法
  • 模型輸出走 untrusted 淨化路徑
  • 部分 JSON 解析失敗時安靜跳過,不 throw
  • 只在使用者位於底部時自動捲動
  • 中斷時保留已收到的內容
  • 回應標頭有 no-transform

參考模組:串流對話Agent 執行流程

顯示設定

這裡改的每一項,會即時套用到站上所有預覽。

風格

密度

圓角

動態

系統層級的「減少動態效果」永遠優先於這裡的設定。

語言