一个真的会动钱的 L2 退款 workflow,长什么样

Yaqin Hei··25分钟阅读
中文EN
一个真的会动钱的 L2 退款 workflow,长什么样

WORKFLOW DEEP DIVE。 一篇工程深挖。Agentic AI 落地方法论第二篇立了规矩——会动钱的写操作必须做成 L2 deterministic workflow,不能交给 L3 autonomous 自主规划。那篇讲「为什么」,这篇讲「那条 workflow 真做出来长什么样」:一个小额退款 workflow 从线性 8 步长成分流树的全过程,以及让它敢上线的那些设计。配套的 Critic fail-closed上线前卡哪几道闸在系列里。English version: What a Real, Money-Moving L2 Refund Workflow Actually Looks Like.

开场:那条线性 8 步,读起来太干净了

现状的小额退款 workflow 是一条线性的 8 步。代码读起来很舒服:

# app/workflows/small_refund.py —— 线性版
STEPS = [
    KillSwitchStep(),       # 1. 总开关,关了直接转人工
    CheckParamsStep(),      # 2. 参数齐不齐
    QueryOrderStep(),       # 3. 查订单事实
    ValidateStep(),         # 4. 金额阈值 / 时效窗口 / 去重
    CriticCheckStep(),      # 5. LLM Critic 语义校验
    ConfirmCardStep(),      # 6. 二次确认卡(pause / resume)
    ExecuteRefundStep(),    # 7. 真正调财务接口退款
    ConfirmStep(),          # 8. 通知客户、留痕
]

一条直线,从「该不该退」走到「退了」。Demo 给业务方看,没人有意见——它确实能跑通一个最干净的退款。

我打开这个文件,本来只想加一个分支:退差价。结果发现这不是「加一个分支」的事。

业务方把真实诉求摊开:退款不是一种,是一族——运费、差价、补偿、瑕疵、质量五种诉求;用户来自私域(自有会员渠道)和公域(第三方电商平台)两套体系,规则不一样、落账系统不一样;其中一半的外部系统当时还没接通。那条干净的直线,撑不住这些。

这一篇就是把这条直线改造成一棵能上线的分流树的过程。它最反直觉的一个发现,我先放在这儿:一个真的会动钱的 workflow,大部分代码不是退款,是不退款。

一、线性版为什么能上线,却撑不住真实诉求

线性版能跑,是因为它假设了一个最窄的世界:一个渠道、一种诉求、一个落账系统。它的骨架很标准——每一步是一个 WorkflowStep,返回一个 StepResult 告诉编排器下一步去哪:

# app/workflows/base.py
@dataclass
class StepResult:
    output: dict            # 这一步产出的事实
    next_step: str | None   # 下一步的名字;None = workflow 结束
    message: str | None     # 给用户的话
    tool_used: str | None   # 调了哪个 tool(留痕用)

class WorkflowStep(ABC):
    @abstractmethod
    async def run(self, ctx: Context) -> StepResult: ...

注意 next_step 这个字段——它是这套 workflow 是「deterministic」而非「autonomous」的关键。下一步去哪,是每个 step 用代码显式返回的,不是 LLM 看着对话自己决定的。 这正是 L2 vs L3 那篇反复强调的边界:LLM 可以在某一步里做语义判断(比如 Critic),但「整条路径怎么走」必须是确定的、可测的、可枚举的。

线性版的问题不在骨架,在它只有一条路径。真实诉求一进来,路径就不是一条了。

二、真实诉求把直线撕成一棵树

五种诉求 × 两个渠道,不是简单的 5×2,因为每一格的规则都不同。私域的差价走一个专用退差价系统;公域的差价里,「退货重拍」要转人工、「商品差价」走平台分支;补偿在私域是发券(根本不是退款),在公域要看订单实付金额分两条路落账……

所以线性的 8 步,中段必须裂开成一棵分流树:

kill_switch
  → gate_common              ← 投诉/延迟发货/账号不一致/黑名单/已退过 → 转人工
  → query_order              ← 私域 / 公域 两套查询
  → route_mode_scenario      ← 按 渠道 + 诉求 分流
        私域: 运费 | 差价 | 补偿
        公域: 运费 | 差价 | 瑕疵补偿 | 质量补偿
  → [子场景链] 收图? → 识图? → 场景校验 → 限额核账
  → critic_check             ← 写操作前
  → confirm_card             ← 二次确认
  → execute_by_system        ← 每个子场景落不同系统
  → confirm

A linear 8-step refund workflow branches into a tree: double mode × private/public channel × five sub-scenarios

分流发生在 route_mode_scenario 这一步——它读渠道和用户诉求,返回不同的 next_step

