目录 · 第 13 / 28 章
EinoPart III · ADK 的实现

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(ChatModelAgentsupervisorDeepAgent),运行时其实都被悄悄换成了同一种内部类型: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)里的 AgentEnableStreamingCheckPointStore 塞进这个结构。至于 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 靠共享它来交接上下文。
  • 中断时整个 runContextgob 冻进 checkpoint(adk/interrupt.go:283),Resume 反向解冻——续跑因此是骨架自带能力,而非特例。

下一章我们放大其中一个 Agent 的内部:看 ReAct 如何不是一段 for 循环,而是一张编译好的图。

源码

正在读取完整文件…