乐于分享
好东西不私藏

我如何用 LLM 写软件

我如何用 LLM 写软件

作者分享用 LLM 写软件的工作流与经验,HN 高赞,对 AI 辅助开发有参考价值。

我并不在乎编程的乐趣。

最近我又开始大量做东西,主要是因为 LLM。我曾以为自己喜欢编程,但后来发现我喜欢的其实是创造东西,编程只是其中一种方式。既然 LLM 已经擅长编程,我就一直在用它们不停地做东西,而我们正处在又一个完全未探索的疆域的开端,这让我非常兴奋。

眼下关于 LLM 的争论很多,但有几个朋友问过我具体的工作流,所以我决定详细写下来,希望能帮助他们(以及你)更轻松、更快速、更高质量地做出东西。

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

好处

大约在 Codex 5.2 发布时(感觉像一百年前了),以及最近 Opus 4.6 发布后,我第一次惊讶地发现,我现在可以用 LLM 写软件,缺陷率非常低,很可能比手写代码还低不少,同时还能保持对整个系统如何运作的了解。在那之前,写两三天代码后,代码很快就会变得难以维护,但现在我已经连续几周在几个项目上工作,代码增长到数万行有用代码,每次修改都和第一次一样可靠。

我还注意到,我的工程能力并没有变得无用,只是发生了转移:我不再需要知道如何正确写代码,但如何正确架构系统、如何做出正确选择让东西可用,现在变得重要得多。

在那些我对底层技术没有理解的项目上(例如移动应用),代码仍然会很快变成一堆糟糕的选择。但在我熟悉所用技术的项目上(例如后端应用,尽管不一定是 Python),这种情况还没发生过,即使代码已经达到数万行。其中大部分肯定是因为模型在变好,但我认为很大一部分也因为我改进了与模型协作的方式。

我注意到的一点是,不同的人用 LLM 得到的结果差异很大,所以我怀疑和它们交流的方式会影响结果。因此,我会在本文中深入细节,甚至贴出真实会话,这样你就能看到我开发的全部细节。

还有一点需要说明:我不知道模型未来会如何演变,但我注意到一个趋势:在 LLM 早期(GPT-2 没那么明显,因为它很受限,但从 davinci 开始),我不得不逐行审查代码,确保正确。随着后来几代 LLM,审查层级上升到函数,所以我不必检查代码,但必须检查函数是否正确。现在,这主要是在「整体架构」层面,也许明年连这都不需要了。但目前,你仍然需要一个有良好编程能力的人。

我用这种方式做过什么

我最近做了不少东西,想在这里列出来,因为对 LLM 的一个常见批评是,人们只用它们写玩具脚本。这些项目从严肃的日常工具到艺术项目都有,但它们都是真实的、在维护的、我每天都在用的项目:

Stavrobot

我最近做的最大的东西是一个专注于安全的 OpenClaw 替代品。多年来我一直想要一个 LLM 个人助手,终于用这个实现了。这里大多数人会说「但你不能让 LLM 安全!」——这是误解了安全全是关于权衡的,而我的 agent 试图做的是在给定可用性下最大化安全性。我认为它做得很好,我已经用了一段时间,真的很喜欢能精确推理它能做什么、不能做什么这一点。

它管理我的日历,智能地决定我的空闲时间或任何冲突,为我做调研,通过写代码扩展自己,提醒我所有以前会忘的事,自主管理杂务,等等。助手的好处很难解释,因为它们没有一个杀手级功能,而是缓解一千个小伤口,而每个人的伤口不同。所以,试图向别人解释有个助手有多好,最后往往得到「但我不需要你需要的那些东西」的反应,而忽略了每个人需要的东西不同,一个能访问工具、能智能决策解决问题的 agent 对任何人都是很大帮助。

我打算很快更详细地写这个,因为设计时有一些非常有趣的挑战,我喜欢我解决它们的方式。

Middle

也许我最近的命名不太出色,但这是一个小吊坠,可以录语音笔记、转写,并可选地 POST 到你选择的 webhook。我让它把语音笔记发给我的 LLM,感觉很好:随时从口袋里掏出它,按个按钮,录下想法或问个问题,知道答案或待办下次查看助手消息时就会在那里。

这是个简单的东西,但有用性主要不是来自它做什么,而是它怎么做。它随时可用、始终可靠、使用零摩擦。

Sleight of hand

我也打算写写这个,但这个更像一件艺术品:它是一个滴答作响的挂钟,秒针滴答不规则,但分钟始终准确(时间通过互联网同步)。它有多种模式,一种模式的滴答间隔可变,从 500 毫秒到 1500 毫秒,令人恼火又好笑。另一种模式滴答得比一秒快一点,几乎察觉不到,然后随机暂停一秒,让毫无防备的观察者怀疑自己的理智。还有一种以双倍速度冲到 :59,然后在那里等三十秒,最后一种就是普通钟,因为所有不规则的滴答都让我抓狂。

