EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / AI / ซ่อมไฟล์ session .jsonl ของ Claude Code ที่บวม 38 MB จนเปิดไม่ขึ้น
AI

ซ่อมไฟล์ session .jsonl ของ Claude Code ที่บวม 38 MB จนเปิดไม่ขึ้น

โดย Ton-Or ·8 ส.ค. 2026·อ่าน 8 นาที
ซ่อมไฟล์ session .jsonl ของ Claude Code ที่บวม 38 MB จนเปิดไม่ขึ้น

ประวัติแชทกับ AI ก็คือไฟล์ log ธรรมดา ๆ ที่ต้องดูแลเหมือน log อื่น พอปล่อยให้โตอย่างเดียวไม่เคยตัดจบ วันหนึ่งมันก็เปิดไม่ขึ้น และหลักการซ่อมมันใช้ได้กับไฟล์ append-only ทุกแบบ ไม่ใช่แค่ของ Claude Code

พอดีเปิด workspace เก่าแล้วประวัติ session เปิดไม่ขึ้น UI ค้างค้างอยู่อย่างนั้น กด /resume เข้าไปก็รอจนเบื่อ ไปดูไฟล์ถึงได้รู้ว่า session เดียวที่ผมลากงานยาวไป 8 วันมันโตเป็น 38.61 MB มี 11,537 records — ตัวโปรแกรมต้องโหลดไฟล์เดียวขนาดนั้นเข้า memory แล้ว parse JSON ทุกบรรทัดก่อนจะแสดงอะไรได้ ผมเลยนั่งเขียนสคริปต์ Python ประมาณร้อยบรรทัดมาลดน้ำหนักมันแทนที่จะลบทิ้งทั้ง session

ผลคือเหลือ 17.41 MB ลดไป 55% โดยที่ข้อความของคนและของผู้ช่วยยังครบทุกบรรทัด ลำดับเดิม ไม่มี orphan สักตัว ตัวสคริปต์ผมเก็บไว้ถาวรแล้วเพราะโอกาสได้ใช้ซ้ำมีสูงมาก

38.61 MBขนาดก่อนซ่อม
17.41 MBหลังซ่อม ลดไป 55%
11,537records ทั้งไฟล์
8,171uuid ของข้อความ ครบเท่าเดิม

session นี้กินเวลา 8 วัน มี assistant turn 5,218 รอบ สาเหตุที่บวมไม่มีอะไรซับซ้อนครับ — ทำงานยาวใน session เดียวไม่ตัดจบเป็นรอบ ๆ แล้วทุก tool call ที่อ่านไฟล์ใหญ่ ทุกคำสั่งที่พ่น output ยาว ทุก screenshot ถูก append ลงไฟล์เดียวกันหมด

กายวิภาคของไฟล์ .jsonl ก่อน แล้วค่อยแตะ

ไฟล์ session เป็น JSON Lines ครับ หนึ่งบรรทัดคือหนึ่ง JSON object คือหนึ่ง record ประเภทที่เจอมีสองกลุ่มใหญ่

record ประเภทข้อความ (type: "user" หรือ "assistant") จะมี uuid, parentUuid, timestamp และ message.content[] ที่ข้างในเป็นบล็อกย่อยได้หลายแบบ — text, thinking, tool_use, tool_result, image — บางบรรทัดยังมี toolUseResult แยกไว้ที่ระดับบนสุดอีกก้อน

อีกประเภทคือ file-history-snapshot ที่เก็บ snapshot เนื้อไฟล์ไว้ให้ฟีเจอร์ /rewind ของ session ที่ยังวิ่งอยู่ ตัวนี้สำคัญตรงที่ มันไม่อยู่ในสาย uuid ของบทสนทนา

สายที่ห้ามขาดrecord Auuid->record BparentUuid = A->record CparentUuid = B->ลบ B ทิ้งC กลายเป็น orphan

transcript ถูกประกอบใหม่ด้วยการไล่สาย parentUuid ไปหา uuid ตามลำดับบรรทัดในไฟล์ ถ้าลบ record กลางสายทิ้ง ลูกของมันจะชี้ไป parent ที่ไม่มีอยู่จริง ผลคือบทสนทนาขาดหรือโหลดไม่ขึ้นทั้ง session และอีกคู่ที่ห้ามแยกคือ tool_use ฝั่งผู้ช่วยกับ tool_result ฝั่ง user ที่ต้องจับคู่ tool_use_id กันเสมอ ลบขาใดขาหนึ่งทิ้งแล้ว resume เมื่อไหร่ก็ error เรื่อง content block ไม่จับคู่ทันที

กฎข้อเดียวที่ต้องถือไว้

