Under the Sun with Paddy

第 88 期 · 2026年10月16日

从 DeepSeek 宕机说起:为什么你的 AI 应用需要好架构

今天(2026-10-15)DeepSeek 服务挂了大约四个小时。我手头依赖工具集体罢工,翻了一下社交媒体,不少开发者和产品团队都在吐槽。

这件事让我想聊几个更深的问题,以及对应的技术解法。

一、世界是个草台班子,但专业公司在对抗熵增

先说一个直觉:任何规模足够大的系统,出故障都是常态,不是例外。

这不针对 DeepSeek。OpenAI 2023 年 11 月那次全线宕机持续了数小时,Hacker News 上有几百条评论在复盘当时的影响 1。Google SRE 手册里有一章专门讲「级联失败」——一个组件过载导致相邻组件也挂掉,像多米诺骨牌一样扩散 2。

所以「世界是草台班子」这个感受是对的。但反过来看:正因为系统天然会坏,才需要专业的工程团队、商业公司和制度设计去做一件事——对抗熵增。你的手机信号大多数时候是通的、你刷的网站大多数时候能打开,不是因为它们不会坏,而是因为有人在设计冗余、监控、恢复流程。

当然也希望“文科程序员”们意识到,有了AI确实可以一句话开发出软件了,但持续稳定的运行才能称之为服务,而这依赖于无数人的努力和坚守。

落到我们写的 AI 应用上,道理一样:你不能假设第三方 API 永远在线。你需要为故障做设计。

二、SaaS 的依赖转移:从自建模型到调用 API

大模型火了之后,一个明显的趋势是:大部分 SaaS 公司不会自己训练和部署模型。

原因很直接:训练一个大模型的成本是千万甚至亿级,推理部署需要 GPU 集群和运维团队。绝大多数公司选择直接调用 OpenAI、Anthropic、DeepSeek 这些厂商的 API 3。

这意味着什么?意味着 SaaS 产品的核心功能——以前可能是自己写的业务逻辑——现在有相当一部分依赖外部服务。你的 app 能不能用,不只取决于你的代码,还取决于第三方 API 能不能正常响应。

三、鲁棒性:系统在故障下还能跑吗?

鲁棒性(robustness)的意思很朴素:系统在遇到问题时,是直接崩掉,还是能降级运行?

放到 AI 调用的场景里,鲁棒性至少要回答这几个问题:

  1. API 偶尔超时或返回 5xx,你会重试吗?重试几次?
  2. API 完全挂了,你能切到另一家厂商吗?切换对业务代码透明吗?
  3. 所有厂商都挂了,你的产品能给出一个可预期的降级结果,而不是抛异常吗?

这些问题的答案不应该散落在每个调用点,而应该有统一的机制。

3.1 错误分类学:先搞清楚「坏」有几种坏

第一步不是写重试逻辑,而是先把错误分类。因为不同的错误,处理方式完全不同。

flowchart LR A[API 调用失败] --> B{什么类型的错误?} B -->|瞬时故障<br/>5xx/429/断网| C[TransportError<br/>可重试] B -->|确定性失败<br/>401/402/参数错| D[SemanticError<br/>不可重试] B -->|响应格式不对<br/>缺字段/维度错| E[ContractError<br/>不可重试] B -->|输出无法解析<br/>JSON 坏/枚举越界| F[ParseError<br/>业务层重试] C --> G[传输层指数退避重试] D --> H[直接报错,找根因] E --> H F --> I[带错误提示再试一次]

四种基本错误类型:

  • TransportError:网络抖动、服务端 5xx、限流 429。这类错误可以重试——过一会儿再发可能就好了。
  • SemanticError:鉴权失败(401)、余额不足(402)、请求参数错误(400/422)。重试没有意义,再试也是同样的结果。
  • ContractError:返回的响应不符合约定——比如你请求了 10 条 embedding,结果只回来了 8 条。这说明要么你请求写错了,要么服务端版本不匹配,重试没用。
  • ParseError:模型返回的内容解析失败(比如让它输出 JSON 结果格式不对)。这种错误的重试不该在传输层做,而应该在业务层——把解析错误的信息一起发回去,让模型修正。

分类的价值在于:谁负责重试,重试几次,策略是什么——一次性说清楚。不会出现每个调用点自己写 while 循环、自己判断状态码的混乱局面。

Vercel AI SDK 的设计就是把 isRetryable 这种判断放在错误类的构造期推导,而不是让调用方自己写字符串判断 4。

3.2 重试的纪律

重试不是越多越好。Google SRE 书里专门警告过「重试叠乘」的问题:如果你的服务有 4 层,每层都重试 4 次,一次失败就会变成 4³ = 64 次尝试,反而会加重故障 2。

