从确定性到不确定性:用 MDP 给后端系统重新建模

The Great Wave off Kanagawa, Katsushika Hokusai, c. 1831
葛饰北斋,《神奈川冲浪里》, c. 1831 — 渔夫驾着小舟在巨浪的阴影下划行,每一桨的结果都不完全由自己决定。海浪的高度、方向、破碎的时机是概率性的,但人仍要在每一个瞬间做出选择:向前、停桨、还是调整方向。这是 MDP 最古老的原型——不确定的世界里,不确定的行动带来不确定的结果,而策略(Policy)是人能把握的唯一东西
这篇文章的价值:LLM 时代之前,我们追求的是"写对"——代码是确定的函数,输入 X 输出 Y,review 一遍就能兜住所有分支。但当 Copilot、Cursor、Claude Code 开始大量生产代码,review 的信噪比急剧下降,"确定性"本身变成了一种幻觉。与此同时,后端系统里很多问题——重试、熔断、缓存——被我们用状态机硬编码了多年,却一直没有最优解。本文的观点是:状态机只是概率状态机的一个退化特例。认识到这一点,就能用 MDP(Markov Decision Process)统一建模这一类"在不确定世界里做决策"的问题。文章从确定性崩塌讲起,梳理 6 种概率状态模型,然后用最朴素的过河问题把 MDP 讲清楚,最后落到重试、熔断、缓存这三个每个后端工程师都写过的场景。

一、确定性的崩塌

在 LLM 之前,写代码是一件非常确定的事。你写 if x > 0 return 1 else return 0,它在每一次运行里都按你想的那样执行。测试的本质是枚举输入空间的一个子集,证明代码在这些点上行为正确。Code review 的本质是人类大脑在运行一个小型的符号执行器,逐行验证你写下的每一个分支。

这套模型的前提是:代码是人一行一行写出来的,评审者有机会看到每一行

2026 年的工程现场已经不是这样了。一个 PR 动辄几百上千行由 agent 生成的代码,review 者即使逐行看完,也很难在有限时间内建立起同样质量的心智模型。更常见的情况是:你 review 了主干逻辑,但错过了一个边角的 fallback 分支、一个被默默加进来的环境变量、一个没人注意的超时配置。

这不是 LLM 的锅——这是"软件规模 × 生产速度"早就预示过的趋势,LLM 只是把它加速了 10 倍。结果就是:

  以前的代码:                 现在的代码:
  ┌──────────────┐            ┌──────────────┐
  │  确定的函数   │            │  大部分确定   │
  │  f(x) = y    │            │  少数角落     │
  │              │            │  你并不真的   │
  │  完全可预测  │            │  知道会发生啥 │
  └──────────────┘            └──────────────┘
        ↑                           ↑
   代码即事实                  代码是"大概率如此"

换句话说:代码本身变成了一个带噪声的分布。我们从"在确定世界里跑确定的程序"滑向了"在不确定世界里跑不完全确定的程序"。

这件事并不可怕——真正可怕的是我们用的建模工具还停留在确定性时代。

二、状态机只是概率状态机的退化特例

翻一翻任何一本后端架构的书,你会看到大量的有限状态机(FSM):TCP 连接的 SYN_SENT → ESTABLISHED → FIN_WAIT,订单系统的 CREATED → PAID → SHIPPED → COMPLETED,熔断器的 CLOSED → OPEN → HALF_OPEN

状态机是个好东西,它把混乱的业务逻辑画成了一张清清楚楚的图。但它有一个巨大的前提假设:状态之间的转移是确定的。在状态 S 下执行动作 A,一定会转移到状态 S'。

真实世界不是这样。真实世界里:

在状态 S 下执行动作 A,系统会以某个概率 P(S' | S, A) 转移到 S'。这就是概率状态机

  确定性状态机:                          概率状态机:

  ┌────┐   action a    ┌────┐            ┌────┐   action a    ┌────┐
  │ S1 │ ────────────► │ S2 │            │ S1 │ ────0.8─────► │ S2 │
  └────┘               └────┘            └────┘               └────┘
   "在 S1 执行 a                            │
    一定到 S2"                              └────0.2─────► ┌────┐
                                                            │ S3 │
                                                            └────┘
                                           "在 S1 执行 a
                                            80% 概率到 S2,20% 到 S3"

任何一个确定性状态机都是概率状态机的特例——转移概率非 0 即 1。反过来说不成立:你不能用普通 FSM 正确表达"重试 3 次之后大概率能成功"这种事,你只能用阈值 + 硬编码近似它。

三、概率状态模型的六种形式

"概率状态机"不是一个单一模型,而是一族模型。根据时间是否离散是否有决策者在施加动作状态是否可观测这三个维度,可以切出 6 种典型形式:

模型时间是否含动作状态可观测典型用途
DTMC Discrete-Time Markov Chain离散PageRank、排队系统稳态分析
CTMC Continuous-Time Markov Chain连续故障率建模、化学反应动力学
HMM Hidden Markov Model离散语音识别、基因序列、异常检测
MDP Markov Decision Process离散重试/熔断/缓存策略、强化学习
SMDP Semi-Markov Decision Process连续资源调度、期权定价
POMDP Partially Observable MDP离散机器人导航、故障诊断、对话系统

它们之间的关系可以画成一棵树:

                        概率状态模型
                              │
              ┌───────────────┴───────────────┐
              │                               │
          没有动作                         有动作(有决策者)
          (系统自己演化)                  (你在做决策)
              │                               │
       ┌──────┴──────┐                 ┌─────┴─────┐
       │             │                 │           │
    全观测         部分观测           全观测      部分观测
       │             │                 │           │
   ┌───┴───┐         │            ┌────┴────┐      │
   │       │         │            │         │      │
 DTMC    CTMC       HMM          MDP      SMDP  POMDP
 (离散)  (连续)    (离散)       (离散)   (连续) (离散)

这 6 种模型的数学工具是一脉相承的——都围绕 Markov 性质("未来只依赖当前")展开,区别在于建模粒度。对后端工程师最有用的是 MDP:时间是离散的(每次请求/每个时钟滴答做一次决策),我们在做决策(retry 还是不 retry?走主链路还是 fallback?),状态基本可观测(我们有指标、有日志)。

接下来这篇文章只讲 MDP。

四、最朴素的 MDP:过河问题

想象你站在河的左岸,想走到右岸。河里有三块可以落脚的石头,每块石头上都有不同程度的青苔——踩上去可能稳稳站住,也可能滑一下:

  左岸                                                    右岸
   │                                                       │
   │   ┌──────┐      ┌──────┐      ┌──────┐               │
   │   │ 石头 │      │ 石头 │      │ 石头 │               │
   │   │  1   │      │  2   │      │  3   │               │
   │   └──────┘      └──────┘      └──────┘               │
   │                                                       │
   └──────────────────── 河 ───────────────────────────────┘

用 MDP 的五元组 ⟨S, A, P, R, γ⟩ 把这个问题形式化:

所谓"解 MDP",就是找到一个策略(Policy) π: S → A,让未来累计折扣奖励的期望最大:

π* = argmax_π  E[ Σ γ^t · R_t | π ]

直观来说:在每一个状态下应该选哪个动作?策略告诉你这件事。

过河问题里一个可能的最优策略大概是:

  状态 = 左岸   →  动作 = 跳一块 (进入石 1)
  状态 = 石 1   →  动作 = 跳一块 (青苔少,成功率高)
  状态 = 石 2   →  动作 = 停下观察(青苔多,先等水流稳定)
  状态 = 石 2'  →  动作 = 跳两块 (直接跳到右岸,跳过最滑的石 3)
  状态 = 石 3   →  动作 = 跳一块 (到达右岸)
  状态 = 落水   →  动作 = 游回左岸重来

这里有几个特别值得注意的点,正是 MDP 区别于状态机的核心:

  1. 策略是"状态到动作"的映射,而不是"路径"。它不告诉你该走哪一条具体的路径,而是告诉你在任何可能出现的状态下如何反应——包括你完全没预料到的"落水"状态
  2. 好的策略会主动"对冲"不确定性。在石 2 上多观察一会儿,就是用时间成本换取更高的成功概率
  3. γ 控制了你多"短视"。γ → 0 时你只在乎立刻的奖励(贪心策略),γ → 1 时你愿意为遥远的大奖励付出很多眼前代价(长远策略)

怎么求解?对于状态空间小的问题,Bellman 方程 + Value Iteration / Policy Iteration 就够了:

V*(s) = max_a [ R(s, a) + γ · Σ_s' P(s'|s,a) · V*(s') ]

意思是:状态 s 的最大价值 = 选出那个让"即时奖励 + 折扣后续价值"最大的动作

当状态空间巨大(比如整个线上服务的观测空间),就不再人工枚举——你用强化学习(Q-Learning、PPO 等)从数据里学出策略。本质上强化学习就是在解一个转移概率未知的 MDP

五、重试策略:MDP 视角下的经典问题

几乎每个后端工程师都写过重试。最常见的代码长这样:

for attempt in range(MAX_RETRIES):
    try:
        return call_downstream()
    except TimeoutError:
        time.sleep(BACKOFF_BASE * (2 ** attempt))  # 指数退避
raise ServiceUnavailable()

这段代码把"重试"建模成了一个简单的 for 循环 + 固定策略。但仔细想想,每次重试都是在做一个决策:在当前失败次数、当前延迟、当前错误类型下,我是该立刻重试、退避重试、切换到 fallback、还是直接放弃?

用 MDP 重新建模:

  状态 S = (已重试次数 n, 最近一次错误类型 e, 当前累计延迟 l,
            下游健康信号 h)

  动作 A = { retry_now, retry_with_backoff_k, fallback_to_cache,
             fallback_to_secondary, give_up }

  转移 P = 取决于下游真实状态,通常通过观测学习得到
          例如:在 (n=2, e=Timeout, l=200ms, h=降级) 下执行 retry_now
                成功概率 ≈ 0.3(下游还没恢复,继续失败概率高)

  奖励 R = {
    success:       +100 - α·l              (减去累计延迟成本)
    final_failure: -500                    (用户真的拿不到结果)
    每次 retry:    -β                      (重试本身的资源成本)
    fallback:      +50                     (给了降级结果)
  }

一旦写成 MDP,几个传统重试做不到的事情就自然浮现出来:

传统指数退避MDP 最优策略
在所有错误类型上用同一个策略根据错误类型动态决策:5xx 可能值得重试,4xx 几乎一定不值得
重试次数是超参,靠拍脑袋重试次数由状态价值函数决定,累计延迟超过某阈值就 fallback
下游刚恢复时重试和持续抖动时重试完全一样引入 h(下游健康信号)做条件决策,利用跨请求的共享信息
重试策略和 fallback 策略是两套独立逻辑重试、fallback、放弃在同一个动作空间里统一权衡

更进一步:自适应重试(Adaptive Retry)本质上就是在线学习 MDP 的策略。AWS SDK 里的 AdaptiveRetryMode、Envoy 里的 Retry Budget,它们的内核都是在用实时观测修正"当前状态下重试的期望收益",只是没人称它们为 MDP 而已。

六、熔断器:状态机 vs MDP 的经典对比

Circuit Breaker 是后端最著名的有限状态机之一,标准三态模型:

                 失败率 > 阈值
  ┌─────────┐ ──────────────────► ┌─────────┐
  │ CLOSED  │                      │  OPEN   │
  │ (正常)   │ ◄───── 探活成功 ──── │ (拒绝)  │
  └─────────┘                      └─────────┘
       ▲                                │
       │                                │ 冷却时间 T
       │    探活失败                    ▼
       │ ┌─────────────────────── ┌─────────┐
       └─┤                         │HALF_OPEN│
         │                         │ (试探)   │
         └────── 探活成功 ──────── └─────────┘

这个模型被 Netflix Hystrix、Resilience4j、Sentinel 抄了无数遍。它能工作,但它在几个地方漏了假设:

每个参数都是一个待调的超参,而且它们彼此纠缠:阈值设得低 → 更早熔断 → 冷却期间用户流量全打到 fallback → fallback 被打挂 → 冷却时间就得更长……

用 MDP 重新看这件事:

  状态 S = (近期成功率 p, 近期平均延迟 τ, 下游 QPS q,
            上游 fallback 容量 c, 距上次熔断时间 t)

  动作 A = {
    allow_all,                // 全放行
    rate_limit(ρ),            // 放行比例 ρ
    shed(%),                  // 按优先级丢弃一部分
    probe_n_requests,         // HALF_OPEN 放 n 个探活
    block_all,                // 完全熔断
  }

  奖励 R = -latency_cost - error_cost - fallback_overload_penalty
          + success_throughput

区别在于:

  1. 动作不再是粗粒度的"开/关/半开",而是一个连续谱(放行比例 ρ)。这让熔断器可以做"软熔断"——下游有点喘不过气时先限 30% 流量,而不是二极管式地直接切断
  2. 奖励里显式把"fallback 被打挂"作为惩罚项,解决了前面说的"熔断过猛反而打垮上游"的连锁问题
  3. 策略可以从历史数据里学出来。你不用再拍阈值——你观测历史,让 Q-Learning 告诉你在 (p=85%, τ=800ms, q=10k) 下最优动作是什么

这就是 Google Maglev 团队、Meta 自适应限流系统背后的基本思想。它们在外面看起来仍然像个"熔断器",但底下是一个持续学习的 MDP 策略,而不是一套硬编码阈值。

七、缓存策略:LRU/LFU 只是简陋的策略

缓存替换算法是另一个被状态机思维统治的领域。LRU、LFU、ARC、2Q——每一个都是一套固定规则。但本质上,"缓存"就是一个 MDP:

  状态 S = (当前缓存里有哪些 key,每个 key 的元信息:
            上次访问时间、访问频率、大小、回源成本 τ_k)

  动作 A = 当一次请求到达、cache miss 需要写入时:
          { 淘汰 key_1, 淘汰 key_2, ..., 不缓存本次结果 }

  转移 P = 下一个请求会命中哪个 key ——由访问模式的分布决定
          这就是 MDP 里"转移概率"在缓存场景的化身

  奖励 R = {
    命中:      +τ_k      (省下了一次昂贵的回源)
    miss:      -τ_k      (付出了一次回源成本)
    存储占用:  -α·size_k (内存/存储是有成本的)
  }

LRU 在这个视角下就是一个非常简单的启发式策略:无论回源成本 τ、无论访问频率、无论对象大小,一律按"最久未访问"淘汰。它的假设是未来访问模式和最近访问模式高度相关——这个假设在随机访问的场景下会被打脸。

策略本质隐含假设
LRU按最近访问时间淘汰时间局部性强,所有 key 的回源成本相同
LFU按历史访问频率淘汰访问频率稳定、不会漂移
W-TinyLFU (Caffeine)频率 + 新鲜度加权前两者的线性组合近似
MDP 最优策略基于期望未来命中收益淘汰依赖对未来访问分布的估计——实际中用学习

这几年工业界开始出现"learned cache"相关工作(Meta 的 Cachelib 里的 ML replacement、Google CacheSack),核心套路都是:把缓存建模成 MDP,用观测数据学出一个比 LRU 更接近最优的替换策略。在命中率绝对值上通常能比 LRU 多拿 3–15%,在大规模系统里就是真金白银。

八、什么时候不要用 MDP

把每个后端问题都硬往 MDP 上套,是一种过度工程。MDP 适合的前提是:

反例:

九、总结

这篇文章其实讲了三件事:

  1. LLM 时代,写出来的代码越来越像一个分布,而不是一个函数。确定性思维(FSM、硬编码分支、拍脑袋阈值)会让系统在真实噪声下暴露得比过去更多
  2. 状态机只是概率状态机的退化情况。DTMC、CTMC、MDP、SMDP、HMM、POMDP 构成了一族模型,覆盖了"时间 × 动作 × 观测"的六种组合。MDP 是后端工程师最容易用上的那一种
  3. 你每天写的重试、熔断、缓存,本质上都是 MDP。只是你在用硬编码阈值近似最优策略。真正把它们建模成 MDP 之后,自适应重试、软熔断、learned cache 就不再是玄学,而是同一个数学框架下的自然产物

下一次你打算加一个 if retry_count > 3 then give_up、一个 if error_rate > 0.5 then circuit_open、一个 if size > 1MB then don't cache 的时候,停一秒问自己:这个阈值是哪里来的?它是对哪个"期望奖励"的近似?在观测数据下它是否仍然最优?

这个问题的答案,就是从确定性走向不确定性的第一步。