< 返回版块

aiqubits 发表于 2026-06-04 11:30

Tags:全模态协议兼容接口,智能路由引擎,闲置算力网关,实时计费快照,Rust 高性能服务,分布式节点调度,主流大模型SDK集成

在 AI API 消费爆炸式增长的今天,中小企业研发团队和个人开发者面临一个共同的技术痛点:如何在多模型混用、账号池管理、精确计量计费、下游分销这四大复杂链路上,构建一套可维护、可扩展的基础设施,而不是在业务代码里堆出一团乱麻?

现有方案(LiteLLM、OpenRouter 等)要么是 Python/Node.js 实现导致运行时开销偏高,要么是 SaaS 化的黑盒,无法满足私有部署和企业定制逻辑的需求。

KeyCompute 以 90%+ 的 Rust 代码、28 个 workspace crate,从零打造了一套覆盖"请求接入 → 双层路由 → 算力接入执行 → 实时计费 → 二级分销"全链路的 AI Token 算力服务平台。


目录

一、架构总览:28 个 crate 组成的 Workspace Monorepo 二、双层路由引擎:模型级路由 + 账号池路由的分层设计 三、LLM 执行网关:llm-gateway 与 Provider trait 的协作边界 四、实时计费引擎:请求级价格快照与后置精确结算 五、闲置算力网关:拉取式边缘接入的 node-gateway 设计 六、认证与限流:JWT + Redis 的双轨限流架构 七、可观测性:Prometheus + 结构化日志的零侵入集成 八、总结:5 个关键工程亮点


快速上手

# 克隆项目
git clone https://github.com/keycompute/keycompute.git
cd keycompute

# 复制环境变量模板
cp .env.example .env
# 编辑 .env,填入数据库密码、JWT 密钥、加密密钥等

# 一键启动(Docker Compose,推荐)
docker compose up -d

# 验证服务状态
docker compose ps
# 访问 http://localhost:8080,默认账号 admin@keycompute.local

# 最简调用示例(OpenAI 兼容格式)
curl http://localhost:3000/v1/chat/completions \
  -H "Authorization: Bearer sk-your-api-key" \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-4","messages":[{"role":"user","content":"Hello!"}]}'

# 流式响应示例
curl -s http://localhost:3000/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer sk-your-api-key" \
  -d '{"model":"deepseek-chat","messages":[{"role":"user","content":"hello"}],"stream":true}'

# 路由到本地 PC 节点(使用 node: 前缀)
curl http://localhost:3000/v1/chat/completions \
  -H "Authorization: Bearer sk-your-api-key" \
  -H "Content-Type: application/json" \
  -d '{"model":"node:llama3","messages":[{"role":"user","content":"Hello!"}]}'

# 请求多模态模型
curl http://localhost:3000/v1/chat/completions \
  -H "Authorization: Bearer sk-your-api-key" \
  -H "Content-Type: application/json" \
  -d '{"model":"gemma4:e4b","messages": [
      {
        "role": "user",
        "content": [
          {"type": "text", "text": "请一句话描述这张图片里有什么内容?"},
          {"type": "image_url", "image_url": {"url": "https://static.www.tencent.com/uploads/2026/05/04/753d15b2999cb08433f68a09b91d8090.jpg", "detail": "auto"}}
        ]
      }
    ],
    "max_tokens": 500
}'

# 启用 Redis 限流特性的本地开发模式
cargo run -p keycompute-server --features redis

环境要求: Rust ≥ 1.92、PostgreSQL ≥ 16、Redis ≥ 7、Docker(容器部署)。


一、架构总览:28 个 crate 组成的 Workspace Monorepo

1.1 项目目录结构

