featured.svg

在當今生成式 AI 與 AI coding agent(例如 Claude Code、Aider 等 agent,或透過 OpenAI SDK / curl 等客戶端)蓬勃發展的時代,開發者與企業內部往往需要調度多個不同的模型後端——從本地執行的 Ollama、vLLM、TGI,到雲端 API。為了提供統一的存取介面、負載平衡、容錯重試以及模型名稱映射,AI Gateway(人工智慧閘道) 扮演著關鍵的中樞角色。

然而,市面上許多閘道方案往往依賴體積龐大的執行環境(如 Python 或 Node.js)或是封裝複雜的 heavy-weight 框架,不僅啟動與記憶體佔用可觀,在處理高並發 Server-Sent Events (SSE) 串流時也常帶來額外的延遲與 context switch 開銷。

這篇文章將完整分享我們最近開源的 C++23 AI Gateway 專案——一個基於 C++23 Stackless 協程、Linux epoll、零重型框架相依,並採用現代 C++ 套件管理器 Cabin 構建的極簡高效 OpenAI 相容閘道服務。

1. 動機與設計哲學:為何選擇 C++23 與 Cabin?

在構想這個閘道時,我們設定了幾個核心設計目標:

  1. 極致輕量與零框架相依(Zero-Framework Overhead):不引入 Boost.Asio 等重量級網路庫,而是直接基於 Linux 原生 epoll 與 non-blocking socket 打造專屬的非同步協程執行期(runtime),將二進位檔體積與執行期記憶體控制在數 MB 以內。
  2. 直觀清晰的協程代碼(Async/Await Syntax):透過 C++20/C++23 的 co_awaitTask<T>,讓非同步 I/O 與網路串流轉發像同步程式碼一樣線性流暢,徹底告別傳統 callback hell。
  3. 充分利用 C++23 語言與標準庫新特性:廣泛使用 std::expected 與 monadic 操作進行零成本錯誤處理、以 std::string_view::contains 簡化字串比對,並以 std::unreachable() 消除無效分支、輔助編譯器最佳化。
  4. 無縫相容 OpenAI API 生態:完整支援 /v1/chat/completions/v1/completions/v1/embeddings/v1/models/healthz,無論是官方 OpenAI Python/Node SDK 或是 curl 都能直接隨插即用。
  5. 低延遲 SSE 串流 Pass-Through:針對大語言模型(LLM)的 token 串流輸出(stream: true),實現零應用層堆積分配的即時 Pass-Through 轉發,並原生支援新一代推理模型(Reasoning Models)的思考鏈串流。
  6. 現代化建置體驗:使用如 Rust Cargo 般簡潔好用的 Cabin (cabin.toml) 管理依賴(nlohmann_jsonspdlogpicohttpparsercatch2),告別繁瑣的 CMake 配置地獄。
flowchart TD Client["📱 Downstream Client
OpenAI SDK / Agent CLI / curl"] subgraph Gateway["⚡ C++23 AI Gateway (Port 8080)"] direction TB EL["🔄 Linux epoll EventLoop
Task<T> 協程調度 · 最小堆定時器"] Parser["⚡ picohttpparser 零拷貝解析
HTTP/1.1 Server · Chunked StreamWriter"] Router["🧭 Model Router & Load Balancer
std::expected · 萬用字元 · 加權輪詢 · 容錯重試"] ClientCore["🌐 Async HTTP Client
即時 SSE Pass-Through · 16MB 緩衝防禦"] EL -->|非同步 I/O 排程| Parser Parser -->|提取模型與路由請求| Router Router -->|分發至目標上游節點| ClientCore end subgraph Upstreams["🖥️ Upstream Inference Clusters"] direction TB Ollama["🦙 Ollama (Local)
127.0.0.1:11434 (gemma4:26b / llama3.3)"] VLLM["🚀 vLLM / TGI
10.0.0.5:8000"] Cloud["☁️ Cloud API Fallback
api.openai.com"] end Client ==>|POST /v1/chat/completions| Parser ClientCore ==>|連線轉發請求| Upstreams Upstreams -.->|SSE Token Chunks 串流| ClientCore ClientCore -.->|即時 Pass-Through| Client classDef clientNode fill:#e0f2fe,stroke:#0284c7,stroke-width:2px,color:#0c4a6e; classDef gwNode fill:#f5f3ff,stroke:#6366f1,stroke-width:2px,color:#312e81; classDef upNode fill:#ecfdf5,stroke:#059669,stroke-width:2px,color:#064e3b; class Client clientNode; class EL,Parser,Router,ClientCore gwNode; class Ollama,VLLM,Cloud upNode; style Gateway fill:#faf5ff,stroke:#a5b4fc,stroke-width:2px,stroke-dasharray:4 4,color:#312e81; style Upstreams fill:#f0fdf4,stroke:#6ee7b7,stroke-width:2px,stroke-dasharray:4 4,color:#064e3b;

2. C++20/C++23 協程核心觀念深度剖析

C++ 協程是現代 C++ 中最強大但門檻也最高的語言特性之一。要寫出正確、高效且無記憶體洩漏的非同步 runtime,必須深刻理解其底層機制。

2.1 Stackless 協程與 Coroutine Frame

C++ 採用的是 Stackless (無堆疊) 協程,這與 Go 的 goroutine 或 Lua 協程 (Stackful) 有本質區別:

  • Stackful 協程:每個協程擁有自己獨立預先分配的呼叫堆疊(例如 2KB–8KB),可以在任何深度的巢狀函式呼叫中暫停。但代價是較高的記憶體開銷與複雜的堆疊切換組合語言程式碼。
  • Stackless 協程:協程本身被編譯器轉換為一個 狀態機 (State Machine)。協程的暫停點(co_awaitco_yieldco_return)必須位於協程函式本體之內(可位於巢狀的迴圈、條件分支與區塊中);若某個輔助函式需要暫停,它本身必須也是協程,無法跨越一般非協程函式呼叫邊界暫停。當協程暫停時,它的局部變數與執行狀態保存在堆積(heap)上分配的 Coroutine Frame (協程訊框) 中。
flowchart TD subgraph CoroutineFrame["📦 Coroutine Frame (堆積記憶體佈局)"] direction TB Header["🏷️ Function Pointers & State
Resume Fn · Destroy Fn · Suspend Index"] PromiseObj["🎯 promise_type (TaskPromise<T>)
continuation_ · exception_ · result_"] ArgsLocals["💾 Parameters & Local Variables
req, buffer, total_read, sock..."] end Handle["🔗 std::coroutine_handle<promise_type>
(非擁有指標,指向 Frame 起始位址)"] TaskObj["🛡️ Task<T> (RAII 封裝)
持有 handle_ · 解構時 handle_.destroy()"] Handle -->|指向| Header TaskObj -->|包裝| Handle classDef frameNode fill:#f8fafc,stroke:#475569,stroke-width:1.5px,color:#0f172a; classDef handleNode fill:#e0f2fe,stroke:#0284c7,stroke-width:2px,color:#0c4a6e; classDef taskNode fill:#ecfdf5,stroke:#059669,stroke-width:2px,color:#064e3b; class Header,PromiseObj,ArgsLocals frameNode; class Handle handleNode; class TaskObj taskNode; style CoroutineFrame fill:#f1f5f9,stroke:#94a3b8,stroke-width:2px,stroke-dasharray:4 4,color:#0f172a;

HALO 最佳化(Heap Allocation eLision Optimization)

雖然 Coroutine Frame 預設透過 operator new 在堆積上分配,但在編譯器最佳化(如 Clang 的 CoroElide Pass,需開啟 -O2/-O3 且協程與呼叫端可被內聯於同一編譯單元)下,若編譯器能證明協程的生命週期嚴格包含在呼叫者(caller)之內,就有機會觸發 HALO(堆積分配消除最佳化),直接將 Coroutine Frame 內聯到 caller 的堆疊上,達到零堆積分配開銷。需要注意的是,HALO 目前屬於編譯器的 best-effort 最佳化而非 C++ 標準保證(GCC 目前尚未實作 coroutine heap elision,業界亦正透過如 [[clang::coro_inplace_task]] 等屬性推動確定性 elision 提案)。

2.2 協程四大核心元件

C++ 協程基礎設施主要由以下四個元件相互協同運作:

  1. promise_type:協程內部的狀態控制器與結果接收者。它決定協程啟動時是否立即暫停(initial_suspend)、結束時如何清理(final_suspend)、如何捕獲返回值(return_value / return_void)與處理未捕獲異常(unhandled_exception)。
  2. std::coroutine_handle<P>:一個輕量級、型別安全的裸指標,指向 Coroutine Frame。可用來執行 resume()destroy()、檢查 done(),以及透過 handle.promise() 存取關聯的 promise 物件。
  3. Coroutine Frame:編譯器在背後自動生成的資料結構,存放狀態機索引、函式參數、局部變數與 promise 實例。
  4. Awaiter(等待器):任何實作了 Awaiter 概念 的物件,包含三個核心函式:
    • bool await_ready():回傳 true 表示結果已備妥,不暫停直接繼續;回傳 false 則進入暫停流程。
    • auto await_suspend(coroutine_handle<>):當協程準備暫停時被呼叫(僅在 await_ready() 回傳 false 時),可將當前 handle 註冊至 event loop 或傳遞給下一個排程者。
    • decltype(auto) await_resume():當協程被喚醒時被呼叫,其回傳值即為 co_await expr 的運算式結果。

2.3 為何將 Return Object (Task<T>) 與 promise_type 解耦?

在設計非同步協程庫時,初學者常困惑:為什麼 Task<T> 不直接就是 promise_type,而要分開宣告?

Task<T>promise_type 解耦是現代 C++ 協程架構的標準範式,背後有三大關鍵考量:

  1. RAII 資源生命週期管理promise_type 存活於 Coroutine Frame 內部,它無法安全管理外部呼叫者的擁有權。Task<T> 是一個 純移動 (move-only) 的外部 handle 物件。當 Task<T> 超出作用域或被解構時,它的解構子可以明確且安全地呼叫 handle_.destroy() 釋放 Coroutine Frame,防止懸空指標與記憶體洩漏。
  2. 公開 API 與內部狀態隔離:呼叫者只需要關心 co_await taskrelease()valid() 等公開介面,不需要看到內部的 continuation 鏈結指標(continuation_)、異常指標(exception_)或底層 variant 結果。
  3. 零拷貝傳值與單次消費意圖:透過將 operator co_await() 限定為 rvalue 參照(operator co_await() &&)並刪除 lvalue 運算子,能強制要求 co_await std::move(task),在語法層面明確表達單次消費語意,防止意外重複排程。

2.4 核心實作:Task<T>from_promise(*this)

以下是我們專案中 include/aigw/coroutine/task.hpp 的精華實作(包含 Task<T> 與針對 Task<void> 的特化支援):

#pragma once
#include <coroutine>
#include <exception>
#include <stdexcept>
#include <utility>
#include <variant>

namespace aigw::coro {

template <typename T = void>
class [[nodiscard]] Task;

namespace detail {

struct TaskPromiseBase {
    std::coroutine_handle<> continuation_{nullptr};
    std::exception_ptr exception_{nullptr};

    struct FinalAwaiter {
        bool await_ready() const noexcept { return false; }
        
        template <typename PromiseType>
        std::coroutine_handle<> await_suspend(std::coroutine_handle<PromiseType> h) noexcept {
            // 當協程執行完畢,若有等待中的父協程,直接對稱轉移 (Symmetric Transfer) 喚醒父協程
            if (h.promise().continuation_) {
                return h.promise().continuation_;
            }
            return std::noop_coroutine();
        }
        
        void await_resume() noexcept {}
    };

    // Lazy 模式:建立協程時先暫停,直到被 co_await 時才開始執行
    std::suspend_always initial_suspend() noexcept { return {}; }
    FinalAwaiter final_suspend() noexcept { return {}; }
    void unhandled_exception() noexcept { exception_ = std::current_exception(); }
};

template <typename T>
struct TaskPromise : TaskPromiseBase {
    std::variant<std::monostate, T> result_;

    Task<T> get_return_object() noexcept;

    template <typename U>
        requires std::convertible_to<U, T>
    void return_value(U&& value) {
        result_.template emplace<T>(std::forward<U>(value));
    }

    T& result() {
        if (exception_) {
            std::rethrow_exception(exception_);
        }
        return std::get<T>(result_);
    }
};

// Task<void> 特化:無需儲存回傳值,提供 return_void()
template <>
struct TaskPromise<void> : TaskPromiseBase {
    Task<void> get_return_object() noexcept;
    void return_void() noexcept {}
    void result() {
        if (exception_) {
            std::rethrow_exception(exception_);
        }
    }
};

} // namespace detail

template <typename T>
class [[nodiscard]] Task {
public:
    using promise_type = detail::TaskPromise<T>;
    using handle_type = std::coroutine_handle<promise_type>;

    Task() noexcept : handle_(nullptr) {}
    explicit Task(handle_type handle) noexcept : handle_(handle) {}

    // Move-only 語意,嚴禁拷貝
    Task(const Task&) = delete;
    Task& operator=(const Task&) = delete;

    Task(Task&& other) noexcept : handle_(std::exchange(other.handle_, nullptr)) {}
    Task& operator=(Task&& other) noexcept {
        if (this != &other) {
            if (handle_) handle_.destroy();
            handle_ = std::exchange(other.handle_, nullptr);
        }
        return *this;
    }

    ~Task() {
        if (handle_) {
            handle_.destroy();
        }
    }

    struct Awaiter {
        handle_type handle_;

        bool await_ready() const noexcept {
            return !handle_ || handle_.done();
        }

        std::coroutine_handle<> await_suspend(std::coroutine_handle<> awaiting_coroutine) noexcept {
            // 將等待者 (caller) 的 handle 記錄進 promise,以便執行完畢時回調
            handle_.promise().continuation_ = awaiting_coroutine;
            return handle_; // 對稱轉移執行子協程
        }

        decltype(auto) await_resume() {
            if (!handle_) {
                throw std::runtime_error("Attempted to await invalid coroutine task");
            }
            return handle_.promise().result();
        }
    };

    auto operator co_await() const & = delete;
    auto operator co_await() && noexcept {
        return Awaiter{handle_};
    }

    handle_type release() noexcept { return std::exchange(handle_, nullptr); }

private:
    handle_type handle_;
};

namespace detail {
// 透過 std::coroutine_handle::from_promise(*this) 將 promise 反向轉換為型別安全的 handle
template <typename T>
Task<T> TaskPromise<T>::get_return_object() noexcept {
    return Task<T>{std::coroutine_handle<TaskPromise<T>>::from_promise(*this)};
}

inline Task<void> TaskPromise<void>::get_return_object() noexcept {
    return Task<void>{std::coroutine_handle<TaskPromise<void>>::from_promise(*this)};
}
} // namespace detail

} // namespace aigw::coro

當一個協程函式被呼叫時:

  1. 編譯器在堆積上分配 Coroutine Frame,並構造 TaskPromise<T>
  2. 呼叫 promise.get_return_object(),內部利用 std::coroutine_handle<TaskPromise<T>>::from_promise(*this) 取得指向當前 Frame 的 handle,封裝成 Task<T> 並立即回傳給呼叫端。
  3. 由於 initial_suspend() 回傳 std::suspend_always,協程會先停在起始處,直到呼叫端執行 co_await task 時才啟動執行。

3. AI Gateway 架構設計與 C++23 現代化實作

有了堅固的協程基礎,接下來我們看看 AI Gateway 如何構建高性能的 HTTP 轉發服務,並深度結合 C++23 的語言特性。

3.1 自行實作 Linux epoll EventLoop 與非同步 Awaiter

我們在 include/aigw/coroutine/event_loop.hpp 中實作了單執行緒非阻塞的 EventLoop,結合 Linux epolleventfd 喚醒機制,並維護一個以 std::priority_queue 搭配自訂比較器(std::greater)實作的最小堆定時器佇列:

struct AsyncReadable {
    int fd;
    EventLoop* loop{nullptr};

    bool await_ready() const noexcept { return false; }
    void await_suspend(std::coroutine_handle<> handle) {
        if (!loop) loop = EventLoop::current();
        if (loop) {
            loop->register_read(fd, handle);
        } else {
            throw std::runtime_error("AsyncReadable awaited outside EventLoop thread");
        }
    }
    void await_resume() const noexcept {}
};

當呼叫 async_read 時,若 read() 回傳 EAGAINEWOULDBLOCK,協程會透過 co_await AsyncReadable{fd} 自動將自身暫停並向 epoll 註冊 EPOLLIN 事件;當核心通知 socket 可讀時,EventLoop 便立即將協程 handle 推入 ready_queue_ 恢復執行,全程零 CPU 忙碌等待(busy-waiting)。

3.2 picohttpparser 零記憶體拷貝 HTTP 解析

為了達到最高的解析速度,我們採用了知名且極簡的 C 語言 HTTP 解析函式庫 picohttpparser

在解析 HTTP Header 時,picohttpparser 直接回傳指向輸入緩衝區的指標與長度,不產生任何不必要的 std::string 堆積分配:

ParseResult HttpParser::parse_request(
    const char* buf, size_t len, size_t last_len,
    HttpRequest& out_req, size_t& header_len) {

    const char* method;
    size_t method_len;
    const char* path;
    size_t path_len;
    int minor_version;
    struct phr_header headers[MAX_HEADERS];
    size_t num_headers = MAX_HEADERS;

    int pret = phr_parse_request(
        buf, len,
        &method, &method_len,
        &path, &path_len,
        &minor_version,
        headers, &num_headers,
        last_len
    );

    if (pret > 0) {
        header_len = static_cast<size_t>(pret);
        out_req.method = from_string_view(std::string_view(method, method_len));
        // ... 解析路徑、Query String 與 Headers
        return ParseResult::Complete;
    } else if (pret == -1) {
        return ParseResult::Error;
    }
    return ParseResult::Incomplete;
}

3.3 即時 SSE (Server-Sent Events) Pass-Through 串流

在 LLM 對話生成場景中,若等所有 token 生成完畢再打包回傳,首字延遲(Time to First Token, TTFT)會非常糟糕。

我們在 HttpClient::stream_request 中設計了雙重回調機制:

  1. on_header:一旦上游回傳 HTTP 200 與 Content-Type: text/event-stream,立即向用戶端下發 SSE 回應標頭(Transfer-Encoding: chunkedCache-Control: no-cache)。
  2. on_chunk:後續 async_read 讀到的每一個原始 chunk,直接透過協程管道寫入客戶端 socket。
sequenceDiagram autonumber actor Client as 📱 Downstream Client (SDK) participant GW as ⚡ AI Gateway (C++23) participant Upstream as 🦙 Upstream LLM (Ollama/vLLM) Client->>GW: POST /v1/chat/completions (stream: true) Note over GW: picohttpparser 解析 JSON
std::expected 路由比對 -> 選擇 Upstream GW->>Upstream: POST /v1/chat/completions (stream: true) Upstream-->>GW: HTTP/1.1 200 OK (Transfer-Encoding: chunked) GW-->>Client: HTTP/1.1 200 OK (Transfer-Encoding: chunked) loop Real-Time Token & Reasoning Streaming Upstream-->>GW: data: {"choices":[{"delta":{"reasoning":"Thinking..."}}]} GW-->>Client: data: {"choices":[{"delta":{"reasoning":"Thinking..."}}]} Upstream-->>GW: data: {"choices":[{"delta":{"content":"Hi"}}]} GW-->>Client: data: {"choices":[{"delta":{"content":"Hi"}}]} end Upstream-->>GW: data: [DONE]\r\n0\r\n\r\n GW-->>Client: data: [DONE]\r\n0\r\n\r\n

3.4 C++23 型別安全路由:std::expected、Monadic 操作與 std::unreachable

在舊式設計中,函式失敗往往透過回傳 std::optional 或拋出 C++ 例外來處理。然而 std::optional 缺乏具體的錯誤原因,而 C++ 例外則伴隨著顯著的 stack unwinding 執行期開銷。

在 C++23 中,我們全面改用 std::expected<RouteDecision, RoutingError> 進行路由解析:

enum class RoutingError {
    NoMatchingRoute,
    PoolNotFound,
    PoolEmpty
};

inline std::string to_string(RoutingError err) {
    switch (err) {
        case RoutingError::NoMatchingRoute: return "No route matched for requested model";
        case RoutingError::PoolNotFound: return "Target upstream pool not found in configuration";
        case RoutingError::PoolEmpty: return "Upstream pool has no configured target nodes";
    }
    std::unreachable(); // C++23: 告知編譯器此路徑不可能發生,消除無效 jump branch
}

std::expected<RouteDecision, RoutingError> ModelRouter::route(const std::string& requested_model) const {
    for (const auto& r : config_.routes) {
        if (match_pattern(r.model_pattern, requested_model)) {
            auto it = pools_.find(r.pool);
            if (it != pools_.end()) {
                if (it->second->nodes().empty()) {
                    return std::unexpected(RoutingError::PoolEmpty);
                }
                RouteDecision decision;
                decision.pool = it->second;
                decision.original_model = requested_model;
                decision.model = r.rewrite_model.empty() ? requested_model : r.rewrite_model;
                return decision;
            } else {
                return std::unexpected(RoutingError::PoolNotFound);
            }
        }
    }

    if (default_pool_) {
        if (default_pool_->nodes().empty()) {
            return std::unexpected(RoutingError::PoolEmpty);
        }
        RouteDecision decision;
        decision.pool = default_pool_;
        decision.original_model = requested_model;
        decision.model = requested_model;
        return decision;
    }

    return std::unexpected(RoutingError::NoMatchingRoute);
}

這項 C++23 升級帶來了幾大核心優勢:

  1. 富型別錯誤狀態(Rich Typed Errors):閘道層可根據 RoutingError 精確映射 HTTP 狀態碼(例如 NoMatchingRoute 映射為 404 Not Found,而 PoolNotFound/PoolEmpty 映射為 500 Internal Error),且全程零例外開銷。
  2. C++23 Monadic 鏈式操作:支援使用 .transform().and_then() 進行優雅的函數式鏈結:
    // 優雅提取 Pool 名稱,無需多層巢狀 if-else
    auto pool_name = router.route("llama3.3").transform([](const RouteDecision& d) {
        return d.pool->name();
    });
  3. C++23 std::string_view::contains:告別冗長易錯的 str.find("chunked") != std::string_view::npos,改用簡潔直觀的 te->contains("chunked")ct->contains("text/event-stream")
  4. std::unreachable() 消除死代碼:在列舉(enum)的 switch 中使用 std::unreachable(),能精確告知編譯器所有有效列舉值已被完全覆蓋,徹底消除「control reaches end of non-void function」警告並產生更緊湊的 jump table 組合語言。

3.5 加權輪詢(Weighted Round-Robin)與透明容錯重試

每個 Upstream Pool 支援配置多個節點,並可設定各自的權重(weight)、最大失敗次數(max_fails)與失敗冷卻時間(fail_timeout_sec)。

std::shared_ptr<UpstreamNode> UpstreamPool::select_node() {
    auto candidates = get_healthy_candidates();
    if (candidates.empty()) return nullptr;

    int total_weight = 0;
    for (const auto& node : candidates) {
        total_weight += std::max(1, node->weight());
    }

    size_t idx = rr_index_.fetch_add(1, std::memory_order_relaxed) % static_cast<size_t>(total_weight);
    int cumulative = 0;
    for (const auto& node : candidates) {
        cumulative += std::max(1, node->weight());
        if (idx < static_cast<size_t>(cumulative)) {
            return node;
        }
    }
    return candidates.front();
}

當某個節點連線逾時或回傳 5xx 錯誤時,閘道會自動將該節點標記為失敗,並在 max_retries 上限內自動挑選下一個健康的節點重試(重試機制適用於尚未開始向下游下發 SSE 標頭與串流 chunk 的初始階段),對下游客戶端完全透明無感。

3.6 現代化 Cabin 構建與 Catch2 測試

專案使用 Cabin 管理,宣告檔 cabin.toml 極其簡潔:

[package]
name = "ai-gateway"
version = "0.1.0"
cxx-standard = "c++23"

[dependencies]
nlohmann_json = { port = true, version = "^3.12.0" }
picohttpparser = { port = true, version = "^2026.4.6" }
spdlog = { port = true, version = "^1.17.0" }

[dev-dependencies]
catch2 = { port = true, version = "^3.15.1" }

只要執行 cabin buildcabin test,就能自動抓取依賴、編譯並運行包含 EventLoop、協程異常傳遞、定時器、HTTP Parser、C++23 std::expected 路由與加權負載平衡的完整單元測試(70 個斷言全部通過)。

4. 代碼審查與強固化實戰(Hardening)

在初步完成功能開發後,我們進行了一輪全面的代碼審查(Code Review),針對安全性、邊界情況與潛在漏洞進行了深度的強固化改善:

4.1 使用 nlohmann::json 避免錯誤訊息 JSON Injection

隱患:原先 HttpResponse::make_error 採用字串拼接方式建構 JSON:

// ❌ 危險:若 message 包含引號、反斜線或控制字元,將造成 JSON 語法損壞
std::string json = "{\"error\":{\"message\":\"" + message + "\",...}}";

當上游回傳異常的 HTML 或非預期格式時,客戶端解析 JSON 將直接崩潰。

修復:全面改用 nlohmann::json 安全序列化,自動處理字元跳脫:

// ✅ 安全:自動正確跳脫所有特殊字元
nlohmann::json j;
j["error"]["message"] = message;
j["error"]["type"] = type;
j["error"]["code"] = "invalid_request_error";
return make_json(status, j.dump());

4.2 Async-Signal-Safe 信號處理與忽略 SIGPIPE

隱患 1:在 UNIX signal handler 中呼叫 spdlog::info 或進行堆積記憶體分配是非 async-signal-safe 的行為,可能在特定情況下導致 deadlock。

修復 1:改用 volatile sig_atomic_t 旗標與 eventfd 進行非同步安全通知:

volatile sig_atomic_t g_shutdown_requested = 0;

void signal_handler(int sig) {
    (void)sig;
    g_shutdown_requested = 1;
    if (g_wakeup_fd >= 0) {
        uint64_t val = 1;
        ::write(g_wakeup_fd, &val, sizeof(val)); // async-signal-safe
    }
}

隱患 2:在 SSE 串流過程中,若客戶端中途關閉連線,伺服器繼續 write() 到已關閉的 socket 會觸發 SIGPIPE 信號,預設情況下會直接 終止整個行程

修復 2:在程式啟動時明確忽略 SIGPIPE

std::signal(SIGPIPE, SIG_IGN);

4.3 async_connect 逾時支援與定時器取消

隱患:原先 async_connect 雖然宣告了 timeout 參數,但在 AsyncWritable 上並未真正關聯定時器。若目標伺服器 SYN-ACK 遺失或被防火牆丟棄,協程將永久掛起。

修復:實作專屬的 AsyncConnectAwaiter,在暫停時註冊定時器,在連線成功恢復時立即取消定時器(cancel_timer),並透過 getsockopt(SOL_SOCKET, SO_ERROR) 嚴格校驗連線狀態。

4.4 16MB 緩衝區上限防禦與 Content-Length 安全解析

隱患:當面對惡意請求或異常的大型回應時,若緩衝區無限 resize(*2),可能導致伺服器 OOM。此外,若 Content-Length 包含負數或尾隨無效字元,解析不嚴格可能造成非預期行為。

修復

  • 限制客戶端與伺服端緩衝區最大上限為 16MBMAX_CLIENT_BUFFER)。
  • 使用 std::from_chars(或 std::stoull 嚴格校驗非負數與尾隨字元完整性)安全解析 Content-Length,遇到非法數值或超出上限時立即中斷連線並回傳 400/502 錯誤。

4.5 SSE 串流 Chunked 編碼與客戶端掛起修復(Transfer-Encoding: chunked)

隱患:在整合 OpenAI Python SDK(底層基於 httpx)測試即時串流時,我們發現連線雖然能正常接收所有 token,但在串流結束時 Python client 會 陷入無限等待(hang),直到連線逾時中斷。

原因剖析

  1. 上游推論後端(如 Ollama)是以 HTTP/1.1 Chunked Transfer Encoding 回傳 SSE 串流資料,每個資料區塊由長度標頭與內容組成,並以長度為 0 的終止區塊(0\r\n\r\n)標記傳輸結束。
  2. 由於我們的閘道採用零拷貝的 raw chunk 直接轉發,若在轉發 downstream HTTP Header 時將 Transfer-Encoding: chunked 標頭過濾或遺漏,客戶端的 HTTP 解碼器(httpx)便無法得知這是 chunked 資料流。
  3. 結果 httpx 會將其當作一般 stream,不會去解析 0\r\n\r\n 結束符號,而是一直傻傻等待 TCP socket 關閉(FIN)。但由於 Keep-Alive 長連線保持開啟,客戶端便發生無限 hang 的現象!

修復:在轉發 SSE 回應標頭時,明確保留並設定 Transfer-Encoding: chunked

if (s_hdr.is_chunked) {
    resp_headers.set("Transfer-Encoding", "chunked");
}
resp_headers.set("Content-Type", "text/event-stream");
resp_headers.set("Cache-Control", "no-cache");
resp_headers.set("Connection", "keep-alive");

Transfer-Encoding: chunked 正確送達客戶端後,httpx 的 chunked decoder 在收到 0\r\n\r\n 時便會立即乾淨俐落地觸發串流結束事件,徹底解決掛起問題。

5. 快速上手與本地推論實機驗證

5.1 啟動與測試

透過 Cabin 編譯與啟動服務:

# 編譯專案
cabin build

# 執行單元測試
cabin test

# 啟動閘道服務
cabin run -- --port 8080 --log-level debug --config config.json

設定檔 config.json 範例:

{
  "host": "0.0.0.0",
  "port": 8080,
  "log_level": "info",
  "default_pool": "local_ollama",
  "pools": [
    {
      "name": "local_ollama",
      "timeout_ms": 60000,
      "max_retries": 2,
      "targets": [
        { "host": "127.0.0.1", "port": 11434, "weight": 1 }
      ]
    }
  ],
  "routes": [
    {
      "model": "gemma*",
      "pool": "local_ollama"
    },
    {
      "model": "llama*",
      "pool": "local_ollama"
    }
  ]
}

5.2 與 OpenAI Python SDK 整合:本地 Ollama、gemma4:26b 與思考過程串流

我們使用本地啟動的 Ollama 載入具備強大推理能力(Reasoning / Thinking)的開源模型 gemma4:26b。新一代推理模型在輸出最終答案前,會先串流輸出內部的思考鏈過程(透過 delta.reasoningdelta.reasoning_content 傳遞)。

我們撰寫了一段 Python 測試腳本 scripts/openai-client.py,展示如何透過閘道即時呈現思考過程與最終回答:

from openai import OpenAI
import sys

# 將 base_url 指向本機 C++23 AI Gateway
client = OpenAI(
    base_url="http://localhost:8080/v1",
    api_key="none"
)

# 測試即時串流對話 (使用具備 Reasoning 能力的 gemma4:26b)
response = client.chat.completions.create(
    model="gemma4:26b",
    messages=[{"role": "user", "content": "請用繁體中文以兩句話解釋 C++20 協程。"}],
    stream=True
)

in_thinking = False

for chunk in response:
    if not chunk.choices:
        continue
    delta = chunk.choices[0].delta
    
    # 處理思考/推理輸出 (Thinking / Reasoning tokens)
    reasoning = getattr(delta, "reasoning", None) or getattr(delta, "reasoning_content", None)
    if reasoning:
        if not in_thinking:
            print("\033[90m[思考中...]\033[0m ", end="", flush=True)
            in_thinking = True
        print(f"\033[90m{reasoning}\033[0m", end="", flush=True)
    elif delta.content:
        if in_thinking:
            print("\n\n\033[32m[回答]\033[0m\n", end="", flush=True)
            in_thinking = False
        print(delta.content, end="", flush=True)

print()

執行輸出效果如下:

[思考中...] 使用者希望用繁體中文以兩句話解釋 C++20 協程。第一句說明協程的核心機制(無堆疊、可暫停與恢復執行的特殊函數),第二句說明其主要優勢(以同步直觀的邏輯處理非同步任務)。

[回答]
C++20 協程是一種能夠在執行過程中暫停並保存狀態,隨後能從原位恢復執行的特殊函數。
它讓開發者可以用撰寫同步程式碼般的直觀邏輯,來高效地處理複雜的非同步與併發任務。

實測結果顯示:

  • 極低轉發延遲:在本地或區域網路環境下,閘道自身的請求解析、std::expected 模型路由與非同步排程轉發開銷在 微秒(microseconds) 等級。
  • 流暢的思考鏈串流:推理模型輸出思考 token 與最終回答 token 均無延遲無阻塞即時傳達終端。
  • 記憶體極致輕量:在維持高並發長連線串流下,整個閘道行程記憶體佔用穩定控制在數 MB 範圍內。

6. 結語

透過這次從零打造 C++23 AI Gateway 的實踐,我們體會到現代 C++(特別是 C++20/C++23 協程、std::expected 與標準庫演進)在系統級網路程式設計上帶來的巨大躍進:

  • 優雅與效能兼得:我們不再需要在「Node.js/Go 的易讀非同步代碼」與「C/C++ 的極致效能」之間做妥協。透過自訂的 Task<T>epoll awaiter,我們能用最簡潔的 co_await 語法寫出極具競爭力的高並發伺服器。
  • 掌控力與安全性:擺脫了笨重龐大的框架相依,每一行 I/O 排程、記憶體配置與轉發邏輯都清晰透明,配合現代化的代碼審查與強固化防護,建構出真正輕盈、穩健且具備生產級品質的基礎架構軟體。

如果你對高並發網路程式設計、C++ 協程原理或自託管 AI 基礎設施感興趣,非常推薦親自動手體驗!