<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: llmops</title>
    <description>The latest articles tagged 'llmops' on DEV Community.</description>
    <link>https://dev.to/t/llmops</link>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/tag/llmops"/>
    <language>en</language>
    <item>
      <title>Grok 4.6: A Practical Test Plan for Persistent VM Agents</title>
      <dc:creator>Tran Tien Van</dc:creator>
      <pubDate>Thu, 13 Aug 2026 10:33:21 +0000</pubDate>
      <link>https://dev.to/tran_tienvan_e45fc26d655/grok-46-a-practical-test-plan-for-persistent-vm-agents-1hlo</link>
      <guid>https://dev.to/tran_tienvan_e45fc26d655/grok-46-a-practical-test-plan-for-persistent-vm-agents-1hlo</guid>
      <description>&lt;p&gt;A 500,000-token context window is a capability, not a target. For Grok 4.6, the useful engineering question is whether the model and its separate persistent VM runtime survive your own production-shaped tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with two systems, not one
&lt;/h2&gt;

&lt;p&gt;The August 12, 2026 launch documentation lists Grok 4.6 through the xAI API, Grok Build, Cursor, OpenRouter, Vercel, and Cloudflare. That distribution makes a trial accessible, but it does not establish that the stack is ready for your workload.&lt;/p&gt;

&lt;p&gt;Grok 4.6 is the frontier-model candidate. Grok Bot is a separate persistent cloud-computer runtime for long-running agents. Keep that boundary visible in the test plan. If a combined agent fails, you need to know whether the model chose the wrong action, a tool call failed, or the runtime could not complete the work.&lt;/p&gt;

&lt;h2&gt;
  
  
  Treat context like a budget
&lt;/h2&gt;

&lt;p&gt;The model supports a 500,000-token context window. The more important operational boundary arrives at 200,000 prompt tokens: reaching that point can move the whole request into a much higher pricing band, rather than charging a premium only for tokens above the line.&lt;/p&gt;

&lt;p&gt;Set a warning before that boundary and require an explicit approval to cross it. Record prompt size for every evaluated job. Begin with the shortest context that can perform the task, then add material only when the accepted outcome improves enough to justify the extra token use and reviewer effort.&lt;/p&gt;

&lt;p&gt;Long-running work adds pressure because stateful loops can carry growing context. A large window offers room for complex tasks, but it can also delay context discipline. That is an honest tradeoff: useful headroom can prevent truncation while making an expensive loop easier to miss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Score production outcomes, not demo appeal
&lt;/h2&gt;

&lt;p&gt;xAI's frontier-parity benchmark results are a reason to shortlist Grok 4.6, not proof of production fit. Use repeated, production-shaped tasks and retain the underlying measures:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Accepted outcomes:&lt;/strong&gt; define completion criteria before the run and record whether the output passes them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool reliability:&lt;/strong&gt; capture attempted actions, successful actions, and failures by tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency:&lt;/strong&gt; measure end-to-end completion time for each task class.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token use:&lt;/strong&gt; log prompt size and flag every crossing of the 200,000-token boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reviewer effort:&lt;/strong&gt; track the human work required to verify, repair, or reject the result.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not compress these measures into one pass/fail number too early. A workflow can return an acceptable artifact while consuming too many tokens or too much review time. Another can look efficient while failing unpredictably when it reaches for a tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Put the persistent VM behind controls
&lt;/h2&gt;

&lt;p&gt;Persistence is valuable when an agent needs to resume stateful work. The same Grok Bot runtime can expose browser, terminal, filesystem, and network access, which enlarges the security boundary beyond the model response.&lt;/p&gt;

&lt;p&gt;Begin with reversible work whose outputs can be discarded. Isolate the VM, grant least privilege, place approvals before consequential actions, retain audit logs, enforce budgets, clean up state, and provide a kill control. Exercise those controls during the trial; a policy that has never stopped or cleaned up a run is not yet evidence of operational control.&lt;/p&gt;

&lt;p&gt;Runtime evaluation should also verify tool reliability independently of model quality. A sound model decision does not rescue a failed terminal action, and a well-isolated VM does not make a weak answer correct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Expand autonomy in stages
&lt;/h2&gt;

&lt;p&gt;First, run Grok 4.6 against a fixed evaluation set with constrained or simulated tools. This establishes outcome quality, latency, token use, and reviewer effort without asking the persistent runtime to prove itself at the same time.&lt;/p&gt;

&lt;p&gt;Next, test Grok Bot with tightly scoped, reversible jobs. Hold the task definition steady while you inspect resumability, access boundaries, auditability, cleanup, budgets, and the kill control.&lt;/p&gt;

&lt;p&gt;Only then combine the chosen model workflow with the persistent VM. Increase permissions or duration after both cost gates and control gates pass. If the combined result regresses, the earlier baselines give you somewhere concrete to investigate.&lt;/p&gt;

&lt;p&gt;The engineering case is a controlled enterprise trial, not an automatic migration. The August 12 release offers broad access and a large context window, but adoption should be earned by accepted outcomes and a runtime that stays governable.&lt;/p&gt;

&lt;p&gt;Which production-shaped task would you choose for the first Grok 4.6 trial, and what exact cost or control result would stop the rollout?&lt;/p&gt;




&lt;p&gt;📖 &lt;strong&gt;Read the full guide → &lt;a href="https://vandatateam.com/blog/grok-4-6" rel="noopener noreferrer"&gt;Grok 4.6 and Persistent VM Agents&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>devops</category>
      <category>llmops</category>
      <category>aiagents</category>
    </item>
    <item>
      <title>Data Privacy in RAG Redaction and Retention [2026 Playbook]</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Tue, 11 Aug 2026 00:44:18 +0000</pubDate>
      <link>https://dev.to/kunal_d6a8fea2309e1571ee7/data-privacy-in-rag-redaction-and-retention-2026-playbook-72l</link>
      <guid>https://dev.to/kunal_d6a8fea2309e1571ee7/data-privacy-in-rag-redaction-and-retention-2026-playbook-72l</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://www.kunalganglani.com/blog/data-privacy-rag-redaction-retention" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt; — read it there for inline code, hero image, and live links.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Data privacy in rag redaction and retention is not a “security checklist” problem. It’s an architecture problem.&lt;/p&gt;

&lt;p&gt;Ship Retrieval-Augmented Generation (RAG) into anything regulated and you find out fast: your privacy posture is defined by a dozen tiny boundaries. Ingestion. Chunking. Embeddings. Retrieval filters. Prompt construction. Tool calls. Model outputs.&lt;/p&gt;

&lt;p&gt;And then the graveyard where everything goes to die.&lt;/p&gt;

&lt;p&gt;Logs, traces, analytics, replay systems. The stuff teams keep “just for a week” until it quietly becomes 180 days.&lt;/p&gt;

&lt;p&gt;Most teams get one boundary right (usually “don’t train on my data”) and then leak sensitive data everywhere else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A RAG system has at least &lt;strong&gt;8 privacy leak points&lt;/strong&gt;. If you only redact at ingestion, you will still leak via prompts, logs, and traces.&lt;/li&gt;
&lt;li&gt;Redact &lt;strong&gt;PII/PHI/PCI&lt;/strong&gt; and &lt;strong&gt;secrets&lt;/strong&gt; differently. Secrets require &lt;em&gt;prevention&lt;/em&gt; and &lt;em&gt;blocking&lt;/em&gt;, not just masking.&lt;/li&gt;
&lt;li&gt;Use a retention matrix. Default to &lt;strong&gt;7–30 days&lt;/strong&gt; for operational traces, &lt;strong&gt;0–7 days&lt;/strong&gt; for raw prompts, and &lt;strong&gt;90–365 days&lt;/strong&gt; for compliance audit events that contain &lt;em&gt;no raw text&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Treat embeddings as sensitive artifacts. They’re not “random vectors.” Retention and deletion must be designed, not wished into existence.&lt;/li&gt;
&lt;li&gt;The best audit trail is structured, hashed, and policy-aware. It should prove what happened without storing what you wish never happened.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;If you can’t delete it, don’t collect it. And if you need it for debugging, collect a safer version.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  What is RAG and where privacy leaks happen
&lt;/h2&gt;

&lt;p&gt;Retrieval-Augmented Generation (RAG) is the pattern where a model answers a user’s question using external knowledge retrieved at request time. Instead of fine-tuning a model on your private docs, you fetch relevant chunks from a search index or vector database and inject them into the prompt.&lt;/p&gt;

&lt;p&gt;That’s why RAG is so attractive in enterprise and regulated domains. It’s also why privacy failures get… sneaky.&lt;/p&gt;

&lt;p&gt;In a typical production pipeline, sensitive data can leak at &lt;strong&gt;eight&lt;/strong&gt; places:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Source documents&lt;/strong&gt; (PDFs, tickets, call transcripts, emails)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Chunked text&lt;/strong&gt; (the “cleaned” form engineers often forget is still raw text)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embeddings&lt;/strong&gt; (vectors derived from sensitive text)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vector DB metadata&lt;/strong&gt; (tenant IDs, doc IDs, ACLs, tags)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval results&lt;/strong&gt; (the top-k chunks your system thinks are relevant)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt assembly&lt;/strong&gt; (where you concatenate user query + system prompt + retrieved context)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generation output&lt;/strong&gt; (which can repeat or transform sensitive data)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability&lt;/strong&gt; (logs, traces, analytics events, replay systems)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I’ve built RAG systems that handle &lt;strong&gt;millions of queries daily&lt;/strong&gt; with &lt;strong&gt;sub-second response times&lt;/strong&gt; for the Walmart conversational commerce chatbot. At that scale, the privacy problem stops being theoretical. One overly-verbose trace attribute doesn’t leak “a little.” It leaks thousands of secrets per hour. Not because anyone is evil. Because volume turns every mistake into an incident.&lt;/p&gt;

&lt;p&gt;The 2026 change is that AI governance is finally becoming operational. Teams are standardizing &lt;strong&gt;policy-as-code&lt;/strong&gt; for prompt/context filtering and adopting &lt;strong&gt;privacy-preserving tracing&lt;/strong&gt; (hashed identifiers, structured audit events) to satisfy audits without hoarding raw prompts.&lt;/p&gt;

&lt;p&gt;Here’s the playbook I wish more teams shipped with.&lt;/p&gt;




&lt;h2&gt;
  
  
  PII and secrets: what to redact (and what not to)
&lt;/h2&gt;

&lt;p&gt;The fastest way to build a broken privacy system is to treat “sensitive data” like it’s one big bucket.&lt;/p&gt;

&lt;p&gt;In RAG, you usually have &lt;strong&gt;four&lt;/strong&gt; categories that behave very differently:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;PII&lt;/strong&gt; (names, emails, phone numbers, addresses, government IDs)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PHI&lt;/strong&gt; (health info, diagnoses, prescriptions, patient IDs)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PCI&lt;/strong&gt; (card numbers, CVV, bank account identifiers)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets&lt;/strong&gt; (API keys, OAuth tokens, session cookies, private keys, internal credentials)&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Plus a fifth that matters in enterprise but gets hand-waved way too often:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Proprietary text&lt;/strong&gt; (contracts, incident reports, roadmap docs, source code)&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Redact PII/PHI/PCI, but don’t destroy meaning
&lt;/h3&gt;

&lt;p&gt;PII redaction in RAG pipelines is about &lt;strong&gt;reducing risk without turning your index into mush&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If you delete every name, address, and number, retrieval quality falls off a cliff. And in production RAG, retrieval quality is the whole game.&lt;/p&gt;

&lt;p&gt;I learned this building the Walmart RAG pipeline. Model choice mattered, but &lt;strong&gt;retrieval quality dominated&lt;/strong&gt; at scale. If your privacy controls crater retrieval, the “fix” the product team reaches for is predictable. “Increase k.” “Log more.” “Add more context.” Congratulations, you just made privacy worse.&lt;/p&gt;

&lt;p&gt;So for PII/PHI/PCI:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Prefer &lt;strong&gt;token-preserving masking&lt;/strong&gt; over deletion. Replace names with stable placeholders like &lt;code&gt;PERSON_1&lt;/code&gt;, &lt;code&gt;PERSON_2&lt;/code&gt; &lt;em&gt;within a single document&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Preserve &lt;strong&gt;document structure&lt;/strong&gt; (headings, tables, bullet points). Structure is signal.&lt;/li&gt;
&lt;li&gt;Keep &lt;strong&gt;non-sensitive numeric values&lt;/strong&gt; when they’re essential (prices, dimensions, dates in public policies). Over-redaction creates useless context and forces more retrieval.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Secrets are different: block, rotate, and alert
&lt;/h3&gt;

&lt;p&gt;Secrets detection in LLM systems is not mainly a “masking” problem. It’s an &lt;em&gt;incident prevention&lt;/em&gt; problem.&lt;/p&gt;

&lt;p&gt;Once a credential gets into the pipeline:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it can land in logs,&lt;/li&gt;
&lt;li&gt;it can get echoed by the model,&lt;/li&gt;
&lt;li&gt;it can get cached,&lt;/li&gt;
&lt;li&gt;it can end up in an eval dataset,&lt;/li&gt;
&lt;li&gt;it can be pasted into a ticket by someone trying to “help.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So for secrets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Detect and &lt;strong&gt;hard-block&lt;/strong&gt; known secret formats (API keys, JWTs, bearer tokens, private keys).&lt;/li&gt;
&lt;li&gt;If a secret shows up in user input, respond with a safe error and trigger rotation workflows.&lt;/li&gt;
&lt;li&gt;If a secret shows up in retrieved context (yes, this happens), quarantine the source doc and re-index.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Practical rule: if it looks like a credential, treat it like a production incident. Not like a string to redact.&lt;/p&gt;




&lt;h2&gt;
  
  
  Redaction enforcement points: ingestion, retrieval, prompt construction, and logging
&lt;/h2&gt;

&lt;p&gt;Teams always ask: “Should we redact before embedding or at retrieval time, or both?”&lt;/p&gt;

&lt;p&gt;My stance: &lt;strong&gt;both&lt;/strong&gt;. But for different reasons. If you pick one, you’ll end up compensating somewhere else. Usually in logging. And that’s how leaks become permanent.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Ingestion-time redaction (before chunking and embedding)
&lt;/h3&gt;

&lt;p&gt;Ingestion-time redaction is your first shot at reducing blast radius.&lt;/p&gt;

&lt;p&gt;Do it here because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;raw docs are the richest leak source,&lt;/li&gt;
&lt;li&gt;you can run heavier detectors (DLP, regex + ML, custom rules),&lt;/li&gt;
&lt;li&gt;you can store &lt;em&gt;two&lt;/em&gt; versions: a raw vault copy (restricted) and a redacted RAG copy.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In healthcare or finance, it’s common to keep raw docs in a separate evidence vault with a different retention policy. Your RAG path should almost never touch raw.&lt;/p&gt;

&lt;p&gt;Ingestion-time output should be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;redacted chunks for indexing&lt;/li&gt;
&lt;li&gt;a redaction manifest (what was removed, by what rule, at what time)&lt;/li&gt;
&lt;li&gt;provenance metadata (source system, owner, tenant, classification)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2) Retrieval-time filtering (tenant, ACL, and policy checks)
&lt;/h3&gt;

&lt;p&gt;Retrieval is where multi-tenant isolation either works… or you end up on a call you don’t want.&lt;/p&gt;

&lt;p&gt;Your retriever must enforce:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tenant isolation&lt;/strong&gt; (hard partition or mandatory tenant filter)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Document-level ACLs&lt;/strong&gt; (user can only retrieve what they can access)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Policy filters&lt;/strong&gt; (e.g., “this user role cannot retrieve PHI”)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Do not rely on the LLM to “refuse” forbidden data. That’s like asking a logging library to enforce RBAC.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Prompt-time redaction (last-mile safety)
&lt;/h3&gt;

&lt;p&gt;Prompt construction is the last place you control the content before it hits a model API.&lt;/p&gt;

&lt;p&gt;This is where you catch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;residual PII from ingestion failures,&lt;/li&gt;
&lt;li&gt;secrets via user input or retrieved text,&lt;/li&gt;
&lt;li&gt;prompt injection payloads trying to force exfiltration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re already doing prompt filtering for &lt;a href="https://dev.to/blog/prompt-injection-2026-owasp-llm-vulnerability"&gt;prompt injection&lt;/a&gt;, extend the same policy engine to redact sensitive spans. Same machinery. Different rules.&lt;/p&gt;

&lt;h3&gt;
  
  
  4) Logging-time minimization (where most leaks actually happen)
&lt;/h3&gt;

&lt;p&gt;LLM prompt logging retention policy is where teams self-own.&lt;/p&gt;

&lt;p&gt;It usually starts with a reasonable intention: “we need traces to debug hallucinations.”&lt;/p&gt;

&lt;p&gt;Then you log:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the full user query&lt;/li&gt;
&lt;li&gt;the full retrieved chunks&lt;/li&gt;
&lt;li&gt;the full prompt&lt;/li&gt;
&lt;li&gt;the full model output&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;…and you keep it for &lt;strong&gt;180 days&lt;/strong&gt; because “compliance.”&lt;/p&gt;

&lt;p&gt;That’s not compliance. That’s a breach backlog with a nice dashboard.&lt;/p&gt;

&lt;p&gt;The fix is to split observability into two streams:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Compliance audit events&lt;/strong&gt;: structured, minimal, long retention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Debug traces&lt;/strong&gt;: richer, short retention, access-gated, redacted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I wrote a full schema approach in &lt;a href="https://dev.to/blog/ai-agent-observability-logging-schema"&gt;AI Agent Observability Logging Schema [2026]: OTel + Redaction&lt;/a&gt;. This post is the same idea, applied specifically to RAG.&lt;/p&gt;




&lt;h2&gt;
  
  
  Retention policies for RAG artifacts (documents, embeddings, prompts, traces)
&lt;/h2&gt;

&lt;p&gt;Retention gets political fast. Security wants “keep nothing.” Debugging wants “keep everything.” Legal wants “keep whatever the policy says, and prove you did it.”&lt;/p&gt;

&lt;p&gt;This is one of those things where the boring answer is actually the right one. Build a retention matrix. Implement it with TTLs, deletion jobs, and an audit trail that doesn’t rely on storing raw content forever.&lt;/p&gt;

&lt;p&gt;Here’s a default retention matrix you can start with. Adjust by domain and regulation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Default retention matrix (practical starting point)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Artifact&lt;/th&gt;
&lt;th&gt;Contains raw text?&lt;/th&gt;
&lt;th&gt;Sensitivity risk&lt;/th&gt;
&lt;th&gt;Suggested default retention&lt;/th&gt;
&lt;th&gt;Implementation pattern&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Raw source docs (vault)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Very high&lt;/td&gt;
&lt;td&gt;1–7 years (policy-driven)&lt;/td&gt;
&lt;td&gt;Separate storage + strict access + legal hold&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Redacted docs/chunks (RAG store)&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;90–365 days&lt;/td&gt;
&lt;td&gt;Object store TTL + re-ingestion pipeline&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Embeddings&lt;/td&gt;
&lt;td&gt;No (but derived)&lt;/td&gt;
&lt;td&gt;Medium–high&lt;/td&gt;
&lt;td&gt;90–365 days&lt;/td&gt;
&lt;td&gt;Vector DB TTL per record + re-embed on refresh&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vector metadata (ACL, tenant)&lt;/td&gt;
&lt;td&gt;No&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;As long as embedding&lt;/td&gt;
&lt;td&gt;Same lifecycle as embedding&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;User queries&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;0–30 days&lt;/td&gt;
&lt;td&gt;Store redacted form; hash identifiers&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Assembled prompts&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Very high&lt;/td&gt;
&lt;td&gt;0–7 days&lt;/td&gt;
&lt;td&gt;Prefer not storing; store prompt hashes&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retrieved context snippets&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;Very high&lt;/td&gt;
&lt;td&gt;0–7 days&lt;/td&gt;
&lt;td&gt;Store doc IDs + chunk hashes, not text&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Model outputs&lt;/td&gt;
&lt;td&gt;Yes&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;td&gt;0–30 days&lt;/td&gt;
&lt;td&gt;Store redacted summaries; avoid raw&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Debug traces (full request graph)&lt;/td&gt;
&lt;td&gt;Sometimes&lt;/td&gt;
&lt;td&gt;Very high&lt;/td&gt;
&lt;td&gt;7–30 days&lt;/td&gt;
&lt;td&gt;Separate sink + access control + sampling&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Compliance audit events&lt;/td&gt;
&lt;td&gt;No (structured)&lt;/td&gt;
&lt;td&gt;Low–medium&lt;/td&gt;
&lt;td&gt;1–3 years&lt;/td&gt;
&lt;td&gt;Append-only store, tamper-evident&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;You’ll notice two things:&lt;/p&gt;

&lt;p&gt;1) The stuff you &lt;em&gt;want&lt;/em&gt; for debugging (prompts, retrieved context) has the shortest retention.&lt;/p&gt;

&lt;p&gt;2) The long-retention trail is mostly &lt;strong&gt;IDs, hashes, and policy decisions&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That’s how you get both: debuggability and defensible privacy. Anything else turns into either blind debugging or an accidental data lake.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do embeddings contain PII? How should embeddings be treated?
&lt;/h3&gt;

&lt;p&gt;Embeddings are derived from sensitive text. They can leak information via membership inference or reconstruction attacks under certain conditions. And they absolutely count as “personal data” in many governance programs because they’re linked to identifiers and can be used to profile.&lt;/p&gt;

&lt;p&gt;Practically: treat embeddings as &lt;strong&gt;sensitive artifacts&lt;/strong&gt;.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Put them under retention.&lt;/li&gt;
&lt;li&gt;Put them under deletion.&lt;/li&gt;
&lt;li&gt;Encrypt them.&lt;/li&gt;
&lt;li&gt;Restrict access.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your design assumes embeddings are harmless, you will eventually build “vector analytics” that becomes a shadow data warehouse. It happens slowly, then all at once.&lt;/p&gt;

&lt;h3&gt;
  
  
  GDPR/CCPA deletion in a vector database: the part everyone avoids
&lt;/h3&gt;

&lt;p&gt;If you support deletion requests, you need a real answer to: “How do we delete a person’s data from the vector index?”&lt;/p&gt;

&lt;p&gt;There are only a few options that actually work:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Hard delete by document IDs&lt;/strong&gt;: maintain a mapping from source doc → chunk IDs → embedding IDs. Cleanest.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tombstones + async purge&lt;/strong&gt;: mark embeddings deleted, exclude at query time, purge later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Re-embedding&lt;/strong&gt;: if PII was embedded into shared chunks, you may need to re-chunk and re-embed affected documents.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Operational reality: deletion is a pipeline, not a database call.&lt;/p&gt;

&lt;p&gt;Design your indexing around &lt;strong&gt;stable IDs&lt;/strong&gt; and &lt;strong&gt;lineage&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;source document ID&lt;/li&gt;
&lt;li&gt;chunk ID&lt;/li&gt;
&lt;li&gt;embedding ID&lt;/li&gt;
&lt;li&gt;tenant ID&lt;/li&gt;
&lt;li&gt;classification&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That lineage is also what makes audit trails possible.&lt;/p&gt;




&lt;h2&gt;
  
  
  Audit trails for RAG: what to log for compliance vs debugging
&lt;/h2&gt;

&lt;p&gt;A good RAG audit trail answers two questions:&lt;/p&gt;

&lt;p&gt;1) &lt;strong&gt;What happened?&lt;/strong&gt; (who queried, what policies applied, which documents were accessed)&lt;br&gt;
2) &lt;strong&gt;Can you prove it?&lt;/strong&gt; (immutability, integrity, reproducibility)&lt;/p&gt;

&lt;p&gt;A bad audit trail is “we stored every prompt for 2 years.” That’s not an audit trail. That’s a liability with extra steps.&lt;/p&gt;

&lt;h3&gt;
  
  
  What should an audit trail contain for RAG?
&lt;/h3&gt;

&lt;p&gt;Here’s an audit event schema I’ve seen hold up well. It’s intentionally not raw-text.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;timestamp&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;request_id&lt;/code&gt; (correlation ID)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tenant_id&lt;/code&gt; (or hashed tenant)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;actor_id&lt;/code&gt; (hashed user ID)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;actor_role&lt;/code&gt; (admin, support, end-user)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;client_app&lt;/code&gt; (web, mobile, internal tool)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;query_class&lt;/code&gt; (support, product Q&amp;amp;A, medical, financial)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;retrieval_policy_version&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;redaction_policy_version&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;model_provider&lt;/code&gt; + &lt;code&gt;model_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;&lt;code&gt;prompt_template_version&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;retrieved_doc_ids&lt;/code&gt; (list)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;retrieved_chunk_ids&lt;/code&gt; (list)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;retrieved_chunk_hashes&lt;/code&gt; (optional)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;decision_flags&lt;/code&gt; (blocked_secret, blocked_phi, allowlist_hit, denylist_hit)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;output_classification&lt;/code&gt; (safe, contains_pii_suspected, blocked)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;latency_ms&lt;/code&gt; (end-to-end)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Notice what’s missing: the user’s full query and the raw retrieved text.&lt;/p&gt;

&lt;p&gt;If you need query-level detail for abuse investigations, store a &lt;strong&gt;redacted query&lt;/strong&gt; or a &lt;strong&gt;hash&lt;/strong&gt; plus a short-lived encrypted debug packet in a separate system.&lt;/p&gt;

&lt;h3&gt;
  
  
  Separate compliance logs from debug traces
&lt;/h3&gt;

&lt;p&gt;This separation is not optional in regulated environments.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Compliance logs are append-only, long retention, minimal data.&lt;/li&gt;
&lt;li&gt;Debug traces are short retention, sampled, access-gated, and aggressively redacted.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you don’t split them, your compliance system becomes your highest-risk data store.&lt;/p&gt;

&lt;p&gt;This is also where modern tracing helps. When you instrument your RAG pipeline like a distributed system, you can log &lt;strong&gt;structured spans&lt;/strong&gt; (retrieval latency, reranker latency, generation latency) without storing raw content. When you need content occasionally, capture it on a sampling path with explicit approvals.&lt;/p&gt;

&lt;p&gt;If you’re already working on evals, tie auditability to your regression gates. My approach in &lt;a href="https://dev.to/blog/ai-engineering-evals-gates"&gt;AI Engineering Evals: Regression Gates for Prompts, Tools, RAG [2026]&lt;/a&gt; is to treat “privacy regressions” like correctness regressions.&lt;/p&gt;




&lt;h2&gt;
  
  
  Encryption and access controls for vector databases and logs
&lt;/h2&gt;

&lt;p&gt;Most “RAG privacy” advice stops at redaction. That’s incomplete.&lt;/p&gt;

&lt;p&gt;Even perfectly redacted systems fail if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;anyone in the org can query the vector DB,&lt;/li&gt;
&lt;li&gt;logs are accessible to the whole engineering org,&lt;/li&gt;
&lt;li&gt;service-to-service permissions are broad.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Vector database security: the basics that still get missed
&lt;/h3&gt;

&lt;p&gt;For your vector database:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Encrypt at rest&lt;/strong&gt; (KMS-managed keys, ideally per environment)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Encrypt in transit&lt;/strong&gt; (mTLS between services)&lt;/li&gt;
&lt;li&gt;Enforce &lt;strong&gt;RBAC&lt;/strong&gt;. Retrieval service accounts should not have admin permissions.&lt;/li&gt;
&lt;li&gt;Enforce &lt;strong&gt;tenant isolation&lt;/strong&gt;. Separate indexes per tenant or mandatory tenant filters enforced server-side.&lt;/li&gt;
&lt;li&gt;Treat metadata like a boundary. A doc ID can be sensitive if it maps to a customer.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re using Postgres with &lt;code&gt;pgvector&lt;/code&gt;, lean on Postgres RBAC and row-level security. If you’re using a managed vector DB, verify that RBAC is enforced at query time, not just in dashboards.&lt;/p&gt;

&lt;h3&gt;
  
  
  Logs and traces: least privilege + “break glass” access
&lt;/h3&gt;

