《独立开发者的商业指南·收入与财务工程》10.3 复盘会与决策记录

月度复盘不是写报告,而是留下一页可复算的决策记录:本期数字、与预算差异、三个原因、一个决策、下期验证点。本节用 LedgerLite 的十月复盘示范五栏模板,并对照 Tidecast 的季节性与 Nord Studio 的双合伙人分工,说明留痕如何让三个月后的复算成为可能。

以下情境为教学假设,人物与数字不代表经核实的真实案例。

复盘最容易写成两种东西:一种是给外人看的月报,数字很漂亮,但看完不知道要做什么;另一种是情绪日记,写了很多「感觉」「可能」「下次注意」,三个月后连当时为什么改价都想不起来。这一节只要一页纸:本期数字、与预算差异、三个原因、一个决策、下期验证点。它的目的不是汇报,是让未来的自己能够复算今天的选择。

复盘不是汇报,是留下可复算的决策

汇报回答「这个月发生了什么」,复盘回答「我们决定做什么,以及下个月怎么知道这个决定对不对」。两个问题的读者不同:汇报的读者是投资人、合伙人、客户;复盘的读者是三个月后的自己。三个月后的自己不会记得当时的焦虑,只会看到一行数字和一个决策;如果那一行没有证据、没有验证点,复盘就退化成故事。

一份有用的复盘记录要满足三个条件:

条件含义反例
数字可复算每个数都能从结账包或银行流水追到「收入大概涨了」
原因可证伪每个原因都写了下个月什么情况会推翻它「市场变好了」
决策可验证有一个负责人、一个日期、一个指标「继续优化产品」

三个条件对应一页纸的五栏:本期数字、与预算差异、三个原因、一个决策、下期验证点。五栏装不下,说明这个月的问题还没有收敛;应该先删到能装下,而不是把一页纸扩成十页。

复盘会的频率可以月度,也可以季度,但记录必须按月留。月度记录是季度复盘的原始材料;没有月度记录,季度复盘只能凭记忆,而记忆会偏向最近一次成功或失败。

一页纸模板:五栏

栏目写什么不写什么LedgerLite 十月示例
本期数字五个指标及与上期/预算的对比全部明细、所有客户名单MRR $20,020;净增 +$740(+3.8%);毛利率 84.2%;SDE 率 54.2%;经营现金流 $5,358(月均);零收入 runway 8.6 个月
与预算差异金额、比率、方向、年初至今「差不多」「略低」本月 $0;年初至今 −$468(−0.78%),来自 Q1 新签 8 家而非 10 家
三个原因事实、证据、可证伪形容词、外部归因、超过三条新签回到 10 家;扩张 +$310;流失 −$290 与 1.5% 一致
一个决策做什么、谁、何时愿望清单、三个以上并行下月第一周排查前 5 家客户续约;负责人 founder;截止 2026-11-10
下期验证点指标、目标、日期「继续观察」NRR ≥ 100%;逾期 >30 天占应收 ≤ 6%;新签 ≥ 10 家

这张表里有两个数字需要说明。第一,「年初至今 −$468」沿用第 5 章的教学假设:Q1 每月新签 8 家而不是预算的 10 家,季度缺口 468 美元;十月本身回到预算,所以本月差异是 0,但全年仍有累计缺口。第二,前 5 家客户占收入 31%、最大一家 9%,这是 LedgerLite 的集中度事实;它不在五个指标里,却是十月决策的直接依据。

一页纸的写法有顺序:先填数字,再填差异,再写原因,最后写决策。顺序反了,就会先有结论再找数字。原因必须恰好三条,不是「至少三条」;三条装不下,说明还没有归因,应该继续合并,而不是继续列。

原因、决策与验证

三个原因不是三个借口,而是三个可被下个月推翻的假设。每个原因都要带三类信息:事实是什么、证据在哪里、什么情况会推翻它。

原因类型写法证据可证伪条件
数量(volume)新签回到 10 家MRR 桥新签 +$780下月新签低于 8 家
价格/结构(price/mix)扩张 +$310,来自存量升级MRR 桥扩张项下月扩张低于 $200,或升级客户退回原档
时点(timing)流失 −$290,约 1.5%,集中在月末流失清单与发生日期下月流失率高于 2.5%,或流失提前到月初

