vault backup: 2026-04-29 19:01:34

This commit is contained in:
2026-04-29 19:01:34 +08:00
parent 84f40fc9d9
commit 65228f9ea8
4 changed files with 765 additions and 624 deletions
+230 -313
View File
@@ -1,24 +1,210 @@
---
Description: ""
date: "2026-03-16"
lastmod: ""
tags: []
title: 第七章:Interrupt/Resume(中断与恢复)
weight: 7
tags: ["Eino", "Agent", "Interrupt", "Resume", "Backend", "DeepAgent", "审批流"]
create time: "2026-04-29 15:30"
---
本章目标:理解 Interrupt/Resume 机制,实现 Tool 审批流程,让用户在敏感操作前进行确认。
# 第七章:Interrupt / Resume(中断与恢复)
## 代码位置
## 概述
- 入口代码:[cmd/ch07/main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch07/main.go)
本章引入 Eino 的 **Interrupt / Resume** 机制——一种在人机协作中实现人工审批的能力。当 Agent 需要执行敏感操作(如删除文件、发送邮件、执行命令)时,可以在执行前暂停并等待用户确认;确认后继续,拒绝则返回错误。这是让 Agent 从"全自动"走向"安全可控"的关键一步。
## 前置条件
## 为什么需要 Interrupt
与第一章一致:需要配置一个可用的 ChatModel(OpenAI 或 Ark)。同时,需要与第四章一样设置 `PROJECT_ROOT`:
前三章我们逐步为 Agent 添加工具能力,使其能够读取文件、搜索代码、执行命令。但全自动执行工具也存在风险:
```bash
export PROJECT_ROOT=/path/to/eino # Eino 核心库根目录(不设置则默认使用当前目录)
| 风险场景 | 后果 |
|---------|------|
| 误删文件 | 不可逆的数据丢失 |
| 发送错误邮件 | 严重的沟通事故 |
| 执行危险命令 | 系统环境被破坏 |
| 修改关键配置 | 服务不可用 |
**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"]
```
## 运行
@@ -26,9 +212,7 @@ export PROJECT_ROOT=/path/to/eino # Eino 核心库根目录(不设置则默
在 `examples/quickstart/chatwitheino` 目录下执行:
```bash
# 设置项目根目录
export PROJECT_ROOT=/path/to/your/project
export PROJECT_ROOT=/path/to/eino # Eino 核心库根目录(不设置则默认使用当前目录)
go run ./cmd/ch07
```
@@ -47,309 +231,42 @@ Approve this action? (y/n): y
hello
```
## 从自动执行到人工审批:为什么需要 Interrupt
前几章我们实现的 Agent 会自动执行所有 Tool 调用,但在某些场景下这是危险的:
**自动执行的风险:**
- 删除文件:误删重要数据
- 发送邮件:发送错误内容
- 执行命令:执行危险操作
- 修改配置:破坏系统设置
**Interrupt 的定位:**
- **Interrupt 是 Agent 的暂停机制**:在关键操作前暂停,等待用户确认
- **Interrupt 可携带信息**:向用户展示即将执行的操作
- **Interrupt 可恢复**:用户确认后继续执行,拒绝后返回错误
**简单类比:**
- **自动执行** = "自动驾驶"(完全信任系统)
- **Interrupt** = "人工接管"(关键决策由人来做)
## 关键概念
### Interrupt 机制
`Interrupt` 是 Eino 中实现人机协作的核心机制。
**核心思想:在执行关键操作前暂停,等待用户确认后继续。**
一个需要审批的 Tool 的执行被分成**两个阶段**:
1. **第一次调用(触发中断)**:Tool 保存当前参数,然后返回一个中断信号。Runner 暂停执行,向调用侧返回 Interrupt 事件。
2. **用户审批后恢复(Resume)**:Runner 重新调用 Tool,此时 Tool 检测到"已中断过",直接读取用户的审批结果并执行(或拒绝)。
**简化版伪代码:**
```
func myTool(ctx, args):
if 第一次调用:
保存 args
return 中断信号 // Runner 暂停,展示审批提示
else: // Resume 后的第二次调用
if 用户批准:
return 执行操作(保存的 args)
else:
return "操作被用户拒绝"
```
**完整代码及关键字段说明:**
```go
// 在 Tool 中触发中断
func myTool(ctx context.Context, args string) (string, error) {
// wasInterrupted: 是否是 Resume 后的第二次调用(第一次为 false,Resume 后为 true)
// storedArgs: 第一次调用时通过 StatefulInterrupt 保存的参数,Resume 后可取回
wasInterrupted, _, storedArgs := tool.GetInterruptState[string](ctx)
if !wasInterrupted {
// 第一次调用:触发中断,同时保存 args 供 Resume 后使用
return "", tool.StatefulInterrupt(ctx, &ApprovalInfo{
ToolName: "my_tool",
ArgumentsInJSON: args,
}, args) // 第三个参数是要保存的状态(Resume 后通过 storedArgs 取回)
}
// Resume 后的第二次调用:读取用户审批结果
// isTarget: 本次 Resume 是否针对当前 Tool(一次 Resume 只针对一个 Tool)
// hasData: Resume 时是否携带了审批结果数据
// data: 用户传入的审批结果
isTarget, hasData, data := tool.GetResumeContext[*ApprovalResult](ctx)
if isTarget && hasData {
if data.Approved {
return doSomething(storedArgs) // 使用保存的参数执行实际操作
}
return "Operation rejected by user", nil
}
// 其他情况(isTarget=false 意味着本次 Resume 目标不是当前 Tool):重新中断
return "", tool.StatefulInterrupt(ctx, &ApprovalInfo{
ToolName: "my_tool",
ArgumentsInJSON: storedArgs,
}, storedArgs)
}
```
### ApprovalMiddleware
`ApprovalMiddleware` 是一个通用的审批中间件,可以拦截特定 Tool 的调用:
```go
type approvalMiddleware struct {
*adk.BaseChatModelAgentMiddleware
}
func (m *approvalMiddleware) WrapInvokableToolCall(
_ context.Context,
endpoint adk.InvokableToolCallEndpoint,
tCtx *adk.ToolContext,
) (adk.InvokableToolCallEndpoint, error) {
// 只拦截需要审批的 Tool
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)
}
isTarget, hasData, data := tool.GetResumeContext[*commontool.ApprovalResult](ctx)
if isTarget && hasData {
if data.Approved {
return endpoint(ctx, storedArgs, opts...)
}
if data.DisapproveReason != nil {
return fmt.Sprintf("tool '%s' disapproved: %s", tCtx.Name, *data.DisapproveReason), nil
}
return fmt.Sprintf("tool '%s' disapproved", tCtx.Name), nil
}
// 重新中断
return "", tool.StatefulInterrupt(ctx, &commontool.ApprovalInfo{
ToolName: tCtx.Name,
ArgumentsInJSON: storedArgs,
}, storedArgs)
}, nil
}
func (m *approvalMiddleware) WrapStreamableToolCall(
_ context.Context,
endpoint adk.StreamableToolCallEndpoint,
tCtx *adk.ToolContext,
) (adk.StreamableToolCallEndpoint, error) {
// 如果 agent 配置了 StreamingShell,则 execute 会走流式调用,需要实现该方法才能拦截到
if tCtx.Name != "execute" {
return endpoint, nil
}
return func(ctx context.Context, args string, opts ...tool.Option) (*schema.StreamReader[string], error) {
wasInterrupted, _, storedArgs := tool.GetInterruptState[string](ctx)
if !wasInterrupted {
return nil, tool.StatefulInterrupt(ctx, &commontool.ApprovalInfo{
ToolName: tCtx.Name,
ArgumentsInJSON: args,
}, args)
}
isTarget, hasData, data := tool.GetResumeContext[*commontool.ApprovalResult](ctx)
if isTarget && hasData {
if data.Approved {
return endpoint(ctx, storedArgs, opts...)
}
if data.DisapproveReason != nil {
return singleChunkReader(fmt.Sprintf("tool '%s' disapproved: %s", tCtx.Name, *data.DisapproveReason)), nil
}
return singleChunkReader(fmt.Sprintf("tool '%s' disapproved", tCtx.Name)), nil
}
isTarget, _, _ = tool.GetResumeContext[any](ctx)
if !isTarget {
return nil, tool.StatefulInterrupt(ctx, &commontool.ApprovalInfo{
ToolName: tCtx.Name,
ArgumentsInJSON: storedArgs,
}, storedArgs)
}
return endpoint(ctx, storedArgs, opts...)
}, nil
}
```
### CheckPointStore
`CheckPointStore` 是实现中断恢复的关键组件:
```go
type CheckPointStore interface {
// 保存检查点
Put(ctx context.Context, key string, checkpoint *Checkpoint) error
// 获取检查点
Get(ctx context.Context, key string) (*Checkpoint, error)
}
```
**为什么需要 CheckPointStore?**
- 中断时保存状态:Tool 参数、执行位置等
- 恢复时加载状态:从中断点继续执行
- 支持跨进程恢复:进程重启后仍可恢复
## Interrupt/Resume 的实现
### 1. 配置 Runner 使用 CheckPointStore
```go
runner := adk.NewRunner(ctx, adk.RunnerConfig{
Agent: agent,
EnableStreaming: true,
CheckPointStore: adkstore.NewInMemoryStore(), // 内存存储
})
```
### 2. 配置 Agent 使用 ApprovalMiddleware
```go
agent, err := deep.New(ctx, &deep.Config{
// ... 其他配置
Handlers: []adk.ChatModelAgentMiddleware{
&approvalMiddleware{}, // 添加审批中间件
&safeToolMiddleware{}, // 将 Tool 错误转换为字符串(中断类错误会继续向上抛出)
},
})
```
### 3. 处理中断事件
```go
checkPointID := sessionID
events := runner.Run(ctx, history, adk.WithCheckPointID(checkPointID))
content, interruptInfo, err := printAndCollectAssistantFromEvents(events)
if err != nil {
return err
}
if interruptInfo != nil {
// 注意:建议使用同一个 stdin reader 同时读取「用户输入」与「审批 y/n」
// 避免审批输入被当成下一轮 you> 的消息
content, err = handleInterrupt(ctx, runner, checkPointID, interruptInfo, reader)
if err != nil {
return err
}
}
_ = session.Append(schema.AssistantMessage(content, nil))
```
## Interrupt/Resume 执行流程
```
┌─────────────────────────────────────────┐
│ 用户:执行命令 echo hello │
└─────────────────────────────────────────┘
↓
┌──────────────────────┐
│ Agent 分析意图 │
│ 决定调用 execute │
└──────────────────────┘
↓
┌──────────────────────┐
│ ApprovalMiddleware │
│ 拦截 Tool 调用 │
└──────────────────────┘
↓
┌──────────────────────┐
│ 触发 Interrupt │
│ 保存状态到 Store │
└──────────────────────┘
↓
┌──────────────────────┐
│ 返回 Interrupt 事件 │
│ 等待用户审批 │
└──────────────────────┘
↓
┌──────────────────────┐
│ 用户输入 y/n │
└──────────────────────┘
↓
┌──────────────────────┐
│ runner.ResumeWith... │
│ 恢复执行 │
└──────────────────────┘
↓
┌──────────────────────┐
│ 执行 execute │
│ 或返回拒绝信息 │
└──────────────────────┘
```
> [!question] 深入思考
>
> 上面的输出中有两条 `hello`——一条来自 `[tool result]`,另一条是 Assistant 的最终回复。你能解释它们分别来自哪里吗?
> 提示:第一条是 `printAndCollectAssistantFromEvents` 对流式事件中 Tool Result 片段的打印,第二条是 Agent 整合信息后生成的自然语言回复。理解了这一点,你就掌握了 Eino 事件模型的核心。
## 本章小结
- **Interrupt**:Agent 的暂停机制,在关键操作前暂停等待确认
- **Resume**:恢复执行,用户确认后继续或拒绝后返回错误
- **ApprovalMiddleware**:通用审批中间件,拦截特定 Tool 调用
- **CheckPointStore**:保存中断状态,支持跨进程恢复
- **人机协作**:关键决策由人来确认,提高安全性
| 概念 | 一句话理解 |
|------|-----------|
| **Interrupt** | Agent 在敏感操作前的暂停机制 |
| **Resume** | 用户审批后恢复执行,支持批准与拒绝两种结果 |
| **Two-stage Execution** | 同一个 Tool 被调用两次,通过 `GetInterruptState` 区分阶段 |
| **ApprovalMiddleware** | 集中式拦截特定 Tool 的审批逻辑,使 Tool 保持干净 |
| **CheckPointStore** | 保存中断状态和执行位置,支持跨进程恢复 |
| **人机协作** | 关键决策由人类确认,兼顾 Agent 自动化与安全可控 |
## 扩展思考
**其他 Interrupt 场景:**
### 更多 Interrupt 应用场景
- 多选项审批:用户选择多个选项之一
- 参数补全:用户提供缺失的参数
- 条件分支:用户决定执行路径
| 场景 | 说明 |
|------|------|
| 多选项审批 | 用户从多个选项中选择一个(而非简单的 y/n) |
| 参数补全 | 用户提供缺失的参数值后才继续执行 |
| 条件分支 | 用户决定不同的执行路径 |
**审批策略:**
### 审批策略
- 白名单:只审批敏感操作
- 黑名单:审批所有操作,除了安全的
- 动态规则:根据参数内容决定是否审批
| 策略 | 适用场景 |
|------|---------|
| 白名单 | 只审批极少数敏感操作(推荐默认做法) |
| 黑名单 | 审批所有操作,除已知的安全操作外 |
| 动态规则 | 根据参数内容决定是否审批(如文件大小、操作范围) |
## 关联笔记
- [[Eino/quick_start/_index]]
- [[Eino/quick_start/chapter_04_tool_and_filesystem]] — 文件系统访问与 DeepAgent(第三章的工具章节)
- [[Eino/quick_start/chapter_05_middleware]] — Middleware 模式详解(上一章)