跳转至

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

工程备忘

  1. Router 是关键:Router 决定切不切、切给谁,它的准确度直接决定用户体验。
  2. 上下文传递:不要传整个 history,做摘要后传,否则新 Agent 上下文太长。
  3. 移交确认:可选方案——切换时跟用户确认"看起来您想问签证,让签证专家来帮您?"
  4. 状态保存:每个 Agent 维护自己的会话状态,handoff 时冻结,回切时恢复。
  5. 与 Agents-as-Tools 的区别:Agents-as-Tools 是主 Agent 调专家拿数据再自己回复;Handoff 是直接把回复权交给新 Agent。
  6. 适用场景:对话式应用(chatbot)首选 Handoff;批处理任务首选 Manager-Worker。
  7. 监控:记录每次 handoff 的触发原因,方便分析 Router 的准确率。

读完以后

如果 Agent 切换后不需要面对用户、只需要返回数据给主 Agent,看 Agents-as-Tools。 如果意图在对话开头就确定、不需要中途切换,看 Routing 模式。 如果多个 Agent 需要同时参与讨论,看 Group Chat

参考资料