新瓶装旧酒:纸牌魔术 MCP

Chico 和 Dico 是两名魔术师,他们有一个经典纸牌魔术:抽五张扑克牌,根据前四张,猜第五张是什么。完成这个魔术不需要任何魔术师的技巧,它完全建立在数学原理之上。只需要一点数学知识和充分的练习,你也能表演这个魔术。

GitHub 项目地址:card-magic-mcp

五年前,我写了一篇文章《用魔法打败魔法》,介绍 Chico & Dico 魔术的数学原理,并用 Python 实现了这个魔术。随着时代的发展、科技的进步,今天我们有更简单的方法来玩这个魔术,不需要运行 Python,不需要看懂公式,只需要打开 ModelScope 和大模型对话,就可以轻松体验这个魔术↓↓↓

访问 ModelScope Playground: card-magic-mcp

下面我将介绍这个魔术的数学原理和算法实现,然后讲解如何用 FastMCP 将魔术流程包装成大语言模型可以调用的工具(MCP Server)。

Chico & Dico 的魔术

Chico 和 Dico 是两位魔术师,他们经常表演的一个魔术是这样的:Chico 将一副 52 张的扑克牌交给观众,让他抽取其中的 5 张。然后 Chico 将这 5 张牌按一定的顺序重新排列,再将其还给观众。上述整个过程 Dico 都是看不见的。然而,当观众按 Chico 排好的顺序依次展示前四张牌之后,Dico 却能准确地说出第五张牌是什么。

乍一看这个魔术很神奇。随机抽取 5 张牌,你能从前 4 张牌中看出第 5 张牌是什么吗?对于普通人来说,当然不行。Chico 和 Dico 的秘诀在于 Chico 有机会将扑克重新排序。如果两人事先约定好某种协议,Chico 有可能通过前四张扑克排列的顺序,向 Dico 传递关于第 5 张牌的信息。

关键在于如何实现这种协议。

数学原理

在进行分析之前,我们需要把 Chico & Dico 的魔术抽象成一个数学模型。

魔术本身已经提供了一些建模信息。假设一共有 $n$ 张牌,抽取其中的 $k$ 张。在对牌进行某种排序之后,我们需要利用前 $(k - j)$ 张牌的信息,推测后 $j$ 张牌是什么。

现在我们有 $n, j, k$ 三个变量,下面我们来进一步探究三个变量之间服从何种关系。

首先,我们知道从 $n$ 张牌中抽取 $k$ 张牌一共有 $C(n, k)$ 种组合方式,而 $(k - j)$ 张牌的排列方式则有 $A(n, k-j)$ 种。按照模型的要求,我们要用后者对前者的信息进行编码。根据信息论中的信源编码理论,至少有: $$C(n, k) \leq A(n, k-j)$$ 根据上述不等式,有: $$ C(n, k) = \frac{n!}{(n-k)!k!} \leq \frac{n!}{(n-k+j)!} = A(n, k-j), \\ \text{即} \quad (n - k + j)! \leq k!(n - k)! $$$n, j, k$ 三个变量至少需要满足: $$ (n - k + j)! \leq k!(n - k)! $$ 特别地,当 $j = 1$ 时,上述不等式等价于: $$ (n - k + 1)! \leq k!(n - k)! $$ 可以简化为: $$ (n - k + 1) \cdot (n - k)! \leq k! \cdot (n - k)! \\ \text{即} \quad n \leq k! + k - 1 $$ 根据本魔术的设定,可知 $n = 52$, $k = 5$。代入上述不等式,可得: $$ 52 \leq 5! + 5 - 1 = 124 $$ 也就是说,用前四张牌编码最后一张牌,理论上是可以实现的!

甚至有很大的冗余空间,在用前四张牌编码最后一张牌的设定下,最大可编码数量是 124,文末我们会推广到 124 张的极限情况。相比起来,Chico & Dico 的魔术仅需编码 52 张牌,根本不在话下。

算法实现

目前为止,一切还很抽象。

