Chat history is not enough for multi-turn support

If the customer has to re-explain the issue, or feels misunderstood you have already lost CSAT points. LLMs can guess from session history but persisting signals is what makes it reliable.

Most writing about AI support agents is about retrieval. Chunking, embeddings, prompt tricks for grounded answers. In a live system that is maybe a third of the work. What shows up on CSAT is whether the next turn still knows what this chat is about.

Open a typical "build a support agent" tutorial and you get the LangGraph workflow:

Decision nodes everywhere: guardrails, intent, RAG vs tools, confidence, resolve, handover. It looks good on paper. Wire the nodes, tune the prompts, ship the demo. For a single-turn FAQ bot it works.

Multi-turn production support is where it falls apart. The graph looks stateful, but most nodes judge this message alone and branch.

Every one of those branches hides a decision that needs the turns before it:

  • Should this escalate to a human?
  • Is this issue resolved, and should we fire the CSAT survey and close the chat?
  • Is the customer stuck, even though every individual reply looked fine?
  • Is this message a real question, or just "ok thanks"?
  • Which sub-agent should handle this turn?

Answering the customer's question with RAG is the easy part. Models are already good at that.

The hard part: most turns mean nothing on their own. The same three letters mean three different irreversible things depending on what the assistant said one turn earlier. Those meanings live outside the RAG box.
When the customer says yes, the previous turn decides what it means:

One utterance, three meanings: a transfer, a data point, a goodbye. Misread the previous turn and you do the wrong irreversible thing with full confidence. The customer feels it immediately. They repeat themselves, get surveyed mid-complaint, or get transferred on a sarcastic "yep."

Every decision in that list is two problems stacked: interpret this turn against the turns before it, then act. That takes more than one LLM call and more than one more decision node in langgraph. Here is the pattern I landed on repeatedly in production, the one that survived contact with real chats.

Four steps

This runs on every turn. Take an ecommerce chat where a package was marked delivered and the customer never got it. The customer complains about it. Each turn writes into the session. That trail is what the next turn decides on.
previous turns influence decisions

Nothing in "yes" says "transfer me." The meaning only appears when you look at all three inputs together:
what happens in turns

One pipeline feeds all of those inputs through and keeps seeing separate from doing. Four roles, one job each:

4 factors

Walk it in order:

  1. Collect signals. Every classifier, counter, and self-report writes a field: what it saw on this turn. It reports and stops there. Reply, escalate, and close belong to step 4.
  2. Build a situation. One function folds those fields into a small typed card for this moment: enums and booleans. Facts only ("live offer on record," "short affirmative," "empty-retrieval streak = 2").
  3. Resolve a plan. A pure function maps that card to a plan: keep talking, offer live support, transfer, fire CSAT, which tone, which agent. Conflicts have a fixed precedence (the most protective rule wins). No model in this step. Business thresholds (order amount, streak length, offer window) live here too, so the same signals produce different plans when policy changes.
  4. Act once. One component carries out the plan. It alone may send the customer-visible reply and it alone may write the session locker afterward. The prompt is allowed to say only what the plan already decided.

Every one of these costs CSAT: the customer re-explains their issue; a short "yes" transfers them when they meant "yes I checked the lobby"; a soft offer repeats every other turn; a survey pops mid-argument. A human after the fifth identical answer arrives too late, the rating is already gone. Continuity means the system remembers enough of the thread to prevent all of it.

Why there has to be memory between turns

Chat history is prose. An LLM guesses from prose. Three things it cannot know reliably:

  • have we already asked for a human this hour?
  • have we already offered live support in the last 15 minutes?
  • is this the third empty retrieval in a row?

Those need deterministic session state: plain facts previous turns wrote down, read back the same way every time instead of re-inferred from vibes.

  chat history (prose)     ->  LLM may misread "yes"
  session state (facts)    ->  offer_made=1, miss_streak=3, ...
                                    |
                                    v
                           plan from rules + thresholds

None of these numbers are prompt engineering. Ops decides them. Someone will eventually ask you to escalate stuck chats only when the order is over fifty dollars, or to give the retrieval two tries instead of three, and you want that to be a config change rather than a rewrite of a prompt someone tuned for a week. The model keeps reporting what it saw. Code decides whether that is worth a human.

Technically that state is a key-value store with expiry, keyed by chat session. We use Redis so every API pod sees the same locker (in-process memory dies with the request and disagrees across replicas). Keys TTL out so cross session decisions are not stored.

Three Redis shapes that cover the layer

1. The consecutive counter

A customer sends one hostile message. Is that abuse, or a bad day, or a classifier having an off moment? You cannot tell from one reading, which is why the expensive action waits for a streak. Two or three in a row is a pattern. The reset matters as much as the count: any clean turn forgives the streak, so you are counting runs and not totals.

  key: abuse_counter:{session_id}

  turn:    hostile   hostile   calm    hostile   hostile
  op:      INCR      INCR      SET 0   INCR      INCR
  value:     1         2         0       1         2
                       |                           |
                  hit threshold               hit threshold
                  -> transfer                 -> transfer

Same shape works for "retrieval came back empty three times in a row." The counter does not care why the streak matters, only that a good turn zeroes it.

2. The one-shot flag

"Get me a human." The first time, you want to ask what they need, because half the time the bot can still fix it. The second time inside the hour, you transfer, and arguing further is how you lose the rating. The flag is the only thing that tells those two asks apart, and its presence is the memory.

get me a human

The TTL is how long you remember. A customer who asked yesterday and is calm today gets a fresh first ask.

3. The atomic claim

The customer sees the same offer twice in a row. Two pods both noticed they looked stuck, both decided to offer live support, and neither knew about the other. The check and the set have to be a single operation.

  key: offer_made:{session_id}

  SET key NX EX 900     # set only if absent; expire in 15 minutes
       |
       +-- succeeded  -> you may render the offer; it is already on record
       +-- failed     -> someone offered within the window; stay silent

The same live key doubles as proof of offer when the customer later says "yes." If the key is present, "yes" can mean acceptance. If it is absent, a bare "yes" must not transfer. It might be answering a totally different question.

One rule across all three shapes: fail open. If Redis is down, you skip escalation, skip transfer, skip the survey. An infrastructure hiccup is never allowed to act on a customer.

Escalation is a tree

Escalation looks like the simplest decision on that list, and it is the one that fooled us longest.

It is a tree of categories, each with its own condition and its own memory:

                       should we escalate?
                              |
        +---------------------+---------------------+
        |  categories, each with its OWN condition  |
        +--------------------------------------------+

  sensitive        escalate NOW. severity (crisis vs harm)
                   changes the handling class.

  human_request    FIRST ask: soft reply. REPEAT ask: transfer.
                   needs the one-shot flag from above.

  offer_acceptance "yes" AFTER we offered a handoff -> transfer.
                   a bare "yes" with NO live offer must not.
                   needs the claim flag to tell them apart.

  abusive          one hostile message: do nothing.
                   a short streak: transfer.
                   needs the consecutive counter (and its reset).

  complexity /     judged on the LATEST message in context.
  time_critical    an earlier heated turn must not escalate
                   a now-calm chat by itself.

  meaningless      suppresses the non-safety categories.
  message          "asdfgh" is not a complexity case.

Same message text can land on different plans when the locker differs. yes with the offer claim set is offer_acceptance → transfer. yes without it stays in the thread. One hostile line with abuse streak 0 stays put; streak at threshold transfers.

Chat resolution is a landmine

Resolved fires the CSAT survey and closes the chat. Get it wrong and you survey a customer mid-complaint.
resolved

Stuck detection needs turns you can count

A customer can be stuck while every individual reply looks fluent and polite. We ended up with two detectors, because there are two different kinds of stuck:
detector

"Third time," "3 consecutive," and "once per window" are the load-bearing words there, and none of them are available to a per-turn LLM call. They come out of the session locker.

The asymmetry is deliberate. Detector 2 is a judgment call, so getting it wrong costs one extra "want me to connect you with live support?" Detector 1 is closer to an objective miss, so it gets to force a transfer. We nearly merged them into one counter because the code looked cleaner that way, which would have let LLM noise spend a human agent's afternoon. The split survives because the decision sits in one place.

There is a third kind of stuck we still do not count well: retrieval succeeds, the model returns a confident answer, and the customer is still not helped. Same promo terms, restated, turn after turn. To the empty-retrieval counter that looks like success, so the streak resets every time.

Routing is the same pattern with different labels

If the product has more than one agent (knowledge / FAQ, game recommendations, retention outreach), routing is another place the last message alone lies.

Ours roughly orders like this:

  1. Support context wins.
     "my bonus for playing slots is broken" -> knowledge,
     even though the words say "slots" and "playing".

  2. Explicit intent beats continuity.
     "recommend me a game" mid-support-chat -> game agent.

  3. Short replies continue the current thread.
     "yes" / "the first one" -> whoever asked owns the answer.

  4. Otherwise -> knowledge agent.

If you are building one of these

  • Producers signal, never act. The moment a classifier can end a turn or write state, you have two sources of truth.
  • Never ask a model to judge its own output. Watch the user's behavior instead.
  • One writer per piece of session state, and record the act after it happens, not before.
  • Put a counter between any noisy signal and any expensive action. "Three consecutive" is a fact in a way that "one" never is.
  • Fail open on shared state. Infra trouble must not transfer or survey anyone.
  • Log the decision itself, not only the outcome. Two fields (did this turn offer help, and which producer decided) turned a failure that needed a screenshot into a query.
  • Read your transcripts. The worst failures were found by a person reading a conversation and saying "that reply is wrong." Not one was caught by a test first.