惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Recent Announcements
Recent Announcements
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
B
Blog
T
The Blog of Author Tim Ferriss
J
Java Code Geeks
腾讯CDC
D
Docker
G
Google Developers Blog
D
DataBreaches.Net
雷峰网
雷峰网
Blog — PlanetScale
Blog — PlanetScale
S
SegmentFault 最新的问题
The Cloudflare Blog
有赞技术团队
有赞技术团队
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Stack Overflow Blog
Stack Overflow Blog
大猫的无限游戏
大猫的无限游戏
量子位
美团技术团队
aimingoo的专栏
aimingoo的专栏
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Engineering at Meta
Engineering at Meta
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More

博客园 - ffl

用AI像大一新生一样学东西 [AI翻译]来自Zig的教训 [AI翻译] 标题:Agentic AI 手册:生产级模式 [AI翻译]:我们如何让 Python 的 packaging 库提速三倍 翻译:Akin 的航天器设计法则 Navida和Groq的交易资金多么? 翻译:这并不是未来 一个完整的软件开发过程,AI在哪些地方加速? 从业务读写流程出发,理解各种分布式系统组件的意义 类型标注,太多和太少一样糟糕 【AI翻译】什么是第三方API?优势、应用场景与最佳实践 【AI翻译】分布式系统中的心跳机制 【AI翻译】Python 3.14来了,有多快? Argo CD 与 Kubernetes 资源关系简述 技术文章阅读todo-list DLM(Diffusion Language Model) vs AR(Autoregressive) 标注的原理:少而完备,监督模型训练的根本 几何平均比算术平均对极值不敏感。 对比理解:什么是AI味浓厚的文章? 重要文章:Asymmetry of verification and verifier’s law
[AI翻译]我如何用大模型写软件
ffl · 2026-03-16 · via 博客园 - ffl

URL 来源: https://www.stavros.io/posts/how-i-write-software-with-llms/

我并不在乎“编程的乐趣”

最近我又开始大量做东西,主要是因为大模型(LLMs)。我原以为自己喜欢的是编程,但后来发现我真正喜欢的是“做东西”,而编程只是实现这一点的一种方式。自从大模型在编程方面变得足够好之后,我就一直用它们来做东西,几乎停不下来。更令人兴奋的是,我们正处在另一个完全未被探索的前沿的起点上。

现在围绕大模型有很多争论,但有几个朋友问我具体是怎么使用它们的,所以我决定把自己的工作流详细写下来,希望能帮到他们(也帮到你),让你比以前更轻松、更快速、更高质量地把东西做出来。

文章末尾我还附了一次真实的(带注释的)编程会话。如果你不想看工作流细节,可以直接跳到那里。

收益

我是第一次在 Codex 5.2(感觉已经是一个世纪前的事了)发布前后,以及最近的 Opus 4.6 出来之后,惊讶地发现:现在我可以通过大模型来写软件,而且缺陷率非常低,甚至很可能比我手写代码还要低,同时又不会失去对整个系统工作原理的理解。在这之前,代码通常在两三天的编程之后很快就变得难以维护,但现在我已经连续几周在几个项目上不停地工作,代码量增长到了几万行有用的代码,而每一次改动都像第一天一样可靠。

我同样注意到的是,我的工程技能并没有变得无用,只是换了形态:我不再需要知道怎样把代码写对,而是需要更加深入地理解如何正确地设计系统架构,以及如何做出正确的选择,让东西真正可用。

在那些我对底层技术并不了解的项目上(例如移动应用),代码仍然会很快变成一团糟糕决策堆叠起来的乱麻。不过,在那些我很了解所用技术的项目里(例如后端应用,即便不一定是 Python),这种情况还没有发生,即使在代码量达到几万行 SLoC 时也一样。当然,大部分可能是因为模型在变好,但我觉得也有相当一部分原因,是因为我改进了与模型协作的方式。

我注意到的一件事是,不同人用大模型的效果差异巨大,所以我怀疑“你是怎么和它说话的”这件事会很大程度上影响结果。正因为如此,在这篇文章里,我会下到非常细的层面,甚至贴出真实的会话记录,这样你就能看到我实际是怎么开发的全部细节。

还需要提的一点是,我不知道模型未来会怎么演化,但我确实看到一个趋势:在大模型早期(GPT‑2 还好,比较受限,但从 davinci 开始),我必须检查每一行代码来确保它是正确的。到了后面几代模型,检查粒度变成了函数级别:我不需要看每行代码,但需要确认函数整体是否正确。而现在,这个粒度基本上到了“整体架构”这个层面 —— 也许再过一段时间(比如明年),连这个都不再需要。但至少在目前,你仍然需要一个具备良好编码能力的人类。

我用这种方式做了什么

最近我已经用这种方式做了不少东西,我想列出其中的一些,因为有关大模型的常见批评是:人们只拿它来写玩具脚本。这些项目从严肃的日常主力工具到艺术项目都有,但它们都是我每天在用的、真实维护中的项目:

Stavrobot

最近我做的最大的一件东西是一个专注于安全性的 OpenClaw 替代品。多年来我一直想要一个 LLM 个人助手,而这个终于满足了我这个愿望。很多人会说“但你不可能让 LLM 安全啊!”,这其实是误解 —— 安全从来都是各种权衡的结果,而我的代理试图做的是:在给定可用性水平的前提下最大化安全性。我认为它做得相当不错,我已经用了一段时间,非常喜欢这样一种状态:我可以精确地推理它能做什么、不能做什么。

