Skip to content

源码设计索引:反复出现的设计模式 ​

小a在代码走查时指着一处结构,很自信地说:“这是观察者模式吧?模块名叫 subscribe,肯定是。”

老z没反驳,只让他先画出调用关系。

小a画完,发现自己说早了——“subscribe”确实存在,但事件怎么发、谁来收、异常怎么传,不画出来根本说不清。他画的图里,Agent.subscribe 注册的监听器既被 loop 调用,也被 harness 调用,执行顺序还不固定。一个监听器返回的 Promise 没有 settle 之前,loop 不会推进到下一个事件。这哪是一个干净利落的“观察者”,分明是一个带时序依赖的异步事件分发链。

“模式是理解工具,不是贴标签。”老z说,“除非注释或命名明确表明,否则以下均写作‘可用某模式理解’。一个结构能不能叫某模式,不取决于它像不像教科书的图,而取决于它的收益和代价是否与该模式一致。本附录服务 Part II,在阅读源码后、需要命名某个结构和权衡时查阅。”

本附录导读: 这不是一份 GoF 模式教程。它从 pi-mono@583f153d 的真实代码里,挑出九处可以用经典设计模式理解的结构,逐一拆解:模式定义是什么、源码里长什么样、用了它换来什么、付出了什么代价、误判它会导致什么后果、它和概念篇/源码篇的哪一章呼应。每个模式都配了真实代码节选和反面论证,不是观点清单。


模式是理解工具,不是贴标签 ​

老z先纠正了小a一个误区。

“你是不是觉得,能叫出模式名就等于理解了代码?”

小a点头。

“反了。”老z说,“**模式名是理解的起点,不是终点。**叫它‘观察者’只告诉你‘这里有个发布订阅’,但真正的价值在后面三个问题:谁订阅、订阅后能改什么、异常怎么传播。这三个问题答不上来,模式名就是装饰。”

为什么不能倒过来:先命名后理解 ​

小a不服气:“我先把名字定下来,再去看细节,不行吗?名字先给我一个锚点。”

“锚点会变成锚——把你钉死在错误的预期上。”老z说,“你看到 subscribe 就叫‘观察者’,于是你预期它是‘松耦合、互不干扰’的。但当你发现监听器的执行顺序会影响 pending writes 的时序时,你的预期和现实就打架了。这时候你会怎么做?要么强行把现实塞进‘观察者’的框里——‘它只是个不太标准的观察者’;要么怀疑自己看错了代码。两种反应都让你离真相更远。”

老z在纸上画了一条线:“正确的顺序是——先画事实,再选名字,最后核代价。”

判断顺序

  1. 画事实——写出调用关系和状态转移。谁调谁、传什么、异常怎么走、状态何时变。这一步逼你看清代码到底在做什么,而不是你觉得它在做什么。
  2. 选名字——从 GoF 二十三个模式里选一个不会夸大事实的名称。判断标准不是“像不像 UML 图”,而是“这个模式的核心约束,代码是否真的满足”。如果代码不满足模式的核心约束,宁可不命名,也不要硬贴。
  3. 核代价——对照教科书里该模式的已知代价,逐一检查代码是否承担了这些代价。如果某个代价在代码里没有被处理,那要么是 bug,要么是这个模式在这里并不完全适用——两种情况都要标注。

“三步都做完,模式名才是有用的。”老z说,“跳过第一步直接命名,你会把想象当事实;跳过第三步只看收益不看代价,你会把模式当成银弹。”

代价列才是契约 ​

小a:“那代价列为什么那么重要?收益不才是用模式的理由吗?”

“收益告诉你为什么选它,代价告诉你它承诺了什么。”老z说,“比如发布者模式的标准承诺是‘订阅者都能收到更新’。但 pi-mono 里的 snapshot publisher,它的 revision 只是广播队列的观察序号——不保证跨网络分区的强一致,也不保证 exactly-once。如果你只看收益‘让 client 校正状态’,就会以为它能保证一致性;但代价列告诉你它不能。模式名是入口,代价列才是契约。”

这个原则贯穿整个附录。下面九个模式,每个都会同时列收益和代价——代价不是缺点,是你在使用这个模式时必须承担的真实成本。如果你不愿意承担某个代价,那就不应该用这个模式,而不是用了它再假装代价不存在。


九个模式一览 ​

先给一张总览表,后面逐个展开。每个模式都有完整的案例推演,不只是表格里的一行。

#结构可用何种模式理解源码位置核心收益核心代价
①createProvider简单工厂packages/ai/src/models.ts:556集中分派 Provider 创建适配层差异随 Provider 增长
②Provider 接口策略packages/ai/src/models.ts:75上层统一调用流入口各策略错误语义必须一致
③transform-messages适配器packages/ai/src/api/transform-messages.ts屏蔽厂商消息形状可能丢失厂商专有信息
④Agent.subscribe观察者(带时序约束)packages/agent/src/agent.ts:243UI 与循环解耦异步事件链更难追踪
⑤resolveProviderAuth责任链packages/ai/src/auth/resolve.ts:48表达凭据优先级诊断必须说明走到哪一步
⑥KeyedOperationQueue按 key 串行队列packages/agent/src/harness/session/keyed-operation-queue.ts同 key 操作有序不是完整事务或命令模式
⑦withFileMutationQueue资源级并发控制packages/coding-agent/src/core/tools/file-mutation-queue.ts:32同路径写入串行不保护外部服务副作用
⑧ValidatedMessageDecoder管道/守卫packages/protocol/src/codec.ts:88分层验证字节失败后必须重建 decoder
⑨ServerSnapshotPublisher发布者(版本化状态)packages/server/src/snapshots.ts:21让 client 校正状态revision 非强一致事务

跨章回扣: 这九个模式不是孤立的——①②③ 都在 pi-ai(源码篇第 19、20 章),④⑤⑥ 在 pi-agent-core(第 21、22 章),⑦在 coding-agent(第25章),⑧在 protocol(第26章),⑨在 server(第27章)。读到对应章节时,可以回来对照这个附录。


模式 ①:createProvider —— 简单工厂 ​

模式定义 ​

简单工厂(Simple Factory)的核心是:把对象的创建逻辑集中在一个函数里,调用方只传参数,不关心具体怎么 new。 GoF 的工厂方法(Factory Method)和抽象工厂(Abstract Factory)是它的进阶版,但 pi-mono 里用的是最朴素的形式——一个函数,根据输入返回一个配置好的对象。

源码里的真实代码 ​

小a打开 packages/ai/src/models.ts,找到第 556 行:

