Token 不是字也不是词:300K 上下文窗口到底能装多少行代码
你在 Cursor 里把整个 Service 目录 @ 进去,说”帮我把这段下单逻辑重构一下”,结果它回你一句 context_length_exceeded。你心里纳闷:不是说好 200K、300K 的上下文窗口吗?我这才几万行代码,怎么连一个模块都塞不下?
问题的根子,在于把 token 当成了”词”。很多人以为 300K 就是”30 万个词”或”30 万个字”,于是按”一个汉字一个字、一行代码一行”去估量。但 token 既不是语言学里的”词”,也不是”字”,它是另一套东西——子词(subword)。
这篇文章把”token 到底是什么”说透,然后给你一套 Java / Python 程序员能直接套用的换算表。它是《大模型无状态》 2.4 节的展开——那篇只给了三句粗经验,这篇把它们算成具体数字。
一、Token 是”子词”,不是”词”也不是”字”
1.1 先看一个真实例子
“我们去打篮球”这 6 个字,到底切成几个 token?我用 tiktoken 实际跑了一下,两个词表给出的结果完全不同:
| 分词器 | 结果 | 说明 |
|---|---|---|
cl100k_base(GPT-3.5/4) |
6 个 token:我们 去 打 � � 球 |
“我们”高频→合并成 1 个;”篮”低频→被拆成 2 个字节级 token |
o200k_base(GPT-4o/o 系列) |
4 个 token:我们 去 打 篮球 |
新词表里”篮球”也常见→合并成 1 个 |
这里有两个反直觉的点,正是理解 token 的钥匙:
- “我们”两个字被拼成了 1 个 token——所以 token 不是”一个字”。它俩高频共现,被整体固化成了一条词表项。
- “篮”这个字被拆成了 2 个 token(表里的
�不是乱码,而是”这个 token 只是半个汉字的字节,单独解码不成合法 UTF-8”)——所以 token 也不是”一个词”,它甚至能拆到比字还小的字节级。
同一句话,一个词表切 6 个,另一个切 4 个。这就是为什么所有”1 汉字 ≈ 1~2 token”的经验值都只能叫”粗经验”——它不是一个规则,而是”常见片段合并、生僻字拆字节“之后统计出来的平均值。
1.2 这套切法叫 BPE
模型不认识”字”,只认 token。发给模型前,整段文本会被一个分词器(tokenizer)切成 token 序列。主流的做法是 BPE(Byte Pair Encoding,字节对编码):
拿海量语料,反复统计”哪两个相邻片段最常一起出现”,把最常一起出现的那对合并成一个新片段,固化成词表里的一条。循环 N 次,得到一张固定大小的词表。之后切文本时,能整段命中词表的就整段拿走,命中不了的就往更小拆,拆到字节为止。
所以词表里既有 the、我们、篮球 这种”整词”,也有 un、ing、appiness 这种”碎片”,还有 � 这种”半个字的字节”。token 就是这张词表里的一个条目,仅此而已。
1.3 三条规律
把上面的原理落到实际,记住三条:
① 高频片段会”打包”成 1 个 token
我们→ 1 个 token(哪怕它是两个字)篮球→ 1 个(o200k)或 3 个(cl100k,因为”篮”被拆了)
② 英文比中文”划算”
playing→ 1 个 token(高频整词)unhappiness→ 3 个:un+h+appiness(注意:不是语言学上的un+happiness,纯粹是语料里un、appiness出现得多)The cat sat on the mat.→ 7 个 token,且空格附着在词上(cat、sat),不单独算
英文平均 1 个词 ≈ 1.3 个 token;中文平均 1 个汉字 ≈ 1~2 个 token。同样一句话,中文往往更”吃 token”,这就是中文对话更贵、更费窗口的原因。
③ 代码比散文更”重”
这段 3 行、61 字符的 Java 代码:
public void doSomething(int x) {
if (x > 0) { return; }
}
切成 21 个 token(≈ 2.9 字符/token),比英文散文(≈ 4 字符/token)更密——因为 {、(、;、缩进空格每个都占 token。代码里的标点和缩进在模型眼里不是免费的。
二、对 Java / Python 程序员:实测 token 密度
光知道”代码更重”不够,得有个能直接用的数。我用一个接近真实业务的 Java 类和 Python 模块做了实测(o200k_base 词表):
Java 类(35 物理行,含 import / 注释 / 空行):
package com.example.order.service;
import java.util.List;
/**
* 订单服务:负责订单的创建与状态流转。
*/
public class OrderService {
private final OrderMapper orderMapper;
public OrderService(OrderMapper orderMapper) {
this.orderMapper = orderMapper;
}
public Order createOrder(CreateOrderRequest request) {
if (request == null || request.getItems().isEmpty()) {
throw new IllegalArgumentException("items 不能为空");
}
Order order = new Order();
order.setUserId(request.getUserId());
order.setStatus(OrderStatus.PENDING);
order.setItems(request.getItems());
return orderMapper.insert(order);
}
private void validatePrice(long price) {
if (price <= 0) {
throw new IllegalArgumentException("价格必须大于 0");
}
}
}
Python 模块(27 物理行):
import logging
from dataclasses import dataclass
from typing import List, Optional
logger = logging.getLogger(__name__)
@dataclass
class Order:
order_id: str
user_id: str
status: str = "PENDING"
items: Optional[List[str]] = None
def create_order(request: dict) -> Order:
"""根据请求创建一个待支付订单。"""
if not request.get("items"):
raise ValueError("items 不能为空")
order = Order(
order_id=request["order_id"],
user_id=request["user_id"],
items=request["items"],
)
logger.info("order created: %s", order.order_id)
return order
实测结果:
| 样本 | 行数 | 字符数 | token 数 | 平均 |
|---|---|---|---|---|
| Java 类 | 35 行 | 856 | 191 | ≈ 5.5 token/行 |
| Python 模块 | 27 行 | 591 | 147 | ≈ 5.4 token/行 |
注意这里的”行”是物理行——包含注释、空行、大括号独占行。如果只数”有效代码行”(LOC,去掉注释和空行),密度会升到 8~12 token/行。两者都常用,所以换算时要分清口径。
结论:Java / Python 一个实用的经验值——物理行按 5~8 token/行估,纯代码行按 8~12 token/行估。 下面按 6 token/物理行 和 10 token/有效代码行 两个口径换算。
三、300K / 250K / 200K 到底是多少
单位是 token,不是字也不是行。套用上面的密度,给你一张能直接查的表:
| 窗口 | 物理行(≈6 token/行) | 有效代码行(≈10 token/行) | 英文单词 | 中文汉字 |
|---|---|---|---|---|
| 200K | ≈ 3.3 万行 | ≈ 2 万行 | ≈ 15 万词 | ≈ 10~20 万字 |
| 250K | ≈ 4.2 万行 | ≈ 2.5 万行 | ≈ 18.8 万词 | ≈ 12.5~25 万字 |
| 300K | ≈ 5 万行 | ≈ 3 万行 | ≈ 22.5 万词 | ≈ 15~30 万字 |
文字的手感:
- 英文:300K ≈ 22.5 万词 ≈ 2 本普通长篇小说(每本约 8~10 万词),或《战争与和平》(约 58 万词) 的四成。
- 中文:300K ≈ 15~30 万汉字 ≈ 一本中等技术书的量,或《三体》第一部(约 90 万字)的 1/6~1/3。
代码的手感(对咱们更重要):
- 300K ≈ 3 万有效代码行,或 5 万物理行。
- 一个典型 Java 服务的一个功能模块(controller/service/mapper 几十个类)通常是 1~3 万行——也就是说 200K~300K 一个窗口,大致能装下”一个功能模块 + 它的测试”,但装不下一个 10 万行以上的完整仓库。
顺带纠一个常被叫错的版本:你说到的 “Sonnet 4.7” 其实是 Claude Sonnet 4.5,官方标称 200K(1M 是 beta 档,且部分已停用)。各家标称的 200K/250K/300K 零头来自不同产品档位,量级都是”十万级”,不用纠结精确值。
四、三个折扣:标称窗口 ≠ 有效窗口
上面那张表是”物理上限”,实际能用到的要打三个折扣(《大模型无状态》 3.4/3.5 节详细讲过):
- 输入 + 输出共享窗口:窗口 = 输入 token(system/历史/上下文/工具 schema)+ 输出 token(模型要生成的回复)。让模型整文件重写,输出本身可能吃掉几万 token,加上输入,很容易顶到上限。
- 迷失在中间(Lost in the Middle):即便没超限,模型对长上下文中间部分的注意力会自然衰减——开头和结尾记得牢,中间一大段”看是看见了,但没真看进去”。塞满反而”抓大放小”,逐行定位变差。
- 注意力退化:超长窗口下,模型对细节的抓取能力下降,精确引用、逐行修改的能力变弱。
所以一条重要心法:窗口长 ≠ 记得准。 对”精确定位、逐行修改”这类编码任务,宁可窗口里装”精准选中的少量相关代码”,也不要塞满一堆无关文件。
五、落到你的两个场景
5.1 代码分析 / 功能修改(Java / Python 为主)
- 你不需要、也不应该喂整个仓库。 200K~300K 足够塞下:目标文件 + 它的调用链邻居 + 测试 + 一段 diff,一次改一个 feature 绰绰有余。
- 实战建议:
- 让 agent 工具(Cursor / Claude Code 的 grep、ripgrep)按需检索,窗口里只留”相关文件”;
- 改动用 diff / 小步 edit,别整文件重写(既省 token,又把窗口留给最相关的上下文);
- 一个会话聚焦一个任务,做完就新开,历史越杂噪声越多。
5.2 查 ES 做数据分析
- 重点:别把 ES 的原始结果 dump 给模型。 JSON 极吃 token(引号、冒号、字段名重复)。实测一条 197 字符的订单 JSON:
{"order_id":"o_20260901_0001","user_id":"u_888","status":"PAID","amount":12800,"items":[{"sku":"A100","qty":2,"price":3900},{"sku":"B200","qty":1,"price":5000}],"created_at":"2026-09-01T10:23:45Z"}
这一条就 ≈ 79 token。照此估算,200K token 只能塞 几百到两千条文档;文档再大点(1~2KB 嵌套),只能塞 300~700 条。
- 而 ES 的查询 DSL 本身只有几百 token,便宜得很。所以正确姿势是:
- 让模型写/改 DSL,让 ES 用
size: 0+ 聚合(aggs)把结果算好,再喂聚合结果/汇总给模型分析; - 要原始数据就
_source过滤只取需要的字段 + 取样 + 限制条数,而不是全量hits。
- 让模型写/改 DSL,让 ES 用
一句话:窗口是给你”放上下文”的,不是给你”倒数据”的。
六、附:自己动手数 token 的脚本
想对自己项目的真实代码有精确数,几行 Python 就够(Java 同学也可以跑,装个 tiktoken 即可):
import sys
import tiktoken
enc = tiktoken.get_encoding("o200k_base")
def count(path):
with open(path, encoding="utf-8") as f:
text = f.read()
lines = text.splitlines()
n = len(enc.encode(text))
print(f"{n:>7} tokens | {len(lines):>4} 行 | {n/len(lines):.1f} token/行 | {path}")
for p in sys.argv[1:]:
count(p)
用法:python tokencount.py src/**/*.java,会按文件输出 token 数和 token/行 密度。用它把你们仓库的关键模块数一遍,你对”一个模块到底占多少 token”就有精确数了,比任何经验表都靠谱。
两个提醒:
tiktoken是 OpenAI 的词表;Claude(Sonnet 4.5)用的是另一套词表,但原理相同(都是 BPE 子词),对同一段代码的具体 token 数略有出入,量级和”合并/拆分”规律一致,用来估窗口完全够用。- 想数 Claude 的精确词表,可以用 Anthropic 官方 SDK 的
count_tokens(Python / TypeScript 都有),但日常估量用上面这个脚本就足够了。
总结
- token 是”子词”,不是”词”也不是”字”:高频片段合并(
我们→1 个),生僻字拆字节(篮→2 个)。 - 英文 1 词 ≈ 1.3 token,中文 1 字 ≈ 1~2 token,代码 1 物理行 ≈ 5~8 token。
- 300K ≈ 3 万有效代码行 / 5 万物理行 / 15~30 万汉字——大约就是一个功能模块的量,不是一个仓库的量。
- 标称窗口要打三个折扣:输入输出共享、迷失在中间、塞满不如塞准。
- 对你:改代码就精准 @ 相关文件 + diff 小步编辑;查 ES 就让模型写 DSL、ES 算聚合,别倒原始 hits。
一句话记住:窗口是”工作台”,不是”仓库”,更不是”数据湖”。