--- 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 模式详解(上一章)