TECH NOTE
5817 字 29 分钟 系列 9 / 2209 实战:用 Subagents 搭一个个人助理(主管 + 日历/邮件子智能体)
ON THIS PAGE / 目录19 节
一句话讲透:主管 Agent 就像公司前台——他自己不会排日程、也不会写邮件,手里只有两张”服务台工单”(排日程、发邮件);用户说一句人话,他判断这单该转给哪个专家,专家办完事只回一句”办好了”,前台再把两句话拼成一句答复你。
这个教程要解决什么
场景:做一个个人助理,既要管日历(查空闲、建会议),又要管邮件(写正文、发通知)。
为什么不能只用一个 Agent:如果给它直接挂上全部底层工具,会同时踩三个坑——工具太像导致选错;每个 API 的格式要求(ISO 时间、邮箱地址)全塞进一段 prompt 会变成一锅粥;想单独优化”邮件措辞”却要回归整个 Agent。
一句话目标:按领域把工具切开做成两个专职子智能体(subagent),让一个主管(supervisor)Agent 只面对两个”高层工具”做调度——主管做的是领域级路由,不是 API 级选参。
读完你能得到:
- 一套可复用的三层架构:底层刚性 API 工具 → 中层子智能体 → 顶层主管;
- “子智能体即工具”的具体写法(含工具描述怎么写才好路由);
- 在子智能体这一层挂人工介入(human-in-the-loop)、由主管层负责暂停/恢复的完整 interrupt / resume 流程,以及信息流裁剪的两种做法和一个高频翻车点。
💡 提示:原文提到,如果你是从旧的
langgraph-supervisor包迁移过来的,官方另有一篇 before/after 迁移文档(含 interrupt 与 resume):Migrate from langgraph-supervisor。
整体架构
flowchart TD U["用户 User"] -->|"自然语言请求"| S["主管 Supervisor<br/>只看到 2 个高层工具"] S -->|"schedule_event"| W1["工具 Tool:schedule_event"] S -->|"manage_email"| W2["工具 Tool:manage_email"] W1 --> C["日历子智能体 Calendar Agent<br/>独立 prompt + 日历工具"] W2 --> E["邮件子智能体 Email Agent<br/>独立 prompt + 邮件工具"] C --> C1["create_calendar_event<br/>get_available_time_slots"] E --> E1["send_email"] C -.->|"只回传最终一条消息"| S E -.->|"只回传最终一条消息"| S C1 -.->|"interrupt 暂停待审"| H{"人工介入<br/>Human-in-the-loop"} E1 -.->|"interrupt 暂停待审"| H H -.->|"approve / edit / reject"| C1 H -.->|"approve / edit / reject"| E1 S -->|"汇总后的回答"| U这张图在说什么:
① 两次”工具”调用,含义不同。主管手里的 schedule_event / manage_email 是”部门转接单”,不是真实 API;点开它,里面才会跑一整个子智能体的循环(自己选工具、自己调、自己总结)。
② 层的边界就是信息的边界。主管看不到子智能体内部的中间推理和中间工具调用,只拿到最后一条消息;反过来,子智能体默认也只看得到主管转给它的那一个 request 字符串——两边都是隔离的。
③ 越往上越”软”,越往下越”硬”。底层 API 工具要严格格式;中层子智能体负责把人话翻译成格式;顶层主管只负责”这事归谁 + 结果怎么合并”。人工介入挂在最下面的敏感动作上,但状态与暂停能力由最顶层持有。
一次跨领域请求的时序:
用户 ──"下周二下午 2 点开会,并邮件提醒设计团队"──▶ 主管 Agent(Supervisor) │ 选择工具(本质是"选专家") ┌──────────────────────────────┴──────────────────────────────┐ ▼ ▼ schedule_event manage_email ▼ ▼ 日历子智能体(一次独立调用,无状态) 邮件子智能体(一次独立调用,无状态) │ 只回传最终确认文本 │ 只回传最终确认文本 └──────────────────────────────┬──────────────────────────────┘ ▼ 主管 Agent 汇总两段结果用户 ◀───────────────"会议已定 + 提醒邮件已发"──────────┘说明:本文只保留骨架代码,不保证逐行可跑;完整可运行脚本见原文。另外,代码里的提示词字符串为方便中文读者已译为中文——如果你要直接复制去跑,建议回原文取英文原版提示词。
分步拆解
§0 前置准备:装包、追踪、选模型(原文 Setup)
安装 pip install langchain(或 conda install langchain -c conda-forge)。想做调用追踪就配上 LangSmith:设环境变量 LANGSMITH_TRACING="true" 与 LANGSMITH_API_KEY(原文给了 shell 的 export 和 Python 的 os.environ[...] 两种写法)。下面需要一个聊天模型对象(下文统一叫 model);原文用 7 个 Tab 列举各家接入方式,套路都是”装 extra + 用 init_chat_model 或具体 Model 类”,这里压成一张表(换提供方只改这一行,其余代码不动):
| 提供方 | 安装 | 初始化 | 需要的环境变量 |
|---|---|---|---|
| OpenAI | pip install -U "langchain[openai]" | init_chat_model("gpt-5.5")(等价 ChatOpenAI(model="gpt-5.5")) | OPENAI_API_KEY |
| Anthropic | pip install -U "langchain[anthropic]" | init_chat_model("claude-sonnet-4-6")(等价 ChatAnthropic(...)) | ANTHROPIC_API_KEY |
| Azure | pip install -U "langchain[openai]" | init_chat_model("azure_openai:gpt-5.5", azure_deployment=...)(等价 AzureChatOpenAI(...)) | AZURE_OPENAI_API_KEY / AZURE_OPENAI_ENDPOINT / OPENAI_API_VERSION |
| Google Gemini | pip install -U "langchain[google-genai]" | init_chat_model("google_genai:gemini-3.7-flash")(等价 ChatGoogleGenerativeAI(...)) | GOOGLE_API_KEY |
| AWS Bedrock | pip install -U "langchain[aws]" | init_chat_model("us.anthropic.claude-sonnet-4-6", model_provider="bedrock_converse") | 按 AWS 文档配置凭证 |
| HuggingFace | pip install -U "langchain[huggingface]" | init_chat_model("microsoft/Phi-3-mini-4k-instruct", model_provider="huggingface", temperature=0.7, max_tokens=1024) | HUGGINGFACEHUB_API_TOKEN |
| OpenRouter | pip install -U "langchain-openrouter" | init_chat_model("auto", model_provider="openrouter")(等价 ChatOpenRouter(model="auto")) | OPENROUTER_API_KEY |
§1 定义底层工具(Define tools)
这一步干什么:先写最底下那层”刚性”工具——参数要求严格、没有商量余地的 API 封装。原文用桩函数(stub)代替真实的 Google Calendar / SendGrid 调用,只为把模式讲清楚。
from langchain.tools import tool
@tooldef create_calendar_event(title: str, start_time: str, # ISO:"2024-01-15T14:00:00" end_time: str, # ISO:"2024-01-15T15:00:00" attendees: list[str], # 邮箱地址列表 location: str = "") -> str: """创建日历事件。要求严格的 ISO 时间格式。""" # 桩函数:真实项目里这里调 Google Calendar API / Outlook API return f"Event created: {title} from {start_time} to {end_time} with {len(attendees)} attendees"
@tooldef send_email(to: list[str], subject: str, body: str, cc: list[str] = []) -> str: """通过邮件 API 发送邮件。要求邮箱地址格式正确。""" return f"Email sent to {', '.join(to)} - Subject: {subject}" # 桩函数:真实项目里调 SendGrid
@tooldef get_available_time_slots(attendees: list[str], date: str, # ISO 格式:"2024-01-15" duration_minutes: int) -> list[str]: """查询指定日期内这些参与者的日历空闲情况。""" return ["09:00", "14:00", "16:00"] # 桩函数:真实项目里查日历 API注意:
@tool的 docstring 就是给模型看的工具描述(description),不是普通注释——它是路由信号的一部分,要写清楚”什么时候用、输入长什么样”。
§2 创建两个子智能体(Create specialized sub-agents)
这一步干什么:每一步都造一个”只懂一个领域”的小 Agent。两个子智能体是同一套模板:一个 model + 一组本领域专属的工具 + 一段本领域专属的 system prompt。
日历子智能体
它负责把”下周二下午 2 点”这种人话翻译成精确的 ISO 时间,需要时先查空闲再建事件。
from datetime import datefrom langchain.agents import create_agent
CALENDAR_AGENT_PROMPT = ( f"今天是 {date.today().isoformat()}。 " # 关键:把今天日期注入 prompt,"下周二"才有参照系 "你是一个日历排期助理。把自然语言的排期请求(例如'下周二下午 2 点')解析成 ISO 时间格式。" "需要时用 get_available_time_slots 查询空闲;用 create_calendar_event 创建事件。" "如果没有合适的时间段,停下来,并在答复中说明该时间不可用。" "在最终答复里始终确认你安排了什么。")
calendar_agent = create_agent(model, tools=[create_calendar_event, get_available_time_slots], system_prompt=CALENDAR_AGENT_PROMPT)单独测一下它。这套 stream_events + interleave 循环后面每一节都用,version="v3" 是 LangChain v1 的流式接口:
query = "下周二下午 2 点安排一个 1 小时的团队会议"stream = calendar_agent.stream_events( {"messages": [{"role": "user", "content": query}]}, version="v3",)for kind, item in stream.interleave("messages", "tool_calls"): if kind == "messages": print(item.text, end="", flush=True) # 逐 token 输出 else: print(f"\n工具调用: {item.tool_name}({item.input})") print(f"工具结果: {item.output}")一次典型运行:先调 get_available_time_slots(attendees=[], date=..., duration_minutes=60),拿到 ["09:00", "14:00", "16:00"];再调 create_calendar_event,参数被填成 2024-06-18T14:00:00 → 2024-06-18T15:00:00;最后回一句”已安排在 6 月 18 日下午 2 点,时长 1 小时”。
⚠️ 原文如此:原文正文写”把 next Tuesday at 2pm 解析成 ISO 格式(
2024-01-16T14:00:00)“,但同一节的 trace 输出里实际是2024-06-18T14:00:00。两处对不上,这里按 trace 记录。
邮件子智能体
它只干一件事:从模糊的自然语言里推断收件人,写一封像样的邮件,然后发出去。
EMAIL_AGENT_PROMPT = ( "你是一个邮件助理。根据自然语言请求撰写专业的邮件。" "提取收件人信息,并写出合适的标题和正文。" "用 send_email 发送邮件,并在最终答复里始终确认你发出了什么。")
email_agent = create_agent(model, tools=[send_email], system_prompt=EMAIL_AGENT_PROMPT)发给它的请求可以很随意,比如”提醒设计团队评审一下新的设计稿”。跑起来你能看到:它自己从”设计团队”推断出 design-team@example.com,生成了标题 Reminder: Please Review the New Mockups 和一段正式正文,调用 send_email,最后回一句”提醒已发出”。
这一步的要点:每个子智能体的关注面很窄——自己的工具 + 自己的 prompt。窄才能专,专才好调优。
§3 把子智能体包装成工具(Wrap sub-agents as tools)
这一步干什么:这是整个模式最关键的动作——把”跑一整个子智能体”包装成一个普通 @tool 函数。主管从此看到的是 schedule_event 这种高层能力,而不是 create_calendar_event 这种底层 API。
@tooldef schedule_event(request: str) -> str: """用自然语言安排日历事件。用户想创建、修改或查询日历安排时用它;内部负责时间 解析、空闲检查和事件创建。输入:自然语言的日程请求(例如"下周二下午 2 点开会")。 """ result = calendar_agent.invoke({"messages": [{"role": "user", "content": request}]}) return result["messages"][-1].text # 只把最终答复交回主管
@tooldef manage_email(request: str) -> str: """用自然语言发送邮件。用户想发通知、提醒或任何邮件沟通时用它;内部负责提取收件 人、生成标题和撰写正文。输入:自然语言的邮件请求(例如"给他们发个会议提醒")。 """ result = email_agent.invoke({"messages": [{"role": "user", "content": request}]}) return result["messages"][-1].text两个细节,原文特意强调过:
- 工具描述决定路由质量。主管唯一能看到的”菜单”就是这两个函数的 name + docstring,所以”什么时候用我”必须写明白。
- 只回传最后一条消息。子智能体的中间推理、中间工具调用,主管一概不需要看——这也是后面”信息流裁剪”的基础。
§4 创建主管智能体(Create the supervisor agent)
这一步干什么:主管是个”很薄”的 Agent。它的工具列表里只有上一步那两个包装函数,system prompt 也只描述”你能做什么、怎么拆解、怎么协调”,完全不需要懂任何 API 格式。
SUPERVISOR_PROMPT = ( "你是一个乐于助人的个人助理。你可以安排日历事件,也可以发送邮件。" "把用户的请求拆解成合适的工具调用,并协调这些结果。" "当一个请求涉及多个动作时,按需顺序或并行地使用多个工具。")
supervisor_agent = create_agent(model, tools=[schedule_event, manage_email], # 只有两个"高层工具" system_prompt=SUPERVISOR_PROMPT)§5 跑起来:主管怎么调度(Use the supervisor)
query = ( "下周二下午 2 点和设计团队开 1 小时的会," "并发封邮件提醒他们评审新的设计稿。")
stream = supervisor_agent.stream_events( {"messages": [{"role": "user", "content": query}]}, version="v3",)# 遍历方式同 §2;这一层的 tool_calls 里出现的,就是那两个"子智能体工具"例 1:单领域请求
“明天上午 9 点安排一个团队站会” → 主管判定这是日历任务,只调 schedule_event;日历子智能体自己解析时间、建事件、回一句确认;主管把这句确认换个说法回给用户。邮件子智能体全程没被唤醒。
例 2:跨领域请求
上面那段 query 会得到这样的执行轨迹(节选):
工具调用: schedule_event(request="下周二下午 2 点和设计团队开 1 小时的会")工具调用: manage_email(request="发邮件提醒设计团队评审新的设计稿") ← 同一条回复里发出工具结果: 会议已安排在 6 月 18 日 14:00–15:00;提醒邮件已发给设计团队 ...最终答复: 会议已定在下周二 6 月 18 日 14:00–15:00;同时也已发出评审提醒邮件。主管在一条模型回复里同时发出了两个工具调用,运行时并行执行;两个子智能体各自干活,最后主管把两段结果合并成一段连贯答复。
💡 提示:想做全链路排查(每一次模型调用的完整 prompt 和 response),原文挂了对应的 LangSmith trace 链接,照着跑一遍最直观。另外原文有一条 Note:主管默认是顺序派发的——一个工具调用跑完才轮到下一个;但很多模型会在单次回复里一次性发多个工具调用,运行时就会并行执行。也可以显式配置并行派发,见
create_supervisor参考文档。
三层架构在说什么
- 底层:刚性 API 工具,格式要求死板,没有智能;
- 中层:子智能体,吃自然语言、吐自然语言确认,中间翻译成结构化 API 调用;
- 顶层:主管,面向”能力”而不是”接口”,负责路由与结果合成。
好处是三件事:每层职责单一;加新领域(比如 CRM、数据库)不影响已有领域;每层可以独立测试和迭代。
§6 加人工介入(Human-in-the-loop review)
这一步干什么:发邮件、建日程这类动作发出去就收不回,值得让人点个头。LangChain 内置了中间件(middleware)来拦截工具调用,这里要拦的正是子智能体手里的工具。两件事要同时做:
- 把
create_calendar_event和send_email配成”命中即中断(interrupt)“,允许approve/edit/reject三种决定; - 检查点(checkpoint)只加在顶层主管——因为暂停/恢复的是主管这份状态。
from langchain.agents.middleware import HumanInTheLoopMiddlewarefrom langgraph.checkpoint.memory import InMemorySaver
calendar_agent = create_agent( model, tools=[create_calendar_event, get_available_time_slots], system_prompt=CALENDAR_AGENT_PROMPT, middleware=[HumanInTheLoopMiddleware( interrupt_on={"create_calendar_event": True}, # 命中即暂停,等人工决定 description_prefix="日历事件待审批", )],)
# 邮件子智能体同理,只把拦截对象换成 send_emailemail_agent = create_agent( model, tools=[send_email], system_prompt=EMAIL_AGENT_PROMPT, middleware=[HumanInTheLoopMiddleware(interrupt_on={"send_email": True}, description_prefix="外发邮件待审批")],)# 检查点只加在主管这一层:暂停与恢复用的都是主管的状态supervisor_agent = create_agent( model, tools=[schedule_event, manage_email], system_prompt=SUPERVISOR_PROMPT, checkpointer=InMemorySaver(),)跑同一条 query,这次会被拦下来。thread_id 是检查点的键,interrupt 事件要收集起来备用:
config = {"configurable": {"thread_id": "6"}} # 检查点按 thread_id 归档
interrupts = []stream = supervisor_agent.stream_events( {"messages": [{"role": "user", "content": query}]}, config, version="v3", # 带上 thread_id,检查点才有地方落)# 遍历同 §2;这次多了"有没有被拦住"的判断if stream.interrupted: for interrupt_ in stream.interrupts: interrupts.append(interrupt_) print(f"\n已中断: {interrupt_.id}")此刻会拿到两条中断事件,分别对应”日历事件待审批""外发邮件待审批”,每条里带着子智能体已经解析好的工具参数(日历的 title / start_time / attendees,邮件的 to / subject / body)——正好拿去给人看。
接着用 Command 按中断 ID 逐条给决定。原文的演示是:日历那条直接批准,邮件那条改掉标题再放行:
from langgraph.types import Command
resume = {}for interrupt_ in interrupts: if interrupt_.id == "2b56f299be313ad8bc689eff02973f16": # 邮件那条:改标题后放行 edited_action = interrupt_.value["action_requests"][0].copy() edited_action["args"]["subject"] = "设计稿提醒" resume[interrupt_.id] = {"decisions": [{"type": "edit", "edited_action": edited_action}]} else: # 日历那条:直接批准 resume[interrupt_.id] = {"decisions": [{"type": "approve"}]}
stream = supervisor_agent.stream_events(Command(resume=resume), config, version="v3")恢复后流程继续走完:日历建好、邮件按改后的标题发出(回执里 Subject: 设计稿提醒),主管照旧汇总成一段答复。
§7 进阶:控制信息流(Control information flow)
默认行为:子智能体只拿到主管转来的那一个 request 字符串。多数场景够用,但遇到”那就按明天同一时间安排”这种带指代的请求,子智能体就会一脸懵——它看不到之前的对话。
做法一:给子智能体补上下文。用 ToolRuntime 读到主管的状态(state),把用户最初那句话捞出来,跟子请求拼在一起喂进去。
from langchain.tools import tool, ToolRuntime
@tooldef schedule_event(request: str, runtime: ToolRuntime) -> str: """用自然语言安排日历事件。""" # 从主管的 state 里取出用户最初那一句原话 original_user_message = next( m for m in runtime.state["messages"] if m.type == "human") prompt = (f"你正在协助处理下面这位用户的请求:\n\n{original_user_message.text}\n\n" f"你这次要完成的子任务:\n\n{request}") result = calendar_agent.invoke({"messages": [{"role": "user", "content": prompt}]}) return result["messages"][-1].text做法二:控制回传给主管的内容。主管不一定只需要一句人话,有时结构化结果更好用。
import json
# ...(前面几行同 §3:拿到 result = calendar_agent.invoke(...))# 方案一:只回一句确认文本(上下文最省)return result["messages"][-1].text# 方案二:回结构化数据,主管更容易做后续判断# return json.dumps({"status": "success", "event_id": "evt_123",# "summary": result["messages"][-1].text})原文在这里给了一句重要提醒:一定要在子智能体的 prompt 里强调”最终消息要包含所有关键信息”。一个常见翻车姿势是——子智能体老老实实调了工具,但最终答复里什么结果都没写,主管于是收到一个空壳,只能瞎编或者反问用户。
关键机制专讲
① “子智能体 = 被包装成工具”到底意味着什么
这不是修辞,是三个实打实的工程约束:
- 无状态。
calendar_agent.invoke(...)每次都是一个全新的、只含一条 user 消息的会话;子智能体不记得上次干过什么,除非显式配置检查点。 - 每次调用从零开始。没有跨调用的记忆,也没有”上次那个会议的 ID”。要延续上下文,得自己在包装函数里把信息拼进
prompt(见 §7 做法一)。 - 上下文隔离。主管的对话历史不会自动流进子智能体,子智能体的中间过程也不会自动流回主管。信息只能靠你手写的那个字符串进出——这既是限制,也是这个模式最大的价值:主管的上下文窗口不会被两个领域的过程细节撑爆。
② 主管如何决策”派谁”
主管的决策依据只有三样,全都在它能看到的范围内:
- 工具名:
schedule_event/manage_email——语义直白最重要; - 工具描述(docstring):写清”什么时候用我、输入长什么样”;
- system prompt 里的分工说明:“拆解请求、按需顺序或并行调用”。
换句话说,主管的”路由准确率”是提示词工程问题,不是代码问题。它做的是领域级判断(“这是日程还是邮件”),不是 API 级判断(“该填 ISO 还是时间戳”)。把这两层混在一起,正是单 Agent 方案会退化的原因。
③ 人工介入到底加在哪一层
这是最容易搞混的一点,记三句话:
- 拦截的钩子挂在子智能体上。
HumanInTheLoopMiddleware(interrupt_on={"send_email": True})配在email_agent上——要被审的是它手里的send_email,不是主管手里的manage_email。 - 暂停/恢复的能力挂在主管上。
checkpointer=InMemorySaver()只加在顶层;原文明确说这是必需的,没有检查点就无法暂停再恢复。 - 中断会冒泡到最外层。你在主管的 stream 上就能看到来自两个子智能体的 interrupt,只需按
interrupt_.id分别回决定:approve(照原样执行)、edit(改参数后执行)、reject(不执行)。
所以调用方(前端/AI 应用)只需要面对一个入口:跑主管 → 收到中断列表 → 展示给人 → 收集决定 → Command(resume=...) 继续跑。子智能体的存在对调用方是透明的。
④ 信息流怎么裁剪
核心原则:边界上只传结论,不传过程。
- 往下的方向:默认只传
request字符串(最省);需要消歧时再补一句用户原话。不要图省事把整段对话历史灌给每个子智能体——那等于把隔离白做了。 - 往上的方向:
return result["messages"][-1].text只回最终答复。中间的工具调用链、推理过程,主管不需要也不该看到。若希望主管做更精确的后续动作(比如”把刚建的事件 ID 记下来”),再改成回 JSON(§7 方案二)。 - 一个隐性成本:裁得越狠,子智能体越”失忆”。所以每个子智能体的 prompt 里都要有一句”最终答复必须包含全部关键信息”——这是在裁剪和信息完整之间唯一的安全带。
完整代码在哪
完整示例见原文 Complete working example 一节(原文放在一个默认折叠的 Expandable 代码块里,约 160 行,把 §1 定义工具 → §2 建子智能体 → §3 包装成工具 → §4 建主管 → §5 跑起来串成一个脚本)。本文只摘骨架片段,不重复粘贴。原文 Next steps 指向的下一步阅读:handoffs(智能体之间直接对话)、context engineering(精细调信息流)、multi-agent overview(横向比较各模式)。
小结
- 主管 + 子智能体 = 按领域切工具,再用一层薄薄的”路由 Agent”把它们串起来。主管看不到底层 API,子智能体看不到全局对话——隔离是设计出来的,不是自然发生的。
- 包装函数是唯一的接口。
schedule_event(request: str) -> str这种签名就是这个模式的全部魔法:进去一句人话,出来一句结论。 - 提示词就是路由表。工具名、工具描述、主管的 system prompt——主管”派谁”完全由这三样决定,调优要从这里下手。
- 人工介入挂在敏感动作上,检查点挂顶层。
HumanInTheLoopMiddleware配在子智能体,checkpointer配在主管;中断会冒泡到最外层统一处理。 - 信息流默认是断的,需要就手动接。往下补上下文用
ToolRuntime,往上裁剪只回最终消息;同时别忘了让子智能体在最终答复里”把话说全”。
一句话选型
该用:有多个界限清晰的领域(日历、邮件、CRM、数据库、检索),每个领域内部工具多或逻辑绕;你希望有一个中心点统一控制流程和对外输出;子智能体不需要直接跟用户对话。—— 这时主管模式能明显降低单 Agent 的工具选择负担,也让每个领域可以独立迭代。
别用:
- 只有两三个工具——直接一个 Agent 就够,多一层只是多一层延迟和调试成本;
- 需要子智能体直接和用户聊(比如”转人工专家”式体验)——用 handoff 交接模式,而不是把子智能体藏进工具里;
- 需要智能体之间平级协作(互相讨论、辩论、投票)——那是另一类多智能体模式,主管模式天生是星形结构,不适合平级通信。
一句话:主管模式买的是”关注点分离”和”中心化控制”,代价是多一跳模型调用和一套需要精心维护的提示词边界。