第 5 章:LLM API 客户端(流式)

# 第 5 章:LLM API 客户端(流式)

> 流式传输不只是性能优化,它改变了用户感知"思考"的方式。

---

## 5.1 核心问题

第 4 章搭好了工具引擎。但谁来决定"现在该调用哪个工具"?谁来理解用户输入并生成回复?答案是 LLM——我们需要把用户消息和工具列表发给 Claude,拿回它的回复。

本章要解决的问题是:

> **如何与 Anthropic API 通信、实时处理流式响应、把 tool_use 块重组为可执行的工具调用,并把 API 成本控制在最低?**

这比看起来复杂。流式 API 不是一次性返回完整 JSON,而是把响应拆成几十乃至几百个"事件"逐条推送。`tool_use` 块的输入参数(本身是 JSON)也是分片流入的——你必须把这些 delta 碎片拼接成完整 JSON 再解析。

Claude Code 在此基础上还叠加了两个性能特性:**Prompt Caching**(缓存 System Prompt,降本最高 90%)和 **Extended Thinking / Ultrathink**(让模型生成内部推理 token,提升复杂任务质量)。

---

## 5.2 原理讲解

### 5.2.1 Messages API:消息格式

Anthropic 的 Messages API 是对话的基础协议。每次调用都发送完整的历史消息列表(无状态——服务端不记忆上下文):

```typescript
// 最小请求结构
const response = await anthropic.messages.create({
  model: 'claude-opus-4-5',
  max_tokens: 4096,
  system: '你是一个 Coding Agent...',  // 系统提示
  messages: [
    { role: 'user',      content: '帮我写一个冒泡排序' },
    { role: 'assistant', content: '好的,我先...' },
    { role: 'user',      content: '请加上类型注解' },
    // ... 完整历史
  ],
  tools: [/* 工具 JSON Schema 列表,见第4章 */],
})
```

响应的 `stop_reason` 有两种关键值:
- `'end_turn'`:正常回复结束
- `'tool_use'`:模型要调用工具(`content` 数组里有 `tool_use` 块)

### 5.2.2 流式 SSE 协议:事件序列

非流式调用等全部 token 生成完才返回,延迟高达数秒。流式调用将响应拆成事件序列,逐条 `yield`,实现"打字机效果":

```
服务端推送顺序:
  message_start              → { type: 'message_start', message: { usage: {...}, ... } }
  content_block_start[0]     → { type: 'content_block_start', index: 0, content_block: { type: 'text' } }
  content_block_delta[0]     → { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: '好' } }
  content_block_delta[0]     → { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: '的,' } }
  ...(更多 text_delta)
  content_block_stop[0]      → { type: 'content_block_stop', index: 0 }
  message_delta              → { type: 'message_delta', delta: { stop_reason: 'end_turn' }, usage: { output_tokens: 42 } }
  message_stop               → { type: 'message_stop' }
```

**关键字段来源**:
- `input_tokens`(输入 token 数):在 `message_start` 事件中
- `output_tokens`(输出 token 数):在 `message_delta` 事件中
- `cache_read_input_tokens`:在 `message_start` 中,命中缓存时不为零

### 5.2.3 tool_use 块的流式重组

当模型决定调用工具时,响应里会出现 `tool_use` 类型的内容块。**关键难点**:工具的 `input` 字段(JSON 对象)是流式的——它通过 `input_json_delta` 事件一片一片推送:

```
content_block_start[1]  → { type: 'content_block_start', index: 1,
                              content_block: { type: 'tool_use', id: 'toolu_01', name: 'Bash', input: '' } }
content_block_delta[1]  → { delta: { type: 'input_json_delta', partial_json: '{"com' } }
content_block_delta[1]  → { delta: { type: 'input_json_delta', partial_json: 'mand' } }
content_block_delta[1]  → { delta: { type: 'input_json_delta', partial_json: '": "ls -la"}' } }
content_block_stop[1]   → { type: 'content_block_stop', index: 1 }
```

重组逻辑:在内存中为每个 `index` 维护一个字符串累积器,`content_block_stop` 时调用 `JSON.parse()` 得到完整输入对象。