&lt;p&gt;Your logging platform is often broader-access than your databases. That’s backwards.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Default debug traces to &lt;strong&gt;restricted access&lt;/strong&gt; (on-call, security, a small number of engineers).&lt;/li&gt;
&lt;li&gt;Add &lt;strong&gt;break-glass&lt;/strong&gt; workflows: temporary access with approvals, fully audited.&lt;/li&gt;
&lt;li&gt;Separate environments. Production traces shouldn’t be casually accessible from dev accounts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In the Walmart chatbot system, we leaned heavily on event streaming for context pipeline latency. Kafka made the pipeline fast. It also creates another place to leak data if you don’t treat topics as sensitive. Partition topics by sensitivity, encrypt payloads where needed, and don’t let “observability” topics become an ungoverned dump.&lt;/p&gt;




&lt;h2&gt;
  
  
  Testing and monitoring: validating redaction and catching regressions
&lt;/h2&gt;

&lt;p&gt;You can’t “promise” data privacy. You can only prove it continuously.&lt;/p&gt;

&lt;p&gt;Here’s what works.&lt;/p&gt;

&lt;h3&gt;
  
  
  Build redaction fixtures and run them in CI
&lt;/h3&gt;

&lt;p&gt;Create a small corpus of synthetic documents and user queries that contain:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;multiple PII types (email, phone, address)&lt;/li&gt;
&lt;li&gt;PHI patterns (patient ID, diagnosis codes)&lt;/li&gt;
&lt;li&gt;PCI patterns (test card numbers)&lt;/li&gt;
&lt;li&gt;secrets (fake API keys, JWT-like tokens)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then test the pipeline at multiple points:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;after ingestion redaction&lt;/li&gt;
&lt;li&gt;after retrieval filtering&lt;/li&gt;
&lt;li&gt;after prompt assembly&lt;/li&gt;
&lt;li&gt;in the logging output&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same mindset as prompt regression testing. You’re not “hoping” you didn’t leak. You’re gating releases on it.&lt;/p&gt;

&lt;p&gt;If you want a broader framework for these gates, &lt;a href="https://dev.to/blog/evaluate-ai-agents-production-testing"&gt;Evaluate AI Agents in Production: 2026 Testing Guide&lt;/a&gt; is a good complement.&lt;/p&gt;

&lt;h3&gt;
  
  
  Monitor with metrics, not vibes
&lt;/h3&gt;

&lt;p&gt;You should have metrics like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;redaction_hit_rate&lt;/strong&gt;: % of requests where any redaction occurred&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;secret_block_rate&lt;/strong&gt;: % blocked due to secrets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;pii_suspected_rate&lt;/strong&gt;: sampled detection on outputs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;unredacted_span_rate&lt;/strong&gt;: traces that contain raw content fields&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Pick a baseline and alert on regressions. Even a 0.1% leak rate is catastrophic at scale.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sampling and review: the human layer that still matters
&lt;/h3&gt;

&lt;p&gt;Automated detection misses edge cases. Use sampling, but do it safely:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;sample &lt;strong&gt;redacted&lt;/strong&gt; traces by default&lt;/li&gt;
&lt;li&gt;allow short-lived access to raw content only with approvals&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In 2026, “privacy-preserving tracing” is the compromise that works. You debug latency and retrieval behavior with IDs and hashes. You pull raw content only on an incident path.&lt;/p&gt;




&lt;h2&gt;
  
  
  Incident response: how audit trails help investigate leaks
&lt;/h2&gt;

&lt;p&gt;When something leaks, the worst moment to discover your logging strategy is when you’re already in incident mode and someone asks: “So… what exactly got stored?”&lt;/p&gt;

&lt;p&gt;A strong audit trail lets you answer quickly:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which tenant was affected?&lt;/li&gt;
&lt;li&gt;Which user role queried it?&lt;/li&gt;
&lt;li&gt;Which documents were retrieved?&lt;/li&gt;
&lt;li&gt;Which policy version allowed it?&lt;/li&gt;
&lt;li&gt;Did redaction run?&lt;/li&gt;
&lt;li&gt;Which model version generated the response?&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  A practical incident workflow
&lt;/h3&gt;

&lt;p&gt;Here’s a workflow I like because it respects both privacy and reality:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Contain&lt;/strong&gt;: disable high-risk features (long-context retrieval, tool calls) for affected tenant(s).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Correlate&lt;/strong&gt;: use &lt;code&gt;request_id&lt;/code&gt; to trace the retrieval chain.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Verify&lt;/strong&gt;: pull the exact retrieved document IDs and chunk IDs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Reproduce&lt;/strong&gt;: replay using hashes and template versions, not the raw prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Remediate&lt;/strong&gt;: fix the policy or ingestion redaction, re-index, rotate secrets if applicable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prove&lt;/strong&gt;: write the incident report referencing audit events, not raw user data.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you don’t have structured audit events, you’ll fall back to grepping logs for raw prompts. That’s slow, it’s messy, and it tends to create a second leak while investigating the first.&lt;/p&gt;

&lt;p&gt;For broader security hardening beyond privacy, pair this with &lt;a href="https://dev.to/blog/ai-security-complete-guide"&gt;The Complete Guide to AI Security in 2026&lt;/a&gt; and &lt;a href="https://dev.to/pillars/ai-security"&gt;AI security&lt;/a&gt; work.&lt;/p&gt;




&lt;h2&gt;
  
  
  A practical end-to-end architecture (where to put the controls)
&lt;/h2&gt;

&lt;p&gt;If you want a concrete system picture, here’s the enforcement map I recommend for production AI.&lt;/p&gt;

&lt;h3&gt;
  
  
  Controls by boundary
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client SDK / UI&lt;/strong&gt;: detect obvious secrets before they leave the device; warn users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API gateway&lt;/strong&gt;: rate-limit, tenant auth, basic PII/secret scanning, request IDs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ingestion pipeline&lt;/strong&gt;: heavy redaction + classification + manifests; store redacted RAG copy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retriever service&lt;/strong&gt;: tenant partitioning, ACL checks, policy filters, denylist/allowlist rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt builder&lt;/strong&gt;: last-mile redaction; prompt injection defenses; template versioning.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM provider&lt;/strong&gt;: use no-training/no-retention modes where available, but don’t pretend this is your main control.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Observability&lt;/strong&gt;: split compliance audit events from debug traces; TTL and access controls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is also how you keep your &lt;a href="https://dev.to/pillars/ai-engineering-production"&gt;production AI&lt;/a&gt; posture sane. When everything becomes “just one more JSON field in the trace,” you will ship a privacy regression.&lt;/p&gt;

&lt;h3&gt;
  
  
  The policy-as-code approach (2026 reality)
&lt;/h3&gt;

&lt;p&gt;You want policies that are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;versioned&lt;/li&gt;
&lt;li&gt;testable&lt;/li&gt;
&lt;li&gt;deployable independently&lt;/li&gt;
&lt;li&gt;referenced in audit events&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same discipline you apply to CI/CD.&lt;/p&gt;

&lt;p&gt;If you’re already thinking in terms of control flow and gates, &lt;a href="https://dev.to/blog/ai-agent-control-flow-patterns"&gt;AI Agent Control Flow Patterns [2026]: Retries, HITL, Checkpoints&lt;/a&gt; has the same spirit. The system is the product, not the prompt.&lt;/p&gt;




&lt;h2&gt;
  
  
  Closing: the prediction
&lt;/h2&gt;

&lt;p&gt;RAG is becoming the default “enterprise LLM” architecture. That also means RAG is becoming the default enterprise privacy failure mode.&lt;/p&gt;

&lt;p&gt;My prediction: by late 2026, the teams that win audits won’t be the ones with the fanciest redaction model. They’ll be the ones who can point to a retention matrix, a policy version, and a tamper-evident audit event stream, and say: “Here’s exactly what we store, for how long, and why.”&lt;/p&gt;

&lt;p&gt;If you’re building RAG today, stop treating data privacy like a bolt-on. Design it the way you design latency budgets. End-to-end. Measurable. Enforced at every boundary.&lt;/p&gt;

&lt;p&gt;Want a challenge? Pick one artifact from the retention matrix above and actually implement TTL + deletion + audit proof for it this week. That’s the difference between a demo and a system you can defend.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.kunalganglani.com/blog/data-privacy-rag-redaction-retention?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=data-privacy-rag-redaction-retention" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>rag</category>
      <category>privacy</category>
      <category>llmops</category>
      <category>datasecurity</category>
    </item>
    <item>
      <title>AI Engineering Evals: Regression Gates for Prompts, Tools, RAG [2026]</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Mon, 10 Aug 2026 12:44:29 +0000</pubDate>
      <link>https://dev.to/kunal_d6a8fea2309e1571ee7/ai-engineering-evals-regression-gates-for-prompts-tools-rag-2026-3pn6</link>
      <guid>https://dev.to/kunal_d6a8fea2309e1571ee7/ai-engineering-evals-regression-gates-for-prompts-tools-rag-2026-3pn6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://www.kunalganglani.com/blog/ai-engineering-evals-gates" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt; — read it there for inline code, hero image, and live links.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;AI engineering evals testing regression gates is the unsexy discipline that keeps LLM apps from randomly breaking when you change a prompt, add a tool, refresh a RAG index, or your provider silently upgrades a model. Backend teams have lived in a world of CI gates for a decade. LLM teams are still shipping vibes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Regression testing for LLM prompts works when you treat prompts as versioned artifacts and gate changes on a golden set, not on a single “looks good” manual check.&lt;/li&gt;
&lt;li&gt;Tool calling needs contract tests: JSON Schema, versioned outputs, and recorded “golden tool traces” so CI can be deterministic.&lt;/li&gt;
&lt;li&gt;RAG evaluation is two systems: retrieval and generation. If you don’t score retrieval, you’ll keep blaming the model for your index.&lt;/li&gt;
&lt;li&gt;Shadow evals (dual-run/tee traffic) are the fastest way to catch “model upgrades broke prod” without betting the business on a big-bang rollout.&lt;/li&gt;
&lt;li&gt;Failure budgets make product and engineering agree on what “good enough” means. Without them, every regression is an argument.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;If your LLM app can’t fail a build, it will eventually fail your customers.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The regression gate mindset: treat prompts, tools, and RAG like APIs
&lt;/h2&gt;

&lt;p&gt;Most teams talk about “LLM evals” like it’s a research problem. In practice it’s an engineering problem: preventing unintended behavior changes.&lt;/p&gt;

&lt;p&gt;A regression gate is just a pass/fail checkpoint in CI that blocks a release when quality drops beyond an agreed threshold. The mental model that finally clicked for me is: &lt;strong&gt;prompts, tool outputs, and retrieval results are all de facto public APIs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;When you ship a prompt change, you changed an API.&lt;br&gt;
When you add a tool or alter its schema, you changed an API.&lt;br&gt;
When you re-embed documents, change chunking, swap your vector database, or adjust re-ranking, you changed an API.&lt;/p&gt;

&lt;p&gt;The reason this is hard is stochastic outputs. But “stochastic” does not mean “untestable”. It means your gates need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;fixed datasets (golden sets)&lt;/li&gt;
&lt;li&gt;versioned artifacts (prompt templates, tool schemas, retrieval configs)&lt;/li&gt;
&lt;li&gt;evaluation harnesses (replay + judges)&lt;/li&gt;
&lt;li&gt;release criteria (failure budgets)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This isn’t theoretical. I’ve built multi-stage RAG pipelines that handle &lt;strong&gt;millions of queries daily&lt;/strong&gt; with &lt;strong&gt;sub-second responses&lt;/strong&gt; and a measured &lt;strong&gt;400% product engagement lift&lt;/strong&gt; (Walmart conversational commerce chatbot at Firework/Zealsight). At that scale, you learn quickly that the biggest outages aren’t “the model got worse”. They’re “we changed something small and didn’t have a gate.”&lt;/p&gt;

&lt;p&gt;If you want a deeper foundation on how I think about “shipping” LLM systems, start with &lt;a href="https://dev.to/pillars/ai-engineering-production"&gt;AI in production&lt;/a&gt; and my broader take on &lt;a href="https://dev.to/pillars/ai-engineering-production"&gt;production AI&lt;/a&gt; tradeoffs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regression testing for LLM prompts (without pretending outputs are deterministic)
&lt;/h2&gt;

&lt;p&gt;Prompt regressions usually come from three sources:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;instruction drift&lt;/strong&gt; (you changed tone/format rules and broke downstream parsing)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;policy drift&lt;/strong&gt; (refusals, safety, compliance wording)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;task drift&lt;/strong&gt; (the model stops doing the core job as reliably)&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  How do you do regression testing for LLM prompts?
&lt;/h3&gt;

&lt;p&gt;I use a boring pattern that works:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Curate a golden set of inputs.&lt;/li&gt;
&lt;li&gt;Store a “golden” expected behavior in a format you can score.&lt;/li&gt;
&lt;li&gt;Run a fixed evaluation harness in CI on every prompt change.&lt;/li&gt;
&lt;li&gt;Fail the build when quality drops beyond your failure budget.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The trick is step 2. For prompts, your “expected output” is rarely a single exact string. It’s usually:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;a required structure (headings, JSON fields, bullet count)&lt;/li&gt;
&lt;li&gt;must-include / must-not-include constraints&lt;/li&gt;
&lt;li&gt;rubric scoring (0–5) from a judge model&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re building &lt;a href="https://dev.to/pillars/ai-agents"&gt;AI agents&lt;/a&gt;, prompts are often only one piece of the loop. Still, prompt behavior is the cheapest thing to gate early.&lt;/p&gt;

&lt;h3&gt;
  
  
  What’s the difference between unit tests and LLM evals?
&lt;/h3&gt;

&lt;p&gt;Unit tests are deterministic and local. LLM evals are probabilistic and behavioral.&lt;/p&gt;

&lt;p&gt;A unit test asks: “Does this function return X for input Y?”&lt;br&gt;
An LLM eval asks: “Does the system reliably produce outputs with properties P, under distribution D, within budget B?”&lt;/p&gt;

&lt;p&gt;That’s why you need artifacts and slices. “It worked once in the playground” is not a test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Practical prompt gates I’ve seen work
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Format gate:&lt;/strong&gt; 99%+ of outputs must parse into the target schema (or match a regex). For structured generation, this is where newer APIs help. More on that in the tool section.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refusal gate:&lt;/strong&gt; refusal rate must stay within a narrow band. For example, if your baseline refusal rate is 1.5%, you may set a budget of +0.5% absolute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content safety gate:&lt;/strong&gt; a red-team slice must remain “clean” (0 critical failures). Tie this to your &lt;a href="https://dev.to/blog/ai-security-complete-guide"&gt;AI security&lt;/a&gt; posture.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency/cost gate:&lt;/strong&gt; if prompt expansion increases average tokens by 20%, it’s a regression even if quality goes up.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This overlaps heavily with my other work on evaluation harnesses. If you’re already thinking in replay/CI terms, read &lt;a href="https://dev.to/blog/agent-evaluation-harness-replay"&gt;Agent Evaluation Harness&lt;/a&gt; and &lt;a href="https://dev.to/blog/ai-agent-evaluation-framework-2026"&gt;AI agent evaluation&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Golden datasets: what they are, and how to curate them without fooling yourself
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What is a golden dataset for LLM evaluation?
&lt;/h3&gt;

&lt;p&gt;A golden dataset is a versioned set of real (or representative) inputs paired with expected behaviors and labels, used to detect regressions across prompt/model/tool/RAG changes.&lt;/p&gt;

&lt;p&gt;The biggest mistake is making your golden set “pretty”. Pretty sets don’t catch production failures.&lt;/p&gt;