ลดขนาดข้างใน record ได้ แต่ห้ามลดจำนวน record ที่อยู่ในสาย — ยกเว้น record ประเภทที่ไม่อยู่ในสายเลยอย่าง file-history-snapshot ที่ทิ้งได้ทั้งก้อน กฎนี้ไม่ได้ผูกกับ Claude Code ใช้กับไฟล์ append-only ที่ผูกกันเป็นสายทุกแบบเลยครับ

ตัดอะไรได้ ตัดอะไรไม่ได้

ตัดได้ ไม่กระทบการอ่าน

4 อย่างนี้เท่านั้น

  • file-history-snapshot ทิ้งทั้ง record — เป็นข้อมูลของ /rewind ที่ใช้กับ session ที่กำลังรันอยู่ session ที่จบไปแล้วไม่มีใครย้อน (414 records = 5.67 MB)
  • thinking block ที่ thinking ว่าง เหลือแต่ signature ที่เข้ารหัสไว้ อ่านไม่ได้ ไม่แสดงผล (1,658 blocks = 7.09 MB)
  • รูป base64 แทนด้วย PNG 1x1 payload เหลือราวร้อยไบต์ แต่ยังเป็นบล็อก image ที่ถูก schema (33 รูป = 2.78 MB)
  • tool_result / toolUseResult clip ที่ 1,200 ตัวอักษรแล้วต่อ marker บอกว่าตัดไปเท่าไหร่
ห้ามแตะเด็ดขาด

ของที่หายแล้วหายเลย

  • message.content[].text ทั้งฝั่ง user และ assistant — นี่คือเนื้อบทสนทนาจริง
  • uuid / parentUuid / timestamp และลำดับบรรทัดในไฟล์
  • tool_use.input คือคำสั่งที่สั่งไป เป็นประวัติที่มีค่าที่สุดรองจากข้อความ (สคริปต์ผม clip ตัวนี้ที่ 1,200 ตัวเหมือนกัน ใครอยากเก็บเต็มปรับตรงนี้ได้)
  • thinking block ที่มีเนื้อจริง อ่านได้ ตัดไม่ได้

จะเห็นว่าพอบวกกันแล้ว snapshot 5.67 + signature 7.09 + รูป 2.78 = 15.54 MB คือของที่ไม่มีใครย้อนกลับไปอ่านทั้งนั้น ที่เหลือเป็น tool output ยาว ๆ เนื้อบทสนทนาจริงเป็นส่วนน้อยของไฟล์มาก

ก่อนซ่อม 38.61 MB5.67 MB7.09 MB2.78ส่วนที่เหลือ 23.07 MBsnapshotthinking ว่างรูปtool output ที่ยาว ๆ และอื่น ๆหลังซ่อม 17.41 MB17.41 MBลดไป 55% และ uuid 8,171 ครบเท่าเดิม
ส่วนสีแดงคือของที่ตัดทิ้งได้ รวม 15.54 MB ความยาวแท่งเทียบตามขนาดจริง

สคริปต์ซ่อม

ผมเก็บไว้ที่ ~/.claude/tools/repair_session_jsonl.py ตัวเต็มตามนี้ครับ

"""Slim a bloated Claude Code session .jsonl so it opens again.

KEEPS every user/assistant message record, its visible text, tool_use inputs,
uuid/parentUuid chain, timestamps -> the transcript still reads end to end.

SHRINKS the parts that carry no readable value:
  1. file-history-snapshot records   (only power /rewind on a live session)
  2. thinking blocks whose `thinking` text is EMPTY -> dropped whole
     (all payload is the opaque encrypted `signature`; nothing to read)
  3. base64 image blobs              -> 1x1 png placeholder
  4. tool_result / toolUseResult     -> clipped to CAP chars with a marker

Usage: repair_session_jsonl.py <in.jsonl> <out.jsonl> [--cap N]
"""
import json, sys, io, collections

IN, OUT = sys.argv[1], sys.argv[2]
CAP = int(sys.argv[sys.argv.index("--cap") + 1]) if "--cap" in sys.argv else 1200

PLACEHOLDER = ("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGP"
               "4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC")
MARK = "\n[... clipped by session-repair: %d chars ...]"
stats = collections.Counter()


def clip(s):
    if isinstance(s, str) and len(s) > CAP:
        stats["clipped_chars"] += len(s) - CAP
        return s[:CAP] + (MARK % (len(s) - CAP))
    return s


def strip_images(o):
    if isinstance(o, dict):
        if o.get("type") == "image" and isinstance(o.get("source"), dict):
            src = o["source"]
            d = src.get("data")
            if isinstance(d, str) and len(d) > len(PLACEHOLDER):
                stats["image_chars"] += len(d) - len(PLACEHOLDER)
                stats["images"] += 1
                src["data"] = PLACEHOLDER
                src["media_type"] = "image/png"
            return o
        for v in o.values():
            strip_images(v)
    elif isinstance(o, list):
        for v in o:
            strip_images(v)
    return o


