最近在学 AI Agent,发现一个绕不开的概念:Token

刚开始我以为 Token 只是“计费单位”,比如输入多少 token、输出多少 token、百万 token 多少钱。后来写了一点 demo 才发现,Token 不只是计费问题,它其实是大模型处理文本的第一步。

这篇文章用 JavaScript 做两个小实验:

  1. js-tiktoken 把文本编码成 token,再解码回文本
  2. 用 embedding 模型把文本变成向量,再计算两段文本的语义相似度

image.png

项目结构大概是这样:

Token
├── readme.md
└── demo
    ├── index.mjs
    ├── main.mjs
    ├── package.json
    └── .env

index.mjs 用来演示 Token 编码和解码。

图中"Hello, tiktoken! 你好,世界!"被划为13个token image.png

main.mjs 用来演示 Embedding 和余弦相似度。

余弦相似度(cos)越接近1,就说明意思越接近。 在这里插入图片描述

一、学习 LLM,为什么要先理解 Token

如果只是使用 ChatGPT、Claude、通义千问、豆包这类工具,可以不关心 Token。

但如果要做 AI 全栈、AI Agent、RAG、知识库、智能客服、代码助手,就必须理解 Token。

原因很直接:

用户输入的文本,不会被大模型直接理解。

大模型真正处理的是数字。

文本进入模型之前,会先经过 tokenizer,被切成一个个 token,然后映射成 token ID。

可以粗略理解为:

文本 -> token -> token ID -> embedding 向量 -> 模型计算 -> 输出 token -> 文本

完整一点的流程是:

Prompt 文本
    ↓
Tokenizer 分词器
    ↓
Token IDs
    ↓
Embedding 向量化
    ↓
Transformer 模型计算
    ↓
输出 Token IDs
    ↓
Decode 解码成文本

所以 Token 不是一个孤立概念,它连接了文本、计费、上下文长度、embedding、模型推理。

二、Token 到底是什么

Token 可以理解成大模型处理文本的最小单位。

但注意,Token 不一定等于“一个字”,也不一定等于“一个单词”。

比如英文里,一个单词可能被切成一个 token,也可能被切成多个 token。

中文里,一个汉字可能对应一个 token,也可能和周围字符组合成 token。

所以不要把 Token 简单理解成“字数”。

更准确的说法是:

Token 是 tokenizer 按照特定规则切出来的文本片段。

然后每个 token 会被映射成一个数字 ID。

大模型真正处理的是这些 ID 背后的向量表示。

三、为什么必须分词

计算机不能直接理解中文、英文、标点符号这些东西。

神经网络处理的是数字,通常是向量、矩阵这类数学对象。

所以模型不能直接吃这段文本:

你好,大模型

它需要先把文本转成数字。

大概过程是:

你好,大模型
    ↓
切成 token
    ↓
[token1, token2, token3]
    ↓
映射成 token ID
    ↓
[57668, 53901, 3922, ...]

这些 ID 再进入 embedding 层,变成高维向量。

这一步之后,模型才有办法用数学方式计算文本之间的关系。

四、Token 和计费、上下文长度的关系

我们平时调用大模型 API,经常会看到这些说法:

输入 token
输出 token
上下文窗口
百万 token 价格

这些都和 Token 有关。

一次 API 调用的总 token 通常可以理解为:

总 token = 输入 token + 输出 token

输入 token 就是你发给模型的 prompt、历史对话、系统提示词、工具结果等。

输出 token 就是模型生成的回答。

如果你做 AI Agent,这个点尤其重要。因为 Agent 往往会带很多上下文:

系统提示词
用户问题
历史对话
工具调用结果
RAG 检索片段
代码文件内容
中间思考过程或执行记录

这些都会占 token。

上下文塞得越多,成本越高,响应越慢,也更容易超过模型上下文限制。

五、项目依赖

package.json 里主要用了三个依赖:

{
  "dependencies": {
    "dotenv": "^17.4.2",
    "js-tiktoken": "^1.0.21",
    "openai": "^6.44.0"
  }
}

分别作用如下:

dotenv:读取 .env 环境变量
js-tiktoken:在 JavaScript 中计算 token
openai:调用兼容 OpenAI SDK 的模型 API

安装方式:

pnpm install

或者:

pnpm i dotenv js-tiktoken openai

六、用 js-tiktoken 做 Token 编码和解码

先看 index.mjs

import { getEncoding } from 'js-tiktoken';

const enc = getEncoding('cl100k_base');

const text = "Hello, tiktoken! 你好,世界!";

const tokens = enc.encode(text);

console.log("Token IDs:", tokens, tokens.length);

const decodedText = enc.decode(tokens);

console.log("Decode Text", decodedText);

这里最核心的是:

