From bd74c82f2c323b5413d0a9461710cd515944b9a3 Mon Sep 17 00:00:00 2001 From: Lebing Xie Date: Fri, 12 Jun 2026 15:24:02 +0800 Subject: [PATCH] update tutorial chapters --- .gitignore | 2 + tutorial/assets/content.js | 6 +- tutorial/assets/styles.css | 144 +- tutorial/chapters/00-preface.html | 1290 ++------ tutorial/chapters/01-agent-loop.html | 1376 ++++----- tutorial/chapters/02-tools.html | 1354 +++++---- tutorial/chapters/03-todo.html | 1183 +++++--- tutorial/chapters/04-subagent.html | 1099 ++++--- tutorial/chapters/05-skill.html | 1016 ++++--- tutorial/chapters/06-compress.html | 1152 ++++--- tutorial/chapters/07-permission.html | 1126 ++++--- tutorial/chapters/08-hook.html | 1126 ++++--- tutorial/chapters/09-memory.html | 1076 ++++--- tutorial/chapters/10-cache.html | 1058 ++++--- tutorial/chapters/11-recovery.html | 1193 ++++---- tutorial/chapters/12-task.html | 1125 ++++--- tutorial/chapters/13-async-run.html | 1167 +++---- tutorial/chapters/14-schedule.html | 1209 ++++---- tutorial/chapters/15-hardening.html | 1175 ++++--- tutorial/chapters/eval.html | 2379 ++++++++++++--- tutorial/chapters/model-policy.html | 1125 +++---- tutorial/chapters/reference.html | 4206 +++++++++++++++++++++++--- 22 files changed, 16737 insertions(+), 9850 deletions(-) diff --git a/.gitignore b/.gitignore index f4ba552..3c64fab 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ skills/ web/temp/ doc/web-tutorial-plan.md doc/todo.md +tmp/ +.DS_Store diff --git a/tutorial/assets/content.js b/tutorial/assets/content.js index b7ad8b9..12b07de 100644 --- a/tutorial/assets/content.js +++ b/tutorial/assets/content.js @@ -134,8 +134,8 @@ export const chapters = { }, "model-policy": { number: "A", - title: "不同大模型不是只换模型名", - navTitle: "不同大模型不是只换模型名", + title: "换个模型, 不只换 baseURL", + navTitle: "换个模型, 不只换 baseURL", group: "topic", file: "./chapters/model-policy.html", ready: true, @@ -151,7 +151,7 @@ export const chapters = { reference: { number: "—", title: "Reference", - navTitle: "术语表、Prompt Pack 与验证手册", + navTitle: "设计模式", group: "reference", file: "./chapters/reference.html", ready: true, diff --git a/tutorial/assets/styles.css b/tutorial/assets/styles.css index 2ee8723..4432843 100644 --- a/tutorial/assets/styles.css +++ b/tutorial/assets/styles.css @@ -747,7 +747,8 @@ mark { } .figure { - margin: var(--space-6) 0; + margin: var(--space-8) auto; + max-width: 760px; } .figure figcaption { @@ -755,6 +756,7 @@ mark { color: var(--color-text-faint); font-size: var(--text-sm); line-height: var(--leading-snug); + text-align: center; } .loop-map, @@ -798,6 +800,146 @@ mark { .flow-map { display: grid; gap: var(--space-3); + justify-items: center; +} + +.flow-row { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: var(--space-3); + width: 100%; +} + +.flow-arrow { + color: var(--color-text-faint); + font-size: var(--text-lg); +} + +/* 居中变体: 单行带箭头, 居中对齐 */ +.flow-row--center { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: var(--space-3); + width: 100%; +} + +/* 居中变体: 树状 (1 父 → N 子) */ +.flow-tree { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-3); + width: 100%; +} + +.flow-tree__children { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: center; + gap: var(--space-4); + width: 100%; +} + +.flow-tree__branch { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--space-2); + flex: 1 1 200px; + max-width: 280px; +} + +.flow-tree__connector { + width: 2px; + height: var(--space-4); + background: var(--color-border-soft); +} + +/* 居中变体: 2 列对比 (左旧右新) */ +.flow-compare { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--space-4); + width: 100%; +} + +.flow-compare__col { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3); + border: 1px solid var(--color-border-soft); + border-radius: var(--radius-md); + background: var(--color-bg); +} + +.flow-compare__col--bad { + border-color: #e8b4b8; + background: #fdf2f3; +} + +.flow-compare__col--good { + border-color: var(--color-accent-soft); + background: var(--color-accent-bg); +} + +.flow-compare__label { + font-weight: 600; + font-size: var(--text-sm); + text-align: center; + color: var(--color-text-faint); + text-transform: uppercase; + letter-spacing: 0.08em; +} + +/* 居中变体: 层次栈 (上 → 下, 越来越具体) */ +.flow-stack { + display: flex; + flex-direction: column; + gap: var(--space-3); + align-items: center; + width: 100%; +} + +.flow-stack__layer { + display: flex; + flex-direction: column; + gap: var(--space-2); + padding: var(--space-3) var(--space-4); + border: 1px solid var(--color-border-soft); + border-radius: var(--radius-md); + background: var(--color-bg); + width: 100%; + max-width: 600px; +} + +.flow-stack__layer--stable { + border-color: var(--color-accent-soft); + background: var(--color-accent-bg); +} + +.flow-stack__layer--dynamic { + border-color: #b8d4e8; + background: #f0f6fb; +} + +.flow-stack__label { + font-size: var(--text-xs); + text-transform: uppercase; + letter-spacing: 0.08em; + font-weight: 600; + color: var(--color-text-faint); +} + +.flow-stack__arrow { + color: var(--color-text-faint); + font-size: var(--text-xl); + text-align: center; } .flow-row { diff --git a/tutorial/chapters/00-preface.html b/tutorial/chapters/00-preface.html index 59662cc..ee58314 100644 --- a/tutorial/chapters/00-preface.html +++ b/tutorial/chapters/00-preface.html @@ -1,1085 +1,293 @@

第 00 章 · 在写代码之前

-

用 LLM 写 LLM Agent 的元方法

+

在写代码之前: 让 LLM 写 LLM Agent 的元方法

- 这一章不写代码。它要回答一个比"如何实现 loop"更前置的问题: - 当你打算让 LLM 帮你写一个让 LLM - 持续工作的系统时,你自己要先想清楚什么? - 读完后,你应该能看懂后续所有章节的 Prompt Card 是怎么来的,也能判断 LLM - 帮你写出来的实现是不是真的"做对了"。 + 这一章不写代码。它要回答一个比"如何实现 loop"更前置的问题: + 当你打算让 LLM 帮你写一个让 LLM 持续工作的系统时, 你自己要先想清楚什么? + 读完后, 你应该能看懂后续所有章节的 Prompt Card 是怎么来的, + 也能判断 LLM 帮你写出来的实现是不是真的"做对了"。

- - - -
- + +

这门课的真正主题

- 表面上看,这是一本"从零实现 coding agent"的教程。往深一层看,它示范的是 - 如何与 LLM 协作完成一类特殊的工作:搭建承载 LLM 自身工作的环境。 这件事有三个特点,让它和普通"让 LLM 写个函数"不同: + 表面上看, 这是一本"从零实现 coding agent" 的教程。往深一层看, + 它示范的是如何与 LLM 协作完成一类特殊的工作: + 搭建承载 LLM 自身工作的环境。 这件事有三个特点, + 让它和普通"让 LLM 写个函数" 不同。

-
对象是 LLM 本身
-
- 你的最终用户是另一个 LLM。你写的接口、命名、错误信息,都是在教一个 - 模型"如何更好地使用你的代码"。这意味着好名字比好实现更重要。 +
对象是 LLM 本身
+
+ 你的最终用户是另一个 LLM。 你写的接口、命名、错误信息, 都是在教一个 + 模型"如何更好地使用你的代码"。 这意味着好名字比好实现更重要: + 一个叫 run_bash 的工具, 比一个叫 execute_command_async_with_callback + 的工具更可能让模型用对。
-
现场是长生命周期
-
- 一个 harness 跑几小时甚至几天,中途会有多轮对话、工具调用、异常恢复。 - 你的代码必须能在"现场丢失"后从历史里重新拼出语义。这是普通 Web - 后端不太需要关心的事。 +
现场是长生命周期
+
+ agent 跑起来之后, history 会一直增长, 文件会被一直改, + 临时状态会一直累积。 你写的每一行代码都要考虑"30 分钟后 + 还在跑" 的场景, 而不是"调用一次就返回" 的普通函数。
-
失败模式是隐性的
-
- 一次跑通不等于实现正确。LLM 生成的代码可能"看起来对、用起来也跑、 - 但悄悄把上下文弄丢"。你必须靠 trace 而不是肉眼判断它是否真的做对了。 +
反馈是定性的, 不是定量的
+
+ "模型调错了工具" 没有 stack trace, 只有一段不像人话的回复; + "上下文爆炸" 表现为成本上升 10 倍, 但你看到的还是一段 + 普通的 prompt。 调试这类系统, 不能依赖传统的 error / log / test。
+

与 LLM 协作的 3 种工作流模式

- 知道这三点,你就不会把这一章当成"普通项目实战",而会理解为什么后面的 Prompt Card - 要写成"目标/场景/模块/接线/边界/验证"六件套 —— 它是为了 - 强迫你自己在写代码前先把"对象/现场/失败模式"想清楚。 -

- -

与 LLM 协作的 4 个动作

-

- 接下来 16 章的每一章,本质都在重复同一个循环。把它单独拎出来, 你就能识别 LLM - 在哪个环节最容易骗你。 -

-
    -
  1. - 想清楚一现象。这一章要解决什么具体问题?用一段话写出来, - 写到"我能给一个非工程师讲明白"为止。 -
  2. -
  3. - 想一个反例。如果不解决,最朴素的实现会长什么样?为什么它 - 不行?这一步是为了逼自己定义"边界"而不是"功能"。 -
  4. -
  5. - 想清楚接口和不变量。哪些模块、哪些函数、哪些"绝对不能 - 被破坏"的规则。这一步是为了让 LLM 知道"改哪里是安全的"。 -
  6. -
  7. - 想清楚怎么验证它做对了。用 fake LLM、用 trace assertion、 - 用 e2e 测试,而不是只靠"它没报错"判断。 -
  8. -
-
-

这一章的最低目标

-

- 读完后,你能看懂后续每一章为什么是这四步的循环,也能识别出 LLM - 在哪一步上容易"看起来做完了"但其实偷懒。 -

-
- -

术语地图: 把后面反复出现的词钉死

-

- 下面这些词在后续章节会反复出现。第一次出现时,我会用 - English (中文释义) 的格式;之后只用英文。请你在自己的笔记里 - 也保持这套对应,不要中途换说法。 + 写一个"让 LLM 持续工作" 的系统, 我们和 LLM 协作的方式有 3 种。 + 这一节先讲清楚模式, 后面的章节都按其中一种或多种协作。

-
harness (外层运行环境)
-
- LLM 之外、替它保管现场、执行工具、约束副作用、记录事实的所有代码。 - 本教程的全部代码都属于 harness。 -
- -
agent loop (主循环)
-
- "用户输入 → 写历史 → 调 LLM → 写回复 → 决定下一步"这条反复执行的路径。 简称 - loop。本教程主线索就是 loop 一圈一圈地长。 +
模式 1 · 一次性原型
+
+ 你提需求, LLM 一次性写出 200 行 TypeScript, 跑通, 收工。 + 适合探索性 demo, 不适合长期演进。 坏处: 改第 3 个需求时, + LLM 会把前 2 个需求的实现搞乱, 因为它没有持续记忆。
- -
History (消息列表)
-
- 当前会话的全部 message (user / assistant / tool / system),它的唯一职责 - 是构造下一次 LLM 请求。不要把"日志""审计""可观测"塞进 History,它们 由 - Transcript 单独承担 (第 15 章会讲)。 -
- -
tool call (工具调用请求)
-
- LLM 返回的结构化动作请求,而不是文本里的"我想读这个文件"。 对应 LLM 协议里的 - tool_use / function_call 字段。 -
- -
- Composition Root (组装根, 通常是 src/index.ts) -
-
- 创建共享依赖 (history / llm / - terminal) - 并把它们传给各模块的位置。业务分支一旦塞进这里,后续测试和子智能体 都会变难。 -
- -
fake LLM (假模型)
-
- 测试时替代真实 LLM 的对象:能预设返回文本、记录所有收到的 messages、 断言 - messages 顺序。本教程要求每一章都用 fake LLM 验证,而不是只跑 e2e - 看最终输出。 -
-
-

- 还有一些更细的术语会在用到时引入 (例如第 02 章的 tool registry、 - 第 06 章的 normalize / block / compress、第 10 章的 - cache-friendly prefix)。届时同样采用"首次出现钉死,之后只用英文" - 的规则。 -

- -

防自欺: 这门课里 4 句不能信的话

-

- vibe coding 最大的陷阱不是写不出代码,而是"看起来写完了"。下面 4 句话 - 在后续章节会反复以各种形式出现,请你把每一条都当成红灯。 -

-
-
- 红灯 1 · 跑通 ≠ 正确 -
-
-

- 常见说法:"我跑了一次,模型回了正确文本,说明 loop - 工作了。" -

-

- 为什么错:模型回正确文本,可能是因为这一轮 messages 凑巧 - 包含全部所需信息。一旦轮次增加、上下文被压缩、或者 History 被某个 bug - 弄丢,问题才会暴露。 -

-

- 正确做法:用 fake LLM 断言"第二轮 LLM 收到的 messages - 长度 == N、最后一条 role == user、第一条 role == user"这类结构性事实, - 而不只是断言最终文本。 -

-
-
- -
-
- 红灯 2 · 测试通过 ≠ 设计合理 -
-
-

常见说法:"vitest 跑过了,这个模块就完成了。"

-

- 为什么错:测试只覆盖了你想到的 case。harness 的真正考验 - 是"多轮 + 异常 + 并发 + 长时间"。这些场景在 happy path 测试里几乎 - 不会触发。 -

-

- 正确做法:把测试分成两层: 一层是 fake LLM 的"messages - 顺序断言",另一层是真实 e2e 的"行为断言"。后者要故意包含异常恢复 - 和上下文压缩。 -

-
-
- -
-
- 红灯 3 · LLM 说"做完了" ≠ 真的做完了 -
-
-

- 常见说法:"我让 LLM 实现工具系统,LLM 回我'已完成', - 我去看了下文件都在。" -

-

- 为什么错:LLM 倾向于"把请求理解为已经满足"。它会跳过 - 你没显式要求的部分,例如权限检查、错误返回结构、History 写入顺序。 -

-

- 正确做法:Prompt Card 里"边界"和"验证"两节必须写得 像 - checklist —— 越具体越好,不要写"注意权限"这种空话,要写 "执行 rm - 前必须确认路径在白名单内,否则抛 PermissionError"。 -

-
-
- -
-
- 红灯 4 · Prompt Card 漂亮 ≠ 实现漂亮 -
-
-

常见说法:"我卡片写得很完整,实现应该也漂亮。"

-

- 为什么错:Prompt Card 是给 LLM 的"需求文档",但 LLM - 仍然可能在命名、模块拆分、依赖方向上做出和你想象不同的选择。 -

-

- 正确做法:LLM 给的实现要先做"逆向核对": 把每个 - 文件的职责读一遍,确认它和卡片"模块/接线/边界"三节里描述的一致。 - 不一致的地方,要么改实现,要么回卡片里把规则说更死。 -

-
-
- -

Vibe Coding 方法论: Prompt Card 怎么交给 LLM

-

- 6 段 Prompt Card 解决了"如何把需求说死",但还有另一半没解决: - 如何把这张卡片交给 LLM,让它真的按卡片交付、而不是用漂亮文本交差。 这一节是教程的元方法,后续每一章末尾都会有 "本次如何 vibe code" 三件套 - (拆卡 / review / 迭代) 复述这里的规则。 -

- -

拆卡: 不要一次性全给

-

- 6 段 Prompt Card 是一张总图,不是一次 prompt 的全部内容。把 6 段一次性 贴给 - LLM,会触发两种典型失败: -

-
-
上下文溢出与失焦
-
- 一次性贴 200 行 prompt,LLM 会在"模块 / 接线 / 边界 / 验证"中轮换注意力, - 最终通常只在它最后读到的那段做对。 +
模式 2 · 迭代增量 + 测试守门
+
+ 你提需求, LLM 写代码 + 写测试, 测试通过才合并。 + 适合中型项目。 关键纪律: 测试必须自己会写, + 不会写测试时让 LLM 写测试, 等于让 LLM 既当选手又当裁判。
-
无法回滚
-
- 一次性给完,LLM 一次性写完,你只能整体 reject,无法定位"它哪一段开始走偏"。 - 这正是第 00 章红灯 4 "Prompt Card 漂亮 ≠ 实现漂亮" 的具体发作场景。 +
模式 3 · 大纲先行 + 章节式实现
+
+ 你先写一份 6 段式 Prompt Card (后面会讲), 钉死抽象边界; + LLM 拿到卡片后, 按章节增量实现, 每章都有独立验收。 + 适合教学项目和大型重构。 本教程走的就是这种模式。

- 推荐的拆卡顺序是 接口 → 接线 → 边界 → 验证,四轮迭代, - 每轮独立可验证: -

-
    -
  1. - 第 1 轮 · 接口:只贴"目标 + 场景 + 模块",请 LLM 给出 - interface 草案 (例如 interface History、 - interface LLMClient)。 这一轮不写实现,只钉形状。 -
  2. -
  3. - 第 2 轮 · 接线:贴"模块 + 接线",请 LLM 给出 - index.ts 的接线代码。注意此时 createHistory - 等工厂还是 stub,返回任意对象即可。 -
  4. -
  5. - 第 3 轮 · 边界:贴"边界 (checklist)",请 LLM 按 checklist - 实现每个工厂的真实逻辑。这一轮是 LLM 偷懒的重灾区, review checklist - 全部要逐条核对。 -
  6. -
  7. - 第 4 轮 · 验证:贴"验证 (vitest 断言清单)",请 LLM - 写测试。优先让它写 fake LLM + messages 顺序断言, 再写 happy path e2e。 -
  8. -
-
-

为什么不一次给完

-

- 四轮迭代的核心是"每轮交付都有独立可验证产物": 第 1 轮交付 interface 草案, 你 - review 命名; 第 2 轮交付接线图, 你 review 依赖方向; 第 3 轮 交付工厂实现, 你 - review checklist; 第 4 轮交付测试, 你跑测试。任一轮 不通过, - 都可以单独回退而不必推翻全部。 -

-
- -

Review: AI 写完一段代码后,看什么

-

- LLM 给的实现不是"读一遍"就能判断对错的, 也不是"跑一遍测试"就能判断 对错的 - (红灯 1)。这一节给出一份通用 review checklist, 适用于每一章、 每一段 LLM - 生成的代码。 -

-
-
- 通用 review checklist · V1 -
-
-

依赖方向 (Composition Root 反向检查):

-
    -
  • - agent.ts / 子智能体 / tool executor 内不出现 - new LLMClient / new OpenAI() -
  • -
  • - agent.ts / REPL 内不出现 process.env 读取 (配置走 - config.ts) -
  • -
  • - 不存在 module-level 单例依赖 (例如 - export const history = createHistory();) -
  • -
  • 工厂函数返回 interface 而不是 class instance, 便于 fake 注入
  • -
-

边界 (Prompt Card checklist 逐条核对):

-
    -
  • - checklist 里每条"绝对不能"都对应一行 grep 验证 (例如 - grep -n 'process.env' src/agent.ts 应当 0 行) -
  • -
  • 边界对应到 validation 里的至少一条 vitest 断言
  • -
-

命名 (给 LLM 看的名片):

-
    -
  • - 导出符号命名稳定, 不会因为 LLM 自由发挥就改名 (例如不能 - createHistory 写到一半变成 createMessageStore) -
  • -
  • - interface 名不带实现细节 (不要 interface HistoryImplinterface HistoryV2) -
  • -
-

副作用 (隐性失败):

-
    -
  • - getMessages / getEntries / - getConfig 之类返回集合的方法都返回浅拷贝或冻结, - 不是内部引用 -
  • -
  • - 没有"读外部文件"隐式行为 (例如 - createAgent 工厂函数内不应当读 package.json) -
  • -
  • - 工具执行、文件写入、命令执行前都有明确的 permission 边界 (第 02 / 07 - 章会展开) -
  • -
-
-
-

- 上述 checklist 适用于任何章节。每一章还会给出"本章专属"的几条, 例如 01 - 章的"getMessages 返回浅拷贝"、02 章的"tool call 与 tool result 配对"、07 - 章的"permission 在工具执行前同步插入"。 -

- -

调试: AI 常常"假装"实现了

-

- LLM 写代码时最危险的失败不是"写错", 而是 - "看起来对、跑得通、但其实在偷偷绕过"。 - 这一节给出一份"假装清单",帮你识别三种最常见的伪装。 -

-
-
- 伪装 1 · 假装实现了 tool call -
-
-

- 症状:agent 跑通, 模型能"读文件", 但 - history.getMessages() 里没有任何 - role: "tool" 消息。 -

-

- 怎么发生的:LLM 倾向把 tool call 写成"读取文本中的命令行", - 在 agent 内部用 exec() 跑, 再把输出塞回 - user 消息。 它跑得通, 模型也"知道"读到了什么, 但 messages - 序列错了, 后续 compress / replay 全部坏掉。 -

-

- 怎么验证:fake LLM 强制返回 - { role: "assistant", content: "", tool_calls: [...] }, - 跑完一轮后断言 - history.getMessages().some(m => m.role === "tool")。 - 这是第 02 章 Validation 卡片必含的一条。 -

-
-
- -
-
- 伪装 2 · 假装做了边界检查 -
-
-

- 症状:代码里能看到 - if (!fs.existsSync(...)) 之类的 守卫, 但实际跑起来仍然越界。 -

-

- 怎么发生的:LLM 在错误位置加守卫 (例如权限检查写在工具 - 内部 if 里, 而不是 agent 主循环), 或者只检查了 happy path - 一个分支。 -

-

- 怎么验证:Prompt Card 的 checklist 每条都要对应到"反向 - 输入"的测试 (例如 "空字符串 query 不写入 history" 对应 - run("") 测试)。边界检查必须在调用栈更外层, 这条要写进 边界 - checklist 里, 不能让 LLM 自由决定位置。 -

-
-
- -
-
- 伪装 3 · 假装有测试 -
-
-

- 症状:仓库里有 *.test.ts 文件, vitest 跑过, - 但实际只测了"返回非 undefined"。 -

-

- 怎么发生的:LLM 喜欢写 happy path 断言, 不会主动写 - 反向断言或顺序断言。 -

-

- 怎么验证:每章 Validation 卡片里至少包含一条"顺序断言" - (例如 "messages.length === 3") 和一条"反向断言" (例如 "外部 push 不影响 - history")。红灯 2 "测试通过 ≠ 设计合理" 的具体发作场景。 -

-
-
- -

迭代节奏: 写多少测多少, 不一次写完

-

- vibe coding 最大的诱惑是"让 LLM 一次写完整个模块"。这几乎一定踩坑。 推荐节奏是 - 小步提交, 每步可测, 不让上下文长到无法 review。 -

-
    -
  1. - 每写完一个工厂, 跑一次该工厂的测试。 - 不要等所有工厂都写完才跑测试 —— 早期失败定位成本是后期的 1/5。 -
  2. -
  3. - 每跑通一个 Validation 条目, 在 commit message 里引用对应 ID。 - 例如 - feat(history): 实现 add/getMessages, 满足 V-01 章 Validation #3。 这样 git log 就是一份"vibe coding 进度表"。 -
  4. -
  5. - 每章结束前, 把"差量表"(本章新增/修改文件)与实际 diff 对一遍。 - LLM 经常顺手"优化"你没要求改的代码 (例如把 terminal.ts - 重命名)。差量对账是发现这种偷偷越界的唯一办法。 -
  6. -
  7. - 每章结束前, 把 fake LLM 跑一次并保留输出。 - 这一步等价于把"messages 顺序"事实存档, 后续章节会反复引用 (例如 02 - 章会断言"第二轮 messages 末尾是 tool message, 不是 user message")。 -
  8. -
-
-

红灯 1 / 2 / 3 在迭代节奏里的应用

-

- 红灯 1 (跑通 ≠ 正确) 的对策: 顺序断言 + 反向断言。红灯 2 (测试通过 ≠ - 设计合理) 的对策: 差量对账 + boundary checklist 逐条 grep。红灯 3 (LLM - 说做完了 ≠ 做完) 的对策: 拆卡 4 轮迭代 + 每轮独立 review。 后续 01–15 章的 - "本次如何 vibe code" 三件套, 会把这 3 盏红灯翻译成 本章具体的 3 步操作。 -

-
- -

Prompt Card 写法: 让 LLM 没法偷懒的 6 段模板

-

- 后续每一章末尾都会有一张 Prompt Card。这张卡片不是"prompt 润色", 而是"把第 1 - 节到第 4 节的思考压成可交给 LLM 的格式"。它有 6 段, 缺一段都会让 LLM - 在某个位置偷懒。 -

-
    -
  1. - 目标 (Goal): 一句话讲清这一章要让 LLM 帮你交付什么。 - 不写"完成 X 模块",写"实现 X, 使得 Y 测试通过"。 -
  2. -
  3. - 场景 (Scene): 给出 1 个具体用户请求 + 期望的 agent - 行为。不要写"一般情况下",写"用户输入'帮我读 agent.ts',agent 应当先 read - tool、再把内容拼到回复里"。 -
  4. -
  5. - 模块 (Modules): 列出这一章要新建/修改的文件, - 写明每个文件的职责。名字要稳定,不要让 LLM 自由发挥。 -
  6. -
  7. - 接线 (Wiring): 写明 Composition Root 怎么创建共享 - 实例、传给哪些模块。这一段是防止"业务逻辑塞进 index.ts"的关键。 -
  8. -
  9. - 边界 (Boundaries): 用 checklist 写"绝对不能做"的事。 - 每条都要可验证。例如"agent.ts 内不要直接 new LLMClient", 而不是"注意架构"。 -
  10. -
  11. - 验证 (Validation): 至少 3 条具体测试断言,包括 happy - path、异常路径、和 messages 顺序断言。 -
  12. -
-
-
- 反例对照 · 差 → 改 → 好 -
-
-

差的卡片 (5 段、但 LLM 会偷懒):

-
目标: 实现 agent loop
-模块: history.ts, llm.ts, agent.ts, index.ts
-验证: 能跑通
-边界: 注意架构
-场景: 用户输入 query, agent 调用 LLM 返回文本
-

- 问题:"能跑通"不是断言;"注意架构"不可验证; "调用 - LLM"没说从哪里调、messages 怎么拼。 -

-

改 (5 段、有信息量但 LLM 仍会猜):

-
目标: 实现最小 agent loop, 多轮对话保留上下文
-模块: createHistory, createLlm, createAgent, createRepl, index.ts
-验证: 连续两次 run 后第二次 LLM 收到 messages 包含第一轮
-边界: agent.ts 不读环境变量, 不直接 new LLM client
-场景: 用户先说"我喜欢简洁", 再问"我喜欢什么风格"
-

改进:有 messages 顺序断言、有"绝对不能"的边界。

-

好 (6 段、LLM 没空间偷懒):

-
目标: 实现最小 agent loop, 多轮上下文由 History 提供
-场景: 用户依次输入 "我喜欢简洁" 与 "我喜欢什么风格",
-      agent 第二次回复应包含"简洁"
-模块:
-  - src/history.ts: createHistory(), 内部 messages: Message[]
-  - src/llm.ts: createLlm(config), 暴露 chat(messages)
-  - src/agent.ts: createAgent(deps), 暴露 run(query)
-  - src/repl.ts: createRepl(deps), 暴露 start()
-  - src/index.ts: 创建 history/llm/terminal, 传给 agent 和 repl
-接线:
-  index.ts 内只做 new + 传参, 不出现 if 分支
-  history / llm 在 agent 和 repl 间是同一实例
-边界 (LLM 必须遵守):
-  - agent.ts 内不出现 process.env
-  - agent.ts 内不出现 new LLMClient
-  - history.getMessages() 返回浅拷贝
-  - 空 query 不写入 history
-验证:
-  - fake LLM 返回 "收到" 时 agent.run("x") === "收到"
-  - 连续两次 run, fake LLM 第二次收到的 messages.length === 3
-  - 第二次收到的 messages[0].role === "user"
-  - run("") 不增加 history 长度
-

- 关键差异:"边界"是可枚举的 checklist; "验证"每条 都能落到 - vitest 一行断言; "接线"写明实例是不是同一份 (避免双 factory - 造成状态分裂,这是 AGENTS.md 里特别强调的)。 -

-
-
-

- 后续章节的 Prompt Card 都会按这个 6 段模板写。请你在自己的项目里也照 - 这个模板,不要自由发挥。模板本身就是把"对象/现场/失败模式"翻译成 LLM - 能照搬的形式。 -

- -

你已经知道什么,还不知道什么

-

- 这门课不会从变量、函数、模块这些编程基础讲起。我们默认你知道 TypeScript - 项目大概如何组织,也知道可以通过 HTTP API 或 SDK 调用大模型。你可能已经 让某个 - coding agent 帮你改过代码,甚至已经习惯了让它跑测试、读报错、 继续修。 -

-

- 但这里要补的是另一层知识: 一个 coding agent 到底靠什么把"模型生成的 - 下一步意图"变成"真实工程动作"?为什么它不是简单的 - await llm.chat(query)?为什么它需要 history、tool call、 - permission、日志、持久化和 eval? -

-

- 接下来,我们先建立对"普通 LLM 调用"和"coding agent harness"差别的 - 直观认识,再真正落到 TypeScript 模块 (第 01 章)。 -

- -

本章怎么学

-

- 接下来分两步。先做一个朴素的 LLM 调用,看它在哪里断;再回到 agent loop - 的最小骨架,明确 History 怎么写、messages 怎么拼、assistant 怎么回写。 第 01 - 章会把这个骨架用真实 TypeScript 模块搭起来。 -

-

- 读这一段时,请一直问自己: 如果 LLM 本身是无状态的,外层程序到底要 - 替它保存什么、执行什么、验证什么?这个问题会贯穿后面所有章节。 -

- -

本章场景

-

- 先想一个很普通的请求: 用户说"帮我记住, 我喜欢简洁直接的解释"。 - 下一轮用户又问:"我刚刚说我喜欢什么风格?"如果只是一次普通 LLM 调用, - 第二次请求里并没有第一句话,模型其实无从知道答案。 + 模式 3 看起来最慢, 但返工率最低。 因为大纲阶段 + 把抽象边界钉死了, 后续 LLM 写出来的代码会自动落到 + 既定的接口里, 不会跑偏。 而模式 1 看起来最快, 但 + 第 3 个需求之后的改造成本, 通常超过模式 3 的 3 倍。

+

6 段 Prompt Card 模板

- 再想一个更像 coding agent 的请求: 用户说"帮我看看项目里主循环是怎么 - 写的"。模型自己没有文件系统,它不能真的打开 - src/agent.ts。它最多能生成"我想读这个文件"的意图,真正读 - 文件的是外层程序,也就是 harness。 + 这是整套教程的"元方法" — 每章末尾的 Prompt Card 都按这个模板写, + 你也可以照着这张卡片自己 vibe 出新功能。

-

- 这就是我们要手搓 coding agent 的原因: 我们不是训练一个新模型, - 而是在模型外面搭一套运行环境,让它能记住上下文、提出动作、接收观察 - 结果,并在安全边界内持续工作。 -

- -

先试一个朴素方案

-

- 有经验的程序员第一反应可能是: 这不就是包一层 LLM API 吗?写成这样 似乎就能跑。 -

-
async function ask(query: string) {
-  const response = await llm.chat([
-    { role: "user", content: query },
-  ]);
-
-  return response.content;
+
// 教学简化版, 真实模板见后续章节
+Prompt Card = {
+  目标:        "用户问什么, 我们让 LLM 写什么"
+  场景:        "具体用户故事, 一段对话能讲清"
+  模块:        "新增/修改哪些文件, 每个文件单一职责"
+  边界:        "LLM 必须遵守的 checklist (5-7 条)"
+  验证:        "怎么跑 fake LLM 确认实现没坏"
+  Prompt:      "可以直接复制粘贴给 LLM 的整段 prompt"
 }

- 这段代码对"翻译一句话""解释一个概念"这类任务确实够用。它的优点是 直观: - 输入一段文本,调用一次模型,返回一段文本。问题是, coding agent - 的任务通常不是一次问答,而是一个不断积累现场、决定动作、观察 - 结果、再决定下一步的过程。 + 6 段顺序是不能换的。 "目标" 先定方向, "场景" 再具体化, + "模块" 才落到代码, "边界" 把不允许的行为钉死, "验证" 闭环, + "Prompt" 整段可复用。 任何一段缺失, 都会让 LLM 写出来的东西跑偏。

- -

朴素方案为什么不够

-

上面的朴素方案至少会在四个地方失败。

+

4 个常见反模式

-
它没有记忆现场
-
- 第二次调用时,第一轮用户说过什么、模型回答过什么都不在请求里。 LLM API - 本身是无状态的; 如果你不把旧消息重新发过去,它不会自动记得。 +
反模式 1 · 只写 "目标" + "Prompt"
+
+ 跳过模块 / 边界 / 验证。 LLM 写出来的代码, 命名不统一, + 还会引入和现有模块不一致的接口。 修起来比从零写还慢。
- -
它不能行动
-
- 模型无法直接读文件、写文件或执行命令。coding agent 需要工具系统, - 让模型提出结构化动作请求,再由 harness 执行。 +
反模式 2 · "边界" 写得太抽象
+
+ 写"代码要清晰" 这种空话。 LLM 不知道什么叫清晰。 + 必须写"工厂模式, 闭包内状态, 不引入 module-level 单例" + 这种可验证的约束。
- -
它没有安全边界
-
- 如果模型说"删除这个目录",程序不能无条件照做。工具执行前必须有 permission - 检查、路径边界和危险命令过滤。 +
反模式 3 · "验证" 写"跑通就行"
+
+ LLM 会给你一个"刚好能跑" 的实现, 边界条件全是漏的。 + 必须写"fake LLM 第一轮调 X, 第二轮不应调 X" 这种 + 显式断言
- -
它不可验证
-
- 如果只看最终文本,你很难知道模型有没有真的读取文件、有没有按顺序 保留 - history、有没有绕过权限。后面我们会用 trace 和 eval 记录行为事实。 +
反模式 4 · 把整章 prompt 塞一张卡片
+
+ Prompt Card 是单元, 不是"整章描述"。 + 每章通常 3-5 张卡片, 每张卡片对应一个独立可验证的差量。
- -

Coding Agent 到底是什么

-

在这门课里,我们先把 coding agent 看成一个工程系统,而不是一个神秘模型。

-
-

一个实用定义

-

- coding agent = LLM + agent loop + tools + context + permission + - persistence + eval。 -

-

- LLM 负责推理和生成意图; agent loop 负责让它持续工作; tools - 让它接触文件系统和命令行; context 让它记住现场; permission 约束副作用; - persistence 保存长期状态; eval 证明行为不是偶然跑通。 -

-
-

- 你可以把 LLM 想成"脑",但这个脑没有手、没有文件系统、不会自动保留 - 对话历史,也不知道哪些命令危险。harness 就是给它配上的工作台: - 记事本、工具箱、安全规则、执行记录和测试仪表盘。 -

- -

回到 Agent Loop

+

教学叙事的 4 步节奏

- 最小 agent loop 先不打开工具分支,只处理"用户输入 → 模型回答 → - 进入下一轮"的路径。即使如此,它也已经比普通聊天调用多了两个关键 动作: 写入 - history,以及从 history 重新构造 messages。 + 每章正文都按同一个节奏写, 你顺着读就会形成预期。

-
- -
- 图 00-1 · 第 00/01 章只实现没有工具的路径; 第 02 章会把"assistant 请求 tool - call"这条分支接上。 -
-
- -

一次真实运行 walkthrough

-

我们用一个没有工具调用的多轮对话,走一遍最小 loop。

-
    -
  1. 用户在 REPL 输入: "记住我喜欢简洁直接的解释。"
  2. -
  3. REPL 不调用模型,只把这行文本交给 agent.run(query)
  4. -
  5. - Agent 把它包装成 { role: "user", content: "..." },写入 - History。 -
  6. -
  7. - Agent 从 History 取出 messages; 如果已经设置 system prompt,也会把 system - message 放在最前面。 -
  8. -
  9. - LLM Client 把 messages 发给模型,模型返回 assistant message: - "好的,我会尽量简洁直接。" -
  10. -
  11. - Agent 把 assistant message 写回 History。现在 History 里有一轮 user 和 - assistant。 -
  12. -
  13. - 用户第二次输入: "我刚才说我喜欢什么风格?" Agent 再次把新 user message 写入 - History。 -
  14. -
  15. - 这一次发给 LLM 的 messages 包含上一轮内容,所以模型可以回答: - "你说你喜欢简洁直接的解释。" -
  16. -
-

- 注意这里的关键点: 所谓"记住",不是模型在服务器端替你保存了什么, 而是 harness - 在下一次请求时把必要历史重新组织进 messages。 -

- -

关键接口和伪码

-

- 本章不需要贴完整源码,但要先把接口形状定下来。接口越清楚,后面让 coding agent - 实现时越不容易把所有逻辑糊进一个文件。 -

-
interface History {
-  add(message: Message): void;
-  getMessages(): Message[];
-  clear(): void;
-}
-
-interface LLMClient {
-  chat(messages: Message[]): Promise<AssistantMessage>;
-}
-
-interface Agent {
-  run(query: string): Promise<string>;
-}
- -

最小 loop 的伪码可以先短到这样:

-
async function run(query: string) {
-  history.add({ role: "user", content: query });
-
-  const messages = history.getMessages();
-  const assistant = await llm.chat(messages);
-
-  history.add(assistant);
-  return assistant.content;
-}
-

- 这段伪码已经埋下了后续扩展点: 当 assistant 里出现 tool call - 时,不能直接返回文本,而要执行工具、写入 tool result,再进入下一轮 LLM 调用。 -

- -

源码地图

-

- 下面 5 个链接指向 GitHub main 分支的 - 当前最新代码, - 而非历史快照。教程正文里描述的接口/函数名/职责边界基于本仓库的稳定设计, - 不会随每次提交漂移; 但具体实现会持续演进, 学生读到时可能看到比教程更 - 多的字段和分支 (例如 History.add 已演进为 - add(message, meta?))。 第一次阅读时, - 建议只看"导出符号"和"职责边界", 不要逐行对位。 -

-