它帮我管理日历,并智能地处理我的可用时间和冲突,会帮我查资料,会通过写代码扩展自己,会提醒我那些以前总会忘记的事情,还会自动处理各种杂务。助理的好处其实很难解释,因为它并没有一个“杀手级功能”,而是帮你消除一千个小小的“纸割伤”,而这些“纸割伤”对每个人都不一样。所以,当你试图向别人解释“有一个助理有什么好处”时,通常会得到“但我不需要你提到的那些东西”这样的反应,完全忽略了真正的重点:每个人需要的东西都不同,而一个有工具访问能力、能做出智能决策去解决问题的代理,对任何人都是极大的帮助。

我打算很快把这个项目写成更详细的文章,因为在设计它时遇到了一些非常有意思的挑战,而我也很喜欢自己解决这些挑战的方式。

Middle

也许我最近给东西起名字的能力不太行,但这是一个小挂坠,用来录制语音笔记,它会把笔记转写成文本,并可选地 POST 到你指定的任意 webhook。对我来说,它会把语音笔记发给我的大模型代理。随时从口袋里掏出这个小东西,按下按钮,录下一个想法或提个问题,然后知道下次查看助理消息时,答案或待办就已经在那里了,这种感觉非常棒。

这东西本身很简单,但它的实用性更多不是来自它做什么,而是来自它怎么做。它总是在那里,总是可靠,使用几乎没有摩擦。

Sleight of hand

我也打算写一篇文章讲讲这个,不过它更多是个艺术作品:这是一只挂在墙上的秒针钟表,它的“秒”走得并不均匀,但始终能在分钟级保持准确(通过网络同步时间)。它有多种模式:一种模式下,滴答间隔在 500 ms 到 1500 ms 之间随机变化,既可爱又让人抓狂。另一种模式下,它会以略快于一秒的速度走,然后随机停一秒,让毫无防备的观察者怀疑自己的理智。还有一种模式会以两倍速度一路冲到 :59,然后在那停上三十秒。最后一种就是普通的钟,因为所有那些不规则的滴答会把我自己逼疯。

Pine Town

Pine Town 是一块充满奇思妙想的、无限的多玩家草地画布,你会得到一小块自己的地来涂鸦。大多数人画的东西……挺“有争议”,但偶尔会有成年人来画点好东西。有些画真的是宝藏,一般来说在上面随便逛逛、看看大家画了什么,是很有趣的。

我用大模型做完了这些项目,却从来没真正读过大部分代码,但我仍然对每个项目的架构和内部工作原理极其熟悉。下面是我是怎么做到的:

控制“挂架”(harness)

在“挂架”这一层,我使用 OpenCode。我非常喜欢它的功能,但显然这里有很多选择,我之前用 Pi 的体验也很好。不过不管你用什么挂架,它都必须至少满足:

  • 能使用来自不同公司的多个模型。大多数一方提供的挂架(Claude Code、Codex CLI、Gemini CLI)在这点上都不合格,因为公司只希望你用自家的模型,但这点是必须的
  • 能定义可以彼此自主调用的自定义代理(agents)。

除此之外,还有一些额外的“锦上添花”,比如会话支持、工作树管理等等,是否需要取决于你的项目和技术栈,这些就见仁见智了。下面我会解释上面提到的两个要求,以及它们为什么重要。

多个模型

你可以把一个特定模型(例如 Claude Opus)当作一个人。你当然可以从干净的上下文重新开始,但这个模型大致上仍然会有同样的观点 / 优点 / 缺点,也很可能会和“它自己”达成一致。这意味着,让模型来审查它自己刚写的代码几乎没什么用,因为它大多会同意自己;但这同样意味着,如果让另一个模型来审查这段代码,质量会大幅提升 —— 本质上,你拿到了一个“第二双眼睛”的代码评审。

不同模型在这里会有不同的长处和短板。比如说(这个非常依赖今天这些具体模型的特点),我觉得 Codex 5.4 在评审时很吹毛求疵、很学院派。这并不是我在“写代码阶段”想要的特性,但在“评审阶段”这就非常棒。Opus 4.6 做出的决策和我自己会做的决策高度相关,而 Gemini 3 Flash(对,就是 Flash!)在提出别人没想到的解法方面也表现很好。

每个人对“哪个模型适合做什么工作”都会有不同的看法,而且“主力模型”也会互相轮换(例如我在去年 11 月一度把 Codex 作为主力,用了一段时间之后又换回 Opus)。要获得最好的效果,你需要的是混合使用

能互相调用的代理

我使用的工作流由多个代理组成,如果挂架不支持代理之间互相调用,你就需要在大模型之间手动来回搬运大量信息,会非常烦人。你很可能想尽量减少这种人工“搬运”,所以这个能力非常有用。

我的工作流

我的工作流由一个架构师(architect)、一个开发者(developer)和一到三个评审(reviewers)组成,具体数量取决于项目重要程度。这些代理被配置成 OpenCode 的 agents(本质上是 skill 文件,也就是带有“我希望这个代理如何行为”的说明文件)。

我之所以使用多个代理(而不是一个代理负责所有事),主要有三个原因:

  1. 这让我可以在“规划和生成详细计划”时使用一个昂贵的模型(Opus),但在“实际写代码”时使用一个更便宜的模型(Sonnet)。这能显著节省 token,相比让 Opus 全程负责来说更划算。
  2. 这让我可以用不同模型来审查代码,这确实会提高质量,因为在评审时,不同模型会发现不同的问题。
  3. 这让我可以配置具备不同能力的代理(比如某个代理只有对代码的只读权限,而另一个代理则拥有写权限)。

我并不认为在相同模型、相同能力上配两个代理有什么太大意义,那更像是一个人假装自己戴着不同的帽子。不过我也没有对此做过系统研究。

另外,我通常会手写这些 skill 文件,因为让大模型来写 skill 帮助其实不大。这有点像:你问一个人“请写一份成为优秀工程师的指南”,然后再把这份指南给他,说“按照这个来,你现在就是优秀工程师了”。显然这不会真的让那个人变得更好。所以我会尽量自己写这些指导说明。

