---
tags: ["Eino", "Agent", "Interrupt", "Resume", "Backend", "DeepAgent", "审批流"]
create time: "2026-04-29 15:30"
---

# 第七章：Interrupt / Resume（中断与恢复）

## 概述

本章引入 Eino 的 **Interrupt / Resume** 机制——一种在人机协作中实现人工审批的能力。当 Agent 需要执行敏感操作（如删除文件、发送邮件、执行命令）时，可以在执行前暂停并等待用户确认；确认后继续，拒绝则返回错误。这是让 Agent 从"全自动"走向"安全可控"的关键一步。

## 为什么需要 Interrupt

前三章我们逐步为 Agent 添加工具能力，使其能够读取文件、搜索代码、执行命令。但全自动执行工具也存在风险：

| 风险场景 | 后果 |
|---------|------|
| 误删文件 | 不可逆的数据丢失 |
| 发送错误邮件 | 严重的沟通事故 |
| 执行危险命令 | 系统环境被破坏 |
| 修改关键配置 | 服务不可用 |

**Interrupt 的定位：**
- **Interrupt 是 Agent 的暂停机制**：在关键操作前暂停，等待用户确认
- **Interrupt 可携带信息**：向用户展示即将执行的操作详情
- **Interrupt 可恢复**：确认后继续执行，拒绝后优雅返回错误

> [!tip] 简单类比
>
> - **自动执行** = "自动驾驶"（完全信任系统）
> - **Interrupt** = "人工接管"（关键决策由人来做）

## 关键概念

### Interrupt 的两阶段执行

一个受审批保护的 Tool 在执行时被分成两个阶段：

```mermaid
flowchart LR
    A["Agent\n决定调用 Tool"] --> B{"Tool 内部"}
    B -->|"第一阶段"| C["保存参数"]
    C --> D["触发 Interrupt"]
    D --> E["Runner 暂停"]
    E --> F["向调用方返回\nInterrupt 事件"]
    F --> G["用户看到审批提示"]
    G --> H{"用户选择"}
    H -->|"批准"| I["runner.ResumeWith... 带上审批结果"]
    H -->|"拒绝"| J["runner.ResumeWith... 带上拒绝结果"]
    I --> K{"Tool 内部"}
    J --> K
    K -->|"第二阶段 Resume"| L["读取审批结果"]
    L -->|"Approved"| M["执行实际操作"]
    L -->|"Rejected"| N["操作被拒绝"]
```

核心 API：

| API | 作用 |
|------|------|
| `tool.GetInterruptState[T](ctx)` | 判断当前是第一阶段还是 Resume 后的第二阶段 |
| `tool.StatefulInterrupt(ctx, info, state)` | 触发中断，`info` 展示给用户，`state` 供 Resume 后取回 |
| `tool.GetResumeContext[T](ctx)` | 获取用户的审批结果数据 |

> [!note] 两阶段设计精妙之处
>
> 同一个 Tool 函数被调用两次，通过 `GetInterruptState` 区分：第一次返回 false（触发中断），第二次返回 true（Resume 恢复）。这种"自反式"设计无需引入额外的状态机或外部协调器，中断逻辑就内聚在 Tool 自身内部。

### ApprovalMiddleware

生产实践中，推荐将中断逻辑放入 **Middleware** 而非每个 Tool 内部实现。这样审批规则集中管理、Tool 本身保持干净：

ApprovalMiddleware 拦截特定的 Tool 调用（如 `execute`），对每次调用统一施加审批逻辑：

```go
type approvalMiddleware struct {
    *adk.BaseChatModelAgentMiddleware
}

func (m *approvalMiddleware) WrapInvokableToolCall(
    _ context.Context,
    endpoint adk.InvokableToolCallEndpoint,
    tCtx *adk.ToolContext,
) (adk.InvokableToolCallEndpoint, error) {
    // 仅拦截需审批的 Tool（例如 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, &commontool.ApprovalInfo{
                ToolName:        tCtx.Name,
                ArgumentsInJSON: args,
            }, args)
        }

        // Resume 阶段 → 检查用户是否批准
        isTarget, hasData, data := tool.GetResumeContext[*commontool.ApprovalResult](ctx)
        if isTarget && hasData {
            if data.Approved {
                return endpoint(ctx, storedArgs, opts...)  // 通过中间件继续原 Tool 的执行
            }
            reason := ""
            if data.DisapproveReason != nil {
                reason = fmt.Sprintf(": %s", *data.DisapproveReason)
            }
            return fmt.Sprintf("tool '%s' disapproved%s", tCtx.Name, reason), nil
        }

        // 非目标 Tool → 重新中断
        return "", tool.StatefulInterrupt(ctx, &commontool.ApprovalInfo{
            ToolName:        tCtx.Name,
            ArgumentsInJSON: storedArgs,
        }, storedArgs)
    }, nil
}
```

> [!warning] Streamable 变体不可遗漏
>
> 如果 Agent 启用了流式输出（EnableStreaming: true），某些 Tool 调用可能走 `StreamableToolCall` 路径。此时必须同时实现 `WrapStreamableToolCall`，否则审批逻辑会被绕过。ch07 完整代码中两者都已覆盖。

### CheckPointStore

中断恢复还需要一个持久化组件来保存执行状态——这就是 `CheckPointStore`：

