การดู : 152

02/11/2026 15:35น.

ระบบ Multi-LLM ด้วย Goroutines และ Channels ในภาษา Go

Golang The Series EP.162: Goroutines for Multi-LLM เรียก AI หลายเจ้าพร้อมกันเพื่อเปรียบเทียบผลลัพธ์แบบ Parallel

#Goroutines

#Multi-LLM

#ภาษา Go

#ทำงานแบบขนาน

#เปรียบเทียบ AI

#Go

#Golang

สวัสดีครับชาว Gophers! กลับมาเจอกันใน EP.162 ช่วงนี้ใครทำ Backend สำหรับระบบ AI น่าจะเจอปัญหาคล้ายๆ กัน คือเราไม่อยากผูกติดระบบไว้กับ LLM แค่เจ้าเดียว บาง Use Case เราอยาก "ถามพร้อมกันทุกเจ้า" ไม่ว่าจะเป็น OpenAI, Gemini, Claude หรือกระทั่ง Local อย่าง Ollama เพื่อเอาคำตอบมาทำระบบโหวตหาข้อเท็จจริง หรือให้ยูสเซอร์ดูเทียบกันแบบ Side-by-Side

ปัญหาคือ ถ้าเราเขียนโค้ดเรียก API ทีละเจ้าแบบปกติ (Sequential) กว่าจะรอครบทุกคน ยูสเซอร์คงรอกันเงือกหลับ (สมมติเจ้าละ 3 วินาที 3 เจ้าก็ปาไปเกือบ 10 วินาที) วันนี้เราเลยจะมาใช้ของถนัดของชาว Go อย่าง Goroutines และ Channels มาทำ Concurrent Querying ยิงคำถามไปทุกค่ายพร้อมกันเลย ใครตอบช้าสุดเราก็รอแค่นั้นแหละครับ!

สถาปัตยกรรม Multi-LLM แบบยิงขนาน (Concurrent Querying)

ไอเดียคือเราจะแตก Goroutines ออกไปเป็น Worker เพื่อยิง API ของ AI แต่ละเจ้าแยกอิสระจากกัน แล้วใช้ sync.WaitGroup คอยคุมให้ทุกตัวทำงานเสร็จครบก่อน จากนั้นใช้ Go Channels ส่งผลลัพธ์กลับมารวมกันตรงกลาง เพื่อป้องกันปัญหา Data Race ครับ

Plaintext

┌──> [Goroutine 1] ──> ยิง OpenAI API (1.5s) ──┐
                  │                                              │
[User คำถามเดียว] ├──> [Goroutine 2] ──> ยิง Gemini API (2.5s) ──┼──> [Go Channel คัดกรอง] ──> ผลลัพธ์รวม (รอรวมแค่ 3.5s)
                  │                                              │
                  └──> [Goroutine 3] ──> ยิง Ollama Local (3.5s) ┘

โครงสร้างข้อมูลและ Worker (Types & Function)

เพื่อให้โค้ดคลีน เราจะแบ่ง Data Structure สำหรับรับผลลัพธ์ และสร้างฟังก์ชัน Worker ย่อยสำหรับคิวรีแยกค่ายกันครับ

Go

package main

import (
	"context"
	"fmt"
	"strings"
	"sync"
	"time"
)

// LLMResponse โครงสร้างสำหรับเก็บผลลัพธ์และสถิติจาก AI แต่ละค่าย
type LLMResponse struct {
	Provider string
	Answer   string
	Duration time.Duration
	Error    error
}

ฟังก์ชันตัวนี้จะทำหน้าที่เป็น Worker วิ่งไปเรียก API ครับ (ในตัวอย่างเราจะจำลอง Latency ด้วย time.After)

Go

// callLLM ทำหน้าที่เป็น Worker แยกยิง API ของแต่ละ Provider
func callLLM(ctx context.Context, provider string, delay time.Duration, question string, ch chan<- LLMResponse, wg *sync.WaitGroup) {
	defer wg.Done()
	startTime := time.Now()

	// จำลอง Network Latency ในการประมวลผล
	select {
	case <-time.After(delay):
		// จำลองการตอบกลับจากโมเดล
		answer := fmt.Sprintf("[%s]: วิเคราะห์คำถาม '%s' แล้วพบว่า...", provider, question)
		
		ch <- LLMResponse{
			Provider: provider,
			Answer:   answer,
			Duration: time.Since(startTime),
			Error:    nil,
		}
	case <-ctx.Done():
		// เคสที่ Context ถูก Timeout หรือ Cancel ไปก่อน
		ch <- LLMResponse{
			Provider: provider,
			Error:    ctx.Err(),
		}
	}
}

ส่วนควบคุมระบบหลัก (Main Orchestrator)

ใน main.go เราจะนำ Worker มารันขนานกัน 3 ตัว และกำหนด Timeout รวมไว้ที่ 4 วินาทีเพื่อป้องกันไม่ให้ระบบรอนานเกินไปจนพัง

Go

