<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://neverthesame.github.io/BeOps/feed.xml" rel="self" type="application/atom+xml" /><link href="https://neverthesame.github.io/BeOps/" rel="alternate" type="text/html" /><updated>2026-06-09T02:46:54+00:00</updated><id>https://neverthesame.github.io/BeOps/feed.xml</id><title type="html">BeOps</title><subtitle>BeOps - Best Practices for DevOps and SRE.
</subtitle><author><name>Kirill Kuklin</name></author><entry><title type="html">Why I Benchmark a Proxy That Already Works</title><link href="https://neverthesame.github.io/BeOps/ai/2026-06-08-why-benchmark-llm-proxy.html" rel="alternate" type="text/html" title="Why I Benchmark a Proxy That Already Works" /><published>2026-06-08T00:00:00+00:00</published><updated>2026-06-08T00:00:00+00:00</updated><id>https://neverthesame.github.io/BeOps/ai/why-benchmark-llm-proxy</id><content type="html" xml:base="https://neverthesame.github.io/BeOps/ai/2026-06-08-why-benchmark-llm-proxy.html"><![CDATA[<p>A week ago I <a href="/BeOps/ai/2026-06-01-openai-compatible-proxy-for-claude.html">put an OpenAI-shaped proxy in front of Claude</a>,
then <a href="/BeOps/ai/2026-06-03-microbatching-llm-proxy.html">taught it to wait on purpose</a>
so concurrent requests get grouped into admission windows instead of stampeding
the upstream. That microbatching post explained <em>how</em> the feature works. This
post is about something I kept dodging: <em>how do I know it actually helps?</em></p>

<p>The honest answer is that, on my laptop, I couldn’t tell. A single <code class="language-plaintext highlighter-rouge">curl</code> against
the proxy returns in the same few hundred milliseconds whether batching is on or
off. The before and after I wrote about is real, but it is <strong>completely invisible
in a demo.</strong> You only see it when 200 clients arrive in the same 50 ms. Which
means the only way to defend the feature, tune it, or trust it is to benchmark
it. That is the whole subject here.</p>

<p>The companion visualization makes the motivation concrete: the thundering herd,
then the fix.
<strong><a href="https://neverthesame.github.io/llm-batcher/batching.html">batching.html on GitHub Pages</a></strong>
(open it in a browser and watch the uncoordinated spike turn into controlled
waves). A benchmark is just that picture with numbers attached.</p>

<h2 id="a-demo-proves-nothing">A demo proves nothing</h2>

<p>Here is the uncomfortable part of shipping infrastructure: the failure mode you
built the feature to prevent does not show up in the way you naturally test it.</p>

<p>A demo is one request at a time. One request opens one upstream connection, waits,
returns. Batching adds a few milliseconds of deliberate delay, so in a demo the
batched path is, if anything, very slightly <em>slower</em>. Every instinct says the
feature is useless.</p>

<p>The feature only earns its keep under concurrency, and concurrency is exactly the
condition a casual test never reproduces. The plain proxy under 200 simultaneous
clients opens roughly 200 upstream connections at once, hits the classic
<a href="https://en.wikipedia.org/wiki/Thundering_herd_problem">thundering herd</a>, and has
no single place to enforce a limit. None of that is observable until you generate
the load on purpose. So “it works on my machine” is not a weak claim here, it is a
meaningless one. The machine was never asked the question the feature answers.</p>

<h2 id="what-you-cannot-see-without-measuring">What you cannot see without measuring</h2>

<p>Three things stay hidden right up until you put real load through the proxy, and
all three are the things that actually matter in production.</p>

<p><strong>Tail latency.</strong> Averages lie under load. The number that wakes people up is
p95 and p99, the slow tail that a handful of requests fall into when everyone is
competing for sockets, DNS, TLS handshakes, and upstream rate limits at once.
Google’s <a href="https://research.google/pubs/pub40801/">Tail at Scale</a> is the canonical
argument that the tail, not the mean, defines the user experience. You cannot
average your way to a p99. You have to measure it, and you only get a meaningful
one under concurrency.</p>

<p><strong>Backpressure and the value of the cap.</strong> The microbatching layer puts a
<a href="https://docs.python.org/3/library/asyncio-sync.html#asyncio.Semaphore">concurrency semaphore</a>
in front of the upstream so it never sees more than <code class="language-plaintext highlighter-rouge">BATCH_MAX_CONCURRENCY</code> calls
in flight. <a href="https://en.wikipedia.org/wiki/Little%27s_law">Little’s Law</a> says that
in a stable system the in-flight work <code class="language-plaintext highlighter-rouge">L = λ × W</code>: arrival rate times latency. If
upstream latency <code class="language-plaintext highlighter-rouge">W</code> spikes, a fixed cap keeps <code class="language-plaintext highlighter-rouge">L</code> bounded instead of letting
in-flight work explode. That is a lovely argument on paper. It is also untestable
on paper. The only way to show the cap turns an unbounded meltdown into a bounded
queue is to drive <code class="language-plaintext highlighter-rouge">λ</code> up and watch what happens.</p>

<p><strong>That the trade is real.</strong> The microbatching post called this a
latency-for-stability trade: pay a few milliseconds of waiting, buy upstream
protection and predictable tails. A benchmark is what turns that sentence from a
claim into a measurement. Without one, it is just a story I am telling about my
own code.</p>

<h2 id="what-benchmarking-actually-buys-you">What benchmarking actually buys you</h2>

<p>This is the part I care about most, because “run a load test” sounds like a chore
and is actually the highest-leverage thing you can do to a system like this.</p>

<p><strong>It tunes the knobs with evidence instead of vibes.</strong> Microbatching exposes real
dials: <code class="language-plaintext highlighter-rouge">BATCH_MAX_WAIT_MS</code> (how long to coalesce), <code class="language-plaintext highlighter-rouge">BATCH_MAX_SIZE</code> (window size),
<code class="language-plaintext highlighter-rouge">BATCH_MAX_CONCURRENCY</code> (in-flight cap). Every one of them is a tradeoff. Bigger
wait means more coalescing and more added latency. Bigger concurrency means more
upstream parallelism and less protection. There is a knee in each of those curves,
and you cannot guess where it is. A concurrency sweep finds it. The defaults I
shipped (20 ms, size 16, concurrency 8) are starting points; a benchmark is how
they stop being starting points.</p>

<p><strong>It makes the design defensible.</strong> “I added admission control” is a sentence
anyone can say. “Here is throughput and p99 with batching off, here it is with
batching on at concurrency 8, and here is where it stops helping” is a different
kind of claim. It is the difference between an opinion and a result, and in an
interview it is the part worth defending.</p>

<p><strong>It catches regressions.</strong> Once a benchmark produces a number, that number is a
contract you can diff across commits. Refactor the dispatch path, rerun, compare.
A silent 30% throughput regression from an accidental <code class="language-plaintext highlighter-rouge">await</code> in the wrong place
is invisible to unit tests (the <a href="https://github.com/NeverTheSame/llm-batcher">batcher tests</a>
are deterministic and run with no network, on purpose) but obvious to a benchmark.</p>

<p><strong>It keeps you honest about limits.</strong> Measuring under load is also how you find
the things the feature does <em>not</em> fix. The windows are per process, so each worker
has its own, and there is no global queue across workers yet. There is no
queue-depth cap, so a sustained flood can still grow the pending list (returning
429/503 past a <code class="language-plaintext highlighter-rouge">max_pending</code> is the natural next increment). I know those are the
weak spots precisely because that is where a benchmark would push the system over.
Numbers tell you where to build next.</p>

<h2 id="what-a-benchmark-for-this-measures">What a benchmark for this measures</h2>

<p>Concretely, the harness I want puts three axes on the table at once, because in
LLM serving they trade against each other and a single number hides the deal:</p>

<ul>
  <li><strong>Throughput:</strong> requests per second the proxy sustains as concurrency climbs.</li>
  <li><strong>Latency:</strong> the full distribution, p50/p95/p99, not the average.</li>
  <li><strong>Cost:</strong> dollars per request, since the point of a proxy in front of a paid
API is partly economic.</li>
</ul>

<p>The experiment is a concurrency sweep comparing <code class="language-plaintext highlighter-rouge">BATCH_ENABLED=0</code> against
<code class="language-plaintext highlighter-rouge">BATCH_ENABLED=1</code>: hold the workload fixed, raise the number of concurrent
clients, and plot all three curves for each mode. The batched line should hold its
tail flatter as concurrency rises, at the cost of a small constant latency add at
the low end. That crossover is the entire feature, drawn.</p>

<p>This is the pre-production twin of the work I did in the
<a href="/BeOps/ai/2026-06-04-llm-proxy-observability.html">observability post</a>:
that one added a live <code class="language-plaintext highlighter-rouge">/metrics</code> surface so the running proxy reports its own
p50/p95/p99 and a dollar estimate. Same
<a href="https://grafana.com/blog/2018/08/02/the-red-method-how-to-instrument-your-services/">RED</a>
instinct, two settings. Benchmarking is how you learn the shape of the system
before it ships; the metrics endpoint is how you keep watching it after. You want
both, and they reinforce each other: the benchmark tells you what “normal” looks
like, so the production metrics can tell you when you have left it.</p>

<h2 id="what-this-demonstrates">What this demonstrates</h2>

<p>The reflex to measure before believing. A demo answers “does it run.” A benchmark
answers “does it do the thing I built it for, and where does it stop.” Tail-latency
thinking, admission control you can prove with Little’s Law instead of assert,
evidence-based tuning, regression gates, and the honesty to let the numbers mark
your limits. The feature was the easy part. Knowing it works is the job.</p>

<p><strong>Visualization:</strong> <a href="https://neverthesame.github.io/llm-batcher/batching.html">batching.html</a> ·
<strong>Repo:</strong> <a href="https://github.com/NeverTheSame/llm-batcher">github.com/NeverTheSame/llm-batcher</a>.
Next I wire the harness itself, so the curves in this post stop being a plan and
start being a chart.</p>]]></content><author><name>Kirill Kuklin</name></author><category term="ai" /><category term="llm-ops" /><category term="inference" /><category term="benchmarking" /><category term="tail-latency" /><category term="sre" /><summary type="html"><![CDATA[A single-request demo of my proxy looks identical with batching on or off. The whole point of the feature is invisible until you put it under concurrent load and measure. So this post is the case for benchmarking: why a demo proves nothing, what numbers you only get under load, and what those numbers actually buy you (tuned knobs, a defensible trade, and regressions you can catch).]]></summary></entry><entry><title type="html">I Taught My LLM Proxy to Watch Itself</title><link href="https://neverthesame.github.io/BeOps/ai/2026-06-04-llm-proxy-observability.html" rel="alternate" type="text/html" title="I Taught My LLM Proxy to Watch Itself" /><published>2026-06-04T00:00:00+00:00</published><updated>2026-06-04T00:00:00+00:00</updated><id>https://neverthesame.github.io/BeOps/ai/llm-proxy-observability</id><content type="html" xml:base="https://neverthesame.github.io/BeOps/ai/2026-06-04-llm-proxy-observability.html"><![CDATA[<p>Two posts ago I put <a href="/BeOps/ai/2026-06-01-openai-compatible-proxy-for-claude.html">an OpenAI-shaped proxy in front of Claude</a>,
and one post ago I <a href="/BeOps/ai/2026-06-03-microbatching-llm-proxy.html">taught it to wait on purpose</a>
so concurrent requests get grouped into admission windows instead of stampeding
the upstream. That microbatching post ended with a promise: the next brick is a
cost and latency observatory, so the tail latency I keep talking about stops
being a claim and starts being a graph. This post builds that brick.</p>

<p>The honest problem up front: <strong>a proxy that translates and batches is still a
black box until it can answer two questions about itself, how fast is it and what
is it costing me.</strong> Both of the things it already does are claims about behavior
under load, and until now there was no way to check either one.</p>

<p>I also built a small interactive dashboard of the metrics surface, which makes
the shape click faster than the JSON does:
<strong><a href="https://neverthesame.github.io/llm-batcher/observability.html">observability.html on GitHub Pages</a></strong>
(open it in a browser to see the percentiles and the cost breakdown laid out).</p>

<h2 id="you-cannot-tune-what-you-cannot-see">You cannot tune what you cannot see</h2>

<p>The proxy translates between the OpenAI and Anthropic shapes, and it can group
concurrent requests into a short window before fanning them out. Did windowing
actually smooth the upstream call rate? Did it add latency, and if so how much,
and to which requests? How much am I spending per thousand requests, and on which
models? Right now those are all shrugs.</p>

<p>Without measurement, the next two roadmap items, backpressure tuning and a
benchmark harness, are guesswork. Observability is the dependency that unblocks
them. So this increment is deliberately the cheapest thing that produces the data
those steps need, and nothing more.</p>

<h2 id="the-metrics-stack-i-rejected-and-why">The metrics stack I rejected, and why</h2>

<p>The obvious move is to reach for a real metrics stack: a Prometheus client
library, a <code class="language-plaintext highlighter-rouge">/metrics</code> endpoint in the text exposition format, histograms with
predefined buckets, a scrape config. That is the correct answer for a production
fleet, and the wrong answer here, for three reasons.</p>

<ol>
  <li>It adds a dependency and a serving format whose value only appears once there
is a Prometheus server, a Grafana instance, and a multi-host deployment to
aggregate. None of that exists in a single-process learning proxy.</li>
  <li>Prometheus histograms commit you to fixed buckets up front. For a quick look
at “what is my p95 right now,” a bounded ring buffer of raw samples is more
honest and easier to reason about than bucket boundaries chosen blind.</li>
  <li>The interesting engineering here is not “wire up a library.” It is the set of
small correctness decisions a metrics layer quietly gets wrong: percentile
method, unknown-model cost handling, where latency is measured relative to
batching, and how errors are accounted. A hand-rolled registry forces each of
those into the open.</li>
</ol>

<p>So the design is an in-process registry with a JSON snapshot. It is explicitly
not durable, not multi-worker, and not a substitute for a real metrics pipeline.
It is the measurement you need to make the next tuning decision, and it states
its own limits. (This is the same instinct I described in a <a href="/BeOps/job-interviews/2026-05-25-staff-ai-engineer-interview.html">Staff AI Engineer
interview</a>:
less model magic, more boring serving engineering.)</p>

<h2 id="what-real-systems-do-in-miniature">What real systems do, in miniature</h2>

<p>The shape of this feature is borrowed from patterns that show up everywhere once
you start measuring latency-sensitive services.</p>

<ul>
  <li><strong>Tail latency over averages.</strong> The canonical argument is Dean and Barroso, “The
Tail at Scale.” The mean latency of a service is nearly useless because a small
fraction of slow requests dominates the experience, especially when one user
action fans out into many backend calls. Report percentiles, and care most
about p95, p99, and the max, not the average.</li>
  <li><strong>Quantile estimation on a stream.</strong> Systems that cannot store every sample use
streaming quantile sketches (t-digest, HdrHistogram, DDSketch, Prometheus
summaries). They trade a little accuracy for bounded memory. A bounded ring
buffer is the simplest member of that family: it keeps exact samples for a
recent window and is exact for that window by definition.</li>
  <li><strong>Cost as a first-class metric.</strong> FinOps practice treats spend like latency,
something you attribute per request, per model, per tenant, then watch over
time. Token-based pricing makes this tractable because the upstream hands you
the exact billable quantity, input and output tokens, in every response.</li>
  <li><strong>RED and USE.</strong> Tom Wilkie’s RED method (rate, errors, duration) and Brendan
Gregg’s USE method (utilization, saturation, errors) are the standard checklists
for a request-serving system. This snapshot is a small RED surface: request rate
(counts), errors, and duration (percentiles), plus the cost dimension that LLM
serving adds on top.</li>
</ul>

<h2 id="three-ideas-that-hold-the-whole-thing-up">Three ideas that hold the whole thing up</h2>

<p><strong>Percentiles by the nearest-rank method.</strong> A percentile answers “what latency is
this request faster than p percent of the recent window.” There are several
defensible definitions; this uses nearest-rank on the sorted window. For a window
of <code class="language-plaintext highlighter-rouge">n</code> ascending samples, the rank for percentile <code class="language-plaintext highlighter-rouge">p</code> is
<code class="language-plaintext highlighter-rouge">round(p/100 * (n - 1))</code>, clamped into <code class="language-plaintext highlighter-rouge">[0, n - 1]</code>. That makes p100 the max, and
makes every percentile of a single sample equal to that sample, with no
interpolation and no off-by-one at the edges. The cost is one sort per snapshot
call, which is fine because the window is bounded.</p>

