构建一个完整的 Email Agent

从零搭建一个能自动分类邮件、查日历、安排会议、撰写回复的智能邮件助手,并加入人工审批机制。项目来源:langgraph-101


一、项目目标

构建一个邮件 Agent,能够:

  1. 自动分类邮件(忽略 / 通知 / 回复)
  2. 查询日历空闲时间
  3. 安排会议
  4. 撰写回复邮件
  5. 对不确定的邮件暂停等待人类审批

二、核心架构

sequenceDiagram participant Email as 新邮件 participant Triage as 分类节点 participant Human as 人工审批 participant Agent as 推理节点 participant Tools as 工具节点 Email->>Triage: 输入邮件 Triage->>Triage: 分类决策 alt ignore Triage->>Triage: 丢弃,结束 else notify Triage->>Human: 暂停,等人类决定 Human->>Agent: 人类说"回复" else respond Triage->>Agent: 直接进入回复流程 end loop ReAct 循环 Agent->>Tools: 调用工具(查日历/排会议/写邮件) Tools->>Agent: 返回结果 end Agent->>Agent: 调用 Done 工具,结束

三、关键概念逐步拆解

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 更可控、更可调试。

posted @ 2026-05-20 17:08  江鸟Dev  阅读(13)  评论(0)    收藏  举报