// 来源:Cargo.toml(workspace 根)
[workspace]
resolver = "2"
members = [
  # ── 前端 ─────────────────────────────────────────────
  "packages/ui",           # 共享 UI 组件库 (Dioxus 0.7)
  "packages/web",          # Web 管理后台
  "packages/client-api",   # API 客户端(供前端调用)

  # ── 后端核心 crate ────────────────────────────────────
  "crates/keycompute-server",       # Axum HTTP 服务入口
  "crates/keycompute-types",        # 全局共享类型定义(零依赖)
  "crates/keycompute-db",           # 数据库访问层(SQLx)
  "crates/keycompute-auth",         # 认证与鉴权(JWT)
  "crates/keycompute-ratelimit",    # 分布式限流(内存/Redis 双后端)
  "crates/keycompute-pricing",      # 定价引擎(Token 单价计算)
  "crates/keycompute-routing",      # 双层路由引擎
  "crates/keycompute-runtime",      # 运行时状态(热配置)
  "crates/keycompute-billing",      # 计费结算(快照 + 后置扣费)
  "crates/keycompute-distribution", # 二级分销引擎
  "crates/keycompute-observability",# Prometheus 指标 + 结构化日志
  "crates/keycompute-config",       # 配置管理(环境变量分层解析)
  "crates/keycompute-emailserver",  # 邮件服务(SMTP)

  # ── LLM 执行层 ────────────────────────────────────────
  "crates/llm-gateway",             # LLM 请求执行网关(核心调度)
  "crates/node-gateway",            # 闲置算力节点接入网关

  # ── Provider 适配器 ───────────────────────────────────
  "crates/llm-provider/keycompute-provider-trait", # Provider 抽象 trait
  "crates/llm-provider/keycompute-openai",   # OpenAI / Claude / Gemini
  "crates/llm-provider/keycompute-deepseek", # DeepSeek
  "crates/llm-provider/keycompute-ollama",   # Ollama 本地模型
  "crates/llm-provider/keycompute-vllm",     # vLLM 自部署模型
  "crates/llm-provider/keycompute-claude",   # Anthropic 原生协议
  "crates/llm-provider/keycompute-gemini",   # Google Gemini 原生协议

  # ── 支付适配器 ────────────────────────────────────────
  "crates/keycompute-payment/keycompute-alipay",    # 支付宝支付
  "crates/keycompute-payment/keycompute-wechatpay", # 微信支付

  # ── 集成测试 ──────────────────────────────────────────
  "crates/integration-tests",
]

这个 workspace 声明是整个项目的架构地图。28 个 crate 被划分为四个明确的层次:前端展示层、后端业务层、LLM 执行层、Provider 适配层。每一层之间的依赖关系是严格单向的——上层依赖下层,下层对上层一无所知。

// 来源:Cargo.toml(workspace.dependencies,简化)
[workspace.dependencies]
# 关键设计:所有 crate 版本在 workspace 根统一管理
keycompute-types = { path = "crates/keycompute-types" }
keycompute-db    = { path = "crates/keycompute-db" }
keycompute-routing = { path = "crates/keycompute-routing" }
llm-gateway      = { path = "crates/llm-gateway" }

[profile.release]
opt-level = "z"      # 关键设计:最小体积优化,容器镜像更小
lto = true           # 跨 crate 链接时优化,消除跨模块死代码
codegen-units = 1    # 最大优化机会(牺牲编译速度换运行时性能)
strip = true         # 移除调试符号
panic = "abort"      # 减少 panic 展开代码体积

[profile.release] 里的五行配置值得仔细看。lto = true 开启了链接时优化(Link-Time Optimization),这意味着编译器可以在所有 28 个 crate 的边界上做死代码消除和内联——对于一个分层紧密的系统,这个优化尤其有价值。panic = "abort" 则意味着一旦出现 panic,进程直接终止而不做栈展开,这减少了二进制体积,也符合"宁可挂掉重启,不留半死不活"的服务哲学。

1.2 核心设计原则

原则一:依赖方向单向性。 keycompute-types 是最底层的 crate,它不依赖任何其他 workspace crate。所有类型定义(结构体、枚举、错误类型)集中在这里,确保上层 crate 之间不会因为类型共享而产生循环依赖。这是大型 Rust workspace 的标准实践。

原则二:feature flag 驱动可选依赖。 Redis 并非强制依赖——keycompute-server 通过 --features redis 来启用分布式限流。这让单机部署无需引入 Redis,降低了运维复杂度。keycompute-ratelimit crate 内部维护两套后端实现,通过 feature 编译期选择,零运行时判断开销。

原则三:Provider 层完全隔离。 llm-gateway 不直接依赖任何具体的 Provider 实现(如 keycompute-openai),而是通过 keycompute-provider-trait 中定义的 trait 来交互。具体 Provider 在运行时通过注入的方式接入。这意味着添加一个新的 Provider 不需要修改 llm-gateway 的任何代码。

原则四:Release 配置面向容器优化。 opt-level = "z" 优先体积而非速度,这是容器化部署场景的正确取舍——AI 网关的瓶颈在网络 I/O 和上游 Provider 的响应延迟,而非 CPU 计算,因此牺牲少量计算性能换取镜像体积减小是划算的。

1.3 服务启动流程

// 来源:crates/keycompute-server/src/main.rs(推断自架构文档,简化)

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // 阶段 1:配置解析(环境变量 → 强类型结构体)
    let config = keycompute_config::Config::from_env()?;

    // 阶段 2:数据库连接池初始化(SQLx PgPool)
    let db = keycompute_db::create_pool(&config.database.url).await?;

    // 阶段 3:运行时状态构建(热配置、Provider 注册表)
    let runtime = keycompute_runtime::AppRuntime::new(&config, db.clone()).await?;

    // 阶段 4:路由引擎初始化(从数据库加载模型→Provider 映射)
    let router = keycompute_routing::RoutingEngine::from_runtime(&runtime).await?;

    // 阶段 5:LLM 网关组装(注入路由引擎 + Provider 适配器)
    let gateway = llm_gateway::Gateway::new(router, /* providers */ ...);

    // 阶段 6:Axum Router 构建与服务启动
    let app = keycompute_server::build_router(gateway, runtime);
    axum::serve(listener, app).await?;
    Ok(())
}

这个初始化流程体现了一个重要的工程原则:依赖关系在编译期确定,状态在启动期构建,请求期零配置读取。没有全局 lazy_static,没有运行时的 Mutex<Option<T>>,每一个组件在被使用前必须已经完全初始化。Rust 的所有权系统在这里充当了"架构卫士"——如果你忘记初始化某个依赖,代码根本无法编译。


二、双层路由引擎:模型级路由 + 账号池路由的分层设计

//! keycompute-routing,双层路由决策引擎。 //! 架构约束:该模块只负责「选择哪个 Provider 账号来执行请求」;不执行网络请求,不修改请求内容,不记录计费数据。

2.1 两层路由的职责划分

想象一家大型酒店的前台调度系统:前台先判断客人需要哪类房型(模型级路由),再在同类房型的多个房间里按照入住情况选一个(账号池路由)。如果选中的房间恰好出了故障,系统自动切到下一个同类房间,不需要客人重新排队。

在 KeyCompute 中,这个比喻对应得非常精确:

// 来源:crates/keycompute-routing/src/lib.rs(推断自架构描述,展示设计意图)

/// 第一层:模型级路由
/// 职责:将用户请求的 model 名称映射到具体的 Provider 渠道
pub struct ModelRouter {
    // 关键设计:model_name -> Vec<ProviderChannel> 的多对多映射
    // 允许同一模型名称对应多个 Provider 渠道,实现故障转移
    routes: Arc<HashMap<String, Vec<ProviderChannel>>>,
}

/// 第二层:账号池路由
/// 职责:在同一渠道的多个 API 账号中,按权重随机选择一个
pub struct AccountPoolRouter {
    // 关键设计:加权随机选择,权重在管理后台动态配置
    // 高权重账号获得更多流量,适合配额更高的账号
    accounts: Vec<WeightedAccount>,
}

impl ModelRouter {
    /// 路由决策的核心函数
    /// 返回有序的候选列表,llm-gateway 按顺序尝试,失败则下移
    pub fn resolve(&self, model_name: &str) -> Option<Vec<ProviderChannel>> {
        self.routes.get(model_name).cloned()
    }
}

ModelRouter::resolve 返回的是一个有序候选列表而非单一选择。这个设计决策看似微小,实则关键:它将"失败重试"的复杂性从路由层剥离出去,委托给 llm-gateway 处理。路由层只管"提供选项",不管"选项失败了怎么办"——单一职责原则的体现。

2.2 加权随机负载均衡

// 来源:crates/keycompute-routing/src/account_pool.rs(推断)

pub struct WeightedAccount {
    pub account: ProviderAccount,
    pub weight: u32,  // 关键设计:整数权重,避免浮点数精度问题
}

impl AccountPoolRouter {
    pub fn select(&self) -> Option<&ProviderAccount> {
        if self.accounts.is_empty() {
            return None;
        }
        let total_weight: u32 = self.accounts.iter().map(|a| a.weight).sum();
        // 关键设计:使用整数随机 + 累加权重,而非浮点数概率
        let mut rng_value = rand::thread_rng().gen_range(0..total_weight);
        for wa in &self.accounts {
            if rng_value < wa.weight {
                return Some(&wa.account);
            }
            rng_value -= wa.weight;
        }
        None
    }
}

为什么不用轮询(Round Robin)而用加权随机?

因为不同 Provider 账号的配额上限可能差异悬殊——一个企业账号每分钟 10000 TPM,一个免费账号只有 500 TPM。轮询会均匀分配请求,导致高配额账号被严重低估,免费账号被频繁触发限流。加权随机允许运维人员按实际配额比例设置权重,流量分配与配额比例自然对齐。

举一个典型失败场景:如果 3 个账号权重均为 1 用轮询,但账号 A 的配额是 B、C 的 10 倍,在高并发下 B 和 C 会持续 429 错误,触发大量无效重试,实际吞吐量反而低于只使用 A 的情况。加权随机(A 权重设为 10,B 和 C 各为 1)则能让流量按配额比例自然分布。

2.3 健康检查与故障转移

// 来源:crates/keycompute-routing/src/health.rs(推断)

pub struct HealthChecker {
    // 关键设计:AtomicBool 做可用性标记,无需 Mutex
    // 读操作使用 Ordering::Relaxed,不需要跨线程同步保证
    availability: Arc<AtomicBool>,
    last_check: Arc<AtomicU64>,  // Unix timestamp,无锁更新
}

impl HealthChecker {
    pub fn is_available(&self) -> bool {
        self.availability.load(Ordering::Relaxed)
    }

    /// 后台 tokio task 定期探测 Provider 可用性
    pub async fn run_periodic_check(self: Arc<Self>, interval: Duration) {
        loop {
            tokio::time::sleep(interval).await;
            let result = self.probe().await;
            self.availability.store(result.is_ok(), Ordering::Relaxed);
            /* ... */
        }
    }
}

这段代码体现了一个精妙的工程权衡:使用 AtomicBool + Ordering::Relaxed 而不是 Mutex<bool>。在健康检查这个场景下,我们不需要严格的跨核同步——偶尔读到一个稍早的状态值(某个账号刚刚恢复但路由器还没感知到)是可接受的,而 Mutex 的锁开销在高并发路由决策中是真实的性能损耗。这是"宽松一致性(Relaxed Consistency)换取性能"的教科书案例。


三、LLM 执行网关:llm-gateway 与 Provider trait 的协作边界

//! llm-gateway,LLM 请求的执行引擎。 //! 架构约束:该模块不拥有业务逻辑(计费、鉴权均在外部完成);只负责「拿到路由决策 → 调用对应 Provider → 返回流式响应」。

3.1 Provider 抽象 trait

// 来源:crates/llm-provider/keycompute-provider-trait/src/lib.rs(推断)

/// 所有 Provider 必须实现的核心 trait
/// Send + Sync 保证跨线程安全传递(tokio 多线程运行时的要求)
#[async_trait::async_trait]
pub trait LlmProvider: Send + Sync {
    /// Provider 唯一标识符(如 "openai", "deepseek", "ollama")
    fn provider_id(&self) -> &str;

    /// 非流式请求:完整响应一次性返回
    async fn chat_completion(
        &self,
        request: ChatCompletionRequest,
        account: &ProviderAccount,
    ) -> Result<ChatCompletionResponse, ProviderError>;

    /// 流式请求:返回 SSE 数据流
    /// 关键设计:返回 BoxStream 而非具体类型,隐藏 Provider 实现细节
    async fn chat_completion_stream(
        &self,
        request: ChatCompletionRequest,
        account: &ProviderAccount,
    ) -> Result<BoxStream<'static, Result<ChatCompletionChunk, ProviderError>>, ProviderError>;
}

注意 Send + Sync 这两个 trait bound,它们不是可以省略的细节。在 tokio 的多线程运行时中,async 函数的 Future 可能在不同线程间迁移(work-stealing 调度),如果 LlmProvider 的实现包含 Rc<T>RefCell<T> 这类非 Send 类型,代码将无法编译。这是 Rust 类型系统在"编译期强制并发安全"方面的典型体现——让不安全的并发使用无法被编译出来,而不是依赖运行时的检测或人工审查。

3.2 流式响应的管道设计

// 来源:crates/llm-gateway/src/execute.rs(推断)

pub struct Gateway {
    routing_engine: Arc<RoutingEngine>,
    providers: HashMap<String, Arc<dyn LlmProvider>>,
}

impl Gateway {
    pub async fn execute_stream(
        &self,
        request: ChatCompletionRequest,
        api_key_ctx: &ApiKeyContext,
    ) -> Result<impl Stream<Item = Result<ChatCompletionChunk, GatewayError>>, GatewayError> {
        // 第一步:路由决策,获取候选 Provider 列表
        let candidates = self.routing_engine
            .resolve(&request.model)
            .ok_or(GatewayError::ModelNotFound(request.model.clone()))?;

        // 第二步:按候选顺序尝试,失败则切换(自动重试)
        for channel in &candidates {
            let provider = self.providers
                .get(&channel.provider_id)
                .ok_or(GatewayError::ProviderNotRegistered)?;

            match provider.chat_completion_stream(request.clone(), &channel.account).await {
                Ok(stream) => {
                    // 关键设计:成功后立即返回 stream,不等待完整响应
                    // 这使得首 token 延迟(TTFT)最小化
                    return Ok(stream);
                }
                Err(e) if e.is_retryable() => {
                    // 可重试错误(限流、服务不可用):切换下一候选
                    tracing::warn!(
                        provider = %channel.provider_id,
                        error = %e,
                        "Provider failed, trying next candidate"
                    );
                    continue;
                }
                Err(e) => return Err(GatewayError::from(e)),
            }
        }
        Err(GatewayError::AllProvidersFailed)
    }
}

为什么不在 Provider 内部实现重试,而是在 Gateway 层实现跨 Provider 切换?

因为 Provider 层的重试只能在同一个账号内部重试,无法感知其他可用的替代 Provider。如果 OpenAI 的账号 A 限流,在 Provider 内重试只会打到账号 A 的其他端点,依然大概率失败。Gateway 层的切换逻辑能够跨越 Provider 边界——从 OpenAI 切换到同等能力的 DeepSeek。这是"关注点分离"在故障处理上的应用:Provider 只知道如何与特定 API 通信,Gateway 知道整个系统的拓扑结构。

3.3 OpenAI 协议适配层

// 来源:crates/llm-provider/keycompute-openai/src/lib.rs(推断)

pub struct OpenAIProvider {
    http_client: reqwest::Client,
    // 关键设计:HTTP 客户端在 Provider 初始化时创建,复用 TCP 连接池
    // 而非每次请求新建 Client(避免 TCP 握手开销)
}

#[async_trait::async_trait]
impl LlmProvider for OpenAIProvider {
    fn provider_id(&self) -> &str { "openai" }

    async fn chat_completion_stream(
        &self,
        request: ChatCompletionRequest,
        account: &ProviderAccount,
    ) -> Result<BoxStream<'static, Result<ChatCompletionChunk, ProviderError>>, ProviderError> {
        let openai_request = self.transform_request(request); // 转换为 OpenAI 格式
        let response = self.http_client
            .post(&account.endpoint_url)
            .bearer_auth(&account.api_key) // 关键设计:key 在执行时注入,不预先嵌入请求
            .json(&openai_request)
            .send()
            .await
            .map_err(ProviderError::Http)?;

        let stream = response
            .bytes_stream()
            .map(|chunk| parse_sse_chunk(chunk?)) // SSE 流解析
            .boxed(); // 关键设计:Box 化为 trait object,统一不同 Provider 的流类型

        Ok(stream)
    }
}

注意 API Key 的处理方式:account.api_key 在每次请求时从 ProviderAccount 里读取,而不是烧录在 Provider 实例里。这意味着同一个 OpenAIProvider 实例可以服务于多个不同的账号,账号切换完全不需要重建 Provider 对象。这是"状态分离"设计的典范——行为(如何构造 HTTP 请求)与状态(用哪个账号的 key)解耦。


四、实时计费引擎:请求级价格快照与后置精确结算

//! keycompute-billing,计费结算引擎。 //! 三不原则:不预扣余额(避免悲观锁)、不在请求链路上写库(避免延迟)、不使用浮点数(避免精度损失)。

4.1 为什么不用 f64 而用精确整数?

为什么不用 f64 来存储 Token 单价和费用?

因为 f64 的浮点精度问题在计费场景下会导致真实的资金损失或用户纠纷。举一个典型的失败场景:0.1 + 0.2 = 0.30000000000000004 在 IEEE 754 浮点数中是精确的,但如果你用 if total >= 0.3 { charge_user() } 判断是否达到计费阈值,这个判断会永远为 false——用户白嫖了。反过来,某些浮点累加可能让计算结果偏大,过度扣费同样是问题。

KeyCompute 使用整数(通常是以"厘"或更小单位为基准的定点数)存储所有金额,避免了这个问题。

// 来源:crates/keycompute-pricing/src/lib.rs(推断)

/// Token 单价,以整数微分(micro-yuan,百万分之一元)为单位存储
/// 关键设计:整数类型,天然避免浮点精度问题
pub struct TokenPrice {
    pub input_price_micro: u64,   // 输入 Token 每千个的价格,单位:微分
    pub output_price_micro: u64,  // 输出 Token 每千个的价格,单位:微分
}

impl TokenPrice {
    /// 计算一次请求的费用
    /// 关键设计:全程整数运算,最后转换为展示用的字符串
    pub fn calculate_cost(
        &self,
        input_tokens: u64,
        output_tokens: u64,
    ) -> u64 {  // 返回微分,而非元
        let input_cost = input_tokens
            .checked_mul(self.input_price_micro)
            .expect("overflow in cost calculation")
            / 1000;  // 除以 1000 是因为单价是"每千 token"
        let output_cost = output_tokens
            .checked_mul(self.output_price_micro)
            .expect("overflow in cost calculation")
            / 1000;
        input_cost + output_cost
    }
}

