<?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: Hamdi Mechelloukh</title>
    <description>The latest articles on DEV Community by Hamdi Mechelloukh (@hamdi_mechelloukh_628620a).</description>
    <link>https://dev.to/hamdi_mechelloukh_628620a</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%2F3835195%2F19c24699-3727-4940-937e-3968ab4d8085.png</url>
      <title>DEV Community: Hamdi Mechelloukh</title>
      <link>https://dev.to/hamdi_mechelloukh_628620a</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hamdi_mechelloukh_628620a"/>
    <language>en</language>
    <item>
      <title>Two months building an investment bot. What it taught me about LLMs</title>
      <dc:creator>Hamdi Mechelloukh</dc:creator>
      <pubDate>Thu, 18 Jun 2026 10:22:26 +0000</pubDate>
      <link>https://dev.to/hamdi_mechelloukh_628620a/two-months-building-an-investment-bot-what-it-taught-me-about-llms-iic</link>
      <guid>https://dev.to/hamdi_mechelloukh_628620a/two-months-building-an-investment-bot-what-it-taught-me-about-llms-iic</guid>
      <description>&lt;p&gt;For two months, I tinkered together a small system that watches my portfolio and sends me, once a month, what it thinks I should do: buy, add, lighten, sell.&lt;/p&gt;

&lt;p&gt;Wrong ideas, bugs hiding other bugs, decisions redone two or three times. And in the end, a much clearer picture of how language models actually behave, pretty far from what I imagined at the start.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bot in one sentence
&lt;/h2&gt;

&lt;p&gt;Once a month, a small script runs on a server. It pulls the composition of my portfolio and public market data, asks an artificial intelligence to analyze all that, and sends me a Telegram message with its recommendations.&lt;/p&gt;

&lt;p&gt;The rest of the article is how I got to this little automated-monitoring script, by using the power of an LLM "correctly".&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 1: the illusion of the edge
&lt;/h2&gt;

&lt;p&gt;To be honest, I knew I couldn't have a head start on the market. But you still want to test the fantasy of code that makes you win on the stock market. I told myself that with enough data, a good model and a bit of code, I'd do as well as thousands of professional analysts.&lt;/p&gt;

&lt;p&gt;It didn't last long. You can't beat the consensus with the consensus's own information; worse, you'll do worse than a good old "buy and hold on the S&amp;amp;P 500".&lt;/p&gt;

&lt;p&gt;So I stopped chasing the edge. The value of this system was elsewhere: finding where the signals are favorable or not, seeing where I can't see, filtering out what I don't need to know, handing me proposals I could check, and then letting me make or not make a decision.&lt;/p&gt;

&lt;p&gt;The edge exists, but it comes either from processing information better or faster than others (the whole quant business), or from having information others don't, and there it's either out of my reach, or it's insider trading. Me, scraping the same public numbers as everyone else, I have none.&lt;/p&gt;

&lt;p&gt;The project took this realistic turn after that realization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 2: the parade of models
&lt;/h2&gt;

&lt;p&gt;A bit of vocabulary first, because the whole article rests on it.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;An LLM (Large Language Model)&lt;/strong&gt; is a program trained to predict the next word. You give it some text, it computes, for each possible word, a probability of being what comes next, then it picks one, and starts over. ChatGPT, Gemini, Claude are LLMs. That's all they do: predict the next word, one word at a time. The rest, the apparent reasoning, the analyses, emerges from this mechanism repeated billions of times.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;My bot delegates its judgment to an LLM. Which one? I had to change the answer 6 times in 2 months, only to end up telling myself there isn't necessarily a right answer; you have to approach the LLM as a simple tool, like most SaaS.&lt;/p&gt;

&lt;p&gt;Anyway, here's the path I took in terms of model choice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Apr      Gemini alone
May 1    + Claude          (two models in parallel, to compare)
May 9    + Bear            (a 3rd model, deliberately pessimistic)
                           -&amp;gt; 3 voices, decision by vote
May 15   STOP. Opus only   (big cleanup, simplify everything)
May 22   back to Gemini    (cost and feature reasons)
Jun 13   MiMo              (Google terms-of-use change, and cost)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;As you can see, at the start I began stacking models, because I had a serious lack of consistency in the bot's answers: one time it would tell me "buy Microsoft" and the next "sell Microsoft", even on 2 runs launched back to back.&lt;/p&gt;

&lt;p&gt;It was pretty annoying, I was looking for a reliable answer, so the first idea was to reinforce the bot with 2 more LLM runs (Gemini) to get a kind of consensus, and I even went as far as adding a new model, Claude.&lt;/p&gt;

&lt;p&gt;It was a bit better, but the bot was aggressive: it could recommend interesting names, but with too many negative theses. I needed a "devil's advocate", hence the idea of the "Bear", a 3rd LLM agent whose job was to look for the theses that lead to structural declines, and to cool down the "optimism" of the other two.&lt;/p&gt;

&lt;p&gt;It was good, but it was expensive, and devilishly complex; something was off and it was tied to the architecture. I rewrote the bot, focusing and trying to simplify the prompts, and I still had the consistency problem.&lt;/p&gt;

&lt;p&gt;After a few days, I went back to Gemini because my costs on Claude were a bit too high.&lt;/p&gt;

&lt;p&gt;I started looking into the information the LLM was pulling in, and that's what made me remove the grounding search, because the LLM is heavily influenced by speculative noise.&lt;/p&gt;

&lt;p&gt;In the end, I moved to MiMo: very good benchmark results, and a token usage cost (price per token + tokens needed to actually handle a task) that beats the competition. The terms-of-use changes for the Gemini API also cooled me off; when the free $300 disappear and you get a bill plus a withdrawal from your account worth 3 months of API usage, it kills the appetite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 3: the memory that made the bot paranoid
&lt;/h2&gt;

&lt;p&gt;I had given the bot a memory. Concretely, a file that kept its past analyses over thirty days, fed back in on each new run. The idea seemed obvious: add context so the bot wouldn't answer randomly.&lt;/p&gt;

&lt;p&gt;Except the memory created a bias. The model saw that it had suggested selling a position the week before, and that pushed it to confirm that sale, over and over, regardless of new facts. A past opinion became a conviction, with no regard for the fundamentals. I patched. I added a rule to remove sells from the memory. It moved the problem without solving it. I patched again. Then I found that the memory was producing an outright form of self-censorship: the model aligned with its past instead of looking at the present.&lt;/p&gt;

&lt;p&gt;It's what I call the paradox of experience, a bit like a lot of older people: we lean on our experience to decide whether a choice is good or not, except the context of a situation changes, and so the same decision can become a good one in another context, something experience erases.&lt;/p&gt;

&lt;p&gt;At some point, I counted the patches. When a feature needs fix upon fix and each fix calls for another, &lt;strong&gt;the feature itself is the bug.&lt;/strong&gt; I removed the memory. The bot went back to being &lt;em&gt;stateless&lt;/em&gt;: no state, no memory. Every month, it analyzes the situation fresh, as if discovering it.&lt;/p&gt;

&lt;p&gt;Adding state (memory) to a system makes it more complex and introduces dependencies on the past that can corrupt the present. Statelessness is often a feature, not a lack. For those who remember their control-theory classes: by feeding the model's past outputs back into its input, I hadn't opened a loop, I had closed one, a positive feedback loop. The output reinforced itself, and the system diverged. Going back to stateless is precisely returning to an open loop, each run independent, with no feedback from the past.&lt;/p&gt;

&lt;p&gt;Keep this memory story in mind. Part of the instability I blamed on it maybe wasn't its fault. We'll come back to it a bit later in the article.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 4: speculation is not information
&lt;/h2&gt;

&lt;p&gt;For the news monitoring, my first version used what's called &lt;em&gt;grounding&lt;/em&gt; (or augmented retrieval).&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Grounding&lt;/strong&gt; is when you let the LLM go fetch information from the web in real time while it answers, instead of relying only on what it memorized during training.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;On paper, perfect: the model gets to read the latest news. In practice, it mostly brought back rumors, analyst speculation, "word is that...". Over a one-year horizon, that kind of information isn't information. It's noise dressed up as signal.&lt;/p&gt;

&lt;p&gt;We're still facing a disturbance, this time at the input: the quality of the input signal itself.&lt;/p&gt;

&lt;p&gt;About-face, again. I cut the grounding and built monitoring on verifiable sources only: official regulatory filings (in the US, the documents companies are legally required to publish), established financial news feeds, and for the rest of the world, targeted searches. Then I imposed two hierarchies on the collected information:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;AUTHORITY RANKING               SEVERITY SCALE
official source      &amp;gt;          G3  structural (changes the thesis)
established newswire &amp;gt;          G2  notable
the rest                        G1  anecdotal
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The goal was no longer to read everything, but to sort. An official filing announcing a change of leadership (G3, official source) doesn't weigh the same as a speculative opinion piece (G1, the rest). The noise filtering promised in Act 1 was taking shape.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 5: the phantom facts
&lt;/h2&gt;

&lt;p&gt;The system was now collecting verified facts. But the final recommendations seemed to ignore them. Worse: they were nearly identical to the runs where the monitoring had completely failed and brought back nothing at all. As if the facts didn't exist.&lt;/p&gt;

&lt;p&gt;And yet they existed. They were right there in the text sent to the model. But they were grouped in a separate block, far from the place in the text where the model made its decision on each name. The model read them, then forgot them when it came time to decide.&lt;/p&gt;

&lt;p&gt;The fix, once the diagnosis was made, was dumb: place each fact right next to the name it concerns, at the exact moment the model rules on that name. The lesson, though, runs deep:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;With an LLM, the position of a piece of information matters as much as its presence.&lt;/strong&gt; A fact present in the context but badly placed relative to the decision point is, in practice, an absent fact, especially when the context is long.&lt;/p&gt;

&lt;p&gt;A corollary I wrote into the code right after: &lt;strong&gt;if the facts layer fails, the system crashes.&lt;/strong&gt; It doesn't send a degraded report. A plausible but hollow report is more dangerous than no report, because it looks like real analysis. Better a visible crash than a false certainty nicely dressed up.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 6: the principle that reorganized everything
&lt;/h2&gt;

&lt;p&gt;By dint of correcting behavioral drifts, I ended up formulating the rule that underpins everything else, and which is probably my biggest realization of the project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The instructions given to an LLM must be principles, never numerical rules. Determinism must live in the data, not in the text of the instruction.&lt;/strong&gt;&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Deterministic&lt;/strong&gt; means: always gives the same result from the same inputs. A computation is deterministic. Human judgment is not.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Concretely, from then on I forbade myself from writing things in the instructions like "aim for about 20% on this position" or "only do this". Why? Because a numerical threshold written in natural language gives you the worst of both worlds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;it makes the model &lt;strong&gt;rigid&lt;/strong&gt; where I wanted nuanced judgment;&lt;/li&gt;
&lt;li&gt;and it hands it a number to &lt;strong&gt;cling to&lt;/strong&gt; and to &lt;strong&gt;make things up&lt;/strong&gt; around (LLMs have an annoying tendency to embroider around the numbers you give them, because their answer is an estimate of the best answer to give, not the best answer to give).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If I want determinism, it has to be upstream, in the pipe that prepares the data. The mental model became this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;   UPSTREAM: DETERMINISTIC           DOWNSTREAM: NON-DETERMINISTIC
   (code, numbers)                   (the LLM's judgment)
 ┌────────────────────────────┐    ┌──────────────────────────┐
 │ portfolio at market value  │    │ weighs the pros and cons │
 │ universe filtered &amp;amp; scored │ ──&amp;gt; │ arbitrates between names │
 │ verified, dated facts      │    │ writes target weights    │
 │ consensus reliability      │    │ following PRINCIPLES      │
 └────────────────────────────┘    └──────────────────────────┘
   the numbers constrain              no magic number
   (computed, verifiable)             in the instructions
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Everything that can be computed cleanly is computed upstream, in code, in a verifiable way, and handed to the model as a numerical constraint. The model, for its part, receives principles ("favor strong convictions", "a reliable consensus beats a high but uncertain target") and judges.&lt;/p&gt;

&lt;p&gt;One last rule in the same spirit, on writing instructions: &lt;strong&gt;one rule = one statement, said once.&lt;/strong&gt; Two near-identical rules are worse than one, because the reader (and an LLM even more so) looks for the difference between them. Since it doesn't exist, it invents one. Rewriting to condense isn't cosmetic, it's reducing the surface for error.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 7: the discovery, the noise was there from the start
&lt;/h2&gt;

&lt;p&gt;Then comes the move to MiMo. And with it, not a new problem, but the revelation of an old problem I had never seen. To understand it, three definitions, like a staircase.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;1. The probability distribution.&lt;/strong&gt; At each word, the LLM doesn't pick "the" next word. It computes a probability for each possible word. For instance, after "the cat drinks", it might rate: &lt;em&gt;milk&lt;/em&gt; 70%, &lt;em&gt;water&lt;/em&gt; 25%, &lt;em&gt;coffee&lt;/em&gt; 4%, etc.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. The temperature.&lt;/strong&gt; It's the knob that sets the randomness of the selection. High temperature: the model sometimes picks unlikely options (more creative, more unpredictable). Zero temperature: it systematically takes the most likely option. &lt;em&gt;milk&lt;/em&gt;, every time.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. The logits.&lt;/strong&gt; These are the raw scores the model computes for each word before turning them into probabilities. They're the raw material of the decision.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At zero temperature, the model always takes the most likely word, so with the same inputs it should produce exactly the same output. Deterministic. In reality, I had misunderstood what an LLM is. It gives the illusion of an exact answer, when in fact it mostly gives an answer that's "good enough". When you code with an LLM, for example, it spits out working code, but not necessarily good code, or rather: not every time.&lt;/p&gt;

&lt;p&gt;When I changed models, for the first time I wanted to measure stability before building on it. I ran &lt;strong&gt;the same prompt, on the same frozen data, several times in a row, at zero temperature.&lt;/strong&gt; I expected identical outputs.&lt;/p&gt;

&lt;p&gt;I got the opposite. Considerable variance from one run to the next. A few real numbers from this test on about thirty positions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the sum of the proposed target allocations came to &lt;strong&gt;44%&lt;/strong&gt; on the first run, &lt;strong&gt;105%&lt;/strong&gt; on the second, &lt;strong&gt;101%&lt;/strong&gt; on the third;&lt;/li&gt;
&lt;li&gt;out of the thirty-odd names, &lt;strong&gt;only 6&lt;/strong&gt; got a stable recommendation from one run to the next;&lt;/li&gt;
&lt;li&gt;one run in three went completely off the rails.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Same input, same zero temperature, very different outputs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;This noise was not new with MiMo.&lt;/strong&gt; It had been there from the start, with Gemini, then Claude, then every model I had used. I tried to fix it with deterministic instructions, because I needed control, but that's nonsense: I can't have both control (deterministic) and the AI finding me insights (non-deterministic). MiMo didn't bring anything special on this front; it was just the occasion where I understood the line between using the LLM or not, because it's just a tool, with, admittedly, a high level of abstraction, but not a solution or a human replacement, even if some companies do very nice marketing to say otherwise.&lt;/p&gt;

&lt;p&gt;And there, Act 3 takes on another meaning. The instability I had blamed on the memory, those recommendations that flip-flopped from month to month that I fought with patches, a good chunk of it probably wasn't the memory at all. It was already this noise, invisible for lack of being measured.&lt;/p&gt;

&lt;p&gt;Careful, though, not to rewrite history too cleanly: the memory's anchoring bias was real, feeding back its own past sells creates a genuine confirmation bias. So there were two overlapping problems, not a single misdiagnosed one. The memory wasn't innocent; it simply had an invisible accomplice I wasn't measuring.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why an LLM stays noisy even at zero temperature
&lt;/h3&gt;

&lt;p&gt;Here's the heart of it, and it's subtler than it looks.&lt;/p&gt;

&lt;p&gt;At zero temperature, the choice of word is &lt;strong&gt;not&lt;/strong&gt; random. The model takes the maximum, perfectly deterministically. &lt;strong&gt;So it's not the selection rule that injects randomness.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The noise comes one notch earlier: &lt;strong&gt;the logits themselves don't always land on the same number.&lt;/strong&gt; And the cause isn't the one people assume. The common explanation ("the parallel computations on the GPU run in a random order") is misleading, and that's exactly what the work cited just below corrects: for a given computation shape, the model is in fact reproducible; re-run identically, it gives back the same logits.&lt;/p&gt;

&lt;p&gt;The real culprit is the &lt;strong&gt;batch&lt;/strong&gt;. On a server, your request is never handled alone: it's grouped with others, and the composition of that group (how many requests, of what lengths) changes on every call depending on load. And to go fast, the GPU splits up and adds the numbers in an order that depends on the shape of the batch. And floating-point addition (the computer's approximate arithmetic on decimal numbers) isn't associative: &lt;code&gt;(a + b) + c&lt;/code&gt; doesn't give exactly &lt;code&gt;a + (b + c)&lt;/code&gt;. So different batch neighbors lead to a different split, hence a different order of additions, hence logits that move by a hair. Each computation taken in isolation is deterministic; it's the batch context that varies from one call to the next.&lt;/p&gt;

&lt;p&gt;This isn't an absolute fatality, by the way. &lt;a href="https://thinkingmachines.ai/blog/defeating-nondeterminism-in-llm-inference/" rel="noopener noreferrer"&gt;Recent work by Thinking Machines Lab&lt;/a&gt; showed that by rewriting these computations so they always add in the same order, whatever the batch composition, you can make a model perfectly reproducible at zero temperature: in their demo, 1000 generations became bit-for-bit identical, where the standard version produced 80 different ones. The price is a slowdown (on the order of 1.6 to 2 times depending on the kernels), and that's why consumer APIs don't enable it by default. So the noise at zero temperature is a fatality in practice, on common APIs, not in principle.&lt;/p&gt;

&lt;p&gt;As long as the best candidate wins comfortably, it doesn't matter. But when two candidates are nearly tied, that hair flips the ranking:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Raw scores (logits) for the next word

  lighten   ████████████████████  8.40
  sell      ███████████████████▉  8.39   &amp;lt;- nearly tied!
  hold      ██████████            4.10

Run 1:  lighten 8.401 , sell 8.399  -&amp;gt;  we pick LIGHTEN
Run 2:  lighten 8.398 , sell 8.402  -&amp;gt;  we pick SELL
                       ^ tiny floating-point gap, and it all flips
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And on a model that "reasons" (that generates a long chain of thought before concluding), a single early flip propagates and amplifies all along the reasoning. A hair's difference at the start, an opposite conclusion at the end.&lt;/p&gt;

&lt;p&gt;So the right way to put it is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;It's not noisy because the choice is random, but because &lt;strong&gt;the estimated values that a deterministic choice rests on are themselves unstable.&lt;/strong&gt; A deterministic choice over unstable estimates becomes unstable again near the ties.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And that detail changes the whole interpretation. &lt;strong&gt;The noise isn't blind.&lt;/strong&gt; It concentrates exactly on the close calls, the ones where the model itself is undecided. A position where the conviction is clear never flips (the best candidate wins comfortably). The positions that waltz from one run to the next are precisely the ones where two options are nearly equal.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The noise marks the zones of real uncertainty.&lt;/strong&gt; It's not a flaw to hide. It's information.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act 8: don't fight the noise, make it vote
&lt;/h2&gt;

&lt;p&gt;If the noise is information about uncertainty, the right response isn't to eliminate it. It's to aggregate it.&lt;/p&gt;

