The thirty-thousand-token tax on asking what time it is
Cost and control engineering on the tool layer of an internal AI assistant.
Go
Vue 3
TypeScript
MongoDB
AWS Bedrock
Embeddings
Every tool a model can call must be described to it again on every single request. Give an assistant a few dozen tools and you have a large fixed cost attached to every message, including the ones that need no tools whatsoever. Asking the assistant what time it was in another city cost about thirty thousand tokens of tool schemas before the model read a word of the question. This is the work that fixed that, and the part of it I most want to talk about is the component I built, measured, and then deleted.
93%largest measured cut in the input tokens one request carried: 30k down to 2.2k
The problem
The assistant had accumulated tools faster than anyone had thought about what tools cost. Every
one of them, its name, its description and its full parameter schema, is serialised into the
request on every message, because the model has no memory of them between calls. The bill is
paid per message, not per tool, and not per use of a tool.
01
The cost is fixed, and unrelated to need
A message that needs no tools pays exactly the same schema overhead as one that needs five. The cheapest possible question was the one where the overhead dominated most completely.
02
It grows with every integration
Each new tool server added its whole surface to every request the platform would ever serve. The incentive that creates is the wrong way round: shipping a useful tool makes every unrelated conversation more expensive.
03
Nobody could opt out
There was no way for a user to say "just answer me, do not go looking". They paid for the capability whether or not they wanted it in that conversation.
04
A cheaper wrong answer is not a win
Any fix here narrows what the model can do. If narrowing it too far makes the assistant reply that it lacks a tool it demonstrably has, the saving is worthless. That failure is more expensive than the tokens.
That last constraint is the one that shaped the design. This is an optimisation, and an
optimisation is only allowed to cost money when it goes wrong. Never capability, and never
permission.
What I built
Two gates in front of the model call: one the user controls, one the system infers.
Already existed
Role-based access control over tools, filtering per user
Embedding generation against the platform’s existing embedding model, built for knowledge-base retrieval
The streaming chat handlers and the tool-call loop
A confirmation gate in front of state-changing tools
What I added
A per-user tool preference, persisted and defaulting to on, honoured across every chat entry point in both the backend and the client
A relevance stage that ranks permitted tools against the message by embedding similarity and passes only the top K
An in-memory embedding cache with periodic refresh, holding tool descriptions only
The fail-open contract, the flag it shipped behind, and the instrumentation that decided K
A user-facing toggle, and the ordering that makes it meaningful.
A persisted per-user preference, on by default, checked before anything else. Off
means zero tools reach the model. Not “fewer tools”, not “cheap tools only”.
Relevance ranking that reuses infrastructure instead of adding it.
The platform already produced embeddings for knowledge-base retrieval. This needed a
similarity function, a cache and a cap, not a new service, a new provider or a new index.
Invariants written down before the code.
Access control is never widened. Any failure falls open to the full permitted set, never to
an empty one. The user’s own preference outranks the optimisation. All three are testable
statements, and they were the acceptance criteria.
Instrumentation as a first-class deliverable,
because the whole feature is a claim about numbers. It logged what it selected and how much
it saved from the first deploy, which is what let measurement overrule my design.
Architecture
Three gates before the model call, and only the last one may fail
Three things about that ordering are deliberate.
The user’s preference is the outermost gate. It would have been marginally tidier to fold it
into the selection service as “select zero tools”, and that would have been wrong: it puts a
user’s explicit instruction inside a component whose entire job is to make judgement calls about
tools. The preference is checked first, in the handler, and the selection service is never reached
when it is off.
Access control is untouched. Selection consumes the already-filtered set and can only shorten
it. There is no code path in which relevance ranking can produce a tool the requesting user was
not already permitted to use. The stage is subtractive by construction, not by policy.
The relevance gate fails open. On any error, whether an embedding timeout, a cold cache, a nil
service or a bad response, it returns the full permitted set. This inverts the usual instinct: a
failure here makes the request expensive rather than making it fail. That is the correct trade,
because the alternative failure mode is an assistant that tells a user it cannot do something it
can, which is indistinguishable from a broken product.
Four problems worth describing
01
The same twenty lines, written four times, one of them wrong
Symptom
The toggle had to be honoured at four separate chat entry points: start a conversation, send a
message, and both of those again with attachments, each with its own copy of the tool
validation block. I drafted the change as four edits.
Diagnosis
Three of the four were right. The fourth diverged: it had been written against the wrong
baseline, so it also introduced a log line that the two attachment paths had never had, and the
branch structure did not match what was actually there. Reviewing my own four blocks
side by side was the only reason it was caught, and it was caught by comparing them to each
other rather than by reading any one of them.
The deeper problem is not the typo. Four hand-maintained copies of a security-relevant decision,
may this request use tools at all, is a structure that guarantees a future divergence.
Whoever adds the fifth entry point copies whichever block they happen to open.
Decision
The toggle went in as four edits, because that matched what was there and I did not want to
refactor the streaming handlers in the same change. But when the relevance stage arrived
shortly after, it went in as one helper called from four places, and the tool decision
consolidated there. A three-state preference is what made this worth getting right: the field
is a nullable boolean, where unset means “never expressed an opinion” and must behave as
on, while false is an explicit refusal. Four copies of a nil-check that has to distinguish
absent from false is exactly the shape of a bug that reaches production as “the toggle doesn’t
stick”.
Not a clever fix, and I have included it because the honest version of this story is that I
wrote the same block four times and one copy was wrong. The lesson I took is narrower than
“don’t repeat yourself”: a decision that must hold on every path should be reachable from one
place, and permission-shaped decisions especially.
02
Measuring before choosing, and choosing the boring option
Symptom
A trivial question, the current time in a given city, was arriving at the model behind roughly
thirty thousand tokens of tool schemas. Nothing was broken. It was simply the case that the
platform’s fixed overhead had grown quietly, and no measurement had ever been attached to it.
Diagnosis
Two findings changed the shape of the fix. There was no caching anywhere in the path: the tool
set was read out of the database on every single request. And the expense was in the schemas,
not in the retrieval, so making the database read faster would have saved nothing that mattered.
Whatever went in had to reduce the number of tool descriptions serialised, and everything else
was noise.
Decision
I compared four approaches on latency, cost, accuracy and, the criterion that decided it,
what new infrastructure each would require, and I wrote the numbers down before choosing rather
than after.
A dedicated reranking model scored highest on accuracy, at ~200–300 ms and 20× the cost
per call, and introduced a new vendor integration. A small language model acting as a router
was ~800–1500 ms, five to ten times slower, at roughly 1500× the cost, plus a new
provider dependency. Keyword matching was under 10 ms and free, and unacceptably crude for
paraphrase, which is most of what real messages contain. Embedding similarity against the model
already running in production for knowledge-base retrieval came in at ~100–150 ms and
needed only a similarity function and a cache.
Then I budgeted the latency I was adding, because this sits in front of every request:
a 150 ms timeout that falls back to the full tool set rather than failing, cosine similarity
at under 5 ms, cache lookup at under 1 ms, and a p95 under 200 ms as the number that
would decide whether it stayed.
I chose the one that added nothing. It is not the most accurate option available and I would not
claim otherwise, but “we now depend on a second vendor to answer what time it is” is a real
cost that does not appear in an accuracy column, and at 1500× the per-call price the most
expensive option was being asked to justify itself against a shortlist it had already lost on
latency.
Input tokens on a tool-irrelevant question
Every tool described on every request
tool schemas serialised all permitted tools
source database read, per request, uncached
input tokens ≈ 30k
tools actually called 1
Top K by relevance, cached embeddings
tool schemas serialised 10
source in-memory cache, refreshed periodically
input tokens ≈ 2.2k
tools actually called 1 ← same answer, same tool
⚙
tool_selectionbefore the model call · every request
“What’s the time in Tokyo?”
Schemas sent
10
Input tokens
≈2.2k
Permitted, unfiltered
27
Top 10 of 27 permitted tools, ranked by embedding similarity to the message. The rest never reach the model.
Reconstruction of the measured before/after in this case study. The permitted-tool count is per user; 27 is the account this was measured on.
Observed reductions across a handful of real messages ranged from roughly a third to over ninety
per cent, the spread depending entirely on how tool-relevant the message was. These are
individual observations from a test environment rather than an average over production traffic.
I did the annual arithmetic at the time, and it is worth saying what it produced, because the
reason it is not on this site is the interesting part. At the measured 60k-to-12.2k reduction and
published input-token pricing, the per-interaction saving is about fourteen cents, which
lands at ~$4,300 a month and ~$52,000 a year at a thousand interactions a day, and
~$523,000 a year at ten thousand. Real numbers, multiplied correctly.
None of them are anywhere on this site, including the front page, because I never measured the
message rate. Every figure above is the token measurement I did make, multiplied by a volume I
guessed, so the guess is carrying the entire result, and picking a different one moves the
answer by a factor of ten. Quoting $523,000 would also invite a reader to discount the
$1,299 a month elsewhere on this site that is an actual invoice. The measurement is 93%; the
dollars are somebody else’s to calculate once they know their own traffic.
03
I built an intent detector, measured it, and deleted it
Symptom
A specific fear, and a legitimate one. Six of the tools are state-changing, covering sending
messages, creating calendar events and replying to mail, and each is protected by a human
confirmation gate. If relevance ranking dropped one of those tools when the user had plainly
asked for it, the model could not propose the action, so the confirmation gate would never fire
and the user would be told the assistant lacked a capability it had. An optimisation silently
disabling a safety feature’s subject is the worst outcome available here.
Diagnosis
So I built a guard: detect that the message expresses intent to take an external action, and if
so force all permitted state-changing tools into the set regardless of their similarity score.
The first design was a keyword list. I rejected it on my own review, because it fires on “send
me a summary” and “create a list of bugs”, which are not external actions at all, and paying
the full gated-tool overhead on every message containing the word “send” gives back much of the
saving.
The second design was better and is what shipped: a handful of action phrases embedded once at
startup, cosine similarity from the message to each, and a threshold above which intent is
declared. It handles paraphrase, and “send me” genuinely does sit far from “send to
someone” in that space.
Decision
Then I instrumented it, and in real use the threshold never fired. Not rarely. Never. The
reason is slightly humbling: when a user asks to send a message, the ranking already puts the
send tools at the top, because that is precisely what similarity to the message measures. The
guard was protecting against a failure the primary mechanism does not have.
I deleted it, and reduced K from fifteen to ten with the headroom it had been reserving. What
made that safe to do is a property of the existing design rather than of my change: the
confirmation gate keys on the tool’s identity after the model has chosen it, not on how the
tool came to be offered. Selection cannot bypass the gate, because the gate does not consult
selection.
The intent-detection layer, before and after instrumentation
Designed for a failure the ranking doesn’t have
action phrases embedded at startup 7
threshold crossings in real use 0
gated tools force-included all permitted, on crossing
top K 15 headroom for the forced set
send tools’ rank without the layer already top 3
Deleted, K reduced
action-intent layer removed
top K 10
confirmation gate unchanged. keys on tool identity,
checked after the model chooses
The favourite thing I did on this project, and it consists of removing my own work. A guard that
never fires is not free: it is a component that must be maintained, a threshold that must be
tuned, and a reserved token budget that costs real money on every request. I only knew it never
fired because I had instrumented it before trusting it.
04
The metric moved, which is not the same as the mechanism working
Symptom
Local testing looked like a success and proved much less than it appeared to. The logs reported
a healthy token reduction, and alongside it a selection of twenty-seven tools out of
twenty-seven available.
Diagnosis
My own account had access to twenty-seven tools, not the full permitted catalogue, so locally
the selection cap sat above the input size and the stage was barely selecting at all. The token
drop was real but was coming mostly from a shorter serialisation path, not from ranking. The
ratio the whole feature rests on was untestable on my own account.
Worse, the log printed counts and names, not positions. A count going down is consistent
with correct ranking and equally consistent with the stage discarding tools arbitrarily. The
only evidence that ranking was any good was whether the right tool survived the cut and was
then actually invoked, so I went to the execution logs and confirmed the datetime tool had run
for a time-of-day question, rather than inferring it from the count.
Decision
Ship behind a flag, default off, and validate against a set large enough for ranking quality to
matter, which meant an environment with a wider tool grant than my own account had. On that
environment the behaviour held up: the reductions were much larger, the tool that should have
ranked first for a given question did, and the confirmation gate still fired on a calendar
request. The flag is the honest acknowledgement that a local pass on twenty-seven tools was not
evidence about a wider grant.
I nearly wrote this up as validated on the strength of the token graph. The number was correct
and the conclusion I was about to draw from it was not. The graph would have moved the same way
if the ranking had been random.
Measured input-token reduction: single-session observations, test environment
Every bar on that chart is a recorded observation rather than an average, and the spread between
them is not noise. It is how tool-relevant each message happened to be. The bar worth reading is
the last one. It is the earliest run, it is the smallest reduction on the chart, and it is the one I
wrote down at the time as evidence the feature worked.
≈93%largest observed reduction in input tokens on one message
0times the action-intent layer fired before I removed it
10tools sent to the model, reduced from 15 after measurement
4duplicated tool-decision call sites, collapsed to one helper
Where it landed
Verified
A persisted per-user tool preference, honoured at every chat entry point in both the backend and the client, with unset behaving as on and false as an explicit refusal
Relevance selection running in front of the model call behind a default-off flag, reusing the platform’s existing embedding model with no new provider or index
Substantial measured reductions in input tokens on real messages, ranging from roughly a third to over ninety per cent with the spread explained by tool relevance
Access control provably not widened, since the stage is subtractive by construction and consumes the already-filtered set
Fail-open verified: an error in selection yields the full permitted set, so the failure mode is cost rather than lost capability
The confirmation gate on state-changing tools still firing, because it keys on tool identity after the model chooses rather than on how the tool was offered
An intent-detection layer removed on the evidence of its own instrumentation, and K reduced with the budget it had reserved
What this doesn't solve
Embedding similarity is a proxy for relevance, not relevance. A question can need a tool that does not sound like it, and no cap size makes that go away.
Any top-K cap is a bet that K is enough. The bet is hedged by failing open, not won.
The reduction figures are individual observations, not a distribution over production traffic. The true average depends on the mix of messages users actually send.
There is no figure here for the size of the permitted tool set, deliberately. It is the per-user access-control result, so it has no single value. The one platform-wide number in the planning material was an estimate used to derive an estimated token cost, and was never counted. The diagrams label that stage per user for the same reason.
Latency figures for the added embedding call are projections rather than measurements, and are described that way wherever they appear.
The cost saving scales with message volume, which is a business figure I do not have. Converting tokens into an annual number would be arithmetic dressed as a result.
What I'd do differently
Attach a number to the fixed cost when the first tool ships, not the fortieth. Nothing here was
hard to find. It was expensive because it was never measured. Each integration added a little
overhead to every request in the system, and no single change was large enough to notice. A
per-request schema-cost figure in the logs from day one would have made this visible years before
it became a project.
Instrument the guard before building the guard. The intent detector cost real design and review
time, and one afternoon of logging would have shown it was unnecessary before any of it was
written. My reason for building it, the fear of silently dropping a safety-critical tool, was
sound. The mistake was acting on the fear rather than first measuring whether it was realised.
Distrust favourable metrics from an unrepresentative account. Testing tool selection on an
account with a fraction of the catalogue told me almost nothing about ranking quality, and the
graph was encouraging enough that I nearly stopped there. I would now write down what the metric
cannot distinguish before reading it.