LLM 应用的 Graceful Shutdown 工程实践:正在进行的 AI 请求如何安全停止

凌晨 2 点的告警

我们的 LLM 服务在一次 K8s 滚动更新后,用户侧出现大批"生成中断"投诉。排查日志发现:新版本 Pod 就绪后,旧 Pod 收到 SIGTERM,直接退出------所有正在 streaming 的请求瞬间断流,返回 503

这不是偶发问题。只要你的 LLM 服务:

  • 部署在 K8s 上且用滚动更新
  • 有任何 streaming 端点(Server-Sent Events / WebSocket)
  • 请求时长超过 30 秒(大模型生成 2000 token 需要 40-60 秒)

就一定会遇到这个问题。

这篇文章把我们踩过的坑和最终落地的方案全部写出来。


问题拆解:LLM Shutdown 比普通 HTTP 服务难在哪里

普通 HTTP 服务的 graceful shutdown 已有成熟方案:等待 in-flight 请求完成,超时后强制退出。但 LLM streaming 请求有三个特殊性:

1. 请求时长不可预测

生成 100 token 需要 3 秒,生成 4000 token 需要 2 分钟。你不知道一个请求要多久结束。设 drain window 多少合适?30 秒打断一半请求,300 秒让 K8s 等到超时。

2. 部分响应有价值,也有风险

普通 API 要么成功要么失败,没有中间态。LLM streaming 的中间态是已生成的 token 序列------它有价值(用户已看到 1000 字),但截断的文本可能语义不完整,客户端需要知道"这是意外中断"而非"正常结束"。

3. 计费状态需要持久化

LLM 调用通常按 token 计费。如果请求在生成 3000 个 output token 后中断,这 3000 个 token 已经在 provider 侧扣费,但你的计费系统可能没有记录。重试会再扣一次。

下面分 4 层讲解解法。


第一层:SIGTERM 处理 + Drain Window

最基础的一层。Node.js / Python 应用默认不处理 SIGTERM,进程直接退出。

Node.js 实现

typescript 复制代码
// shutdown.ts
import { Server } from 'http';

const DRAIN_TIMEOUT_MS = process.env.DRAIN_TIMEOUT_MS
  ? parseInt(process.env.DRAIN_TIMEOUT_MS)
  : 120_000; // 2 分钟

export function setupGracefulShutdown(server: Server) {
  let isShuttingDown = false;

  // K8s 发 SIGTERM,给足够时间 drain
  process.on('SIGTERM', async () => {
    if (isShuttingDown) return;
    isShuttingDown = true;

    console.log(`[shutdown] SIGTERM received. Drain window: ${DRAIN_TIMEOUT_MS}ms`);

    // 1. 停止接收新连接
    server.close();

    // 2. 等待 in-flight 请求完成,或超时
    const drainStart = Date.now();
    await waitForInflightRequests(drainStart);

    console.log('[shutdown] Drain complete. Exiting.');
    process.exit(0);
  });

  // Liveness probe:shutdown 开始后返回 503,让 K8s 不再把流量路由进来
  server.on('request', (req, res) => {
    if (isShuttingDown && req.url === '/healthz') {
      res.writeHead(503);
      res.end('shutting down');
    }
  });
}

K8s 配置

yaml 复制代码
# deployment.yaml
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 180  # 必须 > DRAIN_TIMEOUT_MS
      containers:
        - name: llm-server
          lifecycle:
            preStop:
              exec:
                # 给 kube-proxy 时间更新 iptables,避免 SIGTERM 和流量同时到达
                command: ["/bin/sleep", "5"]

terminationGracePeriodSeconds 是 K8s 给 Pod 的总宽限时间,必须大于你的 drain timeout,否则 K8s 会在 drain 结束前发 SIGKILL。


第二层:In-Flight 请求追踪

光有 drain window 不够------你需要知道"现在有多少个 LLM 请求还在跑",才能决定什么时候可以退出。

typescript 复制代码
// inflight-tracker.ts
export class InflightTracker {
  private requests = new Map<string, {
    startedAt: number;
    model: string;
    estimatedTokens?: number;
    abortController: AbortController;
  }>();

  register(requestId: string, model: string, abortController: AbortController) {
    this.requests.set(requestId, {
      startedAt: Date.now(),
      model,
      abortController,
    });
  }

