第 16 章:后台智能框架(forked Agent 模式)

# 第 16 章:后台智能框架(forked Agent 模式)

> 真正自主的系统,不只在被问到时才思考——它在你看向别处时也在学习。

---

## 16.1 核心问题

第 15 章的 Coding Agent 虽然完整,但它有一个根本局限:**它只在用户主动提问时才运作**。用户输入 → Agent 响应 → 等待下一次输入。Agent 本身不会主动做任何事。

但现实中,有很多有价值的事情可以"顺手"完成:

```
用户刚问完"解释一下认证流程"
  ↓
对话历史里出现了新的架构知识
  ↓  ← 这里可以做什么?
等待下一条输入

用户离开 5 分钟回来
  ↓  ← 这里可以做什么?
"欢迎回来,您在继续实现登录功能..."

Agent 连续处理了 10 个会话后
  ↓  ← 这里可以做什么?
记忆文件里出现了大量重复条目
```

Claude Code 对这三个问题的回答,都用同一个设计模式解决:**forked Agent**。

---

## 16.2 原理讲解

### 16.2.1 forked Agent 模式

forked Agent 是一种设计模式,让主 Agent 在完成一轮工作后,"悄悄"启动一个与自己共享上下文的子 Agent 去做后台工作:

```
主 Agent 完成一轮(stop_reason = "end_turn")
    │
    ├─ 响应展示给用户
    │
    └─ [同时/之后] fork 子 Agent(异步,不阻塞用户输入)
            │
            ├─ 共享父 Agent 的 system prompt + messages(prompt cache 命中)
            ├─ 接收一条新的"任务指令"消息
            └─ 独立执行(写文件、调 API、整理记忆...)
```

关键特性:
- **不阻塞**:子 Agent 在后台运行,用户可以立即继续输入
- **Cache 共享**:子 Agent 的首次 API 请求和父 Agent 使用完全相同的 system prompt + messages 前缀,命中父 Agent 的 prompt cache,**接近零额外成本**
- **结果不回传**:子 Agent 通过写文件或修改状态影响系统,不直接在对话中显示结果

### 16.2.2 `CacheSafeParams`:Cache 共享的关键

Anthropic API 的 prompt cache 键由以下五部分组成:

```
cache_key = hash(system_prompt + tools + model + messages_prefix + thinking_config)
```

`forkedAgent.ts` 定义了 `CacheSafeParams`,它精确携带这五个部分:

```typescript
// src/utils/forkedAgent.ts
export type CacheSafeParams = {
  systemPrompt: SystemPrompt       // 与父 Agent 完全相同的 system prompt
  userContext: { [k: string]: string }
  systemContext: { [k: string]: string }
  toolUseContext: ToolUseContext    // 含 tools + model + thinkingConfig
  forkContextMessages: Message[]   // 父 Agent 的完整消息历史(作为前缀)
}
```

每轮结束后,`saveCacheSafeParams()` 保存当前参数到模块级变量。子 Agent 通过 `getLastCacheSafeParams()` 取出,确保参数与父 Agent 一致。

### 16.2.3 触发机制:postSamplingHooks

`src/utils/hooks/postSamplingHooks.ts` 提供了一个事件系统:在主 Agent 每轮采样完成后调用所有注册的 Hook:

```typescript
// 注册 Hook(在各模块的 init 函数中调用)
registerPostSamplingHook(async (ctx: REPLHookContext) => {
  // ctx.messages = 完整对话历史
  // ctx.systemPrompt = 当前 system prompt
  // ctx.toolUseContext = 含工具列表和模型
  // ... 在这里检测条件,决定是否 fork
})

// 每轮采样后由 QueryEngine 调用
executePostSamplingHooks(messages, systemPrompt, ...)
```

### 16.2.4 四个实际应用

Claude Code 用 forked Agent 模式实现了四个后台智能系统:

**① extractMemories(记忆提取)**

