<?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: zhenjie zhang</title>
    <description>The latest articles on DEV Community by zhenjie zhang (@zhenjie_zhang_bfee4c00180).</description>
    <link>https://dev.to/zhenjie_zhang_bfee4c00180</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%2F4067229%2Fb08b1d42-ca24-4003-8be4-65ee05b0cf0a.png</url>
      <title>DEV Community: zhenjie zhang</title>
      <link>https://dev.to/zhenjie_zhang_bfee4c00180</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zhenjie_zhang_bfee4c00180"/>
    <language>en</language>
    <item>
      <title>Serving 500 concurrent LLM chats on one 4-core box with tier-aware queueing</title>
      <dc:creator>zhenjie zhang</dc:creator>
      <pubDate>Fri, 07 Aug 2026 09:32:04 +0000</pubDate>
      <link>https://dev.to/zhenjie_zhang_bfee4c00180/serving-500-concurrent-llm-chats-on-one-4-core-box-with-tier-aware-queueing-4acg</link>
      <guid>https://dev.to/zhenjie_zhang_bfee4c00180/serving-500-concurrent-llm-chats-on-one-4-core-box-with-tier-aware-queueing-4acg</guid>
      <description>&lt;p&gt;&lt;strong&gt;TL;DR&lt;/strong&gt; — When traffic spikes on a shared LLM backend, a naive concurrency limit lets free-tier users starve paying users. This post walks through why our first solution (a global &lt;code&gt;asyncio.Semaphore&lt;/code&gt;) broke, and how a small Redis-backed tier-aware slot manager brought p99 latency during peaks from 20+ seconds down to under 2. The core code is ~40 lines. There's no magic — just a bit of fairness.&lt;/p&gt;




&lt;h2&gt;
  
  
  What we're running
&lt;/h2&gt;

&lt;p&gt;An AI companion app: users chat with LLM-powered personas, with pgvector-backed long-term memory. Nothing exotic on the infra side:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;FastAPI behind Uvicorn (8 workers)&lt;/li&gt;
&lt;li&gt;Postgres 16 + pgvector (via PgBouncer)&lt;/li&gt;
&lt;li&gt;Redis for cache and queueing&lt;/li&gt;
&lt;li&gt;R2 for media&lt;/li&gt;
&lt;li&gt;A single 4-core / 8 GB box&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;At peak we see a few hundred users online and around 50 concurrent LLM calls in flight. Costs are modest, everything is boringly self-hosted, and I like it that way.&lt;/p&gt;

&lt;p&gt;This post is about one specific thing that broke: &lt;strong&gt;fair queueing of LLM slots across free and paying tiers.&lt;/strong&gt; If you're building on top of hosted LLM APIs and you have a free tier, you'll likely run into this eventually.&lt;/p&gt;




&lt;h2&gt;
  
  
  The symptom
&lt;/h2&gt;

&lt;p&gt;Users on our paid tier started saying that chats felt "unresponsive" during peak hours. Monitoring confirmed it: p99 request latency sat comfortably at ~800 ms most of the day, then spiked to 20+ seconds for 30–60 minute windows.&lt;/p&gt;

&lt;p&gt;The bottleneck wasn't the LLM. Anthropic was responding in ~1.5 s like always. The problem was our own outbound concurrency: we had capped in-flight LLM calls per worker at 4, and a wave of free-tier users was saturating those slots. Paid users' requests queued behind free-tier ones for tens of seconds. FIFO, no fairness — the classic noisy-neighbor pattern.&lt;/p&gt;




&lt;h2&gt;
  
  
  Attempt 1: a global &lt;code&gt;asyncio.Semaphore&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;The obvious first pass:&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;_LLM_SEM&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;Semaphore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;call_llm&lt;/span&gt;&lt;span class="p"&gt;(...):&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;_LLM_SEM&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Two problems with this, both worth understanding before you reach for the same tool:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. It's per-process.&lt;/strong&gt; With 8 uvicorn workers, we actually had 8 × 30 = 240 slots, not 30. When traffic peaked we'd occasionally hit the provider hard enough to get rate-limited (429s).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. It's FIFO.&lt;/strong&gt; Free and paying users compete on equal footing. When free users outnumber paying users 20 to 1, paying users lose the race almost every time.&lt;/p&gt;

&lt;p&gt;Neither of these is a subtle bug — they're both design-level. A per-process semaphore is fundamentally the wrong shape when you have multiple workers, and a FIFO queue is fundamentally the wrong shape when you want to prioritize.&lt;/p&gt;


&lt;h2&gt;
  
  
  Attempt 2: a Redis-backed, tier-aware semaphore
&lt;/h2&gt;