def clip_deep(o):
    if isinstance(o, dict):
        return {k: clip_deep(v) for k, v in o.items()}
    if isinstance(o, list):
        return [clip_deep(v) for v in o]
    return clip(o)


kept = dropped_snap = dropped_think = 0
with io.open(IN, encoding="utf-8") as fin, \
     io.open(OUT, "w", encoding="utf-8", newline="\n") as fout:
    for line in fin:
        if not line.strip():
            continue
        try:
            o = json.loads(line)
        except Exception:
            stats["unparsed_passthrough"] += 1
            fout.write(line if line.endswith("\n") else line + "\n")
            kept += 1
            continue

        if o.get("type") == "file-history-snapshot":
            dropped_snap += 1
            stats["snapshot_chars"] += len(line)
            continue

        msg = o.get("message")
        if isinstance(msg, dict) and isinstance(msg.get("content"), list):
            new = []
            for b in msg["content"]:
                if (isinstance(b, dict) and b.get("type") == "thinking"
                        and not (b.get("thinking") or "").strip()):
                    dropped_think += 1
                    stats["signature_chars"] += len(b.get("signature") or "")
                    continue
                # clip only tool payloads; leave prose untouched
                if isinstance(b, dict) and b.get("type") in ("tool_result", "tool_use"):
                    b = clip_deep(b)
                new.append(b)
            # never leave an assistant turn with an empty content array
            msg["content"] = new if new else [{"type": "text", "text": ""}]

        strip_images(o.get("message"))
        strip_images(o.get("attachment"))
        if "toolUseResult" in o:
            o["toolUseResult"] = clip_deep(strip_images(o["toolUseResult"]))

        fout.write(json.dumps(o, ensure_ascii=False) + "\n")
        kept += 1

print("kept %d lines | dropped %d snapshots, %d empty thinking blocks"
      % (kept, dropped_snap, dropped_think))
for k, v in sorted(stats.items()):
    extra = "  (%.2f MB)" % (v / 1048576) if k.endswith("chars") else ""
    print("  %-20s %10d%s" % (k, v, extra))

บรรทัดที่ผมอยากให้ดูให้ดี

ตัวสคริปต์อ่านทีละบรรทัดแบบ streaming ไม่โหลดทั้งไฟล์เข้า RAM ไฟล์ 40 MB ก็เลยไม่กินอะไร แต่จุดที่เป็นสาระจริง ๆ คือรายละเอียดเล็ก ๆ พวกนี้ครับ

except Exception: passthrough — บรรทัดที่ parse JSON ไม่ผ่านผมเขียนคืนดิบ ๆ ไม่ทิ้ง ปลอดภัยไว้ก่อนดีกว่าไปเดาว่ามันไม่สำคัญ

msg["content"] = new if new else [{"type":"text","text":""}] — assistant turn ที่เหลือ content เป็น array ว่างคือผิด schema พอ resume แล้วส่งกลับ API จะเจอปัญหาทันที เลยต้องยัด text ว่างแทนเสมอ

clip_deep ถูกเรียกเฉพาะบล็อกที่เป็น tool_result กับ tool_use และคีย์ toolUseResult เท่านั้น บล็อก text ไม่โดนแตะ — ตรงนี้สำคัญมาก ถ้าเผลอ clip ทุกสตริงที่ยาวเกิน CAP ทั้งไฟล์ ข้อความยาว ๆ ของคนกับคำตอบยาว ๆ จะโดนตัดไปด้วย เสียของเปล่า ๆ

ensure_ascii=False ต้องมี ไม่งั้นภาษาไทยกลายเป็น \uXXXX แล้วไฟล์บวมกลับเกือบเท่าเดิม และ newline="\n" ตอนเขียนบน Windows กันไม่ให้กลายเป็น CRLF

strip_images เดินลึกทั้ง message และ attachment เพราะรูปจาก paste กับจาก screenshot มันอยู่คนละที่กัน ส่วน placeholder เป็น PNG 1x1 จริง ถอด base64 แล้วเปิดได้ และต้องบังคับ media_type: image/png ด้วยเพราะของเดิมอาจเป็น jpeg

ขั้นตอนใช้งานจริง

  1. ปิด Claude Code ก่อนเสมอ ถ้ายังมี process ถือไฟล์อยู่ มันจะเขียนทับกลับให้ดูเล่น
  2. backup ตัวเต็มไว้ก่อน 38 MB ไม่ได้ใหญ่อะไร เก็บไว้จนกว่าจะยืนยันว่าเปิด session เก่าได้จริงค่อยลบ
  3. รันสคริปต์ออกเป็น ไฟล์ใหม่ ไม่เขียนทับตัวเดิม
  4. ตรวจก่อนสลับ — จำนวน uuid ต้องเท่าเดิม และ orphan ต้องเป็น 0
  5. ผ่านแล้วค่อยเอาไฟล์ที่ซ่อมทับของเดิม แล้วเปิดโปรแกรมใหม่
