LLM 正在拆掉云原生架构的地基

2026-05-14 19 预计阅读时间:1 分钟
来源:oschina.net AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:11 分钟

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 编排 长事务 有限并发 + 状态持久化 类流式处理

几个实操建议:

  1. 推理层和 API 层分开部署。 不要把 LLM 推理塞进普通微服务 Pod 里。推理节点用 Deployment + Pod 反亲和 + 会话亲和 Ingress(或客户端直连),API 层继续用标准 Service。

  2. KV Cache 当成半持久资源来管理。 像 Redis 的内存一样对待它:有 TTL,有淘汰策略,有监控。NVIDIA 的 Triton Inference Server 已经支持 KV Cache 的显存管理和复用配置。

  3. Agent 状态定期落盘。 长事务不能只靠内存。每完成一个子步骤,把中间结果写入数据库或对象存储。节点挂了可以恢复,代价是重新 prefill 一次上下文。

  4. 负载均衡器升级。 从 Round Robin 变成一致性哈希或会话亲和。Envoy 支持基于 header 的一致性哈希路由;Nginx 支持 ip_hash 和自定义 hash key。

地基换了,房子不用推倒

云原生的工具链——容器、编排、服务发现、可观测性——依然有用。只是"计算无状态"这条假设不再普适。LLM 把一部分状态从数据库搬到了 GPU 显存里,把一部分事务从秒级拉到了分钟级。

你需要做的不是抛弃云原生,而是在推理层和 Agent 层补上有状态系统的设计经验——那恰恰是云原生之前,我们就一直在做的事。

下次设计 AI 系统时,先问一个问题:这个请求,能不能随便落到任意节点? 如果不能,就从负载均衡器开始重新想。


相关推荐