最新资讯
bloomberg.com Nvidia 加强东南亚芯片追踪以打击走私 thestack.technology 模型路由需求升温,Agent Router 推出开源替代方案 unite.ai Cashfree 为 AI Agent 的托管结账加入 WebMCP 工具 dev.to 我们在 DolphinBench 上测试记忆检索:纯关键词搜索暴露了自身回归 dev.to 别再把 Agent 写成 while 循环,把它建模为状态机 analyticsindiamag.com 小语言模型可能重塑企业 AI 部署方式 bloomberg.com Rightmove 与 Scout24 借 AI 产品应对低迷房市并上调展望 stackoverflow.blog 欢迎来到芯片时代:半导体制造、AI 数据中心与芯片设计的未来 www.youtube.com AI Agent 总犯同样的错:CentralAgent AI 将记忆重构为经验系统 pub.towardsai.net Netflix 科学家建议重采样 LLM 评判:5 次仅将误差棒缩小 13% pub.towardsai.net Jev 作为 LLM 前的静默层:构建提示感知的模型路由器 reddit.com OpenAI 称 Astra 仅满足 10% 安全任务标准
多元动态
登录 注册
大模型 dev.to · @Zero AI Developer

我搭建了一条读取客服邮件并起草回复的 AI 流水线:实际出了什么问题,以及是否划算的账

原文 我的收藏

一名独立开发者搭建客服邮件处理流水线:通过 Gmail API 拉取未读邮件,用 Claude 做结构化分类,将草稿回复推送到 Slack 供人工审核后再发送。文章介绍端到端架构、三个非显而易见的 bug,并给出运行成本与被替代工作的对比。

AI推荐理由 文章给出 Gmail→Claude API 分类→Slack 草稿→人工审核的完整客服邮件流水线,并承诺披露三个非显而易见的 bug 与成本对比,对 AI 应用落地和成本评估有实际参考;正文片段仅到架构描述,成本数据与 bug 细节未在片段中呈现。

73

正文

