人机协同审核工作流:当 Agent 执行退款操作时的企微审批机器人集成

上周二上午,财务主管气冲冲地推开技术部的大门。起因是前一天晚上,售后客服 Agent 碰到了一个极其难缠的恶意买家。买家在聊天框里疯狂复制“再不退款立刻投诉到 12315”、“工商局见”等极端施压话术,结果大模型在连续多轮对抗后产生对齐破损,竟然跳过了“核对退货物流单号”的刚性逻辑,直接调起底层退款工具给买家放行了 4,200 元的退款,而货物根本就没退回仓库。
事后复盘,很多人把责任推给大模型的 Prompt 写得不够严密。但在真正的企业级生产环境里,靠修改系统提示词来保障资金安全无异于在沙滩上盖高楼。大模型天生具备不确定性,无论是被提示词注入攻击,还是单纯的模型幻觉,只要它拥有无限制调用敏感写操作(如退款、封号、删库、改价)的直连权限,资金穿透就是迟早的必然事件。
涉及真金白银的操作,必须在底层架构上实现“人机协同(Human-in-the-Loop, HITL)”。Agent 可以作为高效率的申请发起方与材料整理方,但在真正扣款执行前,Agent 状态机必须原地异步挂起(Suspension),将决策权移交给企业微信群内的真人审核员。只有收到合法的审批通过回调后,状态机才被重新唤醒(Resumption)继续向下走。
异步挂起与唤醒模型:解耦等待与执行
传统的同步阻塞调用在人机协同场景下完全行不通。审核员可能在 30 秒内点击同意,也可能在开会半小时后才处理。如果让后端协程或者 HTTP 连接一直挂起等待,连接池和系统内存分分钟被耗尽。
必须让 Agent 状态机具备两项核心能力:
- 就地冻结与持久化:当大模型输出的 Tool Call 命中高危操作名单(如
refund_execute)时,拦截器直接打断执行链,提取当前的会话上下文、已推理步骤、参数载荷以及待执行函数名,打包序列化为状态快照,存入数据库,并将状态标记为SUSPENDED。 - 事件驱动唤醒:向企业微信群推送带交互按钮的审批卡片。真人点击后,企微服务器向我们的回调网关推送事件。网关校验签名与幂等性后,根据唯一的
workflow_id重建 Agent 内存栈,将人工审批意见以标准的 Tool Execution Result 形式喂给模型,状态机平滑恢复运行。
[Agent 决策发起退款] ──> 触发高危工具拦截器
│
▼
┌─────────────────┐
│ 状态机序列化快照 │
│ 写入存储: SUSPEND│
└────────┬────────┘
│
▼
向企业微信群推送审批卡片
│
┌────────────────┴────────────────┐
[审批员点击: 同意] [审批员点击: 驳回]
│ │
▼ ▼
企微 Webhook 回调 企微 Webhook 回调
│ │
▼ ▼
加载快照,唤醒状态机 加载快照,注入拒绝结果
执行退款工具并通知用户 终止退款,模型组织委婉话术
状态机调度器:Go 1.27.1 实现
我们基于 Go 1.27.1 实现了一套轻量级工作流状态机。通过方法级通用泛型支持多样化的业务上下文,结合 Go 1.26 的 new(expr) 指针初始化,将快照生成和事件恢复做到了极致的轻量化。
package workflow
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
)
type StepStatus string
const (
StatusRunning StepStatus = "RUNNING"
StatusSuspended StepStatus = "SUSPENDED"
StatusApproved StepStatus = "APPROVED"
StatusRejected StepStatus = "REJECTED"
)
// WorkflowSnapshot 记录挂起时的全量运行态
type WorkflowSnapshot struct {
WorkflowID string `json:"workflow_id"`
OrderID string `json:"order_id"`
RefundAmount float64 `json:"refund_amount"`
AIRationale string `json:"ai_rationale"`
ToolName string `json:"tool_name"`
ToolArgs map[string]any `json:"tool_args"`
Status StepStatus `json:"status"`
CreatedAt time.Time `json:"created_at"`
}
// Storage 抽象快照持久化层
type Storage interface {
Save(ctx context.Context, snapshot *WorkflowSnapshot) error
Load(ctx context.Context, workflowID string) (*WorkflowSnapshot, error)
UpdateStatus(ctx context.Context, workflowID string, status StepStatus, operator string) error
}
// WeComNotifier 负责向企业微信机器人推送交互卡片
type WeComNotifier interface {
SendApprovalCard(ctx context.Context, snapshot *WorkflowSnapshot) error
}
// ApprovalEngine 人机协同状态机引擎
type ApprovalEngine struct {
storage Storage
notifier WeComNotifier
mu sync.Mutex
}
func NewApprovalEngine(s Storage, n WeComNotifier) *ApprovalEngine {
return &ApprovalEngine{storage: s, notifier: n}
}
// SuspendForHumanApproval 利用 Go 1.27.1 泛型方法接收待审核上下文,挂起工作流
func (e *ApprovalEngine) SuspendForHumanApproval[T any](ctx context.Context, workflowID, orderID string, amount float64, rationale string, toolName string, args T) error {
e.mu.Lock()
defer e.mu.Unlock()
rawArgs, err := json.Marshal(args)
if err != nil {
return fmt.Errorf("serialize args failed: %w", err)
}
var parsedArgs map[string]any
_ = json.Unmarshal(rawArgs, &parsedArgs)
// 使用 Go 1.26 简洁指针初始化
snapshot := new(WorkflowSnapshot)
*snapshot = WorkflowSnapshot{
WorkflowID: workflowID,
OrderID: orderID,
RefundAmount: amount,
AIRationale: rationale,
ToolName: toolName,
ToolArgs: parsedArgs,
Status: StatusSuspended,
CreatedAt: time.Now(),
}
if err := e.storage.Save(ctx, snapshot); err != nil {
return fmt.Errorf("save snapshot failed: %w", err)
}
// 异步向企业微信发送审批卡片
go func() {
_ = e.notifier.SendApprovalCard(context.Background(), snapshot)
}()
return nil
}
// ResumeAfterCallback 处理企微审批回调并唤醒流程
func (e *ApprovalEngine) ResumeAfterCallback(ctx context.Context, workflowID string, approved bool, operator string) (string, error) {
snapshot, err := e.storage.Load(ctx, workflowID)
if err != nil {
return "", errors.New("workflow snapshot not found")
}
if snapshot.Status != StatusSuspended {
return "", fmt.Errorf("workflow %s is already processed: %s", workflowID, snapshot.Status)
}
newStatus := StatusRejected
if approved {
newStatus = StatusApproved
}
if err := e.storage.UpdateStatus(ctx, workflowID, newStatus, operator); err != nil {
return "", fmt.Errorf("update status failed: %w", err)
}
// 恢复状态机后,根据审批结论返回给 Agent 的真实结果
if approved {
// 此处执行真实微服务的退款 API
return fmt.Sprintf("人工审批通过(审批人: %s),退款金额 %.2f 已成功划转原账户", operator, snapshot.RefundAmount), nil
}
return fmt.Sprintf("人工审批驳回(审批人: %s),退款请求已被拦截,请向买家说明驳回原因", operator), nil
}
生产实战:企微交互与防御细节
在企业微信端,很多团队一开始图简单,直接发一段文本链接让客服在浏览器里登录后台点审批,结果导致响应极慢。体验最好的方案是使用企业微信的“交互型任务卡片(Template Card)”:
- 结构化卡片呈现:
卡片直接展示关键决策要素:- 【订单编号】:20261010098765
- 【退款金额】:¥ 4,200.00
- 【买家诉求】:商品损坏要求全额退款
- 【AI 研判建议】:买家未提供有效货损图片,且系统无退货入库记录,建议驳回或要求补传凭据。
下方设置两个带确认弹窗的按钮:“同意退款(高危)”与“驳回申请”。
- 防止二次点击与并发竞态:
审核员在企微手机端点击按钮时,可能会因为网络卡顿连点两次。我们在回调网关处理端加了 Redis 分布式排他锁:SETNX workflow:lock:{id} 1 EX 10。一旦某次点击进入处理,后续请求直接丢弃,并且在企业微信卡片上调用 API 实时将卡片状态更新为“已处理”,杜绝多名主管重复审核。 - 超时保护机制(Dead Man's Switch):
人工审批不能无限制卡住用户对话。如果超过 30 分钟没有真人点击企微卡片,后端的定时扫描定时器会自动触发超时熔断:将工作流状态标记为ESCALATED_TO_MANUAL_TICKET,将当前对话平滑切给人工在线坐席,同时在用户前端提示:“您的问题涉及大额退款复核,资深客服专家正在接入为您处理”。
这套人机协同机制上线运行了整整两周,拦截了 3 起恶意的黑产刷单退款,挽回直接经济损失近两万元。把边界划在确定性的代码和真实的真人决策上,大模型才能真正从一个会说胡话的玩具,变成敢在生产环境扛核心业务的帮手。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)