Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions docs/context/system-prompt.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,11 @@ export function asSystemPrompt(value: readonly string[]): SystemPrompt {
| 阶段 | 内容 | 缓存策略 |
|------|------|----------|
| **静态区** | Intro Section、System Rules、Doing Tasks、Actions、Using Tools、Tone & Style、Output Efficiency | 可跨组织缓存(`scope: 'global'`) |
| **BOUNDARY** | `SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'` | 分界标记(不发送给 API) |
| **BOUNDARY** | `SYSTEM_PROMPT_DYNAMIC_BOUNDARY = '__SYSTEM_PROMPT_DYNAMIC_BOUNDARY__'` | 分界标记(不发送给 API,仅用于分割静态区与动态区以实现全局缓存) |
| **动态区** | Session Guidance、Memory、Model Override、Env Info、Language、Output Style、MCP Instructions、Scratchpad、FRC、Summarize Tool Results、Token Budget、Brief | 每次会话不同(`scope: 'org'` 或无缓存) |

> **Boundary 是什么**:它把 System Prompt 分成"不变的静态区"和"因用户/会话而异的动态区"。静态区对所有用户相同,可获得 `scope: 'global'` 跨组织缓存;动态区每次不同,只能 `scope: 'org'` 或不缓存。它本身是一个特殊字符串,在发送给 API 前被移除,AI 永远看不到。

### 动态区的 Section 注册表

动态区通过 `systemPromptSection()` / `DANGEROUS_uncachedSystemPromptSection()` 注册,这两个工厂函数定义于 `src/constants/systemPromptSections.ts`:
Expand Down Expand Up @@ -151,9 +153,7 @@ MCP 工具列表在会话中可能变化(连接/断开),破坏了跨组织

这是缓存效率最高的模式。`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 之前的静态内容(Intro、Rules、Tone & Style 等)对所有用户相同,可跨组织缓存。

### Boundary 插入条件

`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 标记**仅在特定条件**下插入:
> **Boundary 插入条件**:`SYSTEM_PROMPT_DYNAMIC_BOUNDARY` 标记**仅在特定条件**下插入:

```typescript
// src/utils/betas.ts:226-229
Expand Down
75 changes: 47 additions & 28 deletions docs/conversation/multi-turn.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
title: "多轮对话管理 - QueryEngine 会话编排与持久化"
description: "从源码角度解析 Claude Code 多轮对话管理:QueryEngine 的会话状态机、JSONL transcript 持久化、成本追踪模型和模型热切换机制。"
keywords: ["多轮对话", "会话管理", "QueryEngine", "transcript", "成本追踪"]
sourceRef: "3ec5675 (2026-04-08)"
---

{/* 本章目标:从源码角度揭示会话编排、持久化存储、成本追踪和模型切换的完整链路 */}
Expand All @@ -11,15 +12,17 @@ keywords: ["多轮对话", "会话管理", "QueryEngine", "transcript", "成本
- **单轮**(一次 Agentic Loop):`query()` 函数的一次完整执行——组装上下文 → 调 API → 处理工具调用 → 循环直到结束
- **多轮**(一个 Session):`QueryEngine` 类管理的一次会话——跨越数十轮 `submitMessage()` 调用,持续数小时

`QueryEngine`(`src/QueryEngine.ts:186`)是单轮 Agentic Loop 之上的**会话编排器**,它管理的状态远不止消息列表:
`QueryEngine`(`src/QueryEngine.ts`,类定义)是单轮 Agentic Loop 之上的**会话编排器**,它管理的状态远不止消息列表:

```
QueryEngine 内部状态
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 ← 会话级中断控制
```

Expand All @@ -28,29 +31,37 @@ QueryEngine 内部状态
每次用户输入一条消息,REPL 或 SDK 调用 `submitMessage()`,它会执行完整的 turn 初始化链路:

```typescript
// src/QueryEngine.ts:211 — 简化的 submitMessage 流程
async *submitMessage(prompt, options?): AsyncGenerator<SDKMessage> {
// src/QueryEngine.ts — QueryEngine.submitMessage() 简化流程
async *submitMessage(
prompt: string | ContentBlockParam[],
options?: { uuid?: string; isMeta?: boolean },
): AsyncGenerator<SDKMessage> {
// 1. 清除 turn 级追踪状态
this.discoveredSkillNames.clear()
// 2. 解析模型(用户可能中途切换了模型
const mainLoopModel = userSpecifiedModel
? parseUserSpecifiedModel(userSpecifiedModel)

// 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({ tool_name: tool.name, ... })
this.permissionDenials.push({
type: 'permission_denial',
tool_name: sdkCompatToolName(tool.name),
tool_use_id: toolUseID,
tool_input: input,
})
}
return result
}

// 5. 调用核心 query() 函数执行 agentic loop
yield* query({
systemPrompt, messages: this.mutableMessages,
Expand All @@ -68,36 +79,43 @@ async *submitMessage(prompt, options?): AsyncGenerator<SDKMessage> {
### 存储路径

```
~/.claude/projects/<project-hash>/<session-id>.jsonl
~/.claude/projects/<sanitized-cwd>/<session-uuid>.jsonl
```

- `project-hash` 由 `getProjectDir(originalCwd)` 生成,同一项目目录的会话归入同一子目录
- 路径由 `getProjectDir(originalCwd)` 生成,使用 `sanitizePath()` 将项目目录路径转换为安全的目录名(非 hash),同一项目目录的会话归入同一子目录
- 每条记录是一行 JSON(JSONL 格式),支持追加写入而不需要读取-修改-写入整个文件
- 读取上限为 50MB(`MAX_TRANSCRIPT_READ_BYTES`),防止超大会话导致 OOM
- 读取上限为 50MB(`MAX_TRANSCRIPT_READ_BYTES` 常量,`src/utils/sessionStorage.ts`),防止超大会话导致 OOM

### Transcript 写入器

`TranscriptWriter`(`src/utils/sessionStorage.ts:1200+`)是一个写队列,确保并发的消息追加不会互相覆盖
`Project` 类(`src/utils/sessionStorage.ts`,私有类)管理 transcript 的写入。它通过 `writeQueues`(按文件分组的写队列)和 `drainWriteQueue()`(定时批量刷写)确保并发消息追加不会互相覆盖

```
写入流程:
appendEntryToFile(sessionId, entry)
写入流程(异步排队路径)
recordTranscript(sessionId, entry)
ensureCurrentSessionFile() ← 懒初始化:首次写入时才创建文件
project.enqueueWrite(filePath, entry) ← 入列到 writeQueues
序列化为 JSON + 换行符
scheduleDrain() ← 设置定时器(FLUSH_INTERVAL_MS)
appendFile(path, line) ← 原子追加
drainWriteQueue() ← 按 MAX_CHUNK_BYTES 分批
↓ 写入每批
appendToFile(path, batchContent) ← 批量追加
如果配置了远程持久化:
persistToRemote(sessionId, entry)
├── CCR v2: internalEventWriter('transcript', entry)
└── v1 Ingress: sessionIngress.appendSessionLog(...)

同步直写路径(用于元数据重写等场景):
appendEntryToFile(fullPath, entry) ← 同步 appendFileSync
失败时 mkdir + 重试
```

### 会话恢复链路

`--resume` 参数触发的恢复流程(`src/main.tsx:3620+`):
`--resume` 参数触发的恢复流程(`src/main.tsx` 中 `--resume` 分支):

```
1. 解析 resume 参数:
Expand Down Expand Up @@ -130,15 +148,16 @@ async *submitMessage(prompt, options?): AsyncGenerator<SDKMessage> {
### 累计层:cost-tracker.ts

```typescript
// src/cost-tracker.ts — StoredCostState 数据模型
// src/cost-tracker.ts — StoredCostState 类型定义
type StoredCostState = {
totalCostUSD: number // 累计美元花费
totalAPIDuration: number // API 调用总时长(含重试)
totalAPIDurationWithoutRetries: number // 不含重试的纯推理时间
totalToolDuration: number // 工具执行总时长
totalLinesAdded: number // 代码增加行数
totalLinesRemoved: number // 代码删除行数
modelUsage: { [modelName: string]: ModelUsage } // 按模型分拆的用量
lastDuration: number | undefined // 最近一次会话时长
modelUsage: { [modelName: string]: ModelUsage } | undefined // 按模型分拆的用量
}
```

Expand All @@ -156,18 +175,18 @@ saveCurrentSessionCosts(sessionId)

### 预算熔断

`QueryEngineConfig.maxBudgetUsd` 提供了会话级的硬性预算上限。在 REPL 中,当累计费用超过 $5 时(`src/screens/REPL.tsx:2208`),弹出费用提醒对话框——这不是硬性阻断,而是"软提醒"。
`QueryEngineConfig.maxBudgetUsd` 提供了会话级的硬性预算上限。在 REPL 中,当累计费用超过 $5 时(`src/screens/REPL.tsx` 中费用阈值 `useEffect`),弹出费用提醒对话框——这不是硬性阻断,而是"软提醒",且仅在 `hasConsoleBillingAccess()` 为 true 时显示

## 模型热切换

在一个会话中切换模型不会丢失对话历史——因为 `mutableMessages` 与模型选择是解耦的:

```
/model sonnet → setMainLoopModelOverride('claude-sonnet-4-20250514')
/model sonnet → QueryEngine.setModel('claude-sonnet-4-20250514')
实际操作:this.config.userSpecifiedModel = model(QueryEngine.setModel() 方法)
下一次 submitMessage() 开始时:
parseUserSpecifiedModel(userSpecifiedModel)
parseUserSpecifiedModel(this.config.userSpecifiedModel)
→ 返回新的模型配置
fetchSystemPromptParts({ mainLoopModel: newModel })
Expand Down
32 changes: 19 additions & 13 deletions docs/conversation/streaming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
title: "流式响应机制 - Claude Code 打字机效果原理"
description: "解析 Claude Code 流式响应实现:如何通过 SSE 逐 token 接收 AI 输出,实现实时打字机效果,提升用户等待体验。"
keywords: ["流式响应", "SSE", "streaming", "实时输出", "API streaming"]
sourceRef: "3ec5675 (2026-04-08)"
---

## 为什么需要流式
Expand Down Expand Up @@ -31,7 +32,7 @@ message_stop ← 消息结束

### 事件处理状态机

`src/services/api/claude.ts:1980-2298` 实现了一个基于 `switch(part.type)` 的状态机:
`src/services/api/claude.ts` 中 `queryStreamRaw()` 函数的事件处理循环实现了一个基于 `switch(part.type)` 的状态机:

| 事件类型 | 处理逻辑 | 状态变更 |
|----------|----------|----------|
Expand Down Expand Up @@ -76,7 +77,7 @@ content_block_stop (index=2)
`stop_reason` 要等到 `message_delta` 才确定(可能是 `end_turn`、`tool_use`、`max_tokens` 等),所以最后一条消息的 `stop_reason` 是**回写**的:

```typescript
// claude.ts:2246 — 直接属性修改,不用对象替换
// claude.ts — stop_reason 回写逻辑(直接属性修改,不用对象替换
// 因为 transcript 写队列持有 message.message 的引用
const lastMsg = newMessages.at(-1)
if (lastMsg) {
Expand All @@ -89,16 +90,21 @@ if (lastMsg) {

### 网络断开

流式连接依赖 SSE(Server-Sent Events)。当连接中断时:
流式连接依赖 SSE(Server-Sent Events)。当连接中断时,系统有两层检测机制

1. **Stream idle watchdog**:定时检测事件间隔,超过阈值(stall)触发告警和重试
2. **Stream abort**:如果 watchdog 检测到长时间无事件,抛出错误进入重试流程
3. **非流式降级**:作为最后手段,回退到非流式请求(一次性获取完整响应)
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()` 回退到非流式请求(一次性获取完整响应)

```typescript
// claude.ts:2338-2355 — 检测空流
// 1. 完全没有事件 → 代理返回了非 SSE 响应
// 2. 有 message_start 但没有 content_block_stop → 流被截断
// claude.ts — 被动停滞检测
const STALL_THRESHOLD_MS = 30_000 // 30 秒无事件视为停滞
let totalStallTime = 0
let stallCount = 0

// claude.ts — 主动空闲超时
const STREAM_IDLE_TIMEOUT_MS =
parseInt(process.env.CLAUDE_STREAM_IDLE_TIMEOUT_MS || '', 10) || 90_000
```

### API 限流
Expand All @@ -118,7 +124,7 @@ if (lastMsg) {
| **上下文窗口超限** | `model_context_window_exceeded` | 触发 compaction 压缩对话历史后重试 |

```typescript
// claude.ts:2267-2293
// claude.ts — stop_reason 处理
if (stopReason === 'max_tokens') {
yield createAssistantAPIErrorMessage({ error: 'max_output_tokens', ... })
}
Expand All @@ -133,16 +139,16 @@ if (stopReason === 'model_context_window_exceeded') {
系统持续监控事件到达间隔,检测"停滞"(stall):

```typescript
// claude.ts:1940-1966
const STALL_THRESHOLD_MS = 10_000 // 10 秒无事件视为停滞
// 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, ... })
}
```

多个 stall 累积后,watchdog 可能决定中断流并触发重试
这是**被动检测**——仅在下一个 chunk 到达时才触发比较。与之互补的是 90 秒主动空闲超时看门狗(`STREAM_IDLE_TIMEOUT_MS`),会直接中断长时间无响应的流

## 工具执行的流式反馈

Expand Down
Loading
Loading