跳到正文

TECH NOTE

5461 字 27 分钟 系列 10 / 22

10 实战:用 Handoffs 搭一套客服工单流转(一个 Agent + 状态机式步骤)

ON THIS PAGE / 目录22 节

一句话讲透:这套东西就像一个客服按工单流程走——人始终是同一个客服(一个 Agent),手里拿着一本《话术手册》(步骤配置表),翻到哪一页(state["current_step"]),就只说那一页的话、只用那一页的权限(提示词 + 工具集);说完在工单上盖个章(工具里 Command 写回状态),下一轮自动翻到下一页。

这个教程要解决什么#

场景很具体:一个设备报修客服。用户上来说「我手机屏幕碎了」,但流程不能乱——你得先确认在不在保修期,再判断是硬件还是软件问题,最后才能决定给方案还是转人工。

原文要建的 Agent 干四件事:先把保修信息收齐才允许往下走;把问题分类成硬件(hardware)/ 软件(software);给出解决方案或升级给人工客服;在多轮对话之间保持流程状态不丢。

一句话目标:

用一个 Agent + 一个 current_step 状态字段,实现一套有先后顺序、还能回头改的客服工单流程——而不是拉三个 Agent 互相调用。

这套模式原文叫状态机模式(state machine pattern),也归在**交接(handoff)这一类做法里。它和另一篇的子 Agent 模式(subagents pattern)**最大的区别:

Subagents(子 Agent)Handoffs / 状态机(本篇)
形态多个独立 Agent,被当成工具调用一个 Agent,配置随状态变
「换人」的本质真的换了执行者(另一个 Agent 实例)换的是同一 Agent 的提示词 + 工具集
状态放哪各子 Agent 各管各的统一放在共享 state 里
适合什么需要集中调度、任务能并行拆流程有明确阶段、要连续对话

原文对步骤(step)的定义一句话就够:每个「步骤」只是同一个底层 Agent 的另一种配置(系统提示词 + 工具),按状态动态选出来而已。

整体架构#

原文给的是一张流程图(保修 → 问题类型 → 处理)。这里换成更贴合代码的状态迁移图,因为代码里 current_step 就是三个状态在跳:

stateDiagram-v2
state "保修登记 Warranty Collector" as WC
state "问题分类 Issue Classifier" as IC
state "解决方案 Resolution Specialist" as RS
[*] --> WC : 首轮默认步骤(current_step 缺省值)
WC --> IC : record_warranty_status() 写回 current_step
IC --> RS : record_issue_type() 写回 current_step
RS --> [*] : provide_solution() / escalate_to_human()
RS --> WC : go_back_to_warranty() 纠正保修信息
RS --> IC : go_back_to_classification() 纠正问题类型
note right of RS
software → provide_solution;hardware 在保 → provide_solution;hardware 过保 → escalate_to_human
end note

这张图在说什么:① 整条流程只有三个状态,装在一个叫 current_step 的字段里,Agent 始终是同一个;② 状态不是靠「路由器节点」推的,而是工具自己写回的——record_warranty_status 调一次,current_step 就从保修登记变成问题分类;③ 最后一步是分叉点:软件给排障步骤、硬件在保给保修维修、硬件过保才转人工,这个判断由提示词 + 当时挂载的工具集共同完成;④ 还有两条回退边,用户说「我搞错了」时,Agent 可以把你送回上一站重填。

再补一张图,看清「状态变量怎么驱动行为切换」这条链路:

用户消息 ─▶ 中间件读 state["current_step"] ─▶ 注入该步骤的提示词+工具集 ─▶ 模型回答
▲ │
└──────── 工具调用写回 state(进入下一步骤)◀─────────────┘

关键点:中间件(middleware)是每轮对话的「配置注入器」,它自己不做业务判断;业务判断全在那几个返回 Command 的工具里。这条分工线划得很干净,是整篇教程最值得抄的地方。

分步拆解#

§0 环境准备与选模型#

安装一行搞定,原文还建议开 LangSmith 追踪(可观测平台,用来看 Agent 内部到底跑了什么):

Terminal window
pip install langchain
export LANGSMITH_TRACING="true"
export LANGSMITH_API_KEY="..."

