裸辞半年后我靠这套机器学习部署方案拿到了Offer
说实话,写这篇文章的时候我正戴着降噪耳机,Spotify里循环着Lo-Fi Beats,手边是一杯已经凉透的美式。Gap了整整半年,简历投出去像石沉大海,好不容易最近捞到几个面试机会,边刷题边复习,头发是一把一把地掉。
但今天不聊八股文,想跟大家唠唠我最近在项目里折腾的机器学习部署那点事儿。为啥要聊这个?因为面试被问麻了。现在大厂面试,光会训模型已经不够看了,人家更关心你怎么把模型又快又稳地怼到线上去跑。
事情是这么开始的
上个月一个前同事内推我,说他们团队在搞一个智能体开发的项目,需要有人懂模型部署和推理优化。我一听,这不正好嘛,之前在大厂那会儿天天跟K8s打交道,云原生这块我还是有点底气的。
结果面试的时候,面试官上来就甩了个面试题挑战:
"假设你有一个基于LangChain搭建的多智能体系统,底层挂了三个不同的大模型,QPS要求500,P99延迟要控制在2秒以内,你怎么设计部署架构?"
好家伙,我当时脑子里就一个字:麻。
不是不会,是这个问题太综合了。你得懂模型推理优化、懂服务编排、懂资源调度、还得懂LangChain那套东西的坑。我面完出来就在地铁上拿手机记了一堆笔记,回家打开电脑就开始整理,想着干脆写成博客,也算给自己做个复盘。
先说说LangChain智能体部署的那些坑
用过LangChain的朋友都知道,这玩意儿写Demo是真的爽,几行代码就能搭一个Agent出来。但你要真往生产环境上搬,那坑是一个接一个。
我之前的项目里,我们用LangChain搭了一个客服智能体,接入了知识库检索、工具调用、多轮对话这些能力。本地跑得飞起,一上预发环境直接拉胯。
问题出在哪?
第一,LangChain的调用链太深了。 一个用户请求进来,经过Agent规划、Tool选择、LLM调用、结果解析,中间可能套了四五层嵌套。每一层都有序列化反序列化的开销,Python的GIL在这里简直是噩梦。
第二,模型推理没有做批处理。 最开始我们就是一个请求一个请求地发给模型服务,GPU利用率连30%都不到。后来上了Continuous Batching,直接干到80%以上。
第三,没做缓存。 很多相似的Query反复打过来,每次都重新跑一遍推理,这不是纯纯的浪费算力嘛。
我的部署方案长什么样
先说整体架构,画个文字版的图:
┌─────────────┐
│ API Gateway │
│ (限流/鉴权) │
└──────┬──────┘
│
┌──────▼──────┐
│ Agent服务 │
│ (FastAPI) │
└──────┬──────┘
│
┌───────────────┼───────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ 模型推理服务1 │ │ 模型推理服务2 │ │ 模型推理服务3 │
│ (vLLM) │ │ (TGI) │ │ (TensorRT) │
└─────────────┘ └─────────────┘ └─────────────┘
│ │ │
└───────────────┼───────────────┘
│
┌──────▼──────┐
│ Redis缓存 │
│ + 向量数据库 │
└─────────────┘
核心思路就是把推理和Agent逻辑解耦。Agent服务本身不跑模型,只做编排和调度。模型推理全部交给专门的推理服务,用vLLM或者TGI这种高性能框架来跑。
推理服务优化
这块我花的时间最多。以vLLM为例,分享几个关键配置:
# vllm_serving.py
from vllm import AsyncLLMEngine
from vllm.engine.arg_utils import AsyncEngineArgs
engine_args = AsyncEngineArgs(
model="/models/qwen-72b-chat",
tensor_parallel_size=4, # 4卡并行
gpu_memory_utilization=0.92, # 显存利用率拉满
max_num_batched_tokens=32768, # 批处理token上限
max_num_seqs=256, # 最大并发序列数
enable_chunked_prefill=True, # 开启分块预填充
max_paddings=256, # padding容忍度
)
engine = AsyncLLMEngine.from_engine_args(engine_args)
这里有个细节,gpu_memory_utilization我设成了0.92而不是默认的0.9。别小看这0.02,在72B模型上能多塞进去好几个KV Cache的slot,并发能力直接提升了一截。当然你得确保不会OOM,这个需要压测来调。
还有一个大招是Prefix Caching。如果你的业务场景里System Prompt很长且固定,开启这个能省掉大量重复的prefill计算:
engine_args = AsyncEngineArgs(
model="/models/qwen-72b-chat",
enable_prefix_caching=True, # 开启前缀缓存
# ... 其他配置
)
实测下来,在System Prompt有2000+ token的场景下,首token延迟从1.8秒降到了0.6秒,效果非常显著。
Agent服务的异步化改造
LangChain默认是同步调用,这在生产环境完全不能忍。我把它整个改成了异步模式:
# agent_service.py
import asyncio
from fastapi import FastAPI
from langchain.agents import AgentExecutor
from langchain.callbacks import AsyncIteratorCallbackHandler
app = FastAPI()
# 全局连接池,避免每次请求都创建新连接
from aiohttp import ClientSession
session = ClientSession()
@app.post("/chat")
async def chat(request: ChatRequest):
callback = AsyncIteratorCallbackHandler()
# 异步执行Agent
agent_executor = AgentExecutor(
agent=agent,
tools=tools,
callbacks=[callback],
max_iterations=5, # 限制最大迭代次数,防止死循环
return_intermediate_steps=True,
)
# 设置超时,保护下游服务
try:
result = await asyncio.wait_for(
agent_executor.ainvoke(
{"input": request.query},
config={"callbacks": [callback]}
),
timeout=10.0
)
except asyncio.TimeoutError:
return {"error": "Agent执行超时", "code": 408}
return {"response": result["output"]}
这里我加了max_iterations=5和asyncio.wait_for的双重保护。为啥?因为之前线上出过事故,Agent在两个Tool之间反复横跳,死循环了,直接把下游模型服务打挂了。那天晚上运维大哥给我打电话的时候,我正在家打游戏,接完电话手都是抖的。
缓存策略
缓存这块我做了两级:
| 缓存层级 | 存储介质 | 缓存内容 | TTL | 命中率 |
|---|---|---|---|---|
| L1 | 本地LRU Cache | 高频Query的完整响应 | 5min | ~35% |
| L2 | Redis Cluster | Semantic Embedding缓存 | 30min | ~25% |
L1用的是Python的cachetools,简单粗暴但有效:
from cachetools import LRUCache
response_cache = LRUCache(maxsize=10000)
async def get_response(query: str) -> Optional[str]:
cache_key = hashlib.md5(query.encode()).hexdigest()
if cache_key in response_cache:
return response_cache[cache_key]
# ... 调用Agent获取响应
response_cache[cache_key] = response
return response
L2的语义缓存更有意思。我把Query转成embedding之后,不是做精确匹配,而是算余弦相似度。相似度超过0.95的,直接返回缓存结果。这个在客服场景里特别好使,因为用户问法虽然不一样,但意思经常是一样的。
在K8s上部署的一些心得
既然咱对K8s比较熟,这块就多聊两句。
模型服务的Pod配置,我一般这么写:
apiVersion: apps/v1
kind: Deployment
metadata:
name: vllm-serving
spec:
replicas: 3
template:
spec:
containers:
- name: vllm
image: vllm/vllm-openai:latest
resources:
requests:
memory: "64Gi"
cpu: "16"
nvidia.com/gpu: 4
limits:
memory: "64Gi"
cpu: "16"
nvidia.com/gpu: 4
env:
- name: VLLM_ATTENTION_BACKEND
value: "FLASHINFER"
- name: NCCL_DEBUG
value: "WARN"
livenessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 300 # 模型加载需要时间
periodSeconds: 30
readinessProbe:
httpGet:
path: /health
port: 8000
initialDelaySeconds: 120
periodSeconds: 10
几个注意点:
initialDelaySeconds一定要设够。72B模型加载到4张A100上,怎么也得四五分钟。设短了K8s以为Pod挂了,反复重启,那场面别提多壮观了。GPU资源要用
nvidia.com/gpu这种标准资源名,别自己瞎定义。如果用的是多实例推理,建议加上Pod Disruption Budget,保证滚动更新的时候至少有一半的Pod可用:
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: vllm-pdb
spec:
minAvailable: 2
selector:
matchLabels:
app: vllm-serving
HPA的配置
自动扩缩容这块,我用的不是CPU利用率,而是请求队列长度。因为模型推理是GPU密集型任务,CPU利用率根本反映不了真实的负载情况。
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: vllm-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: vllm-serving
minReplicas: 2
maxReplicas: 8
metrics:
- type: Pods
pods:
metric:
name: vllm_queue_size
target:
type: AverageValue
averageValue: "20"
behavior:
scaleUp:
stabilizationWindowSeconds: 60 # 快速扩容
policies:
- type: Pods
value: 2
periodSeconds: 60
scaleDown:
stabilizationWindowSeconds: 300 # 慢速缩容,避免抖动
policies:
- type: Pods
value: 1
periodSeconds: 120
扩容快、缩容慢,这是模型服务的基本策略。因为GPU实例启动本身就慢,你要是缩容也很快,流量一抖动就来回折腾,GPU资源白白浪费。
性能数据对比
折腾了两周,最终的效果还是相当可观的。贴一下压测数据,给大家一个参考:
| 指标 | 优化前 | 优化后 | 提升幅度 |
|---|---|---|---|
| QPS | 120 | 530 | 341% |
| P50延迟 | 3.2s | 0.8s | 75% |
| P99延迟 | 8.5s | 1.9s | 78% |
| GPU利用率 | 28% | 82% | 193% |
| 单请求成本 | ¥0.15 | ¥0.04 | 73% |
P99延迟从8.5秒降到1.9秒,这个是最让我满意的。因为面试的时候面试官就问了P99怎么优化,我能结合实际数据来答,底气就足很多。
关于模型选择和调优
这块也顺带提一嘴,毕竟部署方案跟模型选择是强相关的。
我们项目里用了三个模型,各司其职:
- Qwen-72B:主力模型,处理复杂推理和多轮对话
- Qwen-7B:轻量模型,处理简单的意图识别和分类
- BGE-Large:Embedding模型,用于知识库检索和语义缓存
这里有个小技巧:模型路由。不是所有请求都需要72B的大模型。我写了一个简单的路由器,根据Query的复杂度来分发:
async def route_request(query: str) -> str:
# 简单规则 + 轻量模型判断
if len(query) < 20 and not contains_keywords(query, ["分析", "比较", "为什么"]):
return "qwen-7b" # 简单问题走小模型
# 用7B模型做一个快速分类
intent = await classify_intent(query, model="qwen-7b")
if intent in ["simple_qa", "chitchat"]:
return "qwen-7b"
elif intent in ["complex_reasoning", "multi_hop"]:
return "qwen-72b"
else:
return "qwen-7b" # 默认走小模型
这个路由上线后,72B模型的调用量直接降了40%,但用户满意度几乎没变。因为大部分用户的问题其实并不需要72B来回答。
一些碎碎念
写到这里,咖啡已经喝了三杯了,耳机里的歌单也换了两轮。
说实话,裸辞这半年,焦虑肯定是有的。看着银行卡余额一天天变少,有时候半夜醒来会想,是不是不应该这么冲动。但每当搞定一个技术难题,或者面试的时候能把一个知识点讲得明明白白,那种成就感又让我觉得,嗯,这波不亏。
机器学习部署这个方向,我觉得未来几年都会很吃香。现在会训模型的人一抓一大把,但能把模型又快又省地部署到线上、还能保证稳定性的,真的是稀缺人才。
如果你也在准备这方面的面试,我的建议是:
不要只停留在调API的层面。去了解一下vLLM、TGI这些推理框架的底层原理,知道Continuous Batching是怎么回事,知道KV Cache是怎么管理的。
一定要有实际的性能优化经验。面试官问"你怎么优化推理延迟",你说"我用了xxx技术,P99从多少降到了多少",这比说一百句"我了解xxx"都管用。
关注成本。大厂现在都在降本增效,你能从成本角度来聊部署方案,绝对是加分项。
好了,不说了,LeetCode的每日一题还没刷,明天还有一场面试。祝我好运吧,也祝看到这篇文章的你,面试顺利,Offer拿到手软。
我们下个Gap...啊不,下个工位见。


评论 0