phase 12+13: HTTP API server with OpenAI-compatible endpoint (Milestone ③)
New crate: xserv-server
- Engine thread: loads Qwen3-8B, processes requests sequentially
- axum HTTP server: /health, /v1/models, /v1/chat/completions
- tokio::sync::mpsc channel between API and engine threads
- Non-streaming JSON response (streaming SSE to be added later)
API is OpenAI-compatible:
POST /v1/chat/completions {"messages": [...], "max_tokens": N}
→ {"choices": [{"message": {"content": "..."}}]}
Verified: "Hi" → ", I'm" (3 tokens), model runs correctly via HTTP.
Key learnings:
- std::sync::mpsc::SyncSender is Send but NOT Sync → wrap in Mutex for Arc<AppState>
- MutexGuard must not live across await points (scope carefully)
- axum 0.8 Extension<Arc<T>> requires T: Send + Sync
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ members = [
|
|||||||
"crates/xserv-kernels",
|
"crates/xserv-kernels",
|
||||||
"crates/xserv-model",
|
"crates/xserv-model",
|
||||||
"crates/xserv-tokenizer",
|
"crates/xserv-tokenizer",
|
||||||
|
"crates/xserv-server",
|
||||||
]
|
]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
@@ -20,3 +21,6 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
safetensors = "0.5"
|
safetensors = "0.5"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
axum = "0.8"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
|||||||
21
crates/xserv-server/Cargo.toml
Normal file
21
crates/xserv-server/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[package]
|
||||||
|
name = "xserv-server"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "xserv-server"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
xserv-cuda = { path = "../xserv-cuda" }
|
||||||
|
xserv-tensor = { path = "../xserv-tensor" }
|
||||||
|
xserv-kernels = { path = "../xserv-kernels" }
|
||||||
|
xserv-model = { path = "../xserv-model" }
|
||||||
|
xserv-tokenizer = { path = "../xserv-tokenizer" }
|
||||||
|
half.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
tokio.workspace = true
|
||||||
|
axum.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
115
crates/xserv-server/src/api.rs
Normal file
115
crates/xserv-server/src/api.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
use axum::Extension;
|
||||||
|
use axum::Json;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::engine::{GenerateEvent, GenerateRequest};
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct ChatRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub messages: Vec<Message>,
|
||||||
|
#[serde(default = "default_max_tokens")]
|
||||||
|
pub max_tokens: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
pub struct Message {
|
||||||
|
pub role: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_max_tokens() -> usize { 256 }
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ModelsResponse {
|
||||||
|
object: &'static str,
|
||||||
|
data: Vec<ModelInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct ModelInfo {
|
||||||
|
id: String,
|
||||||
|
object: &'static str,
|
||||||
|
owned_by: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn health() -> &'static str { "ok" }
|
||||||
|
|
||||||
|
pub async fn list_models(Extension(state): Extension<Arc<AppState>>) -> Json<ModelsResponse> {
|
||||||
|
Json(ModelsResponse {
|
||||||
|
object: "list",
|
||||||
|
data: vec![ModelInfo {
|
||||||
|
id: state.model_name.clone(),
|
||||||
|
object: "model",
|
||||||
|
owned_by: "xserv",
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn chat_completions(
|
||||||
|
Extension(state): Extension<Arc<AppState>>,
|
||||||
|
Json(req): Json<ChatRequest>,
|
||||||
|
) -> Json<serde_json::Value> {
|
||||||
|
let id = format!("chatcmpl-{}", Uuid::new_v4());
|
||||||
|
let model_name = state.model_name.clone();
|
||||||
|
let created = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs();
|
||||||
|
|
||||||
|
// Prepare prompt tokens (MutexGuard scoped)
|
||||||
|
let prompt = build_prompt(&req.messages);
|
||||||
|
let prompt_tokens = state.engine_tokenizer.lock().unwrap().encode(&prompt);
|
||||||
|
|
||||||
|
// Create channel and submit request (MutexGuard scoped)
|
||||||
|
let (tx, mut rx) = tokio::sync::mpsc::channel::<GenerateEvent>(64);
|
||||||
|
let gen_req = GenerateRequest {
|
||||||
|
prompt_tokens,
|
||||||
|
max_tokens: req.max_tokens,
|
||||||
|
sender: tx,
|
||||||
|
};
|
||||||
|
state.engine_sender.lock().unwrap().send(gen_req).unwrap();
|
||||||
|
|
||||||
|
// Now await — no MutexGuards held here
|
||||||
|
let mut content = String::new();
|
||||||
|
let mut finish_reason = "length".to_string();
|
||||||
|
while let Some(event) = rx.recv().await {
|
||||||
|
match event {
|
||||||
|
GenerateEvent::Token { text, .. } => content.push_str(&text),
|
||||||
|
GenerateEvent::Done { finish_reason: fr } => { finish_reason = fr; break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"id": id,
|
||||||
|
"object": "chat.completion",
|
||||||
|
"created": created,
|
||||||
|
"model": model_name,
|
||||||
|
"choices": [{
|
||||||
|
"index": 0,
|
||||||
|
"message": { "role": "assistant", "content": content },
|
||||||
|
"finish_reason": finish_reason,
|
||||||
|
}],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 0,
|
||||||
|
"completion_tokens": 0,
|
||||||
|
"total_tokens": 0
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_prompt(messages: &[Message]) -> String {
|
||||||
|
let mut prompt = String::new();
|
||||||
|
for msg in messages {
|
||||||
|
match msg.role.as_str() {
|
||||||
|
"system" => { prompt.push_str(&msg.content); prompt.push('\n'); }
|
||||||
|
"user" | "assistant" => { prompt.push_str(&msg.content); }
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prompt
|
||||||
|
}
|
||||||
76
crates/xserv-server/src/engine.rs
Normal file
76
crates/xserv-server/src/engine.rs
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
use xserv_model::{loader, GpuKVCache, ModelConfig, Qwen3};
|
||||||
|
use xserv_model::qwen3::sample_greedy;
|
||||||
|
use xserv_tensor::{DType, Device};
|
||||||
|
use xserv_tokenizer::Tokenizer;
|
||||||
|
|
||||||
|
pub struct Engine {
|
||||||
|
model: Qwen3,
|
||||||
|
config: ModelConfig,
|
||||||
|
tokenizer: Tokenizer,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct GenerateRequest {
|
||||||
|
pub prompt_tokens: Vec<u32>,
|
||||||
|
pub max_tokens: usize,
|
||||||
|
pub sender: tokio::sync::mpsc::Sender<GenerateEvent>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum GenerateEvent {
|
||||||
|
Token { id: u32, text: String },
|
||||||
|
Done { finish_reason: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Engine {
|
||||||
|
pub fn load(model_dir: &Path) -> Self {
|
||||||
|
xserv_cuda::device::set_device(0).unwrap();
|
||||||
|
let config = ModelConfig::from_file(&model_dir.join("config.json"));
|
||||||
|
eprintln!("[engine] Loading weights...");
|
||||||
|
let weights = loader::load_model_dir(model_dir, Device::Cuda(0));
|
||||||
|
eprintln!("[engine] Loaded {} tensors", weights.len());
|
||||||
|
let model = Qwen3::from_weights(config.clone(), weights);
|
||||||
|
let tokenizer = Tokenizer::from_file(&model_dir.join("tokenizer.json"));
|
||||||
|
eprintln!("[engine] Ready");
|
||||||
|
Self { model, config, tokenizer }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tokenizer(&self) -> &Tokenizer {
|
||||||
|
&self.tokenizer
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate(&self, req: GenerateRequest) {
|
||||||
|
let max_seq = 256;
|
||||||
|
let mut cache = GpuKVCache::new(&self.config, max_seq, DType::BF16);
|
||||||
|
|
||||||
|
let logits = self.model.forward_gpu_cache(&req.prompt_tokens, &mut cache);
|
||||||
|
let mut next = sample_greedy(&logits);
|
||||||
|
|
||||||
|
for _ in 0..req.max_tokens {
|
||||||
|
let text = self.tokenizer.decode(&[next]);
|
||||||
|
if req.sender.blocking_send(GenerateEvent::Token { id: next, text }).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.tokenizer.eos_token_id() == Some(next) {
|
||||||
|
let _ = req.sender.blocking_send(GenerateEvent::Done {
|
||||||
|
finish_reason: "stop".to_string(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if cache.seq_len() >= max_seq - 1 {
|
||||||
|
let _ = req.sender.blocking_send(GenerateEvent::Done {
|
||||||
|
finish_reason: "length".to_string(),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let logits = self.model.forward_gpu_cache(&[next], &mut cache);
|
||||||
|
next = sample_greedy(&logits);
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = req.sender.blocking_send(GenerateEvent::Done {
|
||||||
|
finish_reason: "length".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
62
crates/xserv-server/src/main.rs
Normal file
62
crates/xserv-server/src/main.rs
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
mod api;
|
||||||
|
mod engine;
|
||||||
|
|
||||||
|
use axum::{routing::{get, post}, Extension, Router};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::{mpsc, Arc, Mutex};
|
||||||
|
use engine::GenerateRequest;
|
||||||
|
|
||||||
|
pub struct AppState {
|
||||||
|
pub model_name: String,
|
||||||
|
pub engine_sender: Mutex<mpsc::SyncSender<GenerateRequest>>,
|
||||||
|
pub engine_tokenizer: Mutex<xserv_tokenizer::Tokenizer>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
if args.len() < 2 {
|
||||||
|
eprintln!("Usage: xserv-server <model-dir> [--port PORT]");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
let model_dir = PathBuf::from(&args[1]);
|
||||||
|
let port: u16 = args.iter()
|
||||||
|
.position(|a| a == "--port")
|
||||||
|
.and_then(|i| args.get(i + 1))
|
||||||
|
.and_then(|s| s.parse().ok())
|
||||||
|
.unwrap_or(8080);
|
||||||
|
|
||||||
|
let model_name = model_dir.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
let tokenizer = xserv_tokenizer::Tokenizer::from_file(&model_dir.join("tokenizer.json"));
|
||||||
|
let (tx, rx) = mpsc::sync_channel::<GenerateRequest>(1);
|
||||||
|
|
||||||
|
let model_dir_clone = model_dir.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let engine = engine::Engine::load(&model_dir_clone);
|
||||||
|
eprintln!("[engine] Listening for requests...");
|
||||||
|
while let Ok(req) = rx.recv() {
|
||||||
|
engine.generate(req);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let state = Arc::new(AppState {
|
||||||
|
model_name,
|
||||||
|
engine_sender: Mutex::new(tx),
|
||||||
|
engine_tokenizer: Mutex::new(tokenizer),
|
||||||
|
});
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/health", get(api::health))
|
||||||
|
.route("/v1/models", get(api::list_models))
|
||||||
|
.route("/v1/chat/completions", post(api::chat_completions))
|
||||||
|
.layer(Extension(state));
|
||||||
|
|
||||||
|
let addr = format!("0.0.0.0:{port}");
|
||||||
|
eprintln!("[server] Listening on {addr}");
|
||||||
|
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
98
docs/12-13-serving.md
Normal file
98
docs/12-13-serving.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
# Phase 12+13: Continuous Batching + HTTP API — Design Document (Milestone ③)
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
实现 HTTP serving 层:接收请求、调度执行、streaming 返回结果。OpenAI 兼容 API。
|
||||||
|
|
||||||
|
由于当前是单请求引擎(无 multi-GPU、无并发),Phase 12 (continuous batching) 和 Phase 13 (HTTP API) 合并实现:先实现单请求 serving,scheduler 作为 placeholder 留待后续扩展。
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Client (curl / OpenAI SDK)
|
||||||
|
│
|
||||||
|
▼ HTTP POST /v1/chat/completions
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ xserv-api (axum) │
|
||||||
|
│ - Parse request │
|
||||||
|
│ - Apply chat template │
|
||||||
|
│ - Submit to engine via channel │
|
||||||
|
│ - Stream SSE chunks from channel │
|
||||||
|
└────────────┬────────────────────────┘
|
||||||
|
│ InferenceRequest → mpsc channel
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ xserv-engine (dedicated thread) │
|
||||||
|
│ - Receive requests │
|
||||||
|
│ - Run model forward (prefill+decode)│
|
||||||
|
│ - Send tokens back via channel │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Crates
|
||||||
|
|
||||||
|
- `xserv-engine`: inference orchestration (model + cache + generate loop)
|
||||||
|
- `xserv-api`: HTTP server with axum
|
||||||
|
|
||||||
|
Both in one binary: `xserv-server`
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/chat/completions # main endpoint
|
||||||
|
GET /v1/models # list models
|
||||||
|
GET /health # health check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Request/Response (OpenAI compatible)
|
||||||
|
|
||||||
|
Request:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"model": "qwen3-8b",
|
||||||
|
"messages": [{"role": "user", "content": "Hello"}],
|
||||||
|
"stream": true,
|
||||||
|
"max_tokens": 256,
|
||||||
|
"temperature": 1.0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
SSE Response:
|
||||||
|
```
|
||||||
|
data: {"id":"...","choices":[{"delta":{"content":"Hi"},"index":0}]}
|
||||||
|
|
||||||
|
data: {"id":"...","choices":[{"delta":{},"finish_reason":"stop"}]}
|
||||||
|
|
||||||
|
data: [DONE]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Engine Design
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct Engine {
|
||||||
|
model: Qwen3,
|
||||||
|
config: ModelConfig,
|
||||||
|
tokenizer: Tokenizer,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Engine {
|
||||||
|
pub fn generate(&self, prompt_tokens: &[u32], params: &SamplingParams,
|
||||||
|
sender: mpsc::Sender<Token>) { ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Engine runs on a dedicated OS thread (avoids async/GPU conflicts).
|
||||||
|
API handlers communicate via `tokio::sync::mpsc` channels.
|
||||||
|
|
||||||
|
## Sampling
|
||||||
|
|
||||||
|
For this phase: greedy only (temperature=0 or 1 with argmax).
|
||||||
|
Top-k/top-p sampling added later.
|
||||||
|
|
||||||
|
## Test Plan
|
||||||
|
|
||||||
|
- [ ] curl streaming request → get SSE chunks
|
||||||
|
- [ ] Python OpenAI SDK client works
|
||||||
|
- [ ] /v1/models returns model info
|
||||||
|
- [ ] /health returns 200
|
||||||
|
- [ ] Multiple sequential requests work
|
||||||
Reference in New Issue
Block a user