# 1. backup ตัวเต็ม
cp ~/.claude/projects/<workspace>/<session-uuid>.jsonl \
   ~/.claude/backups/<session-uuid>.jsonl.$(date +%Y%m%d).bak

# 2. ซ่อมออกเป็นไฟล์ใหม่
python repair_session_jsonl.py <session-uuid>.jsonl <session-uuid>.fixed.jsonl --cap 1200

# 3. เทียบขนาดก่อนสลับ
ls -lh <session-uuid>.jsonl <session-uuid>.fixed.jsonl

ตัวตรวจก่อนสลับผมเขียนสั้น ๆ แค่นี้ครับ นับ uuid ทั้งไฟล์แล้วไล่ดูว่ามี parentUuid ตัวไหนชี้ไป uuid ที่ไม่มีอยู่บ้าง

import json, sys
uu, orphan = set(), 0
rows = [json.loads(l) for l in open(sys.argv[1], encoding="utf-8") if l.strip()]
for o in rows:
    if o.get("uuid"): uu.add(o["uuid"])
for o in rows:
    p = o.get("parentUuid")
    if p and p not in uu: orphan += 1
print(len(rows), "records |", len(uu), "uuid |", orphan, "orphan")

รันกับไฟล์เดิมและไฟล์ที่ซ่อมแล้วเทียบกัน ผลที่ต้องได้คือ 8,171 uuid เท่ากันทั้งสองไฟล์ และ orphan = 0 ถ้าตัวเลข uuid ไม่เท่าหรือ orphan ขึ้นมาแม้แต่ตัวเดียว อย่าเอาไปทับเด็ดขาด กลับไปดูเงื่อนไขการตัดใหม่

สามข้อที่พลาดแล้วเจ็บ

1) ตัด file-history-snapshot ทิ้ง = ทิ้ง /rewind ของ session นั้นถาวร ยอมรับได้ถ้า session จบแล้ว แต่ห้ามทำกับ session ที่ยังทำงานค้างอยู่ · 2) thinking block ที่มีเนื้อจริงห้ามตัด เงื่อนไขต้องเป็น "thinking ว่างหลัง strip" เท่านั้น ถ้าเขียนเงื่อนไขเป็น type == thinking เฉย ๆ จะกินเนื้อหาที่อ่านได้ไปด้วยทั้งหมด · 3) ห้ามลบ record ที่มี tool_use โดยไม่ลบ tool_result คู่ของมัน และกลับกัน — resume แล้วพังแน่นอน

กันไม่ให้บวมซ้ำ

ส่วนตัวผมสรุปว่าตัวปัญหาไม่ใช่ขนาดไฟล์หรอกครับ แต่เป็นนิสัยลากงานทั้งสัปดาห์ไว้ใน session เดียว เคสนี้คือ 8 วันกับ assistant turn 5,218 รอบในไฟล์เดียว

วิธีที่ผมเปลี่ยนมาใช้คือแยก session ตามงาน แล้วสรุปเข้า memory ก่อนเลิกงานแต่ละรอบ รอบถัดไปเปิด session ใหม่ต่อจากสรุปนั้น ได้สองเด้งคือไฟล์เล็กลงและบริบทกระชับกว่าเดิมด้วย เพราะ session ที่ยาวเกินไปมันไม่ได้แค่ไฟล์ใหญ่ แต่พาของเก่าที่ไม่เกี่ยวมาถ่วงตลอดทาง

ที่เหลือคือระวัง action ที่ผลิต payload ใหญ่ต่อรอบ — อ่านไฟล์ใหญ่ทั้งไฟล์ คำสั่งที่พ่น output ยาว ๆ และ screenshot รัว ๆ (แค่ 33 รูปก็ 2.78 MB แล้ว) แล้วก็ตั้งนิสัยเช็กขนาดบ้าง ถ้าไฟล์ session เกินสัก 15-20 MB ให้เริ่มคิดได้แล้วว่าถึงเวลาตัดจบ

ls -S ~/.claude/projects/*/*.jsonl | head -5 | xargs ls -lh

ใครจะปรับ --cap ให้มากกว่า 1,200 เพื่อเก็บ tool output ไว้เยอะขึ้นก็ตามสะดวกครับ ผมเลือกเลขนี้เพราะมองว่าเกินจากนี้แทบไม่มีใครย้อนกลับไปอ่านอยู่แล้ว แต่หลักการที่อยากให้ติดไปใช้จริง ๆ คือข้อเดียว — ลดน้ำหนักข้างใน record ได้ แต่อย่าลดจำนวน record ที่อยู่ในสาย ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

ยังไม่มีความคิดเห็น — เป็นคนแรกเลย