2.9 错误处理与测试基础
本章解决什么问题:程序总会失败——模型服务返回 500、要读的文件不存在、模型吐出的 JSON 少了个花括号。本章回答两个问题:失败发生时,这个消息怎么传给能处理它的那一层(
throw/try/catch/finally、Error与自定义错误);以及你怎么知道自己写的失败处理真的有效(自动化测试)。这是第二部分的最后一章,读完你就有了读 Pi 源码所需的全部 TypeScript 基础。前置知识:2.4 泛型、class 与类型收窄(class、
instanceof、unknown的收窄流程)、2.5 异步编程:Promise 与 async/await(await处的失败以异常形式出现)。学习目标:读完本章后你能
- 说清
throw为什么是「第二条返回通道」,以及try/catch/finally三块各自负责什么;- 说出
Error对象的四个关键字段(name/message/stack/cause),并写出一个带错误码的自定义错误 class;- 解释为什么
catch (e)里的e是unknown,并按固定流程用instanceof收窄它;- 分辨「被
try/catch接住的异步失败」和「因为忘了await而无人接住的失败」,并说出后者的后果;- 用 Node.js 内置的
node:test+node:assert写出可运行的测试,读懂describe/it/assert.equal/assert.throws,并说出「测试即文档」是什么意思。
建立直觉:失败需要第二条返回通道
一个函数只有一条返回通道:return。如果失败也必须走这条通道,你只有两个选择,而且都不好。
一是哨兵值:约定用某个特殊返回值代表失败,比如查找失败返回 -1、解析失败返回 null。问题在于哨兵值可以被无声忽略——调用方少写一个 if,程序就带着一个 null 继续往下跑,直到几十行之后才在某个毫不相干的地方崩掉,那时早已看不出真正的原因。
二是把成功和失败都塞进返回值,比如返回 { ok: true, value } | { ok: false, error }(2.4 见过的 Result 类型就是这个思路)。这个办法很稳妥,代价是每一层调用都必须亲手检查并转发:五层深的调用链,中间三层明明什么都不想管,也得逐层写「如果失败就原样返回失败」。
throw 提供的是第三条路,也就是第二条返回通道:函数遇到无法继续的情况时 throw 一个值,这个值不走 return,而是沿着调用栈自动向上冒泡,逐层退出正在执行的函数,直到遇见第一个 catch。中间那些不关心失败的层不需要写任何代码,失败自己会穿过它们。而且如果一路上没有任何人接住,程序会带着完整的错误信息和调用栈直接终止——失败不可能被无声忽略,这是 throw 相对哨兵值最大的价值。
throw 抛出、沿调用栈向上传播的值。JavaScript 里任何值都能被 throw(字符串、数字都行),但约定俗成只 throw Error 对象——因为只有 Error 携带调用栈信息。后面会看到,「任何值都能被 throw」这条规则正是 catch 里的类型是 unknown 的原因。 try / catch / finally:三块的分工
语法只有三块,职责各不相同:
function half(n: number): number {
if (n % 2 !== 0) {
throw new Error(`${n} 不是偶数`);
}
return n / 2;
}
for (const n of [8, 7]) {
try {
console.log(`try:half(${n}) = ${half(n)}`);
} catch (e) {
console.log(`catch:${e instanceof Error ? e.message : String(e)}`);
} finally {
console.log(`finally:收拾现场(${n})`);
}
}真实输出:
try:half(8) = 4
finally:收拾现场(8)
catch:7 不是偶数
finally:收拾现场(7)try块圈出「这段代码可能失败」。注意n = 7那一轮,console.log那行根本没有打印——half(7)抛出后,try块里剩下的代码全部被跳过,控制权直接交给catch。这是throw与return的关键差别:它不是「返回一个坏值继续走」,而是「立刻离开」。catch块接住抛出的值并决定怎么办:记日志、换个方案重试、包装成更有意义的错误再抛出去、或者干脆咽下。咽下要慎重——一个空的catch {}会让失败彻底消失,是最难查的 bug 来源之一。finally块无论try成功、catch接住失败、甚至try里return了,都一定会执行。它专门用来收拾现场:关文件、清计时器、把「正在运行」的标志位改回 false。上面输出里两次「收拾现场」,一次来自成功、一次来自失败——这正是finally存在的理由:把释放资源的代码只写一遍,而不是在成功路径和每个失败路径上各抄一份。
Error:不只是一段文字
Error 是 JavaScript 内置的 class(2.4 讲过 class 在运行时真实存在,所以能用 instanceof 判断)。它有四个字段值得认识:
const err = new Error("读取文件失败", { cause: new Error("ENOENT: no such file") });
console.log(`name = ${err.name}`);
console.log(`message = ${err.message}`);
console.log(`cause = ${err.cause instanceof Error ? err.cause.message : "无"}`);
console.log(`stack 第一行 = ${err.stack?.split("\n")[0]}`);真实输出:
name = Error
message = 读取文件失败
cause = ENOENT: no such file
stack 第一行 = Error: 读取文件失败message:给人看的一句话说明。name:错误的种类名,默认"Error";自定义错误里应该改掉它,这样打印出来能一眼看出是哪类错误。stack:调用栈快照,记录了「错误在哪一行产生、是被谁调用的」。这是throw new Error(...)优于throw "出错了"的直接原因——字符串没有栈,出了事你只知道结果不知道来路。cause:底层原因。构造函数第二个参数写{ cause: 原始错误 },就能把「更底层那个真正的错误」挂在上面。它解决一个常见矛盾:外层想换上一句对调用方友好的说明,又不想丢掉底层的技术细节——cause让两者同时保留。这个字段在 Pi 源码里用得非常多。
自定义错误:让调用方能分辨失败的种类
catch 接住错误之后,第一件事往往是判断「是哪一种失败」——因为不同的失败要用不同的办法处理:网络超时可以重试,参数写错了重试一万次也没用。
那怎么分辨?最容易想到、也最不该用的办法是比对 message 文本:if (e.message.includes("超时"))。这是把「给人看的说明」当成了「给代码看的接口」——哪天有人把提示语从「超时」改成「请求超时」,所有依赖它的判断就全部静默失效,而且没有任何编译错误提醒你。
正确做法是定义自己的错误 class,并挂一个稳定的错误码字段:
type ToolCallErrorCode = "invalid_json" | "missing_name";
class ToolCallError extends Error {
public code: ToolCallErrorCode;
constructor(code: ToolCallErrorCode, message: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "ToolCallError";
this.code = code;
}
}
try {
throw new ToolCallError("missing_name", "缺少 name 字段");
} catch (e) {
if (e instanceof ToolCallError) {
console.log(`收到 ${e.name},错误码 ${e.code}:${e.message}`);
} else if (e instanceof Error) {
console.log(`其它 Error:${e.message}`);
} else {
console.log(`非 Error 的抛出值:${String(e)}`);
}
}真实输出:
收到 ToolCallError,错误码 missing_name:缺少 name 字段三个要点。extends Error(2.4 的 class 继承)让它仍然是一个 Error,instanceof Error 照样成立,stack 照样有——你只是在标准错误上加东西,没有另起炉灶。super(...) 把 message 和 cause 交给基类处理。code 是字面量 union(2.3 讲的可辨识联合思路):取值就那么几个,写错一个字母是编译错误,而且 switch (e.code) 时编译器还能帮你检查有没有漏掉分支。message 随时可以润色,code 是承诺不变的接口——这个分工是错误设计的核心。
catch 里的 unknown:为什么编译器不让你直接用
试试在 catch 里直接读属性:
try {
JSON.parse("{ 坏掉的 JSON");
} catch (e) {
console.log(e.message);
}tsc --noEmit --strict 的真实报错:
d.ts(4,15): error TS18046: 'e' is of type 'unknown'.原因回到本章开头那句话:JavaScript 允许 throw 任何值。一个第三方库完全可能 throw "boom" 或者 throw { code: 42 }。编译器无法证明抛出来的一定是 Error,所以从 TypeScript 4.4 起,strict 模式下 catch 变量的类型就是 unknown(对应的开关叫 useUnknownInCatchVariables,strict: true 会自动打开它)。2.4 讲过 unknown 的含义是「不知道,所以在收窄之前什么都不许做」。
于是 catch 里就有了一套固定的三步流程,本章前面那段代码已经完整演示过:
- 先试最具体的自定义错误:
if (e instanceof ToolCallError)——命中就能安全读e.code; - 再试通用的 Error:
else if (e instanceof Error)——至少能读e.message; - 最后兜底:
String(e)——什么都不假设,也绝不崩溃。
顺序不能反:ToolCallError 也是 Error,先写 instanceof Error 会把它一起吞掉,永远进不了第一个分支。
catch (e) 改成 catch (e: any) 确实能让编译器闭嘴,但这只是把「编译期报错」换成了「运行期崩溃」——真的有人 throw 了一个字符串时,e.message 得到 undefined,日志里留下一句「错误:undefined」,线索全无。多写两个 instanceof 分支,换来的是任何输入都不会二次崩溃。顺带一提,TypeScript 只允许把 catch 变量标注成 any 或 unknown,写 catch (e: Error) 是语法错误——因为这个承诺根本没人能保证。 异步的错误:await 处抛出,忘了 await 就没人接
2.5 讲过:await 一个失败的 Promise,失败会在 await 那一行以异常形式抛出。这意味着异步代码不需要另一套错误机制,普通的 try/catch 就能接住:
try {
const reply = await fetchModelReply(); // 失败在这一行抛出
console.log(reply);
} catch (e) {
console.log(`请求失败:${e instanceof Error ? e.message : String(e)}`);
}危险的是漏掉 await。少了它,失败就发生在 try 块之外——try 块本身早已顺利执行完毕:
async function fetchModelReply(): Promise<string> {
throw new Error("模型服务返回 500");
}
try {
fetchModelReply(); // 忘了 await
console.log("try 块正常走完了——错误一点没被接住");
} catch {
console.log("这一行永远不会执行");
}真实输出(Node.js v26.5.0,进程退出码 1):
try 块正常走完了——错误一点没被接住
Error: 模型服务返回 500
at fetchModelReply (…(省略:本机文件路径与堆栈其余各行))这就是未处理的 Promise 失败(Unhandled Rejection)。fetchModelReply() 立刻返回了一个 Promise,try 块随即正常结束、catch 从未被触发;直到事件循环稍后发现这个 Promise 失败了却无人认领,Node.js 才报告错误并以非零退出码结束进程。规矩很朴素:每条异步链的尽头,要么有人 await 并 try/catch,要么挂着 .catch。
图 2.9-1 一次失败沿调用栈向上冒泡的旅程
阅读顺序:从上到下。前三步是正常的调用方向,SyntaxError 产生之后走的是反方向。重点看中间两段:parseToolCall 把底层的 SyntaxError 翻译成带错误码的 ToolCallError,细节存进 cause 所以线索不丢;而 handleRequest 一行错误处理代码都没写,失败自动穿过它——这就是「第二条返回通道」省下的样板代码。最下面的 finally 说明收拾现场这一步不受成败影响。本章实验的 src/parse-tool-call.ts 就是图中间那一层的完整实现。
为什么要自动化测试
现在换到本章的后半段。上面写的错误处理,你怎么知道它真的对?
手工验证的办法是运行程序、看看输出对不对。它有三个躲不开的问题。第一,错误分支很难手工触发:一个函数有四种失败方式,你得手工构造四种坏输入,跑四遍,看四次输出——而且下次改代码时要再来一遍。第二,改一处要验证全部:今天为了修一个 bug 改了 parseToolCall,谁能保证另外三条分支没被弄坏?靠记忆和眼力,迟早会漏。这类「本来好好的功能被新改动弄坏」的问题有个专门的名字:回归(Regression)。第三,知识留不下来:「这个函数遇到缺省的 arguments 会补成空对象」这种约定只存在于作者脑子里,三个月后连他自己也不确定了。
自动化测试就是把这些验证写成代码,一条命令跑完全部,几十毫秒给出「全绿」或者「第 3 条红了」。它顺带解决了第三个问题:一份写得好的测试,本身就是这个函数最准确的说明书——因为它必须能真的跑通,不会像注释那样悄悄过期。这就是常说的测试即文档。
assert.equal(half(8), 4)。断言成立就静悄悄地过去,不成立就抛出一个 AssertionError,测试框架捕获它并把这条测试标红。所以断言本质上就是本章前半段的 throw——测试框架不过是一个专门收集异常并汇总报告的运行器。 node:test + node:assert:最小用法
Node.js 从 18 版起内置了测试运行器 node:test(20 版后转为稳定),断言库 node:assert 更是一直都在。用它们写测试不需要安装任何第三方包。先有一个被测函数 half.ts:
export function half(n: number): number {
if (n % 2 !== 0) {
throw new Error(`${n} 不是偶数`);
}
return n / 2;
}再写测试文件 half.test.ts(文件名带 .test 是通行约定,方便工具批量找到它们):
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { half } from "./half";
describe("half", () => {
it("偶数返回它的一半", () => {
assert.equal(half(8), 4);
});
it("奇数抛出 Error", () => {
assert.throws(() => half(7), /不是偶数/);
});
});用 tsx --test half.test.ts 运行,真实输出(毫秒数每次不同):
▶ half
✔ 偶数返回它的一半 (0.265792ms)
✔ 奇数抛出 Error (0.156709ms)
✔ half (0.82875ms)
ℹ tests 2
ℹ suites 1
ℹ pass 2
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 95.261要认识的东西不多:
describe("名字", () => { ... })把一组相关测试归到一个标题下,只是为了输出好读,可以嵌套。it("这个函数应该……", () => { ... })是一条测试用例。名字要写成一句人话的行为描述——上面两行it连起来读就是half的完整说明书,这正是「测试即文档」落地的地方。assert.equal(实际值, 期望值)比较两个值(node:assert/strict这个入口用的是严格比较,不做类型转换)。比较对象内容用assert.deepEqual,它逐层比较字段,而不是判断「是不是同一个对象」。assert.throws(函数, 校验)断言这个函数必须抛出异常。第二个参数用来校验抛出的到底是什么:可以是上面这样的正则(匹配message),也可以是一个返回布尔值的函数——本章实验里用后者精确锁定「是ToolCallError且code等于invalid_json」。只写assert.throws(fn)而不校验是很弱的断言:抛出任何异常都算通过,哪怕抛的是一个完全不相干的 bug。
把第一条断言故意改成 assert.equal(half(8), 5),真实的失败报告是这样(已省略堆栈):
✖ 偶数返回它的一半 (1.282917ms)
ℹ pass 1
ℹ fail 1
✖ failing tests:
✖ 偶数返回它的一半 (1.282917ms)
AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:
4 !== 5报告直接给出「哪条测试、哪个断言、实际值与期望值分别是什么」。这就是自动化测试的日常价值:把「程序好像哪里不对」变成「第 7 行的这个断言,得到 4 却期望 5」。
图 2.9-2 有测试之后的日常改代码循环
阅读顺序:从左到右,注意它是一个环。关键在于「运行一条命令」这一步的成本极低——几十毫秒、无需手工构造输入,所以你才愿意每改一次就跑一次,把回归挡在提交之前。没有自动化测试时,这个环的成本高到人们只会在「感觉有必要」时才走一遍,于是 bug 就漏了出去。本章实验第 4 步会让你亲手走一遍红→改→绿。
Pi 中哪里用到了它
本章教的三样东西——带错误码的自定义错误、把 unknown 收窄并翻译成自己的错误类型、测试文件——在 Pi 里全是主力写法,而且形态和上面的示例几乎一模一样。
先看错误定义。Pi 在 packages/agent/src/harness/types.ts 里集中定义了一整族错误 class:FileError、ExecutionError、CompactionError、BranchSummaryError、SessionError、AgentHarnessError——每一个都配一个字面量 union 的错误码类型。文件操作用的这个是典型代表:
/** Error returned by {@link FileSystem} file operations. */
export class FileError extends Error {
/** Backend-independent error code. */
public code: FileErrorCode;
/** Absolute addressed path associated with the failure, when available. */
public path?: string;
constructor(code: FileErrorCode, message: string, path?: string, cause?: Error) {
super(message, cause === undefined ? undefined : { cause });
this.name = "FileError";
this.code = code;
this.path = path;
}
}FileError再看 unknown 的收窄。Node.js 的文件 API 抛出的错误五花八门,Pi 用一个专门的函数把它们统一翻译成 FileError:
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && "code" in error;
}
function toFileError(error: unknown, path?: string): FileError {
if (error instanceof FileError) return error;
const cause = toError(error);
if (isNodeError(error)) {
const message = error.message;
switch (error.code) {
case "ABORT_ERR":
return new FileError("aborted", message, path, cause);
case "ENOENT":
return new FileError("not_found", message, path, cause);
// …(省略:EACCES / EPERM / ENOTDIR / EISDIR / EINVAL 五个分支,形态完全相同)
}
}
return new FileError("unknown", cause.message, path, cause);
}toFileError调用它的地方就是最普通的异步 try/catch。readTextFile(同文件第 499–508 行)先检查 AbortSignal(2.7 的内容),然后 try { return ok(await readFile(...)) } catch (error) { return err(toFileError(error, resolved)) }——await 处抛出的失败被接住,翻译成 FileError,再装进 2.4 见过的 Result 里返回。注意这个组合:Pi 在内部用 throw 传播失败,但在公开的接口边界上换成 Result 返回值,逼迫调用方必须检查。两条通道各用在合适的地方,这个取舍在 5.6 取消与错误处理 会展开。
最后是测试。Pi 用的测试框架不是 node:test 而是 vitest(一个流行的第三方测试框架),但常用 API 几乎同源——describe / it 的用法完全一样,只是断言从 assert.equal(a, b) 换成了链式的 expect(a).toBe(b):
import { mkdtempSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { expandPath, resolveReadPath, resolveToCwd } from "../src/core/tools/path-utils.ts";
describe("path-utils", () => {
describe("expandPath", () => {
it("should expand ~ to home directory", () => {
const result = expandPath("~");
expect(result).not.toContain("~");
});expandPath从源码结构看,Pi 的测试主要覆盖两类东西:纯函数的边界情况(像上面的路径处理),以及带编号的历史 bug——packages/coding-agent/test/suite/regressions/ 目录下的文件名都以 issue 编号开头,例如 5724-sigterm-signal-exit.test.ts。后者是「测试即文档」最直白的形式:每个文件都在说「这个 bug 修过,别再犯」。
实践任务
labs/typescript-basics/12-errors-tests目标:把本章两半内容各走一遍——用自定义错误 class 处理四种失败,再用 node:test 给它们写测试并跑绿。实验目录:labs/typescript-basics/12-errors-tests(全书实验索引见实践任务索引)。
步骤:
进入实验目录,安装依赖并运行两条命令:
shcd labs/typescript-basics/12-errors-tests npm install npm start # 错误处理的四个演示 npm test # 6 个自动化测试对照
expected-output.txt核对npm start的四个部分,重点看第 3 部分——两次「关闭日志」分别来自一次成功和一次失败;读
src/parse-tool-call.ts,找出四个错误码分别对应哪一行throw,以及cause是在哪一行装进去的;改一改:把
src/parse-tool-call.ts里missing_name那个if整段删掉,npm test观察哪一条测试变红、失败报告怎么写,再改回来;加练:把
src/main.ts第 4 部分await fakeReadFile("ghost.txt")的await删掉,运行npm start,观察「未处理的 Promise 失败」如何绕过try/catch让进程非零退出。
预期现象:npm start 与 expected-output.txt 逐字一致;npm test 显示 pass 6、fail 0;第 4 步应看到「缺少 name 字段时抛出 missing_name」那条变成 ✖,统计行变成 fail 1。
如何判断成功:四步改动的结果都与你的预测一致,并能口头回答——为什么分辨失败要靠 code 字段而不是比对 message 文本?
常见错误:tsx: command not found 说明还没在实验目录里 npm install;在 catch (e) 里直接写 e.code 会编译报错 'e' is of type 'unknown',必须先用 instanceof 收窄。
对应源码位置:packages/agent/src/harness/types.ts 第 160–173 行(FileError,实验里的 ToolCallError 是它的简化版)、packages/agent/src/harness/env/nodejs.ts 第 92–118 行(toFileError 的收窄流程)。
本章小结
- 失败需要第二条返回通道:哨兵值会被无声忽略,
Result要求每层转发,而throw让失败沿调用栈自动冒泡,中间不关心的层一行代码都不用写;无人接住则程序终止,失败绝不会消失。 try圈出可能失败的代码(抛出后块内剩余代码被跳过);catch决定怎么办;finally无论成败都执行,专门用来收拾现场。空catch {}会让失败彻底消失,要慎用。Error的四个字段:message(给人看)、name(种类名)、stack(调用栈,只有 Error 对象才有)、cause(底层原因,翻译错误时用它保住线索)。- 自定义错误 =
extends Error+ 一个字面量 union 的code字段。message给人看、随时可改;code给代码看、承诺稳定——分辨失败种类必须靠code。 strict模式下catch (e)的e是unknown(TypeScript 4.4+ 的useUnknownInCatchVariables),因为任何值都能被 throw。收窄顺序固定:最具体的自定义错误 →Error→ 兜底String(e),顺序反了会被父类吞掉。- 异步失败在
await那一行以异常形式抛出,普通try/catch就能接住;忘写await,失败就发生在 try 块之外,变成未处理的 Promise 失败,Node.js 报错并以非零退出码结束进程。 - 自动化测试解决三件事:难手工触发的错误分支、改一处要验证全部(防回归)、口头约定会遗忘(测试即文档)。
node:test+node:assert是 Node.js 内置的零依赖最小方案:describe分组、it一条用例、assert.equal/assert.deepEqual比较值、assert.throws断言抛出——assert.throws一定要带第二个参数校验抛的是什么。 - Pi 把这套写法用到了底:
types.ts里一族带错误码的自定义错误、toFileError把unknown逐层收窄成FileError、数百个 vitest 测试文件(含以 issue 编号命名的回归测试)。
关键术语:异常(Exception)、throw、try / catch / finally、调用栈(Call Stack)、冒泡、哨兵值、Error、name / message / stack / cause、自定义错误、错误码(error code)、useUnknownInCatchVariables、未处理的 Promise 失败(Unhandled Rejection)、自动化测试、断言(Assertion)、测试用例、回归(Regression)、测试即文档、node:test / node:assert、vitest、describe / it / assert.equal / assert.deepEqual / assert.throws
关键源码索引:packages/agent/src/harness/types.ts 的 FileErrorCode 与 FileError(第 150–173 行),同文件还有 ExecutionError、CompactionError、BranchSummaryError、SessionError、AgentHarnessError(第 175–266 行);packages/agent/src/harness/env/nodejs.ts 的 isNodeError 与 toFileError(第 92–118 行)、readTextFile 的异步 try/catch(第 499–508 行);packages/coding-agent/test/path-utils.test.ts(vitest 测试文件样例,第 1–12 行)
自测问题:
- 有人在
catch里写if (e.message.includes("not found"))来判断「文件不存在」。这样写有哪两个问题?换成本章的写法要怎么改? catch (e)里e的类型为什么是unknown而不是Error?如果把收窄顺序写反——先if (e instanceof Error)再else if (e instanceof MyError)——会发生什么?- 一个 async 函数里写了
try { doAsyncWork(); } catch { ... }(漏了await),而doAsyncWork会失败。程序会打印什么、退出码是几?为什么catch接不住? assert.throws(() => parse("坏输入"))这条断言在什么情况下会「通过但毫无意义」?加上第二个参数能挡住哪类问题?
下一章预告:第二部分到此结束——TypeScript 的类型、异步、模块、错误与测试全部到齐,读 Pi 源码的语言门槛已经清空。接下来进入第三部分「Agent 基础概念」,从 3.1 LLM 与模型 API 开始:大语言模型(LLM,Large Language Model)到底是什么、一次 API 调用发出去和收回来的分别是什么、为什么它天生不记得上一句话——这些是理解 Agent Harness 存在意义的起点。