选模型部分,原文用 Tabs 列了 7 个 provider,核心都是同一个 init_chat_model 入口。⚠️ 原文里的模型名(gpt-5.5、claude-sonnet-4-6、gemini-3.7-flash 等)是文档占位写法,实际用时换成你手上真实可用的模型 ID。

OpenAI#

pip install -U "langchain[openai]" → init_chat_model("gpt-5.5")

Anthropic#

pip install -U "langchain[anthropic]" → init_chat_model("claude-sonnet-4-6")

Azure#

pip install -U "langchain[openai]" → init_chat_model("azure_openai:gpt-5.5", azure_deployment=...)

Google Gemini#

pip install -U "langchain[google-genai]" → init_chat_model("google_genai:gemini-3.7-flash")

AWS Bedrock#

pip install -U "langchain[aws]" → init_chat_model("us.anthropic.claude-sonnet-4-6", model_provider="bedrock_converse")

HuggingFace#

pip install -U "langchain[huggingface]" → init_chat_model(..., model_provider="huggingface")

OpenRouter#

pip install -U langchain-openrouter → init_chat_model("auto", model_provider="openrouter")

§1 自定义 state:先定义「有哪几步」#

干什么:继承 AgentState,在共享状态(state)上加三个字段——当前步骤、保修状态、问题类型。 为什么:状态机模式的全部魔力都压在 current_step 上,它决定这一轮加载哪套提示词和工具;另外两个字段既是提示词的填充变量,也是依赖校验的依据。

from langchain.agents import AgentState
from typing_extensions import NotRequired
from typing import Literal
# 工作流里允许出现的步骤名,写成 Literal,拼错立刻报错
SupportStep = Literal["warranty_collector", "issue_classifier", "resolution_specialist"]
class SupportState(AgentState):
"""客服工作流的状态。"""
current_step: NotRequired[SupportStep] # 核心:当前处于哪一步
warranty_status: NotRequired[Literal["in_warranty", "out_of_warranty"]]
issue_type: NotRequired[Literal["hardware", "software"]]

NotRequired 是给 TypedDict 用的:第一轮进来时这些字段本来就不存在,所以不能声明成必填。

§2 用工具管理流程状态#

干什么:写几个工具(tool),让 Agent 既能「记录信息」,又能「推进步骤」。 为什么:这是本篇最核心的一招——工具不返回字符串,而是返回 Command(update={...}),一次性把业务数据和 current_step 都写回 state。工具成了流程的唯一推进器。

from langchain.tools import tool, ToolRuntime
from langchain.messages import ToolMessage
from langgraph.types import Command
@tool
def record_warranty_status(
status: Literal["in_warranty", "out_of_warranty"],
runtime: ToolRuntime[None, SupportState],
) -> Command:
"""记录客户保修状态,并流转到问题分类步骤。"""
return Command(
update={
# 必须自己补一条 ToolMessage,模型才看得到工具执行结果
"messages": [
ToolMessage(
content=f"Warranty status recorded as: {status}",
tool_call_id=runtime.tool_call_id,
)
],
"warranty_status": status,
"current_step": "issue_classifier", # 状态迁移就发生在这一行
}
)

record_issue_type 完全对称:写 issue_type,把 current_step 推到 resolution_specialist。而只干活、不改流程的工具(provide_solution、escalate_to_human)返回普通字符串就够了,比如 return f"Solution provided: {solution}"。注意区分:改状态的工具用 Command,只干活的工具用字符串。别把所有工具都写成 Command,那是过度设计。

§3 步骤配置表#

干什么:给三个步骤各配一份「提示词 + 工具集 + 前置依赖」,塞进一个字典。 为什么:一张表看全流程,加步骤就是加一条 entry,改流程不用碰中间件代码。提示词里还能写 {warranty_status} 这种占位符,运行时由中间件用 state 填充。

WARRANTY_COLLECTOR_PROMPT = """你是客服,负责处理设备问题。
当前步骤:核实保修状态(Warranty verification)
这一步你要做:先友好打招呼 → 询问设备是否在保修期内 → 调用 record_warranty_status
记录答案并进入下一步。语气自然,一次只问一个问题,不要连珠炮。"""
# 另两份提示词结构相同,只是「已知信息」和「要做什么」不同(完整版见原文)
ISSUE_CLASSIFIER_PROMPT = """……
当前步骤:问题分类(Issue classification)
已知信息:保修状态 = {warranty_status}
……(让客户描述问题 → 判断硬件/软件 → 调 record_issue_type)"""
RESOLUTION_SPECIALIST_PROMPT = """……
当前步骤:解决方案(Resolution)
已知信息:保修状态 = {warranty_status},问题类型 = {issue_type}
……(软件给排障步骤;硬件在保给保修维修;硬件过保转人工)"""

