这一章讲「一场对话」这个东西在程序里是怎么被表示和管理的。
大语言模型没有记忆 —— 每次调用都要把全部历史重发一遍。所以必须有个东西持有这场对话的所有状态,在用户的每一次提问之间保持存活。
这就是 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() 就在同一场对话里开启一个新轮次。状态(消息、文件缓存、用量等)跨轮次保留。
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(文件读取状态缓存)记录「模型读过哪些文件、读的是哪个版本」。它有三个用途:
a.ts,后来用户在编辑器里改了它,那么模型手里的内容就过期了。系统会检测到并注入一条提示。abortController(中止控制器)是那个「取消开关」。它被传递到每一个工具调用、每一个网络请求。用户按 Ctrl+C 时拉一下,整条链路都能感知到。第 3 章会讲它的正确处理姿势。
submitMessage() 是这个类的核心方法。它是一个异步生成器 —— 也就是说它不是「算完再返回」,而是一边算一边往外吐消息,调用方可以实时消费。
async *submitMessage(
prompt: string | ContentBlockParam[],
options?: { uuid?: string; isMeta?: boolean },
): AsyncGenerator<SDKMessage, void, unknown>
(async * 是 JavaScript 的异步生成器语法。yield 一个值就等于「先把这个吐出去,调用方拿到之后我再继续」。这是流式界面能实时更新的基础。)
完整流程:
第 ⑤ 步有一段很长的注释,讲的是一个真实的线上问题:
「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 毫秒已经是「最大的可控开销」了。
进入主循环后,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 个块就开始计时了)会先到期。
这一段涉及一个精巧的机制,值得展开:
而 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 章会详细讲。
当主循环发出「压缩分界点」消息时,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.」
译:如果子进程在那之前重启(桌面应用会在轮次之间杀进程),保留段的尾节点就指向了一条从未被写入的消息 → 重新串联函数的「从尾到头」遍历失败 → 直接返回不做裁剪 → 于是恢复时会加载完整的压缩前历史。
症状是:用户压缩过的会话,恢复之后又变回了压缩前的样子,上下文立刻爆掉。
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,代码就退化为「包含全部错误」—— 这是一个安全的降级行为,宁可多报也不漏报。
文件末尾还导出了一个 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()) // ★ 无论如何都要交还
}
}
两个细节:
finally 块保证交还。即使中间抛异常、被中断,「模型读过哪些文件」这个信息也不会丢。丢了会导致下一轮重复注入记忆或者误判文件新鲜度。