【Eino 框架入门】Interrupt/Resume 中断恢复:给 Agent 加个"审批关卡"

【Eino 框架入门】Interrupt/Resume 中断恢复:给 Agent 加个"审批关卡"

Agent 能自动执行命令很方便,但如果它执行了 rm -rf / 呢?

所以有些操作需要人工确认。Interrupt/Resume 就是干这个的。

Interrupt 是什么?

打个比方:Agent 是司机,Interrupt 是红绿灯。

  • 司机想往前开 → 红灯亮了 → 司机停车等待
  • 绿灯亮了 → 司机继续开

核心效果:敏感操作前先暂停,等人确认后再执行。

bash 复制代码
you> 请执行命令 rm -rf /tmp/test

⚠️  Approval Required ⚠️
Tool: execute
Arguments: {"command":"rm -rf /tmp/test"}

Approve this action? (y/n): y
✓ Approved, executing...
[tool result] 删除成功

两次调用机制

Interrupt 的实现很巧妙:一个 Tool 会被调用两次。

第一次调用:触发中断,保存参数,返回中断信号

go 复制代码
wasInterrupted, _, storedArgs := tool.GetInterruptState[string](ctx)

if !wasInterrupted {
    // 第一次:触发中断,保存 args
    return "", tool.StatefulInterrupt(ctx, &ApprovalInfo{
        ToolName:        "execute",
        ArgumentsInJSON: args,
    }, args)  // 第三个参数是状态,Resume 后能取回
}

第二次调用(Resume 后):读取审批结果,执行或拒绝

go 复制代码
isTarget, hasData, data := tool.GetResumeContext[*ApprovalResult](ctx)
if isTarget && hasData {
    if data.Approved {
        return endpoint(ctx, storedArgs, opts...)  // 执行
    }
    return "操作被用户拒绝", nil
}

完整的审批中间件

go 复制代码
type approvalMiddleware struct {
    *adk.BaseChatModelAgentMiddleware
}

func (m *approvalMiddleware) WrapInvokableToolCall(
    _ context.Context,
    endpoint adk.InvokableToolCallEndpoint,
    tCtx *adk.ToolContext,
) (adk.InvokableToolCallEndpoint, error) {
    // 只拦截 execute 命令
    if tCtx.Name != "execute" {
        return endpoint, nil
    }

    return func(ctx context.Context, args string, opts ...tool.Option) (string, error) {
        wasInterrupted, _, storedArgs := tool.GetInterruptState[string](ctx)

        if !wasInterrupted {
            // 第一次:触发中断
            return "", tool.StatefulInterrupt(ctx, &ApprovalInfo{
                ToolName:        tCtx.Name,
                ArgumentsInJSON: args,
            }, args)
        }

        // 第二次:读取审批结果
        isTarget, hasData, data := tool.GetResumeContext[*ApprovalResult](ctx)
        if isTarget && hasData {
            if data.Approved {
                return endpoint(ctx, storedArgs, opts...)  // 执行
            }
            return fmt.Sprintf("tool '%s' disapproved", tCtx.Name), nil
        }

        // 其他情况:重新中断
        return "", tool.StatefulInterrupt(ctx, &ApprovalInfo{
            ToolName:        tCtx.Name,
            ArgumentsInJSON: storedArgs,
        }, storedArgs)
    }, nil
}

关键点:

  • GetInterruptState 判断是不是 Resume 后的调用
  • StatefulInterrupt 触发中断并保存状态
  • GetResumeContext 读取用户的审批结果

CheckPointStore:保存中断状态

中断时需要保存状态,否则 Resume 后不知道要执行什么:

go 复制代码
runner := adk.NewRunner(ctx, adk.RunnerConfig{
    Agent:           agent,
    EnableStreaming: true,
    CheckPointStore: adkstore.NewInMemoryStore(),  // 内存存储
})

运行时带上 CheckPointID:

go 复制代码
checkPointID := sessionID
events := runner.Run(ctx, history, adk.WithCheckPointID(checkPointID))

处理中断事件

Runner 返回的事件里可能包含中断信息:

go 复制代码
content, interruptInfo, err := printAndCollectAssistantFromEvents(events)

if interruptInfo != nil {
    // 有中断,需要用户审批
    content, err = handleInterrupt(ctx, runner, checkPointID, interruptInfo, reader)
}

handleInterrupt 展示审批提示,收集用户输入,然后 Resume:

go 复制代码
func handleInterrupt(ctx context.Context, runner *adk.Runner, checkPointID string, interruptInfo *adk.InterruptInfo, reader *bufio.Reader) (string, error) {
    for _, ic := range interruptInfo.InterruptContexts {
        if !ic.IsRootCause {
            continue
        }

        info, ok := ic.Info.(*ApprovalInfo)
        if !ok {
            continue
        }

        // 展示审批提示
        fmt.Printf("\n⚠️  Approval Required ⚠️\n")
        fmt.Printf("Tool: %s\n", info.ToolName)
        fmt.Printf("Arguments: %s\n", info.ArgumentsInJSON)
        fmt.Print("\nApprove this action? (y/n): ")

        // 读取用户输入
        response, _ := reader.ReadString('\n')
        response = strings.TrimSpace(strings.ToLower(response))

        var resumeData *ApprovalResult
        if response == "y" || response == "yes" {
            resumeData = &ApprovalResult{Approved: true}
        } else {
            resumeData = &ApprovalResult{Approved: false}
        }

        // Resume 继续执行
        events, _ := runner.ResumeWithParams(ctx, checkPointID, &adk.ResumeParams{
            Targets: map[string]any{
                ic.ID: resumeData,
            },
        })

        content, newInterruptInfo, _ := printAndCollectAssistantFromEvents(events)
        if newInterruptInfo != nil {
            return handleInterrupt(ctx, runner, checkPointID, newInterruptInfo, reader)
        }
        return content, nil
    }

    return "", fmt.Errorf("no root cause interrupt context found")
}

执行流程图

lua 复制代码
用户:执行命令 echo hello
        ↓
    Agent 决定调用 execute
        ↓
    ApprovalMiddleware 拦截
        ↓
    第一次调用 execute
        ↓
    触发 Interrupt,保存状态
        ↓
    返回 Interrupt 事件
        ↓
    展示审批提示:Approve? (y/n)
        ↓
    用户输入 y
        ↓
    runner.ResumeWithParams()
        ↓
    第二次调用 execute
        ↓
    读取审批结果:Approved=true
        ↓
    执行真正的命令
        ↓
    返回结果:hello

注意事项

safeToolMiddleware 要放后面:中断错误要继续传播,不能被吞掉

go 复制代码
Handlers: []adk.ChatModelAgentMiddleware{
    &approvalMiddleware{},  // 先拦截需要审批的
    &safeToolMiddleware{},  // 再处理普通错误
}

safeToolMiddleware 要识别中断错误:

go 复制代码
if _, ok := compose.IsInterruptRerunError(err); ok {
    return "", err  // 中断错误继续传播,不转换
}

小结

Interrupt/Resume 实现了人机协作的关键能力:

概念 作用
Interrupt 暂停执行,等待确认
Resume 恢复执行,继续流程
CheckPointStore 保存中断状态
ApprovalMiddleware 拦截敏感操作

核心思想:把一个 Tool 调用拆成两阶段,第一阶段暂停问人,第二阶段根据人的答复决定执行还是拒绝。

相关推荐
星核0penstarry24 分钟前
企业 API 向后兼容实战:版本控制、废弃字段通知与灰度发布策略
人工智能·后端·api·接口隔离原则·版本控制·架构设计·后端架构
用户别睡觉1 小时前
自己搭的中文 PDF RAG 接了 Redis 缓存、跑了 RAGAS,以为能写“提速多少、准确率多少”,结果两个都没按预期来。
后端
彭亚川Allen1 小时前
985 在职研究生到底怎么样?是不是水硕?是不是.....?
前端·后端·程序员
乘风2481 小时前
# 我做了个会遗忘的 AI 记忆,召回率反而从 8% 涨到 100%
后端
Rain的Java大神之路1 小时前
面试被LeetCode 27(移除元素)问懵?别再用两层 for 循环了:一道“简单题”吃透双指针原地删除,附面试加分话术 + 11 个避坑清单
后端·leetcode·面试
利威尔xu2 小时前
Go IM 源码实战(11):token 只是个 HASH,多端互踢的顺序错了就踢错人
后端·微服务·面试·架构·golang
猿究院--Cu-Sn合金2 小时前
Spring Boot + DeepSeek 求职 Agent 实践:工具注册、多轮调用与简历 JD 对比
数据库·人工智能·spring boot·后端·deepseek
程序员cxuan2 小时前
别学 subagent 了,很可能要凉了。
后端·agent·ai编程
小咕聊编程2 小时前
基于Django的个人摄影博客网站设计与开发
后端·python·django
未秃头的程序猿3 小时前
@ConditionalOnMissingBean 把我配好的 RabbitMQ 手动 ack 吃掉了
后端·rabbitmq