# Step 06: 真实 API 调用
一句话导读:把 step05 那个
setTimeout模拟的callAI换成真正的client.messages.create,引入history数组累积对话——从这一步起,你输入文字,Claude 会真的回答你。
# 一、这一步做了什么(What)
step06 用 @anthropic-ai/sdk 造一个真实客户端,callAI(input, history) 把「历史 + 当前输入」拼成 messages 发给 API,取回文本和 token 用量。index.ts 里维护一个 history 数组,每轮把 user 和 assistant 消息都 push 进去,下一轮一起发——多轮对话的记忆就靠它。demo 命令仍走 mock,不烧额度。错误按 401/429/timeout 分类提示。
# 二、面试官视角:AI 没有记忆,多轮对话是怎么「记住」的?(Why)
面试题:Claude 是无状态的,每次请求它都不记得上一句。那多轮对话的上下文从哪来?
答案反直觉但简单:上下文不在服务端,在客户端。每次请求都把完整历史重新发一遍。API 是彻底无状态的——它不存你的会话,你发什么它就基于什么回答。所谓「记忆」,是客户端每轮把过去所有消息 history 打包重发制造的错觉。
history.push({ role: 'user', content: input });
history.push({ role: 'assistant', content: result.text });
// 下一轮:messages = [...history, 新的 user 消息] 一起发
2
3
不这么做会怎样?如果每次只发当前这一句,Claude 就成了「金鱼记忆」——你问「它多高?」它完全不知道「它」指谁。
| 方案 | 服务端存会话 | 客户端重发历史 |
|---|---|---|
| 谁记状态 | 服务端(有状态) | 客户端(服务端无状态) |
| 扩展性 | 服务端要存所有会话 | 服务端零负担,水平扩展容易 |
| 代价 | —— | 每轮请求越来越长、token 越烧越多 |
选后者是架构决策:让服务端无状态,换取极强的可扩展性——代价是上下文窗口和 token 成本,这也正是后面 step 要做「对话压缩」的根本原因。
# 三、原理:从模拟到真实的替换(How)
# 客户端也是惰性单例
let client: Anthropic | null = null;
function getClient(): Anthropic {
if (!client) {
client = new Anthropic({ apiKey: getApiKey()!, baseURL: getBaseUrl() });
}
return client;
}
2
3
4
5
6
7
又见 step03 的惰性单例——apiKey 和 baseURL 全来自 step05 的 config.ts。前面几步的铺垫在这里咬合上了:config 供 Key、单例管客户端。
# 真实调用
export async function callAI(userInput, history?) {
const messages = [...(history || []), { role: 'user', content: userInput }];
const response = await getClient().messages.create({
model: process.env['MODEL'] || 'claude-sonnet-4-20250514',
max_tokens: 1024,
messages,
});
const textBlock = response.content.find(b => b.type === 'text');
return {
text: textBlock?.text ?? '(no text response)',
inputTokens: response.usage.input_tokens,
outputTokens: response.usage.output_tokens,
};
}
2
3
4
5
6
7
8
9
10
11
12
13
14
和 step04 的模拟版函数签名几乎一样(输入字符串、返回文本),所以 index.ts 几乎不用改——这正是 step03/04 坚持模块化和统一接口的回报。区别只在于:一行 import 换掉,setTimeout 变成真实 HTTP。
# response.content 为什么是数组?
注意 response.content 是数组,要用 .find(b => b.type === 'text') 取文本块。因为 Claude 的回复可以由多种块组成——文本块、工具调用块(tool_use)、思考块等。这一步只有文本,但这个数组结构是为后面的工具调用(step11+)预留的。现在多写一个 .find,是为将来 Claude 说「我要调用 Read 工具」时能解析出 tool_use 块做准备。
# 数据流
输入 input
↓
messages = [...history, {role:'user', content:input}]
↓
client.messages.create({ model, max_tokens, messages }) ── 真实 HTTPS 到 api.anthropic.com
↓
response.content 数组 → .find(type==='text') → 取文本
response.usage → inputTokens / outputTokens
↓
显示回复 + token;history.push(user) + push(assistant)
↓
回到 REPL,下一轮带着更长的 history
2
3
4
5
6
7
8
9
10
11
12
# 四、深入追问(面试常见 follow-up)
Q:每轮重发全部历史,历史越长请求越大、越烧钱,怎么办?
A:这正是无状态设计的固有代价,也是后面几步的伏笔。真实 Claude Code 用对话压缩(把老对话摘要成短文本)和Token 预算控制来对冲:监控累积 token,接近上下文上限就触发压缩,把前面几十轮浓缩成一段摘要再继续。step06 只是暴露问题(history 无限增长),QueryEngine 那几步才解决它。
Q:错误为什么要按 401/429/timeout 分类,而不是统一「请求失败」? A:因为不同错误对应不同的正确应对,混为一谈用户就不知道下一步该干嘛:401(Key 无效)需要用户去改配置,程序重试一万次也没用;429(限流)是暂时的,等一会或自动退避重试就行;timeout 多半是网络抖动,直接重试即可。分类的价值是把「错误类型」翻译成「用户该采取的行动」。真实版还会据此决定是否自动重试、退避多久。
Q:max_tokens: 1024 限制的是什么?设太小会怎样?
A:限制的是单次回复的最大输出长度(不是输入)。设太小,长回答会被中途截断(stop_reason 会是 max_tokens),代码里表现为回复戛然而止。设太大则可能浪费预算、增加延迟。真实版会根据任务动态调,并检查 stop_reason 判断是否被截断、要不要续写。
Q:response.usage 的 token 数拿来干嘛?
A:成本追踪和预算控制的数据源。input/output token 各有单价,累加就能算实时花费。真实 Claude Code 的 cost-tracker 就基于这些数字做成本统计和预算熔断(超预算自动停)。这一步先把数据显示出来,后面才用它做控制。
# 五、设计权衡
demo 命令刻意保留走 mock 而非真实 API——演示异步概念不值得烧 Key,把「学概念」和「花钱调真 API」分开是省钱的好习惯。另外这一步没有流式输出:一次 create 等全部回复返回才显示,长回答会有几秒「空白等待」。这是有意的——先把「请求-响应 + 历史累积」这个最小闭环走通,流式作为独立一步(step07)再上,避免一次引入太多变量。
# 六、与真实源码的对照
| 我们的实现 | Claude Code 源码 |
|---|---|
callAI 封装 SDK | src/services/api/(约 20 文件,含重试/缓存) |
history 数组累积 | QueryEngine.ts 的 messages + 压缩 |
| 手动拼 messages | 引擎自动注入 System Prompt / 工具定义 |
| 401/429 分类 | 完整错误分类 + 自动退避重试 |
response.usage 显示 | cost-tracker 成本追踪 + 预算熔断 |
# 七、一句话总结
Step 06 = 一行 import 换来真实对话 + 一个 history 数组换来「记忆」:API 无状态,多轮上下文靠客户端每轮重发历史制造;content 是数组、usage 有 token,都在为后面的工具调用和成本控制埋线。这是第一个「有真实感」的里程碑,也是所有后续能力的地基。
# 下一步
cd step07 && npm start —— 把「等几秒砸出一整段」改成「逐字冒出来」,加上流式输出。
← API Key 与环境变量 流式输出 →