&lt;p&gt;Rather than a single run, I launch several on the same data, then I make the results vote. It's exactly the idea of the &lt;strong&gt;Condorcet jury theorem&lt;/strong&gt;, stated by an 18th-century French mathematician.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Condorcet's theorem (in a few words).&lt;/strong&gt; If each juror has a better-than-50% probability of finding the right answer, and the jurors err independently of one another, then the more jurors you add, the more the majority vote tends toward the certainty of being right.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;As a formula, the probability that the majority of N jurors is right, each correct with probability p:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight tex"&gt;&lt;code&gt;                N
P(majority) =   Σ    C(N,k) · p&lt;span class="p"&gt;^&lt;/span&gt;k · (1 − p)&lt;span class="p"&gt;^&lt;/span&gt;(N−k)
              k=⌊N/2⌋+1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What to take from it without the symbols:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  p (juror quality)            majority vote as N grows
  ─────────────────            ───────────────────────────────────────
  p &amp;gt; 0.5  (better than coin)  ──&amp;gt; tends to 1   (certain to be right)
  p = 0.5  (coin flip)         ──&amp;gt; stays at 0.5 (voting doesn't help)
  p &amp;lt; 0.5  (worse than coin)   ──&amp;gt; tends to 0   (voting makes it worse!)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch the trap the formula makes visible: voting only improves things &lt;strong&gt;if each juror is already better than chance.&lt;/strong&gt; If the model is bad on a question, multiplying the runs only amplifies the error. Voting makes a correct-but-noisy juror reliable; it doesn't save an incompetent one.&lt;/p&gt;

&lt;p&gt;And there's a second trap, more insidious. Condorcet's theorem has &lt;strong&gt;two&lt;/strong&gt; assumptions, not one: jurors better than chance (I just talked about that), and &lt;strong&gt;independent&lt;/strong&gt; errors. But re-running the same model five times is five times the same network, the same biases, the same typical reasoning. The floating-point noise only decorrelates the outputs near the ties, exactly where I want them to vote. But on a systematic error (the model doesn't understand a sector, overrates a thesis), the five runs are wrong together, and worse: they're wrong &lt;strong&gt;unanimously&lt;/strong&gt;. Because a unanimous vote is only a &lt;strong&gt;constant answer&lt;/strong&gt;, and a constant answer is only the &lt;strong&gt;reinforcement of the model's thesis&lt;/strong&gt;, not proof that it's right. 5/5 measures stability, never truth. To settle a consensus, you therefore need a source &lt;strong&gt;outside the model&lt;/strong&gt;; the same model re-run will only repeat its thesis with confidence. Voting neutralizes the sampling noise; it doesn't correct the model's bias.&lt;/p&gt;

&lt;p&gt;My model, on most positions, is far better than chance, just noisy near the close calls. An ideal use case for Condorcet. I refined the vote on two levels: first the &lt;strong&gt;direction&lt;/strong&gt; (should this position go up or down?), then only the &lt;strong&gt;degree&lt;/strong&gt;. Without that, a clear consensus on direction could get buried under slightly different action labels ("lighten" and "sell" both say: go down).&lt;/p&gt;

&lt;p&gt;The result is exactly what I'd been looking for since Act 1 without knowing it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  Stock A  sell 5/5      -&amp;gt;  stable: top of the map (to validate against facts)
  Stock B  add 4/5       -&amp;gt;  stable: consensus
  Stock C  buy 2 / lighten 2 / hold 1
                         -&amp;gt;  NO consensus: shown as "split,
                             your judgment decides"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The clear cases come out by consensus, the close ones show up &lt;strong&gt;honestly&lt;/strong&gt; as split, and I'm the one who decides. But careful not to read this table as a ranking of good recommendations: after everything above, a "5/5" doesn't certify that selling is the right move, it certifies that the model is &lt;strong&gt;stable&lt;/strong&gt; on it. What the ensemble really produces isn't a list of orders, it's a &lt;strong&gt;stability map&lt;/strong&gt;: here's where my judgment is least needed, and here's where it's needed most. The consensus tells me where to look with confidence; it's the primary facts and me who validate the substance. The system stops pretending to a certainty it doesn't have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I had started by making three different models vote, then I deleted everything to simplify. I end up making a single model vote several times.&lt;/strong&gt; The structure is the same (a vote), but the reason changed completely: at the start I voted to combine different viewpoints; in the end I vote to neutralize the noise of a single model. It took me two months and a detour through the whole chain to understand the real point of that vote.&lt;/p&gt;

&lt;p&gt;Then there's the objection: the version truly faithful to Condorcet would be several different models, each run several times, because different architectures err in a more decorrelated way. And let's be honest: the gain is &lt;strong&gt;real&lt;/strong&gt;, not uncertain. An ensemble of different models really does decorrelate errors, that's been the whole point of ensembles forever. It's simply probably not &lt;strong&gt;worth it&lt;/strong&gt;. Each extra model is one more provider to maintain, to pay, to monitor, formats and prompts to keep in sync, for a benefit that becomes marginal next to that operating cost. You leave Pareto's useful 80%. It's a cost trade-off, not a denial of the benefit. So I decided otherwise. The vote of a single model kills the noise, which is the essential part and nearly free. And for the bias, the source outside the model that I need, I already have it: the primary facts I confront the model with (Acts 4 and 5), and my own judgment on the split cases. The 5/5 tells me where the model is stable; the facts and I say whether it's right.&lt;/p&gt;

&lt;h2&gt;
  
  
  The question this article dodges
&lt;/h2&gt;

&lt;p&gt;Everything above is about &lt;strong&gt;consistency&lt;/strong&gt;: is the system stable, honest about its doubts? But consistency isn't &lt;strong&gt;correctness&lt;/strong&gt;. A system can be perfectly stable, perfectly clear-eyed about its zones of uncertainty, and mediocre in returns. Perfect consistency is even, as we just saw, exactly what a prejudice repeated without flinching looks like.&lt;/p&gt;

&lt;p&gt;I myself dismantled the idea of an edge back in Act 1: I have no serious reason to beat the market. So a tension remains that I don't really resolve: what good are such carefully crafted allocations if nothing guarantees they're better than a simple index fund? My honest answer: it's not a performance tool, it's a monitoring and decision-support tool. The targets it produces are a starting point for my judgment, not autopilot. It's still too early to measure whether I beat an index over time. For now, I'm very close to the S&amp;amp;P 500 and below the NASDAQ, over 2-3 months of development, which proves nothing, one way or the other: over that span, everything is drowned in market noise. Telling skill from luck in equity allocation takes years, and ideally going through a real downturn. So I won't have the answer for a long time, now that the bot is stable.&lt;/p&gt;

&lt;p&gt;I wrote this article to explain how I made a machine consistent in its answers and how I gained lucidity about its limits, without claiming that it's right.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I was really looking for
&lt;/h2&gt;

&lt;p&gt;I started out wanting a machine that's right. I ended up with a machine that's honest about what it doesn't know. And that's far more useful.&lt;/p&gt;

&lt;p&gt;After dismantling my illusions one by one, here's what remains, and what's enough to justify the machine: a monitoring system, with a reliable sieve against the noise of information, to surface what matters on the market and in my portfolio. No promise of beating the market. But at the one task I built it for, gathering the right information and discarding the rest, it is, without hesitation, better than me.&lt;/p&gt;

&lt;p&gt;The most transferable lesson reaches far beyond finance:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;An LLM isn't an oracle, it's a sampler.&lt;/strong&gt; It draws its answers from a probability distribution. Its variance isn't a flaw to hide, it's a measure of its own confidence. Good systems built around LLMs don't hide uncertainty, they bring it to the surface.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The bot runs once a month and sends me its conclusions. But what it really gave me isn't a shopping list. It's a sharper way to think about decisions under uncertainty, and a deep respect for the difference between a system that answers, and a system that knows when it doesn't know.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>generativeai</category>
      <category>nondeterminism</category>
      <category>python</category>
    </item>
    <item>
      <title>Real-time streaming pipeline with Apache Flink 2.0, Kafka and Iceberg</title>
      <dc:creator>Hamdi Mechelloukh</dc:creator>
      <pubDate>Tue, 31 Mar 2026 11:09:58 +0000</pubDate>
      <link>https://dev.to/hamdi_mechelloukh_628620a/real-time-streaming-pipeline-with-apache-flink-20-kafka-and-iceberg-2ah8</link>
      <guid>https://dev.to/hamdi_mechelloukh_628620a/real-time-streaming-pipeline-with-apache-flink-20-kafka-and-iceberg-2ah8</guid>
      <description>&lt;p&gt;It's 2:03 PM. A flash sale just started.&lt;/p&gt;

&lt;p&gt;In the warehouse, an operator is entering incoming orders into the management system. He types a quantity, makes a mistake, corrects it immediately. Two events, one reality. Thirty seconds apart.&lt;/p&gt;

&lt;p&gt;The batch job that runs at 2 AM will see both. It won't know which one is right. Depending on how the reconciliation logic is written, if it exists at all, it picks one of the two, often non-deterministically. And if the correction falls into the next batch window, the problem doesn't surface right away: the morning's numbers are wrong, cleanly, with no technical error in sight.&lt;/p&gt;

&lt;p&gt;This is a real and recurring source of data quality problems in data teams.&lt;/p&gt;

&lt;p&gt;Processing events as they arrive, in order, with their temporal context intact, fundamentally changes how this problem is handled. That's the starting point for this project: an end-to-end streaming pipeline on the Olist e-commerce dataset, built with Apache Flink 2.0, Kafka and Iceberg.&lt;/p&gt;

&lt;h2&gt;
  
  
  The dataset and the problem
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://www.kaggle.com/datasets/olistbr/brazilian-ecommerce" rel="noopener noreferrer"&gt;Olist dataset&lt;/a&gt; is a public Brazilian e-commerce dataset: orders, products, sellers, customers, reviews. 100,000 orders over two years.&lt;/p&gt;

&lt;p&gt;I had already built a batch lakehouse on this same dataset. The logical next step was to go to the other extreme: stream processing, one-minute calculation windows, anomaly detection at the second level. Three concrete needs:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Revenue by category in real time&lt;/strong&gt; — know which category is performing at every minute&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Anomaly detection&lt;/strong&gt; — a customer placing multiple orders within a few minutes, or an order at an abnormal price&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Global KPIs&lt;/strong&gt; — average order value, order rate, total revenue in real time&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;These are the three jobs that make up the pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Apache Flink?
&lt;/h2&gt;

&lt;p&gt;The question is worth asking. There are other options for streaming in Java:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Kafka Streams&lt;/strong&gt; — easy to operate, no separate cluster, but limited to Kafka-in/Kafka-out topologies&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Apache Spark Structured Streaming&lt;/strong&gt; — micro-batches, minimum latency of a few seconds, but familiar if you already know Spark&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Flink&lt;/strong&gt; — true event-by-event streaming, native event-time processing, built-in CEP&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Flink was the natural choice for two reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CEP (Complex Event Processing).&lt;/strong&gt; Detecting "3 orders from the same customer within 5 minutes" is not an aggregation, it's a temporal correlation between events. Flink CEP handles this natively with a pattern DSL. In Kafka Streams, it requires maintaining manual state and writing the temporal logic by hand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Flink 2.0.&lt;/strong&gt; Version 2.0 brought native Java 21 support. Working on the current version rather than an end-of-life one was a deliberate choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The architecture
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Olist CSV → Simulator → Kafka (orders)
                              │
              ┌───────────────┼───────────────┐
              ▼               ▼               ▼
    RevenueAggregation  AnomalyDetection  RealtimeKpi
    (tumbling 1 min)    (CEP 5 min)       (windowAll 1 min)
              │               │               │
              ▼               ▼               ▼
      Kafka (revenue)  Kafka (alerts)  Kafka (kpis)
              │               │               │
              └───────────────┴───────────────┘
                              │
                    Apache Iceberg (MinIO)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three independent jobs, one shared source topic, three output topics, and an optional Iceberg data lake.&lt;/p&gt;

&lt;p&gt;The independence of the jobs is a deliberate choice. In production, you want to be able to restart &lt;code&gt;AnomalyDetectionJob&lt;/code&gt; without affecting &lt;code&gt;RevenueAggregationJob&lt;/code&gt;. Each job has its own checkpoint, its own state, its own topology.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job 1: RevenueAggregationJob
&lt;/h2&gt;

&lt;p&gt;The simplest of the three. It aggregates revenue by product category over one-minute windows.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;orders → filter nulls → map to RevenueByCategory → keyBy(category)
       → TumblingWindow(1 min) → reduce + ProcessWindowFunction
       → Kafka sink + Iceberg sink (optional)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A few details that matter.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Watermark strategy.&lt;/strong&gt; The pipeline uses event time, meaning the event timestamp in the Kafka message, not the arrival time. The strategy is &lt;code&gt;forBoundedOutOfOrderness(10 seconds)&lt;/code&gt; with a 5-second idleness timeout.&lt;/p&gt;

&lt;p&gt;Why idleness? If a stream is empty for several minutes (the simulator is stopped, for example), Flink can no longer advance its watermark. Without &lt;code&gt;withIdleness&lt;/code&gt;, windows never close. With &lt;code&gt;withIdleness(5s)&lt;/code&gt;, Flink ignores silent partitions and advances anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Side outputs.&lt;/strong&gt; Invalid events (null price, missing timestamp) are not silently dropped. They are routed to a side output that logs them. This avoids the scenario where events disappear without a trace.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Two-phase reduction.&lt;/strong&gt; Before the window is applied, a &lt;code&gt;reduce&lt;/code&gt; combines events by category on the fly. The &lt;code&gt;ProcessWindowFunction&lt;/code&gt; then only attaches the window start and end timestamps. Less state to store, less work at window closure.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job 2: AnomalyDetectionJob
&lt;/h2&gt;

&lt;p&gt;This one is more interesting. It detects two types of anomalies through two different mechanisms.&lt;/p&gt;

&lt;h3&gt;
  
  
  Threshold detection: price anomaly
&lt;/h3&gt;

&lt;p&gt;A filter on price:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="n"&gt;ordersStream&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;filter&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPrice&lt;/span&gt;&lt;span class="o"&gt;()&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;
                  &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPrice&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;compareTo&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;priceThreshold&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nc"&gt;OrderAlert&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;priceAnomaly&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getCustomerId&lt;/span&gt;&lt;span class="o"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;event&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;getPrice&lt;/span&gt;&lt;span class="o"&gt;()))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The threshold (500 BRL by default) is configurable via environment variable. One subtlety: the filter is &lt;code&gt;&amp;gt; 0&lt;/code&gt;, not &lt;code&gt;&amp;gt;= 0&lt;/code&gt;. An order at exactly 500 BRL is not an anomaly. This behavior is covered by a specific test.&lt;/p&gt;

&lt;h3&gt;
  
  
  Pattern detection: suspicious frequency
&lt;/h3&gt;

&lt;p&gt;This is where CEP comes in.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="nc"&gt;Pattern&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderEvent&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="o"&gt;?&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;pattern&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Pattern&lt;/span&gt;&lt;span class="o"&gt;.&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="n"&gt;begin&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"orders"&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;timesOrMore&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;suspiciousOrderCount&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt;
    &lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;within&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nc"&gt;Duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;ofMinutes&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="o"&gt;));&lt;/span&gt;

&lt;span class="nc"&gt;PatternStream&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="nc"&gt;OrderEvent&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;patternStream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;CEP&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;pattern&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;ordersStream&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;keyBy&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;OrderEvent:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;getCustomerId&lt;/span&gt;&lt;span class="o"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;pattern&lt;/span&gt;
&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This pattern says: if the same customer places 3 or more orders within a 5-minute window, it's suspicious.&lt;/p&gt;

&lt;p&gt;The key is &lt;code&gt;keyBy(customerId)&lt;/code&gt;. Without it, Flink would compare orders from different customers. With &lt;code&gt;keyBy&lt;/code&gt;, each customer has their own independent CEP state.&lt;/p&gt;

&lt;p&gt;Both streams, price alerts and frequency alerts, are then merged with &lt;code&gt;union()&lt;/code&gt; before being sent to the Kafka output topic.&lt;/p&gt;

&lt;h2&gt;
  
  
  Job 3: RealtimeKpiJob
&lt;/h2&gt;

&lt;p&gt;Global KPIs: average order value, orders per minute, total revenue. The calculation is straightforward, but the implementation reveals an interesting trade-off.&lt;/p&gt;

&lt;h3&gt;
  
  
  windowAll: the acknowledged bottleneck
&lt;/h3&gt;

&lt;p&gt;To calculate total revenue across all orders in one minute, you need to aggregate all events together, without splitting by key. In Flink, this is called &lt;code&gt;windowAll&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;windowAll&lt;/code&gt; forces all events through a single processing instance. It's a bottleneck by design. At this volume (50 events per second), it's more than sufficient. If throughput rose to 50,000 events per second, a pre-aggregation by key followed by a merge would be necessary. We don't do that here because adding complexity for a hypothetical need is not good engineering.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two-phase aggregation
&lt;/h3&gt;

&lt;p&gt;The KPI calculation uses the &lt;code&gt;AggregateFunction&lt;/code&gt; + &lt;code&gt;ProcessAllWindowFunction&lt;/code&gt; pattern:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;KpiAggregateFunction&lt;/code&gt; accumulates the count and sum as events arrive, continuously&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;KpiWindowFunction&lt;/code&gt; computes the average and derived metrics at window closure&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This separation maintains minimal state (two numbers) instead of buffering all raw events. The &lt;code&gt;ProcessAllWindowFunction&lt;/code&gt; only receives the final accumulator.&lt;/p&gt;

&lt;h3&gt;
  
  
  The BoundedHistogram
&lt;/h3&gt;

&lt;p&gt;An optional but interesting detail: a custom Flink &lt;code&gt;Histogram&lt;/code&gt; implementation.&lt;/p&gt;

&lt;p&gt;The Flink Metrics API exposes three standard types: &lt;code&gt;Counter&lt;/code&gt;, &lt;code&gt;Gauge&lt;/code&gt;, &lt;code&gt;Histogram&lt;/code&gt;. For a &lt;code&gt;Histogram&lt;/code&gt;, Flink expects an implementation that returns percentiles, mean and standard deviation via a &lt;code&gt;HistogramStatistics&lt;/code&gt; interface.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;BoundedHistogram&lt;/code&gt; is a fixed-size circular buffer (1000 values). When the buffer is full, new values overwrite the oldest ones.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="kd"&gt;public&lt;/span&gt; &lt;span class="kd"&gt;synchronized&lt;/span&gt; &lt;span class="kt"&gt;void&lt;/span&gt; &lt;span class="nf"&gt;update&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;long&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="o"&gt;)&lt;/span&gt; &lt;span class="o"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;[&lt;/span&gt;&lt;span class="n"&gt;writeIndex&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="n"&gt;values&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;length&lt;/span&gt;&lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&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;writeIndex&lt;/span&gt;&lt;span class="o"&gt;++;&lt;/span&gt;
&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Simple, thread-safe, bounded memory. It allows Grafana to show the distribution of average order values, not just the latest single value.&lt;/p&gt;

&lt;h2&gt;
  
  
  Iceberg integration: what I didn't anticipate
&lt;/h2&gt;

&lt;p&gt;The Apache Iceberg integration was optional in the initial architecture. In practice, this is where I spent the most time.&lt;/p&gt;

&lt;h3&gt;
  
  
  The classloader problem
&lt;/h3&gt;

&lt;p&gt;Flink 2.0 loads its filesystem plugins (including &lt;code&gt;flink-s3-fs-hadoop&lt;/code&gt;) in an isolated classloader, invisible to user code. When &lt;code&gt;iceberg-flink-runtime&lt;/code&gt; tries to instantiate &lt;code&gt;S3AFileSystem&lt;/code&gt; at write time, it can't find the class provided by the Flink plugin.&lt;/p&gt;

&lt;p&gt;The solution: bundle &lt;code&gt;hadoop-aws&lt;/code&gt; and the AWS SDK directly in the fat JAR, with aggressive exclusions to avoid dependency conflicts.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight kotlin"&gt;&lt;code&gt;&lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"org.apache.hadoop:hadoop-aws:3.4.1"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;exclude&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;group&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"com.amazonaws"&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="s"&gt;"aws-java-sdk-bundle"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.amazonaws:aws-java-sdk-s3:1.12.780"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;implementation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"com.amazonaws:aws-java-sdk-sts:1.12.780"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fat JAR reaches ~710 MB. Not ideal, but that's the real cost of an Iceberg + Flink + S3 integration outside a managed service.&lt;/p&gt;

&lt;h3&gt;
  
  
  Credential timing
&lt;/h3&gt;

&lt;p&gt;Second surprise: &lt;code&gt;HadoopCatalog&lt;/code&gt; reads its S3 configuration at construction time, not after. The intuitive pattern of creating the catalog and then injecting configuration doesn't work:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Credentials are injected too late&lt;/span&gt;
&lt;span class="nc"&gt;HadoopCatalog&lt;/span&gt; &lt;span class="n"&gt;catalog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HadoopCatalog&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;catalog&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;setConf&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hadoopConf&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight java"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Credentials must be in the Configuration before construction&lt;/span&gt;
&lt;span class="nc"&gt;Configuration&lt;/span&gt; &lt;span class="n"&gt;hadoopConf&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Configuration&lt;/span&gt;&lt;span class="o"&gt;();&lt;/span&gt;
&lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;toProperties&lt;/span&gt;&lt;span class="o"&gt;().&lt;/span&gt;&lt;span class="na"&gt;forEach&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="nl"&gt;hadoopConf:&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="n"&gt;set&lt;/span&gt;&lt;span class="o"&gt;);&lt;/span&gt;
&lt;span class="nc"&gt;HadoopCatalog&lt;/span&gt; &lt;span class="n"&gt;catalog&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;HadoopCatalog&lt;/span&gt;&lt;span class="o"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hadoopConf&lt;/span&gt;&lt;span class="o"&gt;,&lt;/span&gt; &lt;span class="n"&gt;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="na"&gt;warehouse&lt;/span&gt;&lt;span class="o"&gt;());&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same applies to &lt;code&gt;CatalogLoader.hadoop()&lt;/code&gt;. This behavior is not prominently documented. It's the kind of error you only discover through end-to-end testing.&lt;/p&gt;