<p><strong>A bounded window, not all-time.</strong> All-time percentiles drift. A service that was
slow at startup and fast since then reports a misleadingly high p99 forever. A
bounded window, the last <code class="language-plaintext highlighter-rouge">METRICS_LATENCY_WINDOW</code> samples, reports recent behavior,
which is what you act on. The tradeoff is that a quiet service shows stale samples
until new traffic rolls them out. For a tuning tool, that is the right tradeoff.</p>

<p><strong>Cost is an estimate, and it says so.</strong> The upstream reports exact token counts,
so the only source of error in the dollar figure is the pricing table, which is
static and maintained by hand. The dangerous failure mode is a model that is not
in the table silently contributing zero dollars, which makes spend look lower than
it is. The registry refuses that: unknown-model usage goes into a separate
<code class="language-plaintext highlighter-rouge">unpriced</code> bucket, never folded into the priced totals, and an <code class="language-plaintext highlighter-rouge">estimate_complete</code>
flag flips to false whenever any unpriced traffic exists. A wrong-but-confident
number is worse than a right-but-incomplete one.</p>

<h2 id="walking-through-the-code">Walking through the code</h2>

<p>The feature is one module, <code class="language-plaintext highlighter-rouge">app/metrics.py</code>, plus a few lines of wiring in
<code class="language-plaintext highlighter-rouge">app/main.py</code>.</p>

<h3 id="pricing-lookup-with-family-matching">Pricing lookup with family matching</h3>

<p>Model names arrive with version or date suffixes (<code class="language-plaintext highlighter-rouge">claude-3-5-haiku-latest</code>,
<code class="language-plaintext highlighter-rouge">claude-3-5-haiku-20241022</code>). The pricing table is keyed by family, and the lookup
matches the longest table key the model name starts with:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">_price_for</span><span class="p">(</span><span class="n">model</span><span class="p">:</span> <span class="nb">str</span><span class="p">):</span>
    <span class="n">name</span> <span class="o">=</span> <span class="p">(</span><span class="n">model</span> <span class="ow">or</span> <span class="s">""</span><span class="p">).</span><span class="n">lower</span><span class="p">()</span>
    <span class="n">best</span> <span class="o">=</span> <span class="bp">None</span>
    <span class="k">for</span> <span class="n">key</span> <span class="ow">in</span> <span class="n">PRICING_USD_PER_MTOK</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">name</span><span class="p">.</span><span class="n">startswith</span><span class="p">(</span><span class="n">key</span><span class="p">)</span> <span class="ow">and</span> <span class="p">(</span><span class="n">best</span> <span class="ow">is</span> <span class="bp">None</span> <span class="ow">or</span> <span class="nb">len</span><span class="p">(</span><span class="n">key</span><span class="p">)</span> <span class="o">&gt;</span> <span class="nb">len</span><span class="p">(</span><span class="n">best</span><span class="p">)):</span>
            <span class="n">best</span> <span class="o">=</span> <span class="n">key</span>
    <span class="k">return</span> <span class="n">PRICING_USD_PER_MTOK</span><span class="p">[</span><span class="n">best</span><span class="p">]</span> <span class="k">if</span> <span class="n">best</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span> <span class="k">else</span> <span class="bp">None</span>
</code></pre></div></div>

<p>Longest-prefix matching means a more specific family wins over a shorter one if
one is ever added, and unknown models cleanly return <code class="language-plaintext highlighter-rouge">None</code> instead of a guess.</p>

<h3 id="the-percentile-helper">The percentile helper</h3>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">_percentile</span><span class="p">(</span><span class="n">sorted_values</span><span class="p">,</span> <span class="n">pct</span><span class="p">):</span>
    <span class="n">n</span> <span class="o">=</span> <span class="nb">len</span><span class="p">(</span><span class="n">sorted_values</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">n</span> <span class="o">==</span> <span class="mi">1</span><span class="p">:</span>
        <span class="k">return</span> <span class="n">sorted_values</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span>
    <span class="n">rank</span> <span class="o">=</span> <span class="nb">int</span><span class="p">(</span><span class="nb">round</span><span class="p">((</span><span class="n">pct</span> <span class="o">/</span> <span class="mf">100.0</span><span class="p">)</span> <span class="o">*</span> <span class="p">(</span><span class="n">n</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)))</span>
    <span class="n">rank</span> <span class="o">=</span> <span class="nb">max</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="nb">min</span><span class="p">(</span><span class="n">n</span> <span class="o">-</span> <span class="mi">1</span><span class="p">,</span> <span class="n">rank</span><span class="p">))</span>
    <span class="k">return</span> <span class="n">sorted_values</span><span class="p">[</span><span class="n">rank</span><span class="p">]</span>
</code></pre></div></div>

<p>It assumes a non-empty ascending list. The snapshot handles the empty case by
reporting <code class="language-plaintext highlighter-rouge">null</code> percentiles and a <code class="language-plaintext highlighter-rouge">sample_count</code> of zero, so a freshly started
proxy returns a well-formed snapshot instead of dividing by nothing.</p>

<h3 id="the-registry">The registry</h3>

<p><code class="language-plaintext highlighter-rouge">Metrics</code> holds counters, a <code class="language-plaintext highlighter-rouge">deque(maxlen=window)</code> of recent latencies, priced
token totals, a running cost, and the parallel <code class="language-plaintext highlighter-rouge">unpriced</code> accounting. Two record
methods cover the two outcomes:</p>

<ul>
  <li><code class="language-plaintext highlighter-rouge">record_success(model, input_tokens, output_tokens, latency_ms)</code> always bumps
the total and success counts and appends the latency. It then either adds to the
priced totals and cost, or, for an unknown model, adds to the unpriced bucket.
Both paths record latency, because a slow successful call is still a data point
even if it is unpriced.</li>
  <li><code class="language-plaintext highlighter-rouge">record_error(latency_ms)</code> bumps the total and error counts and records the
latency, but touches no token or cost state, because a failed call before the
upstream responds has no usage to bill.</li>
</ul>

<p>Every mutation and the whole <code class="language-plaintext highlighter-rouge">snapshot()</code> copy happen under a <code class="language-plaintext highlighter-rouge">threading.Lock</code>. On
a single event loop the synchronous record methods cannot interleave, but the lock
costs almost nothing and keeps the registry safe under the threaded test client and
any future move to worker threads. The window size is clamped on construction
(<code class="language-plaintext highlighter-rouge">max(1, min(window, 100000))</code>) so a fat-fingered env value can never make the
per-snapshot sort expensive.</p>

<h3 id="wiring-it-in">Wiring it in</h3>

<p><code class="language-plaintext highlighter-rouge">main.py</code> gains a <code class="language-plaintext highlighter-rouge">METRICS_ENABLED</code> flag (default off) and builds the registry in
the existing FastAPI <code class="language-plaintext highlighter-rouge">lifespan</code> only when enabled. The chat endpoint is refactored
so both the batcher path and the direct path run inside one <code class="language-plaintext highlighter-rouge">try/except</code> around a
<code class="language-plaintext highlighter-rouge">perf_counter</code> span:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">start</span> <span class="o">=</span> <span class="n">time</span><span class="p">.</span><span class="n">perf_counter</span><span class="p">()</span>
<span class="k">try</span><span class="p">:</span>
    <span class="k">if</span> <span class="n">BATCH_ENABLED</span> <span class="ow">and</span> <span class="n">_batcher</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">anthropic_json</span> <span class="o">=</span> <span class="k">await</span> <span class="n">_batcher</span><span class="p">.</span><span class="n">submit</span><span class="p">(</span><span class="n">payload</span><span class="p">)</span>
    <span class="k">else</span><span class="p">:</span>
        <span class="n">anthropic_json</span> <span class="o">=</span> <span class="k">await</span> <span class="n">_direct_dispatch</span><span class="p">(</span><span class="n">payload</span><span class="p">)</span>
<span class="k">except</span> <span class="nb">Exception</span><span class="p">:</span>
    <span class="k">if</span> <span class="n">_metrics</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
        <span class="n">_metrics</span><span class="p">.</span><span class="n">record_error</span><span class="p">((</span><span class="n">time</span><span class="p">.</span><span class="n">perf_counter</span><span class="p">()</span> <span class="o">-</span> <span class="n">start</span><span class="p">)</span> <span class="o">*</span> <span class="mf">1000.0</span><span class="p">)</span>
    <span class="k">raise</span>
<span class="n">latency_ms</span> <span class="o">=</span> <span class="p">(</span><span class="n">time</span><span class="p">.</span><span class="n">perf_counter</span><span class="p">()</span> <span class="o">-</span> <span class="n">start</span><span class="p">)</span> <span class="o">*</span> <span class="mf">1000.0</span>
<span class="k">if</span> <span class="n">_metrics</span> <span class="ow">is</span> <span class="ow">not</span> <span class="bp">None</span><span class="p">:</span>
    <span class="n">usage</span> <span class="o">=</span> <span class="n">anthropic_json</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"usage"</span><span class="p">,</span> <span class="p">{})</span>
    <span class="n">_metrics</span><span class="p">.</span><span class="n">record_success</span><span class="p">(</span><span class="n">model</span><span class="p">,</span> <span class="n">usage</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"input_tokens"</span><span class="p">,</span> <span class="mi">0</span><span class="p">),</span>
                            <span class="n">usage</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"output_tokens"</span><span class="p">,</span> <span class="mi">0</span><span class="p">),</span> <span class="n">latency_ms</span><span class="p">)</span>
</code></pre></div></div>

<p>This measures end-to-end request latency, the number the caller actually
experiences. In batch mode that span includes the admission-window wait, which is
the honest thing to report, because that wait is real latency the client paid. It
is deliberately not labeled “upstream call latency,” since under batching one
upstream call can serve several logical requests, and conflating the two would
overstate per-request upstream timing.</p>

<h3 id="the-endpoint">The endpoint</h3>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">@</span><span class="n">app</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"/metrics"</span><span class="p">)</span>
<span class="k">async</span> <span class="k">def</span> <span class="nf">metrics</span><span class="p">():</span>
    <span class="k">if</span> <span class="ow">not</span> <span class="n">METRICS_ENABLED</span> <span class="ow">or</span> <span class="n">_metrics</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
        <span class="k">raise</span> <span class="n">HTTPException</span><span class="p">(</span><span class="n">status_code</span><span class="o">=</span><span class="mi">404</span><span class="p">,</span> <span class="n">detail</span><span class="o">=</span><span class="s">"Metrics are not enabled."</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">_metrics</span><span class="p">.</span><span class="n">snapshot</span><span class="p">()</span>
</code></pre></div></div>

<p>When the feature is off the route returns 404 rather than an empty object. That
keeps the default behavior identical to before the feature existed, and avoids
exposing a traffic-and-spend surface on a public proxy unless the operator opts in.</p>

<h2 id="configuration">Configuration</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">METRICS_ENABLED</span><span class="o">=</span>0           <span class="c"># off by default; 1 enables collection and the endpoint</span>
<span class="nv">METRICS_LATENCY_WINDOW</span><span class="o">=</span>1024 <span class="c"># recent samples kept for percentiles (clamped to 100000)</span>
</code></pre></div></div>

<h2 id="testing-without-a-network">Testing without a network</h2>

<p><code class="language-plaintext highlighter-rouge">tests/test_metrics.py</code> is fully deterministic and never touches the network. It
drives the <code class="language-plaintext highlighter-rouge">Metrics</code> class directly to assert the math, and exercises the endpoint
through the app:</p>

<ul>
  <li>Percentile edges: a single sample, and the nearest-rank values for a known
1..100 window (p50 is 51, p95 is 95, p99 is 99, p100 is 100).</li>
  <li>Pricing: family and dated-alias resolution, and <code class="language-plaintext highlighter-rouge">None</code> for unknown models.</li>
  <li>Cost math: a known model where one million input and one million output tokens
produce an exact dollar figure, with <code class="language-plaintext highlighter-rouge">estimate_complete</code> true.</li>
  <li>Unknown model: zero priced cost, <code class="language-plaintext highlighter-rouge">estimate_complete</code> false, and the usage landing
in the <code class="language-plaintext highlighter-rouge">unpriced</code> bucket instead of the priced totals.</li>
  <li>Error accounting: errors bump the error count and record latency but no tokens.</li>
  <li>Empty snapshot: null percentiles, zero counts, well-formed shape.</li>
  <li>Window clamping: zero clamps up to one, a billion clamps down to 100000.</li>
  <li>Endpoint: 404 by default, and after reloading the module with the flag set, a
mocked round trip produces <code class="language-plaintext highlighter-rouge">total: 1</code>, <code class="language-plaintext highlighter-rouge">success: 1</code>, the right token total, and a
positive estimated cost.</li>
</ul>

<p>The default suite runs with the feature off, proving the existing realtime and
translation tests are untouched. The endpoint test reloads the module with
<code class="language-plaintext highlighter-rouge">METRICS_ENABLED=1</code> to prove the enabled path, so both modes are covered.</p>

<h2 id="limitations-stated-on-purpose">Limitations, stated on purpose</h2>

<ul>
  <li><strong>In process, single worker.</strong> Behind multiple workers each process has its own
counters; a real deployment would aggregate them in a scrape target.</li>
  <li><strong>Recent window, not history.</strong> Percentiles describe the bounded window, not
all-time behavior.</li>
  <li><strong>Cost is an estimate from a static table.</strong> Prices change and the table is
updated by hand. Unknown models are surfaced, never silently zeroed.</li>
  <li><strong>No durability.</strong> A restart resets every counter, which is correct for a tuning
tool and wrong for billing. Billing-grade accounting belongs in the provider’s
own usage records.</li>
</ul>

<h2 id="what-it-demonstrates">What it demonstrates</h2>

<p>The same distributed-systems instincts from the last two posts, now pointed at
self-measurement: tail-latency thinking, a streaming-quantile primitive picked for
the actual scale, FinOps-style cost attribution, a RED surface, and the judgment to
reject the heavyweight metrics stack until it earns its place. Plus the small
refusals that keep numbers honest: the unpriced bucket, the 404 when disabled, the
clamp on the window.</p>

<p><strong>Visualization:</strong> <a href="https://neverthesame.github.io/llm-batcher/observability.html">observability.html</a> ·
<strong>Repo:</strong> <a href="https://github.com/NeverTheSame/llm-batcher">github.com/NeverTheSame/llm-batcher</a>.
With the observatory in place, the remaining roadmap items become measurable
instead of asserted. Backpressure and concurrency tuning can be judged by their
effect on p95 and the error rate, and the benchmark harness can read real
throughput, latency, and cost out of <code class="language-plaintext highlighter-rouge">/metrics</code> under load. The tail latency stops
being a claim and starts being a graph.</p>]]></content><author><name>Kirill Kuklin</name></author><category term="ai" /><category term="llm-ops" /><category term="inference" /><category term="observability" /><category term="finops" /><category term="sre" /><summary type="html"><![CDATA[A proxy that translates and batches requests is still a black box until it can answer two questions about itself: how fast is it, and what is it costing me? So I added a cost and latency observatory: per-request p50/p95/p99, token accounting, and an estimated dollar figure, exposed as an opt-in JSON snapshot at GET /metrics. Here is the metrics stack I deliberately did NOT use, the nearest-rank percentile math, and the unpriced bucket that refuses to lie about spend.]]></summary></entry><entry><title type="html">I Taught My LLM Proxy to Wait on Purpose</title><link href="https://neverthesame.github.io/BeOps/ai/2026-06-03-microbatching-llm-proxy.html" rel="alternate" type="text/html" title="I Taught My LLM Proxy to Wait on Purpose" /><published>2026-06-03T00:00:00+00:00</published><updated>2026-06-03T00:00:00+00:00</updated><id>https://neverthesame.github.io/BeOps/ai/microbatching-llm-proxy</id><content type="html" xml:base="https://neverthesame.github.io/BeOps/ai/2026-06-03-microbatching-llm-proxy.html"><![CDATA[<p>A few days ago I built <a href="/BeOps/ai/2026-06-01-openai-compatible-proxy-for-claude.html"><code class="language-plaintext highlighter-rouge">llm-batcher</code>, an OpenAI-shaped proxy in front of Claude</a>.
It was a clean pass-through, and at the very end I left an IOU: the name promises
batching, and the proxy didn’t do any yet. This post pays that IOU.</p>