Claude Code 的实现(`src/services/api/claude.ts`,`content_block_delta` 分支):

```typescript
// 简化版重组逻辑
case 'content_block_delta': {
  const block = contentBlocks[part.index]
  if (part.delta.type === 'input_json_delta') {
    // block.type === 'tool_use',block.input 是累积字符串
    block.input += part.delta.partial_json
  } else if (part.delta.type === 'text_delta') {
    block.text += part.delta.text
  } else if (part.delta.type === 'thinking_delta') {
    block.thinking += part.delta.thinking  // Extended Thinking
  }
  break
}
// content_block_stop 时:JSON.parse(block.input) → 完整工具参数
```

### 5.2.4 Token 计数与成本估算

Anthropic API 按 token 计费。Claude Code 的 `src/utils/modelCost.ts` 维护了完整的定价表:

```typescript
// 以 claude-sonnet-4-5 为例($3 input / $15 output per Mtok)
export const COST_TIER_3_15 = {
  inputTokens:            3,      // $3 / 1M tokens
  outputTokens:          15,      // $15 / 1M tokens
  promptCacheWriteTokens: 3.75,   // 写缓存
  promptCacheReadTokens:  0.3,    // 读缓存(仅为输入价格的 10%)
  webSearchRequests:      0.01,   // 每次搜索
}
```

成本计算公式:

$$\text{cost} = \frac{N_{in}}{10^6} \times p_{in} + \frac{N_{out}}{10^6} \times p_{out} + \frac{N_{cache\_read}}{10^6} \times p_{cache\_read} + \frac{N_{cache\_write}}{10^6} \times p_{cache\_write}$$

### 5.2.5 Prompt Caching:缓存断点

这是 Claude Code 成本优化的核心机制。原理:对请求中不变的大块内容(System Prompt、工具列表)加上 `cache_control` 标记,Anthropic 服务端会把这段 token 缓存起来,后续请求命中缓存时只收取约 10% 的费用。

```typescript
// 在 system prompt 末尾注入缓存断点
const systemBlocks = [
  {
    type: 'text',
    text: longSystemPrompt,          // 可能有数千 token
    cache_control: { type: 'ephemeral' }  // ← 缓存断点
  }
]

// 在工具列表末尾注入缓存断点
const tools = [
  ...allTools.slice(0, -1),
  {
    ...lastTool,
    cache_control: { type: 'ephemeral' }  // ← 缓存断点
  }
]
```

**两种 TTL**:
- 默认 5 分钟(活跃会话通常足够)
- 1 小时(`should1hCacheTTL()` 返回 true 时:Anthropic 员工 or claude.ai 订阅用户)

**`getPromptCachingEnabled()`** 的实现逻辑:

```typescript
// src/services/api/claude.ts
export function getPromptCachingEnabled(model: string): boolean {
  if (isEnvTruthy(process.env.DISABLE_PROMPT_CACHING)) return false
  // ... 可按模型独立关闭
  return true  // 默认开启
}
```

### 5.2.6 Extended Thinking / Ultrathink

Extended Thinking 让模型在回复前生成"内心独白"(CoT 推理),对复杂任务有显著提升。

**三种配置模式**(`src/utils/thinking.ts`):

```typescript
export type ThinkingConfig =
  | { type: 'adaptive' }               // 模型自决是否思考
  | { type: 'enabled'; budgetTokens: number }  // 固定 budget(越大越深思)
  | { type: 'disabled' }               // 关闭思考
```

**触发机制**:用户消息中含 `ultrathink`(大小写不敏感,须是完整单词)时自动启用最大 thinking budget:

```typescript
// src/utils/thinking.ts
export function hasUltrathinkKeyword(text: string): boolean {
  return /\bultrathink\b/i.test(text)
}
```

API 请求加上 `thinking` 参数后,响应流中会出现 `thinking` 类型的内容块,通过 `thinking_delta` 事件流式传输(内容对用户可见,类似"思考过程展示")。

---

## 5.3 Claude Code 源码细节

### 5.3.1 `queryModelWithStreaming`:主入口

```typescript
// src/services/api/claude.ts(精简)
export async function* queryModelWithStreaming({
  messages,
  systemPrompt,
  thinkingConfig,
  tools,
  signal,
  options,
}: {
  messages: Message[]
  systemPrompt: SystemPrompt
  thinkingConfig: ThinkingConfig
  tools: Tools
  signal: AbortSignal   // 取消信号(用户按 Ctrl+C 时触发)
  options: Options
}): AsyncGenerator<StreamEvent | AssistantMessage | SystemAPIErrorMessage, void>
```

这是一个 **async generator**——调用者用 `for await (const event of queryModelWithStreaming(...))` 逐事件消费,不需要等待整个响应完成。

### 5.3.2 流式事件状态机

Claude Code 内部维护一个 `contentBlocks` 数组(按 index 索引),在流式处理过程中逐步填充:

```
message_start        → 初始化 usage(input_tokens 在此确定)
content_block_start  → contentBlocks[index] = 新建对应类型的块
content_block_delta  → 累积到 contentBlocks[index](text/input_json/thinking)
content_block_stop   → tool_use 块:JSON.parse(input 字符串) → 完整对象
message_delta        → 更新 usage(output_tokens 在此确定)、stop_reason
message_stop         → 构造完整 AssistantMessage yield 出去
```

### 5.3.3 `getCacheControl()`:动态决定缓存 TTL

```typescript
// src/services/api/claude.ts
export function getCacheControl({ scope, querySource } = {}): {
  type: 'ephemeral'
  ttl?: '1h'
  scope?: CacheScope
} {
  return {
    type: 'ephemeral',
    ...(should1hCacheTTL(querySource) && { ttl: '1h' }),
    ...(scope === 'global' && { scope }),
  }
}
```

TTL `'1h'` 只在满足以下所有条件时启用:Anthropic 内部员工 **或** claude.ai 订阅用户 + 未超额 + GrowthBook 功能开关允许。这体现了"按用户等级差异化缓存"的精细化成本管理。

### 5.3.4 `calculateUSDCost()`:成本计算

```typescript
// src/utils/modelCost.ts
export function calculateUSDCost(resolvedModel: string, usage: Usage): number {
  const modelCosts = getModelCosts(resolvedModel, usage)
  return (
    (usage.input_tokens / 1_000_000) * modelCosts.inputTokens +
    (usage.output_tokens / 1_000_000) * modelCosts.outputTokens +
    ((usage.cache_read_input_tokens ?? 0) / 1_000_000) * modelCosts.promptCacheReadTokens +
    ((usage.cache_creation_input_tokens ?? 0) / 1_000_000) * modelCosts.promptCacheWriteTokens
  )
}
```

注意 `cache_read_input_tokens` 字段:第一次请求时为 0(缓存未命中,走写入);后续命中缓存的请求中此值激增,对应成本大幅下降。

---

## 5.4 最小化产出物

> 代码骨架位于 `../chapters/05/src/`,参考实现位于 `../chapters/05/solution/`。
> **前置条件**:需要 `ANTHROPIC_API_KEY` 环境变量(验收脚本不调用 API,但 `npm start` 需要)。

### 本章要实现什么

在 `../chapters/05/src/llm.ts` 中完成 LLM 客户端。

**接口规范**(已提供,不要修改):

```typescript
export const MODEL: string
export const MODEL_COSTS: { inputTokens: number; outputTokens: number; promptCacheWriteTokens: number; promptCacheReadTokens: number }

export type ThinkingConfig = { type: 'enabled'; budget_tokens: number } | { type: 'disabled' }
export type ToolUse = { id: string; name: string; input: Record<string, unknown> }
export type Usage = { input_tokens: number; output_tokens: number; cache_creation_input_tokens?: number; cache_read_input_tokens?: number }

export function calculateCostUSD(usage: Usage): number
export function hasUltrathinkKeyword(text: string): boolean
export async function streamQuery(params: {
  systemPrompt: string
  messages: Anthropic.MessageParam[]
  thinkingConfig: ThinkingConfig
  tools?: Anthropic.Tool[]
  enablePromptCaching: boolean
  onText?: (text: string) => void
}): Promise<{ text: string; thinking?: string; toolUses: ToolUse[]; stopReason: string; usage: Usage }>
```

**你需要实现**:
1. `calculateCostUSD()`:按 MODEL_COSTS 计算美元成本(含 cache_creation 和 cache_read)
2. `hasUltrathinkKeyword()`:检测 `\bultrathink\b`(完整单词,大小写不敏感)
3. `streamQuery()`:
   - 创建 Anthropic client,组装 systemBlocks(可选注入 `cache_control: { type: 'ephemeral' }`)
   - 维护 `contentBlocks` 状态机,处理 `message_start`/`content_block_start`/`content_block_delta`/`message_delta` 事件
   - `text_delta` → 累积 text,调用 `onText` 回调
   - `input_json_delta` → 累积 tool_use 的 input 字符串
   - 最终重组:text、thinking、toolUses(`JSON.parse` input 字符串)

**关键约束**:
- `streamQuery` 必须处理 `tool_use` 块的流式重组(`input_json_delta` 累积 → `JSON.parse`)
- `onText` 回调在每个 `text_delta` 时调用,不是最后一次性调用

### 验收

```bash
cd docs/chapters/05
npm install
npm test
```

卡住时查看 `../chapters/05/solution/llm.ts`。

---

## 5.5 流式事件处理完整生命周期图

```
API 请求发出
     │
     ▼
┌──────────────────┐
│  message_start   │  ← 获取 input_tokens、cache_read/creation_tokens
└────────┬─────────┘
         │
         ▼ 对每个内容块循环:
┌──────────────────────────────────────────────┐
│  content_block_start[i]                       │  ← 按类型初始化槽位
│    type=text    → contentBlocks[i] = {text:''} │
│    type=tool_use → contentBlocks[i] = {input:''} (JSON 将分片到达)
│    type=thinking → contentBlocks[i] = {thinking:''}
└────────┬─────────────────────────────────────┘
         │
         ▼ 多次:
┌──────────────────────────────────────────────┐
│  content_block_delta[i]                       │
│    text_delta      → block.text += delta.text  │  ← 实时打印
│    input_json_delta → block.input += partial   │  ← 累积 JSON 碎片
│    thinking_delta  → block.thinking += delta   │
└────────┬─────────────────────────────────────┘
         │
         ▼
┌──────────────────┐
│  content_block_stop[i] │  ← tool_use: JSON.parse(block.input) → 完整对象
└────────┬─────────┘
         │
         ▼ (所有块完成后)
┌──────────────────┐
│  message_delta   │  ← 获取 output_tokens、stop_reason
└────────┬─────────┘
         │
         ▼
┌──────────────────┐
│  message_stop    │  ← 流结束
└──────────────────┘
```

---

## 5.6 本章小结

本章构建了与 Claude API 通信的完整客户端:

1. **流式 SSE 协议**:响应被拆分为 `message_start` → `content_block_*` → `message_delta` → `message_stop` 的事件序列,需要维护状态机来重组完整内容。

2. **tool_use 块重组**:工具参数(JSON)通过 `input_json_delta` 分片推送,必须逐片累积、最终 `JSON.parse()` 得到完整对象——这是对话循环能执行工具的基础。

3. **Prompt Caching**:在 System Prompt 末尾注入 `cache_control: { type: 'ephemeral' }`,让 Anthropic 服务端缓存不变的内容。缓存命中时费率降低约 90%,长会话的累计节省相当可观。

4. **Extended Thinking**:请求参数加 `thinking: { type: 'enabled', budget_tokens: N }` 触发扩展思考,响应流中出现 `thinking` 块。Claude Code 通过 `ultrathink` 关键词检测自动启用最大 budget。

5. **成本可观测性**:`message_start` 事件中的 `cache_read_input_tokens` 字段是缓存效果的直接证据——第一次请求创建缓存,后续命中时此值激增、成本大幅下降。

**下一章**将把本章的 API 客户端嵌入**多轮对话循环**:维护消息历史、处理 `tool_use` → `tool_result` 的往返、在上下文接近上限时触发压缩。

---

## 下一章

→ [第 6 章:消息循环与对话管理](06-conversation)