# บทเรียนจากการสร้างเลเยอร์ผู้ให้บริการ LLM แบบรวมศูนย์ด้วย Rust

> สิ่งที่เราเรียนรู้จากการปล่อย octolib ไลบรารี Rust ที่อยู่เบื้องหลังสแตก AI ของเรา: วิธีนามธรรมผู้ให้บริการ LLM หลายเจ้า ทำให้ tool calls และการนับโทเคนเป็นมาตรฐานเดียวกัน และเอาตัวรอดในวันที่รูปแบบการให้เหตุผลของโมเดลคืนค่า 400 กลับมา

รายงานบั๊กมีแค่สามคำ: "Opus พัง" มันไม่ได้พัง สิ่งที่พังคือสมมติฐานต่างหาก เรามีเส้นทางโค้ดหนึ่งที่ตั้ง `thinking.type: "enabled"` บนทุกโมเดล Claude ที่รองรับการให้เหตุผลแบบขยาย Anthropic ปล่อย Opus 4.7 ออกมา ซึ่งปฏิเสธฟิลด์นั้นพอดีด้วยรหัส `400` มันรับเฉพาะ `thinking.type: "adaptive"` เท่านั้น โมเดลเดียว ค่า enum ที่เปลี่ยนชื่อหนึ่งค่า และทุกคำขอที่ถูกส่งไปยังมันตายคาสายไฟ

นั่นคืองานทั้งหมดของเลเยอร์ผู้ให้บริการ LLM แบบรวมศูนย์ บีบอัดลงในเหตุการณ์เดียว คุณไม่ได้กำลังเขียน HTTP client คุณกำลังดูแลโมเดลที่มีชีวิตว่าผู้ขายสิบเก้าเจ้าต่างเห็นไม่ตรงกันอย่างไรในสี่แนวคิดเดียวกัน — ข้อความ เครื่องมือ โทเคน และข้อผิดพลาด — และซึมซับทุกความเปลี่ยนแปลงที่พวกเขาทำ เพื่อให้โค้ดที่อยู่เหนือคุณไม่ต้องสนใจเลย