&lt;p&gt;Design goals we landed on:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Total in-flight LLM calls capped &lt;strong&gt;globally across all workers&lt;/strong&gt; (protects the upstream provider)&lt;/li&gt;
&lt;li&gt;Per-tier caps so free and guest users can't consume the entire budget&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Different wait behavior per tier&lt;/strong&gt; — free/guest fail fast (better UX to say "try again" than hang), paid tiers queue patiently&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The slot budget we ended up with:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;GLOBAL_MAX = 30

TIER_CAPS = {
    "premium": 30,   # can use the entire global budget
    "pro":     20,
    "free":    15,
    "guest":    5,
}

MAX_WAIT_BY_TIER = {
    "premium": 60,   # wait, don't fail — they paid
    "pro":     45,
    "free":    12,   # fail fast so FE shows "retry" quickly
    "guest":    8,
}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The key insight: the guest cap of 5 isn't there to throttle guests. It's there to &lt;em&gt;guarantee&lt;/em&gt; that even if 500 guests hit us at once, there are always 25 slots reserved for paying users.&lt;/p&gt;
&lt;h3&gt;
  
  
  The atomic bit
&lt;/h3&gt;

&lt;p&gt;Implementation is a small Lua script that does atomic check-and-increment on two counters (global + per-tier). This matters — if you increment one and then the other in separate calls, you can race and end up over-committed:&lt;br&gt;
&lt;/p&gt;
&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight lua"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- KEYS: global_key, tier_key&lt;/span&gt;
&lt;span class="c1"&gt;-- ARGV: global_max, tier_cap, ttl&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'GET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="kd"&gt;local&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'GET'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;g&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="k"&gt;then&lt;/span&gt;
    &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'INCR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'INCR'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'EXPIRE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'EXPIRE'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;KEYS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="nb"&gt;tonumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ARGV&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;]))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;end&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;The &lt;code&gt;EXPIRE&lt;/code&gt; calls are a safety net: if a worker crashes mid-call, the counter drains itself after 5 minutes instead of leaking a slot forever. In production I've never actually seen this fire in the healthy path, but it saved me during an early deploy when I had to kill a hung worker.&lt;/p&gt;

&lt;p&gt;Wrapped in an async context manager, the call site stays boring:&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;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;tier_slot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;reply&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;generate_reply_async&lt;/span&gt;&lt;span class="p"&gt;(...)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;


&lt;p&gt;Internally it polls every 100 ms, logs a warning if wait exceeds 3 s (&lt;code&gt;queue.slow_wait&lt;/code&gt;), and raises &lt;code&gt;TimeoutError&lt;/code&gt; at the tier's &lt;code&gt;max_wait&lt;/code&gt;. The route layer catches the timeout and returns a structured error like &lt;code&gt;{"code": "SERVER_BUSY", "retry_after": 5}&lt;/code&gt; — the frontend has a localized string for that code.&lt;/p&gt;


&lt;h2&gt;
  
  
  The Redis-is-down fallback
&lt;/h2&gt;

&lt;p&gt;If Redis dies, the context manager silently falls back to a per-process &lt;code&gt;asyncio.Semaphore(30)&lt;/code&gt;. You lose cross-worker isolation, but you don't 500 the user. A single log line (&lt;code&gt;queue.redis_unavailable&lt;/code&gt;) fires our alert.&lt;/p&gt;

&lt;p&gt;This one detail is what actually lets me sleep at night. Redis outages are rare, but they tend to happen at the exact moment when your queueing infrastructure failing catastrophically would compound the incident.&lt;/p&gt;


&lt;h2&gt;
  
  
  Three related lessons that made the difference
&lt;/h2&gt;

&lt;p&gt;The queueing change was the headline, but three unrelated tweaks did as much heavy lifting:&lt;/p&gt;
&lt;h3&gt;
  
  
  1. &lt;code&gt;statement_cache_size=0&lt;/code&gt; on the async Postgres engine
&lt;/h3&gt;

&lt;p&gt;We run behind PgBouncer in transaction pooling mode. asyncpg's prepared-statement cache is per-connection, but transaction pooling hands you a &lt;em&gt;different&lt;/em&gt; connection every query. Without disabling the cache, you get sporadic &lt;code&gt;prepared statement "__asyncpg_stmt_0__" does not exist&lt;/code&gt; errors — which look like DB corruption but aren't.&lt;/p&gt;

&lt;p&gt;If you use &lt;code&gt;asyncpg + PgBouncer + transaction mode&lt;/code&gt;, set this before you deploy. It's a one-line fix for a genuinely confusing bug.&lt;/p&gt;
&lt;h3&gt;
  
  
  2. A 3-second embedding timeout
&lt;/h3&gt;

&lt;p&gt;For every chat message we do a pgvector similarity search over the NPC's long-term memory. That requires embedding the user's message first. When the embedding endpoint is slow, this used to block the entire chat response.&lt;/p&gt;

