<?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: David</title>
    <description>The latest articles on DEV Community by David (@david_chejo).</description>
    <link>https://dev.to/david_chejo</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%2F3960711%2F6eba3453-3ecb-4193-a883-e82a2095828a.jpg</url>
      <title>DEV Community: David</title>
      <link>https://dev.to/david_chejo</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/david_chejo"/>
    <language>en</language>
    <item>
      <title>AI Companion in Production by Month Three: 5 Architecture Decisions and Infra Tuning</title>
      <dc:creator>David</dc:creator>
      <pubDate>Sun, 12 Jul 2026 04:22:43 +0000</pubDate>
      <link>https://dev.to/david_chejo/ai-companion-in-production-by-month-three-5-architecture-decisions-and-infra-tuning-jc0</link>
      <guid>https://dev.to/david_chejo/ai-companion-in-production-by-month-three-5-architecture-decisions-and-infra-tuning-jc0</guid>
      <description>&lt;h1&gt;
  
  
  AI Companion in Production by Month Three: 5 Architecture Decisions and Infra Tuning
&lt;/h1&gt;

&lt;p&gt;Anyone who has tried to build an AI chat product using the most obvious stack — a chat-completions API, OpenAI-style memory, and a single Stable Diffusion endpoint — eventually hits the same walls.&lt;/p&gt;

&lt;p&gt;The bot forgets the conversation after ten messages. Sometimes the server returns HTTP 200 as if everything is fine, but the response body contains an empty string: no error, no timeout, no exception. The model simply refuses to speak, and it does so silently. The same text prompt produces two different faces. And if you try to put a generated character into a specific dress from a catalog, it does not work at all.&lt;/p&gt;

&lt;p&gt;For the last three months I have been running an AI companion in production. The same backend serves both a Telegram bot and a web app. The audience is hundreds of daily users, not hundreds of thousands. Free-to-paid conversion is in the single digits, which is normal for an early-stage product.&lt;/p&gt;

&lt;p&gt;So this article will not contain “millions of MAU” numbers. Instead, it will contain token prices, cache hit effects, daily cost ceilings, production tuning, and the before/after of the infrastructure changes that actually moved our DAU ceiling.&lt;/p&gt;

&lt;p&gt;This post combines four engineering build logs from the “Building HoneyChat” series into one article. I also added two sections that were not part of the original posts: unit economics in month three and the operational tuning that more than doubled the DAU ceiling without rewriting the architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  Table of contents
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Memory: Redis + ChromaDB&lt;/li&gt;
&lt;li&gt;LLM routing and prompt caching&lt;/li&gt;
&lt;li&gt;Visual consistency: LoRA and IP-Adapter&lt;/li&gt;
&lt;li&gt;Unit economics in month three&lt;/li&gt;
&lt;li&gt;Production tuning in month three&lt;/li&gt;
&lt;li&gt;What I would rebuild differently today&lt;/li&gt;
&lt;li&gt;Where this runs in production&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Memory&lt;/strong&gt;: Redis for the hot message buffer plus ChromaDB for compressed summaries of conversation chunks. Three reads happen in parallel. Turning every single message into a vector is a direct path to millions of low-quality documents and noisy retrieval.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LLM routing&lt;/strong&gt;: the user can choose the relationship pace in the UI: &lt;code&gt;slow_burn&lt;/code&gt;, &lt;code&gt;instant&lt;/code&gt;, plus the legacy default &lt;code&gt;natural&lt;/code&gt;. Each pace and each plan can use a different model. There is also a fallback chain across different providers. The main trap: a model may return HTTP 200 with an empty response because a content filter fired. That is not an exception, not a timeout, just silence.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prompt caching&lt;/strong&gt;: on Gemini 3.1 Flash Lite, a single &lt;code&gt;cache_control: ephemeral&lt;/code&gt; marker on top of the system prompt saves about 75% on the cached part of the request. In my case, this one marker covers roughly a quarter of the whole LLM budget.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Images&lt;/strong&gt;: LoRA is a small adapter trained separately for each character. It teaches Stable Diffusion to recognize a specific face. On top of that, IP-Adapter, with moderate strength and early cutoff, can render a specific catalog item without destroying the character’s face.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production tuning&lt;/strong&gt;: LRU eviction in ChromaDB, uvicorn worker restarts by request count, 90 seconds for graceful shutdown, and a higher daily cost ceiling. Together, these moved the API memory ceiling from about 500 DAU to about 1,200 DAU, and the ChromaDB ceiling from about 800 DAU to more than 2,000 DAU. The architecture itself did not change.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Below is the detailed version: code, real numbers, and the things I would do differently if I started again today.&lt;/p&gt;




&lt;h1&gt;
  
  
  1. Memory: Redis + ChromaDB
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Why a rolling summary is not enough
&lt;/h2&gt;

&lt;p&gt;The standard beginner path is simple: put the last &lt;code&gt;N&lt;/code&gt; messages into context and forget everything else. After 10–20 messages, the context falls out and the bot forgets the user’s name, earlier agreements, or the emotional thread of the scene.&lt;/p&gt;

&lt;p&gt;The obvious fix is: “Let’s just increase the context window.”&lt;/p&gt;

&lt;p&gt;That hits two problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The token cost grows quickly in long conversations.&lt;/li&gt;
&lt;li&gt;Even with a long context, models still start losing details from the middle.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The next obvious solution is a rolling summary: every &lt;code&gt;N&lt;/code&gt; messages, generate a compressed version.&lt;/p&gt;

&lt;p&gt;It is cheap, but it loses nuance when summarized repeatedly. Run this manually:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Message 1: “She said she hated her boss because he takes credit for her work.”&lt;br&gt;&lt;br&gt;
Summary 1: “The user mentioned tension with a manager at work.”&lt;br&gt;&lt;br&gt;
Summary 2: “The user is stressed because of work.”&lt;br&gt;&lt;br&gt;
Summary 3: “The user has a job.”&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;By the fourth iteration, the reason is gone. The bot starts sounding like a broken record.&lt;/p&gt;

&lt;p&gt;The fix is to split memory into layers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;recent messages are stored verbatim;&lt;/li&gt;
&lt;li&gt;only truly old chunks are compressed;&lt;/li&gt;
&lt;li&gt;semantic search can still retrieve any summary from the conversation history.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Architecture: two independent layers
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Redis&lt;/strong&gt; is the hot buffer.&lt;/p&gt;

&lt;p&gt;It is keyed by &lt;code&gt;(user_id, character_id, session_id)&lt;/code&gt;, has bounded length, a short TTL, and is updated synchronously on every message. Think of it as short-term memory: the latest 20–30 messages.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;ChromaDB&lt;/strong&gt; is the vector store.&lt;/p&gt;

&lt;p&gt;It stores compressed summaries of dialogue chunks, not individual messages. Writes are asynchronous and batched. Search works through embedding similarity.&lt;/p&gt;

&lt;p&gt;The key idea: &lt;strong&gt;vectorize summaries, not every message&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Ten weeks of active chat becomes 30–50 documents per collection, not tens of thousands. The index stays compact. Search quality does not get polluted by short replies like “yeah” or “ok”, which produce weak vectors and create noisy matches.&lt;/p&gt;

&lt;p&gt;A note on &lt;code&gt;session_id&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;In the web chat, I support “scenes”: a user can start a new conversation with the same character in a different setting, and memory should not leak from the previous scene. That is why Redis keys and ChromaDB collections include &lt;code&gt;session_id&lt;/code&gt; when it exists.&lt;/p&gt;

&lt;p&gt;The Telegram bot still runs in a compatibility mode without session separation. That layer exists for backwards compatibility.&lt;/p&gt;

&lt;p&gt;A summary document in ChromaDB looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"summary:uid42:char_anna:sess_kn3a:turn_120"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"document"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Anna and the user discussed his problems at work..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"summary"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"turn_range"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"100-120"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"ts"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-20T14:32:00Z"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"lang"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"en"&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;type: "summary"&lt;/code&gt; field did not exist in the first version. Initially, documents had almost no metadata. Later, when I added new document types such as &lt;code&gt;event&lt;/code&gt; and &lt;code&gt;fact&lt;/code&gt;, I had to write a backwards compatibility layer.&lt;/p&gt;

&lt;p&gt;My advice: put &lt;code&gt;type&lt;/code&gt; into metadata from day one, even if you currently have only one document type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Writing to Redis: bounded list + TTL in one pipeline
&lt;/h2&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;def&lt;/span&gt; &lt;span class="nf"&gt;save_message&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;char_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;get_redis&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;chat:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;char_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:messages&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;

    &lt;span class="n"&gt;msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;datetime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;timezone&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;utc&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;isoformat&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;

    &lt;span class="n"&gt;pipe&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pipeline&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rpush&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ltrim&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;HOT_BUFFER_SIZE&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="n"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;expire&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;86400&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;HOT_BUFFER_TTL_DAYS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;pipe&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three things matter here.&lt;/p&gt;

&lt;p&gt;First, &lt;code&gt;ltrim&lt;/code&gt; runs on every write. The list keeps only the latest &lt;code&gt;N&lt;/code&gt; messages. Memory usage per user is constant and does not grow with conversation length.&lt;/p&gt;

&lt;p&gt;Second, the key TTL is refreshed on every write. Inactive users disappear automatically. Also, set Redis &lt;code&gt;maxmemory-policy&lt;/code&gt; to &lt;code&gt;allkeys-lru&lt;/code&gt;. The default &lt;code&gt;noeviction&lt;/code&gt; policy refuses writes when memory is full, and that surprise usually happens at the worst possible moment.&lt;/p&gt;

&lt;p&gt;Third, &lt;code&gt;rpush + ltrim + expire&lt;/code&gt; are pipelined. That is one Redis round trip, not three.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading: three sources in parallel
&lt;/h2&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;def&lt;/span&gt; &lt;span class="nf"&gt;build_prompt_context&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;char_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_query&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;memories&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;gather&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nf"&gt;get_recent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;char_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;get_latest_summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;char_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="nf"&gt;get_relevant_memories&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;char_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user_query&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;recent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;recent&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;summary&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;memories&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;memories&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fresh buffer lives in Redis with a short TTL.&lt;/p&gt;

&lt;p&gt;If a summary cache is stale, the system reads from ChromaDB and writes the result back to Redis so the next request is hot again.&lt;/p&gt;

&lt;h2&gt;
  
  
  Production traps I hit
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Race condition between two summarization tasks
&lt;/h3&gt;

&lt;p&gt;Two user messages may arrive almost at the same time. Both launch summarization. Two overlapping documents get written to the collection.&lt;/p&gt;

&lt;p&gt;In production, I keep a global dictionary:&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;_SUMMARIZE_TASKS&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;asyncio&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Task&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is:&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;char_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;session_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When a new task appears, the previous one is cancelled with &lt;code&gt;task.cancel()&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  The user clears history while summarization is still running
&lt;/h3&gt;

&lt;p&gt;The user presses “reset chat” while a background task is still working. The summary arrives into a collection that should already be gone.&lt;/p&gt;

&lt;p&gt;The fix: check whether the Redis key still exists before writing. If the key disappeared, the task exits silently.&lt;/p&gt;

&lt;h3&gt;
  
  
  Empty summaries cached with long TTL
&lt;/h3&gt;

&lt;p&gt;Sometimes the LLM returns an empty string because of a rate limit or provider issue. I cached that empty string for three days.&lt;/p&gt;

&lt;p&gt;The fix is trivial:&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;if&lt;/span&gt; &lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;cache_summary&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Missing collections for new users
&lt;/h3&gt;

&lt;p&gt;A query to a non-existing ChromaDB collection throws an exception. This is normal for first messages from a new user. Wrap it in &lt;code&gt;try/except&lt;/code&gt; and return an empty result.&lt;/p&gt;




&lt;h1&gt;
  
  
  2. LLM routing and prompt caching
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Why one model for everything does not work
&lt;/h2&gt;

&lt;p&gt;At first, I wanted to pick one good model and stop thinking about it.&lt;/p&gt;

&lt;p&gt;After a couple of weeks in production, it became obvious why this does not work.&lt;/p&gt;

