Skip to content

Migrating a Chatbot's Conversation Memory Format

10 min read · updated August 11, 2026

A framework’s memory object is not a transcript. It is a transcript plus a retention policy plus, sometimes, a lossy summary of turns that no longer exist. Only the first of those three converts.

Decide the target shape first

The destination is a plain, boring, serialisable array — a list of objects with a role and a content, in order, with nothing framework- specific in it. Everything else is derived from it at request time.

# The canonical form. Store this; adapt at the edge.
[
  {"role": "system",    "content": "You are a support agent for Acme."},
  {"role": "user",      "content": "Where is order 41?"},
  {"role": "assistant", "content": null,
   "tool_calls": [{"id": "call_1", "type": "function",
                   "function": {"name": "lookup", "arguments": "{\"id\": 41}"}}]},
  {"role": "tool",      "tool_call_id": "call_1", "content": "shipped"},
  {"role": "assistant", "content": "Order 41 shipped on Tuesday."}
]

Two decisions are worth making deliberately. Keep the system message in the stored array even though some providers take it as a separate parameter — it is part of the conversation’s meaning and lifting it out at request time is a one-line adapter, while putting it back in later requires knowing which system prompt version was in force. And store timestamps and a turn id alongside each entry in your own columns, not inside content, because you will need them for trimming and you must not need to parse text to trim.

Step 1: look at what is actually stored

Do not migrate from the framework’s documentation; migrate from your own database. Pull a hundred rows and print the distinct shapes. LangChain’s message objects, for example, serialise through messages_to_dict into entries with a type discriminator and a data payload, where the type is a value such as human, ai, system, tool or the legacy function, and the payload carries content plus an additional_kwargs bag whose contents vary by version.

import json, collections

shapes = collections.Counter()
for row in db.fetch_conversations(limit=100):
    for m in json.loads(row["memory_blob"]):
        shapes[(m.get("type"), tuple(sorted(m.get("data", {}).keys())))] += 1

for shape, n in shapes.most_common():
    print(n, shape)

The output is your actual specification. Expect surprises: a type nobody remembers writing, entries where content is a list rather than a string, and rows from two different framework versions living in the same table.

Step 2: write the role mapper

The core of the conversion is a total function over the discriminator, with an explicit failure for anything unrecognised. Silently dropping an unknown type is how a migration loses turns that nobody notices for a month.

ROLES = {
    "human": "user",
    "ai": "assistant",
    "system": "system",
    "tool": "tool",
    "function": "tool",     # legacy; see step 3
}

def convert(entry):
    t = entry["type"]
    if t not in ROLES:
        raise ValueError(f"unmapped message type: {t!r}")
    data = entry["data"]
    out = {"role": ROLES[t], "content": data.get("content")}

    kw = data.get("additional_kwargs") or {}
    if kw.get("tool_calls"):
        out["tool_calls"] = kw["tool_calls"]
        out["content"] = out["content"] or None
    if t in ("tool", "function"):
        out["tool_call_id"] = data.get("tool_call_id") or kw.get("tool_call_id")
    return out

Run it over every row, not a sample, and collect the exceptions rather than stopping on the first. The list of unmapped types is short and finite, and seeing all of them at once is what lets you decide the policy for each in one sitting.

Step 3: the four cases that do not convert

Legacy function messages have no call id. Rows written before the tool-calling rename carry a function name and a result, with the pairing implied by position. The target shape requires an id on both the assistant message and the reply. Synthesise one per pair, deterministically from the conversation id and the turn index so that re-running the migration is idempotent. The full shape change is in the function-to-tool calling migration.

Summary memory is not a transcript. A summarising memory stores a running prose summary and discards the turns it summarised. There is nothing to convert them back into. The honest migration produces one system-role entry containing the summary, flagged in your own metadata as derived, and accepts that the original turns are gone. Do not fabricate turns from the summary — a reconstructed conversation that never happened is worse than a note saying it was summarised.

Window and token-limit policies are code, not data. A buffer that keeps the last k turns, or trims to a token budget, is a rule applied at request time. Nothing in the stored blob records it. Find the configuration in the application, write the rule down explicitly, and re-implement it against the new array — including the part everyone forgets, which is that an assistant message with tool calls and its replies must be trimmed as one unit or the next request fails validation.

Non-text content. Where content is a list of blocks rather than a string — images, files, structured parts — the block vocabulary is framework-specific and the target provider’s is different again. Convert the text blocks, and for anything else store the original bytes or a reference and re-encode at request time rather than trying to translate the block shape once and for all.

Step 4: reshape for the target provider

The stored array is provider-neutral by design, so the provider-specific work happens in one small function at the edge. Anthropic’s Messages API takes the system prompt as a top-level system parameter rather than as a message, expects the conversation to begin with a user turn, and does not accept two consecutive messages in the same role, so the adapter has to lift, check and merge:

def to_anthropic(msgs):
    system = " ".join(m["content"] for m in msgs if m["role"] == "system")
    rest = [m for m in msgs if m["role"] != "system"]
    while rest and rest[0]["role"] != "user":
        rest.pop(0)                       # a leading assistant turn is rejected

    merged = []
    for m in rest:
        if merged and merged[-1]["role"] == m["role"]:
            merged[-1]["content"] += "\n\n" + m["content"]
        else:
            merged.append(dict(m))
    return system, merged

Google’s Gemini API needs a third variant: the array is called contents, the assistant role is named model rather than assistant, text lives inside a parts list, and the system prompt is a separate systemInstruction. None of that is difficult; all of it is a reason the stored format should not be any one provider’s wire format.

Keep these adapters pure and total: array in, provider payload out, no database access and no truncation logic. The trimming rule belongs before the adapter, operating on the canonical array, so that one policy applies regardless of which provider serves the request. When the two are entangled — a function that both trims and reshapes — every new provider means re-implementing the retention policy, and the two copies drift within a release or two.

Step 5: verify the migration before deleting anything

  1. Run the converter over the full table into a new column. Do not overwrite the original blob, and do not delete it in the same release as the cutover.
  2. Assert invariants on every converted conversation: roles are all in the known set, every tool_call_id matches exactly one earlier tool call, no two adjacent entries have the same role after the provider adapter runs, and the concatenated user text is unchanged from the source.
  3. Count turns before and after per conversation and diff the totals. A migration that loses turns loses them in a specific shape — almost always tool replies — and a count is the cheapest detector.
  4. Replay a sample of converted conversations against the model and check they still produce a sensible next turn. This catches ordering bugs that pass every structural assertion.
  5. Keep the old column for a full retention period. The one thing you cannot do after deleting it is re-run the converter with the fix for the case you did not anticipate.

When this lands, the remaining provider assumptions are in the calling code rather than in the store; the hardcoded-assumption audit finds those.