- **文件**:`src/services/extractMemories/extractMemories.ts`
- **触发**:每轮 `stop_reason = "end_turn"`(非工具调用轮)
- **条件门**:新增了 ≥ N 条消息 + 主 Agent 自身没有写过记忆
- **任务**:fork 子 Agent,分析最近 N 条消息,找出值得长期保留的事实,写入 `~/.claude/projects/<path>/memory/` 下的 Markdown 文件
- **工具限制**:仅允许 `Read`、`Glob`、`Grep`、只读 Bash、`Edit`/`Write`(仅限记忆目录)

```typescript
// 核心提示词策略(buildExtractAutoOnlyPrompt):
// "分析最近 ~N 条消息,找出值得持久化的事实,
//  第 1 轮:并行 Read 所有可能要更新的文件
//  第 2 轮:并行 Write/Edit 所有需要更改的文件
//  不要跑去验证源码,直接处理消息里的信息。"
```

**② autoDream(记忆整合)**

- **文件**:`src/services/autoDream/autoDream.ts`
- **触发**:时间门(≥24 小时)+ 会话数门(≥5 个新会话)
- **任务**:fork 子 Agent,整合最近 N 个会话的所有记忆,去重、归类后重写
- **Lock 机制**:`consolidationLock.ts` 用文件锁防止多个进程同时整合

```
门控逻辑(cheapest-first):
  1. stat(lock_file).mtime → 时间是否 ≥ minHours      ← 最快,一次 stat()
  2. readdir(sessions/) → 新会话数 ≥ minSessions       ← 次快,一次 readdir
  3. try_acquire_lock() → 无其他进程在整合             ← 需要 atomic write
```

**③ Magic Docs(文档自动更新)**

- **文件**:`src/services/MagicDocs/magicDocs.ts`
- **触发**:`FileReadTool` 读取到含 `# MAGIC DOC: [title]` 首行的文件时注册监听;每轮 postSamplingHook 检查已注册的文件
- **任务**:fork 子 Agent,根据当前对话上下文,用 `FileEditTool` 更新文档内容

```markdown
<!-- ARCHITECTURE.md 的首行 -->
# MAGIC DOC: System Architecture
_Keep this document up to date with key architectural decisions._

<!-- Agent 读取此文件后,每轮结束自动检查是否需要更新 -->
```

**④ Speculation(推测性预执行)**

- **文件**:`src/services/PromptSuggestion/speculation.ts`
- **触发**:主 Agent 响应完毕,用户开始输入时(检测到键盘活动)
- **任务**:预测用户下一条指令,提前执行只读工具(Read/Glob/Grep/LSP)
- **结果合并**:若预测命中,把工具结果注入下一轮,节省 TTFT(首 Token 时间)
- **安全限制**:只允许只读工具,禁止任何写操作

```
MAX_SPECULATION_TURNS = 20
SAFE_READ_ONLY_TOOLS = { 'Read', 'Glob', 'Grep', 'ToolSearch', 'LSP', ... }
WRITE_TOOLS = { 'Edit', 'Write', 'NotebookEdit' }  ← 禁止
```

### 16.2.5 Away Summary(返回摘要)

Away Summary 不用 forked Agent,而是直接调用小模型:

```typescript
// src/services/awaySummary.ts
const recent = messages.slice(-30)  // 最近 30 条消息
recent.push(createUserMessage({ content: buildAwaySummaryPrompt(memory) }))

await queryModelWithoutStreaming({
  model: getSmallFastModel(),   // 小模型,省 Token
  skipCacheWrite: true,         // 一次性查询,不写缓存
  // ...
})
```

触发条件:用户离开(最后活跃时间 > 阈值)再回来时,在 UI 上显示"您离开时正在做:..."的提示卡片。

### 16.2.6 为什么不用普通的 setTimeout?

后台任务用 forked Agent 而不是简单的 `setTimeout` + LLM 调用,原因是 **prompt cache**:

```
普通方式(无 cache 复用):
  重新构建 system prompt  ─────────────────────────────────── 全部 Token 入 cache
  重新上传完整消息历史   ─────────────────────────────────── 全部 Token 入 cache
  API 调用开销 ≈ 一次完整对话                                ← 昂贵

forked Agent 方式(共享 parent cache):
  复用父 Agent 的 system prompt + messages prefix
  API 调用开销 ≈ 只有新增的任务指令(1 条消息)              ← 接近免费
```

这使得后台任务的实际 API 成本极低,可以在每轮结束后都触发而不心疼。

---

## 16.3 源码索引

| 文件 | 关键函数/类型 | 作用 |
|------|-------------|------|
| `src/utils/forkedAgent.ts` | `CacheSafeParams`、`runForkedAgent()`、`saveCacheSafeParams()`、`getLastCacheSafeParams()` | forked Agent 核心框架 |
| `src/utils/hooks/postSamplingHooks.ts` | `registerPostSamplingHook()`、`executePostSamplingHooks()`、`REPLHookContext` | 采样后 Hook 注册与执行 |
| `src/services/extractMemories/extractMemories.ts` | `initExtractMemories()`、`createAutoMemCanUseTool()` | 每轮记忆提取 |
| `src/services/extractMemories/prompts.ts` | `buildExtractAutoOnlyPrompt()` | 记忆提取子 Agent 的提示词 |
| `src/services/autoDream/autoDream.ts` | `initAutoDream()` | 定期记忆整合(时间+会话双门控) |
| `src/services/autoDream/consolidationLock.ts` | `tryAcquireConsolidationLock()`、`readLastConsolidatedAt()` | 文件锁防止并发整合 |
| `src/services/MagicDocs/magicDocs.ts` | `detectMagicDocHeader()`、`initMagicDocs()` | Magic Doc 检测与自动更新 |
| `src/services/PromptSuggestion/speculation.ts` | `runSpeculation()`、`SAFE_READ_ONLY_TOOLS` | 推测性预执行(只读工具) |
| `src/services/awaySummary.ts` | `generateAwaySummary()` | 返回摘要(小模型,无 fork) |

---

## 16.4 最小化产出物

> 代码骨架位于 `../chapters/16/src/`,参考实现位于 `../chapters/16/solution/`。
> **前置条件**:需要 `ANTHROPIC_API_KEY` 环境变量(`npm start` 需要)。

### 本章要实现什么

在 `../chapters/16/src/forked.ts` 中完成 forked Agent 框架。

**接口规范**(已提供,不要修改):

```typescript
export interface CacheSafeParams {
  systemPrompt: string
  messages: Anthropic.MessageParam[]
  model: string
}

export type PostSamplingHook = (params: CacheSafeParams) => Promise<void>

export function registerPostSamplingHook(hook: PostSamplingHook): void
// 将 hook 追加到内部数组

export async function executePostSamplingHooks(params: CacheSafeParams): Promise<void>
// 并发执行所有 Hook,捕获每个 Hook 的错误

export async function runForkedAgent(
  client: Anthropic,
  cacheSafeParams: CacheSafeParams,
  taskMessage: string,
  label: string,
): Promise<string>
// 运行 forked 子 Agent,共享父 Agent 的 prompt cache
```

**你需要实现**:
1. `registerPostSamplingHook()`:追加到 hooks 数组
2. `executePostSamplingHooks()`:`Promise.all` 并发执行,每个 hook 用 `.catch()` 捕获错误
3. `runForkedAgent()`:
   - 构造 messages = [...cacheSafeParams.messages, { role: 'user', content: taskMessage }]
   - 调用 client.messages.create(使用父 Agent 的 systemPrompt 和 model)
   - 提取文本内容并返回

**关键约束**:
- `executePostSamplingHooks()` 中单个 Hook 失败不能影响其他 Hook
- `runForkedAgent()` 使用与父 Agent 完全相同的 systemPrompt + messages 前缀,这是命中 prompt cache 的关键

### 验收

```bash
cd docs/chapters/16
npm install
npm test
```