为了更好地理解 Chico 编码牌组,以及 Dico 解码牌组的方法。我们用一个实例复现魔术现场。

根据我们的模型和魔术的设定,显然有 $n = 52, k = 5, j = 1$

此外我们还作如下约定:

  1. cards 代表观众选出的 5 张牌组成的列表
  2. 被 Chico 重新排列过的牌组放入列表 reordered_cards
  3. 约定 s = sum(cards) mod k!
  4. 一般情况下,$q$ 代表商(初始化那次除外),$r$ 代表余数

好了,现在魔术正式开始

第一步,我们让观众取出 5 张牌。假设观众取出的是 $[18, 50, 12, 19, 4]$,我们要对这 5 张牌从小到大进行排序。

cards = [18, 50, 12, 19, 4]
cards = sorted(cards)
# cards = [4, 12, 18, 19, 50]

第二步,计算 s = sum(cards) mod k! 的值。

s = sum(cards) % factorial(5)
# s = 103

第三步,对这 5 张牌按如下规则进行编码:

q = s
for i in range(5, 0, -1):
    q, r = divmod(q, i)
    reordered_cards.append(cards.pop(r))

为了理解这一步做了什么,我们将上述循环展开。

点击查看每轮迭代中的细节

初始化,s = 103:

i 1 2 3 4 5
card NULL NULL NULL NULL NULL
q NULL NULL NULL NULL NULL s = 103
r NULL NULL NULL NULL NULL

当 i = 5 时,被除数初始化为 s。因此有 $q_5 = s / i = 103/5 = 20, r_5 = s % i = 103%5 = 3$。将列表 cards 中的索引为 $r_5$(= 3)的扑克,放入列表 reordered_cards 中。现在列表 cards 为 $[4, 12, 18, 50]$,列表 reordered_cards 为 $[19]$。具体结果如下:

i i_1 = 1 i_2 = 2 i_3 = 3 i_4 = 4 i_5 = 5
card NULL NULL NULL NULL reordered_cards[0] = 19
q NULL NULL NULL NULL q_5 = s / i_5 = 20 s = 103
r NULL NULL NULL NULL r_5 = s % i_5 = 3

当 i = 4 时,有 $q_4 = q_5 / i = 20/4 = 5, r_4 = q_5 % i = 20%4 = 0$。也就是把上一轮的商作为本轮的被除数。本轮之后列表 cards 为 $[12, 18, 50]$

i i_1 = 1 i_2 = 2 i_3 = 3 i_4 = 4 i_5 = 5
card NULL NULL NULL reordered_cards[1] = 4 19
q NULL NULL NULL q_4 = q_5 / i_4 = 5 q_5 = 20 s = 103
r NULL NULL NULL r_4 = q_5 % i_4 = 0 r_5 = 3

当 i = 3 时,有 $q_3 = q_4 / i = 5/3 = 1, r_3 = q_4 % i = 5%3 = 2$。本轮之后列表 cards 为 $[12, 18]$

i 1 2 3 4 5
card NULL NULL 50 4 19
q NULL NULL q_3 = 1 q_4 = 5 q_5 = 20 s = 103
r NULL NULL r_3 = 2 r_4 = 0 r_5 = 3

当 i = 2 时,有 $q_2 = q_3 / i = 1/2 = 0, r_2 = q_3 % i = 1%2 = 1$。本轮之后列表 cards 为 $[12]$

i 1 2 3 4 5
card NULL 18 50 4 19
q NULL q_2 = 0 q_3 = 1 q_4 = 5 q_5 = 20 s = 103
r NULL r_2 = 1 r_3 = 2 r_4 = 0 r_5 = 3

当 i = 1 时,有 $q_1 = q_2 / i = 0/1 = 0, r_1 = q_2 % i = 0%1 = 0$。本轮之后列表 cards 为 []

i 1 2 3 4 5
card 12 18 50 4 19
q q_1 = 0 q_2 = 0 q_3 = 1 q_4 = 5 q_5 = 20 s = 103
r r_1 = 0 r_2 = 1 r_3 = 2 r_4 = 0 r_5 = 3

