01 · 一次 API 调用
打开 Claude 或 ChatGPT,输入一句话,几秒后回答浮现,看起来像是在和一个持续存在的人聊天。
但从我们要实现的程序来看,事情可以先简化成一次请求和一次响应:
我们的程序
│
│ HTTP POST
▼
Messages API
│
│ JSON
▼
我们的程序
本书不会实现大模型本身。模型是我们保留的外部黑箱;我们要从零实现的是围绕模型运行的 agent runtime。
先从最底下的一层开始:发出一次模型调用,看看请求和响应长什么样,再把它套进一个终端循环。
这一章结束后,我们会得到一个可以在终端里连续问答的 CLI。
一次调用就是一个 HTTP POST
Anthropic 提供了 Messages API。先不用 SDK,直接用 curl 发一次请求:
curl https://api.anthropic.com/v1/messages \
-H "content-type: application/json" \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-d '{
"model": "claude-haiku-4-5",
"max_tokens": 1024,
"messages": [
{ "role": "user", "content": "Hello, Claude!" }
]
}'
这里有三个请求头:
content-type:请求体使用 JSON。x-api-key:用于身份验证的 API key。anthropic-version:指定使用的 API 版本,让客户端依赖一个稳定的接口约定。
真正传给模型的内容在请求体里。
{
"model": "claude-haiku-4-5",
"max_tokens": 1024,
"messages": [
{
"role": "user",
"content": "Hello, Claude!"
}
]
}
现在只需要认识三个字段:
model:这次调用哪个模型。本书使用 Haiku,速度快,成本也适合反复实验。max_tokens:模型这次最多可以生成多少 token。这是输出上限。messages:传给模型的消息。它是一个数组,本章只会用到user消息。
这里只有一条消息:
{
"role": "user",
"content": "Hello, Claude!"
}
意思就是:把 "Hello, Claude!" 作为用户输入交给模型。
响应是什么样的
请求成功后,我们会收到一个 JSON。
例如:
{
"model": "claude-haiku-4-5-20251001",
"id": "msg_011CdRx7baTqth4J6TTeVUex",
"type": "message",
"role": "assistant",
"content": [
{
"type": "text",
"text": "Hello! It's nice to meet you. How can I help you today?"
}
],
"stop_reason": "end_turn",
"stop_sequence": null,
"stop_details": null,
"usage": {
"input_tokens": 11,
"cache_creation_input_tokens": 0,
"cache_read_input_tokens": 0,
"cache_creation": {
"ephemeral_5m_input_tokens": 0,
"ephemeral_1h_input_tokens": 0
},
"output_tokens": 19,
"service_tier": "standard",
"inference_geo": "not_available"
}
}
这一章暂时不用处理全部字段,只关注三个。
content
真正的模型输出放在 content 里:
"content": [
{
"type": "text",
"text": "Hello! It's nice to meet you. How can I help you today?"
}
]
注意,content 不是字符串,而是一个数组。
现在我们只看到了:
{
"type": "text"
}
也就是一段文本内容。
因此,模型返回的文字实际上是:
data.content[0].text
stop_reason
"stop_reason": "end_turn"
它表示模型为什么停止生成。
end_turn 表示这次回答自然结束。
如果输出碰到了我们设置的 max_tokens 上限,则可能看到:
"stop_reason": "max_tokens"
所以仅仅拿到一段文字还不够,有时候我们还需要知道:
模型为什么停下来?
usage
"usage": {
"input_tokens": 11,
"output_tokens": 19
}
这里记录了本次调用消耗的输入和输出 token。
API 的使用成本与这些 token 数量有关。现在我们先把它们打印出来,方便以后观察每次请求实际消耗了多少上下文。
Messages API 不会替我们维护对话
还有一个特点需要在写 REPL 之前知道:
每次 Messages API 调用都由我们自己提供这一次需要的消息。
例如刚才发送的是:
"messages": [
{
"role": "user",
"content": "Hello, Claude!"
}
]
如果下一次我们发送:
"messages": [
{
"role": "user",
"content": "What's my name?"
}
]
那么模型这一次能看到的就是 "What's my name?"。
上一轮到底说过什么,不会因为这是同一个终端程序就自动出现在新的请求里。
如果以后希望模型看到之前的对话,我们需要自己把相关历史再次放进 messages。
这一章先不做这件事。
我们的 REPL 每轮只发送当前输入。
建一个最小项目
本章开始需要一个 Anthropic API key,获取方式见第 0 章。如果所在网络访问 api.anthropic.com 不便,或者想用 OpenAI 兼容的端点跑完本书,见附录 A。
-
创建项目并装依赖。
tsx让我们直接运行 TypeScript,不需要先手动执行一次编译。mkdir -p code/src cd code npm init -y npm install --save-dev tsx typescript @types/node -
把
package.json改成 ESM,并增加一个启动脚本。{ "type": "module", "scripts": { "dev": "tsx --env-file=.env src/cli.ts" } } -
加一个最小的
tsconfig.json。{ "compilerOptions": { "target": "es2022", "module": "nodenext", "strict": true, "skipLibCheck": true, "types": ["node"] } } -
在项目根目录创建
.env,把 key 放进去。启动脚本里的--env-file=.env会读它。ANTHROPIC_API_KEY=sk-ant-xxxx -
马上创建
.gitignore。API key 不应该写进代码,也不应该提交到 Git。echo ".env" >> .gitignore
现在目录是:
code/
├─ .env
├─ .gitignore
├─ package.json
├─ tsconfig.json
└─ src/
调用一次模型
创建 src/cli.ts:
const API_URL = 'https://api.anthropic.com/v1/messages'
const MODEL = 'claude-haiku-4-5'
const API_KEY = process.env.ANTHROPIC_API_KEY
async function callModel(userInput: string) {
const res = await fetch(API_URL, {
method: 'POST',
headers: {
'content-type': 'application/json',
'x-api-key': API_KEY!,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify({
model: MODEL,
max_tokens: 1024,
messages: [
{
role: 'user',
content: userInput,
},
],
}),
})
if (!res.ok) {
throw new Error(
`API returned ${res.status}: ${await res.text()}`,
)
}
return res.json()
}
这就是目前程序里最重要的函数:
callModel(userInput)
它做三件事:
userInput
↓
组成 HTTP 请求
↓
发送给 Messages API
↓
返回 JSON
这里暂时没有给响应定义 TypeScript 类型,res.json() 返回的结果先直接使用。
错误处理也保持最小:如果 API 返回失败状态码,就把状态码和原始错误信息抛出来。
现在在文件下面调用一次:
if (!API_KEY) {
console.error('Please set ANTHROPIC_API_KEY')
process.exit(1)
}
const data = await callModel('Hello, Claude!')
console.log(data.content[0].text)
运行:
npm run dev
如果配置正确,会看到类似:
Hello! It's nice to meet you. How can I help you today?
到这里,我们已经能从自己的程序里调用模型了。
加一个 REPL
每次修改代码才能换一个问题显然不方便。
我们给它加一个终端循环:
读输入
↓
callModel
↓
打印回答
↓
继续读输入
Node 自带的 readline 已经够用了。
把文件底部的单次调用替换成:
import readline from 'node:readline/promises'
if (!API_KEY) {
console.error('Please set ANTHROPIC_API_KEY')
process.exit(1)
}
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
})
console.log(`model: ${MODEL} (input /exit to exit)`)
while (true) {
const line = (await rl.question('you> ')).trim()
if (line === '/exit') break
if (line === '') continue
const data = await callModel(line)
const text = data.content
.filter((block: any) => block.type === 'text')
.map((block: any) => block.text)
.join('')
console.log(`claude> ${text}`)
console.log(
` · ${data.usage.input_tokens} in / ${data.usage.output_tokens} out · ${data.stop_reason}`,
)
}
rl.close()
这里仍然只有一次模型调用:
const data = await callModel(line)
只是外面多了一个 while 循环。
每当用户输入一句话,我们就把这一句单独发给模型。
打印结果时,没有直接写:
data.content[0].text
而是过滤所有 text block:
const text = data.content
.filter((block: any) => block.type === 'text')
.map((block: any) => block.text)
.join('')
因为 content 本来就是一个数组。
如果响应里有多个文本块,我们就把它们全部打印出来。
最后顺手显示这一次请求的:
input tokens
output tokens
stop_reason
现在再运行:
npm run dev
它已经能聊天了
$ npm run dev
model: claude-haiku-4-5 (input /exit to exit)
you> hello, claude
claude> Hello! It's nice to meet you. How can I help you today?
· 10 in / 19 out · end_turn
我们已经有了一个最小的终端聊天程序。
但它现在究竟能做什么?
继续试。
you> my name is xiaoming
claude> Nice to meet you, Xiaoming!
you> what's my name
claude> I don't have any information about your name.
它忘了。
回头看 callModel:
messages: [
{
role: 'user',
content: userInput,
},
]
第一次请求发送:
my name is xiaoming
第二次请求发送:
what's my name
第二个请求里根本没有:
my name is xiaoming
所以模型当然不知道。
这就是前面所说的:Messages API 不会替我们维护对话历史。
当前程序中的两个请求,除了碰巧由同一个 while 循环发出之外,没有任何关系。
它也看不到我们的文件
再让它做一件编码 agent 应该能做的事情:
you> 帮我 review 一下 src 目录下的 cli.ts
claude> 我目前无法访问你的本地文件系统。请把 src/cli.ts 的内容粘贴给我,我可以帮你 review。
src/cli.ts 明明就在当前进程旁边。
Node 可以读它:
readFile(...)
但 Claude 看不到。
因为我们现在传给模型的只有:
messages: [
{
role: 'user',
content: userInput,
},
]
模型能做的事情也只有返回响应。
我们的程序拿到响应以后,又只做了一件事:
console.log(...)
所以现在这个程序:
人输入一句话
↓
模型生成文字
↓
程序打印文字
它已经会说话,但还不会做事。
下一步,我们先让模型能够读取一个本地文件。
本章总结
- src/cli.ts├─callModel()组装请求、调用 Messages API、返回整个 JSON└─REPL 循环读取输入,打印回答与本轮 token 消耗
- package.jsonESM + dev 脚本(tsx --env-file=.env)
- tsconfig.jsonstrict、nodenext
- .envANTHROPIC_API_KEY