TECH NOTE
3863 字 19 分钟 系列 5 / 2205 Handoffs 交接:靠状态切换行为,像客服转接电话
ON THIS PAGE / 目录15 节
一句话讲透:用一个状态变量(比如
current_step)记住”现在走到哪一步了”,工具调用负责改这个变量,系统每次读它来决定”这一轮该用哪套提示词、哪些工具、甚至换成哪个 Agent”。用户始终在跟”同一个对话框”说话,但背后的行为已经换了人。
它到底是什么
原文定义:
在 handoffs 架构里,行为会根据状态动态变化。核心机制是:工具更新一个跨回合持久化的状态变量(例如
current_step或active_agent),系统读取这个变量来调整行为——要么套用不同的配置(系统提示词、工具集),要么路由到另一个 Agent。这个模式既支持不同 Agent 之间的交接,也支持同一个 Agent 内部的动态配置切换。
💡 提示(原文):handoffs 这个词是 OpenAI 提出的,指的是用工具调用(例如
transfer_to_sales_agent)在 Agent 或状态之间转移控制权。
两句人话:
- 传统写法里”换 Agent”要写
if/else或画图连线;Handoffs 把这件事变成模型自己调一个工具(transfer_to_sales),于是”什么时候换人”由模型判断,“换成谁”由状态决定。 - 它跟
04Subagents 最大的区别:这里是”当前这个 Agent”直接跟用户对话,不是”主管汇总后再转述”。用户能感知到”换了个专家在跟我聊”。
一个完整的交接长什么样
sequenceDiagram participant U as 用户 participant A as Agent participant S as 工作流状态 U->>A: "我手机摔坏了" Note over A,S: 步骤:获取保修状态<br/>工具:record_warranty_status A-->>U: "设备还在保修期内吗?" U->>A: "在保修期内" A->>S: record_warranty_status("in_warranty") Note over A,S: 步骤:问题分类<br/>工具:record_issue_type A-->>U: "能描述一下具体问题吗?" U->>A: "屏幕裂了" A->>S: record_issue_type("hardware") Note over A,S: 步骤:给出解决方案<br/>工具:provide_solution、escalate_to_human A-->>U: "保修维修流程是这样的…"这张图在说什么:① 同一个 Agent 面对用户,但每一轮的”身份/工具/提示词”都不同;② 区别由状态里的”当前步骤”决定(获取保修状态 → 问题分类 → 给出方案);③ 步骤的推进靠工具调用写状态(record_warranty_status、record_issue_type)——不是靠模型”记得”,而是靠状态锁死。
四个关键特征
| 特征 | 原文 | 说人话 |
|---|---|---|
| 状态驱动行为 | State-driven behavior:行为随状态变量(current_step / active_agent)变化 | 换人格、换工具、换提示词,都看这一个变量 |
| 工具驱动迁移 | Tool-based transitions:工具更新状态变量以在状态间移动 | 推进流程的主动权在工具手里,不靠模型自觉 |
| 直接与用户交互 | Direct user interaction:每个状态的配置直接处理用户消息 | 用户面对的就是”当前这个专家”,不需要主管转述 |
| 状态持久 | Persistent state:状态跨对话回合存活 | 用户中途走开再回来,流程还在原地 |
什么时候用它
原文的判断标准:
- 你需要强制顺序约束(“只有满足前置条件才解锁能力”);
- Agent 需要在不同状态下直接跟用户对话;
- 你在做多阶段会话流程。
原文特别点名了它的主场:
这个模式在客服场景里尤其有价值——你需要按特定顺序收集信息,例如先拿到保修 ID,才处理退款。
🧭 补充解读(非原文):Handoffs 的本质是把业务流程的状态机搬进对话。判断要不要用它,就问一句:“这件事有没有’必须先 A 再 B’的规定?“有,就用它;没有,Skills(
06)可能更轻。
最小可用实现:一个工具 + 一个状态字段
原文的核心机制:工具返回一个 Command 来更新状态,从而触发”进入新步骤 / 换成新 Agent”:
from langchain.tools import toolfrom langchain.messages import ToolMessagefrom langgraph.types import Command
@tooldef transfer_to_specialist(runtime) -> Command: """Transfer to the specialist agent.""" return Command( update={ "messages": [ ToolMessage( # 必须补一条工具回执,见下方说明 content="Transferred to specialist", tool_call_id=runtime.tool_call_id, ) ], "current_step": "specialist", # ← 这一行就是"交接":触发行为切换 } )📌 为什么要塞一条
ToolMessage?(原文的 Note) 当 LLM 调用一个工具时,它期待一个响应。带匹配tool_call_id的ToolMessage完成了这次”请求—响应”闭环——没有它,对话历史就变成畸形(malformed)的。只要你的交接工具改了 messages,这条就是必需的。
📎 官方实战教程:Build customer support with handoffs(本笔记的精讲版见
10)
两种实现路线
原文给了两条路:
| 路线 | 做法 | 什么时候选 |
|---|---|---|
| 单 Agent + 中间件 | 一个 Agent,靠中间件在每次模型调用前动态改系统提示词和工具集 | 绝大多数场景(原文推荐:更简单) |
| 多 Agent 子图 | 多个独立 Agent 作为图里的不同节点,交接工具用 Command.PARENT 指定下一个执行哪个节点 | 需要定制的 Agent 实现(例如某个节点本身就是带反思/检索的复杂图) |
路线一:单 Agent + 中间件(用配置切换行为)
机制(原文):一个 Agent 根据状态改变行为。中间件拦截每次模型调用,动态调整系统提示词与可用工具;工具则更新状态变量以触发迁移。
from langchain.tools import ToolRuntime, toolfrom langchain.messages import ToolMessagefrom langgraph.types import Command
@tooldef record_warranty_status(status: str, runtime: ToolRuntime[None, SupportState]) -> Command: """Record warranty status and transition to next step.""" return Command( update={ "messages": [ ToolMessage( content=f"Warranty status recorded: {status}", tool_call_id=runtime.tool_call_id, ) ], "warranty_status": status, "current_step": "specialist", # 更新状态 → 触发进入下一步 } )完整示例:用中间件做客服(原文,已精简)
from typing import Callablefrom langchain.agents import AgentState, create_agentfrom langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponsefrom langchain.tools import tool, ToolRuntimefrom langchain.messages import ToolMessagefrom langgraph.checkpoint.memory import InMemorySaverfrom langgraph.types import Command
# 1) 定义带 current_step 的状态class SupportState(AgentState): """记录当前处于哪一步。""" current_step: str = "triage" warranty_status: str | None = None
# 2) 工具通过 Command 更新 current_step@tooldef record_warranty_status(status: str, runtime: ToolRuntime[None, SupportState]) -> Command: """Record warranty status and transition to next step.""" return Command(update={ "messages": [ ToolMessage( content=f"Warranty status recorded: {status}", tool_call_id=runtime.tool_call_id, ) ], "warranty_status": status, "current_step": "specialist", # ← 迁移到下一步 })
# 3) 中间件:按 current_step 套用不同配置(提示词 + 工具集)@wrap_model_calldef apply_step_config( request: ModelRequest, handler: Callable[[ModelRequest], ModelResponse],) -> ModelResponse: """根据 current_step 配置 Agent 行为。""" step = request.state.get("current_step", "triage")
configs = { "triage": { "prompt": "Collect warranty information...", "tools": [record_warranty_status], }, "specialist": { "prompt": "Provide solutions based on warranty: {warranty_status}", "tools": [provide_solution, escalate], }, }
config = configs[step] request = request.override( # 改写这次模型调用的配置 system_prompt=config["prompt"].format(**request.state), tools=config["tools"], ) return handler(request)
# 4) 创建带中间件的 Agentagent = create_agent( model, tools=[record_warranty_status, provide_solution, escalate], state_schema=SupportState, middleware=[apply_step_config], checkpointer=InMemorySaver(), # 关键:状态要跨回合持久化)这段代码在说什么:① 状态里加一个 current_step(初始 triage);② 工具通过 Command 改状态 → 相当于”按下流程按钮”;③ 中间件每次模型调用前读 current_step,从 configs 表里取出这一步该用的提示词和工具,用 request.override(...) 换掉;④ checkpointer 负责让状态跨回合活着——没有它,下一轮就”失忆”回到 triage。
🧭 补充解读(非原文):这段代码最值得学的是**“行为配置表”这个结构**——
configs = {步骤: {prompt, tools}}。它把”流程”变成了数据,加一步就是加一个字典条目,不用改控制流。这比一堆if step == ...清晰得多。
路线二:多 Agent 子图(用 Command.PARENT 换节点)
机制(原文):多个独立 Agent 作为图里的不同节点。交接工具用 Command.PARENT 指定下一个该执行哪个节点。
⚠️ 警告(原文):子图式交接需要仔细做上下文工程。不同于单 Agent 中间件(消息历史自然流动),你必须显式决定哪些消息在 Agent 之间传递。搞错了,接收方会拿到畸形的对话历史或膨胀的上下文。见下文「上下文工程」。
from langchain.messages import AIMessage, ToolMessagefrom langchain.tools import tool, ToolRuntimefrom langgraph.types import Command
@tooldef transfer_to_sales(runtime: ToolRuntime) -> Command: """Transfer to the sales agent.""" # 1) 找到触发本次交接的那条 AIMessage last_ai_message = next( msg for msg in reversed(runtime.state["messages"]) if isinstance(msg, AIMessage) ) # 2) 造一条"交接回执",把这次工具调用闭环 transfer_message = ToolMessage( content="Transferred to sales agent", tool_call_id=runtime.tool_call_id, ) return Command( goto="sales_agent", # 下一个节点 update={ "active_agent": "sales_agent", "messages": [last_ai_message, transfer_message], # 只带这两条,别带全量历史 }, graph=Command.PARENT, # 告诉父图:跳到另一个节点 )完整示例:销售 / 支持双 Agent(原文,已精简骨架)
from typing import Literalfrom langchain.agents import AgentState, create_agentfrom langchain.messages import AIMessage, ToolMessagefrom langchain.tools import tool, ToolRuntimefrom langgraph.graph import StateGraph, START, ENDfrom langgraph.types import Commandfrom typing_extensions import NotRequired
# 1) 状态里记录"当前活跃的是哪个 Agent"class MultiAgentState(AgentState): active_agent: NotRequired[str]
# 2) 两个方向的交接工具(transfer_to_sales / transfer_to_support)# 实现同上:取 AIMessage + 造 ToolMessage + goto + Command.PARENT
# 3) 各自带"换人"工具的 Agentsales_agent = create_agent( model="google_genai:gemini-3.6-flash", tools=[transfer_to_support], system_prompt="You are a sales agent... If asked about technical issues, transfer to the support agent.",)support_agent = create_agent( model="google_genai:gemini-3.6-flash", tools=[transfer_to_sales], system_prompt="You are a support agent... If asked about pricing, transfer to the sales agent.",)
# 4) 每个 Agent 一个节点def call_sales_agent(state: MultiAgentState) -> Command: return sales_agent.invoke(state)
def call_support_agent(state: MultiAgentState) -> Command: return support_agent.invoke(state)
# 5) 路由:最后一条 AIMessage 没有工具调用 → 结束;否则回到 active_agentdef route_after_agent(state: MultiAgentState) -> Literal["sales_agent", "support_agent", "__end__"]: messages = state.get("messages", []) if messages: last_msg = messages[-1] if isinstance(last_msg, AIMessage) and not last_msg.tool_calls: return "__end__" # 没有待办工具调用 = 这一轮说完了 active = state.get("active_agent", "sales_agent") return active if active else "sales_agent"
def route_initial(state: MultiAgentState) -> Literal["sales_agent", "support_agent"]: return state.get("active_agent") or "sales_agent"
# 6) 组图builder = StateGraph(MultiAgentState)builder.add_node("sales_agent", call_sales_agent)builder.add_node("support_agent", call_support_agent)builder.add_conditional_edges(START, route_initial, ["sales_agent", "support_agent"])builder.add_conditional_edges("sales_agent", route_after_agent, ["sales_agent", "support_agent", END])builder.add_conditional_edges("support_agent", route_after_agent, ["sales_agent", "support_agent", END])graph = builder.compile()这段代码在说什么:① 状态里的 active_agent 是”现在轮到谁”;② 每个 Agent 的工具里都装着”转给对面”的交接工具;③ 交接工具用 goto 直接跳到另一个节点(graph=Command.PARENT 让父图接管跳转);④ route_after_agent 负责判断”这一轮说完了就结束,否则继续活跃 Agent”——这段路由逻辑是双 Agent 模式的”传送带”。
💡 提示(原文,强烈建议先看这条): 大多数 handoffs 场景用「单 Agent + 中间件」就够了——它更简单。只有当你需要定制的 Agent 实现时(比如某个节点本身就是一个带反思或检索步骤的复杂图),才上「多 Agent 子图」。
两种路线对比(补充解读)
【单 Agent + 中间件】一个 Agent,像"演员换戏服"用户 ──▶ Agent(state.current_step = triage) │ 工具写状态 ▼ Agent(读到 current_step = specialist) │ 中间件自动换上 specialist 的提示词 + 工具 ▼ 同一个 Agent 继续跟用户聊 ← 消息历史天然连续,不用手工搬
【多 Agent 子图】多个 Agent,像"接力棒"用户 ──▶ sales_agent 节点 │ 调 transfer_to_support 工具 ▼ Command(goto="support_agent", graph=Command.PARENT) support_agent 节点 │ ← 只拿到 [AIMessage(工具调用), ToolMessage(交接回执)] 两条 ▼ 继续对话 ← 消息历史要你手工挑,挑错就畸形上下文工程(子图交接的关键)
原文用了一整节讲这件事,因为这里是新手最容易翻车的地方。
交接时怎么处理上下文
在 Agent 之间交接时,你必须保证对话历史依然合法。LLM 期待”工具调用”和”工具响应”成对出现,所以用 Command.PARENT 交接时,必须同时带上两样东西:
- 包含工具调用的那条
AIMessage(触发本次交接的消息); - 一条确认交接的
ToolMessage(这次工具调用的人工响应)。
少了这个配对,接收方会看到一段不完整的对话,可能报错或产生意料之外的行为。
下面的例子假设只调用了交接工具(没有并行工具调用):
@tooldef transfer_to_sales(runtime: ToolRuntime) -> Command: # 取触发本次交接的 AI 消息 last_ai_message = runtime.state["messages"][-1]
# 造一条人工工具响应,补全配对 transfer_message = ToolMessage( content="Transferred to sales agent", tool_call_id=runtime.tool_call_id, )
return Command( goto="sales_agent", update={ "active_agent": "sales_agent", # 只传这两条,不要传子智能体的全部历史 "messages": [last_ai_message, transfer_message], }, graph=Command.PARENT, )📌 为什么不把子智能体的所有消息都传过去?(原文的 Note) 你确实可以把子智能体的完整对话塞进交接里,但这通常会制造问题:接收方会被无关的内部推理搞糊涂,token 成本也无谓上升。只传”交接那一对”消息,可以让父图的上下文保持聚焦在高层协调上。如果接收方确实需要更多背景,考虑在
ToolMessage的内容里给子智能体的工作做个总结,而不是把原始消息历史整包递过去。
把控制权交回用户
原文的一条硬要求:当你要把控制权交回用户时(结束这个 Agent 的回合),确保最后一条消息是 AIMessage。这既保持了对话历史的合法性,也向用户界面发出”Agent 干完了”的信号。
🧭 补充解读(非原文):这条看起来琐碎,实际上是前端体验的开关——很多 UI 是靠”最后一条是 AI 消息且没有工具调用”来判断”可以显示输入框了”。你如果结束回合时最后一条是
ToolMessage,前端会一直转圈等下一轮。
落地时要考虑的几件事
原文列的三条设计考量:
| 考量 | 原文 | 说人话 |
|---|---|---|
| 上下文过滤策略 | 每个 Agent 收到完整历史、过滤后的片段,还是摘要?不同角色的 Agent 可能需要不同上下文 | 交接时别偷懒整包转发,否则又贵又乱 |
| 工具的语义边界 | 交接工具只更新路由状态,还是也产生副作用?例如 transfer_to_sales() 是否还要建一张支持工单,还是那应该是另一个动作 | 一个工具只干一件事,别把”转接”和”建单”揉在一起 |
| Token 效率 | 在”上下文完整”和”token 成本”之间权衡;对话越长,摘要和选择性传递越重要 | 长会话必须有裁剪/摘要策略(参考 03 的账) |
小结
- 机制一句话:工具返回
Command改状态(current_step/active_agent)→ 系统读状态换配置或换 Agent;状态跨回合持久。 - 四个特征:状态驱动行为、工具驱动迁移、直接与用户交互、状态持久。
- 必须补
ToolMessage:只要交接工具改了 messages,就要用匹配的tool_call_id闭环,否则对话历史畸形。 - 优先选单 Agent + 中间件:
configs = {步骤: {prompt, tools}}+wrap_model_call+checkpointer三板斧;只有需要定制 Agent 实现时才用多 Agent 子图。 - 子图交接要手工管上下文:交接时必须带上
AIMessage(工具调用)+ToolMessage(交接回执)这一对,只传这一对,不要整包子智能体历史;结束回合时最后一条必须是AIMessage。 - 它最擅长的事:强制顺序约束 + 多阶段会话(客服:先保修、再分类、再方案)。
一句话选型
- 选它:流程有”必须先 A 再 B”的硬约束;用户要跨阶段持续对话;客服/工单/审批这类多阶段会话。
- 别选它:需要并行处理多个领域(用
04Subagents 或07Router);多领域大上下文(Handoffs 串行,03里它是下策);只是想让单个 Agent 按需加载知识(用06Skills)。
上一篇:04 Subagents 子智能体 | 下一篇:06 Skills 技能 | 实战:10 用 Handoffs 搭客服工单流转