--- tags: [多轮对话, QueryEngine, 会话管理, 成本追踪, 模型切换] create time: 2026-06-09 22:15 --- # 多轮对话管理 - QueryEngine 会话编排与持久化 ## 概述 Claude Code 的多轮对话由 `QueryEngine` 类统一编排,管理会话状态机、JSONL transcript 持久化、成本追踪和模型热切换。REPL、SDK、ACP 三种交互方式共享同一套会话管理基础设施。 ## 正文 ### 交互方式对比 首先要区分 Claude Code 的多种交互方式: REPL 关注交互形态,SDK 关注接入方式,ACP 则关注通信协议。 | 维度 | REPL(交互形态) | SDK(接入方式) | ACP(通信协议) | | :--- | :--- | :--- | :--- | | **是什么** | 供开发者直接在终端使用的**交互式对话环境** | 面向开发者的**程序化调用库**,供集成到其他应用 | 一种**开放式的通信标准**,连接不同 AI Agent 与编辑器 | | **使用方式** | 直接在终端输入 `claude` 命令,进入专用界面 | 在 Node.js/Python 项目中安装 SDK 包,通过 API 发送查询 | 通过 ACP 适配器启动 Claude Code,供编辑器通信 | | **典型场景** | 日常编写代码时提问、修改代码或执行任务 | 集成到自动化脚本、CI/CD 流程或其他应用后台 | 集成到 JetBrains IDE、Zed 等第三方编辑器 | | **主要特点** | 面向人,交互式,功能完整 | 面向程序,编程化,轻量级 | 标准化,双向通信,与编辑器深度整合 | 其中 SDK 与 ACP 采用 `QueryEngine` 实现会话管理。REPL 交互形态则通过 `onQueryImpl` 在 `src/screens/REPL.tsx` 中调用 `query()` 函数。 #### REPL 模式的调用链路 ```mermaid flowchart TD A["用户输入"] --> B["onSubmit REPL.tsx"] B --> C["handlePromptSubmit"] C --> D["executeUserInput"] D --> E["onQuery REPL.tsx"] E --> F["onQueryImpl REPL.tsx"] F --> G["query query.ts"] ``` 其中 `query` 函数是 Agentic Loop 的核心实现,包含 `while(true)` 循环处理对话回合(`query.ts:460-522`)。 `onQueryImpl` 是 REPL 中与 AI 模型交互的核心控制器,负责: 1. 环境准备(IDE、诊断、权限) 2. 会话标题的首次生成 3. 构建动态系统提示和用户上下文 4. 执行流式查询并实时更新 UI 5. 收集性能指标和最终清理 ### onQueryImpl 方法详解 该方法是一个 React `useCallback` 包装的异步函数,负责处理用户消息到 AI 模型的**完整查询流程**。 #### 函数签名与参数 ```typescript const onQueryImpl = useCallback( async ( messagesIncludingNewMessages: MessageType[], newMessages: MessageType[], abortController: AbortController, shouldQuery: boolean, additionalAllowedTools: string[], mainLoopModelParam: string, effort?: EffortValue, ) => { /* ... */ }, [ /* ...dependencies */ ] ) ``` | 参数 | 说明 | |------|------| | `messagesIncludingNewMessages` | 包含新增消息的完整消息列表,用于构建模型输入 | | `newMessages` | 本次新增的消息(例如用户刚输入的文本或附件) | | `abortController` | 用于取消当前查询的控制器 | | `shouldQuery` | 是否真正执行查询;若为 `false` 则跳过模型调用 | | `additionalAllowedTools` | 本轮查询额外允许的工具列表(通常来自 Skill 的 frontmatter) | | `mainLoopModelParam` | 指定本次使用的主模型参数 | | `effort` | 可选,覆盖全局的"努力程度"值 | #### 总体执行流程 ```mermaid flowchart TD A["开始"] --> B{"shouldQuery?"} B -- true --> C["IDE集成: 刷新MCP客户端, 诊断追踪, 关闭差异视图"] B -- false --> D["仅处理compact边界/重置状态并返回"] C --> E["标记项目onboarding完成"] E --> F["尝试生成会话标题 仅一次"] F --> G["将additionalAllowedTools写入全局权限store"] G --> H["获取ToolUseContext 含最新工具/MCP"] H --> I["如有effort, 临时覆盖effortValue"] I --> J["并行执行: 系统提示/用户上下文/系统上下文/自动模式检查"] J --> K["构建有效系统提示"] K --> L["重置各类耗时计时器"] L --> M["执行query生成器, 流式处理事件"] M --> N["后处理: BUDDY/UDS/指标收集"] N --> O["重置加载状态, 输出性能报告"] O --> P["结束"] D --> P ``` #### 核心逻辑要点 **IDE 集成与诊断**: 从 store 中获取最新的 MCP 客户端,通知诊断追踪器查询开始,若存在已连接的 IDE 客户端则关闭所有打开的差异视图。 **会话标题生成**: 仅当全局标题未禁用、当前无任何标题且从未尝试过时执行。从新增消息中提取第一条非元用户消息的真实文本,异步调用 `generateSessionTitle`。 **权限工具覆盖写入**: 将本轮 `additionalAllowedTools` 写入全局 store 的 `toolPermissionContext.alwaysAllowRules.command`,通过浅比较避免不必要的状态更新。 **shouldQuery = false 分支**: 处理不需要实际调用模型的情况(如无效斜杠命令或手动 `/compact`)。若新消息中包含 compact 边界消息,则生成新的 `conversationId`。 **查询前置准备**: `getToolUseContext` 获取最新工具和 MCP 客户端配置;可选的 `effort` 参数临时覆盖 `getAppState` 返回的 `effortValue`(仅限本轮查询);并行获取系统提示、用户上下文和系统上下文。 **流式事件处理**: 重置本轮计时器,调用 `query` 生成器函数,遍历每个事件并调用 `onQueryEvent` 更新 UI。 **后处理与指标收集**: BUDDY 特性的 companion 反应、UDS_INBOX 中断处理、Ant 内部用户的 API 指标记录(TTFT、OTPS)、重置加载状态和输出性能报告。 ### 单轮 vs 多轮: 架构层面的差异 - **单轮**(一次 Agentic Loop): `query()` 函数的一次完整执行——组装上下文 -> 调 API -> 处理工具调用 -> 循环直到结束 - **多轮**(一个 Session): `QueryEngine` 类管理的一次会话——跨越数十轮 `submitMessage()` 调用,持续数小时 `QueryEngine`(`src/QueryEngine.ts`)是单轮 Agentic Loop 之上的**会话编排器**,它管理的状态远不止消息列表: ```mermaid mindmap root(("QueryEngine 内部状态")) mutableMessages "Message[] 完整对话历史" readFileState "FileStateCache 已读文件缓存" totalUsage "NonNullableUsage 累计token消耗" permissionDenials "SDKPermissionDenial[] 权限拒绝记录" discoveredSkillNames "Set string 当前turn已发现的skill" loadedNestedMemoryPaths "Set string 已加载的嵌套memory路径" hasHandledOrphanedPermission "boolean 是否已处理孤立权限请求" abortController "AbortController 会话级中断控制" ``` ### QueryEngine 的核心方法: submitMessage() 每次用户输入一条消息,SDK 调用 `submitMessage()`,它会执行完整的 turn 初始化链路: ```typescript // src/QueryEngine.ts -- submitMessage() 简化流程 async *submitMessage( prompt: string | ContentBlockParam[], options?: { uuid?: string; isMeta?: boolean }, ): AsyncGenerator { // 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({ /* ... */ }) } return result } // 5. 调用核心 query() 函数执行 agentic loop yield* query({ systemPrompt, messages: this.mutableMessages, tools, model: mainLoopModel, ... }) } ``` > [!tip] 关键设计 > `submitMessage()` 是 `async *Generator`——它逐步 yield `SDKMessage`,让调用方(REPL/SDK)能实时展示进度,而不是等整个 turn 结束。 ### 会话持久化: JSONL Transcript 每次对话事件都被追加写入 transcript 文件(`src/utils/sessionStorage.ts`)。 **存储路径**: `~/.claude/projects//.jsonl` - 路径由 `getProjectDir(originalCwd)` 生成,使用 `sanitizePath()` 将项目目录路径转换为安全的目录名 - 每条记录是一行 JSON(JSONL 格式),支持追加写入 - 读取上限为 50MB(`MAX_TRANSCRIPT_READ_BYTES`),防止超大会话导致 OOM #### Transcript 写入流程 ```mermaid flowchart TD A["recordTranscript sessionId, entry"] --> B["project.enqueueWrite filePath, entry"] B --> C["scheduleDrain 设置定时器"] C --> D["drainWriteQueue 按MAX_CHUNK_BYTES分批"] D --> E["appendToFile 批量追加写入"] E --> F{"配置了远程持久化?"} F -- 是 --> G["persistToRemote"] F -- 否 --> H["完成"] G --> H ``` 同步直写路径用于元数据重写等场景: `appendEntryToFile(fullPath, entry)` 使用 `appendFileSync`,失败时 `mkdir` + 重试。 #### 会话恢复链路 `--resume` 参数触发的恢复流程: 1. 解析 resume 参数: UUID 格式 -> `getTranscriptPathForSession(uuid)`;`.jsonl` 文件路径 -> 直接使用;boolean -> 最近一次会话的 picker 2. `loadTranscriptFromFile(path)`: 按 JSONL 行解析,过滤出消息类型记录,重建 `Message[]` 数组 3. 恢复上下文状态: `restoreCostStateForSession(sessionId)` 恢复累计费用、恢复 agentSetting、如有 `--rewind-files` 则恢复文件快照 4. 创建 `QueryEngine({ initialMessages: restoredMessages })` 从恢复的消息继续对话 ### 成本追踪: 从 API Usage 到美元 成本追踪贯穿三个模块,形成完整的记录-累计-展示链路。 **记录层**: 每个 `message_delta` 事件携带 `usage` 字段(`input_tokens`、`output_tokens`、`cache_creation_input_tokens`、`cache_read_input_tokens`)。`accumulateUsage()` 将增量 usage 累加到会话总量。 **累计层** (`src/cost-tracker.ts`): ```typescript type StoredCostState = { totalCostUSD: number // 累计美元花费 totalAPIDuration: number // API 调用总时长(含重试) totalAPIDurationWithoutRetries: number // 不含重试的纯推理时间 totalToolDuration: number // 工具执行总时长 totalLinesAdded: number // 代码增加行数 totalLinesRemoved: number // 代码删除行数 lastDuration: number | undefined // 最近一次会话时长 modelUsage: { [modelName: string]: ModelUsage } | undefined // 按模型分拆的用量 } ``` **持久化**: 每次会话结束时保存到项目配置(`saveCurrentSessionCosts`),跨重启保留。 **预算熔断**: `QueryEngineConfig.maxBudgetUsd` 提供会话级硬性预算上限。REPL 中累计费用超过 $5 时弹出费用提醒对话框(软提醒)。 ### 模型热切换 在一个会话中切换模型不会丢失对话历史——因为 `mutableMessages` 与模型选择是解耦的: ```mermaid sequenceDiagram participant U as 用户 participant Q as QueryEngine participant P as Parser participant S as SystemPrompt participant A as API U->>Q: /model sonnet Q->>Q: setModel("claude-sonnet-4-20250514") Note over Q: config.userSpecifiedModel = model U->>Q: submitMessage(下一条消息) Q->>P: parseUserSpecifiedModel() P-->>Q: 新模型配置 Q->>S: fetchSystemPromptParts(newModel) S-->>Q: 重新组装的 System Prompt Q->>A: query(model=newModel, messages=完整历史) ``` 切换模型时,`contextWindowTokens` 和 `maxOutputTokens` 也会根据新模型的规格重新计算——例如从 Sonnet 切换到 Opus 时,上下文窗口可能从 200K 变为 1M。 ### 文件快照与回滚 `fileHistoryMakeSnapshot()`(`src/utils/fileHistory.ts`)在 AI 每次修改文件前自动保存当前内容。快照绑定到具体的 `message.id`,使得 `--rewind-files ` 可以精确恢复到对话中任意时间点的文件状态——这比 git 更细粒度(git 只追踪已提交的内容)。 ## 关联笔记 - [[the-loop]] - Agentic Loop 核心机制与状态机 - [[streaming]] - 流式响应机制与 SSE 事件处理 - [[../context/system-prompt]] - System Prompt 动态组装 - [[../context/compaction]] - 上下文压缩策略