13Runner 与 flowAgent:运行骨架
toFlowAgent 统一包裹、flowAgent.run() 负责回调/RunPath/transfer、中断时的 checkpoint 持久化。
adk/runner.go:55adk/flow.go:104adk/runctx.go:346打开引擎盖
Part I 你学会了用 ADK,Part II 你理解了它为什么这样设计。现在进入 Part III:当你写下 runner.Query(ctx, "...") 那一行,引擎盖下到底发生了什么?
答案会颠覆一点直觉——你在 Part I 见到的所有 Agent(ChatModelAgent、supervisor、DeepAgent),运行时其实都被悄悄换成了同一种内部类型:flowAgent。而 Runner 本身,薄得几乎不像一个”运行时”。这一章我们就把这层骨架拆开看清楚。
flowchart TB
subgraph USER["你写的 Agent"]
CMA["ChatModelAgent"]
SUP["supervisor"]
DA["DeepAgent"]
end
CMA --> TF["toFlowAgent(ctx, agent)"]
SUP --> TF
DA --> TF
TF --> FA["flowAgent<br/>嵌入原 Agent + 协作状态<br/>subAgents / parent / historyRewriter"]
R["Runner(薄外壳)<br/>标准化输入 · 转 flowAgent · checkpoint"] --> TF
FA --> ITER["AsyncIterator 事件流"]
薄 Runner + 统一的 flowAgent 骨架
Runner:薄薄一层泛型外壳
先看 Runner。它的真身是一个泛型结构 TypedRunner[M](adk/runner.go:55),只有三个字段:被运行的 Agent、是否开启流式、以及可选的 checkpoint 存储。
type TypedRunner[M MessageType] struct { a TypedAgent[M] enableStreaming bool store CheckPointStore}
// 你在 Part I 一直用的 Runner,其实是它的一个具体实例化。type Runner = TypedRunner[*schema.Message]你在前面章节反复调用的 adk.NewRunner(adk/runner.go:89)只是把 RunnerConfig(adk/runner.go:68)里的 Agent、EnableStreaming、CheckPointStore 塞进这个结构。至于 Query(adk/runner.go:108)和 Run(adk/runner.go:102),前者只是把字符串包成一条 user 消息再转调后者——它们都返回一个 *AsyncIterator,也就是你熟悉的那个”事件流”。
// NewRunner creates a new Runner with the given config.func NewRunner(_ context.Context, conf RunnerConfig) *Runner { return NewTypedRunner(conf)}type TypedRunnerConfig[M MessageType] struct { Agent TypedAgent[M] EnableStreaming bool
CheckPointStore CheckPointStore}// Query is a convenience method that starts a new execution with a single user query string.func (r *TypedRunner[M]) Query(ctx context.Context, query string, opts ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[M]] { msgs, err := newUserMessage[M](query) if err != nil { return errorIterator[M](err) } return r.Run(ctx, []M{msgs}, opts...)}func (r *TypedRunner[M]) Run(ctx context.Context, messages []M, opts ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[M]] { return typedRunnerRunImpl(r.a, r.enableStreaming, r.store, ctx, messages, opts...)}📝 注意
Runner 里没有任何 ReAct 循环、没有工具调度、没有 transfer 判断。它的职责只有三件:标准化输入、把 Agent 转成
flowAgent、以及在中断时负责 checkpoint 的读写。真正的运行骨架在flowAgent里。
关键一步:toFlowAgent 统一包裹
Run 内部做的第一件实质工作,是把你传入的 Agent 交给 toFlowAgent(调用点在 adk/runner.go:167,实现在 adk/flow.go:104):
func typedRunnerRunImpl[M MessageType](a TypedAgent[M], enableStreaming bool, store CheckPointStore, ctx context.Context, messages []M, opts ...AgentRunOption) *AsyncIterator[*TypedAgentEvent[M]] { o := getCommonOptions(nil, opts...)
input := &TypedAgentInput[M]{ Messages: messages, EnableStreaming: enableStreaming, }
var zero M if _, ok := any(zero).(*schema.Message); ok { concreteAgent, _ := any(a).(Agent) fa := toFlowAgent(ctx, concreteAgent) if store != nil { fa.checkPointStore = store } concreteInput := any(input).(*AgentInput) ctx = ctxWithNewTypedRunCtx(ctx, input, o.sharedParentSession) AddSessionValues(ctx, o.sessionValues)
iter := fa.Run(ctx, concreteInput, opts...)
if store == nil && o.cancelCtx == nil { return any(iter).(*AsyncIterator[*TypedAgentEvent[M]]) }
niter, gen := NewAsyncIteratorPair[*TypedAgentEvent[M]]() go typedRunnerHandleIterImpl(enableStreaming, store, ctx, any(iter).(*AsyncIterator[*TypedAgentEvent[M]]), gen, o.checkPointID, o.cancelCtx) return niter }
fa := toTypedFlowAgent(a) if store != nil { fa.checkPointStore = store }
ctx = ctxWithNewTypedRunCtx(ctx, input, o.sharedParentSession) AddSessionValues(ctx, o.sessionValues)
iter := fa.Run(ctx, input, opts...)
if store == nil && o.cancelCtx == nil { return iter }
niter, gen := NewAsyncIteratorPair[*TypedAgentEvent[M]]() go typedRunnerHandleIterImpl(enableStreaming, store, ctx, iter, gen, o.checkPointID, o.cancelCtx) return niter}func toFlowAgent(ctx context.Context, agent Agent, opts ...AgentOption) *flowAgent { var fa *flowAgent var ok bool if fa, ok = agent.(*flowAgent); !ok { fa = &flowAgent{Agent: agent} // 普通 Agent:包一层 } else { fa = fa.deepCopy() // 已经是 flowAgent:深拷贝一份 } // ...补上默认的 historyRewriter return fa}这段代码朴素得容易忽略,但它是整套多智能体机制的地基。flowAgent(adk/flow.go:42)通过嵌入 Agent 接口,保留了原 Agent 的全部行为,同时给它挂上了协作所需的额外状态:
type flowAgent struct { Agent // 嵌入:原 Agent 的能力原样保留
subAgents []*flowAgent // 它的下属 parentAgent *flowAgent // 它的上级
disallowTransferToParent bool historyRewriter HistoryRewriter checkPointStore compose.CheckPointStore}🔑 本章的设计钥匙
flowAgent是一个装饰器(decorator):它嵌入原 Agent、原样转发核心行为,只额外接管”协作”这一层——父子关系、RunPath、事件记录、transfer。这就是为什么 supervisor、DeepAgent、单个 ChatModelAgent 在运行时能被一视同仁:无论你写的是哪种 Agent,进入引擎前都会被toFlowAgent归一成同一种结构。“用户可见的 Agent 千姿百态,引擎内部只认一种”——正是这层归一,让取消、中断、续跑、事件记录这些机制只需实现一遍,就对所有 Agent 生效。
flowAgent.run():真正的骨架
flowAgent.Run(adk/flow.go:352)是外层入口:它先 initRunCtx 建立本次运行的上下文,再用 AppendAddressSegment 把自己的名字追加到”地址”上(adk/flow.go:357),准备好输入后,起一个 goroutine 去跑内层的 run(adk/flow.go:478)。真正的循环骨架在这个内层方法里,它干三件事。
func (a *flowAgent) Run(ctx context.Context, input *AgentInput, opts ...AgentRunOption) *AsyncIterator[*AgentEvent] { agentName := a.Name(ctx)
var runCtx *runContext ctx, runCtx = initRunCtx(ctx, agentName, input) ctx = AppendAddressSegment(ctx, AddressSegmentAgent, agentName)
o := getCommonOptions(nil, opts...) cancelCtx := o.cancelCtx
processedInput, err := a.genAgentInput(ctx, runCtx, o.skipTransferMessages) if err != nil { if cancelCtx != nil { cancelCtx.markDone() } cbInput := &AgentCallbackInput{Input: input} ctx = callbacks.OnStart(ctx, cbInput) return wrapIterWithOnEnd(ctx, genErrorIter(err)) }
ctxForSubAgents := ctx
agentType := getAgentType(a.Agent) ctx = initAgentCallbacks(ctx, agentName, agentType, filterOptions(agentName, opts)...) cbInput := &AgentCallbackInput{Input: processedInput} ctx = callbacks.OnStart(ctx, cbInput)
input = processedInput
if wf, ok := a.Agent.(*workflowAgent); ok { ctx = withCancelContext(ctx, cancelCtx) filteredOpts := filterCancelOption(filterCallbackHandlersForNestedAgents(agentName, opts)) iter := wf.Run(ctx, input, filteredOpts...) iter = wrapIterWithCancelCtx(iter, cancelCtx) return wrapIterWithOnEnd(ctx, iter) }
aIter := a.Agent.Run(withCancelContext(ctx, cancelCtx), input, filterOptions(agentName, opts)...)
iterator, generator := NewAsyncIteratorPair[*AgentEvent]()
go a.run(withCancelContext(ctx, cancelCtx), withCancelContext(ctxForSubAgents, cancelCtx), runCtx, aIter, generator, filterCancelOption(opts)...)
return wrapIterWithCancelCtx(iterator, cancelCtx)}func (a *flowAgent) run( ctx context.Context, ctxForSubAgents context.Context, runCtx *runContext, aIter *AsyncIterator[*AgentEvent], generator *AsyncGenerator[*AgentEvent], opts ...AgentRunOption) {
cbIter, cbGen := NewAsyncIteratorPair[*AgentEvent]()
cbOutput := &AgentCallbackOutput{Events: cbIter} icb.On(ctx, cbOutput, icb.BuildOnEndHandleWithCopy(copyAgentCallbackOutput), callbacks.TimingOnEnd, false)
defer func() { panicErr := recover() if panicErr != nil { e := safe.NewPanicErr(panicErr, debug.Stack()) generator.Send(&AgentEvent{Err: e}) }
cbGen.Close() generator.Close() }()
var lastAction *AgentAction for { event, ok := aIter.Next() if !ok { break }
// RunPath ownership: the eino framework sets RunPath exactly once. // If event.RunPath is already set (e.g., by agentTool), we don't modify it. // If event.RunPath is nil/empty, we set it to the current runCtx.RunPath. // This ensures RunPath is set exactly once and not duplicated. if len(event.RunPath) == 0 { event.AgentName = a.Name(ctx) event.RunPath = runCtx.RunPath } // Recording policy: exact RunPath match (non-interrupt) indicates events belonging to this agent execution. // This prevents parent recording of child/tool-internal emissions. if (event.Action == nil || event.Action.Interrupted == nil) && exactRunPathMatch(runCtx.RunPath, event.RunPath) { // copy the event so that the copied event's stream is exclusive for any potential consumer // copy before adding to session because once added to session it's stream could be consumed by genAgentInput at any time // interrupt action are not added to session, because ALL information contained in it // is either presented to end-user, or made available to agents through other means copied := copyTypedAgentEvent(event) setAutomaticClose(copied)// … 省略 52 行;完整声明 L478–577,点击上方「浏览完整文件」第一,维护 RunPath。 每个从 Agent 流出的事件,如果还没有归属,就盖上当前 Agent 的名字和运行路径(adk/flow.go:513):
if len(event.RunPath) == 0 { event.AgentName = a.Name(ctx) event.RunPath = runCtx.RunPath}RunPath 就是一条”事件从哪条 Agent 链上流出来”的轨迹——它既是可观测性的基础,也是下一章 checkpoint 能精确定位”停在哪”的坐标。
第二,把事件录进 session。 注意这里有一条克制的记录策略(adk/flow.go:519):只有 Action 为空或非中断、且 RunPath 精确匹配当前 Agent 的事件,才会被复制一份存进 session。
if (event.Action == nil || event.Action.Interrupted == nil) && exactRunPathMatch(runCtx.RunPath, event.RunPath) { copied := copyTypedAgentEvent(event) // ... runCtx.Session.addEvent(copied)}为什么要”精确匹配”?因为子 Agent、工具内部产生的事件会穿过父 Agent 流出,但它们不属于父 Agent 的对话历史。这条判断防止父 Agent 把下属的中间草稿误记成自己的记忆——这正是第 8 章”隔离上下文”哲学在运行时的落地。中断事件也被刻意排除在记录之外:它携带的信息要么已经呈给用户,要么会通过其他途径交给 Agent。
第三,处理 transfer。 当一个 Agent 决定把控制权转移给另一个时,run 会用 getAgent 找到目标(adk/flow.go:557),直接调用它的 Run,并把子 Agent 的事件原样转发出去:
if destName != "" { agentToRun := a.getAgent(ctxForSubAgents, destName) // ... subAIter := agentToRun.Run(ctxForSubAgents, nil /* 子 Agent 从 runCtx 取输入 */, opts...) for { subEvent, ok_ := subAIter.Next() if !ok_ { break } generator.Send(subEvent) }}这就是第 7 章”transfer = 交接控制权 + 共享上下文”的实现:子 Agent 拿的是 nil 输入,因为它要从共享的 runCtx 里读取上下文——控制权流走了,上下文却留在原地被共享。这与第 16 章 AgentTool 的”隔离上下文”形成鲜明对照。
环境状态:runContext 与 runSession
上面反复出现的 runCtx,是本次运行的环境。它的结构(adk/runctx.go:346)很直白:
type runContext struct { RootInput *AgentInput // 最初的用户输入 RunPath []RunStep // 当前的 Agent 运行路径 AgenticRootInput any Session *runSession // 跨 Agent 共享的会话}其中 runSession(adk/runctx.go:34)是共享上下文的载体——它持有 Values(带锁的键值对,就是你在 Part I 用过的 session 存取)和事件列表。WithSessionValues 这类 API 最终读写的就是这里。initRunCtx(adk/runctx.go:386)在每次运行开始时把它建好,并挂进 context.Context 随调用链传递。
// runSession CheckpointSchema: persisted via serialization.RunCtx (gob).type runSession struct { Values map[string]any valuesMtx *sync.Mutex
Events []*agentEventWrapper LaneEvents *laneEvents mtx sync.Mutex
// TypedEvents stores *[]*typedAgentEventWrapper[M] for M != *schema.Message. // For M = *schema.Message, the existing Events field is used instead. // The any type is required because Go does not support generic fields in non-generic structs. TypedEvents any}func initRunCtx(ctx context.Context, agentName string, input *AgentInput) (context.Context, *runContext) { runCtx := getRunCtx(ctx) if runCtx != nil { runCtx = runCtx.deepCopy() } else { runCtx = &runContext{Session: newRunSession()} }
runCtx.RunPath = append(runCtx.RunPath, RunStep{agentName: agentName}) if runCtx.isRoot() && input != nil { runCtx.RootInput = input }
return setRunCtx(ctx, runCtx), runCtx}中断时:把整个运行状态冻进 checkpoint
最后一块骨架是中断持久化。当事件流里出现中断动作、且你在 RunnerConfig 里配了 CheckPointStore,Runner 会在 adk/runner.go:331 调用 runnerSaveCheckPointImpl(adk/interrupt.go:283),把当下的运行现场用 gob 序列化后写入存储:
func typedRunnerHandleIterImpl[M MessageType](enableStreaming bool, store CheckPointStore, ctx context.Context, aIter *AsyncIterator[*TypedAgentEvent[M]], //nolint:revive // argument-limit gen *AsyncGenerator[*TypedAgentEvent[M]], checkPointID *string, cancelCtx *cancelContext) { defer func() { panicErr := recover() if panicErr != nil { e := safe.NewPanicErr(panicErr, debug.Stack()) gen.Send(&TypedAgentEvent[M]{Err: e}) }
gen.Close() }() var ( interruptSignal *core.InterruptSignal legacyData any ) for { event, ok := aIter.Next() if !ok { break }
if event.Err != nil { var cancelErr *CancelError if errors.As(event.Err, &cancelErr) { if cancelCtx != nil && cancelCtx.isRoot() && cancelCtx.shouldCancel() { cancelCtx.markCancelHandled() } if cancelErr.interruptSignal != nil && checkPointID != nil { cancelErr.InterruptContexts = core.ToInterruptContexts(cancelErr.interruptSignal, allowedAddressSegmentTypes) err := runnerSaveCheckPointImpl(enableStreaming, store, ctx, *checkPointID, &InterruptInfo{}, cancelErr.interruptSignal) if err != nil { gen.Send(&TypedAgentEvent[M]{Err: fmt.Errorf("failed to save checkpoint on cancel: %w", err)}) } } gen.Send(event) break } }
if event.Action != nil && event.Action.internalInterrupted != nil { if interruptSignal != nil { panic("multiple interrupt actions should not happen in Runner") } interruptSignal = event.Action.internalInterrupted interruptContexts := core.ToInterruptContexts(interruptSignal, allowedAddressSegmentTypes) event = &TypedAgentEvent[M]{ AgentName: event.AgentName, RunPath: event.RunPath,// … 省略 24 行;完整声明 L271–342,点击上方「浏览完整文件」err := gob.NewEncoder(buf).Encode(&serialization{ RunCtx: runCtx, // 整个运行环境(含 session、RunPath) Info: info, InterruptID2Address: id2Addr, // 中断点的地址映射 InterruptID2State: id2State, EnableStreaming: enableStreaming,})// ...return store.Set(ctx, key, buf.Bytes())注意被冻进去的是整个 runContext——因为有了前面 RunPath 的精确坐标和 session 的完整快照,恢复时才能不多不少地回到中断的那一刻。这就是为什么”续跑”在 ADK 里不是特例,而是骨架自带的能力:Resume(adk/runner.go:124)和 ResumeWithParams(adk/runner.go:147)反向做这件事——把 checkpoint 里的 runContext 解冻,让运行从原地继续。
// Resume continues an interrupted execution from a checkpoint, using an "Implicit Resume All" strategy.// This method is best for simpler use cases where the act of resuming implies that all previously// interrupted points should proceed without specific data.//// When using this method, all interrupted agents will receive `isResumeFlow = false` when they// call `GetResumeContext`, as no specific agent was targeted. This is suitable for the "Simple Confirmation"// pattern where an agent only needs to know `wasInterrupted` is true to continue.func (r *TypedRunner[M]) Resume(ctx context.Context, checkPointID string, opts ...AgentRunOption) ( *AsyncIterator[*TypedAgentEvent[M]], error) { return r.resumeInternal(ctx, checkPointID, nil, opts...)}// ResumeWithParams continues an interrupted execution from a checkpoint with specific parameters.// This is the most common and powerful way to resume, allowing you to target specific interrupt points// (identified by their address/ID) and provide them with data.//// The params.Targets map should contain the addresses of the components to be resumed as keys. These addresses// can point to any interruptible component in the entire execution graph, including ADK agents, compose// graph nodes, or tools. The value can be the resume data for that component, or `nil` if no data is needed.//// When using this method:// - Components whose addresses are in the params.Targets map will receive `isResumeFlow = true` when they// call `GetResumeContext`.// - Interrupted components whose addresses are NOT in the params.Targets map must decide how to proceed:// -- "Leaf" components (the actual root causes of the original interrupt) MUST re-interrupt themselves// to preserve their state.// -- "Composite" agents (like SequentialAgent or ChatModelAgent) should generally proceed with their// execution. They act as conduits, allowing the resume signal to flow to their children. They will// naturally re-interrupt if one of their interrupted children re-interrupts, as they receive the// new `CompositeInterrupt` signal from them.func (r *TypedRunner[M]) ResumeWithParams(ctx context.Context, checkPointID string, params *ResumeParams, opts ...AgentRunOption) (*AsyncIterator[*TypedAgentEvent[M]], error) { return r.resumeInternal(ctx, checkPointID, params.Targets, opts...)}💡 动手
打开
adk/flow.go:478通读一遍flowAgent.run,对照本章的”三件事”:RunPath 维护、session 记录、transfer 处理。然后想一个问题:如果把”精确匹配 RunPath”这条判断去掉,父 Agent 的记忆会变成什么样?(提示:它会开始”偷听”所有子 Agent 的内心独白。)
本章小结
Runner是薄壳(adk/runner.go:55):标准化输入、转flowAgent、管 checkpoint 读写,不含任何运行逻辑。toFlowAgent(adk/flow.go:104)把任何 Agent 归一成flowAgent(adk/flow.go:42)——一个嵌入原 Agent、只接管”协作层”的装饰器。这是取消/中断/记录能”一次实现、处处生效”的根因。flowAgent.run(adk/flow.go:478)是真正的骨架,做三件事:维护 RunPath、按”精确匹配”策略把事件录进 session、处理 transfer。runContext/runSession(adk/runctx.go:346)是跨 Agent 共享的运行环境;transfer 靠共享它来交接上下文。- 中断时整个
runContext被gob冻进 checkpoint(adk/interrupt.go:283),Resume反向解冻——续跑因此是骨架自带能力,而非特例。
下一章我们放大其中一个 Agent 的内部:看 ReAct 如何不是一段 for 循环,而是一张编译好的图。