LLM 串流輸出:SSE、逐字渲染、串流 JSON 解析
從伺服器代理、SSE 事件解析、不會閃爍的 markdown 漸進渲染,到部分 JSON 的容錯解析與工具呼叫參數的即時累積。
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,全部在上面這段裡:
- 沒有
{ stream: true }→ 中文變亂碼 - 沒有把最後一段留在 buffer → 隨機掉字
- 沒有
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 執行流程。