几条基本规则:

  • 重试只在传输层做,业务层不要自己写重试循环。
  • 指数退避 + 抖动(jitter):第一次等 1 秒,第二次 2 秒,第三次 4 秒,并且每次加一个随机偏移,避免所有客户端同时重试(惊群效应)。
  • 尊重 Retry-After 头:服务端说 30 秒后再试,就老老实实等 30 秒。
  • 重试有上限:通常 2-3 次就够了,再多就是浪费时间和配额。

3.3 故障转移:一家挂了切另一家

重试只能解决瞬时故障。如果一家厂商彻底挂了几小时,你需要能切到另一家。

这就是「故障转移」(failover)——维护一个有序的候选列表,第一个失败了试第二个,第二个失败了试第三个。

sequenceDiagram participant App as 业务代码 participant Router as 选源层 participant P1 as 厂商 A (主) participant P2 as 厂商 B (备) App->>Router: chat("你好") Router->>P1: 发送请求 P1-->>Router: 503 Service Unavailable Router->>Router: 标记 P1 冷却 60s Router->>P2: 发送请求 P2-->>Router: 200 OK + 响应 Router-->>App: 返回响应

但故障转移有几个容易踩的坑:

坑一:什么错误都切换。 参数错误(400)换哪家厂商都会失败,白付延迟。LiteLLM 和 Portkey 的实现都有明确的「切换白名单」——只有特定类型的错误才触发切换 56。

坑二:冷却唯一候选源。 如果你只有一家厂商,就不该有熔断/冷却逻辑——单源的情况下,故障转移没有意义,快速失败走降级就好。

坑三:流式输出已经开始了还切换。 流式响应如果已经吐出了一半内容,切换到另一家厂商只会产生重复计费和拼接残流。主流实现都是「首内容块后不切换」5。

四、可扩展性:模型换代这么快,你改得起吗?

鲁棒性解决的是「服务挂了怎么办」。可扩展性解决的是「出了新模型 / 换了厂商,你要改多少代码」。

一个典型的场景:你一开始只用 OpenAI 的 gpt-4o-mini,后来想加一个 DeepSeek 的便宜档,再后来又想加 Anthropic 的高质量档。每加一家,你要改多少代码?

如果你的代码里到处都是 openai.ChatCompletion.create(...),那每加一家厂商都是灾难——每个调用点都要改。

好的架构应该做到:

变化目标成本
换同协议的另一家厂商改配置,0 行代码
同厂商加一个新模型注册表加一行
接入一种新协议(比如决策问答)写一个协议族子类
接入一种新能力(比如向量化)复制一套已知模式

4.1 分层架构:把变化关在自己的层里

核心思路是分层。每一层只管自己的事,变化只在自己那一层发生。

graph TB L3["L3 业务层<br/>写业务逻辑,选档位"] L2["L2 能力域层<br/>统一接口 + 协议族 + 厂商实现"] L1["L1 传输脊<br/>HTTP 客户端 · 重试 · 错误分类 · 计量"] L0["L0 横切面<br/>配置 · 版本键"] L3 -->|"只调用统一接口"| L2 L2 -->|"只用传输端口"| L1 L0 -.-> L3 L0 -.-> L2

从上到下解释:

L3 业务层:你的业务代码。它只知道「我要一个对话能力」或「我要一个向量化能力」,不知道具体是哪家厂商、什么模型。它调用统一的接口,传业务参数。

L2 能力域层:按能力划分(对话、向量化、重排……),每种能力有一个统一的抽象基类(ABC)。同一种能力下,不同的协议族(比如 OpenAI 兼容协议、厂商自有协议)各有一个基类,具体厂商再继承协议族基类。

理想情况下,厂商子类应该只剩配置——base_url、api_key、模型名。如果一个厂商子类里有大量业务逻辑,说明层次没切对。

L1 传输脊:所有能力共享同一套 HTTP 客户端、重试逻辑、错误分类、计量。代码库里不应该出现第二份重试循环。

L0 横切面:配置读取、版本键管理这种全局的东西,放在最底层,所有人都能用,但传输脊和能力域不直接读环境变量——配置从上层传进来。

Vercel AI SDK 就是按能力切接口的典型:languageModel、embeddingModel、rerankModel 各是各的接口,厂商分别实现 7。

4.2 注册表模式:加新厂商不用改调用代码

「注册表」(registry)是一个很简单但很有用的模式。你维护一个字典,名字映射到实现类。业务代码用名字来选 provider,不用 import 具体的类。

用 Python 写出来大概是这样:

# chat/registry.py
_chat_providers: dict[str, type[ChatProvider]] = {}

def register(name: str):
    """类装饰器:把厂商实现注册进来"""
    def decorator(cls):
        _chat_providers[name] = cls
        return cls
    return decorator

def get_provider(name: str) -> ChatProvider:
    if name not in _chat_providers:
        raise ValueError(f"未知 provider: {name}")
    return _chat_providers[name]()
# chat/deepseek.py
from .registry import register
from .openai_family import OpenAIFamilyChat

@register("deepseek")
class DeepSeekChat(OpenAIFamilyChat):
    base_url = "https://api.deepseek.com/v1"
    default_model = "deepseek-chat"
# 业务代码
from externals.chat.registry import get_provider

provider = get_provider("deepseek")
result = provider.chat("你好")

加新厂商时,你只需要写一个新文件,用装饰器注册一下。业务代码完全不用改。simonw/llm 这个 CLI 工具的插件机制用的就是类似的思路 8。

4.3 版本键:换了模型,旧结果不能混用

这是一个容易被忽略但后果严重的问题。

假设你用 embedding 模型 A 生成了一批向量,存在向量数据库里做检索。后来你换成了模型 B,向量维度或者向量空间分布不一样了——这时候新旧向量混在一起,检索质量会无声地下降,而且你可能很久都发现不了。

Qdrant 的官方迁移文档专门讲这个问题:embedding 模型的结果不可互换,换模型必须带版本标识 9。

解决方法很简单:所有「换了生成源就不可比」的产物——向量、缓存、提示词模板输出——都带一个 model_version 字段。版本不匹配视同缺失,逐步用新模型重新生成。

class ItemVector(Base):
    __table_args__ = (UniqueConstraint("item_id", "model_version"),)
    model_version: Mapped[str]  # 比如 "bge-m3" 或 "text-embedding-3-small"
    vector: Mapped[list[float]]

这个版本槽位必须第一天就留。等你以后想换模型了才发现没留,就只能全量重嵌——数据量大的话,成本和时间都很可观。

五、回到今天的宕机事件

如果今天你的应用因为 DeepSeek 挂了而完全不可用,说明什么?

  • 你可能没有错误分类——超时和参数错误混在一起处理
  • 你可能没有重试机制——一次失败就直接报错给用户
  • 你可能只有一家厂商——没有备选方案
  • 你可能把厂商 API 调用散落在业务代码里——想加备选也不知道从哪改

这些都不是今天改明天就能好的事。但好消息是,你可以从小处开始:

  1. 先把错误分类做了:把所有外部调用的异常统一归到几类里
  2. 再加一个统一的 HTTP 客户端:重试和脱敏只写一份
  3. 再抽一个统一的对话接口:业务代码不再直接调用厂商 SDK
  4. 最后再加第二家厂商做备选:配置里切一下就能用

Google SRE 手册里有一句话说得好:「不用的代码路径 = 不工作的代码路径」10。故障转移这种东西,只有真正演练过,你才知道它能不能用。


参考引用

  1. Hacker News: OpenAI is down (2023-11-08). https://news.ycombinator.com/item?id=38190401 ↩︎
  2. Google SRE Book: Addressing Cascading Failures. https://sre.google/sre-book/addressing-cascading-failures/ ↩︎
  3. Anthropic Engineering: How we built our multi-agent research system (2025-06-13). https://www.anthropic.com/engineering/multi-agent-research-system ↩︎
  4. Vercel AI SDK: AI SDK Errors – AIAPICallError. https://ai-sdk.dev/docs/reference/ai-sdk-errors#ai-api-call-error ↩︎
  5. LiteLLM Router source code (commit 2211fa5f3f27). https://github.com/BerriAI/litellm/blob/2211fa5f3f27/litellm/router.py ↩︎
  6. Portkey AI Gateway source code (commit 669825cb). https://github.com/Portkey-AI/gateway/blob/669825cbe89ee51569918b8f78a9db486fd69dd4/src/handlers/handlerUtils.ts ↩︎
  7. Vercel AI SDK: Provider Management. https://ai-sdk.dev/docs/ai-sdk-core/provider-management ↩︎
  8. Simon Willison: llm, a CLI utility and Python library for interacting with Large Language Models. https://simonwillison.net/2023/Jul/12/llm/ ↩︎
  9. Qdrant Documentation: Migrate to a New Embedding Model with Zero Downtime. https://qdrant.tech/documentation/tutorials-operations/embedding-model-migration ↩︎
  10. Google SRE Workbook: Managing Load. https://sre.google/workbook/managing-load/ ↩︎
广告位
广告位
广告位
广告位
广告位
广告位

读者来信(1)

  1. 执迷者 X 的头像

    从DeepSeek四小时宕机聊到对抗熵增和错误分类学很到位重试纪律那段尤其受用

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注