vault backup: 2026-06-09 23:15:17
This commit is contained in:
@@ -1,68 +1,56 @@
|
||||
---
|
||||
title: "多轮对话管理 - QueryEngine 会话编排与持久化"
|
||||
description: "从源码角度解析 Claude Code 多轮对话管理:QueryEngine 的会话状态机、JSONL transcript 持久化、成本追踪模型和模型热切换机制。"
|
||||
keywords: ["多轮对话", "会话管理", "QueryEngine", "transcript", "成本追踪"]
|
||||
sourceRef: "3ec5675 (2026-04-08)"
|
||||
tags: [多轮对话, QueryEngine, 会话管理, 成本追踪, 模型切换]
|
||||
create time: 2026-06-09 22:15
|
||||
---
|
||||
|
||||
{/* 本章目标:从源码角度揭示会话编排、持久化存储、成本追踪和模型切换的完整链路 */}
|
||||
# 多轮对话管理 - QueryEngine 会话编排与持久化
|
||||
|
||||
首先要区分claude code的多种交互方式
|
||||
## 概述
|
||||
|
||||
REPL关注交互形态,SDK关注接入方式,ACP则关注通信协议。
|
||||
Claude Code 的多轮对话由 `QueryEngine` 类统一编排,管理会话状态机、JSONL transcript 持久化、成本追踪和模型热切换。REPL、SDK、ACP 三种交互方式共享同一套会话管理基础设施。
|
||||
|
||||
### 🆚 核心概念对比
|
||||
## 正文
|
||||
|
||||
| 维度 | 🖥️ REPL (交互形态) | 🧩 SDK (接入方式) | 🌉 ACP (通信协议) |
|
||||
### 交互方式对比
|
||||
|
||||
首先要区分 Claude Code 的多种交互方式: REPL 关注交互形态,SDK 关注接入方式,ACP 则关注通信协议。
|
||||
|
||||
| 维度 | REPL(交互形态) | SDK(接入方式) | ACP(通信协议) |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **是什么** | 供开发者直接在终端使用的**交互式对话环境** | 面向开发者的**程序化调用库**,供集成到其他应用 | 一种**开放式的通信标准**,连接不同AI Agent与编辑器 |
|
||||
| **使用方式** | 1. 直接在终端输入`claude`命令<br>2. 进入专用界面(基于React Ink渲染)<br>3. 通过斜杠命令(如`/help`)交互 | 1. 在自己的Node.js/Python项目中安装SDK包(如`npm install claude-code-sdk`)<br>2. 通过API发送查询 | 1. 通过ACP适配器(如`claude-code-acp`)启动Claude Code<br>2. 供编辑器通过ACP协议与其通信 |
|
||||
| **典型场景** | 开发者日常编写代码时,随时向其提问、修改代码或执行任务 | 将Claude Code的核心能力(对话、工具执行等)集成到自动化脚本、CI/CD流程或其他应用的后台中 | 将Claude Code的能力集成到JetBrains IDE、Zed等第三方编辑器中,利用其UI交互功能 |
|
||||
| **主要特点** | - **面向人**:交互式、直观<br>- **功能完整**:可使用所有内置工具,并支持MCP集成<br>- **处理复杂任务**:可自主规划、执行多步操作 | - **面向程序**:编程化、可集成<br>- **轻量级**:不依赖Claude Code的完整运行时<br>- **由你控制**:适合在自有应用中实现自动化 | - **标准化**:统一不同Agent与编辑器间的通信<br>- **双向通信**:Agent可主动向编辑器请求文件、执行命令等<br>- **与编辑器深度整合**:能完全复用Claude Code的能力 |
|
||||
| **是什么** | 供开发者直接在终端使用的**交互式对话环境** | 面向开发者的**程序化调用库**,供集成到其他应用 | 一种**开放式的通信标准**,连接不同 AI Agent 与编辑器 |
|
||||
| **使用方式** | 直接在终端输入 `claude` 命令,进入专用界面 | 在 Node.js/Python 项目中安装 SDK 包,通过 API 发送查询 | 通过 ACP 适配器启动 Claude Code,供编辑器通信 |
|
||||
| **典型场景** | 日常编写代码时提问、修改代码或执行任务 | 集成到自动化脚本、CI/CD 流程或其他应用后台 | 集成到 JetBrains IDE、Zed 等第三方编辑器 |
|
||||
| **主要特点** | 面向人,交互式,功能完整 | 面向程序,编程化,轻量级 | 标准化,双向通信,与编辑器深度整合 |
|
||||
|
||||
其中的 🧩 SDK (接入方式) 与 🌉 ACP (通信协议)采用如下QueryEngine实现会话管理
|
||||
其中 SDK 与 ACP 采用 `QueryEngine` 实现会话管理。REPL 交互形态则通过 `onQueryImpl` 在 `src/screens/REPL.tsx` 中调用 `query()` 函数。
|
||||
|
||||
作为一个对话终端(🖥️ REPL 交互形态模式),则使用的是 onQueryImpl 在 src/screens/REPL.tsx 中调用 query() 函数
|
||||
#### REPL 模式的调用链路
|
||||
|
||||
对于REPL 交互形态模式的调用链路如下
|
||||
```
|
||||
用户输入
|
||||
↓
|
||||
onSubmit (REPL.tsx)
|
||||
↓
|
||||
handlePromptSubmit (handlePromptSubmit.ts)
|
||||
↓
|
||||
executeUserInput (handlePromptSubmit.ts)
|
||||
↓
|
||||
onQuery (REPL.tsx)
|
||||
↓
|
||||
onQueryImpl (REPL.tsx)
|
||||
↓
|
||||
query (query.ts) ← 在这里调用
|
||||
```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`)。
|
||||
|
||||
query 函数是 Agentic Loop 的核心实现,包含 while(true) 循环处理对话回合 query.ts:460-522
|
||||
`onQueryImpl` 是 REPL 中与 AI 模型交互的核心控制器,负责:
|
||||
|
||||
onQueryImpl 是 REPL(Read-Eval-Print Loop)中与 AI 模型交互的核心控制器,它负责:
|
||||
1. 环境准备(IDE、诊断、权限)
|
||||
2. 会话标题的首次生成
|
||||
3. 构建动态系统提示和用户上下文
|
||||
4. 执行流式查询并实时更新 UI
|
||||
5. 收集性能指标和最终清理
|
||||
|
||||
1.环境准备(IDE、诊断、权限)
|
||||
### onQueryImpl 方法详解
|
||||
|
||||
2.会话标题的首次生成
|
||||
该方法是一个 React `useCallback` 包装的异步函数,负责处理用户消息到 AI 模型的**完整查询流程**。
|
||||
|
||||
3.构建动态系统提示和用户上下文
|
||||
|
||||
4.执行流式查询并实时更新 UI
|
||||
|
||||
5.收集性能指标和最终清理
|
||||
|
||||
## `onQueryImpl` 方法的详细解析
|
||||
以下是对 `onQueryImpl` 方法的详细解析。该方法是一个 React `useCallback` 包装的异步函数,负责处理用户消息到 AI 模型(Claude)的**完整查询流程**,包括预处理、系统提示构建、工具上下文准备、流式查询执行、后处理与指标记录。
|
||||
|
||||
---
|
||||
|
||||
### 一、函数签名与参数
|
||||
#### 函数签名与参数
|
||||
|
||||
```typescript
|
||||
const onQueryImpl = useCallback(
|
||||
@@ -74,280 +62,93 @@ const onQueryImpl = useCallback(
|
||||
additionalAllowedTools: string[],
|
||||
mainLoopModelParam: string,
|
||||
effort?: EffortValue,
|
||||
) => { ... },
|
||||
[ ...dependencies ]
|
||||
) => { /* ... */ },
|
||||
[ /* ...dependencies */ ]
|
||||
)
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
| -------------------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `messagesIncludingNewMessages` | 包含新增消息的完整消息列表,用于构建模型输入 |
|
||||
| `newMessages` | 本次新增的消息(例如用户刚输入的文本或附件) |
|
||||
| `abortController` | 用于取消当前查询的控制器 |
|
||||
| `shouldQuery` | 是否真正执行查询;若为 `false` 则跳过模型调用(例如处理无效斜杠命令、手动 compact 等) |
|
||||
| `additionalAllowedTools` | 本轮查询额外允许的工具列表(通常来自 Skill 的 frontmatter) |
|
||||
| `mainLoopModelParam` | 指定本次使用的主模型参数(如 `'claude-3-opus'`) |
|
||||
| `effort` | 可选,覆盖全局的“努力程度”值(用于控制模型推理深度) |
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `messagesIncludingNewMessages` | 包含新增消息的完整消息列表,用于构建模型输入 |
|
||||
| `newMessages` | 本次新增的消息(例如用户刚输入的文本或附件) |
|
||||
| `abortController` | 用于取消当前查询的控制器 |
|
||||
| `shouldQuery` | 是否真正执行查询;若为 `false` 则跳过模型调用 |
|
||||
| `additionalAllowedTools` | 本轮查询额外允许的工具列表(通常来自 Skill 的 frontmatter) |
|
||||
| `mainLoopModelParam` | 指定本次使用的主模型参数 |
|
||||
| `effort` | 可选,覆盖全局的"努力程度"值 |
|
||||
|
||||
---
|
||||
|
||||
### 二、总体执行流程
|
||||
|
||||
下图概括了函数的主要分支与关键步骤:
|
||||
#### 总体执行流程
|
||||
|
||||
```mermaid
|
||||
graph 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,临时覆盖getAppState中的effortValue"]
|
||||
I --> J["并行执行:系统提示/用户上下文/系统上下文/自动模式检查"]
|
||||
J --> K["构建有效系统提示"]
|
||||
K --> L["重置各类耗时计时器"]
|
||||
L --> M["执行query生成器,流式处理事件"]
|
||||
M --> N["若BUDDY开启,触发companion观察者"]
|
||||
N --> O["若UDS_INBOX且中断,记录错误"]
|
||||
O --> P["ant用户:收集API指标并插入指标消息"]
|
||||
P --> Q["重置加载状态,输出性能报告,调用onTurnComplete"]
|
||||
Q --> R["结束"]
|
||||
D --> R
|
||||
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 客户端则关闭所有打开的差异视图。
|
||||
|
||||
#### 3.1 IDE 集成与诊断(仅 `shouldQuery = true`)
|
||||
**会话标题生成**: 仅当全局标题未禁用、当前无任何标题且从未尝试过时执行。从新增消息中提取第一条非元用户消息的真实文本,异步调用 `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
|
||||
const freshClients = mergeClients(initialMcpClients, store.getState().mcp.clients);
|
||||
diagnosticTracker.handleQueryStart(freshClients);
|
||||
const ideClient = getConnectedIdeClient(freshClients);
|
||||
if (ideClient) closeOpenDiffs(ideClient);
|
||||
```
|
||||
|
||||
- 从 store 中获取最新的 MCP 客户端(因为 `useManageMCPConnections` 可能在闭包捕获后更新了状态)。
|
||||
- 通知诊断追踪器查询开始。
|
||||
- 若存在已连接的 IDE 客户端,关闭所有打开的差异视图(清理环境)。
|
||||
|
||||
#### 3.2 会话标题生成(仅一次)
|
||||
|
||||
```typescript
|
||||
if (!titleDisabled && !sessionTitle && !agentTitle && !haikuTitleAttemptedRef.current) {
|
||||
const firstUserMessage = newMessages.find(m => m.type === 'user' && !m.isMeta);
|
||||
const text = getContentText(firstUserMessage.message.content);
|
||||
if (text && !text.startsWith(`<${LOCAL_COMMAND_STDOUT_TAG}>`) ... ) {
|
||||
haikuTitleAttemptedRef.current = true;
|
||||
generateSessionTitle(text, ...).then(title => setHaikuTitle(title));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- 仅当全局标题未禁用、当前无任何标题且从未尝试过时执行。
|
||||
- 从新增消息中提取第一条**非元用户消息**的真实文本。
|
||||
- 跳过合成面包屑(如 slash 命令输出、skill 扩展标记等)。
|
||||
- 异步调用 `generateSessionTitle`,结果通过 `setHaikuTitle` 保存;失败则重置 ref 允许重试。
|
||||
|
||||
#### 3.3 权限工具覆盖写入 Store
|
||||
|
||||
```typescript
|
||||
store.setState(prev => {
|
||||
const cur = prev.toolPermissionContext.alwaysAllowRules.command;
|
||||
if (cur === additionalAllowedTools || (cur?.length === ...)) return prev;
|
||||
return { ...prev, toolPermissionContext: { ...prev.toolPermissionContext, alwaysAllowRules: { ...prev.toolPermissionContext.alwaysAllowRules, command: additionalAllowedTools } } };
|
||||
});
|
||||
```
|
||||
|
||||
- 将本轮 `additionalAllowedTools` 写入全局 store 的 `toolPermissionContext.alwaysAllowRules.command`。
|
||||
- 用于限定本轮查询中可用的工具集(例如 Skill 专属工具)。
|
||||
- 通过浅比较避免不必要的状态更新。
|
||||
- 即使在 `shouldQuery=false` 时也会执行(例如 forked 命令需要此权限信息),但原代码位置在 `shouldQuery` 分支**之前**,所以始终会更新。
|
||||
|
||||
#### 3.4 `shouldQuery = false` 分支
|
||||
|
||||
```typescript
|
||||
if (!shouldQuery) {
|
||||
if (newMessages.some(isCompactBoundaryMessage)) {
|
||||
setConversationId(randomUUID());
|
||||
if (feature('PROACTIVE') || feature('KAIROS')) proactiveModule?.setContextBlocked(false);
|
||||
}
|
||||
resetLoadingState();
|
||||
setAbortController(null);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
- 处理不需要实际调用模型的情况(如用户输入了无效斜杠命令,或者手动 `/compact` 等)。
|
||||
- 若新消息中包含 **compact 边界消息**(压缩边界),则:
|
||||
- 生成新的 `conversationId`,促使 UI 中消息行组件重新挂载。
|
||||
- 若开启了 PROACTIVE/KAIROS 特性,清除上下文阻塞标志(恢复主动提示)。
|
||||
- 最后重置加载状态并清空 abortController。
|
||||
|
||||
#### 3.5 查询前置准备(`shouldQuery = true`)
|
||||
|
||||
##### 3.5.1 获取 ToolUseContext
|
||||
|
||||
```typescript
|
||||
const toolUseContext = getToolUseContext(messagesIncludingNewMessages, newMessages, abortController, mainLoopModelParam);
|
||||
const { tools: freshTools, mcpClients: freshMcpClients } = toolUseContext.options;
|
||||
```
|
||||
|
||||
- `getToolUseContext` 内部会从 store 中读取最新的 tools 和 MCP 客户端配置,确保闭包捕获的旧值不会导致遗漏新连接的工具或 MCP 服务器。
|
||||
|
||||
##### 3.5.2 Effort 覆盖(临时)
|
||||
|
||||
```typescript
|
||||
if (effort !== undefined) {
|
||||
const previousGetAppState = toolUseContext.getAppState;
|
||||
toolUseContext.getAppState = () => ({ ...previousGetAppState(), effortValue: effort });
|
||||
}
|
||||
```
|
||||
|
||||
- 如果传入了 `effort` 参数,临时覆盖 `getAppState` 返回的 `effortValue`。
|
||||
- 作用域**仅限于本轮查询**,不影响全局 store,避免后台 Agent 或 UI 组件误读到该临时值。
|
||||
|
||||
##### 3.5.3 并行获取提示与上下文
|
||||
|
||||
```typescript
|
||||
const [, , defaultSystemPrompt, baseUserContext, systemContext] = await Promise.all([
|
||||
undefined,
|
||||
feature('TRANSCRIPT_CLASSIFIER') ? checkAndDisableAutoModeIfNeeded(...) : undefined,
|
||||
getSystemPrompt(freshTools, mainLoopModelParam, additionalWorkingDirectories, freshMcpClients),
|
||||
getUserContext(),
|
||||
getSystemContext(),
|
||||
]);
|
||||
```
|
||||
|
||||
- 并行执行以下任务以节省时间:
|
||||
- **自动模式断路器**:如果启用了转录分类器,检查并可能禁用快速模式(`fastMode`)。
|
||||
- **系统提示**:基于最新工具、模型参数、额外工作目录、MCP 客户端生成。
|
||||
- **用户上下文**:如当前工作区、环境变量等。
|
||||
- **系统上下文**:如操作系统、终端信息等。
|
||||
|
||||
##### 3.5.4 增强用户上下文
|
||||
|
||||
```typescript
|
||||
const userContext = {
|
||||
...baseUserContext,
|
||||
...getCoordinatorUserContext(freshMcpClients, getScratchpadDir()),
|
||||
...((feature('PROACTIVE') || feature('KAIROS')) && proactiveModule?.isProactiveActive() && !terminalFocusRef.current
|
||||
? { terminalFocus: 'The terminal is unfocused — the user is not actively watching.' }
|
||||
: {}),
|
||||
};
|
||||
```
|
||||
|
||||
- 合并基本用户上下文、协调器上下文(与 MCP 协作相关)、以及可选的终端焦点状态(当 proactive 特性激活且终端未聚焦时,提示模型用户未在观看)。
|
||||
|
||||
##### 3.5.5 构建最终系统提示
|
||||
|
||||
```typescript
|
||||
const systemPrompt = buildEffectiveSystemPrompt({
|
||||
mainThreadAgentDefinition,
|
||||
toolUseContext,
|
||||
customSystemPrompt,
|
||||
defaultSystemPrompt,
|
||||
appendSystemPrompt,
|
||||
});
|
||||
```
|
||||
|
||||
- 整合主线程 Agent 定义、工具上下文、自定义系统提示、默认系统提示以及需要追加的内容。
|
||||
|
||||
#### 3.6 执行查询与流式事件处理
|
||||
|
||||
```typescript
|
||||
resetTurnHookDuration(); resetTurnToolDuration(); resetTurnClassifierDuration();
|
||||
for await (const event of query({ messages, systemPrompt, userContext, systemContext, canUseTool, toolUseContext, querySource })) {
|
||||
onQueryEvent(event);
|
||||
}
|
||||
```
|
||||
|
||||
- 重置本轮钩子、工具、分类器的耗时计时器。
|
||||
- 调用 `query` 生成器函数(负责与模型 API 通信并返回 SSE 事件流)。
|
||||
- 遍历每个事件并调用 `onQueryEvent`(通常用于更新 UI 消息列表、处理工具调用等)。
|
||||
|
||||
#### 3.7 后处理与指标收集
|
||||
|
||||
##### 3.7.1 BUDDY 特性(companion 反应)
|
||||
|
||||
```typescript
|
||||
if (feature('BUDDY') && typeof fireCompanionObserver === 'function') {
|
||||
fireCompanionObserver(messagesRef.current, reaction => setAppState(prev => ({ ...prev, companionReaction: reaction })));
|
||||
}
|
||||
```
|
||||
|
||||
- 将当前消息列表传递给 companion 观察者,并根据返回的反应更新全局状态。
|
||||
|
||||
##### 3.7.2 UDS_INBOX 中断处理
|
||||
|
||||
```typescript
|
||||
if (feature('UDS_INBOX') && abortController.signal.aborted) {
|
||||
pipeReturnHadErrorRef.current = true;
|
||||
relayPipeMessage({ type: 'error', data: 'Slave request was interrupted before completion.' });
|
||||
}
|
||||
```
|
||||
|
||||
- 若因中断导致查询未完成,标记错误并通过管道中继消息。
|
||||
|
||||
##### 3.7.3 Ant 内部用户的 API 指标记录
|
||||
|
||||
```typescript
|
||||
if (process.env.USER_TYPE === 'ant' && apiMetricsRef.current.length > 0) {
|
||||
const entries = apiMetricsRef.current;
|
||||
const ttfts = entries.map(e => e.ttftMs);
|
||||
const otpsValues = entries.map(e => { /* 计算每请求的 OTPs */ });
|
||||
const isMultiRequest = entries.length > 1;
|
||||
// 创建 API 指标消息并添加到消息列表
|
||||
setMessages(prev => [...prev, createApiMetricsMessage({ ttftMs: isMultiRequest ? median(ttfts) : ttfts[0], ... })]);
|
||||
}
|
||||
```
|
||||
|
||||
- 仅当用户类型为 `'ant'` 且存在 API 指标记录时执行。
|
||||
- 收集每次请求的 **首字节时间 (TTFT)** 和 **每秒输出 Token 数 (OTPS)**。
|
||||
- 若本轮包含多次请求(例如工具调用循环),计算中位数(P50)后存入指标消息。
|
||||
- 同时记录钩子耗时、工具耗时、分类器耗时、本轮总时长、配置写入次数等。
|
||||
|
||||
##### 3.7.4 重置与清理
|
||||
|
||||
```typescript
|
||||
resetLoadingState();
|
||||
logQueryProfileReport();
|
||||
await onTurnComplete?.(messagesRef.current);
|
||||
```
|
||||
|
||||
- 重置加载状态(隐藏 loading 指示器)。
|
||||
- 输出查询性能报告(如果调试标志启用)。
|
||||
- 调用外部传入的 `onTurnComplete` 回调,并传递完整消息列表(通常用于触发后续行为如自动滚动、保存会话等)。
|
||||
|
||||
|
||||
## 单轮 vs 多轮:架构层面的差异
|
||||
|
||||
- **单轮**(一次 Agentic Loop):`query()` 函数的一次完整执行——组装上下文 → 调 API → 处理工具调用 → 循环直到结束
|
||||
- **多轮**(一个 Session):`QueryEngine` 类管理的一次会话——跨越数十轮 `submitMessage()` 调用,持续数小时
|
||||
|
||||
`QueryEngine`(`src/QueryEngine.ts`,类定义)是单轮 Agentic Loop 之上的**会话编排器**,它管理的状态远不止消息列表:
|
||||
|
||||
```
|
||||
QueryEngine 内部状态(src/QueryEngine.ts 构造函数)
|
||||
├── mutableMessages: Message[] ← 完整对话历史,跨 turn 累积
|
||||
├── readFileState: FileStateCache ← 已读文件内容缓存,避免重复读取
|
||||
├── totalUsage: NonNullableUsage ← 累计 token 消耗(input/output/cache)
|
||||
├── permissionDenials: SDKPermissionDenial[] ← 权限拒绝记录
|
||||
├── discoveredSkillNames: Set<string> ← 当前 turn 已发现的 skill
|
||||
├── loadedNestedMemoryPaths: Set<string> ← 已加载的嵌套 memory 路径(防重复)
|
||||
├── hasHandledOrphanedPermission: boolean ← 是否已处理孤立权限请求
|
||||
└── abortController: AbortController ← 会话级中断控制
|
||||
```
|
||||
|
||||
## QueryEngine 的核心方法:submitMessage()
|
||||
|
||||
每次用户输入一条消息,SDK 调用 `submitMessage()`,它会执行完整的 turn 初始化链路:
|
||||
|
||||
```typescript
|
||||
// src/QueryEngine.ts — QueryEngine.submitMessage() 简化流程
|
||||
// src/QueryEngine.ts -- submitMessage() 简化流程
|
||||
async *submitMessage(
|
||||
prompt: string | ContentBlockParam[],
|
||||
options?: { uuid?: string; isMeta?: boolean },
|
||||
@@ -368,12 +169,7 @@ async *submitMessage(
|
||||
const wrappedCanUseTool = async (tool, input, ...) => {
|
||||
const result = await canUseTool(tool, input, ...)
|
||||
if (result.behavior !== 'allow') {
|
||||
this.permissionDenials.push({
|
||||
type: 'permission_denial',
|
||||
tool_name: sdkCompatToolName(tool.name),
|
||||
tool_use_id: toolUseID,
|
||||
tool_input: input,
|
||||
})
|
||||
this.permissionDenials.push({ /* ... */ })
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -386,85 +182,53 @@ async *submitMessage(
|
||||
}
|
||||
```
|
||||
|
||||
关键设计:`submitMessage()` 是 `async *Generator`——它逐步 yield `SDKMessage`,让调用方(REPL/SDK)能实时展示进度,而不是等整个 turn 结束。
|
||||
> [!tip] 关键设计
|
||||
> `submitMessage()` 是 `async *Generator`——它逐步 yield `SDKMessage`,让调用方(REPL/SDK)能实时展示进度,而不是等整个 turn 结束。
|
||||
|
||||
## 会话持久化:JSONL Transcript
|
||||
### 会话持久化: JSONL Transcript
|
||||
|
||||
每次对话事件都被追加写入 transcript 文件(`src/utils/sessionStorage.ts`):
|
||||
每次对话事件都被追加写入 transcript 文件(`src/utils/sessionStorage.ts`)。
|
||||
|
||||
### 存储路径
|
||||
**存储路径**: `~/.claude/projects/<sanitized-cwd>/<session-uuid>.jsonl`
|
||||
|
||||
```
|
||||
~/.claude/projects/<sanitized-cwd>/<session-uuid>.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
|
||||
```
|
||||
|
||||
- 路径由 `getProjectDir(originalCwd)` 生成,使用 `sanitizePath()` 将项目目录路径转换为安全的目录名(非 hash),同一项目目录的会话归入同一子目录
|
||||
- 每条记录是一行 JSON(JSONL 格式),支持追加写入而不需要读取-修改-写入整个文件
|
||||
- 读取上限为 50MB(`MAX_TRANSCRIPT_READ_BYTES` 常量,`src/utils/sessionStorage.ts`),防止超大会话导致 OOM
|
||||
同步直写路径用于元数据重写等场景: `appendEntryToFile(fullPath, entry)` 使用 `appendFileSync`,失败时 `mkdir` + 重试。
|
||||
|
||||
### Transcript 写入器
|
||||
#### 会话恢复链路
|
||||
|
||||
`Project` 类(`src/utils/sessionStorage.ts`,私有类)管理 transcript 的写入。它通过 `writeQueues`(按文件分组的写队列)和 `drainWriteQueue()`(定时批量刷写)确保并发消息追加不会互相覆盖:
|
||||
`--resume` 参数触发的恢复流程:
|
||||
|
||||
```
|
||||
写入流程(异步排队路径):
|
||||
recordTranscript(sessionId, entry)
|
||||
↓
|
||||
project.enqueueWrite(filePath, entry) ← 入列到 writeQueues
|
||||
↓
|
||||
scheduleDrain() ← 设置定时器(FLUSH_INTERVAL_MS)
|
||||
↓
|
||||
drainWriteQueue() ← 按 MAX_CHUNK_BYTES 分批
|
||||
↓ 写入每批
|
||||
appendToFile(path, batchContent) ← 批量追加
|
||||
↓
|
||||
如果配置了远程持久化:
|
||||
persistToRemote(sessionId, entry)
|
||||
├── CCR v2: internalEventWriter('transcript', entry)
|
||||
└── v1 Ingress: sessionIngress.appendSessionLog(...)
|
||||
1. 解析 resume 参数: UUID 格式 -> `getTranscriptPathForSession(uuid)`;`.jsonl` 文件路径 -> 直接使用;boolean -> 最近一次会话的 picker
|
||||
2. `loadTranscriptFromFile(path)`: 按 JSONL 行解析,过滤出消息类型记录,重建 `Message[]` 数组
|
||||
3. 恢复上下文状态: `restoreCostStateForSession(sessionId)` 恢复累计费用、恢复 agentSetting、如有 `--rewind-files` 则恢复文件快照
|
||||
4. 创建 `QueryEngine({ initialMessages: restoredMessages })` 从恢复的消息继续对话
|
||||
|
||||
同步直写路径(用于元数据重写等场景):
|
||||
appendEntryToFile(fullPath, entry) ← 同步 appendFileSync
|
||||
↓
|
||||
失败时 mkdir + 重试
|
||||
```
|
||||
### 成本追踪: 从 API Usage 到美元
|
||||
|
||||
### 会话恢复链路
|
||||
成本追踪贯穿三个模块,形成完整的记录-累计-展示链路。
|
||||
|
||||
`--resume` 参数触发的恢复流程(`src/main.tsx` 中 `--resume` 分支):
|
||||
**记录层**: 每个 `message_delta` 事件携带 `usage` 字段(`input_tokens`、`output_tokens`、`cache_creation_input_tokens`、`cache_read_input_tokens`)。`accumulateUsage()` 将增量 usage 累加到会话总量。
|
||||
|
||||
```
|
||||
1. 解析 resume 参数:
|
||||
├── UUID 格式 → getTranscriptPathForSession(uuid)
|
||||
├── .jsonl 文件路径 → 直接使用
|
||||
└── boolean → 最近一次会话的 picker
|
||||
|
||||
2. loadTranscriptFromFile(path)
|
||||
├── 按 JSONL 行解析
|
||||
├── 过滤出消息类型记录
|
||||
└── 重建 Message[] 数组
|
||||
|
||||
3. 恢复上下文状态:
|
||||
├── restoreCostStateForSession(sessionId) ← 恢复累计费用
|
||||
├── 恢复 agentSetting(用户选择的 Agent 类型)
|
||||
└── 如果有 --rewind-files,恢复文件到指定消息时的快照
|
||||
|
||||
4. 创建 QueryEngine({ initialMessages: restoredMessages })
|
||||
└── 从恢复的消息继续对话
|
||||
```
|
||||
|
||||
## 成本追踪:从 API Usage 到美元
|
||||
|
||||
成本追踪贯穿三个模块,形成完整的记录→累计→展示链路:
|
||||
|
||||
### 记录层:API 响应中的 Usage
|
||||
|
||||
每个 `message_delta` 事件携带 `usage` 字段(`input_tokens`、`output_tokens`、`cache_creation_input_tokens`、`cache_read_input_tokens`)。`accumulateUsage()` 将增量 usage 累加到会话总量。
|
||||
|
||||
### 累计层:cost-tracker.ts
|
||||
**累计层** (`src/cost-tracker.ts`):
|
||||
|
||||
```typescript
|
||||
// src/cost-tracker.ts — StoredCostState 类型定义
|
||||
type StoredCostState = {
|
||||
totalCostUSD: number // 累计美元花费
|
||||
totalAPIDuration: number // API 调用总时长(含重试)
|
||||
@@ -477,43 +241,42 @@ type StoredCostState = {
|
||||
}
|
||||
```
|
||||
|
||||
`addToTotalSessionCost()` 根据模型定价计算每次 API 调用的费用,累计到 `totalCostUSD`。按模型的 `ModelUsage` 支持在同一会话中切换模型后分别统计。
|
||||
**持久化**: 每次会话结束时保存到项目配置(`saveCurrentSessionCosts`),跨重启保留。
|
||||
|
||||
### 持久化:跨重启保留
|
||||
**预算熔断**: `QueryEngineConfig.maxBudgetUsd` 提供会话级硬性预算上限。REPL 中累计费用超过 $5 时弹出费用提醒对话框(软提醒)。
|
||||
|
||||
```typescript
|
||||
// 每次会话结束时保存到项目配置
|
||||
saveCurrentSessionCosts(sessionId)
|
||||
→ projectConfig.lastCost = totalCostUSD
|
||||
→ projectConfig.lastSessionId = sessionId
|
||||
→ projectConfig.lastModelUsage = modelUsage
|
||||
```
|
||||
### 模型热切换
|
||||
|
||||
### 预算熔断
|
||||
在一个会话中切换模型不会丢失对话历史——因为 `mutableMessages` 与模型选择是解耦的:
|
||||
|
||||
`QueryEngineConfig.maxBudgetUsd` 提供了会话级的硬性预算上限。在 REPL 中,当累计费用超过 $5 时(`src/screens/REPL.tsx` 中费用阈值 `useEffect`),弹出费用提醒对话框——这不是硬性阻断,而是"软提醒",且仅在 `hasConsoleBillingAccess()` 为 true 时显示。
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant Q as QueryEngine
|
||||
participant P as Parser
|
||||
participant S as SystemPrompt
|
||||
participant A as API
|
||||
|
||||
## 模型热切换
|
||||
|
||||
在一个会话中切换模型不会丢失对话历史——因为 `mutableMessages` 与模型选择是解耦的:
|
||||
|
||||
```
|
||||
/model sonnet → QueryEngine.setModel('claude-sonnet-4-20250514')
|
||||
↓ 实际操作:this.config.userSpecifiedModel = model(QueryEngine.setModel() 方法)
|
||||
下一次 submitMessage() 开始时:
|
||||
↓
|
||||
parseUserSpecifiedModel(this.config.userSpecifiedModel)
|
||||
→ 返回新的模型配置
|
||||
↓
|
||||
fetchSystemPromptParts({ mainLoopModel: newModel })
|
||||
→ System Prompt 根据新模型能力重新组装
|
||||
↓
|
||||
query({ model: newModel, messages: this.mutableMessages })
|
||||
→ 使用完整历史 + 新模型继续对话
|
||||
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 只追踪已提交的内容)。
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[the-loop]] - Agentic Loop 核心机制与状态机
|
||||
- [[streaming]] - 流式响应机制与 SSE 事件处理
|
||||
- [[../context/system-prompt]] - System Prompt 动态组装
|
||||
- [[../context/compaction]] - 上下文压缩策略
|
||||
|
||||
@@ -1,38 +1,43 @@
|
||||
---
|
||||
title: "流式响应机制 - Claude Code 打字机效果原理"
|
||||
description: "解析 Claude Code 流式响应实现:如何通过 SSE 逐 token 接收 AI 输出,实现实时打字机效果,提升用户等待体验。"
|
||||
keywords: ["流式响应", "SSE", "streaming", "实时输出", "API streaming"]
|
||||
sourceRef: "3ec5675 (2026-04-08)"
|
||||
tags: [流式响应, SSE, streaming, API, 多Provider]
|
||||
create time: 2026-06-09 22:15
|
||||
---
|
||||
|
||||
## 为什么需要流式
|
||||
# 流式响应机制 - Claude Code 打字机效果原理
|
||||
|
||||
## 概述
|
||||
|
||||
Claude Code 通过 SSE(Server-Sent Events)实现流式响应,逐 token 接收 AI 输出,实时呈现"打字机"效果。本文解析事件状态机、内容块交织、错误处理和多 Provider 适配的完整实现。
|
||||
|
||||
## 正文
|
||||
|
||||
### 为什么需要流式
|
||||
|
||||
想象 AI 需要 30 秒才能生成完整回答——如果等 30 秒后才一次性显示,用户体验是灾难性的。
|
||||
|
||||
流式响应让用户**实时看到 AI 的思考过程**:
|
||||
流式响应让用户**实时看到 AI 的思考过程**:
|
||||
- 文字逐字出现,用户能提前判断方向是否正确
|
||||
- 工具调用的参数在生成过程中就能预览
|
||||
- 长时间任务不会让用户觉得"卡死了"
|
||||
|
||||
## `BetaRawMessageStreamEvent` 核心事件类型
|
||||
### BetaRawMessageStreamEvent 核心事件类型
|
||||
|
||||
流式 API 返回的是一系列 `BetaRawMessageStreamEvent`,每种事件类型对应流式响应的不同阶段(`src/services/api/claude.ts`):
|
||||
流式 API 返回的是一系列 `BetaRawMessageStreamEvent`,每种事件类型对应流式响应的不同阶段(`src/services/api/claude.ts`):
|
||||
|
||||
```
|
||||
message_start ← 消息开始,包含 model、usage 初始值
|
||||
├── content_block_start ← 内容块开始(text / tool_use / thinking)
|
||||
│ ├── content_block_delta ← 增量数据(text_delta / input_json_delta / thinking_delta)
|
||||
│ ├── content_block_delta ← ... 持续到达
|
||||
│ └── content_block_stop ← 内容块结束,yield AssistantMessage
|
||||
├── content_block_start ← 下一个内容块...
|
||||
│ └── ...
|
||||
└── message_delta ← stop_reason + 最终 usage
|
||||
message_stop ← 消息结束
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["message_start 消息开始"] --> B["content_block_start 内容块开始"]
|
||||
B --> C["content_block_delta 增量数据"]
|
||||
C --> C
|
||||
C --> D["content_block_stop 内容块结束"]
|
||||
D --> B
|
||||
D --> E["message_delta stop_reason + 最终usage"]
|
||||
E --> F["message_stop 消息结束"]
|
||||
```
|
||||
|
||||
### 事件处理状态机
|
||||
#### 事件处理状态机
|
||||
|
||||
`src/services/api/claude.ts` 中 `queryModelWithStreaming()` 函数的事件处理循环实现了一个基于 `switch(part.type)` 的状态机:
|
||||
`src/services/api/claude.ts` 中 `queryModelWithStreaming()` 函数的事件处理循环实现了一个基于 `switch(part.type)` 的状态机:
|
||||
|
||||
| 事件类型 | 处理逻辑 | 状态变更 |
|
||||
|----------|----------|----------|
|
||||
@@ -41,11 +46,11 @@ message_stop ← 消息结束
|
||||
| `content_block_delta` | 按子类型增量追加数据 | text / thinking / input 累加 |
|
||||
| `content_block_stop` | 构建完整 `AssistantMessage` 并 yield | 消息推入 `newMessages` |
|
||||
| `message_delta` | 更新 stop_reason 和最终 usage | 写回最后一条消息 |
|
||||
| `message_stop` | 无操作(流结束标记) | — |
|
||||
| `message_stop` | 无操作(流结束标记) | -- |
|
||||
|
||||
### 内容块类型及其增量数据
|
||||
#### 内容块类型及其增量数据
|
||||
|
||||
`content_block_start` 中的 `content_block.type` 决定了如何处理后续 delta:
|
||||
`content_block_start` 中的 `content_block.type` 决定了如何处理后续 delta:
|
||||
|
||||
| 内容块类型 | Delta 类型 | 累加逻辑 |
|
||||
|-----------|-----------|----------|
|
||||
@@ -55,18 +60,19 @@ message_stop ← 消息结束
|
||||
| `server_tool_use` | `input_json_delta` | 同 tool_use |
|
||||
| `connector_text` | `connector_text_delta` | 特殊连接器文本(feature flag 控制) |
|
||||
|
||||
关键设计:`content_block_start` 时所有文本字段初始化为空字符串,只通过 `content_block_delta` 累加。这是因为 SDK 有时在 start 和 delta 中重复发送相同文本。
|
||||
> [!tip] 设计细节
|
||||
> `content_block_start` 时所有文本字段初始化为空字符串,只通过 `content_block_delta` 累加。这是因为 SDK 有时在 start 和 delta 中重复发送相同文本。
|
||||
|
||||
## 文本 chunk 和 tool_use block 的交织
|
||||
### 文本 chunk 和 tool_use block 的交织
|
||||
|
||||
一次 AI 响应可能包含多个内容块,交替出现:
|
||||
一次 AI 响应可能包含多个内容块,交替出现:
|
||||
|
||||
```
|
||||
content_block_start (text, index=0) "我来帮你修复这个 bug。"
|
||||
content_block_delta (text_delta) "首先..."
|
||||
content_block_stop (index=0)
|
||||
content_block_start (tool_use, index=1) { name: "Read", input: "..." }
|
||||
content_block_delta (input_json_delta) '{"file_p' → 'ath":' → '"src/foo.ts"}'
|
||||
content_block_delta (input_json_delta) '{"file_p' -> 'ath":' -> '"src/foo.ts"}'
|
||||
content_block_stop (index=1)
|
||||
content_block_start (text, index=2) "我已经看到了问题所在..."
|
||||
content_block_stop (index=2)
|
||||
@@ -74,10 +80,10 @@ content_block_stop (index=2)
|
||||
|
||||
每个 `content_block_stop` 触发一次 `yield`,将完整的 AssistantMessage 推送给消费者。这意味着一个 AI 响应会产生**多条** `AssistantMessage`——文本消息和工具调用消息交替产出。
|
||||
|
||||
`stop_reason` 要等到 `message_delta` 才确定(可能是 `end_turn`、`tool_use`、`max_tokens` 等),所以最后一条消息的 `stop_reason` 是**回写**的:
|
||||
`stop_reason` 要等到 `message_delta` 才确定(可能是 `end_turn`、`tool_use`、`max_tokens` 等),所以最后一条消息的 `stop_reason` 是**回写**的:
|
||||
|
||||
```typescript
|
||||
// claude.ts — stop_reason 回写逻辑(直接属性修改,不用对象替换)
|
||||
// claude.ts -- stop_reason 回写逻辑(直接属性修改,不用对象替换)
|
||||
// 因为 transcript 写队列持有 message.message 的引用
|
||||
const lastMsg = newMessages.at(-1)
|
||||
if (lastMsg) {
|
||||
@@ -86,37 +92,35 @@ if (lastMsg) {
|
||||
}
|
||||
```
|
||||
|
||||
## 流式中的错误处理
|
||||
### 流式中的错误处理
|
||||
|
||||
### 网络断开
|
||||
#### 网络断开
|
||||
|
||||
流式连接依赖 SSE(Server-Sent Events)。当连接中断时,系统有两层检测机制:
|
||||
流式连接依赖 SSE。当连接中断时,系统有三层检测机制:
|
||||
|
||||
1. **被动停滞检测**(`src/services/api/claude.ts` 中 stall 检测逻辑):当下一个事件到达时,计算与上一个事件的时间间隔。超过阈值(30 秒,`STALL_THRESHOLD_MS = 30_000`)记录为一次 stall,累积计数并写入遥测日志。这是被动检测——仅在下一个 chunk 到达时才触发,不会主动中断流。
|
||||
2. **主动空闲超时看门狗**(`src/services/api/claude.ts` 中 `STREAM_IDLE_TIMEOUT_MS` 看门狗逻辑):使用 `setTimeout` 设置 90 秒(可通过 `CLAUDE_STREAM_IDLE_TIMEOUT_MS` 环境变量覆盖)的硬性超时。如果在此期间没有收到任何事件,主动终止流并抛出错误进入重试流程。
|
||||
3. **非流式降级**:作为最后手段,设置 `didFallBackToNonStreaming` 标志,通过 `executeNonStreamingRequest()` 回退到非流式请求(一次性获取完整响应)。
|
||||
1. **被动停滞检测**: 当下一个事件到达时,计算与上一个事件的时间间隔。超过阈值(30 秒,`STALL_THRESHOLD_MS = 30_000`)记录为一次 stall,累积计数并写入遥测日志。
|
||||
2. **主动空闲超时看门狗**: 使用 `setTimeout` 设置 90 秒(可通过 `CLAUDE_STREAM_IDLE_TIMEOUT_MS` 环境变量覆盖)的硬性超时。如果在此期间没有收到任何事件,主动终止流并抛出错误进入重试流程。
|
||||
3. **非流式降级**: 作为最后手段,设置 `didFallBackToNonStreaming` 标志,通过 `executeNonStreamingRequest()` 回退到非流式请求。
|
||||
|
||||
```typescript
|
||||
// claude.ts — 被动停滞检测
|
||||
// claude.ts -- 被动停滞检测
|
||||
const STALL_THRESHOLD_MS = 30_000 // 30 秒无事件视为停滞
|
||||
let totalStallTime = 0
|
||||
let stallCount = 0
|
||||
|
||||
// claude.ts — 主动空闲超时
|
||||
// claude.ts -- 主动空闲超时
|
||||
const STREAM_IDLE_TIMEOUT_MS =
|
||||
parseInt(process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS || '', 10) || 90_000
|
||||
```
|
||||
|
||||
### API 限流
|
||||
#### API 限流
|
||||
|
||||
当 API 返回限流错误时,系统使用 `withRetry` 包装器进行指数退避重试。重试逻辑考虑了:
|
||||
当 API 返回限流错误时,系统使用 `withRetry` 包装器进行指数退避重试。重试逻辑考虑了:
|
||||
- 错误类型(429 限流 vs 500 服务器错误)
|
||||
- 重试次数上限
|
||||
- 退避间隔
|
||||
|
||||
### Token 超限
|
||||
#### Token 超限
|
||||
|
||||
两种 token 超限场景有不同的处理:
|
||||
两种 token 超限场景有不同的处理:
|
||||
|
||||
| 场景 | stop_reason | 处理方式 |
|
||||
|------|------------|----------|
|
||||
@@ -124,7 +128,7 @@ const STREAM_IDLE_TIMEOUT_MS =
|
||||
| **上下文窗口超限** | `model_context_window_exceeded` | 触发 compaction 压缩对话历史后重试 |
|
||||
|
||||
```typescript
|
||||
// claude.ts — stop_reason 处理
|
||||
// claude.ts -- stop_reason 处理
|
||||
if (stopReason === 'max_tokens') {
|
||||
yield createAssistantAPIErrorMessage({ error: 'max_output_tokens', ... })
|
||||
}
|
||||
@@ -134,59 +138,40 @@ if (stopReason === 'model_context_window_exceeded') {
|
||||
}
|
||||
```
|
||||
|
||||
### 流式停滞检测
|
||||
### 工具执行的流式反馈
|
||||
|
||||
系统持续监控事件到达间隔,检测"停滞"(stall):
|
||||
BashTool 的命令执行也是流式的——通过 `onProgress` 回调逐行推送输出:
|
||||
|
||||
```typescript
|
||||
// claude.ts — stall 检测逻辑
|
||||
const STALL_THRESHOLD_MS = 30_000 // 30 秒无事件视为停滞
|
||||
if (timeSinceLastEvent > STALL_THRESHOLD_MS) {
|
||||
stallCount++
|
||||
totalStallTime += timeSinceLastEvent
|
||||
logEvent('tengu_streaming_stall', { stall_duration_ms, stall_count, ... })
|
||||
}
|
||||
```
|
||||
|
||||
这是**被动检测**——仅在下一个 chunk 到达时才触发比较。与之互补的是 90 秒主动空闲超时看门狗(`STREAM_IDLE_TIMEOUT_MS`),会直接中断长时间无响应的流。
|
||||
|
||||
## 工具执行的流式反馈
|
||||
|
||||
BashTool 的命令执行也是流式的——通过 `onProgress` 回调逐行推送输出:
|
||||
|
||||
```
|
||||
BashTool.call() → runShellCommand() → AsyncGenerator
|
||||
├── 每秒轮询输出文件 → onProgress(lastLines, allLines, ...)
|
||||
├── yield { type: 'progress', output, fullOutput, elapsedTimeSeconds }
|
||||
└── return { code, stdout, interrupted, ... }
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["BashTool.call"] --> B["runShellCommand AsyncGenerator"]
|
||||
B --> C["每秒轮询输出文件"]
|
||||
C --> D["onProgress lastLines, allLines"]
|
||||
D --> E["yield progress, output, fullOutput"]
|
||||
B --> F["return code, stdout, interrupted"]
|
||||
```
|
||||
|
||||
UI 层通过 `useToolCallProgress` hook 实时展示命令输出,而不是等命令完全结束。长时间运行的命令还支持自动后台化(`shouldAutoBackground`)。
|
||||
|
||||
## 多 Provider 适配
|
||||
### 多 Provider 适配
|
||||
|
||||
| Provider | 流式协议 | 特殊处理 |
|
||||
|----------|----------|----------|
|
||||
| **firstParty** (Anthropic Direct) | 原生 SSE | 延迟最低,TTFT 最快 |
|
||||
| **firstParty**(Anthropic Direct) | 原生 SSE | 延迟最低,TTFT 最快 |
|
||||
| **AWS Bedrock** | AWS SDK 流式接口 | 需要额外的 beta header 和认证 |
|
||||
| **Google Vertex** | gRPC → 事件流 | 通过 `getMergedBetas()` 适配 |
|
||||
| **Google Vertex** | gRPC -> 事件流 | 通过 `getMergedBetas()` 适配 |
|
||||
| **foundry** | Anthropic 兼容 API | 内部部署 |
|
||||
| **openai** | OpenAI 流式适配器 | 转换为 Anthropic 内部格式 |
|
||||
| **gemini** | Gemini 流式适配器 | 转换为 Anthropic 内部格式 |
|
||||
| **grok** (xAI) | Grok 流式适配器 | 转换为 Anthropic 内部格式 |
|
||||
| **grok**(xAI) | Grok 流式适配器 | 转换为 Anthropic 内部格式 |
|
||||
|
||||
所有 Provider 通过统一的 `Stream<BetaRawMessageStreamEvent>` 抽象层屏蔽差异。上层代码(QueryEngine、REPL)不需要关心底层用的是哪个 Provider。
|
||||
|
||||
### Provider 选择
|
||||
> [!info] Provider 选择
|
||||
> `src/utils/model/providers.ts` 中的 `getAPIProvider()` 根据配置决定使用哪个 Provider。每个 Provider 需要适配认证方式、beta header、请求参数格式、错误码映射——但这些差异在 `claude.ts` 的 `queryStream()` 函数中被统一处理。
|
||||
|
||||
`src/utils/model/providers.ts` 中的 `getAPIProvider()` 根据配置决定使用哪个 Provider:
|
||||
## 关联笔记
|
||||
|
||||
```typescript
|
||||
// 根据 api_provider 配置选择:
|
||||
// "anthropic" → 直连
|
||||
// "bedrock" → AWS SDK
|
||||
// "vertex" → Google SDK
|
||||
// 第三方 base URL → 自动检测
|
||||
```
|
||||
|
||||
每个 Provider 需要适配的细节包括:认证方式、beta header、请求参数格式、错误码映射——但这些差异在 `claude.ts` 的 `queryStream()` 函数中被统一处理。
|
||||
- [[the-loop]] - Agentic Loop 核心机制
|
||||
- [[multi-turn]] - 多轮对话管理与 QueryEngine
|
||||
- [[../context/token-budget]] - Token 预算与输出限制
|
||||
|
||||
@@ -1,44 +1,58 @@
|
||||
---
|
||||
title: "Agentic Loop:AI 自主循环的核心机制"
|
||||
description: "深入解析 Claude Code 的 query() 异步生成器循环——从流式 API 调用、工具并行执行、上下文压缩、错误恢复到终止条件的完整状态机,基于 src/query.ts 的源码级分析。"
|
||||
keywords: ["Agentic Loop", "query loop", "tool_use", "状态机", "auto-compact", "streaming", "recovery"]
|
||||
sourceRef: "3ec5675 (2026-04-08)"
|
||||
tags: [agentic-loop, claude-code, 状态机, 流式API, 工具执行]
|
||||
create time: 2026-06-09 22:15
|
||||
---
|
||||
|
||||
{/* 本章目标:基于 src/query.ts 揭示 Agentic Loop 的完整状态机 */}
|
||||
# Agentic Loop: AI 自主循环的核心机制
|
||||
|
||||
## 什么是 Agentic Loop
|
||||
## 概述
|
||||
|
||||
传统聊天机器人:你问一句,它答一句。
|
||||
Claude Code 的核心不是一问一答,而是基于 `query()` 异步生成器的无限循环——每次迭代完成"思考-行动-观察"周期,支持流式 API 调用、工具并行执行、上下文压缩、错误恢复和多种终止条件。
|
||||
|
||||
## 正文
|
||||
|
||||
### 什么是 Agentic Loop
|
||||
|
||||
传统聊天机器人:你问一句,它答一句。
|
||||
Claude Code 不一样:你说一个需求,它可能连续执行十几步操作才给你最终结果。
|
||||
|
||||
这背后的机制叫做 **Agentic Loop**(智能体循环),核心实现在 `src/query.ts` 的 `queryLoop()` 异步生成器函数。它是一个 `while(true)` 无限循环,每次迭代代表一次"思考→行动→观察"周期。
|
||||
这背后的机制叫做 **Agentic Loop**(智能体循环),核心实现在 `src/query.ts` 的 `queryLoop()` 异步生成器函数。它是一个 `while(true)` 无限循环,每次迭代代表一次"思考-行动-观察"周期。
|
||||
|
||||
<Frame caption="Agentic Loop 循环示意">
|
||||
<img src="/docs/images/agentic-loop.png" alt="Agentic Loop 循环图" />
|
||||
</Frame>
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["开始循环"] --> B["上下文预处理"]
|
||||
B --> C["流式 API 调用"]
|
||||
C --> D{"有工具调用?"}
|
||||
D -- 是 --> E["并行执行工具"]
|
||||
E --> F["工具结果合并"]
|
||||
F --> B
|
||||
D -- 否 --> G["终止条件检查"]
|
||||
G --> H["返回结果"]
|
||||
G -- 需要恢复 --> B
|
||||
```
|
||||
|
||||
## 循环的完整结构
|
||||
### 循环的完整结构
|
||||
|
||||
`queryLoop()` 的每次迭代(`src/query.ts` 中 `while(true)` 主循环)包含以下阶段:
|
||||
|
||||
### 阶段 1:上下文预处理(Pre-Processing Pipeline)
|
||||
#### 阶段 1: 上下文预处理(Pre-Processing Pipeline)
|
||||
|
||||
在调用 API 之前,依次执行 5 个压缩/优化步骤:
|
||||
|
||||
```
|
||||
messagesForQuery(原始消息)
|
||||
↓ applyToolResultBudget() — 工具结果预算截断(按 maxResultSizeChars)
|
||||
↓ snipCompactIfNeeded() — 历史 Snip 压缩(HISTORY_SNIP feature)
|
||||
↓ microcompact() — 微压缩(工具结果摘要)
|
||||
↓ applyCollapsesIfNeeded() — 上下文折叠(CONTEXT_COLLAPSE feature)
|
||||
↓ autocompact() — 自动压缩(超出阈值时触发)
|
||||
messagesForQuery(处理后的消息)→ 发往 API
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["messagesForQuery 原始消息"] --> B["applyToolResultBudget 工具结果预算截断"]
|
||||
B --> C["snipCompactIfNeeded 历史 Snip 压缩"]
|
||||
C --> D["microcompact 微压缩 工具结果摘要"]
|
||||
D --> E["applyCollapsesIfNeeded 上下文折叠"]
|
||||
E --> F["autocompact 自动压缩"]
|
||||
F --> G["messagesForQuery 处理后消息"]
|
||||
G --> H["发往 API"]
|
||||
```
|
||||
|
||||
每个步骤的输出是下一步的输入,形成串行管道。Snip 和 Microcompact 的释放 token 数会传递给 autocompact 的阈值计算(`snipTokensFreed`),避免重复压缩。
|
||||
|
||||
### 阶段 2:流式 API 调用(Streaming Loop)
|
||||
#### 阶段 2: 流式 API 调用(Streaming Loop)
|
||||
|
||||
`deps.callModel()` 发起流式请求(`src/query.ts` 中 `attemptWithFallback` 循环内),返回一个 AsyncGenerator。在流式过程中:
|
||||
|
||||
@@ -51,80 +65,87 @@ messagesForQuery(处理后的消息)→ 发往 API
|
||||
- `backfillObservableInput()` —— 为 tool_use 块回填可观察字段(如文件路径展开),但只在添加了新字段时才克隆消息,避免破坏 prompt cache 的字节一致性
|
||||
- 流式降级检测——如果 `streamingFallbackOccured`,已收集的消息被标记为 tombstone,清空后重试
|
||||
|
||||
### 阶段 3:工具执行(Tool Execution)
|
||||
#### 阶段 3: 工具执行(Tool Execution)
|
||||
|
||||
如果 `needsFollowUp` 为 true,循环不会终止,而是执行工具:
|
||||
|
||||
```typescript
|
||||
// 两种工具执行器(互斥)
|
||||
const toolUpdates = streamingToolExecutor
|
||||
? streamingToolExecutor.getRemainingResults() // 流式:获取已完成的+等待中的
|
||||
? streamingToolExecutor.getRemainingResults() // 流式: 获取已完成的+等待中的
|
||||
: runTools(toolUseBlocks, assistantMessages, canUseTool, toolUseContext)
|
||||
```
|
||||
|
||||
工具结果通过 `normalizeMessagesForAPI()` 标准化后,与原始消息合并,进入**下一轮循环迭代**。
|
||||
|
||||
### 阶段 4:终止或继续
|
||||
#### 阶段 4: 终止或继续
|
||||
|
||||
每次迭代结束时,根据条件决定 `return`(终止)或 `continue`(继续):
|
||||
每次迭代结束时,根据条件决定 `return`(终止)或 `continue`(继续)。
|
||||
|
||||
## 终止条件(源码级)
|
||||
### 终止条件(源码级)
|
||||
|
||||
循环有多种终止路径,按触发时机排列:
|
||||
|
||||
| 终止原因 | 触发位置 | 机制 |
|
||||
|----------|---------|------|
|
||||
| **blocking_limit** | 第 686 行 | Token 计数超过硬限制(非 autocompact 模式)→ 生成 PTL 错误消息 → 返回 |
|
||||
| **image_error** | 第 1021 行 | `ImageSizeError` / `ImageResizeError` 异常 → 直接返回 |
|
||||
| **model_error** | 第 1040 行 | `callModel()` 抛出不可恢复异常 → 生成错误消息 → 返回 |
|
||||
| **aborted_streaming** | 第 1095 行 | `abortController.signal.aborted`(流式阶段)→ 为未完成的 tool_use 生成合成 tool_result → 返回 |
|
||||
| **prompt_too_long** | 第 1219/1226 行 | 413 错误且 reactive compact 无法恢复 → 暂扣的错误消息被释放 → 返回 |
|
||||
| **completed** | 第 1308 行 | API 错误(限流、认证失败等)导致无法继续 → 返回 |
|
||||
| **stop_hook_prevented** | 第 1323 行 | Stop hook 返回 `preventContinuation: true` → 返回 |
|
||||
| **completed** | 第 1401 行 | 正常完成:AI 未发出 tool_use → `needsFollowUp = false` → 经过 stop hooks → 返回 |
|
||||
| **aborted_tools** | 第 1559 行 | `abortController.signal.aborted`(工具执行阶段)→ 返回 |
|
||||
| **hook_stopped** | 第 1564 行 | 工具执行期间 hook 返回 `shouldPreventContinuation` → 返回 |
|
||||
| **max_turns** | 第 1755 行 | 轮次计数超过 `maxTurns` 限制 → 返回 |
|
||||
| **blocking_limit** | 第 686 行 | Token 计数超过硬限制(非 autocompact 模式), 生成 PTL 错误消息, 返回 |
|
||||
| **image_error** | 第 1021 行 | `ImageSizeError` / `ImageResizeError` 异常, 直接返回 |
|
||||
| **model_error** | 第 1040 行 | `callModel()` 抛出不可恢复异常, 生成错误消息, 返回 |
|
||||
| **aborted_streaming** | 第 1095 行 | `abortController.signal.aborted`(流式阶段), 为未完成的 tool_use 生成合成 tool_result, 返回 |
|
||||
| **prompt_too_long** | 第 1219/1226 行 | 413 错误且 reactive compact 无法恢复, 暂扣的错误消息被释放, 返回 |
|
||||
| **completed** | 第 1308 行 | API 错误(限流, 认证失败等)导致无法继续, 返回 |
|
||||
| **stop_hook_prevented** | 第 1323 行 | Stop hook 返回 `preventContinuation: true`, 返回 |
|
||||
| **completed** | 第 1401 行 | 正常完成: AI 未发出 tool_use, `needsFollowUp = false`, 经过 stop hooks, 返回 |
|
||||
| **aborted_tools** | 第 1559 行 | `abortController.signal.aborted`(工具执行阶段), 返回 |
|
||||
| **hook_stopped** | 第 1564 行 | 工具执行期间 hook 返回 `shouldPreventContinuation`, 返回 |
|
||||
| **max_turns** | 第 1755 行 | 轮次计数超过 `maxTurns` 限制, 返回 |
|
||||
|
||||
## 继续条件(恢复路径)
|
||||
### 继续条件(恢复路径)
|
||||
|
||||
循环不仅是一个简单的"有 tool_use 就继续",它还包含多种恢复/重试路径:
|
||||
循环不仅是一个简单的"有 tool_use 就继续",它还包含多种恢复/重试路径:
|
||||
|
||||
### 1. 正常工具循环(`next_turn`)
|
||||
`needsFollowUp = true` → 执行工具 → 新消息追加到 `messagesForQuery` → state 重新赋值 → `continue`
|
||||
#### 1. 正常工具循环(next_turn)
|
||||
|
||||
### 2. max_output_tokens 恢复(`max_output_tokens_escalate` / `max_output_tokens_recovery`)
|
||||
当 AI 输出被截断时(`apiError === 'max_output_tokens'`),分两阶段恢复:
|
||||
- **提升阶段**(`max_output_tokens_escalate`):首次截断时,将 `maxOutputTokens` 从默认值提升到 `ESCALATED_MAX_TOKENS`(64K)。静默重试,不注入 meta 消息。
|
||||
- **恢复阶段**(`max_output_tokens_recovery`):提升后仍然截断时,注入恢复消息"Output token limit hit. Resume directly...",最多重试 `MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3` 次。恢复耗尽后,暂扣的错误消息被释放。
|
||||
`needsFollowUp = true` -> 执行工具 -> 新消息追加到 `messagesForQuery` -> state 重新赋值 -> `continue`
|
||||
|
||||
### 3. Prompt-Too-Long 恢复(`collapse_drain_retry` / `reactive_compact_retry`)
|
||||
当遇到 413 错误时,按优先级尝试两种压缩策略:
|
||||
- **Context Collapse Drain**(`collapse_drain_retry`):提交所有已暂存的折叠(collapse),释放空间后重试。如果上一轮已经是 `collapse_drain_retry` 则跳过,避免无限循环。
|
||||
- **Reactive Compact**(`reactive_compact_retry`):如果 collapse drain 无法恢复,触发即时压缩(reactive compact),生成摘要后重试。`hasAttemptedReactiveCompact` 标志防止无限循环。
|
||||
#### 2. max_output_tokens 恢复
|
||||
|
||||
当 AI 输出被截断时(`apiError === 'max_output_tokens'`),分两阶段恢复:
|
||||
|
||||
- **提升阶段**(`max_output_tokens_escalate`): 首次截断时,将 `maxOutputTokens` 从默认值提升到 `ESCALATED_MAX_TOKENS`(64K)。静默重试,不注入 meta 消息。
|
||||
- **恢复阶段**(`max_output_tokens_recovery`): 提升后仍然截断时,注入恢复消息 "Output token limit hit. Resume directly...",最多重试 `MAX_OUTPUT_TOKENS_RECOVERY_LIMIT = 3` 次。恢复耗尽后,暂扣的错误消息被释放。
|
||||
|
||||
#### 3. Prompt-Too-Long 恢复
|
||||
|
||||
当遇到 413 错误时,按优先级尝试两种压缩策略:
|
||||
|
||||
- **Context Collapse Drain**(`collapse_drain_retry`): 提交所有已暂存的折叠(collapse),释放空间后重试。如果上一轮已经是 `collapse_drain_retry` 则跳过,避免无限循环。
|
||||
- **Reactive Compact**(`reactive_compact_retry`): 如果 collapse drain 无法恢复,触发即时压缩(reactive compact),生成摘要后重试。`hasAttemptedReactiveCompact` 标志防止无限循环。
|
||||
|
||||
#### 4. Stop Hook 阻塞重试(stop_hook_blocking)
|
||||
|
||||
### 4. Stop Hook 阻塞重试(`stop_hook_blocking`)
|
||||
Stop hook 可以注入阻塞错误消息,强制 AI 重新思考。新的消息(包含阻塞错误)被追加到对话中,`stopHookActive = true`,进入下一轮迭代。
|
||||
|
||||
### 5. Token Budget 继续提示(`token_budget_continuation`)
|
||||
#### 5. Token Budget 继续提示(token_budget_continuation)
|
||||
|
||||
当 `TOKEN_BUDGET` feature 启用时,如果 token 消耗达到阈值但未超出预算,注入 nudge 消息让 AI 加速收尾,然后继续。
|
||||
|
||||
## 模型降级(Fallback)
|
||||
### 模型降级(Fallback)
|
||||
|
||||
当主模型不可用时(`FallbackTriggeredError`,`src/query.ts` 中 `attemptWithFallback` 循环的 catch 分支):
|
||||
当主模型不可用时(`FallbackTriggeredError`,`src/query.ts` 中 `attemptWithFallback` 循环的 catch 分支):
|
||||
|
||||
1. 已收集的 `assistantMessages` 被清空,tool_use 块收到合成 tool_result:"Model fallback triggered"
|
||||
1. 已收集的 `assistantMessages` 被清空,tool_use 块收到合成 tool_result: "Model fallback triggered"
|
||||
2. 思维签名块被移除(`stripSignatureBlocks`)—— 因为思维签名与模型绑定,跨模型回放会 400
|
||||
3. 切换到 `fallbackModel`,更新 `toolUseContext.options.mainLoopModel`
|
||||
4. 生成系统消息:"Switched to {fallback} due to high demand for {original}"
|
||||
4. 生成系统消息: "Switched to {fallback} due to high demand for {original}"
|
||||
5. 重新发起流式请求
|
||||
|
||||
## 状态机:State 对象
|
||||
### 状态机: State 对象
|
||||
|
||||
每次迭代的状态通过 `State` 类型(`src/query.ts`,类型定义)传递:
|
||||
每次迭代的状态通过 `State` 类型(`src/query.ts`,类型定义)传递:
|
||||
|
||||
```typescript
|
||||
// src/query.ts — State 类型定义
|
||||
// src/query.ts -- State 类型定义
|
||||
type State = {
|
||||
messages: Message[] // 当前对话消息
|
||||
toolUseContext: ToolUseContext // 工具上下文(含权限)
|
||||
@@ -141,57 +162,63 @@ type State = {
|
||||
|
||||
每次 `continue` 都创建新的 State 对象(不可变更新),而非就地修改。`transition` 字段记录了为什么继续——让后续迭代能检测特定恢复路径(如 `collapse_drain_retry`)避免循环。
|
||||
|
||||
## Token Budget(实验性)
|
||||
### Token Budget(实验性)
|
||||
|
||||
当 `TOKEN_BUDGET` feature 启用时(`src/query.ts` 中 `!needsFollowUp` 分支内的预算检查逻辑),循环在终止前会检查 token 消耗:
|
||||
当 `TOKEN_BUDGET` feature 启用时(`src/query.ts` 中 `!needsFollowUp` 分支内的预算检查逻辑),循环在终止前会检查 token 消耗:
|
||||
|
||||
- **continuation**:未达到预算但超过阈值 → 注入 nudge 消息,让 AI 加速收尾
|
||||
- **diminishing_returns**:检测到收益递减 → 提前终止
|
||||
- **continuation**: 未达到预算但超过阈值 -> 注入 nudge 消息,让 AI 加速收尾
|
||||
- **diminishing_returns**: 检测到收益递减 -> 提前终止
|
||||
- 预算数据来自 `createBudgetTracker()`,跨迭代累计
|
||||
|
||||
## 为什么不是"一次规划,批量执行"
|
||||
### 为什么不是"一次规划,批量执行"
|
||||
|
||||
<Note>
|
||||
源码揭示了为什么 Claude Code 选择逐步循环:
|
||||
</Note>
|
||||
> [!info] 设计选择的源码依据
|
||||
> 源码揭示了为什么 Claude Code 选择逐步循环:
|
||||
|
||||
- **每一步都产生真实信息**:`runTools()` 返回的 `toolResults` 是 API 不可能预知的——命令输出、文件内容、错误信息
|
||||
- **动态上下文管理**:每轮迭代前都重新评估压缩需求(autocompact → microcompact → snip),基于最新的 token 计数
|
||||
- **错误即时恢复**:工具失败不需要推倒重来——stop hook 可以注入阻塞错误让 AI 修正策略
|
||||
- **用户可控**:`abortController.signal` 在循环的多个检查点被检测(第 1059、1095、1529 行),用户按 ESC 可以优雅中断
|
||||
- **成本控制**:Token Budget 在每轮终止前检查,防止 AI 无效循环
|
||||
- **每一步都产生真实信息**: `runTools()` 返回的 `toolResults` 是 API 不可能预知的——命令输出、文件内容、错误信息
|
||||
- **动态上下文管理**: 每轮迭代前都重新评估压缩需求(autocompact -> microcompact -> snip),基于最新的 token 计数
|
||||
- **错误即时恢复**: 工具失败不需要推倒重来——stop hook 可以注入阻塞错误让 AI 修正策略
|
||||
- **用户可控**: `abortController.signal` 在循环的多个检查点被检测(第 1059、1095、1529 行),用户按 ESC 可以优雅中断
|
||||
- **成本控制**: Token Budget 在每轮终止前检查,防止 AI 无效循环
|
||||
|
||||
## 一个完整的迭代示例
|
||||
### 一个完整的迭代示例
|
||||
|
||||
> 用户:"帮我找到项目里所有未使用的导入语句,然后删掉它们"
|
||||
> 用户: "帮我找到项目里所有未使用的导入语句,然后删掉它们"
|
||||
|
||||
```
|
||||
迭代 1: 思考→行动
|
||||
预处理管道: applyToolResultBudget → snipCompact(HISTORY_SNIP feature) → microcompact → applyCollapses(CONTEXT_COLLAPSE feature) → autocompact
|
||||
→ 上下文很短,无需压缩
|
||||
迭代 1: 思考 -> 行动
|
||||
预处理管道: applyToolResultBudget -> snipCompact -> microcompact -> applyCollapses -> autocompact
|
||||
-> 上下文很短,无需压缩
|
||||
API 调用: 返回 tool_use(Glob, "**/*.ts")
|
||||
工具执行: 返回 42 个文件路径
|
||||
→ needsFollowUp = true
|
||||
→ transition: { reason: 'next_turn' }, continue
|
||||
-> needsFollowUp = true
|
||||
-> transition: { reason: 'next_turn' }, continue
|
||||
|
||||
迭代 2: 思考→行动
|
||||
迭代 2: 思考 -> 行动
|
||||
预处理管道: 42 个文件结果仍在预算内
|
||||
API 调用: 返回 tool_use(Grep, "import.*from")
|
||||
工具执行: 在 15 个文件中找到 120 条 import
|
||||
→ needsFollowUp = true
|
||||
→ transition: { reason: 'next_turn' }, continue
|
||||
-> needsFollowUp = true
|
||||
-> transition: { reason: 'next_turn' }, continue
|
||||
|
||||
迭代 3: 思考→行动(多轮)
|
||||
预处理管道: 120 条 Grep 结果触发 microcompact → 摘要化
|
||||
迭代 3: 思考 -> 行动(多轮)
|
||||
预处理管道: 120 条 Grep 结果触发 microcompact -> 摘要化
|
||||
API 调用: 返回 3 个 tool_use(FileEdit, ...)
|
||||
工具执行: 删除 5 条未使用导入
|
||||
→ needsFollowUp = true
|
||||
→ transition: { reason: 'next_turn' }, continue
|
||||
-> needsFollowUp = true
|
||||
-> transition: { reason: 'next_turn' }, continue
|
||||
|
||||
迭代 4: 总结
|
||||
API 调用: 返回纯文本"已清理 3 个文件中的 5 条未使用导入"
|
||||
→ needsFollowUp = false
|
||||
→ Stop hooks 通过
|
||||
→ Token Budget 检查通过(如果启用)
|
||||
→ return { reason: 'completed' }
|
||||
-> needsFollowUp = false
|
||||
-> Stop hooks 通过
|
||||
-> Token Budget 检查通过(如果启用)
|
||||
-> return { reason: 'completed' }
|
||||
```
|
||||
|
||||
## 关联笔记
|
||||
|
||||
- [[multi-turn]] - 多轮对话管理与 QueryEngine 会话编排
|
||||
- [[streaming]] - 流式响应机制与 SSE 事件处理
|
||||
- [[../context/compaction]] - 上下文压缩三层策略
|
||||
- [[../context/token-budget]] - Token 预算动态计算
|
||||
|
||||
Reference in New Issue
Block a user