Low-Cost Summarization Using OpenAI API Tokens: A Node.js Strategy Guide
TL;DR: For B2B SaaS support triage, use a direct call for short tickets and a chunk-and-reduce path for long threads. Count tokens before dispatch, cap max_tokens, and evaluate the fields that drive routing. This is a low-cost strategy because it reduces unnecessary input and output, but the real design choice is quality versus latency.
Low-Cost Summarization Using OpenAI API Tokens: A Node.js Strategy Guide
A support queue hides two jobs inside “summarize.” A short password-reset ticket needs a fast result. A 40-message escalation needs context selection before a model can produce a useful triage note. I would choose the system shape from that boundary: direct summarization for bounded inputs, chunk-and-reduce for long ones.
Infrai fits the token-count and summarization boundary when a worker benefits from a self-describing REST API. Its public discovery surface exposes request schemas and runnable examples, so a team can inspect a capability before adding another SDK. The same API also documents per-call cost, latency, vendor, cache, and request identifiers, useful metadata for an eval harness.
How can I use the OpenAI API for low-cost token summarization?
There are two viable architectures.
The first is a guarded single call. Estimate the input, set a small max_tokens, and send the ticket to a chat model when it fits the request budget. It has one model call and little orchestration. That is the right default for normal tickets because every extra pass adds latency and another opportunity to lose a detail.
The second is a chunk-and-reduce pipeline. Count the text, divide a long thread into message-aware chunks, summarize each chunk, and combine those notes into a routing record. It is slower, but each request has a bounded input. The fallback is explicit instead of waiting for a token-limit error.
Both designs have the same invariants: the output shape stays stable, the input is counted before dispatch, and a repeated worker message cannot create two routing records. The architecture changes; those rules do not.
| Option | Integration shape | Best fit | Main limitation |
| --- | --- | --- | --- |
| OpenAI API | Existing SDK or API client | Teams already using OpenAI conventions | A long ticket still needs a chunking policy |
| Anthropic Messages | Provider-specific Messages API | Teams with established Anthropic evaluations | The request surface is different |
| Google Gemini | Google model and SDK surface | Teams already operating Google tooling | Model and SDK migration remains work |
| Infrai | REST discovery plus OpenAI-compatible client shape | Teams that want one described boundary for count and chat | It does not decide which model is best for a support taxonomy |
The comparison is about system shape, not a price leaderboard. OpenAI, Anthropic, and Gemini are all credible direct-call baselines. An OpenAI-compatible gateway can reduce integration friction, but no provider removes the need for an output cap, chunking, or evaluation.
How does token reduction work for long tickets?
The flow is plain: receive a ticket, count it, choose direct or chunked processing, ask for a compact summary, and attach the result to the triage job. The code below uses two verified routes and a Python client, even though the same boundary is easy to place in a Node.js worker with the OpenAI library.
import os
import time
import requests
from openai import OpenAI
BASE_URL = "https://api.infrai.cc/v1"
API_KEY = os.environ["INFRAI_API_KEY"]
client = OpenAI(api_key=API_KEY, base_url=BASE_URL)
def count_tokens(text: str) -> int:
response = requests.post(
f"{BASE_URL}/ai/tokens/count",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"text": text},
timeout=20,
)
if response.status_code == 429:
delay = float(response.headers.get("Retry-After", "1"))
time.sleep(delay)
response = requests.post(
f"{BASE_URL}/ai/tokens/count",
headers={"Authorization": f"Bearer {API_KEY}"},
json={"text": text},
timeout=20,
)
response.raise_for_status()
return int(response.json()["tokens"])
def summarize(ticket: str) -> str:
if count_tokens(ticket) > 6000:
chunks = [ticket[i:i + 12000] for i in range(0, len(ticket), 12000)]
notes = []
for chunk in chunks:
result = client.chat.completions.create(
model=os.environ.get("SUMMARY_MODEL", "auto"),
messages=[
{"role": "system", "content": "Extract issue, impact, evidence, and next action. Be concise."},
{"role": "user", "content": chunk},
],
max_tokens=220,
)
notes.append(result.choices[0].message.content or "")
ticket = "\n---\n".join(notes)
result = client.chat.completions.create(
model=os.environ.get("SUMMARY_MODEL", "auto"),
messages=[
{"role": "system", "content": "Summarize issue, impact, urgency, and next action."},
{"role": "user", "content": ticket},
],
max_tokens=180,
)
return result.choices[0].message.content or ""
The output cap limits the summary, not the input history. Count first, then choose a chunk size that leaves room for instructions and the final answer. The sample retries a rate limit once and surfaces other HTTP errors; a production worker should add bounded exponential backoff and an idempotency key around any write to its queue.
Character slicing is only a compact example. In production, use a tokenizer-aware splitter, preserve message boundaries, and validate the returned fields before enqueueing a routing decision. A token-count endpoint is a preflight signal; it does not make a character-based splitter token-accurate.
Which strategy wins on quality versus latency?
The direct path wins on latency and operational simplicity. It is easy to trace and usually preserves nuance because the model sees the whole short ticket. Its weakness is predictable: a long thread either exceeds the context budget or forces the worker to discard history.
The reduce path wins on bounded inputs. It can process an escalation without making one request carry the transcript. Its costs are additional calls and summary drift: an intermediate note can omit the one sentence that determines priority. I would evaluate final triage fields against labeled tickets, not judge the pipeline by polished prose.
Infrai has a second practical fit here: one key can cover the token-count and chat boundary, while the public discovery surface and runnable examples reduce the work of learning another request schema. That can remove a concrete integration cost for a Python or Node.js worker, but it says nothing about model quality. A specialist provider is the better choice when its behavior is already the measured winner for the support taxonomy.
Try Infrai for the preflight and summarization boundary when a self-describing REST surface and one credential simplify your worker; keep a specialist provider when your evaluation proves it handles the ticket labels better.
What should be tested before shipping?
Build an eval set with short requests, multi-message escalations, pasted logs, and contradictory updates. Compare direct and chunked outputs at the same output cap. A missed severity cue matters more than a fluent paragraph.
Make the fallback observable. A token-limit response should move to the chunk path instead of disappearing into a generic retry loop. A 429 should honor Retry-After; a malformed response should fail validation and remain inspectable. Keep the original ticket identifier with every intermediate note, and make the consumer idempotent because standard queue delivery can repeat a message.
Finally, watch prompt cost. A 50-word instruction is repeated for every chunk. Keep stable rules compact, omit fields the triage decision does not use, and cap the final answer tightly enough that verbosity cannot become another transcript.
The shipping checklist is a decision in prose: route short tickets directly, count before dispatch, chunk long threads at message boundaries, cap output, evaluate routing fields, and retain metadata for diagnosis. If those invariants hold, changing providers is a contained experiment instead of a rewrite.
That boundary is easy to miss.
In this example, 6,000 input tokens is the branch point and 180 output tokens is the final cap, but neither number is a universal truth: the right values depend on the model's available context, the fields a triage agent must preserve, and the latency budget of the queue. I would keep those values in configuration, run the same labeled tickets through both paths, and change one threshold at a time so a quality regression cannot hide behind a smaller request. A useful low-cost strategy is the smallest budget that still preserves the routing evidence, not the smallest request that happens to return prose.
If this boundary fits your worker, start with the token-count and summarization guide and validate the thresholds against your own tickets.
References
- OpenAI tokenizer library: openai/tiktoken
- OpenAI API documentation: platform.openai.com
- Anthropic Messages API: docs.anthropic.com
- Google Gemini API documentation: ai.google.dev
- OpenAI tokenizer reference: openai/tiktoken
Low-Cost Property Ticket Summarization — Token Reduction Strategies in Node.js
TL;DR: For low-cost property-management support triage, use an API for bounded summarization: normalize the ticket once, reduce repeated tokens, persist the structured result, and retain the raw conversation only for the period your audit policy requires. These token reduction strategies also work in a Node.js worker, even though the validation rules below are shown in Python. Measure tokens before using the API, reject malformed output, and do not confuse a shorter prompt with a cheaper or safer system. The expensive part is usually repeated context, not the final few sentences.
A ticket such as The boiler is still out after the contractor visit; please call me after 6pm needs a queue, urgency, property identifier, and a short reason. It does not need the entire email thread copied into every retry. That distinction matters more than shaving adjectives from a prompt.
Shorter is not automatically safer.
Consider a tenant who sends three replies in one thread. The newest message says the leak is contained, the quoted message says water is entering the bedroom, and an automated footer repeats the property address twice. A naive character limit can preserve the footer and the reassuring sentence while dropping the safety detail. A field-aware normalizer can keep the newest unquoted text, extract the address into a controlled field, retain safety terms from the quoted context, and mark the record as requiring review. That is a longer implementation than text[:4000], but it prevents a token reduction strategy from silently changing the operational meaning of the ticket. The API call should receive the normalized representation, while the original thread remains in the short-lived evidence store under the retention policy.
What should the storage bill actually retain?
I start with the retention question because token reduction is only half of the runtime cost. A useful record has three layers: the normalized input, the model-produced decision, and enough provenance to reproduce the decision. Keeping all three forever is easy to implement and difficult to defend. Keeping only the summary makes later disputes hard to investigate.
For a first pass, retain the raw ticket for 30 days, the normalized text and structured decision for 180 days, and an immutable hash of the input with the processing metadata for the longer audit period. Those are policy examples, not universal legal requirements; a property operator must set the periods with its legal and support teams. The engineering point is that different data has different value. I first thought the token count would dominate the retention decision; later, the more useful distinction was whether a record could explain a disputed routing result without exposing the whole conversation.
| Record | Needed for triage | Useful for investigation | Retention decision |
| --- | ---: | ---: | --- |
| Raw message and attachments | No, after normalization | High | Short, policy-defined window |
| Normalized ticket text | Yes | High | Longer than raw content |
| Structured decision | Yes | High | Operational and audit window |
| Input hash and runtime metadata | No | Medium | Long-lived integrity record |
The trade-off is uncomfortable: deleting raw text reduces exposure and storage, but it removes the easiest evidence when a resident contests an urgency label. I would rather make that loss explicit than hide it behind an apparently low token count. A retention job should record what it deleted and why, while the triage table keeps the decision, schema version, tokenizer name, and processing timestamp.
How can low-cost summarization use an API without losing token-critical detail?
Use a pipeline with a narrow contract. First strip quoted reply chains, signatures, tracking parameters, and duplicated notification text. Then preserve the fields that can change routing: stated problem, location, access constraints, safety language, requested time, and the ticket identifier. Redaction must happen before token counting, and token counting must happen before the request. The same strategies apply when a Node.js service calls an API; the language changes, while the boundary and the evidence do not.
A tokenizer is an estimator for a particular encoding, not a universal measure of meaning. The tiktoken project documents a byte-pair encoding library and provides encoding utilities; use the encoding appropriate to the runtime configuration, then compare the count with the service's actual usage telemetry. A count made with a different encoding can be directionally useful and operationally wrong. In an API integration, call the counter a local estimate and keep the measured usage returned by the service as the accounting record.
Here is the important boundary in Python: the function prepares data and enforces a local input limit, but the network adapter remains generic. It also keeps the summary cache key tied to content, policy, and schema rather than to a mutable ticket status. Version 1 of this boundary should be boring: a normalized payload, one bounded request, and one validated write.
import hashlib
import json
from dataclasses import dataclass
from typing import Any
import tiktoken
@dataclass(frozen=True)
class TicketInput:
ticket_id: str
text: str
policy_version: str
schema_version: str
def token_count(text: str, encoding_name: str) -> int:
encoding = tiktoken.get_encoding(encoding_name)
return len(encoding.encode(text))
def cache_key(ticket: TicketInput) -> str:
payload = json.dumps(
{
"text": ticket.text,
"policy_version": ticket.policy_version,
"schema_version": ticket.schema_version,
},
sort_keys=True,
separators=(",", ":"),
).encode("utf-8")
return hashlib.sha256(payload).hexdigest()
def prepare(ticket: TicketInput, encoding_name: str, max_tokens: int) -> dict[str, Any]:
count = token_count(ticket.text, encoding_name)
if count > max_tokens:
raise ValueError(f"ticket exceeds local input limit: {count} > {max_tokens}")
return {
"ticket_id": ticket.ticket_id,
"text": ticket.text,
"input_tokens_estimate": count,
"cache_key": cache_key(ticket),
"policy_version": ticket.policy_version,
"schema_version": ticket.schema_version,
}
The limit is a guardrail, not a license to truncate blindly. A hard cut in the middle of a sentence can remove not from do not enter, or separate a unit number from an access instruction. Prefer field-aware normalization and an explicit overflow state. Tickets that still exceed the limit can go to manual review or a separate chunking path, with that route visible in metrics.
What does a correct structured result look like?
Summarization is a poor success criterion for triage. The queue needs a valid object. A minimal contract might contain category, urgency, property_id, requested_callback_window, reason, and needs_human_review. Every field needs an allowed shape, and every missing value needs a defined representation. Empty strings and nulls are not interchangeable if downstream routing treats them differently.
Validate the decoded object before writing it to the queue. Check the schema, enum values, identifier format, and cross-field rules. For example, needs_human_review should be true when the category is unknown, when safety language is present, or when the property identifier is absent. That rule belongs in deterministic code; asking a model to remember it in prose makes a storage record look complete while the routing decision remains ambiguous.
from typing import Any
ALLOWED_URGENCY = {"routine", "priority", "emergency", "unknown"}
def validate_decision(value: Any) -> dict[str, Any]:
if not isinstance(value, dict):
raise ValueError("decision must be an object")
required = {
"category",
"urgency",
"property_id",
"requested_callback_window",
"reason",
"needs_human_review",
}
if set(value) != required:
raise ValueError("decision fields do not match the contract")
if value["urgency"] not in ALLOWED_URGENCY:
raise ValueError("unsupported urgency")
if not isinstance(value["needs_human_review"], bool):
raise ValueError("needs_human_review must be boolean")
if not isinstance(value["reason"], str) or not value["reason"].strip():
raise ValueError("reason must be non-empty")
return value
Do not retry a malformed response with the same unbounded prompt. Record the validation failure, apply a bounded retry policy, and route the ticket to a human when the contract still fails. A retry that creates a second stored decision without linking it to the first is an audit defect, even if the second answer looks better.
Which failures survive a shorter prompt?
Token reduction does not fix duplicate delivery, stale cache entries, or a queue worker that crashes after the model call and before the database commit. Give each processing attempt an idempotency key derived from the ticket revision, policy version, and schema version. Store an attempt state such as started, validated, persisted, or manual_review; do not infer state from whether a summary column happens to be non-null.
This is where a low-cost design can become expensive.
If the worker commits the ticket state before it commits the decision, a retry can send the same text again and create two plausible summaries. If it commits the decision first, a crash can leave a decision with no queue transition. The durable answer is a transaction or an outbox-style handoff that makes the state transition explicit; prompt shortening cannot repair that ordering problem.
The cache deserves the same skepticism as the prompt. A ticket can be edited after a resident adds a safety detail. If the key uses only ticket_id, the old classification can be served as new. If the key includes the normalized content hash and policy versions, an edit creates a new decision while identical retries reuse the existing one. That is more storage than a single mutable row, but it gives the operator a defensible history.
Observe four quantities separately: normalized input tokens, output tokens, validation failures, and repeat-processing rate. Aggregate them by ticket class and policy version. A falling average token count can conceal a rising overflow rate; a stable model response can conceal duplicate writes. I would page on the operational consequence, not on a vanity token number.
A practical decision rule
Keep the input bounded by removing repeated context and retaining decision-bearing fields. Count with the runtime's intended encoding. Require a strict structured object, validate it locally, and persist the exact versions that shaped it. Cache by content and policy, not by a mutable identifier alone. Retain raw text for a declared period, then delete it through an auditable job while keeping the minimum provenance needed to explain the routing result.
The deliberate loss is raw evidence after the retention window. The benefit is a smaller exposure surface and a storage policy that can be explained field by field. If the business cannot accept that loss, the answer is a longer approved retention period or a separate controlled archive, not a more aggressive prompt rewrite.
Further reading
- openai/tiktoken
- json-schema.org
- rfc-editor.org
Build a BPE Tokenizer in 80 Lines of TypeScript and Watch JSON Eat Your Tokens
- Book: AI That Answers
- The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
---
The maintainers of the TOON format ran three flat datasets through OpenAI's o200k_base encoding and counted 164,643 tokens for the pretty-printed JSON and 104,908 for the same data minified. Nothing was dropped. The minified version holds every key and every value. It stops paying for indentation and line breaks, and that alone takes about 36% off the count.
Tokens are the unit your LLM bill is written in. Anthropic's pricing table, for one, quotes input and output separately, per million tokens. Yet most developers who pay that bill have never watched a string turn into tokens. You can build the machine that does it in 80 lines of TypeScript and train it on your own JSON.
What happens to your prompt before the model sees it
OpenAI's tokenizers use byte-pair encoding. BPE began as a compression algorithm, and Sennrich, Haddow and Birch adapted it to split words into subword units for neural machine translation (ACL 2016). The tiktoken README says the encoding is reversible and lossless and works on any text, even text the tokenizer never saw in training. On average, a token covers about 4 bytes.
Encoding runs in three steps.
1. A regex cuts the text into chunks, so letters, digits, punctuation and whitespace end up in separate runs. Karpathy's minbpe credits this to the GPT-2 paper and notes it "ensures that no merges will happen across category boundaries."
2. Each chunk becomes its UTF-8 bytes. The starting vocabulary is the 256 possible byte values, so nothing is ever out of vocabulary.
3. Learned merges glue adjacent byte pairs into bigger tokens, in the order they were learned.
Training is where the merges come from. Count every adjacent pair across the corpus, take the pair with the highest count, give it the next free id (256, then 257, and so on), replace it everywhere, repeat. The minbpe README walks through aaabdaaabac: three merges (aa, then ab, then the pair of those two new tokens) turn 11 bytes into five tokens, [258, 100, 258, 97, 99].
!Rows of small square byte tiles fusing pairwise into larger tiles, layer by layer
Byte-pair encoding: start from single bytes, then fuse the most frequent neighbours, one merge at a time.
The tokenizer in 80 lines
This is the whole trainer and encoder. It uses GPT-2's pre-split pattern, minus the contraction rules ('s, 'll, and friends), copied from minbpe's regex.py.
// bpe.ts: a toy byte-level BPE trainer and encoder
type Pair = [number, number];
// GPT-2's pre-split pattern, minus its contraction rules
const SPLIT =
/ ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+/gu;
const utf8 = new TextEncoder();
const text8 = new TextDecoder();
const key = (a: number, b: number) => `${a},${b}`;
const show = (b: Uint8Array) => JSON.stringify(text8.decode(b));
const chunksOf = (text: string) =>
(text.match(SPLIT) ?? []).map((c) => Array.from(utf8.encode(c)));
function countPairs(ids: number[], counts: Map<string, number>) {
for (let i = 0; i < ids.length - 1; i++) {
const k = key(ids[i], ids[i + 1]);
counts.set(k, (counts.get(k) ?? 0) + 1);
}
}
function merge(ids: number[], [a, b]: Pair, id: number) {
const out: number[] = [];
for (let i = 0; i < ids.length; i++) {
if (ids[i] === a && ids[i + 1] === b) {
out.push(id);
i++; // the pair's second half is consumed too
} else out.push(ids[i]);
}
return out;
}
export function train(text: string, numMerges: number) {
let chunks = chunksOf(text);
const merges = new Map<string, number>();
const vocab = new Map<number, Uint8Array>();
for (let b = 0; b < 256; b++) vocab.set(b, Uint8Array.of(b));
for (let m = 0; m < numMerges; m++) {
const counts = new Map<string, number>();
for (const ids of chunks) countPairs(ids, counts);
let best = "";
let bestCount = 1; // a pair seen once earns no token
for (const [k, c] of counts) {
if (c > bestCount) { best = k; bestCount = c; }
}
if (!best) break;
const [a, b] = best.split(",").map(Number) as Pair;
const id = 256 + m;
chunks = chunks.map((ids) => merge(ids, [a, b], id));
merges.set(best, id);
const l = vocab.get(a)!, r = vocab.get(b)!;
const bytes = new Uint8Array([...l, ...r]);
vocab.set(id, bytes);
console.log(`#${m}`, show(l), "+", show(r),
"->", show(bytes), `${bestCount}x`);
}
return { merges, vocab };
}
export function encode(text: string, merges: Map<string, number>) {
const out: number[] = [];
for (let ids of chunksOf(text)) {
while (ids.length > 1) {
// earliest-learned merge first, the order training used
let pair: Pair | null = null;
let rank = Infinity;
for (let i = 0; i < ids.length - 1; i++) {
const r = merges.get(key(ids[i], ids[i + 1]));
if (r !== undefined && r < rank) {
rank = r;
pair = [ids[i], ids[i + 1]];
}
}
if (!pair) break;
ids = merge(ids, pair, rank);
}
out.push(...ids);
}
return out;
}
Pairs are keyed as "a,b" strings because a Map compares array keys by reference, so [1, 2] would never find [1, 2]. Ties go to the pair seen first, since Map iterates in insertion order and the comparison is strict. Training works chunk by chunk, so a merge can never span a letter and a digit, or a word and the punctuation after it, which matches the real thing. And encode always applies the lowest-ranked merge available, the same rule minbpe's _encode_chunk uses: replay the merges in the order training learned them.
The show helper decodes a token's bytes for printing. When a token holds only part of a multi-byte character, TextDecoder prints �, because the token boundary falls inside the character. The Japanese sample further down does the same.
Train it on JSON and read the merges
Feed it the kind of text your app actually sends: an array of records, pretty-printed.
// demo.ts
import { train, encode } from "./bpe";
const records = Array.from({ length: 40 }, (_, i) => ({
customer_id: 1000 + i,
status: i % 4 ? "active" : "suspended",
country: ["DE", "BR", "JP", "US"][i % 4],
}));
const pretty = JSON.stringify(records, null, 2);
const { merges } = train(pretty, 40);
const one = records[0];
const asPretty = encode(JSON.stringify(one, null, 2), merges);
const asMin = encode(JSON.stringify(one), merges);
console.log({ pretty: asPretty.length, minified: asMin.length });
Run it with tsx: npx tsx demo.ts. Each log line is one merge, with the two parts, the new token, and how often the pair appeared. Read all 40 and sort them in your head: how many are fragments of the keys customer_id, status and country, how many are values and digits, and how many are quotes, colons and runs of spaces? On a corpus made of nothing but JSON, every merge has to come from one of those. The keys repeat in every single record, and that repetition is what earns them merges. A production tokenizer learns its merges from a huge general corpus instead, so common English words have a good chance of getting a token of their own (the next script shows you whether customer did). Your acctBalanceAdjFlagV2 pays for every piece.
Then try the experiments the script makes cheap. Train on JSON.stringify(records) instead of the pretty version and compare the merge log. Rename customer_id to cid and see how many merges it frees up. Compare the two counts in the last line, and remember that this toy only knows 40 merges, so the ratio between them tells you more than either number.
Ask a real encoder: o200k_base
Your toy has 40 merges. o200k_base has a vocabulary of about 200,000 tokens, roughly double GPT-4's ~100,000, according to Simon Willison's notes from the GPT-4o launch. tiktoken's model table maps gpt-4o, gpt-4.1, o3, o4-mini and gpt-5 to it. js-tiktoken loads it in Node; the lite import pulls in only the one rank file you need.
// compare.ts
import { Tiktoken } from "js-tiktoken/lite";
import o200k_base from "js-tiktoken/ranks/o200k_base";
const enc = new Tiktoken(o200k_base);
const pieces = (s: string) =>
enc.encode(s).map((id) => enc.decode([id]));
const samples = [
'{\n "customer_id": 1000\n}',
'{"customer_id":1000}',
"customerShippingAddressLine2",
"1730505600000",
"Your order has been cancelled.",
"ご注文はキャンセルされました。",
];
for (const s of samples) {
const p = pieces(s);
console.log(p.length, JSON.stringify(p));
}
You can predict part of what it prints by reading the o200k pre-split regex in tiktoken's openai_public.py. Every chunk the regex produces costs at least one token, so the regex sets a floor.
- camelCase splits at every capital. The letter rule matches a run of uppercase letters (possibly none) followed by lowercase, so customerShippingAddressLine2 cuts into customer, Shipping, Address, Line and 2 before BPE starts. Five chunks, at least five tokens.
- Digits go in groups of three. The number rule is \p{N}{1,3}. A 13-digit millisecond timestamp is five chunks, so at least five tokens, every time it appears in a log line you paste.
- Whitespace is its own chunk. Indentation runs, minus their last space, are cut out separately from the keys they sit in front of. One small quirk: the space after "customer_id": in the pretty version cannot attach to a following number, because the digit rule (\p{N}{1,3}) takes no leading character and the punctuation rule's optional space needs a symbol after it. It becomes a chunk on its own. Before a string value it rides along with the opening quote instead.
- Non-English text gets fewer merges. Byte-level BPE can encode anything, but the merges go to whatever was common in training data. Petrov et al. (NeurIPS 2023) found the same text translated into different languages could differ in token length by up to 15 times on the tokenizers they studied. OpenAI's larger vocabulary cut non-English counts: its launch figures, as quoted in Willison's notes, put Gujarati at 4.4x fewer tokens and Japanese at 1.4x fewer than the previous tokenizer. When a token boundary still lands inside a Japanese character, pieces prints � for those fragments.
!A long accordion of curly braces and indentation blocks next to a compact stack of the same blocks
Same data, same keys and values; only the whitespace and repetition change, and that is where the tokens go.
One payload, three shapes
Now measure the thing you actually send. This script builds one payload and counts it as pretty JSON, minified JSON and TOON, a tabular encoding of the JSON data model that declares the keys of a uniform array once (@toon-format/toon on npm).
// shapes.ts
import { Tiktoken } from "js-tiktoken/lite";
import o200k_base from "js-tiktoken/ranks/o200k_base";
import { encode as toToon } from "@toon-format/toon";
const enc = new Tiktoken(o200k_base);
const orders = Array.from({ length: 50 }, (_, i) => ({
orderId: 5000 + i,
customerId: 70 + (i % 9),
status: i % 3 ? "shipped" : "pending",
totalCents: 1999 + i * 37,
}));
const payload = { orders };
const shapes: Record<string, string> = {
pretty: JSON.stringify(payload, null, 2),
minified: JSON.stringify(payload),
toon: toToon(payload),
};
for (const [name, text] of Object.entries(shapes)) {
console.log(name.padEnd(9), enc.encode(text).length);
}
Swap payload for a real request body from your logs and you have your own numbers. I can't give you numbers for your data, so here are published ones for theirs. TOON's benchmark, counted with o200k_base:
| Track | Pretty JSON | Minified JSON | TOON |
|---|---|---|---|
| Flat data, 3 datasets | 164,643 | 104,908 | 68,030 |
| Mixed structure, 5 datasets | 393,637 | 260,451 | 264,734 |
On flat, uniform records TOON came in 58.7% under pretty JSON and 35.2% under minified. On mixed and nested data the picture flips: minified JSON beat TOON by 1.6% overall, and the README says plainly that "for deeply nested or non-uniform data, JSON may be more efficient." It also notes that absolute counts are tokenizer-specific, since other providers tokenize differently, and that you have to measure latency yourself. Fewer tokens is also not the same as better answers, so run your evals on the new shape before you ship it.
One win held on both tracks: minifying the JSON cut about a third of the tokens, so do that to anything a program produced before it goes into a prompt. Dropping keys the model does not need helps whatever the shape. Save the tabular format for long arrays of identical objects, where TOON won, and keep JSON for nested data, where it lost. Then put shapes.ts next to your prompt builder and check the count every time someone adds a field.
---
If this was useful
Token cost is the first bill an LLM app runs up, and it is one of the threads in AI That Answers, the first book of my AI in TypeScript series. It covers your first LLM app in TypeScript end to end: prompts, structured output, and keeping an eye on what each call costs before the invoice does it for you.

The other four books go from retrieval and tool calling to agents in production: the whole AI in TypeScript series is here.
3 reactions · 4 comments
日本語はなぜトークンを食う? 漢字がバイトに割れる仕組みを tiktoken で数えてみた
はじめに
同じ意味の文なのに、日本語で書くと AI の料金が高くなる。そんな話を聞いたことはありませんか?
AI(大規模言語モデル)は、文章を文字や単語ではなく トークン という単位で数えています。料金も、一度に読める量(コンテキストの長さ)も、このトークンの数で決まります。
この記事では、
トークンとは何か
同じ意味の文で、英語と日本語のトークン数をくらべる
日本語が増えやすい理由(漢字がバイトに割れる)
新しいモデルで差が縮まった話
自分で数える方法
を、手元で実際に数えた値でまとめます。
!
2026 年 10 月 29 日時点の情報です。トークン数は tiktoken 0.14.0 と Hugging Face tokenizers 0.23.3 で手元で数えた値です。出典は記事の最後にまとめています。
トークンとは
AI は文章を、トークンという小さなかたまりに切って読んでいます。英語のよく使う単語は、1 語で 1 トークンのことが多いです。
Sorry to trouble you, but could you please confirm this? は、GPT-4 のトークナイザー(cl100k_base)で 12 トークンになります。単語の前のスペースは、単語と一緒に 1 つのトークンになっています(図の ␣)。
同じ意味の文をくらべる
同じ意味の日本語の文を、同じ GPT-4 のトークナイザーで数えてみます。
文
文字数
GPT-4(cl100k_base)
Sorry to trouble you, but could you please confirm this?
56
12
お忙しいところ恐れ入りますが、ご確認いただけますでしょうか。
30
32
日本語は 30 文字なのに 32 トークン。文字数よりトークンのほうが多いうえに、英語の約 2.7 倍です。図の点線のかたまりは、文字の途中で切れたバイトのトークンです。
トークンの辞書は、よく出る並びをくっつけて作る
トークナイザーの辞書は、BPE(Byte Pair Encoding) という方法で作られています。文章の中でよく隣り合う 2 つをくっつけて新しいトークンにする、という作業をくり返します。
辞書づくりに使う文章に英語が多いと、英語の単語はまるごと 1 トークンになりやすくなります。実際に cl100k_base では、
文字列
トークン数
切れ方
token
1
token
トークン
4
ト ー ク ン
と、カタカナは 1 文字ずつに分かれました。
漢字はバイトに割れる
辞書にない文字は、もっと細かい バイト に割られます。いまのトークナイザーの多くは、文字を UTF-8 のバイト列にしてから扱う「バイトレベル BPE」です。そのため、どんな文字でも最後はバイトのトークンで表せます(辞書に無い文字で困ることはありません)。
UTF-8 では、英語の文字は 1 バイトですが、漢字やひらがなは、たいてい 1 文字 3 バイトです。
文字
UTF-8
バイト数
A
41
1
あ
e3 81 82
3
鬱
e9 ac b1
3
cl100k_base では、「鬱」は 3 バイトがそのまま 3 トークンに、「確認」の 2 文字(6 バイト)は 4 トークンになりました。
鬱 → [e9] [ac] [b1] 3 トークン
確認 → [e7 a2] [ba] [e8 aa] [8d] 4 トークン
先ほどの日本語の文でも、「忙」「恐」「確認」「ょ」がバイトに割れていました。これが、日本語のトークンが増えやすい理由の 1 つです。
新しいモデルでは差が縮まった
GPT-4o からは、トークナイザーが o200k_base になりました。辞書の大きさ(トークンの種類)はおよそ 2 倍です。
トークナイザー
使うモデル(tiktoken の対応表)
辞書の大きさ
cl100k_base
GPT-4、GPT-3.5 Turbo
100,277
o200k_base
GPT-4o、GPT-4.1、o1、GPT-5 など
200,019
同じ日本語の文は、32 → 17 トークン に減りました(英語は 12 のまま)。「確認」も、まるごと 1 トークンです。
import tiktoken
ja = "お忙しいところ恐れ入りますが、ご確認いただけますでしょうか。"
for name in ["cl100k_base", "o200k_base"]:
enc = tiktoken.get_encoding(name)
print(name, len(enc.encode(ja)))
cl100k_base 32
o200k_base 17
モデルごとに切り方が違う
トークンの切り方は、モデル(トークナイザー)ごとに違います。早口言葉の「東京特許許可局」(7 文字)で、公開されているトークナイザーをくらべてみました。
トークナイザー
トークン数
切れ方
GPT-4(cl100k_base)
10
[e6 9d] [b1] 京 特 [e8 a8] [b1] [e8 a8] [b1] 可 局
GPT-4o 以降(o200k_base)
6
東京 特 許 許 可 局
Qwen3
7
東 京 特 許 許 可 局
DeepSeek-V3
5
東京 特 許 許可 局
同じ会社のモデルでも、トークナイザーが変わることがあります。Anthropic は Claude Opus 4.7 で新しいトークナイザーを使い始め、同じ入力が内容によって 約 1.0〜1.35 倍 のトークンになることがあると案内しています。料金を見積もるときは、使うモデルで数えるのが確実です。
自分で数える
OpenAI のモデルなら、tiktoken で数えられます。モデル名からトークナイザーを選ぶこともできます。
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4o") # o200k_base
text = "お忙しいところ恐れ入りますが、ご確認いただけますでしょうか。"
print(len(enc.encode(text))) # 17
どう切れたかを見る (バイトの途中で切れたトークンは文字にならない)
print([enc.decode_single_token_bytes(t) for t in enc.encode(text)])
Claude なら、トークンを数える API(Token counting)があります。
import anthropic
client = anthropic.Anthropic()
r = client.messages.count_tokens(
model="claude-opus-5-5",
messages=[{"role": "user", "content": text}],
)
print(r.input_tokens)
ブラウザで試すなら、Tiktokenizer で切れ方を色分けして見られます。
豆知識: strawberry の r はいくつ?
AI が strawberry の r の数を数え間違えることがある、という話があります。
o200k_base では、strawberry は st raw berry の 3 トークン(番号は 302、1618、19772)です。AI に渡るのはこの番号の列なので、1 文字ずつの並びは見えていません。
まとめ
AI は文章を「トークン」で数える。料金も、一度に読める量もトークン
トークナイザーの辞書は、よく出る並びをくっつけて作る(BPE)
辞書にない文字はバイトに割れる。漢字やひらがなは 1 文字 3 バイトなので、日本語はトークンが増えやすい
新しいトークナイザーほど日本語の差は縮まっている(GPT-4 で 32 → GPT-4o 以降で 17)。見積もりは使うモデルで数える
出典
openai/tiktoken(GitHub)(cl100k_base・o200k_base とモデルの対応は tiktoken/model.py)
Tiktokenizer
Qwen/Qwen3-0.6B(Hugging Face)、deepseek-ai/DeepSeek-V3(Hugging Face)(トークナイザーを手元で読み込んで数えた)
Introducing Claude Opus 4.7(Anthropic)
Token counting(Claude API ドキュメント)
Neural Machine Translation of Rare Words with Subword Units(Sennrich ほか、2016)(BPE をトークン分割に使った論文)
Language Models are Unsupervised Multitask Learners(Radford ほか、2019)(バイトレベル BPE)
RFC 3629: UTF-8
MCPのツール定義は何トークン使うか|tiktokenで実測し、ツールを絞る基準を決める
2026-10-29時点の情報です。
本記事のコードと数値は、CAUCHYE ASIA が 2026-10-28 に Python 3.13.5 / tiktoken 0.14.0 で実行した結果です。公開日の 2026-10-29 に再実行し、同じ結果になることを確認しました。引用した公式ドキュメントの記述も、2026-10-29 に再確認しました。
結論(先に)
1ツールのトークン数は、パラメータ数と説明文の長さでほぼ決まります。合計はツール数に比例します。
英語の説明文200字・パラメータ3個のツールは、o200k_base で106トークンでした。パラメータが1個増えるごとに約13〜14トークン増えます。
同じ内容の説明文を日本語にすると、今回の1文の比較では、o200k_base で約2倍、cl100k_base で約3倍のトークンになりました。
架空サービスの10ツール(実際に近い形)では、平均115.6、最大143トークンでした。合計は1,156トークンです。
絞り込みの目安は、出どころが違う2種類を分けて置きます。業界標準ではありません。
本記事の実測と設計判断による目安:
ツール定義の合計は、コンテキスト長の5%以内(5%は設計上の仮定。ツール数への換算が実測)
1ツールは150トークン以下(o200k_base・空白なしJSON。実測した10ツールの最大143を丸めた値)
公開資料を参考にした目安:
3. 1回の会話に見せるツールは10個前後から始める(Anthropic・OpenAI の公開ガイダンスの数字を参考にした、本記事の読み)
この記事で扱うこと/扱わないこと
扱うこと:
MCP の tools/list が返すツール定義を、tiktoken でトークン数に換算する手順
パラメータ数、説明文の長さ、ツール数、JSON の整形がトークン数に与える影響
実測値から、ツールを絞る基準を導く考え方
手元の tools/list を測るスクリプト
扱わないこと:
各社のツール検索や遅延読み込みの機能紹介
ツール実行の承認フロー
ツールの返す結果を小さくする設計
測定の条件(トークナイザとその限界)
使ったのは次の環境です。
Python 3.13.5
tiktoken 0.14.0(pip で入れたローカルライブラリ)
エンコーディングは o200k_base と cl100k_base
tiktoken 0.14.0 の encoding_for_model を実行した結果は、次のとおりです。
gpt-4o o200k_base
gpt-4.1 o200k_base
o3 o200k_base
gpt-5 o200k_base
gpt-4 cl100k_base
gpt-3.5-turbo cl100k_base
gpt-5.4 o200k_base
gpt-5.5 o200k_base
gpt-6 ERROR KeyError Could not automatically map gpt-6 to a tokeniser
gpt-4o・gpt-4.1・o3・gpt-5・gpt-5.4・gpt-5.5 が o200k_base、gpt-4・gpt-3.5-turbo が cl100k_base です。
これより新しいモデルについては、tiktoken の対応表にありません。tiktoken 0.14.0 では、tiktoken.encoding_for_model("gpt-6") は「Could not automatically map」のエラーになり、対応する encoding を取得できませんでした(2026-10-28、CAUCHYE ASIA が実行)。gpt-5.4・gpt-5.5 は tiktoken の接頭辞の対応で o200k_base と返りますが、これは tiktoken 側の対応表の結果で、OpenAI がそのモデルの実際のトークナイザを保証する情報ではありません。
次の限界があります。
Claude 系のトークナイザは公開されていません。ローカルでは正確に数えられないため、本記事の数値は Claude の実トークン数ではありません。
トークナイザが違えば数値は変わります。実際に、日本語では o200k_base と cl100k_base で大きく違いました(2節)。
測ったのは JSON テキストだけです。API に渡すと、各社の内部形式への変換や、ツール用のシステムプロンプトが加わります。
OpenAI が API 内部でツール定義に足すトークン数は、公開されている範囲では確認できず、不明です。Claude のツール用システムプロンプト(286トークン、モデルにより異なる)のような公表値がある場合も、本記事の数値には含めていません。
API キーを使うトークン数計測 API は使っていません。ローカル計測のみです。
実際の数は、実運用のリクエストで返る usage(入力トークン数)で確認してください。
数え方は次のとおりです。ツール定義1個を json.dumps(..., ensure_ascii=False, separators=(",", ":")) で空白なしの JSON にします。その enc.encode() の長さを数えました。チャット形式の付加分は含みません。
1. ツール定義の形(MCP tools/list)
MCP 仕様 2026-07-28 の Tools によると、サーバーは tools/list でツール一覧を返します。結果は resultType: "complete" と tools、ページ分割用の nextCursor です。
Tool オブジェクトは次の項目を持ちます。
name(ツールを一意に識別する名前)
title
description
icons
inputSchema(JSON Schema。$schema がなければ 2020-12)
outputSchema
annotations
本記事の計測対象は name, title, description, inputSchema の4項目です。
実験用のツールは make_tool() で機械的に作りました。measure.py の抜粋です。
def make_tool(i, n_params, desc_chars, lang="en"):
基本文を繰り返し、ちょうど desc_chars 文字に切る
desc = (BASE[lang] * (desc_chars // len(BASE[lang]) + 1))[:desc_chars]
names = PARAM_NAMES[:n_params]
props = {n: {"type": PARAM_TYPES[k],
"description": f"Value for {n}." if lang == "en" else f"{n} の値。"}
for k, n in enumerate(names)}
return {"name": f"tool_{i:03d}", "title": f"Sample Tool {i}", "description": desc,
"inputSchema": {"type": "object", "properties": props,
"required": names[:min(n_params, 1)]}}
def count(obj, enc, indent=None):
text = json.dumps(obj, ensure_ascii=False,
separators=(",", ":") if indent is None else None, indent=indent)
return len(enc.encode(text))
本文の表の数値を出したループと、10ツールの定義は省略します。
説明文は、英語の基本文 Search records in the sample service and return matching items. を繰り返し、指定した文字数で切っています。日本語版の基本文は「サンプルサービスの記録を検索し、条件に合う項目を返します。」です。
パラメータは query, limit, offset, sort, order, status, owner, created_after, created_before, include_archived, fields, format の先頭から n 個です。各説明は Value for <名前>.、required は先頭の1個です。
後半で使う架空サービスの10ツールのうち、1つ(get_ticket)を indent=2 で表示します。
{
"name": "get_ticket",
"title": "Get Ticket",
"description": "Get one ticket by ID, including its status, assignee, labels and the latest comments. Use it after list_tickets when you need full details of a single ticket.",
"inputSchema": {
"type": "object",
"properties": {
"ticket_id": {
"type": "string",
"description": "Ticket ID, e.g. T-1024."
}
},
"required": [
"ticket_id"
]
}
}
実行は python measure.py です。実験 A〜F の結果と、sample_tools_list.json を保存します。
2. パラメータ数と説明文の長さ
パラメータ数(実験A)
説明文200字・英語で、パラメータ数だけを変えました。単位は1ツールあたりのトークン数です。
n_params
o200k_base
cl100k_base
0
66
67
1
80
80
2
93
93
3
106
106
4
119
119
6
145
145
8
173
173
10
205
205
12
231
231
パラメータが1個増えるごとに、約13〜14トークン増えます。0個から12個までの平均は (231 − 66) / 12 ≈ 13.75 です。英語では o200k_base と cl100k_base はほぼ同じ値でした。
説明文の長さ(実験B)
パラメータ3個で、説明文の字数を変えました。
desc_chars
o200k_base/en
o200k_base/ja
cl100k_base/en
cl100k_base/ja
0
70
72
70
73
50
80
109
80
128
100
88
145
88
183
200
106
219
106
290
400
140
363
140
503
800
209
652
209
931
1600
346
1232
346
1784
1字あたりのトークン数は、説明文0字の値を引いて1600で割ると出ます。
英語: (346 − 70) / 1600 ≈ 0.17
日本語(o200k_base): (1232 − 72) / 1600 ≈ 0.73
日本語(cl100k_base): (1784 − 73) / 1600 ≈ 1.07
同じ字数なら、日本語は英語の約4倍(o200k_base)、約6倍(cl100k_base)です。
ただし、字数は同じ意味の量ではありません。日本語は少ない字数で多くを表せます。そこで、同じ意味の1文を比べました(sentence.py 全文)。
"""同じ意味の1文を英語と日本語で書いたときのトークン数を比べる。"""
import tiktoken
S = {"en": "Search records in the sample service and return matching items.",
"ja": "サンプルサービスの記録を検索し、条件に合う項目を返します。"}
for name in ["o200k_base", "cl100k_base"]:
enc = tiktoken.get_encoding(name)
for lang, s in S.items():
print(f"{name:12s} {lang} chars={len(s):3d} tokens={len(enc.encode(s)):3d}")
実行結果です。
o200k_base en chars= 63 tokens= 11
o200k_base ja chars= 29 tokens= 21
cl100k_base en chars= 63 tokens= 11
cl100k_base ja chars= 29 tokens= 31
英語63字は11トークン、日本語29字は o200k_base で21、cl100k_base で31です。この1文の比較では、日本語は約2倍(o200k_base)、約3倍(cl100k_base)でした。比べたのは1文だけなので、文章の種類が変わると倍率も変わります。
トークン数だけを見れば、モデルが読む description は英語で短く書く方が節約になります。ただし、英語にしたときのモデルの選択精度への影響は測っていません。
3. ツール数を増やすと
パラメータ3個・説明文200字・英語のツールを N 個並べ、{"tools":[...]} で包んで数えました(実験C)。
N
o200k_base 合計
o200k_base 1ツール
cl100k_base 合計
cl100k_base 1ツール
1
110
110.0
109
109.0
5
534
106.8
529
105.8
10
1064
106.4
1054
105.4
20
2124
106.2
2104
105.2
30
3184
106.1
3154
105.1
50
5304
106.1
5254
105.1
100
10604
106.0
10504
105.0
200
21204
106.0
21004
105.0
1ツールあたり約106トークンでほぼ一定です。合計はツール数に比例します。包む部分 {"tools":[]} は4トークンでした。
JSONの整形でも数値が変わる
同じ20ツールを、空白なしと indent=2 で数え比べました(実験D)。
encoding
compact(空白なし)
indent=2
o200k_base
2124
3329
cl100k_base
2104
3329
o200k_base では約57%増です(3329 / 2124 ≈ 1.57)。インデントや改行がトークンになるためです。
測るときも、送るときも、どの形かで数値が変わります。本記事の数値は、すべて空白なしの JSON です。実際の送信形式は SDK や API の実装によります。差を確かめるには、usage を見るのが確実です。
4. 実際に近い10ツールで測る
機械的に作ったツールは、実際より素直です。そこで、架空のチケット・ファイル・予定・通知サービスの10ツールを書きました(実験E)。条件は次のとおりです。
説明文は80〜250字
パラメータは1〜5個
すべて英語
結果です。
tool
o200k_base
cl100k_base
list_tickets
142
141
get_ticket
85
84
create_ticket
122
122
update_ticket
135
135
add_comment
91
91
search_files
109
109
read_file
91
91
list_events
120
118
create_event
143
142
send_notification
118
117
mean
115.6
115.0
min
85
84
max
143
142
total
1156
1150
平均は115.6、最小は85(get_ticket)、最大は143(create_event)、合計は1,156トークンです。実験Cの1ツールあたり106.0より、約1割大きい値です(115.6 / 106.0 ≈ 1.09)。
実サーバーの発表値との比較
Anthropic Engineering の「Introducing advanced tool use」(2025-11-24)は、次の例を紹介しています。
GitHub の MCP サーバー(35ツール)で約26Kトークン
Slack(11ツール)で約21Kトークン
5サーバー58ツールで約55Kトークン
同じ出典の他のサーバー(Sentry 5ツール約3K、Grafana 5ツール約3K、Splunk 2ツール約2K)も含めて1ツールあたりに直すと、次のとおりです(いずれも「約」の値の割り算で、丸めの誤差を含みます)。
サーバー
ツール数
発表値
1ツールあたり
GitHub
35
約26K
約743
Slack
11
約21K
約1,909
Sentry
5
約3K
約600
Grafana
5
約3K
約600
Splunk
2
約2K
約1,000
これは Claude のトークナイザでの発表値です。本記事の o200k_base の数値とは直接比べられません。各サーバーの定義の中身(説明文の長さ、スキーマの入れ子、outputSchema の有無など)も、発表からは分かりません。
それでも、発表例の1ツールあたりは約600〜1,900で、本記事のサンプル(平均115.6)の約5〜17倍です。実サーバーでは、本記事のサンプルよりずっと大きい定義が珍しくない、という目安にはなります。自分のサーバーの値は、6節の方法で測ってください。
5. 予算に占める割合から、ツールを絞る基準を決める
ツール定義は、会話の前に毎回入る固定費です。コンテキスト長に対する割合で見ます。平均115.6トークン/ツール(o200k_base)を使いました(実験F)。
割合以内に収まる最大ツール数
budget
5%
10%
8000
3
6
32000
13
27
128000
55
110
200000
86
173
ツール数ごとの予算の使用割合(%)
budget
N=10
N=30
N=50
N=100
8000
14.4
43.4
72.2
144.5
32000
3.6
10.8
18.1
36.1
128000
0.9
2.7
4.5
9.0
200000
0.6
1.7
2.9
5.8
小さいコンテキストでは、30ツールで43.4%(8K)、100ツールで144.5%(8K)になります。100ツールは8Kに収まりません。
本記事の基準
次の3つを基準にします。業界標準ではありません。基準1・2は本記事の目安、基準3は公開資料を参考にした目安です。
基準1:合計はコンテキスト長の5%以内
会話履歴、ツールの結果、システムプロンプトの余地を残すためです。5%という値は、実測から導いたものではなく、本記事の設計上の仮定です。実測値は、5%に収まる最大ツール数への換算に使っています。
128K なら 6,400トークン(128000 × 0.05)です。平均115.6なら55個まで(6400 / 115.6 ≈ 55.4)。
32K なら 1,600トークンで、13個です(1600 / 115.6 ≈ 13.8)。
基準2:1ツールは150トークン以下(o200k_base・空白なしJSON)
実測した10ツールの最大が143で、それを丸めた値です。サンプルは10ツールだけです。目安として、次の換算が使えます。
パラメータ1個 ≈ 14トークン
英語の説明100字 ≈ 17トークン
パラメータ5個・英語の説明250字程度に収まる大きさです(実験Aの3個・200字の106に足すと、106 + 2 × 14 + 50 × 0.17 ≈ 142)。日本語の説明文はトークンが増えます。今回の1文の比較では約2〜3倍でした。
基準3:1回の会話に見せるツールは10個前後から始める
これは、実測ではなく、公開資料の数字を参考にした目安です。公式が「最初に見せるのは10個」と書いているわけではありません。
Anthropic の「Introducing advanced tool use」によると、ツール定義が10Kトークンを超える、または10個以上のツールがある場合に Tool Search Tool が有効です。10個未満では効果が小さくなりやすいとされています。よく使う3〜5個は常に読み込んでおく、とも書かれています。
OpenAI の Tool search ガイドは、名前空間(namespace)ごとの関数を10個未満に抑えることを、トークン効率とモデルの性能のために推奨しています。なお、Responses API の tool_search は gpt-5.4 以降のモデルが対象です。
根拠の種類は区別してください。
基準1:5%は本記事の設計上の仮定です。実測は、5%に収まる最大ツール数への換算に使っています。
基準2:実測した10ツール(最大143)をもとに、本記事で丸めた値です。サンプルは10ツールだけです。
基準3:Anthropic と OpenAI の公開ガイダンスの数字(10個)を参考にした、本記事の読みです。
10個を超えるなら、絞り込みか遅延読み込みを検討します。
なお、上で見た公開例の大きなサーバー(1ツールあたり約600〜1,900)は、基準2の150トークンを大きく超えます。その場合は、基準1(合計の割合)と、ツール検索などの併用で考えてください。
6. 自分のツール定義を測る(count_file.py)
手元のサーバーの tools/list の応答を JSON ファイルに保存すれば、同じ方法で測れます。count_file.py 全文です。
"""tools/list の JSON ファイルを読み、ツールごとのトークン数と予算に占める割合を出す。"""
import json, sys
import tiktoken
path, budget = sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 128000
data = json.load(open(path, encoding="utf-8"))
tools = data.get("result", data)["tools"]
enc = tiktoken.get_encoding("o200k_base")
n = lambda o: len(enc.encode(json.dumps(o, ensure_ascii=False, separators=(",", ":"))))
rows = sorted(((n(t), t["name"]) for t in tools), reverse=True)
for tok, name in rows:
print(f"{tok:6d} {name}")
total = sum(t for t, _ in rows)
print(f"tools={len(rows)} total={total} mean={total/len(rows):.1f} "
f"budget={budget} share={total/budget*100:.2f}%")
JSON-RPC の応答全体(result を持つ形)でも、tools を直接持つ形でも読めます。
先ほどの10ツールで、予算32,000として実行した結果です(python count_file.py sample_tools_list.json 32000)。
143 create_event
142 list_tickets
135 update_ticket
122 create_ticket
120 list_events
118 send_notification
109 search_files
91 read_file
91 add_comment
85 get_ticket
tools=10 total=1156 mean=115.6 budget=32000 share=3.61%
予算を128,000にすると、末尾の行は次のとおりです(ツールごとの行は同じです)。
tools=10 total=1156 mean=115.6 budget=128000 share=0.90%
見るのは、合計と、上位のツールです。基準2(150トークン)を超えるツールは、説明文かパラメータを見直す候補です。合計が基準1(5%)を超えるなら、見せるツールを絞ります。
最終確認は、実運用のリクエストで返る usage で行います。Anthropic の Tool use overview によると、tools パラメータ(名前・説明・スキーマ)は入力トークンとして課金されます。加えて、ツール利用時は専用のシステムプロンプトが自動で入ります。例えば Claude Sonnet 5.5 / Opus 5.5 / Haiku 5.5 では、tool_choice が auto か none のとき 286 トークンです。実際のトークン数はレスポンスの usage に出る、とも書かれています。この286はモデルごとに違う値です。使うモデルの行を確認してください。
本記事の方法は、あくまで事前の見積もりです。
この計測の限界
数値は o200k_base と cl100k_base のものです。Claude の実トークン数ではありません。
測ったのは、空白なしの JSON テキストだけです。API 内部の形式変換とツール用のシステムプロンプトは含みません。
実験用ツールは機械的に作ったものです。10ツールのセットは架空のサービスで、実際のサーバーの代表ではありません。
説明文の例は英語の短い文だけです。日本語の約2倍・約3倍という値も、1文の比較です。文章の種類によって、1字あたりのトークン数は変わります。
説明文を英語にしたときのモデルの選択精度への影響は、測っていません。
5%と150トークンは本記事の設計上の目安で、10個は公開資料の数字を参考にした目安です。いずれも用途やモデルによって適切な値は変わります。
outputSchema、icons、annotations は計測対象に入れていません。これらを持つサーバーでは、値が増えます。
まとめ
1ツールのトークン数は、パラメータ数(1個あたり約13〜14)と説明文の長さで決まります。合計はツール数に比例します。
日本語の説明文は、今回の1文の比較では、o200k_base で約2倍、cl100k_base で約3倍でした。トークンだけを見れば、モデルが読む description は英語で短く書く方が節約になりますが、モデルの選択精度への影響は測っていません。
JSON の整形でも数値は変わります。o200k_base では、indent=2 が空白なしより約57%多くなりました。
本記事の目安は、合計がコンテキスト長の5%以内(設計上の仮定)、1ツール150トークン以下(本記事の実測から丸めた値)の2つです。これとは別に、公開資料の数字を参考にして、見せるのは10個前後から始めることを勧めます。
手元のサーバーは count_file.py で測り、最終確認は実運用の usage で行ってください。
参考
Model Context Protocol, Specification 2026-07-28 / Tools: modelcontextprotocol.io
Anthropic, Tool use overview: platform.claude.com
Anthropic Engineering, Introducing advanced tool use: anthropic.com
OpenAI, Tool search: developers.openai.com
OpenAI, tiktoken: openai/tiktoken
設計の考え方(見せるツールを絞る・必要なときに読み込む等)は、CAUCHYE ASIA の解説記事にまとめています。本記事は、その数字を手元で測る手順です。
cauchye.com
@kennethreitz42 @Bollish @theunipcs Even OpenAI’s own open-source project, "tiktoken", uses Python’s Requests library. 🐍
Built by Kenneth Reitz. Used across the Python ecosystem.
$REQUESTS a real developer legacy.
@kennethreitz42
github.com/openai/tiktoken/blob/main/tiktoken/loa…
3 likes · 106 views
A Bigger Context Window Is Not More Memory: Five Token Myths and a Budget Gate
Your counter says 12k tokens. The API returns a 400. Which one is lying?
Probably neither. They are counting different things. One of them is counting your string. The other is counting what the model actually receives.
I keep seeing the same five assumptions in code review. Each one feels safe. Each one fails quietly, or fails loudly at 2 a.m.
Here is the FAQ, the evidence you can collect yourself, and a small gate you can run in CI.
Myth 1: "One token is about four characters"
This is a rule of thumb for English prose. Vendors describe it that way. It is not a unit conversion.
The ratio moves with content. Code, JSON, CJK text, emoji, and long identifiers all drift. Whitespace and punctuation get their own pieces. So does anything the tokenizer has never seen.
Corrected model: characters are a hint. Tokens are what the model bills and truncates.
You do not need to trust my ratio. Measure yours. Grab 20 real prompts from your logs. Count them with the tokenizer your provider actually ships. Then compare against len(text) / 4.
Different tokenizers disagree with each other too. tiktoken encodings are one family (see the tiktoken repo). Other model families use other vocabularies. A prompt that fits under one tokenizer can overflow under another.
So: which tokenizer does your budget check use?
Myth 2: "The context window is one number"
It is at least three numbers wearing a trench coat.
- Input budget. What the prompt may consume.
- Output budget. What the answer may consume. Often a separate parameter with its own ceiling.
- Template overhead. Role markers, separators, and tool schemas. Not free. Not zero.
Input plus output usually share one window. Reserve the answer before you count the prompt. Most overflow bugs I read are really "nobody subtracted max_tokens".
Tool schemas are the sneaky part. A generous function signature block can cost more than your whole user message. It is sent on every turn.
Corrected model: budget = window − reserved output − template overhead. Then subtract tool schema cost, every turn.
Myth 3: "More context means better recall"
Long-context evaluations keep reporting uneven recall by position. The well-known result is "Lost in the Middle". Details placed mid-prompt get retrieved less reliably than details at the edges.
That is not a reason to panic. It is a reason to stop stuffing.
Corrected model: context is capacity, not attention. A 200k window does not mean 200k of useful signal. Placement and redundancy matter more than volume.
Practical translation: put the constraint you cannot afford to lose near the top. Put the task near the bottom. Do not bury the schema between two chatty tool results.
Myth 4: "Dropping old messages is always safe"
Old messages carry two things. Conversation, and contract.
When you slice messages[2:], you may delete the system constraints. You may also orphan a tool call. Many APIs reject an assistant tool_calls block that has no matching tool result, or the reverse.
That is the 400 you cannot reproduce locally. Your unit test never had the tool pair in the middle.
Corrected model: truncation is a structural edit, not a string edit. Validate pairing after every cut.
Myth 5: "If the API accepted it, the model saw all of it"
Sometimes something upstream trims. A proxy, a gateway, a retry wrapper, an SDK default. The request succeeds. The answer misses the paragraph you cared about.
You cannot detect that from the response alone. You can detect it from the request you sent.
Corrected model: log the estimate at send time. If the answer looks blind, diff the budget against the prompt you thought you sent.
The gate: one file, standard library only
Here is the artifact. It estimates prompt cost, reserves answer space, and checks tool-call pairing. It exits non-zero when the prompt does not fit.
Treat the built-in counter as a conservative estimate. Swap in a real tokenizer when you have one.
#!/usr/bin/env python3
"""context_gate.py - fail fast when a prompt no longer fits the budget."""
import json
import re
import sys
from dataclasses import dataclass
def estimate_tokens(text: str) -> int:
# Deliberately conservative placeholder. Never exact. Swap in a real tokenizer.
words = len(re.findall(r"\w+|[^\w\s]", text))
chars = (len(text) + 3) // 4
return max(words, chars)
@dataclass
class Budget:
context: int # total window, from the provider docs
reserve: int # space kept for the answer
overhead: int = 8 # role markers / separators per message
def count_prompt(messages, budget: Budget, counter=estimate_tokens) -> int:
total = 0
for m in messages:
total += counter(str(m.get("content") or "")) + budget.overhead
for call in m.get("tool_calls") or []:
total += counter(json.dumps(call.get("function", {}))) + budget.overhead
return total
def unpaired_tool_calls(messages):
pending, problems = [], []
for i, m in enumerate(messages):
for call in m.get("tool_calls") or []:
pending.append(call.get("id"))
if m.get("role") == "tool":
cid = m.get("tool_call_id")
if cid in pending:
pending.remove(cid)
else:
problems.append(f"message {i}: result for unknown call {cid}")
problems += [f"call {c}: no result" for c in pending]
return problems
def check(messages, budget: Budget, counter=estimate_tokens):
used = count_prompt(messages, budget, counter)
allowed = budget.context - budget.reserve
return {
"prompt_tokens_est": used,
"prompt_budget": allowed,
"answer_reserve": budget.reserve,
"fits": used <= allowed,
"pairing_problems": unpaired_tool_calls(messages),
}
if __name__ == "__main__":
path, context, reserve = sys.argv[1], int(sys.argv[2]), int(sys.argv[3])
with open(path) as f:
payload = json.load(f)
report = check(payload["messages"], Budget(context=context, reserve=reserve))
print(json.dumps(report, indent=2))
sys.exit(0 if report["fits"] and not report["pairing_problems"] else 1)
Run it against a captured request:
python3 context_gate.py prompt.json 32768 1024
A failing run looks like this:
{
"prompt_tokens_est": 31895,
"prompt_budget": 31744,
"answer_reserve": 1024,
"fits": false,
"pairing_problems": []
}
The 32768 is a flag you supply. Read the real number from your provider's docs. Do not copy mine.
Run it before the request, not after
1. Capture one real request body as JSON. Keep it as a fixture.
2. Add your two hardest cases: a large tool schema, and a sliced history.
3. Run the gate on both. Fix the budget, not the fixture.
4. Wire it into CI as a preflight step.
5. Log prompt_tokens_est next to every response you store.
Step 5 is the one people skip. Then the incident review has no numbers.
I run this preflight on MonkeyCode's free server option so the check travels with the job instead of living on my laptop. I also point it at free model access to compare estimates across more than one tokenizer family. That comparison is the whole point — one counter proves nothing.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
Cut-first decision table
When the gate says no, do not just slice. Pick the cheapest effective edit.
| Symptom | First cut | Why |
| --- | --- | --- |
| Tool schema dominates | Prune optional params, shorten descriptions | Paid every turn, not once |
| Long history, short task | Summarize turns older than N | Keeps the contract intact |
| One huge document | Move to retrieval, pass top-k | Placement beats volume |
| Duplicated instructions | Keep one canonical system block | Redundancy is not safety |
| Answer keeps getting cut | Raise reserve before trimming prompt | Reserve is the real ceiling |
Limitations and who should skip this
- The built-in counter is an estimate. It will not match provider billing. Use the provider's tokenizer when precision matters.
- Windows, reserves, and limits change. Read current docs. This gate reads numbers you pass in, never assumptions.
- For multi-megabyte corpora, this is the wrong tool. Use retrieval instead.
- If your prompts are short and single-turn, a gate is overhead. Skip it.
- This checks shape and budget, not answer quality. A prompt can fit and still be wrong.
The takeaway
Context is a budget with a shape. Tokens vary by tokenizer. Output competes with input. Placement beats stuffing. Truncation is structural. And anything upstream can trim silently.
So the next time a 400 lands on your desk, what will you show the reviewer: a guess about characters, or a gate that printed the number?
1 reaction · 2 comments
Rust Didn't Win Because It's Fast
GitHub rewrote Copilot's runtime in Rust. OpenAI's Codex is now almost all Rust. Bun, the runtime Claude Code ships on, moved 535,496 lines of Zig to Rust in 11 days. But it isn't about speed: C has been fast for half a century. We fly inside a real laptop, down to the CPU die and the memory cells, to show what Rust actually changed, and why AI coding agents love it.
0:00 Everyone is rewriting in Rust
0:49 This is Sidequest Explained
1:00 Inside the machine: instructions, bytes, stack and heap
1:53 Way one: freeing memory by hand (C and C++)
2:23 An elevator crash started Rust
2:40 Way two: the garbage collector (Discord's spikes)
3:12 Way three: ownership
3:36 Checked before it runs, and Android's results
4:07 Close to the metal: 28 bytes vs 8
4:21 Copilot: 5.25 s to 292 ms
4:41 Why AI agents love the Rust compiler
5:11 Is Python dead? What it means for you
Sources
GitHub Blog, migrating the Copilot runtime to Rust (Sep 2026): github.blog
Bun, Rewriting Bun in Rust (Jul 2026): bun.sh
OpenAI Codex repository: openai/codex
Codex native rewrite discussion: codex#1174
Grok Build repository: xai-org/grok-build
Dennis Ritchie, The Development of the C Language: bell-labs.com
Microsoft MSRC, 70% of security bugs are memory safety (2019): microsoft.com
Chromium memory safety: chromium.org
MIT Technology Review, Rust's origin (2023): technologyreview.com
Rust 1.0 announcement: blog.rust-lang.org
Discord, Why Discord is switching from Go to Rust (2020): discord.com
The Rust Book, ownership and borrowing: doc.rust-lang.org
Google, memory-safe languages in Android 13 (2022): security.googleblog.com
Google, Rust in Android (Nov 2025): blog.google
Python sys.getsizeof / CPython ints: docs.python.org
Hugging Face tokenizers: huggingface/tokenizers
OpenAI tiktoken: openai/tiktoken
pydantic-core: pydantic/pydantic-core
uv by Astral: astral-sh/uv
Numbers are the sources' own. The Copilot timings are client + session + one-turn overhead in the opt-in in-process mode, not counting the model or network. Android's 1000× is memory-safety vulnerability density. Code on screen is an illustration, not a run.
Credits
Die micrographs: Fritzchens Fritz (CC0). Memory-cell micrograph (Micron MT4C1024) and SEM plate (1886VE10): ZeptoBars, CC BY 3.0. Office and hardware photos: Pexels. HDRIs and materials: Poly Haven, ambientCG (CC0). Music: "Lofi Study" by FASSounds (Pixabay). Rust, GitHub, Copilot, OpenAI, Codex, Bun, Claude, xAI, Grok, Microsoft, Chrome, Android, Discord, Python, Hugging Face, Astral, pydantic and other names and logos belong to their owners, used for identification and commentary; Sidequest is not affiliated with any of them. Narration: an AI clone of my own voice. Animation: original, built in three.js.
#rust #programming #ai
50 likes · 14 comments · 6,610 views
Токеномика для самых маленьких
Кажется, мы довольно быстро привыкли к тому, что с нейросетью можно разговаривать почти как с человеком. Попросить написать код, пересказать статью, разобраться в документах. Потом уточнить. Потом ещё раз уточнить, потому что она опять сделала что-то не то.
А где-то между «почти готово» и «ты зачем вообще это переписал?» заканчивается лимит. А иногда - ещё до первого ответа. Отправляешь запрос в Claude, он долго думает. Потом ещё думает. Ты уже начинаешь подозревать, что там решают что-то на уровне устройства Вселенной, и наконец получаешь уведомление: лимит закончился. Ответа пока нет. Зато размышление получилось основательное.
Особенно интересно это выглядит с ИИ-агентами: отправил короткую задачу, отвлёкся, вернулся - агент прочитал половину проекта, запустил тесты, починил что-то по соседству и написал подробный отчёт о своих приключениях. Всё это, конечно, за ваши токены - этот господин уплатит за всё.
Немного похвалюсь: у меня самого с лимитами достаточно неплохо - не жалуюсь. При том что регулярно и очень активно пользуюсь ИИ для работы, и личных задач, и просто поржать. На нынешней подписке в $100 ещё ни разу не упирался в лимит, и кнопка сброса лимитов мне ни разу не понадобилась: просто не успевал их потратить. А некоторые друзья жалуются, что им не хватает даже подписки за $200.
Конечно, и $100 за подписку кажется многовато. Но тарифа за $20 для моего режима уже не всегда достаточно: кроме обычных диалогов есть фоновые процессы, периодические задачи и прочая работа, которая выполняется без моего постоянного участия. Разбаловал меня ИИ. Поэтому приходится раскошеливаться.
Конечно, задачи и объём работы у всех разные. Но мне стало интересно разобраться, сколько ресурсов уходит на полезный результат, а сколько - на то, как мы вообще организовали общение с моделью.
Поэтому сегодня немного токеномики (не про NFT щиткоины, извините за байт в заголовке). Разберёмся, что такое токены, почему короткое сообщение иногда обходится неожиданно дорого и на чём действительно имеет смысл экономить.
Что вообще такое токен?
Да вы и так уже хорошо представляете, но хочется проговорить основные понятия, чтобы не было недопониманий в дальнейшем.
Для нас сообщение - это текст. Для языковой модели его сначала превращают в последовательность числовых идентификаторов. Каждый такой идентификатор соответствует токену: это может быть слово, часть слова, пробел вместе со словом, знак препинания или фрагмент байтов.
Этим занимается отдельный компонент - токенизатор. На выходе модели происходит обратное преобразование: последовательность токенов снова становится понятным нам текстом. Это и есть входные (input) и выходные (output) токены соответственно.
Почему нельзя просто читать по буквам? Можно строить и такие системы. Но последовательности получаются длинными. Если же завести отдельный токен для каждого возможного слова, довольно быстро начнутся вопросы к размеру словаря, новым словам, опечаткам и всем языкам мира. Разбиение на части - компромисс между длинными последовательностями и огромным словарём.
При этом токенизатор не обязан соблюдать школьную морфологию. Он не обещал делить слова на корни и суффиксы. У него свои представления о прекрасном, основанные на алгоритме и данных, на которых строился словарь.
Например, в кодировках cl100k_base и o200k_base из библиотеки tiktoken получается так:
Текст
Разбиение
Токенов
hello
hello
1
Hello
Hello
1
HELLO
HEL + LO
2
hello
hello, вместе с пробелом
1
Одно и то же слово, но заглавные буквы поменяли количество токенов. А пробел вообще уехал внутрь токена.
Поэтому правила вроде «один токен - это четыре символа» можно использовать только для грубой прикидки на подходящих текстах. Посчитать точно помогает токенизатор конкретной модели.
А русский правда дороже
Разные языки действительно могут занимать разное количество токенов при передаче одного смысла. Это исследовали, например, авторы работы Language Model Tokenizers Introduce Unfairness Between Languages.
Помимо того, что русский язык великий и могучий, за что тоже приходится платить токенами, есть и более техническая причина.
При обучении токенизатора на основе BPE часто встречающиеся фрагменты текста объединяются в токены. Если английского в обучающих текстах заметно больше, его слова и части слов получают больше готовых «кирпичиков» в словаре. Русские слова при этом могут разбиваться на более мелкие кусочки. А разнообразие окончаний и словоформ добавляет вариантов, которые тоже нужно уместить в словарь. Поэтому один и тот же смысл на русском нередко занимает больше токенов.
Но универсальный курс «одно русское слово равно трём токенам» устанавливать рано. Вот две фразы:
Write a Python function that returns the sum of two numbers.
Напиши функцию на Python, которая возвращает сумму двух чисел.
И результат подсчёта через tiktoken 0.14.0:
Кодировка
Английский
Русский
Отношение RU / EN
cl100k_base
12
23
1,92
o200k_base
12
14
1,17
Текст не изменился, поменялся токенизатор - и разница сократилась с почти двукратной до примерно 17%. Это подсчёт двух конкретных строк, без служебных сообщений приложения. Если вы свободно формулируете задачу на английском, можно проверить такой вариант. Но если ради экономии вам приходится дольше переводить запрос и вы теряете важные оттенки смысла, стоит посчитать ещё и стоимость собственного времени. Я, кстати, часто общаюсь с LLM через переводчик, если собственного английского не хватает. И токены пытаюсь сэкономить, и язык подтягиваю.
Куда уходят токены между сообщением и ответом
Когда вы пишете «а теперь добавь тесты», модель обычно получает гораздо больше этих четырёх слов. Во входе могут оказаться системные инструкции, правила проекта, предыдущие сообщения и ответы, содержимое файлов, результаты поиска, описания инструментов. Приложение собирает из этого контекст, с которым модель будет работать на очередном шаге.
Контекстное окно - это ограничение на объём информации, доступной модели в одном вызове. А расход за всю задачу складывается из множества вызовов. Поэтому агент вполне может потратить миллион токенов, ни разу не имея миллион токенов в контексте одновременно.
Модель также может расходовать токены на внутреннее рассуждение, которое не целиком показывается пользователю. Например, у Claude thinking-токены учитываются как выходные - это описано в документации контекстного окна. Короткий видимый ответ ещё не означает, что вся работа была короткой.
При оплате через API у входных, выходных и кэшированных токенов могут быть разные ставки. Выход у многих моделей дороже обычного входа. Поэтому убрать тысячу токенов из промпта и сократить ответ на тысячу токенов - не обязательно одинаковая экономия в деньгах.
С агентами добавляется ещё один слой. Попросить прочитать файл - один шаг. Получить его содержимое и решить, что делать дальше, - следующий. Потом поискать соседний файл, посмотреть документацию, запустить команду, изучить ошибку. Между этими действиями модель вызывается снова и снова. И вот уже задача «исправь пару строк» превращается в маленькую экспедицию.
Почему длинные диалоги обходятся дорого
В обычной схеме работы с LLM следующий вызов получает предыдущую переписку, чтобы модель понимала, что происходило раньше. Приложение может обрезать историю, делать выжимки или выбирать только её часть, но сама история от этого бесплатной памятью не становится.
Давайте посчитаем на условном примере. Каждый ваш запрос занимает 1000 токенов, каждый ответ - ещё 1000. Вся переписка сохраняется, служебные инструкции и инструменты пока уберём из расчёта.
Номер обращения
Вход в этом вызове
Всего токенов за все обращения
1
1 000
2 000
5
9 000
30 000
10
19 000
110 000
20
39 000
420 000
На двадцатом обращении вы добавляете очередную тысячу токенов, а во входе уже 39 тысяч. При наших условиях суммарный расход за n обращений составляет 1000 × n × (n + 1). Это арифметика повторной передачи истории, без учёта кэширования и её сокращения. Переводить эту таблицу в деньги напрямую нельзя.
Тут приходит кэш
У провайдеров есть prompt caching: повторяющийся начальный фрагмент запроса может использовать уже подготовленное внутреннее состояние модели. В подходящем случае это уменьшает вычисления и стоимость обработки входа. Например, Anthropic отдельно тарифицирует создание кэша и его чтение. Ставки, срок хранения и условия попадания зависят от модели и настройки. Главное здесь - повторяющаяся часть контекста может стоить дешевле обычного входа.
Поэтому большой контекст не обязательно целиком пересчитывается по полной цене на каждом шаге. Но кэш не делает его полезнее и не увеличивает контекстное окно. Он также не гарантирует, что конкретное приложение удачно им пользуется.
Для разработчика API-приложения отсюда следует практический вывод: стабильные инструкции и общие материалы удобно держать в начале, меняющуюся часть - после них. Кэширование префикса требует совпадения предшествующего содержимого.
Подписка и API: сравнение цен
А для пользователя чата полезно просто помнить, что счётчик токенов, денежная стоимость и лимиты подписки - три разных показателя. Правила конкретной подписки нужно смотреть отдельно.
Несколько месяцев назад я увидел такую таблицу со сравнением цены подписки и стоимости сопоставимого объёма работы через API. Цифры внушительные: тысячи долларов токенами за пару сотен.
Исторический срез за июнь 2026 года. Справа - оценка расхода в ценах API при исчерпании лимитов, а не баланс подписки или себестоимость работы модели.
Как именно они тогда замеряли расход, я, честно, не знаю. Зато в более свежем исследовании SemiAnalysis от 5 октября 2026 года методика уже описана: авторы измеряли, как разные типы токенов расходуют лимит подписки, а затем пересчитывали доступный объём в API-цены для выбранной модели и нагрузки. Получилось примерно так:
Подписка
Модель в расчёте
Цена в месяц
Оценка объёма за месяц в API-ценах
Отношение к цене подписки
Claude Pro
Opus 5.5
$20
≈ $1 178
≈ 59 раз
Claude Max 5x
Opus 5.5
$100
≈ $5 725
≈ 57 раз
Claude Max 20x
Opus 5.5
$200
≈ $11 726
≈ 59 раз
ChatGPT Plus
GPT-6.1 Sol
$20
≈ $211
≈ 11 раз
ChatGPT Pro
GPT-6.1 Sol
$100
≈ $1 055
≈ 11 раз
ChatGPT Pro
GPT-6.1 Sol
$200
≈ $2 084
≈ 10 раз
Оценки SemiAnalysis на 5 октября 2026 года при полном использовании лимитов и заданном составе агентной нагрузки. Это не гарантированный баланс подписки. Отношения округлены.
Здесь есть существенная деталь: около 96,6% токенов в расчёте приходится на чтение кэша. Если у вас другое соотношение входа, выхода и кэширования, получится другая сумма. Да и модели в двух половинах таблицы разные: качество результата и количество попыток она не сравнивает. Поэтому выбирать победителя только по последней колонке я бы не стал.
Сами цены тоже проверил 25 октября 2026 года. У Claude при помесячной оплате это по-прежнему $20 за Pro, $100 за Max 5x и $200 за Max 20x. У ChatGPT - $20 за Plus и варианты Pro за $100, $200 и $500. Последнего в сравнении выше нет. Итоговая цена покупки может зависеть от региона, налогов и способа оформления.
Ещё одна отдельная история: в Claude Max теперь входят ежемесячные API-кредиты на $100 или $200 соответственно. Их нужно активировать, связав подписку с организацией в Console; новым подписчикам это доступно через семь дней. Эти кредиты не увеличивают лимиты чата и не оплачивают дополнительное использование Claude Code, а остаток не переносится на следующий расчётный период. С тысячами долларов из таблицы их смешивать не нужно.
Подписка действительно может оказаться выгодной при активном использовании. Но если за месяц вам понадобилась пара коротких запросов, потенциальные тысячи долларов так и останутся потенциальными. Для своих задач сравнивать варианты лучше по фактическому расходу и полученному результату.
Большое контекстное окно ещё не решает всё
Кажется, решение очевидное: давайте дадим модели огромное окно и загрузим туда вообще всё. Переписку за год, кодовую базу, документацию, инструкцию к аэрогрилю. Но техническая возможность поместить информацию в контекст не означает, что модель одинаково хорошо воспользуется каждой его частью.
В исследовании Context Rot команда Chroma проверила 18 моделей и обнаружила, что с ростом входного контекста надёжность выполнения задач может ухудшаться. Причём результат зависит от модели, задачи и устройства самого текста. Размер окна из рекламной таблицы этого не показывает.
Поэтому я бы не ориентировался на магическую границу вроде «до 100 тысяч токенов всё хорошо, потом всё плохо». Иногда полезен большой объём исходных данных. Иногда десять страниц посторонних инструкций только мешают найти нужное. Хороший вопрос перед добавлением очередного файла: какая информация из него нужна для текущего решения?
На чём я бы экономил в первую очередь
Можно начать с удаления каждого «пожалуйста». Но если рядом лежит лог на двадцать тысяч строк, экономия получится довольно своеобразная. Мне кажется разумным сначала посмотреть на крупные источники расхода: ненужные документы, накопленную историю, повторные исследования и исправления неудачных ответов. Уже потом заниматься отдельными словами.
Как сформулировать понятную задачу
Здесь хочу сослаться на работу моего коллеги из Яндекса - Юрия Зеленкова, доктора технических наук и компьютерного лингвиста. На основе его исследования я составил публичную версию руководства «Две грамматики за один вечер» о промптах и скиллах. Одна из полезных идей оттуда: запросу нужен минимально достаточный контракт. Что должно получиться, какие условия действительно важны и как проверить результат. Например:
Сделай сервис для заказов.
Что именно? Класс в существующем приложении? Отдельный микросервис? HTTP API? Мы с вами можем представлять разные вещи. Модель тоже что-нибудь представит и начнёт делать. А можно так:
В существующем Django-приложении добавь метод OrderService.cancel_order(order_id). Отменять можно только заказы в статусе NEW. Для остальных верни существующую доменную ошибку. Ориентируйся на соседние методы и добавь тесты для обоих случаев.
Запрос стал длиннее. Зато из него исчезло несколько развилок, по которым агент мог уйти совсем в другую сторону.
В Grammar.md разбираются ещё две похожие ловушки. Первая - скрытое допущение: вопрос «почему мой код медленный?» уже предполагает, что он действительно медленный. Сначала полезно попросить проверить саму проблему. Вторая - подразумеваемые требования: под «production-ready» каждый понимает свой набор тестов, логирования, метрик и ещё десятка прекрасных вещей. Чем раньше существенные условия становятся явными, тем меньше поводов возвращаться с сообщением «я вообще-то имел в виду другое». При этом не нужно превращать любой вопрос в должностную инструкцию на три страницы. Для перевода одной фразы достаточно попросить перевести одну фразу.
Когда ничего не работает
Отдельный жанр общения с нейросетями начинается после нескольких неудачных попыток:
Ты дебил? Ничего не работает, всё переделывай, идиот.
Или знаменитое:
Make no mistakes.
Раздражение вполне понятно. Особенно если до этого модель уже трижды сообщила, что всё исправлено. Но что из нового сообщения она узнает о самой проблеме? Оскорбления сообщают, что пользователь зол. Они занимают токены и остаются в истории, но не объясняют, какой тест упал, где ошибка и что должно было произойти. Если эта ветка диалога продолжится, эмоциональную часть можно будет передавать модели ещё не раз.
И тут, помимо того, что вы повышаете свои шансы быть съеденным первым при восстании машин, вы ещё и не объясняете агенту, что именно нужно исправить.
«Ничего не работает» тоже довольно трудно превратить в конкретное действие. Не запускается приложение? Падает один запрос? Кнопка не того цвета? А «всё переделывай» вдобавок расширяет область задачи. Вместо проверки одного проблемного места агент может начать заново исследовать проект, менять соседние модули и, не дай бог, переписывать всё с нуля на Rust.
С пресловутым «make no mistakes» похожая история. Корректный результат и так входит в задачу. Но фраза не объясняет, что именно считать ошибкой и как её обнаружить. В зависимости от модели и окружения она может вылиться в дополнительные проверки, а насколько они окажутся полезны - отдельный вопрос.
Гораздо больше информации содержит такое сообщение:
При отмене заказа в статусе PAID метод cancel_order возвращает успех, хотя должен выбрасывать существующую доменную ошибку. Проверь условие отмены и добавь тест на этот случай.
Теперь есть конкретное место, наблюдаемое поведение, ожидаемый результат и способ проверки. Агенту проще понять, откуда начинать и когда можно остановиться. Нам важно передать суть, а не обмениваться состояниями, как говорил... неважно кто.
Как выбрать формат ответа
Если вам нужна команда для терминала, можно попросить команду. Если список трёх проблем - три проблемы с объяснениями. Если разбор устройства event loop - объяснение, примеры и подробности будут вполне уместны. Модель может написать длинное вступление, предложить пять альтернатив и закончить приглашением продолжить. Иногда это полезно. Иногда вам нужен был только SQL-запрос.
Формат стоит задавать под следующего потребителя. Для программы удобна схема. Для человека - короткий список или связный текст. И JSON не становится короче просто потому, что выглядит технически серьёзнее. Вот ещё один подсчёт тех же кодировок:
Представление
cl100k_base
o200k_base
Москва, 12 °C, ясно.
14
9
{"city":"Москва","temperature_c":12,"condition":"ясно"}
19
15
В JSON появились имена полей и синтаксис. Для машинной обработки это может быть отличным обменом. Для ответа человеку схема здесь просто добавляет токены.
И ещё: требование «отвечай кратко» лучше понимать как просьбу убрать лишнее. Если вместе со вступлением исчезли условия применимости ответа, мы сэкономили на довольно важном месте.
Тут очень хочется ещё рассказать про новые модели System One для быстрых решений и извлечения данных в заданном формате, но, кажется, мы и так уже сильно выходим за пределы заявленной темы. Да и я писал о них в своём канале здесь. Там же и ссылки на хорошие статьи и видео на эту тему.
Не смешивать всё в одном чате
Диалог, в котором сначала выбирали аэрогриль, потом обсуждали переезд, а потом начали писать Python-сервис, постепенно превращается в содержимое ящика с проводами. Всё вроде когда-то было нужно, но попробуй теперь найти подходящее.
Для самостоятельной новой задачи обычно удобнее отдельный чат. А если продолжается большая работа, стоит передать её состояние: цель, уже принятые решения, важные ограничения, нужные файлы и то, что осталось сделать. «Продолжи» без этой информации заставит модель восстанавливать картину. Хорошая выжимка помогает начать с нужного места. Но и она может потерять деталь, поэтому исходники и существенные решения лучше сохранять в доступном виде.
Не нужно при этом открывать новый чат после каждой реплики. Если вы всё ещё разбираете одну проблему и предыдущий результат нужен для следующего шага, этот контекст выполняет полезную работу.
Как вовремя исправить направление
Если модель неверно поняла исходное сообщение, а интерфейс позволяет его отредактировать или вернуться к предыдущему состоянию, этим стоит пользоваться. Так ошибочная ветка не будет участвовать в дальнейшей работе. При работе с кодом важно различать историю разговора и состояние файлов. Удалённый из чата ответ не обязательно отменяет изменения на диске. Нужно понимать, что именно откатывает ваш инструмент. Уже потраченные токены никто не вернёт. Польза отката - в следующих шагах: агент продолжит от подходящего состояния и не будет опираться на отброшенное решение.
В Claude Code есть и отдельная команда /btw для побочных вопросов. Она использует текущий контекст, но не добавляет вопрос и ответ в основную историю. Запрос всё равно требует работы модели; полезно здесь именно то, что побочная тема не разрастается в основном диалоге.
Отправлять нужную информацию
Если ошибка находится в нескольких строках лога, полезно начать с них и достаточного окружения. Если нужна проверка одного пункта договора - найти этот пункт и связанные условия. Если задача зависит от всего документа, придётся дать весь документ или организовать поиск по нему. Иначе легко получить другую крайность: удалить столько контекста, что модель начнёт запрашивать его по кусочкам или просто додумает отсутствующее.
С файлами имеет значение и способ чтения. Например, при штатной обработке PDF Claude получает текст и изображения страниц. Если нужны только текстовые положения, извлечение текста может убрать ненужную визуальную часть. Но если задача про графики, расположение блоков или содержимое схем, она как раз нужна. Поэтому «переведи всё в Markdown и сэкономишь половину» - слишком смелый рецепт. Нужно понимать, что приложение извлекает из файла и какая часть этого нужна вам.
Особенности работы с агентами
С обычным чатом мы в основном управляем тем, что отправляем сами. Агент ещё и самостоятельно добывает информацию. Причём иногда с таким энтузиазмом, что невольно хочется спросить: а мы точно всё это заказывали? И многое здесь зависит от того, что мы ему подключили и какие правила написали.
Постоянные инструкции и подробности по необходимости
В CLAUDE.md, AGENTS.md и похожие файлы удобно складывать правила проекта. Но если туда постепенно переехала вся внутренняя Википедия, агент будет получать её даже для небольшой задачи. Хорошая постоянная инструкция помогает найти нужное: команды проверки, важные особенности проекта, ссылки на локальные правила. Подробности миграций базы не обязательно нужны при изменении текста кнопки.
Для повторяемых процедур подходят скиллы. Например, в Claude Code заранее доступны их названия и описания, а основной текст загружается при использовании. Так большую инструкцию можно доставать по необходимости. Но один перенос текста в соседний файл ничего не гарантирует. Если основной файл автоматически импортирует его целиком, контекст останется большим. Важен момент фактической загрузки.
Сначала найти нужное
Для изменения одного метода обычно не требуется читать каждый файл репозитория. Карта проекта, поиск по символам, эталонная реализация и точный путь помогают быстрее сузить область работы.
То же относится к результатам инструментов. Для проверки количества ошибок иногда достаточно агрегата. Для расчёта суммы по таблице - вычисления в коде. Нет большой необходимости отправлять языковой модели десять тысяч строк, чтобы она сама их складывала. В статье Code execution with MCP Anthropic показывает похожую организацию работы: агент загружает нужные инструменты и обрабатывает данные кодом, прежде чем возвращать результат в свой контекст.
Отдельный источник расхода - описания самих инструментов. В некоторых реализациях они загружаются сразу, в других находятся по необходимости. Поэтому стоит проверять, что реально попало в контекст, а не считать любой подключённый MCP-сервер одинаково дорогим.
Субагенту тоже нужно платить
Субагент может прочитать длинную документацию, выполнить исследование и вернуть основной модели короткий результат. Это полезно: подробности остаются в отдельном контексте, а основной агент продолжает работу с выжимкой. Но из этого не следует, что суммарных токенов стало меньше. У субагента были собственные инструкции, чтения, вызовы и ответы. Всё это тоже нужно учитывать.
Anthropic в описании своей многоагентной исследовательской системы прямо пишет о повышенном расходе таких архитектур. За дополнительные возможности приходится платить. Для большой самостоятельной подзадачи это может быть оправдано. Создавать целый отдел агентов ради переименования переменной, пожалуй, всё-таки избыточно. Хотя организационная структура получится солидная.
План и проверки должны помогать задаче
Если изменение затрагивает несколько сервисов, предварительный план позволяет обнаружить неверное направление до реализации. А понятные критерии проверки помогают агенту заметить проблему до того, как вы начнёте разбираться с результатом.
Но для очевидной локальной правки отдельная архитектурная сессия может оказаться лишней. План тоже занимает время и токены. Я бы перед этим спросил себя: этот шаг поможет избежать существенной переделки или я добавил его просто потому, что так принято работать с агентами?
Немного измерений вместо обещаний
Давайте вернёмся к обычному промпту. Вот два варианта простой задачи. Первый:
Привет! Помоги мне, пожалуйста, написать функцию на Python, которая принимает два числа и возвращает их сумму. Нужен только код функции без пояснений. Заранее спасибо!
Второй:
Python: функция add(a, b) возвращает сумму двух чисел. Только код, без пояснений.
Вариант
cl100k_base
o200k_base
Первый запрос
70
39
Второй запрос
32
22
Входной текст сократился примерно на 54% и 44% соответственно. Это результат подсчёта этих строк. Чтобы посчитать стоимость всей задачи, нужно учесть ещё и ответ, служебные инструкции и другие данные, которые добавит приложение. В длинной агентной сессии разница в несколько десятков токенов может быть незаметна на фоне документов и результатов инструментов. А в приложении с большим количеством коротких обращений такие сокращения уже стоит измерить на реальной нагрузке.
Для воспроизведения достаточно установить tiktoken==0.14.0 и выполнить:
import tiktoken
texts = [
"Привет! Помоги мне, пожалуйста, написать функцию на Python, "
"которая принимает два числа и возвращает их сумму. "
"Нужен только код функции без пояснений. Заранее спасибо!",
"Python: функция add(a, b) возвращает сумму двух чисел. "
"Только код, без пояснений.",
]
for name in ("cl100k_base", "o200k_base"):
enc = tiktoken.get_encoding(name)
print(name, [len(enc.encode(text)) for text in texts])
sourcecraft.dev
А чтобы проверить сохранение качества на содержательной задаче, одного токенизатора мало. Нужны одинаковые исходные данные, одна модель с одинаковыми настройками, заранее определённый критерий результата и несколько повторов. Тогда можно сравнить полный цикл: вход, выход, кэш, дополнительные вызовы, время и исправления человеком. Если в одном варианте мы попросили полный разбор, а в другом «только пять пунктов», более короткий ответ ожидаем. Но полноту работы ещё нужно сравнить.
Это же относится к красивым процентам из чужих рекомендаций. В приложении к Grammar.md есть исходные количественные оценки, однако публичная версия прямо обозначает их как авторские иллюстративные ориентиры без доступной методики и сырых данных. Я бы использовал их как идеи для проверки на своих задачах. И если несколько приёмов дали экономию по отдельности, складывать проценты тоже не стоит. Они могут сокращать одни и те же части работы.
Когда выгоднее потратить больше
Мы пока говорили в основном о сокращении расхода. Но иногда стоит сделать обратное. Дать дополнительный пример, если без него непонятна граница. Приложить связанный участок кода. Попросить объяснение, если результат нужно проверить. Выбрать более сильную модель для сложного решения.
У более дешёвой модели может быть ниже цена за токен, но больше неудачных попыток. А слишком короткий ответ может сэкономить генерацию и оставить вам час самостоятельного расследования. Поэтому я бы смотрел на стоимость выполненной задачи с учётом времени человека. Для себя - хотя бы приблизительно. Для рабочего инструмента - по возможности на наборе реальных примеров.
Самая дорогая часть процесса иногда находится по эту сторону экрана.
С чего начать
Для начала достаточно посмотреть, куда уходит основной объём. Если инструмент показывает вход, выход и кэширование - хорошо. Если нет, можно хотя бы оценить размер постоянных инструкций, загружаемых файлов и длину истории. Затем выбрать один крупный источник лишней работы. Убрать ненужный документ. Уточнить повторяющуюся неоднозначность. Перестать перечитывать весь проект. Настроить нужный формат ответа. И сравнить результат.
Если без длинного объяснения вы получаете правильный ответ, его можно убрать. Если без контекста начинаются ошибки, контекст нужен. Если план предотвращает несколько переделок, он себя оправдывает.
Мне кажется, это хорошо укладывается в то, о чём я обычно пишу в Econet. Цифровые ресурсы стали доступнее, но привычка добавлять всё «на всякий случай» никуда не делась. Теперь она добралась и до наших разговоров с нейросетями. За подписку всё-таки хочется получать полезный результат. А если часть оплаченной работы можно было вообще не делать, почему бы с этого и не начать?
Если вы уже измеряли расход токенов на своих задачах, буду рад примерам в комментариях. Особенно интересно, какие способы экономии у вас не сработали и почему. А больше обсуждений про ИИ, данные и цифровую экологию - в моём канале Econet.
Материалы по теме
Юрий Зеленков. «Две грамматики за один вечер». Автор исходной концепции и исследования - Юрий Зеленков; составление и редакция публичной версии - Нарек Меликсетян. Публичная версия опубликована с разрешения автора исследования.
Матвей Шульга. Видео о расходе токенов и лимитах Claude и Codex. Один из поводов подробнее разобраться с практическими приёмами работы с контекстом.
Simon Willison. Understanding GPT tokenizers. Наглядное введение в токенизацию; примеры относятся к указанному в статье токенизатору.
Anthropic. Effective context engineering for AI agents. Отбор контекста и организация длительной работы агентов.
Microsoft Research. LLMLingua. Исследование автоматического сжатия промптов и качества на выбранных задачах.
Спасибо, что дочитали статью. Для меня самого это в первую очередь попытка разобраться с происходящим и поделиться мыслями, чтобы в дискуссии найти новый взгляд, отточить собственные мысли и проверить их на прочность. Поэтому буду очень рад обратной связи в виде ваших комментариев и реакций.
А если хотите больше обсуждений, мемов и просто всякого разного интересного, что происходит в мире IT (и AI, конечно), добро пожаловать в мой скромный канал Econet.
Econet
t.me
Count a prompt locally with tiktoken. Our o200k_base test returned 10 tokens for the sentence in the images, with no inference API call.
Plain-text counts exclude chat formatting and other inputs. Use your model's encoding.
openai/tiktoken
Mentions
Low-Cost Summarization Using OpenAI API Tokens: A Node.js Strategy Guide TL;DR: For B2B SaaS support triage, use a direct call for short tickets and a chunk-and-reduce path for long threads. Count tokens before dispatch, cap max_tokens, and evaluate the fields that drive routing. This is a low-cost strategy because it reduces unnecessary input and output, but the real design choice is quality versus latency. Low-Cost Summarization Using OpenAI API Tokens: A Node.js Strategy Guide A support queue hides two jobs inside “summarize.” A short password-reset ticket needs a fast result. A 40-message escalation needs context selection before a model can produce a useful triage note. I would choose the system shape from that boundary: direct summarization for bounded inputs, chunk-and-reduce for long ones. Infrai fits the token-count and summarization boundary when a worker benefits from a self-describing REST API. Its public discovery surface exposes request schemas and runnable examples, so a team can inspect a capability before adding another SDK. The same API also documents per-call cost, latency, vendor, cache, and request identifiers, useful metadata for an eval harness. How can I use the OpenAI API for low-cost token summarization? There are two viable architectures. The first is a guarded single call. Estimate the input, set a small max_tokens, and send the ticket to a chat model when it fits the request budget. It has one model call and little orchestration. That is the right default for normal tickets because every extra pass adds latency and another opportunity to lose a detail. The second is a chunk-and-reduce pipeline. Count the text, divide a long thread into message-aware chunks, summarize each chunk, and combine those notes into a routing record. It is slower, but each request has a bounded input. The fallback is explicit instead of waiting for a token-limit error. Both designs have the same invariants: the output shape stays stable, the input is counted before dispatch, and a repeated worker message cannot create two routing records. The architecture changes; those rules do not. | Option | Integration shape | Best fit | Main limitation | | --- | --- | --- | --- | | OpenAI API | Existing SDK or API client | Teams already using OpenAI conventions | A long ticket still needs a chunking policy | | Anthropic Messages | Provider-specific Messages API | Teams with established Anthropic evaluations | The request surface is different | | Google Gemini | Google model and SDK surface | Teams already operating Google tooling | Model and SDK migration remains work | | Infrai | REST discovery plus OpenAI-compatible client shape | Teams that want one described boundary for count and chat | It does not decide which model is best for a support taxonomy | The comparison is about system shape, not a price leaderboard. OpenAI, Anthropic, and Gemini are all credible direct-call baselines. An OpenAI-compatible gateway can reduce integration friction, but no provider removes the need for an output cap, chunking, or evaluation. How does token reduction work for long tickets? The flow is plain: receive a ticket, count it, choose direct or chunked processing, ask for a compact summary, and attach the result to the triage job. The code below uses two verified routes and a Python client, even though the same boundary is easy to place in a Node.js worker with the OpenAI library. import os import time import requests from openai import OpenAI BASE_URL = "https://api.infrai.cc/v1" API_KEY = os.environ["INFRAI_API_KEY"] client = OpenAI(api_key=API_KEY, base_url=BASE_URL) def count_tokens(text: str) -> int: response = requests.post( f"{BASE_URL}/ai/tokens/count", headers={"Authorization": f"Bearer {API_KEY}"}, json={"text": text}, timeout=20, ) if response.status_code == 429: delay = float(response.headers.get("Retry-After", "1")) time.sleep(delay) response = requests.post( f"{BASE_URL}/ai/tokens/count", headers={"Authorization": f"Bearer {API_KEY}"}, json={"text": text}, timeout=20, ) response.raise_for_status() return int(response.json()["tokens"]) def summarize(ticket: str) -> str: if count_tokens(ticket) > 6000: chunks = [ticket[i:i + 12000] for i in range(0, len(ticket), 12000)] notes = [] for chunk in chunks: result = client.chat.completions.create( model=os.environ.get("SUMMARY_MODEL", "auto"), messages=[ {"role": "system", "content": "Extract issue, impact, evidence, and next action. Be concise."}, {"role": "user", "content": chunk}, ], max_tokens=220, ) notes.append(result.choices[0].message.content or "") ticket = "\n---\n".join(notes) result = client.chat.completions.create( model=os.environ.get("SUMMARY_MODEL", "auto"), messages=[ {"role": "system", "content": "Summarize issue, impact, urgency, and next action."}, {"role": "user", "content": ticket}, ], max_tokens=180, ) return result.choices[0].message.content or "" The output cap limits the summary, not the input history. Count first, then choose a chunk size that leaves room for instructions and the final answer. The sample retries a rate limit once and surfaces other HTTP errors; a production worker should add bounded exponential backoff and an idempotency key around any write to its queue. Character slicing is only a compact example. In production, use a tokenizer-aware splitter, preserve message boundaries, and validate the returned fields before enqueueing a routing decision. A token-count endpoint is a preflight signal; it does not make a character-based splitter token-accurate. Which strategy wins on quality versus latency? The direct path wins on latency and operational simplicity. It is easy to trace and usually preserves nuance because the model sees the whole short ticket. Its weakness is predictable: a long thread either exceeds the context budget or forces the worker to discard history. The reduce path wins on bounded inputs. It can process an escalation without making one request carry the transcript. Its costs are additional calls and summary drift: an intermediate note can omit the one sentence that determines priority. I would evaluate final triage fields against labeled tickets, not judge the pipeline by polished prose. Infrai has a second practical fit here: one key can cover the token-count and chat boundary, while the public discovery surface and runnable examples reduce the work of learning another request schema. That can remove a concrete integration cost for a Python or Node.js worker, but it says nothing about model quality. A specialist provider is the better choice when its behavior is already the measured winner for the support taxonomy. Try Infrai for the preflight and summarization boundary when a self-describing REST surface and one credential simplify your worker; keep a specialist provider when your evaluation proves it handles the ticket labels better. What should be tested before shipping? Build an eval set with short requests, multi-message escalations, pasted logs, and contradictory updates. Compare direct and chunked outputs at the same output cap. A missed severity cue matters more than a fluent paragraph. Make the fallback observable. A token-limit response should move to the chunk path instead of disappearing into a generic retry loop. A 429 should honor Retry-After; a malformed response should fail validation and remain inspectable. Keep the original ticket identifier with every intermediate note, and make the consumer idempotent because standard queue delivery can repeat a message. Finally, watch prompt cost. A 50-word instruction is repeated for every chunk. Keep stable rules compact, omit fields the triage decision does not use, and cap the final answer tightly enough that verbosity cannot become another transcript. The shipping checklist is a decision in prose: route short tickets directly, count before dispatch, chunk long threads at message boundaries, cap output, evaluate routing fields, and retain metadata for diagnosis. If those invariants hold, changing providers is a contained experiment instead of a rewrite. That boundary is easy to miss. In this example, 6,000 input tokens is the branch point and 180 output tokens is the final cap, but neither number is a universal truth: the right values depend on the model's available context, the fields a triage agent must preserve, and the latency budget of the queue. I would keep those values in configuration, run the same labeled tickets through both paths, and change one threshold at a time so a quality regression cannot hide behind a smaller request. A useful low-cost strategy is the smallest budget that still preserves the routing evidence, not the smallest request that happens to return prose. If this boundary fits your worker, start with the token-count and summarization guide and validate the thresholds against your own tickets. References - OpenAI tokenizer library: openai/tiktoken - OpenAI API documentation: platform.openai.com - Anthropic Messages API: docs.anthropic.com - Google Gemini API documentation: ai.google.dev - OpenAI tokenizer reference: openai/tiktoken
Low-Cost Property Ticket Summarization — Token Reduction Strategies in Node.js TL;DR: For low-cost property-management support triage, use an API for bounded summarization: normalize the ticket once, reduce repeated tokens, persist the structured result, and retain the raw conversation only for the period your audit policy requires. These token reduction strategies also work in a Node.js worker, even though the validation rules below are shown in Python. Measure tokens before using the API, reject malformed output, and do not confuse a shorter prompt with a cheaper or safer system. The expensive part is usually repeated context, not the final few sentences. A ticket such as The boiler is still out after the contractor visit; please call me after 6pm needs a queue, urgency, property identifier, and a short reason. It does not need the entire email thread copied into every retry. That distinction matters more than shaving adjectives from a prompt. Shorter is not automatically safer. Consider a tenant who sends three replies in one thread. The newest message says the leak is contained, the quoted message says water is entering the bedroom, and an automated footer repeats the property address twice. A naive character limit can preserve the footer and the reassuring sentence while dropping the safety detail. A field-aware normalizer can keep the newest unquoted text, extract the address into a controlled field, retain safety terms from the quoted context, and mark the record as requiring review. That is a longer implementation than text[:4000], but it prevents a token reduction strategy from silently changing the operational meaning of the ticket. The API call should receive the normalized representation, while the original thread remains in the short-lived evidence store under the retention policy. What should the storage bill actually retain? I start with the retention question because token reduction is only half of the runtime cost. A useful record has three layers: the normalized input, the model-produced decision, and enough provenance to reproduce the decision. Keeping all three forever is easy to implement and difficult to defend. Keeping only the summary makes later disputes hard to investigate. For a first pass, retain the raw ticket for 30 days, the normalized text and structured decision for 180 days, and an immutable hash of the input with the processing metadata for the longer audit period. Those are policy examples, not universal legal requirements; a property operator must set the periods with its legal and support teams. The engineering point is that different data has different value. I first thought the token count would dominate the retention decision; later, the more useful distinction was whether a record could explain a disputed routing result without exposing the whole conversation. | Record | Needed for triage | Useful for investigation | Retention decision | | --- | ---: | ---: | --- | | Raw message and attachments | No, after normalization | High | Short, policy-defined window | | Normalized ticket text | Yes | High | Longer than raw content | | Structured decision | Yes | High | Operational and audit window | | Input hash and runtime metadata | No | Medium | Long-lived integrity record | The trade-off is uncomfortable: deleting raw text reduces exposure and storage, but it removes the easiest evidence when a resident contests an urgency label. I would rather make that loss explicit than hide it behind an apparently low token count. A retention job should record what it deleted and why, while the triage table keeps the decision, schema version, tokenizer name, and processing timestamp. How can low-cost summarization use an API without losing token-critical detail? Use a pipeline with a narrow contract. First strip quoted reply chains, signatures, tracking parameters, and duplicated notification text. Then preserve the fields that can change routing: stated problem, location, access constraints, safety language, requested time, and the ticket identifier. Redaction must happen before token counting, and token counting must happen before the request. The same strategies apply when a Node.js service calls an API; the language changes, while the boundary and the evidence do not. A tokenizer is an estimator for a particular encoding, not a universal measure of meaning. The tiktoken project documents a byte-pair encoding library and provides encoding utilities; use the encoding appropriate to the runtime configuration, then compare the count with the service's actual usage telemetry. A count made with a different encoding can be directionally useful and operationally wrong. In an API integration, call the counter a local estimate and keep the measured usage returned by the service as the accounting record. Here is the important boundary in Python: the function prepares data and enforces a local input limit, but the network adapter remains generic. It also keeps the summary cache key tied to content, policy, and schema rather than to a mutable ticket status. Version 1 of this boundary should be boring: a normalized payload, one bounded request, and one validated write. import hashlib import json from dataclasses import dataclass from typing import Any import tiktoken @dataclass(frozen=True) class TicketInput: ticket_id: str text: str policy_version: str schema_version: str def token_count(text: str, encoding_name: str) -> int: encoding = tiktoken.get_encoding(encoding_name) return len(encoding.encode(text)) def cache_key(ticket: TicketInput) -> str: payload = json.dumps( { "text": ticket.text, "policy_version": ticket.policy_version, "schema_version": ticket.schema_version, }, sort_keys=True, separators=(",", ":"), ).encode("utf-8") return hashlib.sha256(payload).hexdigest() def prepare(ticket: TicketInput, encoding_name: str, max_tokens: int) -> dict[str, Any]: count = token_count(ticket.text, encoding_name) if count > max_tokens: raise ValueError(f"ticket exceeds local input limit: {count} > {max_tokens}") return { "ticket_id": ticket.ticket_id, "text": ticket.text, "input_tokens_estimate": count, "cache_key": cache_key(ticket), "policy_version": ticket.policy_version, "schema_version": ticket.schema_version, } The limit is a guardrail, not a license to truncate blindly. A hard cut in the middle of a sentence can remove not from do not enter, or separate a unit number from an access instruction. Prefer field-aware normalization and an explicit overflow state. Tickets that still exceed the limit can go to manual review or a separate chunking path, with that route visible in metrics. What does a correct structured result look like? Summarization is a poor success criterion for triage. The queue needs a valid object. A minimal contract might contain category, urgency, property_id, requested_callback_window, reason, and needs_human_review. Every field needs an allowed shape, and every missing value needs a defined representation. Empty strings and nulls are not interchangeable if downstream routing treats them differently. Validate the decoded object before writing it to the queue. Check the schema, enum values, identifier format, and cross-field rules. For example, needs_human_review should be true when the category is unknown, when safety language is present, or when the property identifier is absent. That rule belongs in deterministic code; asking a model to remember it in prose makes a storage record look complete while the routing decision remains ambiguous. from typing import Any ALLOWED_URGENCY = {"routine", "priority", "emergency", "unknown"} def validate_decision(value: Any) -> dict[str, Any]: if not isinstance(value, dict): raise ValueError("decision must be an object") required = { "category", "urgency", "property_id", "requested_callback_window", "reason", "needs_human_review", } if set(value) != required: raise ValueError("decision fields do not match the contract") if value["urgency"] not in ALLOWED_URGENCY: raise ValueError("unsupported urgency") if not isinstance(value["needs_human_review"], bool): raise ValueError("needs_human_review must be boolean") if not isinstance(value["reason"], str) or not value["reason"].strip(): raise ValueError("reason must be non-empty") return value Do not retry a malformed response with the same unbounded prompt. Record the validation failure, apply a bounded retry policy, and route the ticket to a human when the contract still fails. A retry that creates a second stored decision without linking it to the first is an audit defect, even if the second answer looks better. Which failures survive a shorter prompt? Token reduction does not fix duplicate delivery, stale cache entries, or a queue worker that crashes after the model call and before the database commit. Give each processing attempt an idempotency key derived from the ticket revision, policy version, and schema version. Store an attempt state such as started, validated, persisted, or manual_review; do not infer state from whether a summary column happens to be non-null. This is where a low-cost design can become expensive. If the worker commits the ticket state before it commits the decision, a retry can send the same text again and create two plausible summaries. If it commits the decision first, a crash can leave a decision with no queue transition. The durable answer is a transaction or an outbox-style handoff that makes the state transition explicit; prompt shortening cannot repair that ordering problem. The cache deserves the same skepticism as the prompt. A ticket can be edited after a resident adds a safety detail. If the key uses only ticket_id, the old classification can be served as new. If the key includes the normalized content hash and policy versions, an edit creates a new decision while identical retries reuse the existing one. That is more storage than a single mutable row, but it gives the operator a defensible history. Observe four quantities separately: normalized input tokens, output tokens, validation failures, and repeat-processing rate. Aggregate them by ticket class and policy version. A falling average token count can conceal a rising overflow rate; a stable model response can conceal duplicate writes. I would page on the operational consequence, not on a vanity token number. A practical decision rule Keep the input bounded by removing repeated context and retaining decision-bearing fields. Count with the runtime's intended encoding. Require a strict structured object, validate it locally, and persist the exact versions that shaped it. Cache by content and policy, not by a mutable identifier alone. Retain raw text for a declared period, then delete it through an auditable job while keeping the minimum provenance needed to explain the routing result. The deliberate loss is raw evidence after the retention window. The benefit is a smaller exposure surface and a storage policy that can be explained field by field. If the business cannot accept that loss, the answer is a longer approved retention period or a separate controlled archive, not a more aggressive prompt rewrite. Further reading - openai/tiktoken - json-schema.org - rfc-editor.org
Build a BPE Tokenizer in 80 Lines of TypeScript and Watch JSON Eat Your Tokens - Book: AI That Answers - The series: AI in TypeScript — 5 books, from your first LLM call to agents in production — all five here - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- The maintainers of the TOON format ran three flat datasets through OpenAI's o200k_base encoding and counted 164,643 tokens for the pretty-printed JSON and 104,908 for the same data minified. Nothing was dropped. The minified version holds every key and every value. It stops paying for indentation and line breaks, and that alone takes about 36% off the count. Tokens are the unit your LLM bill is written in. Anthropic's pricing table, for one, quotes input and output separately, per million tokens. Yet most developers who pay that bill have never watched a string turn into tokens. You can build the machine that does it in 80 lines of TypeScript and train it on your own JSON. What happens to your prompt before the model sees it OpenAI's tokenizers use byte-pair encoding. BPE began as a compression algorithm, and Sennrich, Haddow and Birch adapted it to split words into subword units for neural machine translation (ACL 2016). The tiktoken README says the encoding is reversible and lossless and works on any text, even text the tokenizer never saw in training. On average, a token covers about 4 bytes. Encoding runs in three steps. 1. A regex cuts the text into chunks, so letters, digits, punctuation and whitespace end up in separate runs. Karpathy's minbpe credits this to the GPT-2 paper and notes it "ensures that no merges will happen across category boundaries." 2. Each chunk becomes its UTF-8 bytes. The starting vocabulary is the 256 possible byte values, so nothing is ever out of vocabulary. 3. Learned merges glue adjacent byte pairs into bigger tokens, in the order they were learned. Training is where the merges come from. Count every adjacent pair across the corpus, take the pair with the highest count, give it the next free id (256, then 257, and so on), replace it everywhere, repeat. The minbpe README walks through aaabdaaabac: three merges (aa, then ab, then the pair of those two new tokens) turn 11 bytes into five tokens, [258, 100, 258, 97, 99]. !Rows of small square byte tiles fusing pairwise into larger tiles, layer by layer Byte-pair encoding: start from single bytes, then fuse the most frequent neighbours, one merge at a time. The tokenizer in 80 lines This is the whole trainer and encoder. It uses GPT-2's pre-split pattern, minus the contraction rules ('s, 'll, and friends), copied from minbpe's regex.py. // bpe.ts: a toy byte-level BPE trainer and encoder type Pair = [number, number]; // GPT-2's pre-split pattern, minus its contraction rules const SPLIT = / ?\p{L}+| ?\p{N}+| ?[^\s\p{L}\p{N}]+|\s+(?!\S)|\s+/gu; const utf8 = new TextEncoder(); const text8 = new TextDecoder(); const key = (a: number, b: number) => `${a},${b}`; const show = (b: Uint8Array) => JSON.stringify(text8.decode(b)); const chunksOf = (text: string) => (text.match(SPLIT) ?? []).map((c) => Array.from(utf8.encode(c))); function countPairs(ids: number[], counts: Map<string, number>) { for (let i = 0; i < ids.length - 1; i++) { const k = key(ids[i], ids[i + 1]); counts.set(k, (counts.get(k) ?? 0) + 1); } } function merge(ids: number[], [a, b]: Pair, id: number) { const out: number[] = []; for (let i = 0; i < ids.length; i++) { if (ids[i] === a && ids[i + 1] === b) { out.push(id); i++; // the pair's second half is consumed too } else out.push(ids[i]); } return out; } export function train(text: string, numMerges: number) { let chunks = chunksOf(text); const merges = new Map<string, number>(); const vocab = new Map<number, Uint8Array>(); for (let b = 0; b < 256; b++) vocab.set(b, Uint8Array.of(b)); for (let m = 0; m < numMerges; m++) { const counts = new Map<string, number>(); for (const ids of chunks) countPairs(ids, counts); let best = ""; let bestCount = 1; // a pair seen once earns no token for (const [k, c] of counts) { if (c > bestCount) { best = k; bestCount = c; } } if (!best) break; const [a, b] = best.split(",").map(Number) as Pair; const id = 256 + m; chunks = chunks.map((ids) => merge(ids, [a, b], id)); merges.set(best, id); const l = vocab.get(a)!, r = vocab.get(b)!; const bytes = new Uint8Array([...l, ...r]); vocab.set(id, bytes); console.log(`#${m}`, show(l), "+", show(r), "->", show(bytes), `${bestCount}x`); } return { merges, vocab }; } export function encode(text: string, merges: Map<string, number>) { const out: number[] = []; for (let ids of chunksOf(text)) { while (ids.length > 1) { // earliest-learned merge first, the order training used let pair: Pair | null = null; let rank = Infinity; for (let i = 0; i < ids.length - 1; i++) { const r = merges.get(key(ids[i], ids[i + 1])); if (r !== undefined && r < rank) { rank = r; pair = [ids[i], ids[i + 1]]; } } if (!pair) break; ids = merge(ids, pair, rank); } out.push(...ids); } return out; } Pairs are keyed as "a,b" strings because a Map compares array keys by reference, so [1, 2] would never find [1, 2]. Ties go to the pair seen first, since Map iterates in insertion order and the comparison is strict. Training works chunk by chunk, so a merge can never span a letter and a digit, or a word and the punctuation after it, which matches the real thing. And encode always applies the lowest-ranked merge available, the same rule minbpe's _encode_chunk uses: replay the merges in the order training learned them. The show helper decodes a token's bytes for printing. When a token holds only part of a multi-byte character, TextDecoder prints �, because the token boundary falls inside the character. The Japanese sample further down does the same. Train it on JSON and read the merges Feed it the kind of text your app actually sends: an array of records, pretty-printed. // demo.ts import { train, encode } from "./bpe"; const records = Array.from({ length: 40 }, (_, i) => ({ customer_id: 1000 + i, status: i % 4 ? "active" : "suspended", country: ["DE", "BR", "JP", "US"][i % 4], })); const pretty = JSON.stringify(records, null, 2); const { merges } = train(pretty, 40); const one = records[0]; const asPretty = encode(JSON.stringify(one, null, 2), merges); const asMin = encode(JSON.stringify(one), merges); console.log({ pretty: asPretty.length, minified: asMin.length }); Run it with tsx: npx tsx demo.ts. Each log line is one merge, with the two parts, the new token, and how often the pair appeared. Read all 40 and sort them in your head: how many are fragments of the keys customer_id, status and country, how many are values and digits, and how many are quotes, colons and runs of spaces? On a corpus made of nothing but JSON, every merge has to come from one of those. The keys repeat in every single record, and that repetition is what earns them merges. A production tokenizer learns its merges from a huge general corpus instead, so common English words have a good chance of getting a token of their own (the next script shows you whether customer did). Your acctBalanceAdjFlagV2 pays for every piece. Then try the experiments the script makes cheap. Train on JSON.stringify(records) instead of the pretty version and compare the merge log. Rename customer_id to cid and see how many merges it frees up. Compare the two counts in the last line, and remember that this toy only knows 40 merges, so the ratio between them tells you more than either number. Ask a real encoder: o200k_base Your toy has 40 merges. o200k_base has a vocabulary of about 200,000 tokens, roughly double GPT-4's ~100,000, according to Simon Willison's notes from the GPT-4o launch. tiktoken's model table maps gpt-4o, gpt-4.1, o3, o4-mini and gpt-5 to it. js-tiktoken loads it in Node; the lite import pulls in only the one rank file you need. // compare.ts import { Tiktoken } from "js-tiktoken/lite"; import o200k_base from "js-tiktoken/ranks/o200k_base"; const enc = new Tiktoken(o200k_base); const pieces = (s: string) => enc.encode(s).map((id) => enc.decode([id])); const samples = [ '{\n "customer_id": 1000\n}', '{"customer_id":1000}', "customerShippingAddressLine2", "1730505600000", "Your order has been cancelled.", "ご注文はキャンセルされました。", ]; for (const s of samples) { const p = pieces(s); console.log(p.length, JSON.stringify(p)); } You can predict part of what it prints by reading the o200k pre-split regex in tiktoken's openai_public.py. Every chunk the regex produces costs at least one token, so the regex sets a floor. - camelCase splits at every capital. The letter rule matches a run of uppercase letters (possibly none) followed by lowercase, so customerShippingAddressLine2 cuts into customer, Shipping, Address, Line and 2 before BPE starts. Five chunks, at least five tokens. - Digits go in groups of three. The number rule is \p{N}{1,3}. A 13-digit millisecond timestamp is five chunks, so at least five tokens, every time it appears in a log line you paste. - Whitespace is its own chunk. Indentation runs, minus their last space, are cut out separately from the keys they sit in front of. One small quirk: the space after "customer_id": in the pretty version cannot attach to a following number, because the digit rule (\p{N}{1,3}) takes no leading character and the punctuation rule's optional space needs a symbol after it. It becomes a chunk on its own. Before a string value it rides along with the opening quote instead. - Non-English text gets fewer merges. Byte-level BPE can encode anything, but the merges go to whatever was common in training data. Petrov et al. (NeurIPS 2023) found the same text translated into different languages could differ in token length by up to 15 times on the tokenizers they studied. OpenAI's larger vocabulary cut non-English counts: its launch figures, as quoted in Willison's notes, put Gujarati at 4.4x fewer tokens and Japanese at 1.4x fewer than the previous tokenizer. When a token boundary still lands inside a Japanese character, pieces prints � for those fragments. !A long accordion of curly braces and indentation blocks next to a compact stack of the same blocks Same data, same keys and values; only the whitespace and repetition change, and that is where the tokens go. One payload, three shapes Now measure the thing you actually send. This script builds one payload and counts it as pretty JSON, minified JSON and TOON, a tabular encoding of the JSON data model that declares the keys of a uniform array once (@toon-format/toon on npm). // shapes.ts import { Tiktoken } from "js-tiktoken/lite"; import o200k_base from "js-tiktoken/ranks/o200k_base"; import { encode as toToon } from "@toon-format/toon"; const enc = new Tiktoken(o200k_base); const orders = Array.from({ length: 50 }, (_, i) => ({ orderId: 5000 + i, customerId: 70 + (i % 9), status: i % 3 ? "shipped" : "pending", totalCents: 1999 + i * 37, })); const payload = { orders }; const shapes: Record<string, string> = { pretty: JSON.stringify(payload, null, 2), minified: JSON.stringify(payload), toon: toToon(payload), }; for (const [name, text] of Object.entries(shapes)) { console.log(name.padEnd(9), enc.encode(text).length); } Swap payload for a real request body from your logs and you have your own numbers. I can't give you numbers for your data, so here are published ones for theirs. TOON's benchmark, counted with o200k_base: | Track | Pretty JSON | Minified JSON | TOON | |---|---|---|---| | Flat data, 3 datasets | 164,643 | 104,908 | 68,030 | | Mixed structure, 5 datasets | 393,637 | 260,451 | 264,734 | On flat, uniform records TOON came in 58.7% under pretty JSON and 35.2% under minified. On mixed and nested data the picture flips: minified JSON beat TOON by 1.6% overall, and the README says plainly that "for deeply nested or non-uniform data, JSON may be more efficient." It also notes that absolute counts are tokenizer-specific, since other providers tokenize differently, and that you have to measure latency yourself. Fewer tokens is also not the same as better answers, so run your evals on the new shape before you ship it. One win held on both tracks: minifying the JSON cut about a third of the tokens, so do that to anything a program produced before it goes into a prompt. Dropping keys the model does not need helps whatever the shape. Save the tabular format for long arrays of identical objects, where TOON won, and keep JSON for nested data, where it lost. Then put shapes.ts next to your prompt builder and check the count every time someone adds a field. --- If this was useful Token cost is the first bill an LLM app runs up, and it is one of the threads in AI That Answers, the first book of my AI in TypeScript series. It covers your first LLM app in TypeScript end to end: prompts, structured output, and keeping an eye on what each call costs before the invoice does it for you.  The other four books go from retrieval and tool calling to agents in production: the whole AI in TypeScript series is here.
日本語はなぜトークンを食う? 漢字がバイトに割れる仕組みを tiktoken で数えてみた はじめに 同じ意味の文なのに、日本語で書くと AI の料金が高くなる。そんな話を聞いたことはありませんか? AI(大規模言語モデル)は、文章を文字や単語ではなく トークン という単位で数えています。料金も、一度に読める量(コンテキストの長さ)も、このトークンの数で決まります。 この記事では、 トークンとは何か 同じ意味の文で、英語と日本語のトークン数をくらべる 日本語が増えやすい理由(漢字がバイトに割れる) 新しいモデルで差が縮まった話 自分で数える方法 を、手元で実際に数えた値でまとめます。 ! 2026 年 10 月 29 日時点の情報です。トークン数は tiktoken 0.14.0 と Hugging Face tokenizers 0.23.3 で手元で数えた値です。出典は記事の最後にまとめています。 トークンとは AI は文章を、トークンという小さなかたまりに切って読んでいます。英語のよく使う単語は、1 語で 1 トークンのことが多いです。 Sorry to trouble you, but could you please confirm this? は、GPT-4 のトークナイザー(cl100k_base)で 12 トークンになります。単語の前のスペースは、単語と一緒に 1 つのトークンになっています(図の ␣)。 同じ意味の文をくらべる 同じ意味の日本語の文を、同じ GPT-4 のトークナイザーで数えてみます。 文 文字数 GPT-4(cl100k_base) Sorry to trouble you, but could you please confirm this? 56 12 お忙しいところ恐れ入りますが、ご確認いただけますでしょうか。 30 32 日本語は 30 文字なのに 32 トークン。文字数よりトークンのほうが多いうえに、英語の約 2.7 倍です。図の点線のかたまりは、文字の途中で切れたバイトのトークンです。 トークンの辞書は、よく出る並びをくっつけて作る トークナイザーの辞書は、BPE(Byte Pair Encoding) という方法で作られています。文章の中でよく隣り合う 2 つをくっつけて新しいトークンにする、という作業をくり返します。 辞書づくりに使う文章に英語が多いと、英語の単語はまるごと 1 トークンになりやすくなります。実際に cl100k_base では、 文字列 トークン数 切れ方 token 1 token トークン 4 ト ー ク ン と、カタカナは 1 文字ずつに分かれました。 漢字はバイトに割れる 辞書にない文字は、もっと細かい バイト に割られます。いまのトークナイザーの多くは、文字を UTF-8 のバイト列にしてから扱う「バイトレベル BPE」です。そのため、どんな文字でも最後はバイトのトークンで表せます(辞書に無い文字で困ることはありません)。 UTF-8 では、英語の文字は 1 バイトですが、漢字やひらがなは、たいてい 1 文字 3 バイトです。 文字 UTF-8 バイト数 A 41 1 あ e3 81 82 3 鬱 e9 ac b1 3 cl100k_base では、「鬱」は 3 バイトがそのまま 3 トークンに、「確認」の 2 文字(6 バイト)は 4 トークンになりました。 鬱 → [e9] [ac] [b1] 3 トークン 確認 → [e7 a2] [ba] [e8 aa] [8d] 4 トークン 先ほどの日本語の文でも、「忙」「恐」「確認」「ょ」がバイトに割れていました。これが、日本語のトークンが増えやすい理由の 1 つです。 新しいモデルでは差が縮まった GPT-4o からは、トークナイザーが o200k_base になりました。辞書の大きさ(トークンの種類)はおよそ 2 倍です。 トークナイザー 使うモデル(tiktoken の対応表) 辞書の大きさ cl100k_base GPT-4、GPT-3.5 Turbo 100,277 o200k_base GPT-4o、GPT-4.1、o1、GPT-5 など 200,019 同じ日本語の文は、32 → 17 トークン に減りました(英語は 12 のまま)。「確認」も、まるごと 1 トークンです。 import tiktoken ja = "お忙しいところ恐れ入りますが、ご確認いただけますでしょうか。" for name in ["cl100k_base", "o200k_base"]: enc = tiktoken.get_encoding(name) print(name, len(enc.encode(ja))) cl100k_base 32 o200k_base 17 モデルごとに切り方が違う トークンの切り方は、モデル(トークナイザー)ごとに違います。早口言葉の「東京特許許可局」(7 文字)で、公開されているトークナイザーをくらべてみました。 トークナイザー トークン数 切れ方 GPT-4(cl100k_base) 10 [e6 9d] [b1] 京 特 [e8 a8] [b1] [e8 a8] [b1] 可 局 GPT-4o 以降(o200k_base) 6 東京 特 許 許 可 局 Qwen3 7 東 京 特 許 許 可 局 DeepSeek-V3 5 東京 特 許 許可 局 同じ会社のモデルでも、トークナイザーが変わることがあります。Anthropic は Claude Opus 4.7 で新しいトークナイザーを使い始め、同じ入力が内容によって 約 1.0〜1.35 倍 のトークンになることがあると案内しています。料金を見積もるときは、使うモデルで数えるのが確実です。 自分で数える OpenAI のモデルなら、tiktoken で数えられます。モデル名からトークナイザーを選ぶこともできます。 import tiktoken enc = tiktoken.encoding_for_model("gpt-4o") # o200k_base text = "お忙しいところ恐れ入りますが、ご確認いただけますでしょうか。" print(len(enc.encode(text))) # 17 どう切れたかを見る (バイトの途中で切れたトークンは文字にならない) print([enc.decode_single_token_bytes(t) for t in enc.encode(text)]) Claude なら、トークンを数える API(Token counting)があります。 import anthropic client = anthropic.Anthropic() r = client.messages.count_tokens( model="claude-opus-5-5", messages=[{"role": "user", "content": text}], ) print(r.input_tokens) ブラウザで試すなら、Tiktokenizer で切れ方を色分けして見られます。 豆知識: strawberry の r はいくつ? AI が strawberry の r の数を数え間違えることがある、という話があります。 o200k_base では、strawberry は st raw berry の 3 トークン(番号は 302、1618、19772)です。AI に渡るのはこの番号の列なので、1 文字ずつの並びは見えていません。 まとめ AI は文章を「トークン」で数える。料金も、一度に読める量もトークン トークナイザーの辞書は、よく出る並びをくっつけて作る(BPE) 辞書にない文字はバイトに割れる。漢字やひらがなは 1 文字 3 バイトなので、日本語はトークンが増えやすい 新しいトークナイザーほど日本語の差は縮まっている(GPT-4 で 32 → GPT-4o 以降で 17)。見積もりは使うモデルで数える 出典 openai/tiktoken(GitHub)(cl100k_base・o200k_base とモデルの対応は tiktoken/model.py) Tiktokenizer Qwen/Qwen3-0.6B(Hugging Face)、deepseek-ai/DeepSeek-V3(Hugging Face)(トークナイザーを手元で読み込んで数えた) Introducing Claude Opus 4.7(Anthropic) Token counting(Claude API ドキュメント) Neural Machine Translation of Rare Words with Subword Units(Sennrich ほか、2016)(BPE をトークン分割に使った論文) Language Models are Unsupervised Multitask Learners(Radford ほか、2019)(バイトレベル BPE) RFC 3629: UTF-8
MCPのツール定義は何トークン使うか|tiktokenで実測し、ツールを絞る基準を決める 2026-10-29時点の情報です。 本記事のコードと数値は、CAUCHYE ASIA が 2026-10-28 に Python 3.13.5 / tiktoken 0.14.0 で実行した結果です。公開日の 2026-10-29 に再実行し、同じ結果になることを確認しました。引用した公式ドキュメントの記述も、2026-10-29 に再確認しました。 結論(先に) 1ツールのトークン数は、パラメータ数と説明文の長さでほぼ決まります。合計はツール数に比例します。 英語の説明文200字・パラメータ3個のツールは、o200k_base で106トークンでした。パラメータが1個増えるごとに約13〜14トークン増えます。 同じ内容の説明文を日本語にすると、今回の1文の比較では、o200k_base で約2倍、cl100k_base で約3倍のトークンになりました。 架空サービスの10ツール(実際に近い形)では、平均115.6、最大143トークンでした。合計は1,156トークンです。 絞り込みの目安は、出どころが違う2種類を分けて置きます。業界標準ではありません。 本記事の実測と設計判断による目安: ツール定義の合計は、コンテキスト長の5%以内(5%は設計上の仮定。ツール数への換算が実測) 1ツールは150トークン以下(o200k_base・空白なしJSON。実測した10ツールの最大143を丸めた値) 公開資料を参考にした目安: 3. 1回の会話に見せるツールは10個前後から始める(Anthropic・OpenAI の公開ガイダンスの数字を参考にした、本記事の読み) この記事で扱うこと/扱わないこと 扱うこと: MCP の tools/list が返すツール定義を、tiktoken でトークン数に換算する手順 パラメータ数、説明文の長さ、ツール数、JSON の整形がトークン数に与える影響 実測値から、ツールを絞る基準を導く考え方 手元の tools/list を測るスクリプト 扱わないこと: 各社のツール検索や遅延読み込みの機能紹介 ツール実行の承認フロー ツールの返す結果を小さくする設計 測定の条件(トークナイザとその限界) 使ったのは次の環境です。 Python 3.13.5 tiktoken 0.14.0(pip で入れたローカルライブラリ) エンコーディングは o200k_base と cl100k_base tiktoken 0.14.0 の encoding_for_model を実行した結果は、次のとおりです。 gpt-4o o200k_base gpt-4.1 o200k_base o3 o200k_base gpt-5 o200k_base gpt-4 cl100k_base gpt-3.5-turbo cl100k_base gpt-5.4 o200k_base gpt-5.5 o200k_base gpt-6 ERROR KeyError Could not automatically map gpt-6 to a tokeniser gpt-4o・gpt-4.1・o3・gpt-5・gpt-5.4・gpt-5.5 が o200k_base、gpt-4・gpt-3.5-turbo が cl100k_base です。 これより新しいモデルについては、tiktoken の対応表にありません。tiktoken 0.14.0 では、tiktoken.encoding_for_model("gpt-6") は「Could not automatically map」のエラーになり、対応する encoding を取得できませんでした(2026-10-28、CAUCHYE ASIA が実行)。gpt-5.4・gpt-5.5 は tiktoken の接頭辞の対応で o200k_base と返りますが、これは tiktoken 側の対応表の結果で、OpenAI がそのモデルの実際のトークナイザを保証する情報ではありません。 次の限界があります。 Claude 系のトークナイザは公開されていません。ローカルでは正確に数えられないため、本記事の数値は Claude の実トークン数ではありません。 トークナイザが違えば数値は変わります。実際に、日本語では o200k_base と cl100k_base で大きく違いました(2節)。 測ったのは JSON テキストだけです。API に渡すと、各社の内部形式への変換や、ツール用のシステムプロンプトが加わります。 OpenAI が API 内部でツール定義に足すトークン数は、公開されている範囲では確認できず、不明です。Claude のツール用システムプロンプト(286トークン、モデルにより異なる)のような公表値がある場合も、本記事の数値には含めていません。 API キーを使うトークン数計測 API は使っていません。ローカル計測のみです。 実際の数は、実運用のリクエストで返る usage(入力トークン数)で確認してください。 数え方は次のとおりです。ツール定義1個を json.dumps(..., ensure_ascii=False, separators=(",", ":")) で空白なしの JSON にします。その enc.encode() の長さを数えました。チャット形式の付加分は含みません。 1. ツール定義の形(MCP tools/list) MCP 仕様 2026-07-28 の Tools によると、サーバーは tools/list でツール一覧を返します。結果は resultType: "complete" と tools、ページ分割用の nextCursor です。 Tool オブジェクトは次の項目を持ちます。 name(ツールを一意に識別する名前) title description icons inputSchema(JSON Schema。$schema がなければ 2020-12) outputSchema annotations 本記事の計測対象は name, title, description, inputSchema の4項目です。 実験用のツールは make_tool() で機械的に作りました。measure.py の抜粋です。 def make_tool(i, n_params, desc_chars, lang="en"): 基本文を繰り返し、ちょうど desc_chars 文字に切る desc = (BASE[lang] * (desc_chars // len(BASE[lang]) + 1))[:desc_chars] names = PARAM_NAMES[:n_params] props = {n: {"type": PARAM_TYPES[k], "description": f"Value for {n}." if lang == "en" else f"{n} の値。"} for k, n in enumerate(names)} return {"name": f"tool_{i:03d}", "title": f"Sample Tool {i}", "description": desc, "inputSchema": {"type": "object", "properties": props, "required": names[:min(n_params, 1)]}} def count(obj, enc, indent=None): text = json.dumps(obj, ensure_ascii=False, separators=(",", ":") if indent is None else None, indent=indent) return len(enc.encode(text)) 本文の表の数値を出したループと、10ツールの定義は省略します。 説明文は、英語の基本文 Search records in the sample service and return matching items. を繰り返し、指定した文字数で切っています。日本語版の基本文は「サンプルサービスの記録を検索し、条件に合う項目を返します。」です。 パラメータは query, limit, offset, sort, order, status, owner, created_after, created_before, include_archived, fields, format の先頭から n 個です。各説明は Value for <名前>.、required は先頭の1個です。 後半で使う架空サービスの10ツールのうち、1つ(get_ticket)を indent=2 で表示します。 { "name": "get_ticket", "title": "Get Ticket", "description": "Get one ticket by ID, including its status, assignee, labels and the latest comments. Use it after list_tickets when you need full details of a single ticket.", "inputSchema": { "type": "object", "properties": { "ticket_id": { "type": "string", "description": "Ticket ID, e.g. T-1024." } }, "required": [ "ticket_id" ] } } 実行は python measure.py です。実験 A〜F の結果と、sample_tools_list.json を保存します。 2. パラメータ数と説明文の長さ パラメータ数(実験A) 説明文200字・英語で、パラメータ数だけを変えました。単位は1ツールあたりのトークン数です。 n_params o200k_base cl100k_base 0 66 67 1 80 80 2 93 93 3 106 106 4 119 119 6 145 145 8 173 173 10 205 205 12 231 231 パラメータが1個増えるごとに、約13〜14トークン増えます。0個から12個までの平均は (231 − 66) / 12 ≈ 13.75 です。英語では o200k_base と cl100k_base はほぼ同じ値でした。 説明文の長さ(実験B) パラメータ3個で、説明文の字数を変えました。 desc_chars o200k_base/en o200k_base/ja cl100k_base/en cl100k_base/ja 0 70 72 70 73 50 80 109 80 128 100 88 145 88 183 200 106 219 106 290 400 140 363 140 503 800 209 652 209 931 1600 346 1232 346 1784 1字あたりのトークン数は、説明文0字の値を引いて1600で割ると出ます。 英語: (346 − 70) / 1600 ≈ 0.17 日本語(o200k_base): (1232 − 72) / 1600 ≈ 0.73 日本語(cl100k_base): (1784 − 73) / 1600 ≈ 1.07 同じ字数なら、日本語は英語の約4倍(o200k_base)、約6倍(cl100k_base)です。 ただし、字数は同じ意味の量ではありません。日本語は少ない字数で多くを表せます。そこで、同じ意味の1文を比べました(sentence.py 全文)。 """同じ意味の1文を英語と日本語で書いたときのトークン数を比べる。""" import tiktoken S = {"en": "Search records in the sample service and return matching items.", "ja": "サンプルサービスの記録を検索し、条件に合う項目を返します。"} for name in ["o200k_base", "cl100k_base"]: enc = tiktoken.get_encoding(name) for lang, s in S.items(): print(f"{name:12s} {lang} chars={len(s):3d} tokens={len(enc.encode(s)):3d}") 実行結果です。 o200k_base en chars= 63 tokens= 11 o200k_base ja chars= 29 tokens= 21 cl100k_base en chars= 63 tokens= 11 cl100k_base ja chars= 29 tokens= 31 英語63字は11トークン、日本語29字は o200k_base で21、cl100k_base で31です。この1文の比較では、日本語は約2倍(o200k_base)、約3倍(cl100k_base)でした。比べたのは1文だけなので、文章の種類が変わると倍率も変わります。 トークン数だけを見れば、モデルが読む description は英語で短く書く方が節約になります。ただし、英語にしたときのモデルの選択精度への影響は測っていません。 3. ツール数を増やすと パラメータ3個・説明文200字・英語のツールを N 個並べ、{"tools":[...]} で包んで数えました(実験C)。 N o200k_base 合計 o200k_base 1ツール cl100k_base 合計 cl100k_base 1ツール 1 110 110.0 109 109.0 5 534 106.8 529 105.8 10 1064 106.4 1054 105.4 20 2124 106.2 2104 105.2 30 3184 106.1 3154 105.1 50 5304 106.1 5254 105.1 100 10604 106.0 10504 105.0 200 21204 106.0 21004 105.0 1ツールあたり約106トークンでほぼ一定です。合計はツール数に比例します。包む部分 {"tools":[]} は4トークンでした。 JSONの整形でも数値が変わる 同じ20ツールを、空白なしと indent=2 で数え比べました(実験D)。 encoding compact(空白なし) indent=2 o200k_base 2124 3329 cl100k_base 2104 3329 o200k_base では約57%増です(3329 / 2124 ≈ 1.57)。インデントや改行がトークンになるためです。 測るときも、送るときも、どの形かで数値が変わります。本記事の数値は、すべて空白なしの JSON です。実際の送信形式は SDK や API の実装によります。差を確かめるには、usage を見るのが確実です。 4. 実際に近い10ツールで測る 機械的に作ったツールは、実際より素直です。そこで、架空のチケット・ファイル・予定・通知サービスの10ツールを書きました(実験E)。条件は次のとおりです。 説明文は80〜250字 パラメータは1〜5個 すべて英語 結果です。 tool o200k_base cl100k_base list_tickets 142 141 get_ticket 85 84 create_ticket 122 122 update_ticket 135 135 add_comment 91 91 search_files 109 109 read_file 91 91 list_events 120 118 create_event 143 142 send_notification 118 117 mean 115.6 115.0 min 85 84 max 143 142 total 1156 1150 平均は115.6、最小は85(get_ticket)、最大は143(create_event)、合計は1,156トークンです。実験Cの1ツールあたり106.0より、約1割大きい値です(115.6 / 106.0 ≈ 1.09)。 実サーバーの発表値との比較 Anthropic Engineering の「Introducing advanced tool use」(2025-11-24)は、次の例を紹介しています。 GitHub の MCP サーバー(35ツール)で約26Kトークン Slack(11ツール)で約21Kトークン 5サーバー58ツールで約55Kトークン 同じ出典の他のサーバー(Sentry 5ツール約3K、Grafana 5ツール約3K、Splunk 2ツール約2K)も含めて1ツールあたりに直すと、次のとおりです(いずれも「約」の値の割り算で、丸めの誤差を含みます)。 サーバー ツール数 発表値 1ツールあたり GitHub 35 約26K 約743 Slack 11 約21K 約1,909 Sentry 5 約3K 約600 Grafana 5 約3K 約600 Splunk 2 約2K 約1,000 これは Claude のトークナイザでの発表値です。本記事の o200k_base の数値とは直接比べられません。各サーバーの定義の中身(説明文の長さ、スキーマの入れ子、outputSchema の有無など)も、発表からは分かりません。 それでも、発表例の1ツールあたりは約600〜1,900で、本記事のサンプル(平均115.6)の約5〜17倍です。実サーバーでは、本記事のサンプルよりずっと大きい定義が珍しくない、という目安にはなります。自分のサーバーの値は、6節の方法で測ってください。 5. 予算に占める割合から、ツールを絞る基準を決める ツール定義は、会話の前に毎回入る固定費です。コンテキスト長に対する割合で見ます。平均115.6トークン/ツール(o200k_base)を使いました(実験F)。 割合以内に収まる最大ツール数 budget 5% 10% 8000 3 6 32000 13 27 128000 55 110 200000 86 173 ツール数ごとの予算の使用割合(%) budget N=10 N=30 N=50 N=100 8000 14.4 43.4 72.2 144.5 32000 3.6 10.8 18.1 36.1 128000 0.9 2.7 4.5 9.0 200000 0.6 1.7 2.9 5.8 小さいコンテキストでは、30ツールで43.4%(8K)、100ツールで144.5%(8K)になります。100ツールは8Kに収まりません。 本記事の基準 次の3つを基準にします。業界標準ではありません。基準1・2は本記事の目安、基準3は公開資料を参考にした目安です。 基準1:合計はコンテキスト長の5%以内 会話履歴、ツールの結果、システムプロンプトの余地を残すためです。5%という値は、実測から導いたものではなく、本記事の設計上の仮定です。実測値は、5%に収まる最大ツール数への換算に使っています。 128K なら 6,400トークン(128000 × 0.05)です。平均115.6なら55個まで(6400 / 115.6 ≈ 55.4)。 32K なら 1,600トークンで、13個です(1600 / 115.6 ≈ 13.8)。 基準2:1ツールは150トークン以下(o200k_base・空白なしJSON) 実測した10ツールの最大が143で、それを丸めた値です。サンプルは10ツールだけです。目安として、次の換算が使えます。 パラメータ1個 ≈ 14トークン 英語の説明100字 ≈ 17トークン パラメータ5個・英語の説明250字程度に収まる大きさです(実験Aの3個・200字の106に足すと、106 + 2 × 14 + 50 × 0.17 ≈ 142)。日本語の説明文はトークンが増えます。今回の1文の比較では約2〜3倍でした。 基準3:1回の会話に見せるツールは10個前後から始める これは、実測ではなく、公開資料の数字を参考にした目安です。公式が「最初に見せるのは10個」と書いているわけではありません。 Anthropic の「Introducing advanced tool use」によると、ツール定義が10Kトークンを超える、または10個以上のツールがある場合に Tool Search Tool が有効です。10個未満では効果が小さくなりやすいとされています。よく使う3〜5個は常に読み込んでおく、とも書かれています。 OpenAI の Tool search ガイドは、名前空間(namespace)ごとの関数を10個未満に抑えることを、トークン効率とモデルの性能のために推奨しています。なお、Responses API の tool_search は gpt-5.4 以降のモデルが対象です。 根拠の種類は区別してください。 基準1:5%は本記事の設計上の仮定です。実測は、5%に収まる最大ツール数への換算に使っています。 基準2:実測した10ツール(最大143)をもとに、本記事で丸めた値です。サンプルは10ツールだけです。 基準3:Anthropic と OpenAI の公開ガイダンスの数字(10個)を参考にした、本記事の読みです。 10個を超えるなら、絞り込みか遅延読み込みを検討します。 なお、上で見た公開例の大きなサーバー(1ツールあたり約600〜1,900)は、基準2の150トークンを大きく超えます。その場合は、基準1(合計の割合)と、ツール検索などの併用で考えてください。 6. 自分のツール定義を測る(count_file.py) 手元のサーバーの tools/list の応答を JSON ファイルに保存すれば、同じ方法で測れます。count_file.py 全文です。 """tools/list の JSON ファイルを読み、ツールごとのトークン数と予算に占める割合を出す。""" import json, sys import tiktoken path, budget = sys.argv[1], int(sys.argv[2]) if len(sys.argv) > 2 else 128000 data = json.load(open(path, encoding="utf-8")) tools = data.get("result", data)["tools"] enc = tiktoken.get_encoding("o200k_base") n = lambda o: len(enc.encode(json.dumps(o, ensure_ascii=False, separators=(",", ":")))) rows = sorted(((n(t), t["name"]) for t in tools), reverse=True) for tok, name in rows: print(f"{tok:6d} {name}") total = sum(t for t, _ in rows) print(f"tools={len(rows)} total={total} mean={total/len(rows):.1f} " f"budget={budget} share={total/budget*100:.2f}%") JSON-RPC の応答全体(result を持つ形)でも、tools を直接持つ形でも読めます。 先ほどの10ツールで、予算32,000として実行した結果です(python count_file.py sample_tools_list.json 32000)。 143 create_event 142 list_tickets 135 update_ticket 122 create_ticket 120 list_events 118 send_notification 109 search_files 91 read_file 91 add_comment 85 get_ticket tools=10 total=1156 mean=115.6 budget=32000 share=3.61% 予算を128,000にすると、末尾の行は次のとおりです(ツールごとの行は同じです)。 tools=10 total=1156 mean=115.6 budget=128000 share=0.90% 見るのは、合計と、上位のツールです。基準2(150トークン)を超えるツールは、説明文かパラメータを見直す候補です。合計が基準1(5%)を超えるなら、見せるツールを絞ります。 最終確認は、実運用のリクエストで返る usage で行います。Anthropic の Tool use overview によると、tools パラメータ(名前・説明・スキーマ)は入力トークンとして課金されます。加えて、ツール利用時は専用のシステムプロンプトが自動で入ります。例えば Claude Sonnet 5.5 / Opus 5.5 / Haiku 5.5 では、tool_choice が auto か none のとき 286 トークンです。実際のトークン数はレスポンスの usage に出る、とも書かれています。この286はモデルごとに違う値です。使うモデルの行を確認してください。 本記事の方法は、あくまで事前の見積もりです。 この計測の限界 数値は o200k_base と cl100k_base のものです。Claude の実トークン数ではありません。 測ったのは、空白なしの JSON テキストだけです。API 内部の形式変換とツール用のシステムプロンプトは含みません。 実験用ツールは機械的に作ったものです。10ツールのセットは架空のサービスで、実際のサーバーの代表ではありません。 説明文の例は英語の短い文だけです。日本語の約2倍・約3倍という値も、1文の比較です。文章の種類によって、1字あたりのトークン数は変わります。 説明文を英語にしたときのモデルの選択精度への影響は、測っていません。 5%と150トークンは本記事の設計上の目安で、10個は公開資料の数字を参考にした目安です。いずれも用途やモデルによって適切な値は変わります。 outputSchema、icons、annotations は計測対象に入れていません。これらを持つサーバーでは、値が増えます。 まとめ 1ツールのトークン数は、パラメータ数(1個あたり約13〜14)と説明文の長さで決まります。合計はツール数に比例します。 日本語の説明文は、今回の1文の比較では、o200k_base で約2倍、cl100k_base で約3倍でした。トークンだけを見れば、モデルが読む description は英語で短く書く方が節約になりますが、モデルの選択精度への影響は測っていません。 JSON の整形でも数値は変わります。o200k_base では、indent=2 が空白なしより約57%多くなりました。 本記事の目安は、合計がコンテキスト長の5%以内(設計上の仮定)、1ツール150トークン以下(本記事の実測から丸めた値)の2つです。これとは別に、公開資料の数字を参考にして、見せるのは10個前後から始めることを勧めます。 手元のサーバーは count_file.py で測り、最終確認は実運用の usage で行ってください。 参考 Model Context Protocol, Specification 2026-07-28 / Tools: modelcontextprotocol.io Anthropic, Tool use overview: platform.claude.com Anthropic Engineering, Introducing advanced tool use: anthropic.com OpenAI, Tool search: developers.openai.com OpenAI, tiktoken: openai/tiktoken 設計の考え方(見せるツールを絞る・必要なときに読み込む等)は、CAUCHYE ASIA の解説記事にまとめています。本記事は、その数字を手元で測る手順です。 cauchye.com
@kennethreitz42 @Bollish @theunipcs Even OpenAI’s own open-source project, "tiktoken", uses Python’s Requests library. 🐍 Built by Kenneth Reitz. Used across the Python ecosystem. $REQUESTS a real developer legacy. @kennethreitz42 github.com/openai/tiktoken/blob/main/tiktoken/loa…
A Bigger Context Window Is Not More Memory: Five Token Myths and a Budget Gate Your counter says 12k tokens. The API returns a 400. Which one is lying? Probably neither. They are counting different things. One of them is counting your string. The other is counting what the model actually receives. I keep seeing the same five assumptions in code review. Each one feels safe. Each one fails quietly, or fails loudly at 2 a.m. Here is the FAQ, the evidence you can collect yourself, and a small gate you can run in CI. Myth 1: "One token is about four characters" This is a rule of thumb for English prose. Vendors describe it that way. It is not a unit conversion. The ratio moves with content. Code, JSON, CJK text, emoji, and long identifiers all drift. Whitespace and punctuation get their own pieces. So does anything the tokenizer has never seen. Corrected model: characters are a hint. Tokens are what the model bills and truncates. You do not need to trust my ratio. Measure yours. Grab 20 real prompts from your logs. Count them with the tokenizer your provider actually ships. Then compare against len(text) / 4. Different tokenizers disagree with each other too. tiktoken encodings are one family (see the tiktoken repo). Other model families use other vocabularies. A prompt that fits under one tokenizer can overflow under another. So: which tokenizer does your budget check use? Myth 2: "The context window is one number" It is at least three numbers wearing a trench coat. - Input budget. What the prompt may consume. - Output budget. What the answer may consume. Often a separate parameter with its own ceiling. - Template overhead. Role markers, separators, and tool schemas. Not free. Not zero. Input plus output usually share one window. Reserve the answer before you count the prompt. Most overflow bugs I read are really "nobody subtracted max_tokens". Tool schemas are the sneaky part. A generous function signature block can cost more than your whole user message. It is sent on every turn. Corrected model: budget = window − reserved output − template overhead. Then subtract tool schema cost, every turn. Myth 3: "More context means better recall" Long-context evaluations keep reporting uneven recall by position. The well-known result is "Lost in the Middle". Details placed mid-prompt get retrieved less reliably than details at the edges. That is not a reason to panic. It is a reason to stop stuffing. Corrected model: context is capacity, not attention. A 200k window does not mean 200k of useful signal. Placement and redundancy matter more than volume. Practical translation: put the constraint you cannot afford to lose near the top. Put the task near the bottom. Do not bury the schema between two chatty tool results. Myth 4: "Dropping old messages is always safe" Old messages carry two things. Conversation, and contract. When you slice messages[2:], you may delete the system constraints. You may also orphan a tool call. Many APIs reject an assistant tool_calls block that has no matching tool result, or the reverse. That is the 400 you cannot reproduce locally. Your unit test never had the tool pair in the middle. Corrected model: truncation is a structural edit, not a string edit. Validate pairing after every cut. Myth 5: "If the API accepted it, the model saw all of it" Sometimes something upstream trims. A proxy, a gateway, a retry wrapper, an SDK default. The request succeeds. The answer misses the paragraph you cared about. You cannot detect that from the response alone. You can detect it from the request you sent. Corrected model: log the estimate at send time. If the answer looks blind, diff the budget against the prompt you thought you sent. The gate: one file, standard library only Here is the artifact. It estimates prompt cost, reserves answer space, and checks tool-call pairing. It exits non-zero when the prompt does not fit. Treat the built-in counter as a conservative estimate. Swap in a real tokenizer when you have one. #!/usr/bin/env python3 """context_gate.py - fail fast when a prompt no longer fits the budget.""" import json import re import sys from dataclasses import dataclass def estimate_tokens(text: str) -> int: # Deliberately conservative placeholder. Never exact. Swap in a real tokenizer. words = len(re.findall(r"\w+|[^\w\s]", text)) chars = (len(text) + 3) // 4 return max(words, chars) @dataclass class Budget: context: int # total window, from the provider docs reserve: int # space kept for the answer overhead: int = 8 # role markers / separators per message def count_prompt(messages, budget: Budget, counter=estimate_tokens) -> int: total = 0 for m in messages: total += counter(str(m.get("content") or "")) + budget.overhead for call in m.get("tool_calls") or []: total += counter(json.dumps(call.get("function", {}))) + budget.overhead return total def unpaired_tool_calls(messages): pending, problems = [], [] for i, m in enumerate(messages): for call in m.get("tool_calls") or []: pending.append(call.get("id")) if m.get("role") == "tool": cid = m.get("tool_call_id") if cid in pending: pending.remove(cid) else: problems.append(f"message {i}: result for unknown call {cid}") problems += [f"call {c}: no result" for c in pending] return problems def check(messages, budget: Budget, counter=estimate_tokens): used = count_prompt(messages, budget, counter) allowed = budget.context - budget.reserve return { "prompt_tokens_est": used, "prompt_budget": allowed, "answer_reserve": budget.reserve, "fits": used <= allowed, "pairing_problems": unpaired_tool_calls(messages), } if __name__ == "__main__": path, context, reserve = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]) with open(path) as f: payload = json.load(f) report = check(payload["messages"], Budget(context=context, reserve=reserve)) print(json.dumps(report, indent=2)) sys.exit(0 if report["fits"] and not report["pairing_problems"] else 1) Run it against a captured request: python3 context_gate.py prompt.json 32768 1024 A failing run looks like this: { "prompt_tokens_est": 31895, "prompt_budget": 31744, "answer_reserve": 1024, "fits": false, "pairing_problems": [] } The 32768 is a flag you supply. Read the real number from your provider's docs. Do not copy mine. Run it before the request, not after 1. Capture one real request body as JSON. Keep it as a fixture. 2. Add your two hardest cases: a large tool schema, and a sliced history. 3. Run the gate on both. Fix the budget, not the fixture. 4. Wire it into CI as a preflight step. 5. Log prompt_tokens_est next to every response you store. Step 5 is the one people skip. Then the incident review has no numbers. I run this preflight on MonkeyCode's free server option so the check travels with the job instead of living on my laptop. I also point it at free model access to compare estimates across more than one tokenizer family. That comparison is the whole point — one counter proves nothing. Disclosure: This article was prepared as part of MonkeyCode's product outreach. Cut-first decision table When the gate says no, do not just slice. Pick the cheapest effective edit. | Symptom | First cut | Why | | --- | --- | --- | | Tool schema dominates | Prune optional params, shorten descriptions | Paid every turn, not once | | Long history, short task | Summarize turns older than N | Keeps the contract intact | | One huge document | Move to retrieval, pass top-k | Placement beats volume | | Duplicated instructions | Keep one canonical system block | Redundancy is not safety | | Answer keeps getting cut | Raise reserve before trimming prompt | Reserve is the real ceiling | Limitations and who should skip this - The built-in counter is an estimate. It will not match provider billing. Use the provider's tokenizer when precision matters. - Windows, reserves, and limits change. Read current docs. This gate reads numbers you pass in, never assumptions. - For multi-megabyte corpora, this is the wrong tool. Use retrieval instead. - If your prompts are short and single-turn, a gate is overhead. Skip it. - This checks shape and budget, not answer quality. A prompt can fit and still be wrong. The takeaway Context is a budget with a shape. Tokens vary by tokenizer. Output competes with input. Placement beats stuffing. Truncation is structural. And anything upstream can trim silently. So the next time a 400 lands on your desk, what will you show the reviewer: a guess about characters, or a gate that printed the number?
Rust Didn't Win Because It's Fast GitHub rewrote Copilot's runtime in Rust. OpenAI's Codex is now almost all Rust. Bun, the runtime Claude Code ships on, moved 535,496 lines of Zig to Rust in 11 days. But it isn't about speed: C has been fast for half a century. We fly inside a real laptop, down to the CPU die and the memory cells, to show what Rust actually changed, and why AI coding agents love it. 0:00 Everyone is rewriting in Rust 0:49 This is Sidequest Explained 1:00 Inside the machine: instructions, bytes, stack and heap 1:53 Way one: freeing memory by hand (C and C++) 2:23 An elevator crash started Rust 2:40 Way two: the garbage collector (Discord's spikes) 3:12 Way three: ownership 3:36 Checked before it runs, and Android's results 4:07 Close to the metal: 28 bytes vs 8 4:21 Copilot: 5.25 s to 292 ms 4:41 Why AI agents love the Rust compiler 5:11 Is Python dead? What it means for you Sources GitHub Blog, migrating the Copilot runtime to Rust (Sep 2026): github.blog Bun, Rewriting Bun in Rust (Jul 2026): bun.sh OpenAI Codex repository: openai/codex Codex native rewrite discussion: codex#1174 Grok Build repository: xai-org/grok-build Dennis Ritchie, The Development of the C Language: bell-labs.com Microsoft MSRC, 70% of security bugs are memory safety (2019): microsoft.com Chromium memory safety: chromium.org MIT Technology Review, Rust's origin (2023): technologyreview.com Rust 1.0 announcement: blog.rust-lang.org Discord, Why Discord is switching from Go to Rust (2020): discord.com The Rust Book, ownership and borrowing: doc.rust-lang.org Google, memory-safe languages in Android 13 (2022): security.googleblog.com Google, Rust in Android (Nov 2025): blog.google Python sys.getsizeof / CPython ints: docs.python.org Hugging Face tokenizers: huggingface/tokenizers OpenAI tiktoken: openai/tiktoken pydantic-core: pydantic/pydantic-core uv by Astral: astral-sh/uv Numbers are the sources' own. The Copilot timings are client + session + one-turn overhead in the opt-in in-process mode, not counting the model or network. Android's 1000× is memory-safety vulnerability density. Code on screen is an illustration, not a run. Credits Die micrographs: Fritzchens Fritz (CC0). Memory-cell micrograph (Micron MT4C1024) and SEM plate (1886VE10): ZeptoBars, CC BY 3.0. Office and hardware photos: Pexels. HDRIs and materials: Poly Haven, ambientCG (CC0). Music: "Lofi Study" by FASSounds (Pixabay). Rust, GitHub, Copilot, OpenAI, Codex, Bun, Claude, xAI, Grok, Microsoft, Chrome, Android, Discord, Python, Hugging Face, Astral, pydantic and other names and logos belong to their owners, used for identification and commentary; Sidequest is not affiliated with any of them. Narration: an AI clone of my own voice. Animation: original, built in three.js. #rust #programming #ai
Токеномика для самых маленьких Кажется, мы довольно быстро привыкли к тому, что с нейросетью можно разговаривать почти как с человеком. Попросить написать код, пересказать статью, разобраться в документах. Потом уточнить. Потом ещё раз уточнить, потому что она опять сделала что-то не то. А где-то между «почти готово» и «ты зачем вообще это переписал?» заканчивается лимит. А иногда - ещё до первого ответа. Отправляешь запрос в Claude, он долго думает. Потом ещё думает. Ты уже начинаешь подозревать, что там решают что-то на уровне устройства Вселенной, и наконец получаешь уведомление: лимит закончился. Ответа пока нет. Зато размышление получилось основательное. Особенно интересно это выглядит с ИИ-агентами: отправил короткую задачу, отвлёкся, вернулся - агент прочитал половину проекта, запустил тесты, починил что-то по соседству и написал подробный отчёт о своих приключениях. Всё это, конечно, за ваши токены - этот господин уплатит за всё. Немного похвалюсь: у меня самого с лимитами достаточно неплохо - не жалуюсь. При том что регулярно и очень активно пользуюсь ИИ для работы, и личных задач, и просто поржать. На нынешней подписке в $100 ещё ни разу не упирался в лимит, и кнопка сброса лимитов мне ни разу не понадобилась: просто не успевал их потратить. А некоторые друзья жалуются, что им не хватает даже подписки за $200. Конечно, и $100 за подписку кажется многовато. Но тарифа за $20 для моего режима уже не всегда достаточно: кроме обычных диалогов есть фоновые процессы, периодические задачи и прочая работа, которая выполняется без моего постоянного участия. Разбаловал меня ИИ. Поэтому приходится раскошеливаться. Конечно, задачи и объём работы у всех разные. Но мне стало интересно разобраться, сколько ресурсов уходит на полезный результат, а сколько - на то, как мы вообще организовали общение с моделью. Поэтому сегодня немного токеномики (не про NFT щиткоины, извините за байт в заголовке). Разберёмся, что такое токены, почему короткое сообщение иногда обходится неожиданно дорого и на чём действительно имеет смысл экономить. Что вообще такое токен? Да вы и так уже хорошо представляете, но хочется проговорить основные понятия, чтобы не было недопониманий в дальнейшем. Для нас сообщение - это текст. Для языковой модели его сначала превращают в последовательность числовых идентификаторов. Каждый такой идентификатор соответствует токену: это может быть слово, часть слова, пробел вместе со словом, знак препинания или фрагмент байтов. Этим занимается отдельный компонент - токенизатор. На выходе модели происходит обратное преобразование: последовательность токенов снова становится понятным нам текстом. Это и есть входные (input) и выходные (output) токены соответственно. Почему нельзя просто читать по буквам? Можно строить и такие системы. Но последовательности получаются длинными. Если же завести отдельный токен для каждого возможного слова, довольно быстро начнутся вопросы к размеру словаря, новым словам, опечаткам и всем языкам мира. Разбиение на части - компромисс между длинными последовательностями и огромным словарём. При этом токенизатор не обязан соблюдать школьную морфологию. Он не обещал делить слова на корни и суффиксы. У него свои представления о прекрасном, основанные на алгоритме и данных, на которых строился словарь. Например, в кодировках cl100k_base и o200k_base из библиотеки tiktoken получается так: Текст Разбиение Токенов hello hello 1 Hello Hello 1 HELLO HEL + LO 2 hello hello, вместе с пробелом 1 Одно и то же слово, но заглавные буквы поменяли количество токенов. А пробел вообще уехал внутрь токена. Поэтому правила вроде «один токен - это четыре символа» можно использовать только для грубой прикидки на подходящих текстах. Посчитать точно помогает токенизатор конкретной модели. А русский правда дороже Разные языки действительно могут занимать разное количество токенов при передаче одного смысла. Это исследовали, например, авторы работы Language Model Tokenizers Introduce Unfairness Between Languages. Помимо того, что русский язык великий и могучий, за что тоже приходится платить токенами, есть и более техническая причина. При обучении токенизатора на основе BPE часто встречающиеся фрагменты текста объединяются в токены. Если английского в обучающих текстах заметно больше, его слова и части слов получают больше готовых «кирпичиков» в словаре. Русские слова при этом могут разбиваться на более мелкие кусочки. А разнообразие окончаний и словоформ добавляет вариантов, которые тоже нужно уместить в словарь. Поэтому один и тот же смысл на русском нередко занимает больше токенов. Но универсальный курс «одно русское слово равно трём токенам» устанавливать рано. Вот две фразы: Write a Python function that returns the sum of two numbers. Напиши функцию на Python, которая возвращает сумму двух чисел. И результат подсчёта через tiktoken 0.14.0: Кодировка Английский Русский Отношение RU / EN cl100k_base 12 23 1,92 o200k_base 12 14 1,17 Текст не изменился, поменялся токенизатор - и разница сократилась с почти двукратной до примерно 17%. Это подсчёт двух конкретных строк, без служебных сообщений приложения. Если вы свободно формулируете задачу на английском, можно проверить такой вариант. Но если ради экономии вам приходится дольше переводить запрос и вы теряете важные оттенки смысла, стоит посчитать ещё и стоимость собственного времени. Я, кстати, часто общаюсь с LLM через переводчик, если собственного английского не хватает. И токены пытаюсь сэкономить, и язык подтягиваю. Куда уходят токены между сообщением и ответом Когда вы пишете «а теперь добавь тесты», модель обычно получает гораздо больше этих четырёх слов. Во входе могут оказаться системные инструкции, правила проекта, предыдущие сообщения и ответы, содержимое файлов, результаты поиска, описания инструментов. Приложение собирает из этого контекст, с которым модель будет работать на очередном шаге. Контекстное окно - это ограничение на объём информации, доступной модели в одном вызове. А расход за всю задачу складывается из множества вызовов. Поэтому агент вполне может потратить миллион токенов, ни разу не имея миллион токенов в контексте одновременно. Модель также может расходовать токены на внутреннее рассуждение, которое не целиком показывается пользователю. Например, у Claude thinking-токены учитываются как выходные - это описано в документации контекстного окна. Короткий видимый ответ ещё не означает, что вся работа была короткой. При оплате через API у входных, выходных и кэшированных токенов могут быть разные ставки. Выход у многих моделей дороже обычного входа. Поэтому убрать тысячу токенов из промпта и сократить ответ на тысячу токенов - не обязательно одинаковая экономия в деньгах. С агентами добавляется ещё один слой. Попросить прочитать файл - один шаг. Получить его содержимое и решить, что делать дальше, - следующий. Потом поискать соседний файл, посмотреть документацию, запустить команду, изучить ошибку. Между этими действиями модель вызывается снова и снова. И вот уже задача «исправь пару строк» превращается в маленькую экспедицию. Почему длинные диалоги обходятся дорого В обычной схеме работы с LLM следующий вызов получает предыдущую переписку, чтобы модель понимала, что происходило раньше. Приложение может обрезать историю, делать выжимки или выбирать только её часть, но сама история от этого бесплатной памятью не становится. Давайте посчитаем на условном примере. Каждый ваш запрос занимает 1000 токенов, каждый ответ - ещё 1000. Вся переписка сохраняется, служебные инструкции и инструменты пока уберём из расчёта. Номер обращения Вход в этом вызове Всего токенов за все обращения 1 1 000 2 000 5 9 000 30 000 10 19 000 110 000 20 39 000 420 000 На двадцатом обращении вы добавляете очередную тысячу токенов, а во входе уже 39 тысяч. При наших условиях суммарный расход за n обращений составляет 1000 × n × (n + 1). Это арифметика повторной передачи истории, без учёта кэширования и её сокращения. Переводить эту таблицу в деньги напрямую нельзя. Тут приходит кэш У провайдеров есть prompt caching: повторяющийся начальный фрагмент запроса может использовать уже подготовленное внутреннее состояние модели. В подходящем случае это уменьшает вычисления и стоимость обработки входа. Например, Anthropic отдельно тарифицирует создание кэша и его чтение. Ставки, срок хранения и условия попадания зависят от модели и настройки. Главное здесь - повторяющаяся часть контекста может стоить дешевле обычного входа. Поэтому большой контекст не обязательно целиком пересчитывается по полной цене на каждом шаге. Но кэш не делает его полезнее и не увеличивает контекстное окно. Он также не гарантирует, что конкретное приложение удачно им пользуется. Для разработчика API-приложения отсюда следует практический вывод: стабильные инструкции и общие материалы удобно держать в начале, меняющуюся часть - после них. Кэширование префикса требует совпадения предшествующего содержимого. Подписка и API: сравнение цен А для пользователя чата полезно просто помнить, что счётчик токенов, денежная стоимость и лимиты подписки - три разных показателя. Правила конкретной подписки нужно смотреть отдельно. Несколько месяцев назад я увидел такую таблицу со сравнением цены подписки и стоимости сопоставимого объёма работы через API. Цифры внушительные: тысячи долларов токенами за пару сотен. Исторический срез за июнь 2026 года. Справа - оценка расхода в ценах API при исчерпании лимитов, а не баланс подписки или себестоимость работы модели. Как именно они тогда замеряли расход, я, честно, не знаю. Зато в более свежем исследовании SemiAnalysis от 5 октября 2026 года методика уже описана: авторы измеряли, как разные типы токенов расходуют лимит подписки, а затем пересчитывали доступный объём в API-цены для выбранной модели и нагрузки. Получилось примерно так: Подписка Модель в расчёте Цена в месяц Оценка объёма за месяц в API-ценах Отношение к цене подписки Claude Pro Opus 5.5 $20 ≈ $1 178 ≈ 59 раз Claude Max 5x Opus 5.5 $100 ≈ $5 725 ≈ 57 раз Claude Max 20x Opus 5.5 $200 ≈ $11 726 ≈ 59 раз ChatGPT Plus GPT-6.1 Sol $20 ≈ $211 ≈ 11 раз ChatGPT Pro GPT-6.1 Sol $100 ≈ $1 055 ≈ 11 раз ChatGPT Pro GPT-6.1 Sol $200 ≈ $2 084 ≈ 10 раз Оценки SemiAnalysis на 5 октября 2026 года при полном использовании лимитов и заданном составе агентной нагрузки. Это не гарантированный баланс подписки. Отношения округлены. Здесь есть существенная деталь: около 96,6% токенов в расчёте приходится на чтение кэша. Если у вас другое соотношение входа, выхода и кэширования, получится другая сумма. Да и модели в двух половинах таблицы разные: качество результата и количество попыток она не сравнивает. Поэтому выбирать победителя только по последней колонке я бы не стал. Сами цены тоже проверил 25 октября 2026 года. У Claude при помесячной оплате это по-прежнему $20 за Pro, $100 за Max 5x и $200 за Max 20x. У ChatGPT - $20 за Plus и варианты Pro за $100, $200 и $500. Последнего в сравнении выше нет. Итоговая цена покупки может зависеть от региона, налогов и способа оформления. Ещё одна отдельная история: в Claude Max теперь входят ежемесячные API-кредиты на $100 или $200 соответственно. Их нужно активировать, связав подписку с организацией в Console; новым подписчикам это доступно через семь дней. Эти кредиты не увеличивают лимиты чата и не оплачивают дополнительное использование Claude Code, а остаток не переносится на следующий расчётный период. С тысячами долларов из таблицы их смешивать не нужно. Подписка действительно может оказаться выгодной при активном использовании. Но если за месяц вам понадобилась пара коротких запросов, потенциальные тысячи долларов так и останутся потенциальными. Для своих задач сравнивать варианты лучше по фактическому расходу и полученному результату. Большое контекстное окно ещё не решает всё Кажется, решение очевидное: давайте дадим модели огромное окно и загрузим туда вообще всё. Переписку за год, кодовую базу, документацию, инструкцию к аэрогрилю. Но техническая возможность поместить информацию в контекст не означает, что модель одинаково хорошо воспользуется каждой его частью. В исследовании Context Rot команда Chroma проверила 18 моделей и обнаружила, что с ростом входного контекста надёжность выполнения задач может ухудшаться. Причём результат зависит от модели, задачи и устройства самого текста. Размер окна из рекламной таблицы этого не показывает. Поэтому я бы не ориентировался на магическую границу вроде «до 100 тысяч токенов всё хорошо, потом всё плохо». Иногда полезен большой объём исходных данных. Иногда десять страниц посторонних инструкций только мешают найти нужное. Хороший вопрос перед добавлением очередного файла: какая информация из него нужна для текущего решения? На чём я бы экономил в первую очередь Можно начать с удаления каждого «пожалуйста». Но если рядом лежит лог на двадцать тысяч строк, экономия получится довольно своеобразная. Мне кажется разумным сначала посмотреть на крупные источники расхода: ненужные документы, накопленную историю, повторные исследования и исправления неудачных ответов. Уже потом заниматься отдельными словами. Как сформулировать понятную задачу Здесь хочу сослаться на работу моего коллеги из Яндекса - Юрия Зеленкова, доктора технических наук и компьютерного лингвиста. На основе его исследования я составил публичную версию руководства «Две грамматики за один вечер» о промптах и скиллах. Одна из полезных идей оттуда: запросу нужен минимально достаточный контракт. Что должно получиться, какие условия действительно важны и как проверить результат. Например: Сделай сервис для заказов. Что именно? Класс в существующем приложении? Отдельный микросервис? HTTP API? Мы с вами можем представлять разные вещи. Модель тоже что-нибудь представит и начнёт делать. А можно так: В существующем Django-приложении добавь метод OrderService.cancel_order(order_id). Отменять можно только заказы в статусе NEW. Для остальных верни существующую доменную ошибку. Ориентируйся на соседние методы и добавь тесты для обоих случаев. Запрос стал длиннее. Зато из него исчезло несколько развилок, по которым агент мог уйти совсем в другую сторону. В Grammar.md разбираются ещё две похожие ловушки. Первая - скрытое допущение: вопрос «почему мой код медленный?» уже предполагает, что он действительно медленный. Сначала полезно попросить проверить саму проблему. Вторая - подразумеваемые требования: под «production-ready» каждый понимает свой набор тестов, логирования, метрик и ещё десятка прекрасных вещей. Чем раньше существенные условия становятся явными, тем меньше поводов возвращаться с сообщением «я вообще-то имел в виду другое». При этом не нужно превращать любой вопрос в должностную инструкцию на три страницы. Для перевода одной фразы достаточно попросить перевести одну фразу. Когда ничего не работает Отдельный жанр общения с нейросетями начинается после нескольких неудачных попыток: Ты дебил? Ничего не работает, всё переделывай, идиот. Или знаменитое: Make no mistakes. Раздражение вполне понятно. Особенно если до этого модель уже трижды сообщила, что всё исправлено. Но что из нового сообщения она узнает о самой проблеме? Оскорбления сообщают, что пользователь зол. Они занимают токены и остаются в истории, но не объясняют, какой тест упал, где ошибка и что должно было произойти. Если эта ветка диалога продолжится, эмоциональную часть можно будет передавать модели ещё не раз. И тут, помимо того, что вы повышаете свои шансы быть съеденным первым при восстании машин, вы ещё и не объясняете агенту, что именно нужно исправить. «Ничего не работает» тоже довольно трудно превратить в конкретное действие. Не запускается приложение? Падает один запрос? Кнопка не того цвета? А «всё переделывай» вдобавок расширяет область задачи. Вместо проверки одного проблемного места агент может начать заново исследовать проект, менять соседние модули и, не дай бог, переписывать всё с нуля на Rust. С пресловутым «make no mistakes» похожая история. Корректный результат и так входит в задачу. Но фраза не объясняет, что именно считать ошибкой и как её обнаружить. В зависимости от модели и окружения она может вылиться в дополнительные проверки, а насколько они окажутся полезны - отдельный вопрос. Гораздо больше информации содержит такое сообщение: При отмене заказа в статусе PAID метод cancel_order возвращает успех, хотя должен выбрасывать существующую доменную ошибку. Проверь условие отмены и добавь тест на этот случай. Теперь есть конкретное место, наблюдаемое поведение, ожидаемый результат и способ проверки. Агенту проще понять, откуда начинать и когда можно остановиться. Нам важно передать суть, а не обмениваться состояниями, как говорил... неважно кто. Как выбрать формат ответа Если вам нужна команда для терминала, можно попросить команду. Если список трёх проблем - три проблемы с объяснениями. Если разбор устройства event loop - объяснение, примеры и подробности будут вполне уместны. Модель может написать длинное вступление, предложить пять альтернатив и закончить приглашением продолжить. Иногда это полезно. Иногда вам нужен был только SQL-запрос. Формат стоит задавать под следующего потребителя. Для программы удобна схема. Для человека - короткий список или связный текст. И JSON не становится короче просто потому, что выглядит технически серьёзнее. Вот ещё один подсчёт тех же кодировок: Представление cl100k_base o200k_base Москва, 12 °C, ясно. 14 9 {"city":"Москва","temperature_c":12,"condition":"ясно"} 19 15 В JSON появились имена полей и синтаксис. Для машинной обработки это может быть отличным обменом. Для ответа человеку схема здесь просто добавляет токены. И ещё: требование «отвечай кратко» лучше понимать как просьбу убрать лишнее. Если вместе со вступлением исчезли условия применимости ответа, мы сэкономили на довольно важном месте. Тут очень хочется ещё рассказать про новые модели System One для быстрых решений и извлечения данных в заданном формате, но, кажется, мы и так уже сильно выходим за пределы заявленной темы. Да и я писал о них в своём канале здесь. Там же и ссылки на хорошие статьи и видео на эту тему. Не смешивать всё в одном чате Диалог, в котором сначала выбирали аэрогриль, потом обсуждали переезд, а потом начали писать Python-сервис, постепенно превращается в содержимое ящика с проводами. Всё вроде когда-то было нужно, но попробуй теперь найти подходящее. Для самостоятельной новой задачи обычно удобнее отдельный чат. А если продолжается большая работа, стоит передать её состояние: цель, уже принятые решения, важные ограничения, нужные файлы и то, что осталось сделать. «Продолжи» без этой информации заставит модель восстанавливать картину. Хорошая выжимка помогает начать с нужного места. Но и она может потерять деталь, поэтому исходники и существенные решения лучше сохранять в доступном виде. Не нужно при этом открывать новый чат после каждой реплики. Если вы всё ещё разбираете одну проблему и предыдущий результат нужен для следующего шага, этот контекст выполняет полезную работу. Как вовремя исправить направление Если модель неверно поняла исходное сообщение, а интерфейс позволяет его отредактировать или вернуться к предыдущему состоянию, этим стоит пользоваться. Так ошибочная ветка не будет участвовать в дальнейшей работе. При работе с кодом важно различать историю разговора и состояние файлов. Удалённый из чата ответ не обязательно отменяет изменения на диске. Нужно понимать, что именно откатывает ваш инструмент. Уже потраченные токены никто не вернёт. Польза отката - в следующих шагах: агент продолжит от подходящего состояния и не будет опираться на отброшенное решение. В Claude Code есть и отдельная команда /btw для побочных вопросов. Она использует текущий контекст, но не добавляет вопрос и ответ в основную историю. Запрос всё равно требует работы модели; полезно здесь именно то, что побочная тема не разрастается в основном диалоге. Отправлять нужную информацию Если ошибка находится в нескольких строках лога, полезно начать с них и достаточного окружения. Если нужна проверка одного пункта договора - найти этот пункт и связанные условия. Если задача зависит от всего документа, придётся дать весь документ или организовать поиск по нему. Иначе легко получить другую крайность: удалить столько контекста, что модель начнёт запрашивать его по кусочкам или просто додумает отсутствующее. С файлами имеет значение и способ чтения. Например, при штатной обработке PDF Claude получает текст и изображения страниц. Если нужны только текстовые положения, извлечение текста может убрать ненужную визуальную часть. Но если задача про графики, расположение блоков или содержимое схем, она как раз нужна. Поэтому «переведи всё в Markdown и сэкономишь половину» - слишком смелый рецепт. Нужно понимать, что приложение извлекает из файла и какая часть этого нужна вам. Особенности работы с агентами С обычным чатом мы в основном управляем тем, что отправляем сами. Агент ещё и самостоятельно добывает информацию. Причём иногда с таким энтузиазмом, что невольно хочется спросить: а мы точно всё это заказывали? И многое здесь зависит от того, что мы ему подключили и какие правила написали. Постоянные инструкции и подробности по необходимости В CLAUDE.md, AGENTS.md и похожие файлы удобно складывать правила проекта. Но если туда постепенно переехала вся внутренняя Википедия, агент будет получать её даже для небольшой задачи. Хорошая постоянная инструкция помогает найти нужное: команды проверки, важные особенности проекта, ссылки на локальные правила. Подробности миграций базы не обязательно нужны при изменении текста кнопки. Для повторяемых процедур подходят скиллы. Например, в Claude Code заранее доступны их названия и описания, а основной текст загружается при использовании. Так большую инструкцию можно доставать по необходимости. Но один перенос текста в соседний файл ничего не гарантирует. Если основной файл автоматически импортирует его целиком, контекст останется большим. Важен момент фактической загрузки. Сначала найти нужное Для изменения одного метода обычно не требуется читать каждый файл репозитория. Карта проекта, поиск по символам, эталонная реализация и точный путь помогают быстрее сузить область работы. То же относится к результатам инструментов. Для проверки количества ошибок иногда достаточно агрегата. Для расчёта суммы по таблице - вычисления в коде. Нет большой необходимости отправлять языковой модели десять тысяч строк, чтобы она сама их складывала. В статье Code execution with MCP Anthropic показывает похожую организацию работы: агент загружает нужные инструменты и обрабатывает данные кодом, прежде чем возвращать результат в свой контекст. Отдельный источник расхода - описания самих инструментов. В некоторых реализациях они загружаются сразу, в других находятся по необходимости. Поэтому стоит проверять, что реально попало в контекст, а не считать любой подключённый MCP-сервер одинаково дорогим. Субагенту тоже нужно платить Субагент может прочитать длинную документацию, выполнить исследование и вернуть основной модели короткий результат. Это полезно: подробности остаются в отдельном контексте, а основной агент продолжает работу с выжимкой. Но из этого не следует, что суммарных токенов стало меньше. У субагента были собственные инструкции, чтения, вызовы и ответы. Всё это тоже нужно учитывать. Anthropic в описании своей многоагентной исследовательской системы прямо пишет о повышенном расходе таких архитектур. За дополнительные возможности приходится платить. Для большой самостоятельной подзадачи это может быть оправдано. Создавать целый отдел агентов ради переименования переменной, пожалуй, всё-таки избыточно. Хотя организационная структура получится солидная. План и проверки должны помогать задаче Если изменение затрагивает несколько сервисов, предварительный план позволяет обнаружить неверное направление до реализации. А понятные критерии проверки помогают агенту заметить проблему до того, как вы начнёте разбираться с результатом. Но для очевидной локальной правки отдельная архитектурная сессия может оказаться лишней. План тоже занимает время и токены. Я бы перед этим спросил себя: этот шаг поможет избежать существенной переделки или я добавил его просто потому, что так принято работать с агентами? Немного измерений вместо обещаний Давайте вернёмся к обычному промпту. Вот два варианта простой задачи. Первый: Привет! Помоги мне, пожалуйста, написать функцию на Python, которая принимает два числа и возвращает их сумму. Нужен только код функции без пояснений. Заранее спасибо! Второй: Python: функция add(a, b) возвращает сумму двух чисел. Только код, без пояснений. Вариант cl100k_base o200k_base Первый запрос 70 39 Второй запрос 32 22 Входной текст сократился примерно на 54% и 44% соответственно. Это результат подсчёта этих строк. Чтобы посчитать стоимость всей задачи, нужно учесть ещё и ответ, служебные инструкции и другие данные, которые добавит приложение. В длинной агентной сессии разница в несколько десятков токенов может быть незаметна на фоне документов и результатов инструментов. А в приложении с большим количеством коротких обращений такие сокращения уже стоит измерить на реальной нагрузке. Для воспроизведения достаточно установить tiktoken==0.14.0 и выполнить: import tiktoken texts = [ "Привет! Помоги мне, пожалуйста, написать функцию на Python, " "которая принимает два числа и возвращает их сумму. " "Нужен только код функции без пояснений. Заранее спасибо!", "Python: функция add(a, b) возвращает сумму двух чисел. " "Только код, без пояснений.", ] for name in ("cl100k_base", "o200k_base"): enc = tiktoken.get_encoding(name) print(name, [len(enc.encode(text)) for text in texts]) sourcecraft.dev А чтобы проверить сохранение качества на содержательной задаче, одного токенизатора мало. Нужны одинаковые исходные данные, одна модель с одинаковыми настройками, заранее определённый критерий результата и несколько повторов. Тогда можно сравнить полный цикл: вход, выход, кэш, дополнительные вызовы, время и исправления человеком. Если в одном варианте мы попросили полный разбор, а в другом «только пять пунктов», более короткий ответ ожидаем. Но полноту работы ещё нужно сравнить. Это же относится к красивым процентам из чужих рекомендаций. В приложении к Grammar.md есть исходные количественные оценки, однако публичная версия прямо обозначает их как авторские иллюстративные ориентиры без доступной методики и сырых данных. Я бы использовал их как идеи для проверки на своих задачах. И если несколько приёмов дали экономию по отдельности, складывать проценты тоже не стоит. Они могут сокращать одни и те же части работы. Когда выгоднее потратить больше Мы пока говорили в основном о сокращении расхода. Но иногда стоит сделать обратное. Дать дополнительный пример, если без него непонятна граница. Приложить связанный участок кода. Попросить объяснение, если результат нужно проверить. Выбрать более сильную модель для сложного решения. У более дешёвой модели может быть ниже цена за токен, но больше неудачных попыток. А слишком короткий ответ может сэкономить генерацию и оставить вам час самостоятельного расследования. Поэтому я бы смотрел на стоимость выполненной задачи с учётом времени человека. Для себя - хотя бы приблизительно. Для рабочего инструмента - по возможности на наборе реальных примеров. Самая дорогая часть процесса иногда находится по эту сторону экрана. С чего начать Для начала достаточно посмотреть, куда уходит основной объём. Если инструмент показывает вход, выход и кэширование - хорошо. Если нет, можно хотя бы оценить размер постоянных инструкций, загружаемых файлов и длину истории. Затем выбрать один крупный источник лишней работы. Убрать ненужный документ. Уточнить повторяющуюся неоднозначность. Перестать перечитывать весь проект. Настроить нужный формат ответа. И сравнить результат. Если без длинного объяснения вы получаете правильный ответ, его можно убрать. Если без контекста начинаются ошибки, контекст нужен. Если план предотвращает несколько переделок, он себя оправдывает. Мне кажется, это хорошо укладывается в то, о чём я обычно пишу в Econet. Цифровые ресурсы стали доступнее, но привычка добавлять всё «на всякий случай» никуда не делась. Теперь она добралась и до наших разговоров с нейросетями. За подписку всё-таки хочется получать полезный результат. А если часть оплаченной работы можно было вообще не делать, почему бы с этого и не начать? Если вы уже измеряли расход токенов на своих задачах, буду рад примерам в комментариях. Особенно интересно, какие способы экономии у вас не сработали и почему. А больше обсуждений про ИИ, данные и цифровую экологию - в моём канале Econet. Материалы по теме Юрий Зеленков. «Две грамматики за один вечер». Автор исходной концепции и исследования - Юрий Зеленков; составление и редакция публичной версии - Нарек Меликсетян. Публичная версия опубликована с разрешения автора исследования. Матвей Шульга. Видео о расходе токенов и лимитах Claude и Codex. Один из поводов подробнее разобраться с практическими приёмами работы с контекстом. Simon Willison. Understanding GPT tokenizers. Наглядное введение в токенизацию; примеры относятся к указанному в статье токенизатору. Anthropic. Effective context engineering for AI agents. Отбор контекста и организация длительной работы агентов. Microsoft Research. LLMLingua. Исследование автоматического сжатия промптов и качества на выбранных задачах. Спасибо, что дочитали статью. Для меня самого это в первую очередь попытка разобраться с происходящим и поделиться мыслями, чтобы в дискуссии найти новый взгляд, отточить собственные мысли и проверить их на прочность. Поэтому буду очень рад обратной связи в виде ваших комментариев и реакций. А если хотите больше обсуждений, мемов и просто всякого разного интересного, что происходит в мире IT (и AI, конечно), добро пожаловать в мой скромный канал Econet. Econet t.me
Count a prompt locally with tiktoken. Our o200k_base test returned 10 tokens for the sentence in the images, with no inference API call. Plain-text counts exclude chat formatting and other inputs. Use your model's encoding. openai/tiktoken