2 · 会话层:QueryEngine

这一章讲「一场对话」这个东西在程序里是怎么被表示和管理的

2.1 它解决什么问题

大语言模型没有记忆 —— 每次调用都要把全部历史重发一遍。所以必须有个东西持有这场对话的所有状态,在用户的每一次提问之间保持存活。

这就是 QueryEngine(查询引擎)。源码里的类注释说得很清楚:

「QueryEngine owns the query lifecycle and session state for a conversation. One QueryEngine per conversation. Each submitMessage() call starts a new turn within the same conversation. State (messages, file cache, usage, etc.) persists across turns.」

译:QueryEngine 持有一场对话的查询生命周期和会话状态。一场对话对应一个 QueryEngine 实例。每次调用 submitMessage() 就在同一场对话里开启一个新轮次。状态(消息、文件缓存、用量等)跨轮次保留。

2.2 它持有哪些状态

export class QueryEngine {
  private config: QueryEngineConfig       // 不变的配置(工具、命令、模型等)
  private mutableMessages: Message[]      // ★ 完整的消息历史,会一直增长
  private abortController: AbortController // 中止开关,贯穿整条调用链
  private permissionDenials: SDKPermissionDenial[]  // 被拒绝过的操作记录
  private totalUsage: NonNullableUsage    // 累计 token 用量
  private hasHandledOrphanedPermission = false
  private readFileState: FileStateCache   // ★ 读过哪些文件、什么版本

  // 下面两个是"轮次内追踪",每轮开头清空
  private discoveredSkillNames = new Set<string>()   // 本轮发现了哪些技能
  private loadedNestedMemoryPaths = new Set<string>() // 本轮加载了哪些记忆文件
}

claude-code/src/QueryEngine.ts

其中两个字段值得单独说

readFileState(文件读取状态缓存)记录「模型读过哪些文件、读的是哪个版本」。它有三个用途:

  1. 防止重复注入。记忆系统预取到一个文件时,如果模型自己已经读过它,就不再作为「记忆」注入一遍。
  2. 检测文件被外部修改。模型读过 a.ts,后来用户在编辑器里改了它,那么模型手里的内容就过期了。系统会检测到并注入一条提示。
  3. 编辑前的安全检查。模型要改一个它从没读过的文件时,工具会拒绝并要求它先读 —— 因为盲改极易出错。

abortController(中止控制器)是那个「取消开关」。它被传递到每一个工具调用、每一个网络请求。用户按 Ctrl+C 时拉一下,整条链路都能感知到。第 3 章会讲它的正确处理姿势。

2.3 一次 submitMessage 的完整流程

submitMessage() 是这个类的核心方法。它是一个异步生成器 —— 也就是说它不是「算完再返回」,而是一边算一边往外吐消息,调用方可以实时消费。

async *submitMessage(
  prompt: string | ContentBlockParam[],
  options?: { uuid?: string; isMeta?: boolean },
): AsyncGenerator<SDKMessage, void, unknown>

async * 是 JavaScript 的异步生成器语法。yield 一个值就等于「先把这个吐出去,调用方拿到之后我再继续」。这是流式界面能实时更新的基础。)

完整流程:

① 清空轮次级追踪状态 discoveredSkillNames.clear() setCwd(cwd) 设置当前工作目录 ↓ ② 包装权限检查函数 把外部传入的 canUseTool 包一层,用来记录所有被拒绝的操作 (这些记录最终会出现在返回结果的 permission_denials 字段里) ↓ ③ 构造系统提示词 fetchSystemPromptParts() → 三段结构(见第 1.6 节) + 可选的记忆机制说明 + 可选的 --append-system-prompt 追加内容 ↓ ④ 处理用户输入 processUserInput() · 解析斜杠命令(如果是 /compact 就不用调模型了) · 处理 @文件提及 → 读文件内容作为附件 · 处理粘贴的图片 · 返回:要追加的消息、是否需要调模型、允许的工具、指定的模型 ↓ ⑤ ★ 把用户消息先落盘(这一步的时机很关键,见 2.4) ↓ ⑥ 发出 system_init 消息 告诉调用方:这次用什么模型、有哪些工具、哪些 MCP 服务、哪些技能、哪些插件 ↓ ⑦ 分支: ├─ shouldQuery === false(纯本地斜杠命令) │ → 直接把命令的输出吐出去,发一个 result 消息,返回 │ └─ 需要调模型 → 进入 query() 主循环(第 3 章) → 一边消费主循环吐出的消息,一边做记录、转换、预算检查

2.4 用户消息为什么必须先落盘

第 ⑤ 步有一段很长的注释,讲的是一个真实的线上问题:

「Persist the user's message(s) to transcript BEFORE entering the query loop. The for-await below only calls recordTranscript when ask() yields an assistant/user/compact_boundary message — which doesn't happen until the API responds. If the process is killed before that (e.g. user clicks Stop in cowork seconds after send), the transcript is left with only queue-operation entries; getLastSessionLog filters those out, returns null, and --resume fails with "No conversation found".」

译:在进入查询循环之前就把用户消息写入对话记录。因为下面那个循环只有在生成器吐出模型消息 / 用户消息 / 压缩分界点消息时才会调用记录函数 —— 而这要等到接口响应回来才会发生。如果进程在那之前就被杀掉(比如用户点了发送之后几秒就点了停止),对话记录里就只剩下队列操作条目;而读取上次会话记录的函数会把这些过滤掉、返回空,于是 --resume 会报「找不到对话」。

翻译成人话:用户发出消息后、模型还没回复的那几秒钟里,如果程序被杀掉,这次对话就彻底找不回来了。因为落盘的时机在模型回复之后。

修复方式是把落盘提前到「用户消息被接受」的那一刻。但这里又冒出一个性能权衡:

if (persistSession && messagesFromUserInput.length > 0) {
  const transcriptPromise = recordTranscript(messages)
  if (isBareMode()) {
    void transcriptPromise          // ★ 极简模式:发射后不管,不等它写完
  } else {
    await transcriptPromise         // 正常模式:等写完再继续
    ...
  }
}

注释解释了为什么极简模式要特殊处理:

「--bare / SIMPLE: fire-and-forget. Scripted calls don't --resume after kill-mid-request. The await is ~4ms on SSD, ~30ms under disk contention — the single largest controllable critical-path cost after module eval.」

译:极简模式下发射后不管。脚本化调用不会在请求中途被杀之后去恢复。这个 await 在固态硬盘上约 4 毫秒,在磁盘竞争时约 30 毫秒 —— 是继模块加载之后关键路径上最大的可控开销

这句话的信息量很大:他们把关键路径上的每一项开销都量化过,4 到 30 毫秒已经是「最大的可控开销」了。

2.5 消费主循环输出:一个大 switch

进入主循环后,QueryEngine 用一个 for await 循环消费主循环吐出的每一条消息,按类型分别处理:

消息类型QueryEngine 做什么
assistant
模型回复
记录停止原因、追加到历史、用「发射后不管」的方式落盘(原因见下)、转换成标准格式吐给调用方
user
用户消息 / 工具结果
追加到历史、同步等待落盘、轮次计数 +1
progress
进度
追加到历史并立刻落盘(原因见下)
attachment
附件
追加、立刻落盘。如果是「结构化输出」附件则提取结果;如果是「达到最大轮次」则发一个错误结果并返回
stream_event
流式事件
累计 token 用量。只有开了 --include-partial-messages 才吐给调用方
system
系统消息
压缩分界点 → 释放分界点之前的消息供垃圾回收;接口错误 → 转成重试通知
tombstone
墓碑
控制信号,表示「删除某条消息」,直接跳过不处理
tool_use_summary
工具摘要
转发给调用方(用于移动端界面显示「刚才做了什么」)

为什么模型消息要「发射后不管」,而用户消息要同步等待

「Fire-and-forget for assistant messages. claude.ts yields one assistant message per content block, then mutates the last one's message.usage/stop_reason on message_delta — relying on the write queue's 100ms lazy jsonStringify. Awaiting here blocks ask()'s generator, so message_delta can't run until every block is consumed; the drain timer (started at block 1) elapses first.」

译:模型消息用发射后不管。接口层为每个内容块吐出一条模型消息,然后在收到 message_delta 事件时修改最后那条消息的用量和停止原因字段 —— 这依赖写入队列 100 毫秒的延迟序列化。如果在这里等待,就会阻塞生成器,导致 message_delta 事件要等所有内容块被消费完才能处理;而排空定时器(从第 1 个块就开始计时了)会先到期。

这一段涉及一个精巧的机制,值得展开:

模型流式返回时,接口层的行为: · 每收到一个内容块,就 yield 一条模型消息 · 最后收到 message_delta 事件时,回头修改"最后那条消息"的用量字段 ↓ 落盘的写入队列有一个 100 毫秒的延迟: 消息进队列后不立刻序列化,等 100 毫秒再统一处理 ★ 这个延迟正是为了让 message_delta 有机会先把字段补全 ↓ 如果这里 await(同步等待落盘): 生成器被阻塞 → 后续内容块无法被消费 → message_delta 永远轮不到 → 100 毫秒的排空定时器先到期 → 用量字段还是空的就被序列化了 ↓ 所以必须"发射后不管"。而写入队列本身是保序的,所以不会乱序。

progress(进度)消息要「立刻落盘」,也有专门的注释:

「Record inline so the dedup loop in the next ask() call sees it as already-recorded. Without this, deferred progress interleaves with already-recorded tool_results in mutableMessages, and the dedup walk freezes startingParentUuid at the wrong message — forking the chain and orphaning the conversation on resume.」

译:就地记录,这样下一次调用时的去重循环才能看到它已被记录。否则延迟的进度消息会和已记录的工具结果交错,导致去重遍历把「起始父节点」固定在错误的消息上 —— 从而分叉出一条支链,让对话在恢复时变成孤儿。

这里透露了对话记录的一个重要结构:它不是一个线性列表,而是一棵通过「父节点 ID」串起来的树。第 11 章会详细讲。