&lt;p&gt;Here’s the curation approach that’s saved me the most pain:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start with production logs.&lt;/strong&gt; If you don’t have them, you don’t have a product. Instrument your system (see &lt;a href="https://dev.to/blog/ai-agent-observability-logging-schema"&gt;AI agent observability logging schema&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stratify.&lt;/strong&gt; Break inputs into slices: common, long-tail, high-value customers, risky intents, edge cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add adversarial slices.&lt;/strong&gt; Include attempts at &lt;a href="https://dev.to/blog/prompt-injection-2026-owasp-llm-vulnerability"&gt;prompt injection&lt;/a&gt; and tool abuse. This isn’t optional if you ship tool use.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Label minimally, score intelligently.&lt;/strong&gt; Labels are expensive. Use rubric + judge where you can. Use exact labels where you must.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version everything.&lt;/strong&gt; Dataset v1, v2, etc. You want to compare gates across time.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Concrete numbers: for most teams, you can get meaningful signal with &lt;strong&gt;200–500 examples&lt;/strong&gt; per major capability. For a core business workflow, I like &lt;strong&gt;1,000+&lt;/strong&gt; with slices. Below &lt;strong&gt;100&lt;/strong&gt;, you’re basically doing vibes.&lt;/p&gt;

&lt;p&gt;For a site-level anchor: the keyword neighborhood for this topic already has traction on this blog. Based on a Google Search Console neighborhood estimate I maintain internally (via &lt;code&gt;score_keyword_winnability()&lt;/code&gt;), this topic cluster shows &lt;strong&gt;~678 related impressions&lt;/strong&gt; and an estimated &lt;strong&gt;~3,100 searches/month&lt;/strong&gt; across &lt;strong&gt;143 related queries&lt;/strong&gt;. That’s exactly why I’m writing this as a concrete playbook instead of another “evals are important” essay.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pass/fail thresholds when outputs are stochastic (failure budgets beat arguments)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do you set pass/fail thresholds when outputs are stochastic?
&lt;/h3&gt;

&lt;p&gt;Stop trying to pick a single perfect metric. Use &lt;strong&gt;failure budgets&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;A failure budget is an agreed allowance for regressions on a slice, similar to an SLO error budget in SRE. It forces tradeoffs into the open.&lt;/p&gt;

&lt;p&gt;Examples I’ve used with product teams:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“On the checkout-support slice (n=250), we allow at most &lt;strong&gt;2%&lt;/strong&gt; absolute drop in task success.”&lt;/li&gt;
&lt;li&gt;“On the compliance slice (n=120), we allow &lt;strong&gt;0&lt;/strong&gt; critical failures, even if overall score improves.”&lt;/li&gt;
&lt;li&gt;“On the long-tail slice (n=400), we allow a &lt;strong&gt;5%&lt;/strong&gt; drop if latency improves by &lt;strong&gt;15%&lt;/strong&gt;.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The important part is that product signs this. Otherwise you’re stuck in endless debates about whether a regression “matters”.&lt;/p&gt;

&lt;h3&gt;
  
  
  A release criteria template that product teams can actually agree on
&lt;/h3&gt;

&lt;p&gt;I keep it short:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Primary metric:&lt;/strong&gt; task success or rubric score (by slice)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hard blockers:&lt;/strong&gt; critical policy/security failures, schema parse failures&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budgets:&lt;/strong&gt; max regression allowed per slice&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost/latency:&lt;/strong&gt; token delta and p95 latency delta&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rollback triggers:&lt;/strong&gt; what will make us revert in production&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re already tracking &lt;a href="https://dev.to/blog/ai-agent-cost-per-task-2026"&gt;LLM cost&lt;/a&gt; or trying to reduce it, tie gates to cost regression. In creative-generation systems I shipped (Firework’s AI short-video generation platform), the bill was dominated by &lt;strong&gt;retries and regeneration&lt;/strong&gt;, not first-pass tokens. Gates that ignore retries miss the real cost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Tool calling contract testing: JSON Schema, versioning, and golden tool traces
&lt;/h2&gt;

&lt;p&gt;Tool calling is where most “LLM testing” advice falls apart. Because tools turn your app into a distributed system with an LLM as the orchestrator.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you test tool-calling / function-calling outputs?
&lt;/h3&gt;

&lt;p&gt;Treat tool calls like you’d treat an external API client:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;validate request shape&lt;/li&gt;
&lt;li&gt;validate response shape&lt;/li&gt;
&lt;li&gt;record and replay known-good traces&lt;/li&gt;
&lt;li&gt;test backward compatibility&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In practice, I like three layers:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Schema gate (hard):&lt;/strong&gt; tool call arguments must validate against JSON Schema. If your model supports constrained outputs, turn it on.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trace gate (deterministic):&lt;/strong&gt; replay recorded tool sequences against a mock tool server.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Behavior gate (soft):&lt;/strong&gt; score whether the tool was called when it should have been called.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Tool calling contract testing with JSON Schema (and why versioning matters)
&lt;/h3&gt;

&lt;p&gt;A schema without versioning is a trap.&lt;/p&gt;

&lt;p&gt;You need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;tool_name&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;&lt;code&gt;schema_version&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;explicit optional vs required fields&lt;/li&gt;
&lt;li&gt;backward-compatible evolution rules&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Rules of thumb I enforce:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You can add optional fields in &lt;code&gt;v1&lt;/code&gt; without breaking.&lt;/li&gt;
&lt;li&gt;You cannot change field meaning without bumping version.&lt;/li&gt;
&lt;li&gt;You cannot remove fields without a deprecation window.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the same discipline you already use in &lt;a href="https://dev.to/glossary/microservices"&gt;microservices&lt;/a&gt;. The only difference is the “client” is an LLM that will happily hallucinate fields unless you constrain it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Golden tool traces: the missing artifact
&lt;/h3&gt;

&lt;p&gt;The artifact most teams don’t have is a golden trace:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;prompt + system instructions&lt;/li&gt;
&lt;li&gt;tool schemas&lt;/li&gt;
&lt;li&gt;the exact tool calls emitted (arguments)&lt;/li&gt;
&lt;li&gt;the tool responses (or mocks)&lt;/li&gt;
&lt;li&gt;final user-visible output&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Store this as JSON. Version it. When a provider upgrades a model and tool-call formatting shifts, your CI should catch it.&lt;/p&gt;

&lt;p&gt;If you’re building agentic systems, also read &lt;a href="https://dev.to/blog/ai-agent-control-flow-patterns"&gt;agent orchestration&lt;/a&gt; patterns and &lt;a href="https://dev.to/blog/langgraph-crewai-autogen-pydanticai"&gt;agent framework&lt;/a&gt; tradeoffs. Tool use is where frameworks earn their keep.&lt;/p&gt;

&lt;h2&gt;
  
  
  RAG evaluation beyond “answer correctness”: retrieval, faithfulness, and context metrics
&lt;/h2&gt;

&lt;p&gt;Retrieval-Augmented Generation (RAG) is the fastest way to ship knowledge updates, and also the fastest way to ship silent regressions.&lt;/p&gt;

&lt;h3&gt;
  
  
  How do you evaluate RAG systems reliably?
&lt;/h3&gt;

&lt;p&gt;You evaluate two things:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval quality&lt;/strong&gt;: did we fetch the right context?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generation quality&lt;/strong&gt;: did the model use the context faithfully?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;In the Walmart chatbot work, the pattern was blunt: &lt;strong&gt;retrieval quality dominated answer quality at scale&lt;/strong&gt;. Model swaps moved the needle less than fixing chunking, metadata, and ranking.&lt;/p&gt;

&lt;h3&gt;
  
  
  Metrics that actually matter for RAG gates
&lt;/h3&gt;

&lt;p&gt;You don’t need 30 metrics. You need a few that map to failure modes.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Context precision:&lt;/strong&gt; what fraction of retrieved chunks are actually relevant?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context recall:&lt;/strong&gt; did we retrieve the necessary chunk at all?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Answer faithfulness:&lt;/strong&gt; are claims grounded in the provided context?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Citation coverage:&lt;/strong&gt; if you require citations, what % of answers include them?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency:&lt;/strong&gt; retrieval adds time. Track p95 end-to-end.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Concrete guidance: for a production RAG system, I like a gate where &lt;strong&gt;context recall&lt;/strong&gt; on key slices stays above &lt;strong&gt;90%&lt;/strong&gt;. If recall drops to &lt;strong&gt;80%&lt;/strong&gt;, you’ll feel it in support tickets.&lt;/p&gt;

&lt;p&gt;If you want to go deeper on the security side of RAG, see &lt;a href="https://dev.to/blog/rag-context-window-limitations"&gt;RAG&lt;/a&gt; failure patterns and my playbook to &lt;a href="https://dev.to/blog/prevent-sensitive-data-leakage-rag"&gt;prevent sensitive data leakage in RAG&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running evals in CI/CD without spending a fortune (or waiting 40 minutes)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do you run LLM evals in CI/CD pipelines?
&lt;/h3&gt;

&lt;p&gt;Make CI evals fast and cheap by design:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Small, high-signal golden set:&lt;/strong&gt; 200–500 examples for PR gates.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bigger nightly suite:&lt;/strong&gt; 2,000–10,000 examples with slice reports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model routing:&lt;/strong&gt; use cheaper judge models for coarse scoring, escalate only on borderline cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Caching:&lt;/strong&gt; cache tool mocks, retrieval results, and judge outputs when inputs are identical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time limits:&lt;/strong&gt; treat slow evals as failing infrastructure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A pattern I like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;PR gate must finish in &lt;strong&gt;&amp;lt;10 minutes&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Nightly suite can take &lt;strong&gt;1–2 hours&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Shadow evals run continuously but sampled.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your team is already strong at CI/CD, borrow the discipline from non-LLM pipelines. I’ve seen the same cultural trick work repeatedly: once a gate can fail a build, engineers suddenly treat quality as “real.”&lt;/p&gt;

&lt;p&gt;For the infra-minded, my posts on &lt;a href="https://dev.to/blog/github-actions-vs-circleci"&gt;CI/CD&lt;/a&gt; and &lt;a href="https://dev.to/blog/docker-compose-kubernetes-ai-ml-2026"&gt;Kubernetes&lt;/a&gt; choices show the same principle: pick the simplest thing that keeps you shipping safely.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shadow evals in production: dual-run, traffic tee, and slice analysis
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What are shadow evals and how do you implement them?
&lt;/h3&gt;

&lt;p&gt;Shadow evals are when you run a candidate model/prompt/RAG config in parallel with production, on the same inputs, without affecting the user-visible output. Then you compare.&lt;/p&gt;

&lt;p&gt;Implementation blueprint:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Tee traffic&lt;/strong&gt;: copy a sample of requests to the shadow pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Redact&lt;/strong&gt;: strip PII and secrets before storing for eval. This should reuse your logging redaction rules (see &lt;a href="https://dev.to/blog/opentelemetry-ai-agents-instrumentation"&gt;OpenTelemetry instrumentation for AI agents&lt;/a&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Replay deterministically&lt;/strong&gt;: freeze tool responses where possible.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Judge offline&lt;/strong&gt;: score deltas by slice, not just globally.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Alert on drift&lt;/strong&gt;: if a slice crosses the failure budget, block rollout.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Sampling numbers that are realistic:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Start with &lt;strong&gt;0.5%–2%&lt;/strong&gt; of traffic.&lt;/li&gt;
&lt;li&gt;For high-risk flows, go up to &lt;strong&gt;5%&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;For expensive agentic flows, sample by customer tier or intent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Shadow evals are also where you catch provider/model drift that you didn’t initiate. This is the real fix for “model upgrades broke prod.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollback and kill switches: what you do when gates fail after deploy
&lt;/h2&gt;

&lt;p&gt;Gates reduce risk. They don’t eliminate it.&lt;/p&gt;

&lt;p&gt;You need a plan for when production still drifts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kill switch:&lt;/strong&gt; ability to disable tool use or fall back to a simpler path.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Model pinning:&lt;/strong&gt; pin model versions where your provider supports it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Config rollback:&lt;/strong&gt; prompt and retrieval configs must be deployable artifacts with version history.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Graceful degradation:&lt;/strong&gt; return “I’m not confident” instead of hallucinating.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’m opinionated here: if you can’t roll back a prompt or retrieval config in under &lt;strong&gt;5 minutes&lt;/strong&gt;, you’re not operating a production system. You’re doing experiments on customers.&lt;/p&gt;

&lt;p&gt;This is also where &lt;a href="https://dev.to/glossary/ai-security"&gt;AI security&lt;/a&gt; intersects. A kill switch isn’t just for quality regressions. It’s for incidents.&lt;/p&gt;

&lt;h2&gt;
  
  
  Drift monitoring and refreshing golden sets (without turning evals into a second product)
&lt;/h2&gt;

&lt;h3&gt;
  
  
  How do you monitor for drift and refresh golden sets?
&lt;/h3&gt;

&lt;p&gt;Golden sets rot. User behavior changes, catalog/content changes, new intents appear.&lt;/p&gt;

&lt;p&gt;A simple governance loop:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Weekly:&lt;/strong&gt; add top new failure cases (from logs) to an “incoming” bucket.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Monthly:&lt;/strong&gt; promote a subset to the golden set after labeling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quarterly:&lt;/strong&gt; re-balance slices and retire dead cases.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Drift signals to watch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;spike in fallback/deflection rate&lt;/li&gt;
&lt;li&gt;increase in tool error rate&lt;/li&gt;
&lt;li&gt;increase in “no relevant context” retrieval outcomes&lt;/li&gt;
&lt;li&gt;distribution shift in input lengths or languages&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you want to connect this to the broader agent world: drift is amplified in &lt;a href="https://dev.to/pillars/ai-agents"&gt;agentic AI&lt;/a&gt; systems because the model’s actions change the environment (tools, memory, retries). That’s why your eval harness must capture traces, not just final answers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Regression gates for LLM apps in CI (7 steps)
&lt;/h2&gt;

&lt;p&gt;Here’s the playbook I’d implement on any serious LLM team:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Define artifacts:&lt;/strong&gt; prompt templates, tool schemas, retrieval configs, dataset versions.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build a golden set:&lt;/strong&gt; stratified + adversarial slices, versioned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add hard gates:&lt;/strong&gt; schema parse rate, critical safety failures, tool contract validation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Add soft gates:&lt;/strong&gt; rubric score deltas, task success deltas, refusal deltas.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set failure budgets:&lt;/strong&gt; per slice, signed off by product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run CI + nightly:&lt;/strong&gt; small PR suite, bigger nightly suite with reports.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run shadow evals:&lt;/strong&gt; tee traffic, slice analysis, rollback triggers.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And yes: this is more work than tweaking prompts in a notebook. It’s also how you stop waking up to “something changed and we don’t know what.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Comparison table: gates by layer (prompts vs tools vs RAG)
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Primary failure mode&lt;/th&gt;
&lt;th&gt;Best gate artifact&lt;/th&gt;
&lt;th&gt;Hard pass/fail signal&lt;/th&gt;
&lt;th&gt;“Soft” quality signal&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Prompts&lt;/td&gt;
&lt;td&gt;Format/policy/task drift&lt;/td&gt;
&lt;td&gt;Golden input set + rubrics&lt;/td&gt;
&lt;td&gt;Parse rate ≥ 99%, 0 critical policy failures&lt;/td&gt;
&lt;td&gt;Judge rubric score delta by slice&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool calling&lt;/td&gt;
&lt;td&gt;Hallucinated args, schema drift, wrong tool choice&lt;/td&gt;
&lt;td&gt;JSON Schema + golden tool traces&lt;/td&gt;
&lt;td&gt;100% schema-valid calls, deterministic trace replay&lt;/td&gt;
&lt;td&gt;Correct tool selection rate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;RAG&lt;/td&gt;
&lt;td&gt;Wrong/missing context, hallucination despite context&lt;/td&gt;
&lt;td&gt;Labeled retrieval set + retrieval logs&lt;/td&gt;
&lt;td&gt;Context recall ≥ 90% on key slices&lt;/td&gt;
&lt;td&gt;Faithfulness + citation coverage&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I think happens next
&lt;/h2&gt;

&lt;p&gt;Newer APIs pushing structured outputs and stronger tool calling are going to make “contract testing for LLM apps” normal. Not optional. Normal.&lt;/p&gt;

&lt;p&gt;My prediction: within 12 months, teams that still treat prompts as ad-hoc strings will look as outdated as teams shipping backend changes without CI. The winners won’t have the fanciest model. They’ll have the tightest regression gates.&lt;/p&gt;

&lt;p&gt;If you’re building LLM features today, pick one gate to ship this week. A tiny golden set and a schema check is enough to start. Then keep going until your app can fail a build before it fails a customer.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.kunalganglani.com/blog/ai-engineering-evals-gates?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=ai-engineering-evals-gates" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llmops</category>
      <category>testing</category>
      <category>evals</category>
      <category>cicd</category>
    </item>
    <item>
      <title>AI Agent Observability Logging Schema [2026]: OTel + Redaction</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Mon, 10 Aug 2026 00:44:30 +0000</pubDate>
      <link>https://dev.to/kunal_d6a8fea2309e1571ee7/ai-agent-observability-logging-schema-2026-otel-redaction-l21</link>
      <guid>https://dev.to/kunal_d6a8fea2309e1571ee7/ai-agent-observability-logging-schema-2026-otel-redaction-l21</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://www.kunalganglani.com/blog/ai-agent-observability-logging-schema" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt; — read it there for inline code, hero image, and live links.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;AI agent observability logging schema is the difference between “we shipped an agent” and “we can debug it at 2 a.m. without leaking customer data.” I’ve watched too many teams celebrate the demo and then faceplant the first time the agent starts looping, burning tokens, and timing out in production.&lt;/p&gt;

&lt;p&gt;Here’s the nasty part: the most tempting thing to log (raw prompts and tool payloads) is also the easiest way to spray secrets into your telemetry pipeline. If you’re not treating your logs as a data exfiltration channel, you’re doing security theater.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Treat an agent run like a distributed trace. One trace per run, spans for planning/LLM/tool/retrieval steps, and stable IDs everywhere.&lt;/li&gt;
&lt;li&gt;Default-on production logging should be metadata-only. Full prompt/tool content must be opt-in, short-lived, access-controlled, and aggressively redacted.&lt;/li&gt;
&lt;li&gt;Tool I/O logging should be allowlist-first, with redaction transforms in the SDK and again in the collector (defense in depth).&lt;/li&gt;
&lt;li&gt;Sampling for agents should be head-based by default, with tail sampling triggered by failures, retries, high cost, and “stuck loop” signatures.&lt;/li&gt;
&lt;li&gt;If you align your fields to &lt;code&gt;gen_ai.*&lt;/code&gt; and &lt;code&gt;mcp.*&lt;/code&gt; now, you avoid migration churn as OpenTelemetry GenAI conventions harden.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;If your agent is a distributed system, your observability has to be one too.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What is LLM Observability?
&lt;/h2&gt;

&lt;p&gt;LLM observability is the practice of instrumenting large language model-backed systems so you can understand behavior, performance, quality, and cost in production.&lt;/p&gt;

&lt;p&gt;For agents, it’s not “prompt + response.” That mental model is how you end up with a pile of useless logs and a compliance incident. An agent run is a sequence of decisions: planning, tool calls, retrieval, retries, policy checks, and some final outcome you can grade.&lt;/p&gt;

&lt;p&gt;This matters more in 2026 than it did even a year ago. OpenTelemetry’s GenAI semantic conventions now live in a dedicated repository, &lt;code&gt;open-telemetry/semantic-conventions-genai&lt;/code&gt;. They include explicit agent spans, events, and Model Context Protocol (MCP) context propagation guidance. That’s where the ecosystem is going. If your telemetry vocabulary drifts from it now, you’re signing yourself up for a migration later. Migration taxes are real. I hate them.&lt;/p&gt;

&lt;p&gt;Security-wise, OWASP’s GenAI LLM Top 10 2026 dropped on &lt;strong&gt;August 4, 2026&lt;/strong&gt;, and the project has grown to &lt;strong&gt;600+ contributing experts&lt;/strong&gt; across &lt;strong&gt;18+ countries&lt;/strong&gt; with &lt;strong&gt;nearly 8,000&lt;/strong&gt; active community members. Translation: prompt/data leakage is no longer a “maybe.” It’s a thing that happens to real companies, on real timelines, with real consequences.&lt;/p&gt;

&lt;h2&gt;
  
  
  Traditional vs. LLM Observability: What’s the Difference?
&lt;/h2&gt;

&lt;p&gt;Traditional observability assumes requests are mostly deterministic. Same inputs, mostly same outputs. If something breaks, traces and logs usually tell a reasonably linear story.&lt;/p&gt;

&lt;p&gt;Agents don’t.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Nondeterminism is normal.&lt;/strong&gt; Temperature, tool timing, retrieval variance, model drift. Two “identical” runs can diverge in annoying ways.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control flow is dynamic.&lt;/strong&gt; Agents decide what to do next. One user request can fan out into 3 tool calls or 30.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Payloads are radioactive.&lt;/strong&gt; Prompts contain user data, internal instructions, credentials you didn’t know were there, and compliance landmines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost is a first-class metric.&lt;/strong&gt; Token usage and tool spend can spike silently with retries, self-corrections, or loops.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So yes, you still need traces, metrics, and logs. But you need a schema that assumes the run is messy, and defaults to not leaking sensitive data.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Five Pillars of LLM Observability
&lt;/h2&gt;

&lt;p&gt;I like the “five pillars” framing because it forces you to stop obsessing over whether you captured the prompt, and start measuring what actually matters.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Reliability debugging&lt;/strong&gt;: where runs fail, where they loop, which tool calls are flaky.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Latency&lt;/strong&gt;: not just end-to-end. Step-level breakdowns, especially retrieval and tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Quality&lt;/strong&gt;: outcomes, rubric scores, user feedback, automated checks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cost&lt;/strong&gt;: tokens, retries, tool spend, per-tenant attribution.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security &amp;amp; compliance&lt;/strong&gt;: redaction, access control, retention tiers, auditability.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you only do the first three, you’ll ship faster. If you ignore the last two, you’ll eventually have an incident. Not because you’re unlucky. Because your logging pipeline becomes your leak.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting Started: the schema I’d ship in a weekend
&lt;/h2&gt;

&lt;p&gt;You can implement this in two layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Application logging contract&lt;/strong&gt;: JSON events you emit from your agent runtime&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;OpenTelemetry mapping&lt;/strong&gt;: trace/span/attribute conventions that make it portable across vendors&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The point isn’t “pick the perfect vendor.” The point is you have a contract your teams follow, and you can move backends without rewriting your entire debugging story.&lt;/p&gt;

&lt;h3&gt;
  
  
  The schema levels
&lt;/h3&gt;

&lt;p&gt;Think in four levels:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Run&lt;/strong&gt;: the whole agent invocation (one trace)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step&lt;/strong&gt;: planning / reasoning / decide-next-action (spans)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool call&lt;/strong&gt;: external side effects (spans)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Content blobs&lt;/strong&gt;: raw prompt/response/tool payloads (almost never in spans)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Agent observability logging schema table
&lt;/h3&gt;

&lt;p&gt;This is the minimum viable contract. Copy it into your internal docs and make teams conform to it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;When emitted&lt;/th&gt;
&lt;th&gt;Required fields&lt;/th&gt;
&lt;th&gt;Optional fields&lt;/th&gt;
&lt;th&gt;Redaction rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Run (trace root)&lt;/td&gt;
&lt;td&gt;Start + end of agent run&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;run_id&lt;/code&gt;, &lt;code&gt;trace_id&lt;/code&gt;, &lt;code&gt;tenant_id&lt;/code&gt;, &lt;code&gt;environment&lt;/code&gt;, &lt;code&gt;agent.name&lt;/code&gt;, &lt;code&gt;agent.version&lt;/code&gt;, &lt;code&gt;start_ts&lt;/code&gt;, &lt;code&gt;end_ts&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;user_id&lt;/code&gt;, &lt;code&gt;session_id&lt;/code&gt;, &lt;code&gt;request_id&lt;/code&gt;, &lt;code&gt;deployment.sha&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No raw user text. Store only stable IDs and hashes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Step span&lt;/td&gt;
&lt;td&gt;Each logical step&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;run_id&lt;/code&gt;, &lt;code&gt;span_id&lt;/code&gt;, &lt;code&gt;parent_span_id&lt;/code&gt;, &lt;code&gt;step.type&lt;/code&gt;, &lt;code&gt;start_ts&lt;/code&gt;, &lt;code&gt;end_ts&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;retry.count&lt;/code&gt;, &lt;code&gt;loop.iteration&lt;/code&gt;, &lt;code&gt;policy.decision&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Metadata-only by default.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LLM span&lt;/td&gt;
&lt;td&gt;Each model call&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;gen_ai.request.model&lt;/code&gt;, &lt;code&gt;gen_ai.operation.name&lt;/code&gt;, &lt;code&gt;tokens.in&lt;/code&gt;, &lt;code&gt;tokens.out&lt;/code&gt;, &lt;code&gt;latency_ms&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;temperature&lt;/code&gt;, &lt;code&gt;top_p&lt;/code&gt;, &lt;code&gt;cache.hit&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;No prompts in attributes. Content via opt-in event/blob.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool span&lt;/td&gt;
&lt;td&gt;Each tool execution&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tool.name&lt;/code&gt;, &lt;code&gt;tool.status&lt;/code&gt;, &lt;code&gt;latency_ms&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;tool.error_class&lt;/code&gt;, &lt;code&gt;tool.cost_usd&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Allowlist tool fields. Redact everything else.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retrieval span (RAG)&lt;/td&gt;
&lt;td&gt;Each retrieval query&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;retrieval.source&lt;/code&gt;, &lt;code&gt;retrieval.count&lt;/code&gt;, &lt;code&gt;latency_ms&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;vector_db&lt;/code&gt;, &lt;code&gt;reranker&lt;/code&gt;, &lt;code&gt;top_k&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Don’t log document text. Log doc IDs + hashes.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content blob&lt;/td&gt;
&lt;td&gt;Only when debugging/approval&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;blob_id&lt;/code&gt;, &lt;code&gt;run_id&lt;/code&gt;, &lt;code&gt;blob.type&lt;/code&gt;, &lt;code&gt;ttl_hours&lt;/code&gt;, &lt;code&gt;kms_key_id&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;content_sha256&lt;/code&gt;, &lt;code&gt;redaction.summary&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;Encrypt + TTL + strict ACL. No “forever logs.”&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;A few numbers to make this concrete:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ttl_hours&lt;/code&gt;&lt;/strong&gt;: I default to &lt;strong&gt;24 hours&lt;/strong&gt; in prod for content blobs. Debuggability without building a compliance nightmare.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;top_k&lt;/code&gt;&lt;/strong&gt;: log it when you do retrieval. I’ve seen RAG regressions caused by a quiet shift from &lt;strong&gt;5&lt;/strong&gt; to &lt;strong&gt;20&lt;/strong&gt; retrieved chunks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;loop.iteration&lt;/code&gt;&lt;/strong&gt;: if your agent can loop, you need a counter. An iteration cap of &lt;strong&gt;8–12&lt;/strong&gt; is a sane default for many workflows.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What NOT to log (and what to log instead)
&lt;/h3&gt;

&lt;p&gt;This is where most teams screw it up.&lt;/p&gt;

&lt;p&gt;Don’t log:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;System prompts verbatim&lt;/li&gt;
&lt;li&gt;Chain-of-thought / private reasoning&lt;/li&gt;
&lt;li&gt;Raw user content by default&lt;/li&gt;
&lt;li&gt;Raw tool payloads by default (especially anything that can contain credentials)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Log instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;prompt_template_id&lt;/code&gt; and &lt;code&gt;prompt_template_version&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;prompt_hash&lt;/code&gt; (hash of the rendered prompt after deterministic normalization)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;input_classification&lt;/code&gt; (e.g., &lt;code&gt;public&lt;/code&gt;, &lt;code&gt;internal&lt;/code&gt;, &lt;code&gt;restricted&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tool.payload_schema_version&lt;/code&gt; plus allowlisted fields&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The goal is to make prompts diffable without making them readable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set up tracing with OpenTelemetry (minimal but correct)
&lt;/h2&gt;

&lt;p&gt;OpenTelemetry gives you a portable shape: trace → spans → events → attributes. The GenAI semantic conventions extend that shape with vocabulary that’s actually relevant to model calls and agent steps.&lt;/p&gt;

&lt;p&gt;Authoritative baseline: the &lt;a href="https://opentelemetry.io/docs/specs/otel/" rel="noopener noreferrer"&gt;OpenTelemetry community&lt;/a&gt; specification.&lt;/p&gt;

&lt;h3&gt;
  
  
  Trace boundaries
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One trace per agent run.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;Root span name: &lt;code&gt;agent.run {agent.name}&lt;/code&gt; (internal naming). Map to GenAI conventions inside.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Span mapping to GenAI conventions
&lt;/h3&gt;

&lt;p&gt;OpenTelemetry’s GenAI span guidance includes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Agent spans like plan and execute tool spans in the agent spec. See the &lt;code&gt;plan&lt;/code&gt; and &lt;code&gt;execute tool&lt;/code&gt; sections in the &lt;a href="https://raw.githubusercontent.com/open-telemetry/semantic-conventions-genai/main/docs/gen-ai/gen-ai-agent-spans.md" rel="noopener noreferrer"&gt;OpenTelemetry community&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;Client inference spans for model calls with &lt;code&gt;gen_ai.operation.name&lt;/code&gt; and &lt;code&gt;gen_ai.request.model&lt;/code&gt;. See &lt;a href="https://raw.githubusercontent.com/open-telemetry/semantic-conventions-genai/main/docs/gen-ai/gen-ai-spans.md" rel="noopener noreferrer"&gt;OpenTelemetry community&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My minimal mapping:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Root&lt;/strong&gt;: &lt;code&gt;agent.run&lt;/code&gt; (INTERNAL)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan span&lt;/strong&gt;: &lt;code&gt;plan&lt;/code&gt; (INTERNAL)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM inference span&lt;/strong&gt;: &lt;code&gt;{gen_ai.operation.name} {gen_ai.request.model}&lt;/code&gt; (CLIENT or INTERNAL)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Execute tool span&lt;/strong&gt;: &lt;code&gt;execute_tool {tool.name}&lt;/code&gt; (CLIENT if remote)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retrieval span&lt;/strong&gt;: &lt;code&gt;retrieval {retrieval.source}&lt;/code&gt; (INTERNAL)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Attributes you should standardize
&lt;/h3&gt;

&lt;p&gt;At minimum:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Identity: &lt;code&gt;agent.name&lt;/code&gt;, &lt;code&gt;agent.version&lt;/code&gt;, &lt;code&gt;environment&lt;/code&gt;, &lt;code&gt;tenant_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Model: &lt;code&gt;gen_ai.provider.name&lt;/code&gt;, &lt;code&gt;gen_ai.request.model&lt;/code&gt;, &lt;code&gt;gen_ai.operation.name&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Tokens: &lt;code&gt;tokens.in&lt;/code&gt;, &lt;code&gt;tokens.out&lt;/code&gt;, &lt;code&gt;tokens.total&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Cost: &lt;code&gt;cost.usd&lt;/code&gt; at span-level and &lt;code&gt;run.cost.usd&lt;/code&gt; at root&lt;/li&gt;
&lt;li&gt;Control flow: &lt;code&gt;retry.count&lt;/code&gt;, &lt;code&gt;loop.iteration&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Cost note: if you can’t attribute spend per tenant and per user, you don’t have “cost observability.” You have vibes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Events vs. attributes for payloads
&lt;/h3&gt;

&lt;p&gt;The GenAI semantic conventions discuss strategies for capturing instructions/inputs/outputs: full buffered content, attributes, external storage, or streaming chunks.&lt;/p&gt;

&lt;p&gt;The boring answer is the right one: &lt;strong&gt;metadata in attributes; content in external blobs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Use span attributes for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;hashes&lt;/li&gt;
&lt;li&gt;lengths&lt;/li&gt;
&lt;li&gt;MIME types&lt;/li&gt;
&lt;li&gt;schema versions&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Use events only when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;you’re in a controlled dev environment&lt;/li&gt;
&lt;li&gt;you have strict access controls&lt;/li&gt;
&lt;li&gt;you need just enough content for a short time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And even then, redact first.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you trace tool calls in an LLM agent?
&lt;/h2&gt;

&lt;p&gt;Tool calls are where your agent stops being “chat” and starts being “software.” They’re also where you leak secrets if you log like it’s 2015.&lt;/p&gt;

&lt;p&gt;A practical approach:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Create a tool span for every tool invocation.&lt;/li&gt;
&lt;li&gt;Put timing, status, and a stable request ID on the span.&lt;/li&gt;
&lt;li&gt;For request/response payloads, log only allowlisted fields (think: &lt;code&gt;resource_id&lt;/code&gt;, &lt;code&gt;query_type&lt;/code&gt;, &lt;code&gt;result_count&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Store full payloads as encrypted blobs with TTL, and only when explicitly enabled.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you’re using MCP tools/servers, OpenTelemetry recommends context propagation by injecting &lt;code&gt;traceparent&lt;/code&gt;, &lt;code&gt;tracestate&lt;/code&gt;, and &lt;code&gt;baggage&lt;/code&gt; into &lt;code&gt;params._meta&lt;/code&gt; and extracting it on the receiver as the remote parent. See the MCP conventions doc by the &lt;a href="https://raw.githubusercontent.com/open-telemetry/semantic-conventions-genai/main/docs/gen-ai/mcp.md" rel="noopener noreferrer"&gt;OpenTelemetry community&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;That one detail is what makes your tool spans line up cleanly across process boundaries.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do I avoid logging secrets and PII in LLM prompts and tool outputs?
&lt;/h2&gt;

&lt;p&gt;Treat telemetry as a data exfil path. Because it is.&lt;/p&gt;

&lt;p&gt;Here’s the defense-in-depth model that actually holds up under reality (meaning: under deadlines, new services, and humans making mistakes).&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Allowlist-first logging
&lt;/h3&gt;

&lt;p&gt;For each tool, define:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;tool.payload.allowlist.paths&lt;/code&gt; (JSONPaths you permit)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;tool.payload.denylist.paths&lt;/code&gt; (high-risk fields you always drop)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Assume “log nothing” unless explicitly allowed.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) Redaction transforms (SDK-side)
&lt;/h3&gt;

&lt;p&gt;Before anything leaves the process:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;redact credentials (&lt;code&gt;Authorization&lt;/code&gt;, &lt;code&gt;api_key&lt;/code&gt;, &lt;code&gt;token&lt;/code&gt;, &lt;code&gt;cookie&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;redact common PII patterns (emails, phone numbers)&lt;/li&gt;
&lt;li&gt;truncate long strings (cap at &lt;strong&gt;256&lt;/strong&gt; chars for any field)&lt;/li&gt;
&lt;li&gt;hash stable identifiers (SHA-256) when you need correlation without disclosure&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3) Redaction transforms again (collector-side)
&lt;/h3&gt;

&lt;p&gt;Someone will bypass your SDK. Or a new service will ship without it. So you do it again in the OpenTelemetry Collector:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;drop known sensitive attributes&lt;/li&gt;
&lt;li&gt;enforce max attribute size&lt;/li&gt;
&lt;li&gt;enforce environment-based policies (prod stricter than dev)&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  4) Don’t put secrets in prompts
&lt;/h3&gt;

&lt;p&gt;This sounds obvious until you see real systems.&lt;/p&gt;

&lt;p&gt;Secrets belong in a secret manager and should never be embedded into prompts. The authoritative baseline is &lt;a href="https://cloud.google.com/secret-manager/docs/best-practices" rel="noopener noreferrer"&gt;Google Cloud&lt;/a&gt;: minimize access, rotate, audit, and never leak via logs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What are spans and traces in OpenTelemetry?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;A &lt;strong&gt;trace&lt;/strong&gt; is a tree representing one end-to-end operation.&lt;/li&gt;
&lt;li&gt;A &lt;strong&gt;span&lt;/strong&gt; is one timed unit of work within that trace.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For agents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;trace = one run&lt;/li&gt;
&lt;li&gt;spans = planning step, model call, retrieval, tool execution, policy check&lt;/li&gt;
&lt;li&gt;logs/events = details you might want to attach (carefully)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you adopt that mental model, most schema decisions stop being mysterious.&lt;/p&gt;

&lt;h2&gt;
  
  
  What is tail-based sampling and when should I use it?
&lt;/h2&gt;

&lt;p&gt;Head-based sampling decides at the start. Tail-based sampling decides after the trace finishes, based on what happened.&lt;/p&gt;

&lt;p&gt;For agents, tail sampling is ridiculously effective because “interesting” traces are rare but expensive:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;failures&lt;/li&gt;
&lt;li&gt;high retry count (&amp;gt;= &lt;strong&gt;2&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;loop iterations above a threshold (&amp;gt;= &lt;strong&gt;6&lt;/strong&gt;)&lt;/li&gt;
&lt;li&gt;high cost (e.g., &lt;code&gt;run.cost.usd&lt;/code&gt; &amp;gt; &lt;strong&gt;$0.25&lt;/strong&gt; for consumer workflows)&lt;/li&gt;
&lt;li&gt;policy violations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Default: head sample low (say &lt;strong&gt;1–5%&lt;/strong&gt;) for volume control.&lt;/p&gt;

&lt;p&gt;Then: tail sample 100% of “bad” runs so you can debug what matters without setting money on fire.&lt;/p&gt;

&lt;h2&gt;
  
  
  How do you correlate logs with traces for debugging?
&lt;/h2&gt;

&lt;p&gt;Use three IDs everywhere:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;trace_id&lt;/code&gt; (OTel)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;run_id&lt;/code&gt; (your business identifier)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;request_id&lt;/code&gt; (edge ingress)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Logs should include &lt;code&gt;trace_id&lt;/code&gt; and &lt;code&gt;span_id&lt;/code&gt;. Your agent events should include &lt;code&gt;run_id&lt;/code&gt;. This lets you start from a user complaint, find the run, then pivot into traces.&lt;/p&gt;

&lt;p&gt;And yes, this is why “one trace per run” is such a powerful simplification.&lt;/p&gt;

&lt;h2&gt;
  
  
  Trace a RAG application (agent + retrieval)
&lt;/h2&gt;

&lt;p&gt;RAG (Retrieval-Augmented Generation) is where observability gets subtle.&lt;/p&gt;

&lt;p&gt;You need to see:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;retrieval latency&lt;/li&gt;
&lt;li&gt;retrieval count (&lt;code&gt;top_k&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;which index/collection you hit&lt;/li&gt;
&lt;li&gt;reranker model (if any)&lt;/li&gt;
&lt;li&gt;how many tokens you spent stuffing context&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But you do &lt;strong&gt;not&lt;/strong&gt; want raw retrieved chunks in traces.&lt;/p&gt;

&lt;p&gt;Instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;log &lt;code&gt;doc_id&lt;/code&gt; and &lt;code&gt;doc_hash&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;log &lt;code&gt;chunk_id&lt;/code&gt; and &lt;code&gt;chunk_hash&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;log &lt;code&gt;retrieval.query_hash&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then store actual content only behind explicit gates.&lt;/p&gt;

&lt;p&gt;If you want a deeper security playbook here, I’ve already written the adjacent piece on &lt;a href="https://dev.to/blog/prevent-sensitive-data-leakage-rag"&gt;RAG&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  View traces (what “good” looks like)
&lt;/h2&gt;

&lt;p&gt;When you open a trace view for a run, you should be able to answer in under &lt;strong&gt;60 seconds&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Did it succeed?&lt;/li&gt;
&lt;li&gt;Where did time go?&lt;/li&gt;
&lt;li&gt;Where did cost go?&lt;/li&gt;
&lt;li&gt;Which tool failed?&lt;/li&gt;
&lt;li&gt;Did it loop?&lt;/li&gt;
&lt;li&gt;Was a policy gate triggered?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you can’t, you’re collecting trivia, not observability.&lt;/p&gt;

&lt;p&gt;For deeper control-flow patterns (retries, checkpoints, HITL), see &lt;a href="https://dev.to/pillars/ai-agents"&gt;AI agents&lt;/a&gt; and my guide on &lt;a href="https://dev.to/pillars/ai-engineering-production"&gt;AI in production&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Monitor performance (latency + cost)
&lt;/h2&gt;

&lt;p&gt;Monitoring agents is mostly about catching regressions before your users do:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;P95 step latency changes&lt;/li&gt;
&lt;li&gt;token usage drift&lt;/li&gt;
&lt;li&gt;tool error rate spikes&lt;/li&gt;
&lt;li&gt;loop frequency&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I keep two dashboards:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Run-level SLOs&lt;/strong&gt;: success rate, p95 end-to-end latency, p95 cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Step-level heatmap&lt;/strong&gt;: which spans are dominating time/cost.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you want a measurement discipline for LLM latency, I wrote up the methodology I use in &lt;a href="https://dev.to/blog/llm-latency-benchmark-methodology"&gt;LLM latency&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;For cost math (including retries and tools), connect this with &lt;a href="https://dev.to/glossary/llm-cost"&gt;LLM cost&lt;/a&gt; and the practical breakdown in &lt;a href="https://dev.to/glossary/production-ai"&gt;AI in production&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Default-on vs opt-in logging in production for agents
&lt;/h2&gt;

&lt;p&gt;Here’s my stance: &lt;strong&gt;production should be metadata-only by default.&lt;/strong&gt; If you need content, you earn it with explicit controls.&lt;/p&gt;

&lt;p&gt;Default-on (prod):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;spans with timings + statuses&lt;/li&gt;
&lt;li&gt;token counts&lt;/li&gt;
&lt;li&gt;cost estimates&lt;/li&gt;
&lt;li&gt;tool names + status codes&lt;/li&gt;
&lt;li&gt;prompt/template IDs + hashes&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Opt-in (prod, time-limited):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;full prompts/responses&lt;/li&gt;
&lt;li&gt;full tool payloads&lt;/li&gt;
&lt;li&gt;retrieved content&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And when you enable opt-in, it should be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;scoped to a tenant/user/session&lt;/li&gt;
&lt;li&gt;time-boxed (e.g., &lt;strong&gt;1 hour&lt;/strong&gt; window)&lt;/li&gt;
&lt;li&gt;stored as encrypted blobs with TTL&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  How to store full prompt/response safely (without bloating traces)
&lt;/h2&gt;

&lt;p&gt;The pattern that scales:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Put raw content in object storage (S3/R2/GCS).&lt;/li&gt;
&lt;li&gt;Make it content-addressed (&lt;code&gt;sha256&lt;/code&gt; as key).&lt;/li&gt;
&lt;li&gt;Encrypt with KMS.&lt;/li&gt;
&lt;li&gt;Apply TTL lifecycle policy.&lt;/li&gt;
&lt;li&gt;Store only &lt;code&gt;blob_id&lt;/code&gt; plus &lt;code&gt;content_sha256&lt;/code&gt; plus &lt;code&gt;ttl_hours&lt;/code&gt; in your traces.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This keeps traces lightweight and searchable, and keeps sensitive data behind a stricter access model than “anyone with read access to logs.”&lt;/p&gt;

&lt;h2&gt;
  
  
  Retries, self-corrections, and agent loops: how to represent them
&lt;/h2&gt;

&lt;p&gt;A few rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A single logical model call with retries should be one inference span, with &lt;code&gt;retry.count&lt;/code&gt; and retry events.&lt;/li&gt;
&lt;li&gt;Agent loops should increment &lt;code&gt;loop.iteration&lt;/code&gt; and emit a loop span per iteration.&lt;/li&gt;
&lt;li&gt;If the agent is stuck (same tool call repeated), emit a &lt;code&gt;stuck.signature_hash&lt;/code&gt; so you can alert on it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Once you do this, tail sampling becomes mechanical. “Keep any trace where &lt;code&gt;loop.iteration &amp;gt;= 6&lt;/code&gt;.” Easy.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to design a redaction pipeline (SDK + collector)
&lt;/h2&gt;

&lt;p&gt;This is the part I want teams to stop hand-waving.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;SDK-side&lt;/strong&gt;: redact early so secrets never leave process memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Collector-side&lt;/strong&gt;: redact again because someone will ship without the SDK.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Backend-side&lt;/strong&gt;: enforce access control and retention policies.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I’ve built the 7-agent blog publishing pipeline for this site, and one of the biggest lessons was boring: deterministic gates catch issues earlier than “we’ll review it later.” Same idea here. Put deterministic redaction and drop rules in front of your exporter, not as a best-effort afterthought.&lt;/p&gt;

&lt;p&gt;To go deeper on agent security failure modes, connect this to my broader &lt;a href="https://dev.to/blog/ai-security-complete-guide"&gt;AI security&lt;/a&gt; and the specific risk of &lt;a href="https://dev.to/blog/prompt-injection-2026-owasp-llm-vulnerability"&gt;prompt injection&lt;/a&gt;.&lt;/p&gt;




&lt;h3&gt;
  
  
  Internal links you’ll likely want next
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://dev.to/pillars/ai-agents"&gt;AI agents&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/pillars/ai-engineering-production"&gt;AI in production&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/blog/rise-of-agentic-ai"&gt;agentic AI&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/blog/multi-agent-ai-systems-production"&gt;agent orchestration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/blog/opentelemetry-ai-agents-instrumentation"&gt;OpenTelemetry&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/blog/prevent-sensitive-data-leakage-rag"&gt;RAG&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/blog/advanced-prompt-injection-techniques-2026"&gt;prompt injection&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/blog/claude-code-security-2026"&gt;Claude Code&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/blog/local-llms-complete-guide"&gt;local LLM&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.kunalganglani.com/blog/ai-agent-observability-logging-schema?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=ai-agent-observability-logging-schema" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llmops</category>
      <category>observability</category>
      <category>opentelemetry</category>
      <category>aiagents</category>
    </item>
    <item>
      <title>High-memory VPS for AI agents: when your fleet actually needs the RAM</title>
      <dc:creator>EQVPS</dc:creator>
      <pubDate>Sun, 09 Aug 2026 11:42:24 +0000</pubDate>
      <link>https://dev.to/eqvps/high-memory-vps-for-ai-agents-when-your-fleet-actually-needs-the-ram-2acc</link>
      <guid>https://dev.to/eqvps/high-memory-vps-for-ai-agents-when-your-fleet-actually-needs-the-ram-2acc</guid>
      <description>&lt;p&gt;The mistake I see most often with agent hosting is sizing for the wrong thing. Someone runs one agent, it uses 400 MB, and they conclude agents are cheap to host. Then they scale to a real crew and the box starts swapping at 3 a.m.&lt;/p&gt;

&lt;p&gt;One agent is cheap. That's not the interesting case.&lt;/p&gt;

&lt;p&gt;Where the memory actually goes&lt;br&gt;
An agent that only fires off API calls to a model is light — it's mostly waiting on the network. You could run a dozen of those on a small plan and never notice.&lt;/p&gt;

&lt;p&gt;The RAM disappears when agents start holding state. Conversation history that grows every turn. A working set several agents read and write. A vector store for long-term memory sitting in the same process. The moment your architecture stops being "call API, forget" and becomes "remember, coordinate, hand off," memory becomes the constraint, not CPU.&lt;/p&gt;

&lt;p&gt;CrewAI, LangGraph, AutoGPT-style loops — they all trend this way as they get serious. The framework doesn't eat the RAM; the state does.&lt;/p&gt;

&lt;p&gt;Rough sizing, honestly&lt;br&gt;
I won't pretend there's a formula, because there isn't — it depends entirely on how much each agent keeps around. But a practical feel from running these:&lt;/p&gt;

&lt;p&gt;Light, API-bound agents — you don't need Pro at all here; a NAT or dedicated-IP plan ($3–20) handles it. Pro earns its place once shared state pushes you past ~32 GB.&lt;br&gt;
32 GB — the sweet spot for a real multi-agent system: 5–10 agents with shared memory plus a vector database that's actually useful. Most people land here.&lt;br&gt;
64 GB — bigger fleets, longer histories, a memory index in the millions of vectors, or several services co-located. This is where one box replaces the three smaller ones you'd otherwise juggle.&lt;br&gt;
80 GB — heavy, memory-bound work: large in-memory datasets, many concurrent agents, or agents plus local model inference on the same host.&lt;br&gt;
Start below where you think you need to be. Watch htop for a day. Resize up when you see swap, not before — guessing high just wastes money.&lt;/p&gt;

&lt;p&gt;The part that's hard to buy&lt;br&gt;
Here's the thing that makes this awkward: renting 64 GB of RAM is easy. Renting 64 GB with crypto and no identity check is not. Most hosts that sell serious memory cheaply do it behind a card and a KYC form.&lt;/p&gt;

&lt;p&gt;If your agent provisions its own server, or the workload touches data you'd rather not tie to a name, that combination — high memory, crypto, no KYC, and orderable by the agent itself over MCP — is the actual product. It's not cheaper per gigabyte, and I've written separately about why that comparison misleads. It's available on terms almost nobody offers.&lt;/p&gt;

&lt;p&gt;So what do you do&lt;br&gt;
If your agents are light and API-bound, don't overthink it — a small NAT or dedicated-IP plan is plenty, skip the whole high-memory question. If you're running a real fleet that holds state, size by what's actually in memory, start at 32 GB, and move up when the graph tells you to.&lt;/p&gt;

&lt;p&gt;When you're there, the Pro line covers 32 to 80 GB with a dedicated IP and nightly backups. Pick the tier that matches your working set, not your ambitions.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>selfhosted</category>
      <category>llmops</category>
      <category>agents</category>
    </item>
    <item>
      <title>40% of AI Agent Projects Will Be Canceled by 2027. Token Governance Is How You Avoid It.</title>
      <dc:creator>Tisha</dc:creator>
      <pubDate>Sun, 09 Aug 2026 10:55:47 +0000</pubDate>
      <link>https://dev.to/tisha/40-of-ai-agent-projects-will-be-canceled-by-2027-token-governance-is-how-you-avoid-it-38id</link>
      <guid>https://dev.to/tisha/40-of-ai-agent-projects-will-be-canceled-by-2027-token-governance-is-how-you-avoid-it-38id</guid>
      <description>&lt;p&gt;&lt;em&gt;By Tisha Chawla and Susheem Koul&lt;/em&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR.&lt;/strong&gt; AI agents spend tokens to think, and how many they spend is nondeterministic. The same task can cost wildly different amounts across runs, and a stuck recursive loop can spend without limit while still looking successful. You cannot budget a nondeterministic resource up front. You have to &lt;strong&gt;observe&lt;/strong&gt; it, &lt;strong&gt;attribute&lt;/strong&gt; it, and &lt;strong&gt;enforce&lt;/strong&gt; limits at runtime, from a control loop kept separate from the agent.&lt;/p&gt;

&lt;p&gt;🛠️ &lt;strong&gt;We built this into an open-source tool.&lt;/strong&gt; &lt;a href="https://github.com/theagentplane/tokenops" rel="noopener noreferrer"&gt;&lt;strong&gt;TokenOps&lt;/strong&gt;&lt;/a&gt; is run-aware token governance for multi-agent systems: a control plane + SDK that caps spend and steers behavior across a whole agent workflow, not per request.&lt;/p&gt;


&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;agent-tokenops
&lt;/code&gt;&lt;/pre&gt;


&lt;p&gt;If this field guide is useful, &lt;strong&gt;&lt;a href="https://github.com/theagentplane/tokenops" rel="noopener noreferrer"&gt;⭐ star the repo&lt;/a&gt;&lt;/strong&gt; so more people building agents can find it.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  Contents
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;First principles&lt;/li&gt;
&lt;li&gt;The problem&lt;/li&gt;
&lt;li&gt;Primitives for token governance&lt;/li&gt;
&lt;li&gt;The current space&lt;/li&gt;
&lt;li&gt;The discipline&lt;/li&gt;
&lt;li&gt;Where TokenOps fits&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  0. First principles
&lt;/h2&gt;

&lt;p&gt;Start from what is provably true, then build up.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A token is a metered unit of a paid resource.&lt;/strong&gt; Every model call consumes a countable number of tokens at a published price.&lt;sup id="fnref1"&gt;1&lt;/sup&gt;&lt;sup id="fnref2"&gt;2&lt;/sup&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Consumption is nondeterministic.&lt;/strong&gt; Token generation is stochastic: at each step the model samples the next token from a probability distribution, so the same prompt can return a different completion, and a different number of output tokens, on every run. OpenAI states its APIs are nondeterministic by default, and that determinism is not guaranteed even with a fixed seed.&lt;sup id="fnref3"&gt;3&lt;/sup&gt; Agents amplify this. An agent picks its next action from that nondeterministic output, so the number of reasoning steps and tool calls, and therefore the total token spend, is a random variable, not a fixed cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You can only govern what you can measure, attribute, and bound.&lt;/strong&gt; Measure it (how many), attribute it (whose), bound it (a ceiling that actually holds).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You cannot bound it by prediction.&lt;/strong&gt; A static estimate set in advance is wrong on the tail. The only bound that holds is enforced at runtime: watch spend as it accrues, and stop the run when it crosses a limit.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The bound must sit outside the agent.&lt;/strong&gt; An agent chooses its own next call, so nothing in its own logic is guaranteed to stop it. Governance is an external control loop around the agent, not a feature inside it.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;The three questions that decide whether you govern token spend:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;How many tokens did this run spend? &lt;em&gt;(measure)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;Who spent them: which user, which agent, which run? &lt;em&gt;(attribute)&lt;/em&gt;
&lt;/li&gt;
&lt;li&gt;What stops a run before the invoice does? &lt;em&gt;(bound)&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you cannot answer all three, you are observing spend after the fact, not governing it.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  1. The problem
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;One example, used throughout.&lt;/strong&gt; Agent A is a research agent: it calls a &lt;code&gt;search&lt;/code&gt; tool, reads the results, and hands findings to Agent B, a summarizer. Agent A runs on OpenAI, Agent B on Anthropic. We follow this single task through every failure mode below.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flk931hx1u49v04rfkqrm.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flk931hx1u49v04rfkqrm.png" alt="Research Agent" width="799" height="280"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What a token is.&lt;/strong&gt; A token is a chunk of text mapped to an integer ID from the model's fixed vocabulary. A subword tokenizer (for example, byte-pair encoding) splits text into these pieces, so one token can be a whole word, part of a word, or a single character.&lt;sup id="fnref1"&gt;1&lt;/sup&gt;&lt;sup id="fnref4"&gt;4&lt;/sup&gt; Different models use different tokenizers, so the same sentence is a different token count on each. Rule of thumb in English: roughly 4 characters, or 0.75 words, per token.&lt;sup id="fnref1"&gt;1&lt;/sup&gt; Output tokens cost several times more than input tokens.&lt;sup id="fnref5"&gt;5&lt;/sup&gt;&lt;sup id="fnref2"&gt;2&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why tokens became an ops problem.&lt;/strong&gt; Agents do not make one call, they loop. Anthropic measured agents using about &lt;strong&gt;4x&lt;/strong&gt; the tokens of a chat, and multi-agent systems about &lt;strong&gt;15x&lt;/strong&gt;, with token usage alone explaining roughly &lt;strong&gt;80%&lt;/strong&gt; of the variance in cost.&lt;sup id="fnref6"&gt;6&lt;/sup&gt; In our example, Agent A might call &lt;code&gt;search&lt;/code&gt; once, or twenty times. You do not know which until it runs.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.1 The token leak
&lt;/h3&gt;

&lt;p&gt;Agent A calls &lt;code&gt;search("pricing")&lt;/code&gt;. The result is weak, so it feeds its own output back in and calls &lt;code&gt;search("pricing")&lt;/code&gt; again, and again. This is a &lt;strong&gt;recursive loop&lt;/strong&gt;: the agent keeps invoking the same step because nothing tells it to stop. Fifty calls later it still returns a clean summary, so every functional dashboard shows success. The spend shows up in exactly one place: &lt;strong&gt;the bill&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That is a &lt;strong&gt;token leak&lt;/strong&gt;: spend that grows with no bound and no attribution, whether from a silent retry loop, a context window that keeps growing, or an unflagged model upgrade. Provider guardrails do not catch it: OpenAI project budgets are alerts, not hard caps.&lt;sup id="fnref7"&gt;7&lt;/sup&gt; Unmanaged, this is now a top failure mode. Gartner predicts over &lt;strong&gt;40% of agentic AI projects will be canceled by end of 2027&lt;/strong&gt;, citing escalating cost.&lt;sup id="fnref8"&gt;8&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The quieter leak: cache busting.&lt;/strong&gt; Repeated input can be cached at a steep discount: OpenAI documents 50% off cached input,&lt;sup id="fnref5"&gt;5&lt;/sup&gt; and Anthropic charges roughly 90% below base input for cache reads.&lt;sup id="fnref2"&gt;2&lt;/sup&gt; The catch: caching matches the prompt &lt;strong&gt;from the top down&lt;/strong&gt;, only up to the first point where the text differs. If Agent A pastes a &lt;code&gt;current_timestamp&lt;/code&gt; or &lt;code&gt;session_id&lt;/code&gt; near the top of its system prompt, it throws away the cache for everything below it, and a large context that should cost a tenth of full price pays full price every turn. Same text, same step count, &lt;strong&gt;10x the bill&lt;/strong&gt;. The fix is structural: put static content (system prompt, tool definitions) first and volatile values last, and treat a drop in cache hit rate as a spend alarm on its own.&lt;sup id="fnref5"&gt;5&lt;/sup&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A third leak: reasoning bloat.&lt;/strong&gt; Reasoning models (OpenAI's o-series and GPT-5 reasoning, Anthropic extended thinking) spend hidden reasoning tokens server-side before emitting a single visible token, and those tokens are billed.&lt;sup id="fnref9"&gt;9&lt;/sup&gt; Two consequences. First, &lt;code&gt;max_output_tokens&lt;/code&gt; caps the &lt;em&gt;sum&lt;/em&gt; of reasoning and visible output,&lt;sup id="fnref9"&gt;9&lt;/sup&gt; so a runaway reasoning loop can burn the entire budget inside one call without returning a character. Second, if your breaker trips by parsing the output stream, it is blind during the thinking phase: the spend has already happened before the first chunk arrives. Read the usage totals the model reports, not just the visible stream.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.2 Distributed token lineage
&lt;/h3&gt;

&lt;p&gt;The monthly bill arrives: one total from OpenAI, one from Anthropic. Neither number tells you that Agent A's recursive loop caused most of the cost, or which user started the run. &lt;strong&gt;Lineage&lt;/strong&gt; is the missing label on every call: which user, which agent (A or B), and which run produced it. OpenTelemetry's GenAI conventions define exactly these attributes: &lt;code&gt;gen_ai.usage.input_tokens&lt;/code&gt;, &lt;code&gt;gen_ai.usage.output_tokens&lt;/code&gt;, plus agent and session identifiers.&lt;sup id="fnref10"&gt;10&lt;/sup&gt; Recent versions also add attributes for the costly hidden categories, cached and reasoning tokens, mirroring the provider payloads: OpenAI's &lt;code&gt;prompt_tokens_details.cached_tokens&lt;/code&gt; and &lt;code&gt;completion_tokens_details.reasoning_tokens&lt;/code&gt;, and Anthropic's &lt;code&gt;cache_read_input_tokens&lt;/code&gt;.&lt;sup id="fnref10"&gt;10&lt;/sup&gt; Track those, or your lineage misses exactly the tokens most likely to surprise you.&lt;/p&gt;

&lt;p&gt;One more catch: Agent A's OpenAI tokens and Agent B's Anthropic tokens are &lt;strong&gt;not the same unit&lt;/strong&gt;, because the tokenizers and prices differ. You cannot add raw tokens across providers. Convert each call to &lt;strong&gt;cost&lt;/strong&gt; first (one denominator, such as micro-dollars), then sum and enforce. Enforce on cost, not tokens.&lt;/p&gt;

&lt;h3&gt;
  
  
  1.3 Isolate the guard
&lt;/h3&gt;

&lt;p&gt;Put the budget check inside Agent A's loop and two things break: the same bug that makes it loop can skip the check, and Agent B needs its own copy. So keep two planes apart:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Data plane&lt;/strong&gt;: the agent doing the work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Control plane&lt;/strong&gt;: the code that meters, attributes, and halts spend.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The meter and the limit live in the control plane, an out-of-band loop both agents call but neither can edit or bypass.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Primitives for token governance
&lt;/h2&gt;

&lt;p&gt;Six building blocks. Each answers one question. Together they are the vocabulary for everything below.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Primitive&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Question it answers&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Meter&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Counts tokens and cost on every call&lt;/td&gt;
&lt;td&gt;How much did this consume?&lt;/td&gt;
&lt;td&gt;&lt;sup id="fnref11"&gt;11&lt;/sup&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Attribution context&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tags every call with user, agent, run&lt;/td&gt;
&lt;td&gt;Whose spend is this?&lt;/td&gt;
&lt;td&gt;&lt;sup id="fnref10"&gt;10&lt;/sup&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Budget / quota&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A hard ceiling on tokens or cost over a window&lt;/td&gt;
&lt;td&gt;What is the cap, and does it hold?&lt;/td&gt;
&lt;td&gt;
&lt;sup id="fnref12"&gt;12&lt;/sup&gt;&lt;sup id="fnref13"&gt;13&lt;/sup&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Rate limit&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;A cap on tokens or requests per unit time&lt;/td&gt;
&lt;td&gt;How fast is too fast?&lt;/td&gt;
&lt;td&gt;&lt;sup id="fnref14"&gt;14&lt;/sup&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Circuit breaker&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Trips and fails fast when a condition crosses a threshold&lt;/td&gt;
&lt;td&gt;What stops a bad run immediately?&lt;/td&gt;
&lt;td&gt;&lt;sup id="fnref15"&gt;15&lt;/sup&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Control plane&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Where the rules live, out of band from the agent&lt;/td&gt;
&lt;td&gt;Who owns the decision?&lt;/td&gt;
&lt;td&gt;§1.3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;The circuit breaker, precisely.&lt;/strong&gt; You wrap a protected call in an object that monitors it. When a chosen condition crosses a threshold, the breaker trips to an &lt;em&gt;open&lt;/em&gt; state and fails fast, returning an error without making the call. Classic breakers add a &lt;em&gt;half-open&lt;/em&gt; state to probe recovery.&lt;sup id="fnref15"&gt;15&lt;/sup&gt; A failure can be any condition you define, such as a timeout or an HTTP 429. For token governance, the condition is behavioral: a repeated action, a spend velocity, or a collapse in cache hit rate. The breaker trips to halt the run before the spend lands.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How a behavioral check actually works.&lt;/strong&gt; You do not need brittle, domain-specific rules. Three system-level signals over an agent's step history cover most runaway modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Semantic loop.&lt;/strong&gt; Hash each tool call's name and arguments, and trip when the same signature repeats inside a sliding window. A vector-similarity check on consecutive prompts catches near-duplicates, such as an agent feeding the same error back to a tool.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spend velocity.&lt;/strong&gt; Track cost per step (the derivative of cost over steps). A sharp spike means the context is compounding, for example appending full raw logs on every failed turn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Efficiency decay.&lt;/strong&gt; Watch the ratio of progress made to tokens burned. This one is the most heuristic and hardest to define cleanly, so treat it as a warning, not a hard trip.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The first two are cheap and robust. Start there.&lt;/p&gt;

&lt;h3&gt;
  
  
  2.1 Beyond halt: two richer responses
&lt;/h3&gt;

&lt;p&gt;Halting is the simplest response to a trip, not the only one. Two production patterns are worth knowing, each with a caveat.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Model cascade (graceful degradation).&lt;/strong&gt; At a warning threshold (say 75% of a session budget), the control plane swaps the next call to a cheaper model and trims non-essential tools, trying a low-cost completion before any hard halt. This ships today: Cloudflare can switch to a cheaper model once a budget is exhausted,&lt;sup id="fnref13"&gt;13&lt;/sup&gt; and gateways like LiteLLM support model fallbacks.&lt;sup id="fnref12"&gt;12&lt;/sup&gt; &lt;strong&gt;Caveat:&lt;/strong&gt; a weaker model can reason worse and loop more, trading a hard failure for a quality risk and sometimes more spend. It is a tradeoff, not a free win.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Escrow and human-in-the-loop.&lt;/strong&gt; Instead of killing the run, the control plane suspends it, snapshots its state, and pings a human to inspect and resume. Real, but not free: pause-and-resume requires a durable, checkpointable runtime. LangGraph, for example, requires a checkpointer to persist state across an interrupt.&lt;sup id="fnref16"&gt;16&lt;/sup&gt; A lightweight in-process wrapper cannot serialize and resume an arbitrary call stack on its own.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  3. The current space
&lt;/h2&gt;

&lt;h3&gt;
  
  
  3.1 Five dimensions
&lt;/h3&gt;

&lt;p&gt;Score every tool on five questions. The first three are table stakes. The last two are where the gap is.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Dimension&lt;/th&gt;
&lt;th&gt;Primitive&lt;/th&gt;
&lt;th&gt;Question&lt;/th&gt;
&lt;th&gt;Source&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Observe&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Meter&lt;/td&gt;
&lt;td&gt;Can you see tokens per call?&lt;/td&gt;
&lt;td&gt;
&lt;sup id="fnref10"&gt;10&lt;/sup&gt;&lt;sup id="fnref11"&gt;11&lt;/sup&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Attribute&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Attribution context&lt;/td&gt;
&lt;td&gt;Whose spend: user, agent, run?&lt;/td&gt;
&lt;td&gt;&lt;sup id="fnref17"&gt;17&lt;/sup&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Enforce&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Budget, rate limit&lt;/td&gt;
&lt;td&gt;Can you stop spend in real time?&lt;/td&gt;
&lt;td&gt;
&lt;sup id="fnref12"&gt;12&lt;/sup&gt;&lt;sup id="fnref13"&gt;13&lt;/sup&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Behavioral&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Circuit breaker&lt;/td&gt;
&lt;td&gt;Can you halt on a runaway pattern, not just a dollar line?&lt;/td&gt;
&lt;td&gt;
&lt;sup id="fnref6"&gt;6&lt;/sup&gt;&lt;sup id="fnref18"&gt;18&lt;/sup&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Ownership&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Control plane&lt;/td&gt;
&lt;td&gt;Which layer holds the control point?&lt;/td&gt;
&lt;td&gt;§1.3&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  3.2 Five categories
&lt;/h3&gt;

&lt;p&gt;Every tool sits at one of five layers in the stack.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;th&gt;Examples&lt;/th&gt;
&lt;th&gt;Stops spend?&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Provider&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Vendor-native limits&lt;/td&gt;
&lt;td&gt;OpenAI, Anthropic, Azure OpenAI&lt;/td&gt;
&lt;td&gt;Coarse, often alerts&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Gateway&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Out-of-process proxy you route through&lt;/td&gt;
&lt;td&gt;LiteLLM, Portkey, Cloudflare, Kong&lt;/td&gt;
&lt;td&gt;Yes, threshold-based&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Component&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;In-process library inside your app&lt;/td&gt;
&lt;td&gt;OpenLLMetry, LangChain callbacks&lt;/td&gt;
&lt;td&gt;Rare&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Backend&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Collector or dashboard&lt;/td&gt;
&lt;td&gt;Langfuse, Datadog, Arize, LangSmith&lt;/td&gt;
&lt;td&gt;No, after the fact&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Standard&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The wire format everyone emits&lt;/td&gt;
&lt;td&gt;OpenTelemetry GenAI conventions&lt;/td&gt;
&lt;td&gt;n/a&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  3.3 The gap
&lt;/h3&gt;

&lt;p&gt;Across these tools, most &lt;strong&gt;observe&lt;/strong&gt;, several &lt;strong&gt;enforce&lt;/strong&gt; a dollar or rate threshold (the gateway layer is moving fast: Cloudflare shipped per-user spend limits in June 2026&lt;sup id="fnref13"&gt;13&lt;/sup&gt;), but almost none &lt;strong&gt;halt a run on pathological behavior&lt;/strong&gt; (a runaway loop) before the threshold is hit.&lt;/p&gt;

&lt;p&gt;Enforcement also tends to live at the &lt;strong&gt;gateway&lt;/strong&gt;, which sees a stream of independent requests, not the &lt;strong&gt;in-process&lt;/strong&gt; context (the agent's step sequence and loop structure) needed to recognize a loop early. Behavioral signals like a collapsing cache hit rate or a semantic loop are easiest to read right next to the prompt structure and steps that caused them, which a gateway does not retain.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;In-process, behavioral enforcement is the least served square in the landscape today.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  4. The discipline
&lt;/h2&gt;

&lt;p&gt;You already do this for CPU and memory. You measure it, label it, watch it, and cap it. Tokens are simply the newest resource to earn the same four steps. The order matters, because each step depends on the one before it: you cannot attribute what you never measured, and you cannot enforce what you cannot see.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;In plain terms&lt;/th&gt;
&lt;th&gt;What it does&lt;/th&gt;
&lt;th&gt;Mechanism&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;1. Instrument&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Meter it&lt;/td&gt;
&lt;td&gt;Emit one record per model and tool call, carrying token counts&lt;/td&gt;
&lt;td&gt;OpenTelemetry GenAI spans &lt;sup id="fnref10"&gt;10&lt;/sup&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;2. Attribute&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Tag it&lt;/td&gt;
&lt;td&gt;Stamp every record with user, agent, and run&lt;/td&gt;
&lt;td&gt;OTel attributes and vendor tags &lt;sup id="fnref17"&gt;17&lt;/sup&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;3. Surface&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Watch it&lt;/td&gt;
&lt;td&gt;Stream the records so a climbing cost or a falling cache hit rate is visible live, not at month end&lt;/td&gt;
&lt;td&gt;Observability backends &lt;sup id="fnref17"&gt;17&lt;/sup&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;4. Enforce&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Cap it&lt;/td&gt;
&lt;td&gt;Apply a budget, a rate limit, or a behavioral circuit breaker that trips on a recursive loop&lt;/td&gt;
&lt;td&gt;Budgets and breakers &lt;sup id="fnref12"&gt;12&lt;/sup&gt;&lt;sup id="fnref15"&gt;15&lt;/sup&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Walk Agent A and Agent B through it:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Instrument.&lt;/strong&gt; Every &lt;code&gt;search&lt;/code&gt; call and every model call, on both OpenAI and Anthropic, emits a record with its token usage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Attribute.&lt;/strong&gt; Each record carries &lt;code&gt;agent=A&lt;/code&gt; or &lt;code&gt;agent=B&lt;/code&gt; and the run id, so the bill splits cleanly instead of arriving as one number.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Surface.&lt;/strong&gt; Agent A's recursive search loop appears as a steeply climbing cost line the moment it starts, not four weeks later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enforce.&lt;/strong&gt; When Agent A repeats the same &lt;code&gt;search&lt;/code&gt; call past a set threshold, the breaker trips and halts the run before the spend lands.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;All four steps live in the control plane (see 1.3), outside the agents, so neither Agent A nor Agent B can skip them.&lt;/p&gt;

&lt;p&gt;The four verbs are a way to organize the work, not an official standard. Every mechanism they point to (spans, attributes, budgets, breakers) is real and documented in the footnotes.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Where TokenOps fits
&lt;/h2&gt;

&lt;p&gt;The least-served square in section 3.3, &lt;strong&gt;in-process, behavioral enforcement that governs the whole run&lt;/strong&gt;, is exactly the square we built &lt;a href="https://github.com/theagentplane/tokenops" rel="noopener noreferrer"&gt;&lt;strong&gt;TokenOps&lt;/strong&gt;&lt;/a&gt; to fill.&lt;/p&gt;

&lt;p&gt;TokenOps is a &lt;strong&gt;control plane + SDK&lt;/strong&gt; for agent stacks. Entry agents register a run; every LLM and tool crossing shares one &lt;code&gt;run_id&lt;/code&gt; and one ledger. Mapping it back to the primitives from section 2:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Govern the run, not the request.&lt;/strong&gt; One &lt;code&gt;run_id&lt;/code&gt; spans every model, tool, and agent-to-agent hop, so the Agent A → Agent B workflow stays inside a &lt;em&gt;single&lt;/em&gt; budget, the multi-agent case a gateway can't see.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared ledger across processes.&lt;/strong&gt; Spend, inflight, and halt state live in SQLite, so Agent A and Agent B can't each burn the full cap locally. This is the cross-provider, enforce-on-cost point from section 1.2.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;In-path enforcement.&lt;/strong&gt; &lt;code&gt;wrap_complete&lt;/code&gt; runs detect → decide → apply &lt;em&gt;before&lt;/em&gt; the next LLM call, the control loop from section 1.3, out of band from the agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Steer or stop.&lt;/strong&gt; Actuators go beyond halt: &lt;code&gt;HALT&lt;/code&gt; · &lt;code&gt;MUTATE&lt;/code&gt; · &lt;code&gt;INJECT&lt;/code&gt; · reject/queue, the richer responses from section 2.1.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;agent-tokenops
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Its companion, &lt;a href="https://github.com/theagentplane/chronicle" rel="noopener noreferrer"&gt;&lt;strong&gt;Chronicle&lt;/strong&gt;&lt;/a&gt;, covers the record-and-replay half: it captures decision boundaries so a production incident becomes a deterministic regression test.&lt;/p&gt;

&lt;p&gt;TokenOps is early (0.x) and MIT-licensed. If this field guide gave you the vocabulary you were missing, the repo is where it becomes runnable:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;👉 &lt;strong&gt;&lt;a href="https://github.com/theagentplane/tokenops" rel="noopener noreferrer"&gt;github.com/theagentplane/tokenops&lt;/a&gt;&lt;/strong&gt;. If it's useful, &lt;strong&gt;&lt;a href="https://github.com/theagentplane/tokenops" rel="noopener noreferrer"&gt;⭐ star it&lt;/a&gt;&lt;/strong&gt; so more people building agents can find it. Issues and PRs welcome.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;em&gt;Built by Tisha Chawla and Susheem Koul.&lt;/em&gt;&lt;/p&gt;







&lt;ol&gt;

&lt;li id="fn1"&gt;
&lt;p&gt;OpenAI, "What are tokens and how to count them." &lt;a href="https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them" rel="noopener noreferrer"&gt;https://help.openai.com/en/articles/4936856-what-are-tokens-and-how-to-count-them&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn2"&gt;
&lt;p&gt;Anthropic, "Pricing" (base input vs output; cache-read pricing). &lt;a href="https://platform.claude.com/docs/en/about-claude/pricing" rel="noopener noreferrer"&gt;https://platform.claude.com/docs/en/about-claude/pricing&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn3"&gt;
&lt;p&gt;OpenAI, "Reproducible outputs with the seed parameter" (APIs are nondeterministic by default; determinism not guaranteed). &lt;a href="https://developers.openai.com/cookbook/examples/reproducible_outputs_with_the_seed_parameter" rel="noopener noreferrer"&gt;https://developers.openai.com/cookbook/examples/reproducible_outputs_with_the_seed_parameter&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn4"&gt;
&lt;p&gt;OpenAI, "tiktoken" (open-source tokenizer). &lt;a href="https://github.com/openai/tiktoken" rel="noopener noreferrer"&gt;https://github.com/openai/tiktoken&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn5"&gt;
&lt;p&gt;OpenAI, "Prompt Caching in the API" (input vs output pricing; cached-input discount). &lt;a href="https://openai.com/index/api-prompt-caching/" rel="noopener noreferrer"&gt;https://openai.com/index/api-prompt-caching/&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn6"&gt;
&lt;p&gt;Anthropic, "How we built our multi-agent research system" (4x and 15x token multipliers; ~80% of cost variance). &lt;a href="https://www.anthropic.com/engineering/multi-agent-research-system" rel="noopener noreferrer"&gt;https://www.anthropic.com/engineering/multi-agent-research-system&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn7"&gt;
&lt;p&gt;OpenAI, "Managing projects in the API platform" (project budgets are alerts, not hard caps). &lt;a href="https://help.openai.com/en/articles/9186755-managing-projects-in-the-api-platform" rel="noopener noreferrer"&gt;https://help.openai.com/en/articles/9186755-managing-projects-in-the-api-platform&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn8"&gt;
&lt;p&gt;Gartner, "Over 40% of Agentic AI Projects Will Be Canceled by End of 2027" (June 25, 2025). &lt;a href="https://www.gartner.com/en/newsroom/press-releases/2025-06-25-gartner-predicts-over-40-percent-of-agentic-ai-projects-will-be-canceled-by-end-of-2027" rel="noopener noreferrer"&gt;https://www.gartner.com/en/newsroom/press-releases/2025-06-25-gartner-predicts-over-40-percent-of-agentic-ai-projects-will-be-canceled-by-end-of-2027&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn9"&gt;
&lt;p&gt;OpenAI, "Reasoning models" (internal reasoning tokens are billed; &lt;code&gt;max_output_tokens&lt;/code&gt; caps reasoning plus visible output). &lt;a href="https://developers.openai.com/api/docs/guides/reasoning" rel="noopener noreferrer"&gt;https://developers.openai.com/api/docs/guides/reasoning&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn10"&gt;
&lt;p&gt;OpenTelemetry, "Semantic conventions for generative AI" (status: Development). &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/" rel="noopener noreferrer"&gt;https://opentelemetry.io/docs/specs/semconv/gen-ai/&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn11"&gt;
&lt;p&gt;OpenTelemetry, "Semantic conventions for generative AI metrics" (&lt;code&gt;gen_ai.client.token.usage&lt;/code&gt;). &lt;a href="https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/" rel="noopener noreferrer"&gt;https://opentelemetry.io/docs/specs/semconv/gen-ai/gen-ai-metrics/&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn12"&gt;
&lt;p&gt;LiteLLM, "Budgets and Rate Limits." &lt;a href="https://docs.litellm.ai/docs/proxy/users" rel="noopener noreferrer"&gt;https://docs.litellm.ai/docs/proxy/users&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn13"&gt;
&lt;p&gt;Cloudflare, "AI Gateway spend limits." &lt;a href="https://blog.cloudflare.com/ai-gateway-spend-limits/" rel="noopener noreferrer"&gt;https://blog.cloudflare.com/ai-gateway-spend-limits/&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn14"&gt;
&lt;p&gt;Kong, "Token Rate-Limiting and Tiered Access for AI Usage." &lt;a href="https://konghq.com/blog/engineering/token-rate-limiting-and-tiered-access-for-ai-usage" rel="noopener noreferrer"&gt;https://konghq.com/blog/engineering/token-rate-limiting-and-tiered-access-for-ai-usage&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn15"&gt;
&lt;p&gt;Martin Fowler, "CircuitBreaker" (popularizing Michael Nygard, &lt;em&gt;Release It!&lt;/em&gt;). &lt;a href="https://martinfowler.com/bliki/CircuitBreaker.html" rel="noopener noreferrer"&gt;https://martinfowler.com/bliki/CircuitBreaker.html&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn16"&gt;
&lt;p&gt;LangChain, "Persistence" (LangGraph checkpointers; pause and resume require a checkpointer). &lt;a href="https://docs.langchain.com/oss/python/langgraph/persistence" rel="noopener noreferrer"&gt;https://docs.langchain.com/oss/python/langgraph/persistence&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn17"&gt;
&lt;p&gt;Helicone, "Cost Tracking." &lt;a href="https://docs.helicone.ai/guides/cookbooks/cost-tracking" rel="noopener noreferrer"&gt;https://docs.helicone.ai/guides/cookbooks/cost-tracking&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;li id="fn18"&gt;
&lt;p&gt;Portkey, "Budget Limits." &lt;a href="https://docs.portkey.ai/docs/product/ai-gateway/virtual-keys/budget-limits" rel="noopener noreferrer"&gt;https://docs.portkey.ai/docs/product/ai-gateway/virtual-keys/budget-limits&lt;/a&gt;&amp;nbsp;↩&lt;/p&gt;
&lt;/li&gt;

&lt;/ol&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>machinelearning</category>
      <category>llmops</category>
    </item>
    <item>
      <title>Switchboard: building a tool router so your AI agent stops drowning in MCP tools</title>
      <dc:creator>Shashank Shah</dc:creator>
      <pubDate>Sun, 09 Aug 2026 07:26:05 +0000</pubDate>
      <link>https://dev.to/shashank1719/switchboard-building-a-tool-router-so-your-ai-agent-stops-drowning-in-mcp-tools-53hm</link>
      <guid>https://dev.to/shashank1719/switchboard-building-a-tool-router-so-your-ai-agent-stops-drowning-in-mcp-tools-53hm</guid>
      <description>&lt;p&gt;Keyword search picks the right tool for an AI agent 21% of the time. The router&lt;br&gt;
we built picks it &lt;strong&gt;88%&lt;/strong&gt; of the time, while cutting the tokens spent describing&lt;br&gt;
those tools by &lt;strong&gt;99.6%&lt;/strong&gt;. This is how we got there, including the parts we got&lt;br&gt;
wrong first.&lt;br&gt;
Architecture: &lt;a href="https://drive.google.com/file/d/1xQP3BX-2e4z-4saY0dDQjv_gpJdZTZVv/view?usp=drive_link" rel="noopener noreferrer"&gt;Link&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  The problem nobody talks about until the bill shows up
&lt;/h2&gt;

&lt;p&gt;Model Context Protocol (MCP) solved a real problem. It gave every AI agent a&lt;br&gt;
standard way to talk to external tools: CRMs, observability stacks, email,&lt;br&gt;
vector databases, internal APIs. It created a quieter problem in the process.&lt;/p&gt;

&lt;p&gt;Once you connect more than a handful of MCP servers to a single agent, you are&lt;br&gt;
no longer describing a tool catalog to the model. You are dumping an entire&lt;br&gt;
warehouse inventory into every prompt. In our own environment, three backends&lt;br&gt;
exposed 142 tools. Every one of those schemas, with its names, descriptions,&lt;br&gt;
and parameter shapes, gets tokenized and sent to the LLM on every turn,&lt;br&gt;
whether the user's question needs one of them or none of them.&lt;/p&gt;

&lt;p&gt;That has three costs, and only one of them shows up on an invoice.&lt;/p&gt;

&lt;p&gt;The first is token cost. You are paying to re-describe N tools you will&lt;br&gt;
never call, every single time.&lt;/p&gt;

&lt;p&gt;The second is selection accuracy. The more tools an LLM has to choose from in&lt;br&gt;
a single context, the more often it picks the wrong one, hallucinates&lt;br&gt;
parameters, or gets confused by two similarly-named tools from different&lt;br&gt;
servers.&lt;/p&gt;

&lt;p&gt;The third is operational fragility. Every new MCP server you connect makes the&lt;br&gt;
prompt bigger and the agent's job harder, so scaling tool count and scaling&lt;br&gt;
reliability end up pulling in opposite directions.&lt;/p&gt;

&lt;p&gt;We built Switchboard to decouple those three curves. The name is the metaphor:&lt;br&gt;
a telephone switchboard operator connects your call to the right line so you&lt;br&gt;
never need to know the number. Switchboard does that for tools. The host asks&lt;br&gt;
for what it wants in plain language, and the router works out which of N tool to patch it through to.&lt;/p&gt;
&lt;h2&gt;
  
  
  Two tools instead of two hundred
&lt;/h2&gt;

&lt;p&gt;Switchboard sits between your AI host (Claude Code, a custom chat agent, an&lt;br&gt;
IDE, anything that speaks MCP) and every backend MCP server you own. From the&lt;br&gt;
host's point of view Switchboard is an MCP server, but instead of exposing&lt;br&gt;
your full tool catalog it exposes exactly two meta-tools.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;find_tools(request)&lt;/code&gt; takes the user's request in plain language and returns a&lt;br&gt;
small, dynamically-sized set of relevant tools: zero, one, or a handful, never&lt;br&gt;
a fixed top-k and never the whole catalog. &lt;code&gt;invoke(tool_id, args)&lt;/code&gt; then calls&lt;br&gt;
the selected tool against whichever backend actually owns it.&lt;/p&gt;

&lt;p&gt;Everything else is the router's problem rather than the host's: which backend&lt;br&gt;
hosts which tool, how many servers are connected, how the catalog changes over&lt;br&gt;
time.&lt;/p&gt;
&lt;h2&gt;
  
  
  How find_tools actually decides
&lt;/h2&gt;

&lt;p&gt;This is the part that took the most iteration, because search alone is not&lt;br&gt;
enough. A naive nearest-neighbor lookup either returns too much, which defeats&lt;br&gt;
the purpose, or misses the right tool because of vocabulary mismatch between&lt;br&gt;
how a user asks and how a tool is described.&lt;/p&gt;

&lt;p&gt;The retrieval pipeline runs in four stages.&lt;/p&gt;

&lt;p&gt;It starts with concurrent dense and sparse search against a Pinecone vector&lt;br&gt;
registry. Dense embeddings catch semantic similarity, so "send the report to&lt;br&gt;
finance" finds &lt;code&gt;send_email_with_attachment&lt;/code&gt;. Sparse keyword-style search&lt;br&gt;
catches the exact-term matches dense embeddings sometimes miss: API names,&lt;br&gt;
service identifiers, error codes. Both index round-trips are independent I/O,&lt;br&gt;
so they run in parallel and cost one round-trip of wall-clock rather than two.&lt;/p&gt;

&lt;p&gt;Next, a cosine-similarity gate filters out anything too far from the query's&lt;br&gt;
intent before it ever reaches the LLM. It is cheap and deterministic, and it&lt;br&gt;
keeps obviously irrelevant tools from wasting judge tokens.&lt;/p&gt;

&lt;p&gt;Then an LLM judge does the expensive part properly: capability-fit selection,&lt;br&gt;
de-duplication across near-identical tools from different backends, ordering&lt;br&gt;
by execution sequence, and deciding to ask a clarifying question instead of&lt;br&gt;
guessing when the request is genuinely ambiguous.&lt;/p&gt;

&lt;p&gt;Finally, the result count is fully dynamic. Most routing systems force a fixed&lt;br&gt;
top-k. We don't. Some queries need zero tools, some need exactly one, and a&lt;br&gt;
few legitimately need several, so the judge decides rather than a hardcoded&lt;br&gt;
number.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;find_tools&lt;/code&gt; comes back empty there is a fallback, &lt;code&gt;find_more_tools&lt;/code&gt;, that&lt;br&gt;
relaxes the gate before giving up and clarifying. It is a second chance before&lt;br&gt;
the decision gets punted back to the user.&lt;/p&gt;
&lt;h2&gt;
  
  
  How we measured accuracy, and why the grader is strict
&lt;/h2&gt;

&lt;p&gt;The headline is &lt;strong&gt;85 to 90%&lt;/strong&gt; accuracy on a held-out suite of 70 realistic&lt;br&gt;
multi-tool queries, against a roughly 21% naive keyword-search baseline on the&lt;br&gt;
same suite. The baseline matters more than the headline, because it is the&lt;br&gt;
difference between "the pipeline works" and "an embedding lookup would have&lt;br&gt;
done fine."&lt;/p&gt;

&lt;p&gt;The grader is deliberately unforgiving. A case passes only if every required&lt;br&gt;
tool appears in the result and no forbidden tool appears, where &lt;code&gt;forbidden:&lt;br&gt;
["*"]&lt;/code&gt; means the correct answer is no tools at all. For &lt;code&gt;allow_any_of&lt;/code&gt; groups,&lt;br&gt;
which are sets of interchangeable tools, exactly one member must be present.&lt;br&gt;
Returning two valid alternatives is a failure rather than a hedge, because it&lt;br&gt;
pushes the choice back onto the model we are trying to protect. When an order&lt;br&gt;
is specified, the returned tools have to contain it as a subsequence, with&lt;br&gt;
relative order preserved and unrelated tools allowed to interleave. And when a&lt;br&gt;
case is marked &lt;code&gt;should_clarify&lt;/code&gt;, the router must return zero tools and set the&lt;br&gt;
clarify flag, which alone decides the case.&lt;/p&gt;

&lt;p&gt;That last rule is the one I would defend hardest. An accuracy metric that does&lt;br&gt;
not reward asking instead of guessing quietly incentivizes overconfident wrong&lt;br&gt;
answers, because the model learns that any answer beats admitting ambiguity.&lt;br&gt;
Ours treats correct abstention as a pass, which means the 88% includes the&lt;br&gt;
system knowing what it does not know.&lt;/p&gt;

&lt;p&gt;One honest limitation: cases requiring the same tool to be called multiple&lt;br&gt;
times are structurally unsatisfiable, because &lt;code&gt;route()&lt;/code&gt; emits each tool id&lt;br&gt;
once. We count those as real misses rather than excluding them, so the&lt;br&gt;
reported number is a floor rather than a flattered figure.&lt;/p&gt;
&lt;h2&gt;
  
  
  The decisions that mattered, and the alternatives we rejected
&lt;/h2&gt;

&lt;p&gt;Most of the interesting engineering here is not in what the pipeline does. It&lt;br&gt;
is in the four or five places where we deliberately chose the harder option.&lt;/p&gt;
&lt;h3&gt;
  
  
  Dynamic-K over fixed top-k
&lt;/h3&gt;

&lt;p&gt;The obvious design is "return the top 5 matches." We rejected it because a&lt;br&gt;
fixed k is wrong in both directions at once. For a query that needs one tool,&lt;br&gt;
top-5 injects four irrelevant schemas and reintroduces exactly the&lt;br&gt;
selection-confusion problem the router exists to solve. For a genuine&lt;br&gt;
multi-step request, top-5 might truncate a plan that needed six. And for an&lt;br&gt;
out-of-scope question, top-5 confidently returns five wrong tools.&lt;/p&gt;

&lt;p&gt;Making k dynamic means the judge has to answer "how many?" as well as&lt;br&gt;
"which?", which is a harder prompt and a harder thing to evaluate. It was&lt;br&gt;
still the right trade. Our measured average is 1.2 tools per call out of 142,&lt;br&gt;
which no fixed k would have produced.&lt;/p&gt;
&lt;h3&gt;
  
  
  One hard-coded rule, and only one
&lt;/h3&gt;

&lt;p&gt;There is a real temptation to encode catalog-specific heuristics, something&lt;br&gt;
like "queries mentioning 'log' should prefer the observability server." We&lt;br&gt;
kept exactly one code-side rule: an absolute cosine floor for out-of-scope&lt;br&gt;
detection.&lt;/p&gt;

&lt;p&gt;The reasoning is a division of labor. That floor is the one judgment the LLM&lt;br&gt;
cannot make cheaply. To know that nothing in the catalog fits, a judge would&lt;br&gt;
have to see the entire catalog, which is precisely the cost we are&lt;br&gt;
eliminating. A cosine threshold answers it in one vector op. Everything else&lt;br&gt;
(which tools, dedup, ambiguity, ordering) is semantic work and gets delegated&lt;br&gt;
to the judge. Rules that encode catalog specifics would need rewriting every&lt;br&gt;
time someone connects a new backend, which defeats the pluggability goal.&lt;/p&gt;
&lt;h3&gt;
  
  
  Judge order, not score order
&lt;/h3&gt;

&lt;p&gt;Early on we sorted the returned tools by cosine score. That is wrong for&lt;br&gt;
multi-step requests. "Pull last week's errors and open a ticket for the worst&lt;br&gt;
one" has an inherent execution order that has nothing to do with which tool&lt;br&gt;
embeds closer to the query. The judge reasons about sequence, so the judge's&lt;br&gt;
output order is the router's output order, and we explicitly do not re-sort.&lt;/p&gt;
&lt;h3&gt;
  
  
  Filter unhealthy backends before the judge, not after
&lt;/h3&gt;

&lt;p&gt;The intuitive design for backend health is retry-on-failure: select a tool,&lt;br&gt;
call it, and handle the error if the backend is down. We invert it. A backend&lt;br&gt;
marked down has its tools excluded from the candidate set before the judge&lt;br&gt;
sees them, re-evaluated on every call.&lt;/p&gt;

&lt;p&gt;This matters because of the specific failure it prevents. The judge picks the&lt;br&gt;
perfect tool, explains its reasoning, and then &lt;code&gt;invoke&lt;/code&gt; fails, so the user&lt;br&gt;
gets a wrong-looking answer for a right-looking decision. Filtering early&lt;br&gt;
means the judge selects the best reachable tool instead, possibly a&lt;br&gt;
second-choice tool on a healthy backend, which is the correct behavior.&lt;/p&gt;

&lt;p&gt;We also chose reactive health detection over a heartbeat loop. A backend gets&lt;br&gt;
marked down on an actual failed call and retried on next use. A separate&lt;br&gt;
polling process is one more thing to keep in sync with reality, and health&lt;br&gt;
checks that themselves flake produce false "down" states.&lt;/p&gt;
&lt;h3&gt;
  
  
  Deprecated tools stay fetchable
&lt;/h3&gt;

&lt;p&gt;Our first filter dropped every tool marked deprecated. We removed that clause&lt;br&gt;
deliberately, because a request like "export it in the legacy format"&lt;br&gt;
specifically needs the deprecated version. Governance filtering still applies,&lt;br&gt;
with PII-touching tools gated behind an explicit flag and destructive tools&lt;br&gt;
behind another, but deprecation is metadata for the judge to weigh rather than&lt;br&gt;
a hard exclusion for code to enforce.&lt;/p&gt;
&lt;h2&gt;
  
  
  Keeping the registry honest
&lt;/h2&gt;

&lt;p&gt;A tool router is only as good as its index, and tool catalogs are not static.&lt;br&gt;
Backends add tools, deprecate others, and change descriptions. Switchboard&lt;br&gt;
runs a background ingestion pipeline, independent of request-time traffic,&lt;br&gt;
which polls every registered backend on an interval and also reacts&lt;br&gt;
immediately to admin-triggered registration or removal. Same pipeline, two&lt;br&gt;
triggers.&lt;/p&gt;

&lt;p&gt;Each tool runs through enrichment: deterministic structural and length checks,&lt;br&gt;
LLM-based description-quality and off-topic detection, TF-IDF duplicate and&lt;br&gt;
outlier detection, and incremental LLM clustering against a persisted&lt;br&gt;
taxonomy.&lt;/p&gt;

&lt;p&gt;There is a circuit breaker on the LLM enrichment step. If fewer than half the&lt;br&gt;
tools survive validation, the pass is treated as an outage and the registry is&lt;br&gt;
left untouched. This was a deliberate answer to a real failure mode, since a&lt;br&gt;
flaky upstream model silently emptying your tool catalog is worse than having&lt;br&gt;
no enrichment at all.&lt;/p&gt;

&lt;p&gt;Before touching Pinecone the pipeline diffs against a Redis hash cache, so a&lt;br&gt;
full re-poll of a backend only costs an embedding call for what actually&lt;br&gt;
changed rather than the whole catalog every time. Each tool is then embedded&lt;br&gt;
as three views (name, description, parameters) into dense and sparse Pinecone&lt;br&gt;
indexes.&lt;/p&gt;

&lt;p&gt;That three-view embedding deserves a note. A single embedding of a&lt;br&gt;
concatenated tool blob dilutes each signal, and a distinctive parameter name&lt;br&gt;
gets averaged into prose. Embedding name, description, and parameters&lt;br&gt;
separately, then collapsing to the best-scoring view per tool at query time,&lt;br&gt;
means a query that matches strongly on one dimension still surfaces the tool&lt;br&gt;
instead of being averaged into mediocrity.&lt;/p&gt;
&lt;h2&gt;
  
  
  Plugging in a new backend without touching a running system
&lt;/h2&gt;

&lt;p&gt;This was a hard requirement from day one. Adding or removing a backend MCP&lt;br&gt;
server should never mean a restart, a redeploy, or a config-file hand-edit&lt;br&gt;
under load.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://&amp;lt;host&amp;gt;:&amp;lt;port&amp;gt;/admin/backends &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"X-Admin-Token: &lt;/span&gt;&lt;span class="nv"&gt;$ROUTER_ADMIN_TOKEN&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{"server_id":"firecrawl","transport":"http",
       "url":"https://my-mcp-server.example.com/mcp",
       "auth_header":"Authorization","auth_value":"Bearer &amp;lt;token&amp;gt;"}'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One authenticated call hot-connects the backend. Every tool it exposes is&lt;br&gt;
validated and embedded through the same enrichment pipeline the interval&lt;br&gt;
process uses, &lt;code&gt;backends.json&lt;/code&gt; is updated, and the moment the call returns&lt;br&gt;
those tools are live and discoverable through &lt;code&gt;find_tools&lt;/code&gt;. No restart, no&lt;br&gt;
downtime for existing traffic. Removal is the mirror image: one &lt;code&gt;DELETE&lt;/code&gt; call&lt;br&gt;
clears Pinecone, Redis, and the config in one step, so you never leave&lt;br&gt;
orphaned vectors behind.&lt;/p&gt;

&lt;p&gt;Registration is serialized behind a lock so two concurrent calls cannot race&lt;br&gt;
on shared state. It is an unglamorous detail that matters the first time two&lt;br&gt;
people onboard backends simultaneously.&lt;/p&gt;

&lt;p&gt;On the host side, plugging in is equally uneventful. Switchboard speaks&lt;br&gt;
standard MCP over HTTP, so it is just another entry in &lt;code&gt;mcp.json&lt;/code&gt;. We wired it&lt;br&gt;
into Claude Code with zero special prompting, no "please use the router"&lt;br&gt;
instruction needed. A plain question like "show me the error logs for&lt;br&gt;
checkout-api in the last hour" triggers &lt;code&gt;find_tools&lt;/code&gt; automatically, gets back&lt;br&gt;
exactly the one tool that matters, and resolves. That was the actual bar we&lt;br&gt;
were aiming for. The router should be invisible when it is working.&lt;/p&gt;

&lt;h2&gt;
  
  
  What this actually saves
&lt;/h2&gt;

&lt;p&gt;We instrumented every routing decision end to end. Every &lt;code&gt;find_tools&lt;/code&gt; call&lt;br&gt;
writes a telemetry row recording the routed-versus-full-catalog token&lt;br&gt;
estimate, search latency, judge latency, and outcome. Those writes are&lt;br&gt;
fire-and-forget, so they never block the response even if the DB write fails.&lt;br&gt;
A Streamlit dashboard reads that table live, and nothing on it is hardcoded.&lt;/p&gt;

&lt;p&gt;Measured against the 142-tool catalog, routing cuts input tokens per call by&lt;br&gt;
about 99.6% versus sending the full catalog every time, which works out to&lt;br&gt;
roughly 28,700 tokens saved per call (about 124 tokens routed against about&lt;br&gt;
28,860 for the full catalog dump). The average call selects 1.2 tools out of&lt;br&gt;
142 available, which is the dynamic-k judge working as intended rather than a&lt;br&gt;
fixed top-k masquerading as precision.&lt;/p&gt;

&lt;p&gt;End-to-end latency averages about 6.5 seconds, with roughly 4.9 seconds of&lt;br&gt;
that sitting in the LLM judge step. That is the honest cost of doing selection&lt;br&gt;
properly instead of guessing. The clarify rate is about 12%, meaning genuinely&lt;br&gt;
ambiguous requests get kicked back to the user instead of the router guessing&lt;br&gt;
and invoking the wrong tool.&lt;/p&gt;

&lt;p&gt;Projected from those per-call savings, at 1,000 calls a day (one active team's&lt;br&gt;
traffic) and Sonnet-class input pricing, that comes to roughly &lt;strong&gt;10.5 billion&lt;br&gt;
tokens&lt;/strong&gt; and about &lt;strong&gt;$31,000 a year&lt;/strong&gt; in avoided token spend. The per-call &lt;strong&gt;saving&lt;/strong&gt; is&lt;br&gt;
measured. The annual figure is that measurement multiplied by an assumed call&lt;br&gt;
volume, and it scales linearly with whatever volume your deployment actually&lt;br&gt;
sees.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a tool router matters more than it looks like it should
&lt;/h2&gt;

&lt;p&gt;It is tempting to file this under prompt optimization. It isn't. As agentic&lt;br&gt;
systems connect to more of an organization's real infrastructure, three things&lt;br&gt;
become true at once.&lt;/p&gt;

&lt;p&gt;Tool catalogs only grow, because nobody removes MCP servers once they are&lt;br&gt;
useful, they just add more. Context windows are not free even when they are&lt;br&gt;
technically large enough, since every token spent on tool schemas is a token&lt;br&gt;
not spent on reasoning, retrieved context, or conversation history. And&lt;br&gt;
selection errors compound, because a wrong tool call against a real backend&lt;br&gt;
(sending an email, querying production data, triggering a workflow) is not a&lt;br&gt;
cosmetic bug. It is an action with consequences.&lt;/p&gt;

&lt;p&gt;A router answers all three at once. It decouples how many tools exist from how&lt;br&gt;
many tools the model has to reason about, and it puts a validating,&lt;br&gt;
explainable decision layer between "the user asked for something" and "a real&lt;br&gt;
backend got called."&lt;/p&gt;

&lt;h2&gt;
  
  
  Handling the edges
&lt;/h2&gt;

&lt;p&gt;A few decisions exist specifically because the happy path is not where systems&lt;br&gt;
like this actually fail.&lt;/p&gt;

&lt;p&gt;If the judge is unavailable or its call fails, routing falls back to dense&lt;br&gt;
top-5 rather than returning nothing, because a degraded answer beats an&lt;br&gt;
outage. The admin API 404s entirely unless an admin token is configured, so&lt;br&gt;
there is no insecure-by-default state to accidentally ship. When &lt;code&gt;find_tools&lt;/code&gt;&lt;br&gt;
cannot confidently resolve intent the system asks rather than picking the&lt;br&gt;
closest-sounding tool and hoping. Instrumentation writes are fire-and-forget,&lt;br&gt;
so a Postgres hiccup degrades your dashboard and never your response latency.&lt;br&gt;
And ingestion is idempotent and incremental, so backends can be re-polled&lt;br&gt;
constantly without re-embedding a catalog that has not changed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;The current bottleneck is not accuracy. It is the 4.9-second judge step, plus&lt;br&gt;
the fact that the system learns nothing from its own traffic. Both are&lt;br&gt;
addressable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Draining admin-triggered ingestion asynchronously.&lt;/strong&gt; Registering a new&lt;br&gt;
backend currently blocks the API call until every tool is validated and&lt;br&gt;
embedded. That is honest but not ideal at scale. The target is to enqueue the&lt;br&gt;
job and let the background worker drain it so registration returns&lt;br&gt;
immediately. The work is already queue-shaped; the admin path just drains its&lt;br&gt;
own jobs synchronously today.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cutting judge latency with a cascade.&lt;/strong&gt; Roughly 75% of end-to-end latency is&lt;br&gt;
one LLM call, and most queries are not close calls. When the top candidate&lt;br&gt;
leads by a wide cosine margin and no near-twins exist, a small fast model or&lt;br&gt;
even a deterministic path could resolve it, escalating to the full judge only&lt;br&gt;
for genuinely contested cases. The measurement to run first is what fraction&lt;br&gt;
of traffic is actually contested. If it turns out to be 20%, a cascade cuts&lt;br&gt;
average latency substantially at close to zero accuracy cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Query rewriting before retrieval.&lt;/strong&gt; Terse or jargon-heavy requests like&lt;br&gt;
"502s on checkout" embed poorly against prose tool descriptions. Expanding the&lt;br&gt;
query before it hits Pinecone closes the vocabulary gap between how users ask&lt;br&gt;
and how tools are documented. The trade-off is another model call in the hot&lt;br&gt;
path, which argues for doing it only when the first retrieval scores weakly.&lt;br&gt;
Rewrite as a fallback, not a default.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Hard negative mining from real clarify and miss cases.&lt;/strong&gt; Every clarify is a&lt;br&gt;
labeled example of "these tools looked similar but the query was ambiguous,"&lt;br&gt;
and every user retry after a bad selection is a labeled negative. Mining those&lt;br&gt;
into a fine-tune or a re-ranker trains on our own traffic distribution instead&lt;br&gt;
of generic semantic similarity. This is the highest-leverage accuracy lever we&lt;br&gt;
have, because it compounds. More traffic means better signal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Feedback-loop learning from invoke outcomes.&lt;/strong&gt; We currently log which tools&lt;br&gt;
were selected, but not whether the invocation succeeded or whether the user&lt;br&gt;
immediately tried something else. Closing that loop, from selection through&lt;br&gt;
invocation to outcome, turns the telemetry table from a cost dashboard into a&lt;br&gt;
training set. It is mostly a schema change and a follow-up write.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Per-tenant re-ranking.&lt;/strong&gt; Usage patterns differ sharply by deployment. A team&lt;br&gt;
that lives in observability tooling should see different ranking than one that&lt;br&gt;
lives in CRM tools. A lightweight per-tenant prior over the global model&lt;br&gt;
captures most of that gain without maintaining separate indexes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tightening the enrichment quality bar.&lt;/strong&gt; Bad retrieval is often a symptom of&lt;br&gt;
bad tool descriptions rather than a bad retriever. Flagging ambiguous or&lt;br&gt;
poorly-described tools at ingestion time, and reporting them back to the&lt;br&gt;
backend owner, fixes the problem at its source instead of compensating for it&lt;br&gt;
at query time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cross-system observability.&lt;/strong&gt; The dashboard reports router cost, latency,&lt;br&gt;
and outcome. Unifying it with orchestrator-side telemetry, whether&lt;br&gt;
OTel/SigNoz/Langfuse or a shared events table, would let cost and accuracy be&lt;br&gt;
viewed end to end rather than only at the router boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;The interesting problem in agentic AI right now is not whether the model can&lt;br&gt;
call a tool. It is whether the model can call the right tool, reliably, as the&lt;br&gt;
number of tools you connect grows past what any single prompt should ever have&lt;br&gt;
to describe. That is an infrastructure problem rather than a prompting&lt;br&gt;
problem, and infrastructure problems deserve infrastructure solutions: a&lt;br&gt;
registry, a retrieval pipeline, a validation layer, and telemetry that tells&lt;br&gt;
you the truth about whether it is working.&lt;/p&gt;

&lt;p&gt;That is Switchboard. One operator, any number of lines, and a host that never&lt;br&gt;
has to know the number.&lt;/p&gt;

</description>
      <category>llmops</category>
      <category>ai</category>
      <category>mcp</category>
    </item>
    <item>
      <title>LLM Latency Benchmark Methodology: Streaming UX Metrics [2026]</title>
      <dc:creator>Kunal</dc:creator>
      <pubDate>Sun, 09 Aug 2026 00:42:41 +0000</pubDate>
      <link>https://dev.to/kunal_d6a8fea2309e1571ee7/llm-latency-benchmark-methodology-streaming-ux-metrics-2026-4043</link>
      <guid>https://dev.to/kunal_d6a8fea2309e1571ee7/llm-latency-benchmark-methodology-streaming-ux-metrics-2026-4043</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally published at &lt;a href="https://www.kunalganglani.com/blog/llm-latency-benchmark-methodology" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt; — read it there for inline code, hero image, and live links.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I don’t trust your “250ms TTFT” claim unless you can show me what the user actually saw.&lt;/p&gt;

&lt;p&gt;I’ve watched teams ship “fast” streaming chat UIs that still feel sluggish because tokens arrive in weird clumps, markdown doesn’t render until the end, and tool calls create dead-air pauses long enough for users to think the app froze. That’s the gap this &lt;strong&gt;llm latency benchmark methodology&lt;/strong&gt; is trying to close. Not model latency in a vacuum. Perceived latency in real-time chat and agent products.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Key takeaways&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A streaming LLM benchmark should report &lt;strong&gt;time-to-first-token (TTFT)&lt;/strong&gt; and &lt;strong&gt;token cadence/jitter&lt;/strong&gt;, because users feel “bursty” streams as lag.&lt;/li&gt;
&lt;li&gt;For chat UIs, the only honest metric is &lt;strong&gt;time-to-first-meaningful-render&lt;/strong&gt; (the first usable chunk rendered in your UI), not the provider’s first byte.&lt;/li&gt;
&lt;li&gt;For agents, track &lt;strong&gt;tool-call stall time&lt;/strong&gt; as a first-class metric. Tool execution pauses dominate perceived speed in multi-step flows.&lt;/li&gt;
&lt;li&gt;Always report &lt;strong&gt;p50/p95/p99&lt;/strong&gt; for TTFT, cadence, and end-to-end &lt;strong&gt;time-to-usable&lt;/strong&gt;, otherwise you’re hiding tail pain.&lt;/li&gt;
&lt;li&gt;A reproducible harness needs controlled prompts, controlled output length, warmup rules, fixed concurrency, and client-side instrumentation.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;If your benchmark can’t explain why the UI felt slow, it’s not a latency benchmark. It’s a provider marketing number.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  What is LLM latency benchmark methodology?
&lt;/h2&gt;

&lt;p&gt;LLM latency benchmark methodology is a repeatable way to measure and report how long a large language model takes to produce useful output under controlled conditions.&lt;/p&gt;

&lt;p&gt;In 2026, “useful output” usually means streaming output (SSE/WebSockets) inside a chat UI or an agent loop. So a modern methodology can’t stop at TTFT. It has to quantify:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Streaming LLM latency metrics&lt;/strong&gt; like inter-token delay distributions and chunk cadence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client-perceived milestones&lt;/strong&gt; like first rendered text, first rendered markdown, and first actionable instruction.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent-specific latency&lt;/strong&gt; like tool-call stalls, retries, and multi-turn loops.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I’m framing this as methodology, not a one-off benchmark chart. Charts go stale. A harness keeps paying rent.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why TTFT-only benchmarks are misleading for real-time apps
&lt;/h2&gt;

&lt;p&gt;TTFT is the first number everyone asks for because it’s easy to explain. It’s also the easiest number to game.&lt;/p&gt;

&lt;p&gt;Three common failure modes I keep seeing:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The first token is basically junk.&lt;/strong&gt; Your model can emit “Sure,” in 120ms, then take 2.5 seconds to produce anything that changes what the user does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Streaming is bursty.&lt;/strong&gt; Systems deliver tokens in clumps because of server flush cadence, proxy buffering, or client rendering. A stream that updates every 400ms feels slower than one that updates every 80ms, even if TTFT is identical.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Agent loops hide stalls.&lt;/strong&gt; Tool calls create “thinking…” gaps. Users don’t care that TTFT was 200ms if the app goes silent for 4 seconds before it does anything.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here’s a brutal example: if your UI renders markdown only after the full message, your perceived “first output” is effectively &lt;strong&gt;end-of-message&lt;/strong&gt; time. I’ve seen this exact bug show up in agentic chat demos. The model was streaming. The UI was not.&lt;/p&gt;

&lt;p&gt;This is why I split metrics into two buckets:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Provider-side latency&lt;/strong&gt;: what the model server did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;User-perceived latency&lt;/strong&gt;: what your product actually showed.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When those disagree, the user-perceived numbers win. Every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  The streaming UX metric suite (beyond TTFT)
&lt;/h2&gt;

&lt;p&gt;If you’re building a streaming-first product, you need a metric suite that matches what users feel, not what vendors want to print on a slide.&lt;/p&gt;

&lt;p&gt;Here’s what I recommend for a streaming-first &lt;strong&gt;inter-token latency benchmark&lt;/strong&gt; and chat/agent UX.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Time-to-first-token (TTFT)
&lt;/h3&gt;

&lt;p&gt;TTFT is still useful. It’s just not the whole story.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Definition&lt;/strong&gt;: request start → first token received by the client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report&lt;/strong&gt;: p50/p95/p99 in milliseconds.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re aiming for a “snappy” chat UI, a common target is &lt;strong&gt;sub-500ms p95 TTFT&lt;/strong&gt;. For voice or live-assist experiences, you often want &lt;strong&gt;sub-250ms p95&lt;/strong&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) Time-to-first-render (TTFR)
&lt;/h3&gt;

&lt;p&gt;This is the first moment the user sees anything on screen.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Definition&lt;/strong&gt;: request start → first UI paint of streamed content (not “spinner shown”).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Instrumentation&lt;/strong&gt;: your UI code, not the server.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A practical rule: TTFR should be within &lt;strong&gt;+50–150ms&lt;/strong&gt; of TTFT on a healthy web UI. If it’s &lt;strong&gt;+500ms&lt;/strong&gt;, your rendering pipeline is the bottleneck. Congratulations, you’re “optimizing the model” while React is the thing on fire.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Time-to-first-meaningful-render (TTFMR)
&lt;/h3&gt;

&lt;p&gt;This is where perceived speed actually lives.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Definition&lt;/strong&gt;: request start → first rendered chunk that changes user understanding or next action.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Examples of “meaningful”:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;First real bullet in a plan (not “Here’s a plan”).&lt;/li&gt;
&lt;li&gt;First tool result summary.&lt;/li&gt;
&lt;li&gt;First SQL query draft.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In a lot of real apps, TTFMR is &lt;strong&gt;2–10× larger&lt;/strong&gt; than TTFT because early tokens are pleasantries, hedges, or formatting.&lt;/p&gt;

&lt;h3&gt;
  
  
  4) Inter-token latency distribution
&lt;/h3&gt;

&lt;p&gt;“Average tokens/sec” is the metric equivalent of averaging the weather for an entire year. Technically true. Practically useless.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Definition&lt;/strong&gt;: time between consecutive tokens (or chunks) as observed by the client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report&lt;/strong&gt;: p50/p95/p99 inter-token delay, plus a histogram if you can.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A stream that’s 30 tok/s on average but has 800ms gaps will feel worse than 15 tok/s with steady 60–90ms updates.&lt;/p&gt;

&lt;h3&gt;
  
  
  5) Chunk cadence + jitter
&lt;/h3&gt;

&lt;p&gt;Most streaming APIs deliver chunks, not individual tokens. Users see chunks. So measure chunks.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Definition&lt;/strong&gt;: time between UI updates that add new text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cadence&lt;/strong&gt;: average update interval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jitter&lt;/strong&gt;: variance of the interval.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is the “streaming feels alive” metric. A good UX typically updates at &lt;strong&gt;5–15 Hz&lt;/strong&gt; (every &lt;strong&gt;70–200ms&lt;/strong&gt;). Below &lt;strong&gt;2 Hz&lt;/strong&gt; (500ms+), people start second-guessing whether the app froze.&lt;/p&gt;

&lt;h3&gt;
  
  
  6) Time-to-usable (TTU)
&lt;/h3&gt;

&lt;p&gt;This is the metric I want agent teams to rally around because it forces you to define “useful.”&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Definition&lt;/strong&gt;: request start → first moment the user can take the next action with confidence.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a chat app, TTU might be “first complete answer sentence.” For an agent, it might be “first step + the tool the agent is about to run + a confirmation prompt.”&lt;/p&gt;

&lt;p&gt;TTU is product-specific. Good. You’re building a product, not a latency demo.&lt;/p&gt;

&lt;h3&gt;
  
  
  7) Tool-call stall time (agents)