<p>The counterintuitive part up front: <strong>under load, the fastest thing a proxy can
do is sometimes wait.</strong> A few milliseconds of deliberate waiting buys you
upstream protection, smoother throughput, and predictable tail latency. That
trade is the whole feature.</p>

<p>I also built a small interactive visualization of the window filling and
flushing, which makes the idea click faster than any paragraph:
<strong><a href="https://neverthesame.github.io/llm-batcher/batching.html">batching.html on GitHub</a></strong>
(open the raw file in a browser to watch the waves).</p>

<h2 id="before-and-after-200-clients-in-the-same-50-ms">Before and after: 200 clients in the same 50 ms</h2>

<p>The plain proxy does the obvious thing: every <code class="language-plaintext highlighter-rouge">POST /v1/chat/completions</code> opens
its own connection to the Anthropic Messages API and waits. Perfect for one
request at a time. Ugly the moment real concurrency shows up.</p>

<p><strong>Before (no batching).</strong> 200 clients call you inside the same 50 ms:</p>

<ul>
  <li>You open roughly 200 simultaneous upstream connections.</li>
  <li>The upstream sees an uncoordinated spike, the classic
<a href="https://en.wikipedia.org/wiki/Thundering_herd_problem">thundering herd</a>.</li>
  <li>There is no single place to enforce a concurrency limit, a queue-depth limit,
or any fairness policy.</li>
  <li>Your p95 and p99 go unpredictable, because every request fights every other
request for sockets, DNS, TLS handshakes, and the upstream rate limit at once.</li>
</ul>

<p><strong>After (microbatching on).</strong> The same 200 requests land in an admission window,
get grouped, and go out as a coordinated wave behind a concurrency cap. The
upstream never sees more than N calls in flight. The spike becomes a shaped
flow. You pay a few milliseconds of latency and you get a system that degrades
gracefully instead of melting down.</p>

<table>
  <thead>
    <tr>
      <th>Under a 200-request burst</th>
      <th>Plain proxy (before)</th>
      <th>Microbatching (after)</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Upstream connections</td>
      <td>~200 at once</td>
      <td>capped at <code class="language-plaintext highlighter-rouge">BATCH_MAX_CONCURRENCY</code></td>
    </tr>
    <tr>
      <td>Spike shape</td>
      <td>thundering herd</td>
      <td>controlled waves</td>
    </tr>
    <tr>
      <td>Place to enforce limits</td>
      <td>none</td>
      <td>one dispatch choke point</td>
    </tr>
    <tr>
      <td>Tail latency (p99)</td>
      <td>unpredictable</td>
      <td>bounded and tunable</td>
    </tr>
    <tr>
      <td>Cost</td>
      <td>one uncoordinated call per request</td>
      <td>a few ms of intentional wait</td>
    </tr>
  </tbody>
</table>

<p>This is the exact gap between “works in a demo” and “survives production
traffic,” and closing it is the kind of work an inference-serving team does every
day. That is why it was the right second feature.</p>

<h2 id="what-it-actually-does">What it actually does</h2>

<p>When <code class="language-plaintext highlighter-rouge">BATCH_ENABLED=1</code>, requests are not dispatched immediately. They drop into
an <strong>admission window</strong> that flushes when <strong>either</strong> trigger fires first:</p>

<ol>
  <li><strong>Size trigger:</strong> the window reaches <code class="language-plaintext highlighter-rouge">BATCH_MAX_SIZE</code> requests, or</li>
  <li><strong>Time trigger:</strong> <code class="language-plaintext highlighter-rouge">BATCH_MAX_WAIT_MS</code> milliseconds pass since the first
request entered the window.</li>
</ol>

<p>The window then dispatches together. Each request is still its own realtime
Anthropic call, but the calls leave as a coordinated group on a <strong>shared HTTP
client</strong>, gated by a <strong>semaphore</strong> so the upstream never sees more than
<code class="language-plaintext highlighter-rouge">BATCH_MAX_CONCURRENCY</code> in flight.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>                 admission window (size OR time)
clients ─▶ [ r1 r2 r3 ... rN ] ─▶ dispatch ─▶ semaphore(N) ─▶ Anthropic
                                                   │
                              at most BATCH_MAX_CONCURRENCY in flight
</code></pre></div></div>

<p>It is a latency-for-stability trade, and it is opt-in. Off by default, the proxy
behaves exactly as before.</p>

<h2 id="the-decision-i-rejected-this-is-the-actual-engineering">The decision I rejected (this is the actual engineering)</h2>

<p>The roadmap line said “accumulate concurrent requests into one upstream call
window.” Read literally, Anthropic ships something that looks like a perfect
match: the <strong>Message Batches API</strong>, which takes many requests in one call and is
50% cheaper. I did not use it here, on purpose.</p>

<p>The Message Batches API is an <strong>offline, asynchronous bulk primitive</strong>. You
submit a batch and poll for completion, with an SLA of up to <strong>24 hours</strong> (often
minutes, but the contract is “eventually”). Putting that behind a
<strong>synchronous, OpenAI-compatible chat endpoint</strong> would mean a client’s
<code class="language-plaintext highlighter-rouge">POST /v1/chat/completions</code> could block for minutes. That is surprising,
timeout-prone, and wrong for a realtime API.</p>

<p>So the design splits into two tools for two jobs:</p>

<table>
  <thead>
    <tr>
      <th>Need</th>
      <th>Right tool</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Low-latency realtime chat under concurrency</td>
      <td>In-process microbatching (this feature)</td>
    </tr>
    <tr>
      <td>Cheap, huge, non-urgent bulk and eval jobs</td>
      <td>Anthropic Message Batches API (a separate future endpoint)</td>
    </tr>
  </tbody>
</table>

<p>Picking the right primitive instead of the one that happens to share your
feature’s name is the judgment call worth defending in an interview. The cheaper
API is not the correct API here.</p>

<h2 id="its-the-same-idea-as-a-dozen-systems-you-already-trust">It’s the same idea as a dozen systems you already trust</h2>

<p>Microbatching is not exotic. It is one pattern wearing many hats:</p>

<ul>
  <li><strong>Continuous batching in LLM serving.</strong> <a href="https://docs.vllm.ai/en/latest/">vLLM</a>
and TGI batch tokens across concurrent requests to keep the GPU busy. Same
instinct (group concurrent work), different layer (token scheduling vs request
admission).</li>
  <li><strong><a href="https://en.wikipedia.org/wiki/Nagle%27s_algorithm">Nagle’s algorithm</a> in
TCP.</strong> Briefly buffer small sends to coalesce them into fewer, fuller packets.
A time-or-size window, exactly like ours.</li>
  <li><strong>Group commit</strong> in databases and logging. Batch many writes into one fsync.</li>
  <li><strong><a href="https://github.com/graphql/dataloader">DataLoader</a> batching.</strong> Coalesce many
field resolutions in a tick into one backend call.</li>
</ul>

<p>If you have ever tuned any of those, you already understand this layer. That is
the point: it is a small, honest instance of a technique that shows up
everywhere in high-throughput systems.</p>

<h2 id="why-a-concurrency-cap-is-the-right-lever-littles-law">Why a concurrency cap is the right lever (Little’s Law)</h2>

<p>Two ideas justify the knobs.
<a href="https://en.wikipedia.org/wiki/Admission_control">Admission control</a> is the
window plus the semaphore deciding how much work is allowed downstream at once.
Backpressure is having a single dispatch point where you can push back when
overwhelmed (today via the cap and timeout; a queue-depth limit returning
429/503 is the next increment).</p>

<p><a href="https://en.wikipedia.org/wiki/Little%27s_law">Little’s Law</a> is why the cap is
the correct dial. In a stable system, <code class="language-plaintext highlighter-rouge">L = λ × W</code>: average in-flight requests
(L) equals arrival rate (λ) times average latency (W). When upstream latency W
rises, holding L fixed with a semaphore stops in-flight work from exploding. You
trade a little queueing delay for a bounded, predictable system instead of an
unbounded meltdown. SRE instinct, pointed at an LLM upstream.</p>

<h2 id="the-async-footguns-i-had-to-dodge">The async footguns I had to dodge</h2>

<p>The interesting bugs in this kind of code are all in the <code class="language-plaintext highlighter-rouge">asyncio</code> lifecycle. The
accumulator holds pending <code class="language-plaintext highlighter-rouge">(params, Future)</code> items under an <code class="language-plaintext highlighter-rouge">asyncio.Lock</code>, and
the snapshot-and-clear is atomic, but the upstream work happens <strong>outside</strong> the
lock so a slow dispatch never blocks new arrivals:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">with</span> <span class="bp">self</span><span class="p">.</span><span class="n">_lock</span><span class="p">:</span>
    <span class="bp">self</span><span class="p">.</span><span class="n">_pending</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">item</span><span class="p">)</span>
    <span class="k">if</span> <span class="nb">len</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">_pending</span><span class="p">)</span> <span class="o">&gt;=</span> <span class="bp">self</span><span class="p">.</span><span class="n">_max_batch_size</span><span class="p">:</span>
        <span class="n">batch</span> <span class="o">=</span> <span class="bp">self</span><span class="p">.</span><span class="n">_take_pending_locked</span><span class="p">()</span>                 <span class="c1"># size trigger
</span>    <span class="k">elif</span> <span class="bp">self</span><span class="p">.</span><span class="n">_timer</span> <span class="ow">is</span> <span class="bp">None</span><span class="p">:</span>
        <span class="bp">self</span><span class="p">.</span><span class="n">_timer</span> <span class="o">=</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">create_task</span><span class="p">(</span><span class="bp">self</span><span class="p">.</span><span class="n">_timer_flush</span><span class="p">())</span>  <span class="c1"># arm time trigger
</span></code></pre></div></div>

<p>Dispatch is where three guarantees come from a handful of lines: a concurrency
cap, failure isolation, and no hangs.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">async</span> <span class="k">def</span> <span class="nf">run_one</span><span class="p">(</span><span class="n">item</span><span class="p">):</span>
    <span class="k">async</span> <span class="k">with</span> <span class="bp">self</span><span class="p">.</span><span class="n">_sem</span><span class="p">:</span>                      <span class="c1"># concurrency cap
</span>        <span class="k">return</span> <span class="k">await</span> <span class="bp">self</span><span class="p">.</span><span class="n">_dispatch_one</span><span class="p">(</span><span class="n">item</span><span class="p">.</span><span class="n">params</span><span class="p">)</span>

<span class="n">results</span> <span class="o">=</span> <span class="k">await</span> <span class="n">asyncio</span><span class="p">.</span><span class="n">wait_for</span><span class="p">(</span>              <span class="c1"># per-window timeout
</span>    <span class="n">asyncio</span><span class="p">.</span><span class="n">gather</span><span class="p">(</span><span class="o">*</span><span class="p">(</span><span class="n">run_one</span><span class="p">(</span><span class="n">it</span><span class="p">)</span> <span class="k">for</span> <span class="n">it</span> <span class="ow">in</span> <span class="n">batch</span><span class="p">),</span> <span class="n">return_exceptions</span><span class="o">=</span><span class="bp">True</span><span class="p">),</span>
    <span class="n">timeout</span><span class="o">=</span><span class="bp">self</span><span class="p">.</span><span class="n">_batch_timeout_s</span><span class="p">,</span>
<span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">return_exceptions=True</code> means one request blowing up does not fail its
neighbors; each <code class="language-plaintext highlighter-rouge">Future</code> gets its own result or its own error.
<code class="language-plaintext highlighter-rouge">asyncio.wait_for</code> puts a hard ceiling on a window, so a stuck batch fails every
still-pending caller instead of hanging forever. Three more traps I had to
handle explicitly:</p>

<ul>
  <li><strong>Timer self-cancellation.</strong> The size path cancels the timer, but the timer
must never cancel itself. The timer clears its own reference under the lock
instead of calling the shared cancel helper, avoiding a race that would drop a
window on the floor.</li>
  <li><strong>Task garbage collection.</strong> Background dispatch tasks are kept in a set until
done, because the event loop will happily GC a task you don’t hold a reference
to, mid-flight. A real footgun.</li>
  <li><strong>Caller cancellation.</strong> If a client disconnects before its window flushes,
<code class="language-plaintext highlighter-rouge">submit</code> removes its pending item so the proxy never spends an upstream call on
a result nobody is waiting for.</li>
</ul>

<p>Graceful shutdown (<code class="language-plaintext highlighter-rouge">aclose()</code>) cancels the timer and fails any leftover futures,
so a shutdown never leaves a request hanging.</p>

<h2 id="tested-with-no-network-and-no-key">Tested with no network and no key</h2>

<p>Six deterministic tests run the batcher against an injected async fake, so
behavior is fully controllable with no API key and no network:</p>

<ol>
  <li>Flush by size groups into exactly one window.</li>
  <li>Flush by time releases a partial window after the wait.</li>
  <li>Results route back to the correct caller even when dispatch finishes out of
order.</li>
  <li>A per-item error is isolated; neighbors still succeed.</li>
  <li>A stuck dispatcher trips the batch timeout and fails every caller (itself
bounded by <code class="language-plaintext highlighter-rouge">wait_for</code> in the test, so a bug cannot hang CI).</li>
  <li>Shutdown resolves any still-queued futures instead of hanging.</li>
</ol>

<p>The original translation tests keep passing, because batching defaults to off.</p>

<h2 id="the-knobs">The knobs</h2>

<p>All opt-in, all environment variables:</p>

<table>
  <thead>
    <tr>
      <th>Variable</th>
      <th>Default</th>
      <th>Meaning</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">BATCH_ENABLED</code></td>
      <td><code class="language-plaintext highlighter-rouge">0</code></td>
      <td>Master switch. Off keeps the plain realtime path.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">BATCH_MAX_SIZE</code></td>
      <td><code class="language-plaintext highlighter-rouge">16</code></td>
      <td>Flush a window once it holds this many requests.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">BATCH_MAX_WAIT_MS</code></td>
      <td><code class="language-plaintext highlighter-rouge">20</code></td>
      <td>Max wait for a window to fill before flushing.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">BATCH_MAX_CONCURRENCY</code></td>
      <td><code class="language-plaintext highlighter-rouge">8</code></td>
      <td>Max simultaneous in-flight upstream calls.</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">BATCH_TIMEOUT_S</code></td>
      <td><code class="language-plaintext highlighter-rouge">60</code></td>
      <td>Hard ceiling on one window’s dispatch.</td>
    </tr>
  </tbody>
</table>

<p>Tuning intuition: bigger <code class="language-plaintext highlighter-rouge">BATCH_MAX_WAIT_MS</code> means more coalescing and more added
latency; bigger <code class="language-plaintext highlighter-rouge">BATCH_MAX_CONCURRENCY</code> means more upstream parallelism and less
protection. These are the same dials a real serving system exposes, and the
defaults favor low latency.</p>

<h2 id="honest-limitations">Honest limitations</h2>

<ul>
  <li><strong>Per process.</strong> Each worker has its own window. This protects the upstream but
is not a single global queue across workers. On purpose, and documented.</li>
  <li><strong>No queue-depth limit yet.</strong> A sustained flood can still grow the pending
list. A <code class="language-plaintext highlighter-rouge">max_pending</code> returning 429/503 is the natural next increment.</li>
  <li><strong>N calls, not literally one.</strong> This is operational batching (coordinated
waves), not a single multiplexed upstream call. That is the correct trade for a
realtime endpoint, and I keep the naming precise about it.</li>
</ul>

<h2 id="what-it-demonstrates">What it demonstrates</h2>

<p>Distributed-systems instincts pointed at LLM serving: admission control,
backpressure, concurrency limiting, failure isolation, timeout discipline, clean
async lifecycle management, and the judgment to pick the right primitive over the
literally-named one. The job, in miniature.</p>

<p><strong>Visualization:</strong> <a href="https://neverthesame.github.io/llm-batcher/batching.html">batching.html</a> ·
<strong>Repo:</strong> <a href="https://github.com/NeverTheSame/llm-batcher">github.com/NeverTheSame/llm-batcher</a>.
Next brick is the cost and latency observatory: per-request p50/p95/p99 and a
dollar estimate, so the tail latency I keep talking about stops being a claim and
starts being a graph.</p>]]></content><author><name>Kirill Kuklin</name></author><category term="ai" /><category term="llm-ops" /><category term="inference" /><category term="backpressure" /><category term="asyncio" /><category term="sre" /><summary type="html"><![CDATA[The plain proxy opened one upstream connection per request. That is fine for a demo and ugly under load. So I added microbatching: the proxy now waits a few milliseconds on purpose, groups concurrent requests into admission windows, and releases them in controlled waves behind a concurrency cap. Here's the before and after, the Anthropic feature I deliberately did NOT use, and the async footguns I had to dodge.]]></summary></entry><entry><title type="html">I Put an OpenAI-Shaped Proxy in Front of Claude</title><link href="https://neverthesame.github.io/BeOps/ai/2026-06-01-openai-compatible-proxy-for-claude.html" rel="alternate" type="text/html" title="I Put an OpenAI-Shaped Proxy in Front of Claude" /><published>2026-06-01T00:00:00+00:00</published><updated>2026-06-01T00:00:00+00:00</updated><id>https://neverthesame.github.io/BeOps/ai/openai-compatible-proxy-for-claude</id><content type="html" xml:base="https://neverthesame.github.io/BeOps/ai/2026-06-01-openai-compatible-proxy-for-claude.html"><![CDATA[<p>I built a small service called <strong><code class="language-plaintext highlighter-rouge">llm-batcher</code></strong>: a FastAPI proxy that speaks the
<strong>OpenAI Chat Completions API</strong> on the front and the <strong>Anthropic Messages API</strong> on
the back. Any tool that already emits OpenAI-shaped requests (an SDK, <code class="language-plaintext highlighter-rouge">curl</code>,
LangChain) can point at it and get <strong>Claude</strong> instead, with zero client changes.</p>

<ul>
  <li><strong>Repo:</strong> <a href="https://github.com/NeverTheSame/llm-batcher">github.com/NeverTheSame/llm-batcher</a></li>
  <li><strong>Stack:</strong> Python 3.12, FastAPI, httpx (async), Pydantic v2, Docker</li>
  <li><strong>Tests:</strong> 5 unit tests, mocked upstream, no API key required, all green</li>
</ul>

<p>It’s deliberately small: a clean pass-through with request/response translation and
a real test suite. But it’s the seed of the thing an inference-serving team actually
runs in production: adaptive batching, routing, backpressure, and a cost/latency
observatory.</p>

<h2 id="why-a-proxy-at-all">Why a proxy at all</h2>

<p>I’ve spent years as an SRE keeping distributed systems alive: Kubernetes, Terraform,
observability, the on-call pager. I’m deliberately pointing those skills at <strong>AI
inference</strong>, the part of the ML stack that’s <em>least</em> about training and <em>most</em> about
the thing I already do: serving traffic reliably, cheaply, and fast at scale. (I
<a href="/BeOps/job-interviews/2026-05-25-staff-ai-engineer-interview.html">said roughly this in a Staff AI Engineer interview</a>,
less model magic and more boring engineering.)</p>

<p>The insight that unlocked the project: <strong>an LLM inference proxy is just a reverse
proxy with interesting backpressure.</strong> Connection pooling, request batching, queueing
under load, latency budgets, cost telemetry. Those are exactly the problems I’ve
solved for databases and microservices. I don’t need to learn ML from scratch; I need
to point existing skills at a new kind of upstream. It’s the same “treat generative AI
like any other production workload” stance I took on
<a href="/BeOps/ai/2025-11-20-composer-llm-model.html">Composer LLM</a> and the
<a href="/BeOps/ai/2025-11-20-huggingface-model-radar.html">Hugging Face model radar</a>,
just from the serving side instead of the model-selection side.</p>

<h2 id="before-and-after-switching-a-real-app-to-claude">Before and after: switching a real app to Claude</h2>

<p>Here’s the concrete payoff, the thing I care about most: a real app, before and
after. Say you have a small tool built on the <strong>official OpenAI Python SDK</strong>. It
works, it’s in production, and now someone asks: “can we try Claude for this?”</p>

<p><strong>Before (no proxy):</strong> to answer that question you rewrite the call against the
<strong>Anthropic SDK</strong>. Watch how many lines that touch the API have to change.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># BEFORE: the app as it ships today, talking to OpenAI
</span><span class="kn">from</span> <span class="nn">openai</span> <span class="kn">import</span> <span class="n">OpenAI</span>

<span class="n">client</span> <span class="o">=</span> <span class="n">OpenAI</span><span class="p">()</span>  <span class="c1"># reads OPENAI_API_KEY
</span><span class="n">resp</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">chat</span><span class="p">.</span><span class="n">completions</span><span class="p">.</span><span class="n">create</span><span class="p">(</span>
    <span class="n">model</span><span class="o">=</span><span class="s">"gpt-4o-mini"</span><span class="p">,</span>
    <span class="n">messages</span><span class="o">=</span><span class="p">[</span>
        <span class="p">{</span><span class="s">"role"</span><span class="p">:</span> <span class="s">"system"</span><span class="p">,</span> <span class="s">"content"</span><span class="p">:</span> <span class="s">"You are terse."</span><span class="p">},</span>
        <span class="p">{</span><span class="s">"role"</span><span class="p">:</span> <span class="s">"user"</span><span class="p">,</span> <span class="s">"content"</span><span class="p">:</span> <span class="s">"Reply with exactly: pong"</span><span class="p">},</span>
    <span class="p">],</span>
    <span class="n">max_tokens</span><span class="o">=</span><span class="mi">16</span><span class="p">,</span>
<span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="n">resp</span><span class="p">.</span><span class="n">choices</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="n">message</span><span class="p">.</span><span class="n">content</span><span class="p">)</span>
</code></pre></div></div>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># BEFORE: the rewrite you need to run that same logic on Claude
</span><span class="kn">from</span> <span class="nn">anthropic</span> <span class="kn">import</span> <span class="n">Anthropic</span>

<span class="n">client</span> <span class="o">=</span> <span class="n">Anthropic</span><span class="p">()</span>  <span class="c1"># different client, reads ANTHROPIC_API_KEY
</span><span class="n">resp</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">messages</span><span class="p">.</span><span class="n">create</span><span class="p">(</span>
    <span class="n">model</span><span class="o">=</span><span class="s">"claude-3-5-haiku-latest"</span><span class="p">,</span>
    <span class="n">system</span><span class="o">=</span><span class="s">"You are terse."</span><span class="p">,</span>                  <span class="c1"># system prompt leaves the messages array
</span>    <span class="n">messages</span><span class="o">=</span><span class="p">[</span>
        <span class="p">{</span><span class="s">"role"</span><span class="p">:</span> <span class="s">"user"</span><span class="p">,</span> <span class="s">"content"</span><span class="p">:</span> <span class="s">"Reply with exactly: pong"</span><span class="p">},</span>
    <span class="p">],</span>
    <span class="n">max_tokens</span><span class="o">=</span><span class="mi">16</span><span class="p">,</span>                            <span class="c1"># no longer optional
</span><span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="n">resp</span><span class="p">.</span><span class="n">content</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="n">text</span><span class="p">)</span>                   <span class="c1"># response is a list of typed blocks
</span></code></pre></div></div>

<p>Every line that touches the API changed: the client class, the system-prompt
placement, the now-mandatory <code class="language-plaintext highlighter-rouge">max_tokens</code>, and the response parsing. Multiply
that across a codebase with retries, streaming, function-calling, logging, and
tests, and “let’s just try Claude” quietly becomes a multi-day migration.</p>

<p><strong>After (with <code class="language-plaintext highlighter-rouge">llm-batcher</code>):</strong> the app doesn’t change at all. You point the
OpenAI client at the proxy and ask for a Claude model. That’s the entire diff.</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1"># AFTER: same OpenAI SDK, same message shape, same parsing
</span><span class="kn">from</span> <span class="nn">openai</span> <span class="kn">import</span> <span class="n">OpenAI</span>

<span class="n">client</span> <span class="o">=</span> <span class="n">OpenAI</span><span class="p">(</span><span class="n">base_url</span><span class="o">=</span><span class="s">"http://localhost:8000/v1"</span><span class="p">)</span>  <span class="c1"># the only line that changed
</span><span class="n">resp</span> <span class="o">=</span> <span class="n">client</span><span class="p">.</span><span class="n">chat</span><span class="p">.</span><span class="n">completions</span><span class="p">.</span><span class="n">create</span><span class="p">(</span>
    <span class="n">model</span><span class="o">=</span><span class="s">"claude-3-5-haiku-latest"</span><span class="p">,</span>   <span class="c1"># a Claude model id
</span>    <span class="n">messages</span><span class="o">=</span><span class="p">[</span>
        <span class="p">{</span><span class="s">"role"</span><span class="p">:</span> <span class="s">"system"</span><span class="p">,</span> <span class="s">"content"</span><span class="p">:</span> <span class="s">"You are terse."</span><span class="p">},</span>
        <span class="p">{</span><span class="s">"role"</span><span class="p">:</span> <span class="s">"user"</span><span class="p">,</span> <span class="s">"content"</span><span class="p">:</span> <span class="s">"Reply with exactly: pong"</span><span class="p">},</span>
    <span class="p">],</span>
    <span class="n">max_tokens</span><span class="o">=</span><span class="mi">16</span><span class="p">,</span>
<span class="p">)</span>
<span class="k">print</span><span class="p">(</span><span class="n">resp</span><span class="p">.</span><span class="n">choices</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="n">message</span><span class="p">.</span><span class="n">content</span><span class="p">)</span>
</code></pre></div></div>

<p>Or change nothing in code at all, because the OpenAI SDK already honors an env var:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">export </span><span class="nv">OPENAI_BASE_URL</span><span class="o">=</span><span class="s2">"http://localhost:8000/v1"</span>
</code></pre></div></div>

<p>The proxy absorbs every contract difference, so the application code stays
exactly as it was:</p>

<table>
  <thead>
    <tr>
      <th>What changes to use Claude</th>
      <th>Without the proxy</th>
      <th>With <code class="language-plaintext highlighter-rouge">llm-batcher</code></th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>SDK / client class</td>
      <td>swap OpenAI for Anthropic</td>
      <td>unchanged</td>
    </tr>
    <tr>
      <td>System prompt</td>
      <td>move into top-level <code class="language-plaintext highlighter-rouge">system</code></td>
      <td>unchanged</td>
    </tr>
    <tr>
      <td><code class="language-plaintext highlighter-rouge">max_tokens</code></td>
      <td>add it (now required)</td>
      <td>unchanged</td>
    </tr>
    <tr>
      <td>Response parsing</td>
      <td>rewrite for content blocks</td>
      <td>unchanged</td>
    </tr>
    <tr>
      <td>Net code change</td>
      <td>every API call site</td>
      <td>one <code class="language-plaintext highlighter-rouge">base_url</code>, or one env var</td>
    </tr>
  </tbody>
</table>

<p>That’s the whole idea: meet the tool where it already is. The interesting
engineering is in <em>how</em> the proxy absorbs those differences, which is the rest of
this post.</p>

<h2 id="what-it-does">What it does</h2>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>┌────────────────┐   OpenAI-shaped    ┌───────────────┐   Anthropic-shaped   ┌──────────────────┐
│ OpenAI client  │ ─ POST /v1/chat/ ─▶│  llm-batcher  │ ─ POST /v1/messages ▶│  Anthropic API   │
│ (SDK, curl,    │   completions      │  (translates  │                      │  (Claude models) │
│  LangChain…)   │◀─ chat.completion ─│   both ways)  │◀─ messages response ─│                  │
└────────────────┘                    └───────────────┘                      └──────────────────┘
</code></pre></div></div>

<p>Concretely, the proxy:</p>

<ol>
  <li><strong>Accepts an OpenAI <code class="language-plaintext highlighter-rouge">POST /v1/chat/completions</code> request</strong>, the de-facto shape
nearly every LLM tool already emits.</li>
  <li><strong>Translates it to Anthropic’s Messages format</strong> (the two contracts differ in
subtle, important ways; more below).</li>
  <li><strong>Forwards it</strong> to <code class="language-plaintext highlighter-rouge">https://api.anthropic.com/v1/messages</code> over async httpx, with a
timeout and proper error propagation.</li>
  <li><strong>Translates Claude’s response back</strong> into the OpenAI <code class="language-plaintext highlighter-rouge">chat.completion</code> shape,
including <code class="language-plaintext highlighter-rouge">usage</code> token counts and a mapped <code class="language-plaintext highlighter-rouge">finish_reason</code>, so the caller never
knows it wasn’t talking to OpenAI.</li>
  <li>Exposes <strong><code class="language-plaintext highlighter-rouge">GET /health</code></strong> for liveness checks (the SRE in me refuses to ship a
service without one).</li>
</ol>

<h2 id="the-interesting-part-the-two-apis-are-almost-the-same">The interesting part: the two APIs are <em>almost</em> the same</h2>

<p>This is the kind of detail that looks trivial until you wire it up. OpenAI and
Anthropic both do “chat,” but the contracts diverge in ways that <em>will</em> bite you.</p>

<h3 id="1-the-system-prompt-lives-in-a-different-place">1. The system prompt lives in a different place</h3>

<p>OpenAI carries the system prompt as a <em>message</em> with <code class="language-plaintext highlighter-rouge">role: "system"</code> inside the
<code class="language-plaintext highlighter-rouge">messages</code> array. Anthropic does <strong>not</strong> allow a <code class="language-plaintext highlighter-rouge">system</code> role inside <code class="language-plaintext highlighter-rouge">messages</code>. The
system prompt is a <strong>top-level field</strong>. So the translator has to lift every <code class="language-plaintext highlighter-rouge">system</code>
message out of the array and join them into the top-level <code class="language-plaintext highlighter-rouge">system</code> field:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">_to_anthropic_payload</span><span class="p">(</span><span class="n">req</span><span class="p">:</span> <span class="n">ChatCompletionRequest</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="n">Any</span><span class="p">]:</span>
    <span class="n">system_parts</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">str</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="n">messages</span><span class="p">:</span> <span class="nb">list</span><span class="p">[</span><span class="nb">dict</span><span class="p">[</span><span class="nb">str</span><span class="p">,</span> <span class="nb">str</span><span class="p">]]</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">for</span> <span class="n">m</span> <span class="ow">in</span> <span class="n">req</span><span class="p">.</span><span class="n">messages</span><span class="p">:</span>
        <span class="k">if</span> <span class="n">m</span><span class="p">.</span><span class="n">role</span> <span class="o">==</span> <span class="s">"system"</span><span class="p">:</span>
            <span class="n">system_parts</span><span class="p">.</span><span class="n">append</span><span class="p">(</span><span class="n">m</span><span class="p">.</span><span class="n">content</span><span class="p">)</span>
        <span class="k">else</span><span class="p">:</span>
            <span class="n">role</span> <span class="o">=</span> <span class="s">"assistant"</span> <span class="k">if</span> <span class="n">m</span><span class="p">.</span><span class="n">role</span> <span class="o">==</span> <span class="s">"assistant"</span> <span class="k">else</span> <span class="s">"user"</span>
            <span class="n">messages</span><span class="p">.</span><span class="n">append</span><span class="p">({</span><span class="s">"role"</span><span class="p">:</span> <span class="n">role</span><span class="p">,</span> <span class="s">"content"</span><span class="p">:</span> <span class="n">m</span><span class="p">.</span><span class="n">content</span><span class="p">})</span>

    <span class="k">if</span> <span class="ow">not</span> <span class="n">messages</span><span class="p">:</span>
        <span class="k">raise</span> <span class="n">HTTPException</span><span class="p">(</span><span class="n">status_code</span><span class="o">=</span><span class="mi">400</span><span class="p">,</span>
                            <span class="n">detail</span><span class="o">=</span><span class="s">"At least one non-system message is required."</span><span class="p">)</span>

    <span class="n">payload</span> <span class="o">=</span> <span class="p">{</span>
        <span class="s">"model"</span><span class="p">:</span> <span class="n">req</span><span class="p">.</span><span class="n">model</span> <span class="ow">or</span> <span class="n">DEFAULT_MODEL</span><span class="p">,</span>
        <span class="s">"max_tokens"</span><span class="p">:</span> <span class="n">req</span><span class="p">.</span><span class="n">max_tokens</span> <span class="ow">or</span> <span class="n">DEFAULT_MAX_TOKENS</span><span class="p">,</span>
        <span class="s">"messages"</span><span class="p">:</span> <span class="n">messages</span><span class="p">,</span>
    <span class="p">}</span>
    <span class="k">if</span> <span class="n">system_parts</span><span class="p">:</span>
        <span class="n">payload</span><span class="p">[</span><span class="s">"system"</span><span class="p">]</span> <span class="o">=</span> <span class="s">"</span><span class="se">\n\n</span><span class="s">"</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">system_parts</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">payload</span>
</code></pre></div></div>

<h3 id="2-max_tokens-is-optional-in-openai-but-required-in-anthropic">2. <code class="language-plaintext highlighter-rouge">max_tokens</code> is optional in OpenAI but <strong>required</strong> in Anthropic</h3>

<p>Forward an OpenAI request that omits <code class="language-plaintext highlighter-rouge">max_tokens</code> and Anthropic rejects it. The proxy
defaults it (<code class="language-plaintext highlighter-rouge">DEFAULT_MAX_TOKENS</code>, env-configurable) so callers don’t have to care.</p>

<h3 id="3-the-response-envelope-is-shaped-differently">3. The response envelope is shaped differently</h3>

<p>Anthropic returns content as a list of typed blocks with its own stop-reason and
usage names. OpenAI clients expect <code class="language-plaintext highlighter-rouge">choices[].message.content</code>, a <code class="language-plaintext highlighter-rouge">finish_reason</code>, and
<code class="language-plaintext highlighter-rouge">usage</code> keyed as <code class="language-plaintext highlighter-rouge">prompt_tokens</code> / <code class="language-plaintext highlighter-rouge">completion_tokens</code> / <code class="language-plaintext highlighter-rouge">total_tokens</code>. The translator
flattens the blocks, maps the stop reason, and renames the usage fields:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">_to_openai_response</span><span class="p">(</span><span class="n">anthropic_json</span><span class="p">,</span> <span class="n">model</span><span class="p">):</span>
    <span class="n">text</span> <span class="o">=</span> <span class="s">""</span><span class="p">.</span><span class="n">join</span><span class="p">(</span><span class="n">b</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"text"</span><span class="p">,</span> <span class="s">""</span><span class="p">)</span>
                   <span class="k">for</span> <span class="n">b</span> <span class="ow">in</span> <span class="n">anthropic_json</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"content"</span><span class="p">,</span> <span class="p">[])</span>
                   <span class="k">if</span> <span class="n">b</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"type"</span><span class="p">)</span> <span class="o">==</span> <span class="s">"text"</span><span class="p">)</span>
    <span class="n">stop</span> <span class="o">=</span> <span class="n">anthropic_json</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"stop_reason"</span><span class="p">)</span>
    <span class="n">finish_reason</span> <span class="o">=</span> <span class="s">"stop"</span> <span class="k">if</span> <span class="n">stop</span> <span class="ow">in</span> <span class="p">(</span><span class="s">"end_turn"</span><span class="p">,</span> <span class="s">"stop_sequence"</span><span class="p">)</span> <span class="k">else</span> <span class="s">"length"</span>
    <span class="n">usage</span> <span class="o">=</span> <span class="n">anthropic_json</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"usage"</span><span class="p">,</span> <span class="p">{})</span>
    <span class="n">pt</span><span class="p">,</span> <span class="n">ct</span> <span class="o">=</span> <span class="n">usage</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"input_tokens"</span><span class="p">,</span> <span class="mi">0</span><span class="p">),</span> <span class="n">usage</span><span class="p">.</span><span class="n">get</span><span class="p">(</span><span class="s">"output_tokens"</span><span class="p">,</span> <span class="mi">0</span><span class="p">)</span>
    <span class="k">return</span> <span class="p">{</span>
        <span class="s">"id"</span><span class="p">:</span> <span class="s">"chatcmpl-"</span> <span class="o">+</span> <span class="n">uuid</span><span class="p">.</span><span class="n">uuid4</span><span class="p">().</span><span class="nb">hex</span><span class="p">,</span>
        <span class="s">"object"</span><span class="p">:</span> <span class="s">"chat.completion"</span><span class="p">,</span>
        <span class="s">"created"</span><span class="p">:</span> <span class="nb">int</span><span class="p">(</span><span class="n">time</span><span class="p">.</span><span class="n">time</span><span class="p">()),</span>
        <span class="s">"model"</span><span class="p">:</span> <span class="n">model</span><span class="p">,</span>
        <span class="s">"choices"</span><span class="p">:</span> <span class="p">[{</span><span class="s">"index"</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
                     <span class="s">"message"</span><span class="p">:</span> <span class="p">{</span><span class="s">"role"</span><span class="p">:</span> <span class="s">"assistant"</span><span class="p">,</span> <span class="s">"content"</span><span class="p">:</span> <span class="n">text</span><span class="p">},</span>
                     <span class="s">"finish_reason"</span><span class="p">:</span> <span class="n">finish_reason</span><span class="p">}],</span>
        <span class="s">"usage"</span><span class="p">:</span> <span class="p">{</span><span class="s">"prompt_tokens"</span><span class="p">:</span> <span class="n">pt</span><span class="p">,</span> <span class="s">"completion_tokens"</span><span class="p">:</span> <span class="n">ct</span><span class="p">,</span>
                  <span class="s">"total_tokens"</span><span class="p">:</span> <span class="n">pt</span> <span class="o">+</span> <span class="n">ct</span><span class="p">},</span>
    <span class="p">}</span>
</code></pre></div></div>

<h3 id="4-auth-and-versioning-headers-differ">4. Auth and versioning headers differ</h3>

<p>OpenAI uses <code class="language-plaintext highlighter-rouge">Authorization: Bearer &lt;key&gt;</code>. Anthropic uses a custom header pair, and
forgetting <code class="language-plaintext highlighter-rouge">anthropic-version</code> is a classic first-time 400:</p>

<div class="language-python highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">headers</span> <span class="o">=</span> <span class="p">{</span>
    <span class="s">"x-api-key"</span><span class="p">:</span> <span class="n">api_key</span><span class="p">,</span>
    <span class="s">"anthropic-version"</span><span class="p">:</span> <span class="s">"2023-06-01"</span><span class="p">,</span>
    <span class="s">"content-type"</span><span class="p">:</span> <span class="s">"application/json"</span><span class="p">,</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Pinning the version in one place means I never chase that bug across call sites.</p>

<h2 id="engineering-choices-and-the-sre-reasoning-behind-them">Engineering choices (and the SRE reasoning behind them)</h2>

<p><strong>Async all the way down (<code class="language-plaintext highlighter-rouge">httpx.AsyncClient</code>).</strong> A proxy’s whole job is to wait on an
upstream. Blocking I/O would cap concurrency at the worker count; async lets one
process hold thousands of in-flight requests cheaply, which matters enormously once
batching enters the picture, because batching is <em>literally</em> “hold requests and wait.”</p>

<p><strong>Timeouts and explicit error mapping.</strong> Every upstream call has a timeout. Network
failures become <code class="language-plaintext highlighter-rouge">502 Bad Gateway</code>; a missing key becomes a clear <code class="language-plaintext highlighter-rouge">500</code> with a human
message; Anthropic’s own errors are propagated with their status code. No silent hangs,
no leaked stack traces. That’s the difference between “works on my laptop” and “survives a
bad afternoon in prod.”</p>

<p><strong>Config via environment, secrets via <code class="language-plaintext highlighter-rouge">.env</code> (git-ignored).</strong> <code class="language-plaintext highlighter-rouge">ANTHROPIC_API_KEY</code>,
<code class="language-plaintext highlighter-rouge">DEFAULT_MODEL</code>, <code class="language-plaintext highlighter-rouge">DEFAULT_MAX_TOKENS</code>, and <code class="language-plaintext highlighter-rouge">REQUEST_TIMEOUT_S</code> are all env-driven. The
repo ships a <code class="language-plaintext highlighter-rouge">.env.example</code> and a <code class="language-plaintext highlighter-rouge">.gitignore</code> that keeps real keys out of version
control, verified before I made the repo public.</p>

<p><strong>Pydantic v2 request models.</strong> Incoming requests validate against a typed schema, so
malformed input fails fast with a 422 instead of exploding three layers deep.</p>

<h2 id="testing-without-spending-a-cent-or-leaking-a-key">Testing without spending a cent (or leaking a key)</h2>

<p>I wanted a suite that runs in CI with <strong>no API key and no network</strong>, because tests
that hit the real API are slow, flaky, and cost money, and a key in CI is a key that
can leak. So the upstream Anthropic call is <strong>mocked</strong> and the deterministic
translation logic is tested directly. Five tests cover:</p>

<ol>
  <li>System-message lifting into the top-level <code class="language-plaintext highlighter-rouge">system</code> field</li>
  <li>Rejecting a request with no non-system message</li>
  <li>Response-shape translation (content flattening, <code class="language-plaintext highlighter-rouge">finish_reason</code>, token math)</li>
  <li>A full <strong>mocked round-trip</strong> through the FastAPI endpoint</li>
  <li>The <code class="language-plaintext highlighter-rouge">/health</code> endpoint</li>
</ol>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>$ pytest -q
.....                                                    [100%]
5 passed in 1.53s
</code></pre></div></div>

<p>There’s also a <code class="language-plaintext highlighter-rouge">tests/smoke.sh</code> for a live round-trip once you drop in a real key, but
it’s never required for the suite to pass.</p>

<h2 id="try-it-yourself">Try it yourself</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git clone https://github.com/NeverTheSame/llm-batcher
<span class="nb">cd </span>llm-batcher

python3 <span class="nt">-m</span> venv venv
./venv/bin/pip <span class="nb">install</span> <span class="nt">-r</span> requirements.txt

<span class="nb">cp</span> .env.example .env          <span class="c"># then paste your ANTHROPIC_API_KEY</span>
./venv/bin/uvicorn app.main:app <span class="nt">--reload</span> <span class="nt">--port</span> 8000
</code></pre></div></div>

<p>Now talk to Claude using an <strong>OpenAI-shaped</strong> request:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-sS</span> <span class="nt">-X</span> POST http://localhost:8000/v1/chat/completions <span class="se">\</span>
  <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="se">\</span>
  <span class="nt">-d</span> <span class="s1">'{
    "model": "claude-3-5-haiku-latest",
    "messages": [
      {"role": "system", "content": "You are terse."},
      {"role": "user", "content": "Reply with exactly: pong"}
    ],
    "max_tokens": 16
  }'</span>
</code></pre></div></div>

<p>Or run it in a container:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>docker compose up <span class="nt">--build</span>
</code></pre></div></div>

<h2 id="where-its-going">Where it’s going</h2>

<p>Today it’s a pass-through. The name, <code class="language-plaintext highlighter-rouge">llm-batcher</code>, is a promise about the next
bricks: <strong>request batching</strong> (accumulate concurrent requests into one upstream window),
a <strong>cost &amp; latency observatory</strong> (per-request p50/p95/p99 and a dollar estimate),
<strong>backpressure and concurrency caps</strong> (graceful degradation under load), and a
<strong>benchmark harness</strong> to put throughput-vs-latency-vs-cost numbers on the table.</p>

<p>The point isn’t to build a production gateway; those exist. The point is to
demonstrate, in public, that I think about LLM serving the way an inference engineer
does: in terms of tail latency, fleet efficiency, and dollars per million tokens, not
model accuracy.</p>

<p><strong>Repo:</strong> <a href="https://github.com/NeverTheSame/llm-batcher">github.com/NeverTheSame/llm-batcher</a>.
Follow along to watch a reverse proxy turn into an inference-serving primitive.</p>]]></content><author><name>Kirill Kuklin</name></author><category term="ai" /><category term="llm-ops" /><category term="inference" /><category term="fastapi" /><category term="api-design" /><category term="sre" /><summary type="html"><![CDATA[A small FastAPI service that speaks the OpenAI Chat Completions API on the front and the Anthropic Messages API on the back. Any tool that already talks to OpenAI can point at it and get Claude instead, with zero client changes. Here's what I built, the API mismatches that bite, and the SRE reasoning underneath.]]></summary></entry><entry><title type="html">AI Engineer Coach Is an Eval Harness — But for the Human</title><link href="https://neverthesame.github.io/BeOps/ai/2026-05-28-ai-engineer-coach-evals-for-the-human.html" rel="alternate" type="text/html" title="AI Engineer Coach Is an Eval Harness — But for the Human" /><published>2026-05-28T00:00:00+00:00</published><updated>2026-05-28T00:00:00+00:00</updated><id>https://neverthesame.github.io/BeOps/ai/ai-engineer-coach-evals-for-the-human</id><content type="html" xml:base="https://neverthesame.github.io/BeOps/ai/2026-05-28-ai-engineer-coach-evals-for-the-human.html"><![CDATA[<p>Microsoft quietly dropped <a href="https://github.com/microsoft/AI-Engineering-Coach">AI Engineer Coach</a>
and the framing is, I think, deliberately understated. The README calls it a
dashboard that “reads your local AI session logs and turns them into actionable
insights.” Which is true. It’s also undersell of the year.</p>

<p>What it actually is: <strong>the first piece of AI tooling I’ve used that runs evals
on <em>the engineer</em> instead of the model.</strong> No data leaves your machine. The
rules — 45 of them, across prompt quality, session hygiene, code review, tool
mastery, and context management — run locally against your VS Code session
logs and tell you, with receipts, how good (or sloppy) your agentic-coding
practice has been this week.</p>

<p>I’ve been quietly arguing some version of this for a while
(<a href="https://neverthesame.github.io/BeOps/job-interviews/2026-05-25-staff-ai-engineer-interview.html">Staff AI Engineer interview</a> →
“the model is a junior; the senior is whoever defines the eval”). AI Engineer
Coach takes that to its logical conclusion: the senior is also whoever
<strong>measures themselves</strong>.</p>

<h2 id="three-things-that-surprised-me">Three things that surprised me</h2>

<h3 id="1-it-scores-your-agentsmd">1. It scores your AGENTS.md</h3>

<p>There’s an “instruction-file audit” pass. If your <code class="language-plaintext highlighter-rouge">AGENTS.md</code> is vague, missing,
or contradicts itself, the coach will tell you. This is the most satisfying
moment of dogfooding I’ve had this year, because back in August I wrote
<a href="https://neverthesame.github.io/BeOps/devops/2025-08-27-agentsmd-ate-my-readme.html">Agents.md ate my README</a>
arguing that this file was about to become a standard. It is — and now Microsoft
is shipping a linter for it. Full circle 🔁.</p>

<p>The check is dumb-simple: does the file exist, does it cover setup / test /
style / branch policy, is it short enough that an agent will actually read it.
That’s it. But “dumb-simple thing the team kept forgetting” is the whole job
description of half the SRE work I’ve ever done.</p>

<h3 id="2-anti-patterns-are-mostly-about-you-not-the-agent">2. Anti-patterns are mostly about you, not the agent</h3>

<p>The list reads like the worst quarterly review you’ll never have to write
about yourself:</p>

<ul>
  <li>prompting the agent without ever pasting the failure</li>
  <li>never reading what it changed before accepting</li>
  <li>12 sessions on the same problem with no rollback</li>
  <li>letting context drift past the model’s window because you can’t be bothered
to start a new session</li>
</ul>

<p>This is what eval culture should have been doing for AI <em>users</em> all along.
The model providers built evals for their own training loops. We — the
people who type into the chat — built nothing for ourselves. The coach
finally does.</p>

<h3 id="3-skill-finder-turns-repeated-prompts-into-reusable-skills">3. “Skill Finder” turns repeated prompts into reusable skills</h3>

<p>If you keep typing the same prompt variants (“turn this into a runbook”,
“explain like I’m an on-call engineer at 2am”), it surfaces the pattern
and offers to extract it as a saved skill. This is the same instinct
behind the <a href="https://neverthesame.github.io/BeOps/ai/2026-05-25-forward-deployed-engineering-ate-customer-success.html">Forward Deployed Engineering</a>
workflow: repeated bespoke work → productized tool. The coach is just doing
it for your own prompts.</p>

<h2 id="what-its-not">What it’s not</h2>

<p>It’s not telemetry going home. The repo is MIT, the analysis is local,
and the only “share” surface is a screenshot generator if you actually
<em>want</em> to post your stats. I ran it for three days against my Copilot CLI
sessions and the scariest finding was the number of sessions where I
accepted a change without reading it. Not the model’s fault. Mine.</p>

<h2 id="why-this-matters-more-than-another-agent-benchmark">Why this matters more than another agent benchmark</h2>

<p>The industry has been measuring <strong>models</strong> for two years and almost
nobody for <strong>practice</strong>. SWE-bench, MMLU, HumanEval — fine. But none of
those tell me whether I, personally, am writing prompts that produce
working code or whether my AGENTS.md is doing its job. AI Engineer Coach
is the first widely-available answer to that question, and the fact that
it ships as a VS Code extension you can <code class="language-plaintext highlighter-rouge">code --install-extension</code>
in under a minute is probably its most underrated feature.</p>

<p>It also closes a loop I keep talking about: agentic engineering needs the
same observability discipline as production systems. Logs, dashboards,
anti-pattern alerts, weekly trends. We built all of that for our services.
We had built basically none of it for our own AI coding practice. Now
there’s at least a starting point.</p>

<hr />

<p><em>Try it: clone, <code class="language-plaintext highlighter-rouge">npm ci &amp;&amp; npm run package</code>, install the VSIX, point it
at your VS Code session folder, and look at your own anti-pattern board.
Be prepared to flinch.</em></p>]]></content><author><name>Kirill Kuklin</name></author><category term="ai" /><category term="ai-engineering" /><category term="observability" /><category term="vscode" /><category term="agents" /><category term="anti-patterns" /><summary type="html"><![CDATA[Microsoft quietly dropped AI Engineer Coach and the framing is, I think, deliberately understated. The README calls it a dashboard that “reads your local AI session logs and turns them into actionable insights.” Which is true. It’s also undersell of the year.]]></summary></entry><entry><title type="html">Forward Deployed Engineering Ate Customer Success</title><link href="https://neverthesame.github.io/BeOps/ai/2026-05-25-forward-deployed-engineering-ate-customer-success.html" rel="alternate" type="text/html" title="Forward Deployed Engineering Ate Customer Success" /><published>2026-05-25T00:00:00+00:00</published><updated>2026-05-25T00:00:00+00:00</updated><id>https://neverthesame.github.io/BeOps/ai/forward-deployed-engineering-ate-customer-success</id><content type="html" xml:base="https://neverthesame.github.io/BeOps/ai/2026-05-25-forward-deployed-engineering-ate-customer-success.html"><![CDATA[<p>Forward Deployed Engineer (FDE) is the role nobody had heard of two years ago and that every AI lab is now hiring 50–1000 of. Palantir invented it. OpenAI, Anthropic, Cohere, Databricks, Salesforce, and Ramp copied it. EY just spun up an entire FDE practice in the UK. Job listings are up ~800% YoY between Jan and Sep 2025 🚀.</p>

<p>The numbers, if you’re shopping:</p>

<ul>
  <li>Base: <strong>$174k–$265k</strong></li>
  <li>TC at top AI startups: <strong>$400k–$630k</strong></li>
  <li>At Palantir, FDEs are reportedly <strong>~50% of the technical workforce</strong></li>
</ul>

<p>Big if true. So what is it, actually?</p>

<h2 id="its-not-a-sales-engineer-with-a-laptop">It’s not a sales engineer with a laptop</h2>

<p>An FDE is a software engineer who ships production code <strong>inside the customer’s environment</strong>. Not a slide deck. Not a POC. A running system, wired into legacy IT, weird auth, weirder data, and a compliance team that has Opinions.</p>

<p>The mental model I like: <strong>a startup CTO embedded in someone else’s company for 6 months.</strong> You own the outcome, you write the code, you eat the on-call pager when the LLM agent decides Tuesday is a great day to hallucinate invoice numbers.</p>

<p>It’s adjacent to — but distinctly not — these roles:</p>

<ul>
  <li><strong>Sales engineer</strong> — demos and pre-sales. FDE shows up <em>after</em> the contract.</li>
  <li><strong>Solutions architect</strong> — diagrams and guidance. FDE writes the code that backs the diagram.</li>
  <li><strong>Consultant</strong> — bills hours, leaves a deck. FDE leaves a deployed service.</li>
  <li><strong>Customer success</strong> — adoption metrics and QBRs. FDE owns whether the thing works in prod.</li>
</ul>

<h2 id="why-now">Why now</h2>

<p>Because the “95% of AI pilots produce no measurable ROI” stat finally made it into board decks.</p>

<p>Every enterprise has bought GPT seats, signed an Anthropic contract, slapped a copilot on something. Almost none of them have a working deployment. The bottleneck was never the model — it was:</p>

<ul>
  <li>17-year-old Oracle databases with column names like <code class="language-plaintext highlighter-rouge">CUST_TYP_3</code></li>
  <li>A VPN that requires a hardware token issued by a vendor that went bankrupt in 2019</li>
  <li>A security review process that thinks “SaaS” means “sus”</li>
  <li>A data team that — fairly — won’t hand over PII to a model whose system prompt is in a Notion page</li>
</ul>

<p>You don’t fix that with a better model. You fix it with <strong>an engineer who will sit in a Teams call at 7am and rewrite the integration until it works</strong>. That’s the FDE.</p>

<h2 id="what-the-day-actually-looks-like">What the day actually looks like</h2>

<p>From talking to a few FDEs at AI labs and reading the postings:</p>

<ol>
  <li><strong>Discovery week</strong>: shadow the customer’s analyst / ops person. Find the spreadsheet that runs the business. (There is always a spreadsheet that runs the business.)</li>
  <li><strong>Prototype week</strong>: stand up an agent / RAG pipeline / fine-tune that replaces the worst 30% of the spreadsheet. Show the analyst. They cry happy tears or tell you the prototype is wrong in 14 specific ways. Both are wins.</li>
  <li><strong>Hardening</strong>: auth, audit logs, eval harness, rollback plan, on-call rotation. This is the part that separates FDEs from “prompt engineers with consulting hours.”</li>
  <li><strong>Handoff or expand</strong>: either you train the customer’s team and leave, or — more often lately — the customer asks you to do the next workflow, and the next one, until you’ve quietly become their AI platform team.</li>
</ol>

<h2 id="the-skill-stack">The skill stack</h2>

<ul>
  <li><strong>Python + TypeScript</strong> — non-negotiable</li>
  <li><strong>LLM plumbing</strong> — RAG, agent loops, tool calling, evals (Braintrust / LangSmith / homegrown)</li>
  <li><strong>Cloud + IaC</strong> — at least one of AWS/GCP/Azure, plus Terraform or Pulumi</li>
  <li><strong>Data</strong> — SQL, dbt, Snowflake/BigQuery/Databricks, enough Spark to be dangerous</li>
  <li><strong>Security-ish</strong> — SSO, IAM, VPC peering, data residency, “what does SOC 2 actually require”</li>
  <li><strong>People skills</strong> — you will get yelled at by a VP who doesn’t understand why their PDF doesn’t parse. Don’t yell back.</li>
</ul>

<p>The SRE/DevOps crowd is honestly well-positioned here. You already know that prod is where dreams go to die. The only new muscle is <strong>doing it inside someone else’s prod</strong>.</p>

<h2 id="is-it-durable-or-just-a-2026-bubble-role">Is it durable, or just a 2026 bubble role?</h2>

<p>My bet: durable, but the title will fragment.</p>

<p>In two years “FDE” will split into:</p>

<ul>
  <li><strong>Deployment engineers</strong> at the AI labs (closer to today’s FDE)</li>
  <li><strong>AI platform engineers</strong> inside enterprises (the people the labs hand off to)</li>
  <li><strong>AI-native consulting</strong> (EY, Accenture, the new wave of boutique shops)</li>
</ul>

<p>The work doesn’t go away — enterprise integration never goes away. The label might.</p>

<h2 id="if-youre-considering-jumping">If you’re considering jumping</h2>

<p>Three honest filters:</p>

<ol>
  <li><strong>Do you like customers?</strong> Real ones. With problems. Who interrupt you.</li>
  <li><strong>Are you willing to write boring glue code in exchange for outsized impact and comp?</strong></li>
  <li><strong>Can you context-switch between a board-level outcome conversation and a 3am <code class="language-plaintext highlighter-rouge">kubectl logs</code> session?</strong></li>
</ol>

<p>If yes to all three: it’s probably the best comp-per-stress-unit role in tech right now, and the moat (enterprise reality is messy) is structural.</p>

<p>If no to any: stay platform-side. Build the tools FDEs use. That’s also a good job.</p>

<p>— k</p>]]></content><author><name>Kirill Kuklin</name></author><category term="ai" /><category term="fde" /><category term="forward-deployed-engineer" /><category term="ai-careers" /><category term="hiring" /><category term="palantir" /><category term="openai" /><summary type="html"><![CDATA[Forward Deployed Engineer job postings are up ~800% YoY and the role has quietly become the default "ship AI into a real company" job at OpenAI, Anthropic, Palantir, and basically every AI lab with revenue targets. Here's why the hype is real, and what it actually looks like day-to-day.]]></summary></entry><entry><title type="html">I Just Took a Staff AI Engineer Interview — Here’s What I Said</title><link href="https://neverthesame.github.io/BeOps/job-interviews/2026-05-25-staff-ai-engineer-interview.html" rel="alternate" type="text/html" title="I Just Took a Staff AI Engineer Interview — Here’s What I Said" /><published>2026-05-25T00:00:00+00:00</published><updated>2026-05-25T00:00:00+00:00</updated><id>https://neverthesame.github.io/BeOps/job-interviews/staff-ai-engineer-interview</id><content type="html" xml:base="https://neverthesame.github.io/BeOps/job-interviews/2026-05-25-staff-ai-engineer-interview.html"><![CDATA[<hr />

<p><em>Posting this mostly for me — a sanity-check of the answers I gave in a recent Staff AI Engineer loop. Also because writing it down is cheaper than therapy 💸. Names of the hiring company and my current employer are off-limits, so I’ll keep the specifics generic, but the technical substance is exactly what I said in the room.</em></p>

<hr />

<h2 id="background-they-asked-about">Background they asked about</h2>

<p>My path is one I’ve seen more and more of: SRE → DevOps → AI-facing engineering, with the operational instincts still intact (read: I still twitch at the word “deploy on Friday”). Before AI, I did system architecture and a long stint in customer/technical support — which I told them, with a straight face, was the single most useful background for building anything customer-facing on top of LLMs.</p>

<blockquote>
  <p>“You stop reaching for clever model tricks and start asking ‘what does the on-call human actually need at 2am?’” 🌙</p>
</blockquote>

<p>I’ve been shipping AI features since the GPT-3 era — early on, building an abstraction layer over the Azure OpenAI APIs so the rest of the org could stop copy-pasting <code class="language-plaintext highlighter-rouge">requests.post</code> snippets. More recently, an automated incident-quality assessment pipeline that other teams ended up adopting, which is honestly the cleanest signal that a tool is working: people steal it.</p>

<hr />

<h2 id="the-system-i-walked-them-through">The system I walked them through</h2>

<p>My current charter: an incident-triage automation system for a security product line, processing multiple customer-reported incidents per day. The inputs are messy — missing logs, half-finished troubleshooting notes, screenshots without context, and the occasional <em>“it’s broken, plz fix 🙏”</em>. My job is to turn that into something a human responder can act on in seconds.</p>

<p>The system is more boring than the conference talks make it sound, which is the point. Boring in production is a compliment. Exciting in production means someone’s getting paged.</p>

<p><strong>1. Quality assessment.</strong> Before anything else, an LLM call grades the incoming incident against the backend data we already have — does the customer’s description actually match what telemetry shows? Low-quality inputs get bounced back for clarification instead of poisoning the rest of the pipeline. Garbage in, very confidently-worded garbage out.</p>

<p><strong>2. Retrieval.</strong> A RAG layer over ~1,600 internal articles plus the public product documentation, indexed in a vector database with semantic matching. Nothing exotic — the work was in chunking and threshold tuning, not algorithm choice. Sorry, no secret sauce 🥲.</p>

<p><strong>3. Historical context.</strong> KQL (Kusto) queries pull the five most recent incidents with similar symptoms. When they asked which retrieved context moves the needle most, I said historical incidents — higher than docs, every time. The docs tell you what <em>should</em> happen. Past incidents tell you what <em>actually</em> happens.</p>

<p><strong>4. Synthesis.</strong> The model drafts a triage summary with citations back into the retrieved set. Citations are mandatory; uncited answers are dropped. No vibes-based answers, no matter how confident the model sounds.</p>

<hr />

<h2 id="rag--what-actually-moved-the-metric">RAG — what actually moved the metric</h2>

<p>This was the section where I expected the deepest follow-ups, and got them. My short version:</p>

<blockquote>
  <p>“Most of the wins came from things you’d find in a 2023 blog post. The mistake is thinking you’ve already done them.”</p>
</blockquote>

<ul>
  <li><strong>Precision@k as the primary metric</strong>, not recall. For triage, missing a relevant article is annoying; surfacing an irrelevant one is poison.</li>
  <li><strong>Recursive / semantic chunking</strong> consistently beat fixed-window chunking.</li>
  <li><strong>Relevance threshold of 0.6</strong> before a chunk is allowed into the answer set. Below that, drop it. Counter-intuitively, returning less context produced better answers — the model is happier with a small, clean tray than a giant buffet.</li>
  <li><strong>LLM-as-judge</strong> as a second pass — a smaller model scores the synthesized answer against the retrieved chunks. Disagreement triggers a re-run with tighter retrieval.</li>
  <li><strong>Language-specific datasets.</strong> English and German incidents get separate weights. The temptation is to treat language as a translation problem. It isn’t — the technical vocabulary, log formats, and even the way customers complain are different.</li>
</ul>

<hr />

<h2 id="evaluation">Evaluation</h2>

<p>The interviewer leaned in here, which I took as a good sign.</p>

<ul>
  <li><strong>Offline-only evals</strong>, run in GitHub Actions on every PR. No live A/B in production for the triage path; the cost of a wrong answer is too high.</li>
  <li><strong>50–100 incidents manually categorized with SMEs</strong> as the golden set. We re-do this every quarter — the distribution of incident types drifts faster than you’d think.</li>
  <li><strong>LangFuse</strong> for dataset management and trace inspection. I admitted I had previously tried to bend Grafana into doing this and gave up. Grafana is the wrong shape for LLM calls — you’re not looking at metrics, you’re looking at a conversation.</li>
  <li>A separate eval suite per feature — quality assessment, retrieval, synthesis, and the judge each get their own.</li>
</ul>

<hr />

<h2 id="safety-policy-and-the-human-in-the-loop">Safety, policy, and the human in the loop</h2>

<p>The team services some VIP customers where any automated action needs human approval. The implementation I described and would defend in any review:</p>

<ul>
  <li><strong>Approval requests routed via Teams</strong>, with an escalation chain — primary owner, then a colleague, then mobile phones via the on-call system if neither responds in N minutes. The approval is a feature, not friction — but it has to feel fast or people will route around it.</li>
  <li><strong>LangGraph for policy enforcement.</strong> Every tool call passes through a policy node that checks:
    <ul>
      <li>tool name against an allow-list,</li>
      <li>argument validation (types, ranges, regex on resource IDs),</li>
      <li>role-based access for the calling agent,</li>
      <li>a risk level on the tool that maps to required approval.</li>
    </ul>
  </li>
  <li><strong>Read-only database credentials</strong> for any code path that touches sensitive data. <em>“If your agent has DELETE, it will eventually DELETE.”</em> (Frame this and hang it above your desk.)</li>
  <li><strong>Prompt-injection defence</strong>: untrusted text (customer descriptions, retrieved docs) is wrapped in clearly delimited blocks, and the system prompt instructs the model to treat anything inside those blocks as data, not instructions. Belt and suspenders, but it has caught real attempts.</li>
</ul>

<hr />

<h2 id="memory--context-engineering">Memory &amp; context engineering</h2>

<p>This is where I leaned hardest on the SRE background, and I think it landed.</p>

<blockquote>
  <p>“State is durable. Context is what the model needs <em>right now</em>. Stop mixing them.”</p>
</blockquote>

<ul>
  <li><strong>State vs context split.</strong> State lives in a real store and survives across turns. Context is rebuilt every turn and contains only what the next step needs.</li>
  <li><strong>Compression triggers at ~40% of the context window.</strong> I explicitly do not wait for the window to fill — by then, performance has already degraded.</li>
  <li><strong>Memory hierarchy:</strong>
    <ul>
      <li><strong>Cold storage</strong> — immutable raw logs. Source of truth, never queried by the model directly.</li>
      <li><strong>Structured memory</strong> — extracted facts (entities, decisions, customer preferences) stored as JSON. Queried deterministically.</li>
      <li><strong>Compressed summaries</strong> — what actually goes into the prompt.</li>
    </ul>
  </li>
  <li><strong>Critical-information tagging</strong> before any compression pass. User preferences, explicit decisions, constraints, and tool outputs are tagged high-importance and survive every compression round verbatim. Tool outputs are almost always more important than the model thinks they are.</li>
</ul>

<hr />

<h2 id="skills-mcp-and-the-capability-registry">Skills, MCP, and the capability registry</h2>

<p>I’m bearish on giving an agent unrestricted MCP access and I said so out loud. The setup I described:</p>

<ul>
  <li><strong>Capability registry</strong> — every tool has an explicit contract: name, permission level, side-effect class (read / mutate / external), and whether it’s exposed publicly (as a slash command) or only callable internally.</li>
  <li><strong>Dynamic skill selection per conversation.</strong> Pipeline:
    <ol>
      <li>Pull candidate skills from the registry filtered by the user’s role.</li>
      <li>Rank by relevance to the current user message and the conversation’s accumulated context.</li>
      <li>Cap at <strong>3–5 MCPs per conversation</strong>. More than that and the model’s tool-choice accuracy collapses — we measured it. Apparently agents, like the rest of us, get decision fatigue at the buffet.</li>
    </ol>
  </li>
  <li><strong><code class="language-plaintext highlighter-rouge">Examples.md</code> files</strong> per skill that the model is shown verbatim. Deterministic invocation patterns beat clever tool descriptions every time.</li>
  <li>Hard RBAC enforcement happens <em>outside</em> the LLM, in the policy layer — the model is never trusted to gate its own access.</li>
</ul>

<hr />

<h2 id="observability--infrastructure">Observability &amp; infrastructure</h2>

<ul>
  <li><strong>LangFuse is the standard</strong> for LLM observability on my team. Grafana stays for the rest of the stack.</li>
  <li><strong>Everything is logged and alerted.</strong> Every tool invocation, every retrieval, every policy decision, every approval response. If it isn’t logged, it didn’t happen — and if it didn’t happen, you can’t reproduce the bug that just paged you.</li>
  <li><strong>Strict separation of production and evaluation environments.</strong> Different credentials, different data stores, different model deployments.</li>
  <li><strong>Mock tools for CI/CD.</strong> The eval pipeline runs against mocks so it can exercise the agent’s reasoning without making real API calls or mutations.</li>
  <li><strong>Sandbox / shadow environments</strong> for testing new policies or skills against real traffic patterns without affecting production.</li>
  <li><strong>Dry-run mode</strong> is a first-class feature, not a debug flag. Any mutating tool can be run with <code class="language-plaintext highlighter-rouge">dry_run=true</code> and the agent’s plan is logged but not executed.</li>
</ul>

<hr />

<h2 id="what-id-tell-past-me-before-the-loop">What I’d tell past-me before the loop</h2>

<p>A few patterns kept showing up in my own answers that I want to plant flags on:</p>

<ol>
  <li><strong>The metric is precision, not recall.</strong> This applies to retrieval, to tool selection, to approval workflows. Returning less, but better, beats returning more.</li>
  <li><strong>State and context are different things.</strong> Treating them as one is the most common mistake I see in agent code reviews — including my own old ones.</li>
  <li><strong>The policy layer is not part of the model.</strong> It runs around the model and is allowed to refuse. The model is never the security boundary.</li>
  <li><strong>Offline evals with a small, hand-curated golden set, refreshed quarterly.</strong> Glamour-free, the only thing that actually moved the metric.</li>
  <li><strong>Operational instincts beat ML instincts</strong> for this class of system. If you’re hiring, look at SRE / support / platform résumés.</li>
</ol>

<hr />

<h2 id="the-verdict">The verdict</h2>

<p>I got the email a couple of days later: <strong>invited to the next round</strong> 🎉. So either the above answers were directionally right or I caught the interviewer on a generous day — I’ll take either. Next stage is a system-design round, and I suspect “design the incident-triage system without the benefit of having already built it” is going to be the prompt. Updates to follow.</p>

<p>If you’re prepping for a Staff-level AI engineering loop yourself, the section headers above are basically the question list. You’re welcome 🎁.</p>]]></content><author><name>Kirill Kuklin</name></author><category term="job-interviews" /><category term="ai-engineering" /><category term="rag" /><category term="agents" /><category term="observability" /><category term="llm-ops" /><category term="incident-response" /><summary type="html"><![CDATA[My own brain-dump from a Staff AI Engineer loop — what I shipped, what I'd defend in a code review, and the answers that apparently didn't tank me because I got invited back. Spoiler: less magic, more boring engineering, and one or two opinions about Grafana.]]></summary></entry><entry><title type="html">AI-First Companies Stock Market Developments - November 2025</title><link href="https://neverthesame.github.io/BeOps/ai/2025-11-20-ai-first-companies-stock-market-developments.html" rel="alternate" type="text/html" title="AI-First Companies Stock Market Developments - November 2025" /><published>2025-11-20T00:00:00+00:00</published><updated>2025-11-20T00:00:00+00:00</updated><id>https://neverthesame.github.io/BeOps/ai/ai-first-companies-stock-market-developments</id><content type="html" xml:base="https://neverthesame.github.io/BeOps/ai/2025-11-20-ai-first-companies-stock-market-developments.html"><![CDATA[<p>The AI revolution has fundamentally reshaped the stock market landscape, with AI-first companies experiencing unprecedented volatility and growth. As we approach the end of 2025, several key trends are emerging that DevOps and platform teams should understand—not just for investment decisions, but for understanding where enterprise AI infrastructure is heading.</p>

<h2 id="the-ai-infrastructure-leaders">The AI Infrastructure Leaders</h2>

<h3 id="nvidia-the-gpu-empire">NVIDIA: The GPU Empire</h3>

<p>NVIDIA continues to dominate the AI hardware space, with its stock performance closely tied to enterprise AI adoption cycles. Recent developments show:</p>

<ul>
  <li><strong>Blackwell Architecture Adoption</strong>: The Blackwell GPU platform has become the de facto standard for large-scale AI training, driving consistent revenue growth. Data center revenue now represents over 85% of total revenue.</li>
  <li><strong>Enterprise AI Partnerships</strong>: Major cloud providers (AWS, Azure, GCP) are committing to multi-year Blackwell deployments, creating predictable revenue streams.</li>
  <li><strong>China Market Challenges</strong>: Export restrictions continue to impact revenue, but the company has pivoted to focus on compliant chips and software solutions.</li>
</ul>

<p><strong>Key Takeaway for DevOps</strong>: GPU availability and pricing directly impact your AI infrastructure costs. Budget planning should account for Blackwell-class hardware and potential supply constraints.</p>

<h3 id="microsoft-the-openai-bet-pays-off">Microsoft: The OpenAI Bet Pays Off</h3>

<p>Microsoft’s strategic partnership with OpenAI has positioned it as a leader in enterprise AI adoption:</p>

<ul>
  <li><strong>Azure AI Services Growth</strong>: Revenue from Azure AI and OpenAI services has grown 150% YoY, making it one of Microsoft’s fastest-growing segments.</li>
  <li><strong>Copilot Integration</strong>: The widespread adoption of GitHub Copilot and Microsoft 365 Copilot has created a sticky revenue stream that scales with developer productivity.</li>
  <li><strong>Infrastructure Investment</strong>: Microsoft is investing heavily in data center expansion to support AI workloads, with capex reaching record levels.</li>
</ul>

<p><strong>Key Takeaway for DevOps</strong>: Microsoft’s AI infrastructure investments mean better tooling and integration for Azure-native deployments. Consider Azure AI services for enterprise workloads requiring compliance and security guarantees.</p>

<h3 id="alphabetgoogle-the-gemini-push">Alphabet/Google: The Gemini Push</h3>

<p>Google’s response to ChatGPT has been aggressive, with Gemini models driving cloud revenue:</p>

<ul>
  <li><strong>Cloud AI Revenue</strong>: Google Cloud’s AI services revenue has doubled, driven by Gemini API adoption and Vertex AI platform growth.</li>
  <li><strong>Open Source Strategy</strong>: The release of Gemma models and open-source AI frameworks has created developer mindshare, translating to enterprise adoption.</li>
  <li><strong>Search AI Integration</strong>: Google Search’s AI Overview feature has driven engagement metrics, though initial rollout challenges impacted stock performance.</li>
</ul>

<p><strong>Key Takeaway for DevOps</strong>: Google’s open-source AI models (Gemma) provide alternatives to proprietary APIs, enabling on-premises deployments and reducing vendor lock-in.</p>

<h3 id="amazon-aws-ai-services-scale">Amazon: AWS AI Services Scale</h3>

<p>Amazon’s Bedrock platform and SageMaker have become critical infrastructure for AI workloads:</p>

<ul>
  <li><strong>Bedrock Adoption</strong>: AWS Bedrock has seen rapid enterprise adoption, with revenue growing 200% YoY as companies migrate from direct API calls to managed services.</li>
  <li><strong>Inferentia and Trainium</strong>: Custom AI chips are reducing AWS’s dependency on NVIDIA while offering cost advantages to customers.</li>
  <li><strong>Enterprise AI Contracts</strong>: Large multi-year AI infrastructure contracts are becoming common, providing revenue predictability.</li>
</ul>

<p><strong>Key Takeaway for DevOps</strong>: AWS’s custom AI chips (Inferentia, Trainium) can reduce inference costs by 40-60% compared to GPU-based solutions. Evaluate these for production workloads.</p>

<h2 id="emerging-ai-first-companies">Emerging AI-First Companies</h2>

<h3 id="palantir-enterprise-ai-platform">Palantir: Enterprise AI Platform</h3>

<p>Palantir’s shift to AI-powered analytics platforms has driven significant stock appreciation:</p>

<ul>
  <li><strong>AIP Platform Growth</strong>: The Artificial Intelligence Platform (AIP) has become Palantir’s fastest-growing product, with enterprise customers adopting it for operational AI.</li>
  <li><strong>Government Contracts</strong>: Continued strong performance in government contracts provides revenue stability.</li>
  <li><strong>Commercial Expansion</strong>: Commercial revenue growth has accelerated as enterprises seek AI-powered decision-making tools.</li>
</ul>

<p><strong>Key Takeaway for DevOps</strong>: Palantir’s AIP demonstrates the value of AI platforms that integrate with existing enterprise infrastructure. Consider similar platform approaches for internal AI deployments.</p>

<h3 id="c3ai-enterprise-ai-applications">C3.ai: Enterprise AI Applications</h3>

<p>C3.ai focuses on industry-specific AI applications:</p>

<ul>
  <li><strong>SaaS Transition</strong>: The shift to SaaS delivery models has improved margins and customer acquisition.</li>
  <li><strong>Industry Verticals</strong>: Strong performance in energy, manufacturing, and financial services verticals.</li>
  <li><strong>Platform Approach</strong>: C3.ai’s platform enables rapid deployment of industry-specific AI applications.</li>
</ul>

<p><strong>Key Takeaway for DevOps</strong>: Industry-specific AI platforms can accelerate time-to-value compared to building custom solutions. Evaluate vertical-specific platforms before building from scratch.</p>

<h3 id="snowflake-data--ai-convergence">Snowflake: Data + AI Convergence</h3>

<p>Snowflake’s integration of AI capabilities into its data platform has driven growth:</p>

<ul>
  <li><strong>AI/ML Features</strong>: Native AI/ML capabilities within Snowflake reduce data movement and improve performance.</li>
  <li><strong>Cortex AI</strong>: Snowflake Cortex provides LLM capabilities directly on data, eliminating the need for separate AI infrastructure.</li>
  <li><strong>Enterprise Adoption</strong>: Large enterprises are consolidating data and AI workloads on Snowflake’s platform.</li>
</ul>

<p><strong>Key Takeaway for DevOps</strong>: Unified data and AI platforms reduce infrastructure complexity. Consider platforms that combine data warehousing with AI capabilities.</p>

<h2 id="market-trends-and-implications">Market Trends and Implications</h2>

<h3 id="infrastructure-investment-cycle">Infrastructure Investment Cycle</h3>

<p>The AI infrastructure investment cycle is entering a new phase:</p>

<ul>
  <li><strong>Training to Inference Shift</strong>: As models mature, spending is shifting from training infrastructure to inference infrastructure.</li>
  <li><strong>Edge AI Growth</strong>: Edge AI deployments are growing, driven by latency requirements and data privacy concerns.</li>
  <li><strong>Open Source Impact</strong>: Open-source models are reducing the cost of AI adoption, impacting proprietary AI service providers.</li>
</ul>

<h3 id="enterprise-ai-adoption-patterns">Enterprise AI Adoption Patterns</h3>

<p>Stock performance correlates with enterprise AI adoption:</p>

<ul>
  <li><strong>Platform Plays Outperform</strong>: Companies offering AI platforms (Microsoft, Google, AWS) outperform point solutions.</li>
  <li><strong>Vertical Integration Matters</strong>: Companies that integrate AI into existing products (Microsoft 365, Google Workspace) show stronger growth.</li>
  <li><strong>Developer Tools</strong>: AI-powered developer tools (GitHub Copilot, Cursor) are creating new revenue streams.</li>
</ul>

<h3 id="regulatory-and-geopolitical-factors">Regulatory and Geopolitical Factors</h3>

<ul>
  <li><strong>Export Restrictions</strong>: US restrictions on AI chip exports to China impact NVIDIA and other hardware providers.</li>
  <li><strong>AI Regulation</strong>: EU AI Act and similar regulations create compliance requirements that favor established providers.</li>
  <li><strong>Data Sovereignty</strong>: Requirements for on-premises AI deployments benefit companies offering hybrid solutions.</li>
</ul>

<h2 id="practical-implications-for-devops-teams">Practical Implications for DevOps Teams</h2>

<h3 id="infrastructure-planning">Infrastructure Planning</h3>

<ol>
  <li><strong>GPU Availability</strong>: Monitor GPU supply chains and pricing. Consider alternatives (AWS Inferentia, Google TPU) for cost optimization.</li>
  <li><strong>Multi-Cloud Strategy</strong>: Diversify AI workloads across providers to avoid vendor lock-in and optimize costs.</li>
  <li><strong>Open Source Models</strong>: Evaluate open-source models (Llama, Gemma, Mistral) for on-premises deployments to reduce API costs.</li>
</ol>

<h3 id="cost-management">Cost Management</h3>

<ol>
  <li><strong>Inference Optimization</strong>: Focus on inference cost optimization as training costs stabilize.</li>
  <li><strong>Model Selection</strong>: Choose models based on cost-performance tradeoffs, not just capability.</li>
  <li><strong>Reserved Capacity</strong>: Consider reserved capacity commitments for predictable workloads to reduce costs.</li>
</ol>

<h3 id="platform-selection">Platform Selection</h3>

<ol>
  <li><strong>Integration Requirements</strong>: Choose AI platforms that integrate with existing infrastructure and tooling.</li>
  <li><strong>Compliance</strong>: Ensure AI platforms meet regulatory requirements (GDPR, HIPAA, SOC 2).</li>
  <li><strong>Vendor Lock-in</strong>: Prefer platforms that support open standards and allow model portability.</li>
</ol>

<h2 id="looking-ahead">Looking Ahead</h2>

<p>The AI stock market is maturing, with clear winners emerging in infrastructure, platforms, and applications. For DevOps teams, understanding these trends helps with:</p>

<ul>
  <li><strong>Technology Selection</strong>: Choosing AI infrastructure and platforms aligned with market leaders.</li>
  <li><strong>Cost Planning</strong>: Anticipating infrastructure costs based on market trends.</li>
  <li><strong>Risk Management</strong>: Understanding vendor stability and market dynamics.</li>
</ul>

<p>The next phase of AI adoption will focus on production deployment, operational excellence, and cost optimization—areas where DevOps expertise becomes critical. Companies that can operationalize AI at scale will have a significant competitive advantage, and the stock market is already reflecting this reality.</p>