LedgerLite 的十月复盘可以写成:

  1. 数量:新签 10 家、新增 MRR $780,回到预算节奏。证据是 MRR 桥的新签项;如果下月新签低于 8 家,这个原因不成立。
  2. 结构:扩张 +$310,来自存量客户升级。证据是扩张项;如果下月扩张低于 $200,说明这次扩张是一次性的,不是结构改善。
  3. 时点:流失 −$290、约 1.5%,与全年假设一致。证据是流失清单;如果下月流失率高于 2.5%,说明流失在加速,不是正常波动。

三条原因分别对应三种动作:数量问题改获客,结构问题改定价或套餐,时点问题改续约节奏。如果三条原因都写成「市场不好」,动作就只能是「等市场变好」,这不是复盘。

原因还要区分暂时与结构,这一点第 5 章的季度校准已经讲过。月度复盘的三个原因只需要回答「这个月为什么和预算不一样」,不需要立刻判断全年;暂时与结构的判断留到季度,但每条原因都要写下个月能验证的条件,否则季度复盘时没有材料。

一页纸只有一个决策。不是每个月只能做一件事,而是复盘记录只对一件事负责;其他事情照常做,但不进入决策留痕。决策要写三件事:做什么、谁负责、什么时候完成。缺一件,下个月就无法复算。

LedgerLite 十月的决策是「下月第一周排查前 5 家客户的续约」,负责人 founder,截止 2026-11-10。它对准的是集中度:前 5 家占收入 31%,最大一家 9%;这不是五个指标里的红灯,但它是 runway 和 NRR 的上游风险。决策不需要宏大,但必须能在两周内开始、在一个月内看到第一个结果。

下期验证点要写成「指标 + 目标 + 日期」,不能写成「继续观察」。LedgerLite 的三个验证点是:

验证点目标日期判定
NRR≥ 100%2026-11-30低于 100% 说明存量在漏
逾期 >30 天占应收≤ 6%2026-11-30高于 6% 说明回款在恶化
新签≥ 10 家2026-11-30低于 10 家说明数量原因不成立

下个月复盘的第一行,应该先读上个月的决策和验证点,再读本月的数字。顺序不能反:先看数字,会被本月的新鲜事吸引,忘记上个月承诺过什么。代码里的 nextReviewPrompt 就是干这件事的:它把上个月的决策和验证点拼成一句话,放在本期复盘的最前面。

决策的结果只有三种:命中、未命中、无法判定。

结果含义下月动作
命中决策有效,验证点达标保持,或扩大范围;不要因为一次命中就加码三倍
未命中假设错,或执行不到位改假设,不重复同一个动作;先分清是判断错还是没做
无法判定验证点没测,或数据缺失先补测量,再谈决策;无法判定本身就是流程问题

留痕的价值在第三种结果上最明显。没有记录,三个月后只会记得「当时好像做过一次续约排查」,无法判断它有没有用;有记录,就能看到当时的目标、日期和实际结果,决定要不要再做一次。

三案例的复盘差异

同一张一页纸,三个案例的复盘频率和重点不同:

项LedgerLiteTidecastNord Studio
复盘频率月度月度看现金流,季度看销量月度,两位合伙人共同参加
主指标MRR 净变化、NRR、集中度滚动三个月净得、退款率咨询工时、产品 MRR、SDE 率
决策类型续约、定价、获客开发排期、折扣、续作预算合伙人时间分配、招聘、服务产品配比
留痕重点每月 MRR 桥与集中度季节性假设与退款准备两人的分工与退出条款

Tidecast 的月度数字波动大:12 月 2,400 份、1 月 480 份,Q4 占全年销量 38%。它的复盘不能因为 1 月销量低就改价,也不能因为 12 月销量高就扩产;正确做法是把季节性假设写进记录,用滚动三个月净得判断趋势,把退款率 4% 作为固定验证点。它的决策周期应该跟着开发里程碑走,而不是跟着自然月走。

Nord Studio 的复盘要同时看两条线:咨询 110 小时/月 × $85 = $9,350,产品 90 家 × $39 = $3,510;服务收入占 72.7%,咨询续约率 70%,产品月流失 3.0%。两条线的驱动不同,不能合成一个「总收入」决策。两位合伙人各 50%、没有书面退出条款,复盘记录还要留下两人对分工的共识;否则一次招聘或一次退出就会把过去一年的决策推翻。

