3.6 依赖注入 --- 四个依赖的精准边界
对应原书 :3.9 依赖注入:四个依赖的精准边界 + 3.10 子模块的单一职责
源码文件 :
query/deps.ts(40行)、query.ts(1729行)、query/config.ts(46行)、query/tokenBudget.ts(93行)、query/stopHooks.ts(~400行)重点关注:查询依赖注入、QueryDeps 类型设计、typeof 同步技巧、子模块单一职责、渐进式扩展策略
一、导语:为什么 DI 不需要框架
在 Agent 系统的核心循环中,query() 需要调用多个外部服务------LLM API、压缩服务、UUID 生成器。这些是"真正的 I/O 边界":它们与网络、文件系统、随机数生成器交互,行为不可预测,是测试时最需要 mock 的部分。
原书 3.9 节提出了一个尖锐的问题:为什么不用 InversifyJS、tsyringe 等成熟的 DI 框架?
答案只有一行数字:4。
query() 只需要 mock 4 个依赖。为 4 个 mock 点引入一个 DI 框架------带来装饰器语法、容器配置、运行时类型检查------是典型的过度工程。Claude Code 选择了最朴素的方案:手动定义最小依赖接口 + 工厂函数。
这个 40 行的文件,是整个引擎层 DI 设计的缩影。让我们深入拆解它。
二、QueryDeps 类型设计:typeof 的类型同步技巧
2.1 完整源码(query/deps.ts,40行)
typescript
// 第1-4行:导入真实实现
import { randomUUID } from 'crypto'
import { queryModelWithStreaming } from '../services/api/claude.js'
import { autoCompactIfNeeded } from '../services/compact/autoCompact.js'
import { microcompactMessages } from '../services/compact/microCompact.js'
// 第6-31行:类型定义 + 工厂函数
// I/O dependencies for query(). Passing a `deps` override into QueryParams
// lets tests inject fakes directly instead of spyOn-per-module --- the most
// common mocks (callModel, autocompact) are each spied in 6-8 test files
// today with module-import-and-spy boilerplate.
//
// Using `typeof fn` keeps signatures in sync with the real implementations
// automatically. This file imports the real functions for both typing and
// the production factory --- tests that import this file for typing are
// already importing query.ts (which imports everything), so there's no
// new module-graph cost.
//
// Scope is intentionally narrow (4 deps) to prove the pattern. Followup
// PRs can add runTools, handleStopHooks, logEvent, queue ops, etc.
export type QueryDeps = {
// -- model
callModel: typeof queryModelWithStreaming
// -- compaction
microcompact: typeof microcompactMessages
autocompact: typeof autoCompactIfNeeded
// -- platform
uuid: () => string
}
export function productionDeps(): QueryDeps {
return {
callModel: queryModelWithStreaming,
microcompact: microcompactMessages,
autocompact: autoCompactIfNeeded,
uuid: randomUUID,
}
}
2.2 四个依赖的分类与职责
| 依赖 | 类型签名 | 职责分类 | 为什么需要 mock |
|---|---|---|---|
callModel |
typeof queryModelWithStreaming |
模型层(网络 I/O) | 调用 LLM API,最核心的 I/O 边界 |
microcompact |
typeof microcompactMessages |
压缩层(磁盘/缓存 I/O) | 操作消息数组 + 缓存状态,测试需要控制压缩行为 |
autocompact |
typeof autoCompactIfNeeded |
压缩层(网络 I/O) | 可能调用 LLM 生成摘要,与 callModel 同级别 |
uuid |
() => string |
平台层(随机数) | 测试需要确定性 UUID(如 'test-uuid-001') |
2.3 typeof 的类型同步:自动追随函数签名
这是 deps.ts 中最精巧的设计技巧。原书 3.9.2 节专门讨论了这一点:
typescript
callModel: typeof queryModelWithStreaming // ← 类型自动与函数签名同步
如果使用传统接口定义方式:
typescript
// 传统方式(不推荐)------手动维护两份签名
interface QueryDeps {
callModel: (
messages: Message[],
config: QueryConfig,
signal?: AbortSignal
) => AsyncGenerator<StreamEvent> // ← 当原函数新增参数时,这里容易遗漏
}
当 queryModelWithStreaming 的签名变化时(比如新增 taskBudget 参数),手动定义的接口不会自动更新------测试中的 mock 可能使用了旧的签名,编译时不报错,运行时才发现参数不匹配。
而 typeof 方式的优势在于:
- 零维护成本 :类型定义直接引用真实函数的签名,当真实函数签名变化时,TypeScript 编译器会自动检测 所有
QueryDeps的使用方(包括测试中的 mock)是否兼容 - 编译时保护:如果 mock 函数的签名与真实函数不匹配,TypeScript 会立即报错------不会等到运行时才发现问题
- 文档即代码 :
typeof既是类型约束,也是文档------开发者看到typeof queryModelWithStreaming就知道这个依赖的真实签名是什么
2.4 uuid 的特殊处理
uuid 是唯一不使用 typeof 的依赖:
typescript
uuid: () => string // ← 手写签名,而非 typeof randomUUID
原因:Node.js 的 randomUUID 返回值是 ${string}-${string}-${string}-${string}-${string} 的模板字面量类型(TypeScript 4.x 的 UUID 类型推导),过于具体。mock 时返回 'test-uuid-001' 会被类型检查拒绝(因为不匹配 UUID 格式)。使用 () => string 放宽了约束,让测试可以注入任意字符串 UUID。
这是一个务实的 Trade-off:精确的类型约束(UUID 格式)vs 测试的灵活性(任意字符串) 。Claude Code 选择了后者------UUID 的格式验证由 randomUUID 本身保证,mock 不需要遵守这个约束。
三、productionDeps 工厂函数:零成本的默认值
3.1 工厂函数的实现
typescript
export function productionDeps(): QueryDeps {
return {
callModel: queryModelWithStreaming,
microcompact: microcompactMessages,
autocompact: autoCompactIfNeeded,
uuid: randomUUID,
}
}
这个工厂函数的设计有几个关键特点:
- 纯函数 :无副作用,每次调用返回相同结构的对象(虽然
uuid内部调用randomUUID有随机性,但工厂函数本身只是组装引用) - 零模块图成本:注释明确说明 "tests that import this file for typing are already importing query.ts (which imports everything), so there's no new module-graph cost"------deps.ts 的导入与 query.ts 的导入完全重叠,不会增加测试的模块加载开销
- 无 new 依赖 :四个真实实现函数都是通过
import导入的模块级引用,productionDeps()只是组装它们,不需要任何新的依赖
3.2 消费方式:默认值模式
typescript
// query.ts 第263行
const deps = params.deps ?? productionDeps()
这个 ?? 空值合并运算符是 DI 消费的核心模式:
- 生产环境 :
params.deps为undefined(QueryEngine 不传递 deps),使用productionDeps()的真实实现 - 测试环境 :
params.deps传入 mock 对象,跳过productionDeps()
关键观察:QueryEngine 从不传递 deps 。搜索 QueryEngine.ts 中的 deps/QueryDeps 关键词,结果为零。这意味着 QueryEngine 调用 query() 时(第675行),deps 参数始终是 undefined,始终使用 productionDeps():
typescript
// QueryEngine.ts 第675-686行
for await (const message of query({
messages,
systemPrompt,
userContext,
systemContext,
canUseTool: wrappedCanUseTool,
toolUseContext: processUserInputContext,
fallbackModel,
querySource: 'sdk',
maxTurns,
taskBudget,
// 注意:没有 deps 参数!使用 productionDeps() 的默认值
})) {
这是刻意的设计:DI 的边界只在 query() 层 ,QueryEngine 不参与依赖注入------它的测试策略是集成测试(使用真实 query() + Mock canUseTool),不需要在更高层级注入 deps。
四、deps 在 queryLoop 中的消费点映射
4.1 完整消费清单
搜索 deps. 在 query.ts 中的所有出现点:
| 行号 | 消费点 | deps 依赖 | 用途 |
|---|---|---|---|
| 第353行 | deps.uuid() |
uuid |
生成查询链 ID(首次查询时) |
| 第414行 | deps.microcompact(...) |
microcompact |
微压缩(四层压缩管线第二层) |
| 第454行 | deps.autocompact(...) |
autocompact |
自动压缩(四层压缩管线第三/四层) |
| 第523行 | deps.uuid() |
uuid |
生成压缩后轮次 ID |
| 第659行 | deps.callModel(...) |
callModel |
核心! LLM API 流式调用 |
4.2 消费路径分析
queryLoop 内部 deps 消费路径:
┌─────────────────────────────────────────────┐
│ while (true) { │
│ │
│ ① 阶段 A:状态解构 & 预取 │
│ deps.uuid() → chainId (第353行) │
│ │
│ ② 阶段 B:四层压缩管线 │
│ deps.microcompact() (第414行) │
│ deps.autocompact() (第454行) │
│ deps.uuid() → turnId (第523行) │
│ │
│ ③ 阶段 C:API 调用 & 流式响应 │
│ deps.callModel() (第659行) ← 最核心! │
│ │
│ ④ 阶段 D-F:钩子/终止/工具执行 │
│ 不直接使用 deps │
│ │
│ } // while (true) │
└─────────────────────────────────────────────┘
关键发现:deps 只在循环的前三个阶段被消费(压缩 + API 调用),后三个阶段(钩子/终止/工具执行)不直接使用 deps。这意味着 mock 只需要覆盖 I/O 操作------压缩和 API------就能控制循环的核心行为。
4.3 为什么 runTools、handleStopHooks、logEvent 不在 deps 中?
原书 3.9.3 节("渐进式扩展")和 deps.ts 注释都明确列出了可能在未来添加的依赖:
typescript
// Scope is intentionally narrow (4 deps) to prove the pattern. Followup
// PRs can add runTools, handleStopHooks, logEvent, queue ops, etc.
但它们目前不在 QueryDeps 中。原因分析:
| 未注入的依赖 | 当前 mock 方式 | 为什么不用 DI |
|---|---|---|
runTools |
spyOn |
工具执行涉及 ToolUseContext 的深层依赖图,mock 成本与 DI 成本差不多 |
handleStopHooks |
spyOn |
Stop Hook 测试通常在 stopHooks.ts 自身的测试文件中,不需要在 query() 层级 mock |
logEvent |
spyOn |
日志是观测性工具,mock 它不改变查询循环的行为 |
核心原则:只注入改变行为的 I/O 边界,不注入观测性工具 。callModel 改变行为(返回什么响应),logEvent 不改变行为(只是记录)。
五、子模块的单一职责:四模块分工矩阵
5.1 原书 3.10 节的模块分工
原书将 query() 的逻辑拆分到四个子模块,每个维护严格的单一职责:
| 模块 | 职责 | 输入 | 输出 | 副作用 |
|---|---|---|---|---|
config.ts |
配置快照 | 全局状态、环境变量 | QueryConfig(纯数据) |
无(纯函数) |
deps.ts |
依赖注入边界 | 真实实现函数 | QueryDeps(函数接口) |
无(纯工厂函数) |
tokenBudget.ts |
Token 预算决策 | BudgetTracker + token 计数 |
TokenBudgetDecision |
修改 tracker(可变) |
stopHooks.ts |
Hook 编排 | 消息、上下文 | StopHookResult + yield 消息 |
触发后台任务 |
5.2 副作用分离的深层含义
注意"副作用"列的差异:config.ts 和 deps.ts 是纯函数(无副作用),而 tokenBudget.ts 和 stopHooks.ts 有副作用。
原书的解释值得深思:
"这种分离不是偶然的------将可以纯化的部分拆出来,使其更接近
(state, event, config) → (state, output)的 reducer 形态,为未来可能的重构(如提取step()函数使 queryLoop 可被单步测试)做准备。"
这意味着当前的 while(true) + yield 模式可能在未来演化为:
typescript
// 未来可能的 step() 函数(纯 reducer)
function step(state: State, event: Event, config: QueryConfig): {
newState: State,
outputs: Output[]
}
当 step() 被提取出来时,config 和 deps 已经是纯数据/纯接口------可以直接作为 reducer 的输入参数。而 tokenBudget 和 stopHooks 的副作用需要额外处理------tokenBudget 的 tracker 可并入 State,stopHooks 的 yield 需要适配为 Output 列表。
5.3 纯函数子模块的独立可测试性
| 子模块 | 可独立测试? | 测试方式 |
|---|---|---|
config.ts |
✅ | 构造环境变量 → 调用 buildQueryConfig() → 验证输出 |
deps.ts |
✅ | 调用 productionDeps() → 验证每个字段是正确的函数引用 |
tokenBudget.ts |
✅ | 构造 BudgetTracker + budget → 调用 checkTokenBudget() → 验证决策 |
stopHooks.ts |
⚠️ | 需要 ToolUseContext(复杂依赖图),独立测试较困难 |
config.ts 和 deps.ts 的独立测试成本为零------它们不依赖任何运行时状态,输入完全可控,输出完全可验证。这是纯函数设计的直接收益。
六、DI 的使用模式:测试中的注入方式
6.1 原书描述的测试注入方式
原书 3.9.1 节给出了测试注入的示例:
typescript
// 测试中可以注入 mock
const testDeps: Partial<QueryDeps> = {
callModel: mockCallModel,
uuid: () => 'test-uuid-001'
}
注意 Partial<QueryDeps> 的使用------测试可以只 mock 需要的依赖,其余使用 productionDeps() 的真实实现。
6.2 query() 的参数设计支持渐进式 mock
typescript
// query.ts 第198行
type QueryParams = {
// ... 其他10个必填参数
deps?: QueryDeps // ← 可选!不传则使用 productionDeps()
}
deps 是 QueryParams 中唯一标记为可选(?)的参数。这创造了一个自然的 mock 梯度:
| Mock 级别 | deps 传入方式 | 效果 |
|---|---|---|
| 零 mock | 不传 deps | 全部使用真实实现(productionDeps()) |
| 部分 mock | { callModel: mockFn } |
只 mock callModel,其余使用真实实现 |
| 全 mock | 完整 QueryDeps | 全部使用 mock 实现 |
但注意:当前代码的合并逻辑是 params.deps ?? productionDeps(),不支持部分 mock ------要么全部使用传入的 deps,要么全部使用 productionDeps。如果传入 { callModel: mockFn },microcompact 和 autocompact 会是 undefined,运行时会抛错。
原书描述的 Partial<QueryDeps> 模式与实际代码不兼容------这是一个需要验证的发现。
6.3 实际的测试策略(推断)
基于 deps.ts 的注释和 query.ts 的代码,推断实际测试策略:
typescript
// 推断的实际测试代码模式
const testDeps: QueryDeps = {
callModel: mockCallModel, // mock LLM API
microcompact: mockMicrocompact, // mock 微压缩(或使用真实实现)
autocompact: mockAutocompact, // mock 自动压缩
uuid: () => 'test-uuid-001', // mock UUID
}
const result = await collectAll(query({
messages: [...],
systemPrompt: '...',
// ... 其他必填参数
deps: testDeps, // ← 注入完整 mock
}))
如果只需要 mock callModel 而保持其他依赖的真实行为:
typescript
// 使用 productionDeps() 作为基础,只替换 callModel
const testDeps: QueryDeps = {
...productionDeps(),
callModel: mockCallModel, // ← 只替换这一个
}
这种"spread + override"模式比 Partial<QueryDeps> 更安全------它保证了所有依赖都有值,不会出现 undefined。
七、deps 与 config 的协同:两个纯函数子模块的对比
7.1 设计理念对比
| 维度 | config.ts |
deps.ts |
|---|---|---|
| 抽象层次 | 数据(纯值) | 行为(函数接口) |
| 不可变性 | ✅ 快照后不可变 | ✅ 工厂函数每次返回新对象 |
| 可测试性 | ✅ 构造环境变量即可 | ✅ 构造 mock 对象即可 |
| 模块图成本 | 低(只导入 state + growthbook) | 零(与 query.ts 重叠) |
| 变更频率 | 低(配置项稳定) | 低(4 个依赖稳定) |
| Tree-shaking 影响 | 无 | 无 |
两者都是"纯函数子模块",但抽象层次不同:
config.ts快照的是数据 ------sessionId、gates,是静态配置值deps.ts快照的是行为 ------callModel、microcompact,是函数引用
这个区分很重要:数据快照保证一致性(防止配置漂移),行为快照保证可替换性(允许测试注入)。两者共同构成了 query() 的"不可变基础层"------在 while(true) 循环开始前一次性确定,循环期间不变。
7.2 循环入口的"双快照"模式
typescript
// query.ts 第261-263行
const config = buildQueryConfig() // ← 配置快照(数据)
const deps = params.deps ?? productionDeps() // ← 依赖快照(行为)
let state: State = { ... } // ← 可变状态(循环体内更新)
这三行代码建立了 queryLoop 的三层基础:
| 层级 | 变化性 | 角色 |
|---|---|---|
config |
完全不可变 | 确定性保证(防止配置漂移) |
deps |
循环期间不可变(但可由外部替换) | 可替换性保证(测试注入) |
state |
每轮更新 | 进度追踪(循环逻辑) |
config 和 deps 都在循环入口一次性确定,循环期间不再变化------这是 3.5 配置快照模式在依赖注入领域的延伸应用。
八、deps 与 DI 框架的全面对比
8.1 为什么不用 DI 框架?原书的理由
原书 3.9.1 节给出了简洁的回答:
"答案很简单:query 只需要 mock 4 个依赖。为了 4 个 mock 点引入一个 DI 框架------带来装饰器语法、容器配置、运行时类型检查------是典型的过度工程。"
让我们量化这个"过度工程":
| DI 方案 | 代码量 | 学习曲线 | 运行时开销 | Mock 方式 |
|---|---|---|---|---|
| 手动 DI(当前方案) | 40行(deps.ts) | 零(懂 TypeScript 即可) | 零(纯函数组装) | 直接传入 mock 对象 |
| InversifyJS | ~100行(容器配置 + 装饰器) | 高(需学习 @injectable/@inject) | 有(反射 + 容器解析) | 通过容器注册 mock |
| tsyringe | ~60行(装饰器 + 注册) | 中(需学习 @injectable) | 有(反射 API) | 通过容器注册 mock |
| Awilix | ~50行(容器 + 注册) | 中(需学习容器模式) | 有(代理注入) | 通过容器注册 mock |
40 行 vs 50-100 行的代码量差异看起来不大,但关键不在代码量,而在于:
- 概念复杂度:DI 框架引入了容器、生命周期、注册/解析等概念------团队需要学习这些概念才能理解代码。手动 DI 只需要理解"函数参数"
- 调试复杂度:DI 框架的注入发生在运行时------调试时需要追踪容器的解析过程。手动 DI 的注入发生在调用点------调试时直接看参数传递
- 类型安全:DI 框架的运行时类型检查与 TypeScript 的编译时类型检查重叠------两种类型检查机制共存增加了认知负担
8.2 手动 DI vs DI 框架:适用场景对比
| 场景 | 手动 DI | DI 框架 |
|---|---|---|
| mock 点 < 10 | ✅ 最优选择 | ❌ 过度工程 |
| mock 点 10-50 | ⚠️ 可能需要更结构化的方式 | ⚠️ 开始有价值 |
| mock 点 > 50 | ❌ 手动维护困难 | ✅ 框架开始有意义 |
| 团队对 DI 框架不熟悉 | ✅ 零学习成本 | ❌ 学习成本高 |
| 需要生命周期管理(单例/瞬态) | ❌ 需要手动实现 | ✅ 框架内建 |
Claude Code 的 4 个依赖远在手动 DI 的舒适区内。原书 3.9.3 节的"渐进式扩展"策略也验证了这个判断------即使未来添加 runTools、handleStopHooks、logEvent,依赖数量也在 10 以内,手动 DI 仍然是最优选择。
九、四模块与 while(true) 循环的集成方式
9.1 四模块在循环中的调用位置
while (true) {
┌───────────────────────────────────────┐
│ ① 状态解构 & 预取 │
│ deps.uuid() → chainId │
│ config → 快照值 │
│ │
│ ② 四层压缩管线 │
│ applyToolResultBudget → 预算裁剪 │
│ Snip → 条件压缩 │
│ deps.microcompact() → 微压缩 │
│ contextCollapse → 上下文折叠 │
│ deps.autocompact() → 自动压缩 │
│ deps.uuid() → turnId │
│ │
│ ③ API 调用 & 流式响应 │
│ deps.callModel() → 流式响应 │
│ config.gates → 门控检查 │
│ │
│ ④ 钩子 & 中断处理 │
│ stopHooks → Hook 编排 │
│ tokenBudget → 预算决策 │
│ │
│ ⑤ 终止 & 错误恢复 │
│ (纯逻辑,不使用四模块) │
│ │
│ ⑥ 工具执行 & 下一轮准备 │
│ runTools → 工具执行 │
│ (不使用四模块) │
└───────────────────────────────────────┘
}
关键观察:
- deps 只在前三个阶段被消费------压缩和 API 是 queryLoop 的 I/O 密集区
- config 在阶段 ③ 被消费 ------
config.gates控制 fastMode、streamingToolExecution 等 - tokenBudget 和 stopHooks 在阶段 ④ 被消费------它们有副作用,负责"是否继续"的决策
- 阶段 ⑤ 和 ⑥ 不使用四模块------纯逻辑操作(状态转换、工具调度)
9.2 模块间依赖关系图
query.ts (1729行)
┌───────────┐
│ while(true) │
│ queryLoop │
└──────┬──────┘
│
┌────────────────┼────────────────┐
│ │ │
┌─────┴─────┐ ┌─────┴─────┐ ┌─────┴─────┐
│ config.ts │ │ deps.ts │ │tokenBudget │
│ (46行) │ │ (40行) │ │ (93行) │
│ 纯数据 │ │ 纯行为 │ │ 有副作用 │
└─────┬─────┘ └─────┬─────┘ └─────┬─────┘
│ │ │
getSessionId productionDeps createBudgetTracker
checkStatsig queryModelWith checkTokenBudget
isEnvTruthy Streaming
microcompact
autoCompact
randomUUID
│ │ │
└────────────────┼────────────────┘
│
┌─────┴─────┐
│stopHooks │
│(~400行) │
│ 有副作用 │
└───────────┘
│
executeStopHooks
executeTaskCompletedHooks
executeTeammateIdleHooks
无交叉依赖 :四个子模块之间没有相互导入或调用。每个模块只依赖 query.ts 传入的参数或全局模块级的函数。这种独立性是单一职责的直接体现。
十、渐进式依赖注入:从最小 mock 集合开始
10.1 原书的"渐进式 DI"原则
原书 3.9.3 节的结论值得完整引用:
"这就是'渐进式依赖注入'的精髓:从最小的 mock 集合开始,按需扩展,而不是在项目早期就引入重量级 DI 方案。"
这个原则可以用一个决策树来表达:
需要 mock 一个外部依赖?
│
├─ 当前 mock 集合 < 10 个?
│ ├─ YES → 手动 DI(当前方案)
│ │ 添加到 QueryDeps + productionDeps()
│ └─ NO → 考虑 DI 框架
│
├─ mock 的依赖是 I/O 边界(改变行为)?
│ ├─ YES → 加入 deps(如 callModel、autocompact)
│ └─ NO → 使用 spyOn(如 logEvent、runTools)
│
└─ 测试中是否需要部分 mock?
├─ YES → 使用 spread + override 模式
│ { ...productionDeps(), callModel: mockFn }
└─ NO → 传入完整 mock 对象
10.2 可能的扩展路径
deps.ts 注释列出了未来可能的扩展:
typescript
// Followup PRs can add runTools, handleStopHooks, logEvent, queue ops, etc.
如果这些依赖被添加,QueryDeps 将从 4 个扩展到 8 个:
typescript
// 可能的扩展版 QueryDeps
export type QueryDeps = {
// -- 当前 4 个
callModel: typeof queryModelWithStreaming
microcompact: typeof microcompactMessages
autocompact: typeof autoCompactIfNeeded
uuid: () => string
// -- 可能的扩展
runTools: typeof runToolOrchestration // 工具执行
handleStopHooks: typeof handleStopHooks // Stop Hook 处理
logEvent: typeof logEventFn // 事件日志
queueOps: typeof queueOperations // 队列操作
}
但 8 个依赖仍然在手动 DI 的舒适区内------不需要 DI 框架。这个数字只有在超过 50 个时才会让手动维护变得困难。
10.3 扩展时的类型安全保证
当添加新依赖时,TypeScript 的 typeof 技巧保证了类型安全:
typescript
// 添加 runTools 依赖
export type QueryDeps = {
callModel: typeof queryModelWithStreaming
// ... 其他现有依赖
runTools: typeof runTools // ← typeof 自动同步签名
}
export function productionDeps(): QueryDeps {
return {
callModel: queryModelWithStreaming,
// ... 其他现有依赖
runTools: runTools, // ← TypeScript 会检查 runTools 的类型是否匹配
}
}
如果 runTools 的签名在未来变化,所有使用 QueryDeps 的地方(包括测试中的 mock)都会被编译器自动检测到------不需要手动维护。
十一、与全局状态管理的对比:DI vs 全局状态
11.1 两种状态管理策略的适用场景
在 2.2 笔记中,我们讨论了 Claude Code 使用全局状态(bootstrap/state.ts)而非 DI 来管理环境检测结果。现在有了 deps.ts 的对比,可以更清楚地理解两种策略的适用场景:
| 维度 | 全局状态(bootstrap/state.ts) | DI(query/deps.ts) |
|---|---|---|
| 数据类型 | 环境检测结果、配置值 | 函数引用、I/O 边界 |
| 变化频率 | 进程启动时确定,运行时不变 | 循环入口确定,循环内不变 |
| mock 需求 | 低(环境检测结果通常不需要 mock) | 高(I/O 边界是测试核心 mock 点) |
| 消费者数量 | 200+ 个模块 | 1 个模块(query.ts) |
| 传播方式 | 全局可访问(所有模块直接导入) | 参数传递(只传入 query()) |
关键原则:当消费者数量多(>10)且 mock 需求低时,全局状态更务实;当消费者数量少(<10)且 mock 需求高时,DI 更清晰。
环境检测结果(操作系统、Node 版本、终端类型)被 200+ 个模块使用,但测试中几乎不需要 mock------用全局状态更合适。
I/O 边界(LLM API、压缩服务)只有 query() 使用,但测试中必须 mock------用 DI 更合适。
11.2 两种策略的共存
Claude Code 的选择不是"全 DI"或"全全局状态",而是按场景选择:
bootstrap/state.ts:全局状态(环境检测,200+ 消费者)query/deps.ts:DI(I/O 边界,1 消费者)query/config.ts:快照(运行时配置,1 消费者,可复现性需求)
这种"混合策略"体现了务实的设计哲学------没有银弹,只有最适合当前场景的方案。
十二、设计模式提炼
模式 1:窄依赖注入边界(Narrow DI Boundary)
问题:核心代码需要调用外部服务,测试时需要 mock,但 DI 框架过重。
方案:手动定义最小依赖接口 + 工厂函数,只注入核心 I/O 边界。
实现要素:
typeof类型同步:类型定义直接引用真实函数签名- 工厂函数:
productionDeps()提供默认实现 - 默认值模式:
params.deps ?? productionDeps() - 渐进式扩展:从最小 mock 集合开始,按需添加
适用条件:
- ✅ mock 点数量少(< 10 个)
- ✅ 团队对 DI 框架不熟悉或框架开销不可接受
- ❌ 需要大规模依赖注入(100+ 个注入点)
模式 2:纯函数子模块分离(Pure-Function Submodule Separation)
问题:核心循环中混合了纯逻辑和有副作用的操作,难以单步测试。
方案:将可以纯化的部分拆为独立模块,使其接近 reducer 形态。
实现要素:
- 纯函数模块(config/deps):无副作用,输入→输出
- 有副作用模块(tokenBudget/stopHooks):修改 tracker 或 yield 消息
- 为未来
step()提取做准备:纯模块可直接成为 reducer 输入
适用条件:
- ✅ 核心循环需要单步测试能力
- ✅ 部分逻辑可以纯化(不依赖外部 I/O)
- ❌ 所有逻辑都有副作用(无法分离)
模式 3:Spread + Override 渐进式 Mock(Progressive Mock via Spread)
问题 :测试时只需要 mock 部分依赖,但 ?? productionDeps() 不支持部分替换。
方案 :使用 { ...productionDeps(), callModel: mockFn } 的 spread + override 模式。
适用条件:
- ✅ 大部分依赖需要真实行为,只有 1-2 个需要 mock
- ✅ mock 的依赖与真实实现签名兼容(typeof 保证)
- ❌ 需要mock全部依赖(此时直接传入完整 mock 对象更简单)
十三、原书对照验证
| # | 原书描述 | 源码验证 | 结果 |
|---|---|---|---|
| 1 | "QueryDeps 只有 4 个依赖" | deps.ts 定义了 callModel/microcompact/autocompact/uuid 4 个字段 | ✅ 完全一致 |
| 2 | "typeof 保持签名同步" | callModel: typeof queryModelWithStreaming 等 3 个使用 typeof,uuid 手写 |
✅ 一致(uuid 手写是务实调整) |
| 3 | "productionDeps() 返回真实实现" | 函数返回 4 个模块级导入的函数引用 | ✅ 完全一致 |
| 4 | "测试可注入 Partial<QueryDeps>" |
params.deps ?? productionDeps() 不支持 Partial------必须传入完整 QueryDeps |
⚠️ 原书描述与代码不兼容,需要 spread + override 模式实现部分 mock |
| 5 | "为什么不用 DI 框架" | 4 个依赖远在手动 DI 舒适区内 | ✅ 完全一致 |
| 6 | "渐进式扩展:runTools/handleStopHooks/logEvent 等" | deps.ts 注释明确列出这些可能的扩展 | ✅ 完全一致 |
| 7 | "四模块的单一职责分工" | config=数据快照、deps=行为注入、tokenBudget=预算决策、stopHooks=Hook编排 | ✅ 完全一致 |
| 8 | "config/deps 是纯函数,tokenBudget/stopHooks 有副作用" | config.ts 和 deps.ts 无可变状态,tokenBudget 修改 tracker,stopHooks yield 消息 | ✅ 完全一致 |
| 9 | "为未来 step() 提取做准备" | 注释中提到 "makes future step() extraction tractable" | ✅ 完全一致 |
| 10 | "QueryEngine 不传递 deps" | 搜索 QueryEngine.ts 中 deps/QueryDeps 关键词,结果为零 | ✅ 原书未显式描述,但源码验证了这一点 |
| 11 | "deps 只在压缩和 API 阶段被消费" | 5 个消费点都在阶段 A-C(压缩+API),阶段 D-F 不使用 deps | ✅ 原书未描述,源码验证了新模式 |
| 12 | "uuid 手写签名而非 typeof" | uuid: () => string 而非 typeof randomUUID |
✅ 完全一致(原书解释了 UUID 格式类型过于具体的 Trade-off) |
| 13 | "模块间无交叉依赖" | 四个子模块之间没有相互导入 | ✅ 原书未显式描述,但源码验证了独立性 |
验证结果汇总:
- ✅ 准确:10 项
- ⚠️ 原书描述与代码不兼容:1 项(Partial vs 完整 QueryDeps)
- ✅ 原书未描述但源码验证:2 项(QueryEngine 不传递 deps、deps 只在前三阶段消费)
十四、关键发现总结
发现 1:Partial 不可用
原书描述测试可以注入 Partial<QueryDeps>,但实际代码 params.deps ?? productionDeps() 要求完整的 QueryDeps 对象。部分 mock 需要使用 spread + override 模式:
typescript
const testDeps: QueryDeps = {
...productionDeps(),
callModel: mockCallModel,
}
发现 2:QueryEngine 不参与 DI
QueryEngine.ts 中没有任何 deps/QueryDeps 的引用。DI 的边界只在 query() 层------QueryEngine 的测试策略是集成测试(使用真实 query() + Mock canUseTool),不需要在更高层级注入 deps。
发现 3:deps 消费集中在前三阶段
deps 的 5 个消费点全部集中在 while(true) 循环的前三个阶段(压缩 + API),后三个阶段(钩子/终止/工具执行)不直接使用 deps。这意味着 mock 只需要覆盖 I/O 操作就能控制循环的核心行为。
发现 4:config 和 deps 的"双快照"模式
在 while(true) 循环入口,config(数据快照)和 deps(行为快照)共同构成了"不可变基础层"------两者都在循环入口一次性确定,循环期间不再变化。这是配置快照模式在依赖注入领域的延伸。
发现 5:四模块的独立性
四个子模块(config/deps/tokenBudget/stopHooks)之间没有交叉依赖,每个模块只依赖 query() 传入的参数或全局模块级函数。这种独立性是单一职责的直接体现,也为未来的 step() 提取创造了条件。
下一篇预告 :按照
SOURCE_CODE_READING_PLAN.md计划,下一篇是 3.7 停止条件 ,重点分析query/stopHooks.ts的 Hook 编排和终止保护机制。如果您准备继续,请告诉我!