&lt;/h3&gt;

&lt;p&gt;Agentic UIs made stalls unavoidable. The only question is whether you measure them or pretend they don’t exist.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Definition&lt;/strong&gt;: time from “tool call requested” → “tool result available to model” → “stream resumed to user.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Break it down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Tool API round-trip time.&lt;/li&gt;
&lt;li&gt;Queueing in your tool infrastructure.&lt;/li&gt;
&lt;li&gt;Model “resume” latency after tool output.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A lot of teams eventually discover that the “LLM latency problem” was actually “our tool services are slow at p99.” Which is… not surprising.&lt;/p&gt;

&lt;h3&gt;
  
  
  8) End-to-end completion time (E2E)
&lt;/h3&gt;

&lt;p&gt;Still matters for throughput planning and cost.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Definition&lt;/strong&gt;: request start → final token rendered.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report&lt;/strong&gt;: p50/p95/p99, and include output length.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without output length, completion time is meaningless. You’re comparing apples to an entire fruit salad.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to measure inter-token latency accurately on the client
&lt;/h2&gt;

&lt;p&gt;If you want honest streaming metrics, measure at the point of truth: the client.&lt;/p&gt;

&lt;p&gt;Here’s the minimum I instrument in production chat/agent UIs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Request start timestamp&lt;/strong&gt; (when the user hits enter, or when your agent step begins).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First byte received&lt;/strong&gt; (stream opened).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First token/chunk received&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First UI render&lt;/strong&gt; (first paint of model output).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Every chunk render time&lt;/strong&gt; (when you append content to the DOM).&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For web apps, store timestamps in a trace context and emit them as structured events. I like doing this alongside OpenTelemetry tracing. If you’re already instrumenting agent workflows, you’ll get even more value from &lt;a href="https://dev.to/blog/opentelemetry-ai-agents-instrumentation"&gt;OpenTelemetry&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Two gotchas that keep biting people:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Measure render events, not network events.&lt;/strong&gt; The user doesn’t care when the browser received bytes if you don’t paint them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Token vs chunk.&lt;/strong&gt; Most APIs deliver chunks. Don’t invent token timestamps unless you’re actually receiving token-level events.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Concrete example: if your stream updates every &lt;strong&gt;200ms&lt;/strong&gt; but your UI batches DOM updates every &lt;strong&gt;500ms&lt;/strong&gt; (because of a throttled render loop), your “inter-token latency” is a lie. You’re measuring the wrong boundary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chunk sizes, SSE/WebSocket flush cadence, and why your stream is bursty
&lt;/h2&gt;