checked_mul 的使用值得一提。直接用 * 在 release 模式下不会 panic,溢出会静默地产生错误结果。checked_mul 在溢出时返回 None,这里用 expect 让它 panic——因为费用计算溢出是严重的程序错误,应该立即暴露,而不是产生一个错误的账单数字悄悄继续运行。

4.2 请求级价格快照

// 来源:crates/keycompute-billing/src/snapshot.rs(推断)

/// 请求级价格快照:在请求开始时捕获当前价格,用于最终结算
/// 关键设计:快照而非结算时查价,防止价格变动导致结算不一致
pub struct PriceSnapshot {
    pub model_name: String,
    pub captured_at: DateTime<Utc>,
    pub input_price_micro: u64,   // 快照时的输入 token 单价
    pub output_price_micro: u64,  // 快照时的输出 token 单价
}

/// 计费记录:在流式响应完成后写入数据库
pub struct BillingRecord {
    pub request_id: Uuid,
    pub user_id: Uuid,
    pub price_snapshot: PriceSnapshot,  // 包含快照价格,而非引用当前价格
    pub actual_input_tokens: u64,        // Provider 返回的实际消耗
    pub actual_output_tokens: u64,
    pub total_cost_micro: u64,           // 最终费用 = 实际 token × 快照价格
}

为什么不在请求结束后再查当前价格,而是在请求开始时做价格快照?

因为流式 LLM 请求可能持续数十秒甚至数分钟。如果在请求完成后才查价,而管理员恰好在这期间调整了价格,用户看到的"请求时价格"与最终账单价格就会不一致,产生争议。快照机制确保用户在发起请求时就确定了这次请求的结算价格,类似于"锁价"。这个设计体现了"时间点一致性"原则——对同一个经济事件,所有相关数据应来自同一个时间点。

4.3 后置结算的异步架构

// 来源:crates/keycompute-billing/src/settle.rs(推断)

/// 后置结算任务:在请求完成后异步执行,不阻塞响应路径
pub async fn settle_request(
    db: &PgPool,
    record: BillingRecord,
) -> Result<(), BillingError> {
    // 使用数据库事务保证余额扣减的原子性
    let mut tx = db.begin().await?;

    // 关键设计:先插入账单记录,再扣减余额
    // 即使扣减余额失败,账单记录也已存在,可用于后续对账
    sqlx::query!(
        "INSERT INTO billing_records (...) VALUES (...)",
        /* fields */
    )
    .execute(&mut *tx)
    .await?;

    sqlx::query!(
        "UPDATE users SET balance = balance - $1 WHERE id = $2 AND balance >= $1",
        record.total_cost_micro as i64,
        record.user_id,
    )
    .execute(&mut *tx)
    .await?;

    tx.commit().await?;
    Ok(())
}

注意 UPDATE users SET balance = balance - $1 WHERE ... AND balance >= $1 这个 SQL 语句的结构。AND balance >= $1 既是业务约束(余额不能为负),也是并发安全措施——在高并发下,两个请求同时读到余额 10,同时发起 8 的扣减,如果没有这个条件,可能导致余额变为 -6。这个条件让 UPDATE 在余额不足时静默返回 0 行受影响,上层代码检查受影响行数即可判断是否成功。这是"数据库层乐观并发控制"的轻量实践。


五、闲置算力网关:拉取式边缘接入的 node-gateway 设计

//! node-gateway,闲置算力节点的接入网关。 //! 架构约束:节点无需公网 IP;只允许节点主动拉取任务,服务端不主动推送;单节点故障不影响其他节点。

5.1 拉取式轮询的核心设计

允许个人 PC 通过运行推理引擎接入算力网络,无需公网 IP 或复杂的 NAT 穿透配置。

// 来源:crates/node-gateway/src/poll.rs(推断)

/// 节点客户端:运行在用户 PC 上,主动向服务端轮询任务
pub struct NodeClient {
    server_url: String,
    registration_token: String,  // 关键设计:共享令牌,防止未授权节点接入
    node_id: Uuid,
    ollama_url: String,          // 本地 Ollama 服务地址,通常为 http://127.0.0.1:11434
}

