# 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}"`;
}
2
3
4
5
Promise 有三态:pending → fulfilled(resolve/return)或 rejected(throw/reject)。它把「现在还没有、未来会有」的值装进一个盒子,让你能对「尚不存在的结果」提前编排后续动作。
# 2. async/await —— 让异步代码长得像同步
const reply = await callAI(input); // 暂停在这,但不阻塞事件循环
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)));
}
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)`); }),
]);
}
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 ┘
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 读进来。
← 模块化拆分 API Key 与环境变量 →