&lt;p&gt;Streaming performance is often dominated by boring plumbing. Not the model.&lt;/p&gt;

&lt;p&gt;A stream becomes bursty because:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The server buffers output and flushes every N tokens.&lt;/li&gt;
&lt;li&gt;Reverse proxies buffer response bodies.&lt;/li&gt;
&lt;li&gt;Compression (especially gzip) delays flushes because it wants bigger blocks.&lt;/li&gt;
&lt;li&gt;Your client parses events in batches.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you’re using Server-Sent Events, treat “flush cadence” as a configurable part of your serving stack. With WebSockets, you still have to decide message frequency.&lt;/p&gt;

&lt;h3&gt;
  
  
  A practical measurement approach
&lt;/h3&gt;

&lt;p&gt;In your harness, record:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Chunk size in characters (or bytes).&lt;/li&gt;
&lt;li&gt;Time between chunks at the client.&lt;/li&gt;
&lt;li&gt;Time between chunks at the UI render.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then compute:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Chunk cadence p50/p95/p99&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jitter&lt;/strong&gt; (standard deviation or p95–p50 gap).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you see a pattern like “chunks every 1.0s with 200–500 chars,” that’s not a model problem. That’s buffering.&lt;/p&gt;

&lt;h3&gt;
  
  
  A concrete target
&lt;/h3&gt;

&lt;p&gt;For a typical chat UI, I aim for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;p50 chunk cadence &amp;lt; 150ms&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;p95 chunk cadence &amp;lt; 300ms&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you can’t hit this, at least make the UX honest. Show progress. Show tool steps. Show partial structure. Don’t leave users staring at a cursor like it’s 1999.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to report p50/p95/p99 for TTFT, cadence, and time-to-usable
&lt;/h2&gt;

&lt;p&gt;Percentiles are not optional. Streaming and tail latency go together.&lt;/p&gt;

&lt;p&gt;If you only report p50, you’re measuring your best day.&lt;/p&gt;

&lt;p&gt;If you report p50 and p95, you’re starting to see operational risk.&lt;/p&gt;

&lt;p&gt;If you report p99, you’re finally talking about what on-call will feel like.&lt;/p&gt;

&lt;h3&gt;
  
  
  Recommended report format
&lt;/h3&gt;

&lt;p&gt;For each scenario (prompt + output length + concurrency), report:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;TTFT p50/p95/p99 (ms)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TTFR p50/p95/p99 (ms)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Chunk cadence p50/p95/p99 (ms)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TTU p50/p95/p99 (ms)&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;E2E completion p50/p95/p99 (ms)&lt;/strong&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also include:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Output length (tokens or chars) and &lt;code&gt;max_tokens&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Concurrency level.&lt;/li&gt;
&lt;li&gt;Warm vs cold.&lt;/li&gt;
&lt;li&gt;Region (client region and model region) if relevant.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  A compact mapping table (metric → how to measure → why it matters)
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;How to measure&lt;/th&gt;
&lt;th&gt;Why it matters to streaming UX&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;TTFT&lt;/td&gt;
&lt;td&gt;Client timestamp: request start → first token/chunk&lt;/td&gt;
&lt;td&gt;Controls the “is it alive?” feel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TTFR&lt;/td&gt;
&lt;td&gt;UI timestamp: request start → first paint of output&lt;/td&gt;
&lt;td&gt;Captures rendering delays TTFT misses&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Inter-chunk latency&lt;/td&gt;
&lt;td&gt;Client timestamps between chunk arrivals&lt;/td&gt;
&lt;td&gt;Quantifies burstiness users feel&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chunk cadence jitter&lt;/td&gt;
&lt;td&gt;Variance/p95–p50 of inter-chunk latency&lt;/td&gt;
&lt;td&gt;High jitter feels like freezing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time-to-usable (TTU)&lt;/td&gt;
&lt;td&gt;Product milestone: start → first actionable/meaningful content&lt;/td&gt;
&lt;td&gt;Best proxy for perceived speed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool-call stall time&lt;/td&gt;
&lt;td&gt;Tool start → tool result → stream resume&lt;/td&gt;
&lt;td&gt;Dominant in agent workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;E2E completion&lt;/td&gt;
&lt;td&gt;Start → final rendered token&lt;/td&gt;
&lt;td&gt;Needed for throughput/cost planning&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  Benchmarking agent/tool-call workflows (where the stream stalls)
&lt;/h2&gt;

&lt;p&gt;Agent benchmarks that ignore tool calls are measuring the wrong product.&lt;/p&gt;

&lt;p&gt;In a tool-calling loop, generation often looks like:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Model streams a plan.&lt;/li&gt;
&lt;li&gt;Model emits a tool call.&lt;/li&gt;
&lt;li&gt;Streaming stops.&lt;/li&gt;
&lt;li&gt;Tool runs.&lt;/li&gt;
&lt;li&gt;Model resumes streaming with results.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Users experience step 3 as “the app froze.” You can call it “tool execution.” They’ll call it “this thing is broken.”&lt;/p&gt;

&lt;h3&gt;
  
  
  What to measure
&lt;/h3&gt;

&lt;p&gt;For each tool call, capture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Tool request latency&lt;/strong&gt; (client → your backend → tool).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool execution time&lt;/strong&gt; (inside the tool).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool response latency&lt;/strong&gt; (tool → backend → model).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Resume latency&lt;/strong&gt; (tool result attached → model streams again).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then roll up:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Total &lt;strong&gt;tool-call stall time&lt;/strong&gt; per run.&lt;/li&gt;
&lt;li&gt;Stall time as a percentage of TTU and E2E.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Concrete example: If TTU is &lt;strong&gt;6.0s&lt;/strong&gt; and tool stalls account for &lt;strong&gt;4.2s&lt;/strong&gt;, optimizing the model is missing the point.&lt;/p&gt;

&lt;h3&gt;
  
  
  UX instrumentation that makes stall time visible
&lt;/h3&gt;

&lt;p&gt;If your UI can’t stream during tool execution, at least:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Show which tool is running.&lt;/li&gt;
&lt;li&gt;Show the input (sanitized) and what it’s waiting on.&lt;/li&gt;
&lt;li&gt;Show a time counter after &lt;strong&gt;500ms&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is as much product as performance engineering.&lt;/p&gt;

&lt;p&gt;If you’re building serious &lt;a href="https://dev.to/pillars/ai-agents"&gt;AI agents&lt;/a&gt;, you’ll also want a handle on tool governance patterns. I’ve written about &lt;a href="https://dev.to/blog/ai-agent-control-flow-patterns"&gt;agent control flow patterns&lt;/a&gt; and &lt;a href="https://dev.to/blog/tool-approval-patterns-ai-agents"&gt;tool approval patterns&lt;/a&gt; for that reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separating network latency, provider queueing, and model decode time
&lt;/h2&gt;

&lt;p&gt;If your benchmark can’t decompose latency, you can’t improve it.&lt;/p&gt;

&lt;p&gt;I like splitting end-to-end time into segments:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Client → edge&lt;/strong&gt; (DNS, TCP, TLS): network setup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Edge → provider&lt;/strong&gt;: upstream network.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Provider queue time&lt;/strong&gt;: waiting for capacity.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prefill time&lt;/strong&gt;: prompt processing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Decode time&lt;/strong&gt;: token generation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Server flush/buffering&lt;/strong&gt;: chunk emission schedule.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Client parse + render&lt;/strong&gt;: turning bytes into UI.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool time&lt;/strong&gt; (agents): external calls.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What you can actually observe
&lt;/h3&gt;

&lt;p&gt;You usually can’t directly observe “provider queue time” unless the provider exposes it. But you can still triangulate.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If TTFT balloons under concurrency while token cadence stays stable, you’re probably queueing.&lt;/li&gt;
&lt;li&gt;If cadence slows down but TTFT stays stable, decode throughput is the bottleneck.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When I built a multi-stage RAG chatbot for Walmart product pages, the lesson that kept repeating at scale was that the slow part was often not the model. Kafka event-streaming the context pipeline mattered more for latency than model-side tricks, because it removed cross-service waiting in the critical path. Same mental model here. Break the path into segments or you’ll optimize the wrong thing.&lt;/p&gt;

&lt;p&gt;If you want a deeper take on production tracing, see my write-up on &lt;a href="https://dev.to/pillars/ai-engineering-production"&gt;production AI&lt;/a&gt; and &lt;a href="https://dev.to/blog/evaluate-ai-agents-production"&gt;AI in production&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  A reproducible benchmarking harness design (chat + agents)
&lt;/h2&gt;

&lt;p&gt;Reproducibility is what separates a benchmark from a vibe.&lt;/p&gt;

&lt;p&gt;Here’s a harness design that’s actually worth trusting.&lt;/p&gt;

&lt;h3&gt;
  
  
  1) Fixed scenarios, not random prompts
&lt;/h3&gt;

&lt;p&gt;Create a small scenario set:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;5–10 prompts for chat.&lt;/li&gt;
&lt;li&gt;3–5 agent tasks that include tool calls.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Make them stable. Version them. Treat them like a test suite.&lt;/p&gt;

&lt;p&gt;If you already maintain eval datasets, reuse that discipline. My agent posts like &lt;a href="https://dev.to/blog/ai-agent-evaluation-framework-2026"&gt;AI Agent Evaluation Framework 2026: 8 Metrics Beyond Task Success&lt;/a&gt; and &lt;a href="https://dev.to/blog/agent-evaluation-harness-replay"&gt;Agent Evaluation Harness&lt;/a&gt; focus on correctness, but the harness mechanics apply here too.&lt;/p&gt;

&lt;h3&gt;
  
  
  2) Control output length
&lt;/h3&gt;

&lt;p&gt;Uncontrolled output length will destroy comparability.&lt;/p&gt;

&lt;p&gt;Options:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Use explicit &lt;code&gt;max_tokens&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Force a structured output with a fixed number of bullets.&lt;/li&gt;
&lt;li&gt;For agents, stop after N steps.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Report the target length and the actual length.&lt;/p&gt;

&lt;h3&gt;
  
  
  3) Define warmup vs cold start
&lt;/h3&gt;

&lt;p&gt;You need both.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cold&lt;/strong&gt;: first request after idle (captures model spin-up, connection setup, cache misses).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Warm&lt;/strong&gt;: steady-state.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Run at least &lt;strong&gt;10 warmup requests&lt;/strong&gt; before collecting “warm” stats. Don’t mix them.&lt;/p&gt;

&lt;h3&gt;
  
  
  4) Concurrency settings
&lt;/h3&gt;

&lt;p&gt;Real-time apps often run at concurrency &amp;gt; 1 even per user (prefetching, tool calls, parallel retrieval).&lt;/p&gt;

&lt;p&gt;Benchmark at:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Concurrency 1 (baseline)&lt;/li&gt;
&lt;li&gt;Concurrency 5&lt;/li&gt;
&lt;li&gt;Concurrency 20 (or whatever matches your traffic class)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If p95 TTFT jumps from &lt;strong&gt;400ms&lt;/strong&gt; at concurrency 1 to &lt;strong&gt;2,000ms&lt;/strong&gt; at concurrency 20, you just found your scaling wall.&lt;/p&gt;

&lt;h3&gt;
  
  
  5) Network conditions
&lt;/h3&gt;

&lt;p&gt;If you only test from one data center on a perfect link, you’re benchmarking your luck.&lt;/p&gt;

&lt;p&gt;At minimum, run from:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The same region as your users.&lt;/li&gt;
&lt;li&gt;A second region.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you have a mobile app, include a high-latency profile (e.g., &lt;strong&gt;100–200ms RTT&lt;/strong&gt;).&lt;/p&gt;

&lt;h3&gt;
  
  
  6) Client + server tracing
&lt;/h3&gt;

&lt;p&gt;Correlate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Client events (TTFT, TTFR, cadence)&lt;/li&gt;
&lt;li&gt;Server events (request received, upstream call start/end, flush events)&lt;/li&gt;
&lt;li&gt;Tool events (start/end)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is where OpenTelemetry shines. Instrument once, then reuse across performance and reliability work.&lt;/p&gt;

&lt;h3&gt;
  
  
  7) Store raw traces
&lt;/h3&gt;

&lt;p&gt;Don’t just store aggregates.&lt;/p&gt;

&lt;p&gt;Raw traces let you answer questions like:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“Why did p99 cadence spike at 3pm?”&lt;/li&gt;
&lt;li&gt;“Which tool calls are causing stalls?”&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  UI instrumentation: measuring perceived latency inside your product
&lt;/h2&gt;

&lt;p&gt;If you take one thing from this post, take this: &lt;strong&gt;instrument your UI like it’s a performance-critical system. Because it is.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;What I track in production chat/agent UIs:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;First contentful paint&lt;/strong&gt; after send.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First token painted&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First markdown block rendered&lt;/strong&gt; (if you support markdown).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;First actionable UI element enabled&lt;/strong&gt; (copy button, approve tool, “run” button).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-chunk render time&lt;/strong&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Then I compute a product metric leadership can understand without a lecture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;“p95 time-to-usable for chat answers is &lt;strong&gt;1.8s&lt;/strong&gt;.”&lt;/li&gt;
&lt;li&gt;“p95 time-to-usable for agent tasks is &lt;strong&gt;7.2s&lt;/strong&gt;, and &lt;strong&gt;58%&lt;/strong&gt; of that is tool stalls.”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These numbers start real conversations. TTFT alone doesn’t.&lt;/p&gt;

&lt;h2&gt;
  
  
  Avoiding misleading benchmarks (the stuff that ruins your data)
&lt;/h2&gt;

&lt;p&gt;Here are the traps that make benchmarks quietly wrong.&lt;/p&gt;

&lt;h3&gt;
  
  
  Caching
&lt;/h3&gt;

&lt;p&gt;If your provider caches responses for identical prompts, you might be measuring cache hits.&lt;/p&gt;

&lt;p&gt;Mitigation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Add a nonce in the prompt that doesn’t change the task.&lt;/li&gt;
&lt;li&gt;Or explicitly disable caching if supported.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Rate limits and retries
&lt;/h3&gt;

&lt;p&gt;Rate limit backoffs create long-tail spikes.&lt;/p&gt;

&lt;p&gt;Mitigation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Log retry counts.&lt;/li&gt;
&lt;li&gt;Separate “successful no-retry” distributions from “all requests.”&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Batching
&lt;/h3&gt;

&lt;p&gt;Server-side batching can change inter-token cadence and TTFT.&lt;/p&gt;

&lt;p&gt;Mitigation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Record concurrency and batching settings.&lt;/li&gt;
&lt;li&gt;Don’t compare “batching on” to “batching off” without saying so.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Speculative decoding
&lt;/h3&gt;

&lt;p&gt;Speculative decoding can improve perceived speed but may change cadence patterns.&lt;/p&gt;

&lt;p&gt;Mitigation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Treat it as a configuration dimension, not a constant.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Client-side throttling
&lt;/h3&gt;

&lt;p&gt;Your UI might be throttling renders to reduce jank.&lt;/p&gt;

&lt;p&gt;Mitigation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Measure both “chunk received” and “chunk rendered.”&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Output-length drift
&lt;/h3&gt;

&lt;p&gt;If one run generates 200 tokens and another generates 900, your completion time comparison is meaningless.&lt;/p&gt;

&lt;p&gt;Mitigation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Control &lt;code&gt;max_tokens&lt;/code&gt; and enforce structured outputs.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A data anchor from my own benchmarks (and why it matters here)
&lt;/h2&gt;

&lt;p&gt;Based on the benchmark methodology and results I maintain at &lt;strong&gt;&lt;a href="https://dev.to/llm-benchmarks"&gt;kunalganglani.com/llm-benchmarks&lt;/a&gt;&lt;/strong&gt;, a recurring pattern in local inference is that &lt;strong&gt;TTFT and steady-state throughput move differently depending on hardware and runtime&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;For example, on Apple Silicon you can often load larger models than your “VRAM limit intuition” suggests because of unified memory, but throughput becomes the real trade. That same TTFT vs throughput vs perceived UX trade shows up in cloud streaming too: you can win TTFT and still lose the user experience if cadence is bursty or tool stalls dominate.&lt;/p&gt;

&lt;p&gt;If you want a local-focused methodology baseline, see &lt;a href="https://dev.to/blog/local-llm-benchmark-methodology"&gt;Local LLM Benchmark Methodology&lt;/a&gt;. The point of this post is extending that thinking to streaming UX.&lt;/p&gt;

&lt;h2&gt;
  
  
  Putting it all together: a step-by-step benchmark plan
&lt;/h2&gt;

&lt;p&gt;A tutorial needs an executable plan. Here’s mine.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Define your UX milestones&lt;/strong&gt;: TTFR, TTFMR, TTU (write down what “usable” means in your product).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick 5–10 fixed prompts&lt;/strong&gt; and 3–5 agent tasks with at least 1 tool call each.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implement client instrumentation&lt;/strong&gt;: timestamps for receive + render, plus chunk sizes.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Implement server tracing&lt;/strong&gt;: upstream call timing, flush timing, tool timing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run warm and cold suites&lt;/strong&gt;: at least 10 warmup runs, then 30+ measured runs per scenario.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run at 3 concurrency levels&lt;/strong&gt;: 1, 5, 20 (adjust to your expected load).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Report p50/p95/p99&lt;/strong&gt; for TTFT, cadence, TTU, and E2E. Include output length.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Visualize stalls&lt;/strong&gt;: a timeline per run showing model stream, tool stall, stream resume.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Do this and your benchmark will tell you what to fix. Do less than this and you’ll spend a quarter shaving 80ms off TTFT while users complain the app “hangs.”&lt;/p&gt;

&lt;h2&gt;
  
  
  The uncomfortable prediction
&lt;/h2&gt;

&lt;p&gt;Streaming is about to get judged like video performance.&lt;/p&gt;

&lt;p&gt;Users are developing an instinct for “this app is laggy” that has nothing to do with your backend dashboards. They’ll feel it in the cadence. The stalls. The weird clumps. The UI that waits to render markdown until the very end.&lt;/p&gt;

&lt;p&gt;My bet: within a year, serious teams will treat &lt;strong&gt;time-to-usable&lt;/strong&gt; the way web teams treat Core Web Vitals. And the teams still bragging about TTFT will get outsold by the teams that make streaming feel alive.&lt;/p&gt;

&lt;p&gt;If you’re building real-time chat or agents, stop measuring what’s easy. Measure what the user feels. Then optimize that.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://www.kunalganglani.com/blog/llm-latency-benchmark-methodology?utm_source=devto&amp;amp;utm_medium=referral&amp;amp;utm_campaign=llm-latency-benchmark-methodology" rel="noopener noreferrer"&gt;kunalganglani.com&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

</description>
      <category>llmops</category>
      <category>latency</category>
      <category>benchmarking</category>
      <category>streaming</category>
    </item>
    <item>
      <title>Our Token Counter Took 26 Seconds on a Single Prompt</title>
      <dc:creator>Talha Anwar</dc:creator>
      <pubDate>Fri, 07 Aug 2026 23:37:36 +0000</pubDate>
      <link>https://dev.to/ailegend/our-token-counter-took-26-seconds-on-a-single-prompt-3l9p</link>
      <guid>https://dev.to/ailegend/our-token-counter-took-26-seconds-on-a-single-prompt-3l9p</guid>
      <description>&lt;p&gt;&lt;em&gt;This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Project Overview
&lt;/h2&gt;

&lt;p&gt;I work on &lt;a href="https://acruxcore.com" rel="noopener noreferrer"&gt;AcruxCore&lt;/a&gt;, an LLM ops platform with an Express API gateway. Every completion passes a budget pre-check: estimate the token cost, compare against the team's remaining spend, reject if it doesn't fit.&lt;/p&gt;

&lt;p&gt;For OpenAI-family models, that estimate used &lt;a href="https://github.com/dqbd/tiktoken" rel="noopener noreferrer"&gt;&lt;code&gt;js-tiktoken&lt;/code&gt;&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isOpenAiFamily&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Fall through to the heuristic if the encoder rejects the input.&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines. They do the right thing. They just don't always do it quickly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bug Fix or Performance Improvement
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Performance bug — a quadratic BPE tokenizer on the synchronous request path.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I didn't find this through a user complaint. Two &lt;code&gt;datasets&lt;/code&gt; tests started timing out at Jest's 5-second limit. I assumed I'd broken something, stashed my changes, checked out clean &lt;code&gt;staging&lt;/code&gt;, and ran them again. Still failed.&lt;/p&gt;

&lt;p&gt;The tests were fine. The code under them was just brutally slow.&lt;/p&gt;

&lt;p&gt;The BPE encoder is &lt;strong&gt;quadratic on whitespace-free input&lt;/strong&gt;. BPE works by repeatedly merging the most common adjacent pair of characters. Each pass scans the entire string to find the best pair. One merge per pass, full scan each time. A string of N characters with no whitespace means roughly N passes over N characters — N² work.&lt;/p&gt;

&lt;p&gt;Normal text never hits this because the pre-tokenizer splits on whitespace first. Every piece is one short word (~5 chars), so N² is nothing. A long unbroken string has no whitespace to split on, so the whole thing becomes one piece and N becomes the full length.&lt;/p&gt;