const enc = getEncoding('cl100k_base');

cl100k_base 是一种常见的 GPT 系列 tokenizer 编码规则。

可以把它理解成一张映射表:

文本片段 -> token ID

然后这行代码负责编码:

const tokens = enc.encode(text);

它会把文本转成 token ID 数组。

比如:

Hello, tiktoken! 你好,世界!

会被转成类似这样的数组:

[9906, 11, 87272, ...]

具体数字不重要,重要的是理解这个过程:

文本被 tokenizer 转成了模型能处理的数字 ID

接着这行代码负责解码:

const decodedText = enc.decode(tokens);

也就是把 token ID 再还原成文本。

所以 encodedecode 是一对操作:

encode:文本 -> token ID
decode:token ID -> 文本

七、为什么要自己统计 Token

如果只是聊天,可以不用管。

但写 AI 应用时,自己统计 Token 很有用。

比如做 RAG 知识库时,不能把整篇文档全部塞给模型。

需要先切片:

长文档 -> 多个 chunk -> 每个 chunk 控制 token 数

如果不统计 token,很容易出现两个问题。

第一个问题是超过上下文长度。

比如模型最多支持一定上下文,你却塞了太多文档进去,请求可能直接失败。

第二个问题是成本失控。

用户问一个简单问题,你却把几十页文档全塞进去,效果不一定更好,但 token 费用一定更高。

所以工程里经常需要做这些事:

统计 prompt token
限制单个 chunk token
控制历史对话长度
按 token 截断文本
预估 API 调用成本

js-tiktoken 就适合做这类事情。

八、Token 之后是什么:Embedding

Token ID 只是离散数字。

比如:

[9906, 11, 87272]

这些数字本身还不直接表示语义。

模型还需要把 token ID 转成向量。

这个过程就是 embedding。

Embedding 可以理解成:

把文本变成一串高维数字

比如一个文本被转成 1024 维向量:

[  0.012,  -0.034,  0.156,  ...]

这些数字不是随便来的,而是模型学到的语义表示。

如果两段文本语义相近,它们的 embedding 向量在空间中的方向通常也会更接近。

九、调用 Embedding 模型

再看 main.mjs

import { OpenAI } from 'openai';
import dotenv from 'dotenv';

dotenv.config();

const client = new OpenAI({
  apiKey: process.env.DASHSCOPE_API_KEY,
  baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
});

这里用了 openai SDK,但 baseURL 指向的是阿里云百炼 DashScope 的兼容模式接口:

baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1'

API Key 放在 .env 里:

DASHSCOPE_API_KEY=你的密钥

注意:密钥不要提交到 GitHub。

然后封装一个获取 embedding 的函数:

async function getEmbedding(text) {
  const res = await client.embeddings.create({
    model: 'text-embedding-v4',
    input: text,
    dimensions: 1024
  });

  return res.data[0].embedding;
}

这段代码做了三件事:

传入一段文本
调用 embedding 模型
返回这段文本对应的向量

这里指定了:

dimensions: 1024

表示希望返回 1024 维向量。

十、什么是语义相似度

有了 embedding 之后,就可以比较两段文本是不是语义接近。

比如这两句话:

Andrej Karpathy LLM Tokenization 分词原理
卡帕西讲解大模型 BPE 字符分词

它们表面文字不完全一样。

但语义上很接近,都在说 Karpathy、大模型、Tokenization、分词。

再看第三句话:

今天天气晴朗,适合出门运动

这句话和前两句明显不是一个主题。

人能看出来这个区别,embedding 也可以通过向量相似度体现出来。

十一、余弦相似度

项目里用了余弦相似度:

function cosineSimilarity(vecA, vecB) {
  let dot = 0, magA = 0, magB = 0;

  for (let i = 0; i < vecA.length; i++) {
    dot += vecA[i] * vecB[i];
    magA += vecA[i] ** 2;
    magB += vecB[i] ** 2;
  }

  return dot / (Math.sqrt(magA) * Math.sqrt(magB));
}

它计算的是两个向量方向是否接近。

可以粗略理解为:

结果越接近 1,语义越相似
结果越接近 0,语义越不相关
结果越接近 -1,方向越相反

在文本 embedding 场景里,我们更常用它判断两段文本是否语义接近。

余弦相似度公式是:

cosine(A, B) = A · B / (|A| * |B|)

代码里的 dot 是点积:

dot += vecA[i] * vecB[i];

magAmagB 是两个向量长度的平方累加:

magA += vecA[i] ** 2;
magB += vecB[i] ** 2;

最后除以两个向量的模:

return dot / (Math.sqrt(magA) * Math.sqrt(magB));

十二、完整运行逻辑

run 函数大概是这样:

async function run() {
  const text1 = "Andrej Karpathy LLM Tokenization 分词原理";
  const text2 = "卡帕西讲解大模型 BPE 字符分词";
  const text3 = "今天天气晴朗,适合出门运动";

  const vec1 = await getEmbedding(text1);
  const vec2 = await getEmbedding(text2);
  const vec3 = await getEmbedding(text3);

  console.log(vec1);
  console.log(vec1.length);

  console.log("相似度:", cosineSimilarity(vec1, vec2));
  console.log("相似度:", cosineSimilarity(vec1, vec3));
}

run();

这里预期会看到:

vec1.length = 1024

说明 embedding 返回的是 1024 维向量。

然后比较:

text1 和 text2 的相似度
text1 和 text3 的相似度

正常情况下,text1text2 的相似度应该更高。

因为它们都在讨论大模型分词。

text3 是天气和运动,主题完全不同,相似度应该更低。

十三、这个 demo 和 RAG 有什么关系

这其实已经是 RAG 的基础了。

RAG 的核心流程可以简化为:

文档切片
    ↓
每个切片生成 embedding
    ↓
用户问题生成 embedding
    ↓
计算问题和文档切片的相似度
    ↓
取最相似的几个切片
    ↓
塞给大模型生成回答

也就是说,RAG 不是简单地把所有资料都丢给大模型。

它会先用 embedding 做语义检索。

比如用户问:

Tokenization 为什么重要?

系统会把这个问题转成向量。

然后去文档库里找语义最接近的片段。

找到之后,再把这些片段作为上下文交给大模型。

所以这个 demo 虽然很小,但它已经覆盖了 RAG 里最关键的一块:

文本 -> embedding -> 相似度计算

十四、Token、Embedding、LLM 的关系

可以用一条链路串起来:

文本
  ↓
Tokenizer
  ↓
Token IDs
  ↓
Embedding
  ↓
向量表示
  ↓
Transformer
  ↓
输出 Token
  ↓
Decode 成文本

Tokenization 解决的是:

怎么把文本切成模型能处理的基本单位

Embedding 解决的是:

怎么把 token 或文本变成带有语义信息的向量

LLM 解决的是:

基于上下文预测接下来应该生成什么

这几个概念连起来,才是大模型真正工作的样子。

十五、我对学习 AI Agent 的理解

如果要学 AI Agent,不建议一上来就堆框架。

比如一开始就冲 LangChain、AutoGen、CrewAI、各种 Agent 框架,很容易只会调 API,不知道底层发生了什么。

更好的路径是先把这些基础概念吃透:

Prompt 是什么
Token 是什么
上下文窗口是什么
Embedding 是什么
相似度检索是什么
Function Calling / Tool Calling 是什么
RAG 是什么
Agent 为什么需要工具和记忆

然后再去做项目。

比如:

个人知识库问答
代码仓库问答助手
简历优化 Agent
AI 学习笔记助手
客服知识库机器人
网页内容总结工具

这些项目都离不开 Token 和 Embedding。

十六、容易踩的坑

1. 把 token 当成字数

Token 不是字数。

中文、英文、标点、空格、代码都会影响 token 数。

所以要用 tokenizer 工具实际统计。

2. 忽略输入 token

很多人只关注模型输出花了多少 token。

但实际成本里,输入 token 也很重要。

尤其是 RAG 和 Agent,输入上下文经常很长。

3. 把 embedding 当成模型回答

Embedding 不负责生成回答。

它负责把文本转成向量,方便做检索、聚类、推荐、相似度计算。

真正生成自然语言回答的,是聊天模型或文本生成模型。

4. 直接把密钥写进代码

不要这样写:

apiKey: "sk-xxxx"

应该放到 .env

DASHSCOPE_API_KEY=你的密钥

然后代码里读取:

apiKey: process.env.DASHSCOPE_API_KEY

5. 只看文字是否相同,不看语义是否相近

Embedding 的价值就在这里。

两段文本可以字面不同,但语义相近。

比如:

Tokenization 分词原理
BPE 字符分词机制

字面不完全一样,但主题很接近。

十七、总结

这次 demo 其实就做了两件事。

第一,用 js-tiktoken 理解 Token:

文本 -> token ID -> 文本

第二,用 embedding 理解语义相似度:

文本 -> 向量 -> 余弦相似度

如果把 AI 应用比作一栋楼,Token 和 Embedding 就是地基。

不理解 Token,就很难控制上下文、成本和模型输入。

不理解 Embedding,就很难理解 RAG、知识库、语义搜索和 Agent 记忆。

所以这两个概念值得认真写代码跑一遍。

代码不复杂,但能帮我们把“大模型到底怎么处理文本”这件事看得更清楚。

更多推荐