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
评论区
写评论还没有评论