Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions apps/course/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
"order": 1,
"title": "文字怎样变成 Token?",
"outcome": "观察同一句话如何分成片段并映射为编号;区分字符、词和 Token。",
"status": "planned",
"route": null,
"status": "reviewed",
"route": "/learn/tokenization/",
"published": false
},
{
Expand Down
25 changes: 25 additions & 0 deletions apps/site/learn/tokenization/LESSON.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# Token 分词教学场景

核心问题:一个词如何按已有规则变成编号序列?46秒从固定英文词开始,展开基础字符,按三条规则合并相邻片段,检查Token边界,查表得到ID,最后解码还原。

表现:上方刻度保留输入字符,下方片段宽度准确覆盖字符跨度;颜色取自片段起点,合并后边界改变而输入不变。宽度不编码概率。编号沿原顺序出现,指引跟随当前片段。

规则来自 [Hugging Face BPE 教学章节](https://huggingface.co/learn/llm-course/en/chapter6/5) 的已训练示例:u+g、u+n、h+ug。本实现为独立教学代码。词表编号从0起设定,不是生产模型ID。切换0–3条规则代表不同教学版本,绝不是对每个输入重新训练。本节不实现字节级编码、空格处理、中文或特殊Token,不运行语言模型。

反事实实验:固定 hugs,规则0条得到4个Token;规则1条得到3个;规则3条得到hug|s两个Token。字符数一直为4,解码一直为hugs。无匹配的u+n规则在hugs上是无操作。

迁移检验:输入unhug,先预测有几个Token,再解释u|n|h|ug → un|h|ug → un|hug,最后在图中验证[8,9]。错误答案提供中间步骤。

测试命令:node apps/site/learn/tokenization/model.test.mjs。独立黄金值覆盖全部五个词;检查所有规则版本与每0.1秒状态下的字符跨度守恒、顺序、解码、反向跳转、无匹配规则和输入范围。

教学评审:角色、规则、输出均可见;分词和理解/生成明确区分;提供受控实验与新词预测。这是开发者教学适配评审,没有真实学习者效果研究。浏览器验收在发布前补充。

复用请求:用单舞台、浅色细线的可操作图解,解释一个词如何按固定BPE合并规则变成编号。每次只解释当前对象,能暂停、倒退、换词和切换规则版本;输出来源、简化边界和独立验证。

## 2026-10-27 验收记录

独立模型测试和全站npm run check通过,既有721条行星约束没有回归。桌面完整播放到46/46恢复配置;正反向跳转能清除/重建片段和编号;390×844手机截图核对正文、片段、ID与解码均可读。手机上的错误与正确预测都能给出推导,验证按钮返回unhug、规则3条和[8,9]。已修复SVG留白导致的箭头偏差、片段表面的多余对角线,并让无匹配规则明确显示跳过。

静态阶段按阶段/规则/输入缓存SVG,时间轴继续由同一绝对时钟推进;没有每帧重建相同图形。CSS支持减少动态效果,隐藏标签页有暂停处理;本轮云浏览器没有独立实测系统减少动态效果开关和标签页可见性切换,不宣称它们实测通过。没有实体手机、屏幕阅读器或真实学习者研究。

规则滑块实测发现暂停重绘会覆盖事件中的新值,已先捕获输入再暂停;最终浏览器复测通过:0条输出[2,6,1,5]共4片段,3条恢复[9,5]共2片段。
67 changes: 67 additions & 0 deletions apps/site/learn/tokenization/SCENE_SPEC.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
{
"question": "同一个词怎样按固定合并规则变成 Token 编号?",
"durationSeconds": 46,
"objects": [
{
"id": "input",
"meaning": "一个预设英文词"
},
{
"id": "span",
"meaning": "覆盖输入连续字母的片段;宽度表示字母跨度,不表示概率"
},
{
"id": "id",
"meaning": "该教学词表的固定索引"
}
],
"relationships": [
"每轮合并只覆盖相邻匹配片段",
"所有片段拼接始终等于输入",
"同词表解码编号等于输入",
"规则版本改变时Token数量可能改变"
],
"inputs": {
"word": [
"hugs",
"hug",
"pug",
"unhug",
"bun"
],
"limit": [
0,
1,
2,
3
]
},
"outputs": [
"pieces",
"ids",
"decoded",
"trace"
],
"simplifications": [
"已确定规则,非本轮训练",
"预设ASCII英文词;不处理空格、中文、字节级BPE与特殊Token",
"编号与分词结果均非生产模型词表"
],
"sources": [
"https://huggingface.co/learn/llm-course/en/chapter6/5"
],
"acceptance": [
"独立黄金分词与ID",
"字符跨度守恒",
"解码可逆",
"无匹配规则不改变状态",
"反向seek确定性",
"真实桌面/手机验收"
],
"untested": [
"真实学习者效果",
"实体手机",
"独立浏览器引擎",
"屏幕阅读器实机"
]
}
21 changes: 21 additions & 0 deletions apps/site/learn/tokenization/app.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {WORDS,RULES,VOCAB,STARTS,DURATION,sample,narration} from './model.mjs';
import {createStage} from './stage.mjs';
const $=s=>document.querySelector(s),$$=s=>[...document.querySelectorAll(s)],stage=createStage($('#artwork')),motion=matchMedia('(prefers-reduced-motion: reduce)');
let word='hugs',limit=3,time=0,playing=false,raf=0,last=0,lastCopy='',dead=false;
const names=['固定输入','基础符号','按规则合并','Token 边界','查词表编号','解码还原'];
function focus(on){$('#app').classList.toggle('is-playing',on);$$('.secondary').forEach(x=>{x.inert=on;x.setAttribute('aria-hidden',String(on));});}
function anchor(){const box=$('.exhibit').getBoundingClientRect(),bubble=$('#callout').getBoundingClientRect(),art=$('#artwork');art.style.top=Math.max(185,bubble.bottom-box.top+13)+'px';const a=art.getBoundingClientRect(),point=stage.projectAnchor();if(!point)return;const x=a.left-box.left+point.x*a.width,y=a.top-box.top+point.y*a.height,cx=Math.max(bubble.width/2+17,Math.min(box.width-bubble.width/2-17,x));$('#callout').style.left=cx+'px';$('#leader').setAttribute('viewBox',`0 0 ${box.width} ${box.height}`);const sy=bubble.bottom-box.top;$('#leader-line').setAttribute('d',`M${cx} ${sy}C${cx} ${sy+20},${x} ${y-25},${x} ${y-8}`);$('#leader-dot').setAttribute('cx',x);$('#leader-dot').setAttribute('cy',y);}
function render(){if(dead)return;const f=sample(time,{word,limit}),c=narration(f),key=JSON.stringify(c);stage.render(f);if(key!==lastCopy){lastCopy=key;$('#copy-eyebrow').textContent=c.eyebrow;$('#copy-title').textContent=c.title;$('#copy-body').textContent=c.body;}$('#phase-label').textContent=`0${f.phase+1} / 06 · ${names[f.phase]}`;$('#counts').textContent=`${word.length} 字母 · ${f.phase<3?"当前 "+f.current.length+" 片段":f.pieces.length+" Token"}`;$('#rules').value=limit;$('#rule-value').textContent=limit;$$('[data-word]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.word===word)));$('#timeline').value=time;$('#timeline').style.setProperty('--fill',`${time/DURATION*100}%`);$('#time').textContent=`00:${String(Math.floor(time)).padStart(2,'0')} / 00:46`;$('#play-label').textContent=playing?'暂停':time>=DURATION?'再看一遍':time?'继续讲解':'播放讲解';$('#play-icon').textContent=playing?'Ⅱ':'▶';$('#play').setAttribute('aria-pressed',String(playing));$('#back').disabled=time===0;$('#next').disabled=time===DURATION;$('#play-note').textContent=playing?'配置已收起 · 空格暂停':'六个步骤 · 片段与编号';$('#challenge').hidden=time<DURATION;anchor();}
function advance(now){if(playing){time=Math.min(DURATION,time+Math.max(0,now-last)/1000);last=now;}}
function stop(capture=true){if(capture)advance(performance.now());playing=false;cancelAnimationFrame(raf);focus(false);render();}
function tick(now){if(!playing)return;advance(now);render();if(time===DURATION)stop(false);else raf=requestAnimationFrame(tick);}
function play(){if(playing){stop();return;}if(time===DURATION)time=0;$('#play').focus({preventScroll:true});playing=true;focus(true);last=performance.now();render();raf=requestAnimationFrame(tick);}
function seek(t){stop();time=Math.max(0,Math.min(DURATION,t));render();}
$('#play').onclick=play;$('#timeline').oninput=e=>seek(+e.target.value);$('#back').onclick=()=>seek(STARTS.filter(t=>t<time-.1).at(-1)??0);$('#next').onclick=()=>seek(STARTS.find(t=>t>time+.1)??DURATION);$('#reset').onclick=()=>{stop();time=0;word='hugs';limit=3;render();};
$$('[data-word]').forEach(b=>b.onclick=()=>{stop();word=b.dataset.word;render();});$('#rules').oninput=e=>{const value=+e.target.value;stop();limit=value;render();};$('#experiment').onclick=()=>{seek(30);$('#rules').focus();};
function info(){stop();$('#info').showModal();}$('#about').onclick=info;$('#show-rules').onclick=info;$('#close-info').onclick=()=>$('#info').close();
$('#challenge').onclick=()=>{stop();$('#feedback').textContent='';$('#quiz-try').hidden=true;$$('[data-answer]').forEach(b=>b.removeAttribute('aria-pressed'));$('#quiz').showModal();};
$$('[data-answer]').forEach(b=>b.onclick=()=>{$$('[data-answer]').forEach(x=>x.setAttribute('aria-pressed',String(x===b)));$('#feedback').textContent=(+b.dataset.answer===2?'预测正确。':'会得到 2 个 Token。')+'先得到 u | n | h | ug,再合并成 un | h | ug,最后得到 un | hug,编号是 [8, 9]。字母数仍然是 5。';$('#quiz-try').hidden=false;});
$('#quiz-try').onclick=()=>{$('#quiz').close();word='unhug';limit=3;time=46;render();};$('#close-quiz').onclick=()=>$('#quiz').close();
document.addEventListener('visibilitychange',()=>{if(document.hidden&&playing)stop();});document.addEventListener('keydown',e=>{if($('#info').open||$('#quiz').open)return;if(e.key==='Escape')stop();if(e.code==='Space'&&!['INPUT','BUTTON','A'].includes(e.target.tagName)){e.preventDefault();play();}});
const observer=new ResizeObserver(()=>{stage.resize();anchor();});observer.observe($('#artwork'));$('.exhibit').addEventListener('transitionend',anchor);window.addEventListener('pagehide',e=>{stop();if(!e.persisted){dead=true;observer.disconnect();stage.dispose();}});render();
2 changes: 2 additions & 0 deletions apps/site/learn/tokenization/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>文字怎样变成 Token? · Explain3D</title><link rel="stylesheet" href="./style.css"><script type="module" src="./app.mjs"></script></head><body><main class="app" id="app"><header><a class="brand" href="/learn/"><i>e·</i> Explain3D</a><span>AI 原理课堂 / 01</span><button class="quiet" id="about">规则与边界 ↗</button></header><section class="intro secondary"><div><p class="eyebrow">FROM TEXT TO TOKEN IDS</p><h1>文字,<span>怎样变成 Token?</span></h1></div><p class="intro-copy">看见片段的边界,再查到对应编号。</p></section><section class="controls secondary" aria-label="分词实验"><div class="presets" role="group" aria-label="选择输入词"><button data-word="hugs">hugs</button><button data-word="hug">hug</button><button data-word="pug">pug</button><button data-word="unhug">unhug</button><button data-word="bun">bun</button></div><span class="divider"></span><label for="rules">合并规则 <output id="rule-value">3</output></label><input id="rules" type="range" min="0" max="3" step="1" value="3" aria-label="启用的合并规则数量"><button id="experiment" class="experiment">比较规则版本 ↗</button></section><section class="exhibit"><div class="exhibit-meta"><span id="phase-label"></span><span id="counts"></span></div><div class="callout" id="callout" role="status" aria-live="polite"><span id="copy-eyebrow"></span><h2 id="copy-title"></h2><p id="copy-body"></p><button id="show-rules" class="calculation">查看词表与规则 +</button></div><svg class="leader" id="leader" aria-hidden="true"><path id="leader-line"/><circle id="leader-dot" r="4"/></svg><div class="artwork" id="artwork"></div><div class="stage-bottom"><span>教学 BPE · 预设英文词 · 未运行语言模型</span><button class="quiet" id="challenge" hidden>检验理解 ↗</button></div></section><div class="transport"><button id="play" class="play"><span id="play-icon">▶</span><span id="play-label">播放讲解</span></button><button id="back" class="quiet" aria-label="上一步">←</button><div class="track"><input type="range" id="timeline" min="0" max="46" step="0.05" value="0" aria-label="演示进度"><div><span id="play-note">六个步骤 · 一条因果关系</span><span id="time">00:00 / 00:46</span></div></div><button id="next" class="quiet" aria-label="下一步">→</button><button id="reset" class="quiet">重置</button></div>
<footer class="secondary"><span>Explain3D · AI 原理课堂</span><span>规则来自教学示例 · ID 仅属于本词表</span></footer></main><dialog id="info" aria-labelledby="info-title"><button class="close" id="close-info" aria-label="关闭规则说明">×</button><p class="eyebrow">A SMALL BPE TOKENIZER</p><h2 id="info-title">先有规则,再处理输入。</h2><p>本页实现一个字符级 BPE 教学分词器。三条已确定的合并规则按优先顺序应用:u + g → ug;u + n → un;h + ug → hug。没有匹配的相邻片段时,该规则不产生变化。</p><p>规则顺序参考 Hugging Face 的小型 BPE 教学示例。原示例通过语料频率学习规则,本页仅演示已有规则的应用,不演示训练。滑块切换的是教学分词器的规则版本,不表示真实模型每次输入都重新训练。</p><div class="formula">ID 0…9:b, g, h, n, p, s, u, ug, un, hug</div><p>本页只接受五个预设英文词,不处理空格、中文、特殊 Token、Unicode 归一化或未知字符。真实 tokenizer 可能使用字节级 BPE、WordPiece、Unigram 等方法。这里的分词结果与 ID 不能当作 ChatGPT、Claude 或其他模型的真实结果。</p><p><a href="https://huggingface.co/learn/llm-course/en/chapter6/5" target="_blank" rel="noopener">算法与示例来源 ↗</a></p><p class="fine">分词的输出是编号序列;它不等于理解词义,也不等于生成下一个 Token。</p></dialog><dialog id="quiz" aria-labelledby="quiz-title"><button class="close" id="close-quiz" aria-label="关闭学习检验">×</button><p class="eyebrow">PREDICT · TRY · EXPLAIN</p><h2 id="quiz-title">换个词,能自己推一次吗?</h2><p>启用全部三条规则,输入 unhug。按照 u+g、u+n、h+ug 的顺序,它会得到几个 Token?</p><div class="challenge-answers"><button data-answer="1">1 个</button><button data-answer="2">2 个</button><button data-answer="5">5 个</button></div><p id="feedback" role="status" aria-live="polite"></p><button class="experiment" id="quiz-try" hidden>在图中验证 ↗</button></dialog></body></html>
25 changes: 25 additions & 0 deletions apps/site/learn/tokenization/model.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
export const WORDS=['hugs','hug','pug','unhug','bun'];
export const VOCAB=['b','g','h','n','p','s','u','ug','un','hug'];
export const RULES=[['u','g'],['u','n'],['h','ug']];
export const STARTS=[0,6,13,25,34,41],DURATION=46;
export function encode(word,limit=3){
if(!WORDS.includes(word)||!Number.isInteger(limit)||limit<0||limit>3)throw new RangeError('Use a supported teaching word and 0–3 rules');
let pieces=[...word].map((text,start)=>({text,start,end:start+1}));const trace=[pieces];
for(const [a,b] of RULES.slice(0,limit)){
const next=[];for(let i=0;i<pieces.length;i++){const left=pieces[i],right=pieces[i+1];if(right&&left.text===a&&right.text===b){next.push({text:a+b,start:left.start,end:right.end});i++;}else next.push({...left});}pieces=next;trace.push(pieces);
}
return {word,limit,trace,pieces,ids:pieces.map(p=>VOCAB.indexOf(p.text)),decoded:pieces.map(p=>p.text).join('')};
}
export function sample(time,{word='hugs',limit=3}={}){
if(!Number.isFinite(time))throw new RangeError('Finite time required');const t=Math.max(0,Math.min(DURATION,time)),phase=STARTS.findLastIndex(s=>s<=t),progress=(t-STARTS[phase])/((STARTS[phase+1]??DURATION)-STARTS[phase]);
const final=encode(word,limit),step=phase<2?0:phase===2?Math.min(limit,Math.floor(progress*(limit+1))):limit;
return {...final,time:t,phase,progress,step,current:phase===0?[{text:word,start:0,end:word.length}]:final.trace[step],rule:phase===2&&step<limit?RULES[step]:null};
}
export function narration(f){const texts=[
['固定输入','一个词,不一定只对应一个 Token。',`先看 ${f.word}:它是一个英文词,含 ${f.word.length} 个字母。本节用小型教学词表解释如何得到模型输入。`],
['基础符号','先把输入展开为基础符号。','本例从单个英文字母开始。真实分词器也可能从字节开始;字符数不是普遍的 Token 数。'],
['固定规则','按已有规则,合并相邻片段。',f.rule?`下一条规则:${f.rule[0]} + ${f.rule[1]} → ${f.rule.join('')}。${f.current.some((p,i)=>p.text===f.rule[0]&&f.current[i+1]?.text===f.rule[1])?'合并这两个相邻片段。':'此输入没有这个相邻组合,本条跳过。'}规则在输入前已经确定。`:'本版本的规则已用完。没有匹配的规则会保留片段,不能为了凑成整个词而随意拼接。'],
['Token 边界','一个片段,就是本版本的一个 Token。',`${f.word} 现在分成 ${f.pieces.length} 个 Token。片段可以小于一个词,也可能刚好等于一个词。`],
['词表查找','把片段查成编号,顺序保持不变。','每个片段在这张词表中有固定 ID。编号是索引,不是含义、分数或概率;换词表后编号也可能不同。'],
['还原检查','编号可以查回片段,再拼回输入。',`这次得到 [${f.ids.join(', ')}]。按同一张词表解码,仍然是 ${f.decoded}。分词没有生成新内容,也没有解释词义。`]
][f.phase];return {eyebrow:'看这里 · '+texts[0],title:texts[1],body:texts[2]};}
8 changes: 8 additions & 0 deletions apps/site/learn/tokenization/model.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import assert from 'node:assert/strict';
import {WORDS,VOCAB,encode,sample,DURATION} from './model.mjs';
const golden={hugs:['hug','s'],hug:['hug'],pug:['p','ug'],unhug:['un','hug'],bun:['b','un']};
for(const word of WORDS){assert.deepEqual(encode(word).pieces.map(p=>p.text),golden[word]);for(let k=0;k<=3;k++){const f=encode(word,k);assert.equal(f.decoded,word);assert.equal(f.ids.map(i=>VOCAB[i]).join(''),word);for(const row of f.trace){assert.equal(row.map(x=>x.text).join(''),word);let pos=0;for(const p of row){assert.equal(p.start,pos);assert.equal(p.text,word.slice(p.start,p.end));pos=p.end;}assert.equal(pos,word.length);}}}
assert.deepEqual(encode('hugs').ids,[9,5]);assert.deepEqual(encode('unhug').ids,[8,9]);assert.equal(encode('hugs',0).pieces.length,4);assert.equal(encode('hugs',1).pieces.length,3);assert.deepEqual(encode('hugs',1).pieces,encode('hugs',2).pieces);
for(const word of WORDS)for(let limit=0;limit<=3;limit++)for(let t=0;t<=DURATION;t+=.1){const a=sample(t,{word,limit});assert(a.current.every(p=>p.text===word.slice(p.start,p.end)));assert.equal(a.current.map(p=>p.text).join(''),word);sample(46,{word,limit});assert.deepEqual(sample(t,{word,limit}),a);}
assert.throws(()=>encode('未知'));assert.throws(()=>encode('hugs',4));assert.equal(sample(-1).time,0);assert.equal(sample(99).time,DURATION);
console.log('Tokenization: independent golden segmentations/IDs, span conservation, reversible decode, rule no-op, limits and backward seeking pass.');
Loading
Loading