如果你也想尝试这种方式,可以下载我的代理文件

架构师

架构师(目前是 Claude Opus 4.6)是唯一一个我直接交互的代理。它必须是非常强的模型,通常是我能用到的最强模型。这个步骤消耗的 token 并不多,因为大多是聊天,但你需要这个阶段有非常扎实的推理。

我会告诉大模型我的主要目标(会是一个非常具体的功能或 bug 修复,比如“我想给 Stavrobot 加上带指数退避的重试功能,以便在 LLM 提供商挂掉时自动重试”),然后和它来回沟通,直到我确信它真正理解了我要什么。这个步骤最花时间,有时要聊上半小时,直到我们把这个方案的所有目标、限制和取舍都讨论清楚,并对最终架构达成一致。最后会形成一个相当“贴地”、细到文件和函数级别的计划。比如任务会是:“我要在这个文件中这两个组件的这三条调用 LLM 提供商的路径上加上指数退避,因为没有其他组件会直接访问 LLM 提供商”。

我知道有些人在这个步骤更喜欢让大模型把计划写进一个文件里,然后他们在那个文件上加反馈,而不是直接和大模型聊天。两种方式我都能理解,感觉都能很好工作,所以你可以按自己习惯来做评审。就我个人而言,我更喜欢跟大模型聊天。

需要明确的是,在这个步骤里我并不是只是在提问,而是在大模型的帮助下共同塑造这个计划。我仍然需要频繁纠正大模型,要么是因为它错了,要么是因为它做事的方式和我不一样。这是我贡献的很大一部分,也是让我感到快乐的部分。这种“主导方向”的过程,是让我能把这些项目称为“我的项目”的原因,因为即便另一个人用了同一个大模型,他做出来的东西也会和我的不一样。

当我满意地觉得我们已经把所有细节都抹平(大模型在这方面其实很有帮助,它会对自己不知道的东西发问,并给我不同选择)之后,我就会批准这个计划。我要求架构师在我明确说出“approved(批准)”之前不要开始任何执行,因为有些模型太过“积极主动”,会在它自己觉得弄明白的时候就去开始实现,而我则想确保也确信它理解了。

然后,架构师会把工作拆分成任务,把每个任务写成一个计划文件,通常比我们的聊天记录更详细(粒度更低),再调用开发者开始干活。这样开发者就有了非常具体的执行方向,它在高层架构上的自由度会被降到最低,因为那些高层决策已经全部做完了。

开发者

开发者可以是一个弱一点、但 token 更省的模型(我用的是 Sonnet 4.6)。计划里不应该给它太多“自由发挥”的空间,它的工作就是严格按照计划实现改动。完成之后,它会调用评审们来审查它的工作。

评审

每个评审都会独立地查看计划和刚刚实现的 diff,并进行批评性审查。在这个步骤里,我一定会用 Codex,有时会再加上 Gemini,而在重要项目里我还会额外加上 Opus。

评审给出的反馈会回到开发者那里,如果评审意见一致,开发者就把反馈整合进去;如果评审意见不一致,开发者就会把争议升级交给架构师处理。我发现 Opus 在选择“该采纳哪些反馈”这件事上做得很好,有时会刻意忽略一些反馈,因为那些反馈“太吹毛求疵”(即:实现起来很麻烦,但在实践中不太可能真正成为问题)。当然,当我说“非常好”这种主观词的时候,本质上是“我和它的判断高度一致”。

整体思路

以这种方式工作,意味着我仍然知道函数级以上的每一个选择是怎么做出来的,并且可以在后续的迭代中利用这些知识。我经常会注意到大模型建议一些在别的代码库里可能很好的东西,但在我的代码里要么行不通,要么不够优雅,这也表明大模型在“学习项目代码”时会有一些盲区。此时我会说“不要那样做,你应该用 Y”,这时大模型就会意识到“原来代码里已经有 Y 了,而且比它推荐的方式更好”。

失败模式

反过来说,当我对某项技术不够熟悉,没办法在架构层面牢牢“压住场子”的时候,我往往就抓不住大模型做出的糟糕决策。这会导致大模型在这些糟糕决策的基础上继续堆东西,最终进入一个乱成一团、自己也没法解开的状态。你会知道这种情况发生了:当你不断告诉大模型“代码还是不工作”,它会说“我知道为什么!我来修”,然后继续把东西越修越坏。

这是一个真实的失败模式,我已经被它坑太多次了,所以最后才演化出了现在这套工作流。正因如此,我会尽可能在“规划阶段”努力去理解事情,即便我没那么熟悉那项具体技术。如果我能在这个阶段把大模型带好,后面就能省掉非常多的麻烦。

一次真实会话

下面是一次真实会话的带注释记录,展示的是我给 Stavrobot 增加邮件支持的过程。我删掉了工具调用和过于啰嗦的部分,但对话和决策过程完全真实。

一开始我会先给大模型一个很高层的目标描述。有时如果我已经大致想好实现方式,我会提前提供更多细节。

我:我想给这个机器人加上邮件支持。我们来想一想要怎么做这件事。

机器人会去读代码,找到所有相关部分,然后提出一些问题。

LLM:整体情况已经掌握。下面是我看到的内容以及需要解决的问题:

## 当前模式

所有渠道都遵循同一个流程:入站 webhook / 监听器 → enqueueMessage(message, source, sender, attachments) → LLM 处理 → LLM 调用 send_*_message 工具回复。渠道有两类:进程内的(Telegram webhook,WhatsApp 通过 Baileys),以及单独容器的(Signal 桥)。Email 会是一个新的渠道,遵循同样的模式。

## 关键设计问题