impl NodeClient {
    pub async fn run_poll_loop(&self) {
        loop {
            match self.poll_task().await {
                Ok(Some(task)) => {
                    // 有任务:异步执行,不阻塞下一次轮询
                    let client = self.clone();
                    tokio::spawn(async move {
                        client.execute_task(task).await;
                    });
                }
                Ok(None) => {
                    // 无任务:等待一小段时间再轮询(避免空转浪费带宽)
                    tokio::time::sleep(Duration::from_millis(500)).await;
                }
                Err(e) => {
                    tracing::error!(error = %e, "Poll failed, backing off");
                    // 关键设计:指数退避,网络故障时不要压垮服务端
                    tokio::time::sleep(Duration::from_secs(5)).await;
                }
            }
        }
    }
}

为什么用拉取式轮询而不是 WebSocket 或 SSE 长连接?

因为长连接需要服务端维护每个节点的连接状态,在节点数量增加时服务端资源消耗线性增长。而且家庭宽带的 NAT 设备通常会在 1-5 分钟内断开闲置的 TCP 连接,导致 WebSocket 连接需要复杂的心跳维护逻辑。拉取式轮询让每个节点完全独立——节点挂掉不需要服务端做任何清理,服务端完全无状态。这是一个"以增加延迟换取系统简单性"的工程权衡。对于边缘计算节点这类场景,500ms 的任务获取延迟是可接受的,但节点掉线导致服务端状态泄漏是不可接受的。

5.2 模型前缀路由

// 来源:crates/keycompute-routing/src/node_route.rs(推断)

/// 特殊前缀路由规则:node: 开头的模型名路由到节点池
pub fn resolve_with_node_prefix(
    model_name: &str,
    node_pool: &NodePool,
) -> Option<RoutingTarget> {
    // 关键设计:显式前缀而非自动推断,避免意外将请求发到本地节点
    if let Some(local_model) = model_name.strip_prefix("node:") {
        // 从在线节点池中选择一个声明了该模型的节点
        node_pool.select_node_for_model(local_model)
            .map(RoutingTarget::LocalNode)
    } else {
        None  // 无前缀:走正常的云端路由
    }
}

node:llama3 这个前缀设计体现了"显式优于隐式"的哲学。用户明确写出 node: 前缀,才会将请求路由到本地 PC 节点;没有前缀则走云端 Provider。这避免了"自动路由到本地导致意外的慢响应"的问题,同时保留了利用本地算力的能力。用户知道自己在做什么,系统不替用户做隐式决策。


六、认证与限流:JWT + Redis 的双轨限流架构

//! keycompute-auth,认证与鉴权。 //! keycompute-ratelimit,分布式限流。 //! 架构约束:auth 层只做身份验证(这个 key 是谁的),不做业务鉴权(这个 key 能调哪些模型)。

6.1 API Key 的加密存储

// 来源:crates/keycompute-auth/src/key.rs(推断)

pub struct ApiKey {
    // 关键设计:数据库只存储加密后的 key,明文 key 只在创建时返回一次
    // 使用 KC__CRYPTO__SECRET_KEY 环境变量派生的 AES-256-GCM 加密
    pub encrypted_key: Vec<u8>,
    pub key_prefix: String,  // 存储 key 的前几位用于列表展示(如 "sk-3299...")
    pub user_id: Uuid,
    pub name: String,
    pub created_at: DateTime<Utc>,
}

/// 验证 API Key 的流程
pub async fn verify_api_key(
    db: &PgPool,
    crypto_secret: &[u8],
    presented_key: &str,
) -> Result<ApiKeyContext, AuthError> {
    // 步骤1:从请求 key 计算哈希,用于数据库快速查找
    // 关键设计:不需要解密所有 key 来找匹配项,只需哈希比较
    let key_hash = compute_key_hash(presented_key);
    let record = sqlx::query_as!(ApiKey,
        "SELECT * FROM api_keys WHERE key_hash = $1", key_hash
    )
    .fetch_optional(db)
    .await?
    .ok_or(AuthError::InvalidKey)?;

    Ok(ApiKeyContext { user_id: record.user_id, /* ... */ })
}

注意 KC__CRYPTO__SECRET_KEY 在 README 中有一条特别的警告:"一旦数据库写入数据后不可更改(会导致历史数据无法解密)"。这说明系统使用的是对称加密,而不是哈希——API Key 的明文在需要时是可以恢复的(用于某些需要展示完整 key 的管理场景)。这是加密 vs 哈希的设计权衡:哈希不可逆但更安全,加密可逆但需要保护好密钥。

6.2 双轨限流:内存 vs Redis

// 来源:crates/keycompute-ratelimit/src/lib.rs(推断)

/// 限流 trait:两种后端的统一接口
#[async_trait::async_trait]
pub trait RateLimiter: Send + Sync {
    async fn check_and_increment(
        &self,
        key: &str,
        window: Duration,
        limit: u64,
    ) -> Result<RateLimitResult, RateLimitError>;
}

/// 内存后端(单实例部署,无 Redis 依赖)
pub struct InMemoryRateLimiter {
    counters: Arc<DashMap<String, AtomicU64>>,
}

/// Redis 后端(多实例部署,状态共享)
#[cfg(feature = "redis")]
pub struct RedisRateLimiter {
    redis: Arc<redis::Client>,
}

这个 feature flag 设计让单实例部署和分布式部署的代码路径在编译期就分离:没有 --features redis 时,RedisRateLimiter 的代码根本不参与编译,零运行时判断,零条件分支。这是 Rust feature flag 相比运行时配置 if config.use_redis 的关键优势——编译期的可选依赖,而非运行时的条件判断


七、可观测性:Prometheus + 结构化日志的零侵入集成

// 来源:crates/keycompute-observability/src/metrics.rs(推断)

use prometheus::{Counter, Histogram, IntGauge, Registry};

pub struct Metrics {
    // 关键设计:每个指标都带有 label,便于按 Provider、模型、用户分组查询
    pub requests_total: Counter,
    pub request_duration_seconds: Histogram,
    pub active_requests: IntGauge,
    pub tokens_consumed_total: Counter,
}

impl Metrics {
    pub fn new(registry: &Registry) -> Self {
        Self {
            requests_total: register_counter_with_registry!(
                opts!("keycompute_requests_total", "Total number of requests"),
                registry
            ).unwrap(),
            request_duration_seconds: register_histogram_with_registry!(
                histogram_opts!("keycompute_request_duration_seconds", "Request duration"),
                registry
            ).unwrap(),
            /* ... */
        }
    }
}

结合 /health 健康检查端点和 Prometheus 指标,KeyCompute 给出了一个"可观测性最小可行方案":不依赖任何外部 APM 系统,只需要一个 Prometheus + Grafana 栈即可获得完整的监控视图。


总结:5 个关键工程亮点

1. Workspace Monorepo 的边界清晰

28 个 crate 严格单向依赖,keycompute-types 作为零依赖的类型基座,确保整个系统不会出现循环依赖。opt-level = "z" + lto = true 的 release 配置,让跨 crate 的优化成为可能,最终二进制体积最小化。对于借鉴者的价值:这个目录结构可以直接作为中型 Rust 服务的组织模板。

2. 双层路由引擎分离关注点

模型路由(model → Provider 渠道)与账号路由(渠道 → 具体账号)严格解耦。加权随机负载均衡让流量分配自然匹配各账号的实际配额上限。故障转移逻辑在 Gateway 层而非 Provider 层,允许跨 Provider 的能力兜底。

3. Provider trait 的"让错误无法编译"设计

LlmProvider: Send + Sync 的 trait bound 在编译期强制所有 Provider 实现线程安全,消除了一整类并发 bug。BoxStream<'static, ...> 的返回类型统一了不同 Provider 的异构流类型,使得 Gateway 层可以以完全相同的方式处理 OpenAI、DeepSeek、Ollama 的响应流。这是类型驱动设计(Type-Driven Design)的实践。

4. 请求级价格快照防止计费时间线不一致

在请求开始时锁定价格快照,在请求完成后用快照价格结算,而非结算时查询当前价格。数据库层用 AND balance >= amount 做乐观并发控制替代悲观锁,避免了高并发余额操作的锁争用。整数定点数替代浮点数彻底消除精度问题。

5. 拉取式边缘节点——无需公网 IP 的算力众包

node: 前缀路由 + 轮询拉取任务的组合,让闲置 PC 以极低的运维门槛接入算力网络。显式前缀避免了隐式路由带来的用户困惑,指数退避避免了网络故障时对服务端的冲击。这个设计思路对"边缘计算资源整合"类产品有直接的参考价值。


KeyCompute 不仅是一个 AI Token 代理网关,更是一个完整的"AI 算力基础设施工程实践样本"——从 Workspace Monorepo 的依赖组织,到 trait 抽象的 Provider 扩展,到精确计费的类型安全保证,每一层都体现了对"大规模 AI API 服务"在可靠性、可扩展性和精确性上的深刻理解。


📌 项目地址: https://github.com/keycompute/keycompute

📖 开源协议: MIT License

🦀 技术要求: Rust ≥ 1.92,PostgreSQL ≥ 16,Redis ≥ 7,Dioxus ≥ 0.7.1(前端)


Ext Link: https://mp.weixin.qq.com/s/vd1M463sE_D16cEaKZRfuA

评论区

写评论

还没有评论

1 共 0 条评论, 1 页