手搓 AI Agent

第 0 篇 · Hello LLM

「和大模型的一次对话」的本质其实只是一次 HTTP 请求。

这是《手搓 AI Agent》系列的第 0 篇,这一篇我们只做一件事:手写一个 HTTP 请求,让大模型回我们一句话。


0.1 前置知识

  1. 会 TypeScript 和 Node.js。不要求精通,能读懂基础的 TS 代码、会用 npm 装依赖跑脚本就行。思路本身跟语言无关,学完之后你换 Python、Go 也能照着实现一遍。
  2. 知道 HTTP 请求是怎么回事。分得清 GET 和 POST、知道请求头和请求体是什么。

0.2 准备工作

在和大模型对话前,我们需要准备两样东西:

  1. 模型
  2. 环境

模型

我们选用 GLM-4.7-Flash 作为教程示例模型,因为它是一个免费的模型,你可以去 https://bigmodel.cn/ 注册账号并创建一个 API_KEY 。

拿到 key 之后,我们要连的是它的 Anthropic 兼容端点。下面这几样 0.3 的代码会直接用到,先记下来:

  • base URL:https://open.bigmodel.cn/api/anthropic
  • 完整请求路径:base URL 再接 /v1/messages,即 https://open.bigmodel.cn/api/anthropic/v1/messages
  • 鉴权:HTTP 请求头里放 x-api-key: 你的api key
  • 模型名:请求体里写 glm-4.7-flash

免费的代价是偶尔会挤:如果遇到"该模型当前访问量过大,请您稍后再试",把模型名换成同样免费的 glm-4.6v-flashglm-4-flash-250414 就行。当然,你也可以选择其他厂商的模型——DeepSeek、Kimi、MiniMax 等也都提供 Anthropic 兼容端点,把上面几个值换成对应厂商的即可。

💡 补充 · "Anthropic 兼容端点"是什么? 大模型的 API 没有统一标准,每家厂商都定义了自己的接口规范——请求发到哪个路径、鉴权头怎么写、消息体长什么样。目前最主流的两套是 OpenAI 的 Chat Completions 和 Anthropic 的 Messages API。国内模型多半两套都支持:你按 Anthropic 的格式发请求,它就能正常响应,这就叫"Anthropic 兼容端点"。我们全程用 Anthropic 这套,后面手写的消息结构、工具调用格式都按它的规范来,遇到不明白的字段,翻 Anthropic 官方文档就能对上。

环境

示例代码用 TypeScript,你需要安装 Node.jsnode -v 看一下版本——我们要用它自带的 fetch,18 以上才有)。

新建一个项目文件夹,初始化并装上三个开发依赖:

mkdir my-agent && cd my-agent
npm init -y
npm pkg set type=module   # 按 ES 模块运行,代码里才能在顶层直接写 await(0.3 会用到)
npm install -D tsx typescript @types/node

tsx.ts 免编译直接跑——写完 xxx.ts,执行 npx tsx xxx.ts


0.3 向模型发起第一次对话

在项目里新建一个 hello.ts,写入下面的代码。先整体看一遍:没有 SDK、没有魔法,它只是一次普通的 HTTP POST

const BASE_URL = process.env.LLM_BASE_URL ?? "https://open.bigmodel.cn/api/anthropic";
const MODEL = process.env.LLM_MODEL ?? "glm-4.7-flash";
const API_KEY = process.env.LLM_API_KEY ?? "";

const res = await fetch(`${BASE_URL}/v1/messages`, {
  method: "POST",
  headers: {
    "content-type": "application/json",
    "x-api-key": API_KEY, // 鉴权:你的 key
  },
  body: JSON.stringify({
    model: MODEL,
    max_tokens: 256, // 这次回复最多生成多少 token
    messages: [
      {
        role: "user",
        content: [
          { type: "text", text: "你好,我叫小明。请用一句话介绍你自己。" },
        ],
      },
    ],
  }),
});

const data = await res.json();
console.log(JSON.stringify(data, null, 2)); // 把模型的返回原样打印出来

三个常量都用 process.env.X ?? 默认值 的写法:优先读环境变量,读不到就用后面那个默认值。所以 BASE_URLMODEL 开箱即用,唯独 API_KEY 默认值是空字符串——你得在运行时用环境变量把自己的 key 传进去。这样 key 既不写死在代码里,也不会跟着代码进 git。