1. 入站 —— 邮件是如何到达的?
   - IMAP 轮询(最简单 —— 定时轮询某个邮箱,适用于任何提供商)
   - 来自事务邮件服务(SendGrid/Mailgun inbound parse)的 webhook(需要此类服务)
   - 自建 SMTP 服务器(最复杂,需要 DNS/MX 配置)

2. 出站 —— 我们如何发送?
   - SMTP 客户端(适用于任何提供商 —— Gmail、Fastmail、自建)
   - 事务邮件 API(SendGrid/Mailgun API)

3. 你的实际使用场景是什么?这会影响不少决定:
   - 全双工对话(回复邮件、维护线程)?
   - 只做单向通知?
   - 处理入站邮件(例如转发的邮件、摘要邮件)?

4. 架构 —— 单独容器还是进程内?
   Signal 使用桥接容器是因为 signal-cli 是个很重的 Java 进程。Email 类库很轻,所以像 Telegram 一样走进程内是可行的。而且 AGENTS.md 里那个“不要往应用容器里加运行时”的规则在这里不适用,因为这里只是 TypeScript/Node。

5. Email 特有的问题:
   - 如何处理 HTML 邮件?直接剥离为纯文本?还是转换成 markdown 后再传给 LLM?
   - 线程跟踪 —— 是通过 In-Reply-To / References 头来匹配会话,还是把每封邮件单独处理?
   - 附件 —— 是否需要?

你现在预想的场景是什么?这能很快帮助我们收窄选择范围。

在这个会话里,我一开始只有一个模糊的想法,并没有把细节想透。大模型通过提具体问题来帮忙,而我则做出选择,决定实现方式:

我:
1. 邮件会通过 webhook 到达,是的。我会创建一个 Cloudflare Email worker,把整个邮件 body 转发到 webhook。它会上传一个 JSON 版的消息:

   {"from":"me@mydomain.com", 之类}

2. 出站通过 SMTP 客户端。

3. 需要全双工对话,是的。它会和其他渠道一样:如果有人给机器人发邮件,但没有被配置成 interlocutor,就会被忽略。Owner 也可以发送邮件或转发邮件让机器人处理。

4. 进程内,因为这是 webhook。

5. 需要 Markdown 转换。先把每封邮件当作独立的,之后如有必要再改。附件需要支持,行为应该和其它平台一致,代理会把它们作为文件收到。

   我想要的场景包括:既能通过子代理像现在的 Signal/Telegram/Whatsapp 那样替我和别人邮件往来,也能让 owner 把邮件(例如预定确认邮件)转发给机器人,让它处理并摄取信息。这样是否合理?你能预见哪些边界情况?有什么是你担心的吗?

大模型会根据这些信息来细化计划:

LLM:这很合理,而且可以很好地套进现有的渠道模式。下面是我的观察和看到的边界情况:

## 担忧与边界情况

1. MIME 解析并不简单。纯文本邮件很简单,但真实世界里的邮件通常是多部分 MIME(HTML+text alternative、内嵌图片、附件、嵌套 multipart、各种字符编码)。我们应该在服务端使用 mailparser(Nodemailer 项目的一部分)来解析原始 RFC 2822 邮件,而不是试图在 worker 里搞这一套。这意味着 worker 发来的内容可以尽量简单 —— 只需要 from、to 和 raw。

2. Webhook 认证。这个端点必须是公开的(Cloudflare 不可能做浏览器登录),但我们得防止任何人随便 POST 假邮件。可以借鉴 Telegram 的方案:启动时生成一个随机 secret,但因为我们没法回调 Cloudflare 注册 secret,所以可以在 config.toml 里配置一个共享 secret,由 worker 通过 header 发送。端点再验证这个 header。

3. 出站邮件必须有主题。其他渠道没有“主题”概念,但邮件必须要有。send_email 工具需要一个 subject 参数,让 LLM 来提供。这对代理来说是个新东西。

4. 只有 HTML 的邮件。有些邮件没有纯文本部分。mailparser 可以提取 textAsHtml,我们可以把它转换为 markdown。可以用 html-to-text 库,或者直接用 mailparser 自带的 .text 做基础 HTML 剥离。

5. 发件人身份。出站 SMTP 里的 from 地址必须是 SMTP 服务器允许的地址。这是一个配置值。如果有人给 bot@mydomain.com 发邮件,回复也应该来自这个地址。

6. 用来处理的转发邮件。当 owner 转发一封邮件时,原发送者地址会在转发内容里,而 from 字段会变成 owner 的地址。LLM 需要在语义上理解这一点 —— 代码层面不需要特别处理,转发内容会包含在邮件 body 里,这本身就足够。理论上这应该自然就能工作。

7. 出站附件。现有的 send_*_message 工具通过文件路径支持附件。send_email 需要同样的能力,用 nodemailer 来附加文件。这部分很直接。

## 设计建议

Worker 简化:
// 只发我们真正需要的内容 —— 所有解析都在服务端完成
const payload = {
  from: message.from,
  to: message.to,
  raw: rawEmail
};
// 加认证 header
headers: {
  "Authorization": "Bearer <secret>",
  "Content-Type": "application/json"
}

配置(config.toml):
[email]
smtp_host = "smtp.example.com"
smtp_port = 465
smtp_user = "..."
smtp_password = "..."
from_address = "bot@mydomain.com"
webhook_secret = "some-random-secret"

入站流程:
- POST /email/webhook(公共路由,通过 webhook_secret 头验证)
- 用 mailparser 解析 raw → 提取 subject、文本正文(HTML 退化成 markdown)
- 格式化发给 LLM 的消息:Subject: ...\n\n<body>
- 通过 saveAttachment() 保存附件
- enqueueMessage(message, "email", senderEmailAddress, attachments)
- 把 "email" 加进 GATED_SOURCES,在 isInAllowlist() 里加上 email 处理