I run a one-person dev shop, and I keep hearing the same complaint from small business owners: answering customer support emails eats hours every day, especially when half the questions are variations of the same five things. So I built a small pipeline: Gmail → Claude API classification → Slack notification with a drafted reply → human approves or edits before anything goes out. This post covers the architecture, three bugs that weren't obvious until I hit them, and — since "AI will save you time" is a claim everyone makes and almost nobody backs with numbers — an actual breakdown of what this costs to run versus what it replaces. The pipeline, end to end Pull unread messages from Gmail via the Gmail API (messages().list with q="is:unread in:inbox") Extract the body - handling multipart/alternative, nested parts, and HTML fallback (more on why this is harder than it sounds below) Classify the message using Claude with structured output: category, priority, a fine-grained intent label, and a confidence score (0-100) Route to a human based on category and confidence - not just category (more below) Generate a draft reply in the same API call, using the same structured response Post a Slack card (Block Kit) with the classification, the draft, and who it's routed to Mark the email as read and write the result to SQLite, so re-running the pipeline never double-processes a message A human clicks Approve or edits the draft in Gmail directly - nothing sends automatically Stack: Python 3.9, google-api-python-client, google-auth-oauthlib, requests (no Slack SDK - I hit the Slack Web API directly), SQLite for state, no web framework at all - the CRM view is a plain http.server.BaseHTTPRequestHandler serving a small HTML table. Three external dependencies total. Classification: structured output, not "please return JSON" The classification call uses Claude's structured output feature - you pass a JSON Schema directly in the request, and the API guarantees the response matches it. This removes an entire category of bugs that shows up in every "prompt the model for JSON" tutorial: truncated output, extra prose before or after the object, a field that's sometimes a string and sometimes an array. I'm not parsing free text and hoping - the shape is enforced. The schema: category: one of four fixed values (Sales inquiry / Support-technical issue / Billing question / Complaint) priority: High / Medium / Low intent: a fine-grained snake_case label the model generates freely (sso_redirect_loop_outage, proration_charge_clarification) - useful for spotting patterns later even though it's not used for routing confidence: 0-100 confidence_tier: derived from confidence, not generated by the model directly (see the routing section - this distinction mattered) suggested_reply: a drafted response, generated in the same call One deliberate constraint: the model does not choose who handles the email. It's tempting to let the AI output an assignee name directly, but that means it can hallucinate a person who doesn't work there, or drift between "Sam" and "Samuel" and break your routing table. The model outputs category and confidence; a plain lookup table in code - not the model - decides the assignee. Keep anything with real-world consequences (assigning work to a specific person, sending money, deleting data) in deterministic code, and let the model only produce the judgment calls that a human reviews anyway. Bug #1: the OAuth scope trap I started with gmail.readonly + gmail.send. That's correct until you add a "mark as read" step, which needs write access — so I switched to gmail.modify (a superset of readonly). The trap: don't request both readonly and modify in the same scope list. Google silently drops the redundant one from the actual grant, and the next time your token refreshes - not immediately, which is what made this confusing - google-auth throws a Scope has changed exception with no obvious connection to what you changed. I only tracked it down by reading the google-auth-oauthlib source after the second refresh failure. If you're touching Gmail scopes: request the narrowest single scope that covers everything you need, not the union of several. Bug #2: text/plain losing to text/html for the wrong reason Gmail messages are frequently multipart/alternative - the same content exists as both plain text and HTML, and the MIME structure can nest arbitrarily (a multipart wrapping another multipart wrapping the actual parts). My first body-extraction function recursed through the tree and, at each level, fell back to HTML if plain text wasn't found at that level. The bug: if the HTML part happened to appear before the plain-text part in the tree - which depends on how the sending client built the message, not anything I control - the function returned HTML first, even though a plain text version existed one level deeper. The fix is two full passes instead of one recursive pass with a fallback mixed in: first, walk the entire tree looking only for text/plain. Only if that complete search comes up empty do you do a second full pass for text/html. The general lesson: when you have a preferred format and a fallback format in a tree structure, don't let the fallback trigger based on local absence - it has to be based on absence across the whole structure. Bug #3: routing wasn't a category → person table My assumption going in: Sales → Jordan, Support → Priya, Billing → Sam, Complaints → Alex. Simple lookup. Then one test case broke it - a billing question routed to Alex (the complaints person), not Sam. The actual rule, once I found it in the test data: anything below a confidence threshold escalates to one specific person, regardless of category. The category table only applies once the model is confident enough; below that, it doesn't matter what the AI thinks the category is - a human with broader context handles it. I'd been treating confidence as purely cosmetic (which icon to show in Slack), not as something that changes the actual routing decision. Once I separated "confidence as display" from "confidence as a routing input," the fix was a few lines - but it wouldn't have surfaced without a test case that specifically checked routing against confidence, not just against category. This is also why confidence_tier (auto / approval_needed / human_review) is computed in code from the numeric confidence score, not asked of the model directly - the boundaries are a business decision (how much error are you willing to tolerate before requiring a human?), and business decisions belong in code you can read and change without touching the prompt. Idempotency: making "run it twice" safe Because this polls Gmail rather than using a webhook, and because a real deployment means "the script might run again after a crash," double processing has to be structurally impossible, not just unlikely: Marking a message as read removes it from the is:unread query, so a re-run never sees it again through the normal path Independently, the SQLite table has gmail_message_id TEXT NOT NULL UNIQUE, with ON CONFLICT(gmail_message_id) DO UPDATE - so even if something bypasses the first guard, inserting the same message twice updates the existing row instead of creating a duplicate Two independent guards, not one - because "the email got marked read but the script crashed before writing to the database" is exactly the kind of half-completed state that a single guard doesn't protect against. Now the part that actually matters: does this save money? Here's the honest math, using numbers you can substitute your own values into. What it costs to run: The system prompt is about 3,100 characters (~840 tokens), sent on every call along with the email body. At Claude Sonnet's current pricing ($3/MTok input, $15/MTok output), a typical classification-plus-draft call - roughly 1,050 input tokens and 200 output tokens - costs approximately $0.0064 per email. Round up to $0.01 to be safe. What it replaces: Industry surveys on support ticket handling time put a straightforward inquiry (the kind this pipeline is built for - not novel or complex issues) at somewhere between 3 and 8 minutes of staff time: reading, understanding intent, checking context if needed, and writing a reply from scratch. Take the low end - 4 minutes - and a loaded hourly cost for support staff around $25/hr (US small-business range, fully loaded with overhead), and one manually-answered routine email costs roughly $1.67 in labor. The comparison: $0.01 per email in API cost versus ~$1.67 in labor for the routine cases this is built for - a difference of two orders of magnitude. The pipeline doesn't remove the human (every reply is still reviewed and approved before sending), but it collapses "read, understand, draft from scratch" down to "read a pre-classified summary and a drafted reply, then approve or edit" - which is a few seconds of review instead of minutes of composition, even before counting the API cost difference. Where this breaks down: this math only holds for the routine, high-confidence cases - which is exactly why the confidence_tier routing exists. Complex or ambiguous emails fall through to human_review and get full human handling with no shortcut, because getting a complex case wrong costs far more than the minutes saved. The system is designed to be conservative about what it treats as "routine" rather than to maximize the percentage automated - across the 8 test scenarios I validated the confidence thresholds against, that split lands around half auto-approvable and half requiring a human look, which feels like the right ratio for a first deployment rather than something to be tuned aggressively for automation percentage. What I'd change for a production deployment Prompt caching: the system prompt is fixed across every call and close to (but under) the ~1,024 token minimum cacheable length for Sonnet. Padding it slightly to clear that threshold would cut input cost further on any real volume. Webhook instead of polling: users().watch() with Cloud Pub/Sub instead of a batch run - relevant once this isn't a demo run on-demand but something meant to react within seconds of an email arriving. Slack rate limiting: chat.postMessage is limited to roughly one message per second per channel. At demo volume this doesn't matter; at real volume you need a delay between posts and a retry against Retry-After when you get a 429 - both already in place here, sized for a demo rather than tuned for a specific real load. Try it I put together a live version of this - the full flow, including the Slack card and the confidence-based routing. If your inbox has the "half of these are the same five questions" problem, this is the kind of automation work I do - happy to share the demo link and talk through it, just drop a comment.

来源报道 · 1 篇 · 以下链接均前往原始媒体

← 返回精选动态 原文

更多内容