Pine Town

Pine Town 是一个异想天开的无限多人草地画布,你有一小块地可以画。大多数人画的是…… questionable 的内容,但偶尔会有成年人来画点好看的东西。有些画是真正的宝石,四处滚动看看大家画了什么,通常挺有趣。

我用 LLM 做了所有这些项目,大部分代码我甚至没读过,但我仍然对每个项目的架构和内部运作非常熟悉。这是怎么做到的:

工具链

工具链我用的 OpenCode。我很喜欢它的功能,但显然有很多选择,Pi 我也用得很顺手,但无论用什么工具链,它都需要让你能够:

  • 使用来自不同公司的多个模型。大多数第一方工具链(Claude Code、Codex CLI、Gemini CLI)做不到这一点,因为公司只想让你用他们的模型,但这是必要的。
  • 定义可以自主相互调用的自定义 agent。

还有其他各种可选功能,比如会话支持、工作树管理等,取决于你的项目和技术栈,但那些由你决定。我会解释上面两个要求,以及为什么必要。

多模型

你可以把特定模型(例如 Claude Opus)看作一个人。当然,你可以用全新的上下文重新开始,但模型大多会有和以前一样的观点/优势/弱点,很可能同意自己。这意味着让一个模型审查它刚写的代码基本没用,因为它倾向于 mostly 同意自己,但这也意味着让另一个模型审查代码会带来很大改进。本质上,你是在获得第二双眼睛的审查。

不同模型在这方面有不同的优势和弱点。例如(这对今天的模型非常具体),我发现 Codex 5.4 相当吹毛求疵和迂腐。写代码时我不想要这个,但审查时我 definitely 想要。Opus 4.6 做的决定和我会做的决定相关性很好,Gemini 3 Flash(是的,Flash!)甚至非常擅长想出其他模型没想到的解决方案。

每个人对什么模型适合什么工作有不同的看法,模型往往会交替(例如我 11 月用 Codex 作为主模型,后来又换回 Opus)。要得到最好结果,你需要混合使用它们。

Agent 相互调用

我用的工作流由不同的 agent 组成,如果工具链不能让 agent 相互对话,你会在 LLM 之间做很多烦人的信息传递。你可能想减少这个,所以这是非常有用的功能。

我的工作流

我的工作流包括一个架构师、一个开发者,以及一到三个审查者,取决于项目的重要性。这些 agent 配置为 OpenCode agent(基本上是 skill 文件,即包含我希望每个 agent 如何行为的指令的文件)。

我用多个 agent(而不是只用一个做所有事)有三个原因:

  1. 它让我用昂贵的模型(Opus)做规划和生成详细计划,但用便宜的(Sonnet)实际写代码。相比让 Opus 做全部,这样能省 token。
  2. 它让我用不同模型审查代码,这确实能提高质量,因为不同模型审查时会发现不同的问题。
  3. 它让我用具有不同能力的 agent(例如一个可能只有代码的只读权限,而另一个可能有写权限)。

我不认为用两个相同模型、相同能力的 agent 有多大意义,那就像一个人假装戴不同的帽子,但我没有深入研究过这个。

我也倾向于手写 skill 文件,因为我觉得让 LLM 写 skill 没什么帮助。那就像让某人写下如何成为优秀工程师的指示,然后给他们自己的指示说「这就是如何成为优秀工程师,现在去当吧」。显然不会真的让他们变好,所以我尽量自己写指示。

如果你想自己试试,可以下载我的 agent 文件。

架构师

架构师(目前是 Claude Opus 4.6)是我唯一交互的 agent。这需要是一个非常强的模型,通常是我能接触到的最强的。这一步消耗的 token 不多,因为主要是聊天,但你希望推理非常周密。

我会告诉 LLM 我的主要目标(会是非常具体的功能或 bug 修复,例如「我想给 Stavrobot 添加指数退避重试,这样 LLM 提供商宕机时可以重试」),然后和它聊直到我确定它理解我想要什么。这一步花的时间最多,有时甚至要来回半小时才能敲定所有目标、限制和方法的权衡,并就最终架构应该是什么样子达成一致。结果是一个相当低层次的计划,详细到单个文件和函数。例如,任务可能是「我会在这个文件的这两个组件的这三个代码路径添加指数退避,因为没有其他组件和 LLM 提供商通信」。

我知道有些人在这一步更喜欢让 LLM 把计划写到文件里,然后他们在那份文件里加反馈,而不是和 LLM 聊。这是个人偏好,我看两种方式都能同样有效,所以如果你更喜欢那样做审查,尽管用。我个人更喜欢和 LLM 聊天。