&lt;h3&gt;
  
  
  Docker Compose and .env resolution
&lt;/h3&gt;

&lt;p&gt;A less expected issue: Docker Compose v2 resolves the &lt;code&gt;.env&lt;/code&gt; file from the directory containing &lt;code&gt;docker-compose.yml&lt;/code&gt;, not from the current working directory.&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;# From the project root, this command ignores the .env at the root&lt;/span&gt;
docker compose &lt;span class="nt"&gt;-f&lt;/span&gt; docker/docker-compose.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;

&lt;span class="c"&gt;# You need to pass the path explicitly&lt;/span&gt;
docker compose &lt;span class="nt"&gt;--env-file&lt;/span&gt; .env &lt;span class="nt"&gt;-f&lt;/span&gt; docker/docker-compose.yml up &lt;span class="nt"&gt;-d&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without this, &lt;code&gt;ICEBERG_ENABLED=true&lt;/code&gt; in the &lt;code&gt;.env&lt;/code&gt; is ignored and jobs start without an Iceberg sink, with no error message.&lt;/p&gt;

&lt;h2&gt;
  
  
  Observability
&lt;/h2&gt;

&lt;p&gt;Flink exposes its metrics via Prometheus on port 9249. Each job exposes custom metrics:&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;Type&lt;/th&gt;
&lt;th&gt;Job&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;windowsEmitted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Counter&lt;/td&gt;
&lt;td&gt;RevenueAggregationJob&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;kpiWindowsEmitted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Counter&lt;/td&gt;
&lt;td&gt;RealtimeKpiJob&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lastWindowOrderCount&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Gauge&lt;/td&gt;
&lt;td&gt;RealtimeKpiJob&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;orderValueDistribution&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Histogram&lt;/td&gt;
&lt;td&gt;RealtimeKpiJob&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;priceAnomalyAlertsEmitted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Counter&lt;/td&gt;
&lt;td&gt;AnomalyDetectionJob&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;suspiciousFrequencyAlertsEmitted&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Counter&lt;/td&gt;
&lt;td&gt;AnomalyDetectionJob&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;deserializationErrors&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Counter&lt;/td&gt;
&lt;td&gt;All&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These metrics land in Prometheus every 15 seconds and are visualized in Grafana. The &lt;code&gt;deserializationErrors&lt;/code&gt; metric is particularly useful: if the simulator sends a malformed message, the counter rises and you see it immediately in the dashboard, without the job crashing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing
&lt;/h2&gt;

