在浏览器里运行 DeepSeek-R1:推理、流式输出与停止生成(二)
上一篇完成了模型下载、单例加载和 WebGPU 预热。现在浏览器里的模型已经处于 ready 状态,但"用户输入一句话,页面逐字显示回答"仍然要经过一条完整的数据链路:
text
用户输入
-> React 维护 messages
-> Worker 接收 generate
-> 分词器应用聊天模板
-> 模型逐 token 生成
-> TextStreamer 增量解码
-> Worker 连续发送 update
-> React 追加到最后一条助手消息
这一篇重点拆解推理侧:对话历史如何组织、<think> 与 </think> 如何帮助区分思考和回答、流式输出怎样实现、tokens/second 怎样计算,以及用户点击停止按钮后到底发生了什么。
一、对话历史为什么要用消息数组
聊天数据使用最简单的角色消息结构:
js
[
{ role: "user", content: "Solve x^2 - 3x + 2 = 0" },
{ role: "assistant", content: "..." },
{ role: "user", content: "Explain the second root" },
]
用户发送消息时,不是直接调用模型,而是先更新 React 状态:
tsx
function onEnter(message: string) {
setMessages((prev) => [
...prev,
{ role: "user", content: message },
]);
setTps(null);
setIsRunning(true);
setInput("");
}
这样做把"用户操作"和"副作用"拆开了。点击按钮与按 Enter 最终都走 onEnter,而真正向 Worker 发消息的逻辑可以统一观察 messages:
tsx
useEffect(() => {
if (messages.filter((item) => item.role === "user").length === 0) {
return;
}
if (messages.at(-1)?.role === "assistant") {
return;
}
worker.current?.postMessage({
type: "generate",
data: messages,
});
}, [messages, isRunning]);
两个提前返回条件很重要:
- 没有用户消息时不能生成;
- 最后一条已经是助手消息时不能再次生成。
第二个条件还阻止了流式输出造成的循环。每收到一段新文本,messages 都会更新;如果不判断最后一个角色,每一个增量片段都可能再次触发一次 generate。
依赖中还包含 isRunning。生成完成时它会变成 false,Effect 虽然再次执行,但此时最后一条消息来自助手,因此会立即返回。
二、Worker 如何接收生成命令
Worker 只有一个消息入口:
js
self.addEventListener("message", async (event) => {
const { type, data } = event.data;
switch (type) {
case "check":
check();
break;
case "load":
load();
break;
case "generate":
stopping_criteria.reset();
generate(data);
break;
case "interrupt":
stopping_criteria.interrupt();
break;
case "reset":
past_key_values_cache = null;
stopping_criteria.reset();
break;
}
});
一套 switch 把模型的生命周期动作集中起来。每次生成前先重置停止条件,否则上一次的中断状态可能影响下一轮生成。
需要注意,load() 与 generate() 都是异步函数,这里没有用 await 阻塞消息监听。Worker 触发任务后仍可继续接收消息,这正是生成过程中还能响应 interrupt 的基础。
三、聊天模板:从 messages 到模型输入
生成函数先取得已经加载好的单例:
js
async function generate(messages) {
const [tokenizer, model] =
await TextGenerationPipeline.getInstance();
const inputs = tokenizer.apply_chat_template(messages, {
add_generation_prompt: true,
return_dict: true,
});
// 后续执行生成......
}
messages 是方便应用维护的结构化数据,模型真正接收的则是 token 化后的输入。apply_chat_template 同时完成两件事:
- 按模型对应的聊天模板组织不同角色的内容;
- 把结果转换为可传给
model.generate()的输入对象。
add_generation_prompt: true 表示在历史消息末尾加入开始生成助手内容所需的提示;return_dict: true 让结果以对象形式返回,后面可以直接通过展开运算符传入生成函数。
js
await model.generate({
...inputs,
max_new_tokens: 2048,
});
这里不要自己把 user:、assistant: 等文本随意拼起来。分词器已经和模型 ID 配套,聊天模板也应该交给它处理。
四、识别思考结束的特殊 token
DeepSeek-R1 的输出包含思考段与回答段。为了在界面中分别展示两部分,需要找到它们的边界。
先用当前分词器编码特殊标记:
js
const [START_THINKING_TOKEN_ID, END_THINKING_TOKEN_ID] =
tokenizer.encode("<think></think>", {
add_special_tokens: false,
});
当前分段逻辑真正需要的是结束标记 END_THINKING_TOKEN_ID。开始时直接把状态设为 thinking:
js
let state = "thinking";
const token_callback_function = (tokens) => {
if (tokens[0] === END_THINKING_TOKEN_ID) {
state = "answering";
}
};
因此状态转换只有一条路径:
text
thinking --遇到 </think> 对应 token--> answering
这里比较的是 token ID,而不是在不断增长的字符串中搜索 </think>。分词器知道特殊标记如何编码,用同一个分词器得到边界 ID,判断会和当前模型保持一致。
START_THINKING_TOKEN_ID 在当前流程里没有参与判断,因为状态默认已经是 thinking。保留这个变量可以表达两个特殊 token 的含义,但若要求代码通过严格的未使用变量检查,也可以只取第二项:
js
const [, END_THINKING_TOKEN_ID] = tokenizer.encode(
"<think></think>",
{ add_special_tokens: false },
);
五、TextStreamer 怎样把一次生成变成连续更新
如果等待 model.generate() 全部完成以后再解码,用户只能长时间看到空白,然后一次性得到完整答案。TextStreamer 把新 token 增量解码成文本,使 Worker 可以边生成边发送。
js
import { TextStreamer } from "@huggingface/transformers";
const streamer = new TextStreamer(tokenizer, {
skip_prompt: true,
skip_special_tokens: true,
callback_function,
token_callback_function,
});
这两个回调分工不同:
token_callback_function接收 token,用于识别思考边界和计算速度;callback_function接收已经转换为文本的增量输出,用于发给页面。
skip_prompt: true 避免把用户输入再输出一遍;skip_special_tokens: true 跳过特殊 token 的文本展示。
文本回调只负责组织消息:
js
const callback_function = (output) => {
self.postMessage({
status: "update",
output,
tps,
numTokens,
state,
});
};
每个 update 同时带回四类信息:新增文本、当前生成速度、已生成 token 数量、当前处于思考还是回答阶段。
在正式调用模型前,Worker 先通知主线程创建一条空的助手消息:
js
self.postMessage({ status: "start" });
React 收到后执行:
tsx
case "start":
setMessages((prev) => [
...prev,
{ role: "assistant", content: "" },
]);
break;
之后每个 update 只需要修改数组最后一项,而不是反复创建新的助手气泡。
六、在 React 中安全地追加流式文本
主线程收到更新后,先解构性能和状态数据:
tsx
const { output, tps, numTokens, state } = event.data;
setTps(tps);
setNumTokens(numTokens);
再用不可变更新的方式追加文本:
tsx
setMessages((prev) => {
const cloned = [...prev];
const last = cloned.at(-1);
const next = {
...last,
content: last.content + output,
};
if (
next.answerIndex === undefined &&
state === "answering"
) {
next.answerIndex = last.content.length;
}
cloned[cloned.length - 1] = next;
return cloned;
});
这里没有直接修改 prev,而是复制数组和最后一条消息。React 依靠新的引用识别状态变化,这种写法也避免污染先前状态。
answerIndex 是展示层最需要的数据。它记录最终回答在完整文本中的起始位置:
text
content = [思考文本................][回答文本........]
↑
answerIndex
当 Worker 第一次报告 state === "answering" 时,把"追加本次输出之前的文本长度"保存为分界点。后续即使继续收到回答内容,也不会再次覆盖这个位置。
七、tokens/second 是怎样计算的
生成速度在 token 回调里计算:
js
let startTime;
let numTokens = 0;
let tps;
const token_callback_function = (tokens) => {
startTime ??= performance.now();
if (numTokens++ > 0) {
tps =
(numTokens / (performance.now() - startTime)) * 1000;
}
if (tokens[0] === END_THINKING_TOKEN_ID) {
state = "answering";
}
};
performance.now() 返回毫秒级时间,所以最后乘以 1000 换算为每秒 token 数:
text
tps = token 数 / 已用毫秒 × 1000
第一次回调只记录起始时间,拥有足够时间差后才计算速度。页面在生成过程中显示当前 tps;生成完成后,还可以根据 numTokens / tps 得到近似耗时:
tsx
Generated {numTokens} tokens in
{(numTokens / tps).toFixed(2)} seconds
({tps.toFixed(2)} tokens/second)
这个值适合做当前会话的直观性能反馈。
八、完整的生成调用
准备好输入、Streamer 与停止条件后,就可以调用模型:
js
const { past_key_values, sequences } = await model.generate({
...inputs,
do_sample: false,
max_new_tokens: 2048,
streamer,
stopping_criteria,
return_dict_in_generate: true,
});
当前参数有几个明确特点:
do_sample: false:不启用采样;max_new_tokens: 2048:限制本轮最多新增 token 数;streamer:把生成过程变成增量文本回调;stopping_criteria:允许外部请求停止;return_dict_in_generate: true:让返回值包含序列与缓存等字段。
代码中也预留了采样配置:
js
// repetition_penalty: 1.1,
// top_k: 3,
// temperature: 0.2,
因为它们当前被注释,所以实际执行的仍是非采样生成。讲解运行行为时,必须以启用的参数为准。
生成结束后还会完整解码序列:
js
const decoded = tokenizer.batch_decode(sequences, {
skip_special_tokens: true,
});
self.postMessage({
status: "complete",
output: decoded,
});
页面正文已经通过 update 持续拼接,所以 complete 的主要作用是结束运行状态;完整解码结果虽然随消息返回,当前界面没有再次覆盖已流式生成的内容。
九、停止生成为什么不是直接终止 Worker
如果直接调用 worker.terminate(),模型实例和 Worker 内的全部状态都会消失,再次对话需要重新创建 Worker,代价太大。
更合适的方式是使用可中断停止条件:
js
import {
InterruptableStoppingCriteria,
} from "@huggingface/transformers";
const stopping_criteria =
new InterruptableStoppingCriteria();
用户点击停止按钮,主线程只发送命令:
tsx
function onInterrupt() {
worker.current?.postMessage({ type: "interrupt" });
}
Worker 收到后改变停止条件:
js
case "interrupt":
stopping_criteria.interrupt();
break;
模型生成下一个 token 时会检查停止条件,随后正常退出生成流程,并发送 complete。因此点击停止时,React 不急着把 isRunning 改为 false,而是等待 Worker 确认真正完成:
tsx
case "complete":
setIsRunning(false);
break;
这能避免界面先显示"已停止",Worker 却仍在生成的状态错位。
十、KV Cache:当前代码做了什么,又没有做什么
自回归生成会返回 past_key_values。当前代码把它保存下来:
js
let past_key_values_cache = null;
const { past_key_values, sequences } = await model.generate({
...inputs,
// past_key_values: past_key_values_cache,
// ...
});
past_key_values_cache = past_key_values;
但是传入下一次生成的这一行仍被注释:
js
// past_key_values: past_key_values_cache,
所以当前实现只是保存了缓存,并没有实际复用。重置操作会把它清空:
js
case "reset":
past_key_values_cache = null;
stopping_criteria.reset();
break;
这个边界必须说清楚:代码已经预留 KV Cache 变量和重置入口,但当前多轮对话仍然通过完整的 messages 重新构造输入,不能把"已经保存变量"误写成"已经实现缓存加速"。
页面点击 Reset 时还会清空可见消息:
tsx
worker.current?.postMessage({ type: "reset" });
setMessages([]);
Worker 状态和 React 状态同时重置,新的对话就不会混入旧历史。
十一、本篇小结
浏览器中的流式推理并不是一个黑盒调用,而是一组边界明确的步骤:
- React 用角色消息数组保存完整对话;
apply_chat_template把消息变成模型输入;TextStreamer分别提供 token 回调与文本回调;</think>对应的 token 把状态从thinking切换到answering;- Worker 连续发送
update,React 只追加最后一条助手消息; answerIndex记录思考与最终回答的边界;InterruptableStoppingCriteria在不销毁 Worker 的情况下停止生成;- KV Cache 已有存储结构,但当前还没有传回下一次生成中复用。
下一篇将回到展示层:如何让输入框自动增高,怎样控制聊天区域"贴底滚动",为什么模型返回的 Markdown 必须先净化,以及如何把推理过程、最终答案和数学公式组合成一个安全、清晰的聊天界面。