澄清一下,在这一步我不是仅仅在 prompt,我是在 LLM 的帮助下塑造计划。我仍然要大量纠正 LLM,要么因为它错了,要么 simply 因为它做事的方式和我不一样,这是我贡献的一大块,也是我获得乐趣的部分。这种方向让我能把项目称为我的,因为别人用同样的 LLM 会做出不同的东西。

当我满意我们已经理清所有问题(LLM 在这方面很有帮助,会问它还不知道的问题并给我选项),我 finally 可以批准计划。我让架构师在我 actually 说出「approved」这个词之前不要开始任何事,因为有些模型倾向于过于急切,在它们觉得理解了的时候就去开始实现,而我想确保有信心它理解了。

然后,架构师会把工作拆成任务,把每个任务写到计划文件里,通常比我们聊天更详细(层次更低),并调用开发者开始工作。这给开发者具体方向,并最小化开发者能做的宏观选择,因为选择已经为它做好了。

开发者

开发者可以是更弱、更省 token 的模型(我用 Sonnet 4.6)。计划不应该给它太多发挥空间,它的工作 strictly 是按计划实现变更。完成后,它调用审查者审查它的工作。

审查者

每个审查者会独立查看计划和刚实现的功能的 diff,并批评。这一步,我总会用至少 Codex,有时会加 Gemini,重要项目还会加 Opus。

反馈回到开发者,如果审查者一致同意就整合,如果审查者意见不一就升级到架构师。我发现 Opus 很擅长选择要实现的正确反馈,有时会忽略反馈因为太迂腐(即难以实现且实践中不太可能出问题)。显然,当我说「very good」这种客观评价时,我 really 的意思是「我非常同意」。

整体方法

这种工作方式意味着我仍然知道函数层级以上的每一个选择,并能在后续运行中使用这些知识。我经常注意到 LLM 推荐的东西在另一个代码库可能不错,但在我的代码库要么不工作要么次优,这说明 LLM 在研究代码时有一些盲点。我经常会说「不,你应该用 Y 做这个」,这时 LLM 会意识到 Y 其实存在于代码里,是比它推荐的方式更好的方式。

失败模式

另一方面,当我对技术不够熟悉、无法掌控架构时,我往往抓不住 LLM 做的糟糕决定。这导致 LLM 在那些糟糕决定上越建越多,最终陷入无法理清混乱的状态。当这种情况发生时,你会不断告诉 LLM 代码不工作,它说「我知道为什么!让我修」,然后越修越糟。

这是一个真实的失败模式,我已经遇到太多次了,这就是为什么我最终采用了这个工作流。因此,我尽量在规划时尽可能理解,即使我对具体技术不熟悉。如果我能很好地引导 LLM,后面会省很多麻烦。

一段真实会话

下面是一段真实会话的带注释转录,我给 Stavrobot 添加了邮件支持。我删减了工具调用和冗长部分,但对话和决策过程 exactly 如发生的那样。

我以非常高的层次告诉 LLM 我想实现什么。有时我会给更多细节,尤其是我已经对实现方式有想法时。

我:我想给这个 bot 添加邮件支持。我们来想想怎么做。

Bot 读了代码,找到所有相关部分,并问了一些问题。

LLM:已经了解了大致情况。这是我看到的以及我们需要解决的问题:

 ## 当前模式 所有渠道遵循相同流程:入站 webhook/监听器 → enqueueMessage(message, source, sender, attachments) → LLM 处理 → LLM 调用 send_*_message 工具回复。渠道要么是进程内(Telegram webhooks、通过 Baileys 的 WhatsApp)要么是独立容器(Signal bridge)。邮件将是一个遵循同样模式的新渠道。 ## 关键设计问题 1. 入站——邮件如何到达?    - IMAP 轮询(最简单——定时轮询邮箱,适用于任何提供商)    - 来自事务邮件服务的 webhook(SendGrid/Mailgun 入站解析——需要该服务)    - 运行 SMTP 服务器(最复杂,需要 DNS/MX 配置) 2. 出站——我们如何发送?    - SMTP 客户端(适用于任何提供商——Gmail、Fastmail、自托管)    - 事务邮件 API(SendGrid/Mailgun API) 3. 你的实际用例是什么?这影响几个决定:    - 完整双向对话(回复邮件,维护线程)?    - Bot 的单向通知?    - 处理入站邮件(例如转发的邮件、摘要)? 4. 架构——独立容器还是进程内?    Signal 用 bridge 容器因为 signal-cli 是重量级 Java 进程。邮件库很轻量,所以进程内(像 Telegram)可行。但 AGENTS.md 关于不往 app 容器加运行时的规则这里不适用,因为只是 TypeScript/Node。 5. 邮件特定问题:    - 如何处理 HTML 邮件?剥离成纯文本?把 markdown 转换传给 LLM?    - 线程追踪——通过 In-Reply-To/References 头匹配回复到对话,还是每封邮件独立处理?    - 附件——需要吗? 你设想的是什么场景?这会很快缩小范围。

