บทความนี้เป็นตอนที่ 1 ของซีรีส์ "เทคนิคการไม่อ่าน" เรียงอาการที่ไปได้ไม่ดีกับวิธีแก้ไว้ทีละอย่าง แต่ละตอนจบได้ด้วยการวางไฟล์หรือสคริปต์หนึ่งอย่าง ทำไมกลไกนั้นถึงจำเป็น อ่านคำอธิบายหลังวางแล้วก็จะเข้าใจ ภาพรวมของซีรีส์และรายการตอนทั้งหมดอยู่ในบทนำ

คราวนี้ว่าด้วยผลลัพธ์ของ test ทุกครั้งที่ AI รัน test ผลลัพธ์หลายหมื่นบรรทัดจะไหลเข้าไปใน context window นั้น AI เลือกผลลัพธ์ที่ส่งมาถึงไม่ได้ เพราะทั้งหมดเข้าไปอยู่ใน context window ยิ่งเพิ่มก็ยิ่งเจือจางแบบที่บทนำพูดถึงจึงเกิดขึ้นที่นี่เหมือนกัน ที่ต่างออกไปคือสิ่งที่กำลังเพิ่มขึ้นไม่ใช่คำสั่ง แต่เป็นผลลัพธ์ของ tool

เริ่มจากการตรวจสอบเพียงอย่างเดียว ใน session ล่าสุดกับ AI coding agent ให้มองหาผลลัพธ์ตรงจุดที่รัน test มีกี่บรรทัด แล้วมีใครอ่านผลลัพธ์นั้นจนจบบ้างไหม

คงไม่มีใครอ่าน แต่ AI เป็นอีกเรื่องหนึ่ง AI ข้ามผลลัพธ์ของ tool ไปไม่ได้ ทุกบรรทัดที่ส่งมาถึงจะเข้าไปอยู่ใน context window กลายเป็นวัตถุดิบของคำตอบถัดไป และกลายเป็นค่าใช้จ่ายด้วย รันไปแก้ไป 5 ครั้งก็เข้าไป 5 ครั้ง ยิ่ง context window แน่นขึ้น คุณภาพของคำตอบก็ยิ่งตก จึงเป็นโครงสร้างที่ยิ่งรัน test ความสามารถในการแก้ test ก็ยิ่งลดลง

ใน production project ที่อยู่ในมือของผมเอง ผลลัพธ์ของ test ทั้งหมดมี 26,147 บรรทัด (2.1MB) คือ typingtube web service ที่ฝึกพิมพ์ดีดไปกับมิวสิกวิดีโอบน YouTube ได้ ซึ่งดูแลเองอยู่คนเดียว พอรัน test 13,950 รายการก็ได้ปริมาณเท่านี้ สิ่งที่ ทำให้เหลือ 3 บรรทัด คือ wrapper (สคริปต์ที่ครอบ command เดิมไว้แล้วรันแทน)

สิ่งที่เข้าใจแล้วเขียนเป็นประโยคเดียวได้

AI เลือกผลลัพธ์ที่ส่งมาถึงไม่ได้ ดังนั้นฝั่งที่ส่งออกต้องบีบให้เหลือแค่ความล้มเหลวกับจำนวนรายการ

CC BY 4.0

ทำไมยิ่งรันก็ยิ่งแก้ไม่ได้

ประวัติบทสนทนาจะส่งใหม่ทั้งก้อนทุกครั้งที่ตอบ ผลลัพธ์ที่รันไปแล้วไม่ได้หายไปตรงนั้น หลังรันไป 5 ครั้ง ของทั้ง 5 ครั้งก็ขึ้นมาทุกครั้ง

แต่ไม่ใช่ว่าทั้งหมดจะส่งมาถึง ทาง Claude Code วางขีดจำกัดของผลลัพธ์จาก command ไว้ ส่วนที่เกินจะตัดออก ใน 26,147 บรรทัดนั้น สิ่งที่ AI ได้รับคือส่วนที่คัดมาจากหัวและท้าย ถ้าความล้มเหลวอยู่ตรงกลาง AI ก็อ่านว่า "ผ่าน" ทั้งที่ไม่ได้เห็น

ในการวัดที่ให้ค้นหาบรรทัดที่ต้องการจาก input ที่ยาว แค่มีบรรทัดที่ไม่เกี่ยวข้องปนเข้ามา 1 บรรทัด ความแม่นยำก็ลดลง ผลลัพธ์ของ test นั้น เมื่อมองจาก 1 รายการที่กำลังแก้อยู่ ก็อยู่ฝั่งที่เกี่ยวข้องน้อยที่สุด