⚠️ 原文如此:折叠块里的提示词写的是 CURRENT STAGE:,而文末完整示例里写的是 CURRENT STEP:,两处不一致;正文叙述统一用「步骤 / step」。

把「步骤名 → 配置」映射成字典,这就是状态机的转移表:

# 步骤配置:步骤名 → (提示词, 工具集, 前置必需字段)
STEP_CONFIG = {
"warranty_collector": {
"prompt": WARRANTY_COLLECTOR_PROMPT,
"tools": [record_warranty_status],
"requires": [],
},
"issue_classifier": {
"prompt": ISSUE_CLASSIFIER_PROMPT,
"tools": [record_issue_type],
"requires": ["warranty_status"], # 没拿到保修状态不许进来
},
"resolution_specialist": {
"prompt": RESOLUTION_SPECIALIST_PROMPT,
"tools": [provide_solution, escalate_to_human],
"requires": ["warranty_status", "issue_type"],
},
}

这张表的好处原文列了四条:一眼看全流程、加步骤只加一条、requires 把依赖关系显式化、提示词能用 state 变量做模板。多补一条:tools 字段同时是一道权限墙——保修登记那一步的 Agent,物理上就调不到 provide_solution。

§4 步骤式中间件:把配置注入进去#

干什么:写一个 @wrap_model_call 装饰的中间件,每次模型调用前读 current_step,把对应的提示词和工具覆盖进去。 为什么:不这么做,就得为每一步创建一个 Agent 实例;用了中间件,一个实例走完全程。

from langchain.agents.middleware import wrap_model_call, ModelRequest, ModelResponse
from typing import Callable
@wrap_model_call
def apply_step_config(
request: ModelRequest,
handler: Callable[[ModelRequest], ModelResponse],
) -> ModelResponse:
"""按当前步骤配置 Agent 行为。"""
# 取当前步骤;首轮没有该字段,默认从保修登记开始
current_step = request.state.get("current_step", "warranty_collector")
step_config = STEP_CONFIG[current_step] # 查表
# 前置依赖校验:该有的数据没有就直接报错,别让模型瞎猜
for key in step_config["requires"]:
if request.state.get(key) is None:
raise ValueError(f"{key} must be set before reaching {current_step}")
# 用 state 里的值渲染提示词模板
system_prompt = step_config["prompt"].format(**request.state)
# 覆盖系统提示词与可用工具集:不新建 Agent,只改这一次调用的配置
request = request.override(
system_prompt=system_prompt,
tools=step_config["tools"],
)
return handler(request)

中间件五步走:读步骤 → 查配置 → 校验依赖 → 渲染提示词 → 覆盖配置。其中 request.override() 是钥匙——它让你不新建 Agent 实例就能改行为。

§5 创建 Agent#

干什么:把 state schema、中间件、检查点(checkpointer)组装起来。 为什么:中间件负责「切配置」,检查点负责「把状态跨轮存住」——少了检查点,用户第二句话进来 current_step 就丢了,整个流程直接失忆。

from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver
# 所有步骤用到的工具汇总注册;注册 ≠ 可用,实际可见范围由中间件裁剪
all_tools = [
record_warranty_status,
record_issue_type,
provide_solution,
escalate_to_human,
]
agent = create_agent(
model,
tools=all_tools,
state_schema=SupportState, # 用自定义 state
middleware=[apply_step_config],
checkpointer=InMemorySaver(), # 跨轮次保持状态
)

为什么需要 checkpointer? 检查点(checkpoint)负责把 state 跨对话轮次保住。没有它,current_step 会在两条用户消息之间丢失,工作流直接断掉。生产环境换成 Postgres / Redis 的持久化 checkpointer 即可,接口不变。

§6 测试整条流程#