出站流程:
- 新的 send_email 工具,参数:to、subject、body(markdown)、attachments(可选文件路径)
- 把 markdown 正文转换为 HTML 作为邮件内容(带纯文本 fallback)
- 通过 nodemailer 的 SMTP transport 发送
- Interlocutor 身份服务:service "email",标识符:email 地址

新增依赖:mailparser、nodemailer、html-to-text(或者先只用 mailparser 自带的文本提取,如果质量不够再加 html-to-text)。

会改动的文件:
- src/email.ts —— 新文件:webhook 处理、MIME 解析、入站处理
- src/email-api.ts —— 新文件:基于 nodemailer 的 SMTP 发送
- src/agent.ts —— 加 send_email 工具定义
- src/config.ts —— 增加 EmailConfig 接口并读入
- src/index.ts —— 加 /email/webhook 路由,并在 isPublicRoute() 里放行
- src/queue.ts —— 把 "email" 加入 GATED_SOURCES
- src/allowlist.ts —— 增加 email 渠道处理
- config.example.toml —— 增加 [email] 配置段

明确**不做**(YAGNI):
- 邮件线程 / In-Reply-To 头
- CC / BCC 处理
- 退信处理
- 已读回执
- 邮件签名剥离(LLM 在语义上可以处理)

这些是否看起来合理?在我把它写成任务之前,有什么你想改的吗?

我会回复一些我认为它漏掉的东西。这里要求我对架构非常熟悉,而遵循这个流程可以让我在中低层细节上持续保持同步。

我:记得更新 README.md 和 config.example.toml。另外,记得把 email allowlist 页面改一下,不要再对这些邮箱做 E.164 验证了。

大模型会更新计划,并再问几个关键问题:

LLM:很好,现在我有了完整图景。下面是更新后的共识:

## 需求

1. 通过 Cloudflare Email Worker 的 webhook 接收入站邮件 → POST /email/webhook(公开,通过共享 secret 认证)
2. 通过 SMTP(nodemailer)和 send_email 工具(参数:to、subject、body、可选附件)发送出站邮件
3. 邮件渠道是 gated 的 —— 把 email 加入 GATED_SOURCES、allowlist 和 interlocutor 身份系统(service: "email",标识符:email 地址)
4. Allowlist UI —— 在设置页新增“Email allowlist”部分;做 email 格式校验(而不是 E.164)
5. Allowlist 数据 —— Allowlist 接口新增 email: string[] 字段;后端校验允许 email 地址
6. Owner 配置 —— config.toml 中新增 owner.email,并像其它渠道一样自动注入 allowlist
7. MIME 解析 —— 使用 mailparser 从原始 RFC 2822 邮件中提取 subject、文本正文(HTML 退化)以及附件
8. 发给 LLM 的消息格式 —— Subject: <subject>\n\n<body>,让代理能理解邮件主题
9. 配置 —— 新增 [email] 段:smtp_host、smtp_port、smtp_user、smtp_password、from_address、webhook_secret
10. 更新 config.example.toml 与 README.md

## 约束

- 进程内(不新增容器)
- Email 是可选功能 —— 如果配置里没有 [email] 段,就完全不加载 email 相关功能(和其它可选渠道一样)
- 严格遵循现有模式(webhook 处理像 Telegram,allowlist 像 WhatsApp/Signal,工具定义像其它 send 工具)

## 成功标准

- 能通过 webhook 收到邮件,由 LLM 处理,并能通过 send_email 回复
- Allowlist UI 能正确处理 email 地址
- Owner 邮箱能自动注入 allowlist
- 构建与现有测试全部通过,并为 webhook 处理器与 allowlist 校验新增测试

## 非目标 / 范围外

- 邮件线程(In-Reply-To / References 头)
- CC/BCC 处理
- 退信 / 投递状态处理
- 已读回执
- 邮件签名剥离
- 频率限制
- 出站邮件的 Markdown → HTML 转换(目前用纯文本就好 —— 以后需要再加)

在我写任务前有一个问题:出站正文是只发纯文本,还是要发 HTML(把 LLM 的 markdown 转成 HTML)?纯文本更简单、兼容性最好,但 HTML 看起来更漂亮。我个人偏向现在先做纯文本,之后再看。

另外 —— Worker 的 payload。我会把它简化为 { from, to, raw },并加一个认证 header,所有解析都在服务端做。可以吗?

有时我在看到它的消息前,会先想起要补充点东西:

我:在 config.example.toml 里加上完整的配置说明,包括要用的 Cloudflare worker 代码。

大模型则会把这个需求整合进计划,然后重复一遍完整任务拆解:

LLM:收到 —— config.example.toml 里会在注释里包含 worker 代码和搭建说明。我会更新计划并给出完整任务拆解。

关于你刚才没回复的两个问题:

1. 出站正文格式 —— 我会先用纯文本(YAGNI)。需要的话再加 markdown→HTML。
2. Worker payload —— 我会简化成 { from, to, raw },加上 auth header,所有解析逻辑都放在服务端。

## 计划目录建议

misc/coding-team/email-support/

## 任务拆解

1. 配置与依赖 —— 在 config.ts 里加 EmailConfig 接口,在 config.example.toml 里加 [email] 段(加上 Cloudflare worker 搭建说明),安装 mailparser 与 nodemailer(以及各自的 @types 包)。给 OwnerConfig 加上 owner.email。

2. Allowlist —— 给 Allowlist 接口加 email: string[];更新 validateAllowlist、loadAllowlist(自动注入 owner email)、getAllowlist、isInAllowlist、getOwnerIdentities。在 queue.ts 的 GATED_SOURCES 里加 "email"。

3. Allowlist UI 和后端校验 —— 在设置页 HTML/JS 里加入“Email allowlist”部分(使用 email 格式校验而非 E.164);更新 handlePutAllowlistRequest 以接受并校验 email 数组;更新 saveAllowlist 的序列化逻辑(包括 notes 键清理、owner 身份合并)。

