构建一个完整的 Email Agent
从零搭建一个能自动分类邮件、查日历、安排会议、撰写回复的智能邮件助手,并加入人工审批机制。项目来源:langgraph-101
一、项目目标
构建一个邮件 Agent,能够:
- 自动分类邮件(忽略 / 通知 / 回复)
- 查询日历空闲时间
- 安排会议
- 撰写回复邮件
- 对不确定的邮件暂停等待人类审批
二、核心架构
三、关键概念逐步拆解
3.1 State — 共享数据结构
class State(TypedDict):
email_input: dict # 原始邮件
classification_decision: Literal["ignore", "respond", "notify"] # 分类结果
messages: Annotated[list[AnyMessage], add_messages] # 对话历史(自动追加)
loaded_memory: str # 长期记忆
remaining_steps: int # 防无限循环
思考: Annotated[..., add_messages] 是关键设计 — 每个节点只返回新消息,框架自动追加到历史列表。如果没有这个 reducer,每次节点返回都会覆盖整个历史,对话上下文就丢了。
3.2 工具定义
@tool
def write_email(to: str, subject: str, content: str) -> str:
"""撰写并发送邮件。"""
return f"Email sent to {to} with subject '{subject}'"
@tool
def schedule_meeting(attendees, subject, duration_minutes, preferred_day, start_time) -> str:
"""安排日历会议。"""
return f"Meeting scheduled..."
@tool
def check_calendar_availability(day: str) -> str:
"""查询某天的空闲时间。"""
return f"Available times on {day}: 9:00 AM, 2:00 PM, 4:00 PM"
@tool
class Done(BaseModel):
"""标记任务完成。"""
done: bool
思考: Done 继承 BaseModel 而不是用函数,因为它不需要执行任何逻辑,只是一个"退出信号"。模型调用 Done 时,条件边检测到后直接结束循环。
3.3 两种模型配置
# 配置 1:用于分类(结构化输出)
llm_router = model.with_structured_output(RouterSchema)
# 返回:RouterSchema(reasoning="...", classification="respond")
# 配置 2:用于推理+执行(工具调用)
llm_with_tools = model.bind_tools(tools, tool_choice="any")
# 返回:AIMessage(tool_calls=[{name: "write_email", args: {...}}])
思考: 同一个底层模型,两种"工作模式"。with_structured_output 适合"做判断"(输出固定格式),bind_tools 适合"做事情"(调用工具执行操作)。tool_choice="any" 强制模型必须调工具,确保只能通过 Done 退出循环。
3.4 分类节点 — 结构化输出
class RouterSchema(BaseModel):
reasoning: str = Field(description="分类推理过程")
classification: Literal["ignore", "respond", "notify"] = Field(
description="邮件分类结果"
)
def triage_router(state: State):
prompt = create_triage_prompt(state)
result = llm_router.invoke(prompt) # 返回 RouterSchema 对象
return {"classification_decision": result.classification}
思考: Field(description=...) 会被转成 JSON Schema 发给模型,模型通过 description 理解每个字段该填什么。这本质上是"用代码写 prompt"。
3.5 推理节点 — 工具调用循环
def reasoning_node(state: State):
prompt = create_agent_prompt(state)
result = llm_with_tools.invoke(prompt)
return {"messages": [result]}
思考: create_agent_prompt 每次都会把 state["messages"](包含之前的工具调用和结果)追加到 prompt 末尾。这样模型才能看到"我上一步做了什么",避免重复调用同一个工具。
3.6 条件边 — 路由逻辑
def should_continue(state) -> Literal["Tools", "__end__"]:
last_message = state["messages"][-1]
if last_message.tool_calls:
for tool_call in last_message.tool_calls:
if tool_call["name"] == "Done":
return END # 任务完成
return "Tools" # 继续执行工具
思考: 这里用 for 循环是因为模型可能一次返回多个 tool_calls。但当前写法有个小问题 — 第一次迭代就 return 了,实际只检查了第一个。更严谨的写法是先遍历完再决定。
3.7 Human-in-the-Loop — 人工审批
def human_input(state: State):
email_markdown = format_email_markdown(...)
user_input = interrupt(f"这封邮件需要回复吗?(Y/n): {email_markdown}")
if str(user_input).lower() == "y":
return {"classification_decision": "respond"}
else:
return {"classification_decision": "ignore"}
思考: interrupt() 暂停图执行,把信息展示给人类。人类通过 Command(resume="y") 恢复。这个设计让 Agent 在不确定时"请示上级",而不是自作主张。
四、图的组装
builder = StateGraph(State)
# 节点
builder.add_node("triage", triage_router)
builder.add_node("human_input", human_input)
builder.add_node("email_agent", reasoning_node)
builder.add_node("tools", ToolNode(tools))
# 边
builder.add_edge(START, "triage")
builder.add_conditional_edges("triage", handle_classification, {
"human_input": "human_input",
"email_agent": "email_agent",
END: END,
})
builder.add_conditional_edges("human_input", handle_human_input, {
"email_agent": "email_agent",
END: END,
})
builder.add_conditional_edges("email_agent", should_continue, {
"Tools": "tools",
END: END,
})
builder.add_edge("tools", "email_agent") # 工具执行后回到推理节点
agent = builder.compile(checkpointer=MemorySaver(), store=InMemoryStore())
思考: add_edge("tools", "email_agent") 是 ReAct 循环的"回路"。没有它,工具执行完就断路了。整个图的设计体现了"分层决策":先粗分类,再精细执行。
五、两种构建方式对比
Notebook 展示了同一个 Agent 的两种实现:
| Part 1:手动搭图 | Part 2:create_agent() |
|
|---|---|---|
| 代码量 | 多(需要定义节点、边、路由) | 少(约 10 行) |
| 灵活性 | 高(可加分类、审批等自定义节点) | 低(固定 ReAct 模式) |
| 适用场景 | 生产级复杂流程 | 快速验证 |
思考: create_agent() 本质上就是帮你自动搭了一个最简单的 ReAct 图。当你需要加分类、审批、多 Agent 协作时,就必须退回到手动搭图。
六、踩坑记录
6.1 思考模型兼容性
DeepSeek-V4-Pro / MiMo 等思考模型在多轮工具调用时会报错:
'The reasoning_content in the thinking mode must be passed back to the API.'
解决:关闭思考模式或换非思考模型。
6.2 with_structured_output 兼容性
部分模型不严格遵循 JSON Schema,返回格式不对导致 ValidationError。
解决:在 prompt 里显式写出期望的 JSON 格式示例。
6.3 f-string 中的花括号
# ❌ 报错:Python 把 {} 当成变量插值
f'Example: {"reasoning": "...", "classification": "respond"}'
# ✅ 正确:双写花括号
f'Example: {{"reasoning": "...", "classification": "respond"}}'
七、总结
| 概念 | 作用 |
|---|---|
State(TypedDict) |
定义共享数据结构,add_messages 实现自动追加 |
with_structured_output |
让模型输出固定格式的判断结果 |
bind_tools + ToolNode |
一个负责"点菜",一个负责"做菜" |
tool_choice="any" |
强制模型必须调工具,只能通过 Done 退出 |
interrupt() |
暂停执行,等待人类审批 |
add_conditional_edges |
根据运行时状态动态路由 |
MemorySaver |
短期记忆(线程内) |
InMemoryStore |
长期记忆(跨线程) |
核心设计思想: 把复杂的邮件处理拆成"分类 → 审批 → 执行"三层,每层用不同的模型配置和节点类型,通过条件边串联。这比一个大而全的 prompt 更可控、更可调试。

从零搭建一个能自动分类邮件、查日历、安排会议、撰写回复的智能邮件助手,并加入人工审批机制。
浙公网安备 33010602011771号