class RouteModeScenarioStep(WorkflowStep):
    async def run(self, ctx):
        source = ctx.order["source"]          # private | public
        scenario = ctx.classified_scenario     # fee | price_diff | compensation | defect | quality
        branch = ROUTE_TABLE.get((source, scenario))
        if branch is None:
            return StepResult(output={}, next_step="transfer_to_human",
                              message=None, tool_used=None)   # 没有对应分支 = 转人工
        return StepResult(output={"branch": branch}, next_step=branch,
                          message=None, tool_used=None)

注意最后那个 if branch is None——任何没有明确分支的组合,默认转人工,而不是「猜一个最接近的」。 这个默认值,是整套设计的基调。

三、数清楚才发现:大部分步骤不是退款,是不退款

把这棵树上的每个节点按职责标个色,会看到一件反直觉的事。

Most of a money-moving workflow is guardrails, not payout — the actual refund is a sliver

真正「把钱退出去」的,只有 execute_by_system 那一片。它前面站着一长排只做一件事的步骤——判断要不要走到它

  • kill_switch:这个场景现在到底开没开
  • gate_common:有没有投诉、账号对不对、是不是黑名单、是不是已经退过
  • route_mode_scenario:这个组合有没有合法分支
  • scenario_validate:这个子场景的特定条件满足没
  • limit_ledger_check:累计退款撞没撞上限
  • critic_check:这笔写操作语义上站不站得住
  • confirm_card:用户二次确认了没

七道判断,一次付款。 一个会动钱的 workflow,payout 是少数派——它八成的代码在回答「要不要退」,两成在「退」。

这件事对架构师的意义不是「代码量分布」,是判断一个所谓的退款 Agent 靠不靠谱,看的不是它能不能退款(那是最容易的一步),是它有没有这一排『不退款』的护栏。供应商 Demo 里 Agent 丝滑退款那一段,恰恰是最不值钱的部分。

四、gate_common:在查订单之前,就把一半情况挡掉

最便宜的护栏,是放在最前面的那道。 gate_common 在查订单之前就跑,任一命中就转人工或走赔付,根本不进退款主链:

class GateCommonStep(WorkflowStep):
    async def run(self, ctx):
        for check, reason in [
            (has_active_complaint, "存在未结投诉"),
            (is_delayed_shipment,  "延迟发货 → 走赔付而非退款"),
            (account_mismatch,     "下单账号与申请账号不一致"),
            (in_blacklist,         "命中黑名单"),
            (already_refunded_once,"该单已退过一次"),
        ]:
            if await check(ctx):
                return StepResult(output={"gate": reason}, next_step="transfer_to_human",
                                  message=None, tool_used=None)
        return StepResult(output={}, next_step="query_order",
                          message=None, tool_used=None)

这五条没一条是「退款逻辑」,全是「这笔退款根本不该由 Agent 自动处理」的信号。把它们前置,省掉了后面所有步骤——也堵住了最常见的几类事故:给投诉中的用户自动退款、给冒用账号的人退款、给同一单退第二次。

检测信号: 看供应商的退款 workflow,先问「在查订单之前,你拦掉了哪几类情况?」答不出一道前置 gate 的,说明它的设计是「先假设能退,再找理由不退」——方向反了。安全的设计是「先假设不该退,逐道闸放行」。

五、每个外部系统没就绪的叶子,默认转人工

真实落地最难看、也最重要的一块:外部系统不是一起就绪的。 私域退款接口通了,公域订单查询的适配器还没建;识图服务在评估;退差价系统、工单系统要等对方排期。

线性版假设所有依赖都在。分流树必须假设它们大多不在。所以每个「需要某个还没接通的外部系统」的叶子节点,统一降级——transfer_to_human

class ExecuteBySystemStep(WorkflowStep):
    async def run(self, ctx):
        target = SYSTEM_ROUTE[ctx.branch]      # 该子场景该落哪个系统
        if not target.is_ready():              # 外部系统未就绪
            return StepResult(output={"degraded": target.name},
                              next_step="transfer_to_human",
                              message=None, tool_used=None)   # fail-closed
        result = await target.execute(ctx)
        return StepResult(output=result, next_step="confirm", ...)

Every leaf whose external system isn't wired yet fails closed to a human — degrade is the default, not the exception

这让落地可以分期,而每一期都是安全的:

  • P0(现在就能上):私域最小闭环——总开关 + 公共 gate + 私域查单 + 限额核账 + critic + 确认 + 私域退款执行。没识图、没退差价系统、没工单系统的子场景,先全部转人工。
  • P1:识图(运费凭证、物流单号)接上、公域订单适配器接上。
  • P2:退差价系统、工单系统、平台后台等外部就绪后,逐个把对应叶子从「转人工」切到「自动执行」。

