跳到正文

TECH NOTE

3863 字 19 分钟 系列 5 / 22

05 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),于是”什么时候换人”由模型判断,“换成谁”由状态决定。
  • 它跟 04 Subagents 最大的区别:这里是”当前这个 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 tool
from langchain.messages import ToolMessage
from langgraph.types import Command
@tool
def 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, tool
from langchain.messages import ToolMessage
from langgraph.types import Command
@tool
def 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 Callable
from langchain.agents import AgentState, create_agent
from langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponse
from langchain.tools import tool, ToolRuntime
from langchain.messages import ToolMessage
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.types import Command
# 1) 定义带 current_step 的状态
class SupportState(AgentState):
"""记录当前处于哪一步。"""
current_step: str = "triage"
warranty_status: str | None = None
# 2) 工具通过 Command 更新 current_step
@tool
def 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_call
def 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) 创建带中间件的 Agent
agent = 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, ToolMessage
from langchain.tools import tool, ToolRuntime
from langgraph.types import Command
@tool
def 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 Literal
from langchain.agents import AgentState, create_agent
from langchain.messages import AIMessage, ToolMessage
from langchain.tools import tool, ToolRuntime
from langgraph.graph import StateGraph, START, END
from langgraph.types import Command
from 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) 各自带"换人"工具的 Agent
sales_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_agent
def 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 交接时,必须同时带上两样东西:

  1. 包含工具调用的那条 AIMessage(触发本次交接的消息);
  2. 一条确认交接的 ToolMessage(这次工具调用的人工响应)。

少了这个配对,接收方会看到一段不完整的对话,可能报错或产生意料之外的行为。

下面的例子假设只调用了交接工具(没有并行工具调用):

@tool
def 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 的账)

小结#

  1. 机制一句话:工具返回 Command 改状态(current_step / active_agent)→ 系统读状态换配置或换 Agent;状态跨回合持久。
  2. 四个特征:状态驱动行为、工具驱动迁移、直接与用户交互、状态持久。
  3. 必须补 ToolMessage:只要交接工具改了 messages,就要用匹配的 tool_call_id 闭环,否则对话历史畸形。
  4. 优先选单 Agent + 中间件:configs = {步骤: {prompt, tools}} + wrap_model_call + checkpointer 三板斧;只有需要定制 Agent 实现时才用多 Agent 子图。
  5. 子图交接要手工管上下文:交接时必须带上 AIMessage(工具调用)+ ToolMessage(交接回执)这一对,只传这一对,不要整包子智能体历史;结束回合时最后一条必须是 AIMessage。
  6. 它最擅长的事:强制顺序约束 + 多阶段会话(客服:先保修、再分类、再方案)。

一句话选型#

  • 选它:流程有”必须先 A 再 B”的硬约束;用户要跨阶段持续对话;客服/工单/审批这类多阶段会话。
  • 别选它:需要并行处理多个领域(用 04 Subagents 或 07 Router);多领域大上下文(Handoffs 串行,03 里它是下策);只是想让单个 Agent 按需加载知识(用 06 Skills)。

上一篇:04 Subagents 子智能体 | 下一篇:06 Skills 技能 | 实战:10 用 Handoffs 搭客服工单流转

05 Handoffs 交接:靠状态切换行为,像客服转接电话
https://aicooleo.cn/blog/posts/multiagent-05-handoffs/
作者
aicooleo
发布于
2026-08-27
许可协议
CC BY-NC-SA 4.0
多 Agent 精讲 · 第 5 / 22 篇 返回合集