AI 大模型落地系列|Eino 编排篇:从自动执行到人工接管,如何避免Agent一把梭

AI 大模型落地系列|Eino 编排篇:从自动执行到人工接管,如何避免Agent一把梭 声明本文数据源于官方文档与官方示例重点参考 第七章Interrupt/Resume中断与恢复、Interrupt CheckPoint 使用手册、Eino ADK: Agent Runner and Extension、v0.7.*-interrupt resume refactor 与 eino-examples/quickstart/chatwitheino。为什么敏感 Tool 不能自动执行一文讲透 Interrupt、Resume 与 CheckPoint1. 为什么敏感 Tool 不能默认全自动2. 哪些 Tool 应该审批哪些可以白名单放行2.1 必须审批的是那些会对外部世界产生真实副作用的 Tool2.2 可以白名单放行的是强约束下的只读或低风险 Tool3. Interrupt/Resume 到底在解决什么问题4. 官方 execute 示例一次审批流是怎么跑完的4.1 谁在主导这次中断4.2 tool.StatefulInterrupt4.3 tool.GetInterruptState[T]4.4 tool.GetResumeContext[T]5. 审批为什么适合放在 middleware而不是写死在 Tool 里6. CheckPoint 为什么不是“顺手存个参数”7. 从审批示例回到底层机制Agent 只是复用了编排层的中断恢复能力8. 五大重点8.1 静态 Interrupt不是所有暂停都要在节点内部自己抛8.2 动态 Interrupt8.3 流式传输和 CheckPoint 放在一起时别忘了拼接规则8.4 嵌套图里的 Interrupt/Resume不只是“子图也能停一下”8.5 外部主动 Interrupt它不是冷门能力优雅退出时很实用9. 总结参考资料很多人前面学到Agent Tool时第一反应都很兴奋终于不用自己手动串动作了模型会判断、会选工具、会自动把活干掉。但只要你把Agent真正接到execute、write_file、send_mail这类Tool上问题立刻就变了。这时你最该关心的已经不是“它能不能调起来”而是“它准备做危险操作时谁来审批现场怎么保存用户确认后又怎么恢复执行”。也正因为这样Interrupt / Resume在工程里不是一个可有可无的交互点而是一套人工接管机制。如果说前几章解决的是“Agent 怎么跑起来”那本章解决的就是另一个问题当 Agent 已经有能力调用真实 Tool 时系统怎样在关键动作前停下来等人确认再从原地继续往下走。以及Eino 又是如何把“中断、审批、恢复、持久化”这一整套链路收进同一个运行时里的。1. 为什么敏感 Tool 不能默认全自动前几章里的 Tool 调用很多人都会默认理解成一句话模型判断需要什么就直接去调用什么。这个思路放在 demo 里当然成立。可一旦 Tool 不再只是“查天气”“读文档”而是开始触碰真实环境自动执行的风险会陡增execute可能执行 shell 命令write_file可能覆盖配置send_mail可能把错误内容发给真实用户某些数据库 Tool 可能直接修改生产数据很多人一开始会觉得这不就是给 Tool 加个确认框吗实际上你要解决的不只是“要不要弹个确认框”而是下面四件事得一起成立Tool 在危险动作前必须真的停下来而不是只在 UI 上做个提示中断时要把这次调用的上下文保存住不能确认完以后参数丢了拒绝和批准都要有确定结果不能让 Runner 卡在半路进程重启、会话切换甚至跨机器恢复时仍然要知道上次停在什么地方这就是Interrupt / Resume存在的背景。它关心的是人机协作时的执行控制权。你可以把它理解成自动执行像自动驾驶Interrupt像人工接管系统当然还是能自己跑。但到了高风险动作方向盘必须重新回到人手里。2. 哪些 Tool 应该审批哪些可以白名单放行把风险说清以后工程上第一个要落地的判断不是先选 API而是先划边界。因为没有任何一个团队会真的愿意给所有 Tool 都弹审批。那样系统会慢得不可用。2.1 必须审批的是那些会对外部世界产生真实副作用的 Tool这类 Tool 最典型执行命令写文件或删文件改数据库数据调用外部系统发消息、发邮件、下工单修改云资源、网络策略、系统配置这些动作一旦执行就不再只是“推理结果”而是“现实世界里的变化”。这类能力默认不该全自动。2.2 可以白名单放行的是强约束下的只读或低风险 Tool比如只读查询本地纯计算固定范围内的格式化转换已经被沙箱限制死权限的安全工具即便如此我也不建议直接放飞。更稳妥的做法通常是白名单按 Tool 类型放黑名单按参数特征拦动态规则按操作范围升级审批举个很实际的例子execute(ls)和execute(rm -rf)不该一视同仁write_file写临时目录和写核心配置目录也不该走同一条策略这时候approvalMiddleware的价值就会体现出来。因为它天然适合做这类“按 Tool 名 按参数内容”的集中治理。3. Interrupt/Resume 到底在解决什么问题把边界划清之后再看机制本身就更容易理解它为什么一定要成对出现。Interrupt / Resume解决的不是交互花活而是 Tool 的两阶段执行。如果只看名字很多人会把Interrupt / Resume想成“暂停一下再继续”。这话不算错但只是表皮。在审批流里它更准确的意思其实是把一次 Tool 调用拆成两次进入。第一次进入不真的执行业务动作只负责两件事把当前输入和必要状态保存下来发出一个中断信号让 Runner 停住并把审批信息交还给外部第二次进入也就是用户批准后的Resume才会去读回之前保存的状态再决定真正执行还是拒绝返回。流程图第一次调用 - 中断 - 审批 - 恢复 - 执行funcmyTool(ctx context.Context,argsstring)(string,error){// 看当前是不是“上次中断后恢复执行”// storedArgs 是中断时保存下来的参数wasInterrupted,_,storedArgs:tool.GetInterruptState[string](ctx)// 如果是第一次执行就先发起审批并中断不继续往下执行if!wasInterrupted{return,tool.StatefulInterrupt(ctx,approvalInfo(args),args)}// 如果已经是恢复执行就读取恢复时附带的审批结果isTarget,hasData,result:tool.GetResumeContext[*ApprovalResult](ctx)// 只有审批结果属于当前中断点、并且审批通过才真正执行危险操作ifisTargethasDataresult.Approved{returndoDangerousThing(storedArgs)}// 审批没通过或者恢复数据不对就拒绝执行returnoperation rejected,nil}这个代码案例是为了展示其背后的职责切分tool.StatefulInterrupt负责“抛出中断同时把本地状态保存”tool.GetInterruptState[T]负责“恢复后拿回上次保存的状态并判断这是不是第二次进入”tool.GetResumeContext[T]负责“读取这次 Resume 是否就是冲着当前中断点来的以及用户到底给了什么恢复数据”一旦你把这三件事看懂了后面无论是 Tool 审批、参数补全、用户二次确认思路都一样。4. 官方execute示例一次审批流是怎么跑完的官方放到 GitHub 上的案例是cmd/ch07/main.go[代码]。它演示的不是复杂 Agent而是一个非常有代表性的最小闭环用户输入一句自然语言Agent 判断需要调用execute中间件拦截这次 Tool 调用Runner 收到Interrupt后暂停用户输入y/n系统恢复执行继续跑完这轮控制台输出大概是这样you 请执行命令 echo hello ⚠️ Approval Required ⚠️ Tool: execute Arguments: {command:echo hello} Approve this action? (y/n): y [tool result] hello hello很多人第一次看这个示例时会把关注点放在“怎么把y/n读出来”。但更值得盯住的是这条执行链到底断在哪里、又是从哪里接回去的。┌────────────────────────────────────────────┐ │ 用户输入请执行命令 echo hello │ └────────────────────────────────────────────┘ ↓ ┌──────────────────────────┐ │ Agent 决定调用 execute │ └──────────────────────────┘ ↓ ┌──────────────────────────┐ │ approvalMiddleware 拦截 │ └──────────────────────────┘ ↓ ┌──────────────────────────┐ │ 抛出 Interrupt │ │ 保存 CheckPoint │ └──────────────────────────┘ ↓ ┌──────────────────────────┐ │ Runner 结束当前执行 │ │ 把审批信息返回调用侧 │ └──────────────────────────┘ ↓ ┌──────────────────────────┐ │ 用户确认或拒绝 │ └──────────────────────────┘ ↓ ┌──────────────────────────┐ │ Resume 后重新进入 Tool │ └──────────────────────────┘ ↓ ┌──────────────────────────┐ │ 真正执行 execute 或拒绝 │ └──────────────────────────┘也就是说Interrupt不是在 Tool 旁边挂一个提示层。它是真的把这次调用变成了一个可恢复的执行断点。因为只有这样审批才不是“前端交互”而是“运行时协议”。4.1 谁在主导这次中断很多人会直觉地认为暂停和恢复一定是 Runner 主导的。实际上在 Eino 这套机制里先举手说“我这里要停一下”的通常是节点自己或者像 Tool middleware 这种包在节点外面的拦截层。下面这段代码就是官方示例里最关键的那一层第一次进来先中断等审批第二次恢复进来再看审批结果批准才调用真正的execute。func(m*approvalMiddleware)WrapInvokableToolCall(_context.Context,endpoint adk.InvokableToolCallEndpoint,tCtx*adk.ToolContext,)(adk.InvokableToolCallEndpoint,error){// 只拦截 execute 这个工具// 其他工具不做审批直接放行iftCtx.Name!execute{returnendpoint,nil}// 返回一个“包了一层审批逻辑”的新 tool 调用函数returnfunc(ctx context.Context,argsstring,opts...tool.Option)(string,error){// 看这次调用是不是“中断后恢复执行”// storedArgs 是上次中断时保存下来的原始参数wasInterrupted,_,storedArgs:tool.GetInterruptState[string](ctx)// 第一次执行时不真正调用 execute// 而是先抛出中断请求外部审批并把 args 保存起来if!wasInterrupted{return,tool.StatefulInterrupt(ctx,commontool.ApprovalInfo{ToolName:tCtx.Name,ArgumentsInJSON:args,},args)}// 恢复执行后读取外部传回来的审批结果isTarget,hasData,data:tool.GetResumeContext[*commontool.ApprovalResult](ctx)// 只有当前恢复数据确实属于这个中断点// 并且拿到了审批结果、且审批通过才真正执行原始 executeifisTargethasDatadata.Approved{returnendpoint(ctx,storedArgs,opts...)}// 审批没通过就不执行工具直接返回拒绝结果returnfmt.Sprintf(tool %s disapproved,tCtx.Name),nil},nil}这段代码把审批流里最关键的职责分工都摆出来了。4.2tool.StatefulInterrupt它做的不是“报个错”而是“挂起并存档”。很多人第一次看会觉得中断不就是返回一个特殊错误吗从调用面上看确实像。但它真正做的事情比普通错误大得多向上层发出“这里要中断”的明确信号挂上展示给用户看的info顺手把state一起持久化供下一次Resume取回也正因为这样StatefulInterrupt很适合放那些“当前输入就是恢复时关键证据”的场景。审批流就是典型例子。你第一次拦下来的args就是第二次真正要执行的storedArgs。4.3tool.GetInterruptState[T]tool.GetInterruptState[T]解决的是“我现在到底是第一次进还是恢复后第二次进”。如果没有这个 API开发者就得自己做一套状态管理是不是中断过中断时保存了什么从哪儿取回来这套事如果全让业务自己管最后很容易变成一堆零散布尔值和自定义上下文。4.4tool.GetResumeContext[T]tool.GetResumeContext[T]解决的是“这次恢复是不是找我以及用户到底给了什么”。这点也很关键。真实系统里不一定每次恢复都只对应一个唯一中断点。尤其到了嵌套图、并行中断、多 Tool 协作时“恢复谁”本身就已经是个问题。所以GetResumeContext[T]给了三层判断这次Resume的目标是不是当前中断点恢复时有没有带数据数据是不是当前 Tool 预期的类型它的作用就是把恢复这件事从“继续跑”收紧成“有目标地恢复某个断点”。5. 审批为什么适合放在middleware而不是写死在 Tool 里看到这里很自然会冒出一个问题我把审批直接写进executeTool 里不就行了为什么还要放到中间件中。因为审批从来都不是某个单点业务逻辑。它更像一层横切治理规则。你真正想控制的通常是哪些 Tool 需要审批哪些参数命中高风险时才审批同步和流式调用是不是一套策略后面新增 Tool 时规则能不能统一复用这也是为什么官方示例把它放到了 middleware而不是塞进 Tool 本体里。中间件的好处很直接Tool 本身仍只负责“真正做事”审批规则集中在一处配置新增或调整策略时不需要改每个 Tool 的业务实现如果用中间件做治理时有一点非常值得注意在 Agent 里做治理不能只盯“业务成功路径”还得把同步、流式、恢复路径一并考虑到。否则你只对同步做了拦截选择流式路径时拦截就可能失效。只有把这些路径一起拦住才是生产级治理。6. CheckPoint 为什么不是“顺手存个参数”你可以把CheckPoint理解成断点存档。没有CheckPoint就没有真正意义上的Resume。很多人会把CheckPointStore误解成一个“把审批参数存一下”的小缓存。这个理解太窄了。CheckPoint在 Eino 里承担的是运行现场持久化。在 ADK 的 Runner 视角里至少有两件事必须同时成立RunnerConfig里配置了CheckPointStore执行时传入了adk.WithCheckPointID(checkPointID)只有这样Runner 才知道中断发生时要把现场保存到哪里之后恢复时该从哪个 key 把状态拉回来官方给的典型代码配置如下runner:adk.NewRunner(ctx,adk.RunnerConfig{Agent:agent,EnableStreaming:true,CheckPointStore:adkstore.NewInMemoryStore(),})checkPointID:sessionID events:runner.Run(ctx,history,adk.WithCheckPointID(checkPointID))它其实做了一件很重要的事把“这次 Agent 运行”从一次临时调用变成了一次可恢复的会话执行。你会发现CheckPointStore不是普通缓存。为什么呢缓存的思路通常是丢了可以重算命中了算赚到不要求严格对应某次运行现场但CheckPoint不是。它保存的是这次运行“停在什么地方、手里拿着什么输入、下一步应该接到哪里”的现场信息。按官方Agent Runner and Extension文档的说法Runner 捕获到Interrupted Action后如果同时配置了CheckPointStore和CheckPointID会把原始输入、会话历史以及 InterruptInfo 等运行状态持久化下来后续再通过恢复接口继续执行。所以你最好把CheckPointStore理解成恢复协议的一部分而不是缓存层的一个可选优化。到这里其实审批流在 Agent 层的闭环已经完整了Tool 为什么不能直接执行、中断是谁发起的、状态怎么保存、为什么恢复时还能接着往下跑。接下来再往下看一层这套能力在框架分层里到底属于哪里。7. 从审批示例回到底层机制Agent 只是复用了编排层的中断恢复能力如果只看到前面这个审批示例很多人会自然以为这是 ADK 在 Agent 层单独做出来的一套审批机制。但官方却明确表示Agent 里的Interrupt/Resume只是上层用法底下复用的是更通用的编排中断恢复能力。funcResume(ctx context.Context,interruptIDs...string)context.ContextfuncResumeWithData(ctx context.Context,interruptIDstring,data any)context.ContextfuncBatchResumeWithData(ctx context.Context,resumeDatamap[string]any)context.Context这些 API 解决的就是更底层、更通用的问题是恢复所有中断点还是只恢复某一个恢复时要不要带自定义数据并行中断时要不要批量恢复而 Tool 审批流本质上只是在这个框架上定义了自己的ApprovalInfo和ApprovalResult。你现在再回头看tool.GetResumeContext[T]就会更容易理解了它不是“顺便取一下用户输入”。它是在一个更通用的恢复机制之上帮当前 Tool 判断这次Resume是不是发给我的发给我的数据是不是我能消费的那份数据这个分层关系一旦看懂后面再学Graph Tool、Workflow Agent、嵌套图里的中断恢复脑子会清楚很多。8. 五大重点上面几节已经足够支撑你理解“审批流为什么能成立”。但如果你准备把这套能力接进真实业务之前带给你的知识是不够用的。8.1 静态 Interrupt不是所有暂停都要在节点内部自己抛也就是说你可以在Compile图的时候通过compose.WithInterruptBeforeNodes(...)和compose.WithInterruptAfterNodes(...)这类选项声明某些节点执行前或执行后必须暂停。这类能力适合什么场景某个节点前必须等人工确认某个步骤后必须做外部审计某些链路希望在固定位置留下可恢复断点它和 Tool 内部动态Interrupt的区别在于静态 Interrupt 是编排层声明式控制动态 Interrupt 是节点运行时自己决定要不要停两者不是互斥关系。一个偏治理一个偏业务时机。8.2 动态 Interruptv0.7.0之后重点不是“重跑”而是“带状态地中断”官方手册明确写了v0.7.0之前动态中断更像“节点返回特殊错误后 rerun”v0.7.0及之后新增了Interrupt、StatefulInterrupt、CompositeInterrupt这个变化很关键。它意味着新语义下的中断不再只是“等会再跑一遍”。而是可以保留局部状态可以透出内部中断信号可以支持并行中断与更精细的恢复目标8.3 流式传输和 CheckPoint 放在一起时别忘了拼接规则这一点特别容易被忽略。普通Invoke场景里保存 checkpoint 还算直接。但流式场景下运行中的输出是分块到来的。这时如果你希望在流中断点也能恢复就必须告诉框架多个 chunk 最终怎么拼成一个可持久化的整体。手册里给了专门的注册方法RegisterStreamChunkConcatFunc[T any](fn func([]T) (T, error))。默认情况下Eino 已经给string、*schema.Message这些内置常见类型准备了 concat 逻辑。但如果你自己定义了流 chunk 结构就需要你手动去补充了。8.4 嵌套图里的 Interrupt/Resume不只是“子图也能停一下”很多系统的复杂度最终都不在单图里而在嵌套图里。比如大图里挂一个子 Workflow某个 Lambda 节点里再调一个独立 GraphAgent 里包着 FlowAgent再包着 Tool 节点这时中断恢复最难的地方已经不是“停不停”而是中断到底发生在第几层状态该保存到哪一层Resume到底要对准哪个中断点所以你如果打算把审批流往复杂 Agent 里扩最好从一开始就把“断点地址”和“恢复目标”当正式设计来看。8.5 外部主动 Interrupt它不是冷门能力优雅退出时很实用这也是很工程化的一条能力。有时候中断不是节点自己想停而是系统外部要求它先停下来。典型场景就是实例要优雅退出运维要求先挂起长链路某条执行流需要临时冻结等待外部资源官方提供了WithGraphInterrupt这套机制让你在 Graph 外部主动触发 interrupt。所以Interrupt / Resume不只是“审批专用功能”。它更像运行时的可暂停、可恢复协议。审批只是它最容易理解、也最贴近业务价值的一种用法。9. 总结很多人一开始学Interrupt / Resume会把它看成 Agent 的一个附属能力。但真走到生产环境你会发现它的重要性一点都不比Tool Calling低。因为 Agent 越有行动能力你就越不能把所有控制权都交给它。Interrupt解决的是“该停时能不能真的停住”。Resume解决的是“确认以后能不能从原地接着跑”。CheckPoint解决的是“停住以后现场能不能被可靠地保存和恢复”。而approvalMiddleware则把这套东西从单个 Tool 的临时写法提升成了整条 Agent 链路的治理策略。所以说Agent 一旦能调真实 Tool审批就不再是前端交互而是运行时协议而 Interrupt / Resume CheckPoint就是这套协议在 Eino 里的落地方式。参考资料第七章Interrupt/Resume中断与恢复Interrupt CheckPoint 使用手册Eino ADK: Agent Runner and Extension