Skip to content
签到签到

第 2 章:LLM 调用与流式输出

本章解决的问题

前端 AI 面试几乎必问流式输出。这一章解释 token、context window、SSE、chunk 和流式文本处理。

交互图解:一次 Agent Turn 完整调用链 · 查看图解导读

大白话理解

模型不是一次生成整段答案,而是一个 token 一个 token 地生成。

流式输出的本质是:

text
服务器生成一个 token
-> 立即发给浏览器
-> 浏览器把它追加到界面
-> 服务器继续生成下一个 token

这样用户看到的是逐字出现,而不是长时间白屏后一次性出现全文。

必须掌握的概念

1. Token

Token 是模型处理文本的最小单位。中文、英文单词、标点都可能被拆成一个或多个 token。

2. Context Window

Context Window 是模型一次请求能看到的 token 上限。上下文包括:

  • 系统提示词
  • 历史消息
  • 工具结果
  • 当前问题

3. SSE

SSE 是浏览器和服务器之间的一条单向流。服务器可以持续发送文本片段。

SSE 适合:

  • 模型到前端的文本流。
  • 状态更新。
  • 日志推送。

WebSocket 适合需要浏览器频繁上行消息的场景,例如协同编辑或强实时双向通信。很多 AI 对话系统会混合使用:上行用普通 HTTP,下行用 SSE;复杂协议可以用 WebSocket 承载逻辑流。

4. Chunk

Chunk 是流中的一个片段。前端需要处理:

  • 普通文本增量。
  • JSON 增量。
  • 工具调用开始和结束。
  • 完成或错误事件。

DeepSeek Harness 对应实现

DeepSeek Harness 定义了 StreamChunk 词汇。模型适配器返回 chunk,BlockAssembler 把它组装成最终 assistant message。

agent-loop 的核心逻辑大致是:

text
for await chunk of stream:
  append assistant/chunk
  assembler.push(chunk)

finish = assembler.finish

这意味着原始 chunk 既用于 UI 展示,也保留在会话日志中用于回放。

进一步阅读:

  • packages/llm/llm/src/index.ts
  • packages/core/agent-loop/src/agent.ts

代码示例

使用 fetch 读取流

ts
async function streamCompletion(prompt: string, onDelta: (text: string) => void): Promise<void> {
  const response = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ prompt }),
  })

  if (!response.body) throw new Error('streaming is not supported')

  const reader = response.body.getReader()
  const decoder = new TextDecoder()

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

    const text = decoder.decode(value, { stream: true })
    onDelta(text)
  }
}

将流转换为 AsyncIterable

ts
async function* completionChunks(prompt: string): AsyncIterable<string> {
  const response = await fetch('/api/chat', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({ prompt }),
  })

  if (!response.body) return

  const reader = response.body.getReader()
  const decoder = new TextDecoder()

  while (true) {
    const { done, value } = await reader.read()
    if (done) return
    yield decoder.decode(value, { stream: true })
  }
}

面试怎么问

问:SSE 和 WebSocket 怎么选?

回答要点:

  • SSE 基于 HTTP,实现简单,适合服务器持续推送。
  • WebSocket 是双向全双工,适合浏览器也要高频上行。
  • 很多 AI 产品用普通 HTTP 上行、SSE 下行,或者用 WebSocket 承载自定义 RPC。

问:流式 JSON 为什么难处理?

回答要点:

  • 网络 chunk 可能在任意位置切断 JSON。
  • 不能对半截 JSON 调用 JSON.parse
  • 需要增量解析器或协议设计成多个小事件。

问:如何优化 token 消耗?

回答要点:

  • 裁剪历史消息。
  • 对工具结果做摘要或截断。
  • 使用合适的 system prompt。
  • 后端使用 compaction 或投影,避免把无关日志发给模型。

自测题

  1. Token 和字符有什么区别?
  2. Context Window 为什么会影响前端产品设计?
  3. fetch 读取流式响应的基本步骤是什么?
  4. SSE 和 WebSocket 各自适合什么场景?

本章小结

前端处理 LLM 的第一步不是“把返回文本放到 div 里”,而是理解 token、流式传输、chunk 和错误结束。这是后续所有 UI 状态设计的基础。

进一步阅读:

  • packages/llm/llm/src/assembler.ts
  • docs/subsystems/llm-streaming.md