  complete(requestId: string) {
    this.requests.delete(requestId);
  }

  get count() {
    return this.requests.size;
  }

  // 超过 maxAgeMs 的请求视为挂起,强制中止
  abortStale(maxAgeMs: number) {
    const now = Date.now();
    for (const [id, req] of this.requests) {
      if (now - req.startedAt > maxAgeMs) {
        console.warn(`[tracker] Aborting stale request ${id} (age: ${now - req.startedAt}ms)`);
        req.abortController.abort('shutdown-stale');
        this.requests.delete(id);
      }
    }
  }

  abortAll(reason: string) {
    for (const [id, req] of this.requests) {
      console.log(`[tracker] Aborting in-flight request ${id} (reason: ${reason})`);
      req.abortController.abort(reason);
    }
    this.requests.clear();
  }
}

export const inflightTracker = new InflightTracker();

在 drain window 里轮询:

typescript 复制代码
async function waitForInflightRequests(drainStart: number) {
  while (inflightTracker.count > 0) {
    const elapsed = Date.now() - drainStart;

    if (elapsed >= DRAIN_TIMEOUT_MS - 10_000) {
      // 最后 10 秒:中止所有剩余请求,发送 shutdown token
      console.warn(`[shutdown] Drain timeout approaching. Aborting ${inflightTracker.count} requests.`);
      inflightTracker.abortAll('shutdown');
      break;
    }

    console.log(`[shutdown] Waiting for ${inflightTracker.count} in-flight requests (elapsed: ${elapsed}ms)`);
    await new Promise(r => setTimeout(r, 2000));
  }
}

第三层:Shutdown Token + 客户端续传

最核心的一层,也是最容易被忽视的。

当 drain window 到期时,你不能直接关闭 SSE 连接------客户端会以为是网络故障,不知道该不该重试。你需要在 SSE 流的最后发一个特殊的 shutdown token,告诉客户端"我要停了,你可以从断点续传"。

服务端:发送 shutdown token

typescript 复制代码
// sse-handler.ts
import { inflightTracker } from './inflight-tracker';

export async function handleStreamRequest(req: Request, res: Response) {
  const requestId = req.headers['x-request-id'] || crypto.randomUUID();
  const abortController = new AbortController();

  // 注册到 tracker
  inflightTracker.register(requestId, req.body.model, abortController);

  // 设置 SSE headers
  res.setHeader('Content-Type', 'text/event-stream');
  res.setHeader('Cache-Control', 'no-cache');
  res.setHeader('X-Request-Id', requestId);

  // 监听 abort 信号(来自 drain window 超时)
  abortController.signal.addEventListener('abort', () => {
    const reason = abortController.signal.reason;

    // 发送 shutdown token,携带断点信息
    const checkpointData = {
      type: 'shutdown',
      reason,
      requestId,
      // 已生成的 token 数,用于客户端决策
      generatedTokens: tokenCounter.get(requestId) || 0,
      // 客户端可用此 ID 发起续传请求
      resumeToken: generateResumeToken(requestId),
      timestamp: Date.now(),
    };

    res.write(`data: ${JSON.stringify(checkpointData)}\n\n`);
    res.end();
    console.log(`[sse] Sent shutdown token for ${requestId}`);
  });

  try {
    // 正常流式生成
    for await (const chunk of llmClient.streamGenerate(req.body, {
      signal: abortController.signal,
    })) {
      if (res.destroyed) break;
      res.write(`data: ${JSON.stringify(chunk)}\n\n`);
      tokenCounter.increment(requestId);
    }
    // 正常结束
    res.write('data: [DONE]\n\n');
    res.end();
  } catch (e) {
    if (e.name === 'AbortError') {
      // abort 已通过 signal 事件处理,这里不重复发
    } else {
      res.write(`data: ${JSON.stringify({ type: 'error', message: e.message })}\n\n`);
      res.end();
    }
  } finally {
    inflightTracker.complete(requestId);
  }
}

客户端:识别 shutdown token 并续传

