3.6 依赖注入 — 四个依赖的精准边界

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 方式的优势在于:

  1. 零维护成本 :类型定义直接引用真实函数的签名,当真实函数签名变化时,TypeScript 编译器会自动检测 所有 QueryDeps 的使用方(包括测试中的 mock)是否兼容
  2. 编译时保护:如果 mock 函数的签名与真实函数不匹配,TypeScript 会立即报错------不会等到运行时才发现问题
  3. 文档即代码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,
  }
}

这个工厂函数的设计有几个关键特点:

  1. 纯函数 :无副作用,每次调用返回相同结构的对象(虽然 uuid 内部调用 randomUUID 有随机性,但工厂函数本身只是组装引用)
  2. 零模块图成本:注释明确说明 "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 的导入完全重叠,不会增加测试的模块加载开销
  3. 无 new 依赖 :四个真实实现函数都是通过 import 导入的模块级引用,productionDeps() 只是组装它们,不需要任何新的依赖

3.2 消费方式:默认值模式

typescript 复制代码
// query.ts 第263行
const deps = params.deps ?? productionDeps()

这个 ?? 空值合并运算符是 DI 消费的核心模式:

  • 生产环境params.depsundefined(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.tsdeps.ts 是纯函数(无副作用),而 tokenBudget.tsstopHooks.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() 被提取出来时,configdeps 已经是纯数据/纯接口------可以直接作为 reducer 的输入参数。而 tokenBudgetstopHooks 的副作用需要额外处理------tokenBudget 的 tracker 可并入 State,stopHooks 的 yield 需要适配为 Output 列表。

5.3 纯函数子模块的独立可测试性

子模块 可独立测试? 测试方式
config.ts 构造环境变量 → 调用 buildQueryConfig() → 验证输出
deps.ts 调用 productionDeps() → 验证每个字段是正确的函数引用
tokenBudget.ts 构造 BudgetTracker + budget → 调用 checkTokenBudget() → 验证决策
stopHooks.ts ⚠️ 需要 ToolUseContext(复杂依赖图),独立测试较困难

config.tsdeps.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()
}

depsQueryParams 中唯一标记为可选(?)的参数。这创造了一个自然的 mock 梯度:

Mock 级别 deps 传入方式 效果
零 mock 不传 deps 全部使用真实实现(productionDeps()
部分 mock { callModel: mockFn } 只 mock callModel,其余使用真实实现
全 mock 完整 QueryDeps 全部使用 mock 实现

但注意:当前代码的合并逻辑是 params.deps ?? productionDeps()不支持部分 mock ------要么全部使用传入的 deps,要么全部使用 productionDeps。如果传入 { callModel: mockFn }microcompactautocompact 会是 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 快照的是数据 ------sessionIdgates,是静态配置值
  • deps.ts 快照的是行为 ------callModelmicrocompact,是函数引用

这个区分很重要:数据快照保证一致性(防止配置漂移),行为快照保证可替换性(允许测试注入)。两者共同构成了 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 行的代码量差异看起来不大,但关键不在代码量,而在于:

  1. 概念复杂度:DI 框架引入了容器、生命周期、注册/解析等概念------团队需要学习这些概念才能理解代码。手动 DI 只需要理解"函数参数"
  2. 调试复杂度:DI 框架的注入发生在运行时------调试时需要追踪容器的解析过程。手动 DI 的注入发生在调用点------调试时直接看参数传递
  3. 类型安全:DI 框架的运行时类型检查与 TypeScript 的编译时类型检查重叠------两种类型检查机制共存增加了认知负担

8.2 手动 DI vs DI 框架:适用场景对比

场景 手动 DI DI 框架
mock 点 < 10 ✅ 最优选择 ❌ 过度工程
mock 点 10-50 ⚠️ 可能需要更结构化的方式 ⚠️ 开始有价值
mock 点 > 50 ❌ 手动维护困难 ✅ 框架开始有意义
团队对 DI 框架不熟悉 ✅ 零学习成本 ❌ 学习成本高
需要生命周期管理(单例/瞬态) ❌ 需要手动实现 ✅ 框架内建

Claude Code 的 4 个依赖远在手动 DI 的舒适区内。原书 3.9.3 节的"渐进式扩展"策略也验证了这个判断------即使未来添加 runToolshandleStopHookslogEvent,依赖数量也在 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 → 工具执行                   │
  │    (不使用四模块)                       │
  └───────────────────────────────────────┘
}

关键观察:

  1. deps 只在前三个阶段被消费------压缩和 API 是 queryLoop 的 I/O 密集区
  2. config 在阶段 ③ 被消费 ------config.gates 控制 fastMode、streamingToolExecution 等
  3. tokenBudget 和 stopHooks 在阶段 ④ 被消费------它们有副作用,负责"是否继续"的决策
  4. 阶段 ⑤ 和 ⑥ 不使用四模块------纯逻辑操作(状态转换、工具调度)

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 边界。

实现要素

  1. typeof 类型同步:类型定义直接引用真实函数签名
  2. 工厂函数:productionDeps() 提供默认实现
  3. 默认值模式:params.deps ?? productionDeps()
  4. 渐进式扩展:从最小 mock 集合开始,按需添加

适用条件

  • ✅ mock 点数量少(< 10 个)
  • ✅ 团队对 DI 框架不熟悉或框架开销不可接受
  • ❌ 需要大规模依赖注入(100+ 个注入点)

模式 2:纯函数子模块分离(Pure-Function Submodule Separation)

问题:核心循环中混合了纯逻辑和有副作用的操作,难以单步测试。

方案:将可以纯化的部分拆为独立模块,使其接近 reducer 形态。

实现要素

  1. 纯函数模块(config/deps):无副作用,输入→输出
  2. 有副作用模块(tokenBudget/stopHooks):修改 tracker 或 yield 消息
  3. 为未来 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 编排和终止保护机制。如果您准备继续,请告诉我!

相关推荐
江华森2 小时前
Python神经网络编程(四):Python从零搭建神经网络
开发语言·python·神经网络
Helen_cai3 小时前
OpenHarmony 完整项目工程整合规范 + 模块化分层架构(API23+ 标准企业级结构)
开发语言·华为·php·harmonyos
大圣编程8 小时前
Java 多维数组详解
java·开发语言
殳翰10 小时前
下服务器端开发流程及相关工具介绍(C++)
开发语言·c++
奇牙coding12311 小时前
gpt-5.5、deepseek-v4-pro、claude-opus-4.8 跑同一套 50 道编程题——有一类任务结果和官方榜单完全反过来
gpt·ai
落寞的星星11 小时前
这个对象就包含了已经转换好的DFA和各种词法分析器运转所需要的参数。下一步,我们就可以用ScannerInfo对象创建出Scanner对象,请看下面的代码:
开发语言·c#
nothing&nowhere11 小时前
用 Python 做问卷数据清洗:无效样本检测与处理实战
开发语言·python·数据清洗·数据处理·问卷星·问卷星脚本·刷问卷
2601_9615934212 小时前
Rust 开发环境配置繁琐?RustRover 开箱即用搞定编码调试
开发语言·后端·macos·rust
笨蛋©12 小时前
[实战] 2026年制造业图纸数字化:图片PDF转DXF的精度控制与质量管理流程
ai·数字化·cad·质量管理·制造业