在这段会话里,我带着一个还没想透的点子来的。LLM 通过问具体问题来帮助,由我决定实现往哪个方向走:

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

 {"from":"me@mydomain.com", 等等} 2. SMTP 客户端 3. 完整双向对话,是的。这将和其他传输中对话者与 bot 交互的方式一样,如果有人发邮件给 bot,除非他们被设为对话者否则会被忽略。主人也可以发邮件或转发邮件供处理。 4. 进程内,因为这是 webhook。 5. Markdown 转换,是的。我们先每封邮件独立处理,必要时再改。需要附件,它们应该像其他平台一样工作,agent 会以文件形式收到它们。 我设想的场景是既能通过子 agent 通过邮件与人通信(像 bot 目前通过 Signal/Telegram/Whatsapp 做的那样),主人也能转发邮件(例如预订确认邮件)让 bot 处理并提取信息。这说得通吗?你能预见到什么边缘情况,或者有什么担心的?

LLM 塑造计划,给它更多细节:

LLM:这说得通,也干净地符合现有渠道模式。以下是我的观察和看到的边缘情况:

 ## 顾虑和边缘情况 1. MIME 解析不简单。纯文本邮件容易,但真实世界的邮件是多部分 MIME(HTML+文本备选、内联图片、附件、嵌套多部分、各种字符编码)。我们应该用 mailparser(来自 Nodemailer 项目)在服务端解析原始 RFC 2822 消息,而不是试图在 worker 里处理。这意味着 worker 的 payload 可以最小——只要 from、to 和 raw。 2. Webhook 认证。端点必须是公开的(Cloudflare 不能做浏览器登录),但我们需要防止任何人 POST 假邮件。与 Telegram 模式匹配的最简单方法:启动时生成随机 secret,但由于我们不能回调 Cloudflare 注册它,我们在 config.toml 里配置共享 secret,让 worker 作为 header 发送。端点验证它。 3. 出站邮件需要主题行。其他渠道都没有主题,但邮件需要。send_email 工具需要 LLM 提供的 subject 参数。这是 agent 要处理的新东西。 4. 纯 HTML 邮件。有些邮件根本没有纯文本部分。mailparser 可以提取 textAsHtml,我们转换成 markdown。html-to-text 库处理这个很好,或者 mailparser 本身提供 .text 属性做基本 HTML 剥离。 5. 发件人地址身份。出站 SMTP 的 from 地址必须匹配 SMTP 服务器允许的。这是配置值。如果有人发邮件到 bot@mydomain.com,回复应该来自那个地址。 6. 转发的邮件供处理。当主人转发邮件时,原始发件人地址会在转发正文里,不在 from 字段(会是主人的地址)。LLM 需要上下文理解这个——不需要特殊代码,转发内容就在消息正文里。这应该自然工作。 7. 出站附件。现有的 send_*_message 工具通过文件路径支持附件。send_email 需要同样的,用 nodemailer 附加文件。这很直接。 ## 提议设计 Worker 简化:   // 只发我们需要的——从 raw 在服务端解析一切   const payload = {     from: message.from,     to: message.to,     raw: rawEmail   };   // 加认证头   headers: {     "Authorization": "Bearer <secret>",     "Content-Type": "application/json"   } Config (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 header 验证)   - 用 mailparser 解析 raw → 提取 subject、文本正文(HTML 回退 → markdown)、附件   - 格式化给 LLM 的消息为:Subject: ...\n\n<body>   - 通过 saveAttachment() 保存附件   - enqueueMessage(message, "email", senderEmailAddress, attachments)   - 在 queue.ts 的 GATED_SOURCES 添加 "email",在 isInAllowlist() 添加邮件处理 出站流程:   - 新的 send_email 工具,参数:to、subject、body(markdown)、attachments(可选文件路径)   - 把 markdown 正文转成 HTML 发邮件(带纯文本回退)   - 通过 nodemailer SMTP 传输发送   - 对话者身份服务:"email",identifier:邮件地址 新依赖: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 — 在 GATED_SOURCES 添加 "email"   - src/allowlist.ts — 添加邮件渠道处理   - config.example.toml — 添加 [email] 节 明确不做(YAGNI):   - 邮件线程 / In-Reply-To 头(你说独立处理)   - CC/BCC 处理   - 退信处理   - 已读回执   - 限流   - 邮件签名剥离(LLM 可以上下文处理) 这样对吗?写任务之前有什么要改的?

我回复了几点它漏掉的。这需要我熟悉架构,遵循这个过程让我在 moderately 低层次上了解所有变更。

