1. FileStateCache:文件读取缓存
1.1 核心数据结构
src/utils/fileStateCache.ts 定义了 FileStateCache,基于 LRU(Least Recently Used) 算法的文件状态缓存:
typescript
export type FileState = {
content: string // 文件内容(已读的部分)
timestamp: number // 文件修改时间(mtime)
offset: number | undefined // 读取起始行
limit: number | undefined // 读取行数上限
isPartialView?: boolean // 是否为部分视图(如自动注入的 CLAUDE.md)
}
// 默认限制:100 个条目,25MB 总大小
export const READ_FILE_STATE_CACHE_SIZE = 100
const DEFAULT_MAX_CACHE_SIZE_BYTES = 25 * 1024 * 1024
路径自动 normalize() 处理,确保 relative、/foo/../bar、Windows 反斜杠等不同形式路径命中同一缓存条目。
1.2 读取时写入
FileReadTool.call() 每次读取文件后执行 readFileState.set()(src/tools/FileReadTool/FileReadTool.ts:1032-1037):
typescript
readFileState.set(fullFilePath, {
content, // 读取到的文本内容
timestamp: Math.floor(mtimeMs), // 文件当前 mtime
offset, // 读取起始行
limit, // 读取行数限制
})
注意 :图片/PDF 不缓存(readFileState.set() 只在文本/notebook 分支调用),缓存仅适用于可编辑文件内容。
1.3 重复读取去重
当模型再次读取同一文件时(src/tools/FileReadTool/FileReadTool.ts:540-573):
css
检查 readFileState 中是否存在该文件
↓ 存在且 offset/limit 相同 ↓
检查文件 mtime 是否变化
↓ mtime 未变 ↓
返回 file_unchanged 类型结果
→ tool_result 内容为:"File unchanged since last read. The content from the earlier Read tool_result in this conversation is still current --- refer to that instead of re-reading."
↓ mtime 已变 ↓
重新读取并更新缓存(反映文件实际变化)
去重效果:BQ Proxy 数据显示约 18% 的 Read 调用是同一文件的重复读取,去重后避免了约 2.64% 的全局 cache_creation token 浪费。
开关控制 :通过 GrowthBook 标志 tengu_read_dedup_killswitch 可关闭此优化(默认开启)。
1.4 变更检测
getChangedFiles()(src/utils/attachments.ts:2063-2161)在每轮对话开始时执行:
- 遍历
readFileState中所有文件 - 检查当前 mtime 是否 > 缓存中的 timestamp
- 仅对已变更的文件重新读取并生成 diff
- 结果以
edited_text_file/edited_image_fileattachment 形式注入系统消息 - 注意:仅跟踪 offset=undefined 的文件(Edit/Write 写入的文件),Read 的 offset 条目跳过变更检测
2. Tool Result 持久化(磁盘缓存)
2.1 大结果溢出持久化
当工具返回结果过大时,不直接截断,而是持久化到磁盘文件(src/utils/toolResultStorage.ts):
bash
工具结果大小 > 阈值
↓
写入 {sessionDir}/tool-results/{toolName}.{uuid}.txt
↓
tool_result 内容替换为文件路径引用 + 前 500 字符预览
默认阈值:MAX_TOOL_RESULT_BYTES(在 constants/toolLimits.ts 中定义)。
不持久化的例外 :Read 工具的结果标记为 Infinity(持久化会形成 Read→文件→Read 的死循环)。
2.2 跨轮结果替换预算
ContentReplacementState(src/utils/toolResultStorage.ts:390-457)追踪每轮对话中哪些工具结果已被替换或持久化,避免同一结果在后续轮次中被反复处理。
3. Prompt Cache(API 级别的缓存)
3.1 系统提示词缓存
系统提示词按 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 分为两部分(src/constants/prompts.ts:114):
| 部分 | 范围 | 缓存行为 |
|---|---|---|
| 静态内容(Intro、System、Doing tasks 等) | scope: 'global' |
跨组织共享缓存 |
| 动态内容(Session guidance、Memory 等) | scope: 'org' / 无缓存 |
按会话组织缓存 |
3.2 Tool Schema 缓存
toolToAPISchema()(src/utils/api.ts:119-240)在会话级 ToolSchemaCache 中缓存每个工具的 base schema(name + description + input_schema + strict + eager_input_streaming):
- 缓存键:工具名(对于
StructuredOutput工具,键为工具名:JSON_Schema_序列化以避免同名不同 Schema) - 缓存内容不包括
cache_control和defer_loading(每轮不同) zodToJsonSchema()使用 WeakMap 按 Zod 对象引用缓存转换结果
4. File Edit/Write 与缓存的交互
4.1 Edit/Write 更新缓存
FileEditTool 和 FileWriteTool 在写入文件后也会更新 readFileState,但写入的 offset 和 limit 均为 undefined:
typescript
// FileEditTool 写入后
readFileState.set(fullFilePath, {
content: newContent,
timestamp: mtimeMs, // 写入后的新 mtime
offset: undefined, // 标记为非 Read 来源
limit: undefined,
})
这确保了:
getChangedFiles()能检测到通过 Edit/Write 修改的文件变更- Read 去重逻辑不会 对 Edit 写入的条目进行去重(
existingState.offset !== undefined检查阻止)
4.2 特殊 stub 标记
FILE_UNCHANGED_STUB(src/tools/FileReadTool/prompt.ts:7-8):
css
"File unchanged since last read. The content from the earlier Read
tool_result in this conversation is still current --- refer to that
instead of re-reading."
模型收到此 stub 后应使用历史消息中的内容,而不是要求重新读取。
5. 会话间与子任务缓存继承
5.1 压缩(Compact)时缓存
压缩会话时,compress.ts:109 中的 cacheToObject() 将 readFileState 序列化为普通对象,压缩完成后通过重新注入消息重新构建缓存状态。
5.2 子任务 Fork
cloneFileStateCache()(src/utils/fileStateCache.ts:122-126)用于子任务分叉时复制父任务的 FileStateCache:
typescript
export function cloneFileStateCache(cache: FileStateCache): FileStateCache {
const cloned = createFileStateCacheWithSizeLimit(cache.max, cache.maxSize)
cloned.load(cache.dump())
return cloned
}
5.3 子任务结果合并
mergeFileStateCaches()(src/utils/fileStateCache.ts:129-142)合并子任务的缓存状态回父任务:
typescript
export function mergeFileStateCaches(first, second) {
const merged = cloneFileStateCache(first)
for (const [filePath, fileState] of second.entries()) {
const existing = merged.get(filePath)
if (!existing || fileState.timestamp > existing.timestamp) {
merged.set(filePath, fileState)
}
}
return merged
}
保留时间戳最新的条目(防止子任务读取的旧版本覆盖父任务的新版本)。
6. 总结:会话中缓存哪些内容
| 缓存类型 | 作用域 | 容量限制 | 失效时机 | 跨会话 |
|---|---|---|---|---|
| FileStateCache(文件内容) | 单会话 | 100 条目 / 25MB LRU | mtime 变化、手动 Evict | ❌ 否 |
| Tool Result 持久化(大结果溢写) | 单会话 | 磁盘容量 | 会话结束清理 | ❌ 否 |
| Tool Schema Cache(工具 Schema) | 单会话 | 工具数量 | 会话结束 | ❌ 否 |
| System Prompt Cache(API 端) | API 级别 | API 控制 | 系统提示词变更 / TTL | ❌ 否 |
| zodToJsonSchema 缓存 | 单进程 | Schema 引用数(WeakMap) | 无(随 Schema 对象 GC) | ❌ 否 |
核心流程
kotlin
模型第一次读取 package.json
↓
FileReadTool.call() 读文件
↓
readFileState.set("package.json", { content, mtime, offset, limit })
↓
返回文件内容
第二轮模型又读取 package.json(相同 offset/limit)
↓
FileReadTool.call() 检查缓存命中
↓
对比 mtime(未变)
↓
返回 "File unchanged since last read." stub
↓
模型从历史消息中拿到实际内容
工具(如 Bash)修改了 package.json
↓
下一轮开始时 getChangedFiles() 检测到 mtime 变化
↓
重新读取并注入 edited_text_file attachment
↓
模型感知到文件已变更
关键文件索引
| 文件 | 功能 |
|---|---|
src/utils/fileStateCache.ts |
LRU 缓存核心实现(100条目/25MB) |
src/tools/FileReadTool/FileReadTool.ts |
读取去重(mtime 对比 + stash stub) |
src/tools/FileReadTool/prompt.ts |
FILE_UNCHANGED_STUB 常量 |
src/utils/attachments.ts |
getChangedFiles() 变更检测 + diff |
src/utils/toolResultStorage.ts |
大结果持久化 + 替换预算 |
src/utils/api.ts |
toolToAPISchema() Schema 缓存 |
src/utils/zodToJsonSchema.ts |
Zod→JSON Schema WeakMap 缓存 |
src/constants/prompts.ts |
系统提示词动态边界(缓存范围) |
src/services/api/claude.ts |
API prompt caching 配置 |