กลไก: เปลี่ยนทางเข้าของผลลัพธ์ให้เหลือทางเดียว

เปลี่ยนทางเข้าของการรัน test ให้เหลือ wrapper เพียงทางเดียว ข้อความเต็มไปที่ log ส่วนบทสนทนาได้แค่สรุป สคริปต์ต่อไปนี้คือโค้ดที่อยู่ในมือย่อให้สั้นลง (ตัวอย่างเป็น Rails / Minitest แต่แค่เปลี่ยนบรรทัดที่ grep ให้เข้ากับผลลัพธ์ของ test runner ของตัวเอง ก็ใช้กับอะไรก็ได้)

#!/bin/bash
# scripts/test.sh — เก็บผลลัพธ์ทั้งหมดไว้ใน log และคืนกลับไปที่บทสนทนาแค่สรุป
log=tmp/test_last.log
mkdir -p tmp
bin/rails test "$@" >"$log" 2>&1
status=$?

echo "--- summary (full log: ${log}) ---"
grep -E "^Finished in " "$log" | tail -1
grep -E "^[0-9]+ runs, " "$log" | tail -1   # บรรทัดจำนวนรายการ ⚠️ ห้ามตัดออก (อธิบายทีหลัง)

failures=$(grep -cE "^(Failure|Error):" "$log" || true)
if [ "${failures:-0}" -gt 0 ]; then
  echo "--- ${failures} failure(s)/error(s) (first excerpt) ---"
  grep -A 3 -E "^(Failure|Error):" "$log" | head -40
fi
exit $status

สิ่งที่ออกมามีแค่นี้

--- summary (full log: tmp/test_last.log) ---
Finished in 277.851362s, 50.2067 runs/s, 318.7712 assertions/s.
13950 runs, 88571 assertions, 0 failures, 0 errors, 228 skips

สรุปที่อยู่ในมือของผมมี 29 บรรทัด และรูปแบบเล็กที่สุดข้างบนคือแกน 3 บรรทัด บรรทัดที่เหลือเป็นรายการของส่วนที่ยังไม่ได้รัน เป็นต้น ข้อความเต็มจะเหลืออยู่ที่ tmp/test_last.log ทุกครั้ง และบรรทัดแรกของสรุปจะบอกตำแหน่งนั้น การลบทิ้งก็ไม่ดีเหมือนกัน ต้องให้ AI กลับไปอ่านตรงนั้นซ้ำได้

ที่สำคัญคือไม่ได้เพิ่มคำสั่งให้ AI แม้แต่บรรทัดเดียว จะเขียนลงในไฟล์คำสั่งว่า "ให้ย่อผลลัพธ์ก่อนอ่าน" ก็ทำได้ แต่อย่างที่เห็นในบทนำ นั่นคือคำตอบฝั่งยิ่งเพิ่มก็ยิ่งเจือจาง สิ่งที่ผมเปลี่ยนไม่ใช่คำสั่ง แต่เป็นทางเข้า กฎนั้นฝ่าฝืนได้ แต่ทางเข้ามีอยู่แค่ตรงนั้น

แต่แค่วางไว้ AI ก็ยังไม่ยอมเดินผ่านทางเข้านี้ command มาตรฐานอย่าง rails test เป็นคำที่ฝังลึกอยู่ใน AI มาจากการเรียนรู้ ส่วน scripts/test.sh ที่เพิ่งวางไว้เป็นคำที่ AI เห็นเป็นครั้งแรกในวันนี้ ต่อให้เขียนลงในไฟล์คำสั่งว่า "ให้ใช้ wrapper" สิ่งที่โผล่ขึ้นมาในวินาทีที่จะรัน test ก็ยังเป็นฝั่ง command มาตรฐานที่เห็นมาแล้วหลายร้อยล้านครั้ง หนึ่งบรรทัดในไฟล์คำสั่งต้องไปแข่งลำดับความสำคัญกับความเคยชินที่ฝังอยู่ใน AI และส่วนใหญ่ก็แพ้