干什么:用同一个 thread_id 连发四条消息,模拟真实的多轮报修。 为什么:状态机的正确性只有跨轮才看得出来,单轮调用测不出来。

from langchain.messages import HumanMessage
from langchain_core.utils.uuid import uuid7
thread_id = str(uuid7())
config = {"configurable": {"thread_id": thread_id}} # 同一个 thread 才算同一通对话
# 第 1 轮:客户只说症状,流程还没到分类那一步
result = agent.invoke({"messages": [HumanMessage("Hi, my phone screen is cracked")]}, config)
# 第 2 轮:回答保修问题 → record_warranty_status 写回 current_step = issue_classifier
result = agent.invoke({"messages": [HumanMessage("Yes, it's still under warranty")]}, config)
print(f"Current step: {result.get('current_step')}")
# 第 3 轮:描述问题 → record_issue_type 写回 current_step = resolution_specialist
# ...(原文还有第 4 轮:用户问 What should I do?,Agent 给出保修维修方案)
for msg in result["messages"]:
msg.pretty_print()

预期走向:问保修 → 问症状并判定为硬件 → 给出保修维修指引。原文提醒:result['messages'] 是全量历史,别以为只有本轮消息。

§7 状态迁移到底怎么发生#

把三轮对话里 state 和中间件的对应关系摊开看——这段是理解整个模式的钥匙:

轮次进入该轮时的 state中间件注入的配置本轮的工具调用轮次结束后的 state
Turn 1current_step 缺省保修提示词 + [record_warranty_status]无(只打招呼、问保修)仍是 warranty_collector
Turn 2warranty_collector保修提示词 + [record_warranty_status]record_warranty_status("in_warranty")current_step=issue_classifier
Turn 3issue_classifier分类提示词(已填入保修状态)+ [record_issue_type]record_issue_type("hardware")current_step=resolution_specialist
Turn 4resolution_specialist解决方案提示词(填入两个变量)+ [provide_solution, escalate_to_human]provide_solution(...)不变(已到终点)

工具写回 state 长这样,就是一次普通的 Command 更新:

# 第 2 轮:record_warranty_status 的产物
Command(update={
"warranty_status": "in_warranty",
"current_step": "issue_classifier", # 状态迁移!
})
# 第 3 轮:record_issue_type 的产物
Command(update={
"issue_type": "hardware",
"current_step": "resolution_specialist", # 状态迁移!
})

原文的结论:工具通过更新 current_step 驱动流程,中间件在下一轮响应并套用对应配置。

§8 消息历史管理#

干什么:再挂一个摘要中间件,历史太长时压缩早期消息。 为什么:流程一步步往前推,消息只增不减;再叠上系统提示词和工具定义,很快撑爆上下文(context)窗口,成本也跟着涨。

from langchain.agents.middleware import SummarizationMiddleware
# 在 §5 的 create_agent 上,middleware 里再加一个摘要中间件
agent = create_agent(
model, tools=all_tools, state_schema=SupportState,
middleware=[
apply_step_config, # 先切步骤配置
SummarizationMiddleware( # 再做历史压缩:用便宜的小模型摘要
model="gpt-5.4-mini",
trigger=("tokens", 4000), # 超过 4000 token 才触发
keep=("messages", 10), # 最近 10 条原样保留
),
],
checkpointer=InMemorySaver(),
)

顺序有讲究:apply_step_config 放前面,保证每次模型调用拿到的都是当前步骤的配置;摘要中间件跟在后面做「瘦身」。

§9 加灵活性:退回上一步#

干什么:让用户能改口——「我搞错了,其实过保了」。 为什么:真实业务一定有人填错。但原文提醒:不是所有迁移都能回退,比如退款已打出去就没法回滚。这套模式适合「主链路顺序推进 + 偶尔回退纠错」,不适合到处乱跳。

@tool
def go_back_to_warranty() -> Command:
"""回到保修核实步骤。"""
return Command(update={"current_step": "warranty_collector"})
@tool
def go_back_to_classification() -> Command:
"""回到问题分类步骤。"""
return Command(update={"current_step": "issue_classifier"})
# 把这两个工具挂到解决方案这一步上
STEP_CONFIG["resolution_specialist"]["tools"].extend([
go_back_to_warranty,
go_back_to_classification,
])

