将LLM从状态管理中解放出来——代码负责重试、调度、同步,LLM只做真正需要判断的决策,显著降低成本并提升工作流可调试性。
我曾经以为邮件是 AI 最糟糕的落地场景。
太乱了。太人性化了。满是从 2017 年转发过来的邮件链,以及用公司没人能叫出名字的软件生成的 HTML。
后来我花了一些时间阅读关于收件箱自动化的讨论帖子,尤其是 r/openclaw 上关于邮件流程的一个好帖子,终于想通了其中的模式:
邮件其实是 AI 的好落地场景——只要你别再让模型扮演邮件服务器。
这听起来是废话。但很多收件箱自动化方案仍然在这样做:
让 GPT-5 判断是不是客服邮件 让 Claude 判断是不是销售邮件 让另一个模型判断是不是垃圾邮件 再问一次它属于哪个别名 再问一次是现在回复还是稍后回复
这不是智能。
这是昂贵的失忆症。
更好的模式很简单:
代码负责状态管理、重试、调度、同步和验证
LLM 只处理真正需要判断力的决策
这种分工让我的收件箱工作流更便宜、更容易调试、也更不容易出问题。
我一直在用的那条规则
OpenClaw 工作流讨论中的一条评论比大多数文档都说得更好:
如果你的工作流在遇到 LLM 使用限制时就停止工作了,那 LLM 很可能做了太多事情。
那是关于编码代理的评论,但它完美适用于收件箱自动化。
如果你的邮件管道依赖模型来记住邮箱状态、去重事件、处理重试,或者每次运行都重新检查路由规则,那你构建了错误的系统。
模型擅长判断。
它们不擅长做管理员。
邮件看起来很混乱,但传输层已经是结构化的
人类觉得邮件是混乱的。
但每条消息都已经带着有用的结构到达:
附件边界
这很重要,因为很多路由决策本来就不应该打到 LLM。
如果发票总是发到 ap@company.com,GPT-5 不应该每天早上都重新发现这条规则。
如果客服邮件总是落到特定别名上,代码应该做确定性路由。
如果一个线程已经被处理过了,你的 worker 应该从数据库里知道这件事,而不是从提示词里。
模型应该做的事
用 GPT-5、Claude Opus 4.6、Grok、Qwen 或 Llama 来处理真正需要推理的部分:
对模糊消息进行分类 总结长线程 从丑陋的转发链中提取意图 起草待人工审核的回复 判断附件看起来像是合同、发票还是客服产物
所有重复性的事情:
强制执行发件人和别名规则 抑制重复处理 验证线程是否已被处理过 记录决策以供审计
这种架构没有"AI 收件箱代理"那么酷。
但这也是下个月还能正常运行的架构。
Gmail 的配额数字基本上告诉了你怎么构建这个
这是改变我思考收件箱管道方式的部分。
Google 发布了 Gmail API 方法的配额成本:
history.list = 2 个配额单位 messages.list = 5 个配额单位 messages.get = 20 个配额单位 threads.get = 40 个配额单位 messages.send = 100 个配额单位
这些数字不是琐事。
它们是设计提示。
Google 在告诉你这样做:
只获取变化的部分 应用确定性过滤器 只在边缘情况下调用 LLM 每分钟轮询一次收件箱 获取所有未读邮件 把整个线程丢给 Claude
如果你的工作流每分钟醒来一次,就让一个前沿模型检查所有未读邮件,那你不是在构建自动化。
你是在构建一张重复出现的账单。
一个合理的 Gmail 管道
对于 Gmail,模式很简单:
用 users.watch 订阅收件箱变化 通过 Cloud Pub/Sub 接收事件 用 history.list 获取变化的消息 ID 只获取你真正需要的消息 先运行确定性规则 将模糊消息升级给 GPT-5 或 Claude
启动邮箱监控
POST https://www.googleapis.com/gmail/v1/users/me/watch
Content-Type: application/json
{
"topicName": "projects/myproject/topics/mytopic",
"labelIds": ["INBOX"],
"labelFilterBehavior": "INCLUDE"
}
变更处理的最小 Node 示例
import { google } from "googleapis";
const gmail = google.gmail({ version: "v1", auth });
async function processMailboxChange(startHistoryId: string) {
const history = await gmail.users.history.list({
userId: "me",
startHistoryId,
historyTypes: ["messageAdded"]
});
const messageIds = new Set<string>();
for (const item of history.data.history ?? []) {
for (const added of item.messagesAdded ?? []) {
if (added.message?.id) messageIds.add(added.message.id);
}
}
for (const id of messageIds) {
const msg = await gmail.users.messages.get({
userId: "me",
id,
format: "metadata",
metadataHeaders: ["From", "To", "Subject", "Reply-To"]
});
const headers = Object.fromEntries(
(msg.data.payload?.headers ?? []).map(h => [h.name!, h.value!])
);
const subject = headers.Subject || "";
const to = headers.To || "";
const from = headers.From || "";
if (to.includes("ap@company.com")) {
await routeToAccountsPayable(id);
continue;
}
if (from.endsWith("@trustedvendor.com") && subject.includes("Invoice")) {
await routeToAccountsPayable(id);
continue;
}
await sendToLLMForClassification({ id, subject, from, headers });
}
}
重要的不是代码风格。
重要的是操作顺序:
先做廉价的邮箱同步 再做确定性路由
Outlook 和 Microsoft 365 用不同的名字做同样的事
Microsoft Graph 有相同的架构,只是用增量查询代替了 Gmail 的 history。
GET https://graph.microsoft.com/v1.0/me/mailFolders/{id}/messages/delta
@odata.nextLink 用于分页
@odata.deltaLink 用于下一个同步周期
那个 token 就是你的记忆。
你的 worker 应该拥有它。
Node 中的示例结构
async function syncOutlookFolder(deltaUrl?: string) {
const url = deltaUrl ?? "https://graph.microsoft.com/v1.0/me/mailFolders/inbox/messages/delta";
const res = await fetch(url, {
headers: { Authorization: `Bearer ${token}` }
});
const data = await res.json();
for (const msg of data.value ?? []) {
if (msg.toRecipients?.some((r: any) => r.emailAddress?.address === "support@company.com")) {
await routeToSupport(msg);
continue;
}
await classifyIfNeeded(msg);
}
if (data["@odata.nextLink"]) {
return syncOutlookFolder(data["@odata.nextLink"]);
}
if (data["@odata.deltaLink"]) {
await saveDeltaLink(data["@odata.deltaLink"]);
}
}
Cloudflare Email Workers 让这个边界变得非常清晰
这是我最喜欢的例子,因为分离做得非常干净。
import PostalMime from "postal-mime";
export default {
async email(message, env, ctx): Promise<void> {
const subject = message.headers.get("subject") || "";
const from = message.from || "";
const to = message.to || "";
if (to === "ap@example.com") {
await message.forward("finance@example.com");
return;
}
if (subject.includes("Invoice") && from.endsWith("@vendor.com")) {
await message.forward("finance@example.com");
return;
}
const parsed = await PostalMime.parse(message.raw);
const llmResult = await classifyEmail({
subject,
from,
to,
text: parsed.text,
html: parsed.html
});
if (llmResult.label === "support") {
await message.forward("support@example.com");
}
}
};
那个第一个 if 语句做的事比很多"自主代理"演示都有用得多。
工具:各适其适
这些都不是营销意义上的"AI 原生"。
但这恰恰是它们有用的原因。
丑陋的部分是真实存在的
公平地说,邮件在实践中并不干净。
你仍然需要处理:
巨大的转发链 附件包含真正有效载荷 收据、合同和法律线程会破坏天真的解析器
这就是为什么纯规则是不够的。
但这也是为什么纯 LLM 管道是错误的。
正确的模式是硬边界加软 fallback:
确定性解析 headers 和 MIME 用规则路由明显的案例 在模型外部持久化状态 将模糊内容升级给 GPT-5、Claude、Grok、Qwen 或 Llama 对于高风险操作(如发送最终回复)保留人工审核
这种混合设置没有"完全自主的收件箱代理"那么酷。
但这也是成年人构建生产自动化方式。
当你按 token 付费时,这变得更加重要
这是经济账变得烦人的地方。
如果你的工作流反复把相同类型的路由决策发送给模型,你就是在反复为模型重新发现你自己的业务逻辑付费。
这是一个伪装成 AI 问题的架构问题。
对于在 n8n、Make、Zapier、OpenClaw 或自定义 Node workers 中全天运行代理的团队来说,按 token 计费让这个问题迅速恶化。
你最终是在盯着使用量仪表盘而不是发布产品。
这正是我喜欢确定性编排优先、LLM 调用第二的模型的原因,也是为什么对于自动化密集型工作负载来说,包月 API 访问如此有吸引力。
使用 Standard Compute,你可以保留工作流已经期望的 OpenAI 兼容 API 形状,但不再把每次分类、重试和长线程都当作计费事件。它是现有 SDK 和 HTTP 客户端的直接替代品,具有跨 GPT-5.4、Claude Opus 4.6 和 Grok 4.20 的动态路由能力,价格可预测、按月计费。
当你的自动化全天候运行,整个目的是不再需要看守收件箱和 token 计量器时,这一点就非常重要了。
我现在实用的规则
如果我从零开始构建收件箱自动化,这是我信任的技术栈:
Gmail API 或 Microsoft Graph 做邮箱同步 Cloudflare Email Workers 或 Node worker 做确定性处理 PostalMime 做解析 需要编排时用 OpenClaw、n8n、Make 或 Zapier 只有当邮件提出真正的问题时才用 GPT-5 或 Claude
这就是整个转变。
别再让模型成为循环。
让模型回答问题。
之后一切都变得更便宜了。
一切都变得更好了。