我:确保更新 README.md 和 config.example.toml。还要记得改邮件白名单页面,不要对邮箱验证 E.164

LLM 更新计划并问任何最后问题。

LLM:好,现在我有了完整图景。这是更新后的约定:

 ## 需求 1. 入站邮件通过 Cloudflare Email Worker webhook → POST /email/webhook(公开,共享 secret 认证) 2. 出站邮件通过 SMTP(nodemailer)通过 send_email 工具(to、subject、body、可选附件) 3. 门控渠道——email 添加到 GATED_SOURCES、allowlist 和对话者身份系统(service: "email",identifier:邮件地址) 4. Allowlist UI——设置页面的新「Email allowlist」节;验证邮件格式(不是 E.164) 5. Allowlist 数据——Allowlist 接口获得 email: string[] 字段;后端验证接受邮件地址 6. 主人配置——config.toml 的 owner.email,像其他渠道一样自动加入 allowlist 7. MIME 解析——用 mailparser 从原始 RFC 2822 消息提取 subject、文本正文(HTML 回退)、附件 8. 给 LLM 的消息格式——Subject: <subject>\n\n<body> 这样 agent 知道邮件是关于什么的 9. Config——新 [email] 节:smtp_host、smtp_port、smtp_user、smtp_password、from_address、webhook_secret 10. 更新 config.example.toml、README.md ## 约束 - 进程内(无新容器) - 邮件是可选的——如果 config 里没有 [email],不加载邮件功能(像其他可选渠道) - 完全遵循现有模式(webhook 处理像 Telegram,allowlist 像 WhatsApp/Signal,工具定义像其他 send 工具) ## 成功标准 - 能通过 webhook 收到邮件,让 LLM 处理,LLM 能通过 send_email 回复 - Allowlist UI 对邮件地址工作 - 主人邮件自动加入 allowlist - 构建通过现有测试;webhook 处理器和 allowlist 验证的新测试 ## 非目标 / 范围外 - 邮件线程(In-Reply-To / References 头) - CC/BCC 处理 - 退信/投递状态处理 - 已读回执 - 邮件签名剥离 - 限流 - 出站正文的 Markdown 转 HTML(纯文本现在就行——以后可以加) 写任务之前有个问题:出站时,正文应该只发纯文本,还是你想要 HTML 邮件(把 LLM 的 markdown 转成 HTML)?纯文本更简单,到处都能用,但 HTML 更好看。我倾向于现在纯文本,需要时再加 HTML。 还有——worker payload。我会简化成只有 { from, to, raw },在服务端从 raw 解析一切。这样你不需要在 worker 里维护解析逻辑。可以吗?

有时我会在读它的消息之前想起什么并告诉 LLM:

我:在 config.example.toml 里加设置说明,包括要用的实际 Cloudflare worker

它通过纳入我的顾虑并重复消息来适应。

LLM:收到——config.example.toml 会在注释里包含 worker 代码和设置说明。让我用那个更新计划并呈现完整任务分解。

 关于你没回答的两个开放问题: 1. 出站正文格式——我先用纯文本(YAGNI)。以后可以加 markdown→HTML 转换。 2. Worker payload——我会简化成 { from, to, raw } 加认证头,在服务端从 raw 解析一切。 ## 提议计划目录 misc/coding-team/email-support/ ## 任务分解 1. Config 和依赖——在 config.ts 添加 EmailConfig 接口,在 config.example.toml 添加 [email] 节(含 Cloudflare worker 设置说明),安装 mailparser 和 nodemailer(及它们的 @types/ 包)。在 OwnerConfig 添加 owner.email。 2. Allowlist——在 Allowlist 接口添加 email: string[],更新 validateAllowlist、loadAllowlist(主人邮件自动加入)、getAllowlist、isInAllowlist、getOwnerIdentities。在 queue.ts 的 GATED_SOURCES 添加 "email"。 3. Allowlist UI 和后端验证——在设置页面 HTML/JS 添加「Email allowlist」节(用邮件格式验证代替 E.164),更新 handlePutAllowlistRequest 验证并接受 email 数组,更新 saveAllowlist 序列化(包括 notes key 修剪和主人身份合并)。 4. 入站邮件——新 src/email.ts:验证 secret 的 webhook 处理器,用 mailparser 解析原始邮件,提取 subject + 文本正文 + 附件,保存附件,调用 enqueueMessage。在 index.ts 添加 /email/webhook 路由,在 isPublicRoute 白名单。 5. 出站邮件——新 src/email-api.ts:nodemailer SMTP 传输设置和发送函数。在 agent.ts 添加 send_email 工具(参数:recipient、subject、body、可选 attachments),遵循其他 send 工具的同样模式(对话者查找、allowlist 检查)。 6. README 和测试——更新 README.md 记录邮件支持。在现有测试文件添加 webhook 处理器验证和 allowlist 邮件验证的测试。 这样对吗?说「approved」继续。

