最底层:用 HTTP 裸调 LLM

最底层:用 HTTP 裸调 LLM

第 01 章我们建立了 Agent 的最小心智模型:模型 + 环境(工具)+ 控制循环。从这一章开始,我们把零件一个一个造出来。第一个零件也是最底层的零件:如何用一个普通 HTTP 请求,让大模型产生一段文本。

这一章不写任何 Agent 逻辑,只做一件事:把「调用 LLM」这件看似普通的事,从头到尾解剖开:请求里有哪些字段、响应里有哪些字段、失败时会发生什么、以及你应该怎么面对失败。这是后面所有章节的地基。

本章目标

读完本章并做完配套练习后,你应该能够:

开场反例:一个调不明白的「黑盒」

假设你嫌手写麻烦,直接用官方 SDK。代码非常「干净」:

import OpenAI from 'openai';
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
const reply = await client.chat.completions.create({
  model: 'gpt-4o',
  messages: [{ role: 'user', content: '你好' }],
});
console.log(reply.choices[0].message.content);

看起来一切正常。直到某天它出了奇怪的问题:

这时候你面对的是一个黑盒:SDK 替你发了 HTTP 请求、做了 JSON 序列化、可能还内置了重试,但你看不到请求长什么样、响应长什么样。你只能查文档、搜 issue、碰运气。

本教程坚持「先裸调一次」,理由就在这。裸调之后你会知道:

先看清 HTTP 层发生的一切,再决定要不要用 SDK——这就是 from-scratch 的顺序。

解剖请求:POST /v1/chat/completions

大模型的「对话」本质上是一个很普通的 HTTP API。OpenAI 风格的接口是 POST /v1/chat/completions,请求体是一个 JSON 对象。我们用 curl 看一眼它长什么样:

curl https://api.openai.com/v1/chat/completions \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $OPENAI_API_KEY" \
  -d '{
    "model": "gpt-4o",
    "messages": [
      { "role": "system",    "content": "You are a concise assistant." },
      { "role": "user",      "content": "解释一下什么是 Agent。" }
    ],
    "temperature": 0.7,
    "top_p": 1,
    "max_tokens": 200,
    "seed": 42
  }'

用原生 fetch 写出来,就是同样的东西(Node ≥ 20 内置,不需要任何库):

const res = await fetch('https://api.openai.com/v1/chat/completions', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
  },
  body: JSON.stringify({
    model: 'gpt-4o',
    messages: [
      { role: 'system', content: 'You are a concise assistant.' },
      { role: 'user', content: '解释一下什么是 Agent。' },
    ],
    temperature: 0.7,
    top_p: 1,
    max_tokens: 200,
    seed: 42,
  }),
});

逐个字段看(这些字段名你会在所有 OpenAI 兼容服务里见到):

字段作用
model必填。用哪个模型(gpt-4oclaude-* 或你的兼容服务名)。不同模型能力、价格、上下文长度都不同
messages必填。完整对话历史,一个有序数组。这是本章最重要的字段,下一节专门讲
temperature采样温度(默认 1)。越低越确定、越高越发散
top_p核采样(默认 1)。只从累计概率达 top_p 的候选词里采样。官方建议 temperaturetop_p 二选一调整,不要同时大幅修改
max_tokens输出的**最大 [[token
seed尽量让多次采样结果稳定(最佳实践:配 temperature=1)。注意是「尽力而为」,不是绝对确定性

为什么要记住这些字段名?因为第 05 章的工具调用、第 13 章的自定义 Provider 抽象,都会围绕这同一个请求结构做文章。底子打牢,后面全是顺水推舟。

解剖响应:choices / finish_reason / usage

请求发出去,服务端返回一个 JSON。真实响应大概长这样(字段已精简):

{
  "id": "chatcmpl-8x4...",
  "object": "chat.completion",
  "created": 1723456789,
  "model": "gpt-4o",
  "choices": [
    {
      "index": 0,
      "message": {
        "role": "assistant",
        "content": "Agent 是一个能自主执行任务的系统……"
      },
      "finish_reason": "stop"
    }
  ],
  "usage": {
    "prompt_tokens": 24,
    "completion_tokens": 31,
    "total_tokens": 55
  }
}

关键字段逐个拆解:

字段含义
id本次补全的唯一标识,调试与对账(找客服、查用量)时有用
model实际处理的模型(可能与请求的 model 不同,比如别名被解析)
choices结果数组。常规请求只有一个元素;未来第 03 章讲流式、第 05 章讲多候选时,这个数组会变大
choices[0].message模型生成的完整消息。role 固定为 assistantcontent 是你最关心的文本
choices[0].finish_reason为什么停止生成。stop = 模型认为回答完毕;length = 撞到 max_tokens 被截断(常见 bug 来源);tool_calls = 想调用工具(第 05 章);还有 content_filter
usagetoken 用量明细prompt_tokens(输入)/ completion_tokens(输出)/ total_tokens。这是你算钱的唯一依据

客户端解析只需两行:

const data = await res.json();
console.log(data.choices[0].message.content, data.choices[0].finish_reason, data.usage);

messages 与 role:对话状态全在客户端

messages 是理解 Agent 的最关键的一个字段。它是这么规定的:你把完整的对话历史作为一个数组传上去,模型基于整个历史生成下一条。

数组里的每条消息必须有 role

多轮拼接长这样:

const messages = [
  { role: 'system', content: 'You are a helpful assistant.' },
  { role: 'user', content: '北京天气怎么样?' },
  { role: 'assistant', content: '我可以帮你查,稍等。' },
  { role: 'user', content: '那上海呢?' },
];

这里藏着一个对 Agent 极其重要的认知:

对话状态(历史)完全由客户端维护,服务端是无状态的。

每次请求,你都要把全部历史重新发给服务端。服务端不记得「上次聊到哪了」。所以:

想清楚这一点,你会明白:Agent 的「记忆」本质上是一份由你的代码保管、每次全量重发的 消息数组。第 09 章我们把它落进数据库,第 14 章用它实现断线恢复——但现在,请先把它刻进脑子:服务端无状态,状态在你手里。

采样参数实战:temperature 与 top_p 的直觉

模型生成下一个词时,会为候选词表算一个概率分布。temperaturetop_p 都影响从这个分布里怎么采样

直觉记忆法:Temperature(采样温度) 管「发不发热」,top_p 管「候选池多大」。

实战中你会有一种困惑:temperature=0.1 为什么还是「每次输出都不一样」?因为:

  1. 采样本身是随机的,低温度只是大概率选高概率词,不是绝对确定(除非 seed 生效);
  2. 你比较的两次输出之间,可能有其他随机源(比如模型做了并发请求、prompt 有细微差别)。

对比实验放这里,同一句 prompt 两种温度各调一次(code/ch02-http-llm/02-sampling.mjs,mock server 会返回脚本化的差异文案):

node 02-sampling.mjs
# temperature = 0.0 : [mock] received: ...
# temperature = 1.2 : [mock-high-temp] received: ...(更发散)

真实模型上你通常能看到:低温的两次输出几乎一样,高温的两次输出差别明显。需要确定性时用低温度 + seed,需要创造性时用高温度

失败面:状态码、超时与指数退避

网络请求必失败,这不是「会不会」而是「什么时候」。LLM API 的失败分为三类:

1. 状态码表(响应已返回,但不可用)

状态码含义能重试吗
400请求参数错误(模型名拼错、messagesrole……)否,改了也没用,重试只会更糟
401鉴权失败(key 错、过期、没带 Authorization否,先修配置
429限流(每秒/每分钟请求超配额),但必须退避等待
5xx(500/502/503/504)服务端过载或临时故障是,通常等一会儿就好

区分它们只有一句话:改你的代码或配置能修好的(400/401),别重试;修不好、等一等就能好的(429/5xx),才重试。

2. 超时(请求根本没返回)

API 再快也可能卡住。裸 fetch 默认没有超时——它可能挂到你怀疑人生。必须自己设。Node ≥ 20 一行搞定:

const res = await fetch(ENDPOINT, {
  method: 'POST',
  headers: { 'content-type': 'application/json' },
  signal: AbortSignal.timeout(5000), // 5 秒超时,超时即中止
  body: JSON.stringify({ model: 'gpt-4o', messages }),
});

超时会以「请求被 abort」的形式抛错,让你不至于无限等下去。每个 LLM 调用都该带超时——尤其是后面做 Agent Loop 时,一个卡死的请求会卡死整个循环。

3. 指数退避重试(为什么 429 必须退避)

429 不是「失败」,是「你太快了」。如果不退避就重试,你会继续撞 429,甚至把限流窗口搞得更糟。正确的姿势是指数退避(exponential backoff:重试间隔随次数指数增长,并加一点随机抖动(jitter)防止多个客户端同步重试造成「重试风暴」:

const BASE_DELAY_MS = 100;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
  const res = await fetch(ENDPOINT, { method: 'POST', ... });
  if (res.ok) return await res.json();
  if (attempt === maxRetries) throw new Error(`failed after ${maxRetries} retries`);
  const delay = BASE_DELAY_MS * 2 ** attempt + Math.random() * 50; // 100, 200, 400, ... + jitter
  await new Promise((r) => setTimeout(r, delay));
}

「429 → 退避 → 成功」的完整日志演示见 code/ch02-http-llm/03-retry.mjs

token 用量与成本意识

usage 字段不只是拿来打印的,它是你的成本仪表盘。直觉上:

第 18 章我们会做完整的成本核算;现在你只需养成一个习惯:每次调用都读一眼 usage,你的 agent 贵不贵、慢不慢,答案全在数字里。

常见坑

坑一:历史消息越攒越长,从不控制。 每轮对话都把全部历史原样重发,轮数一多,请求又慢又贵,最后撞上上下文窗口上限直接报错。治理办法是第 08 章的主题(截断/压缩/滑动窗口),但现在你就要有这个意识:发出去的每一条历史,都是钱和时间。

坑二:429 不重试,或者傻等硬刷。 有人遇到 429 直接放弃(体验极差),有人不设间隔疯狂重试(把自己打回限流)。正确的是指数退避 + jitter,并尊重 Retry-After

坑三:不设超时。fetch 默认没有超时,一个卡死的请求可能让整个 Agent 卡死。每个调用都用 AbortSignal.timeout 兜底。

坑四:把 400/401 当可重试。 参数错了、key 错了,重试一万次也不会好。先分清「改改能好」和「等等能好」,再决定要不要重试。

小结

下一章(ch03)我们让响应流式地到达,把「等 10 秒拿到全文」变成「边生成边显示」:手写 SSE 客户端,逐 chunk 解析 [DONE] 与中断。那是在线对话体验从「能用」到「好用」的分水岭。

延伸阅读

完成阅读,去做练习 →