เอกสารทางการ ก็ขีดเส้นเดียวกัน สิ่งที่เขียนคือ "Bash command ที่ AI เดาไม่ได้" สิ่งที่ไม่เขียนคือ "ธรรมเนียมมาตรฐานของภาษาที่ AI รู้อยู่แล้ว" rails test คืออย่างหลัง ส่วน scripts/test.sh คืออย่างแรก สิ่งที่ไฟล์คำสั่งถือไว้ได้มีแค่ฝั่งที่ AI ไม่รู้ ผมจึงปิดฝั่ง command มาตรฐานไว้ การปิดจนสนิทก็ไม่ดีเหมือนกัน จึงเปิดทางเลี่ยงไว้หนึ่งทางแบบระบุชัด

ถ้าเป็น Claude Code การเติม hook หนึ่งตัวที่หยุด rails test ตรง ๆ แล้วพาไปหา wrapper จะทำให้ทางเข้าเหลือทางเดียวจริง ๆ hook คือสคริปต์ที่แทรกเข้ามาก่อนที่ command จะรัน และลงทะเบียนไว้ใน .claude/settings.json แบบนี้

{
  "hooks": {
    "PreToolUse": [{
      "matcher": "Bash",
      "hooks": [{
        "type": "command",
        "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/use_test_wrapper.sh"
      }]
    }]
  }
}

เนื้อในมีแค่ "ถ้า command เป็น rails test ตรง ๆ ก็แสดงวิธีใช้ wrapper แล้วจบโดยไม่ให้รัน"

#!/bin/bash
# .claude/hooks/use_test_wrapper.sh
command=$(cat | python3 -c 'import json,sys; print(json.load(sys.stdin).get("tool_input",{}).get("command",""))')
case "$command" in
  *scripts/test.sh*|*SKIP_TEST_GUARD=*) exit 0 ;;   # ให้ผ่านทั้งทางที่ผ่าน wrapper และทางเลี่ยงที่ระบุชัด
esac
if printf '%s' "$command" | grep -qE '(^|[;&|[:space:]])(bin/)?rails[[:space:]]+test([[:space:]]|$)'; then
  echo "Run tests with scripts/test.sh <same args> (full output stays in tmp/test_last.log)" >&2
  exit 2   # exit 2 = ไม่ให้รัน และให้ AI อ่านคำแนะนำใน stderr
fi
exit 0

AI ขยับด้วย command ตรง ๆ ไม่ได้ และไปต่อได้ก็ต่อเมื่ออ่านคำแนะนำแล้วเท่านั้น สิ่งที่ได้ผลตรงนี้ไม่ใช่ถ้อยคำของคำแนะนำ แต่เป็นตำแหน่งที่คำแนะนำไปถึง คำแนะนำของ hook จะกลับมาในรูปของการปฏิเสธการรัน ตรงจุดที่ AI พิมพ์ command ลงไป ถ้าคนเข้ามาแทรกในบทสนทนาว่า "ใช้ wrapper สิ" อันนั้นจะไปเรียงต่อท้ายในฐานะการโต้ตอบอีกอันหนึ่ง ต่างกันตรงที่ตำแหน่งหนึ่งเลือกได้ว่าจะอ่านหรือไม่อ่าน กับอีกตำแหน่งที่ถ้าไม่อ่านก็ไปต่อไม่ได้

สิ่งที่เลิกอ่าน

คือผลลัพธ์ดิบของ test ทั้งคนและ AI ก็ไม่อ่าน ไม่ได้ทิ้งไป จึงเปิด log เฉพาะเมื่อการสืบหาสาเหตุต้องใช้ข้อความเต็มจริง ๆ

ข้อควรระวัง: ความผิดปกติที่ไม่ใช่ความล้มเหลวจะมองไม่เห็น

จุดบอดของกลไกนี้คือความผิดปกติที่ไม่ได้อยู่ในรูปของความล้มเหลวจะซ่อนตัวอยู่ จำนวน skip ที่พุ่งขึ้น คำเตือน จำนวนรายการที่รันลดลง แม้ test ทั้งก้อนจะไม่ได้โหลดขึ้นมา ถ้าที่เหลือผ่านทั้งหมดก็เป็น "0 failures"

ในมือของผมเองเคยมีครั้งที่การรัน test ไม่ขึ้นมาตั้งแต่แรก ทั้งที่ไม่ได้รันแม้แต่รายการเดียว กลับเหลือไว้แค่บันทึกว่ารันไปแล้ว เมื่อเป็นแบบนั้นความล้มเหลวมี 0 รายการ และสรุปก็เงียบอยู่อย่างนั้น