我确实会回去读它的消息,因为它通常有好问题,我不想漏答。

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

LLM 需要我明确说出「approved」才能继续。

LLM:收到。计划如上所述。说「approved」继续。

我:Approved。

LLM 工作时我想起什么,停下来问:

我:顺便,邮件对 bot 会是什么样?它能同时看到 subject 和 body 吗?

LLM:是的——计划是把入站消息格式化成给 LLM 的:

   Subject: Hello!   Hi, this is the body of the email. 所以 subject 作为前缀可见,和 body 用空行分隔。这镜像你自然读邮件的方式,给 LLM 足够上下文理解邮件是关于什么的(对转发的邮件特别有用,subject 行承载意义)。 出站时,send_email 工具有 subject 参数,所以 LLM 回复时会写 subject 行。

我:很好,请继续

LLM 写计划,调用开发者,调用审查者,最终完成。这里我省略了所有后台工具/agent 调用以简洁。

LLM:6 个任务都实现了。做了这些:

 新文件:   - src/email.ts — 入站 webhook 处理器(验证 Bearer token 认证,用 mailparser 解析原始 RFC 2822 邮件,提取 subject/body/attachments,检查 allowlist,入队消息)   - src/email-api.ts — 出站 SMTP 传输(nodemailer,纯文本邮件带可选附件)   - src/email.test.ts — webhook 认证测试 修改文件:   - src/config.ts — EmailConfig 接口,Config 和 OwnerConfig 的 email 字段   - src/allowlist.ts — Allowlist 上的 email: string[],不区分大小写匹配,主人加入   - src/allowlist.test.ts — 12 个新邮件 allowlist 测试   - src/settings.ts — 邮件 allowlist UI 节 + 后端验证(邮件格式,不是 E.164)   - src/settings.test.ts — 9 个新邮件后端验证测试   - src/queue.ts — GATED_SOURCES 添加 "email"   - src/agent.ts — createSendEmailTool(收件人解析、allowlist 门控、SMTP 发送)   - src/agent-send-tools.test.ts — 邮件发送工具测试   - 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 的想法,允许 bot 在不配置出站 SMTP 服务器的情况下读取入站邮件,以防用户想转发发票或行程计划之类的东西让它读,但不想让 bot 能发邮件。

我:这里我们检查什么,关于邮件参数?我在想用户不配置出站邮件时,入站邮件能否工作。实现起来有多难?

LLM 评估工作量。如果是小改动,我通常会在这段会话里做,否则会写到 GitHub issue 以后做。

LLM:当前情况:

 - EmailConfig 需要全部 6 个字段(smtp host/port/user/password、fromAddress、webhookSecret)——它们都是非可选 string/number。 - config.email 门控一切:webhook 路由、SMTP 传输初始化、send_email 工具。 所以现在,如果你只想要入站邮件(webhook),你仍然得填满所有 SMTP 字段用假值,这很丑。 修复很直接——在 EmailConfig 里让 SMTP 字段可选。然后: - Webhook 路由:只需要 webhookSecret → config.email 存在时启用。 - SMTP 传输初始化:只有 SMTP 字段存在时运行。 - send_email 工具:只有 SMTP 字段存在时注册。 改动很小:在接口里让 smtpHost、smtpPort、smtpUser、smtpPassword、fromAddress 可选,根据它们的存在门控 initializeEmailTransport 和 createSendEmailTool,webhook 处理器只用 webhookSecret 就能工作。要我做吗?

我:好的,请做

这里省略更多工具/agent 调用。

LLM:完成。改动如下:

 - EmailConfig.webhookSecret 现在是唯一必填字段。5 个 SMTP 字段(smtpHost、smtpPort、smtpUser、smtpPassword、fromAddress)都是可选。 - Webhook 路由只用 webhookSecret 就能工作——入站邮件在没有任何 SMTP 配置时完全可用。 - SMTP 传输和 send_email 工具只有在 smtpHost 存在时初始化。 - config.example.toml 现在清楚分组和标注了必填 vs 可选字段。 全部 421 个测试通过,类型检查干净。接下来想做什么?

我 QA 功能,带着问题回来:

我:我发了封邮件,指定了 owner.email,但 agent 丢弃了:

 [stavrobot] Dropping message: could not resolve target agent. source=email, sender=me@mydomain.com 还有,「to」好像被截断了