&lt;p&gt;There are three reasons.&lt;/p&gt;

&lt;h3&gt;
  
  
  Free and paid plans pull the economics in opposite directions
&lt;/h3&gt;

&lt;p&gt;A free user can send 20 messages per day.&lt;/p&gt;

&lt;p&gt;If every message goes to a flagship model, the free user costs you more than they pay. And they pay exactly zero.&lt;/p&gt;

&lt;p&gt;A top-tier paid user, on the other hand, expects quality. If they pay for the premium plan, they should get the premium model.&lt;/p&gt;

&lt;p&gt;One model for everyone creates one of two bad outcomes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;free users are silently subsidized by paid users;&lt;/li&gt;
&lt;li&gt;paid users get the same quality as free users and feel cheated.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The average solution satisfies nobody.&lt;/p&gt;

&lt;h3&gt;
  
  
  Models treat content differently
&lt;/h3&gt;

&lt;p&gt;GPT and Claude-style models often refuse scenes that are completely normal and legal for an adult companion product.&lt;/p&gt;

&lt;p&gt;Less regulated models are more permissive, but often worse at long-context coherence. They forget who said what ten messages ago.&lt;/p&gt;

&lt;p&gt;Something is always a trade-off.&lt;/p&gt;

&lt;h3&gt;
  
  
  Users choose their own relationship pace
&lt;/h3&gt;

&lt;p&gt;In the UI, the user chooses between two relationship modes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;slow_burn&lt;/code&gt;: “let’s get to know each other first, no instant 18+”&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;instant&lt;/code&gt;: “get to the interesting part without long setup”&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is also a legacy database value:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;natural&lt;/code&gt;: the default for users who never opened the relationship pace setting.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These modes affect not only the story, but also expectations from the model.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;instant&lt;/code&gt; user usually sends shorter messages and expects an answer in about three seconds.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;slow_burn&lt;/code&gt; or &lt;code&gt;natural&lt;/code&gt; user is more likely to write long descriptive scenes and tolerate a 10-second response.&lt;/p&gt;

&lt;p&gt;A hardcoded single model loses in one of those cases: either it is too slow for short chat, or too dry for scenes.&lt;/p&gt;

&lt;p&gt;There is also a separate response style dimension:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;code&gt;standard&lt;/code&gt;&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;cinematic&lt;/code&gt;: a long scene with action markers like &lt;code&gt;✦action✦&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;brief&lt;/code&gt;: one or two short sentences&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;slang&lt;/code&gt;: SMS-style&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;conversational&lt;/code&gt;: natural dialogue without action markers&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Style and pace are independent. A user can pick any style on any pace.&lt;/p&gt;

&lt;p&gt;The response style almost does not affect model routing, but it does affect the final prompt. &lt;code&gt;brief&lt;/code&gt;, &lt;code&gt;slang&lt;/code&gt;, and &lt;code&gt;conversational&lt;/code&gt; remove cinematic markup and limit answer length at the prompt level.&lt;/p&gt;

&lt;p&gt;So the routing scheme is:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;plan × relationship pace → primary model&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Plus a fallback chain through different providers.&lt;/p&gt;

&lt;p&gt;I use OpenRouter as an LLM provider aggregator: one API, one key, many backend providers, and visibility into which backend is actually serving each model.&lt;/p&gt;

&lt;h2&gt;
  
  
  Current model map
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Plan&lt;/th&gt;
&lt;th&gt;Relationship pace&lt;/th&gt;
&lt;th&gt;Model&lt;/th&gt;
&lt;th&gt;Input/output price per 1M tokens&lt;/th&gt;
&lt;th&gt;Cache&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Free / Basic / Premium&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;slow_burn&lt;/code&gt;, &lt;code&gt;natural&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;qwen/qwen3-235b-a22b-2507&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;$0.07 / $0.10&lt;/td&gt;
&lt;td&gt;no&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free / Basic / Premium&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;instant&lt;/code&gt; + explicit request&lt;/td&gt;
&lt;td&gt;&lt;code&gt;deepseek/deepseek-v4-flash&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;$0.14 / $0.28&lt;/td&gt;
&lt;td&gt;implicit, automatic&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;VIP / Elite&lt;/td&gt;
&lt;td&gt;any pace&lt;/td&gt;
&lt;td&gt;&lt;code&gt;google/gemini-3.1-flash-lite&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;$0.25 / $1.50&lt;/td&gt;
&lt;td&gt;explicit marker&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fallbacks&lt;/td&gt;
&lt;td&gt;if refusal or empty response&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;x-ai/grok-4.20&lt;/code&gt;, then &lt;code&gt;minimax/minimax-m2-her&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;usage-based&lt;/td&gt;
&lt;td&gt;—&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;Qwen3-235B-A22B-2507&lt;/code&gt; is the cheapest decent model I have tried.&lt;/p&gt;

&lt;p&gt;It is a 235B parameter MoE model. MoE means “Mixture of Experts”: internally, the model has several specialized expert subnetworks, and only part of them are activated per request. This makes it faster and cheaper than a dense model of the same size.&lt;/p&gt;

&lt;p&gt;It has a 131k token context window and costs $0.10 per million output tokens. For the free plan, that is enough.&lt;/p&gt;

&lt;p&gt;Gemini 3.1 Flash Lite on paid plans gives a 1M token context window and better coherence for long scenes. But without caching it is much more expensive than Qwen, especially on output.&lt;/p&gt;

&lt;p&gt;That is why the next section is about caching.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prompt caching on Gemini 3.1: where 25% of the budget was hiding
&lt;/h2&gt;

&lt;p&gt;The principle is embarrassingly obvious once you learn it.&lt;/p&gt;

&lt;p&gt;Every request to the model starts with the same large system prompt: character description, behavior rules, tone instructions, safety rules. This part is identical across requests within the same dialogue.&lt;/p&gt;

&lt;p&gt;The provider can cache it after the first call and charge less for the cached part on later requests. You pay mostly for the unique part: the user’s fresh message.&lt;/p&gt;

&lt;p&gt;OpenRouter supports prompt caching, but the details vary a lot by model.&lt;/p&gt;

&lt;p&gt;I spent about one and a half months paying full price for Gemini before reading the docs carefully enough and adding one marker to one line.&lt;/p&gt;

&lt;p&gt;The effect: about 25% of the entire project LLM budget.&lt;/p&gt;

&lt;p&gt;One of those commits where you do not know whether to feel proud or annoyed.&lt;/p&gt;

&lt;p&gt;Empirical picture:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;DeepSeek V4 Flash&lt;/strong&gt; caches automatically. My test showed 1.5k–1.8k cached tokens per turn.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DeepSeek Chat v3.1&lt;/strong&gt; showed no visible cache. Skipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Qwen3-235B&lt;/strong&gt; is not in the OpenRouter supported-cache list. Skipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gemini 3.1 Flash Lite&lt;/strong&gt; requires an explicit &lt;code&gt;cache_control&lt;/code&gt; marker. In a test, 3,772 out of 3,779 prompt tokens were cached. Cached reads cost about 25% of normal input, so the saving is about 75% on the cached part.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The cache lifetime is short, about five minutes (&lt;code&gt;ephemeral&lt;/code&gt;), but OpenRouter uses sticky routing: subsequent requests in the same dialogue go to the same backend provider. So the cache stays hot while the user is actively chatting.&lt;/p&gt;

&lt;p&gt;The minimum block size is about 1,024 characters. The system prompt almost always passes that threshold.&lt;/p&gt;

&lt;p&gt;Here is the production helper:&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;_EXPLICIT_CACHE_PREFIXES&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;google/gemini-&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_apply_prompt_caching&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="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Wrap the first system message in block format with cache_control: ephemeral
    for providers that require an explicit marker, such as Gemini.

    Other providers either cache implicitly, like DeepSeek V4,
    or silently ignore the marker.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;startswith&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;_EXPLICIT_CACHE_PREFIXES&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;

    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="n"&gt;cached_one&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;

    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;cached_one&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;m&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;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;system&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;m&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;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;isinstance&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;1024&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;new_msg&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;new_msg&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="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;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;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;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;new_msg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="n"&gt;cached_one&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;

        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four details are easy to miss.&lt;/p&gt;

&lt;p&gt;First, add the marker only for model families that require it. Other providers may ignore it, but some OpenAI SDK clients can reject the field during validation.&lt;/p&gt;

&lt;p&gt;Second, place the marker only on the system prompt. The message history is different on every turn, so caching it is pointless. The system prompt is the largest stable part of the request.&lt;/p&gt;

&lt;p&gt;Third, keep the 1,024-character threshold. Below that, OpenRouter does not cache anything.&lt;/p&gt;

&lt;p&gt;Fourth, &lt;code&gt;cache_control&lt;/code&gt; requires a different &lt;code&gt;content&lt;/code&gt; format. A normal &lt;code&gt;content: "string"&lt;/code&gt; will not work. It must become a block array:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"text"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cache_control"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"ephemeral"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is easy to miss in the docs, but required by OpenRouter.&lt;/p&gt;

&lt;h2&gt;
  
  
  The HTTP 200 empty-response trap
&lt;/h2&gt;

&lt;p&gt;Some reasoning models run content checks before returning the final answer.&lt;/p&gt;

&lt;p&gt;On a borderline request, they do not return an HTTP error. They return HTTP 200 with a body like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"choices"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"finish_reason"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"content_filter"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"content"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;""&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Empty string. No exception. No status code that your retry logic can catch.&lt;/p&gt;

&lt;p&gt;If your retry logic only handles &lt;code&gt;httpx.HTTPStatusError&lt;/code&gt;, the empty answer goes straight to the user.&lt;/p&gt;

&lt;p&gt;The fix is one function that validates the choice before passing it downstream:&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;_is_silent_refusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;
    Reasoning models can return HTTP 200 + finish_reason=content_filter
    + content=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="s"&gt;. If you only look at the HTTP status, the user gets an
    empty message.
    &lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;choice&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;finish_reason&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;choice&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;message&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="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;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;

    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;reason&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;content_filter&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;length&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="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I also check &lt;code&gt;content.strip()&lt;/code&gt; separately from &lt;code&gt;finish_reason&lt;/code&gt;. Some models return &lt;code&gt;finish_reason=stop&lt;/code&gt; with empty content when they refuse softly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Fallback chain
