2010 年前后,整个行业达成了一个共识:计算无状态,状态归数据库。应用服务器随便加,负载均衡器随便转发,请求落到哪台机器都一样——因为真相只存在于数据库里。这套范式统治了二十年,Kubernetes、微服务、Serverless 全是它的产物。
但当你把一个 LLM 接进系统,这根支柱开始晃了。
二十年不变的等式
云原生的核心等式很简单:
任意请求 → 任意服务器 → 唯一数据库
想扩容?垂直升级数据库(换更大机器),水平加应用实例(多起几个 Pod)。负载均衡器不需要记住谁是谁,Round Robin 就够了。服务挂了?重启一个,状态全在 DB 里,毫秒级恢复。
这套逻辑之所以成立,是因为计算本身不携带任何不可丢失的状态。一个 HTTP 请求进来,查数据库,返回 JSON,结束。服务器内存里没有必须持久化的东西。
LLM 让计算变"重"了
LLM 推理打破了"计算无状态"这个前提,至少在三个维度上:
第一,上下文窗口本身就是状态。 一个 128K token 的对话上下文,在推理时必须完整加载到 GPU 显存里。这不是几 KB 的 session 数据——它是几十 MB 到上百 MB 的 KV Cache,和具体的请求、具体的用户绑定。你不能随便把请求扔到另一台机器上,因为那台机器上没有这段 KV Cache。
第二,Agent 的多步执行是长事务。 传统微服务一个请求一个事务,几秒结束。Agent 可能要跑 30 步:调工具、等结果、反思、再调工具。中间状态(已执行的步骤、累积的推理链、待完成的子目标)必须保持在内存里,跨步骤连贯。这不像 HTTP 请求,更像一个持续数分钟的有状态会话。
第三,推理本身不可复现。 同样的 prompt,两次调用可能得到不同结果。数据库里存的不是"正确答案",而是"一次采样结果"。真相来源从数据库的确定性记录,变成了模型输出的概率分布。
这三点叠加,旧的等式不再成立:
特定请求 → 必须落到特定服务器 → 上下文状态在显存里
负载均衡器开始需要"认识"请求了。
实际会发生什么
举一个具体的场景:你做了一个 AI 编程助手,用户在 IDE 里和 Agent 对话。Agent 每次回复要带着之前 50 条对话的上下文。
传统做法(无状态): 每次请求把完整对话历史从数据库读出来,拼成 prompt,发给任意一个推理节点。问题:50 条对话拼出来的 prompt 可能 80K token,每次都要重新做 prefill,延迟 2-3 秒,GPU 算力浪费在重复计算上。
新做法(有状态亲和): 用户第一次连接时,把上下文加载到某个推理节点,后续请求始终路由到同一节点,复用 KV Cache。每次只需要处理新增的几个 token,延迟降到毫秒级。
代价是:节点挂了,KV Cache 丢了,用户要重新 prefill。扩缩容不能随便杀 Pod,得等会话自然结束。这和数据库的"垂直扩展"困境一样——状态集中在哪里,哪里就难动。
下面是一个用 FastAPI + Redis 实现会话亲和路由的最小示例,展示如何在推理层做状态绑定:
# session_router.py — 最小可运行的会话亲和路由示例
# 依赖: pip install fastapi uvicorn redis httpx
# 运行: uvicorn session_router:app --port 8000
# 前置: 本地起一个 Redis (docker run -d -p 6379:6379 redis)
# 以及两个模拟推理节点 (见下方 inference_node.py)
import hashlib
import httpx
from fastapi import FastAPI, Request
from redis import Redis
app = FastAPI()
redis = Redis(host="localhost", port=6379, decode_responses=True)
# 模拟两个推理节点
INFERENCE_NODES = [
"http://localhost:8001", # 推理节点 A
"http://localhost:8002", # 推理节点 B
]
def pick_node(session_id: str) -> str:
"""根据 session_id 做确定性路由,同一会话始终落到同一节点"""
# 先查缓存:之前绑定了哪个节点?
cached = redis.get(f"session:{session_id}:node")
if cached:
return cached
# 没有缓存,用一致性哈希分配
idx = int(hashlib.md5(session_id.encode()).hexdigest(), 16) % len(INFERENCE_NODES)
node = INFERENCE_NODES[idx]
# 写入缓存,TTL 30 分钟(会话超时后释放绑定)
redis.setex(f"session:{session_id}:node", 1800, node)
return node
@app.post("/chat/{session_id}")
async def chat(session_id: str, request: Request):
body = await request.json()
node_url = pick_node(session_id)
try:
resp = await httpx.AsyncClient(timeout=30.0).post(
f"{node_url}/infer", json={"session_id": session_id, **body}
)
return resp.json()
except httpx.ConnectError:
# 节点挂了:清除绑定,重新分配
redis.delete(f"session:{session_id}:node")
new_node = pick_node(session_id)
resp = await httpx.AsyncClient(timeout=30.0).post(
f"{new_node}/infer", json={"session_id": session_id, **body}
)
# 新节点没有 KV Cache,返回中标记需要重新 prefill
result = resp.json()
result["prefill_required"] = True
return result
推理节点侧(模拟 KV Cache 复用):
# inference_node.py — 模拟推理节点,启动两个实例
# 运行: uvicorn inference_node:app --port 8001
# uvicorn inference_node:app --port 8002
from fastapi import FastAPI, Request
from collections import defaultdict
app = FastAPI()
# 模拟每个节点本地维护的 KV Cache 状态
# key=session_id, value=已缓存的 token 数
kv_cache: dict[str, int] = defaultdict(int)
@app.post("/infer")
async def infer(request: Request):
body = await request.json()
session_id = body["session_id"]
new_tokens = len(body.get("prompt", "").split())
cached_tokens = kv_cache[session_id]
kv_cache[session_id] += new_tokens
# 如果有缓存,只处理新增 token(模拟增量推理)
# 如果没有缓存,需要全量 prefill
prefill = cached_tokens == 0
return {
"response": f"[节点 {app.port}] 回复: ...",
"cached_tokens": cached_tokens,
"new_tokens": new_tokens,
"prefill": prefill, # True = 慢,False = 快(复用了 KV Cache)
}
# hack: 让响应里能区分节点
@app.middleware("http")
async def add_port(request: Request, call_next):
import starlette.responses as r
response = await call_next(request)
app.port = request.url.port # 不严谨,仅用于演示区分节点
return response
运行后,同一 session_id 的请求会被固定路由到同一节点。节点挂掉后,路由器会重新分配,但返回 prefill_required: True 提示客户端需要重新加载上下文。
架构层面的取舍
这不是"云原生错了",而是假设条件变了。旧范式在纯 CRUD 场景下依然最优。但当你引入 LLM,需要做分层设计:
| 层 | 状态特征 | 扩容策略 | 适合的范式 |
|---|---|---|---|
| 传统 API / CRUD | 无状态 | 水平加实例 | 云原生,Round Robin |
| LLM 推理 | 有状态(KV Cache) | 会话亲和 + 预热 | 类数据库,垂直优先 |
| Agent 编排 | 长事务 | 有限并发 + 状态持久化 | 类流式处理 |
几个实操建议:
-
推理层和 API 层分开部署。 不要把 LLM 推理塞进普通微服务 Pod 里。推理节点用 Deployment + Pod 反亲和 + 会话亲和 Ingress(或客户端直连),API 层继续用标准 Service。
-
KV Cache 当成半持久资源来管理。 像 Redis 的内存一样对待它:有 TTL,有淘汰策略,有监控。NVIDIA 的 Triton Inference Server 已经支持 KV Cache 的显存管理和复用配置。
-
Agent 状态定期落盘。 长事务不能只靠内存。每完成一个子步骤,把中间结果写入数据库或对象存储。节点挂了可以恢复,代价是重新 prefill 一次上下文。
-
负载均衡器升级。 从 Round Robin 变成一致性哈希或会话亲和。Envoy 支持基于 header 的一致性哈希路由;Nginx 支持
ip_hash和自定义 hash key。
地基换了,房子不用推倒
云原生的工具链——容器、编排、服务发现、可观测性——依然有用。只是"计算无状态"这条假设不再普适。LLM 把一部分状态从数据库搬到了 GPU 显存里,把一部分事务从秒级拉到了分钟级。
你需要做的不是抛弃云原生,而是在推理层和 Agent 层补上有状态系统的设计经验——那恰恰是云原生之前,我们就一直在做的事。
下次设计 AI 系统时,先问一个问题:这个请求,能不能随便落到任意节点? 如果不能,就从负载均衡器开始重新想。