&lt;p&gt;The tests use Flink's &lt;code&gt;MiniCluster&lt;/code&gt;, an embedded Flink cluster that runs in the test process, with no external infrastructure.&lt;/p&gt;

&lt;p&gt;This choice has a cost: tests are slower (a few seconds each). But they test the actual behavior of Flink operators, not a mock. The &lt;code&gt;AnomalyDetectionJobTest&lt;/code&gt; specifically validates CEP edge cases:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;2 orders in 5 minutes → no alert&lt;/li&gt;
&lt;li&gt;3 orders in 5 minutes → alert triggered&lt;/li&gt;
&lt;li&gt;Order at exactly 500 BRL → no price alert&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;18 tests in total, covering all three jobs, the &lt;code&gt;BoundedHistogram&lt;/code&gt; and the deserialization schema. The CI (GitHub Actions) compiles and runs all tests on every push, with a JaCoCo report as an artifact.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch or streaming: the real debate
&lt;/h2&gt;

&lt;p&gt;Back to the opening scene.&lt;/p&gt;

&lt;p&gt;Streaming is often perceived as expensive: the cluster runs continuously, the infrastructure never shuts down. That's true. But this comparison is incomplete.&lt;/p&gt;

&lt;p&gt;A batch pipeline that handles events which correct themselves over time accumulates its own debt. Timeline reconciliation logic. Re-processing when an event arrives late. Alerts, manual interventions, data engineers spending time explaining why numbers are inconsistent across two windows. This cost is diffuse: it doesn't appear on any cloud bill, but it accumulates in sprints, in support, in technical debt.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Nobody actually does this calculation in practice — because it's too costly to conduct seriously.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;This project doesn't claim to settle the debate. What it shows is that an end-to-end streaming pipeline with Flink 2.0 is accessible today without managed infrastructure, without Databricks, without Confluent Cloud. A &lt;code&gt;docker compose up&lt;/code&gt; and the pipeline runs. The complexity is in the integration details, not in the paradigm itself.&lt;/p&gt;

&lt;p&gt;The code is on &lt;a href="https://github.com/HamdiMechelloukh/olist-flink-streaming" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. The &lt;code&gt;start-e2e.sh&lt;/code&gt; script launches the entire pipeline in a single command.&lt;/p&gt;




&lt;p&gt;You can also read this and other articles on &lt;a href="https://www.hamdimechelloukh.com" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>java</category>
      <category>dataengineering</category>
      <category>kafka</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Building an open-source vendor-neutral lakehouse</title>
      <dc:creator>Hamdi Mechelloukh</dc:creator>
      <pubDate>Fri, 20 Mar 2026 11:05:38 +0000</pubDate>
      <link>https://dev.to/hamdi_mechelloukh_628620a/building-an-open-source-vendor-neutral-lakehouse-f2c</link>
      <guid>https://dev.to/hamdi_mechelloukh_628620a/building-an-open-source-vendor-neutral-lakehouse-f2c</guid>
      <description>&lt;p&gt;When you work in data, you always end up asking the same question: &lt;strong&gt;what happens if we need to switch platforms tomorrow?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;I've seen firsthand that software vendors can be aggressive with pricing, and they won't hesitate to sunset a product that isn't generating enough revenue. When that happens, you need to migrate quickly, or face massive costs in migration, redevelopment, and lost time.&lt;/p&gt;

&lt;p&gt;This conviction led me to build &lt;strong&gt;an end-to-end open-source, vendor-neutral lakehouse&lt;/strong&gt;, from messaging to visualization. Here are the architecture choices, the trade-offs, and what I learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  The stack: Kafka → Spark → Iceberg
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Sources → Kafka → Spark (Bronze) → Spark (Silver) → Spark (Gold) → Streamlit
                                                                   ↑
                                                          Great Expectations
                                                          (quality at each layer)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Kafka for ingestion
&lt;/h3&gt;

&lt;p&gt;Choosing event-driven for data transfer isn't trivial. In computing, &lt;strong&gt;managing time is one of the hardest problems&lt;/strong&gt;. On the operational side, we're moving more and more toward event-driven architectures precisely for this reason: an event arrives when it arrives, and the system processes it. No batch window to respect, no "the file should have arrived at 6 AM".&lt;/p&gt;

&lt;p&gt;Kafka is the de facto standard for this kind of architecture. Open-source, battle-tested, and crucially: no vendor lock-in. You can deploy it on any cloud or on-premise.&lt;/p&gt;

&lt;h3&gt;
  
  
  Spark for compute
&lt;/h3&gt;

&lt;p&gt;You might ask: why Spark in an event-driven architecture? My position is pragmatic. Pure streaming via Kafka works well for ingestion into bronze, or even silver, to &lt;strong&gt;handle temporality upstream&lt;/strong&gt;. But once you reach heavy transformations (aggregations, joins, enrichments), Spark remains the most battle-tested and portable tool.&lt;/p&gt;