卡住时查看 `../chapters/16/solution/forked.ts`。

## 16.5 本章小结

### forked Agent 的本质

forked Agent 不是一个新的架构概念,而是对两个已知问题的组合解:

1. **后台任务**:用异步不阻塞的方式触发附加工作
2. **Cache 共享**:通过复用父 Agent 的消息前缀,让附加工作接近免费

```
设计约束                      解决方案
──────────────────────────────────────────────
不能阻塞用户输入            → void Promise(fire and forget)
后台 LLM 调用成本高         → 共享 prompt cache(CacheSafeParams)
后台任务可能并发            → 文件锁(consolidationLock)
后台任务需要访问工具        → 受限的 canUseTool(仅只读 + 记忆目录)
结果不直接显示给用户        → 写文件(MEMORY.md / Magic Doc)
```

### 四个系统的对比

| 系统 | 触发时机 | 频率 | 成本 | 结果 |
|------|---------|------|------|------|
| extractMemories | 每轮 end_turn | 高 | 极低(cache 共享) | MEMORY.md 条目 |
| autoDream | 24h + 5 会话 | 极低 | 中(整合多文件) | MEMORY.md 重写 |
| Magic Docs | 每轮(有追踪文件时) | 中 | 低(cache 共享) | 文档文件更新 |
| Speculation | 用户开始输入时 | 高 | 低(只读工具) | 工具结果预加载 |
| Away Summary | 用户返回时 | 低 | 低(小模型) | UI 提示卡片 |

### 进化路径

```
第 7 章   Agentic Loop ────────────────────────── 主动响应用户请求
     +
第 13 章  AgentTool ──────────────────────────── 并行执行子任务
     +
第 16 章  forked Agent ───────────────────────── 后台持续学习与优化
                                                        ↓
                                        真正自主的 Coding Agent:
                                        - 执行任务(第 7 章)
                                        - 并行分解(第 13 章)
                                        - 持续进化(第 16 章)
```

这是整个系列的终点:从"能响应"到"会学习",从"工具调用"到"持续智能"。

---

## 验收命令

```bash
cd chapters/16
npm install

# 启动 Agent
npx tsx src/main.ts

# 场景 A:记忆提取验收
# 输入:"我们项目用 React 18 + TypeScript 5,后端 FastAPI,数据库 PostgreSQL + Redis"
# 等待 Agent 回复后,查看记忆文件:
cat ~/.mini-agent/memory/MEMORY.md
# 预期:自动写入了技术选型相关的记忆条目

# 再次输入:"我们的测试框架用 Vitest,CI/CD 用 GitHub Actions"
# 再次查看记忆(应追加新条目):
cat ~/.mini-agent/memory/MEMORY.md

# 场景 B:Magic Doc 验收
# 输入(第一次):"请读取 src/ARCHITECTURE.md 文件"
# → Agent 读取后,后台检测到 MAGIC DOC 头,注册追踪

# 输入:"我们采用六边形架构,核心域不依赖任何框架,通过 Adapter 对接外部系统"
# 等待 Agent 回复后,查看文档是否被自动更新:
cat src/ARCHITECTURE.md
# 预期:ARCHITECTURE.md 内容已根据对话自动更新

# 重启验证记忆持久化
# 输入 /exit 退出,重新运行:
npx tsx src/main.ts
# 预期:启动时显示"已有记忆",Agent 在回答中体现对之前技术选型的记忆
```

---

> **进阶选修章结语**
>
> 至此,整个系列的 16 章全部完成。
>
> 你从一个空白的 `main.ts` 出发,经历了进程 I/O、终端 UI、CLI 路由、工具引擎、流式 LLM、对话管理、Agentic Loop、权限系统、上下文注入、记忆持久化、会话存档、斜杠命令、多 Agent 调度、MCP 插件、完整集成,最终到达后台持续学习。
>
> 这不只是 Claude Code 的源码解读——这是一幅现代 AI Agent 系统的完整架构图。