handoffs 架构中,行为会根据状态动态变化。核心机制是:工具更新一个跨轮次持久存在的状态变量(例如 current_stepactive_agent),系统读取该变量来调整行为,包括应用不同配置(系统提示词、工具)或路由到不同代理。此模式既支持不同代理之间的 handoff,也支持单个代理内部的动态配置变化。
handoffs 这个术语由 OpenAI 提出,用于描述通过工具调用(例如 transfer_to_sales_agent)在代理或状态之间转移控制权。

关键特征

  • 状态驱动行为:行为会根据状态变量(例如 current_stepactive_agent)变化
  • 基于工具的转换:工具更新状态变量,从而在状态之间移动
  • 直接用户交互:每个状态的配置都会直接处理用户消息
  • 持久状态:状态会跨对话轮次保留

何时使用

当你需要强制顺序约束(只有满足前置条件后才解锁能力)、代理需要在不同状态下直接与用户对话,或正在构建多阶段对话流程时,请使用 handoffs 模式。此模式对客户支持场景尤其有价值,因为这类场景需要按特定顺序收集信息,例如在处理退款前先收集保修 ID。

基本实现

核心机制是一个会返回 Command工具,用于更新状态并触发转换到新步骤或新代理:
import { tool, ToolMessage, type ToolRuntime } from "langchain";
import { Command } from "@langchain/langgraph";
import { z } from "zod";

const transferToSpecialist = tool(
  async (_, config: ToolRuntime<typeof StateSchema>) => {
    return new Command({
      update: {
        messages: [
          new ToolMessage({
            content: "Transferred to specialist",
            tool_call_id: config.toolCallId  
          })
        ],
        currentStep: "specialist"  // Triggers behavior change
      }
    });
  },
  {
    name: "transfer_to_specialist",
    description: "Transfer to the specialist agent.",
    schema: z.object({})
  }
);
为什么包含 ToolMessage 当 LLM 调用工具时,它期望得到响应。带匹配 tool_call_idToolMessage 会完成这个请求-响应周期。没有它,对话历史会变成格式错误。只要 handoff 工具更新消息,就需要这样做。
如需完整实现,请参阅下面的教程。

教程:使用 handoffs 构建客户支持

学习如何使用 handoffs 模式构建客户支持代理,其中单个代理会在不同配置之间转换。

实现方法

实现 handoffs 有两种方式:带中间件的单代理(一个具备动态配置的代理)或**多个代理子图**(不同代理作为图节点)。

带中间件的单代理

单个代理会根据状态改变行为。中间件拦截每次模型调用,并动态调整系统提示词和可用工具。工具会更新状态变量来触发转换:
import { tool, ToolMessage, type ToolRuntime } from "langchain";
import { Command } from "@langchain/langgraph";
import { z } from "zod";

const recordWarrantyStatus = tool(
  async ({ status }, config: ToolRuntime<typeof StateSchema>) => {
    return new Command({
      update: {
        messages: [
          new ToolMessage({
            content: `Warranty status recorded: ${status}`,
            tool_call_id: config.toolCallId,
          }),
        ],
        warrantyStatus: status,
        currentStep: "specialist", // Update state to trigger transition
      },
    });
  },
  {
    name: "record_warranty_status",
    description: "Record warranty status and transition to next step.",
    schema: z.object({
      status: z.string(),
    }),
  }
);
import {
  createAgent,
  createMiddleware,
  tool,
  ToolMessage,
  type ToolRuntime,
} from "langchain";
import { Command, MemorySaver, StateSchema } from "@langchain/langgraph";
import { z } from "zod";

// 1. Define state with current_step tracker
const SupportState = new StateSchema({
  currentStep: z.string().default("triage"),
  warrantyStatus: z.string().optional(),
});

// 2. Tools update currentStep via Command
const recordWarrantyStatus = tool(
  async ({ status }, config: ToolRuntime<typeof SupportState.State>) => {
    return new Command({
      update: {
        messages: [ 
          new ToolMessage({
            content: `Warranty status recorded: ${status}`,
            tool_call_id: config.toolCallId,
          }),
        ],
        warrantyStatus: status,
        // Transition to next step
        currentStep: "specialist",
      },
    });
  },
  {
    name: "record_warranty_status",
    description: "Record warranty status and transition",
    schema: z.object({ status: z.string() }),
  }
);

// 3. Middleware applies dynamic configuration based on currentStep
const applyStepConfig = createMiddleware({
  name: "applyStepConfig",
  stateSchema: SupportState,
  wrapModelCall: async (request, handler) => {
    const step = request.state.currentStep || "triage";

    // Map steps to their configurations
    const configs = {
      triage: {
        prompt: "Collect warranty information...",
        tools: [recordWarrantyStatus],
      },
      specialist: {
        prompt: `Provide solutions based on warranty: ${request.state.warrantyStatus}`,
        tools: [provideSolution, escalate],
      },
    };

    const config = configs[step as keyof typeof configs];
    return handler({
      ...request,
      systemPrompt: config.prompt,
      tools: config.tools,
    });
  },
});

