The first job is to make the prompt construction explicit. Instead of concatenating strings inline, build the prompt as a named dictionary of segments. Five segments cover most agent checks:
| Segment | What it holds | Typical growth mode |
|---|
| system | Role, rules, output format instructions | Stair step when edited |
| tool_schemas | JSON schemas for every callable tool | Stair step when tools are added |
| retrieved_docs | Documents pulled by the retrieval step | Slow ramp as corpus grows |
| history | Prior turns or prior run outputs | Slow ramp, often unbounded |
| scratchpad | Agent's own intermediate notes | Slow ramp or runaway loop |
| task | The specific item being checked | Flat (bounded by design) |
The names matter less than the discipline of keeping them separate all the way to the logging call. If your prompt is one big f-string, you cannot count the parts. A small builder function fixes that:
def build_prompt(task_text, docs, history, scratchpad):
segments = {
"system": SYSTEM_TEXT,
"tool_schemas": json.dumps(TOOL_SCHEMAS),
"retrieved_docs": "\n\n".join(docs),
"history": "\n\n".join(history),
"scratchpad": "\n\n".join(scratchpad),
"task": task_text,
}
return segments
Now counting is a loop. For plain text, a local tokenizer such as tiktoken gives you a fast count, but OpenAI's own documentation warns that a plain-text count does not include everything in a real request: message structure, roles, tool schemas, images and files all add tokens. For accuracy on the full request, use a token counting endpoint that accepts the same payload you plan to send, as described in OpenAI's token counting guide. The guide notes that the count includes formatting tokens for message roles and boundaries that local tokenizers miss.
A practical pattern is to count each segment locally for the drift chart (cheap, runs on every invocation) and count the assembled request with the provider endpoint on a sample (accurate, catches structure overhead). The drift chart only needs a consistent measure per segment; the absolute total needs the accurate one.
The per-segment count is a rough proxy, and that is fine as long as you know the limits. The same text can produce different token counts on different models because vocabularies differ; Firecrawl's analysis of tokenization across models reports compression ratios varying by 20 to 50 percent on ordinary content between tokenizers, and more on emoji and numeric content. Keep the counting method for a given check fixed, and record which tokenizer produced the number.
Log one row per run with the segment counts, the run ID, and the timestamp. That schema is the whole foundation.