AI 电子伴侣
创建时间: 2026-04-03最后更新: 2026-08-04

1. 总控模式写顺以后,新的问题就来了

上一篇我们把 Supervisor 跑起来了。

用户只面对一个入口,总控 Agent 在后面找日程 Agent、邮件 Agent 这些专门角色来做事。

这种方式很稳,尤其适合「我希望所有事最后都回到一个总控来收口」的场景。

但业务再往前走一步,你会碰到另一类需求。用户并不总是想一直和总控对话。有些时候,他其实更希望直接进入某个角色的上下文里,把接下来的几轮话都交给这个角色处理。

比如用户先说:

「我想请你帮我做旅行规划。」

总控当然可以理解这句话,然后去问旅行 Agent,拿回结果再转述给用户。可一旦用户接着追问:

「预算控制在五千以内。」

「别安排太早的航班。」

「我比较想住在地铁附近。」

如果这几轮都还要先经过总控,再转给旅行 Agent,就会显得有点绕。这个时候,更自然的办法往往不是继续让总控代理一切,而是直接把当前对话交给旅行 Agent。

这就是 handoff 想解决的事。

2. Handoff 说白了,就是把控制权交出去

先把名字放轻一点看。handoff 不是一种神秘的新框架能力,它说的就是一件很直白的事:

当前这个角色不继续处理了,把后面的对话交给另一个角色。

和上一篇的 Supervisor 放在一起看,差别会很清楚:

  • Supervisor 更像总控派活,结果还会收回来
  • Handoff 更像把用户带进另一个角色的工作台,让那个角色继续往下聊
正在加载图示...

所以这两种模式不是谁替代谁,而是适合的场景不一样。

如果你要的是集中收口、统一对外回复,Supervisor 会更顺。
如果你要的是让用户直接进入某个领域角色的上下文里连续对话,handoff 会更自然。

3. 先看一个最简单的 handoff 场景

先用一个生活化的例子来讲。系统里有两个角色:

  • generalAgent 负责接第一句话
  • travelAgent 负责旅行规划

一开始用户先和 generalAgent 对话。只要问题还比较泛,它自己就能接住。但当它判断用户已经明确进入「旅行规划」这个领域以后,就不继续硬接,而是把对话交给 travelAgent

handoff-scene.ts
01
type AgentName = 'general' | 'travel'
02
03
type ChatState = {
04
activeAgent: AgentName
05
messages: Array<{ role: 'user' | 'assistant'; content: string }>
06
}
07
08
const initialState: ChatState = {
09
activeAgent: 'general',
10
messages: [],
11
}

这里真正重要的字段是 activeAgent
只要这个字段从 general 变成了 travel,后面的消息就不再交给总控,而是直接交给旅行角色。

这件事本身不复杂,关键在于你愿不愿意承认:有些对话本来就该换角色继续。

4. 用状态切换把 handoff 落下来

这一类模式最常见的做法,不是把所有角色都揉成一个大 Agent,而是明确在状态里保留「当前谁在接管」。

下面用一张很小的图,把这件事落成代码。

handoff-graph.ts
01
import {
02
StateGraph,
03
StateSchema,
04
ReducedValue,
05
START,
06
END,
07
Command,
08
} from '@langchain/langgraph'
09
import type { GraphNode, ConditionalEdgeRouter } from '@langchain/langgraph'
10
import { z } from 'zod'
11
12
const appendMessages = (
13
current: Array<{ role: 'user' | 'assistant'; content: string }>,
14
update: Array<{ role: 'user' | 'assistant'; content: string }>,
15
) => {
16
return [...current, ...update]
17
}
18
19
const State = new StateSchema({
20
activeAgent: z.enum(['general', 'travel']).default('general'),
21
messages: new ReducedValue(
22
z.array(
23
z.object({
24
role: z.enum(['user', 'assistant']),
25
content: z.string(),
26
}),
27
).default([]),
28
{ reducer: appendMessages },
29
),
30
})
31
32
const generalAgent: GraphNode<typeof State> = (state) => {
33
const lastMessage = state.messages.at(-1)?.content ?? ''
34
35
// 如果用户已经明确在聊旅行,这里就不继续硬接了
36
if (lastMessage.includes('旅行') || lastMessage.includes('机票') || lastMessage.includes('酒店')) {
37
return new Command({
38
update: {
39
activeAgent: 'travel',
40
messages: [{
41
role: 'assistant',
42
content: '接下来由旅行助理继续帮你安排行程。',
43
}],
44
},
45
goto: 'travelAgent',
46
})
47
}
48
49
return {
50
messages: [{
51
role: 'assistant',
52
content: '我先帮你判断一下需求方向,如果是旅行规划,我会把对话切给旅行助理。',
53
}],
54
}
55
}
56
57
const travelAgent: GraphNode<typeof State> = (state) => {
58
const lastMessage = state.messages.at(-1)?.content ?? ''
59
60
// 这里假设控制权已经切到了旅行角色,
61
// 所以后面的回复会直接站在旅行助理的视角继续往下接
62
return {
63
messages: [{
64
role: 'assistant',
65
content: `旅行助理已接手,当前收到的新要求是:${lastMessage}`,
66
}],
67
}
68
}
69
70
const shouldContinue: ConditionalEdgeRouter<typeof State> = (state) => {
71
if (state.activeAgent === 'travel') return 'travelAgent'
72
return END
73
}
74
75
const graph = new StateGraph(State)
76
.addNode('generalAgent', generalAgent, { ends: ['travelAgent'] })
77
.addNode('travelAgent', travelAgent)
78
.addEdge(START, 'generalAgent')
79
.addConditionalEdges('generalAgent', shouldContinue, ['travelAgent'])
80
.addEdge('travelAgent', END)
81
.compile()