// 4. Create agent with middleware
const agent = createAgent({
  model,
  tools: [recordWarrantyStatus, provideSolution, escalate],
  middleware: [applyStepConfig],
  checkpointer: new MemorySaver(), // Persist state across turns
});

多个代理子图

多个不同代理作为图中的独立节点存在。Handoff 工具使用 Command.PARENT 在代理节点之间导航,指定接下来要执行哪个节点。
子图 handoffs 需要谨慎的 context engineering。不同于单代理中间件(消息历史会自然流动),你必须明确决定哪些消息在代理之间传递。如果处理不当,代理会收到格式错误的对话历史或膨胀的上下文。请参阅下面的上下文工程
import {
  tool,
  ToolMessage,
  AIMessage,
  type ToolRuntime,
} from "langchain";
import { Command, StateSchema, MessagesValue } from "@langchain/langgraph";

const CustomState = new StateSchema({
  messages: MessagesValue,
});

const transferToSales = tool(
  async (_, runtime: ToolRuntime<typeof CustomState.State>) => {
    const lastAiMessage = runtime.state.messages 
      .reverse() 
      .find(AIMessage.isInstance);

    const transferMessage = new ToolMessage({
      content: "Transferred to sales agent",
      tool_call_id: runtime.toolCallId,
    });
    return new Command({
      goto: "sales_agent",
      update: {
        activeAgent: "sales_agent",
        messages: [lastAiMessage, transferMessage].filter(Boolean),
      },
      graph: Command.PARENT,
    });
  },
  {
    name: "transfer_to_sales",
    description: "Transfer to the sales agent.",
    schema: z.object({}),
  }
);
此示例展示了一个包含独立销售代理和支持代理的多代理系统。每个代理都是一个独立图节点,handoff 工具允许代理将对话转移给彼此。
import {
  StateGraph,
  START,
  END,
  StateSchema,
  MessagesValue,
  Command,
  ConditionalEdgeRouter,
  GraphNode,
} from "@langchain/langgraph";
import { createAgent, AIMessage, ToolMessage } from "langchain";
import { tool, ToolRuntime } from "@langchain/core/tools";
import { z } from "zod/v4";

// 1. Define state with active_agent tracker
const MultiAgentState = new StateSchema({
  messages: MessagesValue,
  activeAgent: z.string().optional(),
});

// 2. Create handoff tools
const transferToSales = tool(
  async (_, runtime: ToolRuntime<typeof MultiAgentState.State>) => {
    const lastAiMessage = [...runtime.state.messages] 
      .reverse() 
      .find(AIMessage.isInstance);
    const transferMessage = new ToolMessage({
      content: "Transferred to sales agent from support agent",
      tool_call_id: runtime.toolCallId,
    });
    return new Command({
      goto: "sales_agent",
      update: {
        activeAgent: "sales_agent",
        messages: [lastAiMessage, transferMessage].filter(Boolean),
      },
      graph: Command.PARENT,
    });
  },
  {
    name: "transfer_to_sales",
    description: "Transfer to the sales agent.",
    schema: z.object({}),
  }
);

const transferToSupport = tool(
  async (_, runtime: ToolRuntime<typeof MultiAgentState.State>) => {
    const lastAiMessage = [...runtime.state.messages] 
      .reverse() 
      .find(AIMessage.isInstance);
    const transferMessage = new ToolMessage({
      content: "Transferred to support agent from sales agent",
      tool_call_id: runtime.toolCallId,
    });
    return new Command({
      goto: "support_agent",
      update: {
        activeAgent: "support_agent",
        messages: [lastAiMessage, transferMessage].filter(Boolean),
      },
      graph: Command.PARENT,
    });
  },
  {
    name: "transfer_to_support",
    description: "Transfer to the support agent.",
    schema: z.object({}),
  }
);

// 3. Create agents with handoff tools
const salesAgent = createAgent({
  model: "google_genai:gemini-3.5-flash",
  tools: [transferToSupport],
  systemPrompt:
    "You are a sales agent. Help with sales inquiries. If asked about technical issues or support, transfer to the support agent.",
});

const supportAgent = createAgent({
  model: "google_genai:gemini-3.5-flash",
  tools: [transferToSales],
  systemPrompt:
    "You are a support agent. Help with technical issues. If asked about pricing or purchasing, transfer to the sales agent.",
});

// 4. Create agent nodes that invoke the agents
const callSalesAgent: GraphNode<typeof MultiAgentState.State> = async (state) => {
  const response = await salesAgent.invoke(state);
  return response;
};

const callSupportAgent: GraphNode<typeof MultiAgentState.State> = async (state) => {
  const response = await supportAgent.invoke(state);
  return response;
};

