跳转至

第四章 部署、压测与应用开发:把模型变成可靠服务

模型“能生成”与服务“可上线”之间隔着一整套工程系统:模型加载、批处理、流式传输、并发控制、超时、指标、鉴权、配额、结构化输出和工具权限。本章以 FastAPI 与 vLLM 为例说明通用方法,模型可替换为任何提供兼容接口的本地或云端实现。

大模型服务架构

图 4-1 一次请求经过网关、应用编排和推理引擎;监控与安全横跨所有层。

4.1 先建立性能指标语言

先建立直觉。 服务指标必须从用户体验和系统资源同时定义。吞吐高并不保证单请求快,平均延迟也会掩盖排队造成的长尾。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. TTFT 衡量首次反馈。
  2. TPOT 衡量持续生成节奏。
  3. P50/P95/P99 描述分布。
  4. tokens/s 与并发描述容量。

最小例子。 两个系统平均都为 2 秒,但一个 P99 为 3 秒、另一个 P99 为 20 秒,生产体验和容量风险完全不同。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 混用端到端延迟与纯模型延迟、忽略输入输出长度、用单并发结果推算高并发容量,都会误导选型。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:为聊天、批量摘要、代码补全分别写 SLO,并说明为何指标权重不同。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

吞吐量与延迟不能混为一谈。常见指标包括:

  • QPS/TPS:每秒完成请求数、每秒生成 token 数。
  • TTFT:从请求到首 token 的时间,主要受排队和 prefill 影响。
  • TPOT:首 token 后每个输出 token 的平均时间,主要反映 decode。
  • P50/P95/P99:延迟分位数;平均值会掩盖长尾。
  • 并发数:同时在途请求,而不是一秒请求总数。
  • 有效吞吐:在错误率、超时率和质量约束下的吞吐。

压测必须说明输入长度、输出长度、并发模型、采样参数、预热、硬件和模型精度。只报告“每秒多少请求”几乎没有可比性。

4.2 FastAPI:异步不等于计算更快

从问题出发。 FastAPI 的 async 适合等待网络、磁盘和队列,不会让同步 GPU kernel 神奇变快。应用层应把请求校验、调度和推理解耦。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. Pydantic 校验输入。
  2. 异步接收与取消。
  3. 通过队列提交推理。
  4. 流式返回并记录指标。

用小数据走一遍。 请求等待模型服务器响应时可释放事件循环;若在 async 路由里执行长时间纯 Python 计算,整个 worker 仍会阻塞。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 全局可变状态无锁、请求断开后不释放资源、超时只包在客户端、每次请求加载模型,都是常见服务事故来源。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:实现一个带请求 id、超时、并发信号量和取消处理的模拟生成接口。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

async 适合等待网络、数据库等 I/O;GPU 推理仍由推理引擎执行。若在事件循环里直接做长时间 CPU 计算,会阻塞所有请求。下面用异步生成器模拟流式 token:

import asyncio
from collections.abc import AsyncIterator
from fastapi import FastAPI, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field

app = FastAPI()
gate = asyncio.Semaphore(16)

class ChatRequest(BaseModel):
    prompt: str = Field(min_length=1, max_length=8000)
    max_tokens: int = Field(default=256, ge=1, le=2048)

async def fake_generate(req: ChatRequest) -> AsyncIterator[str]:
    for token in ["这是", "一个", "流式", "示例"]:
        await asyncio.sleep(0.05)
        yield f"data: {token}\n\n"

@app.post("/chat/stream")
async def stream_chat(req: ChatRequest):
    try:
        await asyncio.wait_for(gate.acquire(), timeout=2.0)
    except TimeoutError as exc:
        raise HTTPException(503, "服务繁忙,请稍后重试") from exc

    async def guarded():
        try:
            async for chunk in fake_generate(req):
                yield chunk
        finally:
            gate.release()

    return StreamingResponse(guarded(), media_type="text/event-stream")

代码解读:Pydantic 在入口处验证长度;信号量限制应用层在途请求,避免无限排队;finally 保证客户端断开时释放名额。生产系统还要处理取消传播、上游超时、SSE 心跳、错误帧和请求追踪。

4.3 vLLM 与连续批处理

先看它解决什么。 vLLM 的核心价值来自调度与 KV Cache 管理:把不同到达时间、不同长度的请求动态组成批次,并用分页思想减少缓存碎片。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 请求进入等待队列。
  2. 调度器分配 token 预算。
  3. prefill/decode 共享执行批次。
  4. PagedAttention 管理缓存块。

一个可以手算的例子。 静态批处理必须等一批请求结束;连续批处理可在旧请求完成后立即插入新请求,提高 GPU 利用率。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 吞吐提升依赖长度分布与并发;max_model_len、显存利用率和并行配置不合理仍会 OOM 或增加排队。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:构造短输入长输出与长输入短输出两类负载,分别压测并解释 TTFT/TPOT 差异。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

传统静态批处理要等整批请求都结束,长短请求相互拖累。连续批处理在每个调度步动态加入新请求、移除已完成请求,提高 GPU 利用率。PagedAttention 把 KV Cache 划分为可管理的块,减少连续大内存分配和碎片。它们改变的是服务调度与内存管理,不改变语言模型的语义目标。

典型启动方式会暴露 OpenAI 兼容接口;具体参数随版本变化,应以 vLLM 官方文档 为准。配置时重点理解:张量并行度、最大上下文、GPU 内存利用率、最大并发序列、量化格式和模型是否支持自定义代码。不要盲目把并发上限调大;KV Cache、排队时间和 OOM 会共同限制系统。

4.4 同步、异步和流式调用

生成请求生命周期

图 04-2 请求从校验、排队到流式返回,每一步都需要超时、取消和审计。 抓住这一节的主线。 同步、异步和流式是调用语义,不是模型能力。同步简单,异步适合并发等待,流式改善感知延迟但增加状态、取消和错误处理复杂度。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 定义请求生命周期。
  2. 明确重试是否幂等。
  3. 为流式片段设计事件格式。
  4. 在结束事件中给出用量与状态。

先做最小实验。 SSE 可发送 token、tool_call、error、done 等事件;客户端必须能处理连接中断和半条 JSON。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 把网络重试直接复制到有副作用的工具调用、流式途中改变响应 schema、没有心跳和断开检测,都会造成重复操作或资源泄漏。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:写一个流式客户端状态机,覆盖正常结束、用户取消、超时和服务端错误。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

同步调用简单,适合离线脚本;异步调用适合高并发 I/O 编排;流式调用改善感知延迟,但不会减少总计算量。客户端应设置连接、读取和总超时,并对幂等请求做有限次数、带抖动的指数退避。非幂等工具调用不能无脑重试,否则可能重复扣款或重复写数据。

4.5 提示词工程与上下文工程

先把概念落到可观察对象上。 提示词工程组织指令,上下文工程决定模型实际看见哪些规则、历史、证据和工具。可靠系统不依赖一句‘请严格遵守’,而依赖清晰优先级与外部验证。 这一节的重点是建立因果链。先说明问题,再看计算或流程,最后用可重复实验验证结论。

把抽象概念还原成操作:

  1. 分离系统规则和用户数据。
  2. 只放与当前任务相关证据。
  3. 明确输出契约与失败方式。
  4. 用评估集比较版本。

把它缩小到能逐项检查。 摘要任务可规定受众、长度、必须保留的数字和未知时的处理;但引用是否真实仍要由程序检查证据映射。 如果这个小例子还不能解释清楚,扩大数据只会让错误更难发现。

需要特别守住的边界。 把不可信网页拼进系统指令、上下文无限累积、没有版本号、用少数顺手样例判断提示效果,都会放大风险。 保留完整 trace 比猜原因更重要。找到第一次偏离预期的位置,通常比分析最终错误输出更高效。

本节练习:为同一客服任务写三个提示版本,用十条固定样例比较格式正确率和事实错误率。 除代码外,请保留一份结果说明,标明环境、随机种子、输入规模和你主动检查过的边界。

一个可维护的提示通常分为:角色与目标、输入数据、约束、输出格式、示例和失败策略。系统提示不是安全边界,客户端传来的文本、网页和检索文档都属于不可信数据。

不要要求模型展示私密思维过程。需要可审计性时,让模型输出简短依据、引用或可验证步骤。Self-Consistency 是对多个独立候选进行聚合,成本随采样次数增长;它适合存在可比较答案的任务,不是所有生成任务的默认配置。

上下文窗口有限,应优先保留指令、当前任务、关键事实和最近对话。历史消息可以摘要,但摘要本身也可能丢信息;重要状态应放入结构化存储,而不是完全依赖聊天记录。

4.6 结构化输出:验证比提示更重要

先建立直觉。 结构化输出的目标是把自然语言结果变成可验证数据。Schema 负责约束类型和必填字段,业务验证还要检查取值范围、跨字段关系和外部事实。 读这一节时,先不要急着记缩写,而要不断追问:输入是什么,经过了哪些可观察变换,输出又怎样被验证。

把过程拆开看:

  1. 定义最小 JSON Schema。
  2. 让模型按 schema 生成。
  3. 解析并进行二次业务校验。
  4. 失败时有限重试或降级。

最小例子。 日期字段通过字符串格式校验仍可能是不存在的日期;订单金额非负也不代表币种和税额关系正确。 例子越小,越容易手算、打印中间量并判断实现是否偏离定义。

容易踩坑。 仅在提示中贴 JSON 示例、用正则解析任意 JSON、无限自动重试、把校验错误原样暴露给用户,都是脆弱设计。 排错时应保存失败输入和版本,先定位最早出现异常的步骤,再决定是否调整模型或超参数。

动手任务:为旅行计划定义 schema,加入日期顺序、预算总和和城市白名单验证。 完成后不要只保存最终输出,还要保存代码、配置、中间张量或检索结果,以及你对结果的解释。

仅在提示中写“输出 JSON”不能保证合法 JSON,更不能保证字段语义。应使用 JSON Schema/Pydantic 描述结构,并在应用侧验证:

from pydantic import BaseModel, Field, ValidationError

class TravelPlan(BaseModel):
    city: str
    days: int = Field(ge=1, le=30)
    highlights: list[str] = Field(min_length=1, max_length=10)

def parse_model_json(text: str) -> TravelPlan:
    try:
        return TravelPlan.model_validate_json(text)
    except ValidationError as exc:
        # 记录原始输出和 schema 版本;可触发一次受控修复,而不是无限重试
        raise ValueError(f"模型输出不符合结构: {exc}") from exc

支持 Structured Outputs 的 API 可以把 Schema 约束纳入解码,但业务规则仍需应用验证。Schema 要版本化;字段描述应明确单位、枚举和是否允许空值。

4.7 Function Calling:模型提议,程序执行

从问题出发。 Function Calling 中模型只提出工具名与参数,程序才拥有执行权。安全边界必须放在执行器:白名单、身份、作用域、确认、审计和幂等。 先把名词放到一边,沿着输入、状态变化和输出走一遍;只有能指出证据落在哪一步,概念才算真正掌握。

沿数据流逐步检查:

  1. 把工具契约提供给模型。
  2. 解析并验证参数。
  3. 执行器检查权限与风险。
  4. 把结果作为新观察返回。

用小数据走一遍。 模型提出 send_email(to,body) 不等于邮件已发送;程序应在真正发送前检查收件人、敏感信息和是否需要用户确认。 先在纸面预测结果,再让程序打印中间状态;预测与运行不一致的地方,正是需要继续追查的知识缺口。

这里最容易出现的误解。 允许模型拼接任意命令、把隐藏凭据放进工具结果、把读取与写入使用同一权限、错误后盲目重试,都会造成严重风险。 出现异常时先冻结样本、配置和环境,从最靠近输入的环节开始验证;不要同时更换模型、数据和超参数。

小实验:实现只读天气工具和有副作用的日历工具,比较两者确认与重试策略。 提交物应包含预期、运行证据和失败记录;只有别人能按同样步骤复现,结果才具有学习价值。

工具调用的正确边界是:模型根据工具描述生成“调用意图 + 参数”,程序验证参数、检查权限、执行工具,再把结果返回模型。模型不能直接获得数据库管理员权限。

TOOLS = {
    "weather": {
        "description": "查询指定城市的公开天气,不处理历史私人位置",
        "schema": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
            "additionalProperties": False,
        },
    }
}

def execute_tool(name: str, arguments: dict, user_scope: set[str]):
    if name not in user_scope:
        raise PermissionError("当前用户无权调用该工具")
    if name == "weather":
        city = arguments["city"].strip()
        if not city or len(city) > 80:
            raise ValueError("非法城市参数")
        return {"city": city, "temperature_c": 26}
    raise KeyError(name)

工具描述要说明用途、边界和副作用。写操作默认需要幂等键或人工确认;工具结果要限制长度并转义不可信内容;审计日志记录调用者、工具、参数摘要、结果和耗时。

4.8 压测方法与容量规划

先看它解决什么。 压测不是把并发数字调大,而是复现真实长度、到达率和解码参数,并在稳定区间测量资源、排队和错误。容量规划还要留故障与流量突增余量。 理解的标准不是会复述定义,而是能画出数据流、预测中间结果,并设计一个让错误暴露出来的检查。

可以把实现分成以下环节:

  1. 构造代表性请求分布。
  2. 预热模型与 kernel。
  3. 逐级提升到达率。
  4. 记录延迟分位、吞吐、GPU 与失败。

一个可以手算的例子。 固定 100 个请求并发与按泊松到达的 100 QPS 不是同一种压力;前者更像瞬时洪峰,后者反映持续负载。 这里故意不用大模型或大数据,因为小输入能把每个轴、分数和状态变化完整暴露出来。

排错时先看这些地方。 没有预热、客户端先成为瓶颈、只报最好一次、忽略返回 token 数和错误请求,都会产生漂亮但无效的数字。 修复不能止于‘这次跑通’,还要把失败样本变成自动测试,防止同类问题在下一版本重新出现。

现在动手:写压测计划,明确样本来源、运行时长、并发模型、成功标准和停止条件。 把关键断言写进测试,并在 README 说明怎样运行、怎样判断正确、当前实现还不支持什么。

压测分三层:单请求基线确认模型可用;逐级增加并发找到吞吐拐点;长稳测试观察泄漏、碎片和长尾。输入长度与输出长度应来自真实分布,并单独测短问长答、长文短答等场景。

容量不是“峰值 QPS ÷ 单卡 QPS”这么简单。还要给发布、故障和突发流量留余量;按租户限流;把 prefill 密集型与 decode 密集型流量分别观察;对超长输入提前拒绝或转异步任务。

4.9 上线检查清单

抓住这一节的主线。 上线清单把隐含假设变成可验证条件,覆盖模型、数据、接口、性能、安全、监控、回滚与责任人。它应随事故和架构变化持续更新。 遇到新术语,先找它在系统中的位置:它读取什么、保存什么、改变什么,以及失败时会留下什么信号。

真正动手时按这个顺序走:

  1. 离线评估达标。
  2. 容量与降级演练。
  3. 权限和隐私审查。
  4. 监控告警与回滚验证。

先做最小实验。 模型版本回滚不仅换权重,还要确认 tokenizer、提示模板、工具 schema、索引版本和缓存兼容。 把随机性固定并保留中间量,这个例子就能成为后续优化时的回归基线。

别被表面现象带偏。 只检查服务能启动、没有灰度样本、告警无负责人、回滚脚本从未执行,都会在事故中暴露。 先区分定义错误、实现错误、数据问题和性能瓶颈。四类问题需要的证据不同,不能靠盲目调参混在一起处理。

验证任务:为本章服务建立发布门禁表,每项写证据、负责人、截止时间和回滚动作。 实验结束后写三句话:观察到了什么、这些证据支持什么结论、还有哪种解释尚未排除。

  • 模型与 tokenizer 版本固定,许可证可用于目标场景。
  • 输入大小、输出大小、并发、超时和预算都有上限。
  • 记录 TTFT、TPOT、tokens/s、队列、GPU、错误率与质量抽检。
  • 敏感数据最小化,日志脱敏,租户隔离。
  • 工具调用有白名单、参数验证、权限和审计。
  • 有离线评估集、灰度策略、回滚版本和故障降级。

延伸阅读:FastAPI 异步说明FastAPI 流式响应Qwen 官方快速开始

本章配套代码

下面的脚本与正文使用相同符号。建议先在代码中打印形状和中间量,再运行断言;若依赖尚未安装,至少先阅读入口函数、输入输出和测试部分。

本章端到端实验:把知识变成可复现证据

本实验不是把本章代码重新抄一遍,而是把概念、实现、测试和解释串成一个小型工程。请新建独立目录,保存 README.md、环境文件、源代码、测试、运行日志和结果图。README 至少说明任务、输入输出、运行命令、预期现象、已知限制和复现条件。

实验步骤

  1. 为聊天、批量摘要、代码补全分别写 SLO,并说明为何指标权重不同。
  2. 实现一个带请求 id、超时、并发信号量和取消处理的模拟生成接口。
  3. 构造短输入长输出与长输入短输出两类负载,分别压测并解释 TTFT/TPOT 差异。
  4. 写一个流式客户端状态机,覆盖正常结束、用户取消、超时和服务端错误。
  5. 为同一客服任务写三个提示版本,用十条固定样例比较格式正确率和事实错误率。
  6. 为旅行计划定义 schema,加入日期顺序、预算总和和城市白名单验证。

每完成一步,先写下预期,再运行代码。若结果与预期不一致,不要覆盖旧日志;建立 failures.md,记录现象、假设、证据、修复和回归测试。这样得到的不是一次性 Demo,而是一份能证明你真正理解本章内容的实验档案。

验收标准

  • 全新环境能够按照 README 从头运行,依赖和随机种子已记录。
  • 关键函数至少有正常、边界和错误输入三类测试;涉及数值计算时检查有限值与合理误差。
  • 结果包含一个基线和至少一个受控改动,能够说明变化来自哪里。
  • 日志保留输入规模、耗时、内存或显存、软件版本和失败样本。
  • 结论区分“实验直接证明的事实”“根据事实做出的推断”和“仍未验证的猜想”。

本章自测

  1. 不看正文,用自己的话解释“服务指标必须从用户体验和系统资源同时定义”,并给出一个可以证伪的测试。
  2. 不看正文,用自己的话解释“FastAPI 的 async 适合等待网络、磁盘和队列,不会让同步 GPU kernel 神奇变快”,并给出一个可以证伪的测试。
  3. 不看正文,用自己的话解释“vLLM 的核心价值来自调度与 KV Cache 管理:把不同到达时间、不同长度的请求动态组成批次,并用分页思想减少缓存碎片”,并给出一个可以证伪的测试。
  4. 不看正文,用自己的话解释“同步、异步和流式是调用语义,不是模型能力”,并给出一个可以证伪的测试。
  5. 不看正文,用自己的话解释“提示词工程组织指令,上下文工程决定模型实际看见哪些规则、历史、证据和工具”,并给出一个可以证伪的测试。
  6. 不看正文,用自己的话解释“结构化输出的目标是把自然语言结果变成可验证数据”,并给出一个可以证伪的测试。

回答时先画图或写形状,再给结论。若只能说出术语而不能给出最小例子、边界条件和验证方法,说明这一节仍需要回到代码中重做。