4. 入站邮件 —— 新增 src/email.ts:webhook 处理器,校验 secret,使用 mailparser 解析 raw,提取 subject + 文本正文 + 附件,保存附件,并调用 enqueueMessage。在 index.ts 里加 /email/webhook 路由,并在 isPublicRoute 里放行。

5. 出站邮件 —— 新增 src/email-api.ts:nodemailer SMTP transport 初始化与发送函数。在 agent.ts 中添加 send_email 工具(参数:recipient、subject、body、可选 attachments),遵循其它 send 工具的模式(interlocutor 查找、allowlist 检查)。

6. README 与测试 —— 更新 README.md,记录邮件支持;在现有测试文件里为 webhook 处理器和 allowlist 的邮件校验新增测试。

这些看起来对吗?说“approved”我就开始执行。

我会回去仔细读它的消息,因为里面通常会有好问题,我不想漏掉任何一个。

我:关于你的问题,1. 正文用纯文本。2. 是的,能简化就尽量简化。

LLM:了解。计划就按上面那样。说“approved”我就开始执行。
我:Approved。

在它工作的过程中,我突然想到一个问题,于是打断它:
我:顺便问下,对机器人来说,一封邮件会长成什么样?它能同时看到主题和正文吗?
LLM:能 —— 计划里是这样格式化发给 LLM 的:

Subject: Hello!

Hi, this is the body of the email.

所以主题是一个前缀,与正文之间用空行分隔。这和人类阅读邮件的方式很接近,同时也给 LLM 足够的上下文来理解这封邮件是关于什么的(尤其是转发邮件,主题往往蕴含很多信息)。

对于出站邮件,send_email 工具有 subject 参数,所以 LLM 在回复时会生成主题行。
我:太好了,继续吧。

大模型会写完计划,调用开发者和评审,最终完成整个功能。这里我省略了所有后台工具 / 代理调用的细节。
LLM:6 个任务都已经完成。改动如下:

新增文件:
- src/email.ts —— 入站 webhook 处理器(校验 Bearer token 认证,用 mailparser 解析原始 RFC 2822 邮件,提取 subject / body / attachments,检查 allowlist,入队消息)
- src/email-api.ts —— 出站 SMTP transport(nodemailer,纯文本邮件,可选附件)
- src/email.test.ts —— webhook 认证测试

修改文件:
- src/config.ts —— EmailConfig 接口,Config 与 OwnerConfig 新增 email 字段
- src/allowlist.ts —— Allowlist 中新增 email: string[],不区分大小写匹配,owner 注入
- src/allowlist.test.ts —— 新增 12 个 email allowlist 测试
- src/settings.ts —— Email allowlist UI + 后端校验(email 格式,而非 E.164)
- src/settings.test.ts —— 新增 9 个 email 后端校验测试
- src/queue.ts —— 将 "email" 加入 GATED_SOURCES
- src/agent.ts —— createSendEmailTool(收件人解析、allowlist 把关、SMTP 发送)
- src/agent-send-tools.test.ts —— send_email 工具测试
- src/index.ts —— /email/webhook 路由 + isPublicRoute 条目 + 启动时 initializeEmailTransport
- config.example.toml —— 完整 [email] 段配置与 Cloudflare Worker 示例代码
- README.md —— 新增邮件支持说明,更新特性列表和架构描述
- package.json / package-lock.json —— 新增 mailparser、nodemailer 及其类型包

测试结果:421 个测试全部通过,TypeScript 类型检查干净。

你这边需要做的 Worker 修改:把 Cloudflare Worker 更新到 config.example.toml 里那个简化版本 —— 发送 { from, to, raw },带上 Authorization: Bearer <secret> 头,并把目标地址指到 https://<publicHostname>/email/webhook。

接下来你想做什么?

这时我有一个 UX 方面的想法:允许在没配置出站 SMTP 服务器的情况下,机器人也能读取入站邮件。这样如果用户只想把发票、行程单一类的邮件转给机器人阅读,而不想让机器人拥有发邮件的能力,也能用。
我:我们目前在这里都检查了哪些 email 参数?我在想,能不能只让入站邮件工作,即使用户没有配置出站邮件。这要做起来有多难?

大模型会估算工作量。如果只要小改一段,我通常会顺手在这次会话里做完;如果工作量较大,我会把它写成 GitHub issue,留待之后处理。

LLM:当前情况如下:

- EmailConfig 现在要求 6 个字段全部存在(smtpHost/Port/User/Password、fromAddress、webhookSecret)—— 都是非可选的。
- config.email 现在用来 gate 所有东西:webhook 路由、SMTP transport 初始化和 send_email 工具。

也就是说,现在如果你只想要入站邮件(webhook),也得把 所有 SMTP 字段填上哪怕是假的值,这很难看。

修复其实很简单 —— 把 EmailConfig 里的 SMTP 字段全改成可选。然后:

- webhook 路由:只依赖 webhookSecret —— 只要 config.email 存在就启用。
- SMTP transport 初始化:只有在 SMTP 字段都存在时才会运行。
- send_email 工具:只有在 SMTP 字段存在时才会注册。

改动本身很小:把 smtpHost、smtpPort、smtpUser、smtpPassword、fromAddress 在接口里都改成可选,同时在 initializeEmailTransport 和 createSendEmailTool 里按字段是否存在来判断是否初始化。webhook 处理器则只需要 webhookSecret 就能工作。要我做吗?
我:好的,麻烦你。

(中间再省略一段工具 / 代理调用)
LLM:已经完成。变更如下:

- EmailConfig.webhookSecret 现在是唯一必填字段。5 个 SMTP 字段(smtpHost、smtpPort、smtpUser、smtpPassword、fromAddress)全部改为可选。
- 仅凭 webhookSecret,webhook 路由就能正常工作 —— 入站邮件在不配置任何 SMTP 的情况下也完全可用。
- 只有在配了 smtpHost 时,SMTP transport 和 send_email 工具才会初始化。
- config.example.toml 现在清楚地区分并标注了哪些字段是必填、哪些是可选。

421 个测试全部通过,类型检查干净。你接下来打算做什么?

我会对这个功能做 QA 回归,然后回来报告问题:
我:我发了一封邮件,已经设置了 owner.email,但代理把这封邮件丢掉了:

[stavrobot] Dropping message: could not resolve target agent.
source=email, sender=me@mydomain.com

另外,“to” 似乎在日志里被截断了。
LLM:找到了。问题 1(消息被丢弃):seedOwnerInterlocutor 里的 ownerIdentitySet 只会从 Signal、Telegram 和 WhatsApp 的身份里构建,从来没有把 email 加进去。所以 isOwnerIdentity("email", "me@mydomain.com") 会返回 false,owner bypass 被跳过,消息就会落到 allowlist 检查和 interlocutor 查找 —— 而那里很可能也失败,因为现在还没有任何带 email 身份的 interlocutor 记录。

日志里 “To” 被截断 —— 这来自消息格式里的 Subject: ...\n\nFrom: ...\nTo: ...,看起来像是队列里那一行 200 字符的预览。那是邮件 body 里的头信息,mailparser 会把它们包括进去。“截断”只是 200 字符日志预览裁掉了后面的内容 —— 数据本身没有问题。

所以实际上只有一个 bug:seedOwnerInterlocutor 并没有把 owner.email 注册成 owner 身份。

大模型会去修这个问题。

LLM:修好了。Bug 在于 database.ts 中的 seedOwnerInterlocutor 用 Signal、Telegram 和 WhatsApp 的身份来构建 owner identity set,却从来没有加 email。所以 isOwnerIdentity("email", "me@mydomain.com") 一直是 false,owner bypass 被跳过,消息会被丢弃,因为也找不到任何 interlocutor。

日志中 “To” 被截断只是 queue.ts 里那段 200 字符 message preview 的截断 —— 实际消息数据是完整的。

重新构建之后,你的 owner 邮件应该会正确路由到主代理。要再测一遍吗?

这时候我意识到它错过了一种更好的实现方式,于是建议它重构:

我:我们有一个渠道列表,对吧?为什么这里不用那个列表,而是写了一堆硬编码?
LLM:说得对。seedOwnerInterlocutor 里针对每个渠道都有一段 if 块 —— Signal、Telegram、WhatsApp 和新加的 email。与此同时 OwnerConfig 里也已经按渠道名建好了字段。每次新增渠道时,都必须记得在这里再加 if 块,这正是我们这次 bug 出现的原因。

更好的方式是遍历一个共享的渠道名列表(或者直接动态遍历 OwnerConfig 字段),而不是写一堆针对单个渠道的 if 块。相同的模式也适用于 allowlist.ts 里的 getOwnerIdentities,那里面同样是按渠道分别写的。

不过 —— OwnerConfig 里有个 name 字段,它不是渠道,而 Telegram 的标识符需要特殊处理(在 allowlist 里是数字形式)。所以想做到完全通用也不是完全没有坑,但依然比现在这种方式好。

最简单的版本是:在一个地方定义渠道 key 列表(比如 ["signal", "telegram", "whatsapp", "email"]),然后让 seedOwnerInterlocutor 在上面循环。要我做这个清理,还是你更希望先往前推进,之后再回头做?

我对这种“泛化逻辑”有点犹豫,因为有特别的 case。

我:这些特殊情况让我略微有点担心。name 也许可以忽略,但 Telegram 的转换就是一个比较特殊的例外。你怎么看?
LLM:仔细想了想,其实 Telegram 的数字转换只在 allowlist 里有影响(isInAllowlist 比较的是数字,getOwnerIdentities 返回的 Telegram 身份是 number[])。但在 seedOwnerInterlocutor 里,所有身份在 interlocutor_identities 里都是字符串 —— 连 Telegram 也一样。所以这个“注入 owner 身份”的循环其实不需要任何特殊 case。本质上就是:

for (const channel of CHANNELS) {
  const value = ownerConfig[channel];
  if (value !== undefined) {
    identities.push({
      service: channel,
      identifier: channel === "email"
        ? value.toLowerCase()
        : value
    });
  }
}

……不过这确实又引入了一个 email 小写化的特殊 case。严格来说这也不太理想 —— 我们最好是在 email 进入系统的地方(owner 注入、入站 webhook 等)统一做小写,而不是在 identity loop 里做。

想得越多,我越觉得:对 seedOwnerInterlocutor 做清理是值得的(那里一切都是字符串),而 allowlist.ts 里的 getOwnerIdentities 确实有每个渠道不同的返回类型(string[] vs number[]),强行把它抽象成一个循环反而会让类型更糟。

我的建议是:把渠道列表提成一个共享常量,在 seedOwnerInterlocutor 里用它循环,但保留 getOwnerIdentities 目前的结构,因为它的 per-channel 类型差异本身是合理的。这样一来,“新增渠道却忘了在注入 owner 身份的地方加逻辑”这种 bug 就被彻底消除了,而这正是刚刚咬到我们的那条路径。
我:好,那就这么做。
LLM:改完了。四个按渠道的 if 块被替换成了一个循环,循环的是 config.ts 中定义的 OWNER_CHANNELS。以后新增渠道时,只要把它加进这个数组即可。所有 421 个测试依然全部通过。

要不要再构建一下再测一次邮件流程?