把 key 放进一个 .env 文件(新建,和 hello.ts 同目录):

# .env —— 只有这一行;它不该进 git,记得加进 .gitignore
LLM_API_KEY=你的key

再用 --env-file 读它、跑起来(tsx 会把这个参数透传给 Node):

npx tsx --env-file=.env hello.ts

终端会打出一坨 JSON,模型的第一句回复就藏在里面——它长什么样、每个字段什么意思,留到 0.4 拆。这一节先把发出去的请求看懂:

  • URL:base URL 拼上 /v1/messages——这是 Anthropic 消息接口的标准路径,之后所有对话,都发往这一个地址。
  • headerscontent-type 声明我们发的是 JSON;x-api-key 放你的 api key。
  • body 里三个关键字段:
    • model:用哪个模型。
    • max_tokens:回复的长度上限,别设太小否则话说一半被截断。
    • messages:整段对话。 这是重点——
      • roleuser(你)或 assistant(模型)。
      • content 不是一个字符串,而是一个"内容块"数组。 现在你只放了一个 type: "text" 的文本块。 记住这个"块"结构,它会贯穿整个系列;很快你会见到另一种块——tool_use,那就是 agent 的钥匙。

还有一件事,必须从此刻起牢记:模型自己不记事。 每次请求,它都只看你这一次发来的 messages,把这段对话当成全部上下文,据此推测"接下来最该回什么"——答完即忘,服务端不会替你保存任何对话。 所以想让它"记得"之前聊了什么,办法只有一个:每次请求,都把完整的对话历史塞进 messages 发过去。0.6 我们会亲手验证这一点。


0.4 模型的返回

你刚才在终端看到的那坨 JSON,精简掉次要字段后,骨架是这样(你跑出来的文案会不一样,但结构一定是这个):

{
  "role": "assistant",
  "content": [
    {
      "type": "text",
      "text": "你好小明,我是GLM大语言模型,由Z.ai开发。我可以回答问题、提供信息或进行对话交流,用我的知识来帮助你。\n\n有什么我能为你解答或协助的事情吗?"
    }
  ],
  "stop_reason": "end_turn",
  "usage": { "input_tokens": 14, "output_tokens": 43 }
}

被精简掉的字段暂时都用不上,不用纠结——等以后真要用到了,我们再回来认识它。

role 永远是 assistant——这是模型的发言。剩下三个字段各管一件事:说了什么content)、为什么停stop_reason)、花了多少usage)。

逐个说:

content:模型说的话。 眼熟吗?和你发出去的 messages 一样,是"内容块"数组,文本藏在 type: "text" 的块里。

stop_reason:模型为什么停笔。 end_turn 表示话说完了、自然收尾。如果你想看看别的值,可以把 max_tokens 改成 1 再跑:回复被拦腰截断——实测只吐出"你好"俩字就停了,stop_reason 变成 max_tokens,这就是"长度上限"生效的样子(看完记得改回来)。后面你还会遇到第三个值 tool_use:模型想调工具,停下来等你执行——那是整个 agent 循环的开关。

usage:花了多少 token。 input_tokens 是你发进去的输入,output_tokens 是它生成的回复。token 是钱,从第一行代码就建立这个体感。 尤其要记住:对话历史是单调增长的——每多聊一轮,messages 就长一截,而每一轮都要把整段历史重新发一遍,输入只会越滚越大。这就是为什么到了后面,"上下文压缩"会成为刚需。0.6 做第二次对话时,注意看输入涨了多少。


0.5 错误处理

网络请求会失败:key 填错、余额不足、网络不通。永远先检查请求成没成功,并且把服务端的错误信息打出来:

if (!res.ok) {
  console.error(`请求失败,HTTP ${res.status}`);
  console.error(await res.text()); // 服务端会告诉你哪里错了,比如"身份验证失败"
  process.exit(1);
}

小提示:如果你故意填一个错的 API key 去跑,会看到类似这样的输出:

请求失败,HTTP 401
{"error":{"message":"令牌已过期或验证不正确","type":"401"}}

