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

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

นี่คือการเจาะลึกเชิงวิศวกรรมของ octolib ไลบรารี Rust โอเพนซอร์สที่ทำการซึมซับนั้นให้กับ Octocode, Octomind และ Octobrain ผมจะพาเดินผ่านบทเรียนที่ทำให้เราเสียค่าตอบแทนมากที่สุด แต่ละบทผูกกับชิ้นโค้ดจริง ถ้าคุณได้อ่าน บทแนะนำ octolib หรือบทความพี่น้องเรื่อง การรันเอเจนต์ AI ตัวเดียวข้ามหลายโมเดล แล้ว นี่คือเลเยอร์ที่อยู่ใต้ทั้งสองบทความ


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

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

#[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 ให้แฟกทอรี:

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 เดียว:

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 ที่มีแท็ก:

#[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 คือที่ที่บทเรียนอยู่:

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 อาร์กิวเมนต์ กลับ เป็นสตริง การถอยกลับแบบยอมรับได้ก็สำคัญที่นี่เช่นกัน:

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

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


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

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

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 ตัวเดียวต้องทนภาษาถิ่นทุกแบบพร้อมกัน:

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 ที่ใช้ร่วมกัน:

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:

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 ใหม่อย่างอะตอมมิกก่อนความพยายามครั้งถัดไป:

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 ไม่กี่ตัว:

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 ตัวไหนที่โมเดลจะยอมรับ:

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 — เซิร์ฟเวอร์พร็อกซี LLM ประสิทธิภาพสูงที่ก็คือ octolib ตามตัวอักษร พร้อมหน้าตา HTTP และฐานข้อมูล

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

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 และ OctoHub เป็นโอเพนซอร์สภายใต้ Apache-2.0 ถ้าผู้ให้บริการที่คุณต้องการขาดไป เทมเพลตอยู่ใน src/llm/providers/openai.rsเปิด issue หรือ PR.