C16 · AI 服务生产栈 Python 侧
本篇把 Python 从「科研脚本」升级为「AI 推理生产服务」:栈分工定边界、推理服务化定运行时、批处理与流水线定吞吐、显存与并发定容量、跨语言通信定稳定、可观测/版本/缓存/成本/安全定运营。Python 不是 JVM 的替代品,是 AI 推理外挂。
骨架见 核心方法论 · 开篇。本篇只补 AI 生产栈细枝,框在 核心方法论 · 开篇。
Python 生产栈主线是「栈分工定边界 → 推理服务化定运行时 → 批处理/流水线定吞吐 → 显存/并发定容量 → 跨语言通信定稳定 → 可观测/版本/缓存/成本/安全定运营」。
- 分工与边界:推理/特征放 Python,治理/事务留 Java(C16.01/09)。
- 服务化运行时:FastAPI/vLLM/Triton 按吞吐与多模型治理选型(C16.02)。
- 吞吐与容量:动态批处理、流水线编排、显存与多进程是命脉(C16.03/04/05/08)。
- 协同与稳定:异步并发、跨语言通信、可观测性构成可靠链路(C16.07/09/10)。
- 运营闭环:版本灰度、缓存降级、成本限流、安全供应链(C16.11/12/13/14)。
- Python 是 AI 推理外挂,不是重写中台的借口;边界用 IDL 契约化。
- 吞吐靠批处理与流水线重叠,不在「并发连接数」。
- 显存是稀缺资源,要像 CPU 一样做容量规划与配额隔离。
- GIL 决定并行单位是进程;多 worker×多卡是可预测的生产拓扑。
- 跨语言链路的护城河是超时、熔断、降级、trace。
- 模型发布是带业务指标的灰度实验,不是重启换权重。
- 成本是 AI 服务的第一 SRE 指标,token 要配额化、归因化。
- 栈是权衡:选 Python 还是 Java,看生态成熟度与伸缩模型,而非语言偏好。
- 吞吐靠批不靠并发:动态批处理把 GPU 利用率推到极限。
- 显存即容量:KV 缓存与并发配额决定能扛多少流量。
- 服务化是工程:健康探活、指标、降级比模型本身更决定稳定性。
- 运营即生产:版本、缓存、成本、安全把 demo 变成系统。
考察你是否清楚「什么时候该用 Python、什么时候必须回到 Java/JVM」——而非把 Python 当银弹。核心是:AI 推理、数据处理、科研原型天然适合 Python;高并发网关、事务一致性、既有中台仍由 Java 主导;二者是协同而非替代。
Python 在 AI 生产栈的角色经历过四段跃迁:① 科研脚本期(2010s,NumPy/PyTorch 单机实验,无服务化);② Web 封装期(Flask/Django 把模型包成 HTTP,但同步阻塞、无批处理);③ 异步服务期(2020 FastAPI + asyncio,承接高并发 IO);④ AI 专用运行时期(2023 vLLM/Triton/RAY Serve 出现,推理与编排解耦,连续批处理与多模型治理成为一等公民)。关键转折点是「模型即服务(MaaS)」——Python 从「写模型的工具」变为「跑模型的运行时」,与 Java 中台形成外挂式协作。
栈分工原则:① 计算密集且生态在 Python 侧(PyTorch、transformers、vLLM)的推理与特征工程放 Python 服务;② 需要稳定连接池、分布式事务、服务治理的接入层与业务编排留 Java;③ 二者通过 gRPC/HTTP 边界解耦,各自独立伸缩。
- 在 Python 服务里只放「模型 + 轻量预处理」,把鉴权、限流、计费交给 Java 网关。
- 用统一的 IDL(Protobuf)约束 Python↔Java 接口,避免散落的 JSON 约定漂移。
- 明确「什么不能放 Python」:长事务、强一致写、重 CPU 的非模型逻辑优先 Java。
- 原型期允许 Python 单体,但生产期必须切到「模型服务 + 独立网关」两点架构。
Java 侧按契约调用 Python 推理服务,边界只认 DTO 与超时,不感知 Python 运行时
// Java / Spring Boot 3:Python 服务只暴露 HTTP 契约,Java 侧不感知其运行时
@RestController
public class InferenceController {
private final RestClient python;
public InferenceController(RestClient.Builder b,
@Value("${inference.python.base-url}") String url) {
this.python = b.baseUrl(url)
.requestFactory(new SimpleClientHttpRequestFactory() {{
setConnectTimeout(Duration.ofMillis(300));
setReadTimeout(Duration.ofSeconds(8));
}})
.build();
}
@PostMapping("/v1/ask")
public Answer ask(@RequestBody AskRequest req) {
return python.post().uri("/v1/generate")
.body(new GenerateRequest(req.question(), req.traceId()))
.retrieve()
.onStatus(s -> s.is5xxServerError(), (rq, rs) -> {
throw new InferenceUnavailableException(rs.getStatusCode());
})
.body(Answer.class);
}
}
public record GenerateRequest(String question, String traceId) {}
public record Answer(String text, int promptTokens, long latencyMs) {}
Python 侧把「模型 + 业务胶水」收在同一进程入口,健康检查与指标是契约的一部分
# Python:最小服务入口(依赖见 C16.02)
from fastapi import FastAPI
from pydantic import BaseModel, Field
app = FastAPI(title="inference-python")
class GenerateRequest(BaseModel):
question: str = Field(min_length=1, max_length=4096)
trace_id: str | None = None
class Answer(BaseModel):
text: str
prompt_tokens: int
latency_ms: int
@app.get("/healthz")
def healthz() -> dict[str, str]:
return {"status": "ok", "runtime": "cpython"}
@app.post("/v1/generate", response_model=Answer)
def generate(req: GenerateRequest) -> Answer:
# 真实实现委托给推理引擎;此处保留契约与计时边界
return Answer(text="", prompt_tokens=0, latency_ms=0)
栈选型不是语言偏好,是「团队能力 × 生态成熟度 × 伸缩模型」的权衡。把 Python 当成 JVM 生态的推理外挂,而非重写整个中台,规模化落地阻力最小。
- 把 Python 当银弹重写中台:事务、强一致写、长流程编排应留在 Java/JVM,Python 只做推理与特征外挂,否则在 GIL 与生态上双重吃亏。
- 在 Python 侧做长事务 / 强一致写:缺乏成熟的分布式事务与连接池治理体系,容易把弱一致 bug 埋进生产。
- 边界用散落 JSON 约定而非 IDL:Python↔Java 接口靠口头/文档约定,字段漂移无人发现,跨语言调用变成排查黑洞。
- 原型单体直接上生产:允许原型期 Python 单体,但生产必须切「模型服务 + 独立网关」两点架构,否则鉴权/限流/计费无处安放。
- 用 Python 扛起高并发网关:GIL 决定了它不擅长海量同步连接,网关/接入层交给 Java 才稳。
考察你是否理解「裸模型」与「生产推理服务」之间的工程鸿沟。核心是:模型权重本身不可直接上线,需要服务框架承担批处理、并发、健康探活、指标暴露,并针对场景选对运行时。
裸模型到生产推理服务的演进:① 自写封装(FastAPI 包 transformers,无批处理、无指标、无健康探活);② 通用 Web 框架(Flask/FastAPI 异步,易上手但吞吐受限于 Python 层);③ 专用推理服务器(NVIDIA Triton 支持 TF/PyTorch/ONNX 多框架 + 动态批 + 多模型仓库);④ 高吞吐 LLM 引擎(vLLM 以 PagedAttention + 连续批处理把 GPU 利用率推到极限);⑤ 推理编排网关(RAY Serve / 各家 MaaS,承担多模型路由、版本灰度、自动扩缩)。选型主线是「吞吐需求与多模型治理复杂度」逐级上升。
运行时分层:① FastAPI 适合轻量封装、快速暴露 HTTP 接口、做预处理/后处理编排;② vLLM 主打高吞吐 LLM 推理(PagedAttention、连续批处理);③ Triton 适合多框架(TF/PyTorch/ONNX)统一推理、动态批与多模型编排。选型看「是否需要极致吞吐 + 多模型治理」。
- LLM 对话/生成类直接上 vLLM,吞吐与显存利用率远胜自写 FastAPI+transformers。
- FastAPI 更适合「模型 + 业务胶水」型服务;不要把 FastAPI 当推理引擎裸跑大模型。
- 所有推理服务统一暴露 /healthz、/metrics,并接入网关探活与熔断。
- 用 Triton 当多模型推理网关时,模型仓库与版本用配置中心管理,杜绝硬编码路径。
requirements.txt:版本钉死,镜像与本地用同一份依赖清单
fastapi==0.115.6
uvicorn[standard]==0.34.0
pydantic==2.10.4
vllm==0.6.6.post1
prometheus-client==0.21.1
opentelemetry-sdk==1.29.0
opentelemetry-exporter-otlp==1.29.0
httpx==0.28.1
pytest==8.3.4
FastAPI 服务:/v1/generate、/healthz、/metrics 三端点 + 启动命令
# app.py
import time, os
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
from fastapi.responses import Response
app = FastAPI()
REQS = Counter("infer_requests_total", "requests", ["model", "status"])
LAT = Histogram("infer_latency_seconds", "latency", buckets=(.1, .5, 1, 2, 5))
class Req(BaseModel):
prompt: str = Field(min_length=1, max_length=8192)
max_tokens: int = Field(default=256, ge=1, le=4096)
class Resp(BaseModel):
text: str
prompt_tokens: int
latency_ms: int
# vLLM 引擎在进程启动时加载一次,避免每请求重复加载权重
from vllm import LLM, SamplingParams
engine = LLM(model=os.environ["MODEL_PATH"],
gpu_memory_utilization=0.90,
max_model_len=8192,
max_num_seqs=64)
@app.get("/healthz")
def healthz():
return {"status": "ok", "model": os.environ["MODEL_PATH"]}
@app.get("/metrics")
def metrics():
return Response(generate_latest(), media_type=CONTENT_TYPE_LATEST)
@app.post("/v1/generate", response_model=Resp)
def generate(req: Req):
t0 = time.perf_counter()
try:
out = engine.generate([req.prompt],
SamplingParams(max_tokens=req.max_tokens, temperature=0.2))
REQS.labels("vllm", "ok").inc()
return Resp(text=out[0].outputs[0].text,
prompt_tokens=len(out[0].prompt_token_ids),
latency_ms=int((time.perf_counter() - t0) * 1000))
except Exception:
REQS.labels("vllm", "error").inc()
raise HTTPException(status_code=503, detail="inference failed")
finally:
LAT.observe(time.perf_counter() - t0)
# 启动:uvicorn app:app --host 0.0.0.0 --port 8000 --workers 1
# vLLM 自带连续批处理,务必单 worker;多 worker 会重复占用显存
Dockerfile 与 pytest:把「示意级」补成可复现闭环
# Dockerfile
FROM nvidia/cuda:12.4.1-runtime-ubuntu22.04
WORKDIR /srv
RUN apt-get update && apt-get install -y --no-install-recommends python3.11 python3-pip \
&& rm -rf /var/lib/apt/lists/*
COPY requirements.txt .
RUN pip3 install --no-cache-dir -r requirements.txt
COPY app.py .
ENV MODEL_PATH=/models/qwen2.5-7b-instruct
EXPOSE 8000
HEALTHCHECK --interval=15s --timeout=3s --retries=3 \
CMD python3 -c "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/healthz')"
CMD ["python3", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
Python 侧契约测试:无 GPU 环境用替身加载,验证端点与结构化返回
# test_app.py —— CI 无 GPU,导入前用替身替换推理引擎
import sys, types
from fastapi.testclient import TestClient
class FakeOut:
prompt_token_ids = [1, 2, 3]
outputs = [type("O", (), {"text": "ok"})()]
fake = types.ModuleType("vllm")
fake.LLM = lambda *a, **k: types.SimpleNamespace(generate=lambda *a, **k: [FakeOut()])
fake.SamplingParams = lambda **k: k
sys.modules["vllm"] = fake
from app import app # 此时 LLM(...) 不会真加载权重
client = TestClient(app)
def test_healthz():
assert client.get("/healthz").json()["status"] == "ok"
def test_generate_contract():
r = client.post("/v1/generate", json={"prompt": "hi", "max_tokens": 8})
assert r.status_code == 200
body = r.json()
assert {"text", "prompt_tokens", "latency_ms"} <= body.keys()
assert body["text"] == "ok"
# 运行:pip install -r requirements.txt && pytest -q test_app.py
服务化不是「包一层 API」,而是把批处理、KV 缓存、显存、并发隔离这些基础设施前置设计。选错运行时会在流量上来后被吞吐与显存双重卡死。
- FastAPI + transformers 裸跑大模型:吞吐差、显存易溢出,还缺连续批处理,流量一上来就被卡死。
- 预处理/后处理全塞推理进程:CPU 密集的 decode/tokenizer 阻塞 GPU 主线程,GPU 利用率虚低。
- 不暴露 /healthz 与 /metrics:网关无法探活与熔断,实例假死仍接流量。
- 模型路径硬编码:换版本要改代码发版,应走模型仓库 + 配置中心动态加载。
- 把 vLLM 当普通 HTTP 服务用:忽略 max_num_seqs / max_num_batched_tokens 等连续批参数,吞吐与延迟都打折扣。
考察你对「显存是稀缺资源」的工程直觉。核心是:模型加载时机、权重精度、多副本显存占用、冷启动与热更新都要可预测,否则一台 80G 卡可能被悄悄打满。
显存管理演进:① 单卡单模型全精度常驻(早期,浪费严重);② 量化压缩(FP16→INT8→INT4,砍权重但不砍 KV 缓存);③ KV 分页(vLLM PagedAttention 把 KV 缓存像虚拟内存一样分页,解耦「并发数」与「显存占用」);④ 显存配额隔离(多模型共享一卡时按配额互不影响);⑤ 优先级抢占与 MIG 切分(把一卡切成多实例,故障半径与资源都可控)。核心是从「能装下」走向「可预算、可隔离、可抢占」。
显存预算公式:常驻显存 ≈ 权重(精度相关) + KV 缓存(并发×序列长) + 激活/临时。量化(FP16→INT8/INT4)可砍权重但不砍 KV 缓存;并发上涨时 KV 缓存才是真正瓶颈。
- 启动即预热:服务起来后先跑一次 dummy 推理,把权重与 CUDA 上下文常驻,避免首请求超时被摘流量。
- 用 nvidia-smi / DCGM 暴露显存与利用率指标,设「显存 >85% 即告警」。
- 多模型共享一张卡时,按显存配额隔离,禁止某模型把整卡吃满拖垮邻居。
- 权重用 mmap/懒加载 + 量化,缩短冷启动;版本切换走双缓冲(新权重加载完再切流量)。
Python:显存预算先算再加载,量化与 gpu_memory_utilization 是硬闸门
# 显存粗算:权重 + KV Cache + 激活 + 框架预留
def estimate_weights_gb(params_b: float, dtype_bytes: int = 2) -> float:
return params_b * dtype_bytes # 7B FP16 约 14 GB
def kv_cache_gb(layers: int, heads: int, head_dim: int, seq_len: int,
batch: int, dtype_bytes: int = 2) -> float:
# 2 = K + V
return 2 * layers * heads * head_dim * seq_len * batch * dtype_bytes / 1024**3
if __name__ == "__main__":
w = estimate_weights_gb(7.0)
kv = kv_cache_gb(layers=28, heads=28, head_dim=128, seq_len=8192, batch=64)
print(f"weights={w:.1f}GB kv={kv:.1f}GB 需显存 >= {w + kv + 2:.1f}GB")
# 加载闸门:AWQ/GPTQ 量化把权重压到 4bit,并限制 KV 占用
from vllm import LLM
llm = LLM(model="/models/qwen2.5-7b-instruct-awq",
quantization="awq",
gpu_memory_utilization=0.85, # 预留给激活与碎片
max_model_len=4096, # 直接约束 KV 上限
enforce_eager=False,
swap_space=4)
OOM 从来不是「加显存」能根治的,而是「显存预算 + 并发配额 + 优先级抢占」的系统性设计。把显存当 CPU 一样做容量规划,才算 senior。
- 把 OOM 当「加显存」治标:根因往往是显存预算 + 并发配额缺失,加卡只是推迟爆雷。
- 启动不预热:首请求触发权重/ CUDA 上下文加载,超时被网关摘流量,冷启动即雪崩。
- 多模型共享卡不隔离配额:一个模型把整卡吃满,邻居全部被拖垮。
- 版本切换无双缓冲:新权重没加载完就切流量,或加载期间服务不可用。
- 只监控利用率不监控显存占用:显存 >85% 才告警已经来不及,应把显存做一级健康度。
考察你是否知道「推理吞吐靠批不靠并发连接」。核心是:GPU 算一个样本和算一批样本的单步成本接近,动态批处理把时间窗口内的请求聚合成批,是 LLM 服务吞吐的命门。
批处理演进:① 静态批(固定批次大小,凑不满就空等,GPU 浪费);② 动态批(时间窗口内聚合请求,Triton 代表);③ 连续批 / continuous batching(vLLM 按 token 级调度,不同请求在不同阶段进入同一批,显存利用率远高于静态);④ 分块预填充 chunked prefill(把长 prompt 的 prefill 与 decode 解耦,降低首 token 延迟);⑤ 推测解码 / 并行采样(进一步提升吞吐与降本)。主线是「批的粒度从请求级细化到 token 级」。
连续批处理(continuous batching):vLLM 按 token 级调度,不同请求在不同阶段进入同一批,显存利用率远高于静态批。关键参数是 max_num_seqs / max_num_batched_tokens,决定并发与单批上限。
- 按 P99 延迟目标反推批大小,而非盲目拉满;批太大反而拖长首 token 延迟。
- 用动态批的「最大批 token 数」做背压:接近上限时网关侧开始排队/限流。
- 区分在线(低延迟)与离线(高吞吐)流量,走不同批策略甚至不同实例池。
- 监控「批实际大小分布」,长期偏小说明并发不足或请求被过早打散。
Python:连续批处理参数与压测对照,用实测吞吐而不是拍脑袋调参
# vLLM 动态批:三个参数决定「吞吐 / 时延 / 显存」三角
from vllm import LLM, SamplingParams
llm = LLM(model="/models/qwen2.5-7b-instruct",
max_num_seqs=128, # 并发序列上限
max_num_batched_tokens=16384, # 单步可打包的 token 上限
enable_chunked_prefill=True, # 长 prompt 分块,避免阻塞 decode
gpu_memory_utilization=0.90)
import time
prompts = [f"请总结第 {i} 段文档" for i in range(200)]
t0 = time.perf_counter()
outs = llm.generate(prompts, SamplingParams(max_tokens=128, temperature=0))
elapsed_seconds = time.perf_counter() - t0
total_tokens = sum(len(o.outputs[0].token_ids) for o in outs)
print("output tok/s ≈", total_tokens / elapsed_seconds)
# 压测对照:固定并发梯度,观察 p99 与吞吐拐点
# python3 -m vllm.entrypoints.openai.api_server ... &
# hey -z 60s -c 32 -m POST -H 'Content-Type: application/json' \
# -d '{"model":"qwen2.5-7b-instruct","prompt":"hi","max_tokens":64}' \
# http://127.0.0.1:8000/v1/completions
批处理是「吞吐 vs 延迟」的旋钮。会调 batch 参数是 architect,能按 SLA 把吞吐推到硬件极限又不破延迟红线,才是真懂推理服务。
- 盲目拉满 batch:批太大反而拖长首 token 延迟,破 P99 SLA,应按延迟目标反推上限。
- 只按 QPS 限流漏掉长生成:应叠加「并发 + token 速率」双指标,否则长文本把整批拖死。
- 在线/离线流量同一批策略:低延迟在线与高吞吐离线应走不同实例池与批策略。
- 只看 QPS 不看重批实际大小:批长期偏小说明并发不足或请求被过早打散,吞吐没吃满。
- 把批处理当并发连接数:GPU 算一个样本与一批样本单步成本接近,吞吐靠批不靠连接数。
考察你是否把「推理」当成一个流水线而非单点。核心是:tokenizer、图像解码、后处理(解析/过滤/格式化)往往比矩阵乘更占墙钟时间,必须和 GPU 计算重叠。
推理流水线演进:① 同步单点(请求进来,预处理→推理→后处理串行,GPU 大部分时间在等 CPU);② 线程池预处理(numpy/PIL 释放 GIL 的操作移出主线程);③ 异步重叠(asyncio 让预处理、GPU 推理、后处理三段流水线化,互不空等);④ 流式输出 SSE(首块尽早返回,后处理随流进行);⑤ 算子级调度(RAY / LangGraph 把多步 Agent 编排成可观测的 DAG)。主线是「从单点推理到三段重叠的流式流水线」。
重叠执行:预处理在 CPU 线程池做、GPU 推理异步进行、后处理再异步收尾,三者流水线化。用 asyncio / 多进程把阻塞的 numpy/PIL 操作移出事件循环。
- 预处理(解码、归一化、tokenizer)用独立 worker 池,别阻塞推理主线程。
- 后处理(JSON 解析、业务规则、安全过滤)放到推理返回后异步做,缩短 TTFT。
- 对大响应做流式输出(SSE),首块尽早返回,后处理随流进行。
- 用火焰图定位「非 GPU」耗时,多数慢请求瓶颈在预处理/序列化而非模型。
模型快不代表服务快。把流水线各段重叠、把阻塞移出关键路径,往往比换更大模型带来的收益更实在。
- tokenizer/图像解码/后处理放推理主线程:阻塞 GPU,这是多数「模型快服务慢」的真相。
- 后处理同步做拖长 TTFT:JSON 解析、业务规则、安全过滤应异步或随流做。
- 大响应一次性返回:不流式,用户端长时间空白,体感差且易超时。
- 不画火焰图定位非 GPU 瓶颈:多数慢请求卡在预处理/序列化而非矩阵乘,凭感觉优化无效。
- 把流水线当黑盒:各段不重叠、阻塞点不隔离,GPU 利用率与吞吐都上不去。
考察 RAG 体系里 Python 服务承上启下的角色。核心是:embedding 生成、向量库读写、召回重排的边界划分;Python 侧负责 embedding 与重排,向量库独立部署。
RAG 检索栈演进:① 关键词检索(BM25,召回靠字面匹配);② 稠密向量(FAISS 内存索引,语义召回但难扩展);③ 独立向量库(Milvus / pgvector / Qdrant,承担 ANN 索引与持久化);④ Embedding 服务化(Python 侧批量/实时分池生成向量);⑤ 召回-粗排-重排三段 + 混合检索(BM25 + 向量融合,rerank 模型在 Top-K 上二次打分)。主线是「从单一召回走向可量化、可重排、可回滚的检索工程」。
分层:① Embedding 服务(Python)把文本/图像转向量,复用推理服务的批处理与量化;② 向量库(Milvus/pgvector/Qdrant)独立承担 ANN 索引与查询;③ 重排(rerank)模型常在 Python 侧二次打分提升精度。
- Embedding 模型与检索模型解耦版本,避免改一个牵动全链路。
- 批量 embedding 走批接口,单条实时 embedding 走低延迟实例,分池。
- 向量库查询带元数据过滤(业务域/时间),减少无关候选,降低重排压力。
- 召回→粗排→重排三段式,重排只在 Top-K 上做,控制算力成本。
Python:切分 → 批量 Embedding → 向量库写入的完整脚本
# pip install pymilvus sentence-transformers langchain-text-splitters
from langchain_text_splitters import RecursiveCharacterTextSplitter
from sentence_transformers import SentenceTransformer
from pymilvus import connections, Collection, FieldSchema, CollectionSchema, DataType
splitter = RecursiveCharacterTextSplitter(chunk_size=512, chunk_overlap=64,
separators=["\n\n", "\n", "。", ""])
encoder = SentenceTransformer("BAAI/bge-m3")
connections.connect(alias="default", host="milvus", port="19530")
fields = [
FieldSchema(name="id", dtype=DataType.VARCHAR, max_length=64, is_primary=True),
FieldSchema(name="doc_id", dtype=DataType.VARCHAR, max_length=64),
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=2048),
FieldSchema(name="vec", dtype=DataType.FLOAT_VECTOR, dim=1024),
]
schema = CollectionSchema(fields, description="kb_chunks")
col = Collection("kb_chunks", schema=schema)
col.create_index("vec", {"index_type": "HNSW", "metric_type": "COSINE",
"params": {"M": 16, "efConstruction": 200}})
col.load()
def ingest(doc_id: str, raw: str) -> int:
chunks = splitter.split_text(raw)
# 批量编码,避免逐条调用导致 GPU 空转
vectors = encoder.encode(chunks, batch_size=64, normalize_embeddings=True)
rows = [
[f"{doc_id}-{i}" for i in range(len(chunks))],
[doc_id] * len(chunks),
chunks,
vectors.tolist(),
]
col.insert(rows)
col.flush()
return len(chunks)
# 检索:与 Java 侧 VectorStore 使用同一 metric / dim,否则相似度不可比
def search(q: str, top_k: int = 5):
v = encoder.encode([q], normalize_embeddings=True).tolist()
return col.search(v, "vec", {"metric_type": "COSINE", "params": {"ef": 64}},
limit=top_k, output_fields=["doc_id", "text"])
RAG 的质量天花板在「召回相关性 × 重排精度 × 上下文裁剪」,不在模型参数量。Python 侧的价值是把这三段工程化、可观测、可回滚。
- embedding 与检索模型版本耦合:改一个牵动全链路,应解耦独立版本。
- 批量与实时 embedding 混跑:应分池,否则实时低延迟被批量任务拖慢。
- 向量库查询无元数据过滤:业务域/时间不过滤,候选集爆炸,重排压力陡增。
- 召回后直接返回不做重排:Top-K 精度差,RAG 答案质量上不去。
- RAG 质量只堆模型参数量:天花板在「召回相关性 × 重排精度 × 上下文裁剪」,不在参数。
考察你是否真懂 asyncio 而不是「加了 async 就快了」。核心是:Python 推理服务的并发靠事件循环 + 多进程,阻塞调用(numpy 同步、PIL、同步 IO)会卡死整个 loop。
Python 并发模型演进:① 同步阻塞(requests 串行,吞吐极低);② 多线程(受 GIL 限制,CPU 密集仍串行,仅为 IO 并发);③ 多进程(gunicorn/uvicorn workers,绕过 GIL 吃满多核多卡);④ asyncio 协程(单进程内大量等待中的 IO 并发);⑤ 线程池隔离阻塞(loop.run_in_executor 把不释放 GIL 的纯 Python 重计算移出事件循环)。主线是「进程×协程×线程池」三级并发的成型。
两段并发:① 进程级——多 worker(uvicorn --workers)吃满多核与多卡;② 协程级——单进程内 asyncio 处理大量等待中的 IO。两者正交,必须同时存在。
- 所有模型调用走异步/线程池封装,绝不在 handler 里同步阻塞 GPU 推理。
- numpy/OpenCV 等释放 GIL 的操作用线程池;不释放 GIL 的纯 Python 重计算要换进程。
- 警惕「隐式同步」:requests、某些 SDK 默认阻塞,必须显式换成 aiohttp/httpx 异步版。
- 用 loop.run_in_executor 隔离阻塞点,并给 executor 设上限防止线程爆炸。
Python:asyncio 中 CPU 密集与阻塞 IO 必须出事件循环,附陷阱复现
import asyncio, time
from concurrent.futures import ProcessPoolExecutor
async def call_llm(prompt: str) -> str:
# 正确:异步 HTTP 客户端,等待期间事件循环可服务其他请求
await asyncio.sleep(0.05)
return prompt.upper()
async def cpu_bound(text: str) -> int:
# 正确:CPU 密集交给进程池,绕开 GIL
loop = asyncio.get_running_loop()
return await loop.run_in_executor(POOL, len, text * 1000)
POOL = ProcessPoolExecutor(max_workers=4)
async def main():
t0 = time.perf_counter()
results = await asyncio.gather(*(call_llm(f"q{i}") for i in range(50)))
print("50 并发耗时", round(time.perf_counter() - t0, 3), "s", len(results))
# 反例:在协程里直接 time.sleep / 同步 requests,会串行化全部并发
# for i in range(50): time.sleep(0.05) # 耗时约 2.5s,且阻塞整个 loop
asyncio.run(main())
asyncio 的陷阱是「看起来并发其实串行」——任何一个未隔离的同步阻塞都会把整池请求拖垮。会分层并发(进程×协程×线程池)才算掌握。
- 以为加 async 就快:未隔离的同步阻塞仍把整池请求串行拖垮,看起来并发其实串行。
- handler 里同步阻塞 GPU 推理:必须用异步/线程池封装模型调用。
- 用 requests 等默认阻塞 SDK:必须显式换成 aiohttp / httpx 异步版。
- numpy/OpenCV 未走线程池:释放 GIL 的操作也应进 executor,纯 Python 重计算要换进程。
- executor 不设上限:线程爆炸把机器打满,应设容量上限。
考察你对「Python 并发的物理边界」的理解。核心是:GIL 让单进程内多线程无法并行 Python 字节码,推理服务的并行必须靠多进程;每个 worker 独立持有一份模型与一张卡(或分卡)。
部署拓扑演进:① 单进程单线程(开发期);② 多进程 workers(gunicorn/uvicorn,绕过 GIL);③ 每 worker 绑一卡(单卡单 worker 最稳,或单卡多 worker 分时);④ K8s device-plugin 调度(每 worker 调度到独立 GPU);⑤ NUMA 绑卡 + MIG 切分(减少跨片延迟、把一卡安全分给多负载)。主线是「并行单位从线程回归到进程,再到进程×卡的确定性拓扑」。
部署拓扑:① 单卡单 worker(独占,延迟最稳);② 单卡多 worker 分时(吞吐高但相互争抢显存/算力);③ 多卡多 worker(每 worker 绑一卡,K8s 用 device-plugin 调度)。生产优先「一 worker 一卡」的可预测性。
- 用 gunicorn/uvicorn 的 workers 数 = 卡数(或按显存余量),避免 oversubscribe。
- worker 间不共享内存状态,会话/缓存走外部(Redis/向量库),保证可水平扩。
- 启动探针区分「进程活着」与「模型可用」,未加载完权重不算 ready。
- 用 CPU 亲和 + NUMA 绑卡减少跨片延迟,高负载时收益明显。
Python / Shell:多 worker 部署与 GIL 陷阱复现
# GIL 陷阱复现:CPU 密集用线程反而更慢
import threading, time
def burn(n):
s = 0
for i in range(n):
s += i * i
return s
N = 20_000_000
t0 = time.perf_counter(); burn(N); burn(N)
print("串行", round(time.perf_counter() - t0, 2), "s")
t0 = time.perf_counter()
ts = [threading.Thread(target=burn, args=(N,)) for _ in range(2)]
[t.start() for t in ts]; [t.join() for t in ts]
print("双线程", round(time.perf_counter() - t0, 2), "s # 不快反慢")
# 部署:CPU 密集用多进程;vLLM 已占用 GPU,禁止多 worker 重复加载权重
# gunicorn app:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000
# 若单卡 vLLM:uvicorn app:app --workers 1(靠引擎内部连续批处理提吞吐)
GIL 决定了 Python 服务的并行单位是「进程」不是「线程」。把部署拓扑当成容量规划的一部分(woker×卡×批),而非启动参数随手填,是 senior 的体感。
- workers 数 > 卡数 oversubscribe:多 worker 争抢同一卡/显存,相互抖动。
- worker 间共享内存状态:会话/缓存必须外置 Redis/向量库,否则无法水平扩。
- 启动探针只判进程活:未加载完权重就标 ready,接流量即崩。
- 不绑 NUMA:跨节点内存访问延迟在高负载时明显拖累吞吐。
- 把并行单位当线程:GIL 下 Python 的并行单位是进程,部署拓扑要当容量规划的一部分。
考察跨语言链路的可靠性设计。核心是:Python 推理服务与 Java 网关/中台之间的边界要契约化、超时化、可观测,不能把 Python 的慢当成 Java 的慢。
跨语言通信演进:① REST/JSON(易调试但序列化重、字段易漂移);② gRPC/Protobuf(IDL 契约化、二进制高效、流式支持);③ 服务网格(istio 统一 mTLS/重试/熔断);④ W3C tracecontext(跨栈链路追踪标准);⑤ 舱壁隔离 + 降级兜底(Java 侧对推理调用独立连接池/信号量,失败时返回缓存/规则结果)。主线是「从字符串约定到契约化、可观测、可降级的生产链路」。
边界纪律:① 用 Protobuf/gRPC 统一 IDL,避免 JSON 隐式约定;② Java 侧设「推理调用」独立超时与熔断,失败时走降级(缓存结果/规则兜底);③ 跨语言链路用 W3C trace 串联,定位跨栈慢点。
- 所有跨语言调用带 deadline,Python 侧超时就快速 504,绝不挂起拖垮 Java 线程池。
- Java 侧对推理做舱壁隔离(独立连接池/信号量),防止 Python 慢拖垮主链路。
- 协议用 Protobuf 而非裸 JSON,减少序列化开销与字段漂移。
- 降级策略前置:推理不可用时返回缓存/规则结果并打标,保证主流程不崩。
跨语言契约:.proto 单源,Java 与 Python 生成同一份 stub
// proto/inference.proto
syntax = "proto3";
package ai.inference.v1;
option java_package = "com.example.ai.inference.grpc";
option java_outer_classname = "InferenceProto";
service InferenceService {
rpc Generate (GenerateRequest) returns (GenerateReply);
}
message GenerateRequest {
string trace_id = 1; // 跨栈 Trace 透传
string prompt = 2;
int32 max_tokens = 3;
}
message GenerateReply {
string text = 1;
int32 prompt_tokens = 2;
int64 latency_ms = 3; // int64 在 JSON 中须转 string,见 C18.09
}
Python gRPC server:把超时、取消与 trace_id 一起收进上下文
# python -m grpc_tools.protoc -I proto --python_out=. --grpc_python_out=. proto/inference.proto
from concurrent import futures
import grpc, time
import inference_pb2 as pb, inference_pb2_grpc as pbg
class Inference(pbg.InferenceServiceServicer):
def Generate(self, request, context):
t0 = time.perf_counter()
if not request.prompt:
context.abort(grpc.StatusCode.INVALID_ARGUMENT, "prompt required")
# context 可透传 deadline;超时后引擎侧应停止生成,避免 GPU 空转
text = f"echo:{request.prompt}"
return pb.GenerateReply(
text=text,
prompt_tokens=len(request.prompt),
latency_ms=int((time.perf_counter() - t0) * 1000),
)
server = grpc.server(futures.ThreadPoolExecutor(max_workers=8),
options=[("grpc.max_receive_message_length", 8 * 1024 * 1024)])
pbg.add_InferenceServiceServicer_to_server(Inference(), server)
server.add_insecure_port("[::]:50051")
server.start(); server.wait_for_termination()
Java gRPC stub:deadline、幂等重试与 trace_id 透传
// Spring Boot 侧:由 proto 生成的阻塞 stub + Deadline
@Configuration
public class GrpcConfig {
@Bean
InferenceServiceGrpc.InferenceServiceBlockingStub stub(
@Value("${inference.grpc.target}") String target) {
ManagedChannel ch = ManagedChannelBuilder.forTarget(target)
.usePlaintext()
.enableRetry()
.maxRetryAttempts(2)
.build();
return InferenceServiceGrpc.newBlockingStub(ch);
}
}
@Service
public class InferenceGateway {
private final InferenceServiceGrpc.InferenceServiceBlockingStub stub;
InferenceGateway(InferenceServiceGrpc.InferenceServiceBlockingStub s) { this.stub = s; }
public GenerateReply generate(String prompt, String traceId) {
return stub.withDeadlineAfter(3, TimeUnit.SECONDS)
.withInterceptors(MetadataUtils.newAttachHeadersInterceptor(meta(traceId)))
.generate(GenerateRequest.newBuilder()
.setTraceId(traceId).setPrompt(prompt).setMaxTokens(256).build());
}
private Metadata meta(String traceId) {
Metadata m = new Metadata();
m.put(Metadata.Key.of("x-trace-id", Metadata.ASCII_STRING_MARSHALLER), traceId);
return m;
}
}
跨语言链路的脆弱点永远是「边界」:超时、契约、降级、追踪。这些不在模型里,却是生产稳定的真正护城河。
- Python 慢挂起不超时:拖垮 Java 线程池,必须带 deadline 快速 504。
- Java 侧无舱壁隔离:推理慢直接拖垮主链路,应有独立连接池/信号量。
- 用裸 JSON 跨语言:序列化开销 + 字段漂移,应走 Protobuf。
- 无降级策略:推理不可用时主流程跟着崩,应返回缓存/规则结果并打标。
- 跨栈链路无 trace:慢点定位不到,Java 与 Python 互相甩锅。
考察你能否把「黑盒模型」变成「可观测服务」。核心是:除了常规 RED(速率/错误/时延),推理服务还要暴露 token 级指标、批大小、显存、队列深度,并与业务 trace 打通。
可观测性演进:① print 日志(无结构、不可检索);② RED 指标(速率/错误/时延,QPS 视角);③ 分布式 trace(Jaeger 串调用链);④ 推理专属指标(TTFT 首 token、TPOT token 间、批实际大小、KV 缓存占用、队列深度、显存利用率——比通用 QPS 更贴近体验);⑤ 统一 backend(OTel) + 异常输出埋点(空响应/超长生成单独追踪,反推数据或提示词问题)。
专属指标:TTFT(首 token)、TPOT(token 间)、批实际大小、KV 缓存占用、队列等待时长、显存利用率。这些才是推理服务的「黄金信号」,比通用 QPS 更贴近用户体验。
- 统一日志结构(request_id、model、latency、tokens),禁止把 prompt 原文打进日志。
- 指标用 Prometheus 暴露,告警阈值按 TTFT/P99 而非平均延迟设。
- trace 串起「Java 网关 → Python 推理 → 向量库」,慢点一眼定位。
- 对异常输出(空响应、超长生成)单独埋点,反推数据/提示词问题。
Python:OTel + Prometheus + LangFuse 三层埋点(器维横切组件接入)
# pip install opentelemetry-sdk opentelemetry-exporter-otlp langfuse
import os, time
from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
from prometheus_client import Histogram, Counter
from langfuse import Langfuse
provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(
OTLPSpanExporter(endpoint=os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"], insecure=True)))
trace.set_tracer_provider(provider)
tracer = trace.get_tracer("inference.python")
# LLMOps 平台:Run/Node 级 Trace、 Token 成本、评分统一回写
lf = Langfuse(public_key=os.environ["LANGFUSE_PK"],
secret_key=os.environ["LANGFUSE_SK"],
host=os.environ.get("LANGFUSE_HOST", "https://cloud.langfuse.com"))
LAT = Histogram("model_call_seconds", "model latency", ["model"])
TOK = Counter("model_tokens_total", "tokens", ["model", "kind"])
def generate(prompt: str, model: str) -> str:
with tracer.start_as_current_span("llm.generate") as span:
span.set_attribute("llm.model", model)
span.set_attribute("llm.prompt_tokens", len(prompt))
trace_obj = lf.trace(name="generate", input={"prompt": prompt})
gen = trace_obj.generation(name="chat", model=model,
model_parameters={"temperature": 0.2})
t0 = time.perf_counter()
try:
text = do_call(prompt) # 真实模型调用
gen.end(output=text, usage={"input": len(prompt), "output": len(text)})
trace_obj.score(name="quality", value=0.9)
TOK.labels(model, "output").inc(len(text))
return text
finally:
LAT.labels(model).observe(time.perf_counter() - t0)
lf.flush()
可观测性决定你能多快从「用户说慢」走到「第几段 pipeline 慢」。推理服务的观测维度比普通 CRUD 更细,这是 expert 与「能跑起来」的分界线。
- 把 prompt 原文打进日志:合规与隐私风险,应只记 request_id/模型/延迟/ token。
- 告警按平均延迟:长尾用户感知不到,应基于 TTFT/P99。
- trace 不串 Java→Python→向量库:慢点盲,定位靠猜。
- 异常输出不单独埋点:空响应/超长生成无法归因到数据或提示词。
- 只监控通用 QPS:忽略推理专属黄金信号,容量与质量都看不准。
考察模型迭代的发布工程。核心是:模型权重像代码一样要版本化、可回滚、可并行服务多版本,流量按比例灰度而非全量切换。
模型发布演进:① 手工重启换权重(无版本、无回滚);② 权重版本化(模型仓库 + hash,可溯源);③ 同进程多版本(或按流量路由多实例);④ 独立灰度批次(小流量 1%-5% 对比旧版延迟与质量);⑤ A/B 实验框架(对比准确率/满意度/成本等业务指标再放量,上一版热待命秒级回切)。主线是「从换权重到带业务指标的灰度实验」。
版本治理:① 权重与推理代码分离版本,权重走模型仓库(带 hash);② 推理服务支持「同进程多版本」或「按流量路由多实例」;③ 灰度按用户/场景分桶,对比业务指标(准确率、满意度、成本)再放量。
- 每次上线新模型先小流量(1%-5%)灰度,对比旧版的延迟与质量指标。
- 模型与推理代码一起进 CI,权重 hash 进发布单,可一键回滚。
- A/B 用统一实验框架,避免「偷偷换模型」导致问题无法归因。
- 保留上一版实例「热待命」一段时间,异常时秒级切回。
模型发布比代码更危险(效果不可直观 diff)。把它当成「带业务指标的灰度实验」而不是「重启换权重」,是生产级 AI 服务的门槛。
- 全量切换权重:无法归因、无法回滚,出问题只能硬扛。
- 权重与推理代码不一起进 CI:回滚时两者不一致,雪上加霜。
- 偷偷换模型无实验框架:效果不可 diff,问题无法归因。
- 不保留上一版热待命:异常时回切慢,故障窗口拉长。
- 只看延迟不看业务质量:准确率/满意度下降比延迟更致命。
考察成本与稳定性的平衡。核心是:推理贵且慢,重复/近似请求要能命中缓存;极端情况下能降级到「次优但可用」的结果。
缓存与降级演进:① 无缓存(每请求都跑模型,贵且慢);② 结果缓存(相同输入命中);③ KV 缓存(推理引擎内按前缀复用,降低重复 prompt 计算);④ 语义缓存(向量近似检索,问法不同意图相同也命中);⑤ 分级缓存 + 降级兜底(Redis + 内存分级,模型超时返回检索片段/规则结果并提示不完整)。主线是「用确定性(命中/降级)换成本与稳定性」。
两级缓存:① KV 缓存(推理引擎内)复用前缀,降低重复 prompt 的计算;② 语义缓存(向量近似检索)对「问法不同但意图相同」的请求返回历史结果。降级则在前置层返回规则/检索兜底。
- 对高重复度的固定问答建语义缓存,命中即返回,省 token 又降延迟。
- KV 缓存按会话/前缀管理,长对话复用历史,控制上下文膨胀。
- 降级链路:模型超时 → 返回检索片段 + 提示「结果可能不完整」。
- 缓存带 TTL 与版本标签,模型大版本升级时整池失效避免脏数据。
缓存是「用确定性换成本」的杠杆。会区分 KV 缓存(引擎内)与语义缓存(业务层),并在故障时优雅降级,才算把 AI 服务当成生产系统运营。
- 高重复问答不建语义缓存:白白烧 token 又加延迟。
- KV 缓存不按会话/前缀管理:长对话上下文膨胀,显存与计算双浪费。
- 无降级链路:模型超时直接全链路失败,应返回检索片段兜底。
- 缓存无 TTL/版本标签:模型大版本升级后返回脏数据。
- 混淆 KV 缓存与语义缓存职责:KV 是引擎内前缀复用,语义是业务层意图命中,二者层次不同。
考察你对「AI 服务烧钱速度」的敏感度。核心是:token 是硬成本,必须按租户/场景做配额、计费、限流,并把成本摊到业务指标上看 ROI。
成本治理演进:① 不计费(全员无限制烧钱);② 按调用次数(粗,掩盖长生成);③ 按 token 计量(input/output 分别计入);④ 按租户/功能归因(成本摊到业务线看 ROI);⑤ 并发 + token 双限流 + 成本看板(GPU 利用率、单位请求成本作为一级健康度,小模型够用就不上大模型)。主线是「AI 服务的 SRE 第一指标从可用变成成本/有效结果」。
成本闭环:① 每条请求记录 input/output token 并归因到租户/功能;② 按配额限流(QPM、并发、token/分钟),超限走排队或降级;③ 成本看板与质量/收入同屏,指导模型选型(小模型够用就不上大模型)。
- token 用量接入计费系统,按租户出账单,避免「全员无限制烧钱」。
- 限流维度用「并发 + token 速率」双指标,单纯 QPS 会漏掉长生成。
- 对不同场景设模型档位:简单任务路由小模型,复杂任务才上大模型。
- 成本异常(某租户突增 10 倍)即告警,排查是 bug 还是被刷。
Python:token 计费与并发配额,成本口径与 Java 网关对齐
import threading, time
from collections import defaultdict
RATE_PER_1K = {"qwen2.5-7b": 0.002, "qwen-max": 0.012} # 单位:元 / 1K token
class BudgetGuard:
def __init__(self, qps: int = 20, daily_quota: float = 500.0):
self.qps, self.daily_quota = qps, daily_quota
self.cost = defaultdict(float)
self._sem = threading.BoundedSemaphore(qps)
self._lock = threading.Lock()
def acquire(self) -> bool:
return self._sem.acquire(timeout=0.5)
def charge(self, tenant: str, model: str, in_tok: int, out_tok: int) -> float:
amount = (in_tok + out_tok) / 1000 * RATE_PER_1K[model]
with self._lock:
if self.cost[tenant] + amount > self.daily_quota:
raise PermissionError("daily quota exceeded")
self.cost[tenant] += amount
return amount
def release(self):
self._sem.release()
# 使用:超限快速失败,避免 GPU 空转后才发现超预算
guard = BudgetGuard()
def handle(tenant, model, prompt):
if not guard.acquire():
raise RuntimeError("429 too many requests")
try:
text = do_call(prompt)
cost = guard.charge(tenant, model, len(prompt), len(text))
return {"text": text, "cost_cny": round(cost, 6)}
finally:
guard.release()
AI 服务的 SRE 第一指标是「成本/有效结果」。能把 token 当资源一样做配额、归因、优化,才真正具备规模化运营能力。
- 全员无限制烧钱:无账单,月底才发现某租户异常。
- 只按 QPS 限流:漏掉长生成,应叠加并发 + token 速率。
- 不分模型档位:简单任务也上大模型,成本翻倍。
- 成本异常不告警:某租户突增 10 倍也无人知晓,可能是 bug 或被刷。
- 成本不摊到业务指标:看不到 ROI,优化无靶心。
考察你对「AI 栈攻击面」的覆盖。核心是:Python 依赖庞杂、模型可被提示注入、输出可能泄露,必须当生产服务做供应链与内容安全。
AI 栈安全演进:① 裸 pip 无管控(供应链投毒风险);② 依赖锁定(poetry lock / 私有源);③ CVE 扫描(pip-audit/Snyk 进 CI);④ 输入防护(提示注入检测、越权工具调用拦截);⑤ 输出收敛(PII 脱敏、内容安全、防训练数据泄露)+ 沙箱执行(用户自定义脚本禁直连内网/本地文件)。主线是「攻击面从依赖扩到提示词、工具调用、输出,防护须全链路默认收敛」。
三层防护:① 供应链——锁依赖(hash/poetry lock)、私有源、定期扫 CVE;② 输入——提示注入过滤、越权工具调用拦截;③ 输出——敏感信息脱敏、内容安全审核、防止训练数据泄露。
- 依赖用锁定文件 + 私有镜像源,CI 接入 pip-audit/Snyk 扫漏洞。
- 对外暴露的推理接口做提示注入检测,高危操作(调用工具/写库)需显式授权。
- 输出层做 PII 脱敏与内容安全,避免模型把内部数据/密钥吐给用户。
- 沙箱化执行用户自定义脚本/插件,禁直连内网与本地文件。
AI 栈的攻击面比传统服务宽:依赖、提示词、工具调用、输出都可能是入口。安全不是事后加 WAF,而是从供应链到输出的全链路默认收敛。
- 依赖不锁/用公共源:供应链投毒,一个恶意包拖垮整链。
- 对外接口无提示注入检测:越权工具调用/数据外泄无人拦截。
- 输出不脱敏:模型把内部数据/密钥吐给用户,合规爆雷。
- 用户脚本直连内网/本地文件:无沙箱,权限横向移动。
- 安全只靠事后 WAF:应从供应链到输出全链路默认收敛。