最终,我们得到一个列表 reordered_cards = [12, 18, 50, 4, 19]

第四步,我们对上一步中得到的列表 $[12, 18, 50, 4, 19]$ 进行分割,将前 4 张牌 $[12, 18, 50, 4]$ 送入解码器中,最后一个值 19 作为答案保存下来,用于最后魔术成功与否的判定。

第五步,将前 4 张牌 $[12, 18, 50, 4]$ 解码。此步相当于是第三步的逆过程。我们将牌组按如下规则进行解码:

for i in range(1, 5, 1):
    q_list[i] = i * q_list[i-1] + r_list[i-1]
    if i != 4:
        visible_cards.append(first_four[i])
        r_list[i] = sorted(visible_cards).index(first_four[i])

为了理解这一步做了什么,我们将上述循环展开。

点击查看每轮迭代中的细节

当 i = 1 时,有 q_1 = 0, r_1 = 0(证明略,提示:$s < 5!$)。其次,由已知的前 4 张牌 $[12, 18, 50, 4]$,我们知道:

i i_1 = 1 i_2 = 2 i_3 = 3 i_4 = 4 i_5 = 5
card 12 18 50 4 NULL
q q_1 = 0 NULL NULL NULL NULL s = NULL
r r_1 = 0 NULL NULL NULL NULL

当 i = 2 时,有 q_2 = i_1 * q_1 + r_1 = 0, r_2 = [12, 18].index(18) = 1

i i_1 = 1 i_2 = 2 i_3 = 3 i_4 = 4 i_5 = 5
card 12 18 50 4 NULL
q q_1 = 0 q_2 = 0 NULL NULL NULL s = NULL
r r_1 = 0 r_2 = 1 NULL NULL NULL

当 i = 3 时,有 q_3 = i_2 * q_2 + r_2 = 1, r_3 = [12, 18, 50].index(50) = 2

i i_1 = 1 i_2 = 2 i_3 = 3 i_4 = 4 i_5 = 5
card 12 18 50 4 NULL
q q_1 = 0 q_2 = 0 q_3 = 1 NULL NULL s = NULL
r r_1 = 0 r_2 = 1 r_3 = 2 NULL NULL

当 i = 4 时,有 q_4 = i_3 * q_3 + r_3 = 5, r_4 = [4, 12, 18, 50].index(4) = 0

i i_1 = 1 i_2 = 2 i_3 = 3 i_4 = 4 i_5 = 5
card 12 18 50 4 NULL
q q_1 = 0 q_2 = 0 q_3 = 1 q_4 = 5 NULL s = NULL
r r_1 = 0 r_2 = 1 r_3 = 2 r_4 = 0 NULL

当 i = 5 时,有 q_5 = i_4 * q_4 + r_4 = 20

i i_1 = 1 i_2 = 2 i_3 = 3 i_4 = 4 i_5 = 5
card 12 18 50 4 NULL
q q_1 = 0 q_2 = 0 q_3 = 1 q_4 = 5 q_5 = 20 s = NULL
r r_1 = 0 r_2 = 1 r_3 = 2 r_4 = 0 r_5 = NULL

第六步,推测第五张牌的值的取值范围。

经过迭代后,我们知道 $ q_5 = 20 $,虽然不知道 $ r_5 $ 等于多少,但是我们知道 $ r_5 $ 的范围为 $ 0 \leq r_5 \leq 4 $。又因为 $s = 5 * q_5 + r_5 $, 故有 $ 100 \leq s \leq 104 $

此外,我们还知道 s = (v + sum([12, 18, 50, 4]))。设第五张牌的值为 v,有: $$ 100 \leq (v + sum([12, 18, 50, 4])) \bmod 5! \leq 104 \\ 100 \leq (v + 84) \bmod 5! \leq 104 \\ 16 \leq v \bmod 120 \leq 20 $$$ 1 \leq v \leq 52 $,故: $$ 16 \leq v \leq 20 $$ 上述过程的代码如下:

# 总结当前信息
sum_of_first_four = sum(first_four)
q = q_list[-1]

# 计算编码时q的初始值s的范围
range_l = 5 * q_list[-1]  # range_l <= s的值
range_r = 5 * q_list[-1] + 4  # s的值 <= range_r

# 计算第五张牌的值v的范围
times = 0
while True:
    v_l = range_l + times * factorial(5) - sum_of_first_four
    v_r = range_r + times * factorial(5) - sum_of_first_four
    times += 1
    if (v_r >= 1) and (v_l <= 52):
        break
    elif times > 2:
        raise ValueError('can not find the range of v.')

第七步,尝试可行域中的每一种取值。唯一可行的取值即为第五张牌的值。

# 猜解码时第五次迭代的余数r
for v in range(v_l, v_r+1):
    if v not in first_four:
        temp = first_four[:]
        temp.append(v)
        test_r = sorted(temp).index(v)
        s = 5 * q + test_r
        guess_v = s + (times - 1) * factorial(5) - sum_of_first_four
        if v == guess_v:
            return v

MCP 实现

我们可以通过 MCP,实现可供大模型调用的外部工具。

在 MCP 出现以前,大模型就可以通过 Function Calling 的方式调用外部工具。但是每个大模型有每个模型的 Function Calling,相互之间并不兼容,缺少统一的、标准化的工具调用方法。于是 MCP 应运而生。

MCP(Model Context Protocol,模型上下文协议)是一种通信协议,它统一了大模型与外部工具的交互方法。目前 MCP 提供三种通信方式:

  • Stdio(标准输入/输出)
  • SSE(Server-Sent Events)
  • Streamable HTTP

Stdio 适合本地运行,每次运行需要重新加载依赖的资源。SSE 和 Streamable HTTP 是提供远程 MCP 服务的方式。关于两者的区别,可以参考阿里云的文章 MCP 协议:为什么 Streamable HTTP 是最佳选择?

FastMCP 是当下编写 MCP Server 的最佳方式。FastMCP 能够处理与 LLM 通信过程中的复杂性,让开发者得以聚焦功能性代码。只需要写非常少的代码,就可以实现完整的 MCP 功能。

在我的 代码仓库 中,MCP 的实现只有寥寥几行。

from fastmcp import FastMCP
from .magic import decode_cards, encode_cards

mcp = FastMCP("card_magic_sse")

@mcp.tool
async def encode_cards_tool(cards: str) -> str:
    """本函数实现魔术师 Chico 的操作:将五张牌重新排序,
    并在排序过程中将第五张牌的信息编码在前四张的排序信息中

    :param cards: 该参数接受 5 张牌,形如 ♥K ♣3 ♠7 ♦5 ♠A
                  每张牌由【花色】和【数字】组成:
                    - 可选的花色:
                      - ♠ (黑桃, Spades)
                      - ♥ (红心, Hearts)
                      - ♦ (方块/方片, Diamonds)
                      - ♣ (梅花, Clubs)
                    - 可选的数字: A 2 3 4 5 6 7 8 9 10 J Q K
                  注意:请将花色和数字标准化后再作为参数传入
    :return: 本函数返回前四张牌和第五张牌的值
    """
    return encode_cards(cards)

@mcp.tool
async def decode_cards_tool(cards: str) -> str:
    """本函数实现魔术师 Dico 的操作:根据前四张牌猜第五张牌

    :param cards: 该参数接受 4 张牌,形如 ♠A ♥3 ♣10 ♦K
                  每张牌由【花色】和【数字】组成:
                    - 可选的花色:
                      - ♠ (黑桃, Spades)
                      - ♥ (红心, Hearts)
                      - ♦ (方块/方片, Diamonds)
                      - ♣ (梅花, Clubs)
                    - 可选的数字: A 2 3 4 5 6 7 8 9 10 J Q K
                  注意:请将花色和数字标准化后再作为参数传入
    :return: 本函数返回第五张牌的值
    """
    return decode_cards(cards)

