Handoff 模式:对话中途换 Owner
解决什么问题
用户一开始聊"订机票",聊着聊着变成"怎么办签证",又变成"推荐当地美食"。 一个全能 Agent 啥都会但啥都不精。Handoff 在对话中途把控制权移交给更合适的专项 Agent,用户无感知地切换。
关键词:无缝移交。用户感觉一直在跟同一个助手聊天,背后其实换了好几个 Agent。
| 场景 | 为什么选它 |
|---|---|
| 旅行助手跨领域 | 机票→签证→美食,各有专业 Agent |
| 客服分流 | 售前→售后→投诉,不同部门接管 |
| 医疗问诊 | 分诊→专科→药房,逐步移交 |
复杂度
⭐⭐ 中低。核心是"当前 owner"状态机 + 移交触发条件。
和其他模式的关系
| 模式 | 谁决定下一步 | 什么时候用 |
|---|---|---|
| Handoff | Router 判断意图切换,移交控制权 | 对话中用户意图变化,需要换专项 Agent |
| Routing | 一次性路由到目标 Agent | 意图在对话开头就确定,不会中途变 |
| Agents-as-Tools | 主 Agent 调专家取数据再自己回复 | 主 Agent 想保持控制权,不交出回复权 |
Handoff 比 Routing 更动态(对话中途可以反复切换),比 Agents-as-Tools 更彻底(直接交出回复权,不是取数据)。
角色关系
用户: "我想订北京到东京的机票"
│
▼
┌──────────┐
│ 机票 Agent │ ← 当前 owner
└────┬─────┘
│ 检测到 "签证" 关键词 → handoff
▼
┌──────────┐
│ 签证 Agent │ ← 新 owner,接管对话
└────┬─────┘
│ 检测到 "美食" 关键词 → handoff
▼
┌──────────┐
│ 美食 Agent │ ← 新 owner
└──────────┘
- 每次只有一个 Agent 是 owner
- 移交时传递对话历史摘要
- 用户侧看到的是连续对话
完整 Python 实现
"""
Handoff 旅行助手示例
用户对话过程中在机票/签证/美食三个 Agent 之间无缝切换。
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
# ── MockLLM ──────────────────────────────────────────────
class MockLLM:
def __init__(self):
self.call_log: list[dict] = []
def chat(self, role: str, prompt: str) -> str:
self.call_log.append({"role": role, "prompt": prompt[:80]})
if role == "flight_agent":
if "订" in prompt or "机票" in prompt:
return json.dumps({
"reply": "已找到北京→东京航班: NH964 直飞 ¥2800,需要预订吗?",
"handoff_to": None,
})
return json.dumps({"reply": "机票相关问题请说。", "handoff_to": None})
if role == "visa_agent":
return json.dumps({
"reply": "日本旅游签需要: 护照+在职证明+银行流水,办理约7个工作日。",
"handoff_to": None,
})
if role == "food_agent":
return json.dumps({
"reply": "东京必吃: 筑地市场寿司、新宿一蘭拉面、浅草炸猪排。",
"handoff_to": None,
})
# Router 决定是否 handoff
if role == "router":
if "签证" in prompt:
return "visa_agent"
if "美食" in prompt or "吃" in prompt:
return "food_agent"
if "机票" in prompt or "航班" in prompt:
return "flight_agent"
return "current" # 不切换
return "{}"
# ── Agent ────────────────────────────────────────────────
@dataclass
class HandoffAgent:
name: str
role: str
llm: MockLLM = field(repr=False)
def respond(self, user_msg: str, context_summary: str) -> dict:
prompt = f"[上下文] {context_summary}\n[用户] {user_msg}"
raw = self.llm.chat(self.role, prompt)
try:
return json.loads(raw)
except json.JSONDecodeError:
return {"reply": raw, "handoff_to": None}
# ── Handoff Controller ───────────────────────────────────
@dataclass
class HandoffController:
"""管理 Agent 之间的移交逻辑。"""
agents: dict[str, HandoffAgent]
router_llm: MockLLM = field(repr=False)
current_owner: str = "flight_agent"
history: list[dict] = field(default_factory=list)
def _route(self, user_msg: str) -> str:
"""用 router LLM 判断是否需要切换 Agent。"""
return self.router_llm.chat("router", f"用户说: {user_msg}")
def _summarize_context(self) -> str:
"""为新 Agent 提供上下文摘要。"""
recent = self.history[-3:]
return " | ".join(f"{h['role']}: {h['content'][:30]}" for h in recent)
def handle_message(self, user_msg: str) -> str:
self.history.append({"role": "user", "content": user_msg})
# 判断是否需要 handoff
target = self._route(user_msg)
if target != "current" and target in self.agents and target != self.current_owner:
old_owner = self.current_owner
self.current_owner = target
print(f" ↪ Handoff: {old_owner} → {self.current_owner}")
# 当前 owner 回复
agent = self.agents[self.current_owner]
context = self._summarize_context()
result = agent.respond(user_msg, context)
reply = result.get("reply", "")
self.history.append({"role": self.current_owner, "content": reply})
return reply
def run_conversation(self, messages: list[str]):
print("[Handoff] 开始对话\n")
for msg in messages:
print(f"用户: {msg}")
reply = self.handle_message(msg)
print(f"助手 [{self.current_owner}]: {reply}\n")
# ── 运行入口 ─────────────────────────────────────────────
def main():
llm = MockLLM()
agents = {
"flight_agent": HandoffAgent("机票助手", "flight_agent", llm),
"visa_agent": HandoffAgent("签证助手", "visa_agent", llm),
"food_agent": HandoffAgent("美食助手", "food_agent", llm),
}
ctrl = HandoffController(agents=agents, router_llm=llm, current_owner="flight_agent")
# 模拟用户对话——话题自然切换
messages = [
"帮我查北京到东京的机票",
"对了,日本签证怎么办?",
"到了东京有什么好吃的推荐?",
]
ctrl.run_conversation(messages)
# 验证
assert len(ctrl.history) == 6 # 3 user + 3 agent
# 检查确实发生了 handoff
owners = [h["role"] for h in ctrl.history if h["role"] != "user"]
assert "flight_agent" in owners
assert "visa_agent" in owners
assert "food_agent" in owners
print(f"✓ 共 {len(llm.call_log)} 次 LLM 调用,3 次 handoff 成功")
if __name__ == "__main__":
main()
运行记录
[Handoff] 开始对话
用户: 帮我查北京到东京的机票
助手 [flight_agent]: 已找到北京→东京航班: NH964 直飞 ¥2800,需要预订吗?
用户: 对了,日本签证怎么办?
↪ Handoff: flight_agent → visa_agent
助手 [visa_agent]: 日本旅游签需要: 护照+在职证明+银行流水,办理约7个工作日。
用户: 到了东京有什么好吃的推荐?
↪ Handoff: visa_agent → food_agent
助手 [food_agent]: 东京必吃: 筑地市场寿司、新宿一蘭拉面、浅草炸猪排。
✓ 共 6 次 LLM 调用,3 次 handoff 成功
踩坑记录
| 坑 | 现象 | trace 信号 | 修法 |
|---|---|---|---|
| 上下文丢失 | 换 Agent 后忘了前面聊什么 | 新 Agent 的 prompt 中缺少之前对话的关键信息 | handoff 时传递上下文摘要 |
| 乒乓球效应 | 一句话来回切换 Agent | handoff 事件在连续 2-3 轮内反复触发 | 加冷却期:刚切换后 N 轮内不再切 |
| 误触发 | 用户随口提一句就切走了 | router 输出的 target 和用户主意图不符 | Router 要求"明确意图"才切换,闲聊不切 |
| 回切不自然 | 用户想回到之前的话题 | 用户重复之前话题但 Agent 无法衔接 | 支持"回退"指令,恢复之前的 owner |
工程备忘
- Router 是关键:Router 决定切不切、切给谁,它的准确度直接决定用户体验。
- 上下文传递:不要传整个 history,做摘要后传,否则新 Agent 上下文太长。
- 移交确认:可选方案——切换时跟用户确认"看起来您想问签证,让签证专家来帮您?"
- 状态保存:每个 Agent 维护自己的会话状态,handoff 时冻结,回切时恢复。
- 与 Agents-as-Tools 的区别:Agents-as-Tools 是主 Agent 调专家拿数据再自己回复;Handoff 是直接把回复权交给新 Agent。
- 适用场景:对话式应用(chatbot)首选 Handoff;批处理任务首选 Manager-Worker。
- 监控:记录每次 handoff 的触发原因,方便分析 Router 的准确率。
读完以后
如果 Agent 切换后不需要面对用户、只需要返回数据给主 Agent,看 Agents-as-Tools。 如果意图在对话开头就确定、不需要中途切换,看 Routing 模式。 如果多个 Agent 需要同时参与讨论,看 Group Chat。