大多数开发者把LLM用反了。我们用五个词描述需求,拿回200行代码,然后花下一个小时调试模型做出的假设。

我花了三个月时间运行相反的流程:在索要哪怕一行代码之前,先写一份完整的设计说明。结果代码缺陷更少了,但真正的进步在于我自己的理解。

什么是与LLM的Literate Programming?

Donald Knuth在1984年提出了”literate programming”这个概念。思路很简单:先写解释程序的散文,再把代码编织进这些散文里。大多数开发者无视它,因为维护两份产物太繁琐。LLM改变了这笔账。散文不再是文档,它就是prompt。

当你在索要代码之前向LLM解释自己的思路时,你做的不是标准的prompt engineering。你不是在为模型优化token。你是在强迫自己在模型替你幻觉出一套架构之前,先把constraint、edge case和依赖关系讲清楚。LLM变成了一只会回话的rubber duck,但前提是你自己已经完成了真正的思考。

实验:30天先解释再编码

我从待办清单里挑了12个真实任务,从CSV解析器到WebSocket重连策略都有。每个任务我都用了两种方法:

  1. 捷径:把工单描述贴进LLM,直接要代码。
  2. 说明:写300到500字描述问题、constraint、错误处理策略和test case,然后让LLM按这份设计实现。

我从三个维度评估输出:code review发现的缺陷数、合并耗时、修复问题所需的follow-up prompt数量。

数据非常悬殊。说明优先的代码平均只需1.2个follow-up prompt,而捷径法需要4.7个。code review在说明优先的提交中平均每份发现0.3个缺陷,捷径代码则是2.1个。

代价是时间。写说明让每个任务开头多了15到20分钟。值不值取决于你在做什么。

如何组织一份真正有效的说明

好的说明不只是更长的prompt。它是一份有固定结构的design document。以下是我第一周之后精炼出的模板:

## Problem
[What are we solving and why?]

## Constraints
[What can't we do? What formats, protocols, or dependencies must we respect?]

## Edge Cases
[What happens when input is null, empty, malformed, or hostile?]

## Error Handling Strategy
[Fail fast or fail graceful? What do we log? What do we return?]

## Test Cases
[The specific inputs and expected outputs that prove this works.]

这里有一个真实例子。我需要用HMAC-SHA256验证webhook签名的函数,但有个特殊情况:签名头根据发送请求的服务不同,可能采用两种格式。

Problem:验证收到的webhook签名。有些发送方使用X-Signature: sha256=<hex>,另一些使用不带前缀的X-Signature: <hex>。需要同时支持两者,且不破坏现有集成。

Constraints:必须使用Node.js的crypto模块。必须使用constant-time比较以防止timing attack。必须返回明确的boolean,遇到malformed input时绝不能抛出异常。

Edge Cases:头缺失。头为空字符串。头含有未知前缀如sha512=。hex字符串长度为奇数或包含非hex字符。

Error Handling Strategy:在debug级别记录具体失败原因,但所有无效情况都返回false。绝不在错误消息中泄露签名细节。

Test Cases

  1. Valid sha256=abc123... matches computed signature.
  2. Valid bare abc123... matches computed signature.
  3. Missing header returns false.
  4. Wrong signature returns false.
  5. Non-hex characters in signature return false.

然后我补了一句:“Implement this function in TypeScript.”

模型返回了以下代码:

import { createHmac, timingSafeEqual } from 'crypto';

export function validateWebhookSignature(
  payload: string,
  header: string | undefined,
  secret: string
): boolean {
  if (!header || header.length === 0) {
    return false;
  }

  const hexPattern = /^[0-9a-fA-F]+$/;
  let signatureHex: string;

  const prefixMatch = header.match(/^sha256=(.+)$/);
  if (prefixMatch) {
    signatureHex = prefixMatch[1];
  } else {
    signatureHex = header;
  }

  if (!hexPattern.test(signatureHex) || signatureHex.length % 2 !== 0) {
    return false;
  }

  const expected = createHmac('sha256', secret).update(payload).digest();
  const actual = Buffer.from(signatureHex, 'hex');

  if (actual.length !== expected.length) {
    return false;
  }

  return timingSafeEqual(actual, expected);
}

这段代码一次编译通过。更重要的是,它处理了我指定的每一个edge case,因为我已经提前想清楚了。模型不需要猜测”validate”到底是什么意思。

什么时候有用,什么时候浪费时间

这种方法在任何涉及分支逻辑、外部依赖或安全影响的地方都大放异彩。上面的webhook验证器就是完美例子。模型不可能知道你的threat model,除非你告诉它。

但对于样板代码,这就小题大做了。我不会为了生成一个React表单组件或标准的数据库迁移而去写500字。这些任务用捷径法就行,因为模式在training data里已经充分体现,而且stakes很低。

真正的陷阱在中间地带:那些看起来简单实则不然的任务。CSV解析器看起来直截了当,直到你遇到带逗号的引用字段、BOM标记或混合的line ending。跳过说明在这些任务上会让你事后付出最大的时间代价。

今天就能照搬的工作流

你不需要改变整个流程。本周选一个任务试试:

  1. 打开LLM对话,先写说明。先别提代码。
  2. 重读自己的说明。如果你讲不清edge case,停下来。你还没准备好写代码,更没准备好让LLM替你写。
  3. 在同一条消息里附上实现请求。
  4. 对照你自己的test case来审核输出,而不是凭直觉。

大多数人跳过的是第二步。如果你的说明含糊,LLM会把这种含糊以微妙缺陷的形式反射回来。说明是对你自己理解的smoke test。

真正的收益不在代码本身

30天后,我注意到一件意外的事。代码确实更好了,但我自己的设计能力提升得更快。写说明迫使你显式地命名自己的假设。这样做时,你常常会在写下第一行代码之前就意识到自己的思路是错的。

LLM还在负责打字。但思考的是你,而思考从来都是瓶颈。

如果你想亲自尝试,从下一个缺陷修复开始。在向LLM求助之前,先写两段话解释你认为发生了什么、为什么。你可能还没写完就已经解决了。