mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
* docs: 修正 docs/conversation 文档与源码的偏差(multi-turn/streaming/the-loop) - multi-turn: TranscriptWriter→Project 私有类, 会话路径改用 sanitized-cwd, 补充 StoredCostState.lastDuration 字段, 模型切换改为 setModel(), QueryEngine 状态补全 loadedNestedMemoryPaths/hasHandledOrphanedPermission, 行号改为符号引用 - streaming: STALL_THRESHOLD_MS 10s→30s, 新增 90s 主动空闲看门狗描述, 非流式降级补充 didFallBackToNonStreaming/executeNonStreamingRequest, 行号改为符号引用 - the-loop: 终止条件 7→11, 继续条件重整为 5 组层级结构, max_output_tokens 拆分 escalate/recovery 子阶段, prompt-too-long 拆分 collapse_drain/reactive_compact 子策略, State 类型修正 autoCompactTracking 为可选, 行号改为符号引用 - 全部: 添加 sourceRef 版本锚定(3ec5675) * docs: 修正 docs/extensibility 文档与源码的偏差(custom-agents/hooks/skills) - custom-agents: Verification 模型修正为 inherit, 补充 Plugin Agent 字段限制 (permissionMode/hooks/mcpServers 被安全忽略, isolation 仅 worktree), 加载流程修正为 6 层优先级, 补充 memory snapshot 门控条件 - hooks: 事件数 22→27(补充 Notification), Hook 类型定义位置修正为 3 个文件, 行号改为符号引用, Zod schema 范围修正, 去重键修正为四部分复合键, registerFrontmatterHooks/clearSessionHooks 区分定义位置和调用位置 - skills: 字段数 17→16, 权限层级 4→5(补充 remote canonical auto-allow), SAFE_SKILL_PROPERTIES 28→30, skillUsageTracking 路径修正, 行号改为符号引用 - mcp-protocol: 全部验证通过, 无需修改 - 全部: 添加 sourceRef 版本锚定(3ec5675) * Revert "docs: 修正 docs/extensibility 文档与源码的偏差(custom-agents/hooks/skills)" * docs: 修正 docs/extensibility 文档与源码的偏差(hooks/skills/mcp-protocol) hooks: - 事件数 22→27(补充 Notification 事件) - Hook 类型定义位置修正为 3 个文件分布 (schemas/hooks.ts / types/hooks.ts / utils/hooks/sessionHooks.ts) - Zod schema 引用从硬编码行号改为符号引用 - hookSpecificOutput 表从 6 扩展至 15 个事件 (补全 permissionDecisionReason / PostToolUseFailure / SubagentStart 等) - 去重键从 pluginRoot\0command 修正为四部分复合键 (pluginRoot\0shell\0command\0ifCondition) - 全部硬编码行号改为符号引用以避免版本漂移 skills: - parseSkillFrontmatterFields 字段数 17→16 - SAFE_SKILL_PROPERTIES 属性数 28→30 - checkPermissions 层级 4→5 - 第 2 层描述从"官方市场"修正为"远程 canonical" mcp-protocol: - 配置层级从"三级"修正为 "enterprise 独占或合并 user/project/local + plugin + claude.ai" * docs: 修正 system-prompt.mdx 中 Boundary 章节的层级与可读性 - Boundary 插入条件从 ### 降为 blockquote,不再打断三种分块模式的并列结构 - 表格中 Boundary 缓存策略列补充说明其分割作用 - 新增 Boundary 概念释义(blockquote),解释其分割静态区/动态区以实现全局缓存的设计意图
204 lines
8.8 KiB
Plaintext
204 lines
8.8 KiB
Plaintext
---
|
||
title: "多轮对话管理 - QueryEngine 会话编排与持久化"
|
||
description: "从源码角度解析 Claude Code 多轮对话管理:QueryEngine 的会话状态机、JSONL transcript 持久化、成本追踪模型和模型热切换机制。"
|
||
keywords: ["多轮对话", "会话管理", "QueryEngine", "transcript", "成本追踪"]
|
||
sourceRef: "3ec5675 (2026-04-08)"
|
||
---
|
||
|
||
{/* 本章目标:从源码角度揭示会话编排、持久化存储、成本追踪和模型切换的完整链路 */}
|
||
|
||
## 单轮 vs 多轮:架构层面的差异
|
||
|
||
- **单轮**(一次 Agentic Loop):`query()` 函数的一次完整执行——组装上下文 → 调 API → 处理工具调用 → 循环直到结束
|
||
- **多轮**(一个 Session):`QueryEngine` 类管理的一次会话——跨越数十轮 `submitMessage()` 调用,持续数小时
|
||
|
||
`QueryEngine`(`src/QueryEngine.ts`,类定义)是单轮 Agentic Loop 之上的**会话编排器**,它管理的状态远不止消息列表:
|
||
|
||
```
|
||
QueryEngine 内部状态(src/QueryEngine.ts 构造函数)
|
||
├── mutableMessages: Message[] ← 完整对话历史,跨 turn 累积
|
||
├── readFileState: FileStateCache ← 已读文件内容缓存,避免重复读取
|
||
├── totalUsage: NonNullableUsage ← 累计 token 消耗(input/output/cache)
|
||
├── permissionDenials: SDKPermissionDenial[] ← 权限拒绝记录
|
||
├── discoveredSkillNames: Set<string> ← 当前 turn 已发现的 skill
|
||
├── loadedNestedMemoryPaths: Set<string> ← 已加载的嵌套 memory 路径(防重复)
|
||
├── hasHandledOrphanedPermission: boolean ← 是否已处理孤立权限请求
|
||
└── abortController: AbortController ← 会话级中断控制
|
||
```
|
||
|
||
## QueryEngine 的核心方法:submitMessage()
|
||
|
||
每次用户输入一条消息,REPL 或 SDK 调用 `submitMessage()`,它会执行完整的 turn 初始化链路:
|
||
|
||
```typescript
|
||
// src/QueryEngine.ts — QueryEngine.submitMessage() 简化流程
|
||
async *submitMessage(
|
||
prompt: string | ContentBlockParam[],
|
||
options?: { uuid?: string; isMeta?: boolean },
|
||
): AsyncGenerator<SDKMessage> {
|
||
// 1. 清除 turn 级追踪状态
|
||
this.discoveredSkillNames.clear()
|
||
|
||
// 2. 解析模型(用户可能中途通过 setModel() 切换了模型)
|
||
const mainLoopModel = this.config.userSpecifiedModel
|
||
? parseUserSpecifiedModel(this.config.userSpecifiedModel)
|
||
: getMainLoopModel()
|
||
|
||
// 3. 动态组装 System Prompt(每次 turn 都重新构建)
|
||
const { defaultSystemPrompt, userContext, systemContext } =
|
||
await fetchSystemPromptParts({ tools, mainLoopModel, mcpClients })
|
||
|
||
// 4. 包装权限检查(追踪每次拒绝)
|
||
const wrappedCanUseTool = async (tool, input, ...) => {
|
||
const result = await canUseTool(tool, input, ...)
|
||
if (result.behavior !== 'allow') {
|
||
this.permissionDenials.push({
|
||
type: 'permission_denial',
|
||
tool_name: sdkCompatToolName(tool.name),
|
||
tool_use_id: toolUseID,
|
||
tool_input: input,
|
||
})
|
||
}
|
||
return result
|
||
}
|
||
|
||
// 5. 调用核心 query() 函数执行 agentic loop
|
||
yield* query({
|
||
systemPrompt, messages: this.mutableMessages,
|
||
tools, model: mainLoopModel, ...
|
||
})
|
||
}
|
||
```
|
||
|
||
关键设计:`submitMessage()` 是 `async *Generator`——它逐步 yield `SDKMessage`,让调用方(REPL/SDK)能实时展示进度,而不是等整个 turn 结束。
|
||
|
||
## 会话持久化:JSONL Transcript
|
||
|
||
每次对话事件都被追加写入 transcript 文件(`src/utils/sessionStorage.ts`):
|
||
|
||
### 存储路径
|
||
|
||
```
|
||
~/.claude/projects/<sanitized-cwd>/<session-uuid>.jsonl
|
||
```
|
||
|
||
- 路径由 `getProjectDir(originalCwd)` 生成,使用 `sanitizePath()` 将项目目录路径转换为安全的目录名(非 hash),同一项目目录的会话归入同一子目录
|
||
- 每条记录是一行 JSON(JSONL 格式),支持追加写入而不需要读取-修改-写入整个文件
|
||
- 读取上限为 50MB(`MAX_TRANSCRIPT_READ_BYTES` 常量,`src/utils/sessionStorage.ts`),防止超大会话导致 OOM
|
||
|
||
### Transcript 写入器
|
||
|
||
`Project` 类(`src/utils/sessionStorage.ts`,私有类)管理 transcript 的写入。它通过 `writeQueues`(按文件分组的写队列)和 `drainWriteQueue()`(定时批量刷写)确保并发消息追加不会互相覆盖:
|
||
|
||
```
|
||
写入流程(异步排队路径):
|
||
recordTranscript(sessionId, entry)
|
||
↓
|
||
project.enqueueWrite(filePath, entry) ← 入列到 writeQueues
|
||
↓
|
||
scheduleDrain() ← 设置定时器(FLUSH_INTERVAL_MS)
|
||
↓
|
||
drainWriteQueue() ← 按 MAX_CHUNK_BYTES 分批
|
||
↓ 写入每批
|
||
appendToFile(path, batchContent) ← 批量追加
|
||
↓
|
||
如果配置了远程持久化:
|
||
persistToRemote(sessionId, entry)
|
||
├── CCR v2: internalEventWriter('transcript', entry)
|
||
└── v1 Ingress: sessionIngress.appendSessionLog(...)
|
||
|
||
同步直写路径(用于元数据重写等场景):
|
||
appendEntryToFile(fullPath, entry) ← 同步 appendFileSync
|
||
↓
|
||
失败时 mkdir + 重试
|
||
```
|
||
|
||
### 会话恢复链路
|
||
|
||
`--resume` 参数触发的恢复流程(`src/main.tsx` 中 `--resume` 分支):
|
||
|
||
```
|
||
1. 解析 resume 参数:
|
||
├── UUID 格式 → getTranscriptPathForSession(uuid)
|
||
├── .jsonl 文件路径 → 直接使用
|
||
└── boolean → 最近一次会话的 picker
|
||
|
||
2. loadTranscriptFromFile(path)
|
||
├── 按 JSONL 行解析
|
||
├── 过滤出消息类型记录
|
||
└── 重建 Message[] 数组
|
||
|
||
3. 恢复上下文状态:
|
||
├── restoreCostStateForSession(sessionId) ← 恢复累计费用
|
||
├── 恢复 agentSetting(用户选择的 Agent 类型)
|
||
└── 如果有 --rewind-files,恢复文件到指定消息时的快照
|
||
|
||
4. 创建 QueryEngine({ initialMessages: restoredMessages })
|
||
└── 从恢复的消息继续对话
|
||
```
|
||
|
||
## 成本追踪:从 API Usage 到美元
|
||
|
||
成本追踪贯穿三个模块,形成完整的记录→累计→展示链路:
|
||
|
||
### 记录层:API 响应中的 Usage
|
||
|
||
每个 `message_delta` 事件携带 `usage` 字段(`input_tokens`、`output_tokens`、`cache_creation_input_tokens`、`cache_read_input_tokens`)。`accumulateUsage()` 将增量 usage 累加到会话总量。
|
||
|
||
### 累计层:cost-tracker.ts
|
||
|
||
```typescript
|
||
// src/cost-tracker.ts — StoredCostState 类型定义
|
||
type StoredCostState = {
|
||
totalCostUSD: number // 累计美元花费
|
||
totalAPIDuration: number // API 调用总时长(含重试)
|
||
totalAPIDurationWithoutRetries: number // 不含重试的纯推理时间
|
||
totalToolDuration: number // 工具执行总时长
|
||
totalLinesAdded: number // 代码增加行数
|
||
totalLinesRemoved: number // 代码删除行数
|
||
lastDuration: number | undefined // 最近一次会话时长
|
||
modelUsage: { [modelName: string]: ModelUsage } | undefined // 按模型分拆的用量
|
||
}
|
||
```
|
||
|
||
`addToTotalSessionCost()` 根据模型定价计算每次 API 调用的费用,累计到 `totalCostUSD`。按模型的 `ModelUsage` 支持在同一会话中切换模型后分别统计。
|
||
|
||
### 持久化:跨重启保留
|
||
|
||
```typescript
|
||
// 每次会话结束时保存到项目配置
|
||
saveCurrentSessionCosts(sessionId)
|
||
→ projectConfig.lastCost = totalCostUSD
|
||
→ projectConfig.lastSessionId = sessionId
|
||
→ projectConfig.lastModelUsage = modelUsage
|
||
```
|
||
|
||
### 预算熔断
|
||
|
||
`QueryEngineConfig.maxBudgetUsd` 提供了会话级的硬性预算上限。在 REPL 中,当累计费用超过 $5 时(`src/screens/REPL.tsx` 中费用阈值 `useEffect`),弹出费用提醒对话框——这不是硬性阻断,而是"软提醒",且仅在 `hasConsoleBillingAccess()` 为 true 时显示。
|
||
|
||
## 模型热切换
|
||
|
||
在一个会话中切换模型不会丢失对话历史——因为 `mutableMessages` 与模型选择是解耦的:
|
||
|
||
```
|
||
/model sonnet → QueryEngine.setModel('claude-sonnet-4-20250514')
|
||
↓ 实际操作:this.config.userSpecifiedModel = model(QueryEngine.setModel() 方法)
|
||
下一次 submitMessage() 开始时:
|
||
↓
|
||
parseUserSpecifiedModel(this.config.userSpecifiedModel)
|
||
→ 返回新的模型配置
|
||
↓
|
||
fetchSystemPromptParts({ mainLoopModel: newModel })
|
||
→ System Prompt 根据新模型能力重新组装
|
||
↓
|
||
query({ model: newModel, messages: this.mutableMessages })
|
||
→ 使用完整历史 + 新模型继续对话
|
||
```
|
||
|
||
切换模型时,`contextWindowTokens` 和 `maxOutputTokens` 也会根据新模型的规格重新计算——例如从 Sonnet 切换到 Opus 时,上下文窗口可能从 200K 变为 1M。
|
||
|
||
## 文件快照与回滚
|
||
|
||
`fileHistoryMakeSnapshot()`(`src/utils/fileHistory.ts`)在 AI 每次修改文件前自动保存当前内容。快照绑定到具体的 `message.id`,使得 `--rewind-files <user-message-id>` 可以精确恢复到对话中任意时间点的文件状态——这比 git 更细粒度(git 只追踪已提交的内容)。
|