这其实是个好消息:说明你的请求真的打到了模型服务,只是 API key 不对。地址和格式都没问题。

另外,免费模型在高峰期你很可能先遇到 HTTP 529overloaded_error,信息正是 0.2 那句"该模型当前访问量过大,请您稍后再试")——这不是你代码的问题,稍等重试,或按 0.2 的办法换个模型。


0.6 第二次对话:它还记得你吗?

0.3 结尾说"模型自己不记事",口说无凭,我们做个实验。第一次对话里你报过名字("我叫小明"),这个信息只在那次对话里出现过。现在发起第二次对话,问它你叫什么:把 hello.ts 里的 messages 改成:

messages: [
  {
    role: "user",
    content: [{ type: "text", text: "我叫什么名字?" }],
  },
],

跑一下。模型的回复每次生成都不一样——本文引用的都是我们实测某一次的输出,你跑出来的措辞、token 数会有出入,但行为一定一致:它答不上来。

我无法直接回答这个问题,因为我没有关于您个人隐私的信息。

如果您指的是在某个聊天记录或上下文中的名字,可能需要您提供更多的背景信息,或者您可以告诉我您希望我如何称呼您。

它没坏——你的名字只出现在第一次对话里,而这次请求的 messages 里没有那段对话,它根本无从知道,只好反过来请你重新自报家门。

想让它答上来,答案前面已经说了:把第一轮的一问一答原样拼回 messages,再接上新问题:

messages: [
  // 第一轮:你说的
  {
    role: "user",
    content: [{ type: "text", text: "你好,我叫小明。请用一句话介绍你自己。" }],
  },
  // 第一轮:它答的——从上次返回的 content 里取,注意 role 是 assistant
  {
    role: "assistant",
    content: [{ type: "text", text: "你好小明,我是GLM大语言模型,由Z.ai开发。我可以回答问题、提供信息或进行对话交流,用我的知识来帮助你。\n\n有什么我能为你解答或协助的事情吗?" }],
  },
  // 第二轮:新问题
  {
    role: "user",
    content: [{ type: "text", text: "我叫什么名字?" }],
  },
],

再跑一次,这回它答上来了——我们实测的这次,它答得特别干脆:

小明。

顺便兑现 0.4 埋的伏笔,看看这次的 usage

"usage": { "input_tokens": 62, "output_tokens": 3 }

第一次对话的输入是 14 个 token,这次变成了 62——多出来的就是你拼回去的那段历史。以后每多聊一轮,它还会再长一截。这就是 0.4 说的"单调增长",亲眼所见。

💡 补充 · 你的 input_tokens 反而特别小?那是缓存 多轮对话每轮都重发全量历史,开头那一大段服务端其实刚刚算过——所以厂商普遍做了提示词缓存:命中的部分不再重算、计费更便宜,并在 usage 里单独列出。如果你跑出来的是类似 "input_tokens": 2, "cache_read_input_tokens": 43 的结果,就是命中了缓存——把两个数加起来(2 + 43 = 45)才是这次的总输入。缓存能省钱,但省不掉"历史越来越长"本身,上下文管理依然是后面绕不开的话题。

注意中间那条 role: "assistant"——模型自己说过的话,也得由你拼回去,它不会留底。所谓"多轮对话",就是这个数组一轮一轮地变长。而"取出回复 → 拼回 messages → 再发一次"这个动作,要是让代码自动循环起来——那就是之后要讲的 agent 了。


0.7 收个尾:把这次请求抽成 callModel

收工前干最后一件事。从下一篇起,"调一次模型"这个动作会被翻来覆去地用,总不能每次都把这段 fetch 复制一遍——把它抽成一个函数,以后每一篇直接拿来用。

在项目里新建 llm.ts

/**
 * 一个最小的"模型客户端"——其实就是把 0.3 那次 fetch 抽成一个可复用的函数。
 */
const BASE_URL = process.env.LLM_BASE_URL ?? "https://open.bigmodel.cn/api/anthropic";
const MODEL = process.env.LLM_MODEL ?? "glm-4.7-flash";
const API_KEY = process.env.LLM_API_KEY ?? "";

export type Role = "user" | "assistant";

/**
 * 一个"内容块"。一条消息的 content 是若干这种块组成的数组。
 * 目前只有文本这一种块。0.3 说过你很快会见到新的块——到时候就往这里加
 */