工具有了还不够,提示词里得说清什么时候用(原文在解决方案提示词里追加了这几句):

如果客户表示之前提供的信息有误,使用:
- go_back_to_warranty 纠正保修状态
- go_back_to_classification 纠正问题类型

之后这句用户输入就能触发回退,Agent 会调 go_back_to_warranty 重新走保修核实:

result = agent.invoke(
{"messages": [HumanMessage("Actually, I made a mistake - my device is out of warranty")]},
config,
) # 触发回退

💡 提示:如果你的工作流需要在大多数步骤之间任意跳转,那就该先问自己——是不是根本不需要「结构化工作流」。这个模式的适用面是:步骤有清晰的先后顺序,只是偶尔为了纠错往回退一格。

关键机制专讲#

① 为什么是「一个 Agent + 状态」,而不是多个 Agent#

Subagents 模式是真的换执行者:每个子 Agent 有自己的提示词、工具、上下文和消息历史,主 Agent 把它们当工具调。代价是状态被打散在多处,跨子 Agent 的信息传递要靠主 Agent 转述。 状态机模式反过来:执行者只有一个,换的是它的配置。好处很直接——消息历史天然是一条连续的线,客服对话里「他刚才说过什么」不会丢;流程状态放在一个 state 里,可视、可校验、可持久化;中间件是唯一的切换点,加日志、加埋点、加权限校验都只改一处。

代价也要说清:同一时刻只有一个「人格」在跑,没法并行处理两条支线,也做不了「一边查物流一边查退款」。选型因此很简单——流程是一条线、有阶段,用本篇;任务是能拆开并行的,去看 Subagents。

② 步骤配置表怎么设计#

STEP_CONFIG 那三个 key 就是设计的三条线:

  • prompt(这一步说什么):用第二人称讲清「当前步骤 + 已知信息 + 这一轮要做什么」。原文三份提示词结构完全一致,都是「当前步骤 / 客户已知信息 / 这一步你要做」三段式,非常值得抄。已知信息用 {warranty_status} 占位符注入,模型就不必从历史里翻。
  • tools(这一步能用什么):这是最小权限的落地方式。分类步骤只挂 record_issue_type,它连 escalate_to_human(人工介入 human-in-the-loop 的入口)都看不见,也就不会在第 2 轮就急着把客户转人工。工具集窄,模型跑偏的空间就小。
  • requires(进来之前必须有什么):把流程的前置条件从注释变成代码。缺字段直接 raise ValueError,属于 fail-fast——早报错好过让模型拿着 None 编一个保修状态出来。

一个容易踩的坑:create_agent(tools=all_tools) 先注册全量工具,中间件再按步骤收窄。注册 ≠ 可用,真正决定模型能看见哪些工具的,是 request.override(tools=...)。

③ 状态迁移在真实对话里怎么发生#

把原文的 Turn 1/2/3 串起来看:

  • Turn 1:用户只说「我手机屏碎了」。此时 state 里连 current_step 都没有,中间件走默认值 warranty_collector,加载保修提示词和 record_warranty_status。Agent 该做的就是打个招呼、问在不在保修期——哪怕用户已经把症状说了,也不许提前分类。
  • Turn 2:用户回「还在保修期」。这一轮开头用的还是保修那套配置(因为状态还没变),模型顺着提示词调 record_warranty_status("in_warranty"),工具返回的 Command 把 current_step 改成 issue_classifier。迁移发生在这一轮的工具调用里。
  • Turn 3:新一轮进来,中间件读到 current_step="issue_classifier",换成分类提示词(此时 {warranty_status} 有值可填了),工具换成 [record_issue_type]。用户说「是摔裂的」,模型判定 hardware 并写回,current_step 推到 resolution_specialist。
  • Turn 4:用户问「那我该怎么办」。这一轮挂的是 [provide_solution, escalate_to_human] 加填好两个变量的解决方案提示词,于是输出「在保 → 保修维修流程」,而不是转人工。

原文讲的是「下一轮生效」。补一个实现细节:@wrap_model_call 包的是每一次模型调用,不是每一轮对话;而 Agent 内部是个 ReAct 循环——所以同一轮里工具写回状态之后,紧接着的那次模型调用就已经能拿到新配置了,跨轮持久化则交给 checkpointer。理解这点,才明白提示词里为什么值得写「先调用工具,再回话」。

④ 消息历史为什么必须管理#

四轮对话看着不长,但每轮都在往 messages 里追加:用户消息、AI 消息、AI 的 tool_calls、ToolMessage……四轮下来十几条轻轻松松。再加上每轮都要塞进去的系统提示词和工具定义(工具 schema 同样占 token),真实请求体比看上去大得多。

用 SummarizationMiddleware 是最省事的一招:trigger=("tokens", 4000) 表示「超过 4000 token 才动手」,keep=("messages", 10) 表示「最近 10 条原样留着,更早的压成摘要」。既控住长度,又保住客服对话最需要的「刚才聊到哪了」。 注意一个交互:摘要会改写历史消息,而 current_step 这类流程状态并不在 messages 里,它躺在 state 的独立字段中——所以历史被压缩了,流程也不会失忆。把状态从对话历史里拎出来单独存,本身就提高了鲁棒性。

⑤ 「退回上一步」这类灵活性怎么实现#

实现简单到不像话——加两个工具,Command(update={"current_step": "xxx"}) 就完了。真正的功夫在两处:

  1. 提示词里要写清什么时候回退。工具存在不等于模型会用,原文特意在解决方案提示词里补了一段「如果客户表示信息有误,使用 go_back_to…」。工具的能力边界,一半靠工具集,一半靠提示词。
  2. 要有意识地划定「可回退 / 不可回退」。原文明确说不是所有迁移都合理,比如退款已处理就不能回退。工程上可以在回退工具里加校验(比如查一下工单状态),把规则从提示词里的「请勿」升级成代码里的「不许」。 顺带一提:回退只改 current_step,之前收集到的旧数据还留在 state 里(比如 issue_type 仍是 hardware)。回退后别忘了让目标步骤负责刷新它——这是这套模式最容易漏的一个坑。

完整代码在哪#

本篇只挑骨架讲,完整示例见原文 Complete example(含全部导入、三份完整提示词、STEP_CONFIG、中间件、create_agent 和四轮测试脚本,可对照运行):

📎 官方教程:Build customer support with handoffs

原文 Next steps 里的配套阅读:Handoffs / 状态机模式总览、Subagents 模式(对照组)、Middleware 中间件、短期记忆与摘要。

小结#

  1. 一个 Agent,多套配置。本质是按 current_step 动态切换系统提示词 + 工具集,切换点在 @wrap_model_call 中间件,request.override() 是关键 API。
  2. 工具驱动流程。用返回 Command(update={...}) 的工具同时写业务数据和 current_step——流程推进交给模型「决定调哪个工具」,而不是写死在代码分支里。
  3. 一张配置表定义状态机。STEP_CONFIG 把「说什么(prompt)/ 能用什么(tools)/ 需要什么(requires)」收敛到一处,加步骤等于加一条 entry。
  4. 检查点不能省。没有 checkpointer,跨轮的 current_step 会丢;再叠一个 SummarizationMiddleware 控制上下文长度,且因为流程状态存在 state 字段而非消息里,压缩历史不会丢流程。
  5. 回退要有边界。加 go_back_to_* 很容易,难的是判断哪些步骤允许回退,以及回退后旧数据由谁负责刷新。

一句话选型#

该用:流程有清晰的先后阶段、需要跨多轮连续对话、每步该说什么话和该有哪些权限能提前写清楚——比如客服工单、开户 / KYC 信息收集、下单引导、故障排查向导。这类场景用一个 Agent + 状态机,比拉一堆 Agent 互相调用简单得多。

别用:任务能拆成互不依赖的并行支线(用 Subagents 或直接并行调工具);步骤之间需要大量任意跳转(说明你其实没有一个「流程」,写个普通的状态判断函数更清楚);每一步需要的知识与工具差异极大、上下文互不干扰(那不如拆成独立 Agent,各自维护上下文)。

10 实战:用 Handoffs 搭一套客服工单流转(一个 Agent + 状态机式步骤)
https://aicooleo.cn/blog/posts/multiagent-10-case-handoffs/
作者
aicooleo
发布于
2026-08-29
许可协议
CC BY-NC-SA 4.0
多 Agent 精讲 · 第 10 / 22 篇 返回合集