LLM:找到了。问题 1(丢弃消息):seedOwnerInterlocutor 从 Signal、Telegram、WhatsApp 身份构建 ownerIdentitySet,但从未添加 email。所以 isOwnerIdentity(“email”, “me@mydomain.com”) 返回 false,主人绕过被跳过,消息落到 allowlist 检查和对话者查找——可能也失败了因为还没有带 email 身份的对话者记录。

 日志里的「To」截断——来自消息格式 Subject: ...\n\nFrom: ...\nTo: .... 那看起来是邮件正文内容被记录(queue.ts 第 181 行的预览截断到 200 字符)。To: 来自 mailparser 包含在正文文本里的邮件头。截断只是 200 字符日志预览截断——不是实际数据问题。完整消息完好无损。 所以真的只有一个 bug:seedOwnerInterlocutor 没有把 owner.email 注册为主人身份。

LLM 去修了问题。

LLM:修好了。Bug 是 database.ts 里的 seedOwnerInterlocutor 从 Signal、Telegram、WhatsApp 构建主人身份集,但从未添加 email。所以 isOwnerIdentity(“email”, “me@mydomain.com”) 返回 false,主人绕过被跳过,因为没有对话者记录可路由,消息被丢弃。

 日志里的「To」截断只是 queue.ts 里 200 字符消息预览截断——实际消息数据完好无损。 重建后,你的主人邮件应该正确路由到主 agent。要测试吗?

这里我发现它漏了实现功能的更好方式,建议它改实现:

我:我们有来源列表,对吧?为什么不用那个列表,而是硬编码了?

LLM:对。database.ts 里的 seedOwnerInterlocutor 主人身份加入对每个渠道有独立的硬编码 if 块——Signal、Telegram、WhatsApp,现在还有 email。同时 OwnerConfig 已经有渠道名作为字段键。每次加新渠道,有人得记得在那里加另一个 if 块,这正是这个 bug 发生的方式。

 修复是迭代共享的渠道名列表(或直接动态迭代 OwnerConfig 字段)而不是每个渠道一个 if 块。同样模式适用于 allowlist.ts 里的 getOwnerIdentities,它也有每个渠道的块。 但是——OwnerConfig 有个 name 字段不是渠道,Telegram 的 identifier 需要特殊处理(它是转成数字供 allowlist 用的数字字符串)。所以完全通用的循环不是完全 trivial,但仍然比当前方式好。 最简单版本:在一个地方定义渠道键列表(例如 ["signal", "telegram", "whatsapp", "email"]),让 seedOwnerInterlocutor 迭代它。要我做这个清理,还是你想继续前进以后再说?

我对做通用检查有顾虑,因为特殊情况。

我:特殊情况让我犹豫。name 可能没问题,我们可以忽略,但 Telegram 转换更特殊。你怎么看?

LLM 想了想,推荐了合理的东西。

LLM:Telegram 数字转换只在 allowlist 里重要(isInAllowlist 比较数字,getOwnerIdentities 对 Telegram 返回 number[])。但在 seedOwnerInterlocutor 里,所有身份都以字符串存在 interlocutor_identities 里——连 Telegram 也是。所以加入循环其实不需要任何特殊情况。就是:

   for (const channel of CHANNELS) {     const value = ownerConfig[channel];     if (value !== undefined) {       identities.push({         service: channel,         identifier: channel === "email"           ? value.toLowerCase()           : value       });     }   } ……虽然这引入了 email 小写的特殊情况。 arguably 也不对——我们可能应该在任何地方 email 进入系统时(allowlist 加入、入站 webhook 等)小写,而不是在身份循环里。 越想越觉得 seedOwnerInterlocutor 的清理值得做(那里反正都是字符串),但 allowlist.ts 里的 getOwnerIdentities 每个渠道确实有不同的返回类型(string[] vs number[]),所以强行塞进通用循环会让它更糟而不是更好。 我的建议:让渠道列表成为共享常量,在 seedOwnerInterlocutor 里用它循环,但保持 getOwnerIdentities 不变,因为它的每个渠道类型差异是固有的。这样「忘了加新渠道」这类 bug 在加入路径上消除了,正是刚刚咬我们的那个。

我:好,approved

LLM:完成。四个每个渠道的 if 块被 config.ts 里定义的 OWNER_CHANNELS 上的循环替代。下次加渠道,只需追加到那个数组。全部 421 个测试通过。

 要重新构建再测邮件流程吗?

我 QA 再检查。我意识到邮件和电话号码略有不同,bot 现在忽略了我的自定义邮件地址。我和 LLM 讨论加这个:

我:有个问题:邮件白名单是特殊的,因为有人可能控制整个域名。我们能在邮件白名单里加星号吗?例如,我想让我的(主人)邮件地址是 “*@example.com”,而不是只有 “me@example.com”

LLM:说得通——域名级通配符对邮件是自然匹配,对基于电话号码的渠道不存在。

 Allowlist 匹配很直接:像 *@example.com 的条目匹配任何域名匹配的发件人。改动会在 isInAllowlist(匹配逻辑)、设置 UI 和后端验证(接受 *@domain 模式)、以及测试里。 一个问题:owner.email 也应该支持 *@example.com 吗?我倾向于不——主人是具体的人,主人身份用于精确匹配路由(绕过对话者查找)。如果 owner.email 是 *@example.com,那来自该域名的任何邮件都会被当作主人,这似乎不对。你可能想要 owner.email = "me@example.com" 做路由,*@example.com 在 allowlist 里让其他人通过。 这个区分说得通吗,还是你其实也想要主人身份是域名级的?

LLM 误解了我要的,我用具体用例澄清:

我:不,整个问题是我是用一次性邮件地址 “company@mydomain.com”,转发那些邮件意味着邮件来自我用的一次性地址,所以 bot 丢弃了。我不容易从主地址发邮件而不改每封邮件,所以我宁愿 bot 把整个域名当一个人(如果通配符那样设置)。这有什么问题?

LLM:我担心的是 isOwnerIdentity 做精确字符串匹配——ownerIdentitySet.has(“email:me@mydomain.com”)。如果 owner.email 是@mydomain.com,集合会包含 “email:@mydomain.com”,来自company@mydomain.com的入站邮件不会匹配。

 但修复很简单:让 isOwnerIdentity 对邮件理解域名通配符,和 isInAllowlist 一样。就是一个函数里多几行。没有真正缺点——如果你控制域名,无论用哪个地址你都是主人。 所以完整范围是: 1. isInAllowlist — 邮件情况检查 *@domain 条目,比较发件人域名。 2. isOwnerIdentity — 对主人身份集里邮件条目的同样域名通配符匹配。 3. 设置 UI + 后端验证 — 接受 *@domain.com 作为有效邮件 allowlist 条目。 4. 新匹配行为的测试。 要我做吗?

我让它注意一个注意事项。

我:请确保本地部分(通配符)不匹配 @ 符号。我不想有人耍小聪明用验证,从 “me@mydomain.com”@evildomain.com 发邮件绕过我的主人检查

LLM:好发现。匹配逻辑会按最后一个 @ 分割(或 simply 检查发件人 @ 后的域名部分精确匹配,发件人地址恰好包含一个 @)。这样 “me@mydomain.com”@evildomain.com 不会匹配 *@mydomain.com。

并要求一些文档变更。

我:请在 config.example.toml 里澄清本地部分可以包含星号

我还有个可用性想法:

我:我们应该做正确匹配,以防有人想匹配 myusername+*@gmail.com 这样的东西,而不是只忽略整个本地部分。希望没人本地部分会包含合法的星号

以及安全考虑:

我:如果用正则,需要转义邮件地址里每一个其他字符

LLM:完成。邮件 allowlist 现在支持 glob 风格 * 通配符:

 - *@mydomain.com — 匹配域名下任何地址 - myuser+*@gmail.com — 匹配该用户的任何 plus-tag - exact@example.com — 仅精确匹配 - * — 允许所有人 * 在正则里转成 [^@]*,所以不能跨 @ 边界匹配——"me@good.com"@evil.com 不会匹配 *@good.com。这在 allowlist 和主人身份检查里都工作。

我要求澄清以抓住潜在陷阱:

我:这在主人的email =字段和 allowlist 页面/allowlist 本身都工作吗?

LLM:是的,都工作:

 1. config.toml 的 owner.email — 值存在 ownerEmailEntries,通过 isOwnerIdentity 里的 matchesEmailEntry 匹配。所以 owner.email = "*@mydomain.com" 把该域名下任何地址当主人。 2. Allowlist(数据层 + 设置 UI)— isInAllowlist 对邮件对 allowlist 里每个条目用 matchesEmailEntry。所以通过设置页面加 *@example.com 允许该域名下任何人。 两条路径用同一个 matchesEmailEntry 辅助函数,所以匹配行为一致。

会话继续了一会儿,我做了更多 QA 轮次,添加邮件地址通配符匹配、关于 SQLi 的问题,以及抓住子 agent allowlist 里漏掉的条目。对话大致如上,我要么抓住错误要么提议改进,和 LLM 细化,然后实现。

整个功能从头到尾大约花了一小时,我在满意功能工作良好后结束了会话。

尾声

这就是我设置的基本概览。没什么特别花哨的,但对我很管用,我对整个过程的可靠性非常满意。Stavrobot 我已经 24/7 跑了快一个月了,极其可靠。

如有反馈或只是想聊聊,在 Bluesky 找我,或直接发邮件给我。


原文“How I write software with LLMs”作者 Stavros发布于 Stavros’ Stuff (stavros.io)。原文链接:https://www.stavros.io/posts/how-i-write-software-with-llms/


MASEval:Agent时代,评测的对象已经不是模型了

本站文章均为手工撰写未经允许谢绝转载:夜雨聆风 » 我如何用 LLM 写软件

猜你喜欢

  • 暂无文章