Files

12 KiB
Raw Permalink Blame History

tags, create time
tags create time
多轮对话
QueryEngine
会话管理
成本追踪
模型切换
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 模式的调用链路

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 模型的完整查询流程。

函数签名与参数

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 可选,覆盖全局的"努力程度"值

总体执行流程

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 之上的会话编排器,它管理的状态远不止消息列表:

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 初始化链路:

// src/QueryEngine.ts -- 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({ /* ... */ })
    }
    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/<sanitized-cwd>/<session-uuid>.jsonl

  • 路径由 getProjectDir(originalCwd) 生成,使用 sanitizePath() 将项目目录路径转换为安全的目录名
  • 每条记录是一行 JSON(JSONL 格式),支持追加写入
  • 读取上限为 50MB(MAX_TRANSCRIPT_READ_BYTES),防止超大会话导致 OOM

Transcript 写入流程

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):

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 与模型选择是解耦的:

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 <user-message-id> 可以精确恢复到对话中任意时间点的文件状态——这比 git 更细粒度(git 只追踪已提交的内容)。

关联笔记