模型服务与运行维护
模型能在脚本里回答问题,离“别人也能方便地用它”还差几步。
一位读者提交问题,程序开始生成;另一位读者紧接着点了发送。第二个请求去哪里等?回答还没写完,第一位读者关掉了页面,又该怎样结束计算?模型更新以后,接口仍能连上,却开始答错旧问题,怎样退回上一版?
这些问题属于模型服务。它既要执行模型,也要管理请求的进入、等待、输出和结束。本章把第 31 章用过的真实小语言模型接成一个本地 HTTP 服务,再通过网络接口测量它。服务与客户端运行在不同进程中,让“模型算得快不快”和“用户等了多久”都有实际记录。
这也是第八篇的篇末实践:比较启用 KV 缓存前后的首词元延迟、生成速度、并发表现与服务进程内存。代码只监听本机地址,使用 CPU,便于观察完整过程。
1. 一个请求怎样走到模型面前
先约定一个小接口:客户端把提示文本和输出上限发给 POST /generate。服务读取请求,检查字段与长度,再决定能否接下这份工作。
一个已经进入系统的请求,通常会经过这些阶段:
- 读取并解析请求,核对输入限制。
- 等待可以使用的计算资源。
- 把文本套进对话模板并分词。
- 处理提示,生成第一个词元。
- 继续生成,将结果送回客户端,最后释放请求状态。
第 37 章主要研究第 4、5 步的计算。本章会把前面的等待和后面的传输也算进去。
服务不应该悄悄截掉太长的输入。比如,一段 800 词元的说明被截成 768 词元后,丢失的可能恰好是最后一句要求。本例对加上对话模板的输入设定 768 词元上限,对输出设定 64 词元上限,超出时返回错误;字符数和请求体字节数还有各自的检查,不能混为同一个长度。
输入符合要求,也不表示服务有无限的接待能力。这里最多允许两个生成请求进入:一个执行,一个等待。通过字段检查后,如果两个位置已经占满,请求会收到 HTTP 429,得知当前忙碌。这样等待中的请求不会无止境地积累。
图 1:本例共享一份模型,但每个生成请求拥有自己的输入与 KV 缓存。图中的容量是教学服务的明确限制。
2. 流式输出:让客户端知道回答走到了哪一步
等待全部内容生成完再返回,最容易理解,但用户在这段时间里什么也看不见。流式输出则允许服务边生成边发送。
本例采用 NDJSON:每一行是一个完整 JSON 对象。客户端按行读取,无需等到整个响应结束。事件分成四类:
| 事件 | 告诉客户端什么 |
|---|---|
start | 请求 ID、服务版本、输入长度与排队时长 |
token | 新词元 ID、序号和当前累计文本 |
done | 输出数量、结束原因与服务端耗时 |
error | 流已经开始,但生成过程中失败 |
为什么需要 done?因为网络连接断开,不一定意味着模型正常完成了回答。客户端只有读到正常的结束事件,才能把这次请求记作完成;提前断开或收到 error 都要另行处理。
还有一个容易忽略的细节:词元不等于一个完整汉字。某个词元可能只补上字节序列的一部分,也可能是换行、空格或结束标记。本例每次重新解码当前全部输出 ID,发送累计文本。客户端应替换当前显示内容,不能把每一行的 text 再拼接一遍,否则前面的句子会不断重复。
流式输出改善的是结果到达的方式,它不会自动减少模型的运算量。我们仍需要 KV 缓存:每个请求先处理完整提示,后续只送入最新词元,同时传入覆盖历史与当前长度的注意力掩码,并保持正确的缓存位置。实现依据固定版本的 Transformers 缓存接口。
缓存也有边界。这里的 KV 只属于当前请求,请求结束后释放;它不是把整段回答存起来供下一个请求复用。若以后加入前缀共享或回答缓存,还需要把模型版本、输入、生成配置和访问权限纳入匹配条件,不能只比较一句看起来相同的问题。
3. 并发接待,不等于同时计算
本例的 HTTP 层可以同时处理几个连接。即使模型正在生成,健康检查也可以返回。但共享模型前面有一把锁,同一时刻只允许一个生成请求使用它。
因此,两位读者几乎同时发出请求时,可能出现这样的顺序:
请求 A 开始生成;请求 B 等待。A 结束后,B 才开始处理提示并生成。
接待能力是两个请求,模型执行仍然是串行。这里没有实现上一章介绍的连续批处理,也没有把两个请求的词元合并成一个模型批次。锁的等待顺序也不是本例提供的先到先服务保证。
为什么先这样做?它使输入、缓存和失败清理的归属很清楚,也能直接展示排队怎样影响首词元延迟。以后换成支持连续批处理的运行框架时,才容易知道究竟改变了哪里。
本例还限定了等待时间、生成时间和连接数量。最多同时处理 8 个连接,超过连接上限时直接关闭新连接,因此过载并不总能表现为一条 HTTP 429 响应。客户端断开后,服务在后续写入失败时结束这次请求,并在 finally 中释放锁与容量。它不承诺瞬间取消:正在执行的那一次模型计算需要先返回,断开也要被检测到。生成截止时间同样在每个计算步骤之前检查,不是强行中断正在运行的运算。
这些处理能防止一个失败请求永久占住位置。不过,这仍是用于本地教学的服务:它没有公网认证、TLS、反向代理、跨机器调度或自动扩容。Python 的 HTTP 服务说明也明确了标准库服务器的适用边界。这里把监听地址固定为 127.0.0.1,公网部署应使用具备相应能力的服务系统。
4. 先启动,再发出第一条请求
沿用第 31 章的 Qwen2.5-0.5B-Instruct,权重与分词器都固定到同一个 revision。实验使用 CPU、FP32、4 个计算线程、eager 注意力和不加重复惩罚的贪心解码。没有给模型新增微调,也没有使用第 37 章的随机网络代替真实语言模型。
在仓库根目录安装服务依赖:
python3 -m pip install -r src/examples/requirements-service.txt
python3 src/examples/model_service.py --serve --port 8123
首次运行会按固定版本下载模型;已经缓存时,可以追加 --local-only,明确要求只用本地文件。权重来源与许可沿用第 31 章说明,原始权重文件约为 988 MB;加载成 FP32 后还要占用更多内存,请预留数 GB 空间。
看到启动信息中的 "type": "ready" 后,在另一个终端检查服务:
curl http://127.0.0.1:8123/health
curl -N --max-time 90 \
-H 'Content-Type: application/json' \
-d '{"prompt":"为什么学习编程要亲手运行例子?","max_new_tokens":24}' \
http://127.0.0.1:8123/generate
curl -N 关闭客户端自己的输出缓冲,便于观察逐行到达的事件。服务会返回累计文本;最后的 stop_reason 为 eos 时表示模型生成了结束标记,为 limit 时表示碰到了输出上限。后者可能把一个句子停在中间,不能当作完整回答。
展开完整服务实现
"""第 39 章:有界本地 HTTP 流式服务;只有 --serve 才加载模型。"""
import argparse
from collections import deque
from datetime import datetime, timezone
import hashlib
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
from pathlib import Path
import platform
import socket
import threading
import time
import uuid
from local_application_models import MODEL, REVISION
MAX_INPUT, MAX_OUTPUT, MAX_BODY = 768, 64, 32768
SYSTEM = "你是一位耐心的中文学习助手。请直接回答问题。"
class ServiceTimeout(RuntimeError):
pass
def source_hash():
return hashlib.sha256(Path(__file__).read_bytes()).hexdigest()
def validate_request(value):
if not isinstance(value, dict) or set(value) - {"prompt", "max_new_tokens"}:
raise ValueError("只接受 prompt 与可选 max_new_tokens")
prompt, limit = value.get("prompt"), value.get("max_new_tokens", 24)
if not isinstance(prompt, str) or not prompt.strip() or len(prompt) > 6000:
raise ValueError("prompt 必须是 1~6000 字符的非空字符串")
if type(limit) is not int or not 1 <= limit <= MAX_OUTPUT:
raise ValueError(f"max_new_tokens 必须是 1~{MAX_OUTPUT} 的整数")
return prompt, limit
def greedy_tokens(model, input_ids, limit, cached, eos_ids, deadline):
"""单条、无填充的请求;每次调用新建缓存,不跨请求共享。"""
import torch
full = input_ids
cache = None
with torch.inference_mode():
for step in range(limit):
if time.perf_counter() > deadline:
raise ServiceTimeout("生成达到时间上限")
current = full[:, -1:] if cached and step else full
position = torch.arange(full.shape[1]-current.shape[1], full.shape[1])
output = model(input_ids=current, attention_mask=torch.ones_like(full),
cache_position=position, past_key_values=cache,
use_cache=cached, logits_to_keep=1)
token = output.logits[:, -1].argmax(-1, keepdim=True)
cache = output.past_key_values if cached else None
token_id = int(token[0, 0])
full = torch.cat([full, token], dim=1)
del output
yield token_id
if token_id in eos_ids:
break
class Engine:
def __init__(self, cached=True, local_only=False):
import torch
import transformers
from transformers import AutoTokenizer, AutoModelForCausalLM
torch.set_num_threads(4)
torch.manual_seed(2026)
self.cached = cached
self.tokenizer = AutoTokenizer.from_pretrained(
MODEL, revision=REVISION, trust_remote_code=False,
local_files_only=local_only)
self.model = AutoModelForCausalLM.from_pretrained(
MODEL, revision=REVISION, dtype=torch.float32,
attn_implementation="eager", trust_remote_code=False,
local_files_only=local_only).eval()
eos = self.model.generation_config.eos_token_id
self.eos_ids = {eos} if isinstance(eos, int) else set(eos)
self.metadata = dict(model=MODEL, revision=REVISION, cached=cached,
release="qwen-cached-v1" if cached else "qwen-full-v1",
source_sha256=source_hash(), system_prompt=SYSTEM,
max_input=MAX_INPUT, max_output=MAX_OUTPUT,
device="cpu", dtype="float32", attention="eager", threads=4,
decoding="greedy_without_penalties", eos_ids=sorted(self.eos_ids),
logits_to_keep=1,
python=platform.python_version(), torch=torch.__version__,
transformers=transformers.__version__, platform=platform.platform())
def prepare(self, prompt, limit):
ids = self.tokenizer.apply_chat_template(
[{"role": "system", "content": SYSTEM}, {"role": "user", "content": prompt}],
add_generation_prompt=True, return_tensors="pt")
if ids.shape[1] > MAX_INPUT:
raise ValueError(f"加上模板后的输入超过 {MAX_INPUT} 词元;不静默截断")
if ids.shape[1]+limit > self.model.config.max_position_embeddings:
raise ValueError("输入与预留输出超过模型上下文")
return ids
def stream(self, ids, limit, deadline):
generated = []
for token in greedy_tokens(self.model, ids, limit, self.cached, self.eos_ids, deadline):
generated.append(token)
# 累计解码可能修正之前未完整的 Unicode 字符;客户端应替换文本,不逐块追加。
yield token, self.tokenizer.decode(generated, skip_special_tokens=True)
class State:
def __init__(self, engine):
self.engine = engine
self.capacity = threading.BoundedSemaphore(2) # 一个执行、最多一个等待
self.worker = threading.Lock() # 共享模型的生成串行;这不是连续批处理
self.lock = threading.Lock()
self.inflight = 0
self.counts = dict(completed=0, failed=0, disconnected=0, rejected=0)
self.recent = deque(maxlen=50)
def snapshot(self):
with self.lock:
return dict(**self.engine.metadata, inflight=self.inflight,
capacity=2, counters=dict(self.counts), recent=list(self.recent))
def reject(self):
with self.lock:
self.counts["rejected"] += 1
class Server(ThreadingHTTPServer):
daemon_threads = True
allow_reuse_address = True
def __init__(self, address, engine):
self.state = State(engine)
self.connections = threading.BoundedSemaphore(8)
super().__init__(address, Handler)
def process_request(self, request, address):
if not self.connections.acquire(blocking=False):
self.state.reject()
self.shutdown_request(request)
return
try:
super().process_request(request, address)
except BaseException:
self.connections.release()
raise
def process_request_thread(self, request, address):
try:
super().process_request_thread(request, address)
finally:
self.connections.release()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def setup(self):
super().setup()
self.connection.settimeout(5)
def log_message(self, *_):
pass # 不记录原始提示或回答;运行指标见 /metrics
def send_json(self, status, value):
data = (json.dumps(value, ensure_ascii=False)+"\n").encode()
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(data)))
self.send_header("Connection", "close")
self.end_headers()
self.close_connection = True
self.wfile.write(data)
def event(self, value):
self.wfile.write((json.dumps(value, ensure_ascii=False)+"\n").encode())
self.wfile.flush()
def do_GET(self):
if self.path == "/health":
self.send_json(200, dict(ready=True, **self.server.state.snapshot()))
elif self.path == "/metrics":
self.send_json(200, self.server.state.snapshot())
else:
self.send_json(404, {"error": "unknown_path"})
def do_POST(self):
started = time.perf_counter()
if self.path != "/generate":
self.send_json(404, {"error": "unknown_path"})
return
try:
if self.headers.get("Transfer-Encoding") is not None:
raise ValueError("本例不接受分块请求体")
if self.headers.get("Content-Type", "").split(";")[0].strip() != "application/json":
raise ValueError("需要 application/json")
length = int(self.headers.get("Content-Length", "0"))
if not 0 < length <= MAX_BODY:
raise ValueError("请求体为空或超过 32768 字节")
raw = self.rfile.read(length)
if len(raw) != length:
raise ValueError("请求体不完整")
prompt, limit = validate_request(json.loads(raw.decode("utf-8")))
except (ValueError, UnicodeError):
self.send_json(400, {"error": "invalid_request"})
return
except (TimeoutError, OSError):
self.close_connection = True
return
state = self.server.state
if not state.capacity.acquire(blocking=False):
state.reject()
self.send_json(429, {"error": "busy", "retryable": True})
return
with state.lock:
state.inflight += 1
acquired, streaming = False, False
status, queue_seconds, input_tokens, generated = "failed", None, 0, []
request_id = uuid.uuid4().hex
waiting = time.perf_counter()
try:
acquired = state.worker.acquire(timeout=30)
if not acquired:
raise ServiceTimeout("排队超过 30 秒")
queue_seconds = time.perf_counter()-waiting
ids = state.engine.prepare(prompt, limit)
input_tokens = ids.shape[1]
self.send_response(200)
self.send_header("Content-Type", "application/x-ndjson; charset=utf-8")
self.send_header("Connection", "close")
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.close_connection = True
streaming = True
self.event(dict(type="start", request_id=request_id,
release=state.engine.metadata["release"],
input_tokens=input_tokens, queue_seconds=queue_seconds))
deadline = time.perf_counter()+60
for token, text in state.engine.stream(ids, limit, deadline):
generated.append(token)
self.event(dict(type="token", index=len(generated), token_id=token, text=text))
self.event(dict(type="done", request_id=request_id, output_tokens=len(generated),
stop_reason="eos" if generated[-1] in state.engine.eos_ids else "limit",
server_seconds=time.perf_counter()-started))
status = "completed"
except (BrokenPipeError, ConnectionResetError, socket.timeout):
status = "disconnected"
except Exception as error:
code = ("invalid_input" if isinstance(error, ValueError) else
"deadline_exceeded" if isinstance(error, ServiceTimeout) else "generation_failed")
try:
if streaming:
self.event(dict(type="error", request_id=request_id, error=code))
else:
self.send_json(400 if isinstance(error, ValueError) else 503, {"error": code})
except OSError:
status = "disconnected"
finally:
if acquired:
state.worker.release()
record = dict(request_id=request_id, status=status, input_tokens=input_tokens,
output_tokens=len(generated), queue_seconds=queue_seconds,
server_seconds=time.perf_counter()-started)
with state.lock:
state.inflight -= 1
state.counts[status] += 1
state.recent.append(record)
state.capacity.release()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--serve", action="store_true")
parser.add_argument("--port", type=int, default=8123)
parser.add_argument("--no-cache", action="store_true")
parser.add_argument("--local-only", action="store_true")
args = parser.parse_args()
if not args.serve:
print("默认不下载模型、不启动端口。")
print("启动:python3 src/examples/model_service.py --serve --port 8123")
print("仅监听 127.0.0.1;/health、/metrics 与 POST /generate。")
return
engine = Engine(cached=not args.no_cache, local_only=args.local_only)
server = Server(("127.0.0.1", args.port), engine)
print(json.dumps(dict(type="ready", port=server.server_port,
started_at=datetime.now(timezone.utc).isoformat(),
**engine.metadata), ensure_ascii=False), flush=True)
try:
server.serve_forever(poll_interval=.1)
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == "__main__":
main()
为避免构建或阅读示例时意外启动服务,脚本默认只打印用法:
默认不下载模型、不启动端口。
启动:python3 src/examples/model_service.py --serve --port 8123
仅监听 127.0.0.1;/health、/metrics 与 POST /generate。
服务脚本复用 local_application_models.py 中的模型版本常量,该文件还依赖第 31、32 章的提示与检索模块。最方便的运行方式是保留本书整个仓库的目录结构,而不是只下载一个文件。
5. 怎样量出用户实际等了多久
用客户端的时钟记录到达时刻
让 表示客户端开始请求, 表示读到第一个词元事件, 表示读到最后一个词元事件, 表示完整响应结束。那么:
TTFT 是首词元延迟。它包含本次请求的连接、发送、排队、分词、提示计算和首词元传输等时间。它从词元事件计算,不等于首个完整汉字出现的时间。
若一共收到 个词元,还可以计算首词元之后的生成速度:
这里分子和时间段一一对应:第一个词元已经到达了,因此只数后续 个。收到一个词元就结束时,这个指标没有定义,不应硬凑一个除法。本例将实际收到的结束词元也计入数量。
同时发出一组请求时,合计吞吐量则是:
它不能直接用“每个请求的速度相加”替代,因为不同请求可能排队,完成时刻也不同。我们会同时报告单次等待和整组产出,避免一个指标掩盖另一个。
内存也要写清楚口径
基准脚本在独立子进程中启动服务,从客户端进程通过 psutil 读取服务进程的 RSS,约每 10 毫秒采一次,并保存每次的实际时间戳。RSS 表示当时驻留在物理内存中的进程页面,包含权重、运行库和临时分配,也可能包含共享页面;它不等于模型参数大小。字段含义见 psutil 的内存接口说明。
我们报告的是采样看到的最大 RSS。两次采样之间可能出现更高的峰值,线程调度也可能让采样间隔变长;所以不能把它称为精确的峰值内存。这里只测 CPU 服务进程,不包括客户端,也没有测 GPU 显存。
6. 第八篇实践:开关 KV 缓存,重新走一遍请求
本实验使用两段自编中文提示:短提示询问编程练习的方法,长提示在问题前附加六段学习日志。加上对话模板后,输入分别为 40 和 238 个词元。输出上限均为 24。
分别运行缓存关闭、缓存开启两个版本,每个版本启动一个全新的服务进程。每种输入先预热一次,然后在并发 1、并发 2 下各测三组;同一轮中交替并发条件的先后顺序。共记录 24 组、36 个正式请求,另外有 4 个预热请求。
模型下载、加载与预热不计入请求测量。两条计算路径都只计算最后一个位置的词表分数,避免把历史位置上已经用不到的输出层计算混入对照;关闭 KV 时,前面的 Transformer 层仍需处理完整前缀。两种版本仍依次运行,不能消除整机负载或温度随时间变化的影响。每个条件只测三组,适合观察机制,不足以估计生产系统的高分位延迟。
运行:
python3 src/examples/service_benchmark.py \
--run --local-only --out runs/service-benchmark.json
脚本自动选择空闲端口,启动真实服务子进程,并在结束或失败时清理它。没有传入 --run 时,只复核随书记录,不下载模型、不重新测量。若本机尚未缓存模型,可先按上一节启动一次服务,或在测量命令中去掉 --local-only。
展开 HTTP 测量与记录复核代码
"""第 39 章:真实 HTTP 客户端、服务子进程与 RSS 采样;默认复核随书记录。"""
import argparse
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from datetime import datetime, timezone
import hashlib
import json
from pathlib import Path
import queue
import statistics
import subprocess
import sys
import tempfile
import threading
import time
from urllib.request import Request, urlopen
from model_service import MODEL, REVISION, SYSTEM, source_hash
DATA = Path(__file__).with_name("data")/"service-benchmark.json"
NEW_TOKENS, REPEATS, SAMPLE_SECONDS = 24, 3, .01
PROMPTS = {
"short": "请分三点说明,为什么学习编程时应该亲手运行例子?",
"long": ("学习日志:今天我们练习了变量、循环和函数。先猜程序输出,再运行核对,"
"最后修改一个条件,记录结果怎样变化。\n")*6+
"请根据这份日志,分三点建议明天怎样继续练习。"
}
def fingerprint(value):
return hashlib.sha256(json.dumps(value, ensure_ascii=False, sort_keys=True).encode()).hexdigest()
def request_once(base, prompt, limit=NEW_TOKENS, barrier=None):
payload = json.dumps(dict(prompt=prompt, max_new_tokens=limit), ensure_ascii=False).encode()
request = Request(base+"/generate", data=payload, headers={"Content-Type": "application/json"})
if barrier:
barrier.wait(timeout=15)
started = time.perf_counter()
events, stamps, token_ids = [], [], []
with urlopen(request, timeout=120) as response:
if response.status != 200:
raise RuntimeError(f"HTTP {response.status}")
for line in response:
stamp = time.perf_counter()-started
event = json.loads(line)
events.append(event)
if event["type"] == "token":
stamps.append(stamp)
token_ids.append(event["token_id"])
elapsed = time.perf_counter()-started
if not events or events[0]["type"] != "start" or events[-1]["type"] != "done":
raise RuntimeError("流式响应没有完整结束:" + repr(events[-1:]))
start, done = events[0], events[-1]
if not stamps or done["output_tokens"] != len(stamps):
raise RuntimeError("词元数量与完成事件不一致")
return dict(request_id=start["request_id"], release=start["release"],
input_tokens=start["input_tokens"], output_tokens=len(stamps),
queue_seconds=start["queue_seconds"], server_seconds=done["server_seconds"],
total_seconds=elapsed, token_seconds=stamps, ids=token_ids,
text=events[-2]["text"], stop_reason=done["stop_reason"])
@contextmanager
def service_process(cached, local_only=False):
script = Path(__file__).with_name("model_service.py")
command = [sys.executable, str(script), "--serve", "--port", "0"]
if not cached:
command.append("--no-cache")
if local_only:
command.append("--local-only")
with tempfile.TemporaryFile(mode="w+") as errors:
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=errors, text=True)
ready_queue = queue.Queue()
reader = threading.Thread(target=lambda: ready_queue.put(process.stdout.readline()), daemon=True)
reader.start()
try:
line = ready_queue.get(timeout=180)
if not line:
errors.seek(0)
raise RuntimeError("服务启动失败:" + errors.read()[-3000:])
ready = json.loads(line)
if ready["type"] != "ready" or ready["cached"] != cached:
raise RuntimeError("启动信息不符合预期")
yield process, "http://127.0.0.1:"+str(ready["port"]), ready
finally:
process.terminate()
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=10)
process.stdout.close()
reader.join(timeout=1)
def measure_group(process, base, prompt, concurrency):
import psutil
monitor = psutil.Process(process.pid)
samples = []
stopped = threading.Event()
baseline = monitor.memory_info().rss
started = time.perf_counter()
def sample():
while not stopped.is_set():
samples.append([time.perf_counter()-started, monitor.memory_info().rss])
stopped.wait(SAMPLE_SECONDS)
sampler = threading.Thread(target=sample)
sampler.start()
barrier = threading.Barrier(concurrency+1)
try:
with ThreadPoolExecutor(max_workers=concurrency) as pool:
futures = [pool.submit(request_once, base, prompt, NEW_TOKENS, barrier)
for _ in range(concurrency)]
batch_start = time.perf_counter()
barrier.wait(timeout=15)
rows = [future.result() for future in futures]
elapsed = time.perf_counter()-batch_start
finally:
stopped.set()
sampler.join()
samples.append([time.perf_counter()-started, monitor.memory_info().rss])
return dict(concurrency=concurrency, group_seconds=elapsed, requests=rows,
baseline_rss=baseline, peak_sampled_rss=max(rss for _, rss in samples),
rss_samples=samples)
def benchmark(path, local_only=False):
import psutil
report = dict(complete=False, measured_at=datetime.now(timezone.utc).isoformat(),
model=MODEL, revision=REVISION, system_prompt=SYSTEM, prompts=PROMPTS,
prompts_sha256=fingerprint(PROMPTS), new_tokens=NEW_TOKENS,
repeats=REPEATS, rss_sample_seconds=SAMPLE_SECONDS, psutil=psutil.__version__,
source_sha256=source_hash(), benchmark_sha256=hashlib.sha256(
Path(__file__).read_bytes()).hexdigest(), servers=[], rows=[])
path.parent.mkdir(parents=True, exist_ok=True)
# 每种计算路径使用全新服务进程;预热与加载不计入请求测量。
for cached in [False, True]:
with service_process(cached, local_only) as (process, base, metadata):
warmups = {key: request_once(base, prompt) for key, prompt in PROMPTS.items()}
metadata["warmups"] = warmups
report["servers"].append(metadata)
for trial in range(REPEATS):
order = [1, 2] if trial % 2 == 0 else [2, 1]
for case, prompt in PROMPTS.items():
for concurrency in order:
group = measure_group(process, base, prompt, concurrency)
group.update(cached=cached, case=case, trial=trial)
for row in group["requests"]:
assert row["ids"] == warmups[case]["ids"], "同条件的贪心输出发生变化"
report["rows"].append(group)
path.write_text(json.dumps(report, ensure_ascii=False, indent=2)+"\n")
print(f"cached={cached} {case} c={concurrency} trial={trial+1} "
f"group={group['group_seconds']:.2f}s", flush=True)
metadata["final_metrics"] = json.loads(urlopen(base+"/metrics", timeout=10).read())
for case in PROMPTS:
assert report["servers"][0]["warmups"][case]["ids"] == \
report["servers"][1]["warmups"][case]["ids"], "两种计算路径输出不同"
report["complete"] = True
path.write_text(json.dumps(report, ensure_ascii=False, indent=2)+"\n")
return report
def checked_report(path=DATA):
report = json.loads(path.read_text())
assert report["complete"] and report["model"] == MODEL and report["revision"] == REVISION
assert report["source_sha256"] == source_hash()
assert report["system_prompt"] == SYSTEM and report["prompts"] == PROMPTS
assert report["prompts_sha256"] == fingerprint(PROMPTS)
assert report["new_tokens"] == NEW_TOKENS and report["repeats"] == REPEATS
assert report["rss_sample_seconds"] == SAMPLE_SECONDS
expected = {(cached, case, c, t) for cached in [False, True] for case in PROMPTS
for c in [1, 2] for t in range(REPEATS)}
actual = {(g["cached"], g["case"], g["concurrency"], g["trial"]) for g in report["rows"]}
assert actual == expected and len(report["rows"]) == len(expected)
assert len(report["servers"]) == 2
for server, cached in zip(report["servers"], [False, True]):
assert server["cached"] == cached and server["revision"] == REVISION
assert server["source_sha256"] == report["source_sha256"]
assert server["logits_to_keep"] == 1
metrics = server["final_metrics"]
assert metrics["inflight"] == 0
assert metrics["counters"] == dict(completed=20, failed=0, disconnected=0, rejected=0)
for group in report["rows"]:
assert len(group["requests"]) == group["concurrency"]
samples = group["rss_samples"]
assert samples and all(t >= 0 and rss > 0 for t, rss in samples)
assert [t for t, _ in samples] == sorted(t for t, _ in samples)
assert group["peak_sampled_rss"] == max(rss for _, rss in samples)
for row in group["requests"]:
stamps = row["token_seconds"]
assert 0 < stamps[0] <= stamps[-1] <= row["total_seconds"] <= group["group_seconds"]+.1
assert stamps == sorted(stamps)
assert len(stamps) == len(row["ids"]) == row["output_tokens"] <= NEW_TOKENS
assert 0 <= row["queue_seconds"] <= row["server_seconds"] <= row["total_seconds"]
reference = report["servers"][0]["warmups"][group["case"]]
assert row["ids"] == reference["ids"]
return report
def summarize(report):
result = []
for case in PROMPTS:
for cached in [False, True]:
for c in [1, 2]:
groups = [g for g in report["rows"] if
(g["case"], g["cached"], g["concurrency"]) == (case, cached, c)]
requests = [r for g in groups for r in g["requests"]]
first = [r["token_seconds"][0] for r in requests]
last = [r["total_seconds"] for r in requests]
speed = [(r["output_tokens"]-1)/(r["token_seconds"][-1]-r["token_seconds"][0])
for r in requests if r["output_tokens"] > 1]
result.append(dict(case=case, cached=cached, concurrency=c,
input_tokens=requests[0]["input_tokens"],
ttft=statistics.median(first), total=statistics.median(last),
queue=statistics.median(r["queue_seconds"] for r in requests),
decode_tps=statistics.median(speed),
throughput=statistics.median(
sum(r["output_tokens"] for r in g["requests"])/g["group_seconds"]
for g in groups),
max_sampled_rss=max(g["peak_sampled_rss"] for g in groups)))
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--run", action="store_true")
parser.add_argument("--out", type=Path)
parser.add_argument("--local-only", action="store_true")
args = parser.parse_args()
if args.run and args.out is None:
parser.error("请用 --out 保存自己的测量")
report = benchmark(args.out, args.local_only) if args.run else checked_report()
print("本次 HTTP 测量:" if args.run else "随书保存的 HTTP 测量(默认不加载模型):")
for row in summarize(report):
print(f"{row['case']};KV={row['cached']};并发={row['concurrency']};"
f"首词元={row['ttft']:.3f}s;总时长={row['total']:.3f}s;"
f"后续速度={row['decode_tps']:.1f} token/s;"
f"合计吞吐={row['throughput']:.1f} token/s;"
f"RSS采样最大值={row['max_sampled_rss']/2**20:.1f} MiB")
print("两种路径、两种输入、并发 1/2,各测 3 组;同输入的输出 ID 一致。")
print("RSS 只采样服务进程,不含客户端;采样最大值可能低于真实峰值。")
if __name__ == "__main__":
main()
先看等待,再看产出
本书测量使用 Apple M3 Pro、18 GiB 内存、macOS 26.6.2、Python 3.12.12、PyTorch 2.14.1 与 Transformers 4.57.3。下表中的时间和速度均为对应条件的中位数。为便于对照,表头作了简写:C 是同时发出的请求数;“首/总”是首词元延迟与总时长,单位为秒,保留两位小数;“生成/吞吐”是后续生成速度与合计吞吐,单位为 token/s。
短输入,40 个词元:
| KV | C | 首/总 | 生成/吞吐 |
|---|---|---|---|
| 关 | 1 | 0.10/3.87 | 6.4/6.2 |
| 关 | 2 | 1.37/4.25 | 10.2/9.5 |
| 开 | 1 | 0.09/0.78 | 33.3/30.6 |
| 开 | 2 | 0.47/1.17 | 33.6/30.4 |
长输入,238 个词元:
| KV | C | 首/总 | 生成/吞吐 |
|---|---|---|---|
| 关 | 1 | 0.22/5.54 | 4.4/4.3 |
| 关 | 2 | 2.90/8.07 | 4.5/4.4 |
| 开 | 1 | 0.23/1.03 | 29.8/23.2 |
| 开 | 2 | 0.65/1.36 | 33.0/26.5 |
长输入、并发 1 时,启用 KV 后,总时长中位数从 5.541 秒降到 1.033 秒,首词元却没有明显变快。原因与上一章相符:两条路径都要先处理完整提示,缓存的主要收益出现在后续生成。
并发 2 时,后一个请求要等模型空出来,首词元延迟随之增加。“并发 2”那一行的请求指标来自六个请求:其中一半先执行,另一半等待。中位数会把这两类体验混在一起,因此还要看逐请求记录:
图 2:取长输入、并发 2 的第一组;每种路径内按等待时长编号。每条从该请求自己的发出时刻计时。橙色是服务端记录的等待,蓝色与绿色还包含客户端可见的通信开销。
本例没有批量执行模型,因此不能把表中吞吐量的每一点上涨都解释成“并行计算带来的收益”。短输入、不使用 KV 的单请求总时长在 2.414~4.121 秒之间波动,三个值的中位数偏高,导致与并发 2 的比较显得更乐观。长输入、单请求的三次总时长则为 5.367~5.594 秒;启用 KV 后为 0.893~1.076 秒。原始时间全部保留,没有只挑最快的一次。
模型没换,运行内存也会变化
两种路径在正式请求期间观测到的最大 RSS 分别约为 2229.2 MiB 和 2060.7 MiB。它们使用相同的 FP32 权重;差异还涉及完整前缀产生的临时张量、分配器保留空间与页面驻留变化,不能把两者相减就当成 KV 的大小。
图 3:两组来自不同的服务进程,横轴分别从各自开始采样计时;纵轴没有从零开始,以便看清变化。这是运行过程中的 RSS,不是权重大小或精确峰值。
KV 本身要占空间,只是本次输入不长,减少重复计算产生的临时分配影响更明显。若继续增加上下文长度或活动请求数,缓存增长可能成为主要约束。不能从这两条曲线得出“开缓存永远更省内存”。
保留原始输出,确认比较的是同一件事
两种路径、两种并发条件下,同一输入的输出 ID 完全一致。正式请求全部正常结束,两个服务进程各完成 20 次请求,包括各自的两次预热,没有记录到失败或拒绝。
不过,所有请求都因 24 词元上限结束。短提示的实际累计文本是:
1. 通过运行例子可以验证代码的正确性;
2. 通过运行例子可以更好地理解代码的
末句确实没写完。这是一个控制计算量的性能实验,不是三点建议生成质量的验收。真实应用还需要为完整回答留够预算,并单独评价内容。
默认运行测量脚本,会复核随书原始记录并打印:
随书保存的 HTTP 测量(默认不加载模型):
short;KV=False;并发=1;首词元=0.099s;总时长=3.865s;后续速度=6.4 token/s;合计吞吐=6.2 token/s;RSS采样最大值=2106.1 MiB
short;KV=False;并发=2;首词元=1.371s;总时长=4.247s;后续速度=10.2 token/s;合计吞吐=9.5 token/s;RSS采样最大值=2107.8 MiB
short;KV=True;并发=1;首词元=0.092s;总时长=0.784s;后续速度=33.3 token/s;合计吞吐=30.6 token/s;RSS采样最大值=2060.6 MiB
short;KV=True;并发=2;首词元=0.471s;总时长=1.169s;后续速度=33.6 token/s;合计吞吐=30.4 token/s;RSS采样最大值=2060.6 MiB
long;KV=False;并发=1;首词元=0.217s;总时长=5.541s;后续速度=4.4 token/s;合计吞吐=4.3 token/s;RSS采样最大值=2229.0 MiB
long;KV=False;并发=2;首词元=2.901s;总时长=8.066s;后续速度=4.5 token/s;合计吞吐=4.4 token/s;RSS采样最大值=2229.2 MiB
long;KV=True;并发=1;首词元=0.226s;总时长=1.033s;后续速度=29.8 token/s;合计吞吐=23.2 token/s;RSS采样最大值=2060.7 MiB
long;KV=True;并发=2;首词元=0.648s;总时长=1.358s;后续速度=33.0 token/s;合计吞吐=26.5 token/s;RSS采样最大值=2060.6 MiB
两种路径、两种输入、并发 1/2,各测 3 组;同输入的输出 ID 一致。
RSS 只采样服务进程,不含客户端;采样最大值可能低于真实峰值。
记录包含模型版本、服务代码指纹、提示、逐词元到达时间、每次生成 ID、排队时长、RSS 原始采样与结束后的运行计数。复核这些记录不等于重新测量当前电脑;要得到本机结果,仍应显式运行 --run。
7. 服务能连上,还不等于工作正常
GET /health 返回已加载的模型、revision、服务配置与代码指纹。模型忙于生成时,它仍能回答,从而区分“模型正在工作”和“整个进程已经失去响应”。
GET /metrics 则返回正在处理的生成请求数、完成/失败/断开/拒绝计数,以及最近 50 条已接纳请求的耗时摘要。原始提示和回答不进入这些运行记录;请求 ID 用来关联同一次请求的开始、结束和问题定位。预先被字段检查拒绝的无效请求不计入这组生成执行计数。
监控时可以顺着现象找原因:
| 现象 | 下一步看哪里 |
|---|---|
| 首词元变慢,生成速度变化不大 | 排队时间、输入长度、提示计算 |
| 后续词元越来越慢 | 当前负载、缓存配置、计算与内存访问 |
| 忙碌拒绝增多 | 在途请求、等待上限、入口流量 |
| 运行内存持续增长 | 请求结束后是否还保留状态、输入长度分布 |
| 接口正常,旧任务开始答错 | 模型与模板版本、解码配置、能力回归 |
不要让平均值独自承担所有解释。一批短问题混入几个长问题,平均时间会改变;请求都被快速拒绝时,完成请求的平均延迟甚至可能显得“更好了”。成功数、拒绝数、输入输出长度和等待分布应一起看。
本例的指标是便于学习的小型记录器,不是持久化监控系统。进程重启后计数清零;如果要比较长期趋势,需要将带版本、时间和口径的指标送到外部存储。
8. 发布新版本,要把退路一起留下
只保存权重文件,还不足以重现一个服务。分词器、对话模板、生成配置、运行库和服务代码也会影响行为。本例的健康信息已经列出这些关键设置,并用代码 SHA-256 标识实现;完整测量记录还保存提示和运行环境。
可以用两个端口练习一次最简单的版本切换。在两个终端中分别启动:
# 旧版本:完整前缀重算
python3 src/examples/model_service.py --serve --no-cache --port 8123
# 候选版本:启用每个请求自己的 KV 缓存
python3 src/examples/model_service.py --serve --port 8124
先分别请求两个 /health,确认版本与配置。再用固定请求检查事件格式、正常结束、输出 ID 或任务结果,并比较性能。检查通过后,才把调用方的地址从 8123 改到 8124。若出现问题,改回旧地址;保留旧进程到新版本观察期结束,再停止它。
这里切换的是同一权重的计算配置,所以本次实验要求输出 ID 相同。若更新了权重,逐字相同通常不再是验收目标,应回到第 35 章的任务评价与第 38 章的能力回归,检查回答是否仍符合约定。
生产环境常把这个过程交给流量代理:先给新版本少量流量,观察错误、延迟和任务表现,再扩大比例;发现退化时恢复旧版本。本例用客户端 URL 演示切换,没有实现自动灰度、热更新或跨实例路由。回滚也只恢复后续请求的处理方式,不能撤销已经发出的回答或已经执行的外部操作。
9. 自己部署,还是调用托管服务
前面看到的每一个环节,都是自建服务需要承担的工作。托管服务可以替我们处理其中一部分,但选择依据不能只剩下“每次调用多少钱”。
| 需要比较的事 | 可以怎样核对 |
|---|---|
| 能力 | 用同一套代表任务评价正确性与失败方式 |
| 延迟和吞吐 | 用实际输入长度、输出长度与并发测试 |
| 数据边界 | 核对数据流向、保留方式和访问控制 |
| 版本控制 | 能否固定模型版本,更新前能否回归 |
| 运维成本 | 谁负责容量、故障、监控与升级 |
| 总成本 | 设备与运行时间、利用率、调用费用及维护投入 |
如果请求很少而且变化很大,专门常驻一套设备可能大部分时间都闲着;如果长期有稳定负载,设备利用率又会改变比较结果。涉及计价或配额时,应查询候选服务当前的正式说明,再用自己的负载估算。本章没有替某个产品报价,也没有把这台电脑上的实验结果推广成通用部署建议。
10. 动手把一次服务运行解释清楚
- 两个请求同时到达,第二个等待 2 秒才开始计算。它的客户端 TTFT 是否包含这 2 秒?模型内部的 prefill 计时呢?
- 客户端一共收到 24 个词元,第一个在 0.4 秒到达,最后一个在 2.7 秒到达。按本章口径,后续生成速度是多少?
- 收到了 HTTP 200 和几个
token事件,随后连接断开,没有done。为什么不能把它当成一次成功完成? - 本例能同时接受两个生成请求,为什么合计吞吐量不一定翻倍?连续批处理可能改变哪一步?
- 4 位权重文件比 FP32 小,为什么运行进程的 RSS 不能直接按相同比例缩小?
- 在服务终端按 Ctrl+C 后,重新启动并查看
/metrics。计数为什么变了?长期监控需要补上什么? - 保留旧版本在 8123,候选版本在 8124。列出切换前必须核对的模型、模板、接口和任务条件,再用同一请求检查两个服务。
- 把输出上限从 24 改为 48,只改变这一项,重新记录完整实验。首词元、总时长、吞吐与内存分别怎样变化?不要把新结果覆盖到随书记录。
- 进阶:在支持连续批处理的运行框架上复做相同输入与输出预算的实验。写清硬件、权重精度、采样规则、并发与队列限制;若框架改变了多个条件,就分别报告,不把差异全归因于“服务框架更快”。
展开答案与实验提示
- 客户端 TTFT 包含排队;只从模型开始处理提示时计时的 prefill 不包含。比较两份报告前,要先对齐起点。
- 首词元之后有 23 个词元,耗时 秒,所以是 10 token/s。
- HTTP 头已经发送后,模型仍可能失败。完整结束事件用于区分正常完成与中途断流;本例测量脚本会拒绝没有
done的记录。 - 共享模型的执行被锁串行化,第二个请求主要在等待。连续批处理可以把多个活动请求组织到同一次模型计算中,但仍受计算、内存与延迟目标限制。
- RSS 还包含激活、KV、运行库、临时张量和分配器保留的内存。某些低位实现还会临时恢复浮点权重,必须测实际运行。
- 本例的计数只在进程内存里,重启就重新开始。长期监控需要外部保存指标,并记录版本、实例和重启时间。
- 至少核对权重 revision、分词器与模板、输出事件、长度限制、解码规则、重要任务结果及性能。保留可恢复的旧运行环境;端口能连上只是其中一步。
- 使用新的输出路径,并更新自己的实验配置与记录。若模型提前生成 EOS,实际输出数可能不到 48;仍按实际收到的词元计算。不要预设首词元一定不变,整机噪声和实现路径都可能影响结果。
- 把接口开销与模型计算分开看,保留错误与拒绝请求。如果改变精度或输出内容,再补能力对照,不能只比较速度。
11. 从“模型能跑”走到“请求能完成”
第八篇把模型放回了实际计算环境:训练要分配工作,推理要复用状态,压缩要保住能力,服务还要接住并结束每一次请求。一个模型的运行成本,只有把这些环节放在一起,才更容易解释。
接下来,输入不再只有文字。图片怎样进入同一个表示空间?文本与图像怎样找到彼此?跨模态表示与对齐会从这些问题开始。