บันทึกวิจัย

LLM ชอบ "หลบด่าน" ที่เราตั้งไว้

สรุปบทความ LLMs Will Cheese Your Types ของ Justin Le เขาเขียน Haskell ร่วมกับ AI แล้วเก็บสถิติว่า AI มีท่าหลบอะไรบ้างเวลาเจอ type ที่เข้มงวด ทุกท่าคอมไพล์ผ่านหมด รีวิวผ่านตาเร็ว ๆ ก็ดูปกติ แต่มันแอบทำลายสิ่งที่เราตั้งใจสร้างไว้ทีละนิด บทความนี้ยกโค้ดมาให้ครบทุกตัวอย่าง พร้อมเวอร์ชัน Scala 3 และ TypeScript คู่กันทุกท่า


ลองนึกภาพเกมที่มีกำแพงกั้นไม่ให้ตัวละครเดินลัดไปโซนสุดท้าย แล้ววันหนึ่งมีคนค้นพบว่า ถ้ากระโดดมุมนี้แล้วชนกำแพงองศานี้ ตัวละครจะทะลุออกไปเลย วงการเกมเรียกท่าแบบนี้ว่า "ชีส" (cheese) คือเอาชนะด่านโดยไม่ได้เล่นตามที่คนออกแบบตั้งใจ ไม่ได้โกงถึงขั้นแฮ็ก แต่ก็ไม่ได้ผ่านด่านจริง ๆ

ชื่อบทความต้นฉบับคือ "LLMs Will Cheese Your Types" แปลว่า "AI จะชีสระบบ type ของคุณ" ครับ

แกะคำศัพท์

Type อ่านว่า "ไทป์" แปลว่า "ชนิดของข้อมูล" คือการบอกภาษาโปรแกรมล่วงหน้าว่าตัวแปรนี้เก็บอะไรได้บ้าง ถ้าใครเผลอยัดของผิดชนิดลงไป โปรแกรมจะไม่ยอมคอมไพล์ตั้งแต่แรก

Haskell อ่านว่า "แฮสเคลล์" เป็นภาษาโปรแกรมสาย functional ที่ขึ้นชื่อเรื่องระบบ type ที่เข้มงวดและแสดงออกได้ละเอียดมาก ตั้งชื่อตามนักตรรกศาสตร์ Haskell Curry ("แฮสเคลล์ เคอร์รี") จุดขายของมันคือ ถ้าออกแบบ type ดีพอ สถานะที่ผิดจะเขียนออกมาไม่ได้เลย

LLM ย่อจาก Large Language Model อ่านว่า "ลาร์จ แลงเกวจ โมเดล" แปลว่า "โมเดลภาษาขนาดใหญ่" ก็คือ AI แบบ Claude/ChatGPT ที่เราใช้เขียนโค้ดกันนี่แหละ

อ่านโค้ด Haskell ไม่ออกก็ไม่เป็นไร

ทุกท่าในบทความนี้ผมยกโค้ดต้นฉบับ Haskell มาให้ครบ แล้วเขียนเวอร์ชัน Scala 3 กับ TypeScript คู่กันทุกอัน ทุกฝั่งใช้ไลบรารีสาย functional ให้เต็มที่ (Scala ใช้ cats / cats-effect สำหรับ IO, fs2 สำหรับ stream, Pekko สำหรับ actor ; TypeScript ใช้ Effect-TS ที่มี Effect, Stream, Data, Brand ครบ) โค้ดแต่ละท่าอยู่ในกล่องที่มีแท็บให้กดสลับ Haskell / Scala / TypeScript เลือกอ่านฝั่งที่ถนัดได้เลยครับ ความหมายตรงกัน

อีกอย่าง ในโค้ดจะเจอเครื่องหมายย่อโค้ดอยู่บ่อย ๆ ฝั่ง Haskell ใช้ ... ฝั่ง Scala ใช้ ??? ส่วน TypeScript ใช้ todo() หรือ // ... ทั้งหมดแปลว่า "ตรงนี้มีโค้ดจริงอยู่ แต่ผมย่อไว้ให้ดูสั้น" ไม่ต้องไปงงกับมันครับ

Justin ใช้ AI ตัวไหน

ในบทความ Justin อ้างถึง Claude โดยตรง และเอ่ยถึงรุ่น Opus 4.8 ตอนพูดเรื่องการวางโครงโดเมน แต่เขาไม่ได้ประกาศชัดว่าโค้ดตัวอย่างทุกอันมาจากรุ่นเดียวกัน ส่วนใหญ่พูดกลาง ๆ ว่า "LLMs" หรือ "frontier models" ที่น่าคิดคือขนาด Opus 4.8 ซึ่งเป็นรุ่นเรือธง ก็ยังเจอท่าหลบพวกนี้

เพราะงั้นอ่านบทความนี้ในฐานะประสบการณ์และความเห็นของ Justin (บวกกับฝั่ง Scala ที่ผมเจอเอง) ไม่ใช่กฎตายตัว ถ้าเอาไปลองซ้ำกับโมเดลหรือเวอร์ชันอื่น ผลอาจต่างออกไปได้


ความฝันที่สวยงาม: type คือนั่งร้านให้ AI

Justin เริ่มจากมุมที่ผมว่าน่าสนใจมาก เขาบอกว่าในวงการคณิตศาสตร์ มีภาษาชื่อ Lean ("ลีน") ที่ใช้เขียนบทพิสูจน์แล้วให้คอมพิวเตอร์ตรวจให้ว่าถูกจริงไหม พอเอา AI มาช่วยพิสูจน์ทฤษฎีบท มันเลยเวิร์กมาก เพราะ AI ไม่ต้องเดาว่าตัวเองถูกหรือเปล่า มันลองมั่ว ๆ ไปเรื่อย แล้วให้ตัวตรวจสอบเป็นคนบอกว่ายังไม่ผ่าน วนไปจนกว่าจะผ่าน

เขาหวังว่า Haskell จะเล่นบทเดียวกันนี้ให้การเขียนโปรแกรม เราไม่ต้องหวังให้ AI เขียนถูกตั้งแต่ครั้งแรก (ซึ่งเขาบอกตรง ๆ ว่าความถูกต้องตั้งแต่ตอนสร้างนั้น ไม่สมจริง) เราแค่ต้องสร้างนั่งร้านที่บีบให้การมั่วของมันเดินไปในทางที่ถูกเท่านั้น

แต่พอลงมือจริง เขาเจอปัญหาว่า AI ชอบเดินอ้อมนั่งร้านมากกว่าปีนขึ้นไป

ต้นตอของปัญหา

AI ถูกเทรนด้วยโค้ดในโลกจริงเป็นหลัก ซึ่งส่วนใหญ่คือ Python กับ JavaScript ที่ไม่เข้มงวดเรื่อง type เลย นิสัย "หาทางที่เร็วที่สุดให้มันรันผ่าน ๆ ไปก่อน" เลยติดมาด้วย พอเจอกำแพง type มันจึงไม่ได้คิดว่ากำแพงนี้กำลังบอกอะไรฉัน แต่คิดว่าจะข้ามกำแพงนี้ยังไงให้เร็วที่สุด


ท่าที่ 1: ปิดเสียงเตือนทิ้งซะเลย

ท่าที่ตรงไปตรงมาที่สุด คอมไพเลอร์เตือนว่า "เฮ้ย เคสนี้นายยังไม่ได้จัดการนะ" แล้ว AI ก็ไปเติมคำสั่งปิดคำเตือนนั้นซะ

ท่าปิดปากคอมไพเลอร์
{-# OPTIONS_GHC -Wno-incomplete-patterns #-}
-- หรือแบบเจาะจงจุด
{-# HLINT ignore "Use head" #-}

ฝั่ง Scala 3 ยิ่งมีให้เลือกเยอะกว่าด้วยซ้ำ เพราะมีทั้ง annotation, flag ใน build, และการ cast ตรง ๆ กดแท็บ Scala ดูได้เลยครับ

เปรียบเทียบง่าย ๆ คือรถขึ้นไฟเตือนน้ำมันเครื่อง แล้วเราแก้ด้วยการเอาเทปดำแปะทับไฟดวงนั้น รถวิ่งต่อได้ หน้าปัดสวยงามเหมือนเดิม

Justin ยอมรับว่า มีบางกรณี ที่การปิดคำเตือนสมเหตุสมผลจริง ๆ แต่ประเด็นคือ AI แทบไม่มีวิจารณญาณพอจะแยกได้ว่าตอนไหนควรตอนไหนไม่ควร เขาเลยมองว่าการตัดสินใจปิดเสียงเตือนควรเป็นสิทธิ์ของคนเท่านั้น


ท่าที่ 2: ตกลงกันไว้แบบหนึ่ง เขียนออกมาอีกแบบ

อันนี้เจ็บกว่า เพราะมันเกิดหลังจากที่เรานั่งวางแผนกับ AI มาอย่างดีแล้ว ตกลงกันเรียบร้อยว่าจะใช้ type แบบไหน พอถึงเวลาเขียนจริง มันเปลี่ยนเงียบ ๆ

ขอปูฉากก่อน สมมติเรากำลังทำระบบที่มีสามงานเล็ก ๆ งานแรก summarize สรุปรายงานจากตัวเลขชุดหนึ่ง เราตกลงกันว่ามันต้องมีตัวเลขอย่างน้อยหนึ่งตัว (ลิสต์ว่างสรุปอะไรไม่ได้) เลยใช้ type ที่ชื่อ NonEmpty การันตีตรงนี้ งานที่สอง notify ยิงแจ้งเตือนออกไปตามช่องทางที่มีจริงเท่านั้น (อีเมล / SMS / push) เลยทำ Channel เป็นตัวเลือกตายตัว งานที่สาม persist เซฟข้อมูลลงที่เก็บ เราตกลงว่ารับเฉพาะชนิดที่แปลงเป็นไบนารีได้ (Binary) จะได้เขียนลงไฟล์ได้จริง สามข้อนี้คือ "แผน" ที่เราคุยกับ AI ไว้ ทีนี้มาดูว่าพอเขียนจริงมันเป็นยังไง

แผน vs. ของจริง
-- ที่ตกลงกันไว้ตอนวางแผน
summarize :: NonEmpty Int -> Summary
data Channel = Email | Sms | Push    -- ตัวเลือกตายตัว
notify :: Channel -> IO ()           -- รับเฉพาะช่องที่มีจริง
persist :: Binary a => a -> IO ()

-- ที่ AI เขียนออกมาจริง
summarize :: [Int] -> Summary        -- ลิสต์ว่างเข้ามาได้
notify   :: Text -> IO ()            -- พิมพ์ "emial" ก็ยังคอมไพล์ผ่าน
persist  :: Show a => a -> IO ()     -- ใช้ข้อจำกัดที่หลวมกว่า

ทั้งสามกรณีคือการลดความเข้มของข้อจำกัด NonEmpty Int หรือ NonEmptyList[Int] คือลิสต์ที่รับประกันว่ามีอย่างน้อยหนึ่งตัว พอกลายเป็นลิสต์ธรรมดา ภาระการเช็กว่า "แล้วถ้าว่างล่ะ" ก็เด้งกลับมาเป็นของเราทันที การเปลี่ยน Channel ที่เป็นตัวเลือกตายตัวให้กลายเป็น String ก็คือเปิดประตูให้คำที่พิมพ์ผิดอย่าง "emial" หลุดเข้ามาได้ทุกเมื่อ ส่วน persist ที่ลดจาก Binary เหลือ Show ก็คือแอบเปลี่ยนวิธีเซฟข้อมูลไปเป็นคนละอย่างเงียบ ๆ

จุดที่ Justin เน้นคือมันไม่ได้มาบอกเราว่า "ขอเปลี่ยนแผนนะ เพราะ..." ซึ่งถ้าบอกก็ยังคุยกันได้ แต่มันเปลี่ยนแบบเนียน ๆ ให้เราไปเจอเอาทีหลัง ซึ่งขัดกับความหมายของคำว่าวางแผนร่วมกันตั้งแต่ต้น


ท่าที่ 3: "ยัดข้อความ" (String Stuffing)

ท่านี้เป็นไฮไลต์ของบทความ และเป็นท่าที่ Justin ยกตัวอย่างไว้เยอะที่สุด ลองนึกภาพว่าเรากำลังทำเซิร์ฟเวอร์ที่คอยรับคำขอ (request) เข้ามาทำงาน เวลาอะไรพัง มันต้องตอบให้ได้ว่าพังเพราะอะไร เราเลยมี type ตัวหนึ่งชื่อ ErrorEvent ที่รวบรวม "ชนิดของความผิดพลาด" ที่เป็นไปได้ทั้งหมดไว้ในที่เดียว หน้าตาแบบนี้:

type รวมชนิดของ error
data ErrorEvent = UnknownUser String
                | DatabaseErrorCode Int
                | InvalidJSON A.Value
                | NetworkError SomeException
                | Canceled (Maybe CancelationReason)
                | ...

handleRequest :: Request -> IO (Either ErrorEvent Response)

อ่านง่าย ๆ คือ error มีได้หลายแบบ ไม่รู้จักผู้ใช้ / ฐานข้อมูลพัง (พร้อมรหัสตัวเลข) / JSON เสีย / เน็ตล่ม / ถูกยกเลิก ส่วน IO[Either[ErrorEvent, Response]] อ่านว่า "งานที่มีผลข้างเคียง ซึ่งจบแล้วจะได้ error หรือไม่ก็ได้คำตอบ"

ทีนี้เราสั่งให้ AI เพิ่มฟีเจอร์ "เพิ่มกลุ่ม" ซึ่งต้องรายงาน error แบบใหม่ว่ากลุ่มไม่ถูกต้อง สิ่งที่ควรทำคือเพิ่มช่องใหม่ InvalidGroupError เข้าไปในรายการ แต่สิ่งที่ AI ทำจริงมีอยู่ถึงห้ามุก ทุกมุกคอมไพล์ผ่านหมด ฝั่ง Haskell ผมยกมาให้ครบทุกมุก ส่วนฝั่ง Scala รวบไว้บล็อกเดียว (เขียนซ้อน else หลายอันเพื่อให้เห็นครบในที่เดียว ของจริงย่อมมีอันเดียว):

ห้ามุกยัดข้อความ ที่เขาเจอมาจริง
-- มุกที่ 1: ยัดข้อความลงช่อง UnknownUser
handleAddGroup :: AddGroupRequest -> IO (Either ErrorEvent Response)
handleAddGroup req
    | validGroup group = -- ..
    | otherwise = pure $ Left (UnknownUser $ "Invalid group: " <> group)
  where
    group = getGroup req

-- มุกที่ 2: ใช้เลขติดลบเป็น "รหัสพิเศษ"
handleAddGroup :: AddGroupRequest -> IO (Either ErrorEvent Response)
handleAddGroup req
    -- ...
    | otherwise = pure $ Left (DatabaseErrorCode (-1))

-- มุกที่ 3: ประกอบ JSON ปลอมขึ้นมาแล้วบอกว่า "JSON เสีย"
handleAddGroup :: AddGroupRequest -> IO (Either ErrorEvent Response)
handleAddGroup req
    -- ...
    | otherwise = pure $ Left $ InvalidJSON $
        A.object ["errorType" .= "Invalid group", "group" .= group]
  where
    group = getGroup req

-- มุกที่ 4: ห่อเป็น exception แล้วบอกว่า "เน็ตล่ม"
handleAddGroup :: AddGroupRequest -> IO (Either ErrorEvent Response)
handleAddGroup req
    -- ...
    | otherwise = pure $ Left $ NetworkError $
        toException (userError $ "Invalid group: " <> show group)
  where
    group = getGroup req

-- มุกที่ 5: บอกว่า "ถูกยกเลิก" โดยไม่ระบุเหตุผลเลย
handleAddGroup :: AddGroupRequest -> IO (Either ErrorEvent Response)
handleAddGroup req
    -- ...
    | otherwise = pure $ Left (Canceled Nothing)

มุกแรกมันเลือกช่อง UnknownUser ซึ่งแปลว่า "ไม่รู้จักผู้ใช้" แล้วยัดข้อความ "Invalid group: ..." ลงไปแทน คอมไพล์ผ่านฉลุย เพราะช่องนั้นรับข้อความอะไรก็ได้ แต่ตอนนี้โค้ดเราโกหกแล้ว ที่ไหนสักแห่งจะมี error "ไม่รู้จักผู้ใช้" โผล่มาทั้งที่ปัญหาจริงคือเรื่องกลุ่ม และ Justin ก็ไม่ได้เจอแค่มุกเดียว เขาไล่อีกสี่มุกที่เคยเจอมาจริง สรุปทั้งห้ามุก:

ทางที่ถูก (เพิ่มช่องใหม่) ท่าหลบ (ยัดลงช่องเดิม) UnknownUser DatabaseErrorCode InvalidGroupError (ใหม่) error แบบใหม่ UnknownUser "Invalid group…" DatabaseErrorCode (ไม่มีช่องใหม่) error แบบใหม่ คอมไพล์ผ่าน แต่ความหมายผิด
ท่า string stuffing เกิดขึ้นได้เพราะช่องนั้นรับ String อะไรก็ได้ AI เลยยัดเรื่องที่ไม่เกี่ยวลงไป แทนที่จะเพิ่มช่องใหม่ (diagram วาดประกอบโดยผู้เขียน)

ลองหยุดคิดก่อนสักครู่ ถ้าเป็นเรา จะปิดช่องโหว่นี้ยังไงให้ AI ยัดข้อความมั่วไม่ได้อีก พอมีคำตอบในใจแล้วค่อยกดดูวิธีที่ Justin เสนอ

ดูวิธีแก้: ปิดช่องโหว่ด้วย type เฉพาะทาง

ทางแก้ที่เขาเสนอ

อย่าปล่อยให้ช่องไหนรับ String หรือ Int ดิบ ๆ ถ้าความหมายมันเจาะจงกว่านั้น เปลี่ยนเป็น type เฉพาะทางไปเลย แล้วช่องนั้นจะยัดมั่วไม่ได้อีก เพราะข้อความทั่วไปใส่ลงไปไม่ได้ตั้งแต่แรก

ปิดช่องโหว่ด้วย type เฉพาะทาง
data ErrorEvent = UnknownUser UserName
                | DatabaseErrorCode ErrorCode
                | InvalidJSON ParseError
                | NetworkError NetworkException
                | Canceled CancelationReason
                | ...

สังเกตว่า String กลายเป็น UserName และ Int กลายเป็น ErrorCode พอทำแบบนี้ ท่ายัดข้อความก็ตายไปโดยปริยาย ฝั่ง Scala ใช้ opaque type ("โอเพก ไทป์" แปลว่า "ชนิดทึบแสง") ซึ่งข้างในยังเป็น String ตัวเดิม ไม่เปลืองหน่วยความจำเพิ่ม แต่ข้างนอกสร้างขึ้นมาเองมั่ว ๆ ไม่ได้ ต้องผ่านประตูที่เรากำหนดเท่านั้น

หลักคิดเบื้องหลังคือคติที่ชาว Haskell เรียกว่า "Parse, Don't Validate" ("พาร์ส ดอนต์ วาลิเดต" แปลว่า "แปลงให้เป็นของจริง อย่าแค่ตรวจ") แทนที่จะรับข้อมูลดิบเข้ามาแล้วค่อยไล่เช็กทีหลังว่าถูกไหม ให้แปลงมันเป็น type ที่เป็นไปไม่ได้เลยที่จะผิดตั้งแต่ประตูทางเข้า


ท่าที่ 4: ยืมช่องคนอื่นเขาใช้ (Field Abuse)

ก่อนเข้าโค้ด ขอปูก่อนว่าทำไมเรื่องนี้ถึงสำคัญ เรกคอร์ด (record) คือ type ที่จับหลายช่องข้อมูลมามัดรวมเป็นก้อนเดียว เช่น "รายงาน" หนึ่งฉบับก็มีชื่อ มีผู้เขียน มีวันที่ อยู่ในก้อนเดียวกัน หัวใจของการออกแบบเรกคอร์ดให้ดีคือ "แต่ละช่องต้องเก็บของที่ตรงกับความหมายของมัน" ช่องผู้เขียนก็มีแต่ผู้เขียน ช่องวันที่ก็มีแต่วันที่ พอกติกานี้แน่น เวลาอ่านโค้ดเราถึงเชื่อสิ่งที่ช่องบอกได้ ท่าที่ 4 คือการทำลายกติกานี้เงียบ ๆ

มันเหมือนท่าที่ 3 แต่ย้ายจากช่องใน error type มาเป็นช่องข้อมูลในเรกคอร์ดแทน Justin ยกสองเรกคอร์ดนี้ ฝั่ง Haskell โชว์แค่หน้าตาเรกคอร์ด ส่วนท่าหลบผมยกเป็นฝั่ง Scala:

สองเรกคอร์ดที่โดนยืมช่อง
-- เรกคอร์ดรายงาน
data Report = Report
    { reportName :: String
    , reportAuthors :: [String]
    , reportDate :: Day
    , ...
    }

-- เรกคอร์ดเป้าหมาย
data Targets = Targets
    { fooTarget :: String
    , barTarget :: String
    , -- ...
    }

ท่าหลบที่เกิดกับสองอันนี้มีสามมุก มุกแรกคือต้องเก็บหน่วยงานที่สังกัด แต่ไม่มีช่อง เลยยัดลงไปในลิสต์ reportAuthors ซะเลย มุกที่สองคือต้องบอกว่ายังไม่มีวันที่ แต่แทนที่จะเปลี่ยน reportDate เป็น Maybe Day ก็ใส่วันที่ปลอมอย่าง ModifiedJulianDay 0 แทน และมุกที่สามคือต้องใช้ bazTarget แต่การเพิ่มช่องนั้นต้องข้ามไปแก้ไลบรารีอีกตัว ก็เลยยืม barTarget ที่มีอยู่แล้วไปใช้ก่อน

ค่าปลอมแบบวันที่ 0 นี่มีชื่อเรียกว่า sentinel value ("เซนทิเนล แวลิว" แปลว่า "ค่าเฝ้ายาม") คือค่าปกติค่าหนึ่งที่เราแอบตกลงกันเองว่าให้แปลว่าไม่มีข้อมูล ปัญหาคือข้อตกลงนั้นอยู่ในหัวคนเท่านั้น ไม่ได้อยู่ในโค้ด วันหนึ่งมีคนเอาไปคำนวณอายุเอกสารตรง ๆ ก็จะได้ผลว่าเอกสารนี้อายุ 56 ปีมาแบบงง ๆ

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

ดูวิธีแก้: ทำ "ไม่มี" ให้เป็นสถานะที่ type บอกได้

แทนที่จะยืมช่องเดิมหรือใส่ค่าเฝ้ายาม ให้เพิ่มช่องที่สื่อความหมายตรง ๆ (เช่น affiliation) และทำให้ "ยังไม่มีค่า" กลายเป็น Option ที่ type มองเห็น พอเป็นแบบนี้ คนที่หยิบเรกคอร์ดไปใช้ต่อจะถูกบังคับให้เผื่อกรณี "ไม่มีวันที่" เสมอ ยัดวันที่ปลอมเข้ามาไม่ได้อีก:

ที่ควรเป็น (Option บอกว่า "อาจไม่มี")
import java.time.LocalDate

// ที่ควรเป็น: ทำให้ "ไม่มี" เป็นสถานะที่ type บอกได้
final case class Report(
  name: String,
  authors: List[Author],
  affiliation: Option[Affiliation],
  date: Option[LocalDate],
)

ท่าที่ 5: ไม่ยอมสร้าง type ใหม่

ท่านี้ตัวอย่างเป็นเรื่องเขตแดนในอเมริกาเหนือ ขอปูความรู้ก่อนสักนิดจะได้ตามทัน Alaska, Arkansas, Arizona พวกนี้คือ "รัฐ" ของสหรัฐอเมริกา ส่วน Canada กับ Mexico เป็น "ประเทศ" คนละชั้นกัน (ประเทศใหญ่กว่ารัฐ) จุดที่ต้องจับตาคือพอโค้ดเอาประเทศกับรัฐมากองปนกันในระดับเดียวกัน ความจริงข้อนี้จะหายไป

สมมติเดิมเรามีแค่รัฐในอเมริกาอยู่แล้ว:

ของเดิมที่มีอยู่
data State = Alaska | Arkansas | Arizona | ...

processState :: State -> IO ()

วันหนึ่งงานขยาย ต้องรองรับภูมิภาคที่มีแคนาดาและเม็กซิโกด้วย สิ่งที่ AI มักทำคือแบนทุกอย่างมากองรวมกันชั้นเดียว แล้วเลี่ยงการสร้าง type ใหม่:

เวอร์ชันแบนราบ (ท่าหลบ)
data Region = Canada | Mexico | Alaska | Arkansas | Arizona

processRegion :: Region -> IO ()
processRegion = \case
    Canada -> ...
    Mexico -> ...
    st -> processState st

processState :: Region -> IO ()
processState = \case
    Canada -> pure ()
    Mexico -> pure ()
    Alaska -> ... -- actual logic

ดูให้ดีนะครับว่ามันแย่ตรงไหน processState ที่เดิมรับ State ตอนนี้ถูกเปลี่ยนให้รับ Region แทน แปลว่ามันต้องเขียนเคส Canada กับ Mexico ไว้ในฟังก์ชันที่ชื่อว่า "ประมวลผลรัฐ" ทั้งที่แคนาดากับเม็กซิโกเป็นประเทศ ไม่ใช่รัฐ แล้วมันก็กลบด้วย pure () ซึ่งแปลว่าเจอเคสนี้แล้วไม่ต้องทำอะไร เป็นการปิดปากคอมไพเลอร์ที่กำลังจะบอกเราว่าโครงสร้างนี้มันผิดนะ

ดูวิธีแก้: ให้มีลำดับชั้นตามความจริงของโดเมน

ส่วนสิ่งที่ควรเป็นคือให้มันมีลำดับชั้นตามความจริงของโดเมน แยกให้ชัดว่า "รัฐ" เป็นของที่ซ้อนอยู่ใต้ระดับ "ภูมิภาค" อีกที:

เวอร์ชันที่มีลำดับชั้น
data Region = Canada | Mexico | USState State
data State = Alaska | Arkansas | Arizona | ...

processRegion :: Region -> IO ()
processRegion = \case
    Canada -> ...
    Mexico -> ...
    USState st -> processState st

processState :: State -> IO ()
processState = \case
    Alaska -> ... -- actual logic

ต่างกันตรงที่ processState กลับมารับเฉพาะ State จริง ๆ เคสปลอมที่ต้องกลบด้วย pure () หายไปหมด และความจริงที่ว่าอลาสกาเป็นรัฐหนึ่งของอเมริกาก็กลับมาอยู่ในโค้ดอีกครั้ง


ต่อยอด: ท่าเดียวกันในโลก effectful (fs2 & Pekko)

ส่วนนี้ไม่ได้อยู่ในบทความต้นฉบับนะครับ ผมเติมเอง เพราะพอเขียน Scala สาย functional จริง ๆ แล้วท่าหลบพวกนี้มันโผล่ในอีกสองที่ที่เจ็บกว่าเดิม

ที่แรกคือใน stream fs2 ("เอฟพีเอสทู" ย่อจาก Functional Streams for Scala) คือไลบรารีสำหรับไหลข้อมูลทีละก้อนแบบไม่กินแรม พอ AI เจอว่าบางแถวพาร์สไม่ผ่าน ท่าที่มันชอบเลือกคือกลืน error ทิ้ง:

fs2 กลืน error ทิ้ง (ท่าหลบ)
import fs2.Stream
import cats.effect.IO

// ท่าหลบ: แถวที่พังหายไปเงียบ ๆ ไม่มีใครรู้ว่าหายกี่แถว
def ingest(rows: Stream[IO, RawRow]): Stream[IO, Record] =
  rows.evalMap(parse).attempt.collect { case Right(r) => r }

// ท่าหลบอีกแบบ: กลืน error แล้วปิด stream ดื้อ ๆ
def ingest2(rows: Stream[IO, RawRow]): Stream[IO, Record] =
  rows.evalMap(parse).handleErrorWith(_ => Stream.empty)

.attempt.collect { case Right(r) => r } อ่านออกมาเป็นภาษาคนคือ "ลองทำดู ถ้าพังก็ทิ้งแถวนั้นไปเลย" งานรัน 10,000 แถวสำเร็จ 200 แถว แต่ไม่มี error สักตัว ไม่มีใครรู้ว่าหายไป 9,800 แถว นี่คือ pure () ในเวอร์ชันที่แพงกว่าเยอะ ทางที่ถูกคือให้ความล้มเหลวไหลอยู่ใน stream ในฐานะค่า แล้วค่อยแยกสองทางที่ปลาย

ดูวิธีแก้: ให้ error เป็นค่าที่ไหลอยู่ใน stream
ให้ error เป็นค่า แล้วแยกสองทางที่ปลาย
// ที่ควรเป็น: ให้ความล้มเหลวเป็น "ค่า" ที่ไหลอยู่ใน stream ด้วย
def ingest3(rows: Stream[IO, RawRow]): Stream[IO, Either[IngestError, Record]] =
  rows.evalMap(row => parse(row).attemptNarrow[IngestError])

// ปลายทางค่อยแยกสองทาง ของดีไปต่อ ของเสียถูกบันทึกไว้
val (bad, good) = ingest3(rows).broadcastThrough(
  _.collect { case Left(e)  => e }.evalTap(logError),
  _.collect { case Right(r) => r }.through(save),
)

ที่สองคือในโปรโตคอลของ actor Pekko ("เพกโก") คือไลบรารี actor สำหรับระบบกระจายศูนย์ แยกสายมาจาก Akka หัวใจของมันคือ actor แต่ละตัวรับได้เฉพาะข้อความที่ประกาศไว้ ซึ่งก็คือ sum type ดี ๆ นี่เอง ท่าหลบทุกท่าข้างบนจึงใช้ได้หมด:

Pekko typed โปรโตคอลที่ถูกชีส
import org.apache.pekko.actor.typed.{ActorRef, Behavior}
import org.apache.pekko.actor.typed.scaladsl.Behaviors

// โปรโตคอลของ actor ก็คือ sum type ดี ๆ นี่เอง ท่าหลบเดิมใช้ได้หมด
enum Command:
  case AddGroup(group: GroupName, replyTo: ActorRef[Ack])
  case RemoveGroup(group: GroupName, replyTo: ActorRef[Ack])

enum Ack:
  case Ok
  case Failed(reason: String)   // ช่องโหว่: อะไรก็ยัดลงมาได้

def behavior: Behavior[Command] = Behaviors.receiveMessage:
  case Command.AddGroup(g, replyTo) =>
    // ท่าหลบ: ทุกความล้มเหลวกลายเป็นข้อความเดียวกันหมด
    replyTo ! Ack.Failed(s"could not add $g")
    Behaviors.same
  case _ => Behaviors.same   // และเคสที่ยังไม่อยากคิด ก็เงียบไปเฉย ๆ

Failed(reason: String) คือประตูเปิดทิ้งไว้ให้ยัดอะไรก็ได้ พอเปลี่ยนเป็น Failed(reason: ErrorEvent) ปุ๊บ AI ก็ยัดข้อความมั่วไม่ได้อีก มันจะถูกบังคับให้มาคุยกับเราว่าขอเพิ่มชนิดของ error นะ ซึ่งคือสิ่งที่เราอยากได้ตั้งแต่แรก ส่วน case _ => Behaviors.same ท้ายบล็อกก็คือญาติสนิทของ pure () นั่นแหละครับ

ดูวิธีแก้: ให้เหตุผลความล้มเหลวมี type ของมัน
ที่ควรเป็น (Ack.Failed ถือ ErrorEvent)
// ที่ควรเป็น: เหตุผลของความล้มเหลวก็มี type ของมัน
enum Ack:
  case Ok
  case Failed(reason: ErrorEvent)

แล้วทำไมมันถึงทำแบบนี้?

Justin สรุปแรงจูงใจเบื้องหลังไว้สามข้อ ซึ่งผมว่าอ่านแล้วเข้าใจ AI ขึ้นเยอะ:

Justin อ่านพฤติกรรมพวกนี้ว่าเป็นสัญญาณของโมเดลที่ทำงานภายใต้ความกดดัน มันเลือกทางที่เปลืองแรงน้อยที่สุด ไม่ใช่ทางที่ถูกที่สุด

และเขาย้ำจุดต่างที่สำคัญมากระหว่างคนกับ AI เวลาคนเขียนโค้ดแล้วเบี่ยงจากแผน บ่อยครั้งมันเป็นเพราะเขาเพิ่งค้นพบความจริงบางอย่างของโดเมนงานระหว่างทาง ซึ่งมีค่ามาก แต่การเบี่ยงของ AI ไม่ได้มาจากการค้นพบแบบนั้น มันมาจากการเลือกทางที่ง่ายกว่าเฉย ๆ Justin ประเมินว่าโอกาสที่ท่าหลบด่านครั้งหนึ่ง ๆ จะเป็นการตัดสินใจที่ชอบธรรมจริง ๆ นั้นต่ำกว่า 1%


แล้วเราควรทำยังไง

ข้อสรุปของเขาไม่ใช่ให้เลิกใช้ AI แต่คือเลิกหวังว่ามันจะเขียนถูกตั้งแต่แรก แล้วไปลงแรงกับการตั้งกับดักแทน:

ข้อคิดที่เอามาใช้กับภาษาอื่นได้

ท่าทั้งห้านี้เจอได้ทุกที่ครับ ทั้ง # type: ignore ใน Python, as any ใน TypeScript, asInstanceOf ใน Scala, การยัด error ใหม่ลง exception เดิม, หรือการใช้ -1 แทนคำว่าไม่มีค่า ทั้งหมดคือสายพันธุ์เดียวกัน คือการเอาชนะเครื่องมือตรวจสอบ แทนที่จะเอาชนะปัญหา

ปิดท้าย Justin ฝากไอเดียไว้ว่าอยากให้มีคนทำ skill สำหรับ Claude Code ที่คอยรีวิว diff ของ Haskell โดยเฉพาะ ไล่จับทั้งการปิดคำเตือน การลดความเข้มของ type และการยัดข้อความหรือยืมช่อง และที่สำคัญที่สุดคือจับโค้ดที่ควรเปลี่ยนแต่ไม่เปลี่ยนให้ได้ เขาบอกว่ายังมีตอนต่อไปเรื่องการวางแผนงาน Haskell กับ AI ให้ดี และการดูแลโปรเจกต์ที่ปล่อยให้ AI เขียนไปเยอะแล้วอีกด้วย


แหล่งอ้างอิง

  1. Justin Le, "LLMs Will Cheese Your Types: Fighting Back in Haskell" · blog.jle.im (บทความต้นฉบับภาษาอังกฤษ ที่บทความนี้สรุปมา โค้ด Haskell ทุกบล็อกในหน้านี้ยกมาจากที่นี่)
  2. Alexis King, "Parse, Don't Validate" · lexi-lambda.github.io (ที่มาของคติที่อ้างถึงในหัวข้อ String Stuffing)
  3. โค้ด Scala 3 ทั้งหมดในหน้านี้ผมเขียนขึ้นเองเพื่อเทียบเคียง ใช้ cats, cats-effect, fs2 และ Apache Pekko เป็นโค้ดเชิงอธิบาย ไม่ใช่โค้ดที่คอมไพล์ได้ทันทีทั้งไฟล์
  4. โค้ด TypeScript ทั้งหมดผมเขียนเองเช่นกัน ใช้ Effect-TS (โมดูล Effect, Stream, Data.taggedEnum, Brand, Option, Either) เป็นโค้ดเชิงอธิบาย เน้นให้เห็นแพตเทิร์นเดียวกัน ไม่ใช่โค้ดที่คอมไพล์ได้ทันทีทั้งไฟล์