以下情境为教学假设,人物与数字不代表经核实的真实案例。
LTV 是单位经济里最容易被当成「一个数」的指标。问十个人 LTV 怎么算,可能会得到十个不同答案:有人用毛利除以流失率,有人用 ARPA 乘寿命再乘毛利率,有人把队列收入一年一年加起来。三种算法都能叫 LTV,但它们回答的问题并不相同。
LedgerLite 是最好的对照样本,因为它的原始数足够完整:260 家客户、ARPA 77 美元/月、毛利率 84.2%、月流失 1.5%、年流失约 16.6%、GRR 83%、NRR 102%。同一组数,三种算法可以分别得到 4,322、4,688 和 4,091 美元。差异不是算错了,而是假设不同。
这一节的主线是:先拆三种算法,再说明每种算法在什么条件下成立、什么时候会给出误导性的答案。Tidecast 和 Nord Studio 各用两段做边界对照。最后把三种算法写成代码,让差异可以被测试复现。
三个算法回答的不是同一个问题
把 LTV 拆成一句话:一个客户在生命周期内贡献的毛利总额。这句话里有三个词需要定义:客户、生命周期、毛利。
- 「客户」可以是订阅账户,也可以是一份买断拷贝,还可以是一个咨询项目。
- 「生命周期」可以是由流失率推出的期望寿命,也可以是队列实际观察到的存活月数,还可以是一个人为选择的预测期。
- 「毛利」可以是月毛利、年毛利,也可以是扣掉扩张与收缩后的净收入。
三种算法其实是在用不同的方式回答这三个定义问题。
| 算法 | 核心问题 | 对「生命周期」的处理 | 对「扩张」的处理 |
|---|---|---|---|
| 毛利 ÷ 流失率 | 按当前流失速度,客户值多少 | 假设流失率恒定,寿命 = 1 ÷ 流失率 | 忽略 |
| ARPA × 寿命 × 毛利率 | 按一个明确的寿命,客户值多少 | 由使用者指定,可来自流失率或队列观察 | 忽略,或通过 ARPA 假设间接纳入 |
| 队列净收入法 | 一群客户实际留下了多少 | 由观察期决定,必须有限 | 纳入 NRR,扩张与收缩都计入 |
这张表说明:三种算法不是「精度不同」,而是「问题不同」。用毛利÷流失率问的是「稳态下值多少」;用 ARPA×寿命问的是「给定寿命值多少」;用队列净收入法问的是「已经发生的这群客户留下了多少」。把它们放在同一张报表里比较,必须先统一问题。
前两种算法:同一个公式,两种寿命
算法一的公式是:
LTV = 单客户月毛利 ÷ 月流失率
代入 LedgerLite:
LTV = $64.83 ÷ 1.5% = $4,322.00
这个公式本质上是一个永续年金:每个月贡献 64.83 美元毛利,每个月有 1.5% 的客户离开,剩余客户的期望月数就是 1 ÷ 1.5% = 66.67 个月。把月毛利乘以期望月数,得到 4,322 美元。它也等价于把每个月「还活着的概率」乘上月毛利再求和,只是数学上收敛成了一个除法。
算法二的公式是:
LTV = ARPA × 寿命(月)× 毛利率
如果寿命取 1 ÷ 月流失率,算法二和算法一在数学上完全相同:
LTV = $77.00 × (1 ÷ 1.5%) × 84.2% = $4,322.00
差异出现在寿命不再由月流失率推出的时候。案例事实表同时给了月流失 1.5% 和年流失约 16.6%,两者并不完全等价:按月复合,1 − (1 − 1.5%)¹² = 16.6%,方向一致;但用年流失率反推寿命,会得到 1 ÷ 16.6% = 6.02 年 = 72.3 个月。再代入算法二:
LTV = $77.00 × 72.3 × 84.2% = $4,688
比算法一的 4,322 美元高 8.5%。原因不是年流失率算错了,而是「年流失」被当成「每年年末一次性流失」处理,客户在一年内仍然贡献了完整 12 个月的毛利;按月复合的模型则承认客户在一年内逐步离开。流失率越高,两种口径的差距越大。
如果寿命来自真实队列观察,答案又会变。假设 LedgerLite 的 cohort 显示客户平均存活 60 个月(这是教学假设,不是案例事实),算法二给出:
LTV = $77.00 × 60 × 84.2% = $3,890
同一家公司,同一组 ARPA 和毛利率,LTV 可以是 4,322、4,688 或 3,890。差别全部来自「寿命」这个输入。算法二的价值不是给出一个更准的数,而是强迫使用者说清楚寿命从哪里来。
| 寿命来源 | 寿命(月) | LTV | 隐含假设 |
|---|---|---|---|
| 1 ÷ 月流失率 | 66.67 | $4,322 | 月流失恒定,无限期 |
| 1 ÷ 年流失率 × 12 | 72.3 | $4,688 | 流失按年发生,年内不流失 |
| 队列平均存活 | 60.0 | $3,890 | 观察到的平均寿命,不含未来变化 |
三种寿命没有绝对的对错,只有「和使用场景是否匹配」。做年度预算时,用年流失率更贴近报表节奏;做产品留存分析时,用队列寿命更贴近真实行为;做快速筛选时,用月流失率最省事。最危险的做法是混用:用月毛利除以年流失率,或者用年毛利乘以月寿命,都会得到量级错误的 LTV。
第三种算法:队列净收入法
队列净收入法不问「按当前流失率未来值多少」,而问「一群客户从加入到观察期末,实际留下了多少净收入」。做法是:取同一时间加入的一群客户,逐月记录他们贡献的毛利,扣掉流失和收缩,加上扩张,再除以初始客户数。
LedgerLite 的事实表给了两个关键比率:GRR 83%,NRR 102%。GRR 只看存量客户的续费与收缩,不含扩张;NRR 把扩张也算进来。NRR 102% 意味着一年后,同一批客户的收入是初始的 102%,虽然毛留存只有 83%,扩张补上了 17 个百分点的漏损。
把年 NRR 转成月净留存率:
月净留存率 = 102%^(1 ÷ 12) = 100.165% → 10,017 bps
再以单客户月毛利 64.83 美元为起点,逐月累加:
队列 LTV(n) = Σ(t=0 到 n−1) $64.83 × 1.00165^t
不同观察期的结果如下。金额单位为美元,保留两位小数。
| 观察期 | 累计毛利 | 与算法一的差距 | 说明 |
|---|---|---|---|
| 12 个月 | $785.28 | −$3,536.72 | 只覆盖首年,未体现续费 |
| 36 个月 | $2,404.67 | −$1,917.33 | 三年期,仍低于永续模型 |
| 60 个月 | $4,091.44 | −$230.56 | 五年期,接近但未达到 4,322 |
| 约 63.2 个月 | $4,322 | $0 | 与算法一交叉 |
| 无上限 | 发散 | 不适用 | NRR > 100%,总和没有有限值 |
最后一行是队列法的核心边界:当 NRR 大于 100% 时,每月的净收入在增长,队列 LTV 不会自然收敛。观察期越长,数字越大;理论上没有上限。因此队列法必须选一个有限观察期,或者给一个「扩张何时消失」的尾部假设。
一个常用的尾部假设是:首年 NRR 102%,之后扩张消失,净留存回落到 GRR 83%。这时队列 LTV 是一个等比数列:
LTV = 年毛利 $777.96 × [1.02 + 1.02×0.83 + 1.02×0.83² + …]
= $777.96 × 1.02 ÷ (1 − 0.83)
= $4,668
4,668 美元比算法一的 4,322 高 8.0%,因为它承认了首年扩张;又比「NRR 永远 102%」有限,因为尾部回到了 83% 的毛留存。这个数字同样不是「真值」,而是一个显式假设下的结果。
队列法的优点是把扩张、收缩、流失都放在同一张时间表里,不依赖「流失率恒定」这种强假设。缺点是它需要真实的队列数据,而且对观察期和尾部假设高度敏感。没有队列数据时,用 NRR 和 GRR 做平滑插值只能算教学近似,不能当成实测。
三种算法的差异与选择
把三种算法放在一起,差异可以收成一张表。
| 维度 | 毛利 ÷ 流失率 | ARPA × 寿命 × 毛利率 | 队列净收入法 |
|---|---|---|---|
| 需要的输入 | 月毛利、月流失率 | ARPA、毛利率、寿命 | 分月净收入、观察期 |
| LedgerLite 结果 | $4,322 | $3,890–$4,688 | $785–$4,091(视观察期) |
| 是否含扩张 | 否 | 通常否 | 是(NRR) |
| 是否有有限上限 | 是(流失率 > 0) | 是(寿命有限) | 否(NRR > 100% 时) |
| 最适合 | 稳态订阅快筛 | 有明确寿命假设的预算 | 有队列数据的复盘 |
| 最大风险 | 流失率变化即失真 | 寿命来源不可比 | 观察期与尾部假设操纵结果 |
选择规则可以写得很简单:
- 如果你只有月流失率和月毛利,用算法一,但把它标成「稳态估计」,不要当预测。
- 如果你能说清楚寿命从哪来,用算法二;寿命来源必须和结论一起写。
- 如果你有队列数据,用算法三;同时必须写观察期和尾部假设。
Tidecast 是买断制,三种算法里只有队列法还能变形使用。它没有月流失,算法一会除以零;算法二的「寿命」对一次性交易没有意义。它的 LTV 退化成每份净收入 14.39 美元,队列法也只观察到一个时期。如果未来有续作或 DLC,队列法可以追踪同一批玩家的复购,但案例事实表没有复购率,任何「复购 LTV」都只能是假设。
Nord Studio 的产品线可以用算法一:ARPA 39 美元、混合毛利率 84.4%、月流失 3.0%,LTV = 39 × 84.4% ÷ 3.0% = 1,097 美元。服务线则适合算法二:年续约率 70%,期望寿命 = 1 ÷ (1 − 70%) = 3.33 年;每 1 美元年咨询收入对应的 LTV 是 3.33 美元(未计毛利),乘混合毛利率后是 2.81 美元。两条线用不同算法,不是随意选择,而是因为一条有月流失、另一条有年续约率。把两条线的 LTV 直接相加会重复计算共享的获客与品牌成本,也会掩盖 CAC 没有单列这个事实。
把三种算法写成代码
下面的 ltv.ts 把三种算法固化成函数。ltvByChurn 对应算法一,ltvByLifetime 对应算法二,ltvFromCohort 对应算法三;annualToMonthlyRetention 负责把年 NRR/GRR 转成月净留存率。所有函数都用整数美分,队列法要求显式传入观察期,避免「NRR > 100% 时无限大」被隐藏。
// file: src/unit/ltv.ts
// tier: L1
// verify: node --test test/unit/ltv.test.ts
import { type Cents } from "../core/money.ts";
import { type Bps } from "../core/rate.ts";
import { type Result, fail, ok } from "../core/result.ts";
export type LtvComparison = {
byChurn: Cents;
byLifetime: Cents;
lifetimeMonths: number;
};
function isCents(value: number): boolean {
return Number.isInteger(value) && value >= 0;
}
/**
* Algorithm 1: monthly gross profit divided by monthly churn.
* This is an infinite-horizon, constant-churn model.
*/
export function ltvByChurn(
monthlyGrossProfitCents: Cents,
monthlyChurnBps: Bps,
): Result<Cents> {
if (!isCents(monthlyGrossProfitCents)) {
return fail("monthly gross profit must be a non-negative integer number of cents");
}
if (!Number.isInteger(monthlyChurnBps) || monthlyChurnBps <= 0 || monthlyChurnBps > 10000) {
return fail("monthly churn must be an integer between 1 and 10000 bps");
}
return ok(
Math.round((monthlyGrossProfitCents * 10000) / monthlyChurnBps),
);
}
/**
* Algorithm 2: monthly gross profit multiplied by an average lifetime in months.
* The caller supplies the lifetime; this function does not infer it from churn.
*/
export function ltvByLifetime(
monthlyGrossProfitCents: Cents,
lifetimeMonths: number,
): Result<Cents> {
if (!isCents(monthlyGrossProfitCents)) {
return fail("monthly gross profit must be a non-negative integer number of cents");
}
if (!Number.isFinite(lifetimeMonths) || lifetimeMonths <= 0) {
return fail("lifetime must be a positive finite number of months");
}
return ok(Math.round(monthlyGrossProfitCents * lifetimeMonths));
}
/**
* Convert an annual revenue retention rate into a constant monthly rate.
* 102% annual net revenue retention becomes 10017 bps per month.
*/
export function annualToMonthlyRetention(
annualRetentionBps: Bps,
): Result<Bps> {
if (
!Number.isInteger(annualRetentionBps) ||
annualRetentionBps <= 0 ||
annualRetentionBps > 100000
) {
return fail("annual retention must be an integer between 1 and 100000 bps");
}
const monthly = Math.pow(annualRetentionBps / 10000, 1 / 12) * 10000;
return ok(Math.round(monthly));
}
/**
* Algorithm 3: sum the net revenue a cohort actually keeps over a finite
* horizon. The first month is month 0, so `months` terms are added.
*/
export function ltvFromCohort(
monthlyGrossProfitCents: Cents,
monthlyNetRetentionBps: Bps,
months: number,
): Result<Cents> {
if (!isCents(monthlyGrossProfitCents)) {
return fail("monthly gross profit must be a non-negative integer number of cents");
}
if (
!Number.isInteger(monthlyNetRetentionBps) ||
monthlyNetRetentionBps <= 0 ||
monthlyNetRetentionBps > 20000
) {
return fail("monthly net retention must be an integer between 1 and 20000 bps");
}
if (!Number.isInteger(months) || months <= 0 || months > 1200) {
return fail("months must be an integer between 1 and 1200");
}
const factor = monthlyNetRetentionBps / 10000;
let total = 0;
let revenue = monthlyGrossProfitCents;
for (let month = 0; month < months; month++) {
total += revenue;
revenue *= factor;
}
return ok(Math.round(total));
}
/** Run algorithms 1 and 2 side by side with an explicit lifetime input. */
export function compareLtv(
monthlyGrossProfitCents: Cents,
monthlyChurnBps: Bps,
lifetimeMonths: number,
): Result<LtvComparison> {
const byChurn = ltvByChurn(monthlyGrossProfitCents, monthlyChurnBps);
if (!byChurn.ok) {
return byChurn;
}
const byLifetime = ltvByLifetime(monthlyGrossProfitCents, lifetimeMonths);
if (!byLifetime.ok) {
return byLifetime;
}
return ok({
byChurn: byChurn.value,
byLifetime: byLifetime.value,
lifetimeMonths,
});
}
代码里有四个刻意的边界。
第一,ltvByChurn 和 ltvByLifetime 都接收「月毛利」而不是 ARPA 和毛利率。月毛利怎么算,是 2.1 节 grossProfitPerCustomer 的职责;LTV 模块不重复一遍乘法,避免两处口径漂移。
第二,ltvByLifetime 不替你从流失率推寿命。寿命可以来自 1÷流失率、年流失率、队列观察或预算假设,函数只负责乘。这样调用方必须自己回答「寿命从哪来」。
第三,annualToMonthlyRetention 用几何平均,而不是把年率除以 12。102% 的年 NRR 除 12 会得到 8.5% 的月增长,明显错误;正确的月净留存是 100.165%。
第四,ltvFromCohort 必须传观察期,且上限 1200 个月。NRR 大于 100% 时,函数不会返回无穷,而是老老实实按观察期累加;观察期选多长,是使用者的假设,不是函数的默认值。
测试覆盖了三种算法的 LedgerLite 结果、年 NRR/GRR 转换、60 个月队列、边界失败和 compareLtv 的并排输出。运行 node --test test/unit/ltv.test.ts 可以看到 4,322、3,890、785.28、4,091.44 这些数字被逐项断言。
小结
- LTV 不是单一定义,三种算法回答三个不同问题:稳态值、给定寿命值、队列实际留存值。
- 算法一:LTV = 月毛利 ÷ 月流失率;LedgerLite 为 64.83 ÷ 1.5% = 4,322 美元。
- 算法二:LTV = ARPA × 寿命 × 毛利率;寿命取 1÷月流失率时与算法一相同,取年流失率反推的 72.3 个月时为 4,688 美元,取 60 个月队列寿命时为 3,890 美元。
- 算法三:按观察期累加队列净收入;LedgerLite 在 12/36/60 个月分别为 785、2,405、4,091 美元,NRR 102% 时没有自然上限。
- 三种算法的差异主要来自寿命、扩张和观察期,而不是算术精度。
- 选择规则:只有月流失率用算法一,有明确寿命用算法二,有队列数据用算法三;每种都要把假设写出来。
- Tidecast 的买断制让算法一失效,LTV 退化为每份净收入 14.39 美元;Nord Studio 的产品线用算法一得 1,097 美元,服务线用 70% 年续约率推得 3.33 年寿命。
- 下一步是把 LTV/CAC、回本期、毛利率、流失率和 GRR 变成可判定的失效信号。
阅读导航:上一节:2.1 单位经济的四个数:ARPA、毛利、CAC、回本 · 下一节:2.3 单位经济失效的五个信号 。
常见错误与边界
- 把三种算法当成精度不同的同一个数。 它们回答的问题不同;4,322、4,688、4,091 不是谁更准,而是假设不同。
- 用月毛利除以年流失率。 这会把 LTV 放大 12 倍;月毛利必须配月流失率,年毛利必须配年流失率。
- 把年流失率当成「年末一次性流失」。 1 ÷ 16.6% = 72.3 个月,比按月复合的 66.67 个月长,LTV 因此高估 8.5%。
- 寿命来源不写。 算法二的结果完全由寿命决定;不写寿命来源,4,322 和 4,688 看起来都像「算出来的」。
- 在 NRR > 100% 时用无限期队列法。 净收入在增长,总和没有有限值;必须选观察期或尾部假设。
- 把平滑插值的 NRR 当成实测队列。 案例只给了年 NRR 102% 和 GRR 83%,逐月曲线是教学近似,不是客户级数据。
- 忽略扩张与收缩的口径。 算法一不含扩张;算法三含 NRR。用算法一评估一个靠扩张提升 NRR 的业务,会低估 LTV。
- 在买断制里硬套订阅 LTV。 Tidecast 没有月流失,算法一会除以零;它的 LTV 只能退化成单次净收入 14.39 美元。
- 把产品线和服务线的 LTV 直接相加。 Nord Studio 两条线的客户、获客成本和留存口径不同,相加会重复计算共享成本。
- 只报 LTV,不报 LTV/CAC。 LTV 4,322 美元本身不说明好坏;要和 CAC 140 美元放在一起,才有 30.9 这个判断。
LTV 的三种算法不是三个竞争答案,而是三把不同刻度的尺子。选哪把尺子,取决于你要回答的问题和手里有什么数据。下一节会把 LTV/CAC、回本期、毛利率、月流失和 GRR 收成五个失效信号,给每组单位经济一个可执行的判定。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。