ts
export function createProvider<TApi extends Api = Api>(input: CreateProviderOptions<TApi>): Provider<TApi> {
	const baselineModels = input.models;
	let dynamicModels: readonly Model<TApi>[] = [];
	let inflightRefresh: Promise<void> | undefined;
	const fetchModels = input.fetchModels;
	// ... 合并 baseline 和 dynamic models、分派 API 实现 ...
	return {
		id: input.id,
		name: input.name ?? input.id,
		baseUrl: input.baseUrl,
		headers: input.headers,
		auth: input.auth,
		getModels: currentModels,
		refreshModels: fetchModels ? (context) => { /* ... */ } : undefined,
		stream: (model, context, options) => dispatch(model, (streams) => streams.stream(model, context, options)),
		streamSimple: (model, context, options) => dispatch(model, (streams) => streams.streamSimple(model, context, options)),
	};
}

“看清楚它做了什么。”老z说,“它接收一个 CreateProviderOptions——里面有 id、models、api 实现、可选的 fetchModels。然后它做了一堆内部组装:合并 baseline 和动态 models、构建 dispatch 函数来按 model 的 api 字段选择流实现、管理 inflightRefresh 防止重复刷新。最后返回一个完整的 Provider 对象。”

“这跟 new Provider(...) 有什么区别?”小a问。

“区别在于创建逻辑的复杂度被封装了。”老z说,“如果你直接 new Provider,那合并 models、分派 API、管理 refresh 的逻辑就得散在调用方。createProvider 把这些都收进来,调用方只需要传一个描述对象,拿回来一个能用的 Provider。”

收益:集中分派,调用方简单 ​

老z让小a看 packages/ai/src/providers/ 目录——anthropic、openai-responses、openai-completions、google,每家都是一个独立的 Provider 配置,但它们的创建都走同一个 createProvider。

“这意味着什么?”老z问。

“意味着加一个新 Provider,我只要准备一个 CreateProviderOptions,调 createProvider 就行。不用关心 dispatch 怎么分派、models 怎么合并。”小a说。

“对。这就是工厂的收益——**创建逻辑集中,调用方简单。**不管 Provider 内部多复杂,对外只是一个函数调用。”

代价:适配层差异随 Provider 增长 ​

“但有代价。”老z说,“你看 dispatch 里面——它按 model.api 字段选择 streams。如果你加的 Provider 用了一个新的 API 类型,你不仅要写新的 stream 实现,还要确保 dispatch 能找到它。工厂只集中了创建,没有消灭差异——差异被推到了适配层。”

小a翻到 packages/ai/src/api/ 目录——果然,每种 API 类型都有自己的一套转换文件。

“所以加 Provider 的真实成本是:工厂这里加一个分支,但适配层要多写一整套消息转换、事件映射、错误处理。”老z说,“这个代价不会因为叫它‘工厂’就消失。很多人加 Provider 时只估了工厂那部分工作量,结果卡在适配层——这就是只看收益不看代价的后果。”

反面论证:误判后果 ​

老z讲了一个他见过的真实案例:“有个团队把 createProvider 当成‘注册表’,以为加了 Provider 就自动可用。结果他们的新 Provider 用了一个 dispatch 里没有的 API 类型,运行时抛了 Provider has no API implementation for "xxx"。他们花了两天才定位到——因为工厂的创建成功了,错误延迟到了流调用时才暴露。”

教训:工厂成功 ≠ Provider 可用。 createProvider 返回一个对象,但这个对象的 stream 方法在运行时才会检查 API 实现是否存在。创建时的成功只证明描述对象合法,不证明流能跑通。这就是工厂把复杂度推迟到运行时的代价——错误延迟暴露。

跨章回扣 ​

这个模式在源码篇第19章:第19章 目录与户口——pi-ai详细展开——那一章跟读了 createProvider 如何被 ModelsImpl 调用、返回的 Provider 如何被 requireProvider 查找。如果你读完 19 章后对“Provider 是怎么来的”还有疑问,回到这里看工厂的完整内部逻辑。


模式 ②:Provider 接口 —— 策略 ​

模式定义 ​

策略模式(Strategy)的核心是:定义一个接口,让一组可互换的算法各自实现它,调用方不关心具体用的是哪个。 GoF 的原文说的是“封装可互换的算法族”,但在 pi-mono 里,它更像是一组可互换的服务边界——每家模型厂商都是一个策略,它们实现同一个 Provider 接口。

源码里的真实代码 ​

小a翻到 models.ts 第 75 行:

ts
export interface Provider<TApi extends Api = Api> {
	readonly id: string;
	readonly name: string;
	readonly baseUrl?: string;
	readonly headers?: Record<string, string>;
	readonly auth: ProviderAuth;
	getModels(): readonly Model<TApi>[];
	refreshModels?(context: RefreshContext): Promise<void>;
	stream(model: Model<TApi>, context: Context, options: StreamOptions): AssistantMessageEventStream;
	streamSimple?(model: Model<TApi>, context: Context, options: SimpleStreamOptions): AssistantMessageEventStream;
}

“这就是策略接口。”老z说,“不管你是 Anthropic、OpenAI 还是 Google,你都要实现这个接口。上层 ModelsImpl 调 provider.streamSimple(...) 时,它不关心背后是哪家厂商——它只关心返回的是 AssistantMessageEventStream。”

收益:上层统一调用 ​

“这个收益和工厂是配对的。”老z说,“工厂负责创建策略实例,策略接口负责让上层不知道具体是哪个实例。两者合在一起,才实现了‘换 Provider 不改上层代码’。”

小a想了想:“那 streamSimple 后面那个问号是什么意思?”

“可选方法。”老z说,“stream 是必须实现的,streamSimple 是可选的。这意味着策略之间可以有差异——有些 Provider 不支持简化流接口。**策略模式的收益不是‘所有策略完全一样’,而是‘它们遵守同一个最小契约’。**可选方法就是最小契约和扩展能力的平衡点。”

代价:各策略错误语义必须一致 ​

“策略模式最隐蔽的代价在这里。”老z指着 AssistantMessageEventStream 说,“所有 Provider 都返回这个类型,但它们内部的错误语义可能不同。Anthropic 的 SDK 在网络超时时抛一种异常,OpenAI 的 SDK 可能抛另一种。如果你在策略层不统一这些错误语义,上层就会面对一堆形状各异的异常——它以为自己只依赖一个接口,实际上依赖了 N 种错误格式。”

小a:“那怎么统一?”

“这就是 lazyStream 存在的理由之一。”老z打开 packages/ai/src/api/lazy.ts,“它把流的创建延迟到订阅时,统一了错误入口——不管哪家 Provider 的流创建失败了,错误都从同一个边界抛出。但即便如此,流内部的错误(比如生成中途断开)仍然取决于各 Provider 的适配器实现。策略接口统一了入口,没有统一内部——这是策略模式的固有代价。”

反面论证:把可选方法当必须方法 ​

老z又讲了一个案例:“有个团队假设所有 Provider 都实现了 refreshModels,直接调了它,没做 null 检查。结果对接一个静态 Provider(不支持的)时,运行时报 refreshModels is not a function。策略接口的问号告诉了你它是可选的,但你把它当必须的——这就是不看契约只看名字的后果。”

教训:策略接口的可选方法是契约的一部分。 refreshModels? 的问号不是建议,是约束——它告诉你“不能假设所有策略都实现了它”。把这个约束当装饰,就会在运行时撞上 undefined。

对比矩阵:工厂 vs 策略 ​

小a被①和②搞混了:“工厂和策略,到底什么区别?”

老z画了一张表:

维度① 简单工厂 createProvider② 策略 Provider 接口
解决什么问题对象怎么创建创建好的对象怎么被统一调用
谁变了加 Provider 时改工厂的输入加 Provider 时实现接口
调用方知道什么知道有个工厂函数不知道具体是哪个策略
错误暴露时机创建时(描述对象不合法)运行时(流调用失败)
和对方的关系工厂产出策略实例策略定义工厂返回的形状

“它们是配对的——工厂生产策略,策略定义工厂的产物形状。**单独看一个都会漏掉另一半。**这就是为什么很多书把工厂方法和策略模式放在相邻章节:它们在实际代码里几乎总是一起出现。”

跨章回扣 ​

策略接口在源码篇第19章和第20章:第20章 先开票,后办事——pi-ai都被跟读——19 章看 Provider 接口怎么被 ModelsImpl 委派,20 章看策略返回的 AssistantMessageEventStream 怎么穿过适配层变成统一事件。两章合起来才是策略模式在 pi-mono 里的完整落地。


模式 ③:transform-messages —— 适配器 ​

模式定义 ​

适配器模式(Adapter)的核心是:把一个接口转换成另一个接口,让原本不兼容的类能一起工作。 GoF 的经典比喻是"电源适配器"——你的笔记本要两孔插座,墙上只有三孔,适配器把三孔转换成两孔。在 pi-mono 里,每家模型厂商的消息格式就是不同的"插座",transform-messages 就是那个转换器。

源码里的真实代码 ​

小a打开 packages/ai/src/api/transform-messages.ts,第 64 行:

ts
export function transformMessages<TApi extends Api>(
	messages: ReadonlyArray<AgentMessage>,
	api: TApi,
	// ...
): unknown /* 按 api 返回不同厂商的消息格式 */

"它接收的是 AgentMessage——这是 pi-mono 内部的统一消息格式,有 role、content blocks、tool calls、tool results。"老z说,"输出的是 unknown——不是因为它真的什么都返回,而是因为不同 API 的消息形状完全不同。Anthropic 要 content blocks 数组,OpenAI Responses 要 input/output items,OpenAI Completions 要 role-based content strings。同一个 AgentMessage,转给三家就是三种不同的形状。"

收益:屏蔽外部形状 ​

"适配器的收益就一句话:上层只管 AgentMessage,永远不用碰厂商格式。"老z说,"循环发出去的是 AgentMessage,收回来的也基于 AgentMessage 处理。厂商格式的复杂性被关在了 transform-messages 和对应的 adapter 里。换一家厂商,上层代码一行不用改。"

小a:"那损失了什么?"

代价:可能丢失厂商专有信息 ​

"这就是适配器最隐蔽的代价。"老z说,"AgentMessage 是一个公共子集——它定义了所有厂商都支持的字段。但如果某家厂商有一个独有的字段,比如 OpenAI 的 annotations 或 Anthropic 的 citations,它们在转成 AgentMessage 时可能被丢弃,因为 AgentMessage 没有对应字段。"

"这就像翻译——"小a说,"中文的'缘分'翻成英文,怎么翻都丢东西。"

"对。适配器保证的是兼容性,不是无损性。"老z说,"你用了适配器,就必须接受信息损失。如果你的产品强依赖某个厂商的专有字段,那这个字段不能经过适配器——你得在适配器之外单独处理它。把'适配器能处理一切'当成前提,就会在某个版本升级后发现专有字段悄悄消失了。"

反面论证:双向适配的不对称性 ​

老z又指出了一个常见误区:"transform-messages 是单向的——从 AgentMessage 转成厂商格式。但回来的时候(厂商响应转成 AgentMessage)是另一个函数。有人以为适配器是双向对称的,把转出去的函数也用来转回来,结果消息形状对不上。适配器的方向性是契约的一部分——转出和转入是两个独立的适配过程,各自有自己的信息损失。"

跨章回扣 ​

适配器在源码篇第20章:第20章 先开票,后办事——pi-ai被详细跟读——那一章展示了 Anthropic 的 content_block_start / content_block_delta / content_block_stop 事件,怎么经过 adapter 变成统一的 text / tool_call / done 事件。如果你在那里对"事件格式为什么这么乱"感到困惑,回来这里看适配器的定义:乱的不是适配器,是各家厂商的原生格式;适配器的工作就是把这堆乱收拢成秩序。


模式 ④:Agent.subscribe —— 观察者(带时序约束) ​

模式定义 ​

观察者模式(Observer)的核心是:一个对象维护一组依赖者,状态变化时通知它们。 经典实现是 addListener / notify——被观察者不知道也不关心监听者具体是谁,只负责广播。但 pi-mono 的 Agent.subscribe 有一个关键的不标准之处:**它等待监听器 settle。**这让它的观察者不是松耦合的,而是带时序约束的。

源码里的真实代码 ​

小a已经画过调用关系图了,但老z让他回到源码再看一遍。packages/agent/src/agent.ts 第 243 行:

ts
private readonly listeners = new Set<(event: AgentEvent, signal: AbortSignal) => Promise<void> | void>();

subscribe(listener: (event: AgentEvent, signal: AbortSignal) => Promise<void> | void): () => void {
	this.listeners.add(listener);
	return () => this.listeners.delete(listener);
}

"看监听器的签名。"老z指着 (event: AgentEvent, signal: AbortSignal) => Promise<void> | void,"它可以返回 Promise——这意味着 Agent 会等待它 settle。这是和教科书观察者最大的不同。"

老z翻到第 523 行的 processEvents:

ts
private async processEvents(event: AgentEvent): Promise<void> {
	switch (event.type) {
		case "message_start":
			this._state.streamingMessage = event.message;
			break;
		// ... 更新状态 ...
	}
	// 状态更新后,await 所有 listeners
	await Promise.all([...this.listeners].map((listener) => listener(event, signal)));
}

"看到那个 await Promise.all 了吗?"老z说,"Agent 在通知监听器后,会等它们全部 settle 才继续推进下一个事件。这就是时序约束——如果某个监听器(比如写 session 的 Harness)还没写完,loop 就不会发下一个事件。"

收益:UI 与循环解耦 ​

"标准观察者的收益它仍然有——"老z说,"TUI 不需要直接调用 loop 的内部方法,只需要订阅事件。循环不需要知道 UI 怎么渲染,只需要广播事件。这确实是解耦。"

小a:"那它跟标准观察者到底差在哪?"

代价:异步事件链更难追踪 ​

"差在时序依赖。"老z说,"标准观察者是'发了就不管'——fire and forget。但 pi-mono 的观察者是'发了要等'。这意味着一个监听器的性能问题会拖慢整个循环——如果 session 写入监听器每次花 200 毫秒,那每个事件都得多等 200 毫秒。更麻烦的是调试:一个 bug 可能藏在事件到达顺序里,而不是任何单个函数里。"

老z画了一个时序图:

text
loop 产生 message_end 事件
  → processEvents 更新内部状态
  → await listener A (Harness 写 session)   ← 如果这里慢,loop 卡住
  → await listener B (TUI 刷新)
  → 所有 settle 后,loop 才推进到下一个事件

"你要追的不是'谁调用了谁',而是**'事件按什么顺序被谁处理,哪个监听器卡住了'。这是异步事件链特有的调试难度——堆栈告诉你错误在哪,但时序告诉你为什么会到那里**。"

反面论证:误判后果 ​

老z讲了一个他踩过的坑:"我早期以为监听器是 fire-and-forget,就在监听器里做了一个耗时的网络调用(上报 metrics)。结果整个 Agent 变慢了——因为 loop 在等我的监听器 settle。我以为我在旁路观察,实际上我在主路径上挂了一个阻塞点。"

教训:subscribe 的名字暗示'旁路观察',但 await Promise.all 让它变成了'主路径同步点'。 如果你需要真正的 fire-and-forget(比如日志上报),你不能直接在监听器里做耗时操作——要么用 fire-and-forget 的 promise(不 await),要么放到独立队列。把带时序约束的观察者当成标准观察者用,就是最典型的模式误判。

这个模式为什么不完全叫"观察者" ​

小a:"那它到底算不算观察者?"

"算,但要加限定词。"老z说,"它满足观察者的核心定义——一组依赖者,状态变化时通知。但它有一个标准观察者没有的约束——等待 settle。所以更准确的叫法是带时序约束的观察者或同步观察者。如果你只叫它'观察者'而不说约束,后来者就会以为它 fire-and-forget——然后踩我踩过的坑。"

对比矩阵:标准观察者 vs pi-mono 的观察者 ​

维度GoF 标准观察者pi-mono 的 Agent.subscribe
通知方式fire and forgetawait 所有监听器 settle
监听器阻塞影响不影响被观察者阻塞整个循环
调试方式追调用栈追事件时序
典型误判监听器抛异常被吞以为旁路实际在主路径
适合的场景UI 刷新、日志需要保证监听器完成后才推进的场景

跨章回扣 ​

这个模式在源码篇第21章:第21章 双层循环——pi-agent-core和第22章:第22章 调度台——pi-agent-core被跟读——21 章看 loop 怎么产生事件、processEvents 怎么通知,22 章看 Harness 怎么作为监听器写 session。如果你在 22 章对"Harness 怎么知道何时写 session"感到困惑,回来这里看观察者的时序约束——答案就在 await Promise.all。


模式 ⑤:resolveProviderAuth —— 责任链 ​

模式定义 ​

责任链模式(Chain of Responsibility)的核心是:让多个处理者排成一条链,请求沿着链传递,每个处理者决定自己处理还是传给下一个。 经典例子是审批流程——金额小于 1000 的经理批,1000 到 10000 的总监批,超过 10000 的 CEO 批。在 pi-mono 里,认证解析就是一条责任链:显式覆盖 → 存储的凭据 → 环境变量,每一级如果能解析出凭据就返回,不能就交给下一级。

源码里的真实代码 ​

老z让小a看 packages/ai/src/auth/resolve.ts 第 48 行:

ts
export async function resolveProviderAuth(
	provider: { id: string; auth: ProviderAuth },
	credentials: CredentialStore,
	authContext: AuthContext,
	overrides?: AuthResolutionOverrides,
): Promise<AuthResult | undefined> {
	const requestAuthContext = overrides?.env ? overlayEnvAuthContext(authContext, overrides.env) : authContext;

	// 第一级:显式 API key 覆盖
	if (overrides?.apiKey !== undefined && provider.auth.apiKey) {
		return resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, {
			type: "api_key", key: overrides.apiKey, env: overrides.env,
		});
	}

	// 第二级:存储的凭据(OAuth 或 API key)
	const stored = await readCredential(credentials, provider.id);
	if (stored) {
		if (stored.type === "oauth" && provider.auth.oauth) {
			return resolveStoredOAuth(/* ... */);
		}
		if (stored.type === "api_key" && provider.auth.apiKey) {
			return resolveApiKey(/* ... */);
		}
		return undefined;
	}

	// 第三级:环境变量(ambient)
	return provider.auth.apiKey
		? resolveApiKey(requestAuthContext, provider.auth.apiKey, provider.id, undefined)
		: undefined;
}

"这条链有三节。"老z逐一指过去:

  1. 显式覆盖——如果调用方传了 overrides.apiKey,直接用它,不看后面的。
  2. 存储的凭据——从凭据存储里读这个 Provider 的记录。如果找到 OAuth 记录,走 OAuth 刷新;如果找到 API key 记录,直接用。
  3. 环境变量——前两级都没找到时,尝试从环境变量解析。

"每一级的逻辑是:**如果我能解析出凭据,就返回;如果不能,让代码继续往下走到下一级。**这就是责任链——不是 if/else 互斥选择,而是有序的降级链。"

收益:表达凭据优先级 ​

"责任链最大的好处是优先级是隐含在代码顺序里的。"老z说,"你读这段代码,从上到下就是优先级从高到低:显式覆盖最优先,存储的凭据其次,环境变量兜底。不需要看文档,不需要看配置文件,代码本身就告诉你优先级。"

小a:"那如果我想加一级呢?比如从密钥管理服务拉凭据?"

"在存储的凭据和环境变量之间,加一个 if——先尝试密钥服务,找不到再走环境变量。这就是责任链的可扩展性——加一级只改一个地方,不影响其他级。"

代价:诊断必须说明走到哪一步 ​

"但责任链有一个固有代价——出错时,你要知道链走到了哪一步。"老z说,"如果用户报告'我的 Agent 连不上模型',问题可能在任何一级:显式覆盖的 key 写错了、存储的 OAuth token 过期了、环境变量没设。如果你只告诉用户'认证失败',他就无从下手。"

小a:"所以诊断信息很重要?"

"不是重要,是必须。"老z说,"责任链的每一级都应该在日志或诊断里说明'我尝试了什么、为什么没成功'。比如'尝试了显式 apiKey 覆盖:key 格式不合法'、'尝试了存储凭据:未找到 provider anthropic 的记录'、'尝试了环境变量:ANTHROPIC_API_KEY 未设置'。没有这些信息,责任链就变成了黑箱——用户只知道'没认证成功',但不知道是哪一级失败了。"

反面论证:把责任链当 if/else ​

老z讲了一个反面案例:"有个团队把 resolveProviderAuth 当成普通的 if/else 理解,于是在外面又包了一层:'如果环境变量有 key,就不调 resolveProviderAuth 了'。结果他们的外部检查和责任链的第一级逻辑重复了——用户传了显式覆盖,但外部检查先看到了环境变量,直接用了,绕过了优先级更高的显式覆盖。责任链的价值在于'让链自己管理优先级',你在链外面加判断,就破坏了链的优先级语义。"

教训:责任链的优先级是链内部的顺序,不是外部可以覆盖的。 如果你需要改优先级,改链内部的顺序,而不是在外面加判断。在外面加判断,等于平行建立了两条优先级链——它们一旦冲突,行为就不可预测。

跨章回扣 ​

这个模式在源码篇第19章:第19章 目录与户口——pi-ai被提及——那一章讲 ModelsImpl.applyAuth 时,提到了认证合并调用了 resolveProviderAuth。如果你在 19 章对"认证怎么从多个来源合并"感到困惑,回来这里看责任链的三级结构——每一级就是一个来源,优先级在代码顺序里。


模式 ⑥:KeyedOperationQueue —— 按 key 串行队列 ​

模式定义 ​

这不是一个标准的 GoF 模式,但它解决的问题是经典的并发控制:对同一个 key 的操作必须串行执行,不同 key 的操作可以并发。 它介于"全局锁"(太粗,所有操作排队)和"无锁"(太危险,同 key 操作会冲突)之间。可以把它理解为按资源分组的轻量级事务序列化器。

源码里的真实代码 ​

老z让小a看 packages/agent/src/harness/session/keyed-operation-queue.ts:

ts
export class KeyedOperationQueue<TKey> {
	private readonly tails = new Map<TKey, Promise<void>>();
	private readonly maxConcurrentOperations: number | undefined;
	private activeOperations = 0;
	private barrier: Promise<void> = Promise.resolve();

	enqueue<T>(key: TKey, operation: () => Promise<T> | T): Promise<T> {
		const previous = this.tails.get(key) ?? Promise.resolve();
		const result = Promise.all([this.barrier, previous]).then(() => this.runOperation(operation));
		const tail = result.then(() => undefined, () => undefined);
		this.tails.set(key, tail);
		void tail.then(() => {
			if (this.tails.get(key) === tail) this.tails.delete(key);
		});
		return result;
	}
}

小a盯着这段代码看了很久。"它在干嘛?"

"它在做一件很巧妙的事。"老z一行行解释:

  • previous = this.tails.get(key)——拿到这个 key 当前排队的最后一个 Promise("尾巴")。
  • Promise.all([this.barrier, previous])——等全局屏障和这个 key 的前序操作都完成。
  • .then(() => this.runOperation(operation))——然后才执行新操作。
  • this.tails.set(key, tail)——把新操作的"尾巴"记下来,给后续同 key 的操作等。
  • tail.then(() => { if (this.tails.get(key) === tail) this.tails.delete(key); })——操作完成后清理"尾巴",防止 Map 无限增长。

"**关键是:同 key 的操作通过 previous 串成一条链——后一个等前一个。不同 key 的 previous 互不相干——它们可以并发。**这就是按 key 串行的全部奥秘。"

收益:同 key 有序,不同 key 并发 ​

"这个收益在 JSONL 会话写入时特别重要。"老z说,"一个会话的多个写入操作共享同一个文件路径(同一个 key),它们必须串行——否则后写的可能覆盖先写的。但不同会话的写入(不同 key)可以并发——它们写的是不同文件,互不干扰。KeyedOperationQueue 正好实现了这个:同路径排队,跨路径并行。"

代价:不是完整事务或命令模式 ​

"但它有几个代价你要知道。"老z说:

  • 不是事务——如果操作 A 和操作 B 在同一个 key 上排队,A 成功了 B 失败了,B 不会回滚 A。队列保证的是执行顺序,不是原子性。
  • 不是命令模式——队列里存的是 Promise,不是可撤销的命令对象。你无法"回放"或"撤销"已经执行的操作。
  • 内存泄漏风险——如果同 key 的操作永远在排队(tail 永远不 settle),Map 里的 entry 就不会被清理。代码里的清理逻辑依赖 tail 最终 settle,但如果 operation 永远 hang 住,清理就不会发生。

小a:"那 maxConcurrentOperations 是干什么的?"

"全局并发上限。"老z说,"即使不同 key 可以并发,也不能让 1000 个 key 同时执行——那会打爆资源。maxConcurrentOperations 通过 acquirePermit / releasePermit 控制全局同时运行的操作数。它是 key 级串行和全局并发上限的组合——两个维度的控制叠加在一起。"

反面论证:把串行当安全 ​

老z讲了一个案例:"有人以为'同 key 串行'等于'写入安全',于是不做文件锁,直接靠队列保证两个进程写同一个 JSONL 不会冲突。但他们忘了——队列只在单进程内有效。两个独立的 pi 进程各自有自己的 KeyedOperationQueue 实例,它们的'同 key 串行'互相不可见。两个进程同时追加同一个 JSONL 文件,队列帮不了你——你需要文件锁或单写者模式。"