&lt;p&gt;Spark's advantage is that it runs everywhere: on a YARN cluster, on Kubernetes, on Databricks, on EMR, or locally for development. It's one of the few compute tools that doesn't lock you in.&lt;/p&gt;

&lt;h3&gt;
  
  
  Iceberg for the table format
&lt;/h3&gt;

&lt;p&gt;Iceberg is the open table format that's gaining momentum. My choice was partly technical curiosity: I use Delta Lake daily at work, so I wanted to explore the alternative.&lt;/p&gt;

&lt;p&gt;But beyond curiosity, Iceberg has properties that make it particularly suited for a vendor-neutral lakehouse:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Open format&lt;/strong&gt; — no dependency on a specific vendor&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Time travel&lt;/strong&gt; — query data at any point in time&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Schema evolution&lt;/strong&gt; — add or modify columns without rewriting data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Partition evolution&lt;/strong&gt; — change partitioning scheme without migration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Compatible with all engines&lt;/strong&gt; — Spark, Trino, Flink, Dremio, Athena...&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The project could just as well run with Delta Lake or Hudi. In fact, it would be interesting to offer format choice to anyone forking the project.&lt;/p&gt;

&lt;h2&gt;
  
  
  The layered architecture: bronze, silver, gold
&lt;/h2&gt;

&lt;p&gt;The medallion pattern (bronze/silver/gold) structures data in three levels of refinement:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Bronze&lt;/strong&gt; — raw data as it arrives, no transformation&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Silver&lt;/strong&gt; — cleaned, deduplicated, properly typed data&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gold&lt;/strong&gt; — aggregated data ready for business consumption&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Honestly, these terms are recent. A few years ago, we called them dataraw, dataprep, dataset. The vocabulary changes, the principle stays the same. What matters is to &lt;strong&gt;follow this progressive refinement logic without being rigid.&lt;/strong&gt; Functional reality always takes precedence over technical rules. If data doesn't need three layers, it doesn't need three layers.&lt;/p&gt;

&lt;h2&gt;
  
  
  MinIO: S3-compatible without the lock-in
&lt;/h2&gt;

&lt;p&gt;One point that might surprise you: why MinIO rather than S3 directly?&lt;/p&gt;

&lt;p&gt;Because &lt;strong&gt;S3 is an AWS service&lt;/strong&gt;, and using S3 means locking yourself into AWS. MinIO implements the S3 API identically: every tool that speaks S3 speaks MinIO without modification. You can develop and test locally, deploy on any cloud, and migrate to S3, GCS or Azure Blob Storage without changing a single line of application code.&lt;/p&gt;

&lt;p&gt;That's exactly the vendor-neutral principle: &lt;strong&gt;use open standards rather than proprietary managed services&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Data quality: Great Expectations and its limits
&lt;/h2&gt;

&lt;p&gt;Great Expectations is the most widely used data validation tool in the Python/Spark ecosystem. I integrated it at each pipeline layer to validate data on input and output.&lt;/p&gt;

&lt;p&gt;The tool does its job well for simple quality rules: nullability, uniqueness, value ranges, formats. It's also a tool I've seen used in enterprise settings, which validated the choice.&lt;/p&gt;

&lt;p&gt;But it has &lt;strong&gt;real limitations&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Complex quality rules (cross-table consistency, conditional business rules) are hard to express&lt;/li&gt;
&lt;li&gt;Resource-intensive checks (massive joins for cross-source duplicate detection) don't scale easily&lt;/li&gt;
&lt;li&gt;And most importantly: &lt;strong&gt;discovering quality issues is not enough&lt;/strong&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This last point is crucial and comes directly from my production experience at Decathlon. You can set up all the quality alerts in the world. If source teams have no commitment to fix the issues, nothing will change. You need to work on &lt;strong&gt;data quality service-level agreements&lt;/strong&gt;: SLAs on fix turnaround, shared responsibilities, clear escalation paths. Without that, source teams will make little effort to resolve quality problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  The difficulty of vendor-neutral
&lt;/h2&gt;

&lt;p&gt;The biggest challenge of this project wasn't technical in the traditional sense. It was &lt;strong&gt;resisting the temptation of managed services&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;At every step, there's a managed option that saves time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Why manage your own Kafka when there's Amazon MSK or Confluent Cloud?&lt;/li&gt;
&lt;li&gt;Why MinIO when S3 is there, configured in 2 clicks?&lt;/li&gt;
&lt;li&gt;Why self-hosted Airflow when there's MWAA?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The answer is always the same: &lt;strong&gt;because the day the pricing changes or the service is deprecated, you need to be able to leave&lt;/strong&gt;. This doesn't mean you should never use managed services. It means you should do it knowingly, and make sure the abstraction layer allows switching.&lt;/p&gt;

&lt;p&gt;In practice, building vendor-neutral requires more upfront effort:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Terraform for declarative, multi-cloud infrastructure management&lt;/li&gt;
&lt;li&gt;Docker for isolation and portability&lt;/li&gt;
&lt;li&gt;Standard interfaces everywhere (S3 API, JDBC, etc.)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But once it's in place, the freedom it provides is invaluable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Orchestration: Airflow
&lt;/h2&gt;

&lt;p&gt;Airflow is the natural choice for orchestration in a vendor-neutral stack. Open-source, extensible, and above all: the community is massive. When you have an Airflow problem, someone has already had it and posted the solution on Stack Overflow.&lt;/p&gt;

&lt;p&gt;Alternatives would be Dagster or Prefect, but Airflow remains the most widely deployed in production and the most in-demand on the market. Pragmatism.&lt;/p&gt;

&lt;h2&gt;
  
  
  IaC: Terraform for multi-cloud
&lt;/h2&gt;

&lt;p&gt;Terraform is the piece that makes vendor-neutral viable at scale. Infrastructure is described in code, versioned in Git, and deployable on AWS, GCP or Azure with provider changes, no complete rewrite needed.&lt;/p&gt;

&lt;p&gt;In this project, Terraform modules provision AWS infrastructure, but the same logic could be ported to another cloud without rebuilding the application architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I took away
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Vendor-neutral has a cost, but so does lock-in
&lt;/h3&gt;

&lt;p&gt;Building vendor-neutral requires more upfront work. But lock-in has a hidden cost that explodes the day you need to migrate. And that day always comes sooner than you think.&lt;/p&gt;

&lt;h3&gt;
  
  
  Open formats are your data's life insurance
&lt;/h3&gt;

&lt;p&gt;Iceberg, Parquet, Avro: as long as your data is in an open format, you can switch compute engines without losing your data. It's the most important decision in a data architecture.&lt;/p&gt;

&lt;h3&gt;
  
  
  Data quality is an organizational problem, not a technical one
&lt;/h3&gt;

&lt;p&gt;Tools like Great Expectations are necessary but not sufficient. Without service-level agreements with sources, quality alerts are just noise.&lt;/p&gt;

&lt;h3&gt;
  
  
  Functional reality takes precedence over patterns
&lt;/h3&gt;

&lt;p&gt;Bronze/silver/gold is a good guide, not a religion. If your data only needs two layers, don't make three to respect a pattern. Architecture should serve the business need, not the other way around.&lt;/p&gt;

&lt;h3&gt;
  
  
  Streaming doesn't replace batch, it complements it
&lt;/h3&gt;

&lt;p&gt;Kafka for real-time ingestion, Spark for heavy transformations. The two coexist, and that's healthy. Trying to do everything in streaming is as dogmatic as doing everything in batch.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going further
&lt;/h2&gt;

&lt;p&gt;The source code is available on &lt;a href="https://github.com/HamdiMechelloukh/olist-lakehouse" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. The project uses the Olist dataset (Brazilian e-commerce) as a data source, making it testable without heavy infrastructure.&lt;/p&gt;




&lt;p&gt;You can also read this and other articles on &lt;a href="https://www.hamdimechelloukh.com" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>opensource</category>
      <category>kafka</category>
      <category>spark</category>
    </item>
    <item>
      <title>Lessons from 2 years as Production Manager at Decathlon Digital</title>
      <dc:creator>Hamdi Mechelloukh</dc:creator>
      <pubDate>Fri, 20 Mar 2026 10:59:30 +0000</pubDate>
      <link>https://dev.to/hamdi_mechelloukh_628620a/lessons-from-2-years-as-production-manager-at-decathlon-digital-a4b</link>
      <guid>https://dev.to/hamdi_mechelloukh_628620a/lessons-from-2-years-as-production-manager-at-decathlon-digital-a4b</guid>
      <description>&lt;p&gt;For two and a half years, I stepped away from code to manage data production for sales at Decathlon Digital. A role I discovered upon arrival: the job title said "Production Expert", and I quickly realized it was going to be a full-time commitment.&lt;/p&gt;

&lt;p&gt;Here's what I learned from switching to the other side.&lt;/p&gt;

&lt;h2&gt;
  
  
  Context: Perfeco and sales data
&lt;/h2&gt;

&lt;p&gt;Perfeco was the data product that served the company's economic performance and sales data. In practice, it meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;An ingestion pipeline built on &lt;strong&gt;Talend&lt;/strong&gt; and &lt;strong&gt;Redshift&lt;/strong&gt; — data was processed and stored in Redshift, then pushed to S3&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;2 to 3 million sales per day&lt;/strong&gt; ingested&lt;/li&gt;
&lt;li&gt;Data exposed in the datalake and via an API consumed by multiple business teams&lt;/li&gt;
&lt;li&gt;Kafka messages with XML payloads converted to CSV before loading&lt;/li&gt;
&lt;li&gt;A scheduler (OpCon) to orchestrate ingestion jobs&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;My role: &lt;strong&gt;make sure all of this runs&lt;/strong&gt;, every day, without interruption.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "Production Manager" actually means day-to-day
&lt;/h2&gt;

&lt;p&gt;Coming from development, you'd think production is about monitoring and a few alerts. Reality is very different.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reducing the operational burden
&lt;/h3&gt;

&lt;p&gt;My main goal wasn't to react to incidents, but to &lt;strong&gt;reduce their frequency&lt;/strong&gt;. That meant:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Proactive alerting&lt;/strong&gt; — setting up the right dashboards (QuickSight, Tableau) and alerts to detect anomalies before they become incidents. Automatic Jira ticket creation when a threshold is breached.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Data quality at the source&lt;/strong&gt; — analyzing and detecting quality issues upstream, then escalating them to source teams. This is facilitation work, not code: convincing an upstream team that their data is poorly formatted takes time and diplomacy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run documentation&lt;/strong&gt; — writing and maintaining on-call procedures so that any team member can intervene at 3 AM without relying on one person's memory.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Run KPIs&lt;/strong&gt; — scripting metrics collection to objectively measure stability: incident count, resolution time, data availability.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Facilitating, not coding
&lt;/h3&gt;

&lt;p&gt;The biggest surprise was the &lt;strong&gt;relational dimension&lt;/strong&gt; of the role. I spent more time managing people than technology:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Facilitating consumer teams&lt;/strong&gt; — improving incident communication. When an ingestion is delayed, 5 different teams need to know why and when it will be resolved. You need a clear channel, a clear message, and consistency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Facilitating source teams&lt;/strong&gt; — working with teams that produce upstream data so they fix quality issues at the root.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;On-call planning&lt;/strong&gt; — organizing rotations for the team, making sure everyone is trained and the load is fairly distributed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Postmortems&lt;/strong&gt; — I organized regular meetings with both data sources and consumers. Postmortems were filled collaboratively during these sessions: what happened, why, and what actions to take to prevent recurrence. This collaborative format aligned everyone and avoided the blame game.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The typical incident: when the scheduler crashes
&lt;/h2&gt;

&lt;p&gt;My nemesis during those two years was the OpCon scheduler client crashing on the machine. Silently.&lt;/p&gt;

&lt;p&gt;The scenario was always the same:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The OpCon client crashes → no jobs are launched&lt;/li&gt;
&lt;li&gt;Sales keep arriving via Kafka (messages with XML payloads)&lt;/li&gt;
&lt;li&gt;Messages pile up, hundreds of thousands within hours&lt;/li&gt;
&lt;li&gt;When we restart the scheduler, the XML → CSV conversion job faces a massive backlog&lt;/li&gt;
&lt;li&gt;The Talend job struggles, processing times explode, Redshift is overwhelmed, data arrives late in S3&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The biggest incidents we had were all tied to this problem. What made it frustrating was that the client crash was silent: no alert, no explicit log. We'd only discover it by noticing the absence of data downstream.&lt;/p&gt;

&lt;p&gt;The lesson: &lt;strong&gt;monitoring the absence of events is as important as monitoring errors&lt;/strong&gt;. If a job that runs every 15 minutes hasn't executed in 30 minutes, that's a strong signal.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Production is engineering
&lt;/h3&gt;

&lt;p&gt;Reducing operational burden isn't just "adding alerts". It's designing an observability system, automating detection, documenting procedures, and measuring improvement. It's engineering work in its own right.&lt;/p&gt;

&lt;h3&gt;
  
  
  Communication is a technical skill
&lt;/h3&gt;

&lt;p&gt;Writing a clear incident message, running a blameless postmortem, convincing a source team to fix a data format. These are skills as important as writing code. And they can be practiced.&lt;/p&gt;

&lt;h3&gt;
  
  
  Proactive alerting changes everything
&lt;/h3&gt;

&lt;p&gt;The difference between a PM who reacts and one who manages is proactivity. When you discover an incident from an automatic alert at 8 AM instead of a call from a business team at 10 AM, you've gained 2 hours and a lot of peace of mind.&lt;/p&gt;

&lt;h3&gt;
  
  
  Monitor the silence
&lt;/h3&gt;

&lt;p&gt;The most dangerous incidents don't generate errors: they generate silence. A pipeline that stops running, a scheduler that has crashed, a message that never arrives. Alerts on the absence of activity saved me more often than alerts on errors.&lt;/p&gt;

&lt;h3&gt;
  
  
  Documentation is not optional
&lt;/h3&gt;

&lt;p&gt;In dev, you can sometimes get by with readable code and a few comments. In production, if the on-call procedure isn't written down, it doesn't exist. The person on call at 3 AM doesn't have time to guess.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why I went back to technical work
&lt;/h2&gt;

&lt;p&gt;After two and a half years, I decided to return to a Data Engineer role. The reason is simple: &lt;strong&gt;I felt I was regressing technically&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The PM day-to-day is fascinating: the diversity of problems, the human dimension, the direct impact on data reliability. But I was spending my days facilitating, documenting and communicating, and less and less designing and coding.&lt;/p&gt;

&lt;p&gt;I was afraid of falling behind, of no longer being up to speed on fast-evolving technologies: Spark, Databricks, lakehouse architectures. The risk of becoming a purely managerial profile without technical expertise didn't sit well with me.&lt;/p&gt;

&lt;p&gt;Today, looking back, I don't regret the experience. It gave me an understanding of production that many developers don't have. When I design a pipeline now, I naturally think about observability, error recovery, and operational documentation. These are reflexes that code alone wouldn't have given me.&lt;/p&gt;

&lt;h2&gt;
  
  
  In summary
&lt;/h2&gt;

&lt;p&gt;If you're a developer and someone offers you a production-oriented role, here's what I'd tell you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It's a real job&lt;/strong&gt;, not a support role. It requires engineering, rigor, and a lot of soft skills.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;You'll learn things that development will never teach you&lt;/strong&gt; — crisis communication, priority management under pressure, the end-to-end view of a data product.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Set a time limit&lt;/strong&gt;. It's enriching, but if your core expertise is technical, don't stay too long or you risk falling behind.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Bring those reflexes back into your code&lt;/strong&gt;. Observability, documentation, monitoring the silence — these are skills that make better engineers.&lt;/li&gt;
&lt;/ul&gt;




&lt;p&gt;You can also read this and other articles on &lt;a href="https://www.hamdimechelloukh.com" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>dataengineering</category>
      <category>production</category>
      <category>devops</category>
      <category>career</category>
    </item>
    <item>
      <title>AgenticDev: a multi-LLM framework for generating tested code</title>
      <dc:creator>Hamdi Mechelloukh</dc:creator>
      <pubDate>Fri, 20 Mar 2026 10:58:27 +0000</pubDate>
      <link>https://dev.to/hamdi_mechelloukh_628620a/agenticdev-a-multi-llm-framework-for-generating-tested-code-184</link>
      <guid>https://dev.to/hamdi_mechelloukh_628620a/agenticdev-a-multi-llm-framework-for-generating-tested-code-184</guid>
      <description>&lt;p&gt;In late 2025, after spending hours prompting LLMs one by one to generate code, a question kept nagging me: &lt;strong&gt;what if multiple LLM agents could collaborate to produce a complete project?&lt;/strong&gt; Not a single agent doing everything, but a specialized team (an architect, a developer, a tester), each with its own role, tools, and constraints.&lt;/p&gt;

&lt;p&gt;That's how &lt;strong&gt;AgenticDev&lt;/strong&gt; was born, a Python framework that orchestrates 4 LLM agents to turn a plain-text request into tested, documented code.&lt;/p&gt;

&lt;p&gt;In this article, I share the architecture decisions, the problems I ran into, and the lessons learned.&lt;/p&gt;

&lt;h2&gt;
  
  
  Starting point: testing the limits of multi-agent collaboration
&lt;/h2&gt;