typescript 复制代码
// client.ts
async function* streamGenerate(prompt: string, options: {
  resumeToken?: string;
  previousContent?: string;
} = {}) {
  const response = await fetch('/api/generate', {
    method: 'POST',
    body: JSON.stringify({
      prompt,
      resumeToken: options.resumeToken,  // 告诉服务端这是续传
    }),
  });

  const reader = response.body!.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split('\n');
    buffer = lines.pop() || '';

    for (const line of lines) {
      if (!line.startsWith('data: ')) continue;
      const data = line.slice(6);
      if (data === '[DONE]') return;

      const event = JSON.parse(data);

      // 关键:识别 shutdown token
      if (event.type === 'shutdown') {
        console.log(`[client] Server shutting down. Resume token: ${event.resumeToken}`);
        // 自动重试,携带续传上下文
        yield* streamGenerate(prompt, {
          resumeToken: event.resumeToken,
          previousContent: options.previousContent,
        });
        return;
      }

      yield event;
    }
  }
}

第四层:计费状态持久化

这是最容易丢数据的地方。LLM 调用按 token 计费,如果 shutdown 时没有及时记录已消耗的 token,会出现两种问题:

  1. 少计费:用户用了 3000 token,系统没记录,损失收入
  2. 重计费:续传时重发了同一个 prompt,provider 扣了两次,但你只记录了一次

解法:token 消耗的幂等写入

typescript 复制代码
// token-billing.ts
import { createClient } from 'redis';

const redis = createClient({ url: process.env.REDIS_URL });

export async function recordTokenUsage(params: {
  requestId: string;
  userId: string;
  model: string;
  promptTokens: number;
  completionTokens: number;
  isPartial: boolean;  // shutdown 中断时为 true
  resumeToken?: string;
}) {
  // 幂等键:同一个 requestId 只写入一次
  const idempotencyKey = `billing:${params.requestId}`;
  const exists = await redis.exists(idempotencyKey);

  if (exists && !params.isPartial) {
    // 已有完整记录,不覆盖
    return;
  }

  // 原子性写入,用 MULTI/EXEC 避免部分写入
  await redis.multi()
    .hSet(`billing:record:${params.requestId}`, {
      userId: params.userId,
      model: params.model,
      promptTokens: params.promptTokens,
      completionTokens: params.completionTokens,
      isPartial: params.isPartial ? '1' : '0',
      resumeToken: params.resumeToken || '',
      recordedAt: Date.now(),
    })
    .expire(`billing:record:${params.requestId}`, 86400 * 7)  // 7 天 TTL
    .set(idempotencyKey, '1', { EX: 86400 * 7 })
    .exec();

  // 续传时,需要从 resumeToken 找到原始 requestId,扣除重复的 prompt token
  if (params.resumeToken) {
    const originalRequestId = await getOriginalRequestId(params.resumeToken);
    if (originalRequestId) {
      await deductDuplicatePromptTokens(originalRequestId, params.promptTokens);
    }
  }
}

实测数据:有无 graceful shutdown 的对比

我们在压测环境用 100 并发模拟 K8s 滚动更新,对比了两种配置:

指标 无 Graceful Shutdown 有 Graceful Shutdown
中断请求数 / 次更新 ~40 个 0 个(drain window 内)
客户端 503 率 12.3% 0.1%(仅极端慢请求)
Token 计费丢失率 8.7% 0.02%
滚动更新耗时 45s 165s(含 drain window)
用户可感知中断 高频 极低

耗时增加了,但用户体验和计费准确性都大幅提升。对于 LLM 服务,这个代价是值得的。


几个容易踩的坑

坑 1:preStop hook 不够长

K8s 的 preStop 阶段和 terminationGracePeriodSeconds 是并行计时的,不是串行的。很多人以为 preStop sleep 5 + terminationGracePeriodSeconds 180 = 185 秒,实际上 Pod 只有 180 秒,preStop sleep 5 会占用其中 5 秒。

坑 2:Nginx / Envoy 在 Pod 前面先关闭

如果你用 Nginx sidecar 做 SSL 终止,Nginx 收到 SIGTERM 可能比你的 LLM server 先关闭,导致已建立的连接也断了。需要给 Nginx 配置 worker_shutdown_timeout 大于你的 drain window。

nginx 复制代码
worker_shutdown_timeout 130s;  # 比 drain window 略大

坑 3:SSE 连接被 ALB/CLB 强制超时