三个案例的共同点是:复盘记录不是给外人看的,而是给下一次决策用的。LedgerLite 用它跟踪集中度,Tidecast 用它区分季节与趋势,Nord 用它固定合伙人的分工假设。记录的形式可以不同,但五栏不能少。

把复盘写成代码

下面的 review.ts 把一页纸结构化:buildReview 接收本期数字、预算、三个原因、一个决策和验证点,先算预算差异,再检查结构完整性;nextReviewPrompt 把上个月的决策和验证点拼成下一期复盘的第一行。

// file: src/fin/review.ts
// tier: L1
// verify: node --test test/fin/review.test.ts

import { type Cents } from "../core/money.ts";
import { type Result, fail, ok } from "../core/result.ts";
import {
  type Variance,
  type VarianceDirection,
  measureVariance,
} from "./variance.ts";

export type ReasonKind =
  | "volume"
  | "price"
  | "mix"
  | "cost"
  | "timing"
  | "one_off";

export const REASON_KINDS: ReasonKind[] = [
  "volume",
  "price",
  "mix",
  "cost",
  "timing",
  "one_off",
];

export type Reason = {
  kind: ReasonKind;
  statement: string;
  evidence: string;
  falsifier: string;
};

export type Decision = {
  statement: string;
  owner: string;
  dueDate: string;
};

export type Verification = {
  metric: string;
  target: string;
  dueDate: string;
};

export type ReviewInput = {
  period: string;
  actual: Cents;
  budget: Cents;
  direction: VarianceDirection;
  reasons: Reason[];
  decision: Decision | null;
  verification: Verification[];
};

export type ReviewRecord = {
  period: string;
  variance: Variance;
  reasons: Reason[];
  decision: Decision | null;
  verification: Verification[];
  missing: string[];
  complete: boolean;
};

function filled(value: string): boolean {
  return typeof value === "string" && value.trim().length > 0;
}

function checkDecision(decision: Decision): string | null {
  if (!filled(decision.statement)) {
    return "decision statement is required";
  }
  if (!filled(decision.owner)) {
    return "decision owner is required";
  }
  if (!filled(decision.dueDate)) {
    return "decision due date is required";
  }
  return null;
}

function checkVerification(item: Verification): string | null {
  if (!filled(item.metric)) {
    return "verification metric is required";
  }
  if (!filled(item.target)) {
    return "verification target is required";
  }
  if (!filled(item.dueDate)) {
    return "verification due date is required";
  }
  return null;
}

/**
 * A review record is allowed to be incomplete. Structural errors fail, but a
 * missing reason, decision or verification point is recorded in `missing`
 * instead of being rejected. The draft is still worth keeping: next month it
 * shows exactly what the review could not answer at the time.
 */
export function buildReview(input: ReviewInput): Result<ReviewRecord> {
  if (!filled(input.period)) {
    return fail("period is required");
  }
  if (!Array.isArray(input.reasons)) {
    return fail("reasons must be an array");
  }
  if (!Array.isArray(input.verification)) {
    return fail("verification must be an array");
  }
  for (const reason of input.reasons) {
    if (!reason || !REASON_KINDS.includes(reason.kind)) {
      return fail("unknown reason kind");
    }
    if (
      !filled(reason.statement) ||
      !filled(reason.evidence) ||
      !filled(reason.falsifier)
    ) {
      return fail("each reason needs a statement, evidence and falsifier");
    }
  }
  const decision = input.decision ?? null;
  if (decision !== null) {
    const decisionError = checkDecision(decision);
    if (decisionError) {
      return fail(decisionError);
    }
  }
  for (const item of input.verification) {
    const verificationError = checkVerification(item);
    if (verificationError) {
      return fail(verificationError);
    }
  }
  const variance = measureVariance(input.actual, input.budget, input.direction);
  if (!variance.ok) {
    return variance;
  }
  const missing: string[] = [];
  if (input.reasons.length !== 3) {
    missing.push("exactly three reasons");
  }
  if (decision === null) {
    missing.push("one decision");
  }
  if (input.verification.length === 0) {
    missing.push("at least one verification");
  }
  const record: ReviewRecord = {
    period: input.period,
    variance: variance.value,
    reasons: input.reasons,
    decision,
    verification: input.verification,
    missing,
    complete: missing.length === 0,
  };
  return ok(record);
}