func main() {
	question := "แนวโน้มการทำ SEO ในปีนี้ควรเน้นเรื่องอะไร?"
	
	// ตั้ง Timeout ภาพรวมไว้ที่ 4 วินาที 
	ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second)
	defer cancel()

	var wg sync.WaitGroup
	// สร้าง Buffered Channel ขนาดเท่าจำนวนค่าย
	responseChan := make(chan LLMResponse, 3)

	// 1. กระจายงานยิง AI ขนานกัน
	wg.Add(1)
	go callLLM(ctx, "OpenAI (GPT-4o)", 1500*time.Millisecond, question, responseChan, &wg)

	wg.Add(1)
	go callLLM(ctx, "Google Gemini", 2500*time.Millisecond, question, responseChan, &wg)

	wg.Add(1)
	go callLLM(ctx, "Ollama (Llama 3)", 3500*time.Millisecond, question, responseChan, &wg)

	// 2. รัน Background Worker เพื่อรอปิด Channel เมื่อทุกค่ายทำงานเสร็จ
	go func() {
		wg.Wait()
		close(responseChan)
	}()

	fmt.Printf("🚀 กำลังส่งคำถาม: \"%s\" ไปยัง AI ทุกค่ายพร้อมกัน...\n\n", question)

	// 3. วนลูปดึงผลลัพธ์ที่ทยอยส่งกลับเข้ามาใน Channel
	for resp := range responseChan {
		if resp.Error != nil {
			fmt.Printf("❌ [%s] ผิดพลาดหรือ Timeout: %v\n", resp.Provider, resp.Error)
			fmt.Println(strings.Repeat("-", 50))
			continue
		}
		fmt.Printf("🤖 %s (ใช้เวลา: %v)\n", resp.Provider, resp.Duration)
		fmt.Printf("📝 ตอบ: %s\n", resp.Answer)
		fmt.Println(strings.Repeat("-", 50))
	}
}

เทคนิคเสริม: เอาคำตอบแรกที่ไวที่สุด (First Past the Post)

สำหรับบางโปรเจกต์ที่เราไม่ได้อยากรอเอาผลลัพธ์มาเปรียบเทียบกัน แต่อยากได้ "ความไวสูงสุด" คือค่ายไหนตอบก่อนเอาคนนั้นเลย เราสามารถประยุกต์ใช้โค้ดชุดนี้ได้ง่ายมากครับ แค่เปลี่ยนเงื่อนไขในลูป: เมื่อเราดึงข้อมูลแรกจาก responseChan ได้สำเร็จปุ๊บ ให้สั่ง cancel() ทันที สัญญาณจะวิ่งไปบอก Goroutines ตัวอื่นที่กำลังรอ API ให้ยกเลิก Request ทิ้ง ช่วยประหยัดค่า API Token และลดโหลดฝั่งเซิร์ฟเวอร์ได้เยอะเลยครับ

🎯 ท้าให้ลอง (Daily Mission)

ลองเอาโค้ดชุดนี้ไปรันดูครับ แล้วปรับค่า delay ของบางค่ายให้พุ่งไปเป็น 5000*time.Millisecond ดู จะสังเกตเห็นว่าค่ายนั้นจะถูกตัดจบด้วย Timeout Error แทน

การบ้านชวนคิด: หาก Requirement เปลี่ยนไปเป็น "ต้องรอคำตอบจากทุกเจ้า แล้วส่งทั้ง 3 คำตอบไปให้ Judge LLM (AI ตัดสิน) ช่วยสรุปข้อที่ดีที่สุดอีกที" คุณคิดว่าจะออกแบบ Slice มารับค่าจาก Channel แล้วส่งต่อยังไงดี? ลองคิดสถาปัตยกรรมในหัวกันดูครับ!


สรุปและตอนต่อไป (EP.163)

Goroutines และ Channels คือพระเอกตัวจริงที่ทำให้ภาษา Go จัดการงานจำพวก I/O-Bound อย่างการยิง API คุยกับ AI หลายเจ้าพร้อมกันได้อย่างมีประสิทธิภาพและปลอดภัยครับ

ในตอนต่อไป (EP.163): แม้ว่าการยิงขนานแบบนี้จะเร็วขึ้น แต่มันก็ยังต้องจ่ายค่า API ซ้ำซ้อนสำหรับ "คำถามเดิมๆ" ที่ยูสเซอร์มักจะถามซ้ำกัน ตอนหน้าเราจะมาลดต้นทุนด้วยการทำ Caching AI Responses ด้วย Redis ซึ่งจะช่วยให้ระบบตอบคำถามยอดฮิตได้ในหลักมิลลิวินาทีโดยไม่ต้องกวน AI เลย รอติดตามกันนะครับ Gophers!

ฝากกดติดตามพวกเราได้ที่ Superdev Academy ในทุกช่องทางนะครับ!

  • 🔵 Facebook: Superdev Academy Thailand (อัปเดตข่าวสารและบทความใหม่)

  • 🎬 YouTube: Superdev Academy Channel (ติวเข้มแบบวิดีโอ)

  • 📸 Instagram: @superdevacademy (เกร็ดความรู้สั้นๆ และเบื้องหลังการทำงาน)

  • 🎬 TikTok: @superdevacademy (Tips & Tricks ฉบับย่อยง่าย)

FAQ: คำถามที่พบบ่อยเกี่ยวกับบทความนี้

รวมคำถามและคำตอบที่ช่วยให้คุณเข้าใจเนื้อหาในบทความนี้ได้ดียิ่งขึ้น