&lt;p&gt;My initial goal was simple: explore how far LLM agents can collaborate autonomously. Not a throwaway POC, but a real pipeline where each agent has a clear responsibility:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Architect&lt;/strong&gt; — analyzes the request and produces a technical specification (&lt;code&gt;spec.md&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Designer&lt;/strong&gt; — generates SVG assets from the spec&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Developer&lt;/strong&gt; — implements the code following the spec and integrating the assets&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tester&lt;/strong&gt; — writes and runs tests, then sends failures back to the Developer&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The idea is the &lt;strong&gt;Agent as Tool&lt;/strong&gt; pattern: each agent is a node in an execution graph, not an LLM calling other LLMs chaotically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Architecture: why LangGraph over an LLM orchestrator
&lt;/h2&gt;

&lt;p&gt;My first approach was letting an orchestrator agent (Gemini) dynamically decide which sub-agent to call, via function calls. It worked, but I quickly identified a problem: &lt;strong&gt;the more generic the system, the more unpredictable it became.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The LLM orchestrator could decide to skip the Designer, call the Tester before the Developer, or loop indefinitely. For a framework that needs to produce reliable code, that's a deal-breaker.&lt;/p&gt;

&lt;p&gt;So I chose to &lt;strong&gt;delegate orchestration to LangGraph&lt;/strong&gt;, a deterministic graph framework. The pipeline becomes explicit:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Architect → Designer → Developer → Tester
                                      │
                                      ▼ (tests fail?)
                                   Developer ← fix loop (max 3×)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each node is an autonomous agent, but &lt;strong&gt;execution order and retry logic are deterministic&lt;/strong&gt;. The LLM controls the &lt;em&gt;what&lt;/em&gt; (generated content), but not the &lt;em&gt;when&lt;/em&gt; (execution flow).&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="n"&gt;_builder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;StateGraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PipelineState&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;_builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;START&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;architect&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;_builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;architect&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;designer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;_builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;designer&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;developer&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;_builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;developer&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;tester&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;_builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_conditional_edges&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;tester&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;should_fix_or_end&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;fix&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;fix_developer&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;end&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;END&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;_builder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add_edge&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fix_developer&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;tester&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;should_fix_or_end&lt;/code&gt; function is pure Python: it parses the Tester's output and decides whether to rerun the Developer or finish. No LLM in the decision loop.&lt;/p&gt;

&lt;h2&gt;
  
  
  The prompt caching problem and the switch to full Gemini
&lt;/h2&gt;

&lt;p&gt;During the exploration phase, I very quickly hit &lt;strong&gt;API rate limits&lt;/strong&gt; on Gemini. Every agent call sent the full system prompt, tool definitions, project context, thousands of tokens per request.&lt;/p&gt;

&lt;p&gt;The solution: &lt;strong&gt;prompt caching&lt;/strong&gt;. But Gemini and Claude handle it very differently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Gemini: implicit caching
&lt;/h3&gt;

&lt;p&gt;Gemini automatically caches repeated prefixes. If the system prompt and initial instructions are identical between two calls, Google reuses the cached context. On the code side, there's nothing to do: caching is transparent.&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;# Savings show up in usage metadata
&lt;/span&gt;&lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cached_content_token_count&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;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;getattr&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;meta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;prompt_token_count&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;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache hit: %d/%d tokens (%d%%)&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cached&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt; &lt;span class="o"&gt;//&lt;/span&gt; &lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Claude: explicit caching
&lt;/h3&gt;

&lt;p&gt;Claude requires explicit &lt;code&gt;cache_control: ephemeral&lt;/code&gt; markers on the blocks you want cached: the system prompt, tool definitions, and the first user message.&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="n"&gt;system&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;type&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;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;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;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;instructions&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_control&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;type&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;ephemeral&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;claude_tools&lt;/span&gt; &lt;span class="o"&gt;=&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;_fn_to_claude_tool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&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;fn&lt;/span&gt; &lt;span class="ow"&gt;in&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;tools&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;claude_tools&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;claude_tools&lt;/span&gt;&lt;span class="p"&gt;[&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cache_control&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="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&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;ephemeral&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Why I switched to full Gemini
&lt;/h3&gt;

&lt;p&gt;I started with a multi-LLM architecture: Gemini for the Architect and Tester, Claude for the Developer. The idea was appealing: use each LLM where it excels.&lt;/p&gt;

&lt;p&gt;In practice, &lt;strong&gt;Claude's API cost quickly made this approach unsustainable&lt;/strong&gt;. A full pipeline run with Claude as Developer cost significantly more than with Gemini, especially during fix iterations where the context grows with each turn. So I decided to switch to &lt;strong&gt;full Gemini&lt;/strong&gt; as the default pipeline, while keeping the &lt;code&gt;ClaudeAgent&lt;/code&gt; in the framework as a configurable option.&lt;/p&gt;

&lt;p&gt;This pragmatic choice also let me fully benefit from Gemini's implicit caching across the entire pipeline, without managing two different caching strategies in production.&lt;/p&gt;

&lt;p&gt;The contrast between both approaches still pushed me to design the class hierarchy to isolate these differences:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;BaseAgent (ABC)
├── GeminiAgent    → implicit caching, google-genai SDK
│   ├── ArchitectAgent
│   ├── DesignerAgent
│   ├── DeveloperAgent
│   └── TesterAgent
└── ClaudeAgent    → explicit caching, anthropic SDK
    └── DeveloperAgent
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each agent inherits its backend's caching strategy without having to worry about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Agent hierarchy: ABC and specialization
&lt;/h2&gt;

&lt;p&gt;The core of the framework relies on a simple hierarchy:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;BaseAgent&lt;/code&gt;&lt;/strong&gt; (ABC) — defines the contract: &lt;code&gt;run(context) → AgentResult&lt;/code&gt;, tool management&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;GeminiAgent&lt;/code&gt;&lt;/strong&gt; — implements the agentic loop for Gemini (chat + tool calls)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;ClaudeAgent&lt;/code&gt;&lt;/strong&gt; — implements the agentic loop for Claude (messages + tool_use blocks)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Specialized agents (Architect, Developer, Tester) inherit from &lt;code&gt;GeminiAgent&lt;/code&gt; and only define their &lt;strong&gt;instructions&lt;/strong&gt; and &lt;strong&gt;tools&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ArchitectAgent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;GeminiAgent&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;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;Architect&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;You are a software architect...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="n"&gt;tools&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;web_search&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;write_file&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;gemini-3.1-pro-preview&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To add a new agent, just create a class, define its instructions, and add it as a node in the LangGraph pipeline. No need to touch the chat logic, tool calling, or caching.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Designer: a special case
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;DesignerAgent&lt;/code&gt; is an interesting case. Unlike other agents that use the standard agentic loop (chat → tool call → response → tool call → ...), the Designer makes &lt;strong&gt;direct API calls&lt;/strong&gt; to generate SVG.&lt;/p&gt;

&lt;p&gt;Why? Because SVG generation is a well-defined two-step workflow:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Planning&lt;/strong&gt; — "what assets does this project need?" → returns JSON&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generation&lt;/strong&gt; — "generate these N SVG sprites" → returns parsable text&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;No need for an agentic loop with tools here. The Designer still inherits from &lt;code&gt;GeminiAgent&lt;/code&gt; (for the API client and key validation), but it &lt;strong&gt;overrides &lt;code&gt;run()&lt;/code&gt;&lt;/strong&gt; with its own logic.&lt;/p&gt;

&lt;h2&gt;
  
  
  The automatic fix loop
&lt;/h2&gt;

&lt;p&gt;One of the most useful aspects of the pipeline is the &lt;strong&gt;fix loop&lt;/strong&gt;. When the Tester detects failures, the Developer is relaunched in &lt;strong&gt;FIX MODE&lt;/strong&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;should_fix_or_end&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PipelineState&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;Literal&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fix&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;end&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;_has_test_failures&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&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;test_results&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="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;state&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;fix_iterations&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="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;MAX_FIX_ITERATIONS&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;fix&lt;/span&gt;&lt;span class="sh"&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;end&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Developer then receives the test output in its context, with a clear instruction:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;em&gt;"You are in FIX MODE — read existing files and fix these. Do NOT rewrite all files from scratch."&lt;/em&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In practice, 3 iterations are enough in most cases to go from 60-70% passing tests to 100%.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared tools
&lt;/h2&gt;

&lt;p&gt;Agents interact with the file system through 4 simple tools:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;th&gt;Role&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;write_file(path, content)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Write a file (creates parent directories)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;read_file(path)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Read an existing file&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;execute_code(command)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Execute a shell command&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;web_search(query)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Web search via DuckDuckGo&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;These tools are plain Python functions, passed to agents through their constructor. The framework handles exposing them to the LLM in the right format (Gemini function declarations or Claude tool definitions).&lt;/p&gt;

&lt;h2&gt;
  
  
  The limits: a solid foundation, not a finished product
&lt;/h2&gt;

&lt;p&gt;Let's be honest about what the framework can and can't do. &lt;strong&gt;AgenticDev excels at generating a functional project base&lt;/strong&gt;: file structure, initial code, tests, documentation. For simple projects (CLI tools, libraries, small APIs), the output is often usable as-is.&lt;/p&gt;

&lt;p&gt;But as complexity grows (intricate business logic, multiple integrations, performance constraints), &lt;strong&gt;the generated code will be a starting point, not the final product&lt;/strong&gt;. There will be technical limitations (overly naive architectures, uncovered edge cases) and functional gaps (the LLM doesn't know your business context) that you'll need to fix manually or by vibe-coding with a tool like Claude Code or Cursor.&lt;/p&gt;

&lt;p&gt;This is actually the workflow I recommend: let AgenticDev generate the skeleton, then iterate on it with a coding assistant to refine the details. The framework saves you the first hours of setup, not the last hours of polish.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I learned
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Specialization beats generality
&lt;/h3&gt;

&lt;p&gt;An agent that "does everything" is less reliable than a team of specialized agents. The Architect can't code, the Developer can't test, and that's by design. Each agent has precise instructions and a limited scope.&lt;/p&gt;

&lt;h3&gt;
  
  
  Deterministic orchestration is non-negotiable
&lt;/h3&gt;

&lt;p&gt;Letting an LLM decide the execution flow means accepting that the pipeline behaves differently on every run. For a code generation tool, that's unacceptable. LangGraph let me keep the LLMs' creativity while enforcing a predictable execution order.&lt;/p&gt;

&lt;h3&gt;
  
  
  Prompt caching is essential in multi-agent systems
&lt;/h3&gt;

&lt;p&gt;Without caching, a 4-agent pipeline easily consumes 100k+ tokens per run, 80% of which is repeated context. Caching significantly reduces both costs and latency.&lt;/p&gt;

&lt;h3&gt;
  
  
  Cost dictates architecture
&lt;/h3&gt;

&lt;p&gt;Starting with multi-LLM was intellectually satisfying, but economic reality caught up. Keeping the multi-backend abstraction while using a single provider by default is the right trade-off: you only pay for what you use, without sacrificing flexibility.&lt;/p&gt;

&lt;h3&gt;
  
  
  Agent instructions are code
&lt;/h3&gt;

&lt;p&gt;Agent prompts aren't vague sentences: they're precise specifications with rules, examples, and edge cases. For instance, the Developer's prompt includes rules on Python vs TypeScript conventions, placeholder handling, and a mandatory completion audit before returning its response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Going further
&lt;/h2&gt;

&lt;p&gt;The source code is available on &lt;a href="https://github.com/HamdiMechelloukh/AgenticDev" rel="noopener noreferrer"&gt;GitHub&lt;/a&gt;. The framework is designed to be extended: adding a new agent takes about ten lines of code.&lt;/p&gt;

&lt;p&gt;You can also read this and other articles on &lt;a href="https://www.hamdimechelloukh.com" rel="noopener noreferrer"&gt;my portfolio&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Next steps I'm considering:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Support for new LLM backends (Mistral, Llama)&lt;/li&gt;
&lt;li&gt;Quality metrics on generated code&lt;/li&gt;
&lt;li&gt;Interactive mode with human validation between each step&lt;/li&gt;
&lt;/ul&gt;

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