教训:队列是进程内的并发控制,不是跨进程的。 KeyedOperationQueue 保证的是同一个 JS 进程里同 key 操作的顺序,它不知道也不管别的进程在做什么。把进程内的串行当跨进程安全,是并发 bug 最常见的来源之一。

跨章回扣 ​

这个模式在源码篇第22章:第22章 调度台——pi-agent-core被提及——JSONL store 用 KeyedOperationQueue 控制会话写入。如果你在 22 章对"JSONL 追加会不会冲突"感到困惑,回来这里看队列的串行机制——同会话串行,跨会话并行。


模式 ⑦:withFileMutationQueue —— 资源级并发控制 ​

模式定义 ​

这个模式和 ⑥ 解决的问题是同源的——对同一个资源的操作必须串行。但它有两个关键差异:第一,它的 key 不是简单的字符串,而是文件的 realpath(解析后的真实路径);第二,它不需要显式创建一个队列对象,而是用模块级的 Map 自动管理。可以把它理解为**"同路径写入排队"的轻量锁**。

源码里的真实代码 ​

小a打开 packages/coding-agent/src/core/tools/file-mutation-queue.ts,第 32 行:

ts
export async function withFileMutationQueue<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
	const registration = registrationQueue.then(async () => {
		const key = await getMutationQueueKey(filePath);
		const currentQueue = fileMutationQueues.get(key) ?? Promise.resolve();
		let releaseNext!: () => void;
		const nextQueue = new Promise<void>((resolveQueue) => { releaseNext = resolveQueue; });
		const chainedQueue = currentQueue.then(() => nextQueue);
		fileMutationQueues.set(key, chainedQueue);
		return { key, currentQueue, chainedQueue, releaseNext };
	});
	// ...
	await currentQueue;
	try {
		return await fn();
	} finally {
		releaseNext();
		if (fileMutationQueues.get(key) === chainedQueue) {
			fileMutationQueues.delete(key);
		}
	}
}

"看 getMutationQueueKey 先。"老z指着第 19 行:

ts
async function getMutationQueueKey(filePath: string): Promise<string> {
	const resolvedPath = resolve(filePath);
	try {
		return await realpath(resolvedPath);
	} catch (error) {
		if (isMissingPathError(error)) return resolvedPath;
		throw error;
	}
}

"它先做 resolve(把相对路径变成绝对路径),再做 realpath(解析符号链接,得到真实路径)。**这是为了两个看起来不同、实际指向同一个文件的路径,能映射到同一个 key。**比如 ./a.ts 和 /workspace/a.ts 和 /workspace/link-to-a.ts(如果 link-to-a 是符号链接),这三个路径 realpath 后都是同一个真实路径——它们会被排队。"

小a:"那 finally 里的 releaseNext 和 delete 是干嘛的?"

"两个都是防止卡死和泄漏的关键。"老z说:

  • releaseNext()——nextQueue 是一个手动控制的 Promise,releaseNext 是它的 resolve 函数。执行完 fn 后调用 releaseNext(),后续排队的操作才能开始。如果这里不放行,队列就会永久堵塞——这是 finally 而不是 return 里调用的原因,保证 fn 无论成功还是抛错都放行。
  • delete——清理 fileMutationQueues 里的 entry。注意它检查 fileMutationQueues.get(key) === chainedQueue——只有当这个 queue 还是链上最新的那个时才删,防止误删了后来注册的 queue。

收益:同路径写入串行,跨路径并行 ​

"这个收益和 ⑥ 一样,但用在了工具层。"老z说,"Agent 可能同时发出两个写文件的工具调用——一个写 config.ts,一个写 README.md。withFileMutationQueue 保证它们对各自文件的写入串行(先写完整再写下一个),但两个文件之间并行。工具层不用为此设计复杂的锁协议,包一层函数就行。"

代价:不保护外部服务副作用 ​

"但它有几个边界你要清楚。"老z说:

  • 它只保护文件写入,不保护外部服务副作用。 如果 Agent 同时发两个 HTTP 请求(比如 POST 同一个 API),withFileMutationQueue 帮不了你——它只认识文件路径,不认识 URL。
  • 它是进程内的。 和 ⑥ 一样,两个独立的进程各自有自己的 fileMutationQueues Map,跨进程写同一个文件仍然会冲突。
  • realpath 解析本身有代价。 每次调用都要做文件系统 I/O(realpath),对高频小文件写入来说是额外开销。

反面论证:把"同路径串行"当"写入安全" ​

老z讲了一个反面案例:"有人看到这个函数名,以为'文件写入都排队了,很安全'。结果他们把外部工具的副作用(比如调一个外部 lint 服务)也包进来——但那个服务的状态不是文件系统能感知的。两个 lint 请求仍然并发发出。队列只对 filePath 生效,不对'这个文件相关的所有副作用'生效。"

教训:队列的边界是 key 的粒度,不是业务的粒度。 withFileMutationQueue(filePath, fn) 排队的是对 filePath 的写入,不是 fn 的所有副作用。把队列当成"这段逻辑的整体锁",就会漏掉非文件的副作用。

对比矩阵:⑥ ⑦ 的区别 ​

维度⑥ KeyedOperationQueue⑦ withFileMutationQueue
使用方式显式创建队列对象模块级 Map,函数式调用
key 类型调用方传入的任意 TKey文件路径(realpath 归一化)
资源感知不感知,纯逻辑串行感知文件系统(symlink 解析)
适用层会话写入(pi-agent-core)工具写入(coding-agent)
全局并发上限有(maxConcurrentOperations)无

跨章回扣 ​

这个模式在源码篇第25章:第25章 收银台排队——pi-coding-agent被详细跟读——那一章分析了 withFileMutationQueue 的 finally 释放、key 归一化和失败边界。如果你在 25 章对"两个写请求会不会互相覆盖"感到困惑,回来这里看完整的串行机制。


模式 ⑧:ValidatedMessageDecoder —— 管道/守卫 ​

模式定义 ​

管道模式(Pipeline)的核心是:把一个输入依次经过多个阶段处理,每个阶段做一件事,前一个的输出是后一个的输入。 在 pi-mono 的协议层,解码就是一个四段管道:FrameDecoder 切出完整帧 → decodeCbor 解码成 unknown → parseClientMessage 做 schema 校验 → 产出消息。ValidatedMessageDecoder 是这条管道的调度器,同时是一个守卫——任何一段失败,整条管道失效。

源码里的真实代码 ​

小a打开 packages/protocol/src/codec.ts,第 88 行:

ts
class ValidatedMessageDecoder<T> {
	private failed = false;
	private readonly frames: FrameDecoder;

	push(chunk: Uint8Array): T[] {
		if (this.failed) throw new ProtocolValidationError(`${this.kind} message decoder has failed`);
		try {
			const messages: T[] = [];
			for (const frame of this.frames.push(chunk)) {
				messages.push(this.parse(decodeCbor(frame, { maxByteLength: this.maxFrameLength })));
			}
			return messages;
		} catch (error) {
			this.failed = true;
			if (error instanceof ProtocolValidationError) throw error;
			throw new ProtocolValidationError(`Invalid ${this.kind} protocol frame: ${boundedErrorMessage(error)}`);
		}
	}

	end(): void {
		if (this.failed) throw new ProtocolValidationError(`${this.kind} message decoder has failed`);
		try {
			this.frames.end();
		} catch (error) {
			this.failed = true;
			throw new ProtocolValidationError(`Invalid ${this.kind} protocol framing: ...`);
		}
	}
}

"看 push 里的循环。"老z说,"this.frames.push(chunk) 返回一批完整的帧,然后每个帧依次走 decodeCbor → this.parse。这是一个 chunk 可能包含多条消息的原因——一个网络 chunk 可能带着好几个完整的帧。"

小a:"那 failed 标志是干嘛的?"

"这就是守卫的核心。"老z说,"任何一段管道失败(坏帧、坏 CBOR、schema 不匹配),catch 把 failed 置为 true。之后所有 push 和 end 调用,第一行就抛 decoder has failed。失败不是可恢复的——一旦管道被污染,你必须重建 decoder,而不是继续喂字节。"

收益:分层验证,边界清晰 ​

"管道的收益是每一层只做一件事,错误定位清晰。"老z说:

  • FrameDecoder 只负责"从字节流切出完整帧"——它不关心帧内容是 CBOR 还是 JSON。
  • decodeCbor 只负责"把帧解码成 unknown 值"——它不关心这个值合不合法。
  • parseClientMessage 只负责"验证 unknown 是不是合法的 ClientMessage"——它不关心字节是怎么来的。

"一个坏消息进来,你从报错信息就能看出是哪一段失败:Invalid client protocol frame 是 frame 层,Invalid client protocol message 是 schema 层。这就是管道的调试优势——失败分段,定位也分段。"

代价:失败后必须重建 decoder ​

"但代价是失败是永久的。"老z说,"failed 一旦置 true,这个 decoder 就废了。你不能'跳过坏帧继续读'——因为一旦帧边界不可信,后面所有帧的边界都不可信。这是用更严格的失败语义换取更清楚的边界。"

小a:"那代价太狠了吧?一个坏字节就要重建?"

"对,但这恰恰是协议安全的设计。"老z说,"如果 decoder 能'跳过坏帧',恶意攻击者就能构造一个半坏的流,让 decoder 在不可信的状态下继续解析——这正是协议解析器最常见的漏洞来源。**宁可整体重建,也不在不可信边界上猜测恢复。**这在安全上叫 fail-closed(失败即关闭),而不是 fail-open(失败仍继续)。"

反面论证:把"失败后重建"当"重试一次就好" ​

老z讲了一个反面案例:"有人写了个重试逻辑:decoder 报错就 new 一个新的继续读。但他们忘了——**连接层的握手状态、协议版本协商、会话附着关系,全都建立在同一个 decoder 之上。**重建 decoder 不等于重建连接。如果你只重建 decoder 而不重建连接,新 decoder 会读到一个从中间开始的字节流——帧边界全是错的。decoder 失败的正确响应是重建整个协议状态(包括握手),不是只换 decoder。"

教训:管道失败的单位是整个协议会话,不是单个解码器。 ValidatedMessageDecoder 的 failed 是连接级状态的一部分。把它当单次重试的触发器,就是只修了管道的一端,没修整条链路。

跨章回扣 ​

这个模式在源码篇第26章:第26章 发货与报关——pi-protocol被完整跟读——那一章展示了 chunk → FrameDecoder → decodeCbor → schema 校验的完整链路,以及 failed 永久失效的语义。如果你在 26 章对"为什么一个坏帧要重建整个连接"感到困惑,回来这里看管道守卫的设计逻辑。


模式 ⑨:ServerSnapshotPublisher —— 发布者(版本化状态) ​

模式定义 ​

这是观察者模式在远程场景的变体——发布者(Publisher)。观察者是"状态变化通知一组监听者",发布者是"状态变化生成一个带版本号的快照,广播给订阅者"。区别在于:观察者推送的是事件本身,发布者推送的是整个当前状态 + 一个递增的版本号。这解决了观察者模式在分布式场景的一个经典问题:事件可能丢失、乱序,但版本化快照可以校正。

源码里的真实代码 ​

小a打开 packages/server/src/snapshots.ts,第 21 行:

ts
export class ServerSnapshotPublisher {
	private revision = 0;
	// ...

	get(): Promise<ServerSnapshot> {
		return {
			serverId: this.options.serverId,
			protocolVersion: PROTOCOL_VERSION,
			revision: this.revision,
			sessions: await this.options.listSessions(connection),
			models: models ?? (await this.options.backend.listModels()),
		};
	}

	private async performBroadcast(): Promise<void> {
		const revision = ++this.revision;
		const snapshot: ServerSnapshot = { ...current, revision };
		const envelope: EventEnvelope = { type: "event", event: { type: "server_snapshot", snapshot } };
		await this.options.sendMessage(connection, envelope);
	}
}

"看 performBroadcast。"老z说,"每次广播,revision 先自增,然后把整个快照连同新 revision 一起发出去。接收方拿到快照时,同时拿到了一个单调递增的版本号——它可以用这个版本号判断'我手上的状态是不是最新的'。"

收益:让 client 校正状态 ​

"这个收益在远程场景特别重要。"老z说,"远程连接会丢事件、会乱序。假设客户端先收到 revision 5 的快照,又收到 revision 3 的快照——它应该用 3 覆盖 5 吗?不应该,因为 5 比 3 新。版本号让客户端能判断'哪个状态更新',从而校正掉乱序的旧快照。"

小a:"那 progress 呢?"

"progress 是另一类消息——它是增量活动,不是完整状态。"老z说,"schemas.ts 明确写了一句:progress 是 'normalized incremental activity',而 snapshot 才是权威状态。客户端应该把 progress 当作界面及时性信号('正在发生什么'),而用较新的 snapshot 校正完整状态('现在到底是什么状态')。这两者分工明确:progress 给你实时感,snapshot 给你权威性。"