&lt;p&gt;Our current rule: if embedding takes more than 3 s, we skip the memory retrieval and reply without long-term context. A fast, slightly less contextual reply beats a stuck one. Users don't notice; nobody has ever complained about the "missing memory" fallback.&lt;/p&gt;
&lt;h3&gt;
  
  
  3. Cross-provider fallback
&lt;/h3&gt;

&lt;p&gt;Every LLM call is wrapped in a retry that swaps providers on the second attempt (Anthropic ↔ OpenAI). When one provider has a bad 10 minutes, users don't see it. This one is well-known but underimplemented — most teams bolt on retry logic and stop there. Adding the cross-provider fallback took an afternoon and has quietly paid for itself dozens of times.&lt;/p&gt;


&lt;h2&gt;
  
  
  Where we ended up
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;p99 on chat endpoints: &lt;strong&gt;~1.8 s steady, ~4 s at peak&lt;/strong&gt; (was 20+ s during spikes)&lt;/li&gt;
&lt;li&gt;Free-tier abuse no longer affects paying users&lt;/li&gt;
&lt;li&gt;We can absorb roughly a 10× traffic spike without going down — free/guest users start seeing &lt;code&gt;SERVER_BUSY&lt;/code&gt; sooner than pro/premium do&lt;/li&gt;
&lt;li&gt;Single box still handles it. Vertical scale to 8 cores is our next headroom step; horizontal is the one after that&lt;/li&gt;
&lt;/ul&gt;


&lt;h2&gt;
  
  
  The new tradeoff this created
&lt;/h2&gt;

&lt;p&gt;You now have to &lt;em&gt;tune&lt;/em&gt; the tier caps. If you launch a new tier, or your traffic mix shifts, the caps drift out of alignment. There's no principled formula — we started with "premium gets everything, guests get scraps" and adjusted based on the &lt;code&gt;queue.slow_wait&lt;/code&gt; warnings we saw in logs.&lt;/p&gt;

&lt;p&gt;I've thought about auto-tuning based on rolling 5-minute wait percentiles per tier, but so far manual tuning has been fine. The alerting is what matters — if &lt;code&gt;slow_wait&lt;/code&gt; starts firing for pro or premium, we know the caps need adjusting before users notice.&lt;/p&gt;

&lt;p&gt;One honest limitation: this only works if your bottleneck is &lt;em&gt;your own&lt;/em&gt; concurrency, not the provider's. If Anthropic starts rate-limiting you at 15 concurrent, no clever local queueing will help — you'd need actual rate-limit-aware backpressure (react to the provider's own &lt;code&gt;retry-after&lt;/code&gt; headers). We're not there yet.&lt;/p&gt;


&lt;h2&gt;
  
  
  Takeaways
&lt;/h2&gt;

&lt;p&gt;If you're building on top of hosted LLM APIs and expect to serve a mix of free and paying users:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A per-process semaphore doesn't scale past one worker.&lt;/strong&gt; If you have multiple workers, you need a distributed counter.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;FIFO is unfair by construction.&lt;/strong&gt; Reserved slots for paying tiers cost you little and prevent the noisy-neighbor pattern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fail fast on the free tier, queue patiently on the paid tier.&lt;/strong&gt; The right timeout is very different depending on who's waiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Always have a fallback for when your queueing layer itself fails.&lt;/strong&gt; Silent degradation beats cascading outages.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Happy to answer questions in the comments — full source isn't open, but the queue module is small enough that this post is basically the whole thing.&lt;/p&gt;

&lt;p&gt;Built at [platos.me]&lt;/p&gt;
&lt;div class="crayons-card c-embed text-styles text-styles--secondary"&gt;
    &lt;div class="c-embed__content"&gt;
        &lt;div class="c-embed__cover"&gt;
          &lt;a href="https://platos.me/" class="c-link align-middle" rel="noopener noreferrer"&gt;
            &lt;img alt="" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fplatos.me%2Flanding%2Fimg%2Fhero-web-banner.png" height="741" class="m-0" width="800"&gt;
          &lt;/a&gt;
        &lt;/div&gt;
      &lt;div class="c-embed__body"&gt;
        &lt;h2 class="fs-xl lh-tight"&gt;
          &lt;a href="https://platos.me/" rel="noopener noreferrer" class="c-link"&gt;
            Real Friend AI — Platos
          &lt;/a&gt;
        &lt;/h2&gt;
          &lt;p class="truncate-at-3"&gt;
            Create the AI version of anyone. Chat, roleplay, and generate images and videos with private AI companions that reach out, remember, and stay present.
          &lt;/p&gt;
        &lt;div class="color-secondary fs-s flex items-center"&gt;
            &lt;img alt="favicon" class="c-embed__favicon m-0 mr-2 radius-0" src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fplatos.me%2Ffavicon.png%3Fv%3D876f223d5f89" width="64" height="64"&gt;
          platos.me
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;if you want to see what it powers.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>backend</category>
      <category>avatar</category>
      <category>design</category>
    </item>
  </channel>
</rss>
