在浏览器里运行 DeepSeek-R1:推理、流式输出与停止生成(二)

在浏览器里运行 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]);

两个提前返回条件很重要:

  1. 没有用户消息时不能生成;
  2. 最后一条已经是助手消息时不能再次生成。

第二个条件还阻止了流式输出造成的循环。每收到一段新文本,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 状态同时重置,新的对话就不会混入旧历史。

十一、本篇小结

浏览器中的流式推理并不是一个黑盒调用,而是一组边界明确的步骤:

  1. React 用角色消息数组保存完整对话;
  2. apply_chat_template 把消息变成模型输入;
  3. TextStreamer 分别提供 token 回调与文本回调;
  4. </think> 对应的 token 把状态从 thinking 切换到 answering
  5. Worker 连续发送 update,React 只追加最后一条助手消息;
  6. answerIndex 记录思考与最终回答的边界;
  7. InterruptableStoppingCriteria 在不销毁 Worker 的情况下停止生成;
  8. KV Cache 已有存储结构,但当前还没有传回下一次生成中复用。

下一篇将回到展示层:如何让输入框自动增高,怎样控制聊天区域"贴底滚动",为什么模型返回的 Markdown 必须先净化,以及如何把推理过程、最终答案和数学公式组合成一个安全、清晰的聊天界面。

相关推荐
用户2181697049301 小时前
Flutter (二十五)视频播放
前端
飞哥数智坊1 小时前
TRAE Code 接入 DeepSeek Vision 实测
人工智能·deepseek·trae
绿岛之北1 小时前
Electron 安全第四章: Preload 与 IPC
前端·electron
今天AI了吗1 小时前
AI Agent 在数据分析领域的落地判断:哪些场景真的需要 Agent
java·数据库·人工智能·python·sql·数据分析·copilot
两万五千个小时1 小时前
DeepSeek Harness 从 0 开始:16 scope 域(作用域隔离)
人工智能·程序员·架构
cspttty1 小时前
会计专业大学期间考什么证
大数据·数据库·人工智能·数据挖掘
小僧景贤1 小时前
从零实战!ESP32-S3 搭建AI交互机器人终端(小智AI平台完整版教程)
人工智能·机器人·二次开发·零基础实战
二川bro1 小时前
NVIDIA开源NeMo Switchyard!多模型路由,但是离生产还差一步
人工智能
半个落月1 小时前
在浏览器里运行 DeepSeek-R1:从 WebGPU 检测到模型加载(一)
前端·人工智能·react.js