[AI翻译]我如何用大模型写软件

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 工具回复。渠道有两类:进程内的(TG 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 类库很轻,所以像 TG 一样走进程内是可行的。而且 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/TG/Whatsapp 那样替我和别人邮件往来,也能让 owner 把邮件(例如预定确认邮件)转发给机器人,让它处理并摄取信息。这样是否合理?你能预见哪些边界情况?有什么是你担心的吗?

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

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

## 担忧与边界情况

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

2. Webhook 认证。这个端点必须是公开的(Cloudflare 不可能做浏览器登录),但我们得防止任何人随便 POST 假邮件。可以借鉴 TG 的方案:启动时生成一个随机 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 处理像 TG,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、TG 和 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、TG 和 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、TG、WhatsApp 和新加的 email。与此同时 OwnerConfig 里也已经按渠道名建好了字段。每次新增渠道时,都必须记得在这里再加 if 块,这正是我们这次 bug 出现的原因。

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

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

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

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

我:这些特殊情况让我略微有点担心。name 也许可以忽略,但 TG 的转换就是一个比较特殊的例外。你怎么看?
LLM:仔细想了想,其实 TG 的数字转换只在 allowlist 里有影响(isInAllowlist 比较的是数字,getOwnerIdentities 返回的 TG 身份是 number[])。但在 seedOwnerInterlocutor 里,所有身份在 interlocutor_identities 里都是字符串 —— 连 TG 也一样。所以这个“注入 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 上找我,或者直接给我发邮件。

posted @ 2026-03-16 21:45  ffl  阅读(45)  评论(0)    收藏  举报