&lt;/h2&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;def&lt;/span&gt; &lt;span class="nf"&gt;complete&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="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;chain&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;CompletionResult&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;models&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;list&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;chain&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;chain&lt;/span&gt; &lt;span class="ow"&gt;is&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nf"&gt;_build_chain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;primary&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;AsyncClient&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;enumerate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
            &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="n"&gt;data&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;_call&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;client&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;model&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="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HTTPStatusError&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;TRANSIENT_CODES&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;continue&lt;/span&gt;
                &lt;span class="k"&gt;raise&lt;/span&gt;

            &lt;span class="nf"&gt;except &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ReadTimeout&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConnectError&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;

            &lt;span class="n"&gt;choice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data&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;choices&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[{}])[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;_is_silent_refusal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;choice&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;

            &lt;span class="n"&gt;content&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;choice&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;message&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="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;content&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;
            &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;strip&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
                &lt;span class="k"&gt;continue&lt;/span&gt;

            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;CompletionResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
                &lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;content&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;model&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;AllModelsFailedError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;no model returned usable content; tried &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;models&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The fallback rule is: &lt;strong&gt;use different providers&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;If the primary model is hosted by provider A, the fallback should go through provider B. A fallback from the same provider often fails on the same content, because the moderation filter may sit at the API gateway layer before the model itself.&lt;/p&gt;

&lt;p&gt;OpenRouter makes this visible in the model metadata.&lt;/p&gt;

&lt;p&gt;What I log:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Which model actually answered: primary or fallback, and the fallback index.&lt;/li&gt;
&lt;li&gt;Time to first token versus total response time.&lt;/li&gt;
&lt;li&gt;Token cost split by plan and model.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the primary model refuses 10% of a certain request class, that is not a retry problem. It is a routing problem. Move that class to another primary model.&lt;/p&gt;




&lt;h1&gt;
  
  
  3. Visual consistency: LoRA and IP-Adapter
&lt;/h1&gt;

&lt;p&gt;This section covers two related tasks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Keep the character’s face stable from generation to generation. That is LoRA.&lt;/li&gt;
&lt;li&gt;Render a specific catalog item on top of that character without breaking the face. That is IP-Adapter layered on top of LoRA.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  3.1. Why “same prompt = same face” does not work
&lt;/h2&gt;

&lt;p&gt;It seems intuitive:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;anime girl, long silver hair, green eyes, Arknights operator outfit
seed=12345
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Should produce Anna. Always.&lt;/p&gt;

&lt;p&gt;In practice, it does not.&lt;/p&gt;

&lt;p&gt;Three reasons.&lt;/p&gt;

&lt;h3&gt;
  
  
  Batch size changes the output
&lt;/h3&gt;

&lt;p&gt;In many Stable Diffusion configurations, one image with &lt;code&gt;batch_size=1&lt;/code&gt; and the first image from a &lt;code&gt;batch_size=4&lt;/code&gt; batch can differ even with the same seed. The random generator state depends on tensor dimensionality. This is not a bug; it is a sampler implementation detail.&lt;/p&gt;

&lt;h3&gt;
  
  
  External APIs shift samplers and defaults
&lt;/h3&gt;

&lt;p&gt;If you call external services such as fal.ai, Replicate, or Together, the provider can update the model, change default parameters, or switch samplers. Your “fixed” character drifts over weeks.&lt;/p&gt;

&lt;p&gt;One of my characters aged about five years in a month because a provider rolled back a minor model version without warning anyone.&lt;/p&gt;

&lt;h3&gt;
  
  
  Long prompts saturate
&lt;/h3&gt;

&lt;p&gt;After a certain number of tags, adding more details stops helping. The model works with an approximate template of the character and interpolates inside it. In other words, it inserts an average of something similar that it has seen during training.&lt;/p&gt;

&lt;h2&gt;
  
  
  3.2. IP-Adapter alone is weak for faces
&lt;/h2&gt;

&lt;p&gt;IP-Adapter is a technique that passes a reference image along with the text prompt. The model uses visual features from that image during generation.&lt;/p&gt;

&lt;p&gt;It is great for rendering products.&lt;/p&gt;

&lt;p&gt;It is weak for preserving only the face.&lt;/p&gt;

&lt;p&gt;The problem is that IP-Adapter pulls everything from the reference image: lighting, pose, background, sometimes even clothes. If you lower the weight, the face weakens. If you increase it, the reference dominates the whole generation.&lt;/p&gt;

&lt;p&gt;IP-Adapter works well when the reference is exactly what you want to preserve: a specific product or object. When you want to preserve only a face, it is the wrong tool.&lt;/p&gt;

&lt;h2&gt;
  
  
  3.3. LoRA per character: scale and cost
&lt;/h2&gt;

&lt;p&gt;LoRA, or Low-Rank Adaptation, is a small set of additional weights layered over a base Stable Diffusion model.&lt;/p&gt;

&lt;p&gt;A LoRA file is usually 100–300 MB versus 6–7 GB for the base SDXL model. So it is 20–30 times smaller than the base model.&lt;/p&gt;

&lt;p&gt;When trained on 20–30 clean images of one character in different poses, angles, and lighting, it encodes the character’s face into neural weights, not into a text prompt. The model “learns” what that character looks like and can generate them consistently across prompts.&lt;/p&gt;

&lt;p&gt;Real production numbers:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The catalog has 100+ characters. Each has a separate LoRA.&lt;/li&gt;
&lt;li&gt;Training runs on rented GPU through Vast.ai, usually RTX 3090/4090 with 24 GB VRAM.&lt;/li&gt;
&lt;li&gt;The GPU is rented monthly, so the marginal cost of training and inference is close to zero. We pay a fixed rental fee; the number of LoRAs trained during the month is mostly a capacity question.&lt;/li&gt;
&lt;li&gt;We start paying per image only if the GPU goes offline and we fall back to paid providers such as OpenRouter Flux/Riverflow at about $0.03–$0.07 per image.&lt;/li&gt;
&lt;li&gt;One SDXL LoRA takes 15–25 minutes of GPU time depending on dataset size and number of steps.&lt;/li&gt;
&lt;li&gt;Checkpoints live in Storj S3. One LoRA &lt;code&gt;safetensors&lt;/code&gt; file is 100–300 MB depending on rank, usually 16–32 in our setup.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Inference runs on a separate GPU with the base SDXL model preloaded and hot-swapped LoRAs per request. The base model is loaded into VRAM once. Switching LoRA for a specific character takes tens of milliseconds.&lt;/p&gt;

&lt;p&gt;The pipeline:&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;workflow&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;Checkpoint&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c1"&gt;# base SDXL
&lt;/span&gt;    &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;LoRA: &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;char&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lora&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;# character-specific LoRA
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FreeU&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;                &lt;span class="c1"&gt;# noise rebalance, quality boost without much compute
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;KSampler&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;For projects with one or two characters, a LoRA pipeline may be overkill. For 50+ characters, it is the only reasonable architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  3.4. What matters during training
&lt;/h2&gt;

&lt;p&gt;A skeleton config for a Kohya_ss trainer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="nn"&gt;[model_arguments]&lt;/span&gt;
&lt;span class="py"&gt;pretrained_model_name_or_path&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;path/to/sdxl-base.safetensors&amp;gt;"&lt;/span&gt;

&lt;span class="nn"&gt;[dataset_arguments]&lt;/span&gt;
&lt;span class="py"&gt;train_data_dir&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"./dataset/train"&lt;/span&gt;
&lt;span class="py"&gt;resolution&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"1024,1024"&lt;/span&gt;
&lt;span class="py"&gt;caption_extension&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;".txt"&lt;/span&gt;

&lt;span class="nn"&gt;[training_arguments]&lt;/span&gt;
&lt;span class="py"&gt;output_dir&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"./output"&lt;/span&gt;
&lt;span class="py"&gt;output_name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;your_character_v1&amp;gt;"&lt;/span&gt;
&lt;span class="py"&gt;learning_rate&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;tune&amp;gt;"&lt;/span&gt;
&lt;span class="py"&gt;max_train_steps&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;tune&amp;gt;"&lt;/span&gt;
&lt;span class="py"&gt;train_batch_size&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;tune&amp;gt;"&lt;/span&gt;

&lt;span class="nn"&gt;[network_arguments]&lt;/span&gt;
&lt;span class="py"&gt;network_module&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"networks.lora"&lt;/span&gt;
&lt;span class="py"&gt;network_dim&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;tune&amp;gt;"&lt;/span&gt;
&lt;span class="py"&gt;network_alpha&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"&amp;lt;tune&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The parameters that actually matter — learning rate, steps, rank, alpha, dataset size — depend on what you are training.&lt;/p&gt;

&lt;p&gt;Anime faces converge differently from realistic faces. There is no universal best setting. Run three or four variants and compare grids.&lt;/p&gt;

&lt;p&gt;Rules that worked for me:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Dataset quality matters more than size. Twenty clean diverse images beat one hundred noisy ones.&lt;/li&gt;
&lt;li&gt;Use different poses and lighting for the same face. Thirty copies of the same angle teach the model that angle, not the character.&lt;/li&gt;
&lt;li&gt;Captions should describe the scene, not the character. “girl in a garden” is better than “Anna in a garden”. You want the model to learn the face from visual context, not bind it to the word “Anna”.&lt;/li&gt;
&lt;li&gt;Tune rank separately. If rank is too low, the model underfits and the face is vague. If rank is too high, the model overfits and the face becomes stiff and resists pose or emotion changes.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3.5. IP-Adapter on top of LoRA for catalog items
&lt;/h2&gt;

&lt;p&gt;Now we have a stable Anna LoRA.&lt;/p&gt;

&lt;p&gt;The user buys a specific dress.&lt;/p&gt;

&lt;p&gt;We need both:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Anna’s face does not drift;&lt;/li&gt;
&lt;li&gt;this exact dress is recognizable.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Prompt engineering does not solve this. A prompt like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Anna wearing a red silk dress with a white collar
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;will produce some red silk dress, not the exact SKU.&lt;/p&gt;

&lt;p&gt;SKU-level accuracy requires a visual reference.&lt;/p&gt;

&lt;p&gt;The conflict:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LoRA fixes the face.&lt;/li&gt;
&lt;li&gt;IP-Adapter pulls the reference image.&lt;/li&gt;
&lt;li&gt;If IP-Adapter strength is too high, Anna starts looking like the reference.&lt;/li&gt;
&lt;li&gt;If it is too low, the dress becomes vaguely similar, not exact.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The solution is controlled by two parameters.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;weight&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;How strongly IP-Adapter affects generation. Value range: 0 to 1.&lt;/p&gt;

&lt;p&gt;Below the middle range, the reference becomes more like a mood. Above the middle range, it dominates everything.&lt;/p&gt;

&lt;p&gt;The lower half, about &lt;code&gt;0.2–0.5&lt;/code&gt;, usually gives the best face/clothing balance.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;end_at&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;At which share of generation steps IP-Adapter turns off.&lt;/p&gt;

&lt;p&gt;Stable Diffusion does not draw an image in one step. It gradually removes noise during 20–50 denoising steps.&lt;/p&gt;

&lt;p&gt;If IP-Adapter runs through all steps, it also affects final face details. If it stops at 70–90% of the way, the last steps run only under the LoRA-modified model, and the face gets pulled back toward the character.&lt;/p&gt;

&lt;p&gt;Roughly speaking:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The product gets its shape in the middle of generation, and the face is polished at the end.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Node order in ComfyUI:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[Checkpoint Loader]
  -&amp;gt; [LoRA Loader: character_lora]
  -&amp;gt; [FreeU: quality touch-up]
  -&amp;gt; [IPAdapter Advanced: reference, weight=W, end_at=E]
  -&amp;gt; [KSampler]
  -&amp;gt; [VAE Decode]
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;LoRA goes before IP-Adapter. LoRA modifies the base model weights. IP-Adapter modifies intermediate cross-attention layers during generation. When IP-Adapter stops at &lt;code&gt;end_at&lt;/code&gt;, the remaining steps run on the LoRA-modified model without IP-Adapter influence.&lt;/p&gt;

&lt;p&gt;That is what lets the face return to normal.&lt;/p&gt;

&lt;h2&gt;
  
  
  How to tune &lt;code&gt;weight&lt;/code&gt; and &lt;code&gt;end_at&lt;/code&gt; in practice
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Use a reference with a clean background and a character with an already stable LoRA.&lt;/li&gt;
&lt;li&gt;Start with &lt;code&gt;weight=0.4&lt;/code&gt;, &lt;code&gt;end_at=0.8&lt;/code&gt;. In my production pipeline, this usually gives a stable balance of face and clothing.&lt;/li&gt;
&lt;li&gt;If the face drifts, lower &lt;code&gt;weight&lt;/code&gt; or &lt;code&gt;end_at&lt;/code&gt; by &lt;code&gt;0.05&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;If the product is not close enough to the reference, raise &lt;code&gt;weight&lt;/code&gt; by &lt;code&gt;0.05&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Do not jump by &lt;code&gt;0.1&lt;/code&gt; too early. The working range is narrower than it looks.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;When you switch the base model, both numbers will probably move. Do not hardcode them forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  3.6. How this is assembled in the product
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Catalog references&lt;/strong&gt;: each visual item stores a link to its reference image in S3.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Previews are generated in advance&lt;/strong&gt;: when the user opens the shop, they see each item rendered on the active character. These previews are not generated on page load. They are generated by a background Celery task and served from S3 cache.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The same &lt;code&gt;weight&lt;/code&gt; and &lt;code&gt;end_at&lt;/code&gt; values go into the video start frame&lt;/strong&gt;: tune them on static images first, then carefully pass them into the video pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Not every product is visual&lt;/strong&gt;: some catalog items are stat bonuses, dialogue unlocks, or relationship flags. They do not have images. The catalog has an explicit &lt;code&gt;visual: true|false&lt;/code&gt; flag, and the API rejects non-visual items before they enter the GPU queue.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  3.7. Visual stack traps
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Face drifted on shop previews
&lt;/h3&gt;

&lt;p&gt;I set IP-Adapter &lt;code&gt;weight&lt;/code&gt; too high because I wanted the clothes to match better. After user complaints, I moved it back to the lower half of the range.&lt;/p&gt;

&lt;p&gt;The lesson is boring but true: tune one variable at a time, even when it feels slow.&lt;/p&gt;

&lt;h3&gt;
  
  
  Presigned reference URLs expired during the task
&lt;/h3&gt;

&lt;p&gt;The catalog in S3 was served through short-lived presigned URLs. The background task picked up the URL in the queue, but ComfyUI downloaded it later. By then, the URL was dead.&lt;/p&gt;

&lt;p&gt;The fix: the task downloads the image itself and sends a local filename to ComfyUI.&lt;/p&gt;

&lt;h3&gt;
  
  
  IP-Adapter version mismatch with SDXL
&lt;/h3&gt;

&lt;p&gt;IP-Adapter Plus ships in several files tied to specific SDXL versions. A mismatch may not crash. It can simply produce a weaker result.&lt;/p&gt;

&lt;p&gt;Pin the IP-Adapter version in deployment config together with the base model. Treat them as a pair.&lt;/p&gt;

&lt;h3&gt;
  
  
  A non-visual item crashed the image pipeline
&lt;/h3&gt;

&lt;p&gt;The API tried to run a product with no image through the image pipeline. The fix is the &lt;code&gt;visual&lt;/code&gt; flag and a boundary check before the task reaches the queue.&lt;/p&gt;




&lt;h1&gt;
  
  
  4. Unit economics in month three
&lt;/h1&gt;

&lt;h2&gt;
  
  
  Context
&lt;/h2&gt;

&lt;p&gt;The project has been live for three months. The audience is hundreds of daily users, not hundreds of thousands. Conversion to paid plans is in the single digits, which is typical for an early-stage product.&lt;/p&gt;

&lt;p&gt;The implication: free users must be cheap to serve. Otherwise, they burn the budget before you even understand whether you have found paying users.&lt;/p&gt;

&lt;p&gt;Every percent saved on the cost of a free user is extra runway.&lt;/p&gt;

&lt;p&gt;The real daily ceilings live in one config file:&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;daily_cost_alert_usd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;30.0&lt;/span&gt;      &lt;span class="c1"&gt;# sends a Telegram alert to admin, does not block
&lt;/span&gt;&lt;span class="n"&gt;daily_cost_hard_stop_usd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mf"&gt;50.0&lt;/span&gt;  &lt;span class="c1"&gt;# is_generation_allowed() returns False
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Without intervention, the monthly budget is roughly $900–$1,500. On a normal day, we spend much less: tens of dollars. Peak scenarios, such as massive image or video generation, can approach the alert level.&lt;/p&gt;

&lt;h2&gt;
  
  
  Counters and the “block on measurement failure” rule
&lt;/h2&gt;

&lt;p&gt;Counters live in Redis with a seven-day TTL:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;costs:daily:{YYYY-MM-DD}
costs:user:{uid}:{YYYY-MM-DD}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Writes use atomic &lt;code&gt;INCRBYFLOAT&lt;/code&gt;. One Redis command increments a floating-point number. No races under parallel calls.&lt;/p&gt;

&lt;p&gt;A function checks whether a new generation is allowed by reading the counter and comparing it with the ceiling.&lt;/p&gt;

&lt;p&gt;The key detail:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;If Redis is unavailable, the function returns “not allowed”.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This is a fail-closed approach.&lt;/p&gt;

&lt;p&gt;It sounds paranoid until you wake up and find that some runaway generation loop burned the daily budget overnight because the counter was silent.&lt;/p&gt;

&lt;p&gt;The rule is now strict: if we cannot measure, we block.&lt;/p&gt;

&lt;p&gt;The cost of the false negative is low: the user did not get one response.&lt;/p&gt;

&lt;p&gt;The cost of the false positive is high: an uncontrolled loop can burn money at $1.50 per million output tokens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the money goes
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Cost item&lt;/th&gt;
&lt;th&gt;Share&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Gemini 3.1 FL for VIP / Elite&lt;/td&gt;
&lt;td&gt;40–50%&lt;/td&gt;
&lt;td&gt;Expensive output at $1.50/1M tokens, partially offset by caching&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Qwen3-235B for Free / Basic / Premium&lt;/td&gt;
&lt;td&gt;15–20%&lt;/td&gt;
&lt;td&gt;Cheap, but high request volume&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Fallback LLMs: Grok / MiniMax&lt;/td&gt;
&lt;td&gt;&amp;lt;5%&lt;/td&gt;
&lt;td&gt;Trigger only when primary model refuses or fails&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Voice: Inworld TTS-1.5 Max&lt;/td&gt;
&lt;td&gt;15–20%&lt;/td&gt;
&lt;td&gt;$10 per million characters, roughly $0.005–$0.01 per voice message&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Images: Vast.ai GPU + fallback&lt;/td&gt;
&lt;td&gt;15–25%&lt;/td&gt;
&lt;td&gt;Fixed monthly GPU fee, fallback around $0.03–$0.07 per image&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Video: WaveSpeed + fal Pixverse&lt;/td&gt;
&lt;td&gt;spikes&lt;/td&gt;
&lt;td&gt;$0.16–$0.25 per clip, available from Premium plans&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What prompt caching saves
&lt;/h2&gt;

&lt;p&gt;A rough estimate for one VIP reply.&lt;/p&gt;

&lt;p&gt;Without cache:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;system prompt ~3,800 tokens × $0.25/1M = $0.00095 input
output ~300 tokens × $1.50/1M = $0.00045
total ≈ $0.0014 per reply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With cache hit on the system prompt after the first message in a session:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;system prompt ~3,800 tokens × $0.0625/1M = $0.000238
output ~300 tokens × $1.50/1M = $0.00045
total ≈ $0.00069 per reply
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That is about a 50% saving on replies after the first one.&lt;/p&gt;

&lt;p&gt;Almost every active session is mostly “reply 2+”, so this has a real budget impact. In my mix, caching covers about a quarter of the total LLM budget.&lt;/p&gt;

&lt;p&gt;DeepSeek V4 adds another 10–15% saving on &lt;code&gt;instant&lt;/code&gt; pace replies through implicit caching. Qwen3 does not participate in OpenRouter prompt caching.&lt;/p&gt;




&lt;h1&gt;
  
  
  5. Production tuning in month three
&lt;/h1&gt;

&lt;p&gt;Architecture alone does not move the DAU ceiling if the operational layer is falling apart.&lt;/p&gt;

&lt;p&gt;You also need to tune memory limits, timeouts, worker restarts, and daily cost ceilings. This part rarely appears in “how to build a chatbot” tutorials, but it directly determines whether you hit the wall at 500 daily users or 1,500.&lt;/p&gt;

&lt;p&gt;Below are four concrete changes I made in the last few weeks, with before/after numbers.&lt;/p&gt;

&lt;h2&gt;
  
  
  5.1. ChromaDB: LRU eviction and higher memory limit
&lt;/h2&gt;

&lt;p&gt;This problem appeared gradually.&lt;/p&gt;

&lt;p&gt;First, the ChromaDB container started eating 2 GB. I raised it to 4 GB. Two weeks later, it hit OOMKill again, so I raised it to 6 GB. After another week, it looked like I was simply paying for the database’s growing appetite.&lt;/p&gt;

&lt;p&gt;The culprit matched open ChromaDB issues &lt;code&gt;#3336&lt;/code&gt; and &lt;code&gt;#5843&lt;/code&gt; in the 0.5.x branch.&lt;/p&gt;

&lt;p&gt;The internal segment cache keeps loaded segments forever and does not evict them automatically, even when a collection has not been used for a long time.&lt;/p&gt;

&lt;p&gt;I had one collection per &lt;code&gt;(user × character × session)&lt;/code&gt; pair: 2,233 collections. Each gradually pulled its index into memory and never let it go.&lt;/p&gt;

&lt;p&gt;Container memory grew steadily: about 250 MB per week.&lt;/p&gt;

&lt;p&gt;The fix was in the docs: enable LRU eviction.&lt;/p&gt;

&lt;p&gt;LRU means Least Recently Used. Old unused collections are evicted first; active ones stay in memory.&lt;/p&gt;

&lt;p&gt;Config:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;chromadb&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;environment&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;CHROMA_SEGMENT_CACHE_POLICY&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;LRU&lt;/span&gt;
    &lt;span class="na"&gt;CHROMA_MEMORY_LIMIT_BYTES&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;8589934592"&lt;/span&gt;  &lt;span class="c1"&gt;# 8 GB, LRU triggers above this&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;resources&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;limits&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;memory&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;10G&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After that, the active in-memory dataset collapsed to 50–200 MB: mostly sessions from the last five minutes. Cold collections are evicted automatically. Linear memory growth stopped.&lt;/p&gt;

&lt;p&gt;Without LRU, memory death would arrive in six to eight weeks. Bonus risk: if ChromaDB crashes during a write, embeddings from the latest session can be silently lost.&lt;/p&gt;

&lt;p&gt;I added another 2 GB over the LRU limit as a buffer for backups. A 3.3 GB tar snapshot competed for IO with ChromaDB, and Sentry showed intermittent backup failures I could not reproduce manually. After raising the limit, the problem disappeared.&lt;/p&gt;

&lt;p&gt;The architectural limitation remains:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;2,233 collections are a consequence of my own memory architecture.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;At 5,000 DAU, this can become tens of thousands of collections. LRU will start evicting too aggressively, and latency will rise due to “evict-load-evict-load” behavior.&lt;/p&gt;

&lt;p&gt;At that point, I will need to migrate to one shared collection with &lt;code&gt;session_id&lt;/code&gt; filtering in metadata. That is a couple of weeks of refactoring. I postponed it until it becomes necessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  5.2. Restarting uvicorn workers by request count
&lt;/h2&gt;

&lt;p&gt;Before:&lt;/p&gt;

&lt;p&gt;A FastAPI/uvicorn worker leaked memory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;480 MB at startup -&amp;gt; ~800 MB after 8 hours
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;With four workers and a 1.5 GB per-worker budget, that becomes about 6 GB, which is the container limit. Estimated memory death: around 500 DAU.&lt;/p&gt;

&lt;p&gt;I added:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;&amp;gt;&lt;/span&gt;
    &lt;span class="s"&gt;uvicorn api.main:app&lt;/span&gt;
    &lt;span class="s"&gt;--workers 4&lt;/span&gt;
    &lt;span class="s"&gt;--limit-max-requests 5000&lt;/span&gt;
    &lt;span class="s"&gt;--timeout-graceful-shutdown 90&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;--limit-max-requests 5000&lt;/code&gt; restarts a worker after it handles 5,000 requests. The leak does not have time to accumulate.&lt;/p&gt;

&lt;p&gt;After:&lt;/p&gt;

&lt;p&gt;At 300 DAU and about 30k requests per day, each worker restarts one or two times per day. Memory returns to about 480 MB. Four workers use around 1.9 GB, leaving a 3x safety margin inside the 6 GB limit.&lt;/p&gt;

&lt;p&gt;The API memory ceiling moved from about 500 DAU to about 1,200 DAU.&lt;/p&gt;

&lt;h2&gt;
  
  
  5.3. Graceful shutdown: 90 seconds for LLM requests to finish
&lt;/h2&gt;

&lt;p&gt;The previous fix created a new problem.&lt;/p&gt;

&lt;p&gt;Workers now restart regularly. If a worker restarts while an LLM request is in progress, it can interrupt a 10–60 second request. The user sees “Network error”, HTTP 502, or an empty answer.&lt;/p&gt;

&lt;p&gt;On paper:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;6 restarts per day × ~3 in-flight requests = ~18 visible errors/day
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that was caused by my own optimization.&lt;/p&gt;

&lt;p&gt;The fix is to give the worker 90 seconds to finish active requests.&lt;/p&gt;

&lt;p&gt;Important: this must be set in two layers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;api&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;uvicorn ... --timeout-graceful-shutdown &lt;/span&gt;&lt;span class="m"&gt;90&lt;/span&gt;
  &lt;span class="na"&gt;stop_grace_period&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;90s&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The p99 of an LLM request is around 60 seconds, so 90 seconds gives a 50% buffer.&lt;/p&gt;

&lt;p&gt;After that, user-visible restart errors dropped to zero.&lt;/p&gt;

&lt;p&gt;A common mistake: setting only the uvicorn option is not enough. Docker still kills the container after its own default timeout, around 10 seconds. You need both settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  5.4. Daily cost ceiling: $30 → $50
&lt;/h2&gt;

&lt;p&gt;One day in May, daily spend jumped to $21.70. That was too close to the old $30 hard stop.&lt;/p&gt;

&lt;p&gt;If the same spend happened in the first half of the day, we would hit the hard stop in the evening and users would get “generation unavailable” instead of replies.&lt;/p&gt;

&lt;p&gt;I raised the hard stop to $50 and kept the alert at $30.&lt;/p&gt;

&lt;p&gt;The alert at 60% of the ceiling gives five to six hours to notice and react before blocking.&lt;/p&gt;

&lt;p&gt;At the current user profile, about $0.06 per DAU per day, the $50 hard stop should not fire until roughly 800 DAU. After that, we need either better paid conversion, upsells, or a higher ceiling with updated economics.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary: where the ceilings moved
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Bottleneck&lt;/th&gt;
&lt;th&gt;Before&lt;/th&gt;
&lt;th&gt;After&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;API memory / OOM risk&lt;/td&gt;
&lt;td&gt;~500 DAU&lt;/td&gt;
&lt;td&gt;~1,200 DAU&lt;/td&gt;
&lt;td&gt;+140%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;ChromaDB memory&lt;/td&gt;
&lt;td&gt;~800 DAU&lt;/td&gt;
&lt;td&gt;~2,000+ DAU&lt;/td&gt;
&lt;td&gt;+150%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Daily hard cost stop&lt;/td&gt;
&lt;td&gt;~480 DAU&lt;/td&gt;
&lt;td&gt;~830 DAU&lt;/td&gt;
&lt;td&gt;+73%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;502 on worker restart&lt;/td&gt;
&lt;td&gt;~18/day&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;fixed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;IO competition during backup&lt;/td&gt;
&lt;td&gt;intermittent failures&lt;/td&gt;
&lt;td&gt;fixed&lt;/td&gt;
&lt;td&gt;fixed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Cost of changes:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;+4 GB host memory for ChromaDB.&lt;/li&gt;
&lt;li&gt;No meaningful CPU overhead from LRU.&lt;/li&gt;
&lt;li&gt;About 0.5 seconds of downtime during container recreation.&lt;/li&gt;
&lt;li&gt;Low risk: all settings are from official ChromaDB and uvicorn docs, no hacks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Result:&lt;/p&gt;

&lt;p&gt;This tuning bought roughly 6–12 months without touching the ChromaDB architecture or hardware.&lt;/p&gt;

&lt;p&gt;The next bottlenecks are Vast.ai GPU as a single point of failure and the single 32 GB host that runs everything. If the machine dies, the product dies. After 1,500 DAU, this becomes critical.&lt;/p&gt;




&lt;h1&gt;
  
  
  6. What I would rebuild differently today
&lt;/h1&gt;

&lt;p&gt;If I could rewind three months and rebuild this with everything I know now, here is what I would change.&lt;/p&gt;

&lt;h2&gt;
  
  
  Memory
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I would not use &lt;code&gt;pgvector&lt;/code&gt; for this exact workload. On short queries over summaries, retrieval quality was worse than ChromaDB. For other workloads, &lt;code&gt;pgvector&lt;/code&gt; may win.&lt;/li&gt;
&lt;li&gt;I would not vectorize every message. The index grows, but search quality does not.&lt;/li&gt;
&lt;li&gt;I would summarize fixed windows by number of messages, not by time. A daily summary is useless for a user who sends 500 messages in one day.&lt;/li&gt;
&lt;li&gt;I would add background-task cancellation and &lt;code&gt;metadata.type&lt;/code&gt; in ChromaDB documents from day one.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  LLM routing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I would route by relationship pace from the first day.&lt;/li&gt;
&lt;li&gt;I would also allow response style to override the model when needed.&lt;/li&gt;
&lt;li&gt;I would add &lt;code&gt;cache_control&lt;/code&gt; on Gemini immediately. I lost about one and a half months of unnecessary spend.&lt;/li&gt;
&lt;li&gt;I would create a separate metric for silent model refusals: HTTP 200 with an empty body. It is rare, but without a metric you will not see it.&lt;/li&gt;
&lt;li&gt;I would not use the same OpenRouter key in dev and prod. The rate limit is shared, and development noise eats production quota.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Images
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I would launch the image pipeline with LoRA from day one, even with only three characters. Inconsistent images on the free tier kill the first impression before the user reaches the strong parts of the product.&lt;/li&gt;
&lt;li&gt;I would build datasets manually instead of scraping. Five iterations of 20 hand-picked images beat a noisy scrape of 200.&lt;/li&gt;
&lt;li&gt;I would version LoRAs: &lt;code&gt;char_v1&lt;/code&gt;, &lt;code&gt;char_v2&lt;/code&gt;. They should live in parallel, so a regression can be rolled back for one character without rolling back the whole pipeline.&lt;/li&gt;
&lt;li&gt;I would store IP-Adapter settings such as &lt;code&gt;weight&lt;/code&gt; and &lt;code&gt;end_at&lt;/code&gt; as deployment parameters, not code constants. When the base model changes, those values move.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Unit economics and production tuning
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;I would add &lt;code&gt;cache_control&lt;/code&gt; on day one. It is a one-function helper, but I lived without it for a month and a half.&lt;/li&gt;
&lt;li&gt;I would unify cost counters across all generation types. Right now, a mapping dictionary has to be updated every time a new generation type appears.&lt;/li&gt;
&lt;li&gt;I would add a daily per-user cost ceiling alongside the global hard stop. Currently, one very active user could theoretically consume a large share of the daily budget before rate limiting catches up.&lt;/li&gt;
&lt;li&gt;I would build a dashboard for cache hit rate. OpenRouter returns &lt;code&gt;prompt_tokens_details.cached_tokens&lt;/code&gt; in each response, but without aggregation you will not notice when caching breaks because a prompt format changed.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Still open bottlenecks
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Vast.ai GPU is a single point of failure. The solution is a second hot-standby GPU. It starts paying for itself around 1,500+ DAU.&lt;/li&gt;
&lt;li&gt;One 32 GB host runs the whole product. If the machine dies, the product dies. At our current scale, this is acceptable. Later, it is not.&lt;/li&gt;
&lt;li&gt;2,233 ChromaDB collections are an architectural limitation. LRU hides the issue but does not solve it.&lt;/li&gt;
&lt;/ul&gt;




&lt;h1&gt;
  
  
  Where this runs in production
&lt;/h1&gt;

&lt;p&gt;Everything above runs in one backend behind HoneyChat: an AI companion product available both as a Telegram bot and as a web app.&lt;/p&gt;

&lt;p&gt;The same chat, memory, characters, and LoRA pipeline are available from both Telegram and the browser, with history synchronized between them.&lt;/p&gt;

&lt;p&gt;If you want to try the architecture described in this article:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Telegram: &lt;code&gt;@HoneyChatAIBot&lt;/code&gt;, run &lt;code&gt;/start&lt;/code&gt;. The free tier gives 20 messages per day without registration.&lt;/li&gt;
&lt;li&gt;Web: &lt;code&gt;honeychat.bot&lt;/code&gt;. Same backend, full chat interface, images, and voice.&lt;/li&gt;
&lt;li&gt;Code examples: public tutorial folders with runnable examples for each engineering part are available at &lt;code&gt;github.com/sm1ck/honeychat/tree/main/tutorial&lt;/code&gt;. They can be cloned and started with Docker Compose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you are building something similar and hit one of the same walls, I would be interested in hearing about it, especially around:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;race conditions caused by user actions such as clear history or switch character;&lt;/li&gt;
&lt;li&gt;tuning &lt;code&gt;weight&lt;/code&gt; and &lt;code&gt;end_at&lt;/code&gt; on newer SDXL forks;&lt;/li&gt;
&lt;li&gt;memory architectures for long-lived AI companions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There is surprisingly little public material about this outside of anime-generation communities.&lt;/p&gt;




&lt;h1&gt;
  
  
  Sources and related docs
&lt;/h1&gt;

&lt;p&gt;Memory:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;ChromaDB docs&lt;/li&gt;
&lt;li&gt;ChromaDB issue &lt;code&gt;#3336&lt;/code&gt;: segment cache growth&lt;/li&gt;
&lt;li&gt;ChromaDB issue &lt;code&gt;#5843&lt;/code&gt;: LRU eviction&lt;/li&gt;
&lt;li&gt;Redis &lt;code&gt;LTRIM&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;LLM:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;OpenRouter model list&lt;/li&gt;
&lt;li&gt;OpenRouter prompt caching docs&lt;/li&gt;
&lt;li&gt;Chat Completions &lt;code&gt;finish_reason&lt;/code&gt; semantics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Visual stack:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;LoRA paper, Hu et al., 2021&lt;/li&gt;
&lt;li&gt;Kohya_ss SDXL training&lt;/li&gt;
&lt;li&gt;IP-Adapter by Tencent AI Lab&lt;/li&gt;
&lt;li&gt;ComfyUI IPAdapter Plus&lt;/li&gt;
&lt;li&gt;SDXL base model&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Infrastructure:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;uvicorn deployment docs&lt;/li&gt;
&lt;li&gt;Docker Compose &lt;code&gt;stop_grace_period&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>productivity</category>
      <category>devops</category>
    </item>
    <item>
      <title>AI Chatbot Memory Architecture in 2026 — RAG, Long Context, and Hybrid Approaches Compared</title>
      <dc:creator>David</dc:creator>
      <pubDate>Mon, 08 Jun 2026 06:53:33 +0000</pubDate>
      <link>https://dev.to/david_chejo/ai-chatbot-memory-architecture-in-2026-rag-long-context-and-hybrid-approaches-compared-g47</link>
      <guid>https://dev.to/david_chejo/ai-chatbot-memory-architecture-in-2026-rag-long-context-and-hybrid-approaches-compared-g47</guid>
      <description>&lt;p&gt;Building a &lt;a href="https://t.me/HoneyChatAIBot" rel="noopener noreferrer"&gt;chatbot&lt;/a&gt; that "remembers" conversations is one of the most misunderstood problems in production AI systems. &lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F387fq3q93zv2utdstnvs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F387fq3q93zv2utdstnvs.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;br&gt;
Marketing copy at every consumer chat product claims "extended memory" or "persistent memory," but the underlying architecture varies wildly. The implementation choice determines whether your bot genuinely recalls last week's conversation or just has a slightly larger context window.&lt;br&gt;
This is a technical breakdown of the three memory architectures used in production AI chatbots as of 2026, with tradeoffs, when to use each, and what consumer apps actually implement under the hood.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwvh609o08dko2yup1w8h.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fwvh609o08dko2yup1w8h.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The four memory approaches you'll see in production&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;The "AI memory" landscape splits into four approaches, each with different infrastructure cost, latency, and recall fidelity:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pure context window&lt;/strong&gt; — feed the model the last N tokens of conversation, nothing more. This is what most "no memory" products do, often dressed up as "extended memory."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Vector-based RAG&lt;/strong&gt; — store conversation chunks in a vector database, retrieve semantically relevant chunks at query time, insert them into the prompt.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Structured fact extraction&lt;/strong&gt; — parse conversations into discrete facts (name, preferences, events), store as structured data, inject at query time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Hybrid&lt;/strong&gt; — combine vector RAG for "fuzzy" recall, structured facts for "hard" details, and recent context for continuity.
Most consumer chat products use approach #1 (pure context window) and call it memory. Approach #4 is what you actually want for real cross-session recall but requires the most infrastructure.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Pure context window — the cheap default&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;This is what Character.AI's "extended memory" feature actually is. The model sees:&lt;/p&gt;

&lt;p&gt;_&amp;gt; [system prompt with character definition]&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;[last N messages from current session]&lt;br&gt;
[optional: up to 15 pinned messages]&lt;br&gt;
[user's new message]_&lt;br&gt;
That's it. There's no database of past conversations. When you start a new session, the model has zero context from previous sessions. The "memory" is purely the in-session conversation history.&lt;br&gt;
Pros:&lt;br&gt;
• Trivial implementation (just send recent messages to the model)&lt;br&gt;
• Zero infrastructure beyond your LLM API&lt;br&gt;
• No retrieval latency&lt;br&gt;
Cons:&lt;br&gt;
• No actual cross-session memory&lt;br&gt;
• Hard cap on conversation length (model context window)&lt;br&gt;
• Older messages from current session get truncated as window fills&lt;br&gt;
Consumer products using this: Character.AI (all tiers), Chai (all tiers), most ChatGPT wrapper apps, Telegram bots without backend storage.&lt;br&gt;
When to use it: MVP prototypes, single-session use cases, or products where forgetting is feature (e.g., privacy-focused ephemeral chat).&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Vector-based RAG — the standard "real memory" approach&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Vector RAG is the most common approach for products that genuinely persist memory across sessions. Implementation pattern:&lt;/p&gt;

&lt;p&gt;_&amp;gt; # Storage path: every user message + bot response is chunked and embedded&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;async def store_turn(user_id, role, text):&lt;br&gt;
    chunks = chunk_text(text, max_tokens=200)&lt;br&gt;
    for chunk in chunks:&lt;br&gt;
        embedding = await embed(chunk)&lt;br&gt;
        vector_db.upsert(&lt;br&gt;
            id=f"{user_id}&lt;em&gt;{role}&lt;/em&gt;{timestamp}",&lt;br&gt;
            vector=embedding,&lt;br&gt;
            metadata={"user_id": user_id, "role": role, "text": chunk, "ts": now()}&lt;br&gt;
        )_&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;_&amp;gt; # Retrieval path: query vector DB for relevant context, inject into prompt&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;async def build_prompt(user_id, query):&lt;br&gt;
    query_vec = await embed(query)&lt;br&gt;
    relevant = vector_db.query(query_vec, top_k=10, filter={"user_id": user_id})&lt;br&gt;
    context = "\n".join([r.metadata["text"] for r in relevant])&lt;br&gt;
    return f"Relevant past conversations:\n{context}\n\nCurrent query: {query}"_&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The vector database choice matters significantly:&lt;/p&gt;

&lt;p&gt;• &lt;strong&gt;Pinecone&lt;/strong&gt; — managed, easy to start, gets expensive at scale (~$70/mo per pod minimum). Good for teams that don't want infrastructure overhead.&lt;br&gt;
• &lt;strong&gt;Weaviate&lt;/strong&gt; — open source, self-host or managed. Solid choice for production with custom requirements.&lt;br&gt;
• &lt;strong&gt;ChromaDB&lt;/strong&gt; — embedded or server mode. Great for prototyping and single-server deployments. Less suitable for horizontal scaling.&lt;br&gt;
• &lt;strong&gt;Qdrant&lt;/strong&gt; — Rust-based, excellent performance, good for high-throughput. Active development.&lt;br&gt;
• pgvector — Postgres extension. If you already have Postgres and don't need massive scale, this is often the simplest path.&lt;/p&gt;

&lt;p&gt;Pros:&lt;br&gt;
• Semantically relevant recall — bot finds "what's similar to what we're discussing now"&lt;br&gt;
• Scales to millions of conversations per user&lt;br&gt;
• Works across sessions, weeks, months&lt;/p&gt;

&lt;p&gt;Cons:&lt;br&gt;
• Retrieval latency (typically 50-200ms before LLM call)&lt;br&gt;
• Vector DB cost grows linearly with data&lt;br&gt;
• Quality depends heavily on embedding model and chunk strategy&lt;br&gt;
• Cold-start: requires N+ conversations before recall feels "real"&lt;/p&gt;

&lt;p&gt;Consumer products using this: HoneyChat (ChromaDB), several "AI friend" apps built in 2024-2025.&lt;br&gt;
When to use it: Cross-session memory is core to product value. Users expect bot to remember names, preferences, and relationship history.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Structured fact extraction — for "hard" memory&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Vector RAG is great for fuzzy recall ("we talked about your trip to Japan") but bad at structured facts ("user's name is Alex, prefers tea, has a cat named Mochi"). For these, an additional layer parses conversations into structured data.&lt;br&gt;
Implementation pattern:&lt;/p&gt;

&lt;p&gt;_&amp;gt; async def extract_facts(user_id, turn_text):&lt;/p&gt;

&lt;blockquote&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;# Use a smaller, fast model for extraction
response = await llm.complete(
    model="claude-haiku-or-similar",
    prompt=f"Extract facts about the user from this message as JSON: {turn_text}",
    schema={"facts": [{"category": "string", "value": "string", "confidence": "float"}]}
)
for fact in response["facts"]:
    if fact["confidence"] &amp;gt; 0.7:
        facts_db.upsert(user_id, fact["category"], fact["value"])
&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;async def build_prompt(user_id, query):&lt;br&gt;
    facts = facts_db.list(user_id)  # all known facts&lt;br&gt;
    facts_str = "\n".join([f"{f.category}: {f.value}" for f in facts])&lt;br&gt;
    vector_context = await vector_db.query(...)  # RAG for fuzzy recall&lt;br&gt;
    return f"What we know:\n{facts_str}\n\nRelevant past:\n{vector_context}\n\nQuery: {query}"_&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Pros:&lt;br&gt;
• Bot reliably knows hard facts (name, age, preferences) — no embedding similarity gymnastics&lt;br&gt;
• Cheap to query at runtime (key-value lookup)&lt;br&gt;
• Can be edited/corrected by user explicitly&lt;/p&gt;

&lt;p&gt;Cons:&lt;br&gt;
• Extraction step adds cost and latency (typically 100-300ms per turn)&lt;br&gt;
• Extraction quality depends on extraction model&lt;br&gt;
• Schema design is important — too rigid loses nuance, too loose duplicates facts&lt;/p&gt;

&lt;p&gt;Consumer products using this: Nomi AI (structured facts is core to their architecture), HoneyChat (in addition to vector RAG), some enterprise customer service bots.&lt;br&gt;
When to use it: Hard facts matter. User explicitly says "remember that I prefer tea" and expects this to persist. Common in companion apps and personal assistants.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Hybrid: the production-grade pattern&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Real production systems combine all three approaches:&lt;/p&gt;

&lt;p&gt;_&amp;gt; Memory layers (highest fidelity to lowest):&lt;/p&gt;

&lt;blockquote&gt;
&lt;ol&gt;
&lt;li&gt;Structured facts (key-value, "user_name=Alex, prefers=tea")&lt;/li&gt;
&lt;li&gt;Recent conversation buffer (last N=20-50 messages, in-memory or Redis)&lt;/li&gt;
&lt;li&gt;Vector RAG (semantic search over all conversation history)&lt;/li&gt;
&lt;li&gt;Optional: episodic summaries (LLM-generated summaries of past sessions)
At query time:
async def build_context(user_id, query):
facts = await facts_db.get_all(user_id)         # 1ms lookup
recent = await redis.get_recent(user_id, n=20)  # 5ms lookup
relevant = await vector_db.query(query, user_id, top_k=5)  # 50-100ms
return f"""
Facts about user: {facts}
Recent conversation: {recent}
Relevant past context: {relevant}
Current query: {query}
"""_&lt;/li&gt;
&lt;/ol&gt;
&lt;/blockquote&gt;

&lt;p&gt;This hybrid is what serious production AI companion products use. It's expensive in infrastructure (Redis + vector DB + facts DB + extraction model) but delivers the experience users describe as "the bot really knows me."&lt;br&gt;
Latency budget for hybrid approach typically lands around 200-400ms before the main LLM call. With a streaming response from a fast model like Claude Haiku, total time-to-first-token stays under 1 second — acceptable for chat UX.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq8ypzctnst1w6cimfl8i.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq8ypzctnst1w6cimfl8i.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Memory architecture decisions in the wild&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Based on observation of leading platforms in 2026:&lt;br&gt;
• &lt;a href="https://honeychat.bot/en/blog/character-ai-not-working/" rel="noopener noreferrer"&gt;Character.AI&lt;/a&gt;: pure context window. No cross-session memory architecture. Pinned messages (up to 15) are the only persistence layer. Premium tier extends context window size but doesn't add memory layers.&lt;br&gt;
• &lt;a href="https://honeychat.bot/en/blog/chai-nsfw-truth-allowed-content-2026/" rel="noopener noreferrer"&gt;Chai&lt;/a&gt;: pure context window with very short active dialog memory (2-3 messages in active context per community reports). Claims a "Persisted Memory" feature on PRO that appears to be a limited structured-facts layer storing basic profile data between sessions but not extending active context.&lt;br&gt;
• &lt;a href="https://replika.com/" rel="noopener noreferrer"&gt;Replika&lt;/a&gt;: hybrid — structured facts (the "Diary" feature is essentially curated structured memory) plus vector RAG plus recent buffer. By far the strongest memory architecture in the consumer category, which is why it remains relevant despite the 2023 ERP debacle.&lt;br&gt;
• &lt;a href="https://nomi.ai/" rel="noopener noreferrer"&gt;Nomi AI&lt;/a&gt;: structured-facts heavy with vector RAG augmentation. Their "structured facts" branding accurately describes their architecture.&lt;br&gt;
• &lt;a href="https://honeychat.bot" rel="noopener noreferrer"&gt;HoneyChat&lt;/a&gt;: full hybrid — ChromaDB vector RAG + structured facts per character session + Redis recent buffer + optional episodic summaries for long histories.&lt;br&gt;
• &lt;a href="https://janitorai.ai/" rel="noopener noreferrer"&gt;JanitorAI&lt;/a&gt;: depends entirely on which OpenRouter model you choose. The platform itself has minimal memory layer — most "memory" is in the system prompt the user maintains manually.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;When pure context window is enough&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Not every product needs hybrid memory. Use the simplest architecture that works:&lt;br&gt;
• Single-session productivity tools (writing assistant, code helper): pure context window&lt;br&gt;
• Short-form Q&amp;amp;A bots (FAQ, customer service triage): pure context window&lt;br&gt;
• Companion or relationship-focused apps: hybrid required for credibility&lt;br&gt;
• Long-form roleplay platforms: at least vector RAG, hybrid for premium tier&lt;br&gt;
• Enterprise knowledge management: vector RAG over knowledge base, not user history&lt;br&gt;
The memory architecture should match user expectations. Promising "extended memory" with only a larger context window is a marketing claim that doesn't survive contact with users who actually test cross-session recall.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The cost reality&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Memory architectures cost real money:&lt;/p&gt;

&lt;blockquote&gt;
&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Approach&lt;/th&gt;
&lt;th&gt;Storage cost&lt;/th&gt;
&lt;th&gt;Per-query cost&lt;/th&gt;
&lt;th&gt;Infrastructure complexity&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pure context window&lt;/td&gt;
&lt;td&gt;$0&lt;/td&gt;
&lt;td&gt;$0 extra&lt;/td&gt;
&lt;td&gt;Trivial&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Vector RAG&lt;/td&gt;
&lt;td&gt;$0.05-0.30 per user/month (depending on DB choice)&lt;/td&gt;
&lt;td&gt;+50-200ms latency, +embedding cost&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Structured facts&lt;/td&gt;
&lt;td&gt;&amp;lt;$0.01 per user/month&lt;/td&gt;
&lt;td&gt;+extraction LLM cost (~$0.001 per turn)&lt;/td&gt;
&lt;td&gt;Moderate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hybrid&lt;/td&gt;
&lt;td&gt;Sum of above&lt;/td&gt;
&lt;td&gt;Sum of above&lt;/td&gt;
&lt;td&gt;High&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;
&lt;/blockquote&gt;

&lt;p&gt;For a 100K MAU consumer app, hybrid memory infrastructure runs $5-15K/month in storage + compute. This is real budget that has to come out of subscription revenue.&lt;br&gt;
The 2023-2026 consumer apps that promise "real memory" at $5-10/month subscription pricing are either:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Subsidizing memory infrastructure with VC funding (most common)&lt;/li&gt;
&lt;li&gt;Quietly degrading memory architecture as user base scales (Replika did this 2022-23)&lt;/li&gt;
&lt;li&gt;Marketing context-window expansion as "memory" (Character.AI, Chai)
There are exceptions — products with genuinely engineered persistent memory at sustainable unit economics. They tend to be either narrow vertical apps (Nomi text-only) or built on cost-efficient infrastructure (HoneyChat's ChromaDB self-hosted approach).&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Recommendations for builders&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If you're shipping an AI chat product in 2026:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Be honest about what your memory does&lt;/strong&gt;. If it's a context window, don't call it "extended memory." Users will test it and figure out the truth within a week.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pick architecture based on use case, not aspiration&lt;/strong&gt;. Pure context window is fine for productivity tools. Hybrid is required for companion apps if you want to compete on retention.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget for memory infrastructure.&lt;/strong&gt; It's not optional if "memory" is a marketed feature.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test cross-session recall with real users&lt;/strong&gt;. Internal QA usually tests within a single session. Real users notice broken cross-session memory within days.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan for graceful degradation as scale grows&lt;/strong&gt;. Memory architecture that works at 1K users may not work at 100K. Build with horizontal scaling in mind from day one.
The best AI chat products in 2026 win on memory architecture as much as model quality. Users tolerate slightly weaker LLM responses if the bot genuinely remembers them. They abandon stronger LLMs that feel anonymous.&lt;/li&gt;
&lt;/ol&gt;

</description>
      <category>ai</category>
      <category>programming</category>
      <category>productivity</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Why Context Window Is Not Enough for AI Character Memory</title>
      <dc:creator>David</dc:creator>
      <pubDate>Sun, 31 May 2026 08:01:04 +0000</pubDate>
      <link>https://dev.to/david_chejo/why-context-window-is-not-enough-for-ai-character-memory-54ch</link>
      <guid>https://dev.to/david_chejo/why-context-window-is-not-enough-for-ai-character-memory-54ch</guid>
      <description>&lt;p&gt;When I started building &lt;a href="https://honeychat.bot/en/" rel="noopener noreferrer"&gt;AI characters&lt;/a&gt;, I thought memory was mostly a context-length problem.&lt;/p&gt;

&lt;p&gt;If the model could see more previous messages, the character would remember more.&lt;br&gt;
If the context window was larger, the conversation would feel more continuous.&lt;br&gt;
If we could fit enough history into the prompt, the problem would be solved.&lt;/p&gt;

&lt;p&gt;That assumption was wrong.&lt;/p&gt;

&lt;p&gt;A larger context window helps, but it does not create real memory.&lt;/p&gt;

&lt;p&gt;For AI character products, users do not only want the model to see more tokens. They want the character to feel like the same character tomorrow.&lt;/p&gt;

&lt;p&gt;They want continuity.&lt;/p&gt;

&lt;p&gt;They want the character to remember the tone of the relationship, the current roleplay world, the user’s preferences, the previous emotional state, and the small details that make the conversation feel personal.&lt;/p&gt;

&lt;p&gt;That is not the same as dumping chat history into a prompt.&lt;/p&gt;

&lt;p&gt;A context window gives the model temporary visibility.&lt;/p&gt;

&lt;p&gt;Memory gives the product persistent relevance.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;The quick version&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A context window helps an AI character stay coherent inside the current conversation.&lt;/p&gt;

&lt;p&gt;Long-term memory helps the character preserve useful information across sessions.&lt;/p&gt;

&lt;p&gt;A practical memory system for AI characters usually needs several layers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;session context;&lt;br&gt;
user profile memory;&lt;br&gt;
character state;&lt;br&gt;
relationship state;&lt;br&gt;
semantic retrieval;&lt;br&gt;
summary memory;&lt;br&gt;
safety and privacy filters.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The hard part is not storing everything.&lt;/p&gt;

&lt;p&gt;The hard part is deciding what should be remembered, retrieved, updated, ignored, or forgotten.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4l8pnx8z8z2wy53on6f5.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4l8pnx8z8z2wy53on6f5.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Context window vs memory&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A context window is the amount of information the model can see at generation time.&lt;/p&gt;

&lt;p&gt;Memory is a product-level system that decides which information should survive beyond the current prompt.&lt;/p&gt;

&lt;p&gt;They are related, but they are not the same thing.&lt;/p&gt;

&lt;p&gt;You can have a huge context window and still have bad memory.&lt;/p&gt;

&lt;p&gt;You can also have a smaller context window and still create a good memory experience if you retrieve the right information at the right moment.&lt;/p&gt;

&lt;p&gt;Here is the difference:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Context window:&lt;br&gt;
"What can the model see right now?"&lt;br&gt;
Memory:&lt;br&gt;
"What should the product preserve and reuse later?"&lt;br&gt;
For a simple chatbot, a larger context window may be enough.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;For an AI character, it usually is not.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Why dumping history into the prompt fails&lt;/strong&gt;
&lt;/h2&gt;

&lt;blockquote&gt;
&lt;p&gt;The naive approach looks like this:&lt;br&gt;
Take the full chat history&lt;br&gt;
↓&lt;br&gt;
Append it to the prompt&lt;br&gt;
↓&lt;br&gt;
Ask the model to continue&lt;br&gt;
This works for short conversations.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Then it starts to break.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;1. It becomes expensive&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Long prompts cost more.&lt;/p&gt;

&lt;p&gt;They also increase latency, which matters a lot in conversational products. If every reply becomes slower because the product keeps inserting more and more history, the experience starts to feel heavy.&lt;/p&gt;

&lt;p&gt;For AI companions and character chats, response speed is part of the emotional experience.&lt;/p&gt;

&lt;p&gt;A delayed answer can break the rhythm.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;2. It becomes noisy&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;More context is not always better context.&lt;/p&gt;

&lt;p&gt;If the prompt contains too many old messages, the model may focus on irrelevant details.&lt;/p&gt;

&lt;p&gt;The user mentioned a random movie once three weeks ago.&lt;br&gt;
The model suddenly brings it up at the wrong moment.&lt;br&gt;
The user feels watched, not understood.&lt;/p&gt;

&lt;p&gt;Bad memory can be worse than no memory.&lt;/p&gt;

&lt;p&gt;Good memory is selective.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;3. It does not rank importance&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Raw chat history does not tell the model what matters.&lt;/p&gt;

&lt;p&gt;A user may say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I prefer slow, quiet conversations when I'm tired."&lt;br&gt;
That is probably important.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The same user may also say:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I had pasta today."&lt;br&gt;
That is probably not important unless it becomes a recurring preference.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A context dump treats both as just text.&lt;/p&gt;

&lt;p&gt;A memory system should not.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;4. It does not handle cross-session continuity well&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Users do not always talk in one long uninterrupted thread.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;They return tomorrow.&lt;br&gt;
They switch devices.&lt;br&gt;
They open Telegram, then continue in the browser.&lt;br&gt;
They talk to different characters.&lt;br&gt;
They start a new roleplay world.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A context window alone does not solve this.&lt;/p&gt;

&lt;p&gt;Memory has to exist outside one prompt and one session.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;What AI character memory actually needs to preserve&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;When people hear “memory,” they often think of fact recall.&lt;/p&gt;

&lt;p&gt;Things like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;User's name&lt;br&gt;
User's favorite movie&lt;br&gt;
User's city&lt;br&gt;
User's pet's name&lt;br&gt;
These can be useful, but AI character memory is broader than facts.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A character should also remember patterns.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;User prefers short replies when tired.&lt;br&gt;
User likes slow-burn fantasy roleplay.&lt;br&gt;
User dislikes overly energetic responses.&lt;br&gt;
User is practicing Spanish casually.&lt;br&gt;
User and this character are in a cautious but warm relationship dynamic.&lt;br&gt;
The current story arc is set in an abandoned library.&lt;br&gt;
For AI characters, the most useful memory is often not a fact.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is a preference, a dynamic, or a narrative state.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqw9mef2t6o47wtnp8u1q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fqw9mef2t6o47wtnp8u1q.png" alt=" " width="800" height="450"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;A practical memory stack&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here is a simplified architecture that I find useful:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;User message&lt;br&gt;
   ↓&lt;br&gt;
Input moderation / safety checks&lt;br&gt;
   ↓&lt;br&gt;
Session context&lt;br&gt;
   ↓&lt;br&gt;
Memory retrieval query&lt;br&gt;
   ↓&lt;br&gt;
Relevant memories from vector database&lt;br&gt;
   ↓&lt;br&gt;
User profile + character state + relationship state&lt;br&gt;
   ↓&lt;br&gt;
Prompt assembly&lt;br&gt;
   ↓&lt;br&gt;
LLM response&lt;br&gt;
   ↓&lt;br&gt;
Memory extraction / summarization&lt;br&gt;
   ↓&lt;br&gt;
Store / update / ignore / delete&lt;br&gt;
This is not the only possible architecture, but it separates the main responsibilities.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Let’s break it down.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;1. Session context&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Session context is the short-term state of the current conversation.&lt;/p&gt;

&lt;p&gt;It includes:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;recent messages;&lt;br&gt;
current topic;&lt;br&gt;
active scene;&lt;br&gt;
temporary instructions;&lt;br&gt;
immediate user request.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It answers the question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What is happening right now?&lt;br&gt;
This layer usually lives directly in the prompt.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It is necessary, but it is not long-term memory.&lt;/p&gt;

&lt;p&gt;If session context is your only memory layer, the character may feel coherent for one conversation and then reset later.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;2. User profile memory&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;User profile memory stores relatively stable preferences about the user.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;User prefers concise replies.&lt;br&gt;
User likes calm conversations.&lt;br&gt;
User is practicing Japanese.&lt;br&gt;
User prefers being called Alex.&lt;br&gt;
User dislikes pushy motivational language.&lt;br&gt;
This memory should be handled carefully.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It directly affects trust.&lt;/p&gt;

&lt;p&gt;If the system stores incorrect preferences, the user should be able to correct them. If the system stores sensitive information, the user should understand how memory works.&lt;/p&gt;

&lt;p&gt;For consumer AI, memory is not only an engineering problem.&lt;/p&gt;

&lt;p&gt;It is also a trust problem.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;3. Character state&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;AI characters also need memory about themselves.&lt;/p&gt;

&lt;p&gt;This is where many products fail.&lt;/p&gt;

&lt;p&gt;They remember something about the user, but the character drifts.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Character state can include:&lt;br&gt;
Character personality&lt;br&gt;
Backstory&lt;br&gt;
Speaking style&lt;br&gt;
Emotional range&lt;br&gt;
Relationship constraints&lt;br&gt;
Visual identity&lt;br&gt;
Voice style&lt;br&gt;
Current character arc&lt;br&gt;
Example:&lt;/p&gt;

&lt;p&gt;Character state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reserved and calm.&lt;/li&gt;
&lt;li&gt;Uses dry humor.&lt;/li&gt;
&lt;li&gt;Trust develops slowly.&lt;/li&gt;
&lt;li&gt;Avoids sudden emotional intensity.&lt;/li&gt;
&lt;li&gt;Replies in short, thoughtful sentences unless asked for detail.
For character products, consistency is part of the product contract.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;If the user chooses or creates a character, they expect that character to remain recognizable.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;4. Relationship state&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Relationship state is different from global user memory.&lt;/p&gt;

&lt;p&gt;The same user may want different dynamics with different characters.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;With one character, the tone may be playful.&lt;br&gt;
With another, it may be mentor-like.&lt;br&gt;
With another, it may be slow-burn roleplay.&lt;br&gt;
With another, it may be language practice.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;If everything is flattened into one global user profile, you lose this nuance.&lt;/p&gt;

&lt;p&gt;Relationship state answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;What is the current dynamic between this user and this character?&lt;br&gt;
Example:&lt;/p&gt;

&lt;p&gt;Relationship state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;User and character are building a slow-burn fantasy dynamic.&lt;/li&gt;
&lt;li&gt;Current tone is cautious but warm.&lt;/li&gt;
&lt;li&gt;Character should not act overly familiar yet.&lt;/li&gt;
&lt;li&gt;They are gradually building trust.
This layer matters a lot in roleplay and AI companion products.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;A roleplay arc is not just chat history.&lt;/p&gt;

&lt;p&gt;It is a shared state.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;5. Semantic retrieval&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;This is where vector search becomes useful.&lt;/p&gt;

&lt;p&gt;The goal is not to retrieve memories by exact keyword match.&lt;/p&gt;

&lt;p&gt;The goal is to retrieve by meaning.&lt;/p&gt;

&lt;p&gt;If the user says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"I'm tired today. Can we do something quiet?"&lt;br&gt;
A keyword-based system may not retrieve much.&lt;/p&gt;

&lt;p&gt;A semantic system might retrieve:&lt;br&gt;
User prefers calm, low-pressure conversations.&lt;br&gt;
User likes quiet fantasy settings.&lt;br&gt;
User often responds well to short, gentle replies.&lt;br&gt;
User previously enjoyed an abandoned library scene.&lt;br&gt;
That is the difference between literal memory and semantic memory.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A useful AI character memory system should retrieve meaning, not just words.&lt;/p&gt;

&lt;p&gt;The exact vector database is an implementation detail. It could be ChromaDB, pgvector, Qdrant, Pinecone, Weaviate, or something else.&lt;/p&gt;

&lt;p&gt;The product principle is the same:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Retrieve the context that helps the next response feel continuous.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;6. Summary memory&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Raw chat logs are usually not the best long-term memory format.&lt;/p&gt;

&lt;p&gt;They are too verbose and too noisy.&lt;/p&gt;

&lt;p&gt;A better approach is to summarize important sessions, scenes, or patterns.&lt;/p&gt;

&lt;p&gt;Instead of storing twenty messages, store something like:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Summary:&lt;br&gt;
User and character started a quiet fantasy scene in an abandoned library.&lt;br&gt;
User preferred slow pacing, subtle tension, and gradual trust-building.&lt;br&gt;
The scene ended with the character offering to show a hidden archive.&lt;br&gt;
This is much more useful than blindly storing every line.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Summary memory helps with:&lt;/p&gt;

&lt;p&gt;lower token usage;&lt;br&gt;
clearer retrieval;&lt;br&gt;
better prompt assembly;&lt;br&gt;
less noise;&lt;br&gt;
easier memory management.&lt;/p&gt;

&lt;p&gt;But summaries must be updated carefully.&lt;/p&gt;

&lt;p&gt;A bad summary can distort the relationship, the story, or the user’s preference.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;7. Safety and privacy filters&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Memory should not store everything.&lt;/p&gt;

&lt;p&gt;This is one of the most important parts.&lt;/p&gt;

&lt;p&gt;Some information should be ignored.&lt;br&gt;
Some should be summarized.&lt;br&gt;
Some should expire.&lt;br&gt;
Some should require explicit user control.&lt;br&gt;
Some should never become personalization memory.&lt;/p&gt;

&lt;p&gt;Examples:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Do not store:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;sensitive personal identifiers unless truly needed;&lt;/li&gt;
&lt;li&gt;crisis messages as normal personalization memory;&lt;/li&gt;
&lt;li&gt;unsafe content;&lt;/li&gt;
&lt;li&gt;random one-off details with no future value;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;private information that the user did not intend as a preference.&lt;br&gt;
Store carefully:&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;communication preferences;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;boundaries;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;language-learning goals;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;recurring story state;&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;character-specific relationship dynamics.&lt;br&gt;
The more personal the product feels, the more careful memory needs to be.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Bad memory vs good memory&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Here is a simple example.&lt;/p&gt;

&lt;p&gt;User says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I like slower conversations. I’m into quiet fantasy settings, abandoned libraries, and characters who reveal themselves gradually.&lt;br&gt;
Bad memory:&lt;/p&gt;

&lt;p&gt;User likes fantasy.&lt;br&gt;
Better memory:&lt;/p&gt;

&lt;p&gt;User prefers slow-paced fantasy scenes, quiet atmosphere, abandoned-library settings, gradual emotional reveal, and low-pressure dialogue.&lt;br&gt;
Why is the second better?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Because it preserves the pattern, not just the noun.&lt;/p&gt;

&lt;p&gt;The useful memory is not “fantasy.”&lt;/p&gt;

&lt;p&gt;The useful memory is the user’s preferred interaction style.&lt;/p&gt;

&lt;p&gt;That difference matters a lot in AI character products.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Prompt assembly example&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Once the memory layers exist, the next step is prompt assembly.&lt;/p&gt;

&lt;p&gt;A simplified prompt may look like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;System:&lt;br&gt;
You are the selected AI character. Stay consistent with the character profile.&lt;br&gt;
Character state:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Reserved, calm, dry humor.&lt;/li&gt;
&lt;li&gt;Trust develops slowly.&lt;/li&gt;
&lt;li&gt;Avoids sudden emotional intensity.
Relationship state:&lt;/li&gt;
&lt;li&gt;User and character are building a slow-burn fantasy dynamic.&lt;/li&gt;
&lt;li&gt;Current tone: cautious but warm.&lt;/li&gt;
&lt;li&gt;Continue from the abandoned library arc if relevant.
Relevant user memories:&lt;/li&gt;
&lt;li&gt;User prefers slow-paced scenes.&lt;/li&gt;
&lt;li&gt;User dislikes overly energetic replies.&lt;/li&gt;
&lt;li&gt;User is practicing Spanish casually.&lt;/li&gt;
&lt;li&gt;User prefers short replies when tired.
Current session:
User: "I'm tired today. Can we do something quiet?"
The response should not simply list the memories.&lt;/li&gt;
&lt;/ul&gt;
&lt;/blockquote&gt;

&lt;p&gt;That would feel robotic.&lt;/p&gt;

&lt;p&gt;The model should use memory to choose a better response.&lt;/p&gt;

&lt;p&gt;For example:&lt;/p&gt;

&lt;blockquote&gt;
&lt;blockquote&gt;
&lt;p&gt;Of course. We can keep it quiet tonight.&lt;br&gt;
Maybe we return to the old library — not the dangerous part yet, just the upper floor where the rain taps against the glass roof. I can show you one small secret, and we do not have to rush.&lt;br&gt;
Of course. We can keep it quiet tonight.&lt;/p&gt;
&lt;/blockquote&gt;


&lt;/blockquote&gt;

&lt;p&gt;Maybe we return to the old library — not the dangerous part yet, just the upper floor where the rain taps against the glass roof. I can show you one small secret, and we do not have to rush.&lt;br&gt;
The user does not need to see the memory system.&lt;/p&gt;

&lt;p&gt;They just need to feel continuity.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Memory extraction&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;After the model replies, the system needs to decide whether anything should be stored or updated.&lt;/p&gt;

&lt;p&gt;This is where many products over-store.&lt;/p&gt;

&lt;p&gt;Not every message deserves memory.&lt;/p&gt;

&lt;p&gt;A memory extraction step can classify information like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Should this message create or update memory?&lt;br&gt;
Categories:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;stable preference&lt;/li&gt;
&lt;li&gt;temporary preference&lt;/li&gt;
&lt;li&gt;character-specific relationship state&lt;/li&gt;
&lt;li&gt;roleplay world state&lt;/li&gt;
&lt;li&gt;language-learning goal&lt;/li&gt;
&lt;li&gt;safety boundary&lt;/li&gt;
&lt;li&gt;no memory needed
Example:&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;User: Actually, I prefer shorter replies when I'm tired.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This should probably update memory:&lt;/p&gt;

&lt;p&gt;Memory update:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;User prefers shorter replies when tired.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Another example:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;User: I had pasta today.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;This usually should not become long-term memory.&lt;/p&gt;

&lt;p&gt;Unless it becomes a repeated preference or relevant part of the current story, it can be ignored.&lt;/p&gt;

&lt;p&gt;The hard part is knowing the difference.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;A simple memory extraction prompt&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A simplified extraction prompt could look like this:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;You are a memory extraction system.&lt;br&gt;
Given the conversation, extract only information that will likely improve future conversations.&lt;br&gt;
Do not store sensitive personal data unless the user clearly intends it as a preference.&lt;br&gt;
Do not store one-off details unless they are important for an ongoing story or relationship.&lt;br&gt;
Do not store unsafe content.&lt;br&gt;
Return JSON:&lt;br&gt;
{&lt;br&gt;
  "should_store": boolean,&lt;br&gt;
  "memory_type": "stable_preference | temporary_preference | relationship_state | story_state | language_goal | safety_boundary | none",&lt;br&gt;
  "memory": "short memory text",&lt;br&gt;
  "reason": "why this is useful or not useful"&lt;br&gt;
}&lt;br&gt;
Example output:&lt;/p&gt;

&lt;p&gt;{&lt;br&gt;
  "should_store": true,&lt;br&gt;
  "memory_type": "stable_preference",&lt;br&gt;
  "memory": "User prefers shorter replies when tired.",&lt;br&gt;
  "reason": "This preference can improve future response style."&lt;br&gt;
}&lt;br&gt;
This is not enough for production by itself, but it shows the idea.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Memory extraction should be explicit, structured, and conservative.&lt;/p&gt;

&lt;p&gt;Common mistakes&lt;/p&gt;

&lt;p&gt;Here are the mistakes I would avoid.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Mistake 1: Storing too much&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;More memory is not always better.&lt;/p&gt;

&lt;p&gt;Too much memory creates noise and can make the character bring up irrelevant details.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Mistake 2: Storing facts instead of patterns&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Facts are useful, but patterns are often more valuable.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;User likes fantasy.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;is weaker than:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;User prefers slow-paced fantasy scenes with gradual trust-building.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Mistake 3: Mixing global user memory with character-specific state&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;A user may want different dynamics with different characters.&lt;/p&gt;

&lt;p&gt;Do not flatten everything into one profile.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Mistake 4: Making memory creepy&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;If the character constantly says:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;I remember that you told me...&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;the experience can become uncomfortable.&lt;/p&gt;

&lt;p&gt;Good memory should be felt, not announced every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Mistake 5: No user control&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Users should understand that memory exists.&lt;/p&gt;

&lt;p&gt;They should have reasonable ways to correct, manage, or clear it.&lt;/p&gt;

&lt;p&gt;Memory without control damages trust.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Mistake 6: Treating safety as an afterthought&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;Safety rules should be part of the memory pipeline.&lt;/p&gt;

&lt;p&gt;Not something added later.&lt;/p&gt;

&lt;h2&gt;
  
  
  &lt;strong&gt;Where HoneyChat fits&lt;/strong&gt;
&lt;/h2&gt;

&lt;p&gt;This is the direction we are building toward in &lt;a href="https://honeychat.bot/en/" rel="noopener noreferrer"&gt;HoneyChat&lt;/a&gt;: AI characters for &lt;a href="https://t.me/HoneyChatAIBot" rel="noopener noreferrer"&gt;Telegram&lt;/a&gt; and web with long-term memory, voice messages, AI photos, short videos, and character consistency.&lt;/p&gt;

&lt;p&gt;The hard part is not making the first message impressive.&lt;/p&gt;

&lt;p&gt;The hard part is making the next session feel connected.&lt;/p&gt;

&lt;p&gt;A user should be able to start in Telegram, continue in the browser, return later, and still feel like the same character remembers the important parts.&lt;/p&gt;

&lt;p&gt;That is the product goal.&lt;/p&gt;

&lt;p&gt;Not infinite chat history.&lt;/p&gt;

&lt;p&gt;Not a bigger prompt for the sake of it.&lt;/p&gt;

&lt;p&gt;Continuity.&lt;/p&gt;

&lt;p&gt;Final takeaway&lt;/p&gt;

&lt;p&gt;The next generation of AI character products will not be judged only by model quality.&lt;/p&gt;

&lt;p&gt;They will be judged by continuity.&lt;/p&gt;

&lt;p&gt;Context windows make chats longer.&lt;/p&gt;

&lt;p&gt;Memory makes characters persistent.&lt;/p&gt;

&lt;p&gt;That is the real difference between a chatbot and a companion.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>machinelearning</category>
    </item>
  </channel>
</rss>