ดังนั้นเฉพาะบรรทัดจำนวนรายการเท่านั้นที่ไม่ตัดออกจากสรุป ที่ว่านั้นคือ 13950 runs, 88571 assertions, 0 failures, 0 errors, 228 skips ในตัวอย่างการรันข้างบน การย่อมากเกินไปก็ไม่ดีเหมือนกัน ไม่ใส่แค่ตัวเลข แต่ต้องเรียงชื่อรายการคู่กับตัวเลขเสมอ

วิธีการตรวจสอบ: ยืนยันเพียงครั้งเดียวว่าความล้มเหลวส่งมาถึง

เงื่อนไขเบื้องต้น

  • วาง wrapper ของตอนนี้ (scripts/test.sh) แล้ว และลงทะเบียน hook ที่หยุด command รัน test ตรง ๆ เรียบร้อยแล้ว
  • มี test อย่างน้อย 1 รายการ และรันได้ในมือ

เวลาที่ใช้: 5 นาที

ขั้นตอน

  1. คุณทำให้ test พังหนึ่งรายการโดยตั้งใจ เปิดไฟล์ test ที่รันจบเร็วขึ้นมา 1 ไฟล์ แล้วแก้ค่าที่คาดหวังเพียง 1 ตัวอักษร (เปลี่ยน assert_equal "abc", ... เป็น "abd") เพราะจะคืนสภาพเดิมในขั้นเก็บกวาด จึงจดชื่อไฟล์กับหมายเลขบรรทัดเอาไว้
  2. ขอให้ AI ว่า "รัน test ให้หน่อย" ไม่ใช่ให้คุณรัน wrapper เองโดยตรง แต่ให้ขอ AI เพราะสิ่งที่อยากตรวจสอบคือเมื่อ AI เดินผ่านทางเข้าแล้วจะมีอะไรส่งมาถึง

เกณฑ์ผ่าน (สรุปที่ส่งกลับมาต้องเข้าเกณฑ์ครบทุกข้อ)

  • มีตำแหน่งของ log ข้อความเต็มออกมา (บรรทัดแบบ tmp/test_last.log)
  • มีชื่อ test ที่พัง กับส่วนต่างระหว่างค่าที่คาดหวังกับค่าที่ได้จริงออกมา (บรรทัดที่ตรงกับ Expected: "abc" และ Actual: "abd" ชื่อไฟล์ที่จดไว้ในขั้นตอนที่ 1 จะออกมาตรงนี้)
  • failures ในบรรทัดจำนวนรายการเป็น 1 ขึ้นไป (บรรทัดแบบ 13950 runs, 1 failures)

เมื่อไม่ผ่าน

  • ออกมาเป็น 0 failures แปลว่า test ที่พังไม่ได้รันตั้งแต่แรก ทบทวนว่าไฟล์ที่เลือกในขั้นตอนที่ 1 อยู่ในขอบเขตของการรันหรือไม่ แล้วทำขั้นตอนที่ 2 ใหม่
  • ล้มเหลวแล้วแต่ชื่อ test หรือส่วนต่างไม่ออกมา แปลว่าบรรทัดที่สรุปเก็บมายังไม่พอ ให้ขยายขอบเขตของ grep ใน wrapper

เก็บกวาด

  • คืนตัวอักษร 1 ตัวที่แก้ไปให้เป็นเหมือนเดิม แล้วให้ AI รันอีกครั้ง ถ้าบรรทัดจำนวนรายการกลับไปเป็น 0 failures ก็จบ
  • ถ้าลืมคืนสภาพเดิม งานหลังจากนี้จะล้มเหลวต่อเนื่องไปเรื่อย ๆ

สิ่งที่ตรวจสอบไปคือฝั่ง "ล้มเหลวแล้วต้องส่งมาถึงแน่นอน" เท่านั้น ถ้าเริ่มใช้งานจริงโดยไม่ได้เห็นตรงนี้ ก็จะใช้ต่อไปโดยแยกไม่ออกว่าที่สรุปเงียบนั้นเป็นเพราะ "ไม่มีความล้มเหลว" หรือเป็นเพราะ "ต่อกันแบบที่ความล้มเหลวส่งมาไม่ถึง"


ตอนหน้าคือ "เทคนิคการไม่อ่าน memory" memory ที่ AI เขียนสะสมไว้ ผมไม่ได้กลับไปอ่านซ้ำ ถึงอย่างนั้นก็ไม่เคยเดือดร้อนเพราะ memory เพิ่มขึ้น


ซีรีส์ "เทคนิคการไม่อ่าน"

จบส่วน CC BY 4.0