第九篇:query.ts ------ Claude Code 与 Anthropic API 的对话层
源码位置:
src/query.ts(约500行核心逻辑)|难度:高级
一、引言:为什么 query.ts 是神经末梢
前八篇我们拆解了 Claude Code 的架构、CLI、Handler、QueryEngine,但一直有一个关键问题没回答:Claude Code 究竟是怎么和 Anthropic API 对话的?
答案就在 src/query.ts 里。这个文件虽然只有约500行,却是整个系统的"神经末梢"------它负责:
- 构建 API 请求:把内部消息格式转换成 Anthropic Messages API 需要的格式
- 处理流式响应:逐块解析 SSE 事件流
- 错误处理与重试:速率限制、网络超时、服务端错误
- 工具执行编排:协调工具调用和结果回传
- 上下文窗口管理:自动压缩(auto-compact)、快照(snip)、上下文折叠
💡 设计亮点 :
query()函数是一个 AsyncGenerator ,通过yield逐步向外推送事件(文本、工具调用、用量等),而不是等整个处理完成后一次性返回。这使得 UI 可以实时渲染流式输出。
二、query.ts 在架构中的位置
用户输入
↓
CLI / REPL
↓
QueryEngine.ts ← 第八篇讲的,负责对话状态管理
↓
query.ts ← 本篇主角,负责 API 通信
↓
Anthropic Messages API
核心职责划分:
- QueryEngine:管理对话状态(messages 数组)、构建上下文(CLAUDE.md、git 状态)、调用 query()
- query.ts:纯粹负责 API 通信,不包含业务逻辑
这种分离使得:
- QueryEngine 可以专注业务(权限、成本追踪、会话持久化)
- query.ts 可以专注通信(流式解析、重试、错误分类)
三、核心函数:query()
query() 是 query.ts 的唯一导出函数,签名如下:
typescript
export async function* query(
params: QueryParams,
): AsyncGenerator<
| StreamEvent
| RequestStartEvent
| Message
| TombstoneMessage
| ToolUseSummaryMessage,
Terminal
>
QueryParams:输入参数
| 字段 | 说明 |
|---|---|
messages |
对话历史(Message\[\]) |
systemPrompt |
系统提示词(SystemPrompt 类型) |
userContext |
用户上下文(CLAUDE.md 等) |
systemContext |
系统上下文(git status 等) |
canUseTool |
权限检查函数 |
toolUseContext |
工具执行上下文 |
fallbackModel |
备用模型(速率限制时切换) |
querySource |
请求来源(REPL/SDK/agent 等) |
maxOutputTokensOverride |
输出 token 上限覆盖 |
maxTurns |
最大对话轮次 |
taskBudget |
任务预算(API beta 功能) |
四、query 循环:一步步拆解
query() 内部是一个 无限循环 (while (true)),每次循环代表一次 API 调用。循环退出的条件:
- 模型返回
stop_reason: "end_turn"(不需要调用工具) - 达到
maxTurns上限 - 用户取消(AbortController)
- 发生不可恢复错误
循环体的 12 个关键步骤
1. 解构状态(State Destructuring)
typescript
let { toolUseContext } = state
const {
messages,
autoCompactTracking,
maxOutputTokensRecoveryCount,
hasAttemptedReactiveCompact,
maxOutputTokensOverride,
pendingToolUseSummary,
stopHookActive,
turnCount,
} = state
每次循环开始时,从 state 对象中解构出所有可变状态。这样做的好处:
- 裸变量名访问 :循环体内直接用
messages、toolUseContext,不需要state.messages - Continue 站点统一 :所有
continue跳转都通过state = { ... }重新赋值
2. 技能发现预取(Skill Discovery Prefetch)
typescript
const pendingSkillPrefetch = skillPrefetch?.startSkillDiscoveryPrefetch(
null,
messages,
toolUseContext,
)
优化亮点 :技能发现(从项目提取可复用 prompt)在模型流式输出和工具执行期间并行运行,而不是阻塞等待。这隐藏了 97% 的空结果发现(production 数据)。
3. 上下文预处理
在调用 API 之前,需要对消息进行多处预处理:
a) 工具结果预算强制(applyToolResultBudget)
typescript
messagesForQuery = await applyToolResultBudget(
messagesForQuery,
toolUseContext.contentReplacementState,
persistReplacements
? records => void recordContentReplacement(records, toolUseContext.agentId).catch(logError)
: undefined,
new Set(
toolUseContext.options.tools
.filter(t => !Number.isFinite(t.maxResultSizeChars))
.map(t => t.name),
),
)
问题:工具执行结果可能非常大(例如读取一个 10MB 的文件),直接发给 API 会撑爆上下文窗口。
解决方案:
- 对未设置
maxResultSizeChars的工具,强制截断结果 - 用
contentReplacementState记录替换(这样在 resume 时还能恢复完整内容)
b) Snip 压缩(Snip Compaction)
typescript
if (feature('HISTORY_SNIP')) {
const snipResult = snipModule!.snipCompactIfNeeded(messagesForQuery)
messagesForQuery = snipResult.messages
snipTokensFreed = snipResult.tokensFreed
}
Snip 原理 :移除早期对话中的冗余工具调用结果(例如,已经读取过的文件内容),但保留:
- 关键决策点(用户指令、模型回复)
- 工具调用的 元数据(工具名、参数),这样模型知道"这个文件我已经读过了"
c) 微压缩(Microcompact)
typescript
const microcompactResult = await deps.microcompact(
messagesForQuery,
toolUseContext,
querySource,
)
messagesForQuery = microcompactResult.messages
Microcompact 是什么 :一种轻量级压缩,只移除连续重复内容(例如,模型多次调用同一个工具,返回相同结果)。
与普通压缩的区别:
- 普通压缩(Auto-compact):用 Haiku 生成摘要,替换整个对话历史
- 微压缩:只移除重复块,不生成摘要,延迟更低
d) 上下文折叠(Context Collapse)
typescript
if (feature('CONTEXT_COLLAPSE') && contextCollapse) {
const collapseResult = await contextCollapse.applyCollapsesIfNeeded(
messagesForQuery,
toolUseContext,
querySource,
)
messagesForQuery = collapseResult.messages
}
上下文折叠 :将已解决的子任务折叠成一个摘要块。例如:
- 原始:10 轮对话都在调试一个 bug
- 折叠后:一个
[COLLAPSED: debug X bug → fixed]块
这使得模型能"记住"完成了什么,但不会为已完成的事情浪费 token。
4. 构建完整系统提示词
typescript
const fullSystemPrompt = asSystemPrompt(
appendSystemContext(systemPrompt, systemContext),
)
系统提示词的组成:
- 基础系统提示词 (
systemPrompt):从system.md文件加载的核心指令 - 用户上下文 (
userContext):CLAUDE.md 的内容 - 系统上下文 (
systemContext):git status、当前日期等
appendSystemContext() 负责把这三部分拼接成一个字符串(或数组,如果 API 支持多段系统提示词)。
5. 自动压缩(Auto-compact)
typescript
const { compactionResult, consecutiveFailures } = await deps.autocompact(
messagesForQuery,
toolUseContext,
{
systemPrompt,
userContext,
systemContext,
toolUseContext,
forkContextMessages: messagesForQuery,
},
querySource,
tracking,
snipTokensFreed,
)
Auto-compact 触发条件(满足任一):
- 上下文窗口使用率 > 95%
- 单次对话超过 50 轮
- 用户手动触发
/compact
压缩过程:
- 用 Haiku(最快的 Claude 模型)读取完整对话历史
- 生成摘要(保留关键决策、移除冗余细节)
- 用摘要替换原始消息
- 返回
compactionResult(包含压缩前后的 token 数、摘要消息)
如果压缩失败:
consecutiveFailures记录连续失败次数- 失败 3 次后,断路器触发,不再尝试压缩
- 用户会看到警告:"上下文窗口已满,但自动压缩失败"
6. 阻塞限制检查(Blocking Limit Check)
typescript
if (!compactionResult && querySource !== 'compact' && ...) {
const { isAtBlockingLimit } = calculateTokenWarningState(
tokenCountWithEstimation(messagesForQuery) - snipTokensFreed,
toolUseContext.options.mainLoopModel,
)
if (isAtBlockingLimit) {
yield createAssistantAPIErrorMessage({
content: PROMPT_TOO_LONG_ERROR_MESSAGE,
error: 'invalid_request',
})
return { reason: 'blocking_limit' }
}
}
为什么需要阻塞限制:
- 如果上下文窗口已经 100% 满了,API 会返回 413 错误
- 与其让 API 拒绝请求,不如在客户端提前拦截,给用户一个友好的错误信息
跳过检查的情况:
- 刚完成压缩(
compactionResult存在):压缩后的上下文肯定在限制内 - 压缩/会话记忆查询:这些是 fork 的子进程,它们继承完整对话,如果阻塞会死锁
7. 模型选择(Model Selection)
typescript
let currentModel = getRuntimeMainLoopModel({
permissionMode,
mainLoopModel: toolUseContext.options.mainLoopModel,
exceeds200kTokens:
permissionMode === 'plan' &&
doesMostRecentAssistantMessageExceed200k(messagesForQuery),
})
模型选择逻辑:
- 默认:使用用户配置的
mainLoopModel(例如claude-sonnet-4-20250514) - Plan 模式:如果对话超过 200k tokens,自动切换到 Opus(更强大)
- 速率限制触发:切换到
fallbackModel
8. API 调用循环(Streaming Loop)
typescript
try {
while (attemptWithFallback) {
attemptWithFallback = false
try {
for await (const message of deps.callModel({
messages: prependUserContext(messagesForQuery, userContext),
systemPrompt: fullSystemPrompt,
thinkingConfig: toolUseContext.options.thinkingConfig,
tools: toolUseContext.options.tools,
signal: toolUseContext.abortController.signal,
options: {
model: currentModel,
fallbackModel,
querySource,
// ... 其他参数
},
})) {
// 处理流式响应...
}
} catch (innerError) {
if (innerError instanceof FallbackTriggeredError && fallbackModel) {
// 切换到备用模型,重试
currentModel = fallbackModel
attemptWithFallback = true
continue
}
throw innerError
}
}
}
callModel() 是什么:
- 这是一个依赖注入 的抽象(通过
deps.callModel传入) - 实际实现在
src/services/api/callModel.ts,负责:- 发起 HTTPS 请求到
https://api.anthropic.com/v1/messages - 解析 SSE 流
- 处理速率限制(429)重试
- 发起 HTTPS 请求到
流式响应处理 (在 for await 循环内):
typescript
for await (const message of deps.callModel(...)) {
// message 的类型:
// - AssistantMessage(模型生成的文本/工具调用)
// - StreamEvent(流式事件,例如 'content_block_delta')
// - RequestStartEvent(请求开始事件,用于性能追踪)
// 1. 回填工具输入(backfillObservableInput)
if (message.type === 'assistant') {
// 某些工具(例如 Read)会在执行时展开文件路径
// 这里把展开后的路径回填到消息中,方便 UI 显示
}
// 2. withholding 可恢复错误
// ( prompt-too-long、max-output-tokens 等)
let withheld = false
if (reactiveCompact?.isWithheldPromptTooLong(message)) {
withheld = true
}
if (isWithheldMaxOutputTokens(message)) {
withheld = true
}
if (!withheld) {
yield yieldMessage
}
// 3. 收集工具调用
if (message.type === 'assistant') {
assistantMessages.push(message)
const toolUseBlocks = message.message.content.filter(
content => content.type === 'tool_use',
)
if (toolUseBlocks.length > 0) {
toolUseBlocks.push(...toolUseBlocks)
needsFollowUp = true
}
}
// 4. 流式工具执行
if (streamingToolExecutor) {
for (const toolBlock of msgToolUseBlocks) {
streamingToolExecutor.addTool(toolBlock, message)
}
// 检查是否有完成的工具,立即 yield 结果
for (const result of streamingToolExecutor.getCompletedResults()) {
if (result.message) {
yield result.message
}
}
}
}
流式工具执行(StreamingToolExecutor):
- 传统方式:等模型返回所有工具调用 → 逐个执行 → 返回结果
- 流式方式:模型刚返回一个工具调用 → 立即开始执行 → 执行完立即 yield 结果
- 好处:用户能更早看到工具执行结果(减少感知延迟)
9. 错误恢复(Error Recovery)
API 调用可能失败,query.ts 实现了多层恢复机制:
a) 速率限制(429)恢复
- 自动重试,指数退避(1s → 2s → 4s → ...)
- 最多重试 3 次
- 如果 3 次都失败,切换到
fallbackModel
b) Prompt-too-long(413)恢复
typescript
if (isWithheld413) {
// 第一优先:上下文折叠排水(drain collapsed contexts)
if (contextCollapse && state.transition?.reason !== 'collapse_drain_retry') {
const drained = contextCollapse.recoverFromOverflow(messagesForQuery, querySource)
if (drained.committed > 0) {
// 重试,使用折叠后的消息
state = { ...state, messages: drained.messages, transition: { reason: 'collapse_drain_retry' } }
continue
}
}
// 第二优先:响应式压缩(reactive compact)
if (reactiveCompact) {
const compacted = await reactiveCompact.tryReactiveCompact({ ... })
if (compacted) {
// 重试,使用压缩后的消息
state = { ...state, messages: postCompactMessages, transition: { reason: 'reactive_compact_retry' } }
continue
}
}
// 恢复失败:返回错误
yield lastMessage
return { reason: 'prompt_too_long' }
}
c) Max-output-tokens 恢复
typescript
if (isWithheldMaxOutputTokens(lastMessage)) {
// 第一优先:升级输出 token 上限(8k → 64k)
if (maxOutputTokensOverride === undefined) {
state = {
...state,
maxOutputTokensOverride: ESCALATED_MAX_TOKENS,
transition: { reason: 'max_output_tokens_escalate' },
}
continue
}
// 第二优先:注入恢复提示词,让模型"接着说"
if (maxOutputTokensRecoveryCount < MAX_OUTPUT_TOKENS_RECOVERY_LIMIT) {
const recoveryMessage = createUserMessage({
content: `Output token limit hit. Resume directly --- no apology, no recap...`,
isMeta: true,
})
state = {
...state,
messages: [...messagesForQuery, ...assistantMessages, recoveryMessage],
transition: { reason: 'max_output_tokens_recovery', attempt: ... },
}
continue
}
}
d) 图片/PDF 过大恢复
typescript
if (isWithheldMedia && reactiveCompact) {
// 用响应式压缩移除过大的媒体文件
const compacted = await reactiveCompact.tryReactiveCompact({ ... })
if (compacted) {
// 重试,不带媒体文件
continue
}
}
10. 工具执行
如果模型返回了工具调用(needsFollowUp === true),query.ts 会:
typescript
const toolUpdates = streamingToolExecutor
? streamingToolExecutor.getRemainingResults()
: runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)
for await (const update of toolUpdates) {
if (update.message) {
yield update.message
toolResults.push(update.message)
}
}
工具执行结果:
- 每个工具的执行结果被封装成
tool_result消息 - 追加到
toolResults数组 - 在下一轮循环中,这些结果会被加入
messagesForQuery
11. 后置钩子(Post-sampling Hooks)
typescript
if (assistantMessages.length > 0) {
void executePostSamplingHooks(
[...messagesForQuery, ...assistantMessages],
systemPrompt,
userContext,
systemContext,
toolUseContext,
querySource,
)
}
Post-sampling Hooks 是什么:
- 在模型响应完成后、工具执行前,执行用户定义的钩子
- 用途:记录日志、修改工具输入、阻止某些工具调用
12. 循环继续条件检查
在每次循环结束前,检查是否需要继续:
typescript
// 检查 maxTurns
if (maxTurns && nextTurnCount > maxTurns) {
yield createAttachmentMessage({
type: 'max_turns_reached',
maxTurns,
turnCount: nextTurnCount,
})
return { reason: 'max_turns', turnCount: nextTurnCount }
}
// 检查 token 预算(feature flag: TOKEN_BUDGET)
if (feature('TOKEN_BUDGET')) {
const decision = checkTokenBudget(...)
if (decision.action === 'continue') {
// 注入提示词,让模型继续
state = {
...state,
messages: [...messagesForQuery, ...assistantMessages, createUserMessage({ content: decision.nudgeMessage, isMeta: true })],
transition: { reason: 'token_budget_continuation' },
}
continue
}
}
// 继续下一轮
state = {
messages: [...messagesForQuery, ...assistantMessages, ...toolResults],
toolUseContext: toolUseContextWithQueryTracking,
turnCount: nextTurnCount,
transition: { reason: 'next_turn' },
}
五、高级特性
1. 异步生成器(AsyncGenerator)的优势
为什么 query() 返回 AsyncGenerator 而不是 Promise?
对比:
typescript
// 方式 1:Promise(一次性返回)
const result = await query(params)
// 要等整个模型响应完成后,才能拿到结果
// 方式 2:AsyncGenerator(流式返回)
for await (const event of query(params)) {
// 模型每生成一个 token,就能立即拿到
// UI 可以实时渲染流式输出
}
实际效果:
- 用户看到"打字机效果"(字符逐个出现)
- 工具可以在模型还在生成时就提前执行(流式工具执行)
- 取消请求可以立即响应(不需要等当前轮次结束)
2. 状态机设计(State Machine)
query.ts 的核心是一个状态机 ,状态存储在 State 类型中:
typescript
type State = {
messages: Message[]
toolUseContext: ToolUseContext
autoCompactTracking: AutoCompactTrackingState | undefined
maxOutputTokensRecoveryCount: number
hasAttemptedReactiveCompact: boolean
maxOutputTokensOverride: number | undefined
pendingToolUseSummary: Promise<ToolUseSummaryMessage | null> | undefined
stopHookActive: boolean | undefined
turnCount: number
transition: Continue | undefined
}
Continue 类型:
typescript
type Continue = {
reason:
| 'next_turn'
| 'collapse_drain_retry'
| 'reactive_compact_retry'
| 'max_output_tokens_escalate'
| 'max_output_tokens_recovery'
| 'stop_hook_blocking'
| 'token_budget_continuation'
}
每次 continue 跳转,都会记录 transition.reason,这样:
- 调试:可以精确到知道是哪一步触发了重试
- 测试:可以断言恢复路径是否按预期执行
3. 内存预取(Memory Prefetch)
typescript
using pendingMemoryPrefetch = startRelevantMemoryPrefetch(
state.messages,
state.toolUseContext,
)
问题 :如果项目中有 100 个 CLAUDE.md 记忆文件,每次对话都要读取它们,会很慢。
解决方案:
- 在第一次 API 调用时启动预取(异步)
- 如果预取在本轮结束前完成,立即注入到对话中
- 如果预取在下一轮完成,那时再注入
using关键字确保预取在生成器退出时自动清理
4. 任务预算(Task Budget)
typescript
if (params.taskBudget) {
const preCompactContext = finalContextTokensFromLastResponse(messagesForQuery)
taskBudgetRemaining = Math.max(
0,
(taskBudgetRemaining ?? params.taskBudget.total) - preCompactContext,
)
}
Task Budget 是什么:
- Anthropic API 的 beta 功能(
task_budget) - 限制整个 agentic turn 的 token 消耗(而不是单轮)
- 在压缩边界处跨压缩持久化(普通 token 计数在压缩后会丢失)
六、错误处理详解
错误分类
| 错误类型 | 是否可重试 | 恢复策略 |
|---|---|---|
| 429(速率限制) | ✅ | 指数退避重试 → 切换备用模型 |
| 500/502/503(服务端错误) | ✅ | 指数退避重试 |
| 413(prompt-too-long) | ⚠️ | 上下文折叠排水 → 响应式压缩 → 返回错误 |
max_output_tokens |
⚠️ | 升级输出上限 → 注入恢复提示词 → 返回错误 |
| 图片/PDF 过大 | ⚠️ | 响应式压缩(移除媒体)→ 返回错误 |
| 401(认证失败) | ❌ | 立即返回错误 |
| 400(请求格式错误) | ❌ | 立即返回错误 |
| 网络超时 | ✅ | 重试(最多 3 次) |
错误 withholding 机制
问题 :某些错误(例如 413)是可以恢复 的,但如果在流式传输过程中立即 yield 错误,SDK 消费者(例如 cowork/desktop)会立即终止会话。
解决方案:
- 在流式循环中,** withholding** 可恢复错误(不
yield) - 循环继续,尝试恢复策略(折叠/压缩)
- 如果恢复成功,错误被丢弃(用户无感知)
- 如果恢复失败,错误才被
yield(用户看到错误信息)
实现:
typescript
let withheld = false
if (reactiveCompact?.isWithheldPromptTooLong(message)) {
withheld = true
}
if (isWithheldMaxOutputTokens(message)) {
withheld = true
}
if (!withheld) {
yield yieldMessage
}
七、性能优化
1. Memoize 缓存
typescript
const fullSystemPrompt = asSystemPrompt(
appendSystemContext(systemPrompt, systemContext),
)
appendSystemContext() 内部用了 lodash-es/memoize,在一次对话会话内只计算一次。git status 这种耗时操作不会每次都执行。
2. Dump Prompts Fetch
typescript
const dumpPromptsFetch = config.gates.isAnt
? createDumpPromptsFetch(toolUseContext.agentId ?? config.sessionId)
: undefined
问题 :每次 API 调用都要构建完整的请求体(约 700KB),如果会话很长,内存中会保留所有历史请求体(可能达到 500MB)。
解决方案:
createDumpPromptsFetch()创建一个闭包 ,只捕获最新的请求体- 内存占用从 500MB 降到 700KB
3. 流式工具执行
传统方式:
模型返回工具调用 → 等待所有工具执行完 → 返回结果 → 下一轮
流式方式:
模型返回第一个工具调用 → 立即执行 → 执行完立即 yield 结果
→ 同时模型继续返回第二个工具调用 → 立即执行...
好处 :工具执行和模型生成并行,总延迟更低。
八、总结:query.ts 的设计哲学
读完 src/query.ts 的约 500 行代码,有三个设计决策令人印象深刻:
-
AsyncGenerator 而非回调:
- 通过异步生成器逐步
yield事件,使得 REPL UI 可以实时渲染流式输出 - SDK 消费者也能灵活处理中间事件(例如,记录日志、取消请求)
- 通过异步生成器逐步
-
多层恢复机制:
- Prompt-too-long:折叠排水 → 响应式压缩 → 返回错误
- Max-output-tokens:升级上限 → 注入恢复提示词 → 返回错误
- 速率限制:指数退避 → 切换备用模型
- 每层恢复都是独立的,互不干扰,且可以按任意顺序组合
-
特性门控(Feature Gating):
- 所有实验性特性(Snip、Microcompact、Context Collapse、Token Budget)都用
feature()包裹 - 在生产构建中,未启用的特性代码会被 bun:bundle 完全移除(tree-shaking)
- 这使得 query.ts 能同时服务:
- 稳定版(只有核心功能)
- 内测版(启用所有实验性特性)
- Ant 内部版(额外的调试和遥测)
- 所有实验性特性(Snip、Microcompact、Context Collapse、Token Budget)都用
下一篇预告 :我们将深入 src/services/api/callModel.ts ------ 真正发起 HTTPS 请求、解析 SSE 流、处理速率限制的那一层,看看"200ms 首字延迟"是怎么做到的。
Claude Code 源码分析系列 · 第九篇