<?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: Jangwook Kim</title>
    <description>The latest articles on DEV Community by Jangwook Kim (@jangwook_kim_e31e7291ad98).</description>
    <link>https://dev.to/jangwook_kim_e31e7291ad98</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%2F1909290%2F60a8c15f-b2b5-4189-8578-78b8ab78900b.jpg</url>
      <title>DEV Community: Jangwook Kim</title>
      <link>https://dev.to/jangwook_kim_e31e7291ad98</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/jangwook_kim_e31e7291ad98"/>
    <language>en</language>
    <item>
      <title>Google A2A 1.0 in Production: Our Multi-Vendor Interoperability and Compatibility Audit</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Thu, 24 Sep 2026 00:48:04 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/google-a2a-10-in-production-our-multi-vendor-interoperability-and-compatibility-audit-40bh</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/google-a2a-10-in-production-our-multi-vendor-interoperability-and-compatibility-audit-40bh</guid>
      <description>&lt;h2&gt;
  
  
  Why We Brought This Tool Into Our Lab
&lt;/h2&gt;

&lt;p&gt;We tested Google's Agent-to-Agent protocol, or A2A, to evaluate communication between independently built AI agents rather than produce another demo. We brought it in to evaluate whether a shared protocol could reduce custom translation code when agents use different request and response formats.&lt;/p&gt;

&lt;p&gt;A2A 1.0 needs a compatibility layer&lt;/p&gt;
&lt;p&gt;A2A 1.0 handled discovery, task lifecycle, authentication failures, and artifacts between local agents, but pre-1.0 agent cards and methods did not work directly, so a versioned translation proxy remained necessary for backward compatibility.&lt;/p&gt;

&lt;p&gt;One agent accepts web requests using Hypertext Transfer Protocol, or HTTP. Those requests use custom data fields encoded in JavaScript Object Notation, or JSON, a format for representing data as text. Another expects a continuous stream of updates. A third returns a job identifier that the client must use to check repeatedly for results. Authentication, cancellation, progress updates, and generated files all use different conventions. The first integration looks manageable; by the fifth, the team has substantial integration code to maintain.&lt;/p&gt;

&lt;p&gt;A2A addresses that boundary. It gives agents a shared way to find each other, exchange messages, track work, send updates, cancel tasks, and deliver artifacts such as generated files. It does not standardize an agent's internal model, prompt framework, memory system, or tool implementation.&lt;/p&gt;

&lt;p&gt;That distinction mattered in our evaluation. We tested whether A2A let agents exchange work reliably, not whether it chose agents or coordinated their work.&lt;/p&gt;

&lt;p&gt;We anchored our test plan to three primary references: the &lt;a href="https://developers.googleblog.com/en/a2a-a-new-era-of-agent-interoperability/" rel="noopener noreferrer"&gt;original A2A interoperability announcement&lt;/a&gt;, the &lt;a href="https://discuss.google.dev/t/the-a2a-1-0-milestone-ensuring-and-testing-backward-compatibility/352258/1" rel="noopener noreferrer"&gt;A2A 1.0 backward-compatibility discussion&lt;/a&gt;, and &lt;a href="https://blog.n8n.io/agent-to-agent-protocol/" rel="noopener noreferrer"&gt;n8n's A2A implementation analysis&lt;/a&gt;. We then turned the protocol concepts into tests we could run. Successful discovery meant finding an agent and reading its connection details and capabilities, not proving that it could work with another agent.&lt;/p&gt;

&lt;p&gt;Our local test setup contained two Python agents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Coordinator agent:&lt;/strong&gt; accepted a research request and delegated document rendering.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Renderer agent:&lt;/strong&gt; accepted structured data and returned a Markdown artifact.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compatibility proxy:&lt;/strong&gt; a service that converted selected pre-1.0 agent descriptions and messages into formats the new client accepted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mock identity service:&lt;/strong&gt; issued fixed bearer tokens, which grant access to whoever presents them, for tests with valid and invalid credentials.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test client:&lt;/strong&gt; recorded discovery responses, task states, artifacts, HTTP status codes, and protocol errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Agents published an agent card, a document describing how to contact them and which requests they accept. They exchanged structured requests and exposed task records with explicit status information. We inspected requests and responses with the command-line tool &lt;code&gt;curl&lt;/code&gt;, the Python HTTP client &lt;code&gt;httpx&lt;/code&gt;, and a reverse proxy that forwarded traffic to the agents. We could understand these exchanges without adding monitoring code inside a vendor's agent software.&lt;/p&gt;

&lt;p&gt;We also confirmed what A2A did not handle. It did not choose an agent for a task or guarantee that two agents understood a skill the same way. It did not automatically exchange OAuth credentials, which let applications access services with permission, or accept arbitrary older request formats. We still had to decide where requests went, which callers we trusted, and which data formats we accepted. We also needed compatibility tests.&lt;/p&gt;

&lt;p&gt;A2A connects agents to each other, while the Model Context Protocol, or MCP, connects an AI application to tools and sources of context. We use MCP primarily between an AI application and tools or context providers. We use A2A between autonomous or semi-autonomous services that expose agent-level capabilities. Combining them is reasonable: an A2A agent can invoke MCP tools internally. Replacing one with the other usually produces an awkward abstraction.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hands-On Walkthrough: Setup, Execution &amp;amp; Output
&lt;/h2&gt;

&lt;p&gt;We ran the audit in a separate Python environment and limited the software development kit, or SDK, to one major version. The kit supplies code for building software that uses the protocol. We also cloned the sample repository so that our first control run used the published sample structure before we introduced our own coordinator and renderer logic.&lt;/p&gt;

&lt;p&gt;Our setup sequence was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--depth&lt;/span&gt; 1 https://github.com/a2aproject/a2a-samples.git
python3 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;.&lt;/span&gt; .venv/bin/activate

python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--upgrade&lt;/span&gt; pip
python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"a2a-sdk&amp;gt;=1.0,&amp;lt;2"&lt;/span&gt; httpx uvicorn pytest pytest-asyncio

&lt;span class="c"&gt;# We recorded the resolved versions in the audit artifact.&lt;/span&gt;
python &lt;span class="nt"&gt;-m&lt;/span&gt; pip freeze &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; a2a-lab.lock

&lt;span class="c"&gt;# We first ran the repository's Python sample following its checked-in README.&lt;/span&gt;
&lt;span class="c"&gt;# We then started our two-agent fixtures on separate loopback ports.&lt;/span&gt;
python lab/renderer_agent.py &lt;span class="nt"&gt;--host&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;--port&lt;/span&gt; 8101 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--token&lt;/span&gt; renderer-lab-token &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; logs/renderer.log 2&amp;gt;&amp;amp;1 &amp;amp;

python lab/coordinator_agent.py &lt;span class="nt"&gt;--host&lt;/span&gt; 127.0.0.1 &lt;span class="nt"&gt;--port&lt;/span&gt; 8102 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--renderer-url&lt;/span&gt; http://127.0.0.1:8101 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--renderer-token&lt;/span&gt; renderer-lab-token &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; logs/coordinator.log 2&amp;gt;&amp;amp;1 &amp;amp;

python &lt;span class="nt"&gt;-m&lt;/span&gt; pytest &lt;span class="nt"&gt;-q&lt;/span&gt; tests/test_discovery.py &lt;span class="se"&gt;\&lt;/span&gt;
  tests/test_task_lifecycle.py &lt;span class="se"&gt;\&lt;/span&gt;
  tests/test_artifacts.py &lt;span class="se"&gt;\&lt;/span&gt;
  tests/test_auth.py &lt;span class="se"&gt;\&lt;/span&gt;
  tests/test_legacy_cards.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We kept the agents deliberately simple. The coordinator received a text request, converted it into a structured rendering request, and called the renderer through its advertised A2A interface. The renderer emitted a Markdown artifact instead of returning the content only as conversational text.&lt;/p&gt;

&lt;p&gt;Our test sequence covered five parts of agent communication:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Discover the renderer's agent card.&lt;/li&gt;
&lt;li&gt;Select a communication method the agent supports rather than assuming one in advance.&lt;/li&gt;
&lt;li&gt;Submit a message and follow its task state.&lt;/li&gt;
&lt;li&gt;Retrieve and validate the resulting artifact.&lt;/li&gt;
&lt;li&gt;Repeat the exchange with missing, invalid, and valid credentials.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We separately supplied a fixture, a pre-1.0 agent card used as controlled test data. It listed one service address and did not specify how the client and agent should agree on a protocol version. It also used the older operation names and task fields our legacy client expected. We tested the card directly with the 1.0 client, then repeated the test through our compatibility proxy.&lt;/p&gt;

&lt;p&gt;The shortened simulated transcript below comes from our fixture mode, which produces the same output for the same test inputs. We used this output to avoid presenting timings from agents running on the same machine as evidence of production performance.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$ python lab/run_matrix.py --emit-fixture

[discovery] GET http://127.0.0.1:8101/.well-known/agent-card.json
[discovery] HTTP 200
[discovery] name="Effloow Renderer" protocolVersion="1.0"
[discovery] binding="JSONRPC" auth="Bearer"

[auth-negative] send without Authorization header
[auth-negative] HTTP 401 WWW-Authenticate="Bearer"

[auth-negative] send with invalid token
[auth-negative] HTTP 403 error="invalid_token"

[auth-positive] send with renderer-lab-token
[task] id="task-7f31" state="submitted"
[task] id="task-7f31" state="working"
[artifact] name="audit-summary.md" mediaType="text/markdown"
[task] id="task-7f31" state="completed"

[artifact-check] sha256 fixture match: PASS
[context-check] request context preserved: PASS
[terminal-state-check] completed task immutable: PASS

[legacy-direct] old discovery path: HTTP 404
[legacy-direct] top-level url without negotiated interface: REJECTED
[legacy-direct] legacy task method: METHOD_NOT_FOUND
[legacy-direct] sessionId field: SCHEMA_ERROR

[legacy-via-proxy] discovery path fallback: PASS
[legacy-via-proxy] interface normalization: PASS
[legacy-via-proxy] task method translation: PASS
[legacy-via-proxy] sessionId to contextId mapping: PASS

13 passed, 0 failed
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The authentication result deserves emphasis. Advertising a bearer or OAuth-compatible security scheme in an agent card did not authenticate anything by itself. We still had to configure who issued tokens, which service could accept them, and when they expired. We also needed request checks that limited each caller's access to tasks.&lt;/p&gt;

&lt;p&gt;For the local test, a fixed bearer token was sufficient because we were testing connection setup and failures, not the service that verifies identities. In production, we would use short-lived credentials and check who issued them and which service they were intended for. We would also tie task retrieval and cancellation to a verified caller.&lt;/p&gt;

&lt;p&gt;The artifact test also caught more than a normal chat response test. We checked the file type, filename, and associated task. We also compared a checksum—a value calculated from the file's contents; and checked behavior after the task finished. That gave us a concrete contract for generated files rather than relying on text hidden in a message body.&lt;/p&gt;

&lt;p&gt;Teams building similar automated tests can use our broader &lt;a href="https://dev.to/tools"&gt;AI tools collection&lt;/a&gt; to compare related infrastructure tools. For architecture or migration support, see &lt;a href="https://dev.to/services"&gt;effloow services&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Compatibility Failures and Implementation Limits
&lt;/h2&gt;

&lt;p&gt;Our 1.0 exchange worked under the expected successful conditions. Compatibility with pre-1.0 assumptions did not work automatically.&lt;/p&gt;

&lt;p&gt;The first breakage occurred during discovery. Our legacy fixture requested its known discovery location and expected one top-level service URL. The 1.0 endpoint returned no card at that location, and the newer card required the client to select an interface. The client never reached message submission.&lt;/p&gt;

&lt;p&gt;We handled this in the compatibility proxy. It answered requests at both discovery addresses and converted the older service address into the interface format the new client expected. It also kept the original card for troubleshooting. We left both agents' application code unchanged.&lt;/p&gt;

&lt;p&gt;The second failure involved method dispatch: how the server selects the operation that handles a request. The legacy client invoked its historical task-oriented method, while our 1.0 endpoint accepted the current message-oriented operation. The server correctly returned a method error. Retrying the identical body under a different method name was not enough because the nested message and task fields also differed.&lt;/p&gt;

&lt;p&gt;Our workaround translated the complete request and response pair:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;older operation names to their 1.0 equivalents;&lt;/li&gt;
&lt;li&gt;older session identifiers to the identifiers now used to group related exchanges;&lt;/li&gt;
&lt;li&gt;older message components to the structure the new service accepted;&lt;/li&gt;
&lt;li&gt;current task states to the status format the older client expected;&lt;/li&gt;
&lt;li&gt;details about generated files to the older response format wherever it could preserve them.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By default, we rejected translations that would discard information. If a 1.0 response contained multiple artifacts or a capability the old client could not represent, our proxy returned an explicit compatibility error instead of silently dropping data. That policy made compatibility failures explicit instead of reporting success when data was missing.&lt;/p&gt;

&lt;p&gt;The third issue was advertised capabilities that did not work in every case. Listing streaming, authentication, or a skill in an agent card did not prove that the agent consistently supported it. After discovery, we added conformance checks: requests that tested whether the agent actually supported its advertised capabilities. Our client used the card to choose what to test, not as proof of compatibility.&lt;/p&gt;

&lt;p&gt;The fourth issue was the order of task updates. In an early version, our renderer reported &lt;code&gt;completed&lt;/code&gt; before it saved the generated file with the task. A fast client could see a terminal state, meaning the task had ended, yet receive no generated files. We changed the renderer to save files reliably before marking the task as finished.&lt;/p&gt;

&lt;p&gt;We also prevented finished tasks from changing state. Otherwise, a worker retrying an operation could mark a completed task as working again. A state machine controls which task states can follow others, and using the protocol's labels did not make ours correct.&lt;/p&gt;

&lt;p&gt;Cancellation exposed another ambiguity. We could request cancellation, but we could not assume that an underlying model call or external tool stopped instantly. We therefore distinguished between receiving a cancellation request and confirming a canceled terminal state. Our worker checked for cancellation between processing steps and stopped publishing files once it had marked the task as canceled.&lt;/p&gt;

&lt;p&gt;Authentication produced predictable but operationally important failures. A card describing a security scheme did not tell our client where its credentials should come from in every deployment. We had to configure that relationship outside the task message. We also had to ensure that logs redacted authorization headers and that redirected requests did not forward tokens to an untrusted host.&lt;/p&gt;

&lt;p&gt;Finally, matching version labels did not establish compatibility. Two services can both mention A2A 1.0 yet differ in communication methods, authentication, file formats, streamed updates, or optional features. We now save a record of supported capabilities for each agent and card version, then stop using that record when the card changes.&lt;/p&gt;

&lt;p&gt;Our rule: require data to match the expected format inside the core service. Handle known older formats only in a versioned adapter that translates them. Save data from failed exchanges as reusable compatibility test inputs, with sensitive information removed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale, Latency &amp;amp; Cost vs. Alternatives
&lt;/h2&gt;

&lt;p&gt;We did not publish response-time or processing-rate measurements from this audit. Both agents ran on the same machine and performed fixed work, so those measurements would describe our test setup more than A2A in production. Our three sources also did not measure performance under comparable conditions that would support comparisons across products.&lt;/p&gt;

&lt;p&gt;We instead evaluated the network path structurally.&lt;/p&gt;

&lt;p&gt;A synchronous A2A request keeps the caller waiting while the agent works, adding request handling, data checks, authentication, and conversion into a format the receiver understands. Streaming sends updates over an open connection and must handle receivers that cannot keep up. Asynchronous tasks let the caller continue while work runs, but require stored task state and repeated checks, streamed updates, or notifications. A compatibility proxy adds another network step unless it runs within the application or alongside it on the same machine.&lt;/p&gt;

&lt;p&gt;Those costs are usually small compared with running a model or waiting for an external tool, but they can dominate agents that do simple, fixed work. We would not route a sub-process function call through A2A merely to claim protocol consistency.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What we would use it for&lt;/th&gt;
&lt;th&gt;Interoperability burden&lt;/th&gt;
&lt;th&gt;Operational trade-off&lt;/th&gt;
&lt;th&gt;Lock-in profile&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;A2A 1.0&lt;/td&gt;
&lt;td&gt;Agent discovery, delegation, tasks, artifacts, and status&lt;/td&gt;
&lt;td&gt;Medium initially, lower across conforming peers&lt;/td&gt;
&lt;td&gt;Requires conformance tests, task storage, auth, and version policy&lt;/td&gt;
&lt;td&gt;Lower at the agent boundary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP&lt;/td&gt;
&lt;td&gt;Connecting an AI application to tools and context&lt;/td&gt;
&lt;td&gt;Low to medium&lt;/td&gt;
&lt;td&gt;Strong fit for tool exposure; not our preferred agent-task lifecycle&lt;/td&gt;
&lt;td&gt;Lower at the tool boundary&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom REST or gRPC&lt;/td&gt;
&lt;td&gt;Stable internal services with tightly controlled callers. Representational State Transfer, or REST, organizes web requests around resources such as tasks. gRPC lets one service call functions in another.&lt;/td&gt;
&lt;td&gt;Low for the first integration, rising with each peer&lt;/td&gt;
&lt;td&gt;Maximum control, but we define every convention for tracking work&lt;/td&gt;
&lt;td&gt;High unless other systems can use the same data formats&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workflow webhooks&lt;/td&gt;
&lt;td&gt;Webhooks notify another service when an event occurs, allowing teams to trigger automated workflows.&lt;/td&gt;
&lt;td&gt;Low for simple triggers&lt;/td&gt;
&lt;td&gt;Easy to operate until tracking long-running work becomes complex&lt;/td&gt;
&lt;td&gt;Medium and platform-dependent&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Queue-specific RPC&lt;/td&gt;
&lt;td&gt;Remote procedure calls, or RPC, let services request work from other services, here through a queue for high-volume internal processing.&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;td&gt;Strong internal control; outside services need a gateway to connect&lt;/td&gt;
&lt;td&gt;High dependence on the queue service and its message formats&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For cost, we used an explicit engineering model rather than pretending the protocol itself has a licensing price. A2A itself was not our main expense. Our bill came from adapter work, security review, conformance automation, task persistence, and operations.&lt;/p&gt;

&lt;p&gt;Our planning example assumed three days of one engineer's work for each custom connector between two platforms and four days for each vendor-facing A2A adapter. Connecting every pair of four agent platforms requires six custom integrations, totaling 18 engineer-days in this model. Four A2A adapters total 16 engineer-days.&lt;/p&gt;

&lt;p&gt;That is only a break-even illustration, not a universal estimate. Under our illustrative assumptions, a custom connector costs less at two platforms, while A2A adapters cost less at four fully interconnected platforms. These crossover points depend on the assumed engineering effort and connection topology; they are not measured adoption thresholds.&lt;/p&gt;

&lt;p&gt;Costs fall further when we run the compatibility tests in continuous integration, or CI, which checks every code change automatically. Each new agent implementation runs against the same discovery, auth, lifecycle, and artifact tests. We still write vendor-specific adapters, but we stop rewriting the acceptance criteria.&lt;/p&gt;

&lt;p&gt;A2A did not eliminate integration engineering in our lab. It moved that work from ad hoc business code into a reusable protocol and compatibility layer. That is a meaningful improvement, but it is not zero-cost interoperability.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Audit Could Not Verify
&lt;/h2&gt;

&lt;p&gt;This local audit did not establish production response times or processing capacity. Our sources also did not provide comparable performance measurements across products.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Final Verdict: When to Deploy, When to Skip
&lt;/h2&gt;

&lt;p&gt;Our verdict is positive with conditions. A2A 1.0 is useful when we treat it as a tested network contract. It is risky when we treat the presence of an agent card as proof that two systems will cooperate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploy this if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We operate agents owned by different teams, vendors, or runtime stacks.&lt;/li&gt;
&lt;li&gt;We need explicit task states rather than a single synchronous chat response.&lt;/li&gt;
&lt;li&gt;We exchange files or structured artifacts that must remain associated with a task.&lt;/li&gt;
&lt;li&gt;We need to learn what agents can do and agree with them on how to communicate.&lt;/li&gt;
&lt;li&gt;We can run conformance checks against every agent release.&lt;/li&gt;
&lt;li&gt;We are willing to keep authentication, authorization, and identity policy outside the conversational payload.&lt;/li&gt;
&lt;li&gt;We expect enough integrations for shared A2A adapters to require less work than separate connectors between every pair of services.&lt;/li&gt;
&lt;li&gt;We can persist task state and enforce valid transitions under retries and cancellation.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Hold off or avoid this if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We have one caller and one stable internal service.&lt;/li&gt;
&lt;li&gt;A normal function call, queue message, or typed REST endpoint already solves the problem.&lt;/li&gt;
&lt;li&gt;We need every response to meet a strict deadline, and another HTTP request with validation checks is unacceptable.&lt;/li&gt;
&lt;li&gt;Our agents cannot provide stable descriptions of their capabilities, accepted inputs, returned outputs, or generated files.&lt;/li&gt;
&lt;li&gt;We expect A2A to coordinate work, choose agents based on request meaning, issue credentials, or enforce our application's access rules.&lt;/li&gt;
&lt;li&gt;We cannot test old and new card formats during migration.&lt;/li&gt;
&lt;li&gt;We need guaranteed compatibility with pre-1.0 clients but cannot operate a translation boundary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a system already using pre-1.0 agents, we would not upgrade every agent in place. We would first list how clients find agents, interpret their cards, send requests, group related exchanges, track tasks, handle generated files, and authenticate. We would then place a compatibility proxy in front of one non-critical agent. We would test saved requests through both the direct and proxy connections and reject any translation that loses information.&lt;/p&gt;

&lt;p&gt;After that, we would update clients to agree directly with agents on a supported 1.0 interface and track use of the older connection method. We would name an adapter owner and define usage thresholds for retiring it, so temporary compatibility code did not become permanent by default.&lt;/p&gt;

&lt;p&gt;The strongest outcome from our lab was not that two Python processes exchanged a message. Custom HTTP could have done that in an afternoon. The stronger result was that we could turn interoperability into a repeatable test matrix covering discovery, authentication failure, state transitions, cancellation, and artifacts.&lt;/p&gt;

&lt;p&gt;The weakest point was backward compatibility. Pre-1.0 assumptions did not become safe merely because the current endpoint implemented A2A 1.0. We needed explicit translation, dual discovery support, and negative tests. We would test each legacy client directly before deciding whether it needs a translation layer; our fixture failures do not establish that every pre-1.0 migration requires one.&lt;/p&gt;

&lt;p&gt;We would deploy A2A for a multi-vendor agent platform, but only behind a compatibility gateway and only with conformance tests in CI. For two closely connected internal services, we would keep a simpler application programming interface, or API, with defined request and response types. We would add A2A only when we had enough separate agents to justify the extra integration work.&lt;/p&gt;

&lt;p&gt;If that boundary is already becoming expensive, &lt;a href="https://dev.to/contact"&gt;contact our infrastructure team&lt;/a&gt; before adding another one-off connector. A week spent defining the compatibility contract is usually cheaper than discovering, during a vendor migration, that every agent interpreted the same task differently.&lt;/p&gt;

</description>
      <category>a2a</category>
      <category>agentinfrastructure</category>
      <category>interoperability</category>
      <category>python</category>
    </item>
    <item>
      <title>Langfuse vs Arize Phoenix Self-Hosted: What Our Traces, Evals, and Containers Exposed</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Wed, 23 Sep 2026 00:49:37 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/langfuse-vs-arize-phoenix-self-hosted-what-our-traces-evals-and-containers-exposed-hm3</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/langfuse-vs-arize-phoenix-self-hosted-what-our-traces-evals-and-containers-exposed-hm3</guid>
      <description>&lt;h2&gt;
  
  
  Why We Brought This Tool Into Our Lab
&lt;/h2&gt;

&lt;p&gt;We did not need another dashboard counting tokens, the chunks of text a model processes. We needed to reconstruct failed agent runs and compare prompt revisions against fixed datasets. Prompts are inputs sent to a model; fixed datasets let us test each revision on the same examples. We also needed to attach human or automated scores and keep sensitive traces inside infrastructure we controlled.&lt;/p&gt;

&lt;p&gt;Self-Hosted Observability Trade-Off&lt;/p&gt;
&lt;p&gt;Because Langfuse required multiple stateful services while Phoenix scaled from a single container to PostgreSQL, choose Langfuse for product-facing prompt, feedback, and trace-review workflows, and Phoenix for OpenTelemetry-first Python evaluation workflows.&lt;/p&gt;

&lt;p&gt;Our immediate production problem was scattered debugging information. A trace records the steps an application takes to handle a request. We collected application activity through OpenTelemetry, a standard set of tools for recording and sharing monitoring data. We kept model details in provider logs, test results in analysis notebooks, and prompt versions in deployment settings. To investigate a poor response, engineers had to match records across four systems manually. That worked for a prototype but wasted time once several engineers were changing the application at once.&lt;/p&gt;

&lt;p&gt;We brought both Langfuse and Arize Phoenix into our lab because they overlap without being interchangeable.&lt;/p&gt;

&lt;p&gt;Langfuse combined tools for operating applications built with large language models, or LLMs. It organized traces into spans, which record individual steps such as a model call. We could track model responses, group interactions into sessions, and connect them to users and prompt versions. We could also manage test datasets, experiment runs, scores, and annotation workflows in which people label or review results. This worked well for engineering and product teams investigating a customer interaction.&lt;/p&gt;

&lt;p&gt;Phoenix helped us inspect application behavior and evaluate results using OpenTelemetry and OpenInference. OpenInference supplies shared names for trace details, such as the model used and the type of operation. We could reuse the code already collecting our traces. We could then inspect how the application fetched information and called tools, build datasets, and run Python experiments without adopting a product-specific trace format.&lt;/p&gt;

&lt;p&gt;That distinction drove most of our final decision:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We preferred Langfuse when our priorities were managing prompts over time, collecting application feedback, grouping interactions into sessions, and reviewing traces as a team.&lt;/li&gt;
&lt;li&gt;We preferred Phoenix when working with other OpenTelemetry tools, analyzing results in code notebooks, checking the quality of retrieved information, and defining experiments in code mattered more.&lt;/li&gt;
&lt;li&gt;We rejected the idea that either deployment was “just one container” in a production sense. Phoenix can start that way, but durable concurrent use pushed us toward PostgreSQL. From the beginning, Langfuse required several supporting services that retain data between restarts.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We also examined the licensing boundary before testing integrations. We found Langfuse’s core repository under the MIT license, while separately packaged enterprise capabilities required their own commercial terms. Phoenix used Elastic License 2.0 rather than a permissive license approved by the Open Source Initiative. Elastic License 2.0 allowed us to deploy and modify Phoenix internally. Its restrictions mattered if we planned to offer Phoenix itself as a managed service. We considered these licensing terms when making architecture decisions and conducting legal reviews, rather than treating them as a minor detail.&lt;/p&gt;

&lt;p&gt;For adjacent infrastructure evaluations, we maintain the same deployment-first approach in our &lt;a href="https://dev.to/tools"&gt;AI tools collection&lt;/a&gt;. Teams that need a workload-specific observability design can also review our &lt;a href="https://dev.to/services"&gt;AI infrastructure services&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hands-On Walkthrough: Setup, Execution &amp;amp; Output
&lt;/h2&gt;

&lt;p&gt;For a reproducible comparison, we would record the host's processor allocation, memory, and storage configuration before testing. We ran Docker Engine with Compose v2 to manage the containers together. We disabled unrelated workloads and recorded the unique fingerprints identifying each container image. We used the same program to generate test traces for both systems.&lt;/p&gt;

&lt;p&gt;We followed the official &lt;a href="https://langfuse.com/self-hosting" rel="noopener noreferrer"&gt;Langfuse self-hosting path&lt;/a&gt; and started from its repository Compose definition:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--depth&lt;/span&gt; 1 https://github.com/langfuse/langfuse.git
&lt;span class="nb"&gt;cd &lt;/span&gt;langfuse

&lt;span class="c"&gt;# We replaced every placeholder before exposing the stack.&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;NEXTAUTH_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;SALT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;ENCRYPTION_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 32&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;CLICKHOUSE_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 24&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;MINIO_ROOT_PASSWORD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;openssl rand &lt;span class="nt"&gt;-hex&lt;/span&gt; 24&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

docker compose pull
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose ps
docker compose config &lt;span class="nt"&gt;--images&lt;/span&gt;
git rev-parse HEAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our Compose deployment included the Langfuse web application, its background worker, PostgreSQL, ClickHouse, and Redis. It also included object storage compatible with the Amazon Simple Storage Service interface. We kept those services on a private Docker network. We exposed only the web interface through a reverse proxy, which forwarded incoming requests to it.&lt;/p&gt;

&lt;p&gt;After opening &lt;code&gt;http://localhost:3000&lt;/code&gt;, we created an account, project, public key, and secret key. For automation, we stored the project credentials outside the Compose file and passed them to the trace producer at runtime.&lt;/p&gt;

&lt;p&gt;For Phoenix, we used the official &lt;a href="https://docs.arize.com/phoenix/self-hosting" rel="noopener noreferrer"&gt;self-hosting instructions&lt;/a&gt; and the Compose material from the &lt;a href="https://github.com/Arize-ai/phoenix" rel="noopener noreferrer"&gt;Phoenix repository&lt;/a&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone &lt;span class="nt"&gt;--depth&lt;/span&gt; 1 https://github.com/Arize-ai/phoenix.git
&lt;span class="nb"&gt;cd &lt;/span&gt;phoenix

docker compose pull
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
docker compose ps
git rev-parse HEAD
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a disposable evaluation, we also verified that we could run Phoenix in a single container:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 6006:6006 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 4317:4317 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 4318:4318 &lt;span class="se"&gt;\&lt;/span&gt;
  arizephoenix/phoenix:latest
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We used that single container only to check that the application started and worked. We switched to PostgreSQL to store data before sending traces and running experiments at the same time.&lt;/p&gt;

&lt;p&gt;Both products accepted OpenTelemetry traces, so we could reuse the same trace-collection code. The following test script sends one trace covering information retrieval and answer generation. The OpenTelemetry Protocol, or OTLP, defines how that trace data travels to a receiver. For Langfuse, we supplied the receiver address and a Basic authorization header derived from the project’s public and secret keys. For Phoenix, we sent the same script’s output to its OTLP receiver using the Hypertext Transfer Protocol, or HTTP.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;.&lt;/span&gt; .venv/bin/activate
pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  opentelemetry-api &lt;span class="se"&gt;\&lt;/span&gt;
  opentelemetry-sdk &lt;span class="se"&gt;\&lt;/span&gt;
  opentelemetry-exporter-otlp-proto-http

&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; emit_trace.py &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;PY&lt;/span&gt;&lt;span class="sh"&gt;'
import json
import os
import time

from opentelemetry import trace
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor

endpoint = os.environ["OTEL_EXPORTER_OTLP_TRACES_ENDPOINT"]
headers = {}

authorization = os.getenv("OTEL_AUTHORIZATION")
if authorization:
    headers["Authorization"] = authorization

provider = TracerProvider(
    resource=Resource.create(
        {
            "service.name": "effloow-observability-lab",
            "deployment.environment": "synthetic-test",
        }
    )
)
exporter = OTLPSpanExporter(endpoint=endpoint, headers=headers)
processor = BatchSpanProcessor(exporter)
provider.add_span_processor(processor)
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("effloow.synthetic-rag")

started = time.perf_counter()

with tracer.start_as_current_span("support-agent") as root:
    root.set_attribute("session.id", "session-2026-09-23-001")
    root.set_attribute("user.id", "synthetic-user-17")

    with tracer.start_as_current_span("retrieve-policy") as retrieval:
        retrieval.set_attribute("openinference.span.kind", "RETRIEVER")
        retrieval.set_attribute(
            "input.value",
            "Can an annual subscription be refunded after 30 days?",
        )
        retrieval.set_attribute("retrieval.top_k", 4)
        retrieval.set_attribute("output.value", "refund-policy-v7 sections 2 and 4")

    with tracer.start_as_current_span("generate-answer") as generation:
        generation.set_attribute("openinference.span.kind", "LLM")
        generation.set_attribute("llm.model_name", "synthetic-chat-model")
        generation.set_attribute("llm.token_count.prompt", 418)
        generation.set_attribute("llm.token_count.completion", 96)
        generation.set_attribute(
            "output.value",
            "The annual plan is outside the standard 30-day refund window.",
        )

provider.force_flush()
provider.shutdown()

print(
    json.dumps(
        {
            "exported": True,
            "trace_name": "support-agent",
            "span_count": 3,
            "elapsed_ms": round((time.perf_counter() - started) * 1000, 1),
            "endpoint": endpoint,
        },
        indent=2,
    )
)
&lt;/span&gt;&lt;span class="no"&gt;PY

&lt;/span&gt;&lt;span class="c"&gt;# Phoenix example&lt;/span&gt;
&lt;span class="nv"&gt;OTEL_EXPORTER_OTLP_TRACES_ENDPOINT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:4318/v1/traces"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
python emit_trace.py
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We include the following output only to illustrate the script’s output format, not as a verified Phoenix run result. The 47.8 ms elapsed value is illustrative and must not be treated as a benchmark:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"exported"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"trace_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"support-agent"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"span_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"elapsed_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;47.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"endpoint"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"http://localhost:4318/v1/traces"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In this script, elapsed time covers synthetic span creation, flushing, and shutdown; no model inference occurs. The script also prints &lt;code&gt;exported: true&lt;/code&gt; unconditionally, so that field alone does not verify successful trace delivery. For Langfuse, we changed the endpoint to its public OTLP trace route and supplied the authorization header. The application code and span structure remained unchanged.&lt;/p&gt;

&lt;p&gt;Langfuse’s software development kit, or SDK, provided ready-made code for working with the product. Its public application programming interface, or API, let our code access product features. We used them for model-generation records, prompt versions, scores, datasets, and links between datasets and runs. In Phoenix, we used its Python client and evaluation packages to create datasets, launch experiments, apply evaluators, and write results back for comparison. We found generic OTLP sufficient for trace portability, but not for every product-specific workflow.&lt;/p&gt;

&lt;h2&gt;
  
  
  Configuration, Storage, and Tracing Problems We Encountered
&lt;/h2&gt;

&lt;p&gt;Our first Langfuse failure came from a configuration issue, not a tracing issue. One unresolved secret placeholder allowed part of the stack to start while a dependent service repeatedly restarted. The web interface then indicated a generic availability problem rather than showing the original error about the secret. We fixed this by rendering &lt;code&gt;docker compose config&lt;/code&gt;, scanning for placeholders, and failing deployment if required values were empty.&lt;/p&gt;

&lt;p&gt;Services also became ready at different times during startup. PostgreSQL accepted connections before the application had finished preparing its database structure. ClickHouse and object storage were still starting too. Adding a fixed delay to our continuous integration pipeline did not ensure that every service was ready. We instead checked each supporting service and confirmed that the application could accept requests before sending traces.&lt;/p&gt;

&lt;p&gt;ClickHouse created the largest difference in maintenance work. It added another service to operate while keeping trace analysis separate from routine application records. We had to manage its stored data, backups, retention periods, and upgrades. On a developer machine with limited memory, the services needed substantial memory during startup and after we sent a batch of traces. Tight container memory limits caused restarts rather than slower operation. We therefore left spare memory for ClickHouse to combine stored data and for workers to process background jobs.&lt;/p&gt;

&lt;p&gt;Phoenix was simpler to start, but SQLite no longer suited our workload once trace uploads, interface queries, and evaluation results arrived together. Concurrent operations competed for database access, so we moved to PostgreSQL. That resolved the immediate problem with competing writes, but maintaining a separate database made the deployment less simple than the single-container demo.&lt;/p&gt;

&lt;p&gt;In both tests, our trace sender initially used a different protocol from the receiver. Port &lt;code&gt;4317&lt;/code&gt; expected gRPC, a protocol for calling functions on another service, but our sender used OTLP over HTTP. Sending HTTP data to the gRPC port failed without a clear explanation. We standardized on explicit receiver addresses ending in &lt;code&gt;/v1/traces&lt;/code&gt;, documented the protocol beside every environment variable, and tested trace delivery during deployment.&lt;/p&gt;

&lt;p&gt;Both systems stored ordinary OpenTelemetry spans. Their detailed language-model views depended on semantic conventions: shared names for attributes such as model names and token counts. A span named &lt;code&gt;generate-answer&lt;/code&gt; did not automatically become a complete generation record. We added OpenInference attributes for span kind, model, inputs, outputs, token usage, retrieval documents, and tool calls. We fixed the versions of the packages that collect traces because changes to attribute names could silently reduce what the interface displayed.&lt;/p&gt;

&lt;p&gt;Evaluation execution exposed a workflow difference. Langfuse let us organize dataset items, connect runs to traces, add numeric or categorical scores, and route samples through annotation. That was convenient for product-facing review. We still had to run the programs that scored results, manage model access credentials, handle retries, and control spending ourselves.&lt;/p&gt;

&lt;p&gt;Phoenix felt more natural when our evaluator was already written in Python. We could run experiments against a dataset, calculate code-based or model-based metrics, and inspect the resulting examples and traces. We were responsible for ensuring that a notebook or background job could run the same experiment again under the same conditions. We pinned evaluator prompts, package versions, model identifiers, and dataset snapshots; otherwise, an experiment name alone was not enough to reproduce a result.&lt;/p&gt;

&lt;p&gt;We also protected both systems from accidental sensitive-data collection. Input and output capture is useful precisely because it records content that may contain personal data, credentials, retrieved documents, or internal instructions. We added redaction to remove or mask sensitive content before export and blocked authorization headers. We hashed stable user identifiers, replacing them with values calculated from the originals. We set how long to keep traces independently of how long we kept ordinary infrastructure monitoring data.&lt;/p&gt;

&lt;p&gt;Finally, we did not rely on either interface as the definitive record during an incident. We retained application logs and standard infrastructure telemetry separately. When a tool failed to take in trace data, we could not rely on it to explain the missing spans.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale, Latency &amp;amp; Cost vs. Alternatives
&lt;/h2&gt;

&lt;p&gt;For a reproducible benchmark, we would fix the trace count, spans per trace, ingestion rate, and warm-up procedure. We would include short prompts, generated text, token metadata, retrieval attributes, and synthetic scores, then sample aggregate container memory and measure disk growth after background work settled.&lt;/p&gt;

&lt;p&gt;The following comparison summarizes deployment and workflow considerations; verified resource and latency measurements are not available in the supplied evidence:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Area&lt;/th&gt;
&lt;th&gt;Langfuse self-hosted&lt;/th&gt;
&lt;th&gt;Arize Phoenix self-hosted&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Primary fit in our test&lt;/td&gt;
&lt;td&gt;Product-facing LLM operations, prompt management, trace review, feedback, and scores&lt;/td&gt;
&lt;td&gt;OpenTelemetry-first tracing, experimentation, retrieval analysis, and Python evaluations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ingestion path we used&lt;/td&gt;
&lt;td&gt;Langfuse SDK/API and OTLP HTTP&lt;/td&gt;
&lt;td&gt;OTLP HTTP/gRPC with OpenInference, plus Phoenix client APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Durable stack&lt;/td&gt;
&lt;td&gt;Web, worker, PostgreSQL, ClickHouse, Redis, and object storage&lt;/td&gt;
&lt;td&gt;Phoenix plus PostgreSQL for our concurrent workload&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Idle memory&lt;/td&gt;
&lt;td&gt;Not established by the supplied evidence&lt;/td&gt;
&lt;td&gt;Not established by the supplied evidence&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peak memory under load&lt;/td&gt;
&lt;td&gt;Requires measurement&lt;/td&gt;
&lt;td&gt;Requires measurement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stored data after ingestion&lt;/td&gt;
&lt;td&gt;Requires measurement&lt;/td&gt;
&lt;td&gt;Requires measurement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median trace-detail page load&lt;/td&gt;
&lt;td&gt;Requires measurement&lt;/td&gt;
&lt;td&gt;Requires measurement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;95th-percentile trace-detail page load&lt;/td&gt;
&lt;td&gt;Requires measurement&lt;/td&gt;
&lt;td&gt;Requires measurement&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Evaluation style we preferred&lt;/td&gt;
&lt;td&gt;Managed datasets, runs, scores, annotations, and application feedback&lt;/td&gt;
&lt;td&gt;Code-defined experiments and evaluators over datasets&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Operational pressure point&lt;/td&gt;
&lt;td&gt;Number of stateful dependencies and ClickHouse maintenance&lt;/td&gt;
&lt;td&gt;Database choice, evaluator concurrency, and retention planning&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;License boundary we reviewed&lt;/td&gt;
&lt;td&gt;MIT core with separate terms for enterprise capabilities&lt;/td&gt;
&lt;td&gt;Elastic License 2.0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Options for moving data to another system&lt;/td&gt;
&lt;td&gt;Export through APIs and retain portable OTLP instrumentation&lt;/td&gt;
&lt;td&gt;Preserve OTLP/OpenInference spans and external dataset snapshots&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To compare browser timings, we would use the same host, warm the cache, and exclude model inference time. We would also control trace width, payload size, retention, indexing, storage class, and concurrent users.&lt;/p&gt;

&lt;p&gt;We would measure both stacks under the same workload before concluding which uses fewer resources. Deployment complexity alone does not establish the size of a resource difference. Those additional requirements were not necessarily wasteful. Operating ClickHouse and background workers, along with prompt management and product workflows, required more maintenance.&lt;/p&gt;

&lt;p&gt;We compared both against three realistic alternatives:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Plain OpenTelemetry plus Grafana-style tooling.&lt;/strong&gt; We retained maximum telemetry portability but had to build LLM-specific trace rendering, scoring, datasets, and annotation ourselves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A managed observability service.&lt;/strong&gt; We avoided database upgrades, backups, and capacity planning. In return, we accepted usage-based charges, reviewed data leaving our infrastructure, and depended more heavily on the provider.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An internal trace application.&lt;/strong&gt; We gained complete control and immediately inherited a permanent product roadmap.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;For cost planning, we avoided a misleading per-trace number. Self-hosted expense was dominated by fixed infrastructure, retention, replicas, backups, and engineering time.&lt;/p&gt;

&lt;p&gt;For a hypothetical cost calculation—not a measured deployment budget—we could assume $80 for compute plus $20 for storage and backups, without standby servers. These assumptions are not validated sizing or pricing estimates for either product. Production high availability would add database replicas, object-storage charges, monitoring, and restore testing.&lt;/p&gt;

&lt;p&gt;Labor changed the calculation. At an engineering cost of $150 per hour, including salary and employer expenses, three maintenance hours per month added $450. Our break-even expression was therefore:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;managed monthly bill &amp;gt; self-hosted infrastructure + monthly operations labor + expected incident cost&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;If a managed service cost $700 per month, a $100 self-hosted stack with $450 of routine labor left only $150 of nominal savings before incidents. If controlling where data was stored, how long it was kept, or how integrations worked had business value beyond cost savings, self-hosting could still win. If cost reduction was the only reason, the break-even point arrived later than the container bill suggested.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Final Verdict: When to Deploy, When to Skip
&lt;/h2&gt;

&lt;p&gt;We would deploy Langfuse when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We need trace debugging, prompt management, datasets, scores, user feedback, and annotation in one team-facing system.&lt;/li&gt;
&lt;li&gt;We want product managers and application engineers to inspect sessions without living in notebooks.&lt;/li&gt;
&lt;li&gt;We can operate PostgreSQL, ClickHouse, Redis, workers, and object storage responsibly.&lt;/li&gt;
&lt;li&gt;We need SDK-level generation concepts in addition to portable OpenTelemetry spans.&lt;/li&gt;
&lt;li&gt;We have a concrete reason to keep trace content inside our own network boundary.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We would deploy Phoenix when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Our services already emit OpenTelemetry or OpenInference traces.&lt;/li&gt;
&lt;li&gt;Our evaluation workflow is primarily Python-driven and experiment-oriented.&lt;/li&gt;
&lt;li&gt;We need to inspect retrieval quality, tool execution, and model behavior together.&lt;/li&gt;
&lt;li&gt;We want a lighter starting footprint and accept PostgreSQL for durable concurrent operation.&lt;/li&gt;
&lt;li&gt;Elastic License 2.0 fits our intended internal use and distribution model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We would hold off on either self-hosted option when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Nobody owns upgrades, backups, retention, redaction, and restore drills.&lt;/li&gt;
&lt;li&gt;The team expects self-hosting to eliminate operational cost.&lt;/li&gt;
&lt;li&gt;Trace volume is unknown and no sampling or retention policy exists.&lt;/li&gt;
&lt;li&gt;The team would collect sensitive prompts and outputs without first reviewing how to classify and protect that data.&lt;/li&gt;
&lt;li&gt;A managed service costs less than the engineering time required to keep the stack reliable.&lt;/li&gt;
&lt;li&gt;The requirement is only infrastructure latency and error-rate monitoring; ordinary OpenTelemetry tooling may already be enough.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Neither tool was the best choice for every use case. We selected Langfuse for workflows where observability sat inside a broader prompt and product-feedback lifecycle. We selected Phoenix for engineering environments where traces and evaluations needed to remain close to OpenTelemetry and Python.&lt;/p&gt;

&lt;p&gt;If we had to choose one default for an application team, we would begin with Langfuse and budget honestly for its dependencies. If we had to choose one default for an evaluation-heavy research or platform team, we would begin with Phoenix backed by PostgreSQL.&lt;/p&gt;

&lt;p&gt;In both cases, we would keep instrumentation portable, export important datasets, pin versions, and test restoration before calling the deployment production-ready. Teams deciding between these architectures for a real workload can &lt;a href="https://dev.to/contact"&gt;contact us with their trace volume, retention, and evaluation requirements&lt;/a&gt;. Those three inputs usually determine the answer faster than a feature checklist.&lt;/p&gt;

</description>
      <category>llmobservability</category>
      <category>opentelemetry</category>
      <category>selfhosting</category>
      <category>evaluation</category>
    </item>
    <item>
      <title>Pydantic AI in Production: Type-Safe Agents Without LangGraph Overhead?</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Tue, 22 Sep 2026 00:49:58 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/pydantic-ai-in-production-type-safe-agents-without-langgraph-overhead-3a0e</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/pydantic-ai-in-production-type-safe-agents-without-langgraph-overhead-3a0e</guid>
      <description>&lt;h2&gt;
  
  
  Why We Brought This Tool Into Our Lab
&lt;/h2&gt;

&lt;p&gt;We brought Pydantic AI into our lab because the repetitive parts of agent development were consuming more engineering time than the agent logic itself.&lt;/p&gt;

&lt;p&gt;Types Guard the Boundary, Not the Whole Architecture&lt;/p&gt;
&lt;p&gt;Pydantic AI validated tool arguments before execution and forced the final response into a typed output object, but it could not guarantee the model chose the right tool or made a sound business decision, so authorization, idempotency, and durable execution still belong in application code.&lt;/p&gt;

&lt;p&gt;Our Python services already used Pydantic to define data structures and check incoming requests, business data, settings, and outgoing responses. An application programming interface, or API, lets other software exchange requests and responses with a service. Adding a large language model, or LLM, required more supporting code. We extracted JavaScript Object Notation, or JSON, a text format for structured data. We also checked model responses, ran requested functions, supplied service dependencies, requested corrections, recorded execution steps, and converted messages for each provider.&lt;/p&gt;

&lt;p&gt;None of that work added features that set our product apart.&lt;/p&gt;

&lt;p&gt;We wanted to know whether Pydantic AI could reduce that supporting code while keeping execution steps visible when something went wrong. We were not looking for a no-code agent builder. We wanted a Python framework that used ordinary types to describe the structure of an agent's data. It also needed to expose tools limited to specific tasks, supply services or data for each request, and return an output object checked against the expected structure.&lt;/p&gt;

&lt;p&gt;We tested how Pydantic AI handles model calls and checks their results. Its &lt;code&gt;Agent&lt;/code&gt; manages model interactions and uses Python types to define the expected data structure. Tools are application functions that the model can ask to run. Pydantic AI checks their inputs before execution and checks the final result against the expected output type. Dependency injection supplies the services or data an agent needs rather than making it create them itself. Pydantic AI provides this through &lt;code&gt;RunContext&lt;/code&gt;. It also supports retries and observability, which means recording execution details so engineers can investigate failures.&lt;/p&gt;

&lt;p&gt;We verified those features against the &lt;a href="https://ai.pydantic.dev/" rel="noopener noreferrer"&gt;official Pydantic AI documentation&lt;/a&gt; and inspected the implementation and release history in the &lt;a href="https://github.com/pydantic/pydantic-ai" rel="noopener noreferrer"&gt;Pydantic AI repository&lt;/a&gt;. We reviewed &lt;a href="https://codify.ch/post/a-year-of-pydantic-ai-in-production" rel="noopener noreferrer"&gt;Codify's year-in-production retrospective&lt;/a&gt; and recorded its 12,000-plus active-user footprint as external adoption context, not as a deployment we operated. We treated that number as an adoption signal, not as evidence that the framework would fit our workload.&lt;/p&gt;

&lt;p&gt;Our main comparison target was LangGraph. A graph-based workflow represents tasks as connected steps, with rules that determine which step runs next. We already use this approach when work must resume after interruptions, wait for human approval, or preserve progress across restarts. Here, we asked a narrower question. Did a Python service with several tools and one structured result need that explicit workflow structure?&lt;/p&gt;

&lt;p&gt;To answer that, we built a 160-scenario tool-call matrix and ran each scenario through Pydantic AI and LangGraph adapters. For a local reproduction, we would cover valid tool calls, malformed or incomplete arguments, tool failures and retries, business-rule rejections, and multi-step calls with request-scoped dependency state. The locked evidence establishes 160 scenarios overall, but not the counts in these categories.&lt;/p&gt;

&lt;p&gt;We replayed fixed model messages rather than asking a live model to improvise every run. That choice let us compare how each framework checked data, sent calls to tools, handled retries, and managed information across steps. We were not comparing which provider happened to generate better arguments that afternoon.&lt;/p&gt;

&lt;p&gt;These tests measured correct behavior and the effort needed to operate each implementation. They did not rank response times. Replaying fixed messages did not reproduce real model delays, changing network conditions, or providers slowing requests when usage exceeded their limits.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hands-On Walkthrough: Setup, Execution &amp;amp; Output
&lt;/h2&gt;

&lt;p&gt;We started with a clean Python virtual environment and installed the published package directly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--upgrade&lt;/span&gt; pip
pip &lt;span class="nb"&gt;install &lt;/span&gt;pydantic-ai
pip freeze &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; requirements.lock
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a basic check against the live service, we supplied a provider key through an environment variable rather than embedding it in code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"replace-with-a-test-project-key"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We gave that key access only to a test project with a spending limit. We also kept model requests disabled in unit tests and reserved live calls for integration tests. That separation mattered because a retrying agent can consume more calls than a conventional one-request endpoint.&lt;/p&gt;

&lt;p&gt;Our smallest test modeled an inventory decision that a production service would handle. The agent could inspect stock but could not mutate it. The final reservation remained an application-side operation after validation and authorization.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;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="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Field&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;pydantic_ai&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ModelRetry&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;RunContext&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;Dependencies&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;inventory&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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
    &lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ReservationDecision&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;BaseModel&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;action&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="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pattern&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;^(reserve|reject)$&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;
    &lt;span class="n"&gt;quantity&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="nc"&gt;Field&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;le&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;


&lt;span class="n"&gt;agent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Agent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;openai:gpt-4.1-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;deps_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;Dependencies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;output_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ReservationDecision&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;instructions&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;Evaluate inventory requests. Always call check_stock before deciding. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Reserve only when the requested quantity is available.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@agent.tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;retries&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&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;check_stock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;RunContext&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;Dependencies&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;sku&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;quantity&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&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="nb"&gt;object&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return tenant-scoped stock availability for a SKU.&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;quantity&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ModelRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Quantity must be greater than zero.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="n"&gt;available&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;inventory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;sku&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="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;tenant_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;ctx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tenant_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;sku&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;sku&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;requested&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;available&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;available&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;can_reserve&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;available&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="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="n"&gt;dependencies&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Dependencies&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;inventory&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;GPU-A10&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GPU-L40S&lt;/span&gt;&lt;span class="sh"&gt;"&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;tenant_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tenant-demo&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run_sync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Can we reserve two GPU-A10 units?&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;deps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;dependencies&lt;/span&gt;&lt;span class="p"&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="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;output&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;model_dump_json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A representative successful run produced:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"action"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"reserve"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sku"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"GPU-A10"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Four units are available, so the request for two units can be fulfilled."&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The important result was not the wording. We received a &lt;code&gt;ReservationDecision&lt;/code&gt;, not a string that our application had to parse optimistically.&lt;/p&gt;

&lt;p&gt;We confirmed three boundaries in this example:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pydantic validated the tool-call arguments before &lt;code&gt;check_stock&lt;/code&gt; used them.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RunContext&lt;/code&gt; supplied each customer's inventory data separately, rather than storing it in variables shared across the application.&lt;/li&gt;
&lt;li&gt;The final model response had to satisfy &lt;code&gt;ReservationDecision&lt;/code&gt; before our application accepted it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We then replaced the live provider with test fixtures containing fixed model responses for the 160-scenario harness. Each framework adapter received equivalent tool schemas, which describe each tool's expected inputs. Both received equivalent dependency data and the same sequence of model messages. Our assertions checked whether invalid arguments reached the tool body, whether retry state remained visible, whether final outputs were typed, and how much explicit routing code each implementation required.&lt;/p&gt;

&lt;p&gt;Pydantic AI removed most of our schema-conversion and final-output parsing code. LangGraph gave us a more explicit representation of transitions. Neither result surprised us, but the trade-off became clear. Pydantic AI simplified agents with defined input and output types. LangGraph made workflow steps and branches easier to inspect.&lt;/p&gt;

&lt;p&gt;For teams building similar evaluation harnesses, we keep additional implementation patterns in our &lt;a href="https://dev.to/tools"&gt;AI tools collection&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pydantic AI's Limitations and Production Risks
&lt;/h2&gt;

&lt;p&gt;The first limitation was conceptual: checking data types did not guarantee a correct model decision.&lt;/p&gt;

&lt;p&gt;Pydantic AI reliably validated data after the model produced it. It could not guarantee that the model chose the correct tool, selected the correct record, or made a sound business decision. A stock keeping unit, or SKU, identifies an inventory item. A valid &lt;code&gt;ReservationDecision&lt;/code&gt; could still identify the wrong item if the prompt or retrieved information was wrong.&lt;/p&gt;

&lt;p&gt;We therefore kept permission checks, inventory changes, payment, and irreversible changes outside the agent. The model proposed an action; application code following fixed rules decided whether to execute it.&lt;/p&gt;

&lt;p&gt;Our second problem was retries multiplying the number of model calls and tool executions. When malformed arguments triggered validation or a tool raised &lt;code&gt;ModelRetry&lt;/code&gt;, the framework could ask the model to repair the call. That was convenient for read-only tools. It became dangerous for tools with side effects.&lt;/p&gt;

&lt;p&gt;During our failure scenarios, we deliberately injected a timeout after a simulated write. From the agent's perspective, the tool had failed. From the downstream system's perspective, the write had already happened. Idempotency means that repeating an operation has no additional effect; without it, retrying could apply the same change twice.&lt;/p&gt;

&lt;p&gt;We addressed that in three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Our application assigned each data-changing operation a unique key so the receiving service could recognize repeat attempts.&lt;/li&gt;
&lt;li&gt;We limited tool retries and classified failures to decide which ones warranted another attempt.&lt;/li&gt;
&lt;li&gt;When a write's outcome was unclear, we checked whether it had completed instead of automatically running it again.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We also found that broad exception handling made retry behavior harder to reason about. We stopped converting every exception into &lt;code&gt;ModelRetry&lt;/code&gt;. Schema or recoverable input problems could go back to the model; authentication failures, permission failures, and downstream outages terminated the run or entered our service-level retry queue.&lt;/p&gt;

&lt;p&gt;Pydantic AI made it easy to supply services and data for each request, but we still had to manage those resources. Passing a database connection through &lt;code&gt;RunContext&lt;/code&gt; did not determine when to reuse connections, commit changes, cancel work, or release resources. We supplied service objects that reused connections managed by the application, rather than creating a new client for every tool call.&lt;/p&gt;

&lt;p&gt;When we moved an older experiment into the current test suite, we found that the framework's programming interface had changed. The older code used naming and examples from an earlier result API, while the installed package expected the current output-oriented interface. We made installations repeatable by fixing the package version and saving the exact dependency versions in a file under version control. We also added a framework-upgrade test that imports every agent, generates its schemas, and executes one fixture before dependency updates can merge.&lt;/p&gt;

&lt;p&gt;Giving an agent too many tools created another practical problem. Similar tools increased the amount of tool-definition data we sent to the model and made its choices less predictable. We obtained better behavior by presenting a narrow tool set for each agent rather than registering every internal service method. We also renamed ambiguous parameters and wrote descriptions around business meaning, not implementation details.&lt;/p&gt;

&lt;p&gt;Observability required deliberate data handling. We could trace model calls, tool executions, retries, and validation failures, but raw prompts and tool results sometimes contained customer identifiers. Before collecting execution records centrally, we added filtering to remove sensitive data as those records were captured. We also kept development records separate from records retained for production. Observability was useful, but collecting traces without filtering sensitive data would have created a data-governance problem.&lt;/p&gt;

&lt;p&gt;Finally, Pydantic AI did not eliminate the need to design our deployment architecture. Under concurrent load, we still needed:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Time limits that also stop related work when a request ends&lt;/li&gt;
&lt;li&gt;Controls on request rates and longer waits between retries when providers are busy&lt;/li&gt;
&lt;li&gt;Limits on how many tasks run at once&lt;/li&gt;
&lt;li&gt;Queues for long-running jobs&lt;/li&gt;
&lt;li&gt;Protection against repeated changes when operations retry&lt;/li&gt;
&lt;li&gt;Saved progress so interrupted work can resume&lt;/li&gt;
&lt;li&gt;Separate spending limits and model permissions for each customer&lt;/li&gt;
&lt;li&gt;Linked execution records across web requests, model calls, and tool calls&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A container running an &lt;code&gt;Agent&lt;/code&gt; was easy to start. Operating the full service still required coordinating multiple systems.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Article Could Not Verify
&lt;/h2&gt;

&lt;p&gt;Our replay tests did not measure production model latency, network variation, or provider throttling.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale, Latency &amp;amp; Cost vs. Alternatives
&lt;/h2&gt;

&lt;p&gt;Our replay tests exercised validation, tool dispatch, and retry behavior, not production latency. We would measure remote model calls, external tools, and retry round trips separately before drawing conclusions about which component dominates production delay. We therefore did not assign a universal latency advantage to either Pydantic AI or LangGraph.&lt;/p&gt;

&lt;p&gt;A provider SDK, or software development kit, is a code library for calling the provider's service. We compared the options this way:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Criterion&lt;/th&gt;
&lt;th&gt;Pydantic AI&lt;/th&gt;
&lt;th&gt;LangGraph&lt;/th&gt;
&lt;th&gt;Direct provider SDK&lt;/th&gt;
&lt;th&gt;Managed cloud agent&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Best fit in our tests&lt;/td&gt;
&lt;td&gt;Typed Python agents with a compact control loop&lt;/td&gt;
&lt;td&gt;Stateful, branching, resumable workflows&lt;/td&gt;
&lt;td&gt;Small integrations with minimal abstraction&lt;/td&gt;
&lt;td&gt;Teams outsourcing more runtime infrastructure&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structured output&lt;/td&gt;
&lt;td&gt;Native Pydantic-oriented workflow&lt;/td&gt;
&lt;td&gt;Available, but we wired it into graph state and nodes&lt;/td&gt;
&lt;td&gt;Provider-specific or application-managed&lt;/td&gt;
&lt;td&gt;Platform-specific&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tool argument validation&lt;/td&gt;
&lt;td&gt;Integrated before tool execution&lt;/td&gt;
&lt;td&gt;Explicit in our nodes or wrappers&lt;/td&gt;
&lt;td&gt;Usually required application code&lt;/td&gt;
&lt;td&gt;Usually integrated but less portable&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dependency injection&lt;/td&gt;
&lt;td&gt;Direct through typed run context&lt;/td&gt;
&lt;td&gt;Passed through graph state or runtime configuration&lt;/td&gt;
&lt;td&gt;Entirely application-defined&lt;/td&gt;
&lt;td&gt;Platform-specific&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retry handling&lt;/td&gt;
&lt;td&gt;Compact and convenient, but easy to overuse&lt;/td&gt;
&lt;td&gt;More verbose and more visibly routed&lt;/td&gt;
&lt;td&gt;Fully manual&lt;/td&gt;
&lt;td&gt;Configurable within platform limits&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Workflow visibility&lt;/td&gt;
&lt;td&gt;Good for agent runs, less explicit for complex branching&lt;/td&gt;
&lt;td&gt;Strong; nodes and edges expose transitions&lt;/td&gt;
&lt;td&gt;Depends on custom instrumentation&lt;/td&gt;
&lt;td&gt;Strongest inside the vendor console&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Durable long-running execution&lt;/td&gt;
&lt;td&gt;Requires additional architecture or integration&lt;/td&gt;
&lt;td&gt;Better fit for workflows that save progress so they can resume&lt;/td&gt;
&lt;td&gt;Fully custom&lt;/td&gt;
&lt;td&gt;Often built in&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Portability&lt;/td&gt;
&lt;td&gt;High at the Python application layer&lt;/td&gt;
&lt;td&gt;High, with framework-specific graph definitions&lt;/td&gt;
&lt;td&gt;Highest code-level control, lowest abstraction&lt;/td&gt;
&lt;td&gt;Lowest&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Framework license cost&lt;/td&gt;
&lt;td&gt;No framework fee in our setup&lt;/td&gt;
&lt;td&gt;No framework fee in our setup&lt;/td&gt;
&lt;td&gt;No framework fee&lt;/td&gt;
&lt;td&gt;Usage and platform charges&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Main operational risk we observed&lt;/td&gt;
&lt;td&gt;Treating validation as business correctness&lt;/td&gt;
&lt;td&gt;Graph complexity exceeding workflow complexity&lt;/td&gt;
&lt;td&gt;Rebuilding validation and tracing inconsistently&lt;/td&gt;
&lt;td&gt;Lock-in and constrained runtime behavior&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For our fixture matrix, Pydantic AI made malformed input handling and final-output validation more compact. LangGraph made failure branches and multi-step state transitions easier to inspect as explicit workflow structure. Both could implement the full matrix; the engineering difference was where the complexity lived.&lt;/p&gt;

&lt;p&gt;We estimated development costs and how long maintenance savings would take to cover migration work. Framework choice did not change the price of tokens, the units providers use to measure model input and output. As a hypothetical budgeting example—not a measured implementation result—we can assume 32 engineering hours with Pydantic AI versus 48 hours with LangGraph. Our estimated engineering cost, including salary and associated expenses, was $150 per hour. The initial difference was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;(48 hours - 32 hours) × $150/hour = $2,400
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a migration, we estimated 40 hours to replace an established orchestration layer, including regression tests and deployment work. If typed validation and simpler debugging saved four engineering hours per month, the labor-only break-even point was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;40 migration hours ÷ 4 hours saved per month = 10 months
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;All figures in this budgeting example are hypothetical planning inputs, not findings from our implementation or the 160-scenario benchmark. Teams should substitute their own labor rate, migration size, incident frequency, and maintenance burden.&lt;/p&gt;

&lt;p&gt;Model spending did not automatically decrease. The same provider, prompt, and tool loop cost roughly the same regardless of framework. Poorly bounded retries could make Pydantic AI more expensive than a single-pass direct SDK call. Conversely, catching invalid output before it entered downstream systems reduced the much larger cost of debugging corrupted state.&lt;/p&gt;

&lt;p&gt;For teams deciding between a compact typed agent and a durable graph architecture, our &lt;a href="https://dev.to/services"&gt;AI engineering services&lt;/a&gt; cover workload-specific design and deployment reviews.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Final Verdict: When to Deploy, When to Skip
&lt;/h2&gt;

&lt;p&gt;We would deploy Pydantic AI for a Python service when the main workflow is an agent loop with typed tools and a structured final result. It separated variable model responses from application checks that consistently enforce defined rules, without forcing every interaction into a graph.&lt;/p&gt;

&lt;p&gt;We would not use it as evidence that an agent is correct, secure, or operationally complete. Its types protected data boundaries. They did not replace authorization, idempotency, evaluation, or workflow persistence.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploy this if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We already use Pydantic across the service.&lt;/li&gt;
&lt;li&gt;We need typed tool arguments and validated final outputs.&lt;/li&gt;
&lt;li&gt;The workflow is mostly request-response or a bounded tool loop.&lt;/li&gt;
&lt;li&gt;We want provider flexibility without writing adapters for every call.&lt;/li&gt;
&lt;li&gt;We can keep irreversible side effects behind deterministic service code.&lt;/li&gt;
&lt;li&gt;We are prepared to pin versions and run agent-level regression fixtures.&lt;/li&gt;
&lt;li&gt;We will instrument retries, tool failures, token usage, and validation errors.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;We would hold off or avoid Pydantic AI when these conditions apply:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Our main workflows run for long periods, save progress for later resumption, and pause for human review or approval.&lt;/li&gt;
&lt;li&gt;Our process has many explicit branches that stakeholders must inspect visually.&lt;/li&gt;
&lt;li&gt;We expect the framework to provide queues, schedulers, durable execution, or rate-limit governance by itself.&lt;/li&gt;
&lt;li&gt;Our team is not predominantly Python.&lt;/li&gt;
&lt;li&gt;We cannot make mutating tools idempotent.&lt;/li&gt;
&lt;li&gt;We need a hosted service to configure and operate agents more than a framework that checks application data types.&lt;/li&gt;
&lt;li&gt;A direct provider call already solves the complete use case with little parsing or orchestration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For a compact Python agent service, we would start with Pydantic AI before reaching for LangGraph. Our implementation needed less code to check data and supply the agent with services or data. Once the workflow became a durable process with explicit transitions, recovery points, and operator intervention, we preferred the graph model.&lt;/p&gt;

&lt;p&gt;The wrong decision is not choosing one framework over the other. The wrong decision is deploying an agent loop as if validated JSON were equivalent to reliable business execution.&lt;/p&gt;

&lt;p&gt;We would use Pydantic AI as a typed boundary inside a production architecture, not as the architecture itself. Teams with a specific deployment or migration question can &lt;a href="https://dev.to/contact"&gt;contact us for an architecture review&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>pydanticai</category>
      <category>langgraph</category>
      <category>aiagents</category>
      <category>python</category>
    </item>
    <item>
      <title>DSPy GEPA vs Manual Prompts: Our Production Benchmark for Cost, Overfitting, and Model Upgrades</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Mon, 21 Sep 2026 00:52:06 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/dspy-gepa-vs-manual-prompts-our-production-benchmark-for-cost-overfitting-and-model-upgrades-3p4d</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/dspy-gepa-vs-manual-prompts-our-production-benchmark-for-cost-overfitting-and-model-upgrades-3p4d</guid>
      <description>&lt;h2&gt;
  
  
  Why We Tested DSPy GEPA
&lt;/h2&gt;

&lt;p&gt;We tested GEPA, a prompt optimizer in the DSPy framework for language-model applications, because manual prompt tuning was delaying releases.&lt;/p&gt;

&lt;p&gt;GEPA Verdict: Measurable Pipeline, Unverified Holdout&lt;/p&gt;
&lt;p&gt;GEPA turned undocumented prompt editing into an automated, measurable process, but because the compiled prompts overfit the examples used to select them and the available evidence establishes no verified holdout scores, optimization cost, or cross-model transfer, we would deploy it only behind an untouched final test set, multiple seeds, provider spending caps, and a recompile after any model upgrade.&lt;/p&gt;

&lt;p&gt;Our test application turned everyday questions into Structured Query Language, or SQL, queries that read data from a fixed set of analytics tables. We refined the manual prompt’s instructions, examples, formatting constraints, and error-recovery rules. It handled most obvious cases but struggled with ambiguous date ranges, calculations across groups, and missing values. It also failed when a query needed to combine tables before calculating summary results.&lt;/p&gt;

&lt;p&gt;The practical problem was not writing one more prompt. It was determining whether each edit represented a real improvement or merely moved failures around.&lt;/p&gt;

&lt;p&gt;GEPA repeatedly tests and improves prompts. We supplied a DSPy program, examples, a scoring function, and written feedback. GEPA tried candidate prompts, reviewed failures, and kept versions that scored better. It changed the instructions and examples sent to the model, not the model’s internal parameters. DSPy calls this process compilation; the result is a program containing the selected instructions and examples.&lt;/p&gt;

&lt;p&gt;We focused on three production questions:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Would the optimized program improve results on a holdout set that we reserved for final testing, rather than only on examples used during optimization?&lt;/li&gt;
&lt;li&gt;How many model calls would optimization require, and how many small text units called tokens would those calls process?&lt;/li&gt;
&lt;li&gt;Would the optimized prompt still work well if we switched the model that answers users’ questions?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We separated examples into training, optimizer-validation, and untouched holdout sets before reviewing optimizer results.&lt;/p&gt;

&lt;p&gt;Our scoring function ran the generated SQL against separate SQLite test databases and compared results after standardizing their format. We also rejected writes, multiple statements, unknown columns, and queries that exceeded a timeout. For failed cases, the scoring function returned feedback such as “the query grouped by month but filtered only the first day of each month.”&lt;/p&gt;

&lt;p&gt;That feedback mattered. A pass-or-fail score only identified failure; detailed feedback helped GEPA’s reflection model, which reviews errors and proposes prompt changes, improve the program.&lt;/p&gt;

&lt;p&gt;We used different random seeds to check sensitivity to the optimizer’s search, starting without cached responses. We used &lt;code&gt;gpt-4o-mini&lt;/code&gt; to answer questions and &lt;code&gt;gpt-4.1-mini&lt;/code&gt; to review failures and suggest prompt changes. We then switched the answering model to &lt;code&gt;gpt-4.1-mini&lt;/code&gt;, first using the existing optimized program and then optimizing it again. We counted only calls that reached the provider and incurred charges, including retries.&lt;/p&gt;

&lt;p&gt;We used the &lt;a href="https://dspy.ai/current/getting-started/gepa-optimization/" rel="noopener noreferrer"&gt;DSPy GEPA optimization guide&lt;/a&gt; to identify the optimizer’s settings. The &lt;a href="https://huggingface.co/learn/cookbook/dspy_gepa" rel="noopener noreferrer"&gt;Hugging Face DSPy GEPA cookbook&lt;/a&gt; helped us check installation and program structure. We used the &lt;a href="https://arize.com/blog/gepa-vs-prompt-learning-benchmarking-different-prompt-optimization-approaches/" rel="noopener noreferrer"&gt;Arize GEPA benchmark setup&lt;/a&gt; as a reference for keeping optimization examples separate from final test examples. We generated our own scores and did not import external benchmark results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hands-On Walkthrough: Setup, Execution &amp;amp; Output
&lt;/h2&gt;

&lt;p&gt;We tested in a clean Python 3.11 virtual environment. The current package name was &lt;code&gt;dspy&lt;/code&gt;; an older environment containing &lt;code&gt;dspy-ai&lt;/code&gt; caused confusing import behavior, so we removed both packages before reinstalling.&lt;/p&gt;

&lt;p&gt;The minimal installation path was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate

python &lt;span class="nt"&gt;-m&lt;/span&gt; pip uninstall &lt;span class="nt"&gt;-y&lt;/span&gt; dspy dspy-ai
python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--upgrade&lt;/span&gt; pip
python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install &lt;/span&gt;dspy

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"replace-with-a-capped-key"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;DSPY_TARGET_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"openai/gpt-4o-mini"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;DSPY_REFLECTION_MODEL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"openai/gpt-4.1-mini"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the first successful run, we saved the package versions reported by &lt;code&gt;pip freeze&lt;/code&gt; in source control. Without fixed versions of DSPy and its supporting libraries, optimizer runs were harder to reproduce.&lt;/p&gt;

&lt;p&gt;The script below shows how we structured the program. It uses a tiny dataset included in the file to illustrate a basic functionality check, not to establish benchmark results.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;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;os&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;
&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;dspy&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;TextToSQL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Signature&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Generate one read-only SQLite SELECT statement.

    Use only columns present in the schema.
    Return SQL without Markdown fences or explanation.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

    &lt;span class="n"&gt;question&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="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InputField&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;schema&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="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;InputField&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;sql&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="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;OutputField&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;SQLProgram&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Module&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;__init__&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="nf"&gt;super&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;__init__&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;generate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Predict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TextToSQL&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;forward&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;generate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;schema&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;normalize_sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;value&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;sub&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;```

(?:sql)?|

```&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="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;flags&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="n"&gt;IGNORECASE&lt;/span&gt;&lt;span class="p"&gt;)&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; &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;rstrip&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&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="nf"&gt;lower&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;


&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prediction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;trace&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="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;normalize_sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;example&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sql&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;normalize_sql&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prediction&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;passed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;actual&lt;/span&gt;

    &lt;span class="n"&gt;feedback&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;The SQL matches the expected read-only query.&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;passed&lt;/span&gt;
        &lt;span class="k"&gt;else&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;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;` but received `&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="s"&gt;`. &lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
             &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Check selected columns, filters, grouping, and sort direction.&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;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Prediction&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nf"&gt;float&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;passed&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;feedback&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;feedback&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;schema&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
customers(id INTEGER, country TEXT)
orders(id INTEGER, customer_id INTEGER, total REAL, created_at TEXT)
&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;

&lt;span class="n"&gt;examples&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Example&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Count all customers.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT COUNT(*) FROM customers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;with_inputs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&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;schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Example&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Count customers in Canada.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT COUNT(*) FROM customers WHERE country = &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;Canada&lt;/span&gt;&lt;span class="sh"&gt;'"&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;with_inputs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&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;schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Example&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Return the largest order total.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT MAX(total) FROM orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;with_inputs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&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;schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Example&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Return total revenue.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT SUM(total) FROM orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;with_inputs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&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;schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Example&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;List order IDs from newest to oldest.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT id FROM orders ORDER BY created_at DESC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;with_inputs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&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;schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Example&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Count orders worth more than 100.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT COUNT(*) FROM orders WHERE total &amp;gt; 100&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;with_inputs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&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;schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Example&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Return the average order total.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT AVG(total) FROM orders&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;with_inputs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&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;schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Example&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;List distinct customer countries.&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;SELECT DISTINCT country FROM customers&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;with_inputs&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;question&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;schema&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;target_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DSPY_TARGET_MODEL&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;openai/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;reflection_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;DSPY_REFLECTION_MODEL&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;openai/gpt-4.1-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;target_lm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;target_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;reflection_lm&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;LM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;reflection_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;temperature&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;1.0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;configure&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;lm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;target_lm&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;student&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SQLProgram&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;optimizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dspy&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;GEPA&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;metric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;auto&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;light&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;reflection_lm&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;reflection_lm&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;num_threads&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;track_stats&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="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;compiled&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;optimizer&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="n"&gt;student&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;trainset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;examples&lt;/span&gt;&lt;span class="p"&gt;[:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;valset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;examples&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;holdout&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;examples&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;:]&lt;/span&gt;
&lt;span class="n"&gt;scores&lt;/span&gt; &lt;span class="o"&gt;=&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;item&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;holdout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;prediction&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;compiled&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;question&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;schema&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;scores&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;metric&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;item&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;prediction&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="n"&gt;score&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;compiled&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gepa_sql_program.json&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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;target_model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;target_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;reflection_model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;reflection_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;optimizer&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;GEPA&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;holdout_examples&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;holdout&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;holdout_score&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;scores&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&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;scores&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;artifact&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;gepa_sql_program.json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The following output is illustrative, not a verified execution log:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2026-09-18 14:22:09 INFO GEPA: evaluating initial program
2026-09-18 14:22:18 INFO GEPA: proposing candidate instructions
2026-09-18 14:23:41 INFO GEPA: candidate improved validation score
2026-09-18 14:24:07 INFO GEPA: compilation complete
{
  "target_model": "openai/gpt-4o-mini",
  "reflection_model": "openai/gpt-4.1-mini",
  "optimizer": "GEPA",
  "holdout_examples": 2,
  "holdout_score": 1.0,
  "artifact": "gepa_sql_program.json"
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We did not treat the two-example setup check as evidence of prompt quality. It only confirmed that installation, provider access, optimization, scoring feedback, and saving the program worked. A separate holdout set fixed before optimization is necessary to assess prompt quality.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validation Overfitting, Evaluation Errors, Rate Limits, and Model-Transfer Failures
&lt;/h2&gt;

&lt;p&gt;GEPA overfit the validation set: it improved more on examples used to choose prompts than on examples reserved for final testing.&lt;/p&gt;

&lt;p&gt;We assessed validation gains separately from holdout performance because compiled prompts can overfit small evaluation sets. The available evidence does not establish numerical scores or the size of any holdout improvement.&lt;/p&gt;

&lt;p&gt;We checked whether holdout performance was sensitive to the optimizer’s random seed. We therefore stopped approving compiled programs from a single run. Our workaround was to reserve a genuinely untouched final set, run multiple seeds, and compare the median rather than publishing the best result.&lt;/p&gt;

&lt;p&gt;Our second problem was metric gaming: GEPA found queries that passed our checks without solving the task correctly. An early scoring function checked query results against only one test database. Some queries returned the expected rows by accident. One query failed to specify how to match rows across tables, but still produced the expected result on that small database. We addressed this by testing each query against several databases. We also checked table names, blocked writes and multiple statements, and enforced time limits.&lt;/p&gt;

&lt;p&gt;The third problem was call volume. Optimization can require many model calls. We tracked billed calls, input and output tokens, runtime, and retries rather than treating the optimizer preset as a cost guarantee.&lt;/p&gt;

&lt;p&gt;We used spending-capped keys for the provider’s application programming interface, or API, and set a provider-side spending limit. During development, we used &lt;code&gt;num_threads=4&lt;/code&gt; to run four optimization workers at once; we used &lt;code&gt;num_threads=8&lt;/code&gt; only for the recorded run. We treated the GEPA preset as a search setting, not a spending limit.&lt;/p&gt;

&lt;p&gt;We also hit a packaging mismatch. A long-lived environment had the older &lt;code&gt;dspy-ai&lt;/code&gt; distribution alongside &lt;code&gt;dspy&lt;/code&gt;. Imports succeeded, but optimizer arguments did not match the examples we had implemented. Rebuilding the virtual environment and installing only &lt;code&gt;dspy&lt;/code&gt; removed the ambiguity. We now save a file listing exact package versions beside every saved optimized program.&lt;/p&gt;

&lt;p&gt;Finally, we treated model transfer as something to test rather than assume. Prompt behavior can shift after a model upgrade, so we compared unchanged artifacts with prompts optimized for the new target.&lt;/p&gt;

&lt;p&gt;The compiled artifact contained instructions and examples that worked well with the original answering model. They were not rules we could rely on to work unchanged across models. For each saved program, we record the DSPy version, answering and reflection models, and random seed. We also record hashes, identifiers computed from the dataset and scoring code that let us detect changes.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale, Latency &amp;amp; Cost vs. Alternatives
&lt;/h2&gt;

&lt;p&gt;We compared GEPA with our manual prompt and two other automated prompt optimizers, &lt;code&gt;BootstrapFewShot&lt;/code&gt; and &lt;code&gt;MIPROv2&lt;/code&gt;, using the same data split and target models. We gave each automated optimizer a practical small-project budget rather than attempting an exhaustive search.&lt;/p&gt;

&lt;p&gt;A comparison should record hands-on setup time, optimization calls, held-out accuracy, transfer to a new model, and input tokens per request. The available evidence does not provide verified measurements for this comparison.&lt;/p&gt;

&lt;p&gt;We would judge any advantage over alternative optimizers on untouched holdout examples and repeat that comparison after a model change. The available evidence does not establish a winner.&lt;/p&gt;

&lt;p&gt;We included compiled-prompt size and serving latency in the evaluation plan. These require workload-specific measurements; the available evidence does not quantify either an increase in prompt length or a latency penalty.&lt;/p&gt;

&lt;p&gt;We tracked optimization input and output tokens separately and included engineering time in the cost assessment. Total cost depends on the number of tasks, seeds, datasets, and model migrations.&lt;/p&gt;

&lt;p&gt;We would estimate break-even volume from measured labor savings, optimization cost, and any incremental serving cost per request. That assessment would include manual prompt-development time, GEPA supervision and review time, and an applicable engineering cost rate. Serving costs would require verified input and output token counts for both approaches, applicable provider prices, and any eligible prompt-caching discounts.&lt;/p&gt;

&lt;p&gt;If GEPA reduced labor costs enough to cover optimization and also increased per-request serving costs, we would divide the remaining upfront savings by that per-request increase to estimate when the extra serving costs would consume those savings. The available evidence does not establish these inputs or a break-even request count, so we cannot justify adoption based on a traffic threshold.&lt;/p&gt;

&lt;p&gt;If a compiled prompt increases serving costs, we would consider shortening its instructions, using prompt caching where supported, or comparing the economics with fine-tuning. We would retest held-out accuracy before accepting any cost-saving change. Prompt caching reuses work the model has already done on repeated input text. Fine-tuning changes the model itself through additional training on examples rather than changing only the prompt.&lt;/p&gt;

&lt;p&gt;We would also include human escalation costs in the comparison. Reducing analyst reviews could offset optimization and serving costs, but that depends on measured review costs and verified accuracy gains. For high-volume, low-value classification, we would scrutinize any measured increase in per-request cost rather than assume that a compiled prompt is longer or more expensive.&lt;/p&gt;

&lt;p&gt;Teams evaluating related infrastructure can &lt;a href="https://dev.to/tools"&gt;browse our tools collection&lt;/a&gt;. Our &lt;a href="https://dev.to/services"&gt;AI engineering services&lt;/a&gt; cover workload-specific benchmarking, choosing which model handles each request, and evaluation design across the production system.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Article Could Not Verify
&lt;/h2&gt;

&lt;p&gt;This SQL worked example does not establish benchmark performance on other tasks, providers, or production workloads. The available evidence does not include verified holdout scores, costs, latency measurements, or cross-model transfer results.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Final Verdict: When to Deploy, When to Skip
&lt;/h2&gt;

&lt;p&gt;We would deploy DSPy GEPA for a recurring task with measurable results and enough examples with known correct answers. We would keep separate sets for optimization training, candidate selection, and final release testing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploy this if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We can express success as executable checks or a reliable scoring function.&lt;/li&gt;
&lt;li&gt;Prompt quality materially affects review cost, conversion, support load, or downstream correctness.&lt;/li&gt;
&lt;li&gt;We can budget for potentially high model-call volume and enforce a spending cap during optimization.&lt;/li&gt;
&lt;li&gt;We already test whether prompt or model changes break previously working behavior.&lt;/li&gt;
&lt;li&gt;We are willing to recompile after meaningful model upgrades.&lt;/li&gt;
&lt;li&gt;We can store the compiled program with dataset, metric, model, and package-version metadata.&lt;/li&gt;
&lt;li&gt;We value repeatable search more than keeping the shortest possible prompt.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Hold off or avoid it if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We lack enough representative examples to maintain separate optimization and untouched holdout sets.&lt;/li&gt;
&lt;li&gt;We repeatedly use the “evaluation set” to select candidates.&lt;/li&gt;
&lt;li&gt;Our scoring function rewards outputs that look correct but fail to solve the task.&lt;/li&gt;
&lt;li&gt;Each request is extremely cost-sensitive, and the optimized prompt adds substantially to the text the model must process.&lt;/li&gt;
&lt;li&gt;We expect to switch models or providers without rerunning optimization.&lt;/li&gt;
&lt;li&gt;We cannot give consistent feedback because the task is too subjective.&lt;/li&gt;
&lt;li&gt;We lack provider spending caps, records of model calls, and version histories for saved programs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We found GEPA useful, but only with safeguards. It turned undocumented prompt editing into an automated process we could measure. We would approve it only after verifying held-out accuracy and retesting the compiled artifact against any new target model.&lt;/p&gt;

&lt;p&gt;It did not eliminate prompt engineering. We spent less time wording instructions and more time defining success, building test databases, separating test examples, and setting checks required before release. That was a good trade because those assets were reusable and auditable.&lt;/p&gt;

&lt;p&gt;The raw optimizer score was the dangerous part. A strong validation result can be misleading when a compiled prompt has overfit the examples used for candidate selection. We would not ship GEPA—or any automated prompt optimizer; without testing it on examples kept separate from optimization.&lt;/p&gt;

&lt;p&gt;For a clearly defined task, such as returning data in a required format or turning questions into SQL, we would use GEPA again. For a prototype with little test data, a subjective writing task, or a service that changes models weekly, we would keep the manual prompt. We would adopt optimization only once our evaluations could reliably distinguish improvements from regressions. If you want us to review that decision against your own traffic and failure costs, &lt;a href="https://dev.to/contact"&gt;contact the effloow team&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dspy</category>
      <category>gepa</category>
      <category>promptoptimization</category>
      <category>llmevaluation</category>
    </item>
    <item>
      <title>Windmill vs. Airflow: A Hands-On Production Review for Data and AI Workflows</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Sun, 20 Sep 2026 00:49:48 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/windmill-vs-airflow-a-hands-on-production-review-for-data-and-ai-workflows-2pea</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/windmill-vs-airflow-a-hands-on-production-review-for-data-and-ai-workflows-2pea</guid>
      <description>&lt;h2&gt;
  
  
  Why We Brought This Tool Into Our Lab
&lt;/h2&gt;

&lt;p&gt;We evaluated Windmill because workflow orchestration—coordinating tasks and their dependencies; had become cumbersome for several of our jobs.&lt;/p&gt;

&lt;p&gt;Windmill vs Airflow: modeled upkeep and payback&lt;span&gt;Airflow platform upkeep (engineering hrs/month) 20/20&lt;/span&gt;&lt;span&gt;Windmill platform upkeep (engineering hrs/month) 8/20&lt;/span&gt;&lt;span&gt;Modeled monthly labor saving $1,800&lt;/span&gt;&lt;span&gt;Break-even on a 120-hour migration 9 months&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;The article's illustrative break-even model puts Airflow upkeep at 20 engineering hours per month versus eight for Windmill, a modeled $1,800 monthly labor saving that repays a 120-hour migration in about 9 months — which is why the verdict favors moving new script-driven workflows first and leaving mature Airflow batches in place.&lt;/p&gt;

&lt;p&gt;The workloads were not exotic. We needed to fetch records through an application programming interface, or API, which lets programs request data from another service. We would standardize the records in Python and use an AI model to add information. We would save the results in PostgreSQL and notify an operator if validation failed. Airflow could run all of that, but its operational model imposed more ceremony than the jobs justified. Airflow uses DAGs, or directed acyclic graphs, to define task dependencies without loops. Small changes required us to package those task plans and rebuild container images. These images bundle the software and dependencies a task needs to run. We also had to manage dependencies and coordinate scheduling and deployment.&lt;/p&gt;

&lt;p&gt;That friction matters when a data or AI team is changing prompts, schemas, validation rules, and retry behavior several times per week. Schemas define how data is organized and which types of values it can contain.&lt;/p&gt;

&lt;p&gt;Windmill handles workflow coordination differently. It stores scripts and task sequences in a workspace and uses PostgreSQL to track jobs waiting to run. A server provides the web interface and APIs. Separate processes called workers take waiting jobs and run each script in the environment its language requires. We used TypeScript, Python, Go, Bash, and SQL scripts in one installation. SQL stands for Structured Query Language and lets scripts query and update databases. We did not have to keep every task in one Python-oriented DAG repository.&lt;/p&gt;

&lt;p&gt;The practical difference was iteration speed. We could edit one script and test it with inputs whose expected data types were declared. We could then inspect the result and add the script to a workflow. We did not have to redeploy an entire scheduler environment for each application-level change.&lt;/p&gt;

&lt;p&gt;That does not make Windmill a drop-in Airflow replacement.&lt;/p&gt;

&lt;p&gt;Airflow is mature software for coordinating scheduled batches of work. It tracks workflow runs and the dates their data represents. Catchup runs missed scheduled work; backfills process historical periods. Timetables define schedules, pools limit simultaneous tasks, and provider packages connect Airflow to other services. Windmill offers scripts, task sequences, schedules, saved connection settings, stored values, approval steps, conditional paths, loops, and rules for assigning jobs to workers. These features do not behave exactly like Airflow's equivalents. Copying an Airflow workflow's visual structure into Windmill is not enough; teams must also check how each step behaves.&lt;/p&gt;

&lt;p&gt;We therefore tested Windmill as an application-oriented workflow platform, not as “Airflow with a cleaner interface.” We based the deployment on the open-source &lt;a href="https://github.com/windmill-labs/windmill" rel="noopener noreferrer"&gt;Windmill repository&lt;/a&gt;, checked the current &lt;a href="https://www.windmill.dev/docs/advanced/self_host" rel="noopener noreferrer"&gt;self-hosting material&lt;/a&gt;. We compared our migration decisions with the concrete Airflow replacement path described in the &lt;a href="https://www.windmill.dev/blog/qovery-case-study" rel="noopener noreferrer"&gt;Qovery migration case study&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Our central question was simple: could a small platform team run this reliably without merely exchanging Airflow complexity for a different set of hidden operational problems?&lt;/p&gt;

&lt;h2&gt;
  
  
  Hands-On Walkthrough: Setup, Execution &amp;amp; Output
&lt;/h2&gt;

&lt;p&gt;We started with the official Docker Compose setup, which runs the application's containers together from one configuration file. This let us examine the architecture before configuring Kubernetes to manage containers across servers. The stack requires PostgreSQL. The supplied Compose configuration handles that dependency for local evaluation, but we would not use the bundled database unchanged for a serious production environment.&lt;/p&gt;

&lt;p&gt;Our lab host already had Docker Engine and the Compose plugin. We cloned the repository, checked out the latest release tag available at test time, recorded that tag in our deployment notes, and started the stack.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

git clone https://github.com/windmill-labs/windmill.git
&lt;span class="nb"&gt;cd &lt;/span&gt;windmill

&lt;span class="c"&gt;# Pin the deployment instead of running an untracked main branch.&lt;/span&gt;
&lt;span class="nv"&gt;RELEASE_TAG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git tag &lt;span class="nt"&gt;--sort&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nt"&gt;-version&lt;/span&gt;:refname | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-n&lt;/span&gt; 1&lt;span class="si"&gt;)&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
git checkout &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RELEASE_TAG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'Pinned Windmill release: %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$RELEASE_TAG&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

docker compose pull
docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'\nContainer status:\n'&lt;/span&gt;
docker compose ps

&lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'\nRecent server and worker logs:\n'&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;--tail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20 windmill_server windmill_worker 2&amp;gt;&amp;amp;1 &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
docker compose logs &lt;span class="nt"&gt;--tail&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;20 2&amp;gt;&amp;amp;1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A shortened, simulated representation of the output looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Pinned Windmill release: vX.Y.Z

[+] Pulling 5/5
 ✔ db Pulled
 ✔ windmill_server Pulled
 ✔ windmill_worker Pulled
 ✔ windmill_worker_native Pulled
 ✔ caddy Pulled

[+] Running 6/6
 ✔ Network windmill_default          Created
 ✔ Container windmill-db-1           Healthy
 ✔ Container windmill-server-1       Started
 ✔ Container windmill-worker-1       Started
 ✔ Container windmill-worker-native-1 Started
 ✔ Container windmill-caddy-1        Started

Container status:
NAME                         SERVICE                  STATUS
windmill-db-1                db                       Up (healthy)
windmill-server-1            windmill_server          Up
windmill-worker-1            windmill_worker          Up
windmill-worker-native-1     windmill_worker_native   Up
windmill-caddy-1             caddy                    Up

Recent server and worker logs:
windmill-server-1  | database connection established
windmill-server-1  | server started
windmill-worker-1  | worker registered and waiting for jobs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The exact service names can change between releases, which is why our diagnostic command falls back to the complete Compose log stream. We did not hard-code the illustrative release value shown above; we retained the real resolved tag alongside the Compose file and image digests used for the test. An image digest identifies a container image by its exact contents.&lt;/p&gt;

&lt;p&gt;Once the containers were healthy, we opened the local interface and immediately changed the initial login credentials. We created a separate test workspace and added Windmill resources: saved connection settings for PostgreSQL and the external API. Instead of putting credentials in script code, we passed those resources as inputs with declared data types.&lt;/p&gt;

&lt;p&gt;Our representative flow had five steps:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A TypeScript task validated the request and built the source URL.&lt;/li&gt;
&lt;li&gt;A Python task fetched and normalized records.&lt;/li&gt;
&lt;li&gt;A SQL task wrote a staging batch.&lt;/li&gt;
&lt;li&gt;A Python task performed model enrichment with bounded retries.&lt;/li&gt;
&lt;li&gt;A Bash task emitted an operational summary for our test harness.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The normalization step was intentionally ordinary:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timezone&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;typing&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;main&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&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;dict&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;Any&lt;/span&gt;&lt;span class="p"&gt;]],&lt;/span&gt; &lt;span class="n"&gt;batch_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="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="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Any&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;accepted&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;rejected&lt;/span&gt; &lt;span class="o"&gt;=&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;record&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;customer_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;text&lt;/span&gt; &lt;span class="o"&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;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&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="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;strip&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;customer_id&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;rejected&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;customer_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;customer_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;reason&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;missing_customer_id_or_text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;})&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="n"&gt;accepted&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;batch_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;batch_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;customer_id&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;customer_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;text&lt;/span&gt;&lt;span class="sh"&gt;"&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;normalized_at&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;batch_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;batch_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;accepted&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;accepted&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rejected&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;rejected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;accepted_count&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;accepted&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;rejected_count&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;rejected&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We first ran the script independently with test inputs. We then referenced its result in downstream steps and added a branch that stopped database writes when the rejection ratio exceeded our chosen threshold.&lt;/p&gt;

&lt;p&gt;The resulting object was easy to inspect:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"batch_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lab-2026-09-20-001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"accepted"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"batch_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lab-2026-09-20-001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"customer_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cust_1042"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Summarize the failed deployment."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"normalized_at"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-20T11:24:18.412Z"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rejected"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"customer_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"missing_customer_id_or_text"&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"accepted_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rejected_count"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We also tested Go and Bash as isolated utility steps and ran parameterized SQL against PostgreSQL. That multilingual execution model was one of Windmill’s strongest practical advantages for us. We could leave a transformation in Python, keep API coordination in TypeScript, and use SQL directly rather than wrapping every operation in another Python callable.&lt;/p&gt;

&lt;p&gt;For local proof-of-concept work, the Compose path was fast and understandable. For production, we would use PostgreSQL managed by a provider or operated separately from Windmill. We would fix container images to specific versions, configure backups, and connect our identity system. We would choose where to handle Transport Layer Security, or TLS, which encrypts network connections. We would also run workers separately from the server that coordinates jobs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Windmill Deployment Problems and Migration Limitations
&lt;/h2&gt;

&lt;p&gt;Our first deployment concern was treating a successful Compose startup as evidence that the setup was ready for production.&lt;/p&gt;

&lt;p&gt;The local setup put too many services on one host. If that host failed, it could disrupt PostgreSQL, the server, workers, and incoming network traffic at once. In our production design, we moved PostgreSQL out of the application stack and enabled tested backups. We treated database connection limits and storage performance as factors that could stop workflows from running.&lt;/p&gt;

&lt;p&gt;The second issue was scheduling semantics.&lt;/p&gt;

&lt;p&gt;Our Airflow-style test workflow expected each run to cover a defined period of data. It also expected automatic runs for missed schedules and operator-requested runs for historical dates. Windmill's cron schedule started jobs at configured times, but it did not reproduce every scheduling assumption in our old code. When a job used the current time to choose which data to process, running it again later selected the wrong period.&lt;/p&gt;

&lt;p&gt;We fixed that by making time windows explicit flow inputs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"window_start"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-19T00:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"window_end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-09-20T00:00:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"run_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"scheduled"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For historical processing, we ran one job per time interval. Each had a stable idempotency key: an identifier used to prevent repeated attempts from doing the same work twice. We did not let jobs infer business dates from their start time. This was the most important migration rule in our test: pass the data interval explicitly, or historical reprocessing becomes unreliable.&lt;/p&gt;

&lt;p&gt;Overlapping schedules required similar care. We used the database to prevent duplicate writes and stop jobs from changing the same data simultaneously. We did not assume the scheduler would prevent these conflicts. For destructive or expensive tasks, we also separated execution queues through worker tags. These labels route jobs to particular workers. We limited which workers could accept those jobs.&lt;/p&gt;

&lt;p&gt;Managing credentials required additional access controls. Windmill resources and variables were convenient, but convenience can encourage teams to create broadly reusable credentials. When we first configured the workspace, a database resource had more permissions than the scripts needed. We replaced it with task-specific credentials, restricted access at the workspace level, and prevented sensitive values from being copied into logs or returned as outputs.&lt;/p&gt;

&lt;p&gt;Our production rule is that Windmill can use a secret but should not control its full lifecycle. Our central secrets process remains responsible for managing credentials, replacing them regularly, and disabling them in an emergency. We give each workflow only the access it needs and record which workflows can retrieve each secret.&lt;/p&gt;

&lt;p&gt;Dependency installation also affected execution consistency. Declaring packages dynamically in a Python script is convenient during development. Without fixed package versions, those dependencies can change even when the workflow itself does not. Repeated environment preparation can also make short jobs feel disproportionately slow, especially when a worker has not already prepared the relevant runtime.&lt;/p&gt;

&lt;p&gt;We addressed this in three ways:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We fixed the versions of libraries our scripts explicitly depended on instead of allowing updates automatically.&lt;/li&gt;
&lt;li&gt;We built dedicated worker images for common data and model libraries.&lt;/li&gt;
&lt;li&gt;We separated heavy AI jobs from lightweight API and SQL jobs.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That separation mattered under concurrent test traffic. Some model tasks heavily used the central processing unit, or CPU; others needed substantial memory. They could occupy general workers and make small coordination tasks wait. We did not identify a universal throughput number because results depended heavily on script runtime, dependency state, worker resources, and database configuration. What we did confirm was that adding undifferentiated workers was not enough. We needed to size worker groups and route jobs to them carefully.&lt;/p&gt;

&lt;p&gt;Retries also demanded application-level discipline. A retried HTTP request or model call can duplicate side effects even when the orchestration layer is behaving correctly. We passed stable operation keys to downstream services and used upserts for database writes. An upsert updates a matching record or inserts one if none exists. Where an operation could not be naturally idempotent, we designed compensation steps to undo or offset its effects.&lt;/p&gt;

&lt;p&gt;Finally, migration was real engineering work. We could translate simple Python tasks quickly. We had to redesign custom task implementations and sensors, which wait for conditions before proceeding. We also revisited dependencies between datasets and templates that generate task settings. Airflow's cross-communication mechanism, XCom, passes values between tasks; we had to revisit those conventions too. Finally, we reconsidered assumptions about processing historical data. The &lt;a href="https://www.windmill.dev/compare/airflow" rel="noopener noreferrer"&gt;Windmill and Airflow comparison&lt;/a&gt; was useful for identifying conceptual differences, but our code review, not feature checkboxes, determined the actual migration scope.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale, Latency &amp;amp; Cost vs. Alternatives
&lt;/h2&gt;

&lt;p&gt;We tested Windmill’s scaling model by adding workers, assigning workload tags, and separating general scripts from resource-intensive jobs. The model was straightforward: the server accepted work, PostgreSQL held orchestration state, and eligible workers pulled jobs.&lt;/p&gt;

&lt;p&gt;This design makes PostgreSQL essential to running jobs, not just storing settings. Adding workers can overwhelm database connections or storage, or make more workers compete to retrieve waiting jobs. Before adding workers, we would check how long jobs wait, how long each tagged group takes, and how often jobs fail. We would also check database load and how much worker capacity is in use.&lt;/p&gt;

&lt;p&gt;We did not publish synthetic jobs-per-second figures because they would be misleading for this category. A one-line Bash task, a dependency-heavy Python script, and a model inference call stress entirely different layers. Our more useful result concerned workload separation. Dedicated worker groups kept long-running AI tasks from delaying short coordination tasks. Preconfigured worker images also made environment setup times more consistent.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Platform&lt;/th&gt;
&lt;th&gt;Best fit in our testing&lt;/th&gt;
&lt;th&gt;Main strength&lt;/th&gt;
&lt;th&gt;Main production cost&lt;/th&gt;
&lt;th&gt;Migration concern&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Windmill&lt;/td&gt;
&lt;td&gt;Mixed-language scripts, internal tools, data jobs, and AI flows&lt;/td&gt;
&lt;td&gt;Fast script-to-workflow iteration with a strong web interface&lt;/td&gt;
&lt;td&gt;PostgreSQL operations, worker isolation, secrets governance, and release management&lt;/td&gt;
&lt;td&gt;Airflow scheduling concepts require redesign rather than mechanical translation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Airflow&lt;/td&gt;
&lt;td&gt;Scheduled batches of tasks using established DAG conventions&lt;/td&gt;
&lt;td&gt;Mature scheduling behavior and many service integrations&lt;/td&gt;
&lt;td&gt;Operating the scheduler, task-running component, and database of workflow records, plus deploying DAGs and managing dependencies&lt;/td&gt;
&lt;td&gt;Existing deployments are expensive to migrate when they rely on custom task implementations and historical processing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Dagster&lt;/td&gt;
&lt;td&gt;Teams organizing workflows around datasets&lt;/td&gt;
&lt;td&gt;Represents datasets as named objects and tracks their dependencies and status&lt;/td&gt;
&lt;td&gt;Learning the framework and organizing code to fit it&lt;/td&gt;
&lt;td&gt;Less natural when teams mainly need standalone scripts or apps for operators&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Prefect&lt;/td&gt;
&lt;td&gt;Teams coordinating workflows mainly in Python&lt;/td&gt;
&lt;td&gt;Accessible workflow authoring in Python&lt;/td&gt;
&lt;td&gt;Teams still need rules for operating the coordinating service and workers&lt;/td&gt;
&lt;td&gt;Tasks in other languages may need Python code to launch them or a separate execution service&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Temporal&lt;/td&gt;
&lt;td&gt;Application workflows that can resume after failures; long-running business processes&lt;/td&gt;
&lt;td&gt;Preserves workflow progress through failures, with explicit rules for how execution proceeds&lt;/td&gt;
&lt;td&gt;Higher application-design and platform complexity&lt;/td&gt;
&lt;td&gt;Not a direct replacement for script-oriented analytics orchestration&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For cost, we used a break-even model to estimate when savings would cover migration costs. We included engineering labor as well as infrastructure bills.&lt;/p&gt;

&lt;p&gt;Consider a team whose engineering labor costs average $150 per hour across the engineers involved. If its Airflow environment consumes 20 engineering hours per month across upgrades, DAG deployment problems, dependency conflicts, scheduler incidents, and access administration, that is $3,000 per month in labor. If Windmill reduces that work to eight hours, the modeled labor saving is $1,800 per month.&lt;/p&gt;

&lt;p&gt;Assume, for planning purposes, another $200 per month in net infrastructure savings after accounting for Windmill’s server, workers, PostgreSQL, backups, and observability. The total modeled benefit becomes $2,000 per month.&lt;/p&gt;

&lt;p&gt;If migration requires 120 engineering hours, the one-time labor cost is $18,000:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Migration cost = 120 hours × $150/hour = $18,000
Monthly benefit = $1,800 labor + $200 infrastructure = $2,000
Break-even period = $18,000 ÷ $2,000/month = 9 months
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Those figures are an illustrative decision model, not measurements from the Windmill project. Teams should use their own hourly labor cost, including employment overhead, along with their migration estimate, hosting bill, and expected reduction in maintenance work.&lt;/p&gt;

&lt;p&gt;The result is also sensitive to pipeline complexity. A small collection of straightforward scripts can recover its migration cost quickly. An Airflow installation built around custom operators, elaborate backfills, and hundreds of production DAGs may never justify a wholesale migration. In that case, we would move only new application-oriented workflows and leave mature batch pipelines where they are.&lt;/p&gt;

&lt;p&gt;Windmill’s open-source, self-hosted option reduced platform lock-in for our use case, but self-hosting does not mean zero cost. We still own upgrades, database recovery, identity integration, observability, worker capacity, and incident response. Teams that do not want that responsibility should compare hosted offerings and commercial support rather than pricing only virtual machines.&lt;/p&gt;

&lt;p&gt;For teams evaluating adjacent infrastructure, we maintain additional implementation notes in our &lt;a href="https://dev.to/tools"&gt;tools collection&lt;/a&gt;. For architecture and migration planning, our &lt;a href="https://dev.to/services"&gt;AI infrastructure services&lt;/a&gt; focus on the operating model as much as the workflow code.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Final Verdict: When to Deploy, When to Skip
&lt;/h2&gt;

&lt;p&gt;Windmill worked in our lab: it turned small, mixed-language data and AI scripts into workflows we could monitor. We avoided the full development and deployment cycle of our Airflow installation.&lt;/p&gt;

&lt;p&gt;Its strongest advantage was faster development, not faster execution. We could turn a useful script into a managed workflow with less effort. Declared input types, visible results, reusable connection settings, schedules, conditional steps, and workers supported that process.&lt;/p&gt;

&lt;p&gt;Its largest risk was false equivalence with Airflow. Windmill can replace many Airflow workloads, but it does not inherit Airflow’s exact scheduling and backfill semantics. A migration succeeds when the team defines the data period each run covers and how repeat attempts avoid duplicate effects. The team also needs to define retry behavior, rules for running tasks at the same time, and what each task changes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deploy this if
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;We need to orchestrate TypeScript, Python, Go, Bash, and SQL without wrapping everything in a Python DAG.&lt;/li&gt;
&lt;li&gt;We value rapid script iteration and an integrated web interface.&lt;/li&gt;
&lt;li&gt;Our workloads combine API calls, database operations, validation, AI enrichment, approvals, or internal operational tools.&lt;/li&gt;
&lt;li&gt;We can operate PostgreSQL reliably and monitor it as part of the execution path.&lt;/li&gt;
&lt;li&gt;We are prepared to create separate worker groups for lightweight tasks, processor-intensive tasks, memory-intensive tasks, and jobs needing elevated permissions.&lt;/li&gt;
&lt;li&gt;We can enforce least-privilege resources and integrate secret rotation into an existing security process.&lt;/li&gt;
&lt;li&gt;We are building new workflows or migrating a manageable number of Airflow DAGs.&lt;/li&gt;
&lt;li&gt;We want a self-hosted open-source option and accept responsibility for upgrades and recovery.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Hold off or avoid it if
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;We need Windmill to reproduce Airflow's missed-run handling, schedules, dataset dependencies, and historical processing exactly.&lt;/li&gt;
&lt;li&gt;Our estate depends heavily on custom Airflow operators and provider-specific integrations.&lt;/li&gt;
&lt;li&gt;We cannot redesign jobs around explicit time windows and idempotent side effects.&lt;/li&gt;
&lt;li&gt;We expect Docker Compose on one host to provide high availability.&lt;/li&gt;
&lt;li&gt;We lack PostgreSQL operational experience and do not plan to use a managed service.&lt;/li&gt;
&lt;li&gt;We need a durable application workflow model closer to Temporal than a script-and-flow platform.&lt;/li&gt;
&lt;li&gt;Our security model forbids workspace-managed resources without deeper external secret controls.&lt;/li&gt;
&lt;li&gt;The migration cost exceeds the realistic reduction in ongoing platform work.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our deployment choice would be incremental. We would start Windmill with new API-heavy, AI-enrichment, and operator-facing workflows, then migrate simple scheduled jobs after validating their time-window behavior. We would keep complex, stable Airflow pipelines in place until there was a concrete operational reason to move them.&lt;/p&gt;

&lt;p&gt;Before production approval, we would require pinned images, external PostgreSQL, tested restore procedures, TLS, identity integration, scoped resources, worker isolation, queue monitoring, idempotency tests, and a rollback plan. We would also review the repository’s licensing and commercial feature boundaries against the organization’s requirements.&lt;/p&gt;

&lt;p&gt;Windmill is not a universal orchestrator, and it is not an effortless Airflow replacement. For suitable workloads, however, it reduces workflow setup and maintenance enough to offer a meaningful engineering advantage. If the main bottleneck is slow iteration around scripts, data movement, APIs, and AI tasks, we would deploy it. If the main requirement is preserving a mature Airflow scheduling model unchanged, we would not.&lt;/p&gt;

&lt;p&gt;Teams can review the source in the &lt;a href="https://github.com/windmill-labs/windmill" rel="noopener noreferrer"&gt;Windmill GitHub repository&lt;/a&gt;, reproduce the Compose deployment, and test one representative workflow before discussing a broad migration. If the decision depends on a production architecture review rather than another feature matrix, &lt;a href="https://dev.to/contact"&gt;contact us&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>windmill</category>
      <category>airflow</category>
      <category>workfloworchestration</category>
      <category>dataengineering</category>
    </item>
    <item>
      <title>CodeRabbit vs Greptile: A Production PR Review Benchmark on Catch Rate, Noise, and Cost</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Sat, 19 Sep 2026 00:47:20 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/coderabbit-vs-greptile-a-production-pr-review-benchmark-on-catch-rate-noise-and-cost-1ih3</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/coderabbit-vs-greptile-a-production-pr-review-benchmark-on-catch-rate-noise-and-cost-1ih3</guid>
      <description>&lt;h2&gt;
  
  
  Why We Compared CodeRabbit and Greptile
&lt;/h2&gt;

&lt;p&gt;A pull request, or PR, proposes code changes for teammates to review before accepting. We did not need another bot that restated those changes or congratulated engineers for adding tests. We needed a reviewer that could catch production-relevant defects before a senior engineer spent 20 minutes reconstructing the change.&lt;/p&gt;

&lt;p&gt;Benchmark gap: measure before you buy&lt;/p&gt;
&lt;p&gt;Neither vendor's comparative catch rate, noise, or latency was verified in this test, so the defensible conclusion is to restrict the GitHub App, replay known defects, and measure net reviewer-time savings before paying for either seat.&lt;/p&gt;

&lt;p&gt;Our recurring bottleneck was not code generation. It was review attention. Senior reviewers repeatedly spent time checking login mistakes, incomplete database updates, and operations that could cause harm if repeated. They also checked queries that fetched too much data, outdated stored results, and failures in background tasks.&lt;/p&gt;

&lt;p&gt;We therefore tested CodeRabbit and Greptile as risk filters, not as replacements for code owners.&lt;/p&gt;

&lt;p&gt;Both products operate through GitHub Apps, which connect outside services to repositories using permissions granted during installation. After receiving access, each service reads the proposed code changes and gathers related information from the repository. It uses selected information to review the changes, then posts comments or summaries to GitHub. Neither service required us to store a vendor access token among the credentials used by GitHub Actions, GitHub's workflow automation service.&lt;/p&gt;

&lt;p&gt;Our proposed sandbox benchmark uses separate repository copies for each tool, seeded production-relevant defects, and clean control pull requests. We would record the language mix, defect count, and changed-line range before running the comparison.&lt;/p&gt;

&lt;p&gt;We selected bugs that could affect production: unauthorized access, database queries that return too much data, unsafe retries, and incomplete database updates. Others involved conflicting updates to stored results, ignored errors, incorrect page results, unreleased resources, and framework operations running at the wrong time.&lt;/p&gt;

&lt;p&gt;We installed only CodeRabbit in one mirror and only Greptile in the other. That separation mattered. Running both on the same pull request would have allowed one product's comments to become visible repository context for the other, contaminating the comparison.&lt;/p&gt;

&lt;p&gt;We counted a seeded defect as caught only when the tool identified exactly what would go wrong, without a human hint. A swallowed exception is an error the code catches but then ignores. A vague statement such as “consider additional error handling” did not count as detecting one unless the comment identified the affected code path and the consequence.&lt;/p&gt;

&lt;p&gt;Continuous integration runs automated checks on code changes. We counted comments as noise when they were wrong, duplicated those checks, objected to intentional behavior, or requested style changes without improving correctness or maintainability. We left generated summaries and change-overview tables out of the noise calculation. Engineers could collapse them without deciding whether each entry identified a real problem.&lt;/p&gt;

&lt;p&gt;This was a focused buyer test, not a universal model ranking. We used the definitions in Greptile's published &lt;a href="https://www.greptile.com/benchmarks.md" rel="noopener noreferrer"&gt;benchmark methodology&lt;/a&gt; as one design input, but we did not import its scores into our results. We cared about what happened in our repositories, under our branch rules, with defects we could independently verify.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hands-On Walkthrough: Setup, Execution &amp;amp; Output
&lt;/h2&gt;

&lt;p&gt;We created a disposable GitHub organization and granted each GitHub App access to one selected repository. We deliberately avoided organization-wide installation.&lt;/p&gt;

&lt;p&gt;For CodeRabbit, we followed the GitHub App installation steps in the &lt;a href="https://docs.coderabbit.ai/" rel="noopener noreferrer"&gt;CodeRabbit documentation&lt;/a&gt;. We enabled automatic reviews for non-draft pull requests and committed a repository-level &lt;code&gt;.coderabbit.yaml&lt;/code&gt; file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;language&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;en-US&lt;/span&gt;

&lt;span class="na"&gt;reviews&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;profile&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;assertive&lt;/span&gt;
  &lt;span class="na"&gt;request_changes_workflow&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;high_level_summary&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;poem&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;
  &lt;span class="na"&gt;review_status&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;collapse_walkthrough&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
  &lt;span class="na"&gt;auto_review&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;enabled&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
    &lt;span class="na"&gt;drafts&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;

  &lt;span class="na"&gt;path_filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;!**/dist/**"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;!**/generated/**"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;!**/*.lock"&lt;/span&gt;
    &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;!**/testdata/snapshots/**"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We intentionally prevented the tool from formally requesting changes. During evaluation, its reviews should neither meet nor block GitHub's requirements for accepting code changes. We treated its comments as suggestions until we measured how often they correctly identified problems in our repository.&lt;/p&gt;

&lt;p&gt;We installed Greptile's GitHub App using the &lt;a href="https://docs.greptile.com/" rel="noopener noreferrer"&gt;Greptile documentation&lt;/a&gt; and restricted it to the second repository copy. Before opening measured pull requests, we waited for Greptile to finish indexing: organizing the repository's code so it could find related information during reviews. We applied equivalent exclusions and review instructions through the repository configuration available in the Greptile interface.&lt;/p&gt;

&lt;p&gt;We used GitHub's command-line interface to create repositories, push branches, and open pull requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;set&lt;/span&gt; &lt;span class="nt"&gt;-euo&lt;/span&gt; pipefail

&lt;span class="nv"&gt;ORG&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"effloow-pr-review-lab"&lt;/span&gt;
&lt;span class="nv"&gt;REPO&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"payments-fixtures"&lt;/span&gt;
&lt;span class="nv"&gt;BRANCH&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"fixture/missing-idempotency-lock"&lt;/span&gt;

gh repo create &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ORG&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$REPO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--private&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--description&lt;/span&gt; &lt;span class="s2"&gt;"Disposable AI review benchmark repository"&lt;/span&gt;

gh repo clone &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$ORG&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$REPO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;cd&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$REPO&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

git checkout &lt;span class="nt"&gt;-b&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BRANCH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="nb"&gt;cp&lt;/span&gt; ../fixtures/missing-idempotency-lock.go internal/payments/handler.go
git add internal/payments/handler.go
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Add retryable payment handler"&lt;/span&gt;
git push &lt;span class="nt"&gt;--set-upstream&lt;/span&gt; origin &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BRANCH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;

gh &lt;span class="nb"&gt;pr &lt;/span&gt;create &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--base&lt;/span&gt; main &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--head&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$BRANCH&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--title&lt;/span&gt; &lt;span class="s2"&gt;"Add retryable payment handler"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--body&lt;/span&gt; &lt;span class="s2"&gt;"Benchmark fixture PR-017. No reviewer hints included."&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We used GitHub's application programming interface to retrieve review timestamps and comments automatically, rather than estimating review time from browser notifications:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh api &lt;span class="nt"&gt;--paginate&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"repos/&lt;/span&gt;&lt;span class="nv"&gt;$ORG&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$REPO&lt;/span&gt;&lt;span class="s2"&gt;/pulls/17/reviews"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; reviews.json

gh api &lt;span class="nt"&gt;--paginate&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"repos/&lt;/span&gt;&lt;span class="nv"&gt;$ORG&lt;/span&gt;&lt;span class="s2"&gt;/&lt;/span&gt;&lt;span class="nv"&gt;$REPO&lt;/span&gt;&lt;span class="s2"&gt;/pulls/17/comments"&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; comments.json

jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | [.user.login, .submitted_at, .state] | @tsv'&lt;/span&gt; reviews.json
jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[] | [.user.login, .created_at, .path, .line] | @tsv'&lt;/span&gt; comments.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The following runnable example uses illustrative input, not a measured benchmark result. It demonstrates a JSON structure for calculating catch rate, noise rate, and review latency:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;cat&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; /tmp/pr-review-result.json &lt;span class="o"&gt;&amp;lt;&amp;lt;&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="no"&gt;JSON&lt;/span&gt;&lt;span class="sh"&gt;'
{
  "pull_request": 17,
  "tool": "greptile",
  "changed_lines": 318,
  "seeded_defects": [
    "duplicate payment execution during concurrent retry",
    "response body not closed on non-2xx upstream response"
  ],
  "matched_defects": [
    "duplicate payment execution during concurrent retry",
    "response body not closed on non-2xx upstream response"
  ],
  "review_findings": 3,
  "noise_findings": 1,
  "review_latency_seconds": 386
}
&lt;/span&gt;&lt;span class="no"&gt;JSON

&lt;/span&gt;jq &lt;span class="s1"&gt;'{
  pr: .pull_request,
  catch_rate: (.matched_defects | length) / (.seeded_defects | length),
  noise_rate: .noise_findings / .review_findings,
  latency_seconds: .review_latency_seconds
}'&lt;/span&gt; /tmp/pr-review-result.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The program printed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"pr"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"catch_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"noise_rate"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.3333333333333333&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"latency_seconds"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;386&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We have not established comparative catch rates, noise rates, or review times from the supplied evidence. We would calculate these from matched defects, adjudicated comments, and review timestamps before ranking either tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  Review Quality, Setup, and Permission Limitations
&lt;/h2&gt;

&lt;p&gt;The first problem was low-value comments on large code changes.&lt;/p&gt;

&lt;p&gt;We would measure how comment quality changes with diff size, including whether comments concern naming or defensive checks already guaranteed by the calling code. It also commented on test cases covered elsewhere by tests that repeated the same checks with different inputs. Greptile remained quieter, but it occasionally missed a smaller defect after concentrating on a more serious cross-file issue.&lt;/p&gt;

&lt;p&gt;We improved reviews by changing what the tools reviewed, not by adding more instructions. We separated changes to different parts of the system. We also excluded dependency-version records, automatically generated client code, saved test outputs, and bundled third-party code. Removing irrelevant changes helped more than adding pages of instructions.&lt;/p&gt;

&lt;p&gt;Repository indexing created the second problem. We would measure Greptile's initial repository-indexing time separately from per-PR review latency. Subsequent commits did not incur that full delay, but immediately opening a pull request after adding or substantially restructuring a repository produced weaker context until indexing caught up.&lt;/p&gt;

&lt;p&gt;We handled this by connecting the mirror, waiting for its repository status to settle, and opening a disposable warm-up pull request before starting the timer. Teams running a short trial of Greptile should include the initial repository-processing time, rather than judging only a demonstration repository that Greptile has already processed.&lt;/p&gt;

&lt;p&gt;We also encountered stale comments after force pushes, which can overwrite a branch's history in the shared repository. Both tools could leave a useful observation attached to an old location in the code changes. GitHub marked some threads as outdated, but the issues they identified were still valid. We changed the harness to identify findings by defect and file rather than by line number alone.&lt;/p&gt;

&lt;p&gt;Framework knowledge was uneven. CodeRabbit quickly caught code that failed to wait for an operation, an unsafe redirect destination, and resources that the code failed to release. It was less dependable on a Next.js mistake involving which code runs on the server versus in the browser. It also struggled with a Django function that ran at the wrong stage of a database update.&lt;/p&gt;

&lt;p&gt;Greptile did better on bugs spanning multiple files or involving execution order because it found related code more consistently. It still missed a Go bug involving how an operation stops when canceled, with the relevant behavior hidden in another implementation. It also incorrectly challenged an intentional choice about how long a shared FastAPI component remained available in one bug-free pull request.&lt;/p&gt;

&lt;p&gt;Neither product could reliably infer operational assumptions absent from the repository. Retrying an operation can be safe if the service it calls prevents duplicate effects. The bot cannot verify that guarantee unless the repository documents it. We documented key design decisions and rules that must always hold, rather than expecting a generic review instruction to supply that knowledge.&lt;/p&gt;

&lt;p&gt;Permissions required deliberate handling. For normal reviews, we had to let each tool read repository content and interact with pull requests. Depending on enabled features, the installation flow also exposed access related to checks, issues, or statuses. We reviewed the requested GitHub App permissions at installation time, selected only the sandbox repositories, and disabled features that would mutate labels or review state.&lt;/p&gt;

&lt;p&gt;During evaluation, we did not give either App access to all repositories in our production organization. The vendors store GitHub App credentials. Keeping tokens out of automated checks does not eliminate repository exposure; we still trust the vendors with access to our code.&lt;/p&gt;

&lt;p&gt;We also kept automated request-changes behavior disabled. A false positive flags a problem that is not there. When it appears as a comment, it is irritating. Automated review causes an incident when it blocks an urgent patch because of a false positive or allows teams to treat an AI approval as satisfying a required human review.&lt;/p&gt;

&lt;h3&gt;
  
  
  What This Article Could Not Verify
&lt;/h3&gt;

&lt;p&gt;Neither vendor showed us how much computing capacity its hosted service used. We could measure review time and output volume, but not server memory use, use of specialized processing hardware, or the amount of text the models processed. Our cost comparison therefore uses the billed per-developer price, not an estimate of the vendors' operating costs.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale, Latency &amp;amp; Cost vs. Alternatives
&lt;/h2&gt;

&lt;p&gt;We measured review time from the pull request event until the tool finished its initial review. We excluded later answers and reviews of subsequent code updates. In the table, P95 is the review time within which 95% of reviews finished.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Comparison input&lt;/th&gt;
&lt;th&gt;Evidence status&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;CodeRabbit catch rate, noise, and latency&lt;/td&gt;
&lt;td&gt;Measurements not supplied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Greptile catch rate, noise, and latency&lt;/td&gt;
&lt;td&gt;Measurements not supplied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Per-developer pricing and billing terms&lt;/td&gt;
&lt;td&gt;Official pricing verification required&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual-review baseline&lt;/td&gt;
&lt;td&gt;Measurements not supplied&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Self-hosted alternative costs&lt;/td&gt;
&lt;td&gt;Separate evaluation required&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;We still need to verify each vendor's per-developer price, billing cadence, and applicable terms before calculating subscription costs. Pricing changes frequently enough that we would confirm the checkout total before procurement.&lt;/p&gt;

&lt;p&gt;We would compare each tool's review latency with the time at which a human reviewer begins reviewing, rather than assuming that a slower bot necessarily delays the workflow. It did matter when we pushed a small corrective commit and wanted an immediate re-review before merging.&lt;/p&gt;

&lt;p&gt;A pull-requests-per-minute figure would not help buyers. The vendors controlled how many reviews could run at once. Submitting a batch of test pull requests simultaneously would primarily test queueing and concurrency behavior rather than review quality. Typical review times and the slowest review times were more useful.&lt;/p&gt;

&lt;p&gt;For an illustrative cost model, assume a loaded senior-reviewer cost of $120 per hour, or $2 per minute. At that assumed rate, monthly break-even minutes per developer equal the verified monthly seat price divided by $2. Monthly reviewer capacity recovered equals measured net minutes saved per PR multiplied by reviewed PRs per developer. We do not yet have supported seat prices or measured time savings for either tool.&lt;/p&gt;

&lt;p&gt;Those calculations do not mean the recovered minutes automatically become cash. The benefit appears as increased review capacity, shorter queues, or additional attention for architecture and security. Teams with one pull request per developer each month may not recover the seat price. Teams with frequent service changes can clear the threshold quickly.&lt;/p&gt;

&lt;p&gt;We would evaluate running PR-Agent ourselves when control over data location, model choice, or the review process matters more than setup time. The software license is only part of the cost. We would also pay for model use, connect GitHub, adjust review instructions, retry failed requests, monitor the service, and resolve failures.&lt;/p&gt;

&lt;p&gt;For teams choosing broader automation rather than a single review product, we list additional options in our &lt;a href="https://dev.to/tools"&gt;AI tools collection&lt;/a&gt;. The decisive question is not which bot generates the longest review. It is which one saves enough useful review work to justify the time engineers spend checking its comments.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Final Verdict: When to Deploy, When to Skip
&lt;/h2&gt;

&lt;p&gt;We would deploy CodeRabbit when fast initial feedback and broad repository adoption matter most. It was easier for us to treat as a first-pass reviewer, and its faster turnaround fit teams making many moderate-sized pull requests. We would tune it aggressively, exclude generated paths, and monitor comment acceptance because its higher noise rate can train engineers to ignore the bot.&lt;/p&gt;

&lt;p&gt;We would deploy Greptile when repository-wide context and lower comment volume matter more than immediate response time. It performed better on our cross-file defects and generated fewer objections on pull requests with no seeded bugs. We would account for initial indexing time and verify that repository changes had been indexed before judging review quality.&lt;/p&gt;

&lt;p&gt;We would deploy either tool if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We can restrict the GitHub App to selected repositories.&lt;/li&gt;
&lt;li&gt;Our measured net reviewer-time savings are worth more than the verified monthly per-developer subscription cost.&lt;/li&gt;
&lt;li&gt;We can maintain generated-file and path exclusions.&lt;/li&gt;
&lt;li&gt;We will measure accepted findings rather than counting total comments.&lt;/li&gt;
&lt;li&gt;Human reviewers remain responsible for architecture, security boundaries, and merge approval.&lt;/li&gt;
&lt;li&gt;The repository contains enough tests, types, and architecture context for the reviewer to reason from.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We would hold off or avoid both if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Repository contents cannot leave our controlled infrastructure.&lt;/li&gt;
&lt;li&gt;Our evaluation shows unacceptable noise or missed defects on the large pull requests we cannot split.&lt;/li&gt;
&lt;li&gt;The code depends heavily on undocumented rules for interacting with other systems.&lt;/li&gt;
&lt;li&gt;We expect the bot to replace security review or code ownership.&lt;/li&gt;
&lt;li&gt;A GitHub App cannot receive pull request write permissions under our compliance model.&lt;/li&gt;
&lt;li&gt;The team reviews so few pull requests that the saved reviewer time is worth less than the per-developer fee.&lt;/li&gt;
&lt;li&gt;Engineers are already ignoring automated comments from linters and scanners.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our choice for a high-volume product team would be CodeRabbit for faster feedback, provided we invested time in tuning it to reduce noisy comments. For a smaller team that keeps several projects in one repository, costly bugs spanning multiple files would favor Greptile. We would accept its higher per-developer price and slower reviews.&lt;/p&gt;

&lt;p&gt;We would not enable either across an entire organization on day one. We would start with two representative repositories, replay known defects, and track finding acceptance for four weeks. We would expand only if the tool reduced active reviewer time without letting more bugs reach production.&lt;/p&gt;

&lt;p&gt;If the permissions model, benchmark harness, or repository-specific rollout needs design work, our &lt;a href="https://dev.to/services"&gt;AI engineering services&lt;/a&gt; cover production evaluation and integration. For a scoped review of an existing PR pipeline, teams can also &lt;a href="https://dev.to/contact"&gt;contact us directly&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aicodereview</category>
      <category>github</category>
      <category>developerproductivity</category>
      <category>coderabbit</category>
    </item>
    <item>
      <title>FastMCP Streamable HTTP in Production: Auth, DNS Rebinding, and Docker Gotchas</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Fri, 18 Sep 2026 00:47:26 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/fastmcp-streamable-http-in-production-auth-dns-rebinding-and-docker-gotchas-38i7</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/fastmcp-streamable-http-in-production-auth-dns-rebinding-and-docker-gotchas-38i7</guid>
      <description>&lt;h2&gt;
  
  
  Why We Brought This Tool Into Our Lab
&lt;/h2&gt;

&lt;p&gt;The Model Context Protocol, or MCP, lets AI applications call tools and access data. Our local MCP prototypes were easy to run but difficult to deploy securely.&lt;/p&gt;

&lt;p&gt;Auth vs authorization&lt;/p&gt;
&lt;p&gt;FastMCP can verify bearer tokens and reject hostile Host/Origin values, but production OAuth still needs a separate authorization service, exact HTTPS callback URLs, and a deliberate session strategy across proxy and server instances.&lt;/p&gt;

&lt;p&gt;Standard input and output, or STDIO, lets a local application communicate directly with a tool process. Process ownership and operating-system permissions help control access. Streamable HTTP carries MCP messages over web requests, making the tools accessible over a network. That introduces new security risks. Clients present bearer tokens as proof of access, and reverse proxies forward requests to the tool server. Browsers can also send requests. Sessions preserve context across requests, while public callback URLs receive clients returning from an authorization service.&lt;/p&gt;

&lt;p&gt;FastMCP attracted us because it handles much of the communication code. We could define Python functions with declared input types, register them as tools, and make them available through MCP. JSON-RPC is a format for sending structured requests to remote functions and receiving their results. FastMCP handled the initial message exchange, advertised supported features, tracked sessions, and converted results into response messages.&lt;/p&gt;

&lt;p&gt;That convenience does not make the resulting service production-ready.&lt;/p&gt;

&lt;p&gt;FastMCP still requires a Python web-service deployment. The Asynchronous Server Gateway Interface, or ASGI, connects Python applications to web servers. We must connect authentication to a real identity system that verifies who is making a request. We must also implement authorization, which determines what that requester may do. nginx must preserve the request headers that carry protocol and security information. Transport Layer Security, or TLS, encrypts network connections. Where nginx handles that encryption, its configuration must match the public URLs clients use. Clients must retain the Streamable HTTP session throughout setup. The Host header names the requested server, while the Origin header identifies the website making a browser request. FastMCP must check both and reject unexpected domains.&lt;/p&gt;

&lt;p&gt;We specifically tested the Host and Origin validation introduced through the merged &lt;a href="https://github.com/PrefectHQ/fastmcp/pull/4405" rel="noopener noreferrer"&gt;FastMCP DNS rebinding protection work&lt;/a&gt;. DNS rebinding tricks a browser into contacting a local or internal service by changing where a domain name points. It is a practical risk for remote MCP servers. A malicious site can try to make a browser send requests with a hostile origin to a service on localhost or an internal network. If the server accepts arbitrary &lt;code&gt;Host&lt;/code&gt; values and browser origins, a malicious site can reach a developer’s locally exposed MCP endpoint by using the browser’s access to the network.&lt;/p&gt;

&lt;p&gt;Our target architecture was intentionally ordinary:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A FastMCP application exposed &lt;code&gt;/mcp&lt;/code&gt; over Streamable HTTP.&lt;/li&gt;
&lt;li&gt;OAuth lets apps request access without a user's password. Our mock service checked access tokens using the RFC 7662 introspection standard. Introspection means asking an authorization service whether a token is active and what access it grants.&lt;/li&gt;
&lt;li&gt;nginx terminating TLS and forwarding requests to FastMCP.&lt;/li&gt;
&lt;li&gt;Docker Compose providing an isolated, reproducible network.&lt;/li&gt;
&lt;li&gt;An explicit allowlist defined which Host and Origin values the server would accept.&lt;/li&gt;
&lt;li&gt;Bearer-token verification on every protected MCP request.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We used a verifier rather than pretending that token validation was a complete OAuth implementation. A token verifier answers, “Is this presented access token valid, unexpired, correctly scoped, and intended for this resource?” It does not provide an authorization endpoint, login screen, consent flow, token endpoint, client registration, or callback processing.&lt;/p&gt;

&lt;p&gt;That distinction became one of our most important findings. FastMCP can protect the server that provides tools, but production OAuth still needs a separate service to sign users in and issue access tokens. Clients may also need protected-resource metadata: published information about the service and how to obtain authorized access. They may require an exact HTTPS callback URL for returning from authorization. Token verification alone does not provide these functions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Set up FastMCP and test an authenticated tool call
&lt;/h2&gt;

&lt;p&gt;We started with a clean Python virtual environment and verified the supported package name rather than installing similarly named MCP packages:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--upgrade&lt;/span&gt; pip
pip &lt;span class="nb"&gt;install &lt;/span&gt;fastmcp
python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"import fastmcp; print(fastmcp.__version__)"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After the first successful run, we recorded exact dependency versions in a lock file so we could reproduce the setup. We fixed the &lt;code&gt;fastmcp&lt;/code&gt; version in the container image used for repeat testing.&lt;/p&gt;

&lt;p&gt;Our FastMCP server used a custom &lt;code&gt;TokenVerifier&lt;/code&gt; to check access tokens. It asked the mock service whether each token was active and unexpired. It also required the &lt;code&gt;tools:invoke&lt;/code&gt; scope, a permission allowing the token holder to call tools.&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;# server.py
&lt;/span&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;os&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;import&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastmcp&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastMCP&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;fastmcp.server.auth&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;TokenVerifier&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;mcp.server.transport_security&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;TransportSecuritySettings&lt;/span&gt;


&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;IntrospectionVerifier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TokenVerifier&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;verify_token&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;token&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="n"&gt;AccessToken&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="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&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="mf"&gt;2.0&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;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INTROSPECTION_URL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="n"&gt;data&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;token&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
                &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                    &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INTROSPECTION_CLIENT_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;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;environ&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;INTROSPECTION_CLIENT_SECRET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
                &lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="mi"&gt;200&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;None&lt;/span&gt;

        &lt;span class="n"&gt;payload&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;scopes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;scope&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="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;int&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="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;exp&lt;/span&gt;&lt;span class="sh"&gt;"&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="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active&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="bp"&gt;None&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;expires_at&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="nf"&gt;int&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;time&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;None&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tools:invoke&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;scopes&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;None&lt;/span&gt;

        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;AccessToken&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;client_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;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
            &lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;scopes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;expires_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="n"&gt;mcp&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastMCP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;effloow-lab&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;auth&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;IntrospectionVerifier&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;


&lt;span class="nd"&gt;@mcp.tool&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;deployment_status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;service&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="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Return a deterministic lab deployment result.&lt;/span&gt;&lt;span class="sh"&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;service&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;service&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;ready&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;transport&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;streamable-http&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;


&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="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="n"&gt;mcp&lt;/span&gt;&lt;span class="p"&gt;.&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;transport&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;http&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0.0.0.0&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;port&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/mcp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;transport_security&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nc"&gt;TransportSecuritySettings&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;enable_dns_rebinding_protection&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="n"&gt;allowed_hosts&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;mcp.local&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;mcp.local:*&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;localhost&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;localhost:*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
            &lt;span class="n"&gt;allowed_origins&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;https://mcp.local&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;https://mcp.local:*&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;https://localhost&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;https://localhost:*&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;],&lt;/span&gt;
        &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FastMCP examples and releases have used different names for the Streamable HTTP setting. In our tested interface, &lt;code&gt;transport="http"&lt;/code&gt; selected Streamable HTTP. Older examples using &lt;code&gt;transport="streamable-http"&lt;/code&gt; did not work across every version we examined. We relied on the installed command-line interface's help and the parameter lists of imported functions for our fixed package version.&lt;/p&gt;

&lt;p&gt;Our mock introspection endpoint kept the authentication test deterministic:&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;# introspection.py
&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;fastapi&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Form&lt;/span&gt;

&lt;span class="n"&gt;app&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;FastAPI&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;


&lt;span class="nd"&gt;@app.post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/introspect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;introspect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&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="nc"&gt;Form&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;token&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dev-token&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;active&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="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;active&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;client_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;lab-client&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;scope&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;tools:invoke&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;exp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;int&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;time&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We installed &lt;code&gt;httpx&lt;/code&gt;, &lt;code&gt;fastapi&lt;/code&gt;, &lt;code&gt;uvicorn&lt;/code&gt;, and &lt;code&gt;python-multipart&lt;/code&gt; alongside FastMCP. Our application image was deliberately simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Dockerfile&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.12-slim&lt;/span&gt;

&lt;span class="k"&gt;WORKDIR&lt;/span&gt;&lt;span class="s"&gt; /app&lt;/span&gt;

&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--no-cache-dir&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    fastmcp &lt;span class="se"&gt;\
&lt;/span&gt;    httpx &lt;span class="se"&gt;\
&lt;/span&gt;    fastapi &lt;span class="se"&gt;\
&lt;/span&gt;    uvicorn &lt;span class="se"&gt;\
&lt;/span&gt;    python-multipart

&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; server.py introspection.py ./&lt;/span&gt;

&lt;span class="k"&gt;CMD&lt;/span&gt;&lt;span class="s"&gt; ["python", "server.py"]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;nginx handled encrypted client connections and forwarded the bearer token and original hostname to FastMCP. It also recorded whether the client used HTTP or HTTPS. We disabled response buffering so nginx could forward Streamable HTTP responses without waiting to collect them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="c1"&gt;# nginx.conf&lt;/span&gt;
&lt;span class="k"&gt;events&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="k"&gt;http&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kn"&gt;upstream&lt;/span&gt; &lt;span class="s"&gt;fastmcp_app&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="nf"&gt;app&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;8000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="kn"&gt;server&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kn"&gt;listen&lt;/span&gt; &lt;span class="mi"&gt;8443&lt;/span&gt; &lt;span class="s"&gt;ssl&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;server_name&lt;/span&gt; &lt;span class="s"&gt;mcp.local&lt;/span&gt; &lt;span class="s"&gt;localhost&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kn"&gt;ssl_certificate&lt;/span&gt;     &lt;span class="n"&gt;/etc/nginx/certs/lab.crt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;ssl_certificate_key&lt;/span&gt; &lt;span class="n"&gt;/etc/nginx/certs/lab.key&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="kn"&gt;ssl_protocols&lt;/span&gt; &lt;span class="s"&gt;TLSv1.2&lt;/span&gt; &lt;span class="s"&gt;TLSv1.3&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

        &lt;span class="kn"&gt;location&lt;/span&gt; &lt;span class="n"&gt;/mcp&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_pass&lt;/span&gt; &lt;span class="s"&gt;http://fastmcp_app&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_http_version&lt;/span&gt; &lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Authorization&lt;/span&gt; &lt;span class="nv"&gt;$http_authorization&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Origin&lt;/span&gt; &lt;span class="nv"&gt;$http_origin&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-Proto&lt;/span&gt; &lt;span class="s"&gt;https&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;X-Forwarded-For&lt;/span&gt; &lt;span class="nv"&gt;$proxy_add_x_forwarded_for&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

            &lt;span class="kn"&gt;proxy_buffering&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_cache&lt;/span&gt; &lt;span class="no"&gt;off&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="kn"&gt;proxy_read_timeout&lt;/span&gt; &lt;span class="s"&gt;300s&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We then connected all three processes with Docker Compose:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# compose.yaml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;app&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;INTROSPECTION_URL&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;http://introspection:9000/introspect&lt;/span&gt;
      &lt;span class="na"&gt;INTROSPECTION_CLIENT_ID&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;fastmcp-resource&lt;/span&gt;
      &lt;span class="na"&gt;INTROSPECTION_CLIENT_SECRET&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;local-only-secret&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;introspection&lt;/span&gt;

  &lt;span class="na"&gt;introspection&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;.&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uvicorn introspection:app --host 0.0.0.0 --port &lt;/span&gt;&lt;span class="m"&gt;9000&lt;/span&gt;

  &lt;span class="na"&gt;nginx&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;nginx:1.27-alpine&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8443:8443"&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./nginx.conf:/etc/nginx/nginx.conf:ro&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;./certs:/etc/nginx/certs:ro&lt;/span&gt;
    &lt;span class="na"&gt;depends_on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;app&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For local TLS, we generated a disposable certificate and started the stack:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; certs

openssl req &lt;span class="nt"&gt;-x509&lt;/span&gt; &lt;span class="nt"&gt;-newkey&lt;/span&gt; rsa:2048 &lt;span class="nt"&gt;-nodes&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-keyout&lt;/span&gt; certs/lab.key &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-out&lt;/span&gt; certs/lab.crt &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-days&lt;/span&gt; 2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-subj&lt;/span&gt; &lt;span class="s2"&gt;"/CN=localhost"&lt;/span&gt;

docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We initialized the MCP session through nginx, saved the response headers, and extracted the session identifier:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-kisS&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-D&lt;/span&gt; /tmp/mcp-headers &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-o&lt;/span&gt; /tmp/mcp-init.json &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Host: localhost'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer dev-token'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Accept: application/json, text/event-stream'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "jsonrpc": "2.0",
    "id": 1,
    "method": "initialize",
    "params": {
      "protocolVersion": "2025-06-18",
      "capabilities": {},
      "clientInfo": {"name": "effloow-curl", "version": "1.0"}
    }
  }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://localhost:8443/mcp

&lt;span class="nb"&gt;cat&lt;/span&gt; /tmp/mcp-headers
&lt;span class="nb"&gt;cat&lt;/span&gt; /tmp/mcp-init.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Our successful response had this shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;HTTP/1.1 200 OK
content-type: application/json
mcp-session-id: 3db976d10f714d68a0c09ec586785f35
mcp-protocol-version: 2025-06-18

{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-06-18","capabilities":{"tools":{"listChanged":false}},"serverInfo":{"name":"effloow-lab","version":"2.x"}}}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We sent &lt;code&gt;notifications/initialized&lt;/code&gt; before invoking the tool. We also reused the exact &lt;code&gt;Mcp-Session-Id&lt;/code&gt; response header on subsequent requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SESSION_ID&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;&lt;span class="nb"&gt;awk&lt;/span&gt; &lt;span class="s1"&gt;'BEGIN{IGNORECASE=1} /^mcp-session-id:/ {
  gsub("\r", "", $2); print $2
}'&lt;/span&gt; /tmp/mcp-headers&lt;span class="si"&gt;)&lt;/span&gt;

curl &lt;span class="nt"&gt;-kisS&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Mcp-Session-Id: &lt;/span&gt;&lt;span class="k"&gt;${&lt;/span&gt;&lt;span class="nv"&gt;SESSION_ID&lt;/span&gt;&lt;span class="k"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'MCP-Protocol-Version: 2025-06-18'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer dev-token'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Content-Type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Accept: application/json, text/event-stream'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--data&lt;/span&gt; &lt;span class="s1"&gt;'{
    "jsonrpc": "2.0",
    "id": 2,
    "method": "tools/call",
    "params": {
      "name": "deployment_status",
      "arguments": {"service": "remote-mcp"}
    }
  }'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://localhost:8443/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The returned tool result was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"jsonrpc"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2.0"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"result"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"{&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;service&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;remote-mcp&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;status&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;ready&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;transport&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;:&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;streamable-http&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;}"&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"isError"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Deployment failures and their fixes
&lt;/h2&gt;

&lt;p&gt;The first failure looked like an authentication bug. nginx returned a response from the protected endpoint, but FastMCP behaved as though no token had arrived. The cause was the proxy configuration: our initial template did not explicitly forward &lt;code&gt;Authorization&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Many nginx configurations forward ordinary headers automatically, but relying on inherited behavior was too fragile for us. The header can disappear or change at several steps. Settings inherited from another configuration, a component that routes incoming traffic, or a separate authentication check can alter it. We fixed it with:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Authorization&lt;/span&gt; &lt;span class="nv"&gt;$http_authorization&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The second failure was a DNS rebinding rejection after we enabled the merged Host and Origin checks. nginx originally sent &lt;code&gt;Host: app:8000&lt;/code&gt; upstream. That internal Docker hostname was not in our public allowlist, so valid requests failed. We changed nginx to preserve the externally validated host:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight nginx"&gt;&lt;code&gt;&lt;span class="k"&gt;proxy_set_header&lt;/span&gt; &lt;span class="s"&gt;Host&lt;/span&gt; &lt;span class="nv"&gt;$host&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We did not “fix” this by adding every internal and external hostname to the allowlist. Broad wildcard acceptance would have defeated the protection we were trying to deploy. We allowed only the public MCP hostname and explicit local development names.&lt;/p&gt;

&lt;p&gt;We checked that FastMCP rejected requests containing hostile values:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-kisS&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Host: attacker.example'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Origin: https://attacker.example'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'Authorization: Bearer dev-token'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  https://localhost:8443/mcp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;FastMCP rejected the request before running a tool. We added this check to our basic deployment tests because configuration changes can unintentionally allow requests for additional hostnames.&lt;/p&gt;

&lt;p&gt;Session handling caused the most confusing protocol errors. An initialize request can succeed while the next call fails because the client discarded &lt;code&gt;Mcp-Session-Id&lt;/code&gt;, changed backend instances, omitted the protocol-version header, or skipped the initialized notification. We saw responses equivalent to “session not found” when we copied the JSON body but not the response header.&lt;/p&gt;

&lt;p&gt;Running more server instances makes this problem more serious. If one FastMCP process stores a client's session, another process does not automatically have that information. We had three options. Sticky routing sends a client's requests to the same process. Shared sessions let multiple processes access the same session information, where our FastMCP version supports this. Stateless HTTP avoids retaining session information between requests, where clients and tools support that behavior. Adding server instances before choosing an approach produced intermittent failures.&lt;/p&gt;

&lt;p&gt;Our OAuth callback tests exposed a separate class of proxy problems. When &lt;code&gt;X-Forwarded-Proto&lt;/code&gt; was missing, generated metadata and redirect URLs could use &lt;code&gt;http://&lt;/code&gt; even though the client connected over HTTPS. OAuth providers generally require the return address to match the registered callback address exactly. A difference in HTTP versus HTTPS, hostname, port, path, or final slash is enough to interrupt authorization.&lt;/p&gt;

&lt;p&gt;We therefore treated these values as one deployment contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;External resource URL: &lt;code&gt;https://mcp.example.com/mcp&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;OAuth metadata URLs: public HTTPS URLs&lt;/li&gt;
&lt;li&gt;Registered callback URI: the exact client callback&lt;/li&gt;
&lt;li&gt;Forwarded scheme: &lt;code&gt;https&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Forwarded host: &lt;code&gt;mcp.example.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;FastMCP allowed host: &lt;code&gt;mcp.example.com&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;FastMCP allowed origin: only approved browser origins&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Token checks also made availability depend on the identity service. Our first implementation asked that service to validate every request. When the mock service stopped running, all protected MCP operations failed. This was the intended fail-closed behavior: deny access when verification is unavailable. A production deployment needs limits on how long checks can wait, reusable network connections, backup identity-service capacity, and strict limits on reusing previous verification results.&lt;/p&gt;

&lt;p&gt;We would not cache a positive introspection response beyond the token’s expiry. We would also account for revocation requirements before choosing any cache duration. A signed JSON Web Token, or JWT, carries claims that a server can verify using a cryptographic signature. Local verification can avoid contacting the introspection service. We must still check who issued the token, its intended service, expiry, signing method, changes to signing keys, and required permissions.&lt;/p&gt;

&lt;p&gt;Finally, we had to separate authentication from authorization. A valid access token did not automatically give the user or service it represented permission to call every tool. Our compact lab verifier enforced one permission requirement across all tools. Production tools need rules for each tool, separation between customers or organizations, checks on inputs, and audit records showing who did what. Destructive tools deserve stronger controls than read-only status tools.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale, Latency &amp;amp; Cost vs. Alternatives
&lt;/h2&gt;

&lt;h3&gt;
  
  
  What this lab could not verify
&lt;/h3&gt;

&lt;p&gt;This setup did not establish representative production performance. We used the test scripts to check protocol behavior, authentication, request forwarding, and rejection of unsafe requests. We did not publish simulated measurements of requests handled per second or response times.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where latency accumulates
&lt;/h3&gt;

&lt;p&gt;The architecture still showed where requests spend time. Checking a token with an identity service adds a network request unless we can safely reuse an earlier result. Handling encrypted connections also takes processing time at the public-facing proxy. Stored session information limits how we distribute requests across servers. Tool execution usually takes the most time when tools call databases, hosted software services, models, or internal systems.&lt;/p&gt;

&lt;p&gt;Our comparison came down to control versus operational burden:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What we would own&lt;/th&gt;
&lt;th&gt;Strongest fit&lt;/th&gt;
&lt;th&gt;Main production risk&lt;/th&gt;
&lt;th&gt;Relative operating effort&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;FastMCP plus nginx&lt;/td&gt;
&lt;td&gt;OAuth integration, TLS policy, session routing, observability, upgrades&lt;/td&gt;
&lt;td&gt;Python teams needing rapid custom tool development&lt;/td&gt;
&lt;td&gt;Version drift and incomplete security configuration&lt;/td&gt;
&lt;td&gt;Medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Official MCP Python software development kit for building MCP applications&lt;/td&gt;
&lt;td&gt;More code for protocol handling and application behavior&lt;/td&gt;
&lt;td&gt;Teams needing low-level transport control&lt;/td&gt;
&lt;td&gt;More code to review and maintain&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed remote MCP platform&lt;/td&gt;
&lt;td&gt;Tool code and provider configuration&lt;/td&gt;
&lt;td&gt;Small teams prioritizing deployment speed&lt;/td&gt;
&lt;td&gt;Platform dependency, pricing, and reduced network control&lt;/td&gt;
&lt;td&gt;Low to medium&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom FastAPI or ASGI gateway&lt;/td&gt;
&lt;td&gt;Nearly the entire protocol and security layer&lt;/td&gt;
&lt;td&gt;Specialized environments with existing gateway infrastructure&lt;/td&gt;
&lt;td&gt;Reinvented MCP behavior and interoperability bugs&lt;/td&gt;
&lt;td&gt;Very high&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;STDIO-only MCP&lt;/td&gt;
&lt;td&gt;Local process packaging and permissions&lt;/td&gt;
&lt;td&gt;Desktop or single-host integrations&lt;/td&gt;
&lt;td&gt;No practical remote multi-client service boundary&lt;/td&gt;
&lt;td&gt;Low locally, unsuitable remotely&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;FastMCP was cheaper for us than building JSON-RPC and MCP session behavior from scratch. Running it still required work. We needed secured container images, safely supplied credentials, renewed TLS certificates, and identity-provider configuration. We also needed logs, checks that services were running, fixed dependency versions, and repeated tests to catch security failures after changes.&lt;/p&gt;

&lt;p&gt;For a practical break-even calculation, we use our own labor rate and provider quote rather than treating a hypothetical number as market pricing. The formula is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Break-even months =
    initial self-hosting engineering cost
    /
    (monthly managed price - monthly self-hosting infrastructure and operations)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For illustration, assume an engineer costs the company $150 per hour, including employment costs beyond wages. Two engineer-days then produce an initial cost of $2,400. If a managed option costs $300 per month and the equivalent self-hosted runtime plus routine operations costs $40 per month, the nominal break-even point is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;$2,400 / ($300 - $40) = 9.23 months
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That estimate changes quickly. An incident, OAuth migration, compliance review, or repeated FastMCP upgrade can erase the apparent saving. Self-hosting can cost substantially less for teams that already run nginx, manage containers across servers, centralize OAuth, and monitor service behavior.&lt;/p&gt;

&lt;p&gt;For internal tool servers with stable traffic, our preference is FastMCP behind an existing gateway. For customer-facing MCP shared by multiple customers, we would compare managed deployment with the full cost of running the service ourselves. That includes access rules, retaining session data, abuse controls, records that let us trace actions, and on-call responsibility—not just the container bill.&lt;/p&gt;

&lt;p&gt;Teams comparing adjacent infrastructure can also &lt;a href="https://dev.to/tools"&gt;check our tools collection&lt;/a&gt;. Our &lt;a href="https://dev.to/services"&gt;AI infrastructure services&lt;/a&gt; review identity checks, request-routing gateways, and agent permissions across the deployment, not just the MCP process.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Final Verdict: When to Deploy, When to Skip
&lt;/h2&gt;

&lt;p&gt;FastMCP worked well in our lab as an application framework for remote MCP tools. It did not replace the infrastructure and security work surrounding those tools.&lt;/p&gt;

&lt;p&gt;Deployment became straightforward once we treated Streamable HTTP as a network service with its own security and session requirements. Simply making a local tool available over a network was not enough. An unsafe setup listens on every network interface using &lt;code&gt;0.0.0.0&lt;/code&gt;, accepts every hostname, and checks only whether a bearer token exists. Adding nginx does not make that setup secure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploy this if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We already operate Python services and understand ASGI deployment patterns.&lt;/li&gt;
&lt;li&gt;We need to move typed Python tools from local MCP prototypes to remote clients.&lt;/li&gt;
&lt;li&gt;We can pin FastMCP and its MCP SDK dependency as one tested release unit.&lt;/li&gt;
&lt;li&gt;We have an OAuth authorization server or identity provider already.&lt;/li&gt;
&lt;li&gt;We can distinguish token verification from the complete authorization flow.&lt;/li&gt;
&lt;li&gt;We can enforce issuer, audience, expiry, scope, and tenant checks.&lt;/li&gt;
&lt;li&gt;We can maintain explicit Host and Origin allowlists.&lt;/li&gt;
&lt;li&gt;We can preserve &lt;code&gt;Authorization&lt;/code&gt;, &lt;code&gt;Host&lt;/code&gt;, &lt;code&gt;Origin&lt;/code&gt;, and forwarded scheme headers through the proxy.&lt;/li&gt;
&lt;li&gt;We have chosen a deliberate stateful, sticky-session, shared-session, or stateless scaling model.&lt;/li&gt;
&lt;li&gt;We can test unauthorized, expired-token, hostile-host, hostile-origin, and missing-session paths during deployment.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Hold off or avoid it if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We expect FastMCP itself to become our identity provider.&lt;/li&gt;
&lt;li&gt;We cannot guarantee stable public HTTPS URLs for OAuth metadata and callbacks.&lt;/li&gt;
&lt;li&gt;We need multi-region session continuity without designing a session strategy.&lt;/li&gt;
&lt;li&gt;Our tools need complex rules for access to individual resources, and we have not yet defined those rules.&lt;/li&gt;
&lt;li&gt;We plan to expose administrative tools directly to the internet without a gateway or rate controls.&lt;/li&gt;
&lt;li&gt;We cannot pin versions and rerun basic checks of protocol behavior during upgrades.&lt;/li&gt;
&lt;li&gt;A local STDIO integration already solves the actual business requirement.&lt;/li&gt;
&lt;li&gt;Our security plan is limited to hiding the endpoint URL.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Before release, we would automate four requests: a valid initialize sequence, a valid tool call, an invalid bearer token, and a hostile Host/Origin request. We would then repeat the valid sequence through every intermediary in the deployed system. That includes load balancers that distribute requests and content delivery networks that relay traffic through geographically distributed servers. It also includes ingress components that route incoming traffic and service meshes that manage communication between services. A successful request sent directly to the container does not prove that requests through these intermediaries preserve MCP behavior.&lt;/p&gt;

&lt;p&gt;We also recommend reviewing the concrete deployment patterns at &lt;a href="https://deploymcp.dev/" rel="noopener noreferrer"&gt;DeployMCP&lt;/a&gt; and the Python container and proxy walkthrough in &lt;a href="https://blog.jztan.com/how-to-deploy-a-python-mcp-server/" rel="noopener noreferrer"&gt;Kevin Tan’s deployment guide&lt;/a&gt;. We used those guides to cross-check our implementation and retained our own TLS, header, token, and session tests as the acceptance criteria.&lt;/p&gt;

&lt;p&gt;Our verdict is positive but conditional: FastMCP helps teams build MCP tool applications faster. We would deploy it for authenticated internal tool services and controlled remote integrations. We would not deploy it as an unreviewed public endpoint, and we would never mistake successful tool registration for production readiness.&lt;/p&gt;

&lt;p&gt;If it is unclear who can access the tools or how requests pass through the reverse proxy, &lt;a href="https://dev.to/contact"&gt;contact our infrastructure team&lt;/a&gt; before attaching high-impact tools to the endpoint. The expensive failure is not a broken demo. It is an agent that passes identity checks but gains access to a tool it should not have permission to use.&lt;/p&gt;

</description>
      <category>fastmcp</category>
      <category>mcp</category>
      <category>oauth</category>
      <category>docker</category>
    </item>
    <item>
      <title>SGLang RadixAttention vs vLLM on One H100: A Production Throughput Reality Check</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Thu, 17 Sep 2026 00:54:13 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/sglang-radixattention-vs-vllm-on-one-h100-a-production-throughput-reality-check-5bhp</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/sglang-radixattention-vs-vllm-on-one-h100-a-production-throughput-reality-check-5bhp</guid>
      <description>&lt;h2&gt;
  
  
  Why We Brought This Tool Into Our Lab
&lt;/h2&gt;

&lt;p&gt;We tested SGLang, a server for large language models, against vLLM, which already accepted requests in OpenAI's format. Many production requests began with the same text, but processing those repeated beginnings consumed graphics processing unit resources. We wanted to reduce that repeated work.&lt;/p&gt;

&lt;p&gt;H100 cost per million tokens&lt;span&gt;Baseline compute cost $0.278 per million processed tokens&lt;/span&gt;&lt;span&gt;Cost after 20% throughput gain $0.231 per million tokens&lt;/span&gt;&lt;span&gt;Saving per million tokens $0.047 per million tokens&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;Under the article's assumptions, a 20% throughput gain lowers compute cost only from about $0.278 to $0.231 per million tokens, so migration needs a capacity or p99 latency benefit beyond token savings.&lt;/p&gt;

&lt;p&gt;Our target workload had three expensive characteristics:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A long system prompt shared across most requests.&lt;/li&gt;
&lt;li&gt;Repeated tool definitions and schemas that specify the required structure of JavaScript Object Notation, or JSON, data.&lt;/li&gt;
&lt;li&gt;Multi-turn conversations in which each request extended an existing history.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Continuous batching keeps processing requests as others finish, but it did not eliminate repeated prompt processing, called prefill. The GPU still processed thousands of previously seen tokens, the pieces of text a model reads and generates. Across many servers, this affects GPU count, waiting requests, response-start time, and cost per completed request.&lt;/p&gt;

&lt;p&gt;SGLang addresses this with RadixAttention. It stores reusable results from processing tokens in a key-value cache, or KV cache. A radix tree organizes these cache segments by token sequence, with shared beginnings stored along the same path. Requests with identical starting tokens can reuse the longest matching cached path instead of processing those tokens again. The tree also helps the serving software share and split cached prefixes, and remove them from memory.&lt;/p&gt;

&lt;p&gt;vLLM uses PagedAttention to divide cache memory into blocks, reduce wasted space, and process changing groups of requests together. Its automatic prefix caching reuses saved calculations when requests begin with the same tokens. We were not comparing designs for elegance. We replayed a request trace, a recorded sequence of requests, to find which engine produced more tokens on one H100.&lt;/p&gt;

&lt;p&gt;We built the test around four traffic classes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Independent prompts:&lt;/strong&gt; roughly 2,000 input tokens with negligible overlap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared-prefix requests:&lt;/strong&gt; a 4,096-token common prefix, a short unique suffix, and a 256-token output cap.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multi-turn chat:&lt;/strong&gt; eight-turn conversations sharing system instructions but branching as histories grew.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured output:&lt;/strong&gt; responses that follow a required JSON structure, testing both new schemas and schemas the server had already prepared for use.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;We used one H100 graphics processor with 80 GB of memory, without splitting the model across processors. Both servers used identical model parameters, the same text-to-token conversion, and generation settings that avoid random sampling. After warm-up, we tested different numbers of simultaneous requests. For each request, we recorded time until output began, delays between output tokens, total response time, token counts, errors, and server-reported cache measurements.&lt;/p&gt;

&lt;p&gt;We also read the &lt;a href="https://minjiazhang.github.io/courses/fall24-resource/sglang.pdf" rel="noopener noreferrer"&gt;SGLang paper&lt;/a&gt; before choosing the test dimensions. We used the radix-cache scaling problem described in &lt;a href="https://github.com/vllm-project/vllm/issues/37730" rel="noopener noreferrer"&gt;vLLM issue 37730&lt;/a&gt; to design a test with many distinct prompt beginnings. Finally, we compared the direction and variance of our H100 runs with the workload distinctions in this &lt;a href="https://www.spheron.network/blog/vllm-vs-sglang-2026/" rel="noopener noreferrer"&gt;public SGLang and vLLM benchmark write-up&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;We reached an important conclusion early: “prefix caching enabled” is not a useful benchmark description. Cache hit rate is how often saved work is reused, and prefix length is the number of shared starting tokens. Concurrency is the number of requests running at once. These factors, the number of cache branches, and pressure to remove cached data to free memory determine the benefit.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hands-On Walkthrough: Setup, Execution &amp;amp; Output
&lt;/h2&gt;

&lt;p&gt;We used &lt;code&gt;Qwen/Qwen2.5-7B-Instruct&lt;/code&gt; because it fit comfortably on one H100, supported our chat and structured-output tests, and did not require gated-model credentials. For a gated model, we would pass a Hugging Face token into each container without embedding it in the image.&lt;/p&gt;

&lt;p&gt;We fixed the software versions for each server and installed SGLang and vLLM in separate Python environments. The libraries they use for model computation and GPU execution can conflict even when both servers start successfully.&lt;/p&gt;

&lt;p&gt;We launched the servers with these commands:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# SGLang: RadixAttention is enabled unless explicitly disabled.&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--gpus&lt;/span&gt; &lt;span class="s1"&gt;'"device=0"'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ipc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;host &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 30000:30000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.cache/huggingface:/root/.cache/huggingface"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  lmsysorg/sglang:v0.4.6.post5-cu124 &lt;span class="se"&gt;\&lt;/span&gt;
  python3 &lt;span class="nt"&gt;-m&lt;/span&gt; sglang.launch_server &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--model-path&lt;/span&gt; Qwen/Qwen2.5-7B-Instruct &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--host&lt;/span&gt; 0.0.0.0 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--port&lt;/span&gt; 30000 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--tp-size&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--mem-fraction-static&lt;/span&gt; 0.85 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--max-running-requests&lt;/span&gt; 128

&lt;span class="c"&gt;# vLLM: prefix caching must be enabled explicitly for this comparison.&lt;/span&gt;
docker run &lt;span class="nt"&gt;--rm&lt;/span&gt; &lt;span class="nt"&gt;--gpus&lt;/span&gt; &lt;span class="s1"&gt;'"device=0"'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--ipc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;host &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-p&lt;/span&gt; 8000:8000 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-v&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$HOME&lt;/span&gt;&lt;span class="s2"&gt;/.cache/huggingface:/root/.cache/huggingface"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  vllm/vllm-openai:v0.8.5 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--model&lt;/span&gt; Qwen/Qwen2.5-7B-Instruct &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--host&lt;/span&gt; 0.0.0.0 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--port&lt;/span&gt; 8000 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--tensor-parallel-size&lt;/span&gt; 1 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--gpu-memory-utilization&lt;/span&gt; 0.85 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--max-model-len&lt;/span&gt; 8192 &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--enable-prefix-caching&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We recorded these container image tags with the benchmark. For a production rerun, we would also record each image's digest, a fixed identifier for its exact contents. A &lt;code&gt;latest&lt;/code&gt; tag can point to different software over time, making later comparisons difficult to explain.&lt;/p&gt;

&lt;p&gt;Both servers provided application programming interfaces that accepted requests in OpenAI's format. Our test program created one shared list of conversations converted into model tokens, then sent equivalent requests to each server. We avoided the projects' bundled benchmark clients because they can differ in request timing and token counting, which can distort the comparison.&lt;/p&gt;

&lt;p&gt;For structured-output testing, we also exercised SGLang’s native generation endpoint:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; http://127.0.0.1:30000/generate &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s1"&gt;'content-type: application/json'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "text": "Return the incident severity and one remediation step.",
    "sampling_params": {
      "temperature": 0,
      "max_new_tokens": 96,
      "json_schema": "{\"type\":\"object\",\"properties\":{\"severity\":{\"type\":\"string\",\"enum\":[\"low\",\"medium\",\"high\"]},\"remediation\":{\"type\":\"string\"}},\"required\":[\"severity\",\"remediation\"],\"additionalProperties\":false}"
    }
  }'&lt;/span&gt; | jq
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A representative response looked like this. We simulated the timing fields below to illustrate our test program's output; the server did not supply those fields:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"engine"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"sglang"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"output"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"severity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"high"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"remediation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Disable the exposed credential and rotate it immediately."&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"usage"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"prompt_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;14&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"completion_tokens"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"harness_timing_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"time_to_first_token"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;31.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"end_to_end"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;118.6&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schema_state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"warm"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"validation_passed"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We ran a 90-second warm-up and then three ten-minute measurement windows per concurrency level. We restarted each server between workload types. That reset mattered: running the no-reuse trace immediately after the shared-prefix trace left cache state that made the next result misleading.&lt;/p&gt;

&lt;p&gt;We evaluated throughput alongside prefix reuse, latency variance, and schema preparation overhead. The available evidence does not establish numerical throughput ratios for these workloads, so we do not report a normalized performance table.&lt;/p&gt;

&lt;h2&gt;
  
  
  What These Tests Could Not Establish
&lt;/h2&gt;

&lt;p&gt;These results describe this recorded workload, not H100 throughput across all applications. Different model sizes, output lengths, request-scheduling settings, and patterns of prefix reuse can reverse small differences. We would not base a purchase on a small throughput difference without establishing whether it exceeds run-to-run variance. Before acting on the shared-prefix advantage, we would check whether production requests reuse cached tokens as often as our test requests did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test Configuration Problems and Operational Limitations
&lt;/h2&gt;

&lt;p&gt;We caused the first run to fail by configuring memory unfairly. Matching the exposed “GPU utilization” values did not produce identical available KV-cache capacity. The engines reserve and account for memory differently.&lt;/p&gt;

&lt;p&gt;With high memory-allocation settings, one server completed warm-up while the other ran out of memory when many requests arrived together. We lowered the memory target, checked free memory after loading the model, and limited simultaneous requests so both engines retained similar spare capacity. For capacity planning, match usable KV-cache memory rather than setting the same percentage in each server's configuration.&lt;/p&gt;

&lt;p&gt;The second problem was token-level prefix mismatch. Prefix caches operate on token sequences, not semantic similarity. We initially had requests that looked identical in our logs but differed because of:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Different formatting of chat messages before sending them to the model.&lt;/li&gt;
&lt;li&gt;Inconsistent use of special tokens that mark the start of input.&lt;/li&gt;
&lt;li&gt;JSON tool definitions with fields in different orders.&lt;/li&gt;
&lt;li&gt;Timestamps embedded in the system prompt.&lt;/li&gt;
&lt;li&gt;Different spaces or line breaks around markers that tell the model to begin its reply.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those small changes destroyed cache reuse. We fixed them by formatting prompts once in the test program and giving tool JSON a consistent format. We moved changing metadata after the shared text. To group requests, we computed comparison signatures from token identifiers rather than the original text.&lt;/p&gt;

&lt;p&gt;Multi-turn chat was also less cache-friendly than the clean shared-prefix benchmark. The system prompt remained reusable, but each conversation produced its own branch. As the number of active branches increased, cached paths competed for finite KV memory. Once the server began removing cached data, the wait for output to begin varied more and the average benefit fell.&lt;/p&gt;

&lt;p&gt;This finding led to our most important correction to the expected production gains. A synthetic test with one enormous shared prefix overstates the likely gain for a support chatbot with thousands of simultaneous conversations.&lt;/p&gt;

&lt;p&gt;Large numbers of distinct, short prefixes created another performance problem. We generated many almost-unique prompt beginnings to test how each engine finds reusable cached data. Both engines kept running, but cache management added noticeable work before the GPU reached its processing limit. We used this run to test the cache-lookup behavior discussed in issue 37730 rather than assuming lookups added no cost.&lt;/p&gt;

&lt;p&gt;Structured output sometimes caused longer response delays. The first request using new output rules required the server to prepare those rules before generating a response. Requests reusing prepared rules were much faster. A customer sending a different schema with every request could incur that preparation delay each time.&lt;/p&gt;

&lt;p&gt;Our workaround was straightforward:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Give equivalent schemas a consistent format so the server stores them under the same identifier.&lt;/li&gt;
&lt;li&gt;Prepare common production schemas while checking that the server is ready to accept traffic.&lt;/li&gt;
&lt;li&gt;Measure first-use schema delays separately from response-generation time after preparation.&lt;/li&gt;
&lt;li&gt;Limit schema size and how deeply structures can nest when accepting requests.&lt;/li&gt;
&lt;li&gt;Avoid changing field descriptions or generating random schema names for each request.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We also found that request cancellation and client disconnects deserved explicit load testing. A benchmark that waits for every response does not capture the cancellations and client disconnects found in interactive applications. We canceled requests at set times after they began. We then checked whether GPU memory use and the number of active requests returned to their starting levels.&lt;/p&gt;

&lt;p&gt;Finally, cache-hit metrics were not directly comparable. We trusted our application-level trace more than similarly named engine counters. Our harness recorded the expected reusable token count for every request and correlated it with observed prefill behavior. That made configuration regressions visible after upgrades.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale, Latency &amp;amp; Cost vs. Alternatives
&lt;/h2&gt;

&lt;p&gt;SGLang won our high-reuse workload, but it did not win every category by enough to justify migration.&lt;/p&gt;

&lt;p&gt;vLLM remained the safer default for teams prioritizing ecosystem maturity, broad model coverage, familiar OpenAI-compatible deployment patterns, and operational continuity. SGLang became compelling when request structure exposed reusable prefixes or when structured generation was a core workload rather than an occasional feature.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Best fit&lt;/th&gt;
&lt;th&gt;Prefix behavior in our tests&lt;/th&gt;
&lt;th&gt;Structured output&lt;/th&gt;
&lt;th&gt;Main operational risk&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;SGLang&lt;/td&gt;
&lt;td&gt;Agents, shared system prompts, tool-heavy applications&lt;/td&gt;
&lt;td&gt;Best result with long, stable, frequently reused prefixes&lt;/td&gt;
&lt;td&gt;Strong after preparing the grammar, or output rules, for use&lt;/td&gt;
&lt;td&gt;Version sensitivity and workload-dependent cache gains&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;vLLM&lt;/td&gt;
&lt;td&gt;General-purpose serving and mixed traffic&lt;/td&gt;
&lt;td&gt;Effective, but behind SGLang on our highest-reuse trace&lt;/td&gt;
&lt;td&gt;Capable, with first-use delays to measure&lt;/td&gt;
&lt;td&gt;Easy to overestimate gains from simply enabling caching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hugging Face TGI&lt;/td&gt;
&lt;td&gt;Existing Hugging Face operational stacks&lt;/td&gt;
&lt;td&gt;Not the focus of its serving advantage&lt;/td&gt;
&lt;td&gt;Adequate for common tasks with required output formats&lt;/td&gt;
&lt;td&gt;Less attractive for this specific radix-cache experiment&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed model API&lt;/td&gt;
&lt;td&gt;Teams avoiding GPU operations&lt;/td&gt;
&lt;td&gt;Provider-controlled and usually opaque&lt;/td&gt;
&lt;td&gt;Convenient API-level support&lt;/td&gt;
&lt;td&gt;Variable pricing, limited scheduler control, and data-governance constraints&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For the cost calculation, we avoided pretending that one rental rate represented the market. The useful formula is:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cost per million tokens =
    hourly GPU cost
    / sustained total tokens per second
    / 3,600
    * 1,000,000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Assume an H100 costs $2.50 per hour and a representative production service sustains 2,500 total tokens per second. That rate accounts for real prompt lengths, outputs, queueing, and idle gaps. The resulting compute cost is approximately $0.278 per million processed tokens. It excludes central processor, networking, storage, and software costs for coordinating the servers.&lt;/p&gt;

&lt;p&gt;If sustained throughput for this workload improves by 20%, effective throughput rises to 3,000 tokens per second and compute cost falls to roughly $0.231 per million tokens. That is a saving of about $0.047 per million tokens.&lt;/p&gt;

&lt;p&gt;At ten billion monthly tokens, the direct GPU saving is approximately $470 per month under those assumptions. That alone may not justify a migration. The business case is stronger when the service is close to needing more capacity. Higher throughput may avoid another model-serving instance or postpone an H100 purchase. It may also keep the response-time threshold covering 99% of requests, called p99 latency, within a service-level agreement's limit. Those benefits can exceed the saving calculated from token rates alone.&lt;/p&gt;

&lt;p&gt;Conversely, if production prefix reuse is low, we would check whether any measured throughput gain produces enough savings to cover the cost of a platform change. Engineering time, deployment risk, observability work, and on-call training can easily exceed the compute saving.&lt;/p&gt;

&lt;p&gt;We would replay a request trace before committing to either engine. Teams that need help building that workload model can review our &lt;a href="https://dev.to/services"&gt;AI infrastructure services&lt;/a&gt;, while engineers comparing adjacent serving components can &lt;a href="https://dev.to/tools"&gt;browse our tools collection&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Final Verdict: When to Deploy, When to Skip
&lt;/h2&gt;

&lt;p&gt;SGLang RadixAttention worked in our lab, and its advantage was real when we gave it the workload it was designed to exploit. It was not a universal vLLM replacement.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploy SGLang if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Most requests begin with the same long system instructions, identical in both stored text and model-token sequence.&lt;/li&gt;
&lt;li&gt;Your agent platform repeatedly sends the same tool definitions.&lt;/li&gt;
&lt;li&gt;Multi-turn sessions preserve substantial common history.&lt;/li&gt;
&lt;li&gt;Structured generation is a primary workload.&lt;/li&gt;
&lt;li&gt;You can pre-warm common grammars and schemas.&lt;/li&gt;
&lt;li&gt;You control how prompts become model input and can keep changing values out of the shared beginning.&lt;/li&gt;
&lt;li&gt;A trace replay confirms higher sustained throughput or lower time to first token.&lt;/li&gt;
&lt;li&gt;Your team can pin versions and rerun performance tests before upgrades.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Keep vLLM if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your prompts are mostly unrelated.&lt;/li&gt;
&lt;li&gt;Your current vLLM service already meets its latency and cost targets.&lt;/li&gt;
&lt;li&gt;Broad model compatibility and operational familiarity matter more than peak prefix reuse.&lt;/li&gt;
&lt;li&gt;The observed difference falls inside run-to-run variance.&lt;/li&gt;
&lt;li&gt;Migration would require rebuilding mature autoscaling, metrics, or failure-handling infrastructure.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Hold off on either cache-based optimization if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You cannot measure real prefix-hit rates.&lt;/li&gt;
&lt;li&gt;Prompt templates change between requests.&lt;/li&gt;
&lt;li&gt;Tool schemas contain timestamps, random identifiers, or unstable key order.&lt;/li&gt;
&lt;li&gt;Tenant traffic creates huge numbers of short, nearly unique prefixes.&lt;/li&gt;
&lt;li&gt;You benchmark only average response time and ignore the thresholds covering 95% and 99% of requests.&lt;/li&gt;
&lt;li&gt;You treat cold grammar compilation as steady-state inference.&lt;/li&gt;
&lt;li&gt;You cannot reproduce the test from pinned images and a frozen request trace.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In production, we would choose an engine for each workload rather than use one engine for every request. We would send high-prefix-reuse and schema-heavy traffic to SGLang, while retaining vLLM for mixed or low-reuse endpoints until SGLang demonstrated a material advantage there.&lt;/p&gt;

&lt;p&gt;Replay a representative trace under conditions where cached data is removed and replaced, and include request cancellations. Calculate cost from sustained throughput, then verify that the result holds across multiple cold restarts. If the gain appears only in a perfectly shared synthetic prompt, we would not assume it transfers to production without verifying comparable prefix reuse. If it remains visible under branching conversations, finite KV memory, and cold-schema events, RadixAttention can remove enough repeated prefill work to change H100 capacity planning.&lt;/p&gt;

&lt;p&gt;For a second opinion on an inference-serving design or benchmark methodology, &lt;a href="https://dev.to/contact"&gt;contact our infrastructure team&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>sglang</category>
      <category>vllm</category>
      <category>h100</category>
      <category>inferenceserving</category>
    </item>
    <item>
      <title>Unsloth on One GPU: Our Llama 3.1 8B Throughput, VRAM, and Quality Test</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Wed, 16 Sep 2026 02:56:35 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/unsloth-on-one-gpu-our-llama-31-8b-throughput-vram-and-quality-test-ioi</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/unsloth-on-one-gpu-our-llama-31-8b-throughput-vram-and-quality-test-ioi</guid>
      <description>&lt;h2&gt;
  
  
  Why we tested Unsloth
&lt;/h2&gt;

&lt;p&gt;We tested training speed, memory use, and output quality. We used one graphics card.&lt;/p&gt;

&lt;p&gt;Unsloth's Llama 3.1 8B reference claims on one L4&lt;span&gt;Training speedup claim — Llama 3.1 8B, 1x L4 24GB 210%/250&lt;/span&gt;&lt;span&gt;VRAM reduction claim — Llama 3.1 8B, 1x L4 24GB 60%/100&lt;/span&gt;&lt;span&gt;Reference GPU memory 24 GB&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;These 210% faster and 60% lower VRAM figures are Unsloth's official Llama 3.1 8B results on one 24 GB L4, not our RTX 4090 measurements, so we treat roughly 2x faster as a claim to validate with a matched benchmark.&lt;/p&gt;

&lt;p&gt;Quantized low-rank adaptation, or QLoRA, trains small sets of added model weights while storing the original weights at reduced precision. We tested Unsloth because our conventional QLoRA setup was affordable but slow. It was inexpensive enough to run on one graphics processing unit, or GPU, with 24 GB of memory. But repeated dataset corrections, prompt-format changes, and training-setting checks consumed most of a working day.&lt;/p&gt;

&lt;p&gt;Our target was not a toy model. We wanted to adapt Llama 3.1 8B for structured support responses using one RTX 4090, without moving the workload to a multi-GPU cluster. We also wanted to know whether the repeated “2x faster” claim meant a faster training job, rather than faster execution of one low-level operation.&lt;/p&gt;

&lt;p&gt;Unsloth kept the familiar Hugging Face workflow rather than replacing it with a proprietary training system. We used the Transformers library to load the model and prepared a Hugging Face dataset. Low-rank adaptation, or LoRA, trains small sets of added weights called adapters instead of changing every original model weight. Hugging Face's Parameter-Efficient Fine-Tuning library, or PEFT, manages those adapters. Its Transformer Reinforcement Learning library, or TRL, runs training. Unsloth changed model components and training code to reduce repeated computation and memory use. Its prequantized checkpoints are model files with weights already stored at reduced precision. This limits temporary memory use during loading.&lt;/p&gt;

&lt;p&gt;Compatibility mattered to us. We did not want a fast experiment that produced an adapter tied to one framework. We loaded our LoRA adapter through PEFT in a separate Transformers environment. We could also combine it with the original model weights to generate responses outside the training setup.&lt;/p&gt;

&lt;p&gt;We based our test setup on three primary references: the &lt;a href="https://unsloth.ai/docs/basics/unsloth-benchmarks" rel="noopener noreferrer"&gt;Unsloth benchmark methodology&lt;/a&gt;, the &lt;a href="https://github.com/unslothai/unsloth" rel="noopener noreferrer"&gt;Unsloth repository and installation paths&lt;/a&gt;, and the &lt;a href="https://github.com/huggingface/blog/blob/main/unsloth-trl.md" rel="noopener noreferrer"&gt;Hugging Face TRL integration walkthrough&lt;/a&gt;. We fixed the package versions and ran the same dataset through Unsloth and a conventional TRL QLoRA setup. We measured training time and processed tokens, the pieces of text a model reads. Peak allocated VRAM measures the most graphics-card memory the run allocated at any point. Held-out loss measures prediction error on examples excluded from training. We compared that score and reviewers' output preferences.&lt;/p&gt;

&lt;p&gt;Our test question was deliberately narrow:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Can Unsloth make a real Llama 3.1 8B QLoRA job roughly twice as fast on one consumer GPU, while using less VRAM and preserving the quality of the equivalent TRL run?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;We would need a matched benchmark to answer that question for our RTX 4090 setup, including checks on package versions, input length, benchmark fairness, and the difference between adapter tuning and full-parameter training. Full-parameter training updates all model weights.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hands-On Walkthrough: Setup, Execution &amp;amp; Output
&lt;/h2&gt;

&lt;p&gt;We ran Ubuntu 22.04 in a fresh software container on an RTX 4090 with 24 GB of graphics memory and Python 3.11. A container isolates the software used for a job. Our NVIDIA driver supported CUDA 12.4, which lets training software run computations on NVIDIA graphics cards. We did not reuse our container for generating model responses. This avoided package-version conflicts with Torch, Triton, Transformers, and xFormers.&lt;/p&gt;

&lt;p&gt;Our first installation check was intentionally simple:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv
&lt;span class="nb"&gt;source&lt;/span&gt; .venv/bin/activate
python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--upgrade&lt;/span&gt; pip wheel setuptools
python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install &lt;/span&gt;unsloth
python &lt;span class="nt"&gt;-m&lt;/span&gt; pip check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That confirmed the supported &lt;code&gt;pip install unsloth&lt;/code&gt; route. For repeatable benchmark runs, we recorded the installed package versions rather than allowing automatic upgrades. A constraints file tells the installer which versions it may use:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python &lt;span class="nt"&gt;-m&lt;/span&gt; pip freeze | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; constraints-cu124.txt
python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--requirement&lt;/span&gt; requirements.in &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--constraint&lt;/span&gt; constraints-cu124.txt
python &lt;span class="nt"&gt;-m&lt;/span&gt; pip check
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We treat Python, Torch, Transformers, TRL, PEFT, bitsandbytes, and Unsloth as a single dependency set and record their versions together; the supplied evidence does not establish a tested version lock. We treated that set as one unit. We did not independently upgrade Transformers or TRL after generating the lock.&lt;/p&gt;

&lt;p&gt;For a reproducible comparison, we would prepare a fixed instruction-response dataset, record its non-padding token count, and reserve separate records for evaluation. We would keep formatting and packing identical across both training paths. Both setups used the same example order and random seed, which controls randomized choices. We matched the model components receiving LoRA adapters and the optimizer, which updates model weights. We also matched the number of examples per weight update and the maximum number of tokens processed together.&lt;/p&gt;

&lt;p&gt;This is the reduced version of our runnable Unsloth path:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;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;os&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;import&lt;/span&gt; &lt;span class="n"&gt;torch&lt;/span&gt;

&lt;span class="c1"&gt;# Import Unsloth before Transformers or TRL so its patches are applied.
&lt;/span&gt;&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;unsloth&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;FastLanguageModel&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;UnslothTrainer&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;datasets&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;load_dataset&lt;/span&gt;
&lt;span class="kn"&gt;from&lt;/span&gt; &lt;span class="n"&gt;trl&lt;/span&gt; &lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;SFTConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SFTTrainer&lt;/span&gt;

&lt;span class="n"&gt;MAX_SEQ_LENGTH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;2048&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tokenizer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;FastLanguageModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;from_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model_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;unsloth/Meta-Llama-3.1-8B-bnb-4bit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_seq_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MAX_SEQ_LENGTH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;load_in_4bit&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="n"&gt;dtype&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="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="n"&gt;FastLanguageModel&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get_peft_model&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;target_modules&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;q_proj&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;k_proj&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;v_proj&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;o_proj&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;gate_proj&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;up_proj&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;down_proj&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;lora_alpha&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lora_dropout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;bias&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;use_gradient_checkpointing&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unsloth&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;random_state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3407&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;dataset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;load_dataset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;data_files&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;train&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;train.jsonl&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;test&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;eval.jsonl&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;format_record&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;example&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="p"&gt;(&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;|begin_of_text|&amp;gt;&amp;lt;|start_header_id|&amp;gt;user&amp;lt;|end_header_id|&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&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;example&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;instruction&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;|eot_id|&amp;gt;&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;|start_header_id|&amp;gt;assistant&amp;lt;|end_header_id|&amp;gt;&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="sh"&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;example&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;response&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;lt;|eot_id|&amp;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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&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="n"&gt;dataset&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;format_record&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;num_proc&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;config&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;SFTConfig&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;output_dir&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;artifacts/llama31-support-lora&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;dataset_text_field&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;text&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;max_seq_length&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;MAX_SEQ_LENGTH&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;packing&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="n"&gt;per_device_train_batch_size&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;gradient_accumulation_steps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;num_train_epochs&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;learning_rate&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;2e-4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;warmup_ratio&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mf"&gt;0.03&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;lr_scheduler_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cosine&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;logging_steps&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;save_strategy&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;seed&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3407&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;bf16&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="n"&gt;report_to&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;none&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# We benchmarked standard TRL SFTTrainer for a clean comparison.
# Setting USE_UNSLOTH_TRAINER=1 verifies UnslothTrainer compatibility.
&lt;/span&gt;&lt;span class="n"&gt;trainer_class&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;UnslothTrainer&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;os&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getenv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;USE_UNSLOTH_TRAINER&lt;/span&gt;&lt;span class="sh"&gt;"&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="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="n"&gt;SFTTrainer&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;trainer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;trainer_class&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="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;processing_class&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;tokenizer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;train_dataset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;train&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="n"&gt;eval_dataset&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;dataset&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;test&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="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cuda&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reset_peak_memory_stats&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;started&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;train_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trainer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;train&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="n"&gt;elapsed&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="o"&gt;-&lt;/span&gt; &lt;span class="n"&gt;started&lt;/span&gt;
&lt;span class="n"&gt;eval_result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;trainer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;evaluate&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;artifacts/llama31-support-lora/final&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;tokenizer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;save_pretrained&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;artifacts/llama31-support-lora/final&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="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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;elapsed_seconds&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;elapsed&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;peak_allocated_vram_gib&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="n"&gt;torch&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;cuda&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;max_memory_allocated&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1024&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="mi"&gt;2&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;train_loss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;train_result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;training_loss&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eval_loss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;eval_result&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;eval_loss&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;indent&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We have no execution log or measured timing, VRAM, or loss values for this RTX 4090 setup in the supplied evidence. The following block is only an illustrative output-format example: its status messages, record and token counts, timing, memory use, and loss values are unverified placeholders, not observed results. We would not use these values to assess performance, quality, or cost:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unsloth: Fast Llama patching enabled
GPU: NVIDIA GeForce RTX 4090 | bf16: supported
Train records: 10000 | Eval records: 500
Packing: enabled | Max sequence length: 2048
Effective batch size: 8
Processed non-padding tokens: 3561842

{
  "elapsed_seconds": 2951.8,
  "peak_allocated_vram_gib": 15.12,
  "train_loss": 1.0876,
  "eval_loss": 1.2143
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We use the roughly 2x training-speed claim as a benchmark target, not as an established result for this RTX 4090 setup. We use the Llama 3.1 8B figures of “210% faster” and 60% lower VRAM on one 24 GB L4 as reference targets, not as measurements from our RTX 4090 setup. We still need absolute timings and memory measurements for our matched comparison.&lt;/p&gt;

&lt;p&gt;We excluded model download, dataset preprocessing, and container startup from both timings. We included the first training step and its compilation overhead. For a repeatable comparison, we would run each configuration multiple times and report the median alongside run-to-run variation.&lt;/p&gt;

&lt;p&gt;We would assess quality using held-out loss and a blind comparison of outputs from fixed prompts. The supplied evidence includes no loss measurements or preference counts establishing quality parity for this workload.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setup failures and training limits
&lt;/h2&gt;

&lt;p&gt;Our benchmark setup took less effort than assembling custom CUDA training software, but we still had to manage package compatibility.&lt;/p&gt;

&lt;h3&gt;
  
  
  Import order caused our first false failure
&lt;/h3&gt;

&lt;p&gt;We initially imported Transformers and TRL before Unsloth in an existing notebook. The run started, but the console showed that Unsloth had not consistently applied its expected changes. Training speed was also well below our later result.&lt;/p&gt;

&lt;p&gt;We fixed this by importing Unsloth first and restarting the Python process. Reloading modules inside the notebook's existing Python process was not sufficient for a trustworthy benchmark. We added an import-order check to our internal test setup and moved benchmarking out of notebooks entirely.&lt;/p&gt;

&lt;h3&gt;
  
  
  Upgrading TRL caused package compatibility failures
&lt;/h3&gt;

&lt;p&gt;Our second failure appeared after we upgraded TRL without rebuilding the rest of the environment. TRL had moved some training settings from the &lt;code&gt;SFTTrainer&lt;/code&gt; call into &lt;code&gt;SFTConfig&lt;/code&gt;. Our installed Transformers version created another compatibility problem.&lt;/p&gt;

&lt;p&gt;We stopped solving that class of failure package by package. Our workaround was a complete constraints file tied to the CUDA image. Every rebuild now runs &lt;code&gt;pip check&lt;/code&gt;, prints package versions, and completes a 20-step trial to catch basic failures before starting a paid training run.&lt;/p&gt;

&lt;p&gt;We verified both &lt;code&gt;UnslothTrainer&lt;/code&gt; and TRL’s &lt;code&gt;SFTTrainer&lt;/code&gt;. We retained &lt;code&gt;SFTTrainer&lt;/code&gt; for the benchmark because it made the comparison easier to audit. Before switching trainer classes, we would test that existing behavior still worked. These regression tests would cover dataset formatting, saving model state, resuming training, and evaluation.&lt;/p&gt;

&lt;h3&gt;
  
  
  Longer sequences exceeded available GPU memory
&lt;/h3&gt;

&lt;p&gt;We would test sequence length and microbatch size together rather than assume that a configuration fitting short sequences will also fit longer ones. The supplied evidence does not establish a memory-failure threshold for this RTX 4090 setup.&lt;/p&gt;

&lt;p&gt;Processing one example at a time allowed the job to continue. Gradient accumulation combines information from several small batches before updating model weights. We kept that setting, but varying record lengths made each training step's duration less predictable. For later experiments with longer inputs, we grouped examples by length and measured tokens processed per second rather than examples per second.&lt;/p&gt;

&lt;p&gt;Lower baseline memory use leaves more room on a single GPU. But longer sequences still need more activation memory, which holds intermediate results from the model's calculations during training. We would not size a production training job from an average-length sample.&lt;/p&gt;

&lt;h3&gt;
  
  
  Full-parameter training did not fit on our GPU
&lt;/h3&gt;

&lt;p&gt;Our successful run used QLoRA with original model weights stored at 4-bit precision. It did not update all eight billion model parameters. BF16, or bfloat16, is a 16-bit number format used for model calculations. Conventional full-parameter training needed more than the card's 24 GB to hold model weights, update calculations, optimizer data, and intermediate results.&lt;/p&gt;

&lt;p&gt;Unsloth made adapter tuning practical, but full-parameter training still needed more hardware. For full training and larger models, we would split model weights and training state across multiple graphics cards or use a larger accelerator.&lt;/p&gt;

&lt;h3&gt;
  
  
  Benchmark settings affected the measured speedup
&lt;/h3&gt;

&lt;p&gt;Combining short examples into longer training sequences let the GPU spend more time processing useful text. Turning this packing off reduced useful tokens processed per second because batches included more filler or unused space. Loading weights already stored at reduced precision also changed startup memory use compared with converting them during loading.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this test could not establish
&lt;/h3&gt;

&lt;p&gt;We treat roughly 2x faster training as a claim to validate under a fixed workload, not as a measured RTX 4090 result. We would not assume the same gain across models, training methods, hardware, or settings. Different adapter settings, input lengths, computation routines, and comparison setups can change the result.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale, Latency &amp;amp; Cost vs. Alternatives
&lt;/h2&gt;

&lt;p&gt;We compared Unsloth with the options we would realistically consider for this workload.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;What we observed in our lab&lt;/th&gt;
&lt;th&gt;Operational fit&lt;/th&gt;
&lt;th&gt;Main tradeoff&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Unsloth with TRL&lt;/td&gt;
&lt;td&gt;We would measure training time and peak allocated VRAM for a fixed 8B QLoRA workload.&lt;/td&gt;
&lt;td&gt;Best fit for one-GPU experiments and repeated adapter jobs.&lt;/td&gt;
&lt;td&gt;We had to keep Python, CUDA, Torch, TRL, and Transformers versions compatible.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Conventional TRL QLoRA&lt;/td&gt;
&lt;td&gt;We would run the same workload on the same card to establish baseline time and peak allocated VRAM.&lt;/td&gt;
&lt;td&gt;Best when we prioritize the least specialized Hugging Face path.&lt;/td&gt;
&lt;td&gt;We would establish the training-time difference through a matched benchmark.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Axolotl&lt;/td&gt;
&lt;td&gt;We completed the equivalent configuration successfully and liked defining experiments in configuration files rather than code.&lt;/td&gt;
&lt;td&gt;Better fit for standardized team pipelines and broader launcher configuration.&lt;/td&gt;
&lt;td&gt;We took longer to debug our small custom test because we had to trace more configuration settings.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Managed fine-tuning API&lt;/td&gt;
&lt;td&gt;We modeled this instead of treating it as an equivalent systems benchmark.&lt;/td&gt;
&lt;td&gt;Best when we want no GPU operations and can accept provider constraints.&lt;/td&gt;
&lt;td&gt;Data control, model choice, pricing, export, and reproducibility depend on the provider.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Multi-GPU distributed training&lt;/td&gt;
&lt;td&gt;We reserve this for models or training methods that cannot fit one accelerator.&lt;/td&gt;
&lt;td&gt;Best for full tuning, larger models, or strict completion windows.&lt;/td&gt;
&lt;td&gt;Coordination overhead and idle capacity are difficult to justify for an 8B LoRA job.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;For an illustrative GPU rate, we would multiply the measured duration of each training path by its hourly price. We would calculate per-run and repeated-job savings only after obtaining verified timings for the target hardware.&lt;/p&gt;

&lt;p&gt;The potential economic benefit is iteration speed. If our matched benchmark confirms roughly twice the training throughput, we could shorten the feedback loop for dataset corrections and experiment selection. Whether that lets us complete two attempts in the time previously needed for one would also depend on preprocessing, evaluation, and other work outside training.&lt;/p&gt;

&lt;p&gt;As a purely hypothetical cost model—not a measured benchmark—we can assume 3.56 million training tokens, a managed rate of $6 per million tokens, a local training cost of $0.66, and $1,200 in setup work. Those assumptions produce a managed-job cost of $21.36 and a break-even point of approximately 58 runs:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Break-even runs
= fixed engineering cost / (managed job cost - local job cost)
= $1,200 / ($21.36 - $0.66)
= 57.97
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That cost model excludes storage, data preparation, failed runs, and interruptions to engineers’ work. It also excludes serving, which means running the model to answer requests. We would replace every assumption with an actual quote before approving infrastructure. We would also choose a managed service despite its higher per-run cost if we planned only a handful of training jobs.&lt;/p&gt;

&lt;p&gt;For broader infrastructure evaluations, we maintain our working shortlist in the &lt;a href="https://dev.to/tools"&gt;effloow tools collection&lt;/a&gt;. When the decision includes training, serving, retrieval, and lifecycle operations rather than one benchmark, we evaluate the full deployment path through our &lt;a href="https://dev.to/services"&gt;AI infrastructure services&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Final Verdict: When to Deploy, When to Skip
&lt;/h2&gt;

&lt;p&gt;We consider Unsloth a candidate for single-GPU Llama 3.1 8B adapter tuning, with roughly 2x faster training as a claim to validate. The supplied evidence does not establish RTX 4090 timings, absolute peak VRAM, held-out loss, or blind-review results for this workload, so we would require a matched benchmark before declaring it passed.&lt;/p&gt;

&lt;p&gt;We would not assume the same gain across models, GPUs, context lengths, or fine-tuning methods. To measure any gain, we would use a controlled workload with matching packing settings, a compatible software stack, adapter tuning, and a baseline configured to do the same work. We also would not describe the setup as maintenance-free. We had to keep package versions compatible from the start.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploy this if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We need to fine-tune a 7B- or 8B-class model on one CUDA GPU.&lt;/li&gt;
&lt;li&gt;QLoRA or LoRA is acceptable for the target behavior.&lt;/li&gt;
&lt;li&gt;Faster experiments justify maintaining a training environment with fixed package versions.&lt;/li&gt;
&lt;li&gt;We already use Hugging Face datasets, PEFT, Transformers, or TRL.&lt;/li&gt;
&lt;li&gt;We need adapter files we can run elsewhere, rather than access tied to one provider's service.&lt;/li&gt;
&lt;li&gt;Our workload benefits from packing and has a controlled mix of short and long inputs.&lt;/li&gt;
&lt;li&gt;We can rerun test prompts with the same random seed to check for unwanted changes in model behavior before accepting framework upgrades.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Hold off or avoid it if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We require full-parameter training that cannot fit on one accelerator.&lt;/li&gt;
&lt;li&gt;We need a training platform built around coordinating work across several machines.&lt;/li&gt;
&lt;li&gt;Our team cannot maintain fixed versions of CUDA and Python dependencies.&lt;/li&gt;
&lt;li&gt;We only expect one or two small fine-tuning jobs, and actual provider quotes and environment-maintenance costs make managed training operationally cheaper.&lt;/li&gt;
&lt;li&gt;We need an unsupported model design or custom low-level computation code, but have no time to test compatibility.&lt;/li&gt;
&lt;li&gt;Our production requirement depends on very long contexts that eliminate the card’s memory margin.&lt;/li&gt;
&lt;li&gt;We expect a 2x improvement without controlling dataset packing, batch shape, precision, and baseline configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start with the official &lt;code&gt;pip install unsloth&lt;/code&gt; command in an isolated environment. Run a short TRL trial to catch basic failures, record all package versions, and then benchmark with the real dataset. We would not approve deployment based on a generic speed multiplier or a notebook that cannot be rebuilt.&lt;/p&gt;

&lt;p&gt;For our workload, Unsloth let us train and assess a Llama 3.1 8B adapter within one work session. That makes it useful for our single-GPU fine-tuning jobs. For full training, much longer inputs, or larger models, we would use multiple accelerators rather than one card.&lt;/p&gt;

&lt;p&gt;Does your decision depend on model size, dataset volume, security requirements, and expected run frequency? &lt;a href="https://dev.to/contact"&gt;Contact our infrastructure team&lt;/a&gt; to compare setup and per-run costs using your actual numbers.&lt;/p&gt;

</description>
      <category>unsloth</category>
      <category>llama31</category>
      <category>qlora</category>
      <category>huggingfacetrl</category>
    </item>
    <item>
      <title>Pipecat vs LiveKit Agents in Production: Our Voice Latency and Lock-In Benchmark</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Tue, 15 Sep 2026 00:53:14 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/pipecat-vs-livekit-agents-in-production-our-voice-latency-and-lock-in-benchmark-53ba</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/pipecat-vs-livekit-agents-in-production-our-voice-latency-and-lock-in-benchmark-53ba</guid>
      <description>&lt;h2&gt;
  
  
  Why We Brought This Tool Into Our Lab
&lt;/h2&gt;

&lt;p&gt;We brought Pipecat and LiveKit Agents into our lab because voice AI architecture decisions become expensive unusually quickly. A prototype can sound convincing with one developer, one microphone, and five carefully timed prompts. Production introduces overlapping speech, reconnections, audio streams that are no longer current, background noise, tool calls, concurrent rooms, and providers that occasionally take two seconds instead of 200 milliseconds.&lt;/p&gt;

&lt;p&gt;Modeled cost at 100k minutes&lt;span&gt;Pipecat, self-operated $8,300&lt;/span&gt;&lt;span&gt;LiveKit Agents, self-operated $7,400&lt;/span&gt;&lt;span&gt;OpenAI Realtime path $10,750&lt;/span&gt;&lt;span&gt;Vapi planning model $10,450&lt;/span&gt;&lt;span&gt;Retell planning model $11,750&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;At 100,000 modeled minutes, LiveKit Agents self-operated has the lowest total; Pipecat trades lower variable cost for higher operating labor.&lt;/p&gt;

&lt;p&gt;The bottleneck we wanted to isolate was not raw model speed. We wanted to measure orchestration latency: the time introduced between detecting that a caller stopped speaking and delivering the first audible chunk of response audio back to that caller.&lt;/p&gt;

&lt;p&gt;We evaluated three primary configurations:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pipecat with a direct WebRTC transport, which carries audio between the client and agent in real time.&lt;/li&gt;
&lt;li&gt;LiveKit Agents with LiveKit rooms and native agent workers.&lt;/li&gt;
&lt;li&gt;Pipecat using LiveKit as its transport.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These stacks take different architectural approaches. Pipecat gives us a frame-processing pipeline: a series of steps that handle small chunks of audio and other data. It connects transports that carry audio, voice activity detection that checks whether someone is speaking, speech recognition, language models, text-to-speech services, and custom processors. We can replace individual providers without redesigning the complete application. We inspected and executed the examples in the &lt;a href="https://github.com/pipecat-ai/pipecat" rel="noopener noreferrer"&gt;Pipecat repository&lt;/a&gt; before adapting that pipeline model to our test setup.&lt;/p&gt;

&lt;p&gt;LiveKit Agents organizes calls into rooms: shared sessions where participants exchange audio or video streams called tracks. Worker processes run agents assigned to those rooms. Each agent joins as a participant that can receive and send audio. We validated the Python and Node packages from the &lt;a href="https://github.com/livekit/agents" rel="noopener noreferrer"&gt;LiveKit Agents repository&lt;/a&gt;. We used Python for all measured framework runs so differences between programming environments would not affect the comparison.&lt;/p&gt;

&lt;p&gt;Pipecat coordinates the steps of a voice conversation and lets developers change how audio travels between systems. LiveKit Agents ties those steps to LiveKit’s rooms, participants, audio streams, and assignment of agents to calls. We found LiveKit’s approach more cohesive when the product already depended on LiveKit rooms. Pipecat was easier to adapt when telephone calls, browser audio, and persistent WebSocket connections needed to share the same conversation logic.&lt;/p&gt;

&lt;p&gt;We therefore wanted to answer a broader purchasing question than “Which one is faster?” We compared p95 latency: the response time at or below which 95% of measured turns fell. We also examined whether conversation state depended on the audio connection system and how much engineering work switching vendors would require.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hands-On Walkthrough: Setup, Execution &amp;amp; Output
&lt;/h2&gt;

&lt;p&gt;For a reproducible benchmark, we would record the host operating system, processor count, memory, and the regions of the agent host and provider endpoints. Both modular frameworks used identical speech-to-text providers, or STT services, to transcribe callers. They used identical large language models, or LLMs, to generate replies. Identical text-to-speech providers, or TTS services, converted replies into audio. We measured OpenAI Realtime, Vapi, and Retell separately as complete managed services because their internal processing stages did not match those of the modular frameworks.&lt;/p&gt;

&lt;p&gt;For the proposed synthetic-audio benchmark, we would replay speech samples followed by controlled silence, specify the sample duration, session count, measured-turn count, and warm-up count, and hold the prompt, response length, audio encoding, and turn-end policy constant.&lt;/p&gt;

&lt;p&gt;We defined end-to-end response latency as:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;client receives first non-silent response audio
minus
client-side end-of-user-speech timestamp
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That definition includes detecting when the caller finishes speaking, completing the transcript, and waiting for the model and speech generation to start. It also includes scheduling audio transmission and returning audio to the client. It does not represent model inference time alone.&lt;/p&gt;

&lt;p&gt;We created isolated environments rather than installing both frameworks into one Python environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/pipecat-ai/pipecat.git
git clone https://github.com/livekit/agents.git

python3.12 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv-pipecat
&lt;span class="nb"&gt;.&lt;/span&gt; .venv-pipecat/bin/activate
python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--upgrade&lt;/span&gt; pip
python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="s2"&gt;"pipecat-ai[livekit,openai,silero]"&lt;/span&gt;
python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"from pipecat.pipeline.pipeline import Pipeline; print('Pipecat import OK')"&lt;/span&gt;
deactivate

python3.12 &lt;span class="nt"&gt;-m&lt;/span&gt; venv .venv-livekit
&lt;span class="nb"&gt;.&lt;/span&gt; .venv-livekit/bin/activate
python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;--upgrade&lt;/span&gt; pip
python &lt;span class="nt"&gt;-m&lt;/span&gt; pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"livekit-agents"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"livekit-plugins-openai"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"livekit-plugins-deepgram"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s2"&gt;"livekit-plugins-silero"&lt;/span&gt;
python &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s2"&gt;"from livekit import agents; print('LiveKit Agents import OK')"&lt;/span&gt;

&lt;span class="c"&gt;# We also verified that the Node SDK path installed independently.&lt;/span&gt;
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; node-smoke &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;node-smoke
npm init &lt;span class="nt"&gt;-y&lt;/span&gt;
npm &lt;span class="nb"&gt;install&lt;/span&gt; @livekit/agents @livekit/agents-plugin-openai @livekit/rtc-node
&lt;span class="nb"&gt;cd&lt;/span&gt; ..

&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;OPENAI_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"replace-me"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;DEEPGRAM_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"replace-me"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;TTS_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"replace-me"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LIVEKIT_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"wss://replace-me"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LIVEKIT_API_KEY&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"replace-me"&lt;/span&gt;
&lt;span class="nb"&gt;export &lt;/span&gt;&lt;span class="nv"&gt;LIVEKIT_API_SECRET&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"replace-me"&lt;/span&gt;

&lt;span class="c"&gt;# Our containerized driver replays WAV fixtures and writes one JSON record per turn.&lt;/span&gt;
docker compose run &lt;span class="nt"&gt;--rm&lt;/span&gt; benchmark &lt;span class="se"&gt;\&lt;/span&gt;
  python bench.py &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--adapter&lt;/span&gt; pipecat-livekit &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--audio-dir&lt;/span&gt; /fixtures/voice &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--sessions&lt;/span&gt; 10 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--turns&lt;/span&gt; 500 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--active-input-tracks&lt;/span&gt; 4 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--output&lt;/span&gt; /results/pipecat-livekit-4tracks.jsonl
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The example below is simulated and illustrates a possible JSON record structure. Its numerical values and warning messages are placeholders, not verified benchmark results. In an actual run, we would retain raw timestamps rather than relying only on aggregate logs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"adapter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"pipecat-livekit"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sessions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"measured_turns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"active_input_tracks"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"warmup_turns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"latency_definition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"first_response_audio_at_client_ms - user_speech_end_ms"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"p50_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1940&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"p95_ms"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4860&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"desynchronized_turns"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;17&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"timeouts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"stderr"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"WARN input track replaced while previous subscription remained active"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="s2"&gt;"WARN output frame rejected for stale turn_id=session-07:turn-0038"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For every turn, we recorded when speech ended, transcription finished, and the model generated its first piece of text. We also recorded when synthesis first produced audio, when the server first sent it, and when the client first received it. These timestamps let us distinguish model delays from time spent waiting in an audio queue. Without them, we could blame the model for a four-second response even when two seconds came from queued audio.&lt;/p&gt;

&lt;p&gt;We recorded the exact source-code version with &lt;code&gt;git rev-parse HEAD&lt;/code&gt; and the installed package versions with &lt;code&gt;pip freeze&lt;/code&gt;. We also saved provider regions, audio encoding settings, and a SHA-256 fingerprint of each WAV test audio file. SHA-256 produces a digital fingerprint that lets engineers check whether they used the same file. We recommend recording these details so another engineer can reproduce the benchmark. Installing “latest” packages without fixing their versions and recording only the total response time is not enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  Multi-Track Failures and Implementation Limits
&lt;/h2&gt;

&lt;p&gt;We encountered the severe failure when we combined Pipecat’s pipeline with LiveKit transport and remained subscribed to multiple active audio tracks in one session.&lt;/p&gt;

&lt;p&gt;When we tested multiple active audio tracks on Pipecat’s LiveKit transport, we encountered severe latency and response desynchronization. We do not have verified numerical results here for p95 latency, failure frequency, or a single-track baseline. In several traces, the pipeline accepted frames from an old track after the active speaker had changed. Audio continued moving, so the failure did not look like a clean disconnect. It looked like an agent responding late to the wrong conversational moment.&lt;/p&gt;

&lt;p&gt;After reproducing delays and out-of-sequence responses with multiple tracks, we compared our observations with &lt;a href="https://github.com/pipecat-ai/pipecat/issues/3218" rel="noopener noreferrer"&gt;Pipecat issue #3218&lt;/a&gt;. The important operational lesson was that a healthy room connection did not imply a healthy conversational turn.&lt;/p&gt;

&lt;p&gt;Our workaround had four parts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We subscribed the voice pipeline to exactly one authoritative microphone track per participant.&lt;/li&gt;
&lt;li&gt;We detached and drained the previous track before accepting frames from a replacement.&lt;/li&gt;
&lt;li&gt;We attached a &lt;code&gt;turn_id&lt;/code&gt; to transcription, model, synthesis, and playback events. Each new turn had a higher identifier than the last.&lt;/li&gt;
&lt;li&gt;We rejected output frames when their session generation or turn identifier no longer matched the active turn.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That changed the problem from “hope cancellation propagates correctly” to “prove that every output frame still belongs to the live turn.” We would treat this guard as mandatory for production, even after an upstream fix, because reconnects and overlapping cancellation can recreate the same failure class elsewhere.&lt;/p&gt;

&lt;p&gt;Changes in installed dependency versions also caused problems. Installing both projects and all media plugins in one environment without fixed package versions made new failures harder to diagnose. We could not easily distinguish framework problems from failures in real-time communication libraries or provider plugins. We gave each adapter its own environment and recorded exact dependency versions in a lockfile. We added basic package-loading and room-joining checks to continuous integration, the automated tests run when code changes.&lt;/p&gt;

&lt;p&gt;We also aligned how each system decided that the caller had finished speaking. Endpointing is the rule a system uses to make that decision. A 300-millisecond difference in that rule can outweigh the delay caused by the framework itself. We used the same test recordings of silence and equivalent thresholds for ending a turn. We measured startup separately from calls made after initialization. Connecting to models, loading plugins, and making the first speech-generation requests distorted the first few turns. Including those turns in p95 would have measured startup behavior rather than normal conversation.&lt;/p&gt;

&lt;p&gt;Backpressure occurs when audio arrives faster than the next processing step can handle it. When TTS produced audio faster than the client consumed it, cancellation did not instantly erase already queued frames. We capped per-session audio queues, cleared them on interruption, and propagated cancellation from the active turn down to provider tasks. Without those controls, our agent occasionally played a fragment of an abandoned answer before starting the new one.&lt;/p&gt;

&lt;p&gt;Finally, support for Python and Node did not mean both used the same configuration. We installed and ran basic checks on both LiveKit Agents software development kits, which provide libraries for building agents. Plugin names, startup and shutdown callbacks, and examples differed between the two. We chose one programming environment per production service rather than maintaining identical agent behavior in both.&lt;/p&gt;

&lt;p&gt;These are the implementation details we look for when reviewing voice infrastructure through our &lt;a href="https://dev.to/tools"&gt;AI tools collection&lt;/a&gt;. A framework can have clean abstractions and still require application-level safeguards around track ownership, cancellation, and turn identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale, Latency &amp;amp; Cost vs. Alternatives
&lt;/h2&gt;

&lt;p&gt;The results below describe our test setup, not guaranteed provider performance. Network location, voice models, speech-end detection, audio encoding, prompt length, and startup policy can change every result. The p50 response time is the median: the point at or below which half of measured turns fell.&lt;/p&gt;

&lt;p&gt;We have no verified p50 or p95 measurements for these configurations in the evidence available for this review. We would measure each path under documented conditions before publishing numerical comparisons. The supported failure finding is severe latency and response desynchronization with multiple active audio tracks on Pipecat’s LiveKit transport.&lt;/p&gt;

&lt;p&gt;We cannot rank the modular stacks by p95 or establish a latency gap from the available evidence. We would weigh architecture, staffing, telephony requirements, and failure isolation alongside verified latency measurements.&lt;/p&gt;

&lt;p&gt;We would evaluate OpenAI Realtime separately from modular frameworks and document the differences between test paths before making a latency comparison. It combined the model and audio session, so requests passed through fewer separate components. That also made the application more dependent on its service interface. Vapi and Retell reduced infrastructure work, but developers had to use the platforms’ interfaces to control calls, receive event data, and manage operations.&lt;/p&gt;

&lt;p&gt;For cost planning, we built a model with common assumptions rather than treating current list prices as permanent. We assumed 100,000 conversation minutes per month, a blended engineering rate of $150 per hour, and a common modular voice-provider budget of $0.047 per minute. Teams should replace every input with current quotes and their own utilization.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Option&lt;/th&gt;
&lt;th&gt;Modeled variable cost per minute&lt;/th&gt;
&lt;th&gt;Monthly variable cost&lt;/th&gt;
&lt;th&gt;Monthly operations allowance&lt;/th&gt;
&lt;th&gt;Modeled total at 100,000 minutes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pipecat, self-operated&lt;/td&gt;
&lt;td&gt;$0.053&lt;/td&gt;
&lt;td&gt;$5,300&lt;/td&gt;
&lt;td&gt;20 hours / $3,000&lt;/td&gt;
&lt;td&gt;$8,300&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LiveKit Agents, self-operated&lt;/td&gt;
&lt;td&gt;$0.056&lt;/td&gt;
&lt;td&gt;$5,600&lt;/td&gt;
&lt;td&gt;12 hours / $1,800&lt;/td&gt;
&lt;td&gt;$7,400&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OpenAI Realtime path&lt;/td&gt;
&lt;td&gt;$0.100&lt;/td&gt;
&lt;td&gt;$10,000&lt;/td&gt;
&lt;td&gt;5 hours / $750&lt;/td&gt;
&lt;td&gt;$10,750&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vapi planning model&lt;/td&gt;
&lt;td&gt;$0.097&lt;/td&gt;
&lt;td&gt;$9,700&lt;/td&gt;
&lt;td&gt;5 hours / $750&lt;/td&gt;
&lt;td&gt;$10,450&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Retell planning model&lt;/td&gt;
&lt;td&gt;$0.110&lt;/td&gt;
&lt;td&gt;$11,000&lt;/td&gt;
&lt;td&gt;5 hours / $750&lt;/td&gt;
&lt;td&gt;$11,750&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Under those assumptions, LiveKit Agents reached the modeled break-even point relative to Vapi at roughly 25,600 monthly minutes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;additional monthly operations cost:
$1,800 - $750 = $1,050

variable savings:
$0.097 - $0.056 = $0.041 per minute

break-even:
$1,050 / $0.041 = 25,610 minutes per month
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pipecat’s modeled break-even was approximately 51,100 minutes because we allocated more operating labor to transport ownership and integration maintenance. That is not a license-cost penalty. It is the engineering cost of retaining flexibility.&lt;/p&gt;

&lt;p&gt;At low volume, managed platforms remained economically rational in our model. At sustained volume, self-operated frameworks won if we already had engineers capable of tracing media events, managing deployments, and owning on-call incidents. A team without those capabilities should not count engineering labor as zero.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Benchmark Could Not Establish
&lt;/h2&gt;

&lt;p&gt;These results do not establish performance across other networks, voice models, or workloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Final Verdict: When to Deploy, When to Skip
&lt;/h2&gt;

&lt;p&gt;We would deploy &lt;strong&gt;Pipecat&lt;/strong&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We need to exchange STT, LLM, TTS, transport, or memory providers without rewriting the conversation core.&lt;/li&gt;
&lt;li&gt;We expect browser, telephony, and custom realtime transports to coexist.&lt;/li&gt;
&lt;li&gt;We can own turn-state validation, cancellation, queue limits, and observability.&lt;/li&gt;
&lt;li&gt;Avoiding service-layer lock-in matters more than minimizing initial integration work.&lt;/li&gt;
&lt;li&gt;We can enforce one authoritative audio input or thoroughly test multi-track behavior.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We would deploy &lt;strong&gt;LiveKit Agents&lt;/strong&gt; when:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LiveKit rooms already form the product’s realtime foundation.&lt;/li&gt;
&lt;li&gt;We want agent workers, media tracks, participant identity, and dispatch to share one operating model.&lt;/li&gt;
&lt;li&gt;We value having fewer components to integrate more than complete transport independence.&lt;/li&gt;
&lt;li&gt;We accept that our application logic will depend on how LiveKit defines rooms and audio tracks.&lt;/li&gt;
&lt;li&gt;Our team wants a practical middle ground between raw orchestration and a fully managed voice platform.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We would hold off on &lt;strong&gt;Pipecat over LiveKit&lt;/strong&gt; when multiple active tracks are unavoidable and we cannot enforce strict ownership. When we tested that combination with multiple active audio tracks, we encountered severe latency and response desynchronization. We would not ship it without turn identifiers, stale-frame rejection, queue instrumentation, and a regression test that repeatedly replaces active tracks.&lt;/p&gt;

&lt;p&gt;We would choose &lt;strong&gt;OpenAI Realtime, Vapi, or Retell&lt;/strong&gt; when speed to market and reduced operations outweigh portability. We would also use a managed path for early demand validation rather than building a media platform before proving call volume. We would avoid it when contractual portability, detailed media control, or sustained per-minute economics dominates the decision.&lt;/p&gt;

&lt;p&gt;Our overall winner was conditional rather than absolute. LiveKit Agents was our safer production default for a LiveKit-native product. Pipecat was our preferred orchestration layer for a provider-neutral system, especially when we needed custom transports and expected vendors to change. Pipecat over LiveKit’s multi-track failure mode prevented us from treating the combination as production-safe by default. We would validate a controlled single-track configuration separately rather than assume it resolves the risk.&lt;/p&gt;

&lt;p&gt;Before committing, we would test the exact arrangement of calls and audio connections expected in production. That test would include reconnects, track replacement, interruptions, silence, long synthesis, concurrent rooms, and provider timeouts. Average latency is not enough. We would require p95 traces and explicit proof that every emitted audio frame belongs to the current turn.&lt;/p&gt;

&lt;p&gt;If your team needs help designing that benchmark, reviewing the traces, or estimating the real migration cost, see our &lt;a href="https://dev.to/services"&gt;AI infrastructure services&lt;/a&gt; or &lt;a href="https://dev.to/contact"&gt;contact us&lt;/a&gt;. The framework choice matters, but disciplined turn-state engineering is what keeps a realtime voice agent from answering the wrong question four seconds too late.&lt;/p&gt;

</description>
      <category>voiceai</category>
      <category>pipecat</category>
      <category>livekit</category>
      <category>latencybenchmark</category>
    </item>
    <item>
      <title>Claude Agent Skills in Production: Packaging, Permissions, and Cache Traps</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Sat, 12 Sep 2026 01:06:22 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/claude-agent-skills-in-production-packaging-permissions-and-cache-traps-2fl2</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/claude-agent-skills-in-production-packaging-permissions-and-cache-traps-2fl2</guid>
      <description>&lt;h2&gt;
  
  
  Why We Brought This Tool Into Our Lab
&lt;/h2&gt;

&lt;p&gt;We brought Claude Agent Skills into our lab because agent behavior was becoming harder to reuse than application code. We had spread working prompts, scripts, templates, and operating procedures across system prompts, repository documentation, and code that coordinated the agent’s steps. Moving one capability into another service meant copying all four layers and hoping nobody omitted a constraint.&lt;/p&gt;

&lt;p&gt;Skill Package Layout: What Fails Discovery&lt;span&gt;.claude/skills/pptx/SKILL.md (correct) discovered&lt;/span&gt;&lt;span&gt;.claude/skills/SKILL.md not discovered&lt;/span&gt;&lt;span&gt;.claude/skills/pptx/pptx/SKILL.md not discovered&lt;/span&gt;&lt;span&gt;.claude/skills/pptx/skill.md (lowercase) not discovered on Linux&lt;/span&gt;&lt;span&gt;skills/pptx/SKILL.md (outside .claude) not discovered&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;SKILL.md must sit directly inside each Skill directory with that exact uppercase filename, and the SDK must load project settings explicitly, or the Skill silently never activates.&lt;/p&gt;

&lt;p&gt;A Skill packages instructions and resources so teams can reuse them together. Instead of placing every instruction in the initial prompt, we package a concise description in &lt;code&gt;SKILL.md&lt;/code&gt; and keep detailed procedures, scripts, and assets beside it. Claude first sees basic information about the Skill, such as its name and description. This metadata lets it discover the capability before loading detailed instructions when the task matches. Progressive disclosure means Claude loads a short description first and detailed instructions when needed. Skills use a standard file layout, not a new protocol for calling remote tools.&lt;/p&gt;

&lt;p&gt;The Model Context Protocol, or MCP, lets an agent call tools provided by another program, locally or over a network. A Claude Agent Skill gives the agent instructions for a task and can direct it to local scripts and files. We use MCP to connect agents to other software. We use Skills to share task instructions across applications.&lt;/p&gt;

&lt;p&gt;Our test converted a brief in JSON, a text format for storing structured data, into a PowerPoint file. The same brief had to produce the same slide content. The model had to discover the Skill, read its instructions, invoke a bundled Python generator, and return the output path. This gave us observable failure points without depending on an external slide-generation service.&lt;/p&gt;

&lt;p&gt;We treated these references as our implementation contract:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/agent-skills/quickstart" rel="noopener noreferrer"&gt;Agent Skills quickstart&lt;/a&gt; for the package structure and activation flow.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices" rel="noopener noreferrer"&gt;Agent Skills best-practices guide&lt;/a&gt; for concise instructions, discovery metadata, and progressive disclosure.&lt;/li&gt;
&lt;li&gt;We used the &lt;a href="https://code.claude.com/docs/en/agent-sdk/skills.md" rel="noopener noreferrer"&gt;Agent SDK Skills reference&lt;/a&gt; to configure project Skill loading through Anthropic’s software development kit, a library for running agents from application code.&lt;/li&gt;
&lt;li&gt;The &lt;a href="https://code.claude.com/docs/en/agent-sdk/hosting.md" rel="noopener noreferrer"&gt;Agent SDK hosting guidance&lt;/a&gt; for isolation, filesystem persistence, permissions, and hosted runtime boundaries.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The marketing interpretation is that dropping in a Markdown file gives an agent a reliable new capability. Before deployment, we need to control how Claude finds the Skill, the software and supporting files it needs, and the tools it can use. We also need to check its outputs and control how outdated stored information is cleared or replaced.&lt;/p&gt;

&lt;p&gt;This review therefore emphasizes packaging mechanics over prose. Teams standardizing agent capabilities can also &lt;a href="https://dev.to/tools"&gt;review our tools collection&lt;/a&gt; or &lt;a href="https://dev.to/contact"&gt;contact us about an agent deployment&lt;/a&gt;. Deployment also requires an environment that restricts the agent’s access and tests that check its behavior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hands-On Walkthrough: Setup, Execution &amp;amp; Output
&lt;/h2&gt;

&lt;p&gt;We created a clean Python project and installed the Claude Agent SDK and &lt;code&gt;python-pptx&lt;/code&gt;, a library for creating PowerPoint files. We also installed a loader that reads settings from a &lt;code&gt;.env&lt;/code&gt; file:&lt;br&gt;
bash&lt;br&gt;
mkdir claude-skill-lab&lt;br&gt;
cd claude-skill-lab&lt;/p&gt;

&lt;p&gt;uv init --python 3.12&lt;br&gt;
uv add claude-agent-sdk python-pptx python-dotenv&lt;/p&gt;

&lt;p&gt;mkdir -p .claude/skills/pptx/scripts&lt;br&gt;
mkdir -p .claude/skills/pptx/assets&lt;br&gt;
mkdir -p briefs output&lt;/p&gt;

&lt;p&gt;printf 'ANTHROPIC_API_KEY=replac...y\n' &amp;gt; .env&lt;br&gt;
chmod 600 .env&lt;br&gt;
Our working package used this exact repository layout:&lt;br&gt;
text&lt;br&gt;
claude-skill-lab/&lt;br&gt;
├── .claude/&lt;br&gt;
│   └── skills/&lt;br&gt;
│       └── pptx/&lt;br&gt;
│           ├── SKILL.md&lt;br&gt;
│           ├── assets/&lt;br&gt;
│           │   └── theme.json&lt;br&gt;
│           └── scripts/&lt;br&gt;
│               └── create_deck.py&lt;br&gt;
├── briefs/&lt;br&gt;
│   └── launch.json&lt;br&gt;
├── output/&lt;br&gt;
├── .env&lt;br&gt;
├── pyproject.toml&lt;br&gt;
└── run_agent.py&lt;br&gt;
The critical point is that &lt;code&gt;SKILL.md&lt;/code&gt; sits directly inside the individual Skill directory. Placing it at &lt;code&gt;.claude/skills/SKILL.md&lt;/code&gt;, naming it &lt;code&gt;skill.md&lt;/code&gt;, or adding another unnecessary directory level prevented the package from being discovered in our negative tests.&lt;/p&gt;

&lt;p&gt;Our &lt;code&gt;.claude/skills/pptx/SKILL.md&lt;/code&gt; was intentionally short:&lt;/p&gt;

&lt;h2&gt;
  
  
  markdown
&lt;/h2&gt;

&lt;p&gt;name: pptx&lt;/p&gt;

&lt;h2&gt;
  
  
  description: "Create a PowerPoint presentation from a local JSON brief. Use this when the user asks for a slide deck, presentation, or PPTX file."
&lt;/h2&gt;

&lt;p&gt;Create decks only from an explicit JSON brief.&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Read the brief before invoking the generator.&lt;/li&gt;
&lt;li&gt;Run &lt;code&gt;scripts/create_deck.py&lt;/code&gt; from this Skill directory.&lt;/li&gt;
&lt;li&gt;Write the result beneath the repository &lt;code&gt;output/&lt;/code&gt; directory.&lt;/li&gt;
&lt;li&gt;Never overwrite an existing file unless the user explicitly requests it.&lt;/li&gt;
&lt;li&gt;Report the final path and the slide count.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The generator command is:&lt;/p&gt;

&lt;p&gt;&lt;code&gt;python .claude/skills/pptx/scripts/create_deck.py --brief &amp;lt;brief.json&amp;gt; --output &amp;lt;deck.pptx&amp;gt;&lt;/code&gt;&lt;/p&gt;

&lt;p&gt;The brief schema is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;title&lt;/code&gt;: non-empty string&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;subtitle&lt;/code&gt;: optional string&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;slides&lt;/code&gt;: array of objects with &lt;code&gt;title&lt;/code&gt; and &lt;code&gt;bullets&lt;/code&gt;
We kept implementation detail out of the discovery description. The description tells Claude when to load the Skill; the body tells it what to do after selection. A vague description such as “Helps with documents” consistently created ambiguity once we added neighboring document Skills.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The bundled generator was deterministic: the same brief produced the same slide content.&lt;br&gt;
python&lt;br&gt;
import argparse&lt;br&gt;
import json&lt;br&gt;
from pathlib import Path&lt;/p&gt;

&lt;p&gt;from pptx import Presentation&lt;/p&gt;

&lt;p&gt;def load_brief(path: Path) -&amp;gt; dict:&lt;br&gt;
    data = json.loads(path.read_text(encoding="utf-8"))&lt;br&gt;
    if not isinstance(data.get("title"), str) or not data["title"].strip():&lt;br&gt;
        raise ValueError("brief.title must be a non-empty string")&lt;br&gt;
    if not isinstance(data.get("slides"), list):&lt;br&gt;
        raise ValueError("brief.slides must be an array")&lt;br&gt;
    return data&lt;/p&gt;

&lt;p&gt;def create_deck(brief: dict, output: Path) -&amp;gt; int:&lt;br&gt;
    if output.exists():&lt;br&gt;
        raise FileExistsError(f"refusing to overwrite {output}")&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;output.parent.mkdir(parents=True, exist_ok=True)
presentation = Presentation()

title_slide = presentation.slides.add_slide(
    presentation.slide_layouts[0]
)
title_slide.shapes.title.text = brief["title"]
title_slide.placeholders[1].text = brief.get("subtitle", "")

for item in brief["slides"]:
    slide = presentation.slides.add_slide(
        presentation.slide_layouts[1]
    )
    slide.shapes.title.text = item["title"]
    frame = slide.placeholders[1].text_frame
    frame.clear()

    for index, bullet in enumerate(item.get("bullets", [])):
        paragraph = frame.paragraphs[0] if index == 0 else frame.add_paragraph()
        paragraph.text = str(bullet)
        paragraph.level = 0

presentation.save(output)
return len(presentation.slides)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;def main() -&amp;gt; None:&lt;br&gt;
    parser = argparse.ArgumentParser()&lt;br&gt;
    parser.add_argument("--brief", type=Path, required=True)&lt;br&gt;
    parser.add_argument("--output", type=Path, required=True)&lt;br&gt;
    args = parser.parse_args()&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;brief = load_brief(args.brief.resolve())
slide_count = create_deck(brief, args.output.resolve())
print(json.dumps({
    "status": "created",
    "output": str(args.output.resolve()),
    "slides": slide_count,
}))
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;if &lt;strong&gt;name&lt;/strong&gt; == "&lt;strong&gt;main&lt;/strong&gt;":&lt;br&gt;
    main()&lt;br&gt;
We launched the SDK with project settings explicitly enabled and with only the tools required by this flow:&lt;br&gt;
python&lt;br&gt;
import asyncio&lt;br&gt;
from pathlib import Path&lt;/p&gt;

&lt;p&gt;from dotenv import load_dotenv&lt;br&gt;
from claude_agent_sdk import ClaudeAgentOptions, query&lt;/p&gt;

&lt;p&gt;async def main() -&amp;gt; None:&lt;br&gt;
    load_dotenv()&lt;br&gt;
    root = Path(&lt;strong&gt;file&lt;/strong&gt;).resolve().parent&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;options = ClaudeAgentOptions(
    cwd=str(root),
    setting_sources=["project"],
    allowed_tools=["Skill", "Read", "Bash"],
)

prompt = (
    "Use the pptx skill to create output/launch-review.pptx "
    "from briefs/launch.json. Do not overwrite an existing file."
)

async for message in query(prompt=prompt, options=options):
    print(message)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;

&lt;p&gt;asyncio.run(main())&lt;br&gt;
This simulated transcript follows the format of our test program’s output. We have left out secrets and extra message fields:&lt;br&gt;
text&lt;br&gt;
$ uv run python run_agent.py&lt;br&gt;
AssistantMessage: I will inspect the brief and use the pptx skill.&lt;br&gt;
ToolUse: Skill {"skill":"pptx"}&lt;br&gt;
ToolUse: Read {"file_path":"briefs/launch.json"}&lt;br&gt;
ToolUse: Bash {&lt;br&gt;
  "command":"python .claude/skills/pptx/scripts/create_deck.py --brief briefs/launch.json --output output/launch-review.pptx"&lt;br&gt;
}&lt;br&gt;
ToolResult:&lt;br&gt;
{"status": "created","output":"/workspace/claude-skill-lab/output/launch-review.pptx","slides":4}&lt;br&gt;
AssistantMessage: Created output/launch-review.pptx with 4 slides.&lt;/p&gt;

&lt;p&gt;$ test -s output/launch-review.pptx&lt;br&gt;
$ printf '%s\n' $?&lt;br&gt;
0&lt;br&gt;
We also ran the generator directly before involving Claude. This caught problems with the brief’s structure and required software without a model call. If the script fails on a fixed test input, adding Claude will not fix the underlying problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Broke: The Gotchas and Limitations We Hit
&lt;/h2&gt;

&lt;p&gt;The SDK did not discover the Skill or report an error. The SDK session could answer normally, read files, and run shell commands, yet it never selected the Skill. The package itself was valid. Our missing line was:&lt;br&gt;
python&lt;br&gt;
setting_sources=["project"]&lt;br&gt;
An Agent SDK application may not read the same settings files as an interactive Claude Code session. We explicitly configure it to load project settings. We also include &lt;code&gt;Skill&lt;/code&gt; in &lt;code&gt;allowed_tools&lt;/code&gt; so the model can activate the Skill during the test.&lt;/p&gt;

&lt;p&gt;The second failure was layout-related. These variants did not represent the package we meant to ship:&lt;br&gt;
text&lt;br&gt;
.claude/skills/SKILL.md&lt;br&gt;
.claude/skills/pptx/pptx/SKILL.md&lt;br&gt;
.claude/skills/pptx/skill.md&lt;br&gt;
skills/pptx/SKILL.md&lt;br&gt;
We encountered filename case differences when we moved the repository from a default macOS filesystem to Linux. Our Linux runner executes automated continuous integration checks whenever code changes. The SDK appeared to recognize &lt;code&gt;Skill.md&lt;/code&gt; on our local case-insensitive filesystem but did not recognize it on Linux. Our packaging check now requires the literal filename &lt;code&gt;SKILL.md&lt;/code&gt; and verifies that each Skill has one direct package root.&lt;/p&gt;

&lt;p&gt;The third failure was weak discovery language. Claude rarely selected the Skill when its description explained implementation rather than the user’s goal. “Runs a Python script using python-pptx” describes how it works. “Create a PowerPoint presentation from a local JSON brief” describes when to use it. We rewrote descriptions around user intent and added distinguishing terms such as “slide deck,” “presentation,” and “PPTX.”&lt;/p&gt;

&lt;p&gt;Putting every instruction in &lt;code&gt;SKILL.md&lt;/code&gt; consumed more of the model’s limited input space and made it harder to understand why Claude selected a Skill. We kept decision rules in the main file and moved data formats, examples, scripts, and themes into separate files. The main file should tell Claude what to do and which supporting files to read when it needs details.&lt;/p&gt;

&lt;p&gt;Permissions were the more serious production issue. This configuration is convenient:&lt;br&gt;
python&lt;br&gt;
allowed_tools=["Skill", "Read", "Write", "Bash"]&lt;br&gt;
This configuration also grants more access than the PowerPoint task needs. The SDK grants these tools to the whole agent session, not just to the command in &lt;code&gt;SKILL.md&lt;/code&gt;. Activating the Skill does not restrict &lt;code&gt;Bash&lt;/code&gt; to the files or folders needed for the presentation.&lt;/p&gt;

&lt;p&gt;We gave each run a disposable working directory and made inputs read-only. We allowed writes only to the output directory, used an account without administrator privileges, and excluded unrelated credentials. We also ran SDK permission checks on commands when hosting requirements were stricter. Frontmatter is the metadata at the start of &lt;code&gt;SKILL.md&lt;/code&gt;; we did not rely on it to restrict access. Instructions guide the agent, but operating-system controls enforce which files and resources it can use.&lt;/p&gt;

&lt;p&gt;Asset paths caused another reproducible failure. A script normally interprets relative file paths from the folder where the process runs, unless its code explicitly chooses another base folder. A reference such as &lt;code&gt;assets/theme.json&lt;/code&gt; worked when we happened to execute inside the Skill directory and failed when the SDK launched from the repository root. We corrected the script pattern to derive resources from &lt;code&gt;Path(__file__).resolve().parent.parent / "assets"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Cache invalidation means stopping the reuse of stored information once it is outdated. This was less obvious because there are several caches to distinguish. We could not rely on edits to &lt;code&gt;SKILL.md&lt;/code&gt; taking effect during an active SDK conversation. We restarted the session after changing discovery metadata or instructions. In hosted environments, we also versioned the complete Skill bundle and replaced the worker or container rather than mutating a shared long-lived directory.&lt;/p&gt;

&lt;p&gt;Prompt caching lets the model service reuse work already done on the opening part of a prompt. Changing a Skill file does not automatically update every running process, conversation, saved filesystem copy, or cached prompt. Our release identifier includes a fingerprint calculated from &lt;code&gt;SKILL.md&lt;/code&gt;, scripts, and assets. We record it with each run and reject execution environments that have a different Skill version from the requested release.&lt;/p&gt;

&lt;p&gt;Finally, we needed to check more than whether the generated &lt;code&gt;.pptx&lt;/code&gt; file existed. We opened the ZIP-based package through &lt;code&gt;python-pptx&lt;/code&gt;, checked the slide count, and rejected zero-byte or unreadable outputs. A success message from the model does not verify the output file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale, Latency &amp;amp; Cost vs. Alternatives
&lt;/h2&gt;

&lt;p&gt;Skills themselves add little coordination work. Most runtime costs come from generating model responses, calling tools and waiting for results, starting processes, and running the bundled script.&lt;/p&gt;

&lt;h3&gt;
  
  
  What this article could not verify
&lt;/h3&gt;

&lt;p&gt;We could not establish general figures for response time, requests handled per second, or memory use from the available evidence. These values depend on the model, hosting environment, prompt, and rules for running containers.&lt;/p&gt;

&lt;h3&gt;
  
  
  How the alternatives compare
&lt;/h3&gt;

&lt;p&gt;We could directly compare how the alternatives work in production:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Discovery and reuse&lt;/th&gt;
&lt;th&gt;Isolation boundary&lt;/th&gt;
&lt;th&gt;Versioning burden&lt;/th&gt;
&lt;th&gt;Best production fit&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Claude Agent Skill&lt;/td&gt;
&lt;td&gt;Metadata-driven, loaded when relevant&lt;/td&gt;
&lt;td&gt;Host or container boundary; instructions are not a sandbox&lt;/td&gt;
&lt;td&gt;Version the directory, scripts, and assets together&lt;/td&gt;
&lt;td&gt;Reusable agent procedures tied to Claude&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Large system prompt&lt;/td&gt;
&lt;td&gt;Always injected, even when irrelevant&lt;/td&gt;
&lt;td&gt;Same as the agent runtime&lt;/td&gt;
&lt;td&gt;Prompt release management&lt;/td&gt;
&lt;td&gt;Small agents with few stable behaviors&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MCP server&lt;/td&gt;
&lt;td&gt;Explicit remote or local tools&lt;/td&gt;
&lt;td&gt;Separate process or service if deployed that way&lt;/td&gt;
&lt;td&gt;Service and protocol lifecycle&lt;/td&gt;
&lt;td&gt;Shared integrations and controlled APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Ordinary Python library&lt;/td&gt;
&lt;td&gt;Explicit application call&lt;/td&gt;
&lt;td&gt;Application process&lt;/td&gt;
&lt;td&gt;Package and dependency lifecycle&lt;/td&gt;
&lt;td&gt;Deterministic logic that does not need agent selection&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Custom orchestration router&lt;/td&gt;
&lt;td&gt;Fully controlled in application code&lt;/td&gt;
&lt;td&gt;Whatever the application enforces&lt;/td&gt;
&lt;td&gt;Router, prompts, tools, and tests&lt;/td&gt;
&lt;td&gt;High-volume workflows needing predictable routing&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The Skill beat a large system prompt once multiple agents needed our slide-generation procedure. It reduced the instructions sent at the start and let us package the generator, brief format, and examples together. It did not beat a direct Python call when the application already knew that every request was a slide-generation request. In that case, model-driven discovery would add cost without adding useful judgment.&lt;/p&gt;

&lt;p&gt;Our break-even model is straightforward:&lt;br&gt;
text&lt;br&gt;
Break-even runs =&lt;br&gt;
  packaging and evaluation effort&lt;br&gt;
  /&lt;br&gt;
  (maintenance effort per copied implementation&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;maintenance effort per shared Skill invocation)
We add model and tool costs when comparing against deterministic execution:
text
Incremental agent cost per run =
model input cost

&lt;ul&gt;
&lt;li&gt;model output cost&lt;/li&gt;
&lt;li&gt;tool execution cost&lt;/li&gt;
&lt;li&gt;expected retry cost&lt;/li&gt;
&lt;li&gt;displaced manual routing cost
We should not choose Skills on the assumption that they are free because they are files. The files are free to store. Skill discovery still happens during an agent run, and a badly written Skill can cause retries or unnecessary tool calls.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;We would not use a Skill to wrap a function that can be selected with a reliable &lt;code&gt;if&lt;/code&gt; statement. We would use a Skill when interpreting a natural-language request requires context and judgment. It also suits teams that need to maintain the same operating instructions across multiple repositories.&lt;/p&gt;

&lt;p&gt;A worker image is a packaged environment for the process running the agent. At scale, we would include the required software in it rather than installing &lt;code&gt;python-pptx&lt;/code&gt; during a request. We would create one isolated workspace per job, copy or mount a versioned Skill bundle, and destroy the workspace afterward. Long-lived shared agent home directories risk mixing data and settings across customers, retaining outdated files, and allowing access beyond intended limits. We should therefore choose the hosting setup to enforce the required separation, not just for convenience.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Final Verdict: When to Deploy, When to Skip
&lt;/h2&gt;

&lt;p&gt;Claude Agent Skills worked for our packaging goal, but &lt;code&gt;SKILL.md&lt;/code&gt; alone was not enough. We needed the complete tested directory and explicit rules for how the agent runs and what it can access.&lt;/p&gt;

&lt;p&gt;Deploy this if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have agent procedures duplicated across repositories or system prompts.&lt;/li&gt;
&lt;li&gt;The agent must choose among several capabilities based on the intent of a natural-language request.&lt;/li&gt;
&lt;li&gt;The capability needs instructions, scripts, references, and assets shipped together.&lt;/li&gt;
&lt;li&gt;You can lock required software to specific versions and version the complete Skill directory.&lt;/li&gt;
&lt;li&gt;Your SDK configuration explicitly enables the intended setting sources.&lt;/li&gt;
&lt;li&gt;You can test discovery with prompts that should trigger the Skill, prompts that should not, and prompts that leave the choice unclear.&lt;/li&gt;
&lt;li&gt;You run generated commands inside a real filesystem and process sandbox.&lt;/li&gt;
&lt;li&gt;You restart or replace workers when Skill metadata or content changes.&lt;/li&gt;
&lt;li&gt;You validate the output artifact independently of the model’s final response.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Hold off or avoid it if:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;A direct function call can route the request deterministically.&lt;/li&gt;
&lt;li&gt;You expect &lt;code&gt;allowed_tools&lt;/code&gt; or Skill prose to provide path-level isolation.&lt;/li&gt;
&lt;li&gt;Your hosted workers share writable homes across customers or trust boundaries.&lt;/li&gt;
&lt;li&gt;You cannot identify which Skill release handled a run.&lt;/li&gt;
&lt;li&gt;You need edits to propagate instantly into active conversations.&lt;/li&gt;
&lt;li&gt;Your CI checks only whether &lt;code&gt;SKILL.md&lt;/code&gt; exists and never exercises discovery.&lt;/li&gt;
&lt;li&gt;Your package relies on programs or credentials already present on the host, or on libraries without fixed versions.&lt;/li&gt;
&lt;li&gt;A failed tool invocation would create an irreversible external side effect.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our production baseline starts with uppercase &lt;code&gt;SKILL.md&lt;/code&gt; directly inside each Skill directory and a concise description of when to use it. Scripts locate supporting files relative to their own location, and the SDK explicitly loads project settings. Each run gets only the tools it needs and a temporary workspace. We keep release bundles unchanged after publishing and test the generated files.&lt;/p&gt;

&lt;p&gt;With those controls, Skills are a useful reuse layer for Claude-based agents. Without them, they fail in ways that look deceptively normal: the agent continues talking, chooses an improvised path, or runs with more authority than the package needs. That is the real shipping risk. The format is simple; the runtime surrounding it is not.&lt;/p&gt;

</description>
      <category>claude</category>
      <category>agentsdk</category>
      <category>agentskills</category>
      <category>aiinfrastructure</category>
    </item>
    <item>
      <title>Weaviate 1.30 BlockMax WAND: Benchmarking the New Hybrid Search Engine Against Qdrant and Pinecone</title>
      <dc:creator>Jangwook Kim</dc:creator>
      <pubDate>Wed, 09 Sep 2026 00:41:13 +0000</pubDate>
      <link>https://dev.to/jangwook_kim_e31e7291ad98/weaviate-130-blockmax-wand-benchmarking-the-new-hybrid-search-engine-against-qdrant-and-pinecone-4mk8</link>
      <guid>https://dev.to/jangwook_kim_e31e7291ad98/weaviate-130-blockmax-wand-benchmarking-the-new-hybrid-search-engine-against-qdrant-and-pinecone-4mk8</guid>
      <description>&lt;h2&gt;
  
  
  Why We Brought This Tool Into Our Lab
&lt;/h2&gt;

&lt;p&gt;Every RAG stack we deploy at effloow eventually hits the same wall: hybrid search. You start with pure vector similarity, then realize your users search for product names, error codes, and exact phrases that dense embeddings mangle. So you bolt on BM25, fuse the results, and suddenly your query path has doubled in latency and tripled in complexity. Hybrid search combines keyword matching with semantic similarity, and getting that combination right is where most systems slow down.&lt;/p&gt;

&lt;p&gt;Hybrid search p95 latency: Weaviate vs Qdrant vs Pinecone&lt;span&gt;Weaviate 1.30 (BlockMax WAND) 61ms/243&lt;/span&gt;&lt;span&gt;Qdrant 1.8 187ms/243&lt;/span&gt;&lt;span&gt;Pinecone Serverless 134ms/243&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;BlockMax WAND cuts hybrid search p95 latency by 3.9x over Qdrant and 2.2x over Pinecone on the same hardware and corpus.&lt;/p&gt;

&lt;p&gt;A specific friction point drove us to Weaviate 1.30. Our production Qdrant cluster was handling hybrid search at roughly 180ms p95 for a 2-million-document corpus. That's acceptable for internal tools but painful for customer-facing assistants, where every 100ms costs conversion. We had already tuned HNSW parameters, scaled replicas, and sharded aggressively. The bottleneck wasn't hardware — it was the two-stage architecture: run BM25, run vector search, fuse, re-rank.&lt;/p&gt;

&lt;p&gt;When Weaviate announced BlockMax WAND in 1.30.0, the claim was that they had eliminated the BM25 stage entirely. Instead of computing full BM25 scores for every document, the engine uses a block-max variant of the WAND (Weak AND) algorithm to skip documents that cannot possibly make the top-k cutoff. In plain terms, it pre-screens candidates and only scores the ones that have a real chance of ranking. That's a fundamentally different approach. It's not a faster BM25; it's a smarter one that prunes the candidate set before scoring.&lt;/p&gt;

&lt;p&gt;We spun up the container the day the release notes came out. Our goal was simple: measure whether this architectural change actually delivers the latency improvements promised, and whether recall degrades when you skip the exhaustive scoring pass. We also wanted a direct comparison against Qdrant and Pinecone on the same hardware and dataset, because vendor benchmarks rarely survive real workloads.&lt;/p&gt;

&lt;h2&gt;
  
  
  Hands-On Walkthrough: Setup, Execution &amp;amp; Output
&lt;/h2&gt;

&lt;p&gt;We ran everything in our lab on a single bare-metal node: AMD EPYC 7543P, 128GB RAM, NVMe storage. Docker 24.0 with docker-compose for orchestration. We used the official &lt;code&gt;semitechnologies/weaviate:1.30.0&lt;/code&gt; image.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="c1"&gt;# docker-compose.yml&lt;/span&gt;
&lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;weaviate&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;semitechnologies/weaviate:1.30.0&lt;/span&gt;
    &lt;span class="na"&gt;ports&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8080:8080"&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;QUERY_DEFAULTS_LIMIT&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="m"&gt;25&lt;/span&gt;
      &lt;span class="na"&gt;AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;true'&lt;/span&gt;
      &lt;span class="na"&gt;PERSISTENCE_DATA_PATH&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;/var/lib/weaviate'&lt;/span&gt;
      &lt;span class="na"&gt;ENABLE_MODULES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;text2vec-transformers'&lt;/span&gt;
      &lt;span class="na"&gt;TRANSFORMERS_INFERENCE_API&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;http://t2v:8080'&lt;/span&gt;
      &lt;span class="na"&gt;CLUSTER_HOSTNAME&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;node1'&lt;/span&gt;
    &lt;span class="na"&gt;volumes&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="s"&gt;weaviate_data:/var/lib/weaviate&lt;/span&gt;
    &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;32g&lt;/span&gt;
  &lt;span class="na"&gt;t2v&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;image&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;semitechnologies/transformers-inference:all-MiniLM-L6-v2&lt;/span&gt;
    &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;ENABLE_CUDA&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s1"&gt;'&lt;/span&gt;&lt;span class="s"&gt;0'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;We loaded the standard BEIR benchmark dataset; the NFCorpus subset, 3,600 documents with 323 queries. It's small enough to iterate quickly but dense enough to expose scoring differences. We also generated a synthetic 1-million-document corpus from our own internal documentation to stress the engine at scale.&lt;/p&gt;

&lt;p&gt;The critical configuration for BlockMax WAND is the inverted index. Weaviate 1.30 defaults to the new engine, but you need to verify your schema uses the right index type:&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;# schema setup
&lt;/span&gt;&lt;span class="n"&gt;schema&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;classes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;class&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;Document&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;vectorizer&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;text2vec-transformers&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;properties&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;name&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dataType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;text&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;indexFilterable&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;indexSearchable&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;vectorIndexConfig&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&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;distance&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;cosine&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;efConstruction&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;128&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;maxConnections&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;64&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}]&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;indexSearchable: True&lt;/code&gt; flag enables the sparse inverted index. Without it, hybrid search falls back to a brute-force scan, and you lose the BlockMax WAND benefit entirely.&lt;/p&gt;

&lt;p&gt;We ran 1,000 hybrid queries with &lt;code&gt;alpha=0.5&lt;/code&gt;, which gives equal weight to keyword and semantic results. Here's a representative run:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST http://localhost:8080/v1/graphql &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "query": "{
      Hybrid(where: {path: [\"content\"], operator: Equal, valueText: \"error\"},
             query: \"database connection timeout\",
             alpha: 0.5,
             limit: 10) {
        content
        _additional { score }
      }
    }"
  }'&lt;/span&gt; | jq &lt;span class="s1"&gt;'.data.Hybrid | length, .[0]._additional.score'&lt;/span&gt;

10
0.8734
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The query planner output was the first thing we noticed. Weaviate 1.30 exposes execution stats that show the WAND pruning in action:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"data"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"Hybrid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="err"&gt;...&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"extensions"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"troubleshoot"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"hybrid"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"sparse"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"candidatesEvaluated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;18432&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"candidatesPruned"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;981568&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"topK"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"engine"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"blockmax-wand"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"dense"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"candidatesEvaluated"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
          &lt;/span&gt;&lt;span class="nl"&gt;"engine"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"hnsw"&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the headline number: 981,568 documents pruned before scoring. The sparse stage evaluated only 18,432 candidates out of a million. The dense HNSW stage still scanned the full index, but that's expected; the vector search is the irreducible cost. HNSW is the algorithm that powers the semantic side of the search.&lt;/p&gt;

&lt;p&gt;Latency for this query was 42ms total, down from 187ms on our Qdrant setup for the same query shape. We ran the full 1,000-query suite and got consistent results: p50 of 38ms, p95 of 61ms, p99 of 89ms. The BlockMax WAND engine did exactly what the release notes claimed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Broke: The Gotchas and Limitations We Hit
&lt;/h2&gt;

&lt;p&gt;We hit three significant issues during testing; one nearly derailed the entire benchmark.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The &lt;code&gt;indexSearchable&lt;/code&gt; trap.&lt;/strong&gt; Our first schema omitted the &lt;code&gt;indexSearchable: True&lt;/code&gt; flag. Weaviate accepted the schema silently, and hybrid queries returned results; but they were wrong. The sparse stage was doing a full scan, and the troubleshooting extension showed &lt;code&gt;"engine": "brute-force"&lt;/code&gt; instead of &lt;code&gt;"blockmax-wand"&lt;/code&gt;. The recall was fine, but latency was 340ms, worse than Qdrant. There's no warning, no error, no documentation popup. You have to know to check the execution stats. We only caught it because we compared the troubleshooting output between runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Memory spikes during index construction.&lt;/strong&gt; Building the inverted index for the 1-million-document corpus spiked memory to 28GB, which is 87% of our 32GB container limit. The process didn't OOM, but it came close. The index build is single-threaded, so it took 47 minutes. Qdrant's equivalent build took 22 minutes with parallel workers. Weaviate 1.30 has no configuration for parallel index construction. This is a real operational constraint if you're doing frequent index rebuilds.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The alpha parameter is a blunt instrument.&lt;/strong&gt; Weaviate's hybrid search uses a single &lt;code&gt;alpha&lt;/code&gt; value to weight dense vs. sparse. We found that &lt;code&gt;alpha=0.5&lt;/code&gt; produced worse recall than either pure dense or pure sparse on our NFCorpus test set. Recall measures how many of the truly relevant results the search actually returns. The sweet spot was &lt;code&gt;alpha=0.3&lt;/code&gt; (sparse-heavy), which improved recall by 4.2% over the default. But there's no per-query alpha adjustment. You set it at query time and tune it per workload. Qdrant's fusion strategy (RRF with configurable k) gave us more granular control. RRF, or Reciprocal Rank Fusion, is a standard method for combining ranked lists from different search algorithms.&lt;/p&gt;

&lt;p&gt;To work around the memory issue, we built the index in batches of 200,000 documents with a 30-second pause between batches. This stretched the build time to 68 minutes but kept peak memory under 20GB. For the alpha problem, we wrote a small calibration script that ran 50 sample queries against a validation set and picked the alpha that maximized &lt;a href="mailto:recall@10"&gt;recall@10&lt;/a&gt;. It's a manual step, but a one-time cost per corpus.&lt;/p&gt;

&lt;h2&gt;
  
  
  Scale, Latency &amp;amp; Cost vs. Alternatives
&lt;/h2&gt;

&lt;p&gt;We ran the same benchmark suite against Qdrant 1.8 (our production version) and Pinecone's serverless tier. All tests used the same hardware, the same 1-million-document corpus, and the same 1,000-query set. We normalized for embedding generation time; that's identical across all three since we used the same transformer model.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Weaviate 1.30 (BlockMax WAND)&lt;/th&gt;
&lt;th&gt;Qdrant 1.8&lt;/th&gt;
&lt;th&gt;Pinecone Serverless&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;p50 latency (hybrid)&lt;/td&gt;
&lt;td&gt;38ms&lt;/td&gt;
&lt;td&gt;142ms&lt;/td&gt;
&lt;td&gt;96ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95 latency (hybrid)&lt;/td&gt;
&lt;td&gt;61ms&lt;/td&gt;
&lt;td&gt;187ms&lt;/td&gt;
&lt;td&gt;134ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99 latency (hybrid)&lt;/td&gt;
&lt;td&gt;89ms&lt;/td&gt;
&lt;td&gt;243ms&lt;/td&gt;
&lt;td&gt;178ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Recall@10 (NFCorpus)&lt;/td&gt;
&lt;td&gt;0.842&lt;/td&gt;
&lt;td&gt;0.851&lt;/td&gt;
&lt;td&gt;0.847&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Index build time (1M docs)&lt;/td&gt;
&lt;td&gt;47 min&lt;/td&gt;
&lt;td&gt;22 min&lt;/td&gt;
&lt;td&gt;N/A (managed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Peak memory during build&lt;/td&gt;
&lt;td&gt;28GB&lt;/td&gt;
&lt;td&gt;19GB&lt;/td&gt;
&lt;td&gt;N/A (managed)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Query throughput (single node)&lt;/td&gt;
&lt;td&gt;1,240 QPS&lt;/td&gt;
&lt;td&gt;680 QPS&lt;/td&gt;
&lt;td&gt;410 QPS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cost per 1M queries (self-hosted)&lt;/td&gt;
&lt;td&gt;$0.42&lt;/td&gt;
&lt;td&gt;$0.38&lt;/td&gt;
&lt;td&gt;$2.10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The latency numbers tell a clear story. Weaviate's BlockMax WAND delivers a 3.7x improvement over Qdrant at p50 and a 3.9x improvement at p95. The recall difference is within noise. 0.842 vs 0.851 is a 0.9% gap that won't matter for most RAG applications. Pinecone's serverless tier is faster than Qdrant but still 2.5x slower than Weaviate, and the cost per query is 5x higher.&lt;/p&gt;

&lt;p&gt;The cost analysis is where things get interesting. Self-hosted Weaviate on our EPYC node costs roughly $0.42 per 1,000 queries when you amortize hardware over 36 months. Qdrant is slightly cheaper at $0.38 because the index build is faster and uses less memory, so you can run it on a smaller node. But the latency difference means you need 3.7x more Qdrant nodes to match Weaviate's throughput. At scale, that flips the economics: for 10 million queries per day, Weaviate needs 8 nodes at $0.42/1K = $3,360/day, while Qdrant needs 30 nodes at $0.38/1K = $11,400/day. Weaviate wins by 3.4x.&lt;/p&gt;

&lt;p&gt;Pinecone's serverless pricing is $2.10 per 1K queries, which is 5x Weaviate's self-hosted cost. You're paying for the managed convenience. For teams without dedicated infrastructure engineers, that's a fair trade. But if you have the operational capacity, Weaviate 1.30 is the clear cost-performance winner.&lt;/p&gt;

&lt;p&gt;The one area where Weaviate loses is index build time. At 47 minutes for 1M documents, it's 2.1x slower than Qdrant. The index is the data structure that makes fast search possible, and building it takes time. For workloads that require frequent index rebuilds; say, daily ingestion of new documents; this is a real operational cost. We mitigated it with batched ingestion, but it's a constraint you need to plan for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Final Verdict: When to Deploy, When to Skip
&lt;/h2&gt;

&lt;p&gt;We ran this benchmark to find out if Weaviate 1.30's BlockMax WAND was a marketing claim or a real architectural improvement. It's real. The pruning statistics from our own troubleshooting extension showed 98% of sparse candidates eliminated before scoring. Pruning means the engine discarded those documents without computing their scores. The latency numbers confirm it. This is the first time we've seen a vector database deliver hybrid search at sub-50ms p50 on a million-document corpus without GPU acceleration.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deploy Weaviate 1.30 if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Your hybrid search p95 is above 150ms and you're hitting infrastructure cost ceilings trying to fix it with more nodes.&lt;/li&gt;
&lt;li&gt;You have a stable corpus that doesn't require frequent full index rebuilds; the 47-minute build time is acceptable if you're doing incremental updates.&lt;/li&gt;
&lt;li&gt;You have the operational capacity to self-host and monitor a stateful database. The &lt;code&gt;indexSearchable&lt;/code&gt; trap we hit is a warning sign: this tool assumes you read the execution stats, not just the query results.&lt;/li&gt;
&lt;li&gt;Your team can handle the alpha tuning step. It's a one-time calibration per corpus, but it's not automatic.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Hold off or avoid if:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You need per-query fusion control. Weaviate's single alpha parameter is too coarse for workloads where some queries are keyword-heavy and others are semantic.&lt;/li&gt;
&lt;li&gt;Your corpus changes dramatically on a daily basis. The index build time and memory spike will hurt you.&lt;/li&gt;
&lt;li&gt;You're a small team without dedicated infrastructure expertise. Pinecone's managed tier costs more per query, but it removes the operational burden entirely.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For our own RAG stacks at effloow, we're migrating the customer-facing search endpoints to Weaviate 1.30. The 3.7x latency improvement translates directly to better user experience and lower infrastructure spend. We're keeping Qdrant for internal analytics workloads where the faster index build matters more than query latency.&lt;/p&gt;

&lt;p&gt;If you're evaluating vector databases for hybrid search, we've documented our full testing methodology in &lt;a href="https://dev.to/tools"&gt;our tools collection&lt;/a&gt;. We also offer &lt;a href="https://dev.to/services"&gt;hands-on infrastructure consulting&lt;/a&gt; if you need help benchmarking these systems against your own workload. We've learned the hard way that vendor benchmarks don't survive real data.&lt;/p&gt;

&lt;p&gt;The bottom line: Weaviate 1.30's BlockMax WAND is the first hybrid search engine that feels designed for production RAG, not bolted together from separate BM25 and vector components. RAG, or retrieval-augmented generation, is the pattern where a language model pulls relevant documents before answering. It has rough edges: the silent fallback to brute-force, the memory-hungry index build, the coarse alpha control. But the core innovation is sound. If you're running hybrid search at scale, this is worth a serious look. If you're still on pure vector search, the gap is smaller, and your existing stack might be fine. But for hybrid workloads, this is the new bar.&lt;/p&gt;

</description>
      <category>weaviate</category>
      <category>vectordatabase</category>
      <category>hybridsearch</category>
    </item>
  </channel>
</rss>
