<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: skvrtyk</title>
    <description>The latest articles on DEV Community by skvrtyk (@dima_kruglov_608741d9a416).</description>
    <link>https://dev.to/dima_kruglov_608741d9a416</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4200170%2F2fa276f2-d4e3-4468-b4b1-b259b9482a84.png</url>
      <title>DEV Community: skvrtyk</title>
      <link>https://dev.to/dima_kruglov_608741d9a416</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dima_kruglov_608741d9a416"/>
    <language>en</language>
    <item>
      <title>Audio Deadlines: Running On-Device TTS Without Audible Glitches</title>
      <dc:creator>skvrtyk</dc:creator>
      <pubDate>Sat, 24 Oct 2026 00:01:22 +0000</pubDate>
      <link>https://dev.to/dima_kruglov_608741d9a416/audio-deadlines-running-on-device-tts-without-audible-glitches-kdi</link>
      <guid>https://dev.to/dima_kruglov_608741d9a416/audio-deadlines-running-on-device-tts-without-audible-glitches-kdi</guid>
      <description>&lt;p&gt;Gradium open-sourced Phonon, its on-device TTS engine (&lt;a href="https://gradium.ai/phonon" rel="noopener noreferrer"&gt;https://gradium.ai/phonon&lt;/a&gt;). That matters less because of the model than because of what it signals: on-device speech synthesis is now a dependency you can ship rather than a research demo. And a wave of teams is about to find out that the hard part of edge speech is not the model. It is that audio is the one component in your app with a hard real-time deadline, and nothing else in your background trained you for that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two clocks, and only one of them forgives you
&lt;/h2&gt;

&lt;p&gt;A web request can take 900 ms and nobody dies. An audio callback cannot take 21 ms when it had 20. Once playback starts, the DAC consumes samples at a fixed rate forever, and your synthesis loop has to stay ahead of it. Miss by enough and the buffer underruns: you get a click, a pop, or a robotic stutter that users describe as "the app is broken".&lt;/p&gt;

&lt;p&gt;Do the arithmetic once and keep it on the wall. At 24 kHz with a 20 ms mixer period, each callback needs 480 samples. Your synthesis loop is not measured in requests per second, it is measured in whether it produces 480 samples every 20 ms, including while the CPU is thermally throttled and the OS is doing a background sync.&lt;/p&gt;

&lt;p&gt;This changes what you optimize. P50 latency is nearly irrelevant. What matters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Worst-case chunk time&lt;/strong&gt;, because one slow chunk is an audible artifact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sustained real-time factor over minutes&lt;/strong&gt;, not seconds, because governors ramp down.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slack&lt;/strong&gt;, the gap between your worst chunk and the buffer you pre-rolled.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Real-time factor is a mean, and means lie
&lt;/h2&gt;

&lt;p&gt;RTF — synthesis wall time divided by audio duration produced — is the number everyone quotes. RTF 0.3 sounds great. It tells you the engine can run 3x faster than playback on a cold device with the screen on and nothing else running. That is not your user's device.&lt;/p&gt;

&lt;p&gt;I have watched an engine hold RTF 0.55 for 60 seconds and drift to 1.1 by minute four, because the SoC dropped from the big cluster to the little cluster and stayed there. RTF measured over 5 seconds would have shipped that bug.&lt;/p&gt;

&lt;p&gt;So measure the distribution and measure it long:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Deadline harness for an on-device TTS engine.

Reports whether the engine can keep the audio clock fed, not how fast it
is on average. Run it for 10+ minutes on a real device, not a laptop.
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;dataclasses&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dataclass&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;field&lt;/span&gt;

&lt;span class="n"&gt;SAMPLE_RATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;24_000&lt;/span&gt;                    &lt;span class="c1"&gt;# engine output rate
&lt;/span&gt;&lt;span class="n"&gt;BLOCK_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;                           &lt;span class="c1"&gt;# one mixer/callback period
&lt;/span&gt;&lt;span class="n"&gt;BLOCK_SAMPLES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;SAMPLE_RATE&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;BLOCK_MS&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;   &lt;span class="c1"&gt;# 480
&lt;/span&gt;
&lt;span class="nd"&gt;@dataclass&lt;/span&gt;
&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;chunk_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# wall time per chunk
&lt;/span&gt;    &lt;span class="n"&gt;audio_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;default_factory&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;   &lt;span class="c1"&gt;# audio produced per chunk
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;measure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;synth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;warmup&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="c1"&gt;# Warm up: page in weights, let the JIT/kernel selection settle.
&lt;/span&gt;    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="n"&gt;warmup&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="nf"&gt;synth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;run&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;texts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;t0&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;pcm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;synth&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;                                &lt;span class="c1"&gt;# mono float32 or int16
&lt;/span&gt;        &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chunk_ms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;perf_counter&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;t0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;audio_ms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pcm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;SAMPLE_RATE&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;run&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;report&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;Run&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prebuffer_ms&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;400&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;rtf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chunk_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nf"&gt;sum&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;audio_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;p99&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;statistics&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;quantiles&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chunk_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;98&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;worst&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;run&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chunk_ms&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# Conservative bound for a stop-and-wait loop: a chunk longer than the
&lt;/span&gt;    &lt;span class="c1"&gt;# pre-roll plus one block drains the buffer before the next chunk lands.
&lt;/span&gt;    &lt;span class="n"&gt;slack&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;prebuffer_ms&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;BLOCK_MS&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;worst&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RTF (mean)            &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;rtf&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;   # &amp;lt;1.0 is necessary, not sufficient&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chunk p99             &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p99&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;worst chunk           &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;worst&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;slack after prebuffer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;slack&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; ms  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;OK&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;slack&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;UNDERRUN&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;rtf&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p99&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;worst&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If your engine pipelines synthesis and playback, the slack bound is pessimistic — that is fine, it is a bound. What you must not do is report RTF alone and call it green.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prebuffer is your jitter absorber, and it costs you responsiveness
&lt;/h2&gt;

&lt;p&gt;You cannot fix jitter with a faster model alone; you fix it with buffer. Pre-roll 300–500 ms of audio before you start the output stream, and that headroom absorbs a late chunk without an underrun.&lt;/p&gt;

&lt;p&gt;The trade-off is real and it is user-facing. In a voice assistant, prebuffer is perceived as the delay between the user finishing a sentence and the assistant starting to speak. In a screen reader, it is a lag on every cursor move. There is no universal right answer, so pick per surface:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Conversational&lt;/strong&gt;: 150–250 ms prebuffer, tighter model, accept occasional degradation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Narration / media&lt;/strong&gt;: 400–800 ms, and nobody notices.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Accessibility&lt;/strong&gt;: prebuffer short utterances fully before starting; a 3-word phrase is cheaper to synthesize whole than to stream and stitch.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The seam problem
&lt;/h2&gt;

&lt;p&gt;Streaming TTS means chunking text and chunking audio, and both create seams. Sentence-level text chunking changes prosody across the join; frame-level vocoder chunking produces a discontinuity at the boundary unless you overlap and crossfade. A constant-power crossfade of 5–10 ms hides most of it. Under 3 ms you will hear a tick on plosives; over 20 ms you smear consonants.&lt;/p&gt;

&lt;p&gt;Two failure modes I have seen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Chunk boundary lands mid-word.&lt;/strong&gt; Text splitter respects sentence boundaries in English, then a user types "e.g." or a German compound and you cut inside a token. The engine pronounces half a word with full confidence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Crossfade applied to silence.&lt;/strong&gt; Overlap-add on a chunk that ends in a natural pause attenuates the pause and makes the voice sound rushed. Gate the crossfade on frame energy, or normalize at the frame level.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  Quantization changes prosody before it changes intelligibility
&lt;/h2&gt;

&lt;p&gt;Int8 quantization on the acoustic model and vocoder usually preserves intelligibility longer than it preserves naturalness. WER measured through an ASR round-trip will stay flat while the voice starts sounding flat and slightly buzzy — and users notice that immediately even though your metric does not.&lt;/p&gt;

&lt;p&gt;What I measure instead:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;F0 contour drift&lt;/strong&gt; against a float32 reference on a fixed 50-sentence set. Mean absolute deviation in cents over voiced frames.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Spectral distance&lt;/strong&gt; on sustained vowels, which is where buzz shows up.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A/B on device speakers&lt;/strong&gt;, because the phone's tiny driver hides artifacts that earbuds expose. Test both.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also: thread count is not free performance. Four threads finish a chunk faster but draw more power and heat the SoC, which is what caused the RTF drift in the first place. On a phone, 2 threads plus a larger prebuffer often beats 4 threads plus a small one over a 10-minute session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Platform details that bite
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Android&lt;/strong&gt;: &lt;code&gt;AudioTrack&lt;/code&gt; has a low-latency path (&lt;code&gt;PERFORMANCE_MODE_LOW_LATENCY&lt;/code&gt;) but it wants the device native sample rate. Synthesizing at 24 kHz and letting the framework resample to 48 kHz adds latency and a filter you did not choose. Match the device rate or resample yourself with a known-quality filter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;iOS&lt;/strong&gt;: &lt;code&gt;AVAudioSession&lt;/code&gt; category and mode decide whether you get the low-latency route. &lt;code&gt;playback&lt;/code&gt; with &lt;code&gt;modeDefault&lt;/code&gt; is not the same as &lt;code&gt;voicePrompt&lt;/code&gt;; test on hardware, not the simulator.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Embedded Linux&lt;/strong&gt;: ALSA period size is your callback period. Set &lt;code&gt;period_size&lt;/code&gt; explicitly and check &lt;code&gt;snd_pcm_sw_params&lt;/code&gt; for start thresholds; defaults vary by board and are frequently wrong.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Page faults&lt;/strong&gt;: memory-mapping multi-hundred-megabyte weights is correct, but the first utterance after a cold start can fault in enough pages to miss deadlines. Either warm the model at app start or &lt;code&gt;mlock&lt;/code&gt; the hot region.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Fallbacks that don't sound like fallbacks
&lt;/h2&gt;

&lt;p&gt;Decide your degradation ladder before launch, not during an incident:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Full-quality on-device voice.&lt;/li&gt;
&lt;li&gt;Lower sample rate (48k → 24k) — barely perceptible, halves vocoder cost.&lt;/li&gt;
&lt;li&gt;Shorter utterances with more prebuffer per chunk.&lt;/li&gt;
&lt;li&gt;Server TTS, only if the network is up and the user is not on a metered connection.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Never switch voices mid-sentence. A voice change at a sentence boundary is a quirk; a voice change mid-clause sounds like corruption.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Reporting mean RTF and calling the engine "faster than real time".&lt;/li&gt;
&lt;li&gt;Benchmarking on a laptop or an emulator. Both lie about thermals and audio routing.&lt;/li&gt;
&lt;li&gt;Testing with a single short sentence. Long utterances keep the CPU hot&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>devops</category>
      <category>python</category>
    </item>
    <item>
      <title>Your Model Is a Binary You Ship to Strangers: Release Engineering for Edge AI</title>
      <dc:creator>skvrtyk</dc:creator>
      <pubDate>Fri, 23 Oct 2026 22:00:57 +0000</pubDate>
      <link>https://dev.to/dima_kruglov_608741d9a416/your-model-is-a-binary-you-ship-to-strangers-release-engineering-for-edge-ai-1nn3</link>
      <guid>https://dev.to/dima_kruglov_608741d9a416/your-model-is-a-binary-you-ship-to-strangers-release-engineering-for-edge-ai-1nn3</guid>
      <description>&lt;p&gt;Gradium open-sourcing its on-device TTS (&lt;a href="https://gradium.ai/phonon" rel="noopener noreferrer"&gt;Phonon&lt;/a&gt;) is a useful reminder of what edge AI actually is from an operations standpoint. It isn't a model you deploy. It's a binary you ship to hardware you don't own, running an OS you don't patch, on a thermal budget you can't see. Your deploy button doesn't exist. There is no &lt;code&gt;kubectl rollout undo&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The engineers who struggle here are rarely struggling with inference. They're struggling with the fact that the artifact is the release, the rollout is a slow-motion migration across a heterogeneous fleet, and the failure signal arrives weeks later, aggregated, and only if the user opted in.&lt;/p&gt;

&lt;h2&gt;
  
  
  The release unit is the bundle, not the weights
&lt;/h2&gt;

&lt;p&gt;Ship the tokenizer with the weights or you will ship a bug. The classic edge regression is a model that produces subtly wrong output because the text normalization rules changed between the version that trained the tokenizer and the version that runs on device. Same weights, different preprocessing, different audio. Nothing crashes. Your evals, run against a Python pipeline, are green.&lt;/p&gt;

&lt;p&gt;So content-address everything and hash the manifest:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# bundle_manifest.py — build and verify one edge model release unit.
# Run in CI at packaging time and again on device at load time.
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rb&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;chunk&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sa"&gt;b&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chunk&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;h&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bundle_dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;runtime_min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Tokenizer, config, vocoder and weights all live in one directory.
&lt;/span&gt;    &lt;span class="n"&gt;files&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;sorted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bundle_dir&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;iterdir&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;is_file&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;model_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;model_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;# bump on any numeric change, incl. quant level
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;runtime_min&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;runtime_min&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;# e.g. "1.17.0" for an opset-19 ONNX graph
&lt;/span&gt;        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;files&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bundle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;files&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;sort_keys&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bundle_dir&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;files&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;bundle_dir&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: expected &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;, got &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;actual&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter more than they look. First, &lt;code&gt;model_id&lt;/code&gt; must change when the quantization level changes — int8 vs fp16 is a different model, not a build flag. Second, verification runs at load time, not just at download time, because partial writes and aggressive storage cleaners both exist.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compatibility is a matrix you own, not a promise you make
&lt;/h2&gt;

&lt;p&gt;"It ran on my Pixel" is not a compatibility statement. The things that actually break:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ONNX opset vs. onnxruntime version. Opset 19 needs ORT ≥ 1.17.&lt;/li&gt;
&lt;li&gt;Core ML &lt;code&gt;deploymentTarget&lt;/code&gt; vs. minimum iOS version you claim to support.&lt;/li&gt;
&lt;li&gt;NNAPI/QNN delegates: the same graph partitions differently per SoC, and a delegate that silently falls back to CPU can turn a 40 ms frame into a 400 ms frame.&lt;/li&gt;
&lt;li&gt;WebGPU vs. WASM fallback in browser targets, which is a 5–10x throughput difference in my experience, not a rounding error.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Declare a &lt;code&gt;runtime_min&lt;/code&gt; and refuse to load below it. A clean refusal you can measure beats a delegate fallback you can't see.&lt;/p&gt;

&lt;h2&gt;
  
  
  Stage by cohort, not by percentage
&lt;/h2&gt;

&lt;p&gt;Percentage rollouts work when every server is identical. Devices aren't. A 5% rollout that happens to land on one SoC family tells you nothing about the fleet, and a 5% rollout that lands on your most common device tells you everything too late.&lt;/p&gt;

&lt;p&gt;Cohort on the axes that predict failure: SoC family, OS major version, RAM class, free disk. Then gate promotion on guardrails that run automatically:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# rollout.py — decide whether a cohort advances. Runs on your backend,
# against telemetry the client sends back in batches.
&lt;/span&gt;&lt;span class="n"&gt;GUARDRAILS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;load_failure_rate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.005&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;    &lt;span class="c1"&gt;# fraction of loads that raise
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cold_start_p95_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;first_chunk_p95_ms&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c1"&gt;# time-to-first-audio, not RTF
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;should_promote&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;min_samples&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;tuple&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;samples&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;min_samples&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# Small samples produce confident nonsense. Hold.
&lt;/span&gt;        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;insufficient_samples&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ceiling&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;GUARDRAILS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;items&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;ceiling&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; &amp;gt; &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;ceiling&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;min_samples&lt;/code&gt; check is the one people skip. A new bundle on a rare SoC with 40 samples and zero failures is not evidence of anything.&lt;/p&gt;

&lt;h2&gt;
  
  
  The latency budget has three numbers
&lt;/h2&gt;

&lt;p&gt;Steady-state real-time factor is the number everyone reports and the least useful one. What users feel:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cold start.&lt;/strong&gt; Loading weights plus delegate compilation. On some Android devices this is over a second and it happens on first use, not app launch, unless you warm it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time to first chunk.&lt;/strong&gt; For streaming TTS this dominates perceived quality. A model with a better RTF and worse first-chunk latency feels worse.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sustained throughput.&lt;/strong&gt; Measure after 60–90 seconds of continuous synthesis. Thermal throttling is real on phones and invisible in a 5-second benchmark.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Log all three, tagged with the bundle hash and device class. If you can only keep one, keep time to first chunk.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fallbacks must ship, not be configured
&lt;/h2&gt;

&lt;p&gt;The fallback ladder for on-device TTS is roughly: large model → small model → CPU-only path → server round-trip. Every rung has to be in the binary, because the moment you need rung four, the network is probably bad and the user is already annoyed.&lt;/p&gt;

&lt;p&gt;Which means the CPU path needs CI coverage. Untested fallbacks rot. I've watched a "graceful degradation" path fail in production because nobody had run it since the delegate landed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollback is a disk-space decision
&lt;/h2&gt;

&lt;p&gt;You cannot fix a bad model with a download. If the bundle is broken, the download is broken too. What you need:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The N-1 bundle still on disk, with an atomic pointer switch.&lt;/li&gt;
&lt;li&gt;A signed remote config flag that disables the new bundle without fetching anything.&lt;/li&gt;
&lt;li&gt;A migration story for the on-disk format, because the old bundle has to remain loadable by the new app version.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If your rollback requires a 200 MB download over a metered connection, you don't have a rollback.&lt;/p&gt;

&lt;h2&gt;
  
  
  Telemetry you can actually get back
&lt;/h2&gt;

&lt;p&gt;You get counts and histograms, sampled, opt-in, batched. Not traces. Send: bundle hash, device class, load success/failure with an error code, cold start ms, first-chunk ms, and a coarse quality signal (user skipped the output, retried, or switched voices). Don't send the text. Don't send the audio.&lt;/p&gt;

&lt;p&gt;Set your batching interval by battery, not by convenience. A client that phones home every 30 seconds will get uninstalled, and rightly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Hashing only the weights, then shipping a tokenizer change under the same version.&lt;/li&gt;
&lt;li&gt;Testing on one device per OS, not one device per SoC family.&lt;/li&gt;
&lt;li&gt;Promoting on a metric with 40 samples.&lt;/li&gt;
&lt;li&gt;Benchmarking for 5 seconds and calling it throughput.&lt;/li&gt;
&lt;li&gt;Fallback paths with no CI coverage.&lt;/li&gt;
&lt;li&gt;Rollback that depends on the network.&lt;/li&gt;
&lt;li&gt;Treating quantization as a build flag rather than a model version.&lt;/li&gt;
&lt;li&gt;Assuming opt-in telemetry will reach you from the cohort that's failing — it often won't, because those users churn first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Monday morning checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Every release artifact is content-addressed and the manifest is hashed as a unit.&lt;/li&gt;
&lt;li&gt;[ ] &lt;code&gt;model_id&lt;/code&gt; changes on quantization, tokenizer, or preprocessing changes.&lt;/li&gt;
&lt;li&gt;[ ] The runtime declares a minimum version and refuses to load below it.&lt;/li&gt;
&lt;li&gt;[ ] Cohorts are defined by SoC, OS major, RAM class, and free disk.&lt;/li&gt;
&lt;li&gt;[ ] Promotion is automatic, gated on guardrails, with a &lt;code&gt;min_samples&lt;/code&gt; floor.&lt;/li&gt;
&lt;li&gt;[ ] You log cold start, time to first chunk, and 90-second sustained throughput.&lt;/li&gt;
&lt;li&gt;[ ] Every fallback rung has a CI test that exercises it.&lt;/li&gt;
&lt;li&gt;[ ] N-1 bundle stays on disk and can be selected without a download.&lt;/li&gt;
&lt;li&gt;[ ] A signed config flag can disable the new bundle remotely.&lt;/li&gt;
&lt;li&gt;[ ] Telemetry is sampled, batched, opt-in, and carries the bundle hash.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>devops</category>
      <category>python</category>
    </item>
    <item>
      <title>LLMOps Is Supply Chain Work: Pinning the Inputs You Don't Control</title>
      <dc:creator>skvrtyk</dc:creator>
      <pubDate>Fri, 23 Oct 2026 10:32:34 +0000</pubDate>
      <link>https://dev.to/dima_kruglov_608741d9a416/llmops-is-supply-chain-work-pinning-the-inputs-you-dont-control-10dj</link>
      <guid>https://dev.to/dima_kruglov_608741d9a416/llmops-is-supply-chain-work-pinning-the-inputs-you-dont-control-10dj</guid>
      <description>&lt;p&gt;&lt;em&gt;Suggested tags: ai, llm, devops, python&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Most LLMOps advice is about the model: pick a snapshot, set a temperature, watch the latency. That framing is comfortable because the model is the one thing you can't debug. The failures I've been pulled into were rarely the model. They were the corpus it read, the prompt template someone edited at 4pm on Friday, and the tool response nobody validated. Those are dependencies, and you are not pinning them.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three dependencies nobody pins
&lt;/h2&gt;

&lt;p&gt;Compare a normal service to an LLM feature:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;torch==2.4.1&lt;/code&gt; goes in a lockfile. Your retrieval corpus is a directory that gets rsynced by a cron job.&lt;/li&gt;
&lt;li&gt;A config change goes through review. A prompt template lives in a Google Doc or a database row with an &lt;code&gt;updated_at&lt;/code&gt; column and no diff.&lt;/li&gt;
&lt;li&gt;An upstream API contract is versioned. A model alias like &lt;code&gt;gpt-4o-latest&lt;/code&gt; can change behavior under you with no changelog.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are exotic. All of them are ordinary dependency management that we skipped because the artifacts are text and text feels free to change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Content is executable now
&lt;/h2&gt;

&lt;p&gt;Two patterns from the last year worth internalizing, because they generalize:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Instructions hidden in help pages.&lt;/strong&gt; Pages that look like support documentation but contain directives aimed at whatever model reads them. If your retrieval layer indexes third-party or user-editable content, a retrieved document is a user message with extra steps. It arrives inside your system prompt's trust boundary and it is written by someone who is not you.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Published statistics engineered to be cited.&lt;/strong&gt; Publish a plausible-looking number with a memorable framing, and eventually it shows up in generated answers and gets laundered into "facts." This is SEO with a longer feedback loop, and it targets your corpus whether you crawl, license, or accept user uploads.&lt;/p&gt;

&lt;p&gt;Same failure mode both times: &lt;strong&gt;the retrieval corpus is writable by parties outside your trust boundary.&lt;/strong&gt; Prompt injection gets discussed as a prompt problem. It's an ingestion problem. Filtering the output is a losing game; controlling what enters the index is tractable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Gate at ingest, not at prompt
&lt;/h2&gt;

&lt;p&gt;The cheapest control I know is an append-only manifest — a lockfile for content.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# ingest_gate.py — run before any document enters the retrieval index.
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;

&lt;span class="c1"&gt;# Directives that only make sense if a model, not a human, is reading.
&lt;/span&gt;&lt;span class="n"&gt;INSTRUCTION_PATTERNS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;(ignore (all )?previous|you are now|do not recommend|system prompt|&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;when asked about .{0,40}(say|recommend))&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;IGNORECASE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hashlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;hexdigest&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Trust tier decides whether the doc is served, quoted, or quarantined.
&lt;/span&gt;    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;quarantine&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;INSTRUCTION_PATTERNS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;serve&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;ingest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_text&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;record&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;doc_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;stem&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha256&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;sha256&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tier&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;classify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bytes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;source&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;parent&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;  &lt;span class="c1"&gt;# first-party vs. vendor vs. user upload
&lt;/span&gt;    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;# Append-only: this file is the lockfile for your corpus.
&lt;/span&gt;    &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;a&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;encoding&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;utf-8&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;fh&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;write&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;

&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;__name__&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;__main__&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;ingest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;help_page.md&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;pathlib&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;corpus.lock&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The regex is not the point — it will miss things, and a determined author will paraphrase. The manifest is the point. Once &lt;code&gt;corpus.lock&lt;/code&gt; is in git, a pull request that adds forty quarantine-tier documents from a new domain is visible to a reviewer. A corpus that changes silently is not.&lt;/p&gt;

&lt;p&gt;Pair it with a per-source trust tier that the retrieval layer enforces: first-party docs can be quoted directly, vendor docs must be attributed inline, user uploads never get inserted into the system prompt at all — only into a delimited user turn.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evals are the lockfile for behavior
&lt;/h2&gt;

&lt;p&gt;A prompt hash, an eval run id, and a deploy id should be joinable three months later. If you can't answer "which prompt and which corpus produced this answer," you have observability for the wrong system.&lt;/p&gt;

&lt;p&gt;Practical constraints I'd argue for:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Gate on delta, not absolute score.&lt;/strong&gt; A prompt edit that drops your golden set by two points is noise. One that changes the &lt;em&gt;set of failing cases&lt;/em&gt; is signal, even if the total is flat.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sample size, honestly.&lt;/strong&gt; With a 12-case eval, a single flip is an 8-point swing. I'd rather have 150 labeled cases and a fixed seed than 2,000 unlabeled ones and a judge model.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The judge is not ground truth.&lt;/strong&gt; LLM-as-judge is useful for triage at scale. For anything a customer will notice, hand-label a stratified sample and report agreement between judge and human.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Adversarial cases belong in the golden set.&lt;/strong&gt; Include the poisoned documents you've actually seen. Evals that only contain well-formed questions measure the model, not the system.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Traces matter more as reasoning goes latent
&lt;/h2&gt;

&lt;p&gt;There's a visible trend toward agents that reason in a continuous latent space and only emit text at action boundaries. That's a real efficiency win and a real observability loss: the intermediate tokens you used to read are gone. The final answer tells you less than it used to.&lt;/p&gt;

&lt;p&gt;So log the things that survive: the resolved prompt hash, the retrieved document ids and their trust tiers, every tool call with arguments and status, token counts per span, and the model snapshot string the provider actually returned. OpenTelemetry's GenAI semantic conventions give you attribute names that Langfuse, Phoenix, and most vendors will ingest without a custom adapter. Instrument once at the client wrapper, not at every call site.&lt;/p&gt;

&lt;h2&gt;
  
  
  Rollout mechanics that contain damage
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shadow traffic first.&lt;/strong&gt; Run the new prompt or model on live requests, log only. Compare against production output offline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Canary by tenant or route,&lt;/strong&gt; not globally. One enterprise customer with a narrow use case is a better canary than 5% of anonymous traffic.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Per-task budgets.&lt;/strong&gt; Token and tool-call ceilings with a hard stop, not a warning. Unbounded retry loops are the most expensive bug class in this space.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invalidate response caches on corpus change.&lt;/strong&gt; A cached answer produced from a document you've since quarantined is a live injection vector.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A kill switch that degrades to retrieval-without-generation.&lt;/strong&gt; If the model provider is down, a list of links beats a 500.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Versioning the prompt but not the retrieval config.&lt;/strong&gt; Chunk size, top-k, and reranker settings change behavior as much as the prompt does.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Trusting a model alias.&lt;/strong&gt; Pin a dated snapshot. If the provider retires it, you want that to be an incident, not a surprise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treating eval scores as portable.&lt;/strong&gt; A score from last month's corpus says nothing about this month's.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Blocking on content moderation at output.&lt;/strong&gt; It catches tone, not exfiltration or instruction-following.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unbounded tool output into context.&lt;/strong&gt; A 200KB API response will crowd out your instructions and look like a model regression.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Monday checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] Corpus is written to an append-only manifest with &lt;code&gt;sha256&lt;/code&gt;, source, and trust tier; the manifest is in git.&lt;/li&gt;
&lt;li&gt;[ ] Retrieval layer enforces trust tiers — user uploads never enter the system prompt.&lt;/li&gt;
&lt;li&gt;[ ] The model is a dated snapshot string, pinned in config, with an owner for retirement.&lt;/li&gt;
&lt;li&gt;[ ] Prompt template, retrieval config, and corpus manifest are hashed into one deploy id.&lt;/li&gt;
&lt;li&gt;[ ] Golden set has ≥100 cases including every adversarial document seen in the last quarter.&lt;/li&gt;
&lt;li&gt;[ ] Eval gate compares failing-case sets, not just aggregate score.&lt;/li&gt;
&lt;li&gt;[ ] Traces record retrieved doc ids, tool calls with arguments, and token counts per span.&lt;/li&gt;
&lt;li&gt;[ ] Per-task token and tool-call ceilings with hard stops.&lt;/li&gt;
&lt;li&gt;[ ] Response cache is keyed on corpus version and invalidated on change.&lt;/li&gt;
&lt;li&gt;[ ] Shadow and canary paths exist, and someone has flipped the kill switch in staging this quarter.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>devops</category>
      <category>python</category>
    </item>
    <item>
      <title>AI Agents in Production: Identity, Budgets, and Blast Radius</title>
      <dc:creator>skvrtyk</dc:creator>
      <pubDate>Thu, 22 Oct 2026 10:04:07 +0000</pubDate>
      <link>https://dev.to/dima_kruglov_608741d9a416/ai-agents-in-production-identity-budgets-and-blast-radius-58hh</link>
      <guid>https://dev.to/dima_kruglov_608741d9a416/ai-agents-in-production-identity-budgets-and-blast-radius-58hh</guid>
      <description>&lt;p&gt;A demo agent has one user, one task, and a forgiving audience. A production agent has a queue, a bill, a security boundary, and a hostile network. The three signals I keep running into — sites actively blocking agents, research pipelines fanning out thousands of agents at once, and new filesystems built specifically for coding agents — are all symptoms of the same shift: agents are now network citizens with state, cost, and blast radius.&lt;/p&gt;

&lt;p&gt;Here is the production view I wish I'd had before shipping my first one.&lt;/p&gt;

&lt;h2&gt;
  
  
  The web is now an adversarial environment
&lt;/h2&gt;

&lt;p&gt;The Verge's piece on sites closing their doors to agents is not a policy story, it is an infrastructure story. Cloudflare bot management, &lt;code&gt;robots.txt&lt;/code&gt; enforcement, pay-per-crawl, and login walls are all live. Your agent will get &lt;code&gt;403&lt;/code&gt;, &lt;code&gt;429&lt;/code&gt;, and a CAPTCHA where the demo got HTML.&lt;/p&gt;

&lt;p&gt;Design for it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Prefer documented APIs with keys over scraping. If there is no API, ask whether the task is worth a terms-of-service fight.&lt;/li&gt;
&lt;li&gt;Handle &lt;code&gt;403&lt;/code&gt; and &lt;code&gt;429&lt;/code&gt; differently. &lt;code&gt;403&lt;/code&gt; is a policy stop; retrying makes you a bad actor. &lt;code&gt;429&lt;/code&gt; is backoff with jitter, and &lt;code&gt;Retry-After&lt;/code&gt; is a real header.&lt;/li&gt;
&lt;li&gt;Put a per-domain circuit breaker in front of &lt;code&gt;fetch&lt;/code&gt;. One broken site should not stall a 500-item batch.&lt;/li&gt;
&lt;li&gt;Cache aggressively and key the cache on the URL plus a content hash. Re-fetching the same page per agent is how you get blocked.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you build on other people's content, assume the rules can change weekly. A tool that returns "blocked" is a valid result your loop must handle, not an exception to swallow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fan-out is a queueing problem, not a prompting problem
&lt;/h2&gt;

&lt;p&gt;OpenAI's biological-data work with thousands of agents is impressive, but the engineering is unglamorous: a work queue, idempotency, deduplication, and a budget per unit of work. The same applies if you fan out 50 agents over your own backlog.&lt;/p&gt;

&lt;p&gt;What I've seen break:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Duplicate work.&lt;/strong&gt; Two workers claim the same item and both write the same row. Use an idempotency key derived from the input, not from a random UUID generated inside the agent.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry storms.&lt;/strong&gt; A non-idempotent side effect (sending email, creating a ticket) retried after a timeout. Make side effects idempotent at the tool boundary, or make the tool take a dedup key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Unbounded spawning.&lt;/strong&gt; An agent that can call a "spawn sub-agent" tool will do it. Cap depth and total descendants per root task.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No backpressure.&lt;/strong&gt; A queue with no concurrency limit turns a provider rate limit into a wall of &lt;code&gt;429&lt;/code&gt;s. Set &lt;code&gt;max_workers&lt;/code&gt; and let the queue grow.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Temporal, Celery, and SQS all work here. The choice matters less than the invariant: every unit of work can be retried safely and has a hard cost ceiling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give agents a workspace, not a conversation
&lt;/h2&gt;

&lt;p&gt;Coding agents are the clearest case. Context windows are a bad database. A filesystem gives you durable state, diffable artifacts, and tools the agent already understands: &lt;code&gt;rg&lt;/code&gt;, &lt;code&gt;git&lt;/code&gt;, &lt;code&gt;pytest&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Projects like LayerFS are interesting because they treat the agent's workspace as a first-class primitive rather than a side effect of a chat transcript. You can get most of the benefit without a new product:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;One scratch directory per run, outside the repo.&lt;/li&gt;
&lt;li&gt;One &lt;code&gt;git worktree&lt;/code&gt; per parallel agent so two agents never fight over the same files.&lt;/li&gt;
&lt;li&gt;Artifacts (logs, diffs, generated files) written to disk and referenced by path in the transcript, not pasted into the prompt.&lt;/li&gt;
&lt;li&gt;A final &lt;code&gt;git diff&lt;/code&gt; as the review surface. If the diff is empty, the agent did nothing, regardless of what it said.
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# One isolated worktree per agent, then throw it away. Cheaper than merge conflicts.&lt;/span&gt;
git worktree add /tmp/agent-&lt;span class="nv"&gt;$RUN_ID&lt;/span&gt; &lt;span class="nt"&gt;-b&lt;/span&gt; agent/&lt;span class="nv"&gt;$RUN_ID&lt;/span&gt; origin/main
&lt;span class="nb"&gt;cd&lt;/span&gt; /tmp/agent-&lt;span class="nv"&gt;$RUN_ID&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; pytest &lt;span class="nt"&gt;-q&lt;/span&gt;
&lt;span class="c"&gt;# review: git -C /tmp/agent-$RUN_ID diff origin/main&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  A bounded loop you can actually ship
&lt;/h2&gt;

&lt;p&gt;The single most useful thing I've added to agent code is a hard stop: step limit, token budget, and per-tool error handling that feeds failures back into the loop instead of crashing it. This is complete enough to run, with your own tools swapped in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# agent_runner.py — bounded agent loop with a token budget and error feedback.
# pip install openai; export OPENAI_API_KEY=...
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;openai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;OpenAI&lt;/span&gt;

&lt;span class="n"&gt;client&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;OpenAI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;MAX_STEPS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;
&lt;span class="n"&gt;TOKEN_BUDGET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;40_000&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="c1"&gt;# Replace with real tools. Keep them idempotent where side effects exist.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;lookup_order&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;args&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;order_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;shipped&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ValueError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown tool: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt; &lt;span class="o"&gt;|&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;run_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;uuid4&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;  &lt;span class="c1"&gt;# caller supplies this for idempotency
&lt;/span&gt;    &lt;span class="n"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;task&lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;
    &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;step&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;MAX_STEPS&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="n"&gt;resp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;chat&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;completions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gpt-4o-mini&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;tool_choice&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usage&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_tokens&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;TOKEN_BUDGET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: budget exceeded at step &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;step&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; (&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;resp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;
        &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;exclude_none&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;run_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;output&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tokens&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;used&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tool_calls&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;run_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;function&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;arguments&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
                &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;result&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;  &lt;span class="c1"&gt;# errors are data, not crashes
&lt;/span&gt;                &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ok&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;error&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;str&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;
            &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tool_call_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nb"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;: no final answer after &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;MAX_STEPS&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; steps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details matter more than they look. The &lt;code&gt;run_id&lt;/code&gt; comes from the caller, so a retried job reuses the same key. Tool errors are returned to the model as JSON, which is usually enough to recover; a thrown exception just kills a run that was one step from finishing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observe the trajectory, not the answer
&lt;/h2&gt;

&lt;p&gt;Logging the final string tells you nothing when quality drops. Emit one structured event per step: model name, prompt template version, step index, tool name, arguments hash, latency, token count, and outcome. OpenTelemetry spans work; Langfuse, LangSmith, and Arize Phoenix are built for this shape of data.&lt;/p&gt;

&lt;p&gt;What I actually query:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Distribution of step counts. A spike means the prompt or a tool regressed.&lt;/li&gt;
&lt;li&gt;Tool error rate by tool name. A schema change upstream shows up here first.&lt;/li&gt;
&lt;li&gt;Token spend per successful task, broken down by tenant.&lt;/li&gt;
&lt;li&gt;Runs that hit the step limit. These are silent failures, not successes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Pitfalls
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Indirect prompt injection.&lt;/strong&gt; Any fetched page, issue, or email can contain instructions. Treat tool output as untrusted data. Keep secrets out of the context window and require explicit approval for destructive tools.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tool schema drift.&lt;/strong&gt; The model's tool definition and the real API diverge; the model keeps calling a field that no longer exists. Version your tool schemas and test them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silent partial success.&lt;/strong&gt; The agent summarizes three of five documents and calls it done. Verify with a deterministic check (row count, diff, schema validation), not with the model's own claim.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Context rot.&lt;/strong&gt; Long transcripts degrade instruction following. Compress or checkpoint at a fixed token boundary rather than waiting for the window to fill.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Secrets in logs.&lt;/strong&gt; Traces capture arguments. Redact before the span leaves the process.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No kill switch.&lt;/strong&gt; You need a way to stop all runs for one tenant or one tool without a deploy.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Monday checklist
&lt;/h2&gt;

&lt;p&gt;Ship only when every line is true.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[x] Every tool with a side effect takes an idempotency key, and retries reuse it.&lt;/li&gt;
&lt;li&gt;[x] Every run has a hard step limit and token budget, enforced in code.&lt;/li&gt;
&lt;li&gt;[x] &lt;code&gt;403&lt;/code&gt;, &lt;code&gt;429&lt;/code&gt;, timeouts, and empty pages each have a distinct handler, not one generic retry.&lt;/li&gt;
&lt;li&gt;[x] Sub-agent spawning is capped by depth and total descendants.&lt;/li&gt;
&lt;li&gt;[x] Parallel agents get isolated workspaces (&lt;code&gt;git worktree&lt;/code&gt; or equivalent), not a shared directory.&lt;/li&gt;
&lt;li&gt;[x] Traces log step index, tool name, latency, tokens, and outcome, with arguments redacted.&lt;/li&gt;
&lt;li&gt;[x] Success is verified by a deterministic check, not by the agent's summary.&lt;/li&gt;
&lt;li&gt;[x] Untrusted content is labelled as data in the prompt and cannot trigger destructive tools.&lt;/li&gt;
&lt;li&gt;[x] There is a per-tenant kill switch that does not require a deploy.&lt;/li&gt;
&lt;li&gt;[x] You have looked at the step-count and tool-error dashboards in the last seven days.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>llm</category>
      <category>devops</category>
      <category>python</category>
    </item>
  </channel>
</rss>