// 5. Create router that checks if we should end or continue
const routeAfterAgent: ConditionalEdgeRouter<
  typeof MultiAgentState.State,
  "sales_agent" | "support_agent"
> = (state) => {
  const messages = state.messages ?? [];

  // Check the last message - if it's an AIMessage without tool calls, we're done
  if (messages.length > 0) {
    const lastMsg = messages[messages.length - 1];
    if (lastMsg instanceof AIMessage && !lastMsg.tool_calls?.length) {
      return END;
    }
  }

  // Otherwise route to the active agent
  const active = state.activeAgent ?? "sales_agent";
  return active as "sales_agent" | "support_agent";
};

const routeInitial: ConditionalEdgeRouter<
  typeof MultiAgentState.State,
  "sales_agent" | "support_agent"
> = (state) => {
  // Route to the active agent based on state, default to sales agent
  return (state.activeAgent ?? "sales_agent") as
    | "sales_agent"
    | "support_agent";
};

// 6. Build the graph
const builder = new StateGraph(MultiAgentState)
  .addNode("sales_agent", callSalesAgent)
  .addNode("support_agent", callSupportAgent);
  // Start with conditional routing based on initial activeAgent
  .addConditionalEdges(START, routeInitial, [
    "sales_agent",
    "support_agent",
  ])
  // After each agent, check if we should end or route to another agent
  .addConditionalEdges("sales_agent", routeAfterAgent, [
    "sales_agent",
    "support_agent",
    END,
  ]);
  builder.addConditionalEdges("support_agent", routeAfterAgent, [
    "sales_agent",
    "support_agent",
    END,
  ]);

const graph = builder.compile();
const result = await graph.invoke({
  messages: [
    {
      role: "user",
      content: "Hi, I'm having trouble with my account login. Can you help?",
    },
  ],
});

for (const msg of result.messages) {
  console.log(msg.content);
}
大多数 handoffs 用例都应使用带中间件的单代理,因为它更简单。只有在需要定制代理实现时,才使用多个代理子图,例如节点本身就是一个包含反思或检索步骤的复杂图。

上下文工程

使用子图 handoffs 时,你可以精确控制哪些消息在代理之间流动。这种精确性对于维护有效对话历史、避免上下文膨胀并让下游代理困惑至关重要。如需了解更多,请参阅 context engineering 在 handoffs 期间处理上下文 在代理之间 handoff 时,需要确保对话历史保持有效。LLM 期望工具调用与其响应成对出现,因此使用 Command.PARENT handoff 到另一个代理时,必须同时包含:
  1. 包含工具调用的 AIMessage(触发 handoff 的消息)
  2. 确认 handoff 的 ToolMessage(对该工具调用的人为响应)
如果没有这组配对,接收代理会看到不完整的对话,并可能产生错误或意外行为。 下面的示例假设只调用了 handoff 工具(没有并行工具调用):
const transferToSales = tool(
  async (_, runtime: ToolRuntime<typeof MultiAgentState.State>) => {
    // Get the AI message that triggered this handoff
    const lastAiMessage = runtime.state.messages.at(-1);

    // Create an artificial tool response to complete the pair
    const transferMessage = new ToolMessage({
      content: "Transferred to sales agent",
      tool_call_id: runtime.toolCallId,
    });

    return new Command({
      goto: "sales_agent",
      update: {
        activeAgent: "sales_agent",
        // Pass only these two messages, not the full subagent history
        messages: [lastAiMessage, transferMessage],
      },
      graph: Command.PARENT,
    });
  },
  {
    name: "transfer_to_sales",
    description: "Transfer to the sales agent.",
    schema: z.object({}),
  }
);
为什么不传递所有子代理消息? 虽然你可以在 handoff 中包含完整的子代理对话,但这通常会带来问题。接收代理可能会被无关的内部推理混淆,token 成本也会不必要地增加。只传递 handoff 配对,可以让父图的上下文聚焦在高层协调上。如果接收代理需要额外上下文,请考虑在 ToolMessage 内容中总结子代理工作,而不是传递原始消息历史。
将控制权返回给用户 将控制权返回给用户(结束代理轮次)时,请确保最终消息是 AIMessage。这会维护有效的对话历史,并向用户界面表明代理已完成工作。

实现注意事项

设计多代理系统时,请考虑:
  • 上下文过滤策略:每个代理会接收完整对话历史、过滤后的片段,还是摘要?不同代理可能会因角色不同而需要不同上下文。
  • 工具语义:明确 handoff 工具是只更新路由状态,还是也执行副作用。例如,transfer_to_sales() 是否也应创建支持工单,还是应作为单独操作?
  • Token 效率:在上下文完整性和 token 成本之间取得平衡。随着对话变长,摘要和选择性上下文传递会变得更重要。