<p>Bottom line: The AI revolution isn’t just about technology—it’s reshaping entire markets. DevOps teams that understand these dynamics can make better infrastructure decisions and position their organizations for success in the AI era.</p>]]></content><author><name>Kirill Kuklin</name></author><category term="ai" /><category term="ai" /><category term="stock-market" /><category term="nvidia" /><category term="microsoft" /><category term="enterprise-ai" /><summary type="html"><![CDATA[The AI revolution has fundamentally reshaped the stock market landscape, with AI-first companies experiencing unprecedented volatility and growth. As we approach the end of 2025, several key trends are emerging that DevOps and platform teams should understand—not just for investment decisions, but for understanding where enterprise AI infrastructure is heading.]]></summary></entry><entry><title type="html">Ship AI Like It’s Already Under Attack</title><link href="https://neverthesame.github.io/BeOps/ai/2025-11-20-ai-security-lessons-from-micro.html" rel="alternate" type="text/html" title="Ship AI Like It’s Already Under Attack" /><published>2025-11-20T00:00:00+00:00</published><updated>2025-11-20T00:00:00+00:00</updated><id>https://neverthesame.github.io/BeOps/ai/ai-security-lessons-from-micro</id><content type="html" xml:base="https://neverthesame.github.io/BeOps/ai/2025-11-20-ai-security-lessons-from-micro.html"><![CDATA[<p>Security for AI systems is finally getting the same scrutiny as container supply chains. The fastest signal comes from Micro (Microsoft’s secure-by-design program), which spent the past eighteen months wiring model safety into the same controls that already protect Azure, Office, and Xbox. Here’s what they shipped and how you can borrow the playbook inside your own platform.</p>

<h3 id="tldr">TL;DR</h3>

<ul>
  <li><strong>Micro made AI threat modeling real</strong> with a formal Secure Future Initiative (SFI) update, an AI Red Team playbook, and default guardrails in Azure AI Studio.</li>
  <li><strong>Their stack is layered</strong>: harden data sources, instrument training/inference pipelines, and close the loop with SOC-grade telemetry (Security Copilot).</li>
  <li><strong>You can replicate 80% of it today</strong> using reproducible builds, signed checkpoints, isolation boundaries for inference, and policy-as-code guardrails around prompts and outputs.</li>
</ul>

<h3 id="what-micro-already-shipped">What Micro already shipped</h3>

<ol>
  <li>
    <p><strong>Secure Future Initiative for AI (2023 → 2024 refresh)</strong><br />
Micro took the SFI controls—passwordless identity, secret scanning, memory-safe rewrites—and applied them to model hosting. Every Azure OpenAI endpoint now inherits Conditional Access policies, managed identity, and default customer-managed keys.</p>
  </li>
  <li>
    <p><strong>AI Red Team + Responsible AI Standard v2</strong><br />
Their in-house Red Team published threat trees for prompt injection, data exfiltration, and model theft. Those trees plug straight into Microsoft’s Responsible AI Standard (transparency notes, safety evaluations, abuse monitoring) and gate releases of Copilot, Bing, and Phi-3.</p>
  </li>
  <li>
    <p><strong>Security Copilot + Defender connectors (GA October 2024)</strong><br />
The SOC tool now ingests GPT-4-based summaries plus native signals from Defender for Cloud, Purview, and Sentinel. For AI workloads, you can trace a risky prompt to the Azure resource, user identity, and token permissions in one pane.</p>
  </li>
  <li>
    <p><strong>Prompt Shields + Azure AI Content Safety</strong><br />
Build 2024 introduced Prompt Shields (jailbreak/indirect prompt filtering) composed with Content Safety classifiers. Micro exposed both as policies in Azure AI Studio so platform teams can enforce “no PII egress” or “no code execution instructions” without rewriting their apps.</p>
  </li>
  <li>
    <p><strong>Confidential inference + watermarking</strong><br />
The ND H100 v5 VMs ship with Intel TDX/AMD SEV-SNP, so models and prompts stay encrypted in use. At the same time, Micro partnered with C2PA to watermark Copilot outputs, making provenance verification part of the default toolchain.</p>
  </li>
</ol>

<h3 id="build-the-same-layers-in-your-stack">Build the same layers in your stack</h3>

<table>
  <thead>
    <tr>
      <th>Layer</th>
      <th>Primary threat</th>
      <th>Micro control</th>
      <th>Your fast-follow</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Data supply</td>
      <td>Poisoned fine-tune data, embedded malware</td>
      <td>SFI mandates signed datasets + Defender for Storage</td>
      <td>Store training corpora in versioned object buckets; gate merges via security scanning (ClamAV, Semgrep, custom heuristics).</td>
    </tr>
    <tr>
      <td>Training</td>
      <td>Model theft, hyperparameter drift</td>
      <td>Confidential training clusters + Azure Policy</td>
      <td>Keep training jobs inside namespaces with workload identity + short-lived credentials; emit manifests with git SHA + dataset digests.</td>
    </tr>
    <tr>
      <td>Inference</td>
      <td>Prompt injection, privilege escalation</td>
      <td>Prompt Shields + Content Safety</td>
      <td>Layer intent classifiers, allow/deny lists, and sandboxed tool execution (Firecracker, gVisor).</td>
    </tr>
    <tr>
      <td>Operations</td>
      <td>Silent failure, lack of forensics</td>
      <td>Security Copilot timeline view</td>
      <td>Mirror the telemetry by streaming prompts, completions, and system calls to your SIEM with privacy-safe redaction.</td>
    </tr>
  </tbody>
</table>

<h3 id="recommended-controls-copypaste-friendly">Recommended controls (copy/paste friendly)</h3>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">apiVersion</span><span class="pi">:</span> <span class="s">policy.sigstore.dev/v1beta1</span>
<span class="na">kind</span><span class="pi">:</span> <span class="s">AttestationPolicy</span>
<span class="na">metadata</span><span class="pi">:</span>
  <span class="na">name</span><span class="pi">:</span> <span class="s">ai-model-release</span>
<span class="na">spec</span><span class="pi">:</span>
  <span class="na">subjects</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">pattern</span><span class="pi">:</span> <span class="s">ghcr.io/acme/llm-serving:*</span>
  <span class="na">attestations</span><span class="pi">:</span>
    <span class="pi">-</span> <span class="na">name</span><span class="pi">:</span> <span class="s">model-integrity</span>
      <span class="na">predicateType</span><span class="pi">:</span> <span class="s">https://slsa.dev/provenance/v1</span>
      <span class="na">policy</span><span class="pi">:</span>
        <span class="na">script</span><span class="pi">:</span> <span class="pi">|</span>
          <span class="s">rule integrity_is_signed:</span>
            <span class="s">condition:</span>
              <span class="s">input.provenance.builder.id in ["https://micro.ai/redteam/builder", "https://acme.ai/builder"]</span>
</code></pre></div></div>

<p>Wire this into your CI so only images with trusted SLSA provenance—and, optionally, Micro’s shared builders—can reach production.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Sandbox tool execution the same way Micro isolates Copilot plug-ins</span>
kubectl apply <span class="nt">-f</span> - <span class="o">&lt;&lt;</span><span class="sh">'</span><span class="no">EOF</span><span class="sh">'
apiVersion: gvisor.dev/v1
kind: SandboxedRuntime
metadata:
  name: llm-tools
spec:
  runtimeHandler: runsc
  annotations:
    workload: "ai-tools"
</span><span class="no">EOF
</span></code></pre></div></div>

<h3 id="checklist-before-you-ship-the-next-model">Checklist before you ship the next model</h3>

<ol>
  <li><strong>Map assets</strong> – training data, fine-tune weights, prompt templates, eval harness.</li>
  <li><strong>Decide trust boundaries</strong> – separate data prep, training, inference, and retrieval.</li>
  <li><strong>Instrument every hop</strong> – capture prompts, tool calls, and policy verdicts as structured events.</li>
  <li><strong>Rehearse failure</strong> – run Micro-style Red Team drills for jailbreaks, data poisoning, and lateral movement.</li>
  <li><strong>Close the loop</strong> – feed detections back into guardrails and developer training.</li>
</ol>

<p>Treat Micro’s work not as a vendor story but as a template. If their Copilot stack can prove provenance, enforce policy, and keep SOC eyes on every inference, so can yours—just swap in your clouds, your secrets engine, and your favorite LLM runtime.</p>]]></content><author><name>Kirill Kuklin</name></author><category term="ai" /><category term="ai-security" /><category term="microsoft" /><category term="guardrails" /><summary type="html"><![CDATA[Security for AI systems is finally getting the same scrutiny as container supply chains. The fastest signal comes from Micro (Microsoft’s secure-by-design program), which spent the past eighteen months wiring model safety into the same controls that already protect Azure, Office, and Xbox. Here’s what they shipped and how you can borrow the playbook inside your own platform.]]></summary></entry><entry><title type="html">Composer LLM, the Operator-Friendly Foundation Model</title><link href="https://neverthesame.github.io/BeOps/ai/2025-11-20-composer-llm-model.html" rel="alternate" type="text/html" title="Composer LLM, the Operator-Friendly Foundation Model" /><published>2025-11-20T00:00:00+00:00</published><updated>2025-11-20T00:00:00+00:00</updated><id>https://neverthesame.github.io/BeOps/ai/composer-llm-model</id><content type="html" xml:base="https://neverthesame.github.io/BeOps/ai/2025-11-20-composer-llm-model.html"><![CDATA[<p>Composer started life as MosaicML’s open-source training library, and it remains the backbone behind the company’s public LLM checkpoints such as MPT-7B/30B and the newer DBRX Instruct weights Databricks released under Apache 2.0. In most conversations people simply say “the Composer model” to describe that lineage of transparent, retrainable LLMs. If you want something you can fork, fine-tune, and redeploy inside a Kubernetes-backed platform without license headaches, Composer is the pragmatic compromise between rolling your own transformer and licensing a black-box API.</p>

<h3 id="quick-snapshot">Quick snapshot</h3>

<ul>
  <li><strong>Open training stack</strong> - Pure PyTorch with Composer callbacks (layer freezing, gradient clipping, mixup-style augmentation) wired for DeepSpeed ZeRO-3 and PyTorch FSDP.</li>
  <li><strong>Model family</strong> - MPT-7B/30B, DBRX Instruct, and assorted long-context checkpoints (32K) all advertise “Trained with Composer,” so weight provenance is clear.</li>
  <li><strong>License</strong> - Apache 2.0 across the board, which means redistribution, fine-tuning, and even hosted SaaS offerings are allowed with attribution.</li>
  <li><strong>Optimizations</strong> - FlashAttention 2, fused RMSNorm, rotary embeddings + ALiBi, activation checkpointing, and muParam scaling are first-class features in the training recipes.</li>
</ul>

<h3 id="why-it-matters-for-devops-and-platform-teams">Why it matters for DevOps and platform teams</h3>

<ol>
  <li><strong>Transparent supply chain</strong> - Composer ships the full registry of algorithms (progressive resizing, stochastic depth, etc.) in plain config files, so you can document exactly how the base model was trained.</li>
  <li><strong>Predictable scaling</strong> - Because the stack targets commodity PyTorch + NCCL, every knob you already tune for other GPU workloads (batch size, tensor parallelism, ZeRO stage) applies here as-is.</li>
  <li><strong>Native tool hooks</strong> - The repos include reference Helm charts, vLLM configs, and Terraform snippets for Databricks Model Serving, which means GitOps teams can slot Composer weights into their existing release trains.</li>
  <li><strong>Fine-grained guardrails</strong> - You keep the tokenizer, system prompts, reward models, and safety adapters, so policy tuning lives with your infra instead of an upstream vendor.</li>
</ol>

<h3 id="architecture-cheatsheet">Architecture cheatsheet</h3>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Tokenizer   : SentencePiece 32k vocab (shared by MPT + DBRX so fine-tunes stay compatible)
Backbone    : Decoder-only transformer, SwiGLU feed-forward, RMSNorm normalisation
Attention   : Multi-head attention with ALiBi for long context (up to 32,768 tokens in DBRX)
Training    : bfloat16 mixed precision, cosine LR decay, muParam scaling, EMA checkpoints
Inference   : vLLM / Text-Generation-Inference templates + KV cache partitioning for &gt;30 concurrent streams
</code></pre></div></div>

<p>Because the stack is openly documented, you can reproduce the base checkpoint or fork the training recipe (data mixtures, optimizer, evaluation harness) whenever compliance needs proof.</p>

<h3 id="getting-it-running-kubernetes-flavored">Getting it running (Kubernetes flavored)</h3>

<ol>
  <li><strong>Grab the weights</strong>
    <div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>huggingface-cli download mosaicml/mpt-7b-instruct <span class="nt">--local-dir</span> ./models/mpt-7b-instruct
</code></pre></div>    </div>
  </li>
  <li><strong>Package an image</strong> - Base off <code class="language-plaintext highlighter-rouge">nvcr.io/nvidia/pytorch:24.04-py3</code>, add <code class="language-plaintext highlighter-rouge">vllm==0.4.x</code>, copy your tokenizer + weights, and expose a <code class="language-plaintext highlighter-rouge">/generate</code> endpoint.</li>
  <li><strong>Deploy with autoscaling</strong> - Use KServe or OpenShift Serverless, wire GPU node selectors, and let KEDA watch Kafka or Redis queues for traffic bursts.</li>
  <li><strong>Observability</strong> - Attach OpenTelemetry spans around decode loops; Composer’s throughput varies a lot with top-k/top-p choices, so exporting tokens/sec and waiting-room time keeps SREs sane.</li>
</ol>

<h3 id="fine-tuning-playbook">Fine-tuning playbook</h3>

<table>
  <thead>
    <tr>
      <th>Scenario</th>
      <th>Strategy</th>
      <th>Notes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Domain adaptation (e.g., ITSM chat)</td>
      <td>3-5K curated dialogs, LoRA rank 16, LR 2e-4, train 3 epochs</td>
      <td>Fits on a single 24 GB GPU when you use 4-bit NF4 adapters</td>
    </tr>
    <tr>
      <td>Structured output (YAML/JSON)</td>
      <td>System prompt with JSON schema + Rejection Sampling via Composer’s Eval harness</td>
      <td>Keeps hallucinated keys to a minimum</td>
    </tr>
    <tr>
      <td>Tool-augmented agents</td>
      <td>ReAct exemplars + function-calling templates, rely on ALiBi long context</td>
      <td>Keep each tool schema under 1K tokens to avoid cache churn</td>
    </tr>
  </tbody>
</table>

<p>Because Composer exposes full training traces and data-mixture manifests, you can reproduce the base checkpoint if auditors ever demand it or swap out sensitive corpora before re-training.</p>

<h3 id="risk-checklist-before-shipping">Risk checklist before shipping</h3>

<ul>
  <li>Load-test 99th percentile latency with production prompt lengths; Composer is fast, but KV cache thrash happens when users paste entire runbooks.</li>
  <li>Run red-team prompts against your tuned model; the Apache 2.0 license lets you modify safety adapters, which means you must own the residual risk.</li>
  <li>Version both the weights and tokenizer in artifact storage; mismatched vocab files are the most common source of “gibberish output” incidents.</li>
  <li>Bake in drift detection (embedding cosine distance or perplexity over a canary dataset) so you notice when fine-tuning nudges the model off the rails.</li>
</ul>

<p>Bottom line: Composer LLM isn’t a single secret model–it’s a transparent recipe plus a family of open checkpoints that play nicely with modern DevOps pipelines. With a little YAML and observability discipline, you can run it alongside existing service meshes and treat generative AI like any other production workload.</p>]]></content><author><name>Kirill Kuklin</name></author><category term="ai" /><category term="llm" /><category term="mosaicml" /><category term="generative-ai" /><summary type="html"><![CDATA[Composer started life as MosaicML’s open-source training library, and it remains the backbone behind the company’s public LLM checkpoints such as MPT-7B/30B and the newer DBRX Instruct weights Databricks released under Apache 2.0. In most conversations people simply say “the Composer model” to describe that lineage of transparent, retrainable LLMs. If you want something you can fork, fine-tune, and redeploy inside a Kubernetes-backed platform without license headaches, Composer is the pragmatic compromise between rolling your own transformer and licensing a black-box API.]]></summary></entry></feed>