2.6 压缩分界点:主动释放内存

当主循环发出「压缩分界点」消息时,QueryEngine 做一件很重要的事:

if (message.subtype === 'compact_boundary' && message.compactMetadata) {
  // 分界点之前的消息已经被摘要替代了,可以释放给垃圾回收器
  const mutableBoundaryIdx = this.mutableMessages.length - 1
  if (mutableBoundaryIdx > 0) {
    this.mutableMessages.splice(0, mutableBoundaryIdx)   // ★ 直接从数组里删掉
  }
  const localBoundaryIdx = messages.length - 1
  if (localBoundaryIdx > 0) {
    messages.splice(0, localBoundaryIdx)
  }
  yield { type:'system', subtype:'compact_boundary', ... }
}

注释:「Release pre-compaction messages for GC. query.ts already uses getMessagesAfterCompactBoundary() internally, so only post-boundary messages are needed going forward.」(把压缩前的消息释放给垃圾回收。主循环内部已经只用分界点之后的消息了,所以往后只需要保留这些。)

为什么要专门做这件事?因为一场长会话的消息历史可能有几百 MB。压缩之后前面那些消息在逻辑上已经没用了,但只要数组还引用着它们,垃圾回收器就不会回收 —— 内存会一直涨到进程被系统杀掉。

但落盘要在释放之前完成

而且顺序不能错。在删除之前,有一段专门的落盘逻辑:

if (persistSession && message.type === 'system' &&
    message.subtype === 'compact_boundary') {
  const tailUuid = message.compactMetadata?.preservedSegment?.tailUuid
  if (tailUuid) {
    const tailIdx = this.mutableMessages.findLastIndex(m => m.uuid === tailUuid)
    if (tailIdx !== -1) {
      await recordTranscript(this.mutableMessages.slice(0, tailIdx + 1))
    }
  }
}

注释解释了不这么做的后果:「If the SDK subprocess restarts before then (claude-desktop kills between turns), tailUuid points to a never-written message → applyPreservedSegmentRelinks fails its tail→head walk → returns without pruning → resume loads full pre-compact history.」

译:如果子进程在那之前重启(桌面应用会在轮次之间杀进程),保留段的尾节点就指向了一条从未被写入的消息 → 重新串联函数的「从尾到头」遍历失败 → 直接返回不做裁剪 → 于是恢复时会加载完整的压缩前历史。

症状是:用户压缩过的会话,恢复之后又变回了压缩前的样子,上下文立刻爆掉。

2.7 三种退出结果

submitMessage 最终会发出一个 result 消息,标明这次轮次是怎么结束的:

结果类型什么时候发生
success正常完成
error_max_turns达到 --max-turns 上限
error_max_budget_usd达到 --max-budget-usd 上限
error_max_structured_output_retries要求结构化输出,但模型连续 5 次都产出不合格的结果
error_during_execution执行过程中出了没能恢复的错

最后那个错误类型带了一个专门的诊断前缀

errors: (() => {
  const all = getInMemoryErrors()
  const start = errorLogWatermark ? all.lastIndexOf(errorLogWatermark) + 1 : 0
  return [
    // ★ 诊断前缀:直接说明"判定失败"的那三个条件各自是什么值
    `[ede_diagnostic] result_type=${edeResultType} ` +
    `last_content_type=${edeLastContentType} stop_reason=${lastStopReason}`,
    ...all.slice(start).map(_ => _.error),
  ]
})()

而且错误列表是按轮次范围截取的 —— 用了一个「水位标记」:

// 用引用而不是下标作为水位标记,这样 error_during_execution 的 errors 数组
// 是轮次范围内的。用长度下标会在 100 条环形缓冲区发生位移时失效 —— 下标会滑走。
// 如果这条标记本身被轮换出去了,lastIndexOf 返回 -1,就包含全部(安全兜底)。
const errorLogWatermark = getInMemoryErrors().at(-1)
这段代码体现的工程习惯

内存里的错误日志是一个只保留最近 100 条的环形缓冲区。想标记「本轮开始的位置」,最直觉的做法是记下当时的数组长度。

但环形缓冲区在满了之后会从头部丢弃元素 —— 你记的那个下标会「滑走」,指向别的位置。

正确做法是记住那个元素本身的引用,之后用 lastIndexOf 反查它现在在哪。如果它已经被挤出去了,反查返回 -1,代码就退化为「包含全部错误」—— 这是一个安全的降级行为,宁可多报也不漏报。

2.8 ask():一次性调用的便捷封装

文件末尾还导出了一个 ask() 函数,是 QueryEngine 的一次性封装 —— 创建实例、跑一轮、把文件缓存交还给调用方:

export async function* ask({...}) {
  const engine = new QueryEngine({
    ...,
    readFileCache: cloneFileStateCache(getReadFileCache()),   // ★ 传入的是克隆
  })
  try {
    yield* engine.submitMessage(prompt, { uuid: promptUuid, isMeta })
  } finally {
    setReadFileCache(engine.getReadFileState())   // ★ 无论如何都要交还
  }
}

两个细节: