最近在技术社区里看到一些很有意思的交互式作品其中有一种玩法很特别让你从一个语言模型的第一人称视角去生成文本在每一步都要亲自决定下一个 token 是什么。这种“你就是语言模型”的交互实验把大模型最核心的自回归生成机制展示得非常直观。本文不打算只停留在介绍层面而是用 Python 从零实现一个简化版“你就是语言模型”交互模拟器借此讲清楚 Token、上下文窗口、n-gram 统计语言模型和采样策略。读完你可以手动扮演一次模型亲手选择一步一步生成文本理解为什么 GPT 这类模型会以 Token 为单位逐字输出。1. Token 与语言模型先理解两个核心概念1.1 什么是 Token很多人第一次接触大模型时都会好奇一个问题模型读到的“字”和我们看到的“字”是不是同一个东西答案是否定的。语言模型读取的基本单位不是字符也不是完整的单词而是 Token。Token 可以理解成“文本的最小切片”。英文里一个单词可能拆成多个 Token比如language可能被拆成lan和guage也可能单独成为一个 Token这取决于分词器选用的词表。中文场景中一个字可能是一个 Token一个词也可能是一个 Token。这种切分过程叫做 Tokenization。为什么要用 Token 而不是直接用字符原因有几个直接按字符建模序列太长计算量太大。按单词建模词表规模太大而且无法处理未登录词。Token 作为中间粒度既能控制词表大小又能保留常见词和子词信息。Token 还直接影响实际工程中的上下文窗口和费用。大模型通常有max_tokens限制也就是一次能处理的 Token 数量上限。调用付费 API 时输入输出也经常按 Token 数量计费。因此理解 Token是理解语言模型成本和能力边界的第一步。1.2 语言模型如何生成文本现代语言模型生成文本的方式并不是一下子把整段内容写出来而是一个词一个词更准确地说是“一个 Token 一个 Token”地生成。在数学上语言模型做的事情是估计条件概率P(next_token | 前面所有 token)模型根据已有的上下文预测下一个 Token 的概率分布然后从这个分布中选一个 Token 追加到上下文末尾再继续预测下一位直到遇到结束标记或达到最大长度。这个过程叫自回归生成。因为每一步的输出都会变成下一步的输入模型始终在“自己回归自己生成的内容”。举个例子如果上下文是the cat sat on模型可能会给出如下概率分布the0.45a0.20mat0.35如果选择了the新的上下文变成the cat sat on the然后继续预测下一位。整个生成过程就是“观察上下文 → 计算候选概率 → 采样 → 更新上下文”的循环。1.3 “你就是语言模型”的交互设计思路“你就是语言模型”这类交互作品之所以有趣是因为它把这个内部过程“打开”了。平时我们只是给模型输入提示词然后等待模型输出。模型内部到底为什么选择这个 Token、候选词有哪些、概率是多少我们只能通过“试错”去感知。而这类交互作品让用户直接扮演语言模型的“输出头”你在每一步都能看到当前的上下文窗口是什么候选 Token 有哪些每个候选 Token 的条件概率是多少你来决定选择哪一个。这个设计把隐式的概率分布变成了显式的可操作界面非常适合用来教学和科普。下面我用一个简化版项目把整个流程从零实现出来。2. 环境准备与项目结构2.1 运行环境这个模拟器完全使用 Python 标准库实现不需要安装任何第三方依赖也不需要 GPU。推荐环境Python 3.6 及以上版本Windows / macOS / Linux 均可命令行终端或任意 IDE如 VS Code、PyCharm如果你的电脑上还没有 Python可以到 Python 官网下载安装包安装时勾选“Add Python to PATH”。安装完成后可以在终端里验证python --version如果输出类似Python 3.10.12说明环境已经可用。本文示例代码在 Python 3.10 环境下验证过但使用的语法都比较基础3.6 也能运行。版本需要根据你的项目实际情况调整本文重点演示实现思路。2.2 项目目录结构为了让代码清晰建议使用下面的目录结构lm-simulator/ ├── interactive_lm.py └── README.md本文最终会给出一个可运行的完整文件interactive_lm.py。你可以在本地新建一个目录把代码保存进去然后直接运行。2.3 依赖说明核心功能只需要标准库re正则表达式用于 Token 化。collections提供Counter、defaultdict用于统计 n-gram 频率。random用于温度采样和 Top-k 采样。如果之后你想让模拟器支持中文分词可以考虑安装jiebapip install jieba但是在本文的基础版本中我们不引入额外依赖先用英文小语料演示整个原理中文扩展会在后文讨论。3. 从零实现 Token 化与 n-gram 语言模型3.1 简化 Token 化器真实的大型语言模型使用 BPEByte-Pair Encoding、WordPiece、SentencePiece 等复杂算法切分 Token。为了直观理解这里先实现一个简化版分词器它按“单词 标点”进行切分。import re class SimpleTokenizer: def __init__(self): self.pattern re.compile(r\w|[^\w\s]) def encode(self, text: str): return [tok.lower() for tok in self.pattern.findall(text)]这个分词器的逻辑很容易懂\w匹配连续的字母数字下划线[^\w\s]匹配标点符号。例如输入tokenizer SimpleTokenizer() print(tokenizer.encode(The cat sat on the mat!))输出[the, cat, sat, on, the, mat, !]可以看到单词都被转成小写标点!被单独切成一个 Token。这种切分方式虽然简单但已经具备 Token 的基本概念。3.2 构建 n-gram 统计模型有了 Token下一步需要一个“模型”。为了让模拟器不依赖大模型权重我们用经典的 n-gram 统计语言模型。n-gram 模型的核心假设是下一个 Token 的出现概率只依赖于前n-1个 Token。例如 bigramn2只依赖前面 1 个 Tokentrigramn3依赖前面 2 个 Token。下面是构建 n-gram 统计模型的代码from collections import Counter, defaultdict CORPUS [ the cat sat on the mat, the dog sat on the floor, the cat chased the dog, a dog sat with a cat, language models predict the next token, the next token depends on context, ] def build_ngram_model(corpus, n2): model defaultdict(Counter) tokenizer SimpleTokenizer() for text in corpus: tokens tokenizer.encode(text) # 在开头补 n-1 个 BOS在结尾补 EOS sequence [BOS] * (n - 1) tokens [EOS] for i in range(n - 1, len(sequence)): context tuple(sequence[i - n 1:i]) token sequence[i] model[context][token] 1 return model这段代码有几个关键点BOS表示 Begin Of Sequence用来建模“句首出现什么 Token”的概率。EOS表示 End Of Sequence用来建模“什么情况下文本结束”。每次取n-1个 Token 作为上下文统计下一个 Token 的出现次数。例如 bigram 模型会统计(the,) - cat、(the,) - dog等转移关系。对于上下文(the,)所有以the为前文的后续 Token 次数组成了一个 Counter。3.3 计算候选 Token 的概率分布模型存的是频次对外使用时要转换成概率。def get_distribution(model, context): # context 是长度为 n-1 的 Token 列表 dist model[tuple(context)] total sum(dist.values()) if total 0: return [] return [(tok, cnt / total) for tok, cnt in dist.most_common()]get_distribution会返回一个列表列表中每个元素是(token, probability)。如果该上下文在语料中没有出现过就返回空列表表示“没有候选”。我们可以看一下 bigram 模型在上下文[the]下的输出model build_ngram_model(CORPUS, n2) print(get_distribution(model, [the]))输出[(cat, 0.4), (dog, 0.2), (next, 0.2), (mat, 0.2)]这个概率分布说明在语料中the后面跟着cat的频率最高。真实语言模型也是类似思路只不过它的概率分布由神经网络计算而不是简单的计数。4. 交互式生成让用户扮演语言模型4.1 交互主循环设计现在进入最有趣的部分把“模型选择 Token”这一步交给你。交互主循环需要做这几件事维护一个上下文列表。根据当前上下文计算候选 Token 概率分布。把候选项和概率展示给用户。用户通过序号或直接输入 Token 做选择。把选择结果追加到上下文继续下一步。上下文列表的初始值是[BOS] * (n - 1)。这样对于 bigram 模型第一步上下文就是[BOS]模型预测句首词。4.2 完整可运行代码把上述逻辑整合成一个完整的文件interactive_lm.pyimport re from collections import Counter, defaultdict CORPUS [ the cat sat on the mat, the dog sat on the floor, the cat chased the dog, a dog sat with a cat, language models predict the next token, the next token depends on context, ] N 2 # bigram可以改成 3 体验 trigram class SimpleTokenizer: def __init__(self): self.pattern re.compile(r\w|[^\w\s]) def encode(self, text: str): return [tok.lower() for tok in self.pattern.findall(text)] def build_ngram_model(corpus, n2): model defaultdict(Counter) tokenizer SimpleTokenizer() for text in corpus: tokens tokenizer.encode(text) sequence [BOS] * (n - 1) tokens [EOS] for i in range(n - 1, len(sequence)): context tuple(sequence[i - n 1:i]) token sequence[i] model[context][token] 1 return model def get_distribution(model, context): dist model[tuple(context)] total sum(dist.values()) if total 0: return [] return [(tok, cnt / total) for tok, cnt in dist.most_common()] def run_interactive(model, n2, max_steps20): context [BOS] * (n - 1) print(现在你就是语言模型。) print(请根据上下文选择下一个 Token。\n) for step in range(max_steps): context_window context[-(n - 1):] if n 1 else [] dist get_distribution(model, context_window) if not dist: print(\n没有候选 Token生成结束。) break print(fStep {step 1}) print(上下文:, .join(context_window)) for i, (tok, prob) in enumerate(dist): print(f [{i}] {tok:12} {prob:.2f}) print( [q] 退出) choice input(你的选择: ).strip().lower() if choice q: break if choice.isdigit(): idx int(choice) if 0 idx len(dist): chosen dist[idx][0] else: print(序号无效请重试。) continue else: chosen choice context.append(chosen) print(→ 你选择了:, chosen) print() if __name__ __main__: model build_ngram_model(CORPUS, nN) run_interactive(model, nN)你可以在终端中运行python interactive_lm.py4.3 运行示例下面是一次真实交互的过程用户输入序号现在你就是语言模型。 请根据上下文选择下一个 Token。 Step 1 上下文: BOS [0] the 0.25 [1] a 0.17 [2] language 0.17 [3] ... 你的选择: 0 → 你选择了: the Step 2 上下文: the [0] cat 0.40 [1] dog 0.20 [2] next 0.20 [3] mat 0.20 你的选择: 0 → 你选择了: cat当用户选择cat后上下文变成the cat下一步模型会根据cat做预测。整个流程会让你很直观地感受到语言模型不是“想好一句话再输出”而是每一步只考虑下一步边生成边决定。如果你连续选择可能生成出the cat sat on the mat这其实就是语料中最常见的句子。n-gram 模型会复现训练语料里的高频片段而神经网络语言模型在此基础上具备更好的泛化能力。5. 采样策略如果让模型自己选手动选择 Token 很直观但现实中的模型不会每次都在终端等你输入。它需要通过某种策略从概率分布中自动取样。下面介绍三种最常见的解码策略并用代码实现。5.1 贪心解码贪心策略最简单每次选择概率最高的 Token。def greedy_sample(dist): return dist[0][0]这种策略生成的文本比较稳定但容易变得重复和单调。因为一旦进了高频路径就很容易沿着惯性一直走。5.2 温度采样温度参数可以调节概率分布的“尖锐程度”。温度越低概率高的 Token 越容易被选中温度越高分布越平缓随机性越强。import random def temperature_sample(dist, temperature1.0): if temperature 0: return dist[0][0] weights [prob ** (1.0 / temperature) for _, prob in dist] total sum(weights) probs [w / total for w in weights] tokens [tok for tok, _ in dist] return random.choices(tokens, weightsprobs, k1)[0]当temperature1.0时结果接近原始概率分布当temperature0.8时高概率 Token 更容易被选中当temperature1.5时低概率 Token 也有机会“翻身”。温度采样是实际大模型生成时最常用的策略之一。它给生成过程带来了随机性也让文本更像人类创作而不是机械复读。5.3 Top-k 过滤另一种常见策略是 Top-k。只从概率最高的 k 个 Token 中采样彻底放弃低概率候选。def top_k_sample(dist, k3): top dist[:k] total sum(prob for _, prob in top) if total 0: return dist[0][0] tokens [tok for tok, _ in top] probs [prob / total for _, prob in top] return random.choices(tokens, weightsprobs, k1)[0]Top-k 的好处是可以避免模型选中那些概率极低的奇怪 Token。实际工程中Top-k、温度采样、Top-p核采样经常组合使用。你可以把上面两个函数加入模拟器让代码变为“自动模式”每次运行时观察不同策略产生的文本差异。6. 常见问题与排查思路在实际实现和使用这类模拟器时会遇到一些问题。我把高频问题和解决思路整理成了表格。问题现象常见原因解决思路中文语料效果差英文正则分词器不支持中文分词使用jieba分词或改为字符级 Token生成的文本不断重复n 取太小例如 bigram语料过小增大 n使用 trigram扩充语料规模选择某个 Token 后没有下一步候选该 n-gram 上下文在语料中没有出现过添加平滑策略例如回退到低阶 n-gram概率相加不是 1只统计了高频部分或存在未登录 Token确认完整概率分布或者对未登录 Token 做平滑处理模型只会背语料里的句子n-gram 模型本身泛化能力弱换成神经网络语言模型用更大的语料训练程序输入序号越界用户输入了超出候选范围的数字增加输入校验如代码中0 idx len(dist)下面重点说明两个工程上最容易踩的坑。第一个坑是“上下文窗口长度不匹配”。n-gram 模型要求上下文长度固定为n-1如果你传入了更长的上下文model[tuple(context)]会查找不到返回空分布。很多初学者直接把所有历史 Token 都丢进去导致模型失效。解决方法是只保留最后n-1个 Token。第二个坑是“OOVOut-Of-Vocabulary未登录词”。当用户手动输入了一个语料中从未出现过的 Token后续可能没有任何候选 Token生成过程会直接中断。解决办法包括限制用户只能从候选中选择对未知 Token 做统一映射例如全部映射为UNK使用回退模型当高阶 n-gram 无概率时回退到低阶 n-gram。7. 最佳实践与工程建议如果你要把这个模拟器扩展成更正式的教学工具或小项目可以参考下面这些建议。7.1 语料清洗与统一 Token 化语料质量直接影响模型行为。分词之前可以先做统一小写、去除无意义符号、合并空白、处理 URL 等清洗操作。所有模型的输入输出要保持同一个分词器训练和推理阶段不能混用不同分词逻辑。7.2 使用平滑和回退n-gram 模型的最大缺点是数据稀疏。即使语料足够大仍然会出现很多没有见过的 n-gram 组合。推荐使用加一平滑Add-One Smoothing或线性插值让低概率上下文也有一个非零概率。实际大模型内部同样需要处理未登录 Token 和低概率分布的问题只是方式更复杂。7.3 随机种子与复现性如果想让“自动采样”的结果可复现可以在代码开头设置随机种子random.seed(42)这在大模型推理和实验结果复现中尤其重要。无论是写作示例、单元测试还是调参固定随机种子都能帮你减少不确定性。7.4 日志与中间状态输出调试交互程序时建议把“上下文窗口”“候选 Token 列表”“用户选择”都记录下来。这样即使生成结果不可控你也能反推是哪里出了问题。可以用logging模块而不是到处print。7.5 权限与安全边界如果你的模拟器未来接入了真实模型、公网服务或允许用户自定义输入必须考虑安全边界。例如限制单次生成长度、限制用户输入大小、避免用户在 Prompt 中注入不安全内容。这是一个教学项目不涉及生产环境但“最小权限”和“可控输入”这两个原则从第一天就值得遵守。8. 总结与下一步学习方向通过这个模拟器你已经亲手体验了语言模型最核心的生成闭环Token 化、上下文窗口、条件概率预测、解码采样。看似复杂的 GPT 生成过程拆开以后其实就是“观察历史 → 计算分布 → 取样 → 追加历史”四步循环。完成模拟器之后我最大的感受是当你真的手动去选 Token 时你会很快意识到“概率分布”不是抽象概念而是生成质量好坏的根本原因。为什么模型会重复为什么有时候答案很离谱为什么温度调高以后文本更“发散”这些问题的答案都会因为你亲手操作过一遍而变得非常具体。下一步你可以继续了解BPE 和 WordPiece 等真实 Tokenization 算法Transformer 中的注意力机制如何建模长距离上下文GPT、LLaMA 等大模型如何从预训练到指令微调温度、Top-p、频率惩罚等采样参数在生产环境中的配合使用。如果你对基于统计的语言模型感兴趣也可以把语料换成一个中文小说接入jieba分词再对比 bigram 和 trigram 的生成效果。那将是另一个很有价值的实验。希望这篇文章能帮你把“Token”和“Language Model”这两个词从概念变成亲手玩过的体验。