📌 上篇回顾 :我们完成了下载进度条和聊天界面------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() 就是把内部的 interrupted 从 false 改成 true。不需要你知道具体字段名,只要知道调方法就能改。
1.4 三个方法
js
const stopping_criteria = new InterruptableStoppingCriteria();
stopping_criteria.interrupt(); // 喊停:内部标记 = true
stopping_criteria.reset(); // 复位:内部标记 = false("一切正常")
// stopping_criteria.shouldStop() --- 库内部自调用,你不用管
interrupt() 和 reset() 是同一个对象的两个方法,一个开、一个关。
1.5 为什么放模块顶层
generate 和 interrupt 两个 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()
三个原因:
- 时机正确:streamer 和双回调已就绪,主线程可以放心进入"生成中"状态
- 按钮联动 :主线程收到
start→isRunning = true→ 按钮从"发送"切"停止" - 先切状态再推理:如果在 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 后,做两件事:
- 先用原始数字更新内部状态(TPS、思考/回答切换)
- 再解码成文字发给主线程渲染
两个回调各司其职:
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内部(参数传入) | 回调②(发主线程) | ---(不是闭包变量) |
执行顺序 :先回调①(记账),再回调②(发送)。所以发送时 tps、state 已经是回调①刚更新过的值。
双回调执行流程(本节局部)
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,匹配则把 state 从 thinking 切到 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 源码,学习高质量开源代码,输出内容到社区。