我会做一轮 QA 检查。这时我意识到邮件和电话号码不太一样,现在机器人会忽略我配置的“广义邮箱地址”。于是我和大模型讨论增加一项能力:
我:这里有个小区别:Email whitelist 会比较特别,因为有的人可能控制整个域名。我们能不能在 email whitelist 里支持星号?例如我想把自己的(owner)邮箱地址配置为 "*@example.com",而不仅仅是 "me@example.com"。
LLM:很合理 —— 域级别 wildcard 对于 email 是一个非常自然的需求,而在电话号码渠道上并不存在。

Allowlist 的匹配逻辑会比较直接:像 *@example.com 这样的条目会匹配任意来自该域的发件人。改动会发生在 isInAllowlist(匹配逻辑)、设置页面以及后端校验(接受 *@domain 这种模式)还有测试。

有一个问题:owner.email 是否也应该支持 *@example.com?我个人偏向于**否** —— owner 在语义上是一个具体的人,而 owner 身份用来做的是精确匹配路由(绕过 interlocutor 查找)。如果 owner.email = *@example.com,那么来自这个域的任何人都会被认为是 owner,这听起来不太对。更合理的是 owner.email = "me@example.com" 用于路由,而 *@example.com 用于 allowlist 让其他人能够进入。

这样的区别合理吗?还是说你其实想让 owner 身份也是整个域?

大模型一开始误解了我的需求,所以我用具体用例来澄清:

我:不,问题在于:我用的是“company@mydomain.com”这种一次性邮箱,把这些邮件转发给机器人时,邮件的 from 其实是我用的那个一次性邮箱,所以机器人会把它们丢掉。如果我想用主邮箱地址发,就得在每封邮件上修改地址,这太麻烦了。我更希望机器人能把整个域当作一个人(如果 wildcard 这么配置的话)。这有什么问题吗?
LLM:我之前担心的问题是,isOwnerIdentity 做的是精确字符串匹配 —— ownerIdentitySet 里存的是 "email:me@mydomain.com"。如果 owner.email = *@mydomain.com,对应的集合项会是 "email:*@mydomain.com",而来自 company@mydomain.com 的邮件并不会匹配。

不过修复其实很简单:让 isOwnerIdentity 对 email 支持和 isInAllowlist 一样的域 wildcard 匹配。只是多写几行代码而已,没有真正规模上的风险 —— 只要你控制这个域名,可以认为你在语义上就是这个域里的所有地址。

因此完整的 scope 是:

1. isInAllowlist —— 在 email 分支里对 *@domain 项做域名匹配。
2. isOwnerIdentity —— 对 owner identity 集里 email 项同样做域 wildcard 匹配。
3. 设置页面 + 后端校验 —— 接受 *@domain.com 作为合法 allowlist 条目。
4. 针对新匹配行为新增测试。

要我动手吗?

我会提醒它注意一个安全性的边界条件:

我:请确保本地部分(星号的那一段)匹配时不会跨越 @ 符号。我不希望有人搞骚操作,弄一个 "me@mydomain.com"@evildomain.com 然后绕过 owner 检查。
LLM:好提醒。匹配逻辑会按最后一个 @ 来切分(或者简单地检查发件人地址中 @ 后的域名是否精确一致,同时保证地址里恰好只有一个 @)。这样 "me@mydomain.com"@evildomain.com 就不会匹配 *@mydomain.com。

我还会要求在文档中说明这一点:

我:也请在 config.example.toml 里写清楚,本地部分可以包含星号。

我又有一个可用性的想法:

我:最好做成类似 glob 的匹配,以便有人想匹配 myusername+*@gmail.com,而不只是完全忽略本地部分。希望不会有人把星号放在邮箱本地部分里做别的用途……

以及一个安全性考虑:

我:如果你用正则,一定要把邮箱地址里除了星号以外的字符全部转义。
LLM:已经完成。Email allowlist 现在支持 glob 风格的 * wildcard:

- *@mydomain.com —— 匹配该域上任意地址
- myuser+*@gmail.com —— 匹配该用户下所有 plus-tag
- exact@example.com —— 精确匹配
- * —— 允许所有人

* 会被翻译成 [^@]*,所以不会跨越 @ 边界 —— "me@good.com"@evil.com 不会匹配 *@good.com。这在 allowlist 和 owner 身份检查中都一样生效。

最后我会再确认一下,以避免潜在的坑:

我:这在 owner 的 `email = ` 字段和 allowlist 页面 / allowlist 本身里都生效吗?
LLM:是的,两边都是:

1. config.toml 中的 owner.email —— 它的值会被存进 ownerEmailEntries,并在 isOwnerIdentity 中通过 matchesEmailEntry 来匹配。所以 owner.email = "*@mydomain.com" 会让任何来自该域的地址被视为 owner。
2. Allowlist(数据层 + 设置 UI)—— isInAllowlist 在 email 分支下会对 allowlist 中的每一个条目使用 matchesEmailEntry。因此,在设置页面中加上 *@example.com 即表示允许该域下任意地址。

两条路径都使用同一个 matchesEmailEntry helper,所以匹配行为完全一致。

会话之后又持续了一会儿,我继续做 QA,为 email 地址增加 wildcard 匹配,问了一个有关 SQL 注入的问题,还抓到了 subagent allowlist 中遗漏的一项。后面的对话整体模式和上面一样:我发现一个问题或提出一个改进点,然后和大模型一起细化思路、最后落地实现。

整个功能从头到尾大概花了一个小时,我对这个特性的工作效果很满意,于是就结束了这次会话。

尾声

以上就是我的整体设置的大致概览。它不算非常花哨,但对我来说运转得相当好,而且整个流程的可靠性让我非常满意。我已经让 Stavrobot 24/7 连续跑了将近一个月,它一直非常稳定。

如果你有任何反馈,或者只是想聊聊,欢迎在 Bluesky 上找我,或者直接给我发邮件。