上一篇 拆了三种 ADK prebuilt 模式,其中 Supervisor 用 transfer 机制让 agent 之间交接任务。第91篇 讲 Host MultiAgent 时,host 把任务交给 specialist 也是一种"交接"。
这篇聚焦一个问题:Agent 之间怎么交接? 三个具体问题:
- transfer 是怎么发生的?(从 LLM 调 tool 到 flowAgent 启动子 agent 的完整链路)
- 交接时上下文怎么传?(rewriteMessage 重写历史 + session 共享)
- 为什么 transfer 被标注 NOT RECOMMENDED,AgentTool 又怎么做?
(一)Transfer 的完整链路:从 LLM 决策到 flowAgent 启动
第一步:ChatModelAgent 自动注入 transfer_to_agent 工具
chatmodel.go:874-927 的 prepareExecContext 在每个 agent 启动时运行:
go
// chatmodel.go:881-891
transferToAgents := a.subAgents
if a.parentAgent != nil && !a.disallowTransferToParent {
transferToAgents = append(transferToAgents, a.parentAgent)
}
if len(transferToAgents) > 0 {
transferInstruction := genTransferToAgentInstruction(ctx, transferToAgents)
instruction = concatInstructions(instruction, transferInstruction)
toolsNodeConf.Tools = append(toolsNodeConf.Tools, &transferToAgent{})
returnDirectly[TransferToAgentToolName] = true
}
三个关键动作:
- 收集可 transfer 的 agent:subAgents + parentAgent(如果没禁止 transfer 回 parent)
- 注入 instruction:告诉 LLM 有哪些 agent 可以 transfer 到,以及什么时候该 transfer
- 注册 transfer_to_agent 工具:让 LLM 能通过 tool call 发起 transfer
- 设置 ReturnDirectly:transfer 工具调用后直接返回,不再经过 LLM
第二步:LLM 决定 transfer
instruction.go:28-42 定义了 transfer 的 instruction(中英双语):
diff
可用的其他 agent:
- Agent 名字: MathAgent
Agent 描述: 执行数学计算
决策规则:
- 如果根据你的职责描述,你最适合回答这个问题:ANSWER
- 如果根据其职责描述,另一个 agent 更适合:调用 transfer_to_agent 函数,并传入该 agent 的名称
当进行移交时:只输出函数调用,不要输出其他任何内容
LLM 看到这段 instruction 后,如果判断自己不合适,会调用 transfer_to_agent 工具,参数 agent_name 填目标 agent 名。
第三步:transfer_to_agent 工具执行
chatmodel.go:661-677 的 transferToAgent.InvokableRun:
go
func (tta transferToAgent) InvokableRun(ctx context.Context, argumentsInJSON string, _ ...tool.Option) (string, error) {
params := &transferParams{}
sonic.UnmarshalString(argumentsInJSON, params)
// 关键:通过 SendToolGenAction 把 TransferToAgentAction 附加到工具事件
err = SendToolGenAction(ctx, TransferToAgentToolName, NewTransferToAgentAction(params.AgentName))
return transferToAgentToolOutput(params.AgentName), nil
}
SendToolGenAction(react.go:274-285)把 TransferToAgentAction 存到 Eino 图状态中,当工具事件发出时,这个 action 会被附加到事件上。
第四步:flowAgent 处理 transfer
flow.go:481-582 的 flowAgent.run() 是 transfer 的核心处理逻辑:
go
// flow.go:554-581
var destName string
if lastAction != nil {
if lastAction.TransferToAgent != nil {
destName = lastAction.TransferToAgent.DestAgentName
}
}
if destName != "" {
agentToRun := a.getAgent(ctxForSubAgents, destName)
if agentToRun == nil {
generator.Send(&AgentEvent{Err: fmt.Errorf("transfer failed: agent '%s' not found", destName)})
return
}
subAIter := agentToRun.Run(ctxForSubAgents, nil, ...)
for {
subEvent, ok_ := subAIter.Next()
if !ok_ { break }
generator.Send(subEvent)
}
}
流程:agent 运行完 → 检查最后一个 action 是否有 TransferToAgent → 通过 getAgent() 在 subAgents 和 parentAgent 中查找 → 找到后调用 agentToRun.Run() 启动目标 agent。
getAgent:去哪儿找?
flow.go:174-186:
go
func (a *flowAgent) getAgent(ctx context.Context, name string) *flowAgent {
for _, subAgent := range a.subAgents {
if subAgent.Name(ctx) == name {
return subAgent
}
}
if a.parentAgent != nil && a.parentAgent.Name(ctx) == name {
return a.parentAgent
}
return nil
}
先在 subAgents 里找,再在 parentAgent 里找。这意味着一个 agent 可以 transfer 到它的子 agent(下属)或父 agent(上级),但不能 transfer 到 sibling(同级)。
(二)交接时上下文怎么传:rewriteMessage + Session 共享
transfer 的核心问题是:目标 agent 怎么知道之前发生了什么?
Session 共享
所有 agent 共享同一个 runSession(flow.go:352-396)。每个 agent 产出的事件都写入同一个 session,后续 agent 通过 genAgentInput(flow.go:275-328)从 session 读取事件历史来构建自己的输入。
rewriteMessage:标注来源
flow.go:188-253 的 rewriteMessage 是关键:
go
func rewriteMessage(msg Message, agentName string) Message {
var sb strings.Builder
sb.WriteString("For context:")
if msg.Role == schema.Assistant {
if msg.Content != "" {
sb.WriteString(fmt.Sprintf(" [%s] said: %s.", agentName, msg.Content))
}
// tool call 也标注
for i := range msg.ToolCalls {
f := msg.ToolCalls[i].Function
sb.WriteString(fmt.Sprintf(" [%s] called tool: `%s` with arguments: %s.",
agentName, f.Name, f.Arguments))
}
} else if msg.Role == schema.Tool && msg.Content != "" {
sb.WriteString(fmt.Sprintf(" [%s] `%s` tool returned result: %s.",
agentName, msg.ToolName, msg.Content))
}
rewritten := schema.UserMessage(sb.String())
return rewritten
}
作用:把非本 agent 产出的消息重写为 "For context: AgentX said: ..." 格式,注入到目标 agent 的输入中。目标 agent 就知道"这是别人说的,不是我的记忆"。
genAgentInput:构建目标 agent 的输入
flow.go:275-328:
go
func (a *flowAgent) genAgentInput(ctx context.Context, runCtx *runContext, skipTransferMessages bool) (*AgentInput, error) {
input := deepCopyAgentInput(runCtx.RootInput)
events := runCtx.Session.getEvents()
historyEntries := make([]*HistoryEntry, 0)
// 先放用户原始输入
for _, m := range input.Messages {
historyEntries = append(historyEntries, &HistoryEntry{IsUserInput: true, Message: m})
}
// 再放 session 中的所有事件
for _, event := range events {
// 跳过 transfer 消息(如果 skipTransferMessages=true)
if skipTransferMessages && event.Action != nil && event.Action.TransferToAgent != nil {
continue
}
msg, err := getMessageFromWrappedEvent(event)
historyEntries = append(historyEntries, &HistoryEntry{AgentName: event.AgentName, Message: msg})
}
// 调用 historyRewriter 重写
messages, err := a.historyRewriter(ctx, historyEntries)
input.Messages = messages
return input, nil
}
buildDefaultHistoryRewriter(flow.go:330-350)对每条非用户输入的消息调用 genMsg,如果消息来源 agent 不是当前 agent,就调用 rewriteMessage 标注来源。
这就是 NOT RECOMMENDED 的原因
源码中多处标注 NOT RECOMMENDED。flow.go:70-77 的 SetSubAgents:
NOT RECOMMENDED: Agent transfer with full context sharing between agents has not proven to be more effective empirically. Consider using ChatModelAgent with AgentTool or DeepAgent instead.
"经验证明这个方向不如另一个方向"。具体问题:
- 完整上下文共享:每 transfer 一次,目标 agent 的上下文就多一段 rewrite 后的历史。多轮 transfer 后上下文膨胀很快。
- 缺乏隔离:目标 agent 能看到所有前置 agent 的完整历史,包括不该它关心的信息。
- 消息重写损失信息:rewriteMessage 把 assistant/tool 消息重写为 user 消息,可能丢失角色语义。
(三)AgentWithDeterministicTransferTo:限制 transfer 方向
deterministic_transfer.go:43-54:
go
func AgentWithDeterministicTransferTo(_ context.Context, config *DeterministicTransferConfig) Agent {
if ra, ok := config.Agent.(ResumableAgent); ok {
return &resumableAgentWithDeterministicTransferTo{
agent: ra,
toAgentNames: config.ToAgentNames,
}
}
return &agentWithDeterministicTransferTo{
agent: config.Agent,
toAgentNames: config.ToAgentNames,
}
}
这个包装器限制被包装的 agent 只能 transfer 到 toAgentNames 列表中的 agent。
deterministic_transfer.go:139-164 的 forwardEventsAndAppendTransfer:
go
func forwardEventsAndAppendTransfer(iter *AsyncIterator[*AgentEvent],
generator *AsyncGenerator[*AgentEvent], toAgentNames []string) {
var lastEvent *AgentEvent
for {
event, ok := iter.Next()
if !ok { break }
generator.Send(event)
lastEvent = event
}
// 如果最后一个事件没有 Exit 或 Interrupted,自动发出 transfer 事件
if lastEvent != nil && lastEvent.Action != nil &&
(lastEvent.Action.Interrupted != nil || lastEvent.Action.Exit) {
return
}
sendTransferEvents(generator, toAgentNames)
}
sendTransferEvents(286-301)给每个 toAgentNames 中的 agent 生成一对 assistant+tool 消息,模拟 transfer 调用。
Supervisor 模式用这个机制:每个子 agent 被 AgentWithDeterministicTransferTo 包装,toAgentNames 只有 supervisor 的名字。子 agent 完成后自动 transfer 回 supervisor,不能 transfer 到其他子 agent。
(四)AgentTool:更好的替代方案
agent_tool.go:93-103 的 NewAgentTool 把 agent 包装为 tool:
go
func NewAgentTool(_ context.Context, agent Agent, options ...AgentToolOption) tool.BaseTool {
return &agentTool{
agent: agent,
fullChatHistoryAsInput: opts.fullChatHistoryAsInput,
inputSchema: opts.agentInputSchema,
}
}
agent_tool.go:154-280 的 InvokableRun 展示了 key 差异:
- 独立 session :内部 agent 有独立的 checkpoint store(
bridgeStore),不共享父 agent 的 session - Action 隔离 :
Exit, TransferToAgent, BreakLoop被忽略,不传播到父 agent(agent_tool.go:352-356) - 独立 checkpoint:每个 agent tool 有自己的 checkpoint,中断恢复时独立处理
go
// agent_tool.go:352-356
// - Exit, TransferToAgent, BreakLoop: Ignored outside the agent tool; these actions only affect
// the inner agent's execution and do not propagate to the parent agent
对比 transfer 和 AgentTool:
| 维度 | Transfer | AgentTool |
|---|---|---|
| 上下文 | 共享 session,rewrite 历史 | 独立 session,工具参数传 request |
| 隔离性 | 差(完整上下文共享) | 好(独立 checkpoint) |
| 子 agent action | 传播到父 agent | 隔离在工具内部 |
| 上下文膨胀 | 每轮 transfer 追加历史 | 每次调用独立,不累积 |
| 推荐程度 | NOT RECOMMENDED | 推荐 |
(五)Host MultiAgent 的 HandOff 回调
E91 和 E92 提到的 Host MultiAgent 也有"交接"概念,但用的是回调机制,不是 ADK 的 transfer。
callback.go:31-33:
go
type MultiAgentCallback interface {
OnHandOff(ctx context.Context, info *HandOffInfo) context.Context
}
type HandOffInfo struct {
ToAgentName string
Argument string
}
ConvertCallbackHandlers(callback.go:42-89)监听 ChatModel 的 OnEnd 事件,当 host LLM 产出 tool call 时(每个 tool call 对应一个 specialist),触发 OnHandOff 回调。用户可以在回调中记录日志、修改上下文等。
Host MultiAgent 的 HandOff 和 ADK 的 transfer 是两套不同的机制:
- Host HandOff:host 把 specialist 当 tool 调,host 的 Graph 负责汇总结果。交接发生在 host 一个 agent 内部。
- ADK Transfer :agent 之间通过
transfer_to_agent工具和TransferToAgentAction传递控制权。交接发生在 agent 之间。
小结
| 问题 | 答案 | 关键源码 |
|---|---|---|
| transfer 怎么发生 | LLM 调 transfer_to_agent → SendToolGenAction → flowAgent.run 处理 TransferToAgent | chatmodel.go:661-677, flow.go:554-581 |
| 上下文怎么传 | Session 共享 + rewriteMessage 标注来源 + genAgentInput 构建输入 | flow.go:188-253, 275-328 |
| 为什么 NOT RECOMMENDED | 完整上下文共享 → 膨胀快 + 隔离差 + 消息重写损失语义 | flow.go:70-77 |
| DeterministicTransfer 怎么限制 | 包装 agent,子 agent 完成后自动 transfer 回指定列表 | deterministic_transfer.go:43-54, 139-164 |
| AgentTool 怎么替代 | 独立 session + Action 隔离 + 独立 checkpoint | agent_tool.go:93-103, 154-280 |
| Host HandOff 和 ADK Transfer 的区别 | Host 内部回调 vs Agent 间控制权转移 | callback.go:31-89 |
几个设计判断:
-
Transfer 就是"共享 session + 重写历史"。 目标 agent 能看到前面所有 agent 说了什么,但每条消息被标注了来源。这不是"隔离",而是"透明的上下文传递"------问题恰恰出在这里。
-
AgentTool 是"独立 session + 工具参数"。 子 agent 看不到父 agent 的完整历史,只通过
request参数接收任务描述。隔离性更好,但也意味着子 agent 无法利用父 agent 的上下文做推理------需要父 agent 在 request 参数中写清楚。 -
NOT RECOMMENDED 不代表不能用。 Supervisor 模式用 transfer 实现简单的路由------router 分析用户意图,transfer 到合适的专家 agent。如果 agent 层级简单(一层 router + 若干专家),上下文膨胀不是大问题。但复杂场景下 AgentTool 更好。
下一篇(E94)讲 Deep Agent 的文件系统工具链------read/write/glob/grep/shell 五个工具怎么设计,以及 filesystem 中间件的实现。