# Step 04: 异步编程基础

一句话导读:接真实 API 之前的一堂「课外补习」——用 setTimeout 模拟一个会延迟、会失败的 API,把 Promise、async/await、Promise.all、Promise.race 四个模式在真实 REPL 里练一遍,为后面所有 await 打地基。


# 一、这一步做了什么(What)

step04 在 step03 的 REPL 上新增 utils/api.ts,里面用 setTimeout 模拟异步调用:callAI(延迟返回,含错误关键词就抛异常)、callMultiple(Promise.all 并发)、callWithTimeout(Promise.race 超时)。输入 demo 会跑一遍串行 vs 并行的耗时对比。核心不是功能,是把异步的四个基本盘吃透。


# 二、面试官视角:为什么 AI 应用「万物皆异步」?(Why)

面试题:Claude Code 里几乎每个操作都要 await。是设计者偏爱异步,还是被逼的?

是被逼的,而且逼得有道理。Claude Code 的核心操作全是 I/O:

  • API 调用 → 网络请求(几百毫秒到几秒)
  • 文件读写 → 磁盘 I/O
  • Bash 执行 → 子进程
  • 工具调用 → 上面几种的组合

I/O 的共同点是慢,且 CPU 在等待期间无事可做。如果用同步阻塞,程序在等 API 返回的那两秒里会彻底冻住——不能响应 Ctrl+C、不能渲染 loading、不能并发发起下一个请求。Node 是单线程的,一次同步阻塞 = 整个进程罢工。

方案 等 API 的 2 秒里 后果
同步阻塞 进程完全冻结 无法响应任何事件,并发不可能
异步 await 事件循环照转 可渲染 loading、可并发、可中断

所以「万物皆异步」不是风格选择,是单线程 + I/O 密集这两个约束的必然结论。这一步不先练熟,后面每个 await 都会踩坑。


# 三、原理:四个模式各解决什么问题(How)

# 1. Promise —— 「未来的值」的容器

export async function callAI(userInput: string, delayMs = 800): Promise<string> {
  await setTimeout(delayMs);            // 模拟网络延迟
  if (userInput.includes('错误')) throw new Error('模拟 API 调用失败');
  return `(模拟回复) 你说了 "${userInput}"`;
}
1
2
3
4
5

Promise 有三态:pending → fulfilled(resolve/return)或 rejected(throw/reject)。它把「现在还没有、未来会有」的值装进一个盒子,让你能对「尚不存在的结果」提前编排后续动作。

# 2. async/await —— 让异步代码长得像同步

const reply = await callAI(input);  // 暂停在这,但不阻塞事件循环
1

await 暂停的是当前这个函数,不是整个进程。函数在此挂起、把控制权还给事件循环,等 Promise 完成再从这一行继续。这就是为什么 REPL 的 while(true) 能一边 await 一边不卡死。

# 3. Promise.all —— 并发,取「最慢的那个」

export async function callMultiple(tasks: string[]): Promise<string[]> {
  return Promise.all(tasks.map((task, i) => callAI(task, 300 + i * 100)));
}
1
2
3

三个任务同时发出,总耗时 = 最慢那个,不是三者之和。这正是 Claude Code 同时执行多个独立工具的方式。代码里的 demo 实测:3 个 300ms 任务,串行 ~900ms,并行 ~300ms,快 3 倍。

# 4. Promise.race —— 超时保命

export async function callWithTimeout(userInput: string, timeoutMs: number) {
  return Promise.race([
    callAI(userInput, 2000),                              // 慢任务
    setTimeout(timeoutMs).then(() => { throw new Error(`请求超时 (${timeoutMs}ms)`); }),
  ]);
}
1
2
3
4
5
6

race 谁先完成取谁。把「真任务」和「一个定时抛错的计时器」放进 race,真任务超时未完成,计时器就先 reject——超时机制就这么实现。Claude Code 的 Bash 工具默认超时就是这个套路。

# 串行 vs 并行的时间账

串行:任务A(300ms) → 任务B(300ms) → 任务C(300ms) = 900ms
并行:任务A ┐
      任务B ├─ 同时跑 = max(300,300,300) = 300ms
      任务C ┘
1
2
3
4

# 四、深入追问(面试常见 follow-up)

Q:await 和 Promise.all 都跟异步有关,它们是一回事吗? A:不是,这是最常混淆的点。await 表达的是异步(不阻塞地等一件事),本身是串行的——连续 await 三次,就是一个接一个等。Promise.all 表达的是并发(同时等多件事)。「异步」是「不阻塞」,「并发」是「同时进行」,两者正交。串行 900ms 就是因为写成了三次 await;改成 Promise.all 才降到 300ms。

Q:三个任务里有一个失败,Promise.all 会怎样? A:Promise.all 是快速失败——任一 Promise reject,整体立即 reject,其余任务的结果被丢弃(任务不会被取消,只是结果不要了)。如果想「无论成败都拿到全部结果」,得用 Promise.allSettled。选哪个取决于业务:并发调多个工具时,一个失败往往希望整体报错重来,all 合适。

Q:为什么「任何外部操作都要有超时」被当成铁律? A:因为外部依赖(网络、子进程、远端 API)随时可能永远不返回——对端挂了、网络黑洞、进程僵死。没有超时,await 就会永久挂起,用户看到的是「程序卡死且无法中断」。超时把「无限等待」转成「有界失败」,失败了还能重试或报错。它是分布式/IO 系统的保命符,不是可选项。

Q:异步错误怎么捕获?和同步错误一样吗? A:只要用 async/await,就完全一样——try/catch 同时接住同步异常和 await 的 rejection。代码里 try { await callAI('触发错误') } catch(e) {...} 就是。这是 async/await 相对裸 Promise .catch() 的最大好处:错误处理心智模型统一了。


# 五、设计权衡

这一步用 setTimeout 模拟 API,是刻意的——真实 API 又慢又花钱又要 Key,拿来练四个模式成本太高、反馈太慢。用模拟版可以秒级复现串行/并行/超时/失败四种场景,把异步肌肉练熟。到 step06 再把这个 callAI 换成真实 SDK,接口形状不变,无缝替换。先在沙盘上练兵,再上真实战场。


# 六、与真实源码的对照

我们的实现 Claude Code 源码
callAI 模拟延迟 services/api/ 真实请求 + 重试
callMultiple (Promise.all) 并发执行多个独立工具
callWithTimeout (Promise.race) Bash 工具默认超时机制
try/catch 统一错误 引擎层统一异常处理

# 七、一句话总结

Step 04 = 异步四件套的沙盘演练:Promise 装「未来的值」、await 不阻塞地等、Promise.all 并发省时间、Promise.race 超时保命。用模拟 API 把这四个练熟,后面每个真实 await 才不会翻车。核心记牢一句:异步 ≠ 并发。

# 下一步

cd step05 && npm start —— 该接真家伙了,先把 ANTHROPIC_API_KEY 读进来。