```go
type CheckPointStore interface {
    Put(ctx context.Context, key string, checkpoint *Checkpoint) error
    Get(ctx context.Context, key string) (*Checkpoint, error)
}
```

它的作用不止于存储 Tool 参数，还包括 Runner 当前的执行进度。有了它，即使进程重启也能从中断点继续：

> [!example] CheckPointStore 的两种典型实现
>
> | 实现方式 | 适用场景 | 跨进程恢复 |
> |---------|---------|----------|
> | `adkstore.NewInMemoryStore()` | 开发调试、单进程 | ❌ |
> | Redis / SQLite 等外部存储 | 生产部署 | ✅ |

## 代码实现

### 1. 配置 Runner 使用 CheckPointStore

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

### 2. 配置 Agent 注册中间件

```go
agent, err := deep.New(ctx, &deep.Config{
    // ... 其他配置
    Handlers: []adk.ChatModelAgentMiddleware{
        &approvalMiddleware{},       // 审批中间件
        &safeToolMiddleware{},       // 将 Tool 错误转为字符串（中断类错误继续向上抛出）
    },
})
```

### 3. 处理 Runner 返回的事件

```go
checkPointID := sessionID

events := runner.Run(ctx, history, adk.WithCheckPointID(checkPointID))
content, interruptInfo, err := printAndCollectAssistantFromEvents(events)

if interruptInfo != nil {
    // 使用同一个 stdin reader 读取「用户输入」与「审批 y/n」
    // 避免审批输入被误认为下一轮对话消息
    content, err = handleInterrupt(ctx, runner, checkPointID, interruptInfo, reader)
    if err != nil {
        return err
    }
}
```

### 4. 完整的审批交互流程

```mermaid
flowchart TD
    U["用户：执行命令 echo hello"] --> S1["你> 请执行命令 echo hello"]
    S1 --> AGT["Runner.Run() 启动执行"]
    AGT --> A["Agent 分析意图\n决定调用 execute 工具"]
    A --> AM["ApprovalMiddleware\n拦截 Tool 调用"]
    AM --> SI["触发 StatefulInterrupt\n保存参数到 Store"]
    SI --> EVT["返回 Interrupt 事件"]
    EVT --> UI["控制台显示审批提示"]
    UI --> USER{"用户选择"}
    USER -->|"y"| RESUME["runner.ResumeWith\n携带审批结果 Approved=true"]
    USER -->|"n"| REJECT_DIRECT["runner.ResumeWith\n携带审批结果 Approved=false"]
    RESUME --> RTOOL["Tool 再次被调用\nGetInterruptState = true\n读取审批结果并批准"]
    RTOOL --> EXEC["执行 execute\necho hello"]
    EXEC --> OUT["输出: hello"]
    REJECT_DIRECT --> RTOOL2["Tool 再次被调用\nGetInterruptState = true\n读取审批结果并拒绝"]
    RTOOL2 --> NOP["输出: tool  disapproved"]
```

## 运行

在 `examples/quickstart/chatwitheino` 目录下执行：

```bash
export PROJECT_ROOT=/path/to/eino  # Eino 核心库根目录（不设置则默认使用当前目录）
go run ./cmd/ch07
```

输出示例：

```
you> 请执行命令 echo hello

⚠️  Approval Required ⚠️
Tool: execute
Arguments: {"command":"echo hello"}

Approve this action? (y/n): y
[tool result] hello

hello
```

> [!question] 深入思考
>
> 上面的输出中有两条 `hello`——一条来自 `[tool result]`，另一条是 Assistant 的最终回复。你能解释它们分别来自哪里吗？  
> 提示：第一条是 `printAndCollectAssistantFromEvents` 对流式事件中 Tool Result 片段的打印，第二条是 Agent 整合信息后生成的自然语言回复。理解了这一点，你就掌握了 Eino 事件模型的核心。

## 本章小结

| 概念 | 一句话理解 |
|------|-----------|
| **Interrupt** | Agent 在敏感操作前的暂停机制 |
| **Resume** | 用户审批后恢复执行，支持批准与拒绝两种结果 |
| **Two-stage Execution** | 同一个 Tool 被调用两次，通过 `GetInterruptState` 区分阶段 |
| **ApprovalMiddleware** | 集中式拦截特定 Tool 的审批逻辑，使 Tool 保持干净 |
| **CheckPointStore** | 保存中断状态和执行位置，支持跨进程恢复 |
| **人机协作** | 关键决策由人类确认，兼顾 Agent 自动化与安全可控 |

## 扩展思考

### 更多 Interrupt 应用场景

| 场景 | 说明 |
|------|------|
| 多选项审批 | 用户从多个选项中选择一个（而非简单的 y/n） |
| 参数补全 | 用户提供缺失的参数值后才继续执行 |
| 条件分支 | 用户决定不同的执行路径 |

### 审批策略

| 策略 | 适用场景 |
|------|---------|
| 白名单 | 只审批极少数敏感操作（推荐默认做法） |
| 黑名单 | 审批所有操作，除已知的安全操作外 |
| 动态规则 | 根据参数内容决定是否审批（如文件大小、操作范围） |

## 关联笔记

- [[Eino/quick_start/_index]]
- [[Eino/quick_start/chapter_04_tool_and_filesystem]] — 文件系统访问与 DeepAgent（第三章的工具章节）
- [[Eino/quick_start/chapter_05_middleware]] — Middleware 模式详解（上一章）
