🚀 在浏览器里跑 DeepSeek-R1?WebGPU 端侧推理实战(五)—— 中断、重置、缓存与流式生成

📌 上篇回顾 :我们完成了下载进度条和聊天界面------Worker 预热、三种下载事件闭环、受控输入框与三态按钮联动,UI 链路已经打通。

🎯 本篇目标让模型真正能聊起来 ------中断控制、KV 缓存加速、流式输出、思考标签识别,打通从用户输入到流式回复的完整推理链路。 🔜 下篇预告把文字变成真正的聊天气泡------消息渲染、思考折叠、流式填充、自适应输入框、粘性滚动、TS 类型化,以及一路踩过的坑。


📖 目录

  • [一、🎮 InterruptableStoppingCriteria --- 可中断停止条件("遥控器")](#一、🎮 InterruptableStoppingCriteria — 可中断停止条件("遥控器") "#%E4%B8%80interruptablestoppingcriteria--%E5%8F%AF%E4%B8%AD%E6%96%AD%E5%81%9C%E6%AD%A2%E6%9D%A1%E4%BB%B6%E9%81%A5%E6%8E%A7%E5%99%A8")
  • [二、📝 past_key_values_cache --- KV 缓存("草稿纸")](#二、📝 past_key_values_cache — KV 缓存("草稿纸") "#%E4%BA%8Cpast_key_values_cache--kv-%E7%BC%93%E5%AD%98%E8%8D%89%E7%A8%BF%E7%BA%B8")
  • [三、💬 新增消息协议(推理相关)](#三、💬 新增消息协议(推理相关) "#%E4%B8%89%E6%96%B0%E5%A2%9E%E6%B6%88%E6%81%AF%E5%8D%8F%E8%AE%AE%E6%8E%A8%E7%90%86%E7%9B%B8%E5%85%B3")
  • [四、🚪 App.tsx 推理入口 --- 新增内容](#四、🚪 App.tsx 推理入口 — 新增内容 "#%E5%9B%9Bapptsx-%E6%8E%A8%E7%90%86%E5%85%A5%E5%8F%A3--%E6%96%B0%E5%A2%9E%E5%86%85%E5%AE%B9")
  • [五、📋 分词详解 --- apply_chat_template](#五、📋 分词详解 — apply_chat_template "#%E4%BA%94%E5%88%86%E8%AF%8D%E8%AF%A6%E8%A7%A3--apply_chat_template")
  • [六、🧠 DeepSeek-R1 思考标签 --- <think> / </think>](#六、🧠 DeepSeek-R1 思考标签 — / "#%E5%85%ADdeepseek-r1-%E6%80%9D%E8%80%83%E6%A0%87%E7%AD%BE--think--think")
  • [七、🔄 双回调机制](#七、🔄 双回调机制 "#%E4%B8%83%E5%8F%8C%E5%9B%9E%E8%B0%83%E6%9C%BA%E5%88%B6")
  • [八、📡 TextStreamer --- 流式输出管道](#八、📡 TextStreamer — 流式输出管道 "#%E5%85%ABtextstreamer--%E6%B5%81%E5%BC%8F%E8%BE%93%E5%87%BA%E7%AE%A1%E9%81%93")
  • [九、⚙️ model.generate() 参数 + 返回值 + batch_decode](#九、⚙️ model.generate() 参数 + 返回值 + batch_decode "#%E4%B9%9Dmodelgenerate-%E5%8F%82%E6%95%B0--%E8%BF%94%E5%9B%9E%E5%80%BC--batch_decode")
  • [十、📦 generate() 函数全貌(七步)](#十、📦 generate() 函数全貌(七步) "#%E5%8D%81generate-%E5%87%BD%E6%95%B0%E5%85%A8%E8%B2%8C%E4%B8%83%E6%AD%A5")
  • [十一、⏱️ 完整数据流时间线](#十一、⏱️ 完整数据流时间线 "#%E5%8D%81%E4%B8%80%E5%AE%8C%E6%95%B4%E6%95%B0%E6%8D%AE%E6%B5%81%E6%97%B6%E9%97%B4%E7%BA%BF")
  • [十二、📝 小结](#十二、📝 小结 "#%E5%8D%81%E4%BA%8C%E5%B0%8F%E7%BB%93")

一、🎮 InterruptableStoppingCriteria --- 可中断停止条件("遥控器")

1.1 它是什么

Transformers.js 内置的一个类。类比:你给模型一个遥控器,模型每吐一个 token 就看一眼遥控器------红灯亮了就停,绿灯就继续。

翻译:Interruptable(可被中断的)+ Stopping(停止)+ Criteria(条件)= 一个能从外部喊停的"遥控器"。

1.2 工作原理 --- 谁设标记、谁查标记

模型生成是一个 token 一个 token 往外蹦的循环:

arduino 复制代码
生成 token1 → 库检查 → 标记=false → 继续
生成 token2 → 库检查 → 标记=false → 继续  
生成 token3 → 库检查 → 标记=true  → "好,不生了" → 停止
角色 谁干的 干什么
设标记 你的代码 stopping_criteria.interrupt() → 内部标记 = true
查标记 Transformers.js 库 model.generate() 内部,每吐一个 token 自动检查

检查不是你写的代码在做------你只管按遥控器,库自己负责看。

1.3 标记在哪

标记藏在 InterruptableStoppingCriteria 对象内部,你看不到源码:

js 复制代码
const stopping_criteria = new InterruptableStoppingCriteria();

// 对象内部(你看不到,大概长这样):
// {
//     interrupted: false,    ← 这就是标记
//     interrupt() { this.interrupted = true; },
//     reset()     { this.interrupted = false; },
// }

调用 stopping_criteria.interrupt() 就是把内部的 interruptedfalse 改成 true。不需要你知道具体字段名,只要知道调方法就能改。

1.4 三个方法

js 复制代码
const stopping_criteria = new InterruptableStoppingCriteria();

stopping_criteria.interrupt();  // 喊停:内部标记 = true
stopping_criteria.reset();      // 复位:内部标记 = false("一切正常")
// stopping_criteria.shouldStop() --- 库内部自调用,你不用管

interrupt()reset() 是同一个对象的两个方法,一个开、一个关。

1.5 为什么放模块顶层

generateinterrupt 两个 case 需要同一个实例

js 复制代码
// worker.js 顶层(所有函数外面)
const stopping_criteria = new InterruptableStoppingCriteria();

// case "generate":
stopping_criteria.reset();  // 擦干净 → 传给 model.generate()
generate(data);

// case "interrupt":
stopping_criteria.interrupt();  // 按停!

如果放在 generate 函数里面,interrupt case 就拿不到这个实例了(闭包隔离)。

1.6 为什么每次推理前必须 reset()

被打断后,内部标记还是 true(脏标记)。不复位直接下一次生成:

arduino 复制代码
第1轮:用户问 → 模型生成 → 用户点停止 → interrupt() → 标记 = true
第2轮:用户问 → 模型开始生成 → 第1个token前检查 → 标记 = true → "哦,不让生" → 停

第2轮一个 token 都吐不出来。所以每次 generate 前必须 reset()

js 复制代码
// case "generate"
stopping_criteria.reset();  // ← 不能省!
// ❌ 不复位 → 下次生成立刻停
// ✅ 每次 reset() → 干干净净开始

中断数据流(本节局部)

arduino 复制代码
用户点停止 → postMessage("interrupt") → Worker
  │
  └─ case "interrupt":
        stopping_criteria.interrupt()
          │
          └─ 内部:interrupted = true
               │
               └─ 模型下一圈循环检测到 → break → 生成结束
                    past_key_values 是脏数据(半截推理)→ reset 时清掉

二、📝 past_key_values_cache --- KV 缓存("草稿纸")

2.1 它是什么

大模型每算一个新 token,都需要前面所有 token 的 Attention(注意力)计算结果。如果每次从头算一遍,越聊越慢。所以把中间计算结果存下来------这就是 KV Cache。

  • K = Key(注意力计算的"键"矩阵)
  • V = Value(注意力计算的"值"矩阵)
  • Cache = 存 GPU 显存里,不是浏览器硬盘缓存

2.2 存哪、和浏览器缓存的区别

浏览器缓存 KV Cache(past_key_values)
存什么 .onnx 模型文件 对话中间计算结果
在哪 硬盘 GPU 显存
作用 刷新页面不用重新下载模型 追问时不用重新算前文
清空方式 手动清浏览器缓存 = null

2.3 怎么用 --- 缓存命中原理

js 复制代码
let past_key_values_cache = null;  // 模块级变量,全局共享

// 第1轮:"你好"
// past_key_values_cache = null → 缓存未命中 → 从头算所有 token
const output = await model.generate({
    past_key_values: past_key_values_cache,  // null
});
past_key_values_cache = output.past_key_values;  // 存起来!2 个 token 的计算结果

// 第2轮追问:"我叫小明"
// past_key_values_cache = 有值 → 缓存命中!前 2 个 token 直接用
const output2 = await model.generate({
    past_key_values: past_key_values_cache,  // 加速!
});
past_key_values_cache = output2.past_key_values;  // 更新 → 现在缓存了 4 个 token

为什么能命中:同一轮对话里,前面的 token 没变,计算结果也没变,直接用就行。只算新增的部分。

2.4 输入也会被缓存

模型不区分"这句话是用户说的还是我答的"------它把整个对话历史当成一长串 token:

arduino 复制代码
第1轮:
  输入:"你好"(2 token)  → 计算 → 入缓存
  输出:"你好!"(2 token) → 计算 → 入缓存
  缓存 = 4 token

第2轮追问:
  新输入:"我饿了"(2 token) → 追加到末尾
  缓存 = 前4个命中 + 只算新2个 = 6 token

2.5 三种必须清空缓存的场景

场景 为什么 操作
中断后 缓存了半截推理的中间结果,数据是脏的 = null
换话题 旧话题的缓存干扰新对话,文不对题 = null
点"重置" 开启新对话,忘掉所有上下文 = null + stopping_criteria.reset()

核心认知:不是"避免缓存命中",是"避免命中错的缓存"。同一轮对话内缓存加速是好事,换话题时旧缓存才是垃圾。

KV 缓存存取流程(本节局部)

ini 复制代码
第1轮:"你好"
  past_key_values_cache = null → model.generate({ past_key_values: null })
    → 从头算所有 token → 返回 past_key_values
    → past_key_values_cache = past_key_values  ← 存起来!

第2轮追问:"我叫小明"
  past_key_values_cache 有值 → model.generate({ past_key_values: past_key_values_cache })
    → 前文命中,只算新 token → 返回更新后的 past_key_values
    → past_key_values_cache = past_key_values  ← 更新!

用户点重置:
  past_key_values_cache = null  ← 清空 → 下一轮回到第1轮状态

三、💬 新增消息协议(推理相关)

下载阶段的协议(loading/initiate/progress/done/ready)详见 readme2 第三章。

3.1 主线程 → Worker(新增)

type 作用 Worker 的操作
generate 开始推理 sc.reset() + generate(data)
interrupt 中断推理 sc.interrupt()
reset 清空上下文 缓存 = null + sc.reset()

3.2 Worker → 主线程(新增)

status 触发时机 携带的数据
start 开始推理(在 model.generate 之前发出) ---
update 流式每吐一段文字 output, tps, numTokens, state
complete 推理结束 output(冗余,见第九节)
error 出错 错误信息

start 为什么放在 model.generate() 之前?

csharp 复制代码
streamer 创建完毕 → postMessage("start") → 主线程切"停止"按钮 → await model.generate()

三个原因:

  1. 时机正确:streamer 和双回调已就绪,主线程可以放心进入"生成中"状态
  2. 按钮联动 :主线程收到 startisRunning = true → 按钮从"发送"切"停止"
  3. 先切状态再推理:如果在 generate 之后发,用户看不到停止按钮,无法中断

四、🚪 App.tsx 推理入口 --- 新增内容

下载/预热阶段的 UI 逻辑(三态切换、输入框、按钮联动)详见 readme2 第六节。

4.1 onEnter --- 从空壳到实现

readme2 中 onEnter 是空函数,现在填入 setMessages 追加用户消息:

js 复制代码
function onEnter(message) {
    // 函数式更新:prev 是 React 维护的最新 messages
    setMessages((prev) => [...prev, { role: "user", content: message }]);
}
写法 为什么
(prev) => 函数式 避免闭包陷阱,prev 永远是 React 的最新值
...prev 展开 保留旧消息,不丢数据
[...prev, 新消息] 追加在末尾

4.2 useEffect --- 新增:自动触发推理

js 复制代码
useEffect(() => {
    // 守卫①:没有任何用户消息 → 跳过(刚加载时 messages 为空)
    if (messages.filter((x) => x.role === "user").length === 0) return;

    // 守卫②:最后一条已是模型回复 → 跳过(已回复过了,不要重复推理)
    if (messages.at(-1).role === "assistant") return;

    // 通过守卫 → 发整个对话历史(不是只发最后一条)
    worker.current.postMessage({ type: "generate", data: messages });
}, [messages]);

两个守卫详解

css 复制代码
messages 变化 → useEffect 触发
  ↓
守卫①:有用户消息吗?
  场景A:messages = []                          → 没有 → 跳过
  场景B:messages = [{role:"assistant",...}]     → 没有 → 跳过
  场景C:messages = [{role:"user",...}]          → 有   → 继续
  ↓
守卫②:最后一条是模型回复吗?(.at(-1) = 最后一个元素)
  场景C:最后一条是 {role:"user"}    → 不是 → 该推理了 ✅
  场景D:最后一条是 {role:"assistant"} → 是 → 已回复 → 跳过

为什么要发整个 messages 数组而不是只发最后一条?

模型需要完整上下文:

arduino 复制代码
只发"你推荐什么馅的?"
  → 模型不知道之前聊了饺子 → 答非所问

发全部 5 条:
  → 模型理解全部上下文 → 连贯回复"韭菜鸡蛋"

五、📋 分词详解 --- apply_chat_template

5.1 它干了什么(三步转换 + 两个参数)

输入是 JSON 对话数组,模型只认识数字。中间经历三步:

csharp 复制代码
输入:[{role:"user", content:"你好"}, {role:"assistant", content:"你好!"}]

  ↓ ① 套模板:JSON → 带角色标记的纯文本
  "<|User|>你好<|Assistant|>你好!"

  ↓ ② 分词:查词表,文字 → 数字
  [151643, 10341, 2070, 151644, 10341, 2070, 1132]

  ↓ ③ add_generation_prompt → 末尾自动追加 <|Assistant|>
  "<|User|>你好<|Assistant|>你好!<|Assistant|>"
  (实际 token: [151643, 10341, 2070, 151644, 10341, 2070, 1132, 151644])

5.2 参数详解

js 复制代码
const inputs = tokenizer.apply_chat_template(messages, {
    add_generation_prompt: true,   // 参数①
    return_dict: true,             // 参数②
});

参数① add_generation_prompt: true

在对话末尾自动追加助手标记:

go 复制代码
不加(false):
  "<|User|>你好<|Assistant|>你好!<|User|>今天天气怎么样"
  → 模型看到最后一个 `<|User|>` → "哦,用户还在说话" → 继续等

加了(true):
  "...<|User|>今天天气怎么样<|Assistant|>"
  → 模型看到 `<|Assistant|>` → "该我说话了" → 开始生成回复

本质就是在末尾悄悄塞了一个"助手:"标记,告诉模型"轮到你了,请开始续写"。

参数② return_dict: true

控制返回格式:

yaml 复制代码
false(默认)→ [1, 2345, 678, ...]                          ← 只有数组
true        → { input_ids: [...], attention_mask: [...] }  ← 对象

attention_mask:全是 1,标记哪些位置是真实内容(这里没有填充所以全 1)
返回对象的好处:可以直接 `...inputs` 展开传给 model.generate()

5.3 JSON → XML 风格字符串

apply_chat_template 把对象格式转成模型训练时用的纯文本格式:

sql 复制代码
JSON(你写的)                   XML 风格(tokenizer 转的)
───────────────────              ─────────────────────
[                                <|User|>你好
  { role: "user",               
    content: "你好"              <|Assistant|>你好!
  },                            
  { role: "assistant",          <|User|>今天天气怎么样
    content: "你好!"            
  },                            <|Assistant|>  ← 等着模型填空
  { role: "user",
    content: "今天天气怎么样"
  }
]

模型训练时就是这么喂的数据------一堆带标记的纯文本。模型不认识 {},只认识 <|User|> 这种特殊标记。

5.4 不同模型的标记对比

json 复制代码
Qwen 系列:
  <|im_start|>user\n你好<|im_end|>
  <|im_start|>assistant\n你好!<|im_end|>

DeepSeek:
  <|User|>你好<|Assistant|>你好!

Llama:
  [INST] 你好 [/INST] 你好!

| 模型 | 开始标记 | 结束标记 | im 含义 |
|----------|------------------------------|-----------|-------|------|--------|------|------------------------|
| Qwen | `< | im_start | >` | `< | im_end | >` | Interaction Mode(对话模式) |
| DeepSeek | <|User|> / <|Assistant|> | 无显式结束 | --- |
| Llama | [INST] | [/INST] | --- |

不需要关心区别 ------apply_chat_template 根据 tokenizer_config.json 自动选用正确格式。换模型代码不动。

5.5 模型是怎么学会的

训练数据就是一堆带角色标记的纯文本:

sql 复制代码
<|User|>你好<|Assistant|>你好!<|User|>吃饭了吗<|Assistant|>吃了

模型学会:<|User|> 后面的文字是别人说的(只看不算,不需要生成),<|Assistant|> 后面的文字是自己该说的(计算并生成)。标记 = 角色分界线。

六、🧠 DeepSeek-R1 思考标签 --- <think> / </think>

6.1 模型的输出结构

DeepSeek-R1 是推理模型,输出分两段:

xml 复制代码
<think>嗯,用户问今天天气怎么样,我需要查询天气信息。让我想想...</think>

今天天气晴朗,温度25度,适合户外活动。
区间 含义 UI 显示
<think> ~ </think> 内部推理过程("内心独白") 灰色/可折叠
</think> 之后 最终回答(给你的答案) 正常显示

6.2 提取两个标记的 token ID

js 复制代码
const [START_THINKING_TOKEN_ID, END_THINKING_TOKEN_ID] = tokenizer.encode(
    "<think></think>",
    { add_special_tokens: false },
);

逐部分拆解:

代码 含义
tokenizer.encode(...) 纯分词,查词表,不套对话模板
"<think></think>" 要查询的字符串,两个标记拼在一起
add_special_tokens: false 不要自动加 BOS/EOS token,只要这两个标记的数字
const [START, END] = ... 解构:拿第1个数和第二数

encode vs apply_chat_template

方法 做什么 用在哪
apply_chat_template 套对话模板 + 分词 messages 数组 → 模型输入
encode 字符串直接查词表 查特定文字对应哪个数字

6.3 谁加的 <think> 标签?

模型自己加的 。DeepSeek-R1 训练时就是这么学的------先想再说。每次 model.generate() 自动输出这个结构。不是你的代码加的。你的代码只是提前算出这两个标记对应的数字,方便接收时识别。

6.4 在回调中的用法

js 复制代码
// token_callback_function 中:
if (tokens[0] == END_THINKING_TOKEN_ID) {
    state = "answering";
}
ini 复制代码
模型开始生成 → state = "thinking"(初始值)
  ↓
token 128759 (<think>)  → state 不变(本来就是 thinking)
... 思考中 ...
token 128760 (</think>) → 匹配!→ state = "answering" ✅
... 之后所有 token 都是正式回答 ...

只检测 END 不检测 START 的原因 :模型天生从 thinking 状态开始,初始 state = "thinking" 已覆盖。

6.5 每轮只有一组

每次 generate() 调用 = 一组 <think></think>,不是"想了又想"反复跳。一次想,一次答,结束。

<think></think> 各占一个 token,不是每个 token 都带这两个标记。

七、🔄 双回调机制

7.1 为什么需要两个回调

streamer 内部收到 token ID 后,做两件事:

  1. 先用原始数字更新内部状态(TPS、思考/回答切换)
  2. 再解码成文字发给主线程渲染

两个回调各司其职:

js 复制代码
// ===== 回调①:处理原始 token ID("记账")=====
const token_callback_function = (tokens) => {
    // tokens 是数组,如 [10341],通常只有 1 个元素
    // 参数是数字数组,因为可能批量返回多个 token

    startTime ??= performance.now();
    //       ↑ 记录第一个 token 到达的时间
    //       ??= 只有 startTime 是 undefined 时才赋值
    //       performance.now() 高精度时间戳(毫秒),比 Date.now() 更精确

    if (numTokens++ > 0) {
        //   ↑ 先判断 >0,再自增
        //   第1个 token:numTokens = 0 → 条件 false → 跳过 TPS 计算
        //   (第1个 token 时间差 ≈ 0ms,算出来没意义)
        tps = (numTokens / (performance.now() - startTime)) * 1000;
        //    已生成token数 / 已用毫秒数 × 1000 = token/秒
    }

    if (tokens[0] == END_THINKING_TOKEN_ID) {
        // tokens[0]:每次回调通常只带 1 个 token
        // 等于 </think> 的 ID → 思考结束!
        state = "answering";
    }
};

// ===== 回调②:处理解码后的文字("发送")=====
const callback_function = (output) => {
    // output 已经是中文文字,如 "今天天气"
    self.postMessage({
        status: "update",
        output,      // 增量文字(不是完整对话,只是新生成的这一段)
        tps,         // 实时 token/秒(和回调①共享的变量)
        numTokens,   // 已处理的 token 总数(和回调①共享的变量)
        state,       // "thinking" 或 "answering"(和回调①共享的变量)
    });
};

分工对比

回调① token_callback_function 回调② callback_function
收到什么 [10341] 原始数字 "今" 解码文字
做什么 记账:TPS更新、状态切换 发送:postMessage 主线程
谁调用它 streamer 内部 streamer 内部
调用顺序 (解码后才拿到文字)

7.2 TPS 计算详解

js 复制代码
tps = (numTokens / (performance.now() - startTime)) * 1000;

// 假设:已生成 50 个 token,用了 2000 毫秒
// tps = (50 / 2000) × 1000 = 25 token/秒
ini 复制代码
时间线 ──────────→

第1个token → numTokens=0 → 跳过(t=0ms,除法无意义)
第2个token → numTokens=1 → tps = (1/0.05*)   × 1000 = 20000(不准)  
第3个token → numTokens=2 → tps = (2/0.1*)    × 1000 = 20000(不准)
...
第50个token→ numTokens=49 → tps = (49/2.0*)  × 1000 = 24.5(趋于稳定)
                                                     *假设值
越往后越准,因为样本量大了。

performance.now() vs Date.now()

performance.now() Date.now()
精度 微秒级 毫秒级
基准 页面加载后 1970/1/1
适合 性能测量 显示日期

7.3 闭包共享

两个回调都定义在 generate() 函数内部,共享同一组变量:

rust 复制代码
generate() 函数作用域:
  ├─ let state = "thinking"
  ├─ let numTokens = 0
  ├─ let startTime
  └─ let tps

  token_callback_function → 写:
    startTime ??= performance.now()   // 第1个token时赋值一次,之后跳过(??= 只赋undefined)
    numTokens++                       // 每来一个token +1
    tps = 重新计算                     // 每来一个token刷新一次速率
    state = "answering"               // 只在检测到 </think> 时改一次

  callback_function → 读:
    output  → 参数(streamer解码后的文字,不是闭包变量)
    tps     → 来自回调①刚算的最新速率
    numTokens → 来自回调①刚 +1 后的计数
    state   → 来自回调①的当前状态("thinking" / "answering")
    ↓
    打包 → self.postMessage({ status: "update", output, tps, numTokens, state })

写 vs 读 总结

变量 谁写 谁读 写几次
startTime 回调①(??= 只赋一次) 回调①(算TPS用) 1次
numTokens 回调①(++) 回调②(发主线程) 每个token
tps 回调①(= 重新算) 回调②(发主线程) 每个token
state 回调①(= "answering") 回调②(发主线程) 只改1次
output streamer内部(参数传入) 回调②(发主线程) ---(不是闭包变量)

执行顺序 :先回调①(记账),再回调②(发送)。所以发送时 tpsstate 已经是回调①刚更新过的值。

双回调执行流程(本节局部)

scss 复制代码
模型算出一个 token → streamer.put(tokenId)
  │
  ├─ ① token_cb([tokenId])       ← 先执行:记账
  │     ├─ numTokens++            (计数器+1)
  │     ├─ tps = 重新算           (刷新速率)
  │     └─ 如果 == END_ID → state = "answering"
  │
  ├─ ② tokenizer.decode(tokenId)  ← 库内部解码:数字→文字
  │
  └─ ③ cb("文字")                ← 后执行:发送
        └─ postMessage({ output, tps, numTokens, state })
           ↑ tps 和 state 已经是回调①刚算的最新值

八、📡 TextStreamer --- 流式输出管道

8.1 先理解:为什么模型能流式输出

生成本质 = 自回归循环,不是一个操作一次性算完:

erlang 复制代码
"我"      → 模型算 → "我今"
"我今"    → 模型算 → "我今天"
"我今天"  → 模型算 → "我今天很"
...

用上一个 token 算下一个 token,一个个往后推。所以有天然的回调窗口:

js 复制代码
// model.generate() 内部(伪代码,你看不到的 Transformers.js 源码)
for (let step = 0; step < max_new_tokens; step++) {
    const nextToken = await 模型前向传播(当前序列);
    当前序列.push(nextToken);

    if (streamer) {
        streamer.put(nextToken);    // ← 回调就在这触发!
        //                         ↑ for 循环还没结束,await 还没解除
        //                         但你的回调已经跑了!
    }

    if (nextToken == EOS) break;
}
return { past_key_values, sequences };  // ← 循环全跑完才 return

为什么 await 没结束但回调已经在跑?

csharp 复制代码
你的代码:                        Transformers.js 内部:
const result = await model.generate({ streamer });
//            ↑ await 等最后的 return                for 循环 {
//              不是等每一步                        算 token → streamer.put(token) → 你的回调
//                                                   }
//                                                  return → await 解除

await 等的是整个函数 return,不是等每行代码。循环内部 streamer.put()return 之前就跑完了。

类比:你点了一桌菜,不是等全部上齐才吃。每做好一道就端上来(回调),最后上齐了告诉你(await 解除)。

8.2 解码不是你的代码做的

你只看到 callback_function(output) 拿到了文字,但解码在哪?

js 复制代码
// 你创建 streamer 时传入了 tokenizer:
const streamer = new TextStreamer(tokenizer, { ... });
//                                ↑ 解码器

// TextStreamer 库内部(伪代码,你看不到):
class TextStreamer {
    put(tokenId) {
        // ① 先调 token ID 回调(你写的)
        this.config.token_callback_function([tokenId]);
        //   ↓ numTokens++, TPS 更新, state 切换

        // ② 解码:数字 → 文字(库内部,你看不到)
        const text = this.tokenizer.decode(tokenId);
        //   10341 → "今"

        // ③ skip_prompt 检查:过滤掉用户输入部分
        // ④ skip_special_tokens 检查:过滤 <eos> 等不可见标记

        // ⑤ 调文字回调(你写的)
        this.config.callback_function(text);
        //   ↓ postMessage({ status: "update", output: "今", ... })
    }
}

你传 tokenizer → 库用它解码 → 解码完了调你的 callback_function。你只负责注册回调,解码和调用都是库做的。

8.3 你的 streamer 配置

js 复制代码
const streamer = new TextStreamer(tokenizer, {
    skip_prompt: true,
    // 不重复输出 prompt 部分
    // 不加:输出 = "你好\n<|Assistant|>今天天气很好"(连你问的也吐出来了)
    // 加了:输出 = "今天天气很好"(只吐模型新生成的)

    skip_special_tokens: true,
    // 过滤不可见标记如 <eos>、<|User|>、<|Assistant|>

    callback_function,
    // 文字回调:每解码一段文字就触发 → postMessage("update")

    token_callback_function,
    // token ID 回调:每收到原始数字就触发 → 更新 TPS、状态

    stopping_criteria,
    // 中断开关:传给 streamer 内部,每次生成前检查是否该停
});

8.4 谁在推?推什么?

model.generate() 内部循环主动推,不是你轮询。

你的代码 库代码
创建 streamer、注册回调 循环中每算出一个 token,streamer.put(token)
回调中 postMessage 推完之后继续算下一个

推送模式,不是轮询。你写回调,库负责调。

8.5 streamer 不是数据,是管道

js 复制代码
❌ streamer = "今天天气"  // 不是文字
❌ streamer = [10341]      // 不是数组

✅ streamer = new TextStreamer(...)  // 是一个处理管道对象

它是一个容器------负责收 token ID、调用 tokenizer.decode() 转文字、调你注册的两个回调。

streamer 内部管道流程(本节局部)

scss 复制代码
model.generate() 内部循环算出一个 token ID
  │
  └→ streamer.put(tokenId)
       │
       ├─ token_callback_function([tokenId])    → 更新 TPS / state
       ├─ this.tokenizer.decode(tokenId)        → 数字 → "今"
       ├─ skip_prompt 检查                      → 过滤用户输入部分
       ├─ skip_special_tokens 检查              → 过滤 <eos> 等标记
       ├─ callback_function("今")               → postMessage("update")
       └─ stopping_criteria 检查                → 该停?→ break

九、⚙️ model.generate() 参数 + 返回值 + batch_decode

9.1 完整参数

js 复制代码
const { past_key_values, sequences } = await model.generate({
    // ===== 输入 =====
    ...inputs,
    // 展开 { input_ids: [151643, 10341, ...], attention_mask: [1, 1, ...] }
    // 等价于手写 input_ids: inputs.input_ids, attention_mask: inputs.attention_mask

    // ===== KV 缓存 =====
    past_key_values: past_key_values_cache,
    // 首轮为 null → 从零算
    // 追问时有值 → 跳过前文,只算新 token(加速!)
    
    // ===== 生成控制 =====
    do_sample: false,
    // false → 贪心解码:每步选概率最高的 token,生成结果稳定、不走样
    // true  → 随机采样:可配合 temperature/top_k/top_p 调节创造性
    // 对于代码生成、数学推理等场景,false 更可靠
    max_new_tokens: 2048,
    // 最多生成 2048 个新 token(不算输入的)
    // 防止模型无限生成

    // ===== 流式管道 =====
    streamer,
    // 不是数据,是处理管道对象(含两个回调 + tokenizer + 中断开关)
    // 不传 → 模型一次性返回全部
    // 传了 → 边生成边推,实现打字机效果

    // ===== 中断开关 ⚠️ 必须是数组!=====
    stopping_criteria: [stopping_criteria],
    // ❌ 写 stopping_criteria(单对象)→ 不生效
    // ✅ 写 [stopping_criteria](数组)→ 正确

    // 为什么?model.generate() 内部遍历 stopping_criteria:
    //   for (每个停止条件 of stopping_criteria) { 检查是否该停 }
    //
    // 传单个对象 → 遍历不了 → 等于没传 → 打断不生效
    // 包 [ ] 成数组 → 遍历第一个就是遥控器 → 生效

    // ===== 返回值格式 =====
    return_dict_in_generate: true,
    // true  → 返回 { past_key_values, sequences }
    // false → 只返回序列数组
});

do_sample: false --- 贪心解码 vs 随机采样

模型每步不是只算一个"下一个字",而是算出所有可能 token 的概率

erlang 复制代码
"我今天很" → 模型计算下一个 token:
  概率 0.83 → "高"(最可能的字)
  概率 0.10 → "好"
  概率 0.04 → "累"
  概率 0.02 → "帅"
  ...
模式 do_sample 怎么选 结果特点
贪心解码 false 每次选概率最高的 稳定、确定、不走样
随机采样 true 按概率随机挑 更有创意,但可能跑偏

对于代码生成、数学推理、翻译等需要精确结果的场景,贪心解码更可靠(你的项目就是这个场景)。写诗、聊天等需要多样性的场景才用随机采样。

如果设 do_sample: true,通常会配合 temperature(温度,控制随机程度)和 top_k/top_p(限制候选范围)。但注释掉的那些参数在 do_sample: false 时无效。

9.2 返回值解构

js 复制代码
const { past_key_values, sequences } = await model.generate({ ... });
字段 类型 说明
past_key_values 对象 本轮对话的 KV 缓存 → 存到 past_key_values_cache 下轮用
sequences 二维数组 完整 token 序列(输入 + 输出的 token ID)→ batch_decode 用

9.3 batch_decode --- 全量解码(冗余)

js 复制代码
const decoded = tokenizer.batch_decode(sequences, {
    skip_special_tokens: true,
});
self.postMessage({ status: "complete", output: decoded });

生成完成后一次性解码全部。但问题来了:streamer 已经在生成中逐个发了全部文字

scss 复制代码
streamer 回调(生成中):         batch_decode(生成后):
  cb("今")  → postMessage("update","今")    "今天天气很好"
  cb("天")  → postMessage("update","天")    ↓
  cb("很")  → postMessage("update","很")    postMessage("complete", "今天天气很好")
  cb("好")  → postMessage("update","好")    ↑ 和上面四行拼起来一样,重复了

冗余complete 只发个信号就够:

js 复制代码
self.postMessage({ status: "complete" });
// 主线程收到 → 结束 streaming 状态 → isRunning = false

model.generate() 调用链(本节局部)

kotlin 复制代码
await model.generate({ ...inputs, streamer, stopping_criteria, ... })
  │
  │  ┌── 内部循环:for (每个 token) ──────────────────────┐
  │  │  算 token → streamer.put(token)                   │
  │  │         → 检查 stopping_criteria → 该停?→ break   │
  │  │         → 解码 → 回调 → postMessage("update")      │
  │  └─────────────────────────────────────────────────┘
  │
  └→ return { past_key_values, sequences }

  past_key_values_cache = past_key_values  ← 存缓存,下轮用
  self.postMessage({ status: "complete" })  ← 通知主线程结束

十、📦 generate() 函数全貌(七步)

为什么需要这一章? 前面章节把每个概念拆开讲了------遥控器、KV缓存、分词、双回调、streamer。它们是独立零件,读者可能不清楚如何拼成完整函数。本章把 generate() 的完整代码一次性展示,目的是把前面所有章节串联成一整段闭环代码,让你看到闭包变量、回调、streamer、参数是如何在同一函数里协同工作的。

js 复制代码
async function generate(messages) {
    // ① 取出分词器和模型(单例,不重复下载)
    const [tokenizer, model] = await TextGenerationPipeline.getInstance();

    // ② 分词:JSON 对话数组 → 套 DeepSeek 模板 → 转数字 ID
    const inputs = tokenizer.apply_chat_template(messages, {
        add_generation_prompt: true,    // 末尾加 <|Assistant|>,引导回答
        return_dict: true,              // 返回 { input_ids, attention_mask }
    });

    // ③ 查思考标签对应的数字 ID
    const [START_ID, END_ID] = tokenizer.encode("<think></think>", {
        add_special_tokens: false,
    });

    // ④ 定义双回调 + 创建 streamer
    let state = "thinking", numTokens = 0, startTime, tps;
    const token_cb = (tokens) => {
        startTime ??= performance.now();
        if (numTokens++ > 0) {
            tps = (numTokens / (performance.now() - startTime)) * 1000;
        }
        if (tokens[0] == END_ID) state = "answering";
    };
    const cb = (output) => {
        self.postMessage({ status: "update", output, tps, numTokens, state });
    };
    const streamer = new TextStreamer(tokenizer, {
        skip_prompt: true,
        skip_special_tokens: true,
        callback_function: cb,
        token_callback_function: token_cb,
        stopping_criteria,
    });

    // ⑤ 通知主线程:开始生成
    // 必须在 model.generate() 之前发!
    // → 主线程收到 → isRunning = true → 按钮切"停止" → 用户可以中断
    self.postMessage({ status: "start" });

    // ⑥ 调用模型推理(流式 + KV缓存 + 可中断)
    // await 期间 streamer 回调持续触发 → 逐字推送 "update" 给主线程
    const { past_key_values, sequences } = await model.generate({
        ...inputs,
        past_key_values: past_key_values_cache,
        do_sample: false,
        max_new_tokens: 2048,
        streamer,
        stopping_criteria: [stopping_criteria],
        return_dict_in_generate: true,
    });
    //  ← await 解除时,流式回调已全部跑完

    // ⑦ 存 KV 缓存 + 通知完成
    past_key_values_cache = past_key_values;
    self.postMessage({ status: "complete" });
}

十一、⏱️ 完整数据流时间线

javascript 复制代码
主线程
  │ 用户输入"你好" → onEnter → setMessages 追加
  │ useEffect → 两个守卫通过 → postMessage("generate", messages)
  │
  ▼
Worker onMessageReceived
  │
  ├─ case "generate":
  │     stopping_criteria.reset()           ← 遥控器归零
  │     generate(data)                      ← data = 整个 messages 数组
  │       │
  │       ├─ ① getInstance()                    秒取(??= 单例)
  │       ├─ ② apply_chat_template              JSON → 数字 ID
  │       ├─ ③ encode("<think></think>")        查思考标签 ID
  │       ├─ ④ 创建双回调 + TextStreamer        注册处理管道
  │       ├─ ⑤ postMessage("start")             通知主线程
  │       │
  │       └─ ⑥ await model.generate({...streamer...})
  │              │
  │              │  ┌── Transformers.js 内部循环(你看不到)──┐
  │              │  ├─ 算 token → streamer.put(token)         │
  │              │  │   ├─ token_cb([10341])    → TPS 更新    │
  │              │  │   ├─ tokenizer.decode()   → "今"        │
  │              │  │   ├─ callback("今")        → postMessage│
  │              │  │   │   status: "update", output: "今"    │
  │              │  │   └─ 检查 stopping_criteria             │
  │              │  ├─ 算 token → ... → postMessage("update", "天")
  │              │  ├─ 算 token → ... → postMessage("update", "很")
  │              │  ├─ 算 token → ... → postMessage("update", "好")
  │              │  ├─ token[0] == END_ID → state = "answering"
  │              │  ├─ ...继续回答部分...
  │              │  └─ token == EOS → break
  │              │
  │              └─ return { past_key_values, sequences }
  │                   │
  │                   ├─ ⑦ past_key_values_cache = past_key_values(存缓存)
  │                   └─ ⑧ postMessage("complete")(通知结束)
  │
  ▼
主线程 onMessageReceived
  ├─ case "start"    → isRunning = true,按钮切停止
  ├─ case "update"   → 逐字追加到 messages 最后一条 assistant
  ├─ case "complete" → isRunning = false,按钮切发送
  └─ case "error"    → 显示错误

══════════ 中断分支 ══════════

用户点停止
  → postMessage("interrupt")
    → stopping_criteria.interrupt()
      → 内部标记 = true
        → 模型下一圈检测到 → 循环 break → return
          → past_key_values 是脏数据 → 需要 reset 时清掉

══════════ 重置分支 ══════════

用户点重置
  → postMessage("reset")
    → past_key_values_cache = null    → 忘掉所有对话
    → stopping_criteria.reset()      → 遥控器归零
    → 下一轮从头开始

十二、📝 小结

模块 核心知识点
🎮 InterruptableStoppingCriteria 遥控器类比 / 你设标记、库检查 / reset + interrupt / 模块级共享 / 不复位=卡死
📝 past_key_values_cache GPU 显存草稿纸 / 缓存命中加速 / 输入输出一起缓存 / 三种必须清空的场景
💬 消息协议 主→Worker 5 种 type / Worker→主 8 种 status / e.data === x
🚪 App.tsx 入口 onEnter 函数式更新 / useEffect 双守卫 / 传整个 messages 非单条
📋 apply_chat_template 三步:模板→分词→末尾加标记 / add_generation_prompt / return_dict / JSON→XML
🧠 思考标签 encode 纯查词表 / START/END 各占一个 token / state 切换 / 模型自己输出
🔄 双回调 token_cb 记账(TPS+状态)/ cb 发送 / 闭包共享 / 先记账后发送
📡 TextStreamer 自回归循环原理 / 推送模式 / 库内 decode / 管道不是数据
⚙️ model.generate() 参数逐个说明 / stopping_criteria 必须数组 / 返回值 past_key_values+sequences
📦 batch_decode 全量解码 / 和 streamer 重复 → 冗余可删

代码职能回顾

代码块 职能 对应章节
new InterruptableStoppingCriteria() 创建中断遥控器
stopping_criteria.interrupt() 按停
stopping_criteria.reset() 复位(每次推理前必须调用)
past_key_values_cache KV 缓存(GPU 显存草稿纸)
case "generate" 启动推理入口
case "interrupt" 中断入口
case "reset" 重置入口
onEnter 追加用户消息到 messages
useEffect 双守卫 自动触发推理
apply_chat_template JSON → 数字 ID
encode("<think></think>") 查思考标签 ID
token_callback_function 记账(TPS / state 切换)
callback_function 发送(postMessage update)
new TextStreamer(...) 流式管道
model.generate({ stopping_criteria: [sc] }) 必须数组!
past_key_values_cache = past_key_values 存缓存供下轮命中

💼 面试要点总结

面试中:个人介绍 → 聊项目(WebGPU-deepseek)→ 顺势展开:

1. 中断机制怎么实现的?

"Transformers.js 提供了 InterruptableStoppingCriteria 类。我创建一个实例,在 generate case 里传给 model.generate(),在 interrupt case 里调 interrupt() 方法。模型每生成一个 token 就会检查这个标记,标记为 true 就停止生成。关键点:stopping_criteria 参数必须是数组 ,否则遍历不了,中断不生效。每次推理前还要调 reset() 复位,否则上次中断的脏标记会导致下一次推理卡死。"

2. 多轮对话为什么越聊越快?

"大模型每算一个新 token 都需要前面所有 token 的 Attention 结果。如果每次从头算,对话越长越慢。我用 past_key_values 做 KV 缓存------第一轮从头算,把中间结果存 GPU 显存;第二轮追问时,前文直接命中缓存,只算新 token。追问比首轮快很多。缓存是模块级变量,所有对话共享。重置或中断时清空,避免命中脏数据。"

3. 流式输出是怎么实现的?

"模型生成是自回归循环------一个 token 一个 token 往外蹦。Transformers.js 的 TextStreamer 在循环内部每算出一个 token 就触发回调。我注册了两个回调:一个处理原始 token ID(记账、更新 TPS、检测思考标签),一个处理解码后的文字(postMessage 推给主线程渲染)。await model.generate() 等的是整个函数返回,但回调在循环内部就已经触发了------不需要等全部生成完,实现了打字机效果。"

4. DeepSeek-R1 的思考标签怎么处理的?

"DeepSeek-R1 输出分两段:<think> 内部推理和 </think> 正式回答。我用 tokenizer.encode("<think></think>") 提前查出这两个标记的 token ID。在 token 回调里,每次收到 token 就检查是否等于 </think> 的 ID,匹配则把 statethinking 切到 answering<think> 不需要检测,因为初始状态就是 thinking。"

5. apply_chat_template 干了什么?

"它把 JSON 对话数组转成模型能吃的数字 ID。三步:① 套角色标记模板(<|User|> / <|Assistant|>),② 分词查词表转数字,③ add_generation_prompt: true 在末尾追加 <|Assistant|>,告诉模型'该你说话了'。不同模型的标记格式不同(Qwen 用 <|im_start|>,Llama 用 [INST]),apply_chat_template 根据 tokenizer_config.json 自动适配,换模型代码不用改。"

6. 为什么 complete 只发信号不传文字?

"TextStreamer 在生成过程中已经通过 update 事件把每个 token 的文字都推给主线程了。batch_decode 全量解码出来的完整文字和流式拼接的结果完全重复,只是多了个冗余字段。所以 complete 只发一个结束信号,主线程收到后切 isRunning = false,不传文字。"

7. useEffect 两个守卫分别防止什么?

"第一个守卫防止空消息触发推理------messages 初始为空,useEffect 会在挂载时执行一次,没有守卫会发空消息。第二个守卫防止重复推理------用户消息追加后触发推理,生成完 messages 最后一条变成 assistant,如果不加守卫会再次触发推理,造成死循环。"

8. stopping_criteria 为什么必须是数组?

"model.generate() 内部遍历 stopping_criteria 参数------它期望的是一个数组,用 for 循环遍历每个条件检查是否该停。如果传单个对象,遍历不了,等于没传,中断不生效。必须包成 [stopping_criteria]。这是 Transformers.js API 的一个易错点。"

9. 中断后 KV 缓存为什么是脏数据?

"中断发生时,模型生成了半截推理的中间状态------past_key_values 里只缓存了部分 token 的计算结果。下一轮如果不清空直接复用,模型会基于不完整的上下文继续生成,输出混乱。所以中断后必须 past_key_values_cache = null。这也是为什么重置逻辑里要同时清缓存和复位遥控器。"

10. apply_chat_template 自动适配多模型

"不同模型的对话标记格式不同------Qwen 用 <|im_start|>,DeepSeek 用 <|User|>,Llama 用 [INST]apply_chat_template 根据模型仓库里的 tokenizer_config.json 自动选择正确的模板。换模型时我只需要改 model_id,分词代码一行不用动,架构具备扩展性。"

11. 首轮 vs 追问:缓存命中的性能差异

"首轮对话:past_key_values = null,模型从头算所有 token,耗时较长。追问时:past_key_values 有值,前文直接命中缓存,只算新增 token。实测首轮约 3-5 秒出第一个 token,追问约 1-2 秒。缓存命中是对话应用体验的关键优化点。"

💡 学习方法

看《你不知道的 JavaScript》、掘金等社区,关注 AI 博主,读 GitHub 源码,学习高质量开源代码,输出内容到社区。

相关推荐
GuWenyue1 小时前
后端接口又双叒没写好?3 步搭建前端 Mock,从此告别"傻等后端"
前端·mocha
海兰1 小时前
【数据采集】开源的 Web 数据采集与处理Firecrawl(一)
前端·开源
vivo互联网技术1 小时前
从一键检测到 AI 修复:我们如何把无障碍检查做进研发流程
前端·人工智能
liuxiaocheng1 小时前
聊聊 Vercel AI SDK 的流式协议:前后端到底是怎么"边想边说"的
前端·后端
胡萝卜术1 小时前
在浏览器中跑 DeepSeek-R1:WebGPU 推理全流程深度解析
前端·javascript·面试
贵慜_Derek1 小时前
vLLM-07|MegaMoE 与 FusedMoE:路由相同,算 expert 完全不同
人工智能·算法·llm
windliang1 小时前
Claude Code 源码分析(十):MCP 外部工具如何进入下一轮 Agent 调用
前端·算法·面试
Yan_chen6662 小时前
CTFHub XSS反射型实战攻略
前端·网络安全·漏洞·xss·ctfhub web前置技能
阿弱2 小时前
从Plan-Execute到混合PEV:一个运维诊断Agent的架构演进实录
llm·agent