模型判断对了,系统照样会出事。用户点了「确认退款」,页面突然刷新,审批条消失——前端不知道审批还生不生效,后端不知道用户到底有没有做决定;更危险的是,退款请求已经提交给支付渠道,后端却在写入本地状态前重启了,恢复后系统只看到「任务尚未完成」,于是又调了一次退款接口。
这就是「能跑一个 ReAct 循环」和「能跑一个产品」之间的差距。本文拆解 vivo KDC 工程系列(文末有链接)的核心做法:把 Agent 的目标、判断、审批、执行、产物和反馈,变成可恢复、可控制、可审计的软件运行事实。
核心思路:三种事实不能混
- 领域现实:钱到底到没到账——软件只能通过接口、事件和人工确认间接观察。
- 业务判断事实:系统基于什么目标、知识和证据得出结论、为什么建议这个行动。
- 软件运行事实:这次运行已经发生了什么——Run 是否开始、审批是否挂起、Tool 是否执行、Checkpoint 在哪。
tool.call.completed 不等于退款到账,审批条消失不等于用户已授权。用稳定 ID 把两层串起来:runId / turnId / reasoningObjectId / capabilityId / approvalId / toolCallId / feedbackId,这样系统才能知道一条审批属于哪次判断、一次 Tool Call 来自哪个能力。
模式一:State / View / Control 分离
最常见的翻车方式,是前端从聊天文本里猜运行状态——模型输出「需要用户确认」,UI 就渲染一个审批按钮。演示没问题,一刷新就崩。正确做法:
- State:持久化的权威运行事实(activeRun、pendingApprovals、toolCalls、checkpoint)
- View:由 State 派生的展示(isBusy、approvalBanner、canStop),可以随 UI 变,但永远不是事实来源
- Control:命令而非状态写入(
resume(approvalId, decision)、stop(runId)、retry(toolCallId))
一句话边界:Runtime 写事实,View 读事实,Control 提交命令,UI 永远不替 Runtime 补写事实。
模式二:事件日志 + 强类型事件
{
"eventId": "event-109",
"eventType": "approval.required",
"runId": "run-20260727001",
"turnId": "turn-003",
"producer": "policy-runtime",
"reasoningObjectId": "reasoning-021",
"capabilityId": "refund-order-v2",
"correlationId": "refund-intent-031",
"causationId": "event-108",
"schemaVersion": 2,
"occurredAt": "2026-07-27T16:42:10+08:00",
"payload": { "approvalId": "approval-017", "risk": "high", "expiresAt": "2026-07-27T18:00:00+08:00" }
}审批状态必须由审批事件归约出来,不能因为模型在文本里说「用户应该会同意」就改变。同一份日志可以派生出运行状态、用户视图、审计记录和评测轨迹,每种投影保留自己的 Schema、Owner 和使用资格。
模式三:幂等键与 result_unknown
高影响调用要带稳定幂等键。响应丢失时 result_unknown 是独立的恢复边界——它不是「失败」的另一种写法:
{
"toolCalls": [{
"toolCallId": "tool-call-031",
"capabilityId": "refund-order-v2",
"status": "result_unknown",
"idempotencyKey": "refund:order-001:intent-031",
"externalRequestId": "payment-request-8841",
"dispatchedAt": "2026-07-27T16:48:12+08:00",
"acceptedAt": null,
"resultRef": null,
"reconciliationStatus": "pending",
"lastError": "response_timeout"
}]
}如果支付渠道可能已经受理,State 不能回退成「尚未调用」,必须保留同一个 toolCallId 和幂等键。恢复时先凭 externalRequestId 查外部系统、对账,再决定完成 / 补偿 / 转人工,绝不盲目重试回 authorized。
模式四:Resume Contract(Checkpoint ≠ Durable Execution)
持久化 Checkpoint 只说明「知道从哪里恢复」,不保证外部副作用只发生一次,也不保证重放安全。LangGraph 的 Interrupt 恢复时节点会从头重跑,中断前的副作用必须能安全重放;Temporal 靠重放事件历史重建状态,把副作用收敛到 Activity。Resume Contract 应该把这些规则变成 Runtime 可执行、可测试的约束:
- 为高影响调用生成稳定幂等键
- 区分「命令已发送 / 外部已受理 / 业务已确认」
- 结果未知时先查询或对账,而不是盲目重试
- 用事务消息或 Outbox 消除本地状态与事件发布之间的空窗
- 同一个 Approval 不能被重复消费;恢复时重验权限、策略与业务前置条件
另外建议把执行状态和结果验证状态分开跟踪(proposed → authorized → dispatching → accepted → completed/failed 与 not_observed → pending → confirmed_success/failure/inconclusive)——Run 可以在外部工作已可靠移交、结果仍为 pending 时结束。
实践建议
- 先做 State / View / Control 分离、把审批移出聊天文本——性价比最高的一步。
- 统一追加事件日志 + 强类型事件 + 按职责投影。「统一存储」可以,「从自然语言猜权威状态」不行。
- Session(可追加的运行记录)、Harness(循环 / 上下文 / 控制)、执行沙箱三者解耦,Harness 崩了可以从 Session 重建现场,高权限凭证不进入模型上下文。
- 跨天任务加一份结构化 Progress Contract(已验证项、待办项、下一步安全动作),避免新 Agent 高估进度、重复修改或破坏已有成果。
- 运行态是临时的:归档 Checkpoint、关闭审批、撤销短期凭证;只有经过来源确认、边界与时效治理的经验才晋升为 Memory / Knowledge——持久化 ≠ 记忆。