นี่คือการเจาะลึกเชิงวิศวกรรมของ [octolib](https://github.com/Muvon/octolib) ไลบรารี Rust โอเพนซอร์สที่ทำการซึมซับนั้นให้กับ [Octocode](/blog/octolib-the-engine-behind-our-ai-stack), Octomind และ Octobrain ผมจะพาเดินผ่านบทเรียนที่ทำให้เราเสียค่าตอบแทนมากที่สุด แต่ละบทผูกกับชิ้นโค้ดจริง ถ้าคุณได้อ่าน [บทแนะนำ octolib](/blog/octolib-the-engine-behind-our-ai-stack) หรือบทความพี่น้องเรื่อง [การรันเอเจนต์ AI ตัวเดียวข้ามหลายโมเดล](/blog/running-one-ai-agent-across-many-models) แล้ว นี่คือเลเยอร์ที่อยู่ใต้ทั้งสองบทความ

---

## รูปทรงของนามธรรม

สัญญาคือ trait หนึ่งตัว ทุกอย่างที่อยู่ใต้มันคือผู้ให้บริการที่ implement มัน

```rust
#[async_trait::async_trait]
pub trait AiProvider: Send + Sync {
    fn name(&self) -> &str;
    fn supports_model(&self, model: &str) -> bool;
    async fn chat_completion(&self, params: ChatCompletionParams) -> Result<ProviderResponse>;
    fn get_api_key(&self) -> Result<String>;
    // การตรวจความสามารถพร้อมค่าเริ่มต้น: supports_caching, supports_vision,
    // supports_structured_output, supported_sampling_params, get_model_pricing...
}
```

ผู้เรียกไม่เคยสร้างผู้ให้บริการโดยตรง มันส่งสตริง `provider:model` ให้แฟกทอรี:

```rust
let (provider, model) = ProviderFactory::get_provider_for_model("anthropic:claude-opus-4-8")?;
let response = provider.chat_completion(params).await?;
```

`ProviderFactory::parse_model` แยกที่โคลอนตัวแรก — คำนำหน้าผู้ให้บริการ **บังคับ** โดยการออกแบบ ไม่มีการเดา "ผู้ให้บริการเริ่มต้น" `anthropic:claude-opus-4-8`, `deepseek:deepseek-chat`, `openrouter:anthropic/claude-opus-4.8`, `cli:claude/...` — คำนำหน้าคือกุญแจการกำหนดเส้นทาง และชื่อโมเดลคือทุกอย่างที่ตามมาหลังจากนั้น ผู้ให้บริการยี่สิบเอ็ดเจ้าลงทะเบียนใน `match` เดียว:

```rust
match provider_name.to_lowercase().as_str() {
    "openai"    => Ok(Box::new(OpenAiProvider::new())),
    "anthropic" => Ok(Box::new(AnthropicProvider::new())),
    "deepseek"  => Ok(Box::new(DeepSeekProvider::new())),
    "moonshot" | "kimi" => Ok(Box::new(MoonshotProvider::new())),
    "octohub"   => Ok(Box::new(OctoHubProvider::new())),
    // ... cerebras, groq, together, nvidia, cloudflare, fireworks,
    //     featherless, minimax, zai, byteplus, amazon, google,
    //     ollama, local, openrouter
    _ => Err(anyhow::anyhow!("Unsupported provider: {}", provider_name)),
}
```

เมธอดเริ่มต้นของ trait คือครึ่งหนึ่งที่ไม่มีใครพูดถึงของเรื่องนี้ ผู้ให้บริการที่ไม่ทำอะไรพิเศษจะสืบทอดพฤติกรรมที่สมเหตุสมผล: `supports_caching` คืนค่า `false`, `supported_sampling_params` คืนค่า `SamplingSupport::ALL`, การกำหนดราคาถอยกลับไปยังตารางอ้างอิง ผู้ให้บริการที่ _พิเศษ_ จริง ๆ จะ override เฉพาะเมธอดที่มันแตกต่างและไม่มีอะไรมากกว่านั้น นั่นคือปรัชญาการออกแบบทั้งหมด — ทำให้กรณีทั่วไปฟรีและกรณีแปลกเป็นเรื่องเฉพาะที่

**บทเรียนข้อศูนย์ ที่ทุกอย่างวางอยู่บนมัน:** นามธรรมไม่ใช่ "API ของ LLM ตัวหนึ่ง" มันคือชุดคำถามเรื่องความสามารถที่คุณจะต้องถามก่อนสร้างคำขอ เราทำรายการนั้นไม่ถูกตั้งแต่วันแรก เราขยายมันทุกครั้งที่ผู้ให้บริการทำให้เราประหลาดใจ และ trait คือเนื้อเยื่อแผลเป็น

---

## บทเรียน 1: Tool calls คือแนวคิดเดียวกันในสามรูปทรงที่เข้ากันไม่ได้

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

สองผู้ขาย ฟีเจอร์เดียวกัน รูปแบบบนสายต่างกัน:

- **Anthropic** คืนบล็อกเนื้อหา `tool_use` อาร์กิวเมนต์อยู่ในฟิลด์ `input` ที่ **เป็นออบเจกต์ JSON อยู่แล้ว**
- **OpenAI และทุกเจ้าที่รูปทรงแบบ OpenAI** คืนอาร์เรย์ `tool_calls` ที่ `function.arguments` เป็น **สตริง JSON ที่คุณต้องแยกวิเคราะห์เอง** — และซึ่ง เมื่อโมเดลตัดทอนหรือหลอน ไม่ใช่ JSON ที่ถูกต้องเสมอไป

octolib สร้างแบบจำลองความแตกต่างนี้อย่างชัดเจนแทนที่จะแกล้งทำเป็นว่ามันไม่มี ชนิดภายในคือ enum ที่มีแท็ก:

```rust
#[serde(tag = "provider")]
pub enum ProviderToolCalls {
    Anthropic  { content: Vec<AnthropicToolUse> },   // input: serde_json::Value
    OpenAI     { tool_calls: Vec<OpenAIToolCall> },   // function.arguments: String
    OpenRouter { tool_calls: Vec<OpenAIToolCall> },
    DeepSeek   { tool_calls: Vec<OpenAIToolCall> },
    Generic    { calls: Vec<GenericToolCall> },
}
```

`extract_from_exchange` กระจายงานตามชื่อผู้ให้บริการ และ `to_tool_calls()` ยุบทั้งห้าตัวแปรลงเป็น `Vec<ToolCall>` แบนตัวเดียวให้ผู้เรียก สาขา OpenAI คือที่ที่บทเรียนอยู่:

```rust
let arguments: serde_json::Value = if call.function.arguments.is_empty() {
    serde_json::Value::Object(serde_json::Map::new())
} else {
    serde_json::from_str(&call.function.arguments)
        .map_err(ToolCallError::InvalidArguments)?
};
```

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

ยังมีกับดักที่สอง ละเอียดกว่า: **การเดินทางไปกลับ (round-tripping)** เมื่อคุณส่งบทสนทนากลับไปสำหรับเทิร์นถัดไป tool calls ก่อนหน้าของผู้ช่วยต้องถูกเข้ารหัสใหม่ในรูปทรงของ _ผู้ให้บริการนั้น_ ดังนั้น octolib เก็บ tool calls ในรูปแบบมาตรฐานเดียว `GenericToolCall` ในประวัติ และ `convert_messages` ของแต่ละผู้ให้บริการสร้างบล็อกพื้นเมืองของมันขึ้นใหม่ตอนขาออก — Anthropic สร้างบล็อก `tool_use` ขึ้นใหม่โดยออบเจกต์ยังคงสภาพ; เส้นทางที่เข้ากับ OpenAI ทำการ serialize อาร์กิวเมนต์ _กลับ_ เป็นสตริง การถอยกลับแบบยอมรับได้ก็สำคัญที่นี่เช่นกัน:

```rust
serde_json::from_str(&call.function.arguments).unwrap_or_else(|_| {
    serde_json::json!({"raw_arguments": call.function.arguments})
})
```

แทนที่จะสูญเสีย tool call ที่ผิดรูป เราเก็บสตริงดิบไว้ใต้คีย์ `raw_arguments` บางครั้งโมเดลฟื้นตัวได้จากการเห็นผลลัพธ์ที่พังของตัวเอง แต่มันไม่มีทางฟื้นจากการเรียกที่หายไปอย่างเงียบ ๆ

---

## บทเรียน 2: การใช้โทเคนคือที่ที่เงินรั่วไหลอย่างเงียบ ๆ

ผู้ให้บริการทุกเจ้ารายงานการใช้งาน ไม่มีสองเจ้าที่รายงานเหมือนกัน ถ้าคุณต้องการต้นทุนต่อคำขอ — และที่คำขอนับพันข้ามผู้ให้บริการนับสิบ คุณต้องการ — คุณต้องทำให้อินพุตที่ยุ่งเหยิงจริง ๆ เป็นมาตรฐานเดียวกันลงใน struct เดียว:

```rust
pub struct TokenUsage {
    pub input_tokens: u64,        // สะอาด — ไม่เคยรวมโทเคนแคช
    pub cache_read_tokens: u64,
    pub cache_write_tokens: u64,
    pub output_tokens: u64,
    pub reasoning_tokens: u64,
    pub total_tokens: u64,
    pub cost: Option<f64>,
    pub request_time_ms: Option<u64>,
}
```

struct สำหรับ deserialize ของผู้ให้บริการที่เข้ากับ OpenAI เล่าเรื่องจริง ออบเจกต์ `usage` ตัวเดียวต้องทนภาษาถิ่นทุกแบบพร้อมกัน:

```rust
struct OpenAiCompatUsage {
    input_tokens: Option<u64>,       // ผู้ให้บริการบางเจ้า
    prompt_tokens: Option<u64>,      // OpenAI คลาสสิก
    completion_tokens: Option<u64>,
    output_tokens: Option<u64>,      // เจ้าอื่น ๆ
    total_tokens: Option<u64>,
    reasoning_tokens: Option<u64>,
    completion_tokens_details: Option<CompletionTokensDetails>,  // reasoning อยู่นี่...
    prompt_tokens_details: Option<PromptTokensDetails>,          // cached_tokens อยู่นี่
    total_cost: Option<f64>, cost: Option<f64>,                  // หรือที่นี่
    prompt_cost: Option<f64>, completion_cost: Option<f64>,      // ...หรือกระจายในพวกนี้
}
```

การดึงข้อมูลคือสายโซ่ของการถอยกลับด้วย `.or()`: input คือ `input_tokens` **หรือ** `prompt_tokens` **หรือ** `prompt_eval_count` ของ Ollama **หรือ** ศูนย์ โทเคนการให้เหตุผลซ่อนอยู่ในฟิลด์ระดับบนสุด _หรือ_ ซ้อนอยู่ใต้ `completion_tokens_details` ต้นทุนคือ `total_cost` _หรือ_ `cost` _หรือ_ `prompt_cost + completion_cost` และเฉพาะเมื่อ config ของผู้ให้บริการเลือกที่จะเชื่อถือต้นทุนจาก upstream เมื่อ API ไม่คืนฟิลด์ต้นทุน octolib คำนวณมันจากตารางการกำหนดราคาต่อโมเดล

สองสิ่งกัดเราเป็นพิเศษที่นี่ ทั้งคู่ถูกเข้ารหัสในคอมเมนต์ในโค้ดแล้วเพราะเราไม่อยากเรียนรู้มันใหม่อีก:

- **`input_tokens` ต้อง "สะอาด"** บาง API พับโทเคนที่แคชไว้เข้าในจำนวน input; บางเจ้าไม่ เราลบ `cache_read_tokens` ออกเอง เพื่อให้ฟิลด์มีความหมายเดียวกันเสมอ ไม่งั้นคณิตศาสตร์ต้นทุนจะนับโทเคนแคชราคาถูกซ้ำสองครั้งในอัตราแพง
- **การแยก TTL ของแคชใน Anthropic** Anthropic คิดเงินการเขียนแคช 5 นาทีที่ 1.25 เท่าของ input และการเขียน 1 ชั่วโมงที่ 2 เท่า มันคืนการแยกย่อยต่อ TTL ใน `cache_creation` — _แต่บางครั้งเท่านั้น_ เมื่อออบเจกต์ที่ซ้อนนั้นหายไป สมมติฐานที่ปลอดภัยคือ 5m ไม่ใช่ 1h เดา 1h แล้วคุณจะคิดเงินเกินในบัญชีต้นทุนของคุณเองราว 60% ในการใช้งานที่พบบ่อยที่สุด มันคือค่าเริ่มต้นบรรทัดเดียวพร้อมคอมเมนต์สี่บรรทัดอธิบายว่าทำไม

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

---

## บทเรียน 3: เราไม่สตรีม และนั่นคือการตัดสินใจที่ถูกต้อง

ข้อนี้สวนสัญชาตญาณ ขอให้ผมแม่นยำ ในบรรดาผู้ให้บริการที่รับแฟล็ก `stream` octolib ส่ง `stream: false` คอมเมนต์ในอะแดปเตอร์ DeepSeek ตรงไปตรงมา: `// We don't support streaming in octolib yet` เราบัฟเฟอร์การตอบสนองทั้งหมดก่อนคืนค่า

สำหรับไลบรารีที่มีงานคือลูปเอเจนต์และเอาต์พุตที่มีโครงสร้าง นี่คือฟีเจอร์ ไม่ใช่ช่องว่าง เหตุผลอยู่ในตัวช่วย HTTP ที่ใช้ร่วมกัน:

```rust
pub(super) async fn send_and_read(
    request: reqwest::RequestBuilder,
    timeout: Option<Duration>,
) -> anyhow::Result<CapturedResponse> {
    let response = apply_request_timeout(request, timeout).send().await?;
    let status = response.status();
    let headers = response.headers().clone();
    let body = response.text().await?;   // อ่าน body ภายในหน่วยที่ถูกรีทราย
    Ok(CapturedResponse { status, headers, body })
}
```

ทั้ง `send()` และการอ่าน body ทั้งหมดเกิดขึ้นภายในหน่วย await เดียวกันที่ลูปรีทรายห่อหุ้มไว้ สิ่งนี้สำคัญเพราะความล้มเหลวของการขนส่งที่เลวร้ายที่สุดคือ RST กลางสตรีม — การเชื่อมต่อตาย _หลังจาก_ เฮดเดอร์มาถึงแต่ _ขณะที่_ body ยังมาอยู่ ไฟร์วอลล์ที่ตรวจสอบเพย์โหลด โหลดบาลานเซอร์ที่ทำงานเกินกำลัง การหมดเวลาของ NAT บนการสร้างที่ยาวนาน ถ้าคุณสตรีม ความล้มเหลวนั้นจะโผล่ขึ้นมาลึกในตัวจัดการโทเคนของคุณ โดยตอบไปแล้วครึ่งหนึ่ง ไม่มีทางรีทรายอย่างสะอาด เพราะเราอ่าน body ภายในหน่วยที่ถูกรีทราย `is_connection_error` จึงจำแนกความล้มเหลว (`is_connect() || is_request() || is_body()`) ลูปรีเฟรช HTTP client และ _คำขอทั้งหมด_ รีทรายอย่างสะอาด

ข้อแลกเปลี่ยนซื่อตรง: คุณไม่ได้เวลาแฝงโทเคนแรก และคุณเรนเดอร์เอาต์พุตแบบค่อยเป็นค่อยไปไม่ได้ สำหรับ UI แชต ให้สตรีม สำหรับเอเจนต์ที่ยังไงก็จะแยกวิเคราะห์การตอบสนองทั้งหมดเป็น tool calls หรือ JSON schema ก่อนทำอะไรอยู่แล้ว การบัฟเฟอร์ง่ายกว่า ถูกต้องกว่าบนเครือข่ายที่ไม่เสถียร และกำจัดบั๊กสถานะบางส่วนทั้งกลุ่ม เราเลือกความถูกต้อง การสตรีมอยู่ในรายการ มันไม่เคยเร่งด่วน

---

## บทเรียน 4: การรีทรายง่าย การรู้ว่าอะไรรีทรายได้ ไม่ง่าย

ตัว backoff เองธรรมดา — `base_timeout * 2^attempt` จำกัดที่ห้านาที ยกเลิกได้ผ่านโทเคน `watch`:

```rust
pub fn is_retryable_status(status: u16) -> bool {
    status == 429 || status >= 500
}
```

รีทราย `429` และ `5xx` อย่ารีทราย `4xx` เลย — คำขอที่แย่ก็ยังคงแย่ ส่วนนั้นสิบสองบรรทัด

ส่วนที่ได้มาด้วยความยากคือเส้นทางข้อผิดพลาดการเชื่อมต่อ การเชื่อมต่อ HTTP จากพูลที่ upstream ปิดครึ่งหนึ่งเงียบ ๆ ก่อให้เกิดข้อผิดพลาด `reqwest` ที่เป็น `is_request() = true, is_connect() = false` ถ้าคุณตรวจแค่ `is_connect()` คุณจะไม่เคยรีเฟรชพูล และ _ทุกการรีทรายใช้ซ็อกเก็ตที่ตายแล้วตัวเดิมซ้ำ_ — ดังนั้นการรีทรายทั้งหมดล้มเหลวเหมือนกัน และคุณสรุปว่าผู้ให้บริการล่มทั้งที่ไม่ใช่ เราเรียนรู้สิ่งนี้จากเอนด์พอยต์ที่โฮสต์ใน CN (DeepSeek, Moonshot) ที่ปิดการเชื่อมต่อ keep-alive ที่ว่างอยู่อย่างก้าวร้าว วิธีแก้คือปฏิบัติกับ `is_connect()`, `is_request()` และ `is_body()` ทั้งหมดเป็นข้อผิดพลาดการเชื่อมต่อ และสลับ client ใหม่อย่างอะตอมมิกก่อนความพยายามครั้งถัดไป:

```rust
if on_connection_error(&e) {
    crate::llm::providers::shared::refresh_http_client();  // พูลใหม่ ไม่มีซ็อกเก็ตเก่า
}
```

ตัว client เองถูกปรับจูนมาเพื่อสิ่งนี้พอดี: connect timeout 20 วินาที (จำกัดการจับมือ _ไม่ใช่_ การสร้าง ซึ่งอาจใช้เวลาเป็นนาที) TCP keepalive เฟรม PING ของ HTTP/2 และ pool idle timeout 30 วินาทีเพื่อขับการเชื่อมต่อออกก่อนที่ edge ฝั่ง upstream จะฆ่ามัน ไม่มีอะไรในนี้หรูหรา ทั้งหมดนี้คือความต่างระหว่างไลบรารีที่รอดจากเครือข่ายแย่ ๆ ในบ่ายหนึ่งกับไลบรารีที่ปลุกคุณด้วยการแจ้งเตือน

---

## บทเรียน 5: ความแปลกเฉพาะโมเดลอยู่ในข้อมูล ไม่ใช่ใน `if` ที่กระจัดกระจายไปทุกที่

กลับมาที่เหตุการณ์เปิดเรื่อง การแก้ไขไม่ใช่แฮก — มันคือการยอมรับว่า "โมเดลนี้รับปุ่มปรับ sampling และ reasoning อะไรบ้าง" คือ _ข้อมูลเกี่ยวกับโมเดล_ และวางมันไว้ที่ที่ข้อมูลควรอยู่ ในอะแดปเตอร์ Anthropic นั่นคือสไลซ์ `const` ไม่กี่ตัว:

```rust
const NO_SAMPLING_MODELS: &[&str]     = &["fable-5", "mythos-5", "opus-4-8", "opus-4-7"];
const ADAPTIVE_ONLY_MODELS: &[&str]   = &["fable-5", "mythos-5", "opus-4-7"];   // thinking แบบ manual → 400
const NO_TOP_P_MODELS: &[&str]        = &["opus-4-1", "opus-4-7", "sonnet-4-5", /* ... */];
const EFFORT_PARAM_MODELS: &[&str]    = &["fable-5", "mythos-5", "opus-4-7", "opus-4-6", /* ... */];
```

กลไกทั่วไปที่ทำให้สิ่งนี้ประกอบกันได้คือ `SamplingSupport` — มาสก์บูลีนเล็ก ๆ ที่ผู้ให้บริการคืนค่าต่อโมเดล ประกาศว่า `temperature`, `top_p`, `top_k` ตัวไหนที่โมเดลจะยอมรับ:

```rust
pub struct SamplingSupport { pub temperature: bool, pub top_p: bool, pub top_k: bool }
// SamplingSupport::ALL, ::NONE, ::TEMPERATURE_ONLY, ::TEMPERATURE_AND_TOP_P
```

trait ผสานค่าที่ผู้ใช้ร้องขอกับมาสก์นี้ใน `effective_sampling_params` โมเดลที่ปฏิเสธ `top_p` ก็เพียงแค่ไม่เคยมี `top_p` ใน body คำขอ — ไม่มีผู้เรียกที่ไหนเขียน `if model == ...` มีความละเอียดอ่อนที่เกี่ยวข้องซึ่งเราพบเมื่อได้รับ 400 เท่านั้น: บางโมเดล Anthropic ปฏิเสธค่า sampling ที่ไม่ใช่ค่าเริ่มต้น _ใด ๆ_ เมื่อ reasoning เปิดอยู่ ดังนั้นอะแดปเตอร์ตัดสินใจ `thinking_enabled` ล่วงหน้าและบีบ sampling ตามนั้น ก่อนสร้างคำขอ บทเรียนนี้ทั่วไปเกินกว่า Anthropic: ทันทีที่คุณมีการตรวจ `if model.contains(...)` สองครั้งสำหรับแนวคิดเดียวกัน คุณมีตารางข้อมูลที่กำลังพยายามจะเกิด ปล่อยให้มันเกิดเถอะ

---

## OctoHub เข้ากันตรงไหน: พร็อกซีบนเลเยอร์

คำถามตามธรรมชาติเมื่อคุณมี client แบบรวมศูนย์: ถ้าการรวมศูนย์อยู่บนเครือข่ายล่ะ เพื่อให้ client ที่ไม่ใช่ Rust ได้มันด้วย นั่นคือ [OctoHub](https://github.com/Muvon/octohub) — เซิร์ฟเวอร์พร็อกซี LLM ประสิทธิภาพสูงที่ก็คือ octolib ตามตัวอักษร พร้อมหน้าตา HTTP และฐานข้อมูล

เอนจินพร็อกซีของ OctoHub ไม่ได้ implement ผู้ให้บริการใด ๆ ใหม่ มันพึ่งพา octolib และเรียกแฟกทอรีเดียวกัน:

```rust
let (provider_name, model) = self.config.resolve_model(&req.model)?;
let provider = ProviderFactory::create_provider(&provider_name)?;
let response = provider.chat_completion(params).await?;
```

สิ่งที่ OctoHub เพิ่มคือเลเยอร์ปฏิบัติการรอบ ๆ การเรียกนั้น: คีย์ API ต่อผู้เช่า การบันทึกคำขอ/การตอบสนองทั้งหมดลง SQLite/MySQL/Postgres การวิเคราะห์การใช้งาน การแมปชื่อโมเดล (นามแฝงสั้น ๆ ถูกแปลงเป็นรายการสตริง `provider:model` เลือกหนึ่งตัวแบบสุ่มเพื่อการกระจายโหลดแบบหยาบ ๆ) และขีดจำกัดการทำงานพร้อมกันต่อผู้ให้บริการพร้อม timeout ฝั่ง upstream มันพูดรูปทรง Responses-API (`/v1/completions`, `previous_completion_id` สำหรับหลายเทิร์น) และ `/v1/chat/completions` ที่เข้ากับ OpenAI สำหรับ client ที่พูดแบบนั้นอยู่แล้ว

แล้วมันปิดวงจร: octolib _ก็มีผู้ให้บริการ `octohub:` ด้วย_ ดังนั้นแอป Rust ที่ใช้ octolib สามารถกำหนดเส้นทางผ่านอินสแตนซ์ OctoHub — ซึ่งกำหนดเส้นทางกลับออกไปผ่าน octolib สู่ upstream จริง — ได้คีย์รวมศูนย์ การบันทึก และการติดตามต้นทุนฟรี ด้วยสตริง `provider:model` เดียวกันทุกที่ ผู้ให้บริการ `octohub` ประกาศทุกความสามารถว่ารองรับ (`supports_caching`, `supports_vision`, `supports_structured_output` ทั้งหมดเป็น `true`) ก็เพราะ _มันไม่อาจรู้_ ว่าอะไรอยู่หลังพร็อกซี; upstream จริงคืนข้อผิดพลาดที่ชัดเจนถ้าโมเดลพื้นฐานทำตามคำขอไม่ได้ พร็อกซีซื่อสัตย์อย่างจงใจต่อความไม่รู้ของตัวเอง

นี่คือผลตอบแทนของการกำหนดขอบเขต trait ให้ถูกต้อง นามธรรมเดียวกันรองรับทั้งการเรียกไลบรารีในกระบวนการและการกระโดดข้ามเครือข่าย และพวกมันประกอบกันได้โดยทั้งสองฝั่งไม่รู้ว่ากำลังคุยกับฝั่งไหน

---

## สิ่งที่ผมจะบอกคนที่เริ่มทำสิ่งนี้วันนี้

| บทเรียน                                        | สิ่งที่เป็นรูปธรรม                                                                                             |
| ---------------------------------------------- | -------------------------------------------------------------------------------------------------------------- |
| นามธรรมความสามารถ ไม่ใช่เอนด์พอยต์             | trait คือรายการคำถาม (`supports_caching`, `supported_sampling_params`) ไม่ใช่แค่ `chat_completion`             |
| สร้างแบบจำลองความต่างของ tool-call อย่างชัดเจน | enum ที่มีแท็กต่อรูปทรงผู้ให้บริการ ดีกว่า struct "สากล" ที่สูญเสียข้อมูลตัวเดียว                              |
| ทำการใช้งานให้เป็นมาตรฐานก่อนเชื่อต้นทุน       | สายโซ่ `.or()` ระหว่าง `prompt_tokens`/`input_tokens`/`prompt_eval_count`; รักษา `input_tokens` ให้สะอาดจากแคช |
| บัฟเฟอร์ถ้ายังไงก็จะแยกวิเคราะห์อยู่แล้ว       | การอ่าน body ภายในหน่วยที่ถูกรีทรายทำให้ RST กลางสตรีมรีทรายได้                                                |
| รีเฟรชพูลเมื่อเกิดข้อผิดพลาดการเชื่อมต่อ       | `is_request()` และ `is_body()` ก็เป็นข้อผิดพลาดการเชื่อมต่อด้วย ไม่ใช่แค่ `is_connect()`                       |
| ความแปลกคือข้อมูล                              | สไลซ์ `const` ของโมเดลและมาสก์ `SamplingSupport` ไม่ใช่ `if model.contains` ที่กระจัดกระจาย                    |

ไม่มีอะไรในนี้ใหม่ ทั้งหมดนี้คือสิ่งที่คุณจะจดลงก็ต่อเมื่อมันกัดคุณแล้วเท่านั้น นั่นคือเหตุผลที่บทเรียนเหล่านี้ส่วนใหญ่อยู่ในรูปคอมเมนต์สี่บรรทัดเหนือค่าเริ่มต้นบรรทัดเดียวในโค้ดจริง ส่วนที่ยากที่สุดของเลเยอร์ผู้ให้บริการ LLM แบบรวมศูนย์ไม่ใช่นามธรรม มันคือการซื่อตรงว่าสิ่งที่อยู่ใต้มันต่างกันจริง ๆ มากแค่ไหน — และปฏิเสธที่จะกลบความแตกต่างจริงด้วยค่าเริ่มต้นที่เต็มไปด้วยความหวัง

## คำถามที่พบบ่อย

**octolib เป็นไลบรารี LLM ของ Rust ที่ผมใช้แบบสแตนด์อโลนได้ไหม**

ได้ มันเป็นโอเพนซอร์สภายใต้ Apache-2.0 คอมไพล์ด้วย `cargo check` และตัวอย่างรันได้ด้วยแค่ API key สตริง `provider:model` คืออินเทอร์เฟซทั้งหมด มันทำ LLM inference, embeddings และ reranking — ไม่มีอะไรอื่น ไม่มีเอเจนต์ ไม่มีเชน

**เลเยอร์รวมศูนย์รองรับผู้ให้บริการเจ้าไหนบ้าง**

ผู้ให้บริการ LLM ยี่สิบเอ็ดเจ้าลงทะเบียนในแฟกทอรี รวมถึง OpenAI, Anthropic, Google, DeepSeek, Moonshot, MiniMax, Z.ai, Groq, Cerebras, Together, NVIDIA, Cloudflare, Fireworks, Featherless, BytePlus, Amazon Bedrock, OpenRouter, OctoHub, Ollama, เอนด์พอยต์โลคัล และแบ็กเอนด์ `cli:` ผู้ให้บริการพื้นเมืองได้อะแดปเตอร์เฉพาะ; ที่เหลือใช้เส้นทางที่เข้ากับ OpenAI ร่วมกัน

**มันสตรีม tool calls ไหม**

วันนี้ยัง — octolib บัฟเฟอร์การตอบสนองทั้งหมด (`stream: false`) เพื่อให้การรีเซ็ตการเชื่อมต่อกลางสตรีมรีทรายได้อย่างสะอาด ซึ่งสำคัญต่อลูปเอเจนต์และเอาต์พุตที่มีโครงสร้างมากกว่าเวลาแฝงโทเคนแรก การสตรีมอยู่ในโรดแมป

**OctoHub ต่างจาก octolib อย่างไร**

octolib คือไลบรารี Rust ในกระบวนการ OctoHub คือ _เซิร์ฟเวอร์_ พร็อกซีที่สร้างบนมัน — การกำหนดเส้นทางผู้ให้บริการเดียวกัน บวกคีย์หลายผู้เช่า การบันทึกคำขอ การวิเคราะห์การใช้งาน และการกระจายโหลด octolib ถึงขั้นเรียก OctoHub ได้ผ่านผู้ให้บริการ `octohub:` ของมัน

— Don

---

_[octolib](https://github.com/Muvon/octolib) และ [OctoHub](https://github.com/Muvon/octohub) เป็นโอเพนซอร์สภายใต้ Apache-2.0 ถ้าผู้ให้บริการที่คุณต้องการขาดไป เทมเพลตอยู่ใน `src/llm/providers/openai.rs` — [เปิด issue หรือ PR](https://github.com/Muvon/octolib/issues)._