关键认知:「先转人工」不是这个功能没做完,是这个功能的安全默认态。 一个叶子从「转人工」切到「自动执行」,是一次需要单独灰度、单独验收的上线,不是「本来就该自动」。这跟 fail-closed Critic 那篇是同一个原则:拿不准的时候,往「人来兜」的方向倒,不往「机器放行」的方向倒。

六、限额不在 prompt 里,在一张 ledger 表里

凡是「累计」类的约束,绝不能让 LLM 去记——必须是一次确定性的数据库查询。 这是这套设计里我最不肯妥协的一条。

为什么?因为「这个用户这个月已经退过多少」是一个跨会话、跨时间的事实,LLM 的上下文里根本没有它。让模型「估一下用户大概退过几次」是灾难。它必须来自一张专门记账的表:

-- refund_ledger:每笔成功退款落一行
CREATE TABLE refund_ledger (
    id          BIGSERIAL PRIMARY KEY,
    account     TEXT NOT NULL,       -- 渠道内账号(私域会员号 / 公域平台账号)
    channel     TEXT NOT NULL,       -- 不跨渠道归一
    scenario    TEXT NOT NULL,       -- fee | price_diff | compensation | ...
    amount      NUMERIC NOT NULL,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

核账这一步去查它——按 (账号, 场景) 聚合,撞任一上限就转人工:

class LimitLedgerCheckStep(WorkflowStep):
    async def run(self, ctx):
        used = await ledger.usage(account=ctx.account, channel=ctx.channel,
                                  scenario=ctx.scenario)
        # 举例口径(实际数字以业务为准):单笔 min(实付×10%, 20元)、日 20、月 100、累计窗口 90 天
        if (ctx.amount > min(ctx.paid * 0.10, CAP_PER_TXN)
                or used.today + ctx.amount > CAP_DAY
                or used.month + ctx.amount > CAP_MONTH):
            return StepResult(output={"limit": "exceeded"},
                              next_step="transfer_to_human", ...)
        return StepResult(output={}, next_step="critic_check", ...)

Limits live in a ledger table keyed by (account, scenario), per-channel, never unified across domains

这里有两个容易做错的决定:

第一,累计的 key 是 (账号, 场景),而且严格限定在当前渠道内,不做跨渠道归一。 私域会员号和公域各平台账号互不打通——技术上也查不到对方。所以日/月限额是按场景各自计的,一个账号单日的总敞口 = 单场景上限 × 受限场景数。这不是妥协,是「跨渠道账号无法可靠关联」这个事实逼出来的诚实设计。承认查不到,比假装能归一安全。

第二,这张 ledger 走正式的关系型数据库,不走那种轻量 KV。 它写密集、要并发、要审计——和资损审计那篇里讲的「重复退款必须对账台账」是同一张账的两面:预防端写它、测量端查它。

七、每个写操作前,一道 Critic + 一张二次确认卡

到这一步,前面所有 gate 都放行了,限额也没撞。但在真正调财务接口之前,还有最后两道:一道机器的语义闸,一道人的确认。

class CriticCheckStep(WorkflowStep):
    async def run(self, ctx):
        verdict = await llm_critic.review(ctx)     # 这笔退款语义上站得住吗
        if verdict is None or verdict.timed_out:   # ← fail-closed
            return StepResult(output={"critic": "unavailable"},
                              next_step="transfer_to_human", ...)
        if not verdict.approved:
            return StepResult(output={"critic": verdict.reason},
                              next_step="transfer_to_human", ...)
        return StepResult(output={}, next_step="confirm_card", ...)

注意那个 verdict is None or verdict.timed_out——Critic 超时、报错、返回空,一律当「不通过」处理,转人工,而不是「网关抽风就放行」。 这是 Critic fail-closed 那篇的核心:一个会在超时时放行的 Critic,等于没有 Critic,因为攻击者和 corner case 专挑它最弱的时候出现。

Critic 过了,还有 confirm_card——一张让用户二次确认的卡片,workflow 在这里 pause,等用户点确认才 resume。机器判断 + 人类确认,双保险,缺一道都不敢碰钱。

八、子场景开关默认全 OFF,逐场景灰度

这么多子场景,不可能一起上。所以每个子场景挂一个独立开关,配置里默认全关:

# thresholds.yaml
small_refund:
  enabled: false          # 总开关
  refund_fee:    false    # 运费
  price_diff:    false    # 差价
  compensation:  false    # 私域补偿
  defect:        false    # 公域瑕疵
  quality:       false    # 公域质量

Per-sub-scenario switches default to OFF; each leaf is ramped independently through P0/P1/P2

默认全 OFF 意味着:代码合进去 ≠ 这个场景上线了。 一个子场景要生效,得有人显式把那个开关翻成 true——而翻开关的前提,是这个场景过了上线前那几道闸:做对率、误执行红线、限额边界测试全过。开关 + 闸门配合,才能做到「整条 workflow 的代码都在,但只放行验过的那几个场景」。

这也是 P0/P1/P2 能安全分期的底层机制:P0 只翻开私域那几个开关,公域的全留 OFF、对应叶子全转人工——线上跑的是一个「能力完整、但只放行了一小块」的 workflow。

九、怎么验:每道闸、每个限额边界、每条降级路径都要有断言

这套设计的测试,不是「测能不能退款」——退款是最容易测的。难的是测那八成「不退款」的逻辑都对:

  • 每道 gate 一个单测:投诉中、账号不一致、黑名单、已退过——每个都要断言「转人工,且没碰财务接口」。
  • 每个限额边界一个单测:实付 10%、单笔 20、日 20、月 100、90 天窗口——边界值(刚好等于 / 超 1 分)都要测。
  • 每条「外部未就绪 → 转人工」降级路径一个断言:这是 fail-closed 的命脉,每个叶子都要有一条「依赖挂了,确认它转人工而不是报错或放行」。
  • 私域 P0 端到端 happy-path:用一个 7 天内的 mock 单,跑通「能退的真退了」。

注意这个测试矩阵的形状:绝大多数 case 在验「该不退的时候,确实没退」。 这跟 双轨测试那篇里讲的「写操作轨要逐笔验、异常必含」是一致的——一个会动钱的 workflow,它的测试集也应该是「不退款」占多数。

十、本周能拿这张骨架图做的事

把这一篇压成可以直接用的检测工具——下次评审一个退款 / 任何会动钱的 Agent 方案时:

  1. 问「payout 之前有几道判断?」 数不出 5 道以上前置护栏的,是「先假设能做、再找理由不做」的危险设计。
  2. 问「下一步去哪,是代码决定还是 LLM 决定?」 路径由 LLM 自主决定 = L3 autonomous,会动钱的场景里这是红线。
  3. 问「累计限额存在哪?」 答「让模型记着」或「prompt 里写了」= 不及格;必须是一张可查、可审计的 ledger 表。
  4. 问「外部系统没就绪时,默认行为是什么?」 答「先放行/先返回成功」= fail-open,迟早出资损;安全答案是 fail-closed 转人工。
  5. 问「Critic 超时怎么办?」 答「放行」= 等于没有 Critic。
  6. 问「一个新子场景上线,要改代码还是翻开关?」 答「改代码重新部署」说明没有灰度机制;安全的是开关默认 OFF、逐场景翻开。

这六个问题的共性:它们全在问『不做』那一面的设计,没一个在问『能不能退款』。 因为能不能退款是最容易的,敢不敢上线,全在那八成「不退款」的护栏上。

想再深一层

这套东西在工程上有成熟的名字,值得往下挖:

  • State machine / 状态机next_step 显式枚举的本质就是一台确定性状态机。把 workflow 当状态机来设计,路径就是可枚举、可测、可画图的——这正是「deterministic」的工程定义。
  • Saga pattern:跨多个外部系统的写操作(退款 + 落账 + 通知),要么都成、要么可补偿回滚。退款这种动钱的多步操作,迟早要面对部分失败的补偿问题。
  • Idempotency key(幂等键):防重复退款的终极武器——每笔退款请求带一个幂等键,财务接口对同一个键只执行一次。ledger 去重是应用层防线,幂等键是接口层防线,两层都要有。
  • Two-phase confirmconfirm_card 的 pause/resume 本质是两阶段提交的轻量版——先准备、等确认、再执行。

这些不是高深理论,是「让一个会动钱的多步操作不出事」的标准配套。一个 L2 退款 workflow 敢上线,靠的不是某个聪明的 LLM,是这一整套确定性的、可枚举的、默认往安全侧倒的护栏。


下一篇换个角度,从「防守」走到「进攻」:怎么给一个会动钱的 Agent 做红队——A 到 G 七类攻击语料,专挑能造成资损和信息泄漏的打法。

回复关键词「退款骨架」,我把这份《L2 退款 workflow 骨架自查表》发给你:分流树模板 + 7 道护栏 checklist + 6 个供应商问题 + fail-closed 降级路径清单,一页能贴在方案评审会上。

回复渠道见页脚(公众号 / X)。不方便回复的,评论区留邮箱也行。

Subscribe for updates

Get the latest AI engineering posts delivered to your inbox.

评论