
จาก OCR สู่ pixel โดยไม่ทิ้ง fallback
GeekBye เลิกอ่าน screenshot ของคุณด้วย OCR แล้วเริ่มส่งภาพจริง ๆ ไปให้ vision model — ซึ่งเป็นส่วนที่ง่าย ส่วนที่ให้บทเรียนคือการ optimize ที่ข้าม OCR ไปทั้งหมดสำหรับผู้ใช้ vision ซึ่งทำให้ fallback ไปยัง model ที่ไม่รองรับ vision พังไปเงียบ ๆ และต้องถอยกลับไปยังเวอร์ชันที่ตาข่ายนิรภัยทำงานขนานกันและแทบไม่มีต้นทุนเลย
แอปไหนก็ตามที่ให้คุณ screenshot อะไรสักอย่างแล้วถาม AI เกี่ยวกับมัน ล้วนมีทางแยกของจริงอยู่ทางหนึ่ง คุณจะรัน OCR ก็ได้ — ดึงข้อความออกมาบนเครื่อง, ส่ง string จิ๋ว ๆ, แล้วทิ้งภาพไป หรือคุณจะ ส่ง pixel ก็ได้ — ส่งภาพจริง ๆ ออกไป แล้วปล่อยให้ model ที่รองรับ vision อ่านมัน OCR ถูกและเล็ก แต่ตาบอดต่อทุกอย่างที่ไม่ใช่ข้อความ: layout, diagram, การเยื้องของโค้ด, เส้นหยึกหยักสีแดงใต้ error pixel เก็บสิ่งเหล่านั้นไว้ทั้งหมด แต่กินไบต์มากกว่าและต้องการ model ที่มองเห็นได้ GeekBye เริ่มต้นอยู่ฝั่ง OCR ของทางแยกนั้น และข้าม v1.8.6 กับ v1.8.7 เดินข้ามมาฝั่ง pixel การสลับนั้นคือส่วนที่ง่ายของเรื่องนี้ ส่วนที่น่าสนใจคือการ optimize ที่มาพร้อมกับมัน, ทำ fallback พัง, และต้องถูกยกเลิกไป
ก่อนหน้านั้น: อ่านข้อความ, ทิ้งภาพ
ก่อนถึงเส้นเรื่องนี้ screenshot กลายเป็นคำตอบแบบนี้: คุณกดปุ่มลัดจับภาพ, client รัน OCR — บน macOS คือ Vision framework ของ Apple ผ่าน Swift binary ชื่อ VisionOCR, บน Windows คือ PowerShell script, ทั้งคู่อยู่หลัง abstraction เดียวคือ IOcrEngine — แล้วข้อความที่ดึงมาถูกห่อเป็น string ส่งไปยัง backend ในชื่อ screenshotText ตัวภาพเองไม่เคยออกจากเครื่องเลย นั่นมีประสิทธิภาพ และสำหรับกำแพงข้อความล้วน ๆ ก็ใช้ได้ แต่ screenshot น้อยครั้งจะเป็นแค่ข้อความ มันคือประกาศรับสมัครงานแบบ layout สองคอลัมน์, โจทย์ coding ที่มี diagram, error ที่มี stack trace ซึ่งการเยื้องของมันนั่นแหละคือเบาะแส OCR รีดทั้งหมดนั้นให้แบนลงเป็นการถอดความทีละบรรทัด และ model ก็ตอบคำถามที่บางกว่าคำถามที่คุณถามไป
การสลับ: ส่ง pixel (v1.8.6)
v1.8.6 เพิ่มอีกเส้นทางเข้ามา imageOptimizer.ts ตัวใหม่ ซึ่งสร้างบน sharp จึงข้ามแพลตฟอร์มได้ ทำสามอย่างพอดี ๆ กับ screenshot ดิบ:
sharp(pngFilePath).resize({ width: 1920, withoutEnlargement: true }).jpeg({ quality: 80 })
ย่อให้กว้างไม่เกิน 1920px (ไม่ขยาย capture ที่เล็กกว่าเด็ดขาด), แปลง PNG เป็น JPEG, quality 80 ผลลัพธ์คือ base64 ดิบ โดยมี media type แยกไปไว้อีก field หนึ่ง และมันเดินทางไปยัง backend ใน request field ใหม่สองตัวเคียงข้างตัวเดิม: screenshotImage และ screenshotImageMediaType ซึ่งเป็นอิสระจาก screenshotText ตอนนี้ backend รับได้ทั้งภาพ, ข้อความ, หรือทั้งสองอย่าง
ขั้นตอน optimize นั้นไม่ใช่แค่เรื่องผิวเผิน screenshot PNG ดิบมีขนาด 3–8 MB; JPEG ที่ย่อแล้วราว ๆ 200–500 KB นั่นคือหนึ่ง order of magnitude ที่หายไปจากการอัปโหลด และมันยังตัดบิลของ model ด้วย — vision model แบ่งภาพเป็น tile ตาม resolution ของมันและคิดเงินต่อ tile ดังนั้นการจำกัดความกว้างไว้ที่ 1920 แล้วบีบอัดใหม่จึงลดทั้งไบต์บนสายและ image token ที่คุณต้องจ่ายลงโดยตรง โดยความอ่านออกของข้อความบนจอไม่เสียหายอย่างมีนัยสำคัญ
แล้วแอปรู้ได้ยังไงว่า model มองเห็นได้หรือเปล่าตั้งแต่แรก? backend เป็นคนบอก response ของ /api/config ได้ block ai.vision ที่ key ตาม plan เพิ่มเข้ามา และ client cache ค่า boolean ตัวเดียว — aiSupportsVision — ทุกครั้งที่ดึง config เมื่อรองรับ vision ก็ optimize ภาพ; เมื่อไม่รองรับก็ fallback ไป OCR ดังนั้น ความสามารถ เป็นข้อเท็จจริงของ backend ที่ cache ไว้ฝั่ง client และ การตัดสินใจต่อ screenshot แต่ละใบ ว่าจะรันหรือข้าม OCR ก็ตัดสินบน client จาก flag นั้น ส่วนว่า model ตัวไหนตอบจริง ๆ ยังเป็นของ backend อยู่
การ optimize ที่ฉลาดเกินไป
ตรงนี้แหละที่เริ่มให้บทเรียน เวอร์ชันแรกของเส้นทาง vision รัน OCR กับการ optimize ภาพไปด้วยกันแบบขนาน จากนั้น commit "perf" ตัวหนึ่งบีบมันให้เป็นอย่างใดอย่างหนึ่ง: ถ้า plan รองรับ vision ก็ optimize ภาพและ ข้าม OCR ไปทั้งหมด; ถ้าไม่รองรับก็รัน OCR และข้ามงานภาพ บนกระดาษนี่คือชัยชนะที่เห็น ๆ — จะไปดึงข้อความที่ vision model ไม่ต้องการทำไม? changelog ยังโฆษณามันเลย: "OCR is skipped when the AI can read the image directly."
ปัญหาอยู่ที่คำว่า fallback backend ไม่ได้มี model แค่ตัวเดียว; มันมีตัวหลักและตัว fallback และตัว fallback อาจเป็น model ที่ ไม่รองรับ vision ก็ได้ ลองนึกภาพลำดับนี้: model หลักของ plan คุณรองรับ vision ดังนั้น client ก็ข้าม OCR อย่างภาคภูมิและส่งแค่ภาพ provider ตัวหลักสะดุด backend fallback ไปยัง model ที่รับแต่ข้อความ — ซึ่งตอนนี้ถูกยื่นภาพที่มันอ่านไม่ได้และ ไม่มีข้อความ มาให้ เพราะ client optimize ข้อความทิ้งไปแล้ว fast path ได้ลบตาข่ายนิรภัยทิ้งไปเงียบ ๆ คุณทิ้ง fast path ที่ซ้ำซ้อนได้อย่างปลอดภัย; แต่คุณทิ้งสิ่งที่ fast path เป็นทางลัด เลี่ยงผ่าน ไปไม่ได้
ทางแก้: ผูกกับ fallback ไม่ใช่ตัวหลัก
ทางแก้นี่แหละคือส่วนที่ควรขโมยไปใช้ มันไม่ใช่การกลับไปรัน OCR เสมอ — นั่นจะโยนชัยชนะของกรณีทั่วไปทิ้ง มันคือการทำให้การข้ามขึ้นอยู่กับ capability flag ตัวที่สอง: fallback รองรับ vision ไหม? config ของ backend ได้ block fallbackVision เพิ่มมา; client cache aiFallbackSupportsVision ไว้ข้าง flag ตัวแรก ตอนนี้การตัดสินใจมีสี่กิ่ง:
- ทั้งตัวหลักและ fallback มองเห็น → optimize ภาพ, ข้าม OCR เส้นทางที่เร็วและพบบ่อย
- ตัวหลักมองเห็นแต่ fallback ตาบอด → optimize ภาพ และ รัน OCR เป็นประกันให้ fallback
- การ optimize ภาพ throw error → OCR เป็น fallback ขั้นสุดท้าย
- plan ไม่มี vision เลย → OCR อย่างเดียว
และกิ่งที่สอง — กรณีประกัน — คือที่ที่หมากสุดท้ายสำคัญ การรัน OCR หลัง การ optimize ภาพจะเอา latency ของมันมาบวกทับข้างบน ดังนั้นทั้งสองจึงรันไปด้วยกันภายใต้ Promise.all เดียว: การ optimize ภาพกับ OCR เหลื่อมกัน และ OCR ที่มีอยู่เผื่อว่า backend ต้องใช้เท่านั้น ก็ทำเสร็จภายในเวลาที่ภาพกำลังถูก optimize อยู่แล้วอยู่ดี ตาข่ายนิรภัยฟรีในแง่ wall-clock เมื่อไหร่ก็ตามที่ไม่ได้ถูกใช้ ทั้งกลเม็ดสรุปได้ในประโยคเดียว: เก็บ fallback ราคาถูกไว้, ผูกมันกับว่า provider ตัว fallback ของคุณต้องใช้มันจริงไหม, แล้วทำให้มันขนานกัน เพื่อว่ามันจะไม่มีต้นทุนอะไรเลยจนถึงวันที่มันช่วยชีวิตคุณไว้
กับดักเล็กกว่าอีกสองอันที่การข้ามทำให้ลั่น
การข้ามงานที่เลื่อนไว้อย่างดุดัน มักมีวิธีทำให้ทุกที่ที่เคยแอบสมมติว่างานนั้นจะเกิดขึ้น โผล่ขึ้นมาให้เห็น สองอันโผล่ออกมาใน PR review สัปดาห์เดียวกัน
queue deadlock queue ของ screenshot ถือว่ารายการหนึ่ง "พร้อม" เมื่อมันมีข้อความ OCR ตอนนี้ vision screenshot ไม่มีวันได้ข้อความ OCR — โดยการออกแบบ — ดังนั้นการตรวจความครบถ้วน hasIncompleteOCR จึงรอไปชั่วนิรันดร์และ queue ก็ค้าง ทางแก้นิยาม "พร้อม" ใหม่เป็น มีภาพที่ optimize แล้ว หรือ มีข้อความ OCR ซึ่งคือสิ่งที่ pipeline รับประกันจริง ๆ ในตอนนี้
capability flag ที่ค้างเก่า aiSupportsVision ถูก cache ไว้ ซึ่งแปลว่ามันเก่าค้างไปในทิศทางที่ผิดพอดีได้: user logout, หรือการดึง config ล้มเหลว, แล้ว client ก็ถูกทิ้งไว้กับความเชื่อว่ามันยังส่งภาพไปให้ model ที่ไม่รองรับภาพอีกต่อไปได้ ตอนนี้ flag reset เมื่อ logout และเมื่อดึง config ล้มเหลว — capability ที่ถูก cache ไว้ต้องมีเรื่องราวรองรับไว้สำหรับตอนที่สิ่งที่มันบรรยายถึงหายไป
ขอพูดสักคำเรื่องตัวเลข
เพราะความซื่อสัตย์คือหัวใจของซีรีส์นี้: commit message พก figure มาด้วย — การ optimize ภาพ "~30ms" เทียบกับ OCR "~100–150ms", payload "~200–500 KB vs ~3–8 MB PNG" — และมันก็ฟังดูเป็นไปได้ แต่มันเป็นการประมาณแบบ inline ของผู้เขียนเอง ไม่ใช่ผลจาก benchmark ไม่มี latency แบบ end-to-end ก่อน/หลังที่วัดจริงอยู่ที่ไหนในประวัติเลย จงถือว่าข้อโต้แย้งเรื่องความเร็วเป็นเจตนาการออกแบบที่ดี ไม่ใช่ผลลัพธ์ที่พิสูจน์แล้ว การลดขนาด payload เป็นตัวเลขเดียวที่คุณเชื่อได้ เพราะมันหล่นออกมาตรง ๆ จากการย่อและบีบอัดใหม่
สามสิ่งที่รีลีสนี้สอนเรา
- การส่ง pixel ชนะการอ่านข้อความ — เมื่อ model มองเห็นได้ OCR ทิ้งทุกอย่างที่ screenshot สื่อสารนอกเหนือจากตัวอักษร vision model อ่าน layout, diagram, และการเยื้อง และ JPEG ที่ optimize แล้วทำให้มันจ่ายไหว แต่มันคือการเดิมพันกับ capability ซึ่งเป็นเหตุผลว่าทำไมมันถึงต้องมี fallback
- คุณ optimize ทิ้ง fast path ได้ แต่ตาข่ายนิรภัยไม่ได้เด็ดขาด การข้าม OCR สำหรับผู้ใช้ vision ถูกสำหรับตัวหลักและผิดสำหรับ fallback bug ไม่ได้อยู่ที่การข้าม; มันอยู่ที่การข้ามโดยอิงกับ capability ของตัวหลัก ทั้งที่ capability ที่สำคัญคือของ fallback
- ตาข่ายนิรภัยที่ฟรีคือตาข่ายที่ถูกทำให้ขนาน ผูกงานประกันไว้กับว่ามันจำเป็นจริงไหม แล้วรันมันเคียงข้าง fast path ให้มันเหลื่อมกัน ถ้าทำถูก graceful degradation จะไม่มีต้นทุนอะไรเลยจนถึงวันที่มันเป็นเหตุผลเดียวที่ฟีเจอร์ของคุณยังทำงานอยู่
สำหรับบทก่อนหน้าในเรื่องยุค v1 ดูสองโหมดความล้มเหลวของ overlay แบบคลิกทะลุ (v1.8.5); และสำหรับภาพรวมทั้งเส้นเรื่อง ดูกายวิภาคของการส่งมอบซอฟต์แวร์ให้สมบูรณ์แบบ