if __name__ == "__main__":
    mcp.run()

只需要将装饰器 @mcp.tool 添加到工具函数上,FastMCP 会自动读取函数的 docstring 作为该工具函数的解释文本。

如果你希望改变通信方式,也非常简单。只需要修改 mcp.run()transport 参数就可以。transport 参数默认为 stdio,可以通过指定不同的 transport 值,改变 FastMCP 的通信方式。

# Stdio
mcp.run(transport="stdio")

# SSE
mcp.run(transport="sse",
        host='0.0.0.0',
        port='8000')

# Streamable HTTP
mcp.run(transport="http",
        host='0.0.0.0',
        port='8000',
        path="/mcp")

目前,使用 MCP 的最佳方式依然是使用 Agent。主流的 Agent 框架全都支持 MCP,比如 Qwen Agent。直接使用大语言模型,也可以调用 MCP Server,但是需要一定的开发量,不如直接用 Agent 省事。

推至极限

作为项目升级的一部分,我将五年前特定情形的算法($n = 52, k = 5, j = 1$),推广到任意 $n, k, j$ 的情形。当然,参数 $n, k, j$ 依然受信息论的约束: $$ n \leq k! + k - j $$ 算法实现如下:

from functools import lru_cache


@lru_cache(maxsize=128)
def factorial(n):
    """计算n的阶乘"""
    if n == 0 or n == 1:
        return 1
    else:
        return n * factorial(n-1)


class MagicMax:
    """Chico 和 Dico 的魔术 - 推广版"""

    def __init__(self, n, k):
        assert 3 <= k
        assert k <= n < factorial(k) + k
        self.n = n  # 整个牌组有多少牌
        self.k = k  # 从牌组中抽几张牌

    @staticmethod
    def reverse(cards):
        """翻转牌组,分出前 k - 1 张牌"""
        reversed_cards = cards[::-1]
        return reversed_cards[:-1], reversed_cards[-1]

    def encoder(self, cards):
        """将第 k 张牌的牌面信息编码到前 k-1 张的顺序中"""
        res = []
        cards = sorted(cards)
        s = sum(cards) % factorial(self.k)

        q = s
        for i in range(self.k, 0, -1):
            q, r = divmod(q, i)
            res.append(cards.pop(r))
        return res

    def decoder(self, visible_cards):
        """将第 k 张牌的牌面信息从前 k-1 张牌的排列信息中解码出来"""
        # 逆向求解编码过程
        q, r = 0, 0
        for i in range(1, self.k):
            q = i * q + r

            if i < self.k - 1:  # 前 k-2 步需要计算余数
                r = sorted(visible_cards[:i+1]).index(visible_cards[i])

        # 判断模 k! 的偏离量 t
        sum_visible_cards = sum(visible_cards)
        factorial_k = factorial(self.k)

        epoch = sum([self.n - i for i in range(self.k)]) // factorial_k
        tt = None
        for t in range(epoch + 1):
            v_guess = self.k * q + t * factorial_k - sum_visible_cards
            if 1 <= v_guess <= self.n:
                tt = t
                break

        for r in range(self.k):
            # 线索1:牌组总和为 s + k! * t
            s = self.k * q + r
            v = s + factorial_k * tt - sum_visible_cards
            # 线索2:第 k 张牌的值 v 放入牌组中获得正确的余数 r
            if 1 <= v <= self.n and v not in visible_cards:
                real_r = sorted(visible_cards + [v]).index(v)
                if real_r == r:
                    return v

decoder 部分的逻辑还是复杂了些,如果有大佬写出更简洁的实现,请随时向我提 Pull Request

在工程实现上,我还编写了两个核心类:CardMapperCardGame,分别实现了 扑克牌和数字编码的双向转换 和 魔术流程编排 的功能。感兴趣的话,可以查阅项目的 GitHub 代码仓库 luochang212/card-magic-mcp。

以下是本项目的全部资源列表: