最底层:用 HTTP 裸调 LLM
最底层:用 HTTP 裸调 LLM
第 01 章我们建立了 Agent 的最小心智模型:模型 + 环境(工具)+ 控制循环。从这一章开始,我们把零件一个一个造出来。第一个零件也是最底层的零件:如何用一个普通 HTTP 请求,让大模型产生一段文本。
这一章不写任何 Agent 逻辑,只做一件事:把「调用 LLM」这件看似普通的事,从头到尾解剖开:请求里有哪些字段、响应里有哪些字段、失败时会发生什么、以及你应该怎么面对失败。这是后面所有章节的地基。
本章目标
读完本章并做完配套练习后,你应该能够:
- 用
curl和原生fetch各裸调一次 LLM,不发懵; - 逐字段解释 chat/completions 请求体(
model/messages/temperature/top_p/max_tokens/seed)与响应体(choices/message/finish_reason/usage); - 说清
system/user/assistant三种 role 的语义,以及为什么「对话状态全在客户端、服务端无状态」是理解 Agent 的关键; - 直觉理解
temperature与top_p对输出的影响; - 面对 400 / 401 / 429 / 5xx 分别应该怎么处理,以及为什么 429 必须指数退避重试;
- 为请求设置超时,并读懂
usage字段做成本意识。
开场反例:一个调不明白的「黑盒」
假设你嫌手写麻烦,直接用官方 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);
看起来一切正常。直到某天它出了奇怪的问题:
- 偶尔超时:请求 30 秒才返回,或直接抛超时错误;
- 偶尔 429:「rate limit exceeded」,但你不知道它内部重试了没有、退避了多久;
- 参数没生效:你在
create()里传了temperature: 0.1,可输出看起来完全没变。
这时候你面对的是一个黑盒:SDK 替你发了 HTTP 请求、做了 JSON 序列化、可能还内置了重试,但你看不到请求长什么样、响应长什么样。你只能查文档、搜 issue、碰运气。
本教程坚持「先裸调一次」,理由就在这。裸调之后你会知道:
- SDK 的
create()背后就是一个POST https://api.openai.com/v1/chat/completions; - 它发的请求体是 JSON,字段名是
messages、temperature、top_p……而不是topP; temperature: 0.1为什么「看起来没生效」:因为采样是随机的,单次调用本就有方差(本章「采样参数实战」会讲直觉);- 超时和 429 是你的代码必须处理的事,不是 SDK 魔法能替你解决的。
先看清 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-4o、claude-* 或你的兼容服务名)。不同模型能力、价格、上下文长度都不同 |
messages | 必填。完整对话历史,一个有序数组。这是本章最重要的字段,下一节专门讲 |
temperature | 采样温度(默认 1)。越低越确定、越高越发散 |
top_p | 核采样(默认 1)。只从累计概率达 top_p 的候选词里采样。官方建议 temperature 与 top_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 固定为 assistant,content 是你最关心的文本 |
choices[0].finish_reason | 为什么停止生成。stop = 模型认为回答完毕;length = 撞到 max_tokens 被截断(常见 bug 来源);tool_calls = 想调用工具(第 05 章);还有 content_filter 等 |
usage | token 用量明细: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:
system:系统指令,设定模型的角色、行为边界、输出格式。可选但强烈推荐,它是「把模型调教成你的 Agent」的主阵地。user:用户说的话(或工具返回的结果,第 05 章会见)。assistant:模型之前生成的回复。注意:多轮对话时,你要把模型上一轮的回复原样放回messages里。
多轮拼接长这样:
const messages = [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: '北京天气怎么样?' },
{ role: 'assistant', content: '我可以帮你查,稍等。' },
{ role: 'user', content: '那上海呢?' },
];
这里藏着一个对 Agent 极其重要的认知:
对话状态(历史)完全由客户端维护,服务端是无状态的。
每次请求,你都要把全部历史重新发给服务端。服务端不记得「上次聊到哪了」。所以:
- 你漏发历史 → 模型「失忆」;
- 你把历史越攒越长 → 请求越来越慢、越来越贵,直到撞上模型的上下文窗口上限(第 08 章专门讲怎么治理);
- 两个并发请求共享同一份
messages时,谁先谁后会影响模型接的话——状态的顺序就是消息的顺序。
想清楚这一点,你会明白:Agent 的「记忆」本质上是一份由你的代码保管、每次全量重发的 消息数组。第 09 章我们把它落进数据库,第 14 章用它实现断线恢复——但现在,请先把它刻进脑子:服务端无状态,状态在你手里。
采样参数实战:temperature 与 top_p 的直觉
模型生成下一个词时,会为候选词表算一个概率分布。temperature 和 top_p 都影响从这个分布里怎么采样:
temperature(温度):缩放分布。温度低 → 分布变「尖」,几乎总选概率最高的词,输出稳定、保守;温度高 → 分布变「平」,容易选到「没那么顺理成章」的词,输出多样、有创造性、但也更爱跑偏。top_p(核采样):只在累计概率达到top_p的最可能候选词里采样。top_p=0.1几乎只从最可能的几个词里挑,top_p=1则不限制。
直觉记忆法:Temperature(采样温度) 管「发不发热」,top_p 管「候选池多大」。
实战中你会有一种困惑:temperature=0.1 为什么还是「每次输出都不一样」?因为:
- 采样本身是随机的,低温度只是大概率选高概率词,不是绝对确定(除非
seed生效); - 你比较的两次输出之间,可能有其他随机源(比如模型做了并发请求、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 | 请求参数错误(模型名拼错、messages 缺 role……) | 否,改了也没用,重试只会更糟 |
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));
}
- 为什么指数:等得越来越久,给服务端喘气的空间,比固定间隔更稳;
- 为什么加 jitter:几十个客户端同时失败、同时重试、间隔还一样,会在同一刻一起打进来,等于人为制造一波新的 429;
- 响应里的
Retry-After头是服务端给你的明确指示,优先尊重它。
「429 → 退避 → 成功」的完整日志演示见 code/ch02-http-llm/03-retry.mjs。
token 用量与成本意识
usage 字段不只是拿来打印的,它是你的成本仪表盘。直觉上:
prompt_tokens是输入:每一次请求,整个messages数组都会被完整计费,所以历史越长,每次调用越贵,这个成本会随着对话轮数线性增长;completion_tokens是输出:模型的回答越长越贵;- 不同模型的输入/输出单价不一样,而且普遍是输出比输入贵(常见是 3~5 倍)。所以「让模型输出短一点」(
max_tokens设小、prompt 里要求简短)是真金白银的省钱。
第 18 章我们会做完整的成本核算;现在你只需养成一个习惯:每次调用都读一眼 usage,你的 agent 贵不贵、慢不慢,答案全在数字里。
常见坑
坑一:历史消息越攒越长,从不控制。 每轮对话都把全部历史原样重发,轮数一多,请求又慢又贵,最后撞上上下文窗口上限直接报错。治理办法是第 08 章的主题(截断/压缩/滑动窗口),但现在你就要有这个意识:发出去的每一条历史,都是钱和时间。
坑二:429 不重试,或者傻等硬刷。 有人遇到 429 直接放弃(体验极差),有人不设间隔疯狂重试(把自己打回限流)。正确的是指数退避 + jitter,并尊重 Retry-After。
坑三:不设超时。 裸 fetch 默认没有超时,一个卡死的请求可能让整个 Agent 卡死。每个调用都用 AbortSignal.timeout 兜底。
坑四:把 400/401 当可重试。 参数错了、key 错了,重试一万次也不会好。先分清「改改能好」和「等等能好」,再决定要不要重试。
小结
- LLM 调用 = 一个普通 HTTP POST。请求体逐字段是
model/messages/temperature/top_p/max_tokens/seed; - 响应体重点是
choices[0].message.content(文本)、finish_reason(stop=完、length=被截断)与usage(token 账单); - 服务端无状态,对话历史全在客户端,每次全量重发,这是理解 Agent「记忆」的关键;
temperature管发散程度,top_p管候选池,需要确定性就用低温度 +seed;- 失败三分类:400/401 改代码、429/5xx 指数退避重试(+jitter)、超时用
AbortSignal.timeout兜底; - 每次读
usage,养成成本意识。
下一章(ch03)我们让响应流式地到达,把「等 10 秒拿到全文」变成「边生成边显示」:手写 SSE 客户端,逐 chunk 解析 [DONE] 与中断。那是在线对话体验从「能用」到「好用」的分水岭。
延伸阅读
- OpenAI Chat Completions API reference:请求/响应字段的权威文档,本章内容都来自它。
- Anthropic Messages API:另一大厂商的消息接口,字段名不同(
max_tokens必填、system独立成顶层字段),第 04 章做 Provider 抽象时我们会对比。 - HTTP 状态码(MDN):
429、5xx等状态码的语义与规范来源。