
之前在整理大模型入门教程时我总被同一个问题卡住学习者已经知道 token、embedding、Transformer 这些名词但当模型真正生成一个词的那一刻它内部究竟经历了什么却很难用静态图说清楚。最近看到 Between Tokens 这个交互式作品的概念——让用户直接扮演语言模型去感受 token 流转与下一 token 预测的节奏——觉得很适合改造成一篇动手教程。本文会从零实现一个最小可运行版本后端使用 Python Flask前端使用原生 HTML/CSS/JS不依赖复杂框架。文章适合三类读者想理解大模型生成过程的初学者、需要给团队做 NLP 原理培训的开发者、以及想找一个有趣前端小项目的同学。读完你会掌握语言模型关于 token 的核心流程同时拥有一套可以本地运行、自由改写的交互式教学 Demo。全程代码都可以直接复制运行我也会把容易踩坑的地方单独列出。1. 背景与核心概念1.1 什么是 Between Tokens 这类交互式作品Between Tokens 并不是一个真实的大模型推理引擎而是一种教学性质的交互体验。它把用户放进语言模型的位置上屏幕先展示一段已经被切分成 token 的文本然后在某个位置停止并提供几个候选 token要求用户选择“模型最应该生成的下一个 token”。用户选择之后系统会揭晓原文中真实出现的 token并进入下一轮。这种设计最大的价值是改变了我们对语言模型的认知方式。平时我们调用大模型接口看到的是“输入 prompt输出一段完整文本”中间过程对用户完全不可见。Between Tokens 把这个黑盒打开了一个窗口原来模型的每个生成步骤都是基于当前上下文、对下一个 token 的预测。它会让学习者对 prompt 设计、上下文窗口、token 概率这些概念产生更真实的直觉。类似作品可以应用在很多场景NLP 教学课程里的课堂演示、新人入职培训时的概念导入、技术分享会的互动环节甚至可以作为 Prompt Engineering 入门工作坊的暖场游戏。它的重点不是“训练一个模型”而是“理解模型的时间体验”。1.2 Token 在语言模型里的完整旅程Token 是语言模型处理文本的基本单位。它可能是单词的一部分、一个完整的英文单词、一个中文词语也可能是一个标点符号。大模型不会直接读原始字符串而是先把文本转换成 token 序列。一个 token 在模型内部的完整旅程大致可以分成这样几步文本经过 Tokenizer 处理被切分为 token 序列。每个 token 通过嵌入表映射成一个高维向量这个向量包含该 token 的语义信息。向量进入 Transformer 的多层网络每一层里 token 之间会通过自注意力机制互相传递信息。最后一层输出每个 token 对应的得分再经过 softmax 转换成概率分布。模型根据概率分布采样或挑出概率最高的 token作为新生成的 token。新 token 追加到上下文末尾然后重复步骤 2 到 5直到生成结束。Between Tokens 这个名称本身就很有味道。一方面“between tokens” 可以理解成“两次 token 生成之间”的瞬间也就是模型正在决定下一个 token 的那一刻另一方面注意力机制恰好发生在 token 与 token 之间让它们互相查看、互相影响。可以说这是一个把空间上的“之间”和时间上的“之间”结合到一起的概念。1.3 为什么“扮演模型”比单纯看公式更容易理解很多人学 Transformer 时会陷入公式推导最后记住了 QKV 三个字母却不知道模型生成文本时是什么感觉。通过交互式扮演学习者可以在短时间内建立三种直觉。第一种是顺序感。语言模型不是一次性计算整段话而是一步步往后推进。每生成一个 token它都要重新把当前上下文放进计算结构里上下文不断增加最早的 token 对当前决策的影响会逐渐淡化。第二种是概率感。语言模型并不是“查答案”而是给出词汇表上的概率分布。一个词概率高不代表其他词完全不可能。比如“The quick brown fox jumps over the lazy dog”这句话里在“fox”之后出现“jumps”概率很高但换一个语境可能“runs”“sleeps”也完全合理。第三种是上下文敏感。同一个词在不同上下文里模型对下一个 token 的预测会完全不同。比如“银行”后面可能是“账户”也可能是“河岸”这取决于整段文本给模型提供了什么信息。通过“扮演模型”学习者会切身体会这种每一步都面临不确定性的状态。接下来我们就用代码把这个交互体验做出来。2. 环境准备与项目结构2.1 环境依赖本文示例以本地开发环境为主依赖非常简单Python 3.10 或更高版本需要能正常使用 pip 安装依赖。Flask 框架用于提供轻量级 Web 接口和静态页面。现代浏览器推荐 Chrome、Edge 或 Firefox用于打开前端页面。文本编辑器Visual Studio Code、PyCharm 都可以按个人习惯选择。Flask 的版本不需要固定目前使用稳定版本即可。为了兼容本文代码里的路由写法建议版本在 2.0 以上。如果环境差异较大以你本机实际安装版本为准核心逻辑不会受到影响。2.2 创建项目目录在任意工作目录下创建一个新的项目文件夹命名为 between-tokens。项目内部结构如下between-tokens/ ├── app.py ├── data.py ├── requirements.txt ├── templates/ │ └── index.html └── static/ ├── app.js └── style.css其中 data.py 负责保存演示文本app.py 是 Flask 服务端入口templates/index.html 是页面骨架static/app.js 和 static/style.css 分别负责前端交互逻辑和页面样式。创建目录后先建立一个虚拟环境并安装依赖。如果你还没有安装 Flask可以执行pip install -r requirements.txtrequirements.txt 内容如下flask2.0这里不强行指定具体版本是为了避免不同机器、不同 Python 版本之间的兼容问题。安装完成后可以继续编写代码。3. 交互玩法拆解如何让用户“成为”语言模型3.1 游戏流程设计为了让交互体验符合“你是语言模型”的核心设定我们把游戏流程设计成下面这样系统从内置语料中选择一篇短文。短文在服务端被切分成 token 数组这里先用“按空格切分”的方式模拟最简单的 tokenizer。前端展示当前已经看到的 token 流并在末尾显示一个问号占位符表示等待预测。系统同时返回 4 个候选 token其中 1 个是原文中实际出现的 token另外 3 个是从全文其他位置随机抽取的干扰项。用户点击候选 token相当于扮演模型完成了一次“下一步预测”。服务端结算结果返回是否正确、真实 token、新的上下文和下一批候选。用户点击“继续生成下一个 token”进入下一轮预测。这个流程虽然简单但已经能够还原语言模型生成的基本节奏看上下文、预测概率、生成 token、更新上下文、继续下一次预测。3.2 前端界面与后端分工在项目实现中前端和后端各司其职。后端更关心数据准备、候选生成、答案校验前端更关心 token 流展示、按钮交互、反馈展示。模块负责内容data.py内置演示文本简单数据源app.pytoken 切分、候选干扰项生成、答案校验、返回 JSONindex.html页面结构包括状态栏、token 流、选择区、反馈区app.js调用后端接口、渲染 token 和候选、处理用户点击style.css页面视觉风格让 token 流和交互按钮更清晰这种分工让整个项目很容易扩展。如果你想把演示文本换成自己的语料只需要修改 data.py如果想改变视觉风格只需要改 style.css如果想接上真实模型的概率输出则只需要改造 app.py 中的候选生成逻辑。3.3 为什么候选词要随机生成真实语言模型的词汇表通常有几万甚至十几万个 token不可能在页面上一一展示。为了让交互可操作我们只能用“候选词”这种方式缩小预测范围。候选词由正确 token 和若干干扰项组成。干扰项从当前文本的其他位置抽取而不是从外部词典随机选这样设计有两点好处第一候选词都来自原文风格和内容不会太突兀体验更像是“在故事里猜词”第二实现起来非常轻量不需要额外维护一个大型词表。同时也要说明这个演示里的“正确答案”只是原文中真实出现的 token。真实语言模型生成时并不存在数学意义上的唯一正确答案。模型只会计算概率分布然后根据采样策略选出最终 token。我们在教学中为了评分方便才把原文实际出现的 token 当作标准答案。这个区别会在后续“进阶扩展”章节继续讨论。4. 完整实战案例最小可运行版本4.1 数据层data.py先在项目中创建 data.py放入几段演示文本。这些文本会作为用户预测 token 的“原文语料”。# 文件路径between-tokens/data.py DEMO_TEXTS [ { id: 0, title: The quick brown fox, text: The quick brown fox jumps over the lazy dog and runs into the forest. The dog follows him, barking loudly, but the fox is too fast. }, { id: 1, title: A rainy day, text: On a cold rainy morning, Maria looked out of the window and saw a small cat hiding under the old oak tree. She decided to bring it inside and gave it warm milk. }, { id: 2, title: AI news, text: Language models are getting better at writing code, but they still need careful instructions. A good prompt can turn a vague request into a clear solution. } ]这里用英文短句作为演示文本原因很简单英文单词天然以空格分隔方便初学者理解 token 的概念。中文文本也可以作为语料但需要更复杂的切分方式比如按字切分或按词切分后续可以自行扩展。在演示中每段文本由一个字典表示包含 id、title、text 三个字段。id 用于标识语料title 用于在页面上展示当前故事名称text 是真正被切分成 token 的原文。如果你希望换一批文本直接扩展这个列表即可。4.2 API 层app.pyapp.py 是整个项目的核心。它负责把文本切成 token 数组、构造候选词、接收用户选择、返回下一轮状态。# 文件路径between-tokens/app.py import random from flask import Flask, jsonify, render_template, request from data import DEMO_TEXTS app Flask(__name__) # 预处理语料把文本按空格切分为 token 数组 TEXTS [] for item in DEMO_TEXTS: story dict(item) story[tokens] story[text].split() TEXTS.append(story) def build_choices(tokens, masked_index, choice_count4): 构造候选 token 列表。 tokens: 整篇文本的 token 数组 masked_index: 当前需要预测的 token 位置 choice_count: 候选总数默认 4 个 correct tokens[masked_index] pool {token for token in tokens if token.lower() ! correct.lower()} distractor_count min(choice_count - 1, len(pool)) distractors random.sample(sorted(pool), distractor_count) choices [correct] distractors random.shuffle(choices) return choices, choices.index(correct) app.get(/) def index(): return render_template(index.html) app.post(/api/practice/start) def start(): 开始一次新的练习默认返回前几个 token 作为上下文。 payload request.get_json(silentTrue) or {} text_id payload.get(text_id, 0) if not isinstance(text_id, int) or not (0 text_id len(TEXTS)): return jsonify({error: text_id 超出范围}), 400 story TEXTS[text_id] context_len min(4, len(story[tokens]) - 1) position context_len context_tokens story[tokens][:context_len] choices, correct_index build_choices(story[tokens], position) return jsonify({ text_id: text_id, title: story[title], position: position, context_tokens: context_tokens, choices: choices, correct_index: correct_index, token_count: len(story[tokens]), message: f你已经看到了 {context_len} 个 token接下来请预测第 {context_len 1} 个 token。 }) app.post(/api/practice/answer) def answer(): 接收用户选择校验答案并返回下一轮需要的数据。 payload request.get_json(silentTrue) or {} text_id payload.get(text_id) position payload.get(position) selected_token payload.get(selected_token) if not isinstance(text_id, int) or not isinstance(position, int): return jsonify({error: text_id 和 position 必须为整数}), 400 if not isinstance(selected_token, str) or not selected_token.strip(): return jsonify({error: selected_token 必须是非空字符串}), 400 if not (0 text_id len(TEXTS)): return jsonify({error: text_id 超出范围}), 400 story TEXTS[text_id] if not (0 position len(story[tokens])): return jsonify({error: position 超出范围}), 400 correct_token story[tokens][position] is_correct selected_token correct_token next_position position 1 next_context story[tokens][:next_position] finished next_position len(story[tokens]) if not finished: next_choices, next_correct_index build_choices(story[tokens], next_position) else: next_choices, next_correct_index [], None if is_correct: message 答对了你维持住了故事线继续往前推。 else: message f模型答案其实是“{correct_token}”。不过请记住真实语言模型生成时并不是非此即彼。 return jsonify({ correct: is_correct, correct_token: correct_token, selected_token: selected_token, position: next_position, context_tokens: next_context, choices: next_choices, correct_index: next_correct_index, token_count: len(story[tokens]), finished: finished, message: message }) if __name__ __main__: app.run(debugTrue)这段代码里有两个地方需要重点关注。第一个是 build_choices 函数。它从整篇文本的 token 集合中抽取干扰项然后把正确 token 和干扰项一起洗牌。这样每次进入新的一轮时候选顺序都会变化用户无法通过记忆按钮位置来猜答案。干扰项数量使用了 min 方法兜底即使文本很短也不会因为 token 不够而报错。第二个是 answer 接口的参数校验。我们没有让用户传选项下标而是让用户传选中的 token 字符串。这样设计以后即使客户端因为网络问题导致状态不同步服务端也只需要比较字符串是否等于原文中的正确 token逻辑更加稳定。还有一个细节值得注意前端“当前看到的 token 数量”和“当前需要预测的 token 位置”是同一个数字。比如 position4表示前 4 个 token 已经看过现在需要预测第 5 个 token。这种编码方式在操作 token 数组时非常直观不容易出现边界错误。4.3 前端页面templates/index.html接下来创建 templates/index.html。它负责展示页面骨架包括标题区、状态栏、token 流、候选按钮和反馈区域。!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 titleBetween Tokens - 你是语言模型/title link relstylesheet href{{ url_for(static, filenamestyle.css) }} /head body main classcontainer header h1Between Tokens/h1 p你正在扮演一个语言模型看到一段 token 流并预测下一个 token。/p /header section classstatus-bar span idstoryTitle加载中.../span span idscore得分 0 / 0/span /section section classprompt-card p idpromptMessage请稍候.../p div idcontextTokens classtoken-flow/div /section section classchoices-area p在下面选择你认为最可能出现的下一个 token/p div idchoices classchoices-grid/div /section section idfeedback classfeedback hidden/section button idnextBtn classhidden继续生成下一个 token/button /main script src{{ url_for(static, filenameapp.js) }}/script /body /html页面结构非常简洁。状态栏用来显示故事标题和当前得分prompt-card 区域显示当前已经看到的 token 流choices-area 渲染候选按钮feedback 区域用于在用户答题后给出一段解析nextBtn 用来进入下一轮或重新开始。这里把主要交互元素都用 id 标记方便 app.js 根据 id 获取 DOM 元素并更新内容。4.4 交互脚本static/app.js现在编写前端交互逻辑。这个文件负责调用后端的两个接口处理用户点击按钮的行为并渲染 token 流与反馈。// 文件路径between-tokens/static/app.js const state { textId: 0, position: 0, totalTokens: 0, score: 0, attempts: 0, }; const contextEl document.getElementById(contextTokens); const choicesEl document.getElementById(choices); const messageEl document.getElementById(promptMessage); const feedbackEl document.getElementById(feedback); const nextBtn document.getElementById(nextBtn); const titleEl document.getElementById(storyTitle); const scoreEl document.getElementById(score); async function postJSON(path, payload) { const response await fetch(path, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload), }); if (!response.ok) { let message HTTP ${response.status}; try { const body await response.json(); if (body.error) message body.error; } catch (e) { // 忽略解析错误 } throw new Error(message); } return response.json(); } function renderTokens(tokens, maskedIndex) { contextEl.innerHTML ; tokens.forEach((token, index) { const span document.createElement(span); span.className token; if (index maskedIndex) { span.classList.add(masked); } span.textContent token; contextEl.appendChild(span); }); if (maskedIndex tokens.length) { const placeholder document.createElement(span); placeholder.className token masked; placeholder.textContent ?; contextEl.appendChild(placeholder); } } function renderChoices(choices) { choicesEl.innerHTML ; choices.forEach((choice) { const btn document.createElement(button); btn.type button; btn.className choice-btn; btn.textContent choice; btn.addEventListener(click, () submitAnswer(choice)); choicesEl.appendChild(btn); }); } function setChoicesEnabled(enabled) { document.querySelectorAll(.choice-btn).forEach((btn) { btn.disabled !enabled; }); } function renderRound(data) { state.textId data.text_id; state.position data.position; state.totalTokens data.token_count; titleEl.textContent data.title; messageEl.textContent data.message; renderTokens(data.context_tokens, data.context_tokens.length); renderChoices(data.choices); feedbackEl.classList.add(hidden); nextBtn.classList.add(hidden); setChoicesEnabled(true); updateScore(); } function updateScore() { scoreEl.textContent 得分 ${state.score} / ${state.attempts}; } function showFeedback(data) { feedbackEl.classList.remove(hidden, correct, wrong); feedbackEl.classList.add(data.correct ? correct : wrong); feedbackEl.textContent data.message; } async function submitAnswer(choice) { setChoicesEnabled(false); const payload { text_id: state.textId, position: state.position, selected_token: choice, }; try { const data await postJSON(/api/practice/answer, payload); showFeedback(data); renderTokens(data.context_tokens, -1); const tokens contextEl.querySelectorAll(.token); if (tokens.length) { tokens[tokens.length - 1].classList.add(new); } state.score data.correct ? 1 : 0; state.attempts 1; updateScore(); if (data.finished) { nextBtn.textContent 重新开始; nextBtn.classList.remove(hidden); nextBtn.onclick startGame; return; } nextBtn.textContent 继续生成下一个 token; nextBtn.classList.remove(hidden); nextBtn.onclick () continueGame(data); } catch (err) { showError(err.message); } } function continueGame(data) { renderRound(data); } async function startGame() { try { const data await postJSON(/api/practice/start, { text_id: 0 }); state.score 0; state.attempts 0; renderRound(data); } catch (err) { showError(err.message); } } function showError(message) { feedbackEl.classList.remove(hidden, correct, wrong); feedbackEl.textContent 出错了${message}; } document.addEventListener(DOMContentLoaded, startGame);这段脚本的核心是维护 state 对象。它记录当前故事、当前要预测的 token 位置、总 token 数以及用户累计得分。每次用户点击候选按钮后脚本会发送答案到/api/practice/answer然后根据返回结果更新界面。renderTokens 函数支持 maskedIndex 参数。当 maskedIndex 等于 token 数组长度时表示所有已展示 token 都已经“读完”此时需要在末尾追加一个问号占位符告诉用户下一步该做预测了。这个视觉细节虽然简单但能让交互者更直观地感受到“生成即将发生”的节点。submitAnswer 成功后我们会把正确的 token 追加到 token 流中并给最后一个 token 加上 high-class。这样用户可以看到自己预测的结果在上下文中被“固化”了模拟了真实语言模型生成 token 后更新上下文的过程。4.5 样式static/style.css为了让页面看起来更有“模型内部”的氛围我们可以加入一套深色主题样式。深色背景能让高亮 token 更醒目也更贴合终端、GPU、推理任务等大模型话题常见的视觉风格。/* 文件路径between-tokens/static/style.css */ * { box-sizing: border-box; } body { margin: 0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif; background: #1e1e2e; color: #e0e0e0; line-height: 1.6; } .container { max-width: 900px; margin: 0 auto; padding: 24px; } header h1 { color: #89b4fa; margin-bottom: 4px; } header p { margin-top: 0; color: #a6adc8; } .status-bar { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid #45475a; margin-bottom: 16px; } .prompt-card { background: #11111b; border-radius: 12px; padding: 16px; min-height: 130px; } .token-flow { display: flex; flex-wrap: wrap; gap: 8px; font-size: 1.2rem; margin-top: 8px; } .token { background: #313244; padding: 4px 10px; border-radius: 6px; } .token.masked { background: transparent; border: 2px dashed #f38ba8; color: #f38ba8; font-weight: 700; } .token.new { background: #a6e3a1; color: #1e1e2e; font-weight: 700; } .choices-area { margin-top: 20px; } .choices-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 12px; margin-top: 8px; } .choice-btn { padding: 12px; background: #313244; border: none; border-radius: 8px; color: #fff; font-size: 1rem; cursor: pointer; transition: background 0.15s ease; } .choice-btn:hover { background: #45475a; } .choice-btn:disabled { opacity: 0.6; cursor: not-allowed; } .feedback { margin: 16px 0; padding: 12px; background: #11111b; border-radius: 8px; } .feedback.correct { border-left: 4px solid #a6e3a1; } .feedback.wrong { border-left: 4px solid #f38ba8; } .hidden { display: none; } #nextBtn { margin-top: 12px; padding: 10px 20px; background: #89b4fa; color: #11111b; border: none; border-radius: 8px; cursor: pointer; font-weight: 700; font-size: 1rem; } #nextBtn:hover { background: #74a4e8; }这套样式没有引入任何 CSS 框架普通浏览器即可渲染。token 流通过 flex 布局自动换行即使一段文本很长token 也能够在多行中展示。masked 样式使用虚线边框让“待预测位置”在视觉上非常明显。4.6 运行与验证完成上述文件后在项目根目录执行python app.py如果一切正常终端会输出类似下面的日志* Running on http://127.0.0.1:5000然后在浏览器中访问 http://127.0.0.1:5000就可以看到交互页面。页面会展示一个故事标题、四个已经出现的 token以及四个候选按钮。你选择其中一个 token 后页面会显示正确或错误的反馈并允许你点击“继续生成下一个 token”。如果想换一个内置文本可以在 app.js 的 startGame 函数中把 text_id 改成 0、1、2 中的一个。因为 data.py 中定义了三段文本所以 text_id 在 0 到 2 之间都是合法的。4.7 一次完整交互示例假设系统选择了“The quick brown fox”这段文本初始返回的 4 个 token 是The quick brown fox ?四个候选按钮可能是 jumps、lazy、dog、the。其中 jumps 是原文中真实出现的 token其余三个是干扰项。如果用户点击 jumps后端会返回 correct 为 true并给出下一轮状态。此时 token 流会更新为The quick brown fox jumps ?已经出现的 token 数量变成 5新的 masked 位置指向第 6 个 token也就是 over。这个过程会一直持续到整篇文本结束。用户可以在短短十几轮交互里经历语言模型从读取上下文到预测 token、再到更新上下文并继续预测的完整循环。5. 进阶扩展让它更接近真实的模型体验5.1 加入注意力与上下文窗口可视化当前版本只展示了 token 的顺序流动没有展示 token 之间的相互影响。如果想让体验更接近真实 Transformer可以在 token 流中增加“注意力模拟”效果用户点击任意一个 token系统高亮其他与该 token 相关的 token。这种可视化虽然不依赖真实权重但能帮助学习者理解自注意力机制的基本直觉决定下一个 token 时模型不是只看最近一个 token而是会综合整个上下文窗口的信息。实现时可以在前端增加一个 click 事件点击 token 后遍历所有 token 并随机或按规则添加高亮样式。如果你的目标是严肃地展示真实注意力权重那么需要接入一个真实模型并提取 attention map。不过那会显著增加项目复杂度也需要额外的内存和计算资源本文不再展开完整代码。5.2 接入真实预训练模型一个更有意思的扩展方向是把候选 token 从“原文随机词”替换成“真实语言模型的概率输出”。这可以通过 Hugging Face 生态中的 fill-mask 类模型来实现。思路很简单在 app.py 中引入pipeline(fill-mask, model某个小型 Mask 模型)然后把原文本中某个 token 替换成 mask让模型返回概率最高的几个候选单词。再用这些真实候选替换掉 build_choices 函数生成的随机干扰项。需要注意的是这种方式需要联网下载模型权重且不同模型对输入格式、token 切分方式的要求不同。第一次运行可能耗时较长生产环境也应对模型推理做超时和缓存处理。这个方向适合已经理解本文基础逻辑后想进一步研究真实模型概率分布的读者。5.3 增加温度与随机采样真实语言模型生成时往往会引入温度参数用来控制概率分布的尖锐程度。温度越低模型越倾向选择概率最高的 token温度越高输出越随机甚至可能跳出常见的搭配。你可以在前端增加一个“温度”滑动条当用户滑动时调整后端在判定正确答案时的展示方式。比如温度极高时系统不再告诉用户“必须选原文 token”而是提示“任何合理词都可以接受”。这种设计虽然偏离了教学评分但更贴近真实生成模型的采样过程。考虑到教学场景需要明确的得分反馈我建议可以增加一个“难度模式”开关普通模式按原文 token 判分探索模式允许用户在提示语下自由发挥。这样既保留了游戏的互动性又能让高阶用户更深入地理解生成式模型的概率特性。6. 常见问题与排查思路在本地运行这个项目时可能会遇到一些问题。下面按现象整理常见问题。问题现象常见原因解决思路浏览器访问出现 404templates 或 static 目录位置不对确认 index.html 在 templates 下app.js 和 style.css 在 static 下页面一直是“加载中”前端 JS 报错或接口地址不对打开开发者工具查看 Console 和 Network候选顺序每次刷新都变这是设计行为random.shuffle 会打乱候选顺序保证无法记位置刷新页面后状态丢失state 只保存在前端内存中刷新后重新开始即可也可扩展 localStorage 保存状态Flask 端口被占用之前有服务仍占用 5000 端口换一个端口例如 app.run(port5001, debugTrue)文本太短导致候选不足文本 token 不够抽出 3 个干扰项增加文本长度或调低 choice_count 参数debugTrue 是否安全仅用于本地调试生产环境必须关掉 debug并按需配置 host第一次运行项目时最常见的问题其实是浏览器缓存。如果你修改了 app.js 或 style.css但页面没有变化可以强制刷新或者按 CtrlShiftR 清空缓存后重新加载。另外在 Windows PowerShell 或 CMD 中运行 flask 相关内容时如果出现编码问题可以在 app.py 文件头部注释声明编码或者把终端代码页切换到 UTF-8。大多数情况下只要代码文件本身是 UTF-8 保存就不会有乱码。7. 最佳实践与工程建议7.1 教学工具设计建议这类交互式教学工具最核心的不是功能多而是反馈快、门槛低。用户点击候选按钮后系统必须立刻给出结果和解释最好还能用一句话说明为什么这个 token 合理或不合理。当前版本在 message 字段中加入了简短解释你可以根据自己的教学需要扩展成更详细的提示比如“这个 token 与前面某个单词的搭配在语料中出现频率较高”。页面上的 token 流也应该保持可读性。一次不要展示太多 token否则用户在视觉上会失去焦点。建议默认展示 4 到 8 个 token然后继续推进当 context 较长时可以把最早的部分折叠起来或者用透明度区分“远距离 token”和“近期 token”。7.2 API 设计建议这个项目只是为了本地教学所以 API 写得非常轻量状态也保存在前端。如果你的应用场景是多人在线或者需要持久记录成绩建议重新设计 API把 score 和 attempts 放到服务端避免用户刷新页面后丢失。为故事练习创建 session id并对 session 进行超时管理。对所有用户输入做白名单校验不要依赖前端传回的字符串。如果并发请求量大把语料和候选结果写入数据库而不是每次实时计算。本项目中 answer 接口只校验了参数类型和 position 范围这是因为它是单一用户、本地运行的演示工具。在生产环境里还需要考虑频率限制、数据合法性、日志审计等安全措施。7.3 数据与版本管理data.py 把语料和代码逻辑分开这是一个很好的起点。后续你可以把语料迁移到 JSON、数据库或外部配置中心让运营人员不必修改代码就能更新内容。对于这个项目规模来说保持 data.py 简单即可但当你加入更多语料时建议统一管理 id、title、tags 等字段避免重复文本。代码版本管理方面建议用 Git 把项目初始化成仓库提交时排除虚拟环境和缓存文件。如果你准备发布到公共平台注意不要把任何隐私文本或密钥放进代码里。7.4 不要误把演示当真实评估最后也是最重要的一点这个项目里的“正确 token”是原文 token它并不代表真实语言模型的唯一正确答案。不要用它来评估任何大模型的生成能力也不要用演示数据去推断真实模型的行为规律。真实模型生成时会根据上下文给出词汇表上的概率分布。同样的上下文有可能生成多个不同但都合理的 token。判断生成质量需要结合语义、连贯性、事实性和任务目标来看而不是简单比较字符串是否一致。如果你想带领读者做更严肃的实验建议接入真实模型或至少引入困惑度、BLEU、ROUGE 等评估指标。8. 总结与学习路线到这里这个最小版本已经完成了。你已经实现了一个完整的 Between Tokens 交互教程用户可以看到上下文 token 流、预测下一个 token、获得即时反馈并模拟语言模型逐步生成一段文本的节奏。通过这次实战你应该掌握了几件事语言模型处理文本时以 token 为基本单位生成过程是“看上下文、预测概率、生成 token、更新上下文”的循环候选词和干扰项的设计能有效训练用户对上下文的敏感度Flask 前后端分离的结构适合快速搭建类似交互作品。如果继续深入下一步可以学习几个方向BPE 和 WordPiece tokenizer 的真实切分规则理解为什么英文单词不总是按空格切分。自注意力机制的矩阵计算了解 token 之间如何互相影响。softmax 运算和温度采样理解概率分布如何被转换成实际生成词。KV Cache 对长文本推理的性能优化了解为什么生成一个 token 时不需要重新计算所有历史信息。同时动手把这个项目改造成属于你自己的版本也是一种很好的练习。你可以替换成语料、增加段落选择、添加计分排行、把候选词改成图片化按钮甚至给 token 加上动画效果。真正把一个教学 Demo 改到顺手你对语言模型生成机制的理解也会比单纯看论文示例扎实得多。希望这篇教程能帮到你。如果你在复现过程中遇到其他问题欢迎在评论区把报错现象和运行环境发出来一起排查。