跳到主要内容
chapter-01 第〇部分 · 起点

01 · 一次 API 调用

里程碑 → 一个会失忆的聊天 CLI——连问"我叫什么"都答不出
// 让你的 agent 带你读这一章

打开 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 每轮只发送当前输入。

建一个最小项目

先拿到 API key

本章开始需要一个 Anthropic API key,获取方式见第 0 章。如果所在网络访问 api.anthropic.com 不便,或者想用 OpenAI 兼容的端点跑完本书,见附录 A

  1. 创建项目并装依赖tsx 让我们直接运行 TypeScript,不需要先手动执行一次编译。

    mkdir -p code/src
    cd code
    
    npm init -y
    npm install --save-dev tsx typescript @types/node
  2. package.json 改成 ESM,并增加一个启动脚本。

    {
      "type": "module",
      "scripts": {
        "dev": "tsx --env-file=.env src/cli.ts"
      }
    }
  3. 加一个最小的 tsconfig.json

    {
      "compilerOptions": {
        "target": "es2022",
        "module": "nodenext",
        "strict": true,
        "skipLibCheck": true,
        "types": ["node"]
      }
    }
  4. 在项目根目录创建 .env,把 key 放进去。启动脚本里的 --env-file=.env 会读它。

    ANTHROPIC_API_KEY=sk-ant-xxxx
  5. 马上创建 .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(...)

所以现在这个程序:

人输入一句话

模型生成文字

程序打印文字

它已经会说话,但还不会做事。

下一步,我们先让模型能够读取一个本地文件。

本章总结

→ 本章代码 · milestone
新增
  • src/cli.ts├─callModel()组装请求、调用 Messages API、返回整个 JSON└─REPL 循环读取输入,打印回答与本轮 token 消耗
  • package.jsonESM + dev 脚本(tsx --env-file=.env)
  • tsconfig.jsonstrict、nodenext
  • .envANTHROPIC_API_KEY
现在这个 CLI 能在终端里连续问答,每一轮都看得到 token 消耗和 stop_reason,还不能记住上一轮对话、读取本地文件。