export type ContentBlock = { type: "text"; text: string };

export type Message = { role: Role; content: ContentBlock[] };

/**
 * 模型回复里我们关心的三样:说了什么、为什么停、花了多少。
 */
export type AssistantResponse = {
  content: ContentBlock[];
  stop_reason: string;
  usage: { input_tokens: number; output_tokens: number };
};

/**
 * 调一次模型。给它完整的对话历史,拿回它这一轮的完整回复。
 */
export async function callModel(
  messages: Message[],
): Promise<AssistantResponse> {
  const res = await fetch(`${BASE_URL}/v1/messages`, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      "x-api-key": API_KEY,
    },
    body: JSON.stringify({
      model: MODEL,
      max_tokens: 1024, // 抬高一点:后面要说的话会越来越长
      messages,
    }),
  });

  if (!res.ok) {
    throw new Error(`模型请求失败 HTTP ${res.status}:${await res.text()}`);
  }
  return (await res.json()) as AssistantResponse;
}

主体就是 0.3 的那次 fetch 加 0.5 的错误检查,改动了三个地方:

  1. 把消息的类型手写了出来。 MessageContentBlockAssistantResponse 里没有任何新东西——就是你在 0.3 发出去、0.4 收回来的那些 JSON 的形状,现在给它们起了名字。以后写代码,编辑器会替你盯着这些形状。
  2. 错误处理从 exit 改成了 throw。 0.5 里直接把进程杀掉没问题——那是个一次性脚本。但一个给别人复用的函数,无权替调用方决定"程序要不要死":把错误抛出去,让调用的人自己接。
  3. max_tokens 抬到了 1024。 后面的对话会越来越长,256 不够用了。

然后把 hello.ts 改成用它:

import { callModel } from "./llm.js"; // 写 .js 是 ES 模块的规矩,tsx 会自动对应到 llm.ts

const data = await callModel([
  {
    role: "user",
    content: [{ type: "text", text: "你好,我叫小明。请用一句话介绍你自己。" }],
  },
]);
console.log(JSON.stringify(data, null, 2));

跑一下,输出和 0.3 的一模一样——逻辑没变,只是搬了个家。

为什么值得专门开一个文件?因为**"调模型"这件事,在一个 agent 项目里应该只有一个出口**。往后要往这次请求里加的东西还多着——新的字段、失败重试、流式输出……到时候全都只改 llm.ts 这一个文件,其他代码一行不动。成熟的 agent 产品也是这么组织代码的:几十万行的项目里,真正向模型发请求的地方,往往就只有一处。这个 callModel 以后会一篇一篇地"长大"——它长成什么样了,你就学到哪儿了。

💡 提醒 · key 不进代码、不进 git 注意 llm.ts 里的 key 依旧是 process.env.LLM_API_KEY ?? ""——和 0.3 一样,靠 .env 文件 + --env-file 喂进来,明文永远不落进代码,自然也不会被 git 记录。想连别的厂商,LLM_BASE_URLLLM_MODEL 也能一并写进 .env 覆盖,代码一行不用改。


小结

这一篇我们用普通的 HTTP POST 和大模型对上了话。

  1. 一次对话,就是一次 HTTP 请求:POST 到 /v1/messages,请求体里带上 modelmax_tokensmessages;回包看三样——说了什么(content)、为什么停(stop_reason)、花了多少(usage)。
  2. content 不是字符串,是"内容块"数组:你发的、它回的,都是这个结构。把这个"块"的形状焊在脑子里,它会贯穿整个系列。
  3. 模型自己不记事,历史全靠你拼:答完即忘,想让它"记得",就把完整对话历史——包括它自己说过的话——原样拼回 messages 重发。
  4. "调模型"只留一个出口:我们把这次请求抽成了 callModel、收进 llm.ts,往后每一篇都从这一个口子调模型,要加功能也只改这一个文件。

别小看这一小段代码。"取出回复 → 拼回 messages → 再发一次",这个你在 0.6 亲手做过的动作,就是一切的原点——让代码替你把它自动转起来,再允许模型在中途动手做事,一个 agent 就成形了。这正是这个系列接下来要做的事。

On this page