API Server 与启动器¶
最后一块拼图:一个 FastAPI 应用接收 OpenAI 格式的请求,给每个请求分配 uid,发给 tokenizer,再把陆续回来的增量文本以 SSE 流式写回客户端;一个启动器按正确的顺序拉起所有进程。完成这一章,python -m minisgl --model Qwen/Qwen3-0.6B 就是一个可以用任何 OpenAI 客户端访问的推理服务。
自测:能答上来就可以跳过本章
- 成百上千个并发的 HTTP 请求共用一个 ZMQ 接收队列,每个请求怎样只拿到属于自己的回复?
- SSE(Server-Sent Events)的格式是什么?OpenAI 流式接口的最后一条消息是什么?
- 客户端中途断开连接,服务端要做什么?
- 启动器为什么用
spawn而不是fork创建子进程?
自测参考答案(先自己答,再展开对照)
- 每个请求有唯一的
uid:接收协程收到回复后按uid放进对应的回复列表,并设置那个请求的asyncio.Event;每个请求的处理协程只等待自己的事件、读取自己的列表。 - 每条消息是
data: <JSON>后面跟一个空行;OpenAI 的流式接口每个片段带一个delta.content,最后一个片段带finish_reason,然后发送data: [DONE]。 - 发送
AbortMsg给调度器,释放这个请求占用的请求槽和 KV,不要继续为它生成。 fork会复制父进程的状态,包括已经初始化的 CUDA 上下文、线程、ZMQ 的上下文,这些在子进程里不能安全使用;spawn启动一个干净的新解释器,再各自初始化。
本章要写的文件:server/args.py、server/api_server.py、server/launch.py、__main__.py、shell.py。
minisgl/
├── __init__.py
├── core.py
├── env.py
├── __main__.py ← 本章新建
├── shell.py ← 本章新建
├── attention/ (4 个文件,不变)
├── distributed/ (3 个文件,不变)
├── engine/ (5 个文件,不变)
├── kernel/ (2 个文件,不变)
├── kvcache/ (5 个文件,不变)
├── layers/ (8 个文件,不变)
├── llm/ (2 个文件,不变)
├── message/ (5 个文件,不变)
├── models/ (7 个文件,不变)
├── moe/ (2 个文件,不变)
├── scheduler/ (9 个文件,不变)
├── server/
│ ├── __init__.py ← 本章新建
│ ├── args.py ← 本章新建
│ ├── api_server.py ← 本章新建
│ ├── launch.py ← 本章新建
├── tokenizer/ (4 个文件,不变)
├── utils/ (8 个文件,不变)
examples/ch15_server.py——它只用到上面这些文件;python tools/steps.py check 会逐章搭出这棵树、跑这个 main。
一个共享队列,多个等待者¶
API Server 是一个 asyncio 程序。所有请求的回复都从同一个 ZMQ 队列进来,需要按 uid 分发给各自的处理协程:
@dataclass
class FrontendManager:
config: ServerArgs
send_tokenizer: ZmqAsyncPushQueue[BaseTokenizerMsg]
recv_tokenizer: ZmqAsyncPullQueue[BaseFrontendMsg]
uid_counter: int = 0
initialized: bool = False
ack_map: Dict[int, List[UserReply]] = field(default_factory=dict)
event_map: Dict[int, asyncio.Event] = field(default_factory=dict)
def new_user(self) -> int:
uid = self.uid_counter
self.uid_counter += 1
self.ack_map[uid] = []
self.event_map[uid] = asyncio.Event()
return uid
async def listen(self) -> None:
while True:
for reply in _unwrap(await self.recv_tokenizer.get()):
if reply.uid not in self.ack_map: # 已经结束或已取消的请求
continue
self.ack_map[reply.uid].append(reply)
self.event_map[reply.uid].set()
async def send_one(self, msg: BaseTokenizerMsg) -> None:
if not self.initialized: # 第一次调用时(此时事件循环已经在运行)才能创建后台任务
asyncio.create_task(self.listen())
self.initialized = True
await self.send_tokenizer.put(msg)
async def wait_for_ack(self, uid: int) -> AsyncIterator[UserReply]:
event = self.event_map[uid]
try:
while True:
await event.wait()
event.clear()
pending, self.ack_map[uid] = self.ack_map[uid], []
for ack in pending:
yield ack
if ack.finished:
return
finally:
self.ack_map.pop(uid, None)
self.event_map.pop(uid, None)
async def stream_generate(self, uid: int) -> AsyncIterator[bytes]:
async for ack in self.wait_for_ack(uid):
yield f"data: {ack.incremental_output}\n".encode()
yield b"data: [DONE]\n"
async def stream_chat_completions(self, uid: int) -> AsyncIterator[bytes]:
first = True
async for ack in self.wait_for_ack(uid):
delta: Dict[str, str] = {}
if first:
delta["role"] = "assistant"
first = False
if ack.incremental_output:
delta["content"] = ack.incremental_output
chunk = {"id": f"chatcmpl-{uid}", "object": "chat.completion.chunk",
"choices": [{"delta": delta, "index": 0, "finish_reason": None}]}
yield f"data: {json.dumps(chunk)}\n\n".encode()
end = {"id": f"chatcmpl-{uid}", "object": "chat.completion.chunk",
"choices": [{"delta": {}, "index": 0, "finish_reason": "stop"}]}
yield f"data: {json.dumps(end)}\n\n".encode()
yield b"data: [DONE]\n\n"
async def stream_with_cancellation(self, gen: AsyncIterator[bytes], request: Request,
uid: int) -> AsyncIterator[bytes]:
"""客户端断开连接时,通知后端中止这个请求,释放它占用的 KV 缓存。"""
try:
async for chunk in gen:
if await request.is_disconnected():
raise asyncio.CancelledError
yield chunk
except asyncio.CancelledError:
asyncio.create_task(self.abort_user(uid))
raise
async def abort_user(self, uid: int) -> None:
await asyncio.sleep(0.1)
self.ack_map.pop(uid, None)
self.event_map.pop(uid, None)
logger.warning(f"Aborting request {uid}")
await self.send_one(AbortMsg(uid=uid))
def shutdown(self) -> None:
self.send_tokenizer.stop()
self.recv_tokenizer.stop()
new_user() 为新请求分配 uid,并创建一个回复列表和一个 asyncio.Event;
- 后台协程 listen() 不断从 ZMQ 收 UserReply,追加到对应 uid 的列表里,并 set() 它的事件;
- 每个请求的处理协程在 wait_for_ack() 里 await event.wait(),醒来后取走列表里的所有回复逐个 yield,直到遇到 finished;
- listen() 在第一次发送时才创建:必须在事件循环已经运行之后,而 FastAPI 应用启动时还没有事件循环。
这是 asyncio 里"一个生产者、多个消费者按键分发"的标准写法。事件被 clear() 之后可能有新回复到达并再次 set(),所以取走回复时是把整个列表换掉,不会漏掉。
OpenAI 兼容接口¶
@app.post("/v1/chat/completions")
async def v1_chat_completions(req: OpenAICompletionRequest, request: Request):
state = get_state()
if req.messages:
text: str | List[Dict[str, str]] = [m.model_dump() for m in req.messages]
else:
assert req.prompt is not None, "Either 'messages' or 'prompt' must be provided"
text = req.prompt
uid = state.new_user()
await state.send_one(TokenizeMsg(uid=uid, text=text, sampling_params=_sampling_params(req)))
if req.stream:
return StreamingResponse(
state.stream_with_cancellation(state.stream_chat_completions(uid), request, uid),
media_type="text/event-stream")
content = ""
async for ack in state.wait_for_ack(uid):
content += ack.incremental_output
return {
"id": f"chatcmpl-{uid}",
"object": "chat.completion",
"created": int(time.time()),
"model": req.model,
"choices": [{"index": 0, "message": {"role": "assistant", "content": content},
"finish_reason": "stop"}],
}
messages(对话)和 prompt(纯文本)二选一,前者由 tokenizer 套用对话模板;
- 采样参数原样转给调度器,ignore_eos 是一个扩展字段(压测时常用);
- 流式时返回 text/event-stream:每条回复变成一个 data: {...}\n\n,其中 choices[0].delta.content 是增量文本;最后发一条 finish_reason: "stop" 和 data: [DONE];
- 非流式时收集全部回复拼成一个完整的 JSON。
async def stream_chat_completions(self, uid: int) -> AsyncIterator[bytes]:
first = True
async for ack in self.wait_for_ack(uid):
delta: Dict[str, str] = {}
if first:
delta["role"] = "assistant"
first = False
if ack.incremental_output:
delta["content"] = ack.incremental_output
chunk = {"id": f"chatcmpl-{uid}", "object": "chat.completion.chunk",
"choices": [{"delta": delta, "index": 0, "finish_reason": None}]}
yield f"data: {json.dumps(chunk)}\n\n".encode()
end = {"id": f"chatcmpl-{uid}", "object": "chat.completion.chunk",
"choices": [{"delta": {}, "index": 0, "finish_reason": "stop"}]}
yield f"data: {json.dumps(end)}\n\n".encode()
yield b"data: [DONE]\n\n"
断开连接时中止请求¶
客户端断开后,如果服务端继续生成,这个请求会一直占用一个请求槽和它的 KV 缓存,直到达到 max_tokens。所以每发一段数据前检查连接是否还在:
async def stream_with_cancellation(self, gen: AsyncIterator[bytes], request: Request,
uid: int) -> AsyncIterator[bytes]:
"""客户端断开连接时,通知后端中止这个请求,释放它占用的 KV 缓存。"""
try:
async for chunk in gen:
if await request.is_disconnected():
raise asyncio.CancelledError
yield chunk
except asyncio.CancelledError:
asyncio.create_task(self.abort_user(uid))
raise
AbortMsg。它经过 tokenizer 变成 AbortBackendMsg,调度器从等待队列或 decode 集合里找到这个请求,释放资源(第 11 章讨论了它与重叠调度交织时的细节)。
启动器¶
def start_backend(server_args: ServerArgs) -> None:
"""启动所有后端进程:每个 TP rank 一个调度器进程,一个 detokenizer,若干 tokenizer。"""
from minisgl.tokenizer import tokenize_worker
logger = init_logger(__name__, "initializer")
mp.set_start_method("spawn", force=True) # CUDA 不支持 fork 出来的子进程
ack_queue: mp.Queue = mp.Queue()
world_size = server_args.tp_info.size
for i in range(world_size):
mp.Process(target=_run_scheduler,
args=(replace(server_args, tp_info=DistributedInfo(i, world_size)), ack_queue),
name=f"minisgl-TP{i}-scheduler").start()
common = dict(tokenizer_path=server_args.model_path, backend_addr=server_args.zmq_backend_addr,
frontend_addr=server_args.zmq_frontend_addr, local_bs=1, ack_queue=ack_queue)
n = server_args.num_tokenizer
mp.Process(target=tokenize_worker, name="minisgl-detokenizer",
kwargs=dict(common, addr=server_args.zmq_detokenizer_addr,
create=server_args.tokenizer_create_addr, tokenizer_id=n)).start()
for i in range(n):
mp.Process(target=tokenize_worker, name=f"minisgl-tokenizer-{i}",
kwargs=dict(common, addr=server_args.zmq_tokenizer_addr,
create=server_args.tokenizer_create_addr, tokenizer_id=i)).start()
for _ in range(n + 2): # rank 0 调度器 + n 个 tokenizer + 1 个 detokenizer
logger.info(ack_queue.get())
- 主进程解析参数,创建
FrontendManager(此时绑定前端的 ZMQ 地址); - 用
spawn方式启动每个 TP rank 的调度器进程、一个 detokenizer 进程、N 个 tokenizer 进程。CUDA 不能在fork出来的子进程里重新初始化,必须用spawn; - 每个进程准备好后往一个
multiprocessing.Queue里放一条"就绪"消息(调度器只有 rank 0 发,且在所有 rank 完成sync_all_ranks之后);主进程收齐num_tokenizer + 2条后才继续; - 启动 uvicorn(或者交互式 shell)。
def _run_scheduler(args: ServerArgs, ack_queue: mp.Queue) -> None:
import torch
from minisgl.scheduler import Scheduler
with torch.inference_mode():
scheduler = Scheduler(args)
scheduler.sync_all_ranks() # 所有 rank 都初始化完(包括 ZMQ 订阅)再开始
if args.tp_info.is_primary():
ack_queue.put("Scheduler is ready")
if args.silent_output:
logging.disable(logging.INFO)
try:
scheduler.run_forever()
except KeyboardInterrupt:
scheduler.shutdown()
--shell)不起 HTTP 服务,直接在终端里对话;它把 max_running_req 设为 1、只录制批大小为 1 的 CUDA Graph,因为只有一个用户。
运行¶
"""第 15 章:启动完整的服务,用 OpenAI 客户端访问。"""
import os
import signal
import socket
import subprocess
import sys
import time
from concurrent.futures import ThreadPoolExecutor
import httpx
import psutil
from openai import OpenAI
with socket.socket() as s:
s.bind(("127.0.0.1", 0))
port = s.getsockname()[1]
proc = subprocess.Popen(
[sys.executable, "-m", "minisgl", "--model", "models/Qwen3-0.6B", "--dtype", "float32",
"--port", str(port), "--num-pages", "4096", "--max-seq-len-override", "1024"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True)
try:
t = time.time()
while True:
try:
if httpx.get(f"http://127.0.0.1:{port}/v1/models", timeout=1).status_code == 200:
break
except httpx.HTTPError:
time.sleep(0.5)
children = psutil.Process(proc.pid).children(recursive=True)
workers = [c for c in children if "multiprocessing-fork" in " ".join(c.cmdline())]
print(f"服务就绪(约 {time.time() - t:.0f} 秒):主进程(API Server)+ {len(workers)} 个工作进程"
f"(调度器、tokenizer)")
client = OpenAI(base_url=f"http://127.0.0.1:{port}/v1", api_key="none")
r = client.chat.completions.create(
model="qwen3", messages=[{"role": "user", "content": "用一句话介绍北京。/no_think"}],
max_tokens=48, temperature=0)
print("非流式:", repr(r.choices[0].message.content))
stream = client.chat.completions.create(
model="qwen3", messages=[{"role": "user", "content": "Count from 1 to 5. /no_think"}],
max_tokens=24, temperature=0, stream=True)
chunks = [c.choices[0].delta.content for c in stream if c.choices[0].delta.content]
print(f"流式: 收到 {len(chunks)} 个片段:", chunks[:12], "...")
def ask(i: int) -> float:
t0 = time.perf_counter()
client.chat.completions.create(model="qwen3", messages=[{"role": "user", "content": f"{i}+{i}=?"}],
max_tokens=16, temperature=0, extra_body={"ignore_eos": True})
return time.perf_counter() - t0
t0 = time.perf_counter()
with ThreadPoolExecutor(8) as ex:
lat = list(ex.map(ask, range(8)))
print(f"8 个并发请求(各 16 个 token)总用时 {time.perf_counter() - t0:.2f}s,单个 {min(lat):.2f}~{max(lat):.2f}s")
finally:
os.killpg(proc.pid, signal.SIGKILL)
服务就绪(约 9 秒):主进程(API Server)+ 2 个工作进程(调度器、tokenizer)
非流式: '<think>\n\n</think>\n\n北京是中国的首都,位于中国北方,是世界著名的历史文化名城。'
流式: 收到 24 个片段: ['<think>', '\n\n', '</think>', '\n\n', 'Count', ' from', ' ', '1', ' to', ' ', '5', ':'] ...
8 个并发请求(各 16 个 token)总用时 1.32s,单个 1.31~1.32s
<think></think>(/no_think 关闭了思考),然后是答案。
- 流式:每个 token 一个片段,特殊 token <think> 也作为一个完整片段发出。
- 8 个并发请求被调度器合成一个 batch,总用时与单个请求几乎相同——这就是连续批处理带来的吞吐。
官方实现
server/api_server.py · FrontendManager(第 99~213 行)server/launch.py · launch_server(第 40~113 行)server/args.py · parse_args(第 54~268 行)
官方的 /v1/chat/completions 非流式响应里 usage 字段恒为 0;我们直接省略了它。官方交互模式在退出时用 psutil 杀掉所有子进程,我们沿用了这个做法。
测试¶
def test_client_disconnect_aborts_request(server):
"""客户端中途断开:服务端中止请求并释放资源,之后的请求照常工作。"""
port, log_path = server
with httpx.stream("POST", f"http://127.0.0.1:{port}/v1/chat/completions",
json={"model": "m", "prompt": "Tell me a long story.", "max_tokens": 500,
"stream": True, "ignore_eos": True}, timeout=60) as r:
for i, _ in enumerate(r.iter_lines()):
if i >= 3:
break # 读了几段就断开
time.sleep(1.0)
resp = httpx.post(f"http://127.0.0.1:{port}/v1/chat/completions",
json={"model": "m", "prompt": "1 + 1 =", "max_tokens": 4}, timeout=60)
assert resp.status_code == 200
assert "Aborting request" in log_path.read_text()
tests/test_ch15_server.py 另外两个测试:非流式输出与 Hugging Face 的贪心结果逐字相同;流式与并发请求都能正确返回。
怎么讲清楚
讲 API Server:成百上千个 HTTP 请求共用一个 ZMQ 接收队列,用"按 uid 分发的回复列表 + 每个请求一个 asyncio.Event"——后台任务收到回复后放进对应请求的列表并唤醒它。OpenAI 流式接口是 SSE:每个片段一行 data: {...},内容在 delta.content 里,最后一条带 finish_reason,再发 data: [DONE]。客户端中途断开时要发中止消息,释放请求槽和 KV,否则引擎还在为没人要的请求生成。启动器用 spawn 而不是 fork 创建子进程:fork 会复制父进程里已经初始化的 CUDA、线程和锁的状态,不安全。
练习¶
- 在非流式响应里加上正确的
usage(提示词 token 数、生成 token 数)。这些数字在哪个进程里知道?需要在哪些消息里加字段? - 实现
/v1/completions(非对话的补全接口),它和/v1/chat/completions的请求、响应格式有什么不同? - 现在 uid 由 API Server 单调递增分配。如果将来要起多个 API Server 进程共同对外服务,uid 怎么分配才不会冲突?
参考答案
- 提示词长度在 tokenizer 里知道,生成数量在调度器里知道。可以在
UserReply里加prompt_tokens、completion_tokens(由 detokenizer 在结束时填入:它能数出收到了几个 token,提示词长度需要 tokenizer 在分词时记下、或者由调度器随最后一条DetokenizeMsg带回)。 - 请求用
prompt字段而不是messages,不套对话模板;响应里是choices[0].text而不是message.content,流式时每个片段是choices[0].text,object是text_completion。 - 让每个 API Server 使用不同的前缀或步长,例如 uid = 进程编号 + 进程数 × 本地计数;或者使用 UUID。detokenizer 的回复也要能路由回对应的 API Server(每个 API Server 一个接收地址,消息里带上来源)。
小结¶
- API Server 用"按 uid 分发的回复列表 +
asyncio.Event"让所有请求共用一个 ZMQ 接收队列。 - OpenAI 流式接口:SSE,每个片段一个
delta.content,最后是finish_reason和[DONE]。 - 客户端断开时发送
AbortMsg,释放请求占用的请求槽和 KV。 - 启动器用
spawn拉起调度器和 tokenizer 进程,等所有进程就绪再启动 HTTP 服务。
发现错误或有更好的写法?在 GitHub 上编辑此页提交问题