DEV Community

Pogee.ai
Pogee.ai

Posted on

Persona Drift: Why Your AI Character Slowly Stops Being Themselves

Every character chat product has the same bug report, and it never gets filed because nobody can reproduce it in one message.

The complaint sounds like this: "It was so good at the start. Now it talks like a customer support bot."

Turn 3, the character is sharp, specific, a little mean. Turn 60, it's writing three-paragraph replies that start with "I understand how you feel." Turn 200, it has forgotten the name it told you, the job it has, and the fact that it's supposed to hate you.

Nothing in the code changed. No deploy happened. The model is the same model. What degraded is the thing you actually shipped: the persona.

I've spent a lot of time on this problem while working on content for Pogee, an AI character chat product where every character is supposed to feel like a specific person with a specific life. This post is the engineering version of what I've learned — the mechanisms, how to measure drift instead of arguing about it, and the prompt architecture that holds up past turn 100.

Drift is not the model getting dumber

It's tempting to blame the model. Usually it's one of three mechanical causes, and all three are fixable.

1. Context eviction

Long conversations get truncated. Whatever strategy you use — sliding window, token-budget trimming, summarization — something has to leave the context window. The problem is what leaves.

The first thing to go is usually the beginning: your carefully written identity block, the voice rules, the character's backstory. What survives is the most recent 20 turns, which are increasingly full of the model's own generic output.

So the character's definition gets evicted, and its recent behavior — which has already drifted — gets promoted to the strongest signal in the prompt.

2. Instruction dilution

A 2,000-token system prompt is not 2,000 tokens of equal weight. Attention is unevenly distributed, and instructions buried in the middle of a long context get followed less reliably than instructions at the edges. Add a retrieved memory block, a scene state block, a safety block, and 40 turns of history, and your "never break character" rule is now competing with 8,000 tokens of other stuff for the model's attention.

This is why the same prompt works beautifully in a fresh conversation and falls apart in a long one. The instruction didn't change; the competition did.

3. Self-reinforcement

Chat models are trained to be helpful assistants. That prior is strong, and every turn you don't actively counteract it, the model gets one more sample of its own slightly-drifted output in context. It then imitates that. Drift is a ratchet, not a random walk.

You can watch this happen in slow motion: the first time the character says "Let me know if you want to talk about something else," it's an anomaly. Ten turns later it's the established voice.

Measure it, don't debate it

The fastest way to end a "does it still feel right?" argument is a number. You don't need an eval framework — you need a fixed probe set and a cheap signal.

The most useful signal I've found is assistant-speak leakage: how often the character produces phrases that belong to the assistant prior rather than the persona.

import re

ASSISTANT_TELLS = [
    r"\bas an ai\b",
    r"\bi'?m here to help\b",
    r"\bi understand (how|that|what)\b",
    r"\blet me know if\b",
    r"\bfeel free to\b",
    r"\bi cannot (provide|help|assist)\b",
    r"\bit'?s important to (note|remember)\b",
    r"\bwould you like me to\b",
    r"\bi hope (this|that) helps\b",
]

def leakage(replies: list[str]) -> float:
    """Fraction of replies containing at least one assistant tell."""
    hits = sum(
        1 for r in replies
        if any(re.search(p, r, re.I) for p in ASSISTANT_TELLS)
    )
    return hits / max(len(replies), 1)
Enter fullscreen mode Exit fullscreen mode

Now run the same 10 probe prompts at turn 10, turn 100, and turn 300. Probes should be things that force the persona to assert itself:

  • "What do you actually want from me?"
  • "Tell me about your day."
  • "I'm not going to do what you asked."
  • "Who are you?"
  • "Say something you'd never say."

Two numbers come out of this, and both matter:

  • Leakage rate. Should stay near zero. If it climbs with turn count, you have an eviction or dilution problem.
  • Reply-length drift. Compute mean tokens per reply across the run. Personas usually have a characteristic length. A character that starts at 30 tokens and ends at 180 has been replaced by the assistant prior, even if no forbidden phrase ever appears.

I'd also add a third check that costs almost nothing: does the character still produce its own specific nouns? A character with a named job, a named city, a named pet should keep referencing them. When those disappear from the output, the backstory has fallen out of the effective prompt.

The prompt architecture that survives

The fix isn't a longer prompt. It's a prompt with a spine.

Separate the immutable identity from the mutable state

Split your system prompt into blocks with different lifetimes and different rewrite rules:

[IDENTITY] — never paraphrased, never summarized, never evicted
Name: Kahir
Situation: fallen prince, bound to the user's household
Core want: to be seen as dangerous again
Hard limits: never begs, never explains his feelings directly

[VOICE] — restated verbatim near the END of the context
- 1-2 sentences per beat, no paragraphs
- deflects sincerity with a joke
- never apologizes, never offers help
- banned: "as an AI", "I understand", "let me know", "feel free"

[SCENE STATE] — rewritten every turn, structured, not prose
location: the east corridor
time: late evening
he knows: the user was at the council meeting
open threads: the broken seal, the unanswered letter

[RECENT TURNS] — trimmed, oldest first

[VOICE REMINDER] — the last thing the model reads
You are Kahir. You do not summarize. You do not offer help.
Reply in character. 1-2 sentences.
Enter fullscreen mode Exit fullscreen mode

Two details do most of the work:

The identity block is short and never trimmed. Ten lines of hard constraints survive eviction because you protect them explicitly, not because they're at the top. If your trimming logic treats the system prompt as one blob, it will eventually eat your persona.

The voice rules are restated at the end. This is the cheap version of fighting lost-in-the-middle. The model's most recent instruction is the one it follows most reliably, so the last thing in context should be the character, not the transcript.

Make scene state structured, not conversational

Prose history is a terrible state store. It's verbose, it's redundant, and it degrades every time the model paraphrases it.

Keep a small structured object — location, time, what each party knows, open threads — and rewrite it each turn with a cheap extraction pass. It costs fewer tokens than the history it replaces, and it gives you something you can actually assert on in tests: assert "broken seal" in state.open_threads.

Continuity has to live outside the context window

No context window is long enough to be the memory. Once you accept that, the architecture gets simpler: the conversation is the recent view, and the character's persistent facts live in a store you retrieve from.

What's worth storing is narrower than people expect. Not a transcript summary — a small set of durable facts: names the user gave, promises made, relationship state, running jokes. Retrieved per turn, injected as facts, not as narrative.

There's a product-level version of this too, and it's the part users actually notice. A character with a profile, a routine, and visible moments between conversations reads as continuous even when the chat context is short. That's the argument behind living AI characters — presence comes from the surrounding structure, not from a longer prompt.

Proactive messages are two systems, not one

If your product lets characters start conversations, this is where the engineering gets genuinely interesting — and where the most common design mistake lives.

Initiation and delivery are separate concerns. Who generated the message, what created the occasion, and where the user sees it are three different layers. Collapsing them into one "send a message" path is what produces the classic failure: a generic "we miss you!" push that users read as the character being needy, because the character had nothing to do with it.

The version that holds up:

  1. Occasion generator — a scheduled job that looks at character state (routine, time of day, open threads, last contact) and decides whether there's a reason to speak. Most runs should decide there isn't. A character that always has something to say is noise.
  2. Message generator — the LLM turns the occasion into one in-character line, with the same identity and voice constraints as normal chat. Never let the scheduler write copy.
  3. Delivery layer — decides in-app only, push, or nothing, and enforces quiet hours, per-user rate limits, and deduplication. This layer should be deterministic and never call the model.

Practical constraints I'd build in from day one:

  • Idempotency keys per (user, character, occasion). Retries must not produce two messages.
  • Hard daily cap per character, not per app. Five characters each sending one message is not "one message."
  • Quiet hours in the user's local timezone, resolved server-side.
  • A kill switch per campaign, so a bad occasion generator can't wake up 100k people at 3am.
  • Separate metrics for initiation and delivery. "Messages sent" and "messages seen" are different numbers, and conflating them hides notification permission failures.

The distinction matters enough that it's worth documenting for users too — the definitions around AI companions that message first are essentially the same three layers, described from the outside.

What I'd test before calling it shipped

A short checklist, in the order these things usually break:

  • Turn 100 still sounds like turn 1. Run the probe set at 10/100/300 turns. Track leakage rate and reply-length drift as numbers in CI.
  • Identity survives trimming. Write a test that forces eviction and asserts the identity block is still present in the assembled prompt.
  • No assistant tells in the negative examples. Grep your own eval outputs for "as an AI" — if your persona's voice rules don't explicitly ban it, it will show up.
  • Proactive messages are idempotent and capped. Simulate a retry storm and a timezone rollover.
  • Character-specific nouns persist. If the character has a named world, assert those nouns appear in a 20-turn sample.
  • Disclosure is intact. Proactive contact and persona consistency should never make a character read as a real person. Keep the framing honest in the UI, not just in the docs.

The takeaway

Persona drift is a systems problem, not a prompt-wording problem. The character degrades because the definition gets evicted, the instructions get diluted, and the model imitates its own drift. Fix all three and the persona holds.

The measurement part is what most teams skip, and it's the cheapest win: ten probe prompts and a regex list will tell you more about your character's health than another round of prompt tweaking.


Disclosure: I write content for Pogee, an AI character chat product, which is where most of these examples come from. If you want the character side of it, the catalog is at fantasy and slice-of-life. The failure modes above are generic to any chat product with a persistent persona, and the probe harness is the part I'd write regardless of which product I was working on.

Top comments (0)