&lt;p&gt;Measured on &lt;code&gt;cl100k_base&lt;/code&gt; (GPT-4's tokenizer):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;single piece&lt;/th&gt;
&lt;th&gt;encode time&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;256 chars&lt;/td&gt;
&lt;td&gt;4 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;512 chars&lt;/td&gt;
&lt;td&gt;18 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1,024 chars&lt;/td&gt;
&lt;td&gt;70 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;2,048 chars&lt;/td&gt;
&lt;td&gt;281 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;9,000 chars&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;5.4 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;20,000 chars&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;26 s&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Doubling the length roughly quadruples the time. Meanwhile, &lt;strong&gt;20,000 characters of ordinary prose encodes in 3 ms&lt;/strong&gt;. It's not the length — it's the lack of whitespace.&lt;/p&gt;

&lt;p&gt;This function runs on every single request. Node runs JavaScript on one thread. A 26-second synchronous encode doesn't just slow down one request — it freezes every other user on that process.&lt;/p&gt;

&lt;p&gt;And the input that triggers it isn't exotic: a base64 blob in a prompt, a minified JSON payload, a corrupted copy-paste. Any authenticated caller could stall the whole process.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;gateway completion, 2-char rendered variable:      82 ms
gateway completion, 9000-char rendered variable:  5506 ms
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same code path. Same mocked provider. One template variable different.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why not just remove the tokenizer?
&lt;/h3&gt;

&lt;p&gt;My first thought. But &lt;code&gt;estimateTokens&lt;/code&gt; has two jobs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Budget pre-check&lt;/strong&gt; — reject requests that would exceed the spend cap. Overestimate and you reject valid requests. Underestimate and teams blow past their budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Billing&lt;/strong&gt; — when a provider streams without usage data, this estimate becomes the number on the user's bill.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;And &lt;code&gt;chars / 4&lt;/code&gt; is a rough rule of thumb for English prose. It breaks on other inputs. Our test suite uses a synthetic 400 KB input (1,600 repetitions of 250 &lt;code&gt;x&lt;/code&gt; characters plus a space — the same one that caused the timeouts):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Characters: 1,600 × 251 = 401,600
Exact BPE:  52,800 tokens  →  401,600 / 52,800 = 7.6 chars/token
chars / 4:  100,400 tokens  →  401,600 / 4 = 100,400 (nearly 2× the truth)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your budget allows 80,000 tokens, the real prompt fits (52,800) but &lt;code&gt;chars / 4&lt;/code&gt; says it doesn't (100,400) and rejects it. Using &lt;code&gt;chars / 4&lt;/code&gt; everywhere would overestimate so aggressively that it would reject roughly half of all valid prompts.&lt;/p&gt;

&lt;p&gt;So the tokenizer stays. It just doesn't get to run unbounded anymore.&lt;/p&gt;

&lt;h2&gt;
  
  
  Code
&lt;/h2&gt;

&lt;p&gt;The work happened in a private repo, so the PR link won't help. The fixed file is public in our mirror, with the measurement table in the doc comment:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/AcruxCore/AcruxCore/blob/main/apps/api/src/gateway/providers/token-estimate.ts" rel="noopener noreferrer"&gt;&lt;code&gt;apps/api/src/gateway/providers/token-estimate.ts&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Before&lt;/strong&gt; — the entire string in one encoder call:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isOpenAiFamily&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* fall through to heuristic */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;heuristic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;After&lt;/strong&gt; — two decisions: how much text to encode, and how to chop it up first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;isOpenAiFamily&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Bound 2: past 20,000 chars, encode a sample and scale the rest.&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nx"&gt;MAX_BPE_SAMPLE_CHARS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;boundedEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sampleTokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;boundedEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;MAX_BPE_SAMPLE_CHARS&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sampleTokens&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;MAX_BPE_SAMPLE_CHARS&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;sampleTokens&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ceil&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;MAX_BPE_SAMPLE_CHARS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;ratio&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="cm"&gt;/* fall through to heuristic */&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;heuristic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  My Improvements
&lt;/h2&gt;

&lt;p&gt;Two bounds. No new dependencies. No change to what it returns for normal input.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bound 1 — cap each piece at 256 characters
&lt;/h3&gt;

&lt;p&gt;Nothing over 256 characters reaches the encoder. Longer pieces get &lt;code&gt;ceil(chars / 4)&lt;/code&gt; as a quick estimate instead.&lt;/p&gt;

&lt;p&gt;This removes the quadratic cost entirely, because 256 chars keeps each encoder call under ~4 ms.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Important:&lt;/strong&gt; 256 is a &lt;em&gt;threshold&lt;/em&gt;, not a chunk size. A 1,024-character piece is not split into four 256-char chunks. It skips the encoder entirely and gets &lt;code&gt;ceil(1024 / 4) = 256&lt;/code&gt; directly. Zero encoder calls.&lt;/p&gt;

&lt;p&gt;The text is only split at whitespace boundaries — to keep token counts accurate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;boundedEncode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;pendingSeparator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="se"&gt;(\s&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;/&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\s&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;pendingSeparator&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;piece&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pendingSeparator&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;part&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;pendingSeparator&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;piece&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_BPE_PIECE_CHARS&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;heuristic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;piece&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;piece&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pendingSeparator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;tokens&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="nx"&gt;pendingSeparator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;MAX_BPE_PIECE_CHARS&lt;/span&gt;
      &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nf"&gt;heuristic&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pendingSeparator&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
      &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pendingSeparator&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;tokens&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;The easy line to get wrong is &lt;code&gt;pendingSeparator&lt;/code&gt;.&lt;/strong&gt; Whitespace must be attached to the &lt;em&gt;following&lt;/em&gt; word because tiktoken groups &lt;code&gt;" word"&lt;/code&gt; as one token. Encode the space on its own and &lt;code&gt;" the"&lt;/code&gt; becomes two tokens instead of one — every prose prompt gets overcounted by roughly its word count, and budget checks silently start rejecting requests they shouldn't.&lt;/p&gt;

&lt;p&gt;A correctness bug from a performance fix is a bad trade.&lt;/p&gt;

&lt;h4&gt;
  
  
  Effect on the 20,000-character blob
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;encoder calls&lt;/td&gt;
&lt;td&gt;one, on 20,000 chars&lt;/td&gt;
&lt;td&gt;none (20,000 &amp;gt; 256)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;time&lt;/td&gt;
&lt;td&gt;26,952 ms&lt;/td&gt;
&lt;td&gt;0 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tokens returned&lt;/td&gt;
&lt;td&gt;2,500&lt;/td&gt;
&lt;td&gt;5,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The count doubles — that's deliberate. &lt;code&gt;ceil(chars / 4)&lt;/code&gt; overestimates, but 20,000 identical characters isn't a real prompt, and for a budget check, overestimating is the safe direction.&lt;/p&gt;

&lt;p&gt;Ordinary text is untouched. &lt;code&gt;"Hello world"&lt;/code&gt; splits into &lt;code&gt;"Hello"&lt;/code&gt; and &lt;code&gt;" world"&lt;/code&gt;, both under 256, both encoded exactly as before.&lt;/p&gt;

&lt;h3&gt;
  
  
  Bound 2 — cap the total at 20,000 characters
&lt;/h3&gt;

&lt;p&gt;Bound 1 only looks at one piece at a time. It never catches the case of many &lt;em&gt;short&lt;/em&gt; pieces adding up.&lt;/p&gt;

&lt;p&gt;1,600 repetitions of 250 &lt;code&gt;x&lt;/code&gt;s plus a space = 400 KB. Each piece is 250 chars (under the 256 cap), so Bound 1 lets every one through. At ~4 ms each, that's &lt;strong&gt;7 seconds&lt;/strong&gt;. Bound 1 never triggers once.&lt;/p&gt;

&lt;p&gt;Bound 2 fixes this: encode at most 20,000 characters total. For anything longer, encode the first 20K, measure the tokens-per-char ratio, and scale that ratio across the rest.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step by step:&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Take the first 20,000 characters&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;boundedEncode&lt;/code&gt; on them (still uses Bound 1 internally)&lt;/li&gt;
&lt;li&gt;Get the token count from the sample&lt;/li&gt;
&lt;li&gt;Divide by 20,000 to get a &lt;code&gt;tokens-per-char&lt;/code&gt; ratio&lt;/li&gt;
&lt;li&gt;Multiply the ratio by the remaining characters&lt;/li&gt;
&lt;li&gt;Add sample tokens + tail estimate = return value&lt;/li&gt;
&lt;/ol&gt;

&lt;h4&gt;
  
  
  Effect on the 400 KB input
&lt;/h4&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;bound 1 only&lt;/th&gt;
&lt;th&gt;both bounds&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;characters encoded&lt;/td&gt;
&lt;td&gt;401,600&lt;/td&gt;
&lt;td&gt;20,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;time&lt;/td&gt;
&lt;td&gt;7,018 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;334 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tokens returned&lt;/td&gt;
&lt;td&gt;52,800&lt;/td&gt;
&lt;td&gt;52,771&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The first 20K characters gave 2,628 tokens. Applied to the remaining 381,600 characters: 50,143 tokens. Total: 52,771 vs exact 52,800.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sampled ratio:  2,628 / 20,000 = 0.13 tokens per character
chars / 4 ratio:            1 / 4 = 0.25 tokens per character
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;0.13 is roughly half of 0.25. Using &lt;code&gt;chars / 4&lt;/code&gt; would claim 95,400 tokens on the tail instead of 50,143 — nearly double the truth. The sample already measured the real ratio, so using it is almost as accurate as encoding everything, without the 7-second cost.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;0.05% off, for 5% of the work.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Why 20,000 characters? At the 256-char piece cap, worst case is ~80 pieces (20,000 / 256). At ~4 ms each, that's ~320 ms — fast enough to stay on the request path. Anything bigger gets the sample treatment. The number isn't magic; it's where "encode everything" becomes "too slow."&lt;/p&gt;

&lt;p&gt;Worst case is now roughly &lt;strong&gt;350 ms for any input size.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Error direction
&lt;/h3&gt;

&lt;p&gt;Bound 1 can only &lt;strong&gt;overestimate&lt;/strong&gt; — &lt;code&gt;ceil(chars / 4)&lt;/code&gt; always returns more tokens than real BPE on the inputs that trigger it. For a budget check, that's the safe direction.&lt;/p&gt;

&lt;p&gt;Bound 2 is different: it extrapolates from a sample, so it can land slightly above or below the true count. The 400 KB case came out 0.05% low. But that only applies past 20,000 characters, and a fraction of a percent drift on a huge prompt is not a budget problem. A 7-second event-loop stall is.&lt;/p&gt;

&lt;h3&gt;
  
  
  Proving it didn't change the answer
&lt;/h3&gt;

&lt;p&gt;A bounded estimator that returns different numbers is a new bug, not a fix. Tests pin exactness first, speed second.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Exactness&lt;/strong&gt; — must match whole-string BPE on all normal input (under 20K chars):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;matches whole-string BPE counts for ordinary text&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getEncoding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cl100k_base&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;samples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hi Alice, what is the weather in London?&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;The quick brown fox jumps over the lazy dog. &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;114&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;const x = foo.bar(baz, 42); // comment here&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;a&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;hello world&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;b&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;c&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;d&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nested value here&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;}).&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sample&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;samples&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;estimateTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sample&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Prose, code, JSON: identical counts. No existing estimate moved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Direction&lt;/strong&gt; — pathological input must never come back low:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;never underestimates the pathological input&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;encoder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getEncoding&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;cl100k_base&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;estimateTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBeGreaterThanOrEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;encoder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;Speed&lt;/strong&gt; — one test per bound, so removing either guard fails immediately:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stays fast on a long unbroken run of characters&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;estimateTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warm up the encoder&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;estimateTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="nx"&gt;_000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeGreaterThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeLessThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;stays fast when many pieces sit just under the per-piece cap&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;estimateTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;warm up the encoder&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; `&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;repeat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1600&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;estimateTokens&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeGreaterThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBeLessThan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The 1-second limit is deliberately generous — won't flake on loaded CI, but still fails instantly if someone removes a guard (the unfixed path takes minutes).&lt;/p&gt;

&lt;h3&gt;
  
  
  Results
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;before&lt;/th&gt;
&lt;th&gt;after&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;50,000-char unbroken run&lt;/td&gt;
&lt;td&gt;minutes&lt;/td&gt;
&lt;td&gt;&amp;lt; 1 s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;400 KB of 250-char pieces&lt;/td&gt;
&lt;td&gt;7,018 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;334 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;completion, 9,000-char variable (provider mocked)&lt;/td&gt;
&lt;td&gt;5,506 ms&lt;/td&gt;
&lt;td&gt;~82 ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;datasets&lt;/code&gt; test A&lt;/td&gt;
&lt;td&gt;6,340 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;410 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;datasets&lt;/code&gt; test B&lt;/td&gt;
&lt;td&gt;6,243 ms&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;323 ms&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The timing-out tests were never touched. They passed because the code underneath stopped being slow.&lt;/p&gt;

&lt;h3&gt;
  
  
  What I'd carry to another codebase
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test adversarial input, not your own.&lt;/strong&gt; Every prompt I'd written was ordinary prose — the tokenizer's best case. Timing &lt;code&gt;'x'.repeat(20000)&lt;/code&gt; took ten minutes and found the whole bug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A synchronous function on a request path is a shared resource.&lt;/strong&gt; "It's just a pure function over a string" stops being reassuring when the string is caller-controlled and the cost is superlinear.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pin the old answer before you optimize.&lt;/strong&gt; An exact-match test against whole-string encoding is how I caught the &lt;code&gt;pendingSeparator&lt;/code&gt; detail that would have silently overcounted every prompt.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;If you're running a BPE tokenizer anywhere synchronous, it's worth timing &lt;code&gt;encoder.encode('x'.repeat(20000))&lt;/code&gt; on your own setup today. What did you get?&lt;/p&gt;

</description>
      <category>devchallenge</category>
      <category>bugsmash</category>
      <category>performance</category>
      <category>llmops</category>
    </item>
    <item>
      <title>I Thought Building Agent Observability Was a Detector Problem. I Was Wrong.</title>
      <dc:creator>Debashish Ghosal</dc:creator>
      <pubDate>Fri, 07 Aug 2026 04:52:23 +0000</pubDate>
      <link>https://dev.to/debashish_ghosal/i-thought-building-agent-observability-was-a-detector-problem-i-was-wrong-7b</link>
      <guid>https://dev.to/debashish_ghosal/i-thought-building-agent-observability-was-a-detector-problem-i-was-wrong-7b</guid>
      <description>&lt;p&gt;When I started building &lt;code&gt;agent-exec-trace&lt;/code&gt;, I thought I knew where the difficulty would be. OSS &lt;a href="https://github.com/deghosal-2026/agent-exec-trace" rel="noopener noreferrer"&gt;agent-exec-trace&lt;/a&gt; is an observability layer for AI agents. It instruments agent runs using OpenTelemetry-style traces, then analyzes those traces for loops, retry storms, cost spikes, hallucination patterns, and other bad runtime behavior. The goal is simple: when an agent run feels wrong, I want something better than logs and intuition to explain why.&lt;/p&gt;

&lt;p&gt;I thought the hard part would be the detector logic.&lt;/p&gt;

&lt;p&gt;Define the anomalies. Pick the thresholds. Wire the traces. Run the analytics. Ship the repo.&lt;/p&gt;

&lt;p&gt;That was the clean version of the story.&lt;/p&gt;

&lt;p&gt;The real version was uglier.&lt;/p&gt;

&lt;p&gt;On my first large pass over 100,000 real agent traces from Hugging Face, my &lt;code&gt;empty_response&lt;/code&gt; detector fired on 100% of them. Every trace. At the same time, 28 of my 35 rule-based detectors never fired at all.&lt;/p&gt;

&lt;p&gt;That is the moment the project stopped being "build some detectors" and turned into a learning experience.&lt;/p&gt;

&lt;p&gt;The detectors were not wrong.&lt;/p&gt;

&lt;p&gt;The traces were the wrong shape.&lt;/p&gt;

&lt;p&gt;And once I saw that, a bunch of other assumptions broke with it.&lt;/p&gt;

&lt;p&gt;I had a very similar feeling when I shipped my &lt;a href="https://dev.to/debashish_ghosal/i-built-an-agent-eval-harness-real-agents-broke-the-clean-version-of-the-story-53dj"&gt;agent eval harness&lt;/a&gt;. Different tool. Same pattern. Real systems are very good at ruining tidy design stories.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Was Actually Trying To Build
&lt;/h2&gt;

&lt;p&gt;The problem I cared about felt pretty obvious.&lt;/p&gt;

&lt;p&gt;Traditional observability can tell me if a service is up, slow, or failing.&lt;/p&gt;

&lt;p&gt;It cannot tell me why an agent called the same tool eight times, changed its mind three times, burned tokens on a dead-end path, or quietly drifted into behavior I would never want repeated in production.&lt;/p&gt;

&lt;p&gt;That gap matters more than people admit.&lt;/p&gt;

&lt;p&gt;Most teams can tell you the run felt wrong. Very few can point to the exact step where it started going wrong.&lt;/p&gt;

&lt;p&gt;So I built &lt;code&gt;agent-exec-trace&lt;/code&gt;: an OpenTelemetry-style observability layer for agent behavior.&lt;/p&gt;

&lt;p&gt;The idea is simple enough to explain.&lt;/p&gt;

&lt;p&gt;Instrument the run. Capture the behavioral path. Emit spans for planning, tool calls, retrieval, memory, approvals, and cost. Then run detectors over those traces and surface the bad runs in a UI someone can actually use.&lt;/p&gt;

&lt;p&gt;The repo is here: &lt;a href="https://github.com/deghosal-2026/agent-exec-trace" rel="noopener noreferrer"&gt;github.com/deghosal-2026/agent-exec-trace&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;It ships as three PyPI packages, has 794 Python tests and 34 Playwright end-to-end tests, and the SDK is deliberately small enough that someone can wrap a real agent without redesigning their system.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;agent_exec_trace&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AgentTracer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace_agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_span&lt;/span&gt;

&lt;span class="n"&gt;AgentTracer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;otlp_endpoint&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http://localhost:4317&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@trace_agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;agent_name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;my-agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent_version&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1.0.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;tool_span&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tool_args&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;}):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That part was not the lesson.&lt;/p&gt;

&lt;p&gt;The lesson was what happened after I had something real enough to test.&lt;/p&gt;

&lt;h2&gt;
  
  
  The First Thing I Learned: Real Traces Fight Back
&lt;/h2&gt;

&lt;p&gt;I did plan field testing.&lt;/p&gt;

&lt;p&gt;Just not early enough.&lt;/p&gt;

&lt;p&gt;That distinction matters, because the mistake was not forgetting it. The mistake was scheduling it late enough that the rest of the system had already started to feel done.&lt;/p&gt;

&lt;p&gt;The WBS was solid. The PRD was thorough. The detector catalog was documented. The milestones were real. The quality gates were strict: ruff clean, mypy strict clean, tests green, coverage above 90%.&lt;/p&gt;

&lt;p&gt;All of that was good discipline.&lt;/p&gt;

&lt;p&gt;None of it protected me from the fact that mock agents are polite and real traces are not.&lt;/p&gt;

&lt;p&gt;That is what "too late" looked like in practice. By the time field testing showed up, parts of the detector catalog, the assumptions about trace shape, and even some milestone gates already felt validated. Field testing did not arrive early enough to shape the design. It arrived late enough to expose where the design had been validated against the wrong reality.&lt;/p&gt;

&lt;p&gt;The Hugging Face corpus was the first slap.&lt;/p&gt;

&lt;p&gt;My detector expected response content in one place. The corpus stored it in many different shapes. So &lt;code&gt;empty_response&lt;/code&gt; was not telling me "all these agents are broken." It was telling me "your assumptions about trace shape are too narrow."&lt;/p&gt;

&lt;p&gt;That one bug forced four normalization passes. Different response keys. Different tool-name conventions. Different operation names. Timestamp parsing issues. Parent-child inconsistencies.&lt;/p&gt;

&lt;p&gt;Bad validation data does not just create noise. It teaches you the wrong lessons with confidence.&lt;/p&gt;

&lt;p&gt;By the end of that cleanup, the headline got more honest and less comforting.&lt;/p&gt;

&lt;p&gt;The original compatibility number looked decent.&lt;/p&gt;

&lt;p&gt;The real one was 42.4%.&lt;/p&gt;

&lt;p&gt;That was the moment I stopped thinking about observability as a detector problem and started thinking about it as a data-shape problem.&lt;/p&gt;

&lt;p&gt;Because a detector that is perfectly correct in theory is still useless if the data it depends on almost never exists in the wild.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Second Thing I Learned: Synthetic Data Can Save You And Mislead You
&lt;/h2&gt;

&lt;p&gt;Once the real-trace corpus showed me its limits, I went in the other direction.&lt;/p&gt;

&lt;p&gt;I built a synthetic trace generator.&lt;/p&gt;

&lt;p&gt;One million traces. Ten fake agents. Fourteen tools. Deliberate behavior modes: loops, retries, timeouts, inactivity gaps, intervention waits, token explosions, memory bursts.&lt;/p&gt;

&lt;p&gt;That solved one problem immediately.&lt;/p&gt;

&lt;p&gt;Structural compatibility jumped to 99.2%.&lt;/p&gt;

&lt;p&gt;Now the detectors had something they could actually see.&lt;/p&gt;

&lt;p&gt;Twenty of the 35 rule-based detectors fired.&lt;/p&gt;

&lt;p&gt;That sounds like a win. And it was.&lt;/p&gt;

&lt;p&gt;But synthetic data lies in a different way.&lt;/p&gt;

&lt;p&gt;My hallucination detector fired on 98% of synthetic traces. Not because I had accidentally built the greatest detector in history, but because the synthetic outputs and the synthetic tool evidence had a fake relationship that made the detector's job too easy.&lt;/p&gt;

&lt;p&gt;The same thing happened with cost. My &lt;code&gt;cost_spike&lt;/code&gt; detector had a real threshold. My synthetic generator produced mostly cents-level costs. So the detector almost never fired there either.&lt;/p&gt;

&lt;p&gt;Synthetic traces can prove a detector runs. They cannot prove it matters.&lt;/p&gt;

&lt;p&gt;That gave me a second lesson I needed badly:&lt;/p&gt;

&lt;p&gt;Synthetic traces are great for proving a detector can run.&lt;/p&gt;

&lt;p&gt;They are not enough to prove a detector is calibrated for reality.&lt;/p&gt;

&lt;p&gt;So now I think of validation in layers.&lt;/p&gt;

&lt;p&gt;Unit tests tell me the logic works.&lt;/p&gt;

&lt;p&gt;Synthetic traces tell me the detector can see the fields it needs.&lt;/p&gt;

&lt;p&gt;Real traces tell me whether any of this matters outside my own sandbox.&lt;/p&gt;

&lt;p&gt;That sequencing sounds obvious when written down. It did not feel obvious when I was in the middle of building it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Third Thing I Learned: Green Gates Can Lie
&lt;/h2&gt;

&lt;p&gt;This one annoyed me more than any detector bug.&lt;/p&gt;

&lt;p&gt;I had marked the OTLP export milestone done.&lt;/p&gt;

&lt;p&gt;The gate was green.&lt;/p&gt;

&lt;p&gt;The docs looked fine.&lt;/p&gt;

&lt;p&gt;The demo looked fine.&lt;/p&gt;

&lt;p&gt;And the end-to-end export path had never actually been verified.&lt;/p&gt;

&lt;p&gt;Two bugs had cancelled each other out.&lt;/p&gt;

&lt;p&gt;The OTel collector's gRPC port was not exposed in Docker Compose.&lt;/p&gt;

&lt;p&gt;And the SDK path I was using configured local tracing instead of OTLP export.&lt;/p&gt;

&lt;p&gt;So I had a completed milestone for a feature that had not really worked.&lt;/p&gt;

&lt;p&gt;That was a worse lesson than a failing test.&lt;/p&gt;

&lt;p&gt;A failing test is honest.&lt;/p&gt;

&lt;p&gt;A green gate created by two cancelling bugs is dishonest in a way that looks disciplined.&lt;/p&gt;

&lt;p&gt;That changed how I think about milestones.&lt;/p&gt;

&lt;p&gt;I trust a green checkmark a lot less now.&lt;/p&gt;

&lt;p&gt;If the gate cannot prove that a real agent emits, Jaeger receives, analytics ingests, and the API serves the result, then the gate is incomplete. I do not care how many sub-checks passed before it.&lt;/p&gt;

&lt;p&gt;That is not an observability lesson only. That is just software engineering.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fourth Thing I Learned: Structure Without Content Is Not Enough
&lt;/h2&gt;

&lt;p&gt;I started fairly conservative on privacy.&lt;/p&gt;

&lt;p&gt;Metadata-only felt responsible.&lt;/p&gt;

&lt;p&gt;No raw tool arguments. No full tool responses. No memory values by default.&lt;/p&gt;

&lt;p&gt;Reasonable instinct.&lt;/p&gt;

&lt;p&gt;But it taught me a more uncomfortable tradeoff.&lt;/p&gt;

&lt;p&gt;An observability SDK that only captures structure can become blind in exactly the places where you most want judgment.&lt;/p&gt;

&lt;p&gt;My hallucination detector is the clearest example.&lt;/p&gt;

&lt;p&gt;If the detector cannot see what the tool returned, it cannot meaningfully judge whether the agent's claim matches the evidence.&lt;/p&gt;

&lt;p&gt;Once I allowed truncated content instead of metadata-only, the hallucination false-positive rate dropped sharply.&lt;/p&gt;

&lt;p&gt;That did not make the privacy question go away.&lt;/p&gt;

&lt;p&gt;It just made the tradeoff explicit.&lt;/p&gt;

&lt;p&gt;"Safer by default" and "useful by default" are not always the same choice.&lt;/p&gt;

&lt;p&gt;Structure without enough evidence is just a cleaner way to stay blind.&lt;/p&gt;

&lt;p&gt;I do not think enough tooling says that out loud.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Fifth Thing I Learned: Shipping OSS Is Not The Same As Finishing The Idea
&lt;/h2&gt;

&lt;p&gt;The repo is public.&lt;/p&gt;

&lt;p&gt;The packages are published.&lt;/p&gt;

&lt;p&gt;The test suite is real.&lt;/p&gt;

&lt;p&gt;The product works.&lt;/p&gt;

&lt;p&gt;And I still would not call the problem solved.&lt;/p&gt;

&lt;p&gt;That matters to me.&lt;/p&gt;

&lt;p&gt;I think too many OSS launch posts flatten everything into "here is what shipped" and skip "here is what I still do not trust."&lt;/p&gt;

&lt;p&gt;Here is what I still do not trust fully:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the 28 detectors that have not yet fired on real corpora in a meaningful way&lt;/li&gt;
&lt;li&gt;the LLM detectors on production workloads&lt;/li&gt;
&lt;li&gt;span-tree materialization in the API, which is still weaker than I want&lt;/li&gt;
&lt;li&gt;any claim that one threshold set will generalize cleanly across workloads&lt;/li&gt;
&lt;li&gt;any green gate that has not been proven end-to-end&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That does not mean the project should not ship.&lt;/p&gt;

&lt;p&gt;It means the honest version of shipping is: this tool is useful now, and I understand its edges much better than I did when I started.&lt;/p&gt;

&lt;p&gt;That is a good outcome.&lt;/p&gt;

&lt;p&gt;It is also a very different outcome from the clean version I had in my head.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I Would Do Differently Next Time
&lt;/h2&gt;

&lt;p&gt;I would move field testing much earlier.&lt;/p&gt;

&lt;p&gt;Not because I failed to think of it, but because planning it and planning it early are not the same thing.&lt;/p&gt;

&lt;p&gt;Late field testing turns into audit. Early field testing shapes the design.&lt;/p&gt;

&lt;p&gt;By the time mine ran, it was strong enough to expose bad assumptions, but too late to stop me from building confidence on top of them.&lt;/p&gt;

&lt;p&gt;I would treat the trace corpus as a design artifact, not just test input.&lt;/p&gt;

&lt;p&gt;I would insist on an end-to-end smoke path much earlier.&lt;/p&gt;

&lt;p&gt;And I would stop pretending that detector logic is the center of the problem.&lt;/p&gt;

&lt;p&gt;It matters, obviously.&lt;/p&gt;

&lt;p&gt;But after building this, I think the harder questions are:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;what can your traces actually see?&lt;/li&gt;
&lt;li&gt;how weird is the data in the wild?&lt;/li&gt;
&lt;li&gt;what does your detector depend on structurally?&lt;/li&gt;
&lt;li&gt;and how do you know a green gate is real?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That is the actual education this project gave me.&lt;/p&gt;

&lt;p&gt;Not "how to write 40 detectors."&lt;/p&gt;

&lt;p&gt;More like: how many other things have to be true before 40 detectors mean anything.&lt;/p&gt;

&lt;p&gt;And maybe the hardest takeaway of all: a detector that has never fired on a real trace is not really finished. It is just well tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Open For Discussion
&lt;/h2&gt;

&lt;p&gt;I would genuinely love practitioner pushback on this.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;If you are running agents today, what do you actually use when a run feels wrong?&lt;/li&gt;
&lt;li&gt;Have you ever had a green gate that passed for the wrong reason?&lt;/li&gt;
&lt;li&gt;And if you are building agent tooling, where did your clean version of the story break first?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://github.com/deghosal-2026/agent-exec-trace" rel="noopener noreferrer"&gt;github.com/deghosal-2026/agent-exec-trace&lt;/a&gt; · &lt;a href="https://pypi.org/project/agent-exec-trace/" rel="noopener noreferrer"&gt;PyPI&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>opentelemetry</category>
      <category>llmops</category>
      <category>opensource</category>
    </item>
    <item>
      <title>I've Spent Months Grading AI Agents' Code for a Living. Here's the Pattern Nobody's Talking About</title>
      <dc:creator>Marvin Okafor</dc:creator>
      <pubDate>Thu, 06 Aug 2026 18:20:52 +0000</pubDate>
      <link>https://dev.to/marvinoka4/ive-spent-months-grading-ai-agents-code-for-a-living-heres-the-pattern-nobodys-talking-about-34bf</link>
      <guid>https://dev.to/marvinoka4/ive-spent-months-grading-ai-agents-code-for-a-living-heres-the-pattern-nobodys-talking-about-34bf</guid>
      <description>&lt;p&gt;Everyone's talking about agentic AI shipping production code. Nobody's talking about what happens when you actually sit down and grade thousands of lines of it against a rubric, line by line, for months.&lt;/p&gt;

&lt;p&gt;I have. And the failure pattern that shows up over and over isn't the one Twitter/X is arguing about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The job title that didn't exist two years ago
&lt;/h2&gt;

&lt;p&gt;"AI evaluator." "AI trainer." "Expert contributor to frontier model training data." None of these existed as job titles when I started my career. Now they're where a chunk of the most interesting engineering signal in the industry is actually happening — quietly, behind NDAs, far from the demo videos.&lt;/p&gt;

&lt;p&gt;Here's what the job actually is: agentic coding outputs land on your desk, and you grade them against a structured rubric — correctness, instruction adherence, quality, edge-case handling. You design adversarial prompts to find where the model's reasoning breaks. You decide which checks can be programmatic and deterministic, and which genuinely need a human who's shipped production systems to make the call. This is &lt;strong&gt;RL environment design&lt;/strong&gt; and &lt;strong&gt;LLMOps&lt;/strong&gt; in its rawest form, and it's a completely different skill from "prompt engineer" or "ML researcher." It's closer to being a QA lead for a junior engineer who never sleeps, never gets embarrassed, and will confidently ship the wrong answer with perfect syntax.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern: agents are great at code, bad at consequences
&lt;/h2&gt;

&lt;p&gt;Here's the uncomfortable part. The failure mode people are loudest about — hallucinated APIs, made-up library functions — is the &lt;em&gt;easy&lt;/em&gt; failure mode. It's loud, it's obvious, and any decent test suite catches it in seconds.&lt;/p&gt;

&lt;p&gt;The failure mode that actually matters, the one that slips past a surface read and even past a naive test suite, looks like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The code is syntactically perfect and semantically wrong about failure.&lt;/strong&gt; It handles the happy path beautifully and quietly assumes the retry, the timeout, the partial write, the duplicate message never happens.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It optimises for the metric, not the intent&lt;/strong&gt; — the agentic-AI version of Goodhart's Law. Give a model a rubric that checks "does the deploy succeed," and you'll occasionally get a solution that technically satisfies the check while doing something no engineer would sign off on. Evaluators call this &lt;strong&gt;reward hacking&lt;/strong&gt;, and it's a far more common failure than outright hallucination once you're grading real-world infra tasks instead of leetcode.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It's confidently wrong about IAM, concurrency, and distributed state&lt;/strong&gt; — exactly the areas where production engineering experience matters most and where a rubric written by someone who's never operated a real system will miss the defect entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is a knock on the models. It's a knock on how we evaluate them. You cannot catch consequence-blindness with a rubric written by someone who has never had a database silently corrupt state under a race condition at 2am. This is the actual bottleneck in scaling agentic AI into production-grade infrastructure work: not model capability, &lt;strong&gt;evaluation quality&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "vibe coding" breaks down at the infra layer
&lt;/h2&gt;

&lt;p&gt;"Vibe coding" — accepting AI-generated code because it &lt;em&gt;looks&lt;/em&gt; right and the demo works — is fine for a prototype. It is not fine for anything touching IAM policies, message queues, durable storage, or disaster recovery. The gap between "looks right" and "is right" is exactly the gap that &lt;strong&gt;golden reference solutions&lt;/strong&gt; and &lt;strong&gt;deterministic validation tests&lt;/strong&gt; exist to close — the same discipline I wrote about in my last post on building RL environments for cloud infrastructure evaluation.&lt;/p&gt;

&lt;p&gt;The uncomfortable truth for the "AI writes all our code now" crowd: the more production-grade the system, the more the bottleneck shifts from &lt;em&gt;generating&lt;/em&gt; the code to &lt;em&gt;specifying and verifying&lt;/em&gt; it. That's a systems-engineering problem, not a model-scaling problem. It's also, not coincidentally, exactly what senior backend engineers have spent their careers getting good at — writing test suites against real databases instead of mocks, root-causing defects that hide three layers deep, documenting edge cases precisely enough that someone else can reproduce the reasoning. That skill set didn't get less valuable when agents showed up. It became the thing standing between "the demo worked" and "it survived contact with production."&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'm building next
&lt;/h2&gt;

&lt;p&gt;I'm turning this into a real project instead of just a hot take: a lightweight harness for stress-testing AI-agent-generated infrastructure code against realistic, injected failure conditions — retries, partial outages, IAM misconfigurations, the exact defect classes I've been describing above — with &lt;strong&gt;deterministic pass/fail checks&lt;/strong&gt; instead of vibes. Think chaos engineering meets AI evals: inject one fault at a time, assert invariants instead of traces, and see whether an agent's "working" solution is actually working or just golden-path lucky.&lt;/p&gt;

&lt;p&gt;It'll live on my portfolio and GitHub as I build it in the open — seed scenarios, the fault-injection harness, and a write-up of what breaks and why. If you're working on anything adjacent (RL environments, AI evals, chaos engineering, or you've just been burned by AI-generated infra code in production), I want to hear about it — drop it in the comments or find me on GitHub.&lt;/p&gt;

&lt;h2&gt;
  
  
  The takeaway
&lt;/h2&gt;

&lt;p&gt;Agentic AI isn't going to be stopped by a model that can't write a for-loop. It's going to be shaped by whether the industry gets serious about &lt;strong&gt;evaluation infrastructure&lt;/strong&gt; — golden solutions, deterministic tests, adversarial failure scenarios — as fast as it's getting excited about generation. That's the unglamorous, unsexy, extremely fundable problem hiding behind every "AI wrote our whole backend" headline.&lt;/p&gt;

&lt;p&gt;If you've seen this pattern too — agents that ace the demo and fail the disaster-recovery drill — I'd genuinely like to compare notes.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This is the fifth in a series on production engineering, debugging, and building evaluation environments for AI systems. Follow for the harness build-in-public over the coming weeks.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>llmops</category>
      <category>agenticai</category>
      <category>careerdev</category>
    </item>
    <item>
      <title>Our eval gate runs 22 minutes. The queue behind it hit three hours.</title>
      <dc:creator>Ethan Walker</dc:creator>
      <pubDate>Thu, 06 Aug 2026 17:03:21 +0000</pubDate>
      <link>https://dev.to/ethanwritesai/our-eval-gate-runs-22-minutes-the-queue-behind-it-hit-three-hours-4iim</link>
      <guid>https://dev.to/ethanwritesai/our-eval-gate-runs-22-minutes-the-queue-behind-it-hit-three-hours-4iim</guid>
      <description>&lt;p&gt;Nobody complained about the gate. They complained about Tuesday.&lt;/p&gt;

&lt;p&gt;Our merge queue runs the full eval suite before anything lands: 1,400 cases, about 240 of them scored by an LLM judge, the rest deterministic. Wall clock, 22 minutes. One PR at a time, because the suite pins the same eval dataset and the judge budget. Every engineer on the team would tell you 22 minutes is fine. Go get coffee.&lt;/p&gt;

&lt;p&gt;Then one Tuesday I counted nine PRs sitting in the queue at 11:04. The last one merged at 14:22. Three hours and eighteen minutes for a change that took 40 minutes to write, and the gate itself never ran slow, never flaked, never failed. Every single run took its normal 22 minutes. The queue did the rest.&lt;/p&gt;

&lt;h2&gt;
  
  
  The math nobody ran
&lt;/h2&gt;

&lt;p&gt;A serial gate is a single-server queue. What sets the wait is the ratio between the gate's duration and how fast work arrives. That ratio has a name, utilisation, and the wait it produces is not linear in it. It has a knee.&lt;/p&gt;

&lt;p&gt;For a fixed 22-minute service time and randomly arriving merges, the steady-state average wait in queue is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;gate_queue_wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s_min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;arrivals_per_hr&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Average queue wait (minutes) before a serial s_min-minute gate.
    M/D/1: Wq = rho * s / (2 * (1 - rho)), rho = arrival rate * s.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;rho&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;arrivals_per_hr&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;s_min&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rho&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;inf&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;  &lt;span class="c1"&gt;# the queue never drains
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rho&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;s_min&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;rho&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;1.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;2.6&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/hr  wait &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;gate_queue_wait&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;22&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rate&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mf"&gt;6.1&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; min&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it for our 22-minute gate and the knee is right there:&lt;/p&gt;

&lt;p&gt;1.0/hr: utilisation 37%, avg wait in queue 6.4 min, wait + gate 28.4 min&lt;br&gt;
1.5/hr: utilisation 55%, avg wait in queue 13.4 min, wait + gate 35.4 min&lt;br&gt;
2.0/hr: utilisation 73%, avg wait in queue 30.2 min, wait + gate 52.2 min&lt;br&gt;
2.25/hr: utilisation 82%, avg wait in queue 51.9 min, wait + gate 73.9 min&lt;br&gt;
2.5/hr: utilisation 92%, avg wait in queue 121.0 min, wait + gate 143.0 min&lt;br&gt;
2.6/hr: utilisation 95%, avg wait in queue 224.7 min, wait + gate 246.7 min&lt;br&gt;
Capacity is 2.73 merges per hour. That is the whole budget a 22-minute serial gate gives you, ever.&lt;/p&gt;

&lt;p&gt;At one merge an hour the gate is invisible. At two an hour, the average PR waits longer in the queue than it spends being tested. At two and a half, the average experience is over two hours, and that is the average on a quiet, evenly spaced day. The formula assumes arrivals sprinkled at random. Real teams merge in bursts, after standup, before the sprint cutoff, and a burst is strictly worse than the formula. Our nine-PR Tuesday was not an anomaly. It was nine arrivals hitting a server that clears 2.7 an hour, and 9 times 22 minutes is 3 hours 18. The math was never going to do anything else.&lt;/p&gt;

&lt;p&gt;The part that stung: we had spent a month optimising the suite from 26 minutes down to 22, and we only understood what we had bought after running these numbers. At two merges an hour, the old gate held the average wait at 85 minutes; the new one holds it at 30. And on Tuesday-heavy stretches at two and a half, the 26-minute suite was past capacity entirely (60 over 26 is 2.3 an hour): that queue was not slow, it was diverging, and we had fixed it by accident. Service time matters exactly as much as the ratio says it does, and none of that is visible from the gate's runtime alone.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we changed
&lt;/h2&gt;

&lt;p&gt;Three tiers, nothing clever.&lt;/p&gt;

&lt;p&gt;Tier 0, per commit, 90 seconds. Every deterministic check that does not need the full dataset: schema conformance, tool-call shape, regex and exact-match cases, token-budget ceilings. About 400 of the 1,400 cases, and historically the tier that catches most honest mistakes. Runs on push, before review, outside the merge queue entirely.&lt;/p&gt;

&lt;p&gt;Tier 1, the merge queue, batched. The full 22-minute suite still gates every merge, but it gates batches, not PRs. As soon as the runner is free it takes whatever is queued, up to four PRs, and runs once against the batch head. Green, all four land. Red, the batch splits into pairs and reruns, then the failing pair splits again: standard bisection, four extra runs worst case to isolate one offender. A batch of four cuts effective service per PR to five and a half minutes, which moves us from 92 percent utilisation back to about 23 at the same merge rate; the average wait falls off the bottom of the table, into single-digit minutes. Red batches cost us the bisection, and at our failure rate the trade wins by a large margin; if your gate is red a third of the time, batching will hurt, and your problem is the failure rate anyway.&lt;/p&gt;

&lt;p&gt;Tier 2, nightly. The expensive sweeps that never belonged in a merge path: full judge calibration against the human-labelled set, cross-model regression, the long-tail scenarios. Nightly, with a report in the morning, and a rule that a red nightly blocks the next day's releases rather than the next engineer's merge.&lt;/p&gt;

&lt;p&gt;Did we lose per-PR attribution inside a green batch? Yes, and we decided we do not care: a green batch means every PR in it shipped against the full suite. Attribution only matters on red, and red is exactly when bisection runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it cost
&lt;/h2&gt;

&lt;p&gt;One week of one engineer, most of it spent classifying the 1,400 cases into deterministic versus judge-scored, and deciding which of the heavyweight sweeps belong in the new nightly tier. The queue infrastructure was our existing merge queue with batching turned on. The 22-minute suite itself did not change at all, which is the point: we stopped paying for it per PR without deleting a single case. The nightly sweeps run on top of the merge gate, not instead of it.&lt;/p&gt;

&lt;p&gt;Median wait in the queue, the time before your run even starts, went from 74 minutes that Tuesday-heavy week to under six the week after. Add the 22 minutes the suite still takes and a typical merge now lands in under half an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd check first
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Your gate's service time against your busiest hour's arrival rate. Multiply them, service time in hours. Past 0.7, you have a queueing problem, not a test-speed problem, and shaving suite minutes will not save you.&lt;/li&gt;
&lt;li&gt;What fraction of the gate is deterministic checks that could run per commit. Ours was 29 percent of cases and they catch the majority of failures; they were queueing behind an LLM judge for no reason.&lt;/li&gt;
&lt;li&gt;Whether your queue serialises PRs that could gate as a batch. Serial-per-PR is the default in most setups. It is also the strictest possible service discipline, and the table above is the bill for it.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>llm</category>
      <category>cicd</category>
      <category>testing</category>
      <category>llmops</category>
    </item>
  </channel>
</rss>
