n4nAI

Diffing chatbot transcripts to catch regression bugs

Learn how to diff chatbot transcripts regression-style to catch silent LLM behavior changes before deploy, with reproducible capture and CI gating steps.

n4n Team4 min read956 words

Audio narration

Coming soon — every post will get a voice note here.

When you tweak a system prompt or swap the underlying model, silent behavior drift is the norm. The cheapest way to catch it is to diff chatbot transcripts regression against a frozen baseline before the change ships. This post walks through a reproducible pipeline that captures, normalizes, and compares sessions so you can block bad deployments with confidence.

Step 1: Capture transcripts in a deterministic shape

You cannot diff what you do not log. Wrap your LLM client so every turn appends a structured record to a JSONL file. Keep the schema stable across versions: role, content, model, prompt_hash, finish_reason, and tool_calls. Drop volatile request identifiers at write time because they only add noise to later comparisons.

A line-oriented format lets you append streaming chunks and post-process without parsing a giant nested tree. If you use the OpenAI client, subclass or monkey-patch the chat completion call:

import json, hashlib, time

def prompt_hash(messages):
    blob = json.dumps(messages, sort_keys=True).encode()
    return hashlib.sha256(blob).hexdigest()[:16]

def log_turn(fp, role, content, model, finish_reason, tool_calls=None):
    rec = {
        "role": role,
        "content": content,
        "model": model,
        "prompt_hash": prompt_hash(content) if role == "user" else None,
        "finish_reason": finish_reason,
        "tool_calls": tool_calls or [],
    }
    fp.write(json.dumps(rec) + "\n")
    fp.flush()

Run your test conversations through this logger. Each session becomes a .jsonl file named by scenario and git SHA. Store the files in an append-only bucket keyed by date, because you will want historical baselines when chasing regressions that appeared three weeks ago.

For streaming responses, buffer the deltas and write one record per turn after the stream closes. Do not log intermediate token counts; they vary with provider load and will poison your diff.

Step 2: Freeze a baseline before you change anything

Pick a commit where the bot behaves correctly. Replay a fixed set of user inputs—scripts, canned questions, or sampled production slices—with temperature 0 and a pinned model version. Save the output as baseline.jsonl.

If you route through a gateway such as n4n.ai, pin the model explicitly and forward provider cache-control hints to avoid recomputation drift; the gateway’s per-token metering also lets you attribute cost per replay run. That is the only infrastructure assumption here, and it keeps replays cheap enough to run nightly.

python replay.py --scenario checkout_flow --model gpt-4o-mini-2024-07-18 --out baseline.jsonl

Tag the baseline in version control so you can reconstruct the exact environment:

git tag transcript-baseline-$(git rev-parse --short HEAD)

A baseline is only useful if it is reproducible. Lock dependency versions, lock prompt templates, and lock model date stamps. Without those pins, a “regression” may just be a backend update you did not authorize.

Step 3: Normalize before you diff chatbot transcripts regression

Raw logs contain noise: whitespace, token counts, latency, reordered tool arguments. Normalize both baseline and candidate before comparison. Strip any field not semantically meaningful. Before you diff chatbot transcripts regression results, treat only role, content, and finish_reason as diff-worthy; collapse whitespace and lowercase only if your tolerance allows.

Tool calls need special handling. Sort arguments by key and serialize deterministically:

import re, json

def normalize_tool_calls(calls):
    if not calls:
        return []
    return sorted(
        [{"name": c["name"], "args": json.dumps(c["args"], sort_keys=True)} for c in calls],
        key=lambda x: x["name"],
    )

def normalize(path):
    out = []
    with open(path) as f:
        for line in f:
            rec = json.loads(line)
            clean = {
                "role": rec["role"],
                "content": re.sub(r"\s+", " ", rec["content"]).strip(),
                "finish_reason": rec["finish_reason"],
                "tool_calls": normalize_tool_calls(rec.get("tool_calls")),
            }
            out.append(clean)
    return out

Write normalized forms to /tmp/baseline.norm.json and /tmp/candidate.norm.json. At this stage, two sessions that are logically identical should produce byte-identical normalized files.

Step 4: Run the post-change replay and diff

After your prompt or model change, replay the same scenarios against the new build. Normalize the result, then compute a unified diff. Python’s difflib is enough for a first pass when you diff chatbot transcripts regression across builds.

import difflib, json, sys

