TECH NOTE
5461 字 27 分钟 系列 10 / 2210 实战:用 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 内部到底跑了什么):
pip install langchainexport 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 AgentStatefrom typing_extensions import NotRequiredfrom 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, ToolRuntimefrom langchain.messages import ToolMessagefrom langgraph.types import Command
@tooldef 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, ModelResponsefrom typing import Callable
@wrap_model_calldef 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_agentfrom 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 HumanMessagefrom 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_classifierresult = 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 1 | current_step 缺省 | 保修提示词 + [record_warranty_status] | 无(只打招呼、问保修) | 仍是 warranty_collector |
| Turn 2 | warranty_collector | 保修提示词 + [record_warranty_status] | record_warranty_status("in_warranty") | current_step=issue_classifier |
| Turn 3 | issue_classifier | 分类提示词(已填入保修状态)+ [record_issue_type] | record_issue_type("hardware") | current_step=resolution_specialist |
| Turn 4 | resolution_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 加灵活性:退回上一步
干什么:让用户能改口——「我搞错了,其实过保了」。 为什么:真实业务一定有人填错。但原文提醒:不是所有迁移都能回退,比如退款已打出去就没法回滚。这套模式适合「主链路顺序推进 + 偶尔回退纠错」,不适合到处乱跳。
@tooldef go_back_to_warranty() -> Command: """回到保修核实步骤。""" return Command(update={"current_step": "warranty_collector"})
@tooldef 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"}) 就完了。真正的功夫在两处:
- 提示词里要写清什么时候回退。工具存在不等于模型会用,原文特意在解决方案提示词里补了一段「如果客户表示信息有误,使用 go_back_to…」。工具的能力边界,一半靠工具集,一半靠提示词。
- 要有意识地划定「可回退 / 不可回退」。原文明确说不是所有迁移都合理,比如退款已处理就不能回退。工程上可以在回退工具里加校验(比如查一下工单状态),把规则从提示词里的「请勿」升级成代码里的「不许」。
顺带一提:回退只改
current_step,之前收集到的旧数据还留在 state 里(比如issue_type仍是hardware)。回退后别忘了让目标步骤负责刷新它——这是这套模式最容易漏的一个坑。
完整代码在哪
本篇只挑骨架讲,完整示例见原文 Complete example(含全部导入、三份完整提示词、STEP_CONFIG、中间件、create_agent 和四轮测试脚本,可对照运行):
原文 Next steps 里的配套阅读:Handoffs / 状态机模式总览、Subagents 模式(对照组)、Middleware 中间件、短期记忆与摘要。
小结
- 一个 Agent,多套配置。本质是按
current_step动态切换系统提示词 + 工具集,切换点在@wrap_model_call中间件,request.override()是关键 API。 - 工具驱动流程。用返回
Command(update={...})的工具同时写业务数据和current_step——流程推进交给模型「决定调哪个工具」,而不是写死在代码分支里。 - 一张配置表定义状态机。
STEP_CONFIG把「说什么(prompt)/ 能用什么(tools)/ 需要什么(requires)」收敛到一处,加步骤等于加一条 entry。 - 检查点不能省。没有 checkpointer,跨轮的
current_step会丢;再叠一个SummarizationMiddleware控制上下文长度,且因为流程状态存在 state 字段而非消息里,压缩历史不会丢流程。 - 回退要有边界。加
go_back_to_*很容易,难的是判断哪些步骤允许回退,以及回退后旧数据由谁负责刷新。
一句话选型
该用:流程有清晰的先后阶段、需要跨多轮连续对话、每步该说什么话和该有哪些权限能提前写清楚——比如客服工单、开户 / KYC 信息收集、下单引导、故障排查向导。这类场景用一个 Agent + 状态机,比拉一堆 Agent 互相调用简单得多。
别用:任务能拆成互不依赖的并行支线(用 Subagents 或直接并行调工具);步骤之间需要大量任意跳转(说明你其实没有一个「流程」,写个普通的状态判断函数更清楚);每一步需要的知识与工具差异极大、上下文互不干扰(那不如拆成独立 Agent,各自维护上下文)。