AWS ALB 默认 idle timeout 60 秒,LLM streaming 可能超过。需要:

  • 提高 ALB idle timeout(最大 4000 秒)
  • 或在 SSE 流中定期发送 keep-alive 心跳(空注释行)
typescript 复制代码
// 每 30 秒发一个 keep-alive
const keepAlive = setInterval(() => {
  if (!res.destroyed) res.write(': keepalive\n\n');
}, 30_000);
// 完成时清除
onComplete(() => clearInterval(keepAlive));

坑 4:drain window 过长导致 K8s 反复驱逐

如果 drain window 设为 300 秒,K8s 在资源紧张时可能把这个 Pod 标记为"响应过慢"并强制驱逐。建议配合 PodDisruptionBudget 限制同时下线的 Pod 数量:

yaml 复制代码
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
  name: llm-server-pdb
spec:
  minAvailable: "50%"
  selector:
    matchLabels:
      app: llm-server

方案汇总

bash 复制代码
┌─────────────────────────────────────────────────────────────┐
│                    Graceful Shutdown 四层                    │
├─────────────────────────────────────────────────────────────┤
│ 第 1 层  SIGTERM 处理 + Drain Window                         │
│          terminationGracePeriodSeconds > drainTimeout       │
│          preStop sleep 5 给 kube-proxy 时间                 │
├─────────────────────────────────────────────────────────────┤
│ 第 2 层  In-Flight 追踪                                      │
│          Map<requestId, AbortController>                    │
│          drain 结束前中止剩余请求                            │
├─────────────────────────────────────────────────────────────┤
│ 第 3 层  Shutdown Token + 客户端续传                         │
│          SSE 最后发 { type: 'shutdown', resumeToken }       │
│          客户端自动续传,对用户无感知                        │
├─────────────────────────────────────────────────────────────┤
│ 第 4 层  计费状态持久化                                      │
│          幂等写入,续传时扣除重复 prompt token               │
└─────────────────────────────────────────────────────────────┘

总结

LLM 应用的 graceful shutdown 难点不在于"停进程",而在于:

  1. 告知客户端:用 shutdown token 区分"服务停止"和"网络故障"
  2. 保留断点:用 resumeToken 让客户端能续传而非从头重试
  3. 持久化计费:shutdown 前把已消耗的 token 原子性写入,防止漏计和重计
  4. 基础设施配合:drain window 必须和 K8s terminationGracePeriodSeconds、ALB timeout、Nginx 配置对齐

生产中我们配置的值:DRAIN_TIMEOUT_MS=120000terminationGracePeriodSeconds=180,ALB idle timeout=600。这组配置在 99% 的场景下能让正在进行的请求正常结束,只有极少数 2 分钟以上的超长生成会触发 shutdown token 续传。

代码已经能直接跑,有问题欢迎在评论区讨论。

相关推荐
win4r11 小时前
RSI真的临近了吗?AI开始改进AI,但“智能爆炸”还差关键一跳
aigc·openai·ai编程
武汉唯众智创11 小时前
云计算实训室建设指南(2026版):从技能大赛赛项标准反推架构、课程与落地路径
云原生·kubernetes·云计算·云计算实训室·云计算教学平台·职业技能大赛
孟健13 小时前
程序员创业快一年:做出产品不等于拥有生意
ai编程
程序员老刘13 小时前
价值200的AI订阅实测,大家自己判断是否划算
ai编程
阿里云云原生15 小时前
剧透丨AI 原生研发组织的探索和实践
云原生
kyriewen16 小时前
GPT-6 拿下模型众测第一:我拆完 30 个主题的实时榜单,「最强 AI」得看你问哪个场景
人工智能·程序员·ai编程
七牛云行业应用16 小时前
2026 年 9 月 Coding Agent Harness 选型完整指南:30 个工具、SDK 与运行时
人工智能·agent·ai编程
ServBay16 小时前
ServBay 1.33.0 来了:AI Gateway 一键接管主流 AI CLI 与多模型
后端·aigc·ai编程
袋鼠云数栈UED团队16 小时前
AI Coding 方法论分析:同一个需求 SpecKit 、 Superpowers、 MattpocockSkills 不同设计
前端·aigc·ai编程