让模型跑在自己的机器上
先架构设计为主+,部分已实现工程为辅。
以翻译模型为例,打通「下载开源模型 → 私有化部署 → 对外提供 API」全流程
1. 引言:为什么要自己把模型跑起来
1.1 使用大模型的三种方式
| 方式 | 做法 | 推理发生在哪 | 权重在哪 | 典型特征 |
|---|---|---|---|---|
| 调用云 API | 买 Token,发 HTTP 请求 | 云厂商机房 | 云端 | 上手最快,数据出内网,按量付费 |
| 本地部署 | 自己下载权重并加载 | 自己的机器 / GPU | 本地磁盘 | 数据不出内网,可离线,成本一次性 |
| 微调 | 在权重上继续训练 | 自己的机器 | 本地 | 成本最高,收益最垂直 |
本分享只讲第二条路径。它的价值不只在于"把数据留在内网",更在于让我们真正看清一个模型从仓库到线上服务,中间到底经过哪些环节。
1.2 本地部署的四个收益与两个代价
收益:① 数据不出内网;② 成本可控(一次性算力 vs 按 Token 计费);③ 可裁剪(选模型、选量化、选引擎);④ 可离线(断网也能跑)。
代价:① 需要自己管硬件与运维;② 需要理解推理框架,否则"能跑"和"跑得好"之间差距很大------后者正是本分享的重点。
1.3 全流程速览:八个步骤
| 步骤 | 要做的事 | 本工程的对应物 |
|---|---|---|
| ① 选模型 | 看许可、看框架支持、看语言能力 | configs/model.yaml 默认 Qwen/Qwen2.5-1.5B-Instruct |
| ② 下载权重 | 从 Hugging Face 拉到本地缓存 | scripts/download_model.py |
| ③ 选引擎 | vLLM / SGLang / llama.cpp 三选一 | engine 字段 |
| ④ 写配置 | 模型路径、量化、显存、并发 | model.yaml + configs/serve.yaml |
| ⑤ 启动服务 | 加载权重并起 HTTP 服务 | scripts/start_gateway.sh、app/main.py |
| ⑥ 调用接口 | 业务接口 + OpenAI 兼容接口 | app/api/routes_translate.py |
| ⑦ 观测压测 | 延迟、吞吐、缓存命中 | scripts/benchmark.py |
| ⑧ 容器化上线 | 打镜像、挂载权重 | docker/docker-compose.yml |
贯穿全篇的三条硬性约束:
① 必须用公开渠道的开源模型;
② 模型必须被主流推理框架(vLLM / SGLang)支持;
③ 推理必须发生在自己的机器上(服务端自己加载并运行权重)。
1.4 名词解释(阅读前置)
本分享涉及较多大模型 / 推理 / 运维术语,先集中解释,正文首次出现处不再重复展开。
A. 模型与翻译基础
| 名词 | 英文 / 缩写 | 通俗解释 |
|---|---|---|
| 大语言模型 | LLM, Large Language Model | 用海量文本训练出的通用生成模型,能对话、写作、翻译等。本分享用它做翻译。 |
| 机器翻译 | MT / NMT | Machine Translation / Neural MT,用神经网络把一种语言自动翻译成另一种语言。 |
| Encoder-Decoder | 编码器-解码器 | 早期翻译模型结构:先"读懂"源句(编码),再"写出"译文(解码)。如 Marian、NLLB。 |
| 权重 | Weights | 模型训练后保存的参数文件(几十 MB ~ 几十 GB)。本地部署的本质就是把权重放到自己机器上。 |
| 开源模型 | Open-source Model | 权重与许可公开、可下载自部署的模型。要求来自 Hugging Face 等公开渠道。 |
| Hugging Face | HF | 全球最大的开源模型托管平台,形如"AI 界的 GitHub"。 |
| 许可 / 授权 | License | 模型的使用法律条款。如 Apache-2.0(可商用)、CC-BY-NC(禁商用)。选型的关键判断项之一。 |
| Prompt | 提示词 | 喂给模型的指令文本。本分享用固定 System Prompt 来约束翻译行为。 |
| System Prompt | 系统提示词 | Prompt 中固定不变的角色设定部分(如"你是专业翻译,只输出译文")。翻译场景中它不变,可被缓存复用。 |
| Chat Template | 对话模板 | 把 messages 拼成模型认识的字符串的规则,各模型不同,需按官方模板渲染。 |
| 术语表 | Glossary | 用户指定的"必须这样翻译"的对照词表(如 "GPU → 图形处理器"),用于强制译法一致。 |
| 分词 / Token | Tokenizer / Token | 模型处理的最小文本单元。一个汉字≈1 |
| 领域微调 | Fine-tuning / LoRA | 用领域数据继续训练模型以提升特定场景效果。LoRA 是一种轻量微调方法,可热加载。 |
B. 推理与性能
| 名词 | 英文 / 缩写 | 通俗解释 |
|---|---|---|
| 推理 | Inference | 模型"使用"的过程(区别于"训练"):输入 Prompt,输出结果。 |
| 推理框架 | Inference Engine | 专门用于高效跑推理的软件(如 vLLM、SGLang),负责调度、批处理、显存管理。 |
| 吞吐 | Throughput | 单位时间能产出的 token 数(tokens/s)。越高越省钱。 |
| 延迟 | Latency | 一次请求从发出到返回的耗时。常用 P50/P95/P99 表示分位数(P95 = 95% 的请求快于该值)。 |
| 并发 | Concurrency | 同时处理的请求数量。 |
| QPS | Queries Per Second | 每秒请求数。 |
| 显存 | VRAM | GPU 上的内存。模型越大、批次越多,占用越高。是部署选型的硬约束。 |
| KV Cache | 键值缓存 | 模型生成时缓存的中间计算结果,避免重复计算历史 token。占显存大头。 |
| 前缀缓存 | Prefix Caching / APC | 多个请求开头(如固定的 System Prompt)相同时,复用已算好的 KV,省时省显存。翻译场景收益极大。 |
| RadixAttention | --- | SGLang 的前缀缓存实现,用基数树组织,对"固定前缀 + 多变后缀"命中率更高。 |
| PagedAttention | --- | vLLM 首创的显存分页管理技术,像操作系统管理内存一样管理 KV Cache,大幅提升利用率。 |
| 连续批处理 | Continuous Batching | 不等整批结束,动态把新请求插入正在跑的批次,显著提升吞吐。 |
| 冷启动 | Cold Start | 服务刚启动、模型还没加载进显存、首个请求较慢的阶段。 |
| 流式输出 | Streaming | 边生成边返回(逐字/逐句),改善长文本等待体验。 |
| 结构化输出 | Structured Output / Guided Decoding | 强制模型按 JSON / 正则等格式输出,便于程序解析。 |
| 状态机 | FSM | Finite State Machine,实现"受约束解码"的底层机制,保证输出合法。 |
C. 推理框架专有名词
| 名词 | 通俗解释 |
|---|---|
| vLLM | 最主流的开源高吞吐推理框架,生态最全,本分享默认引擎。 |
| SGLang | 另一主流高性能框架,前缀缓存(RadixAttention)与结构化输出更强,本分享可选引擎。 |
| TensorRT-LLM | NVIDIA 官方推理框架,性能极致但需编译、运维重。 |
| TGI | Hugging Face 官方推理服务(Text Generation Inference)。 |
| llama.cpp | 跨平台(Linux / macOS / Windows)的高效推理实现,量化格式为 GGUF,本分享用于 macOS / 无 GPU 场景。 |
| Ollama | 基于 llama.cpp 的"一键跑模型"工具,面向开发体验,非生产级。 |
| GGUF | llama.cpp 使用的量化模型格式(量化档位内嵌于文件名)。 |
D. 量化(省显存 / 提速度)
| 名词 | 通俗解释 |
|---|---|
| 量化 | Quantization,把模型参数用更低精度存储(如 16 位→4 位),省显存、更快,但可能轻微掉点。 |
| FP16 | 16 位浮点,原始精度,质量最好、最占显存。 |
| FP8 / INT8 | 8 位量化,显存约减半,质量损失小;FP8 需较新(Ada/Hopper)显卡。 |
| AWQ | 一种 4 位量化方法,消费级显卡友好,社区权重现成。本分享消费卡推荐。 |
| GPTQ | 另一种 4 位量化方法,效果与 AWQ 相当。 |
| bitsandbytes | 训练友好的量化库,推理场景不推荐。 |
E. 服务与接口
| 名词 | 通俗解释 |
|---|---|
| FastAPI | Python 异步 Web 框架,开发快、自带接口文档,本分享应用网关选型。 |
| Pydantic | Python 数据校验库,用于校验请求/响应字段。 |
| SSE | Server-Sent Events,一种 HTTP 流式推送协议,用于流式输出。 |
| gRPC | 基于 HTTP/2 的高性能 RPC 协议,本分享列为内部通信备选。 |
| Triton | NVIDIA 的推理服务器,适合多模型编排,本分享列为平台化演进项。 |
| OpenAI 兼容 | 接口协议对齐 OpenAI 的 /v1/chat/completions 格式,只是协议兼容,不代表调用 OpenAI 云服务。 |
| 云 API 调用 | 服务端不加载模型、把请求转给云厂商服务的方式。本分享只做本地部署(见 §4.2 对比)。 |
| 网关 | Gateway,流量的统一入口,负责鉴权、限流、路由。 |
| 限流 / 令牌桶 | Rate Limit / Token Bucket,控制单位时间请求量,超限返回 429。 |
| 背压 | Backpressure,下游处理不过来时,反向抑制上游流量,避免雪崩。 |
| 熔断 / 降级 | Circuit Breaker / Degradation,依赖故障时快速失败或走保底逻辑。 |
| 缓存 LRU | Least Recently Used,淘汰最久未用项的内存缓存策略。 |
F. 部署与运维
| 名词 | 通俗解释 |
|---|---|
| 私有化 / 本地化部署 | On-Premise,模型与数据都在自己机房/内网,数据不出内网,可离线运行。 |
| Docker / 镜像 | 容器化打包工具,把环境和代码打包成可移植的"镜像"。 |
| Docker Compose | 单机多容器编排工具,本分享阶段一采用。 |
| Kubernetes(K8s) | 多机容器编排平台,支持 GPU 调度与弹性扩缩,本分享阶段二采用。 |
| GPU Operator | K8s 上管理 GPU 驱动的组件。 |
| HPA / KEDA | K8s 的自动扩缩容组件(按负载/事件伸缩)。 |
| PVC | Persistent Volume Claim,K8s 的持久化存储声明,用于挂载模型权重。 |
| Redis | 高性能内存数据库,本分享用作二级缓存。 |
| Prometheus / Grafana | 指标采集 / 可视化监控组合。 |
| Loki / ELK | 日志聚合方案(Loki 轻量;ELK = Elasticsearch + Logstash + Kibana)。 |
| OpenTelemetry | 开源链路追踪标准。 |
| 张量并行 / 流水并行 / 专家并行 | TP / PP / EP,把大模型切分到多张卡上的三种并行方式。 |
G. 评测与离线
| 名词 | 通俗解释 |
|---|---|
| BLEU | 机器翻译经典自动评测指标(0~100),越高越好,但仅供参考。 |
| COMET | 基于神经网络的翻译质量评测指标,比 BLEU 更贴近人工判断。 |
| 回归测试 | 改动后重跑测试,确认原有能力没变差。 |
| 出网 / 离线运行 | 是否需要访问外网。本分享要求全程离线,无任何外部依赖。 |
2. 模型层:从 HF 仓库到本地权重
2.1 选型原理:先看"能不能用",再看"好不好用"
选型不是比参数大小,而是依次过四道闸:
- 许可(License) ------决定能不能商用,是一票否决项。
Apache-2.0(Qwen 系列)可商用;CC-BY-NC(NLLB-200)禁商用。 - 推理框架支持------模型能不能被 vLLM / SGLang 原生加载、有没有现成的量化权重。专用 NMT(如 Marian)是 Encoder-Decoder,社区支持弱、量化生态差,排除。
- 语言能力------中英互译场景下,中文语料占比高的模型表现更好,Qwen 系列优于同规模 Llama / Gemma。
- 升级路径------1.5B / 3B / 7B 同族参数梯度,可"同一套代码按算力切换模型"。
最终结论:主选 Qwen/Qwen2.5-1.5B-Instruct(Apache-2.0、vLLM/SGLang 双支持、中文强),质量优先时升级到 7B。
2.2 量化原理:省的是显存,换的是精度
推理的显存主要由三块决定:
ini
显存 ≈ 权重 + KV Cache + 激活/框架开销
权重 = 参数量 × 每参数字节数 # FP16=2B, INT8/FP8=1B, INT4=0.5B
KV = 2 × 层数 × 头数 × 头维 × 序列长度 × batch × 每元素字节
一个 1.5B 模型:FP16 权重约 3.2 GB,INT4 约 0.8 GB------量化把"权重"这块压掉 3/4,这也是消费级显卡能跑起来的原因。
| 方案 | 位宽 | 原理直觉 | 质量损失 | 适用 |
|---|---|---|---|---|
| FP16 | 16 | 原始精度 | 基准 | 质量敏感、显存充足 |
| FP8 | 8 | 8 位浮点,动态范围足够 | 极小 | Ada / Hopper 数据中心卡 |
| INT8 | 8 | 8 位整型 + 缩放因子 | 小 | 兼容性好 |
| AWQ | 4 | 保护"重要通道"的 4 位量化 | 小 | 消费级卡首选,社区权重现成 |
| GPTQ | 4 | 逐层最小化量化误差 | 小~中 | 与 AWQ 相当 |
| GGUF | 2~8 | llama.cpp 的容器格式,档位写进文件名 | 按档位 | 跨平台 / CPU / Metal |
关键结论:量化的收益是显存与带宽,代价是精度。选择原则是"先卡后量化"------目标卡是 Ada/Hopper 就上 FP8,是消费卡就上 AWQ 4bit,质量敏感链路保留 FP16。
2.3 权重产物:同一个模型,不同引擎要的东西不一样
这是选完型之后最容易踩坑 的一环。model_path 必须与 engine 匹配:
| 引擎 | 权重产物 | model_path 指向 |
典型仓库 | quantization |
|---|---|---|---|---|
| vLLM / SGLang(FP16) | HF 目录:config.json + *.safetensors + tokenizer |
目录 | Qwen/Qwen2.5-1.5B-Instruct |
null |
| vLLM / SGLang(AWQ) | HF 目录 + AWQ 量化权重 | 目录 | Qwen/Qwen2.5-1.5B-Instruct-AWQ |
awq |
| llama.cpp | 单个 .gguf 文件(量化内嵌) |
文件 | Qwen/Qwen2.5-1.5B-Instruct-GGUF |
无效(被忽略) |
三个差异点:
- 目录 vs 文件 :vLLM/SGLang 指向目录(或仓库 ID),llama.cpp 指向具体
.gguf文件; - 量化是否独立成仓库 :vLLM/SGLang 的量化权重通常是单独仓库(
-AWQ后缀),quantization必须一致;llama.cpp 的量化内嵌在文件名 里(q4_k_m/q5_k_m/q8_0/f16); - 常见错误 :
-GGUF仓库配engine: vllm❌;目录配engine: llamacpp❌;FP16 权重却写quantization: awq❌。
一句话:引擎决定权重格式 ,切 engine 必须同步换 model_path。
2.4 下载与缓存:为什么"默认路径"很关键
huggingface_hub.snapshot_download 的默认行为是下载到 HF 官方缓存目录 ~/.cache/huggingface/hub,并按 models--组织--模型/snapshots/<commit>/ 组织。理解这一点带来两个好处:
- 不污染项目目录,多个项目共享同一份权重;
- 可离线 :缓存命中后,配合
HF_HUB_OFFLINE=1完全断网运行。
bash
python scripts/download_model.py # 默认模型 → 默认缓存
python scripts/download_model.py --dry-run # 只预览路径,不下载
python scripts/download_model.py --model Qwen/Qwen2.5-1.5B-Instruct-GGUF \
--include "*q4_k_m.gguf" # 只下需要的量化档
缓存位置可用 HF_HOME / HF_HUB_CACHE 覆盖(见 scripts/download_model.py 的 default_cache_dir())。
3. 推理引擎层:它们为什么快
这一章是全篇的技术重心:不理解 KV Cache、PagedAttention、批处理与前缀缓存,就无法理解"为什么同样一张卡,吞吐能差 5 倍"。
3.1 自回归推理与 KV Cache
大模型生成是逐 token 自回归 的:每生成一个新 token,都要对"前面所有 token"做注意力计算。若每步都重算历史,代价是 O(n²) 的重复劳动。
KV Cache 把这份重复劳动缓存起来:把历史 token 的 Key/Value 张量保存在显存里,生成第 n 个 token 时只算第 n 个。
两个阶段,瓶颈完全不同------这是理解后面所有优化的前提:
| 阶段 | 做什么 | 并行度 | 瓶颈 |
|---|---|---|---|
| Prefill(预填充) | 一次性算完整个输入 Prompt 的 KV | 高(整段并行) | 算力(compute-bound) |
| Decode(解码) | 每步只算 1 个新 token | 低(逐步串行) | 显存带宽(memory-bound) |
两条最实用的结论:
- Prefill 贵在算力 → 输入越长,首 token 延迟越高;
- Decode 贵在带宽 → 每步都要把权重与 KV 走一遍,所以"更大的 batch"能显著摊薄单位成本(这正是 Continuous Batching 的收益来源),而"更省显存与带宽"的量化对 decode 收益最大。
代价转移:计算量降了,但 KV Cache 要占显存:
KV 显存 ≈ 2 × 层数 × KV头数 × head_dim × 序列长度 × batch × 每元素字节
它随"序列长度 × 并发数"线性增长。于是三个优化方向自然浮现:显存怎么管(PagedAttention)、并发怎么排(Continuous Batching)、前缀怎么复用(前缀缓存)------三者恰好对应后面三节。
原理 → 参数 :这三条原则最终都落在引擎的加载参数上(见 §3.6):
gpu_memory_utilization决定留给 KV 的显存比例,
max_model_len决定单请求 KV 上限,
n_gpu_layers决定权重放 GPU 还是 CPU。
3.2 PagedAttention:像操作系统管内存一样管 KV
问题:传统实现为每个请求预分配一段连续显存(预留到最大长度),实际用量远小于预留量 → 内部碎片严重,显存利用率可能只有 20%~40%。
做法 :vLLM 借鉴操作系统的分页思想,把 KV Cache 切成固定大小的 block,用一张"页表"把逻辑上的连续序列映射到物理上离散的 block。
收益:
- 显存几乎无碎片,同样显存能装下更多并发;
- block 可以按需分配、共享------这直接为下一节的"前缀共享"铺了路。
SGLang 采用了改进版实现,并在此基础上引入 RadixAttention(见 §3.4):用基数树组织共享的前缀块,使"固定前缀 + 多变后缀"的结构被自动共享,命中率更高。
代码落点 :分页是引擎内部机制,业务侧只需把参数给对------vLLM 的关键旋钮是 gpu_memory_utilization(留给 KV Cache 的显存比例),llama.cpp 侧是 n_ctx(KV 容量上限)。见 app/engine/vllm_engine.py。
3.3 Continuous Batching:把"批"从静态变成流式
静态批(Static Batching) :凑够一个 batch 一起跑,必须等最长的那个请求生成完才能收工,短请求早早结束却占着坑位。
Continuous Batching(连续批处理) :以"每一步(step)"为调度单位,某请求结束就立刻把排队的新请求插进去,让 GPU 始终处于满载状态。
收益 :在混合长短请求的真实流量下,吞吐可提升数倍;这也解释了为什么翻译服务不该自己造批------引擎内部已经在做高效的增量调度。
代码落点:vLLM 的批量接口天然吃到这份收益,业务侧只需一次调用,剩下的调度交给引擎:
python
# app/engine/vllm_engine.py ------ 一次调用即完成批量调度
outs = self.llm.generate(prompts, params) # 引擎内部按 step 连续调度
而在没有 Continuous Batching 的 llama.cpp 上,generate_batch 只能退化为逐个执行------这就是"同一接口、不同世界"的典型差异(见 §3.6.4)。
3.4 前缀缓存:翻译场景的最大红利
原理 :多个请求如果开头完全相同 ,那么这段前缀对应的 KV 是一模一样的,可以直接复用,无需重算。
两种主流实现:
| 实现 | 数据结构 | 特点 |
|---|---|---|
| vLLM APC(Automatic Prefix Caching) | 按 block 哈希匹配 | 通用,命中粒度是 block |
| SGLang RadixAttention | 基数树(Radix Tree) | 对"固定前缀 + 多变后缀"命中率更高,且天然支持多级共享 |
为什么翻译是它的最佳场景:翻译请求的 Prompt 结构天然是
css
[固定的 System 段:你是翻译,规则 1/2/3,领域=general] + [多变的 User 段:待翻译文本]
固定段占比高、几乎逐字节不变 → 前缀命中率极高 → 首 token 延迟与整体吞吐同时受益。
代码落点:vLLM 侧只需打开开关,引擎自动按 block 哈希匹配并复用:
python
# app/engine/vllm_engine.py
self.llm = LLM(
model=self.cfg.model_path,
enable_prefix_caching=self.cfg.prefix_caching, # ← 前缀缓存开关
gpu_memory_utilization=self.cfg.gpu_mem_util,
max_model_len=self.cfg.max_model_len,
)
SGLang 侧无需开关:RadixAttention 默认生效,前缀共享由引擎自动完成。
由此反推出一条设计约束(见 §5.3) :为了让前缀保持逐字节稳定,System 段的文本必须稳定,可变部分(术语表)要放到最后 。也就是说,引擎层的收益最终由业务层的 Prompt 设计决定------这是"原理直接决定代码结构"的典型例子。
量化感受:翻译场景中 System 段常占 Prompt 的 50% 以上,前缀全命中意味着这部分 KV 完全不必重算,首 token 延迟可明显下降。
3.5 受约束解码:让模型"必须"输出合法结构
需要 JSON 输出时,靠 Prompt 祈求模型听话是不够的。受约束解码 把语法约束编译成状态机(FSM),在每一步采样时屏蔽掉不合法的 token,从而在解码阶段就保证输出合法。
原理三步:① 把 JSON Schema / 正则编译成 FSM;② 解码前依据 FSM 当前状态过滤 logits(非法 token 置 −∞);③ 采样后推进 FSM 状态。代价是每步多一次过滤开销,因此 SGLang 用"压缩 FSM"降低开销。
代码落点 :同一个 GenRequest.guided_json,三个引擎映射到各自的原生能力(见 app/engine/base.py 的 GenRequest):
| 引擎 | 映射到 | 表达力 |
|---|---|---|
| vLLM | SamplingParams(guided_json=...) |
完整 JSON Schema |
| SGLang | {"json_schema": ...} |
完整 JSON Schema |
| llama.cpp | {"response_format": {"type": "json_object"}} |
仅保证"合法 JSON";需严格结构时改用 GBNF grammar |
注意三者表达力并不等同 :llama.cpp 的 json_object 只保证"是合法 JSON",不保证结构。接口一致、能力有差异------抽象层应当在文档里如实暴露,而不是假装一致。
3.6 引擎抽象层:关键代码实现
原理讲完,接着回答"这些原理在代码里如何落地 "。核心是「一个接口 + 三个实现」(app/engine/)。
3.6.1 统一接口与数据契约
python
# app/engine/base.py
@dataclass
class GenRequest:
messages: list[dict]
max_tokens: int = 512
temperature: float = 0.0 # 翻译默认贪心,保证结果可复现
top_p: float = 1.0
stop: list[str] | None = None
guided_json: dict | None = None
@dataclass
class GenResult:
text: str
prompt_tokens: int = 0
completion_tokens: int = 0
class InferenceEngine(ABC):
@abstractmethod
def load(self) -> None: ...
@abstractmethod
def generate(self, req: GenRequest) -> GenResult: ...
def generate_batch(self, reqs: Sequence[GenRequest]) -> list[GenResult]:
return [self.generate(r) for r in reqs] # 默认逐个;子类可覆盖为真批处理
@abstractmethod
def stream(self, req: GenRequest) -> AsyncIterator[str]: ...
@abstractmethod
def health(self) -> dict: ...
三个设计点:
GenRequest传messages而非裸字符串:把"如何套用 chat 模板"交给引擎(tokenizer 模板 vs GGUF 内嵌模板),业务层不越界;temperature默认 0:翻译是确定性任务,贪心解码让"同一输入 → 同一输出",也让缓存语义干净;generate_batch提供默认实现:不具备原生批能力的引擎无需重复写循环,有能力时再覆盖它。
3.6.2 vLLM:业务只给参数,性能交给引擎
python
# app/engine/vllm_engine.py
def load(self) -> None:
from vllm import LLM # 延迟导入(见 §3.6.5)
self.llm = LLM(
model=self.cfg.model_path,
tensor_parallel_size=self.cfg.tp_size,
gpu_memory_utilization=self.cfg.gpu_mem_util, # §3.1 显存账
max_model_len=self.cfg.max_model_len, # §3.1 KV 上限
enable_prefix_caching=self.cfg.prefix_caching, # §3.4 前缀缓存
)
self._tokenizer = self.llm.get_tokenizer()
def generate_batch(self, reqs):
prompts = [self._render(r) for r in reqs]
params = [self._sampling_params(r) for r in reqs]
return [self._to_result(o) for o in self.llm.generate(prompts, params)]
要点:业务代码只负责"渲染 Prompt + 组装采样参数",PagedAttention / Continuous Batching / 前缀缓存全部由引擎内部完成。这正是"原理决定参数、参数决定性能"的落地方式。
3.6.3 SGLang:同接口,不同调度
python
# app/engine/sglang_engine.py
self.engine = sgl.Engine(model_path=..., tp_size=..., mem_fraction_static=...)
async def stream(self, req):
for chunk in self.engine.generate(prompt=..., sampling_params=..., stream=True):
if chunk.get("text"):
yield chunk["text"] # 原生 token 级流式
差异点:stream=True 是原生增量输出;结构化输出走压缩 FSM;前缀共享由 RadixAttention 自动完成,业务侧没有开关需要调。
3.6.4 llama.cpp:跨平台,但要清楚它的边界
python
# app/engine/llamacpp_engine.py
self.llm = Llama(
model_path=self.cfg.model_path, # 必须是本地 .gguf 文件(见 §2.3)
n_ctx=self.cfg.max_model_len, # KV 容量上限
n_gpu_layers=self.cfg.n_gpu_layers, # 0=纯 CPU;-1=Metal/CUDA 全层卸载
verbose=False,
)
def generate(self, req):
resp = self.llm.create_chat_completion(**self._params(req))
return self._to_result(resp) # 直接读取 usage 中的 token 计数
边界要说清楚:没有 PagedAttention、没有 Continuous Batching ,generate_batch 只能退化为串行;json_object 只保证"是合法 JSON"。因此它适合开发机、边缘与低并发------用"能跑"换"跑得慢"。
这也解释了为什么 §2.3 要强调"权重产物必须匹配":
model_path在 vLLM/SGLang 是目录 、在 llama.cpp 是.gguf文件,配错就直接起不来。
3.6.5 装配与容错:三个工程决策
python
# app/engine/factory.py
def build_engine(cfg: ModelConfig) -> InferenceEngine:
if cfg.engine == "vllm": return VLLMEngine(cfg)
if cfg.engine == "sglang": return SGLangEngine(cfg)
if cfg.engine == "llamacpp": return LlamaCppEngine(cfg)
raise EngineError(f"未知引擎: {cfg.engine}")
- 延迟导入 :三个引擎都在
load()内部才import vllm / sglang / llama_cpp。好处是未安装引擎时整个包仍可导入 (配置校验、接口文档、代码审查都不受影响),错误延迟到加载时刻,并以清晰的EngineError抛出(fail-fast)。 - 配置层类型收窄 :
EngineName = Literal["vllm", "sglang", "llamacpp"],非法取值在配置解析阶段即被拒绝,不会拖到运行期。 - 能力差异如实暴露:流式、结构化输出的差异在实现与文档中写明(如 vLLM 的"分块兼容"),而不是在抽象层假装一致。
3.7 引擎选型:六个框架、三个决定性问题
| 维度 | vLLM | SGLang | TensorRT-LLM | TGI | llama.cpp | Ollama |
|---|---|---|---|---|---|---|
| PagedAttention | ✅(首创) | ✅(改进) | ✅ | ✅ | ❌ | ❌ |
| Continuous Batching | ✅ | ✅ | ✅ | ✅ | ❌ | ⚠️ |
| 前缀缓存 | APC | RadixAttention | ✅ | ✅ | ✅ | ✅ |
| 结构化输出 | ✅ | ✅(压缩 FSM) | ✅ | ⚠️ | ⚠️ | ⚠️ |
| 操作系统 | Linux(CUDA) | Linux(CUDA) | Linux(CUDA) | Linux/Docker | 跨平台 | 跨平台 |
| 是否需要 NVIDIA GPU | 必须 | 必须 | 必须 | 推荐 | 否 | 否 |
| 部署复杂度 | 低 | 中 | 高(需编译) | 低 | 低 | 极低 |
| 生产成熟度 | 高 | 高 | 高 | 中 | 低(服务化) | 不建议生产 |
选型的三个决定性问题:
- 吞吐与延迟要求高吗? → 是,则只有在 vLLM / SGLang / TensorRT-LLM 中选。
- 前缀是否高度复用? → 是(翻译正是),SGLang 的 RadixAttention 有优势。
- 硬件是什么? → Linux + NVIDIA 则选 vLLM/SGLang;macOS / 无 NVIDIA GPU 只能选 llama.cpp。
最终策略:「三引擎 + 统一抽象」------vLLM(默认,稳)、SGLang(高前缀复用)、llama.cpp(跨平台兜底),通过统一接口在配置层切换。
3.8 平台约束:一个真实踩坑
在 macOS 上执行 pip install vllm 必然失败------vLLM / SGLang 只提供 Linux + CUDA 的预编译包:
vbnet
VLLM_TARGET_DEVICE automatically set to cpu due to macOS
Building wheel for vllm (pyproject.toml): finished with status 'error'
结论:Linux + NVIDIA GPU 用 vLLM/SGLang;macOS / 无 GPU 用 llama.cpp(GGUF 权重,CPU 或 Metal 加速)。这不是配置问题,而是引擎的平台边界。
4. 应用架构:把引擎包装成一个可维护的服务
4.1 总体架构
读图要点 :全链路没有任何指向外部服务的箭头,所有权重都在本地运行时里加载------这是约束③在架构上的体现。
4.2 请求全链路时序
以一次翻译请求为例,看清楚"命中缓存"和"未命中"两条路径:
4.3 引擎抽象:一个接口,三种实现
同一接口下,三者的内部世界完全不同(见 app/engine/):
| 维度 | VLLMEngine | SGLangEngine | LlamaCppEngine |
|---|---|---|---|
| 加载 | vllm.LLM |
sgl.Engine |
llama_cpp.Llama |
| 批量 | 原生 generate(list) |
逐个 | 逐个(无 Continuous Batching) |
| 流式 | 离线接口不支持,分块下发兼容 | 原生 stream=True |
stream=True 生成器 |
| 结构化输出 | guided_json |
json_schema |
response_format=json_object |
| 前缀缓存 | enable_prefix_caching=True |
RadixAttention(默认) | 内置 prompt cache |
4.4 部署形态
本工程只落地形态 A :网关与引擎同进程,零网络跳转、延迟最低。形态 B 需要引入一个内网 HTTP 客户端,与约束③"服务端自己加载并运行模型"的取舍冲突,因此刻意不实现(详见 §8.2)。
4.5 目录结构与分层规则
bash
app/
config.py # 配置(pydantic)
main.py # FastAPI 入口:lifespan 装配 + 中间件
api/ # HTTP 接口(业务 + OpenAI 兼容)
engine/ # base(接口) + vllm/sglang/llamacpp(实现) + factory(装配)
translation/ # prompt / glossary / postprocess / service
infra/ # cache / batching / ratelimit / logging / metrics
configs/ # model.yaml / serve.yaml / prompts / glossary
scripts/ # download_model / start_gateway / benchmark
docker/ # Dockerfile.gateway / docker-compose.yml
分层规则(单向依赖):
markdown
api ──▶ translation ──▶ engine ──▶ (vllm / sglang / llamacpp)
│
└──▶ infra
- 业务层只依赖
InferenceEngine抽象,不直接import vllm; engine层不感知 HTTP / Schema,因此可被替换、可被测试;infra被编排层依赖,反向不成立。
5. 服务层实现与关键优化
本章按「先摆问题 → 再给方案 → 落到核心代码」组织:每个小节都从一个真实的工程问题切入,答案都对应仓库中的具体文件。
5.1 问题:引擎没装、或加载失败,服务就起不来吗?
期望 :没装 vLLM 的机器上代码仍可读、可审、可测;装了但加载失败时,错误要尽早、清晰地暴露。
方案与落地:
- 延迟导入 :三个引擎都在
load()内部才import(见 §3.6.5),因此import app永远成功; - chat 模板兜底 :
render_chat()优先用 tokenizer 的apply_chat_template(与训练格式一致),tokenizer 不可用时退化为 Qwen 风格模板; - 错误语义 :
EngineError由路由层统一转 503------这是"依赖不可用",不是"请求错误",客户端据此决定是否重试。
python
# app/engine/base.py ------ 模板缺失也不至于让服务不可用
apply = getattr(tokenizer, "apply_chat_template", None)
if callable(apply):
return apply(messages, tokenize=False, add_generation_prompt=True)
# 兜底:按 Qwen 风格手工拼接
结论 :引擎可以换、可以不装、也可以加载失败,但接口契约与错误语义始终稳定。
5.2 问题:改了 Prompt 或术语表,为什么译文还是旧的?
这是缓存类系统最经典的坑:命中了"过期"结果。
根因:译文不只取决于原文,还取决于 Prompt 模板与术语表。若缓存键只含原文,改配置后只能手动清缓存。
方案:把"影响结果的全部因素"编进键里。
python
# app/infra/cache.py
def cache_key(src, src_lang, tgt_lang, domain, glossary_version="", prompt_version="v1"):
raw = f"{src}|{src_lang}|{tgt_lang}|{domain}|{glossary_version}|{prompt_version}"
return hashlib.sha1(raw.encode("utf-8")).hexdigest()
glossary_version 是术语表内容的哈希、prompt_version 是模板语义版本------任一变更,缓存自动失效,无需人工介入。
顺带澄清一个高频混淆 :业务两级缓存与引擎的前缀缓存不是一回事:
| 维度 | 引擎前缀缓存(§3.4) | 业务两级缓存(infra/cache.py) |
|---|---|---|
| 缓存对象 | KV 张量(中间计算) | 最终译文(业务结果) |
| 存储位置 | GPU 显存,引擎管理 | L1 进程内 LRU / L2 Redis |
| 命中效果 | 省掉 Prefill 计算 | 连引擎都不用进 |
| 何时失效 | 前缀不再逐字节相同 | 键中任一维度变化 |
两者串行互补:先查结果缓存;未命中才进引擎,此时前缀缓存再帮一次。
降级设计(同一问题的一体两面):
python
# app/infra/cache.py ------ Redis 不可用则退化为纯 L1,服务照常
try:
client = redis.Redis.from_url(url, decode_responses=True)
client.ping()
self._redis = client
except Exception:
logger.warning("L2 Redis 不可用,降级为仅 L1")
self._redis = None
5.3 问题:Prompt 怎么写,才能让引擎的前缀缓存真正生效?
背景 :§3.4 已说明前缀缓存的收益,但它是被业务层的 Prompt 结构决定的------写法不对,命中率会很低。
方案:固定部分在前、可变部分在后,并让模板可版本化。
jinja
{# configs/prompts/zh2en.jinja #}
You are a professional translator. Translate the user's text from Chinese to English.
Rules:
1. Output ONLY the translation, ...
2. Preserve numbers, units, code, ...
3. Domain: {{ domain }}.
{%- if terms %}
4. You MUST use the following term mappings exactly:
{%- for t in terms %}
- "{{ t.src }}" => "{{ t.tgt }}"
{%- endfor %}
{%- endif %}
三条对应关系:
| Prompt 设计 | 对应机制 | 收益 |
|---|---|---|
| 角色 + 规则放最前且不变 | 前缀逐字节稳定 | 前缀缓存可命中 |
| 术语放最后 | 只破坏尾部 | 前面前缀仍命中 |
PROMPT_VERSION 参与缓存键 |
模板变更可追踪 | 旧缓存自动失效 |
反例 :把术语表(每次不同)放在最前,等于每次都换一个前缀------前缀缓存直接失效,吞吐断崖。若术语表确实很大,应按领域拆分模板,让同领域请求共享前缀。
5.4 问题:术语必须"译对",但模型不听话怎么办?
四种策略与取舍:
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| Prompt 注入 | 术语对照写进 System | 简单、零训练、可解释 | 术语多时占上下文、侵蚀前缀 |
| 受约束解码 | FSM 限制输出词表 | 强约束 | 实现复杂、影响吞吐 |
| 后处理替换 | 生成后字符串替换 | 兜底可靠 | 可能破坏语法 |
| 组合(本工程) | Prompt 注入 + 后处理校验 | 平衡 | --- |
核心代码------两个细节决定"注入"是否准确:
python
# app/translation/glossary.py
def match(self, text: str, domain: str = "general") -> list[Term]:
hits = []
for term in self._terms:
if term.domain not in (domain, "general"): # ① 域过滤
continue
found = term.src in text if term.case_sensitive else term.src.lower() in text.lower()
if found:
hits.append(term)
hits.sort(key=lambda t: len(t.src), reverse=True) # ② 长词优先
return hits
- 长词优先 :
machine learning必须先于machine命中,否则会出现"机器 learning"这类半截替换; - 域过滤 :
general术语全局生效,其余按domain生效,避免"法律术语污染通用翻译"。
兜底一侧在 postprocess.check_terms():若源文命中术语而译文没有对应译法,就写入 warnings------不静默通过。
5.5 问题:请求稀疏时 GPU 利用率上不去,怎么办?
两种"批"的机制,各管一段:
| 机制 | 位置 | 适用 | 说明 |
|---|---|---|---|
| 引擎原生 Continuous Batching | vLLM / SGLang 内部 | 在线高并发 | 无需业务层干预,最有效(见 §3.3) |
MicroBatcher 微批聚合 |
业务层 app/infra/batching.py |
离线 / 文档翻译 / llama.cpp | 把短窗口内的单请求合成一批,一次 generate_batch |
核心代码------关键是"等待窗口":
python
# app/infra/batching.py
async def _run(self):
first = await self._queue.get()
batch = [first]
deadline = time.monotonic() + self.max_wait_s
while len(batch) < self.max_batch_size: # ① 攒够批
remaining = deadline - time.monotonic()
if remaining <= 0:
break # ② 超时即发
try:
batch.append(await asyncio.wait_for(self._queue.get(), timeout=remaining))
except asyncio.TimeoutError:
break
await self._dispatch(batch) # ③ 一次 generate_batch
async def _dispatch(self, batch):
results = await asyncio.to_thread(self.engine.generate_batch, [p.req for p in batch])
边界要说清 :MicroBatcher 不是 在替代引擎的 Continuous Batching,而是给"自身没有连续批能力(如 llama.cpp)"或"请求稀疏"的场景补位。vLLM / SGLang 在线高并发时应关闭,否则白增延迟。
并发保护有两层:路由层令牌桶限流 (超限 429)+ 全局信号量 (max_concurrency,形成背压)。令牌桶实现很轻:
python
# app/infra/ratelimit.py
self._tokens = min(self.burst, self._tokens + elapsed * self.qps) # 按时间补充
if self._tokens >= amount:
self._tokens -= amount
return True
return False # 触发 429
5.6 问题:FastAPI 是异步的,引擎的 generate() 却是阻塞的
症状 :直接在协程里调用同步引擎,会卡死整个事件循环 ------其他请求(包括 /healthz)全部排队,表现为"一个慢请求拖垮全站"。
方案:把阻塞调用卸载到线程池。
python
# app/translation/service.py
async def _generate(self, req):
if self.batcher is not None and self.settings.serve.batching.enabled:
return await self.batcher.submit(req) # 走微批
return await asyncio.to_thread(self.engine.generate, req) # 否则卸载到线程
为什么是线程而不是进程:引擎调用期间 CPU 主要在等 GPU,属 IO 密集,GIL 处于释放状态,线程池足够;同时避免了跨进程传递模型句柄的复杂度。
5.7 问题:长文本翻译,用户要等十几秒才看到结果
SSE 原理(三句话讲清)
- 是什么 :Server-Sent Events,基于 HTTP 的一条长连接 做单向 (服务端 → 客户端)持续推送;响应头
Content-Type: text/event-stream,连接不立即关闭。 - 怎么传 :响应体按"事件"分块下发,每条事件形如
data: <内容>加一个空行 结尾(空行 = 一个事件结束;还可带event:/id:/retry:字段)。HTTP/1.1 走 chunked transfer,服务端每产出一段就 flush,客户端边收边处理。 - 为什么有效 :把"全部生成完再一次性返回"变成"生成一点、推一点 ",直接改善首字节时间(TTFB) ------总耗时不变,感知延迟大幅下降。这正是 §3.1 里 Decode 阶段逐 token 输出的天然形态。
和 WebSocket 的区别 :SSE 是单向 、基于普通 HTTP、无需协议升级 、支持自动重连(retry);本场景只需"服务端推、客户端收",SSE 更轻。
一个容易踩的坑 :反向代理(如 Nginx)默认会缓冲 响应,导致"伪流式"------所有分块被攒着一起下发。需在代理层关闭缓冲(
proxy_buffering off;)并禁用缓存。
方案:SSE 流式,边生成边返回。本工程提供两个语义不同的入口:
- 业务流式
/v1/translate/stream:输出data: {"delta": "..."}; - OpenAI 兼容流式
/v1/chat/completions(stream=true):按 OpenAI chunk 格式输出chat.completion.chunk,以data: [DONE]收尾。
python
# app/api/routes_openai.py ------ 与 OpenAI 协议对齐的增量帧
async for delta in engine.stream(gen_req):
yield _sse({
"object": "chat.completion.chunk",
"choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}],
})
能力差异如实暴露(这是抽象层的诚实):
| 引擎 | 流式能力 |
|---|---|
| SGLang | 原生 token 级(stream=True) |
| llama.cpp | 原生 token 级生成器 |
vLLM(离线 LLM) |
不支持 → 以"整段生成后分块下发"提供协议兼容 ;要真正 token 级需改用 AsyncLLMEngine |
5.8 问题:可选依赖缺失(没装 Prometheus / Redis 挂了),服务该崩吗?
设计取向 :可选依赖缺失不应让服务起不来。三处降级一一对应:
| 组件缺失 | 行为 | 代码 |
|---|---|---|
prometheus_client 未安装 |
指标退化为 no-op,/metrics 仍可访问 |
app/infra/metrics.py |
| Redis 不可用 | 二级缓存退化为纯进程内 L1 | app/infra/cache.py |
| 引擎未安装 / 加载失败 | EngineError → 路由返回 503 |
app/main.py |
python
# app/infra/metrics.py ------ 未装库时用 no-op 顶替,调用点无需改动
if _HAS_PROM:
self.requests = Counter("translation_requests_total", "...", ["endpoint", "status"])
else:
self.requests = _Noop() # .labels() / .inc() / .observe() 均为空操作
可观测的最后一环:trace_id 贯穿全链路。
python
# app/infra/logging.py
trace_id_var: ContextVar[str] = ContextVar("trace_id", default="-")
# app/main.py 的中间件:每个请求生成 trace_id,写入日志与响应头 X-Request-Id
这样一次请求的"接入 → 缓存 → 引擎 → 后处理"在 Loki/ELK 里可按 trace_id 一键串联------排障从"翻日志"变成"搜一个 ID"。
6. 性能调优实战
6.1 吞吐 vs 延迟:调哪些旋钮
| 旋钮 | 影响 | 建议 |
|---|---|---|
gpu_memory_utilization |
越大 → KV Cache 越多 → 并发越高 | 0.85~0.92,留出碎片余量 |
max_model_len |
越短 → KV 越省 → 并发越高 | 按真实最长输入设置,勿贪大 |
| 量化档位 | 越低 → 权重越小 → 显存越多给 KV | 消费卡 AWQ 4bit;数据中心 FP8 |
| 批处理 | 批越大 → 吞吐越高、单请求延迟越高 | 在线靠引擎调度,离线靠 MicroBatcher |
| 前缀缓存 | 命中越高 → 首 token 越快 | 见 §6.3 |
6.2 显存估算:先算账,再买卡
以 1.5B 模型、max_model_len=4096、batch=16 为例:
markdown
权重(AWQ 4bit) ≈ 0.8 GB
KV Cache ≈ 2 × 28层 × 2(KV) × 1536隐藏维 × 4096长 × 16批 × 2字节 ≈ 11.5 GB
激活/框架开销 ≈ 0.5 GB
------------------------------------------------------------
合计 ≈ 12.8 GB → 单张 24 GB 卡余量充足;8 GB 卡需减小 batch/max_len
关键洞察 :小模型下,KV Cache 往往比权重更吃显存 。因此"调 max_model_len 和并发"比"换更小的量化"更立竿见影。
6.3 缓存命中率提升清单
- 稳定 System 前缀(§5.3)------最高优先级;
- 术语后置,避免高频变化的术语破坏前缀;
- 按领域拆分模板,让同领域请求共享更长的公共前缀;
- 业务层两级缓存------相同原文直接返回,连引擎都不进(见效最快);
- 规整输入------空白、标点的轻微差异会造成缓存键不命中。
6.4 压测与指标解读
bash
python scripts/benchmark.py --requests 200 --concurrency 16
关注四个数:吞吐(req/s) 、P50/P95/P99 延迟 、缓存命中率 、GPU 显存占用。经验判断法:
- 吞吐上不去、GPU 利用率低 → 看批处理是否被浪费(并发不足或
max_model_len过大); - P95 远高于 P50 → 队列积压,考虑限流或扩容;
- 缓存命中率低 → 回到 §6.3 检查 Prompt 稳定性。
7. 落地:配置、启动与容器化
7.1 配置体系:三层覆盖
| 层 | 文件 | 作用 |
|---|---|---|
| 模型层 | configs/model.yaml |
engine / model_path / quantization / n_gpu_layers / max_model_len |
| 服务层 | configs/serve.yaml |
端口 / 并发 / 缓存 / 限流 / 批处理 |
| 环境变量 | TS_ENGINE / TS_MODEL_PATH / TS_N_GPU_LAYERS / TS_L2_REDIS ... |
容器化覆盖,12-factor |
一个配置示例(vLLM + AWQ 与 llama.cpp 两种写法):
yaml
# 方案一:Linux + NVIDIA
engine: vllm
model_path: Qwen/Qwen2.5-1.5B-Instruct-AWQ
quantization: awq
# 方案二:macOS / 无 GPU
# engine: llamacpp
# model_path: ~/.cache/huggingface/hub/.../qwen2.5-1.5b-instruct-q4_k_m.gguf
# n_gpu_layers: -1 # Metal 全层加速;纯 CPU 用 0
7.2 启动与生命周期
三种启动方式行为一致:uvicorn app.main:app / python -m app.main / python app/main.py(后者在 app/main.py 顶部做了包路径引导,使脚本方式也能正确解析相对导入)。
启动时 lifespan 的装配顺序:
lua
读配置 → build_engine → engine.load()(加载权重)→ 缓存/指标/限流/微批 → 起服务 → yield → 关闭时释放
engine.load() 放在启动阶段而非首次请求,是刻意的:把"权重加载失败"这种致命问题暴露在启动时刻(fail-fast),而不是让第一个用户吃掉一次 503。
7.3 容器化与离线
docker/docker-compose.yml 编排为 app(GPU)+ redis:
- 应用镜像基于
vllm/vllm-openai,自带 CUDA 运行时; - 模型权重以只读卷挂载,不进镜像(镜像小、更新快);
- 复用宿主机的 HF 缓存,并设置
HF_HUB_OFFLINE=1强制离线------即使有权重路径写错,也不会偷偷去外网拉取。
8. 优势、局限与展望
8.1 三引擎的优劣
| 引擎 | 优势 | 局限 | 适用 |
|---|---|---|---|
| vLLM | 生态最全、PagedAttention + Continuous Batching、量化与 LoRA 支持最广、文档好 | 仅 Linux+CUDA;离线接口不支持 token 级流式 | 生产在线服务(默认) |
| SGLang | RadixAttention 前缀命中率高、压缩 FSM 结构化输出快、原生流式 | 生态较新、部分边缘模型适配滞后 | 翻译等高前缀复用场景 |
| llama.cpp | 跨平台(含 macOS)、CPU 可用、GGUF 部署极简、启动快 | 无 PagedAttention / Continuous Batching,吞吐低;不适合高并发 | 开发机、边缘、低配部署 |
8.2 局限与风险
| 风险 | 说明 | 缓解 |
|---|---|---|
| 1.5B 长难句质量不足 | 小模型在复杂句上弱于 7B | 按长度分级路由到 7B 实例 |
| 量化掉点 | 4bit 可能影响质量 | 用可信来源权重 + 质量回归;敏感链路保 FP16 |
| 前缀缓存命中率低 | 吞吐不及预期 | System 模板稳定化、术语后置、按领域拆模板 |
| 资源争抢 | 延迟抖动 | 引擎独占卡;网关与引擎分离部署(形态 B) |
| 引擎与权重不匹配 | 启动失败 | 遵循 §2.3 的产物对照;HF_HUB_OFFLINE=1 提前暴露 |
| 平台限制 | macOS 跑不了 vLLM | 改用 llama.cpp;生产部署到 Linux GPU |
关于形态 B(引擎独立服务) :它能让网关水平扩展,但需要引入一个指向内网推理实例的 HTTP 客户端。本工程为保持"服务端自己加载并运行模型"这一约束的一致性,当前不实现;若后续确有网关多副本诉求,应以"仅允许内网地址"的强校验方式引入,并在文档中明确边界。
8.3 演进路线
| 阶段 | 内容 |
|---|---|
| M1 | 单机进程内 vLLM + /v1/translate + 术语 + 缓存 |
| M2 | 接入 SGLang 与 llama.cpp(跨平台 GGUF),补齐 OpenAI 兼容接口与流式 |
| M3 | K8s 化、多实例网关、Prometheus/Grafana 全量观测 |
| M4 | LoRA 领域微调(法律/医疗术语)、结构化输出增强、按长度/领域路由分级(1.5B/7B) |
8.4 可迁移方法论:学会"换一个模型"
这套方法并不绑定翻译。换任务/换模型时,只需改配置与模板,架构不动:
| 想换什么 | 改哪里 | 备注 |
|---|---|---|
| 换模型(同引擎) | model.yaml 的 model_path(+quantization) |
下载新权重即可 |
| 换引擎(同模型) | model.yaml 的 engine |
注意权重产物必须匹配(§2.3) |
| 换语言方向 | configs/prompts/ 下新增 xx2yy.jinja |
模板可插拔 |
| 换术语表 | configs/glossary.yaml |
版本变更自动使缓存失效 |
| 换并发/限流 | serve.yaml |
无需改代码 |
| 换任务(问答/摘要/代码) | Prompt 模板 + 术语表 | 引擎与编排层完全复用 |
一句话总结 :引擎决定权重格式,配置决定部署方式。把"推理发生在本地"当成架构约束而不是部署习惯,服务才既能跑得动,也能长期维护。
9. 本文的劣势与优化点
本文以"翻译样例 + 单个工程"为边界。下面列出已知的不足 与可优化方向,按"影响面 × 改进成本"排序。
9.1 工程实现层面
| # | 不足 | 影响 | 优化方向 |
|---|---|---|---|
| 1 | 仅落地形态 A(进程内嵌) | 网关无法水平扩展;引擎与网关同生共死 | 以"仅允许内网地址"的强校验引入形态 B(引擎独立服务),网关多副本 |
| 2 | vLLM 流式为"分块兼容"而非 token 级 | 首字延迟观感一般 | 切换 AsyncLLMEngine,或对 SSE 场景改用 SGLang / llama.cpp 原生流式 |
| 3 | llama.cpp 无 Continuous Batching | 高并发吞吐低 | 只用于低并发/边缘;或改用其 server 的多序列模式 |
| 4 | 量化后无质量回归 | 4bit 可能掉点而无人发现 | 把 BLEU/COMET 回归纳入 CI,按量化档位设阈值 |
| 5 | 术语以 Prompt 注入为主 | 术语多时占上下文、侵蚀前缀缓存 | 术语量大时引入受约束解码,或按领域拆分模板 |
| 6 | 缓存容量凭经验设置 | L1 命中率可能不佳 | 基于真实流量做"命中率-容量"曲线,按 P95 收益定容量 |
| 7 | 单模型单实例 | 无法按长度/领域分级 | 增加路由层:短句→1.5B、长难句→7B |
| 8 | 无鉴权与租户隔离 | 内网可用,多租户不安全 | 接入 API Key / JWT,按租户限流与配额 |
| 9 | 未采集引擎内部指标 | 看不到 KV 利用率与排队深度 | 打通 vLLM/SGLang 的 /metrics 到统一 Prometheus |
9.2 架构与设计层面
| # | 不足 | 说明 | 优化方向 |
|---|---|---|---|
| 1 | 配置驱动但无热更新 | 改 model.yaml 需重启 |
配置热加载,或滚动重启 + 就绪探针 |
| 2 | MicroBatcher 与引擎批处理职责重叠 |
何时开启靠人工判断 | 用压测数据给出判据,或按引擎能力自动开关 |
| 3 | 异常语义较粗 | 统一 503,缺细分错误码 | 区分"未加载 / 超时 / 显存不足",返回可操作提示 |
| 4 | 缺少超时与熔断阈值调优 | 慢请求可能拖垮队列 | 超时 + 熔断 + 队列上限,并用压测验证 |
| 5 | Prompt 模板无自动校验 | 改模板可能悄悄破坏前缀稳定性 | 加单测:断言"术语为空时 System 文本逐字节不变" |
9.3 选型与场景层面
| # | 不足 | 影响 | 优化方向 |
|---|---|---|---|
| 1 | 1.5B 在长难句/专业领域偏弱 | 译文质量天花板 | 7B 实例,或 LoRA 领域微调 |
| 2 | 平台碎片化(vLLM 要 Linux+CUDA) | 开发机与生产机环境不一致 | 统一容器交付;开发机用 llama.cpp 只做功能验证 |
| 3 | 未覆盖文档级 / 多模态翻译 | 场景受限 | 文档先分句再批量;后续接版面分析 |
| 4 | 评测样本有限 | 结论泛化性不足 | 建领域测试集,分领域评测 |
9.4 如果重来一次,最该先做的三件事
- 先建评测基线(BLEU/COMET + 领域样例集)------没有基线,量化和换引擎都只能凭感觉;
- 把"前缀稳定性"写成单测------它是吞吐收益的源头,也最容易被一次模板改动悄悄破坏;
- 一开始就按"配置驱动 + 引擎抽象"设计------否则换引擎会退化成重写。
欢迎大家一键3连,也可私聊获取上述demo代码~