一个真的会动钱的 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
分流发生在 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——任何没有明确分支的组合,默认转人工,而不是「猜一个最接近的」。 这个默认值,是整套设计的基调。
三、数清楚才发现:大部分步骤不是退款,是不退款
把这棵树上的每个节点按职责标个色,会看到一件反直觉的事。
真正「把钱退出去」的,只有 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", ...)
这让落地可以分期,而每一期都是安全的:
- 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", ...)
这里有两个容易做错的决定:
第一,累计的 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 # 公域质量
默认全 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 方案时:
- 问「payout 之前有几道判断?」 数不出 5 道以上前置护栏的,是「先假设能做、再找理由不做」的危险设计。
- 问「下一步去哪,是代码决定还是 LLM 决定?」 路径由 LLM 自主决定 = L3 autonomous,会动钱的场景里这是红线。
- 问「累计限额存在哪?」 答「让模型记着」或「prompt 里写了」= 不及格;必须是一张可查、可审计的 ledger 表。
- 问「外部系统没就绪时,默认行为是什么?」 答「先放行/先返回成功」= fail-open,迟早出资损;安全答案是 fail-closed 转人工。
- 问「Critic 超时怎么办?」 答「放行」= 等于没有 Critic。
- 问「一个新子场景上线,要改代码还是翻开关?」 答「改代码重新部署」说明没有灰度机制;安全的是开关默认 OFF、逐场景翻开。
这六个问题的共性:它们全在问『不做』那一面的设计,没一个在问『能不能退款』。 因为能不能退款是最容易的,敢不敢上线,全在那八成「不退款」的护栏上。
想再深一层
这套东西在工程上有成熟的名字,值得往下挖:
- State machine / 状态机:
next_step显式枚举的本质就是一台确定性状态机。把 workflow 当状态机来设计,路径就是可枚举、可测、可画图的——这正是「deterministic」的工程定义。 - Saga pattern:跨多个外部系统的写操作(退款 + 落账 + 通知),要么都成、要么可补偿回滚。退款这种动钱的多步操作,迟早要面对部分失败的补偿问题。
- Idempotency key(幂等键):防重复退款的终极武器——每笔退款请求带一个幂等键,财务接口对同一个键只执行一次。ledger 去重是应用层防线,幂等键是接口层防线,两层都要有。
- Two-phase confirm:
confirm_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.