这一段最关键的不是字符串判断,而是那句:

handoff-core.ts
1
activeAgent: 'travel'

handoff 的本质,就是把当前活跃角色切过去。这里我们用的是一个最容易看懂的版本:状态切换以后,后面的处理节点直接换成 travelAgent

5. 连续对话时,handoff 的感觉会更明显

只跑一轮的时候,handoffSupervisor 的差别还不算特别大。真正到了多轮对话里,这种差别就很明显了。

下面这段先演示最小版本,只保留当前接管角色,再继续往下走。这样最容易看清 handoff 的核心是「控制权已经切过去了」。

handoff-turns.ts
01
let state = await graph.invoke({
02
messages: [{ role: 'user', content: '我想做一个日本旅行规划。' }],
03
})
04
05
console.log(state.activeAgent)
06
// → travel
07
08
state = await graph.invoke({
09
// 这里把当前活跃角色继续传回去,
10
// 所以下一轮不会再回到 generalAgent 重新判断
11
activeAgent: state.activeAgent,
12
messages: [{ role: 'user', content: '预算尽量控制在五千以内。' }],
13
})
14
15
console.log(state.messages.at(-1)?.content)
16
// → 旅行助理已接手,当前收到的新要求是:预算尽量控制在五千以内。

到了第二轮,系统已经不需要再问「这是不是旅行问题」。因为 activeAgent 已经切成了 travel,后面就直接在旅行角色的上下文里继续走。

这也是 handoff 真正顺手的地方。它不是让每一轮都重新路由,而是让某个角色接手以后,能把后面的对话连续接下去。

不过这里要注意一件事:上面这段只是为了演示角色切换,所以第二轮只把 activeAgent 传了回去,没有把整段历史消息一起带上。如果你希望旅行角色继续看到完整对话,要么把历史消息一并传回去,要么接上前面讲过的 checkpointer

6. 那 Swarm 又是什么

Swarm 可以先理解成比 handoff 再往前走一步。

如果说 handoff 还是在做「一个角色把控制权交给另一个角色」,那 Swarm 更像一组角色之间可以彼此转交,谁觉得下一步该找谁,就继续往下交。

它不一定总有一个固定总控站在最上面。控制权可能在多个角色之间流动。

比如还是旅行场景:

  • 旅行顾问先接到需求
  • 它发现预算是关键,于是把问题交给预算顾问
  • 预算顾问确认预算可行以后,再把问题交回旅行顾问
  • 旅行顾问再继续给出行程建议

这时候系统更像是一张角色网络,而不是一棵单向分发的树。

7. 什么时候适合 handoff,什么时候更像 swarm

可以把这两个模式放回使用感受里看。

如果你的系统里仍然有一个比较明确的起点角色,只是中途会把用户带进某个更专业的角色里继续聊,那通常还是 handoff 更贴切。

如果你的系统里,多个角色本来就可能互相接力,而且你不太想设一个永远站在最上面的总控,那它就会越来越像 swarm

所以区别不在于「有没有多个 Agent」,而在于:

控制权是一次性交出去,还是可能在多个角色之间持续流动。

8. 写这一类模式时最容易出的问题

最常见的问题,是明明已经 handoff 了,结果历史上下文还是按总控那套思路在塞。这样角色虽然换了,实际看到的还是一锅混在一起的内容,接手的意义就会打折。

第二个问题,是没有把「当前谁在接管」这件事落成状态。写的时候好像知道现在是谁在说话,跑到第二轮、第三轮以后,系统自己却不知道了。结果就是一会儿回总控,一会儿又回专门角色,行为会很飘。

还有一个问题,是把 swarm 写成一堆互相乱跳的 handoff。角色越多,这种问题越明显。所以一旦开始走到 swarm 这种模式,角色边界、共享状态和路由条件就得比前面更清楚。

9. 总结

到了这里,多 Agent 这条线又往前走了一步。

上一篇的 Supervisor,重点是总控怎么派活。
这一篇的 handoffswarm,重点开始变成控制权怎么在角色之间流动。

它们处理的其实不是同一个问题,所以也不应该放在一起比谁更高级。什么时候要统一收口,就用 Supervisor;什么时候希望用户直接进入某个角色的上下文里连续对话,handoff 会更自然;如果角色之间还会继续彼此接力,就开始接近 swarm 了。

下一篇讲 层级团队与并行协作。到那时,视角会再往前走一步,不只是角色怎么接力,而是多个角色怎么同时工作,再把结果汇回来。