代价:revision 非强一致事务 ​

"但发布者的代价是——revision 只是广播队列的观察序号,不是跨网络分区的事务保证。"老z说,"如果你以为 revision 是事务序号,那你会犯一个致命错误:断线重连后,收到一个 revision 更高的 snapshot,就以为所有之前的操作都提交了。不对。 revision 只证明'服务端广播了多少次',不证明'每条命令都执行成功且被包含在快照里'。"

小a:"那到底能保证什么?"

"能保证观察一致性(你看到的状态不会倒退),不能保证操作语义(每条命令都恰好执行一次)。"老z说,"要保证后者,你需要业务级的 request ID、幂等设计或人工确认——这些都不在 snapshot publisher 的职责里。"

反面论证:把"revision 更高"当"命令已提交" ​

老z讲了一个反面案例:"有个客户端团队在断线重连后,看到 revision 从 10 跳到 12,就认为两次操作都成功了。结果发现其中一次操作在服务端执行到一半就断了——revision 是 12,但那个操作的部分副作用可能没进快照。**revision 是广播计数,不是事务提交号。**把两者混为一谈,就会在恢复时做出错误的业务决策(比如以为扣款成功而发货)。"

教训:版本号解决的是"看到的状态新不新",不是"做的事成没成"。 观察一致性(乐观显示)和操作语义(确定提交)是两件不同的事。发布者模式保证前者,不保证后者。

对比矩阵:观察者 vs 发布者 ​

小a把 ④ 和 ⑨ 放在一起对比,老z给他画了张表:

维度④ 观察者 Agent.subscribe⑨ 发布者 ServerSnapshotPublisher
推送内容单个事件完整状态快照 + 版本号
消费场景单进程内(loop → UI)跨网络(server → client)
乱序问题事件顺序由 await 保证网络可能乱序,靠 revision 校正
失败恢复监听器失败影响 loopdecoder/连接失败需重建
核心承诺事件被处理状态可被校正

"**观察者适合进程内、你信任事件顺序的场景;发布者适合跨网络、你必须防乱序的场景。**两者的核心区别不是'谁推送',而是'推送的是事件还是状态'。事件有生命周期(发生了就过去了),状态有版本(新的覆盖旧的)。"

跨章回扣 ​

这个模式在源码篇第27章:第27章 黑板与便签——pi-server+pi-client被详细跟读——那一章展示了 performBroadcast 的 revision 自增、snapshot vs progress 的分工、以及断线后"重新连接和重新附着"的真实边界。如果你在 27 章对"重连后能不能信 snapshot"感到困惑,回来这里看发布者的版本化设计——revision 能校正显示,不能证明提交。


九个模式之外:别犯的命名错误 ​

九个个案讲完了,但还有一类错误比"误判某个模式"更普遍——过度命名。小a把这些错误整理成一张清单,老z逐条点评。

过度命名陷阱全集

  • 别把 TypeScript 接口一律称为模板方法——模板方法要求父类定义算法骨架、子类重写步骤;一个普通的 interface 只是契约,没有算法骨架。
  • 别因模块名含 proxy 就断言其实现完整 GoF 代理——代理模式要求访问控制(延迟加载、权限检查、远程代理);"中间多了一层"不等于代理。
  • 别把任何 if/switch 都叫策略模式——策略要求一组可互换的算法实现同一接口;单纯的条件分支只是逻辑。
  • 别把回调称为命令模式——命令模式要求封装请求为对象(支持撤销、排队、日志);回调只是函数。
  • 别把任何 Map 都叫缓存模式——缓存模式要求有失效策略(TTL、LRU、容量上限);一个无限增长的 Map 是内存泄漏,不是缓存。
  • 别把任何队列都叫生产者消费者模式——该模式要求生产者和消费者解耦、速率不同步;一个简单的 FIFO 队列只是数据结构。
  • 别把任何单例都叫单例模式——单例模式要求全局唯一 + 全局访问点 + 延迟初始化;一个模块级 const 只是模块作用域。

小a看着清单问:"那这些错误有没有一个共同根源?"

"有。"老z说,"**它们的共同根源是'用一个名字代替理解'。**看到 proxy 就以为有代理的访问控制,看到 subscribe 就以为 fire-and-forget,看到 Map 就以为有缓存失效——名字给了你一个预期,但你跳过了验证预期的步骤。"

"那正确姿势是什么?"小a问。

正确的命名三步

  1. 先画事实——写出调用关系和状态转移。谁调谁、传什么、异常怎么走、状态何时变。
  2. 再选名字——从模式库里选一个不会夸大事实的名称。不满足模式核心约束的,宁可不命名。
  3. 后核代价——对照教科书里的已知代价,检查代码是否承担了这些代价。没被处理的代价要么是 bug,要么是模式不完全适用。

"三步都做完,模式名才是有用的;跳过任何一步,它就是装饰。"老z说,"你在 pi-mono 里走查了九个模式——工厂、策略、适配器、观察者、责任链、串行队列、并发控制、管道、发布者——每一个都有真实的代码、真实的收益、真实的代价。这些不是标签,是你理解代码的脚手架。"


收尾:模式是脚手架,不是终点 ​

小a合上笔记本,长长舒了一口气:"所以模式到底有什么用?"

老z把九张对比矩阵叠在一起,推到他面前:"**模式是阅读源码的脚手架。**脚手架帮你搭起理解——看到 subscribe 你知道往观察者方向想,看到 createProvider 你知道往工厂方向想。但脚手架不是房子本身。你最终要验证的是代码的真实行为和代价。"

回到全书目标

模式不是装饰代码的标签,而是理解代码的起点。在 pi-mono 里走查这九个模式,你收获的不只是"这段代码像观察者",而是:

  • 每个模式都有一个真实的代价列——观察者的时序约束、适配器的信息损失、责任链的诊断要求、管道的 fail-closed、发布者的非事务性。
  • 每个代价都对应一个反面案例——把旁路观察当主路径、把进程内队列当跨进程锁、把 revision 当提交号。
  • 每个模式都有跨章回扣——17/20 章看工厂和策略,19/22 章看观察者和队列,25 章看并发控制,26 章看管道,27 章看发布者。

"下次你再看一个陌生代码库,"老z说,"试着用这三步:先画调用关系,再问'它像哪个模式',最后核对代价列。你会发现——**代码不再是散的,而是一组带代价的决策。**这就是设计模式在工程里的真实用途:不是让你贴标签,而是让你看懂每个决策背后的权衡。"