3.1 LLM 与模型 API
本章解决什么问题:第一、二部分教会了你读写 TypeScript 代码,但还没有出现任何「智能」。本章补上 Agent 的动力来源:大语言模型(LLM,Large Language Model)到底是什么、它以什么形式被调用、一次调用的输入输出长什么样。我们只建立一个黑盒观——不讲模型怎么训练出来的,只讲它作为一个可调用的组件,吃什么、吐什么、有哪些必须知道的脾气。
前置知识:2.2 interface、type 与函数类型(
interface与字面量联合类型)、2.5 异步编程:Promise 与 async/await(所有模型调用都是异步的)。学习目标:读完本章后你能
- 用一句话说清 LLM 的输入与输出,并说明「它不记得上一次对话」意味着什么;
- 说出 system、user、assistant 三种角色消息各自的用途,以及为什么需要区分角色;
- 读懂一次「补全(Completion)」请求的最小形状:模型名 + 消息数组,并说明响应里为什么还有
stopReason和用量字段;- 解释 API Key 与按 token 计费的关系,以及为什么「多聊几轮」会越来越贵;
- 说出为什么各家 Provider(模型服务提供方)的接口互不兼容,以及一个统一层能解决什么问题;
- 说明本书为什么用假模型(Fake Model)而不是真模型来学习,并在 Pi 源码里找到 Pi 自己的假模型。
建立直觉:把 LLM 当成一个黑盒
输入什么,输出什么
关于 LLM,这一章只要求你接受一个极简的描述:
LLM 是一个函数。输入是一串 token,输出是「下一个 token 最可能是什么」。
token 你可以暂时理解成「文本被切碎后的一小块」——可能是一个词、半个词、一个汉字或一个标点。文字进模型之前会先被切成 token 序列,出来之后再拼回文字。真实的切法有点反直觉(比如英文单词常被切成两三块),3.2 会专门讲,本章你只需要知道:模型眼里没有「字符串」,只有 token 编号组成的序列。
那模型明明能写出一整段话,怎么会只输出「下一个 token」?因为它被反复调用:算出下一个 token,把它接到输入末尾,再算下一个,如此循环,直到算出一个特殊的「到此为止」token,或者撞上你设定的长度上限。你看到的一整段回复,是这个循环跑了几百上千次的结果。
这个循环发生在模型服务内部,你通常感受不到——除非你用流式输出(Streaming),那时你会一块一块地看到它,这是 3.3 的主题。
两条必须先记住的脾气
第一,它是无状态的。 模型服务不会替你保存对话。你这次调用它,它看到的就是你这次发过去的全部内容;上一次发过什么,它一无所知。所以「多轮对话」这件事,本质上是由你的程序每次把完整历史重新发一遍造出来的假象。
没有这条认知会怎样?你会写出这样的代码:第一轮发「我叫小明」,第二轮只发「我叫什么?」,然后困惑于模型为什么答不上来。你也会写不出 Agent Loop(Agent 循环)——因为 Agent 的每一步都依赖「把上一步的结果带回给模型」,而这件事必须由你的代码显式完成。历史怎么攒、攒到放不下时怎么办,是 3.2 和上下文压缩(Context Compaction)要处理的问题。
第二,它是随机的。 同样的输入,两次调用的输出通常不完全一样。因为「预测下一个 token」给出的是一个概率分布,模型服务默认会从中采样,而不是每次都取最高分的那个。这对写文案是优点,对写测试是灾难:你没法用「回复必须等于某个字符串」这样的断言来验证程序。本章末尾的实验用假模型,正是为了先把这个变量摘掉。
模型 API:黑盒装进了 HTTP 服务
模型太大,跑不进你的笔记本,所以它被架在服务商的机器上,通过网络提供服务。这个网络接口就是模型 API。
它是一个普通的 HTTP 服务,没有任何神秘之处:你往某个网址 POST 一段 JSON,等几秒,收回一段 JSON。以 Anthropic 为例,请求发往 https://api.anthropic.com;OpenAI 是 https://api.openai.com/v1;Google 是 https://generativelanguage.googleapis.com/v1beta。这些地址在 Pi 源码里是写死的常量,6.2 Provider 与模型注册 会逐个拆开看。
请求里有什么:模型名 + 消息数组
一次最基本的请求,剥到只剩必需字段,是这样:
{
"model": "some-model-id",
"messages": [
{ "role": "system", "content": "你是一个只讲事实的助教。" },
{ "role": "user", "content": "什么是 token?" },
{ "role": "assistant", "content": "token 是文本被切碎后的一小块。" },
{ "role": "user", "content": "举个例子?" }
]
}两个字段撑起了全部:model 说明用哪个模型(同一家服务商往往提供十几个模型,快慢与价格不同),messages 是整段对话——注意不是「这一条新消息」,而是从头到现在的全部。这正是上面那条「无状态」的直接后果。
各家 API 的字段名和嵌套结构有出入(有的把系统提示词放在 messages 外面的独立字段里),但「模型名 + 一个按顺序排列的消息列表」这个骨架是共通的。
三种角色:system、user、assistant
消息不只有内容,还要标明谁说的。这就是 role 字段,最基本的三种:
- system:系统提示词(System Prompt)。由你(程序的开发者)写,用来交代模型的身份、规则、可用信息和输出格式要求。它通常排在最前面,且在整段对话中保持不变。Pi 的系统提示词很长,里面写着它是个编码助手、有哪些工具、该怎么用——6.7 会拆开看。
- user:终端用户说的话,也就是你在命令行界面(CLI,Command-Line Interface)里敲进去的那一行。
- assistant:模型自己上一轮说过的话。它由模型生成,但要由你的程序存下来,并在下一次请求时一起发回去。
为什么要区分角色?因为模型需要知道「这句话该被当成指令,还是被当成用户请求,还是当成我自己的发言」。全部混成一段纯文本,模型就分不清哪些是不可违背的规则、哪些是可以商量的请求,也无法在多轮对话里正确接话。角色标记还是安全边界的基础:来自 user 的内容天然可疑,不该被当作系统级指令执行。
第四种角色 toolResult(工具结果)会在 3.4 工具调用 出现,那时消息列表才真正变得有趣。
一次补全的最小形状
把请求发出去、拿回一条 assistant 消息,这个动作叫一次补全(Completion)——名字来自它的原始形态:给一段文本,模型把它「补完」。
不需要真模型也能把这个形状写清楚。下面是可运行的最小示例,存成 completion.ts,放在一个 package.json 里带 "type": "module" 的项目里(1.3 建的那种),用 tsx completion.ts 运行:
interface Message {
role: "system" | "user" | "assistant";
content: string;
}
// 假模型:一次补全 = 消息数组进,一条 assistant 消息出。
async function complete(messages: Message[]): Promise<Message> {
const last = messages[messages.length - 1];
return {
role: "assistant",
content: `我收到了 ${messages.length} 条消息,最后一条来自 ${last.role}。`,
};
}
const history: Message[] = [
{ role: "system", content: "你是一个只讲事实的助教。" },
{ role: "user", content: "什么是补全?" },
];
const reply = await complete(history);
console.log(reply.role, "|", reply.content);
// 想让模型「记住」这一轮,只能由调用方把回复写回历史。
history.push(reply);
history.push({ role: "user", content: "再说一遍?" });
console.log("下一次请求会发出", history.length, "条消息");真实输出:
assistant | 我收到了 2 条消息,最后一条来自 user。
下一次请求会发出 4 条消息请注意 complete 的签名:(messages: Message[]) => Promise<Message>。换成真模型,这个签名一个字都不用改——变的只是函数体里多了一次网络请求。整本书接下来的所有内容,都是围着这个签名往外长的。
真实响应还会多带两类元信息:
stopReason(停止原因):模型为什么停下来。正常说完是stop;撞到你设的输出上限是length(回复会在半句话中间断掉);要调用工具是toolUse。Pi 把这些统一定义成一个联合类型,取值为"pending" | "stop" | "length" | "toolUse" | "error" | "aborted"。usage(用量):这次消耗了多少 token,分输入和输出统计。它直接决定账单。
图 3.1-1 一次补全请求的往返
阅读顺序:从上到下,最后沿右侧那条线绕回去。三处值得停下来看:一是 REQ 这一步,每次请求都要把**整个**历史数组发出去,服务端不替你记;二是 GEN 上面那个自环,那是「预测下一个 token」的循环,它发生在服务端内部,你通常只看到最终结果;三是最下面绕回 REQ 的那条线,把 assistant 消息追加回历史是**你的程序**的责任,漏掉这一步,下一轮模型就像失忆一样。本章实验 `src/main.ts` 里的 `history.push(res.message)` 就是这一步。
API Key 与计费
模型 API 不是公共免费服务,所以每个请求都要证明「我是谁、算谁的账」。这就是 API Key:一串在服务商控制台生成的长字符串,请求时放在 HTTP 头里带上。服务端凭它认人、计费、限速。
计费单位是 token,而且输入和输出分开计价,输出通常贵好几倍。这带来一个初学者常忽略的效应:因为每轮都要把完整历史重发一遍,聊得越久,每次请求的输入 token 越多,单次成本也越高——把整段对话累加起来,总花费的增长远快于轮数的增长。缓解办法有两条,都会在后面出现:提示词缓存(把重复的前缀标记为可缓存,命中后按更低的价格计费,Pi 的用量结构里 cacheRead / cacheWrite 两个字段就是干这个的)和上下文压缩(把旧消息总结掉,见 3.7)。
各家接口互不兼容,所以需要一个统一层
到这里你可能觉得:既然形状都是「模型名 + 消息数组」,那换一家服务商应该很容易吧?
并不。各家 API 在细节上处处不同:网址不同;认证头的名字不同;系统提示词有的放在 messages 里、有的放在顶层独立字段;消息内容有的是纯字符串、有的必须是内容块数组;工具调用的表示方式不同;流式输出的事件格式不同;停止原因的字符串不同;用量字段的名字不同。从源码结构看,Pi 的 packages/ai/src/api/ 目录下并排放着 anthropic-messages.ts、openai-completions.ts、openai-responses.ts、google-generative-ai.ts、bedrock-converse-stream.ts 等十几个互相独立的实现文件——每一个都是一整套「怎么把统一的内部格式翻译成这家服务商听得懂的话,再把回答翻译回来」的适配代码。
没有统一层会怎样?你的 Agent 代码里会散布着「如果是这家就这样、如果是那家就那样」的分支;想支持一家新服务商,要改动整个代码库;想在同一段对话中途从一个模型切换到另一个模型,基本不可能。
图 3.1-2 统一层:把 N 家不同的接口收成一种
阅读顺序:从左往右再折回。关键在最左边那条线——上层 Agent 代码只和统一层打交道,**它不知道下面接的是哪一家**。最下面那条支路是本章的主角:faux 是一个不发网络请求的假 Provider,它接在和真 Provider 完全相同的位置上,所以上层代码换不换它都感觉不出来。这也是本书实验能零 API Key 进行的原理。
这个统一层在 Pi 里就是 packages/ai 这个 package(包)。官方 README 说明它提供「统一的 LLM API,带 Provider 集合、自动认证解析、token 与成本跟踪」。它在 monorepo(单仓库多包)里的位置见 4.3 monorepo 与 package 地图,内部实现见 6.1 pi-ai:统一的模型接口。现在你只需要记住:它存在的理由,就是上面这段「各家都不一样」。
本书的策略:先用 Fake Model
真模型有三样东西会妨碍学习:钱(每次调试都在花钱)、网络(慢,且会失败)、随机性(同样的输入结果不同,你分不清是自己写错了还是模型换了个说法)。
所以本书第三部分和第八部分的前几步一律使用假模型(Fake Model):一个和真模型有着完全相同的方法签名、但回复来自写死脚本的对象。它让你能把注意力放在真正要学的东西上——消息怎么攒、循环怎么转、工具怎么接、取消怎么做。等这些都稳了,8.6 接入真实模型 再把它换掉,而那时你会发现调用处一行都不用改。
Pi 中哪里用到了它
这不是教学专用的偷懒办法——Pi 自己也这么干。packages/ai 内置了一个名为 faux(法语「假的」)的 Provider,专门用于测试和演示。
fauxAssistantMessage配套的 registerFauxProvider() 把这个假 Provider 注册进模型系统,然后用 setResponses() 塞进一串预先写好的回复,按调用顺序一条条吐出来。Pi 的测试就是这样跑完整条 Agent 流程的:
createRuntimeHost从源码结构看,faux 的实现比你想象的讲究:它会按「4 个字符约 1 个 token」估算用量、模拟提示词缓存的命中、按设定的每秒 token 数把回复切成小块延时吐出来模拟流式输出,甚至能模拟中途取消。一个好的假模型,假的是内容,不是行为。 这一点值得记住——本章实验做的就是这件事的最小版本。
实践任务
labs/agent-concepts/01-fake-model目标:亲手实现本章讲的「一次补全」,并用它证明「模型是无状态的」。
步骤:
- 进入
labs/agent-concepts/01-fake-model,运行npm install(只装 tsx 与 typescript,不需要 API Key)。 - 运行
npm start。 - 逐段对照输出与
README.md里的讲解。
预期现象:三轮对话中,「发出 N 条消息」依次是 2 → 4 → 6,每轮增加两条(一条 user、一条 assistant);「无状态演示」里同一个模型实例被调用两次,只因为发过去的消息数组不同,回答就不同;最后一段把输出上限压到 4 个 token,回复在半句话中间断掉,stopReason 变成 length。
如何判断成功:npm start 的输出与 expected-output.txt 逐字一致(假模型没有随机性,也不打印时间戳,所以必然可复现)。
常见错误:把 history.push(res.message) 那一行删掉再运行——你会看到消息数变成 2 → 3 → 4,模型每轮都「看不见」自己上一轮说了什么。这正是真实场景中最常见的一类 bug。
对应源码:packages/ai/src/providers/faux.ts(Pi 自己的假 Provider)。
本章小结
- LLM 可以当成一个函数:输入一串 token,输出下一个 token;连续说出一段话,是这个预测循环跑了很多次的结果。
- 它无状态:每次请求都要把完整对话历史重发一遍,「记忆」是你的程序造出来的。
- 它有随机性:同样输入可能得到不同输出,这让基于真模型的测试很难写。
- 模型 API 就是一个普通 HTTP 服务:POST 一段含
model和messages的 JSON,收回一条 assistant 消息加上stopReason与usage。 - 消息用
role区分 system(规则)、user(用户输入)、assistant(模型自己的上一轮发言)。 - 计费按 token 算,输入输出分开计价;历史越长,每轮越贵。API Key 绝不能进代码库。
- 各家 Provider 接口互不兼容,所以需要一个统一层——这正是 Pi 里
packages/ai存在的理由。 - 学习阶段用假模型能同时摘掉钱、网络、随机性三个干扰变量;Pi 自己的测试也依赖内置的 faux Provider。
关键术语:大语言模型(LLM,Large Language Model)、token、补全(Completion)、模型 API、角色(system / user / assistant)、系统提示词(System Prompt)、Provider(模型服务提供方)、API Key、停止原因(stopReason)、用量(usage)、假模型(Fake Model)。完整定义见术语表。
关键源码索引:
packages/ai/src/providers/faux.ts— 内置假 Provider,fauxAssistantMessage在第 73–94 行。packages/coding-agent/test/agent-session-runtime-events.test.ts— 用假 Provider 跑完整会话的真实测试,第 42–46 行。packages/ai/src/api/— 每家服务商一套的接口适配实现。
自测问题:
- 你在第三轮对话时给模型发了一个消息数组。数组里应该有几条消息?分别是哪些角色?如果只发最后一条 user 消息会发生什么?
- 一次响应里的
stopReason为什么不能只有「成功」和「失败」两种取值?至少举出两种「成功但没说完」的情况。 - 假设你的对话已经进行了 20 轮。为什么第 20 轮的输入 token 数远大于第 1 轮?有哪两类办法可以缓解?
- 如果 Pi 没有
packages/ai这个统一层,想新增支持一家服务商需要改动哪些地方?(用「从源码结构看」的方式回答即可,不必查证细节。)
下一章预告:3.2 消息、上下文与 token 会把本章一笔带过的两件事说透——token 到底怎么切、为什么一个汉字可能不止一个 token;以及「上下文窗口(Context Window)」这个硬上限是什么,历史攒到装不下时会发生什么。本章实验里那个粗糙的 token 估算函数,会在那里被换成真正的算法。