/**
 * The first line of the next review. Its job is to force a re-computation of
 * last month's decision, not to summarize the record.
 */
export function nextReviewPrompt(record: ReviewRecord): string {
  if (record.decision === null) {
    return "上一期没有决策,本期只做复算,不追责。";
  }
  const metrics = record.verification.map((item) => item.metric).join("、");
  return `复算:${record.decision.statement};验证点:${metrics}。`;
}

代码有四个细节。第一,buildReview 允许保存草稿:原因不是三条、没有决策、没有验证点,都不会让函数失败,而是写进 missing;结构错误——未知原因类型、空证据、空负责人——才会失败。第二,预算差异复用第 5 章的 measureVariance,收入正向为有利、成本正向为不利,符号不会在本节重新定义。第三,验证点必须同时有指标、目标和日期,任何一项为空都会被判为结构错误;但「继续观察」这种空泛目标代码识别不了,只能靠模板和复盘习惯约束。第四,nextReviewPrompt 在没有决策时明确说「上一期没有决策」,而不是编一个;这句话本身就是留痕的一部分。

小结

  • 复盘不是汇报,是给三个月后的自己留一份可复算的决策记录;数字可复算、原因可证伪、决策可验证,三条缺一不可。
  • 一页纸五栏:本期数字、与预算差异、三个原因、一个决策、下期验证点;装不下说明问题还没收敛。
  • 三个原因要带事实、证据、可证伪条件;LedgerLite 十月写成数量(新签 10 家)、结构(扩张 +$310)、时点(流失 −$290,约 1.5%)。
  • 一个决策要有做什么、谁负责、何时完成;LedgerLite 的决策是下月第一周排查前 5 家客户续约,对准 31% 的集中度。
  • 验证点写成指标 + 目标 + 日期;下月复盘先读上个月的决策,再读本月的数字。
  • 决策结果分命中、未命中、无法判定;无法判定是流程问题,先补测量,再谈下一步。

阅读导航:上一节:10.2 月度关账流程 · 下一节:11.1 三种估值口径:ARR 倍数、SDE 倍数、DCF 。

常见错误与边界

  1. 把复盘写成月报。 月报给外人看,复盘给未来的自己看;没有决策和验证点,数字再漂亮也无法复算。
  2. 原因写成形容词。 「市场不好」「客户很喜欢」不是原因;原因要有证据和可证伪条件,否则下个月无法判断对错。
  3. 原因超过三条。 三条装不下,说明还没有归因;继续合并,而不是把一页纸扩成三页。
  4. 决策写成愿望。 「优化产品」「提升体验」没有负责人和日期;决策要能在两周内开始、一个月内看到第一个结果。
  5. 一个月做五个决策。 记录只对一件事负责;五个并行决策,下个月无法判断哪个起了作用。
  6. 验证点写成「继续观察」。 没有指标、目标和日期,就无法判定命中还是未命中;这等于没有验证。
  7. 先看本月数字,后看上个月决策。 新鲜事会吸引注意力,承诺会被忘记;顺序要反过来。
  8. 把命中当加码信号。 一次命中可能来自季节或运气;先确认验证点,再决定是否扩大范围。
  9. Tidecast 用单月销量做决策。 12 月 2,400 份、1 月 480 份,单月波动是季节,不是趋势;买断制用滚动三个月。
  10. Nord 把两条线合成一个决策。 咨询 72.7%、产品 27.3%,驱动不同;合成一个「总收入」会掩盖工时和流失的单独变化。

本章的边界是「一页纸的月度复盘」:它假设结账已经完成、五个指标口径稳定、决策可以在一个月内看到第一个结果。它不处理战略级决策、融资谈判和合伙人退出;那些需要更长的验证周期和更正式的文件。复盘的下一步是估值:当历史数字能复算、决策能验证,估值才有可比的基线,这正是第 11 章要用的材料。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《移动游戏产品的架构·商业化与发行工程》附录 C 商业化与发行评审清单
  2. 《移动游戏产品的架构·商业化与发行工程》附录 B 双案例商业化事实表
  3. 《移动游戏产品的架构·商业化与发行工程》附录 A 商业化与发行术语速查