base = normalize("baseline.jsonl")
cand = normalize("candidate.jsonl")

base_txt = [json.dumps(r) for r in base]
cand_txt = [json.dumps(r) for r in cand]

diff = difflib.unified_diff(base_txt, cand_txt, lineterm="", fromfile="baseline", tofile="candidate")
changed = False
for line in diff:
    print(line)
    if line.startswith(("+", "-")) and not line.startswith(("+++", "---")):
        changed = True

sys.exit(1 if changed else 0)

For long conversations, line-level diff on serialized JSON hides intent. Use a sentence tokenizer and diff at the utterance level:

from nltk.tokenize import sent_tokenize

def split_sentences(norm):
    for turn in norm:
        for s in sent_tokenize(turn["content"]):
            yield f'{turn["role"]}: {s}'

Then diff the sentence streams. This surfaces exactly which bot statement diverged, rather than dumping a 40-line JSON blob because one comma moved.

Example diff output:

- assistant: I can refund your order if it shipped less than 30 days ago.
+ assistant: I can refund your order if it shipped less than 14 days ago.

That single sentence change is a policy regression, and the diff caught it in seconds.

Step 5: Classify diffs by severity

Not every diff is a regression. Build a tiny classifier to prioritize review:

  • Exact content change in an assistant turn → high severity.
  • Tool call argument reorder with same values → low, but log it.
  • Finish reason shift (e.g., stop vs length) → medium; may indicate truncation.
  • User turn diff → your test harness is broken, fail fast.
def severity(a, b):
    if a["role"] != b["role"]:
        return "harness-error"
    if a["role"] == "assistant" and a["content"] != b["content"]:
        return "high"
    if a["finish_reason"] != b["finish_reason"]:
        return "medium"
    if a["tool_calls"] != b["tool_calls"]:
        return "low"
    return "none"

Feed the diff lines into this and emit a summary count. Gate deployments on high-severity counts > 0. For borderline cases, compute cosine similarity between embedded sentences; if similarity > 0.95, downgrade high to medium. That avoids false alarms on synonym swaps.

Step 6: Wire diffing into CI

Add a pytest that runs replay + diff on a small smoke set. Keep the full suite as a scheduled job to avoid burning tokens on every PR.

def test_transcript_regression():
    run_replay("baseline.jsonl", "candidate.jsonl")
    base = normalize("baseline.jsonl")
    cand = normalize("candidate.jsonl")
    assert len(base) == len(cand), "turn count changed"
    highs = 0
    for a, b in zip(base, cand):
        if severity(a, b) == "high":
            highs += 1
    assert highs == 0, "assistant output regressed"

In GitHub Actions:

- name: Transcript regression
  run: pytest tests/test_transcript_regression.py
  env:
    LLM_BASE_URL: ${{ secrets.LLM_BASE_URL }}

If you use a routing directive to force a specific provider, the gateway should honor it; n4n.ai forwards client routing headers so your CI always hits the same backend. That consistency is what makes diff chatbot transcripts regression trustworthy across runs.

Budget tokens by limiting smoke scenarios to five high-value paths. Nightly full replays can cover fifty. Per-token metering from the gateway shows you exactly what the test suite costs per month.

Step 7: Verify success

Verification is concrete. First, run the diff on two copies of the same baseline—expect zero changed lines and exit 0. Second, intentionally modify one assistant response in candidate.jsonl and rerun; the script should exit 1 and print the exact sentence. Third, after a real prompt edit, confirm the diff report shows only the turns you expected to change.

A healthy pipeline produces a per-scenario report:

scenario: checkout_flow
  turns: 12
  high: 0
  medium: 1 (finish_reason length->stop)
  low: 2 (whitespace)
result: PASS

If you see high: 0 on every scenario after a model swap, you have evidence the change is behavior-preserving. If not, you caught a regression before users did.

Practical caveats

Temperature 0 does not guarantee identical output across providers; some endpoints sample differently. Freeze model versions by date stamp. Store baselines in Git LFS or a bucket, not in the repo itself, to avoid bloat.

Diffing chatbot transcripts regression is not a substitute for eval metrics, but it is the fastest signal that something moved. Wire it next to your unit tests and treat a red diff as a build blocker. When a prompt engineer proposes a tweak, make them attach the diff report in the PR.

Tagschatbotregression-testingtranscriptsdebugging

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All chatbot session replay & debugging posts →