§1.1LLM API 的本质:一个无状态的请求-响应服务
先给一句话类比:LLM API 就是一个无状态的 HTTP RPC——你每次调用都要把完整的对话历史放进请求体,服务端算完就忘,不保存任何会话状态。把它想象成一个纯函数:
Response reply(List<Message> messages, List<Tool> tools)输出只取决于入参。第二次请求如果不带历史,模型对刚才的对话一无所知。
这与 Java 后端熟悉的 Servlet 世界恰好相反:Tomcat 有 HttpSession,你可以把状态放在服务端,浏览器下次带着 Cookie 回来就能续上。而主流 LLM API(OpenAI、Anthropic、Google 等)没有等价物——请求里的 messages 数组就是会话的全部。所谓「模型记得你」,是客户端在每一轮把历史重新拼接、重新发送制造出来的错觉。Pi 官方文档对这一点的表述非常工程化:提交的消息进入活动分支(active branch),Pi「从系统提示词、活动分支、可用工具与模型设置构建一次模型请求」,然后发给选定的 Provider——每一次请求都是从当前状态完整构造出来的。
由此推出几个直接影响后续源码阅读的结论:
- 会话状态住在客户端:Pi 把会话持久化为 JSONL 文件(一棵消息树),而不是依赖服务端。第 10 讲会展开。
- 每轮成本随历史线性增长:历史越长,输入 Token 越多,费用与延迟越高——这就是 §1.5 上下文管理的根源。
- 请求之间可以替换模型:既然每次都全量重发,理论上每轮都能换模型、换系统提示词,运行时拥有完全的控制权。
把 LLM API 想成一个刻意不设 HttpSession 的 @RestController:它只认请求体。区别在于,普通无状态服务的状态放在数据库,而 Agent 的「会话状态」上移到了请求本身——messages 数组既是输入参数,也是事实上的会话存储。你在 Spring 里做过的「每次带全量参数 vs Cookie 续会话」权衡,在这里被 Provider 一刀切:永远选前者。
§1.2消息角色:对话的四类砖块
既然上下文就是一个消息数组,那么数组里能放什么?Pi 底层库 pi-ai 定义了四种角色(role),它们是所有 Provider 统一后的规范形态:
| 角色 | 谁产生 | 内容形态 | Java 后端类比 |
|---|---|---|---|
system |
运行时注入,用户一般看不到 | 系统提示词(指令、角色设定)。在 Pi 中,系统消息还携带工具声明的变更(toolsAdded / toolsRemoved) |
应用启动配置、全局 Filter 注入的公共上下文 |
user |
用户或应用代码 | 文本,可混入图片 | 一次 HTTP 请求的入参 |
assistant |
模型 | content 是块数组:文本块、思考块、toolCall 块 |
RPC 的响应对象(可同时含「说明」与「下一步动作指令」) |
toolResult |
运行时(不是模型!) | 按 toolCallId 对应某次调用,含执行结果内容与 isError 标志 |
异步任务回调结果写回请求上下文 |
两个最容易踩的细节:
toolCall不是一种消息角色,它是assistant消息content数组里的一个块,类型标记为{ type: "toolCall", id, name, arguments }。模型「调用工具」,物理上只是在回复消息里夹带了一份结构化的调用意图。toolResult由你的运行时产生。模型永远看不到工具的真实执行过程,它看到的只是回填后的结果消息——这也意味着运行时可以决定回填什么:脱敏、截断、注入错误,都在你的代码掌控之中。
下面是 pi-ai 中的真实类型定义(节选),注意四种角色组成联合类型 Message:
export interface UserMessage {
role: "user";
content: string | (TextContent | ImageContent)[];
timestamp: number; // Unix 时间戳(毫秒)
}
export interface AssistantMessage {
role: "assistant";
// 文本块 / 思考块 / 工具调用块 混排在同一个 content 数组里
content: (TextContent | ThinkingContent | ToolCall)[];
// …api、provider、model、usage、stopReason 等元数据字段
}
export type ToolResultMessage<TDetails = JsonValue> = /* 条件类型,展开后核心字段: */ {
role: "toolResult";
toolCallId: string; // 与 assistant 里 toolCall.id 一一配对
toolName: string;
content: (TextContent | ImageContent)[]; // 支持文本与图片
details?: JsonRepresentation<TDetails>; // 给 UI/审计看的结构化细节
isError: boolean; // 工具失败时为 true,模型会看到错误
timestamp: number;
};
export type Message = SystemMessage | UserMessage | AssistantMessage | ToolResultMessage;
SystemMessage(含 sections、toolsAdded、toolsRemoved)与 ToolCall。此处为教学节选,建议打开原文件对照。system 消息很像你在 Servlet Filter / Spring Interceptor 里往 RequestContext 塞的全局参数:每个「业务请求」(模型调用)开始前统一注入,业务代码(模型)只能读不能改。Pi 进一步把它用活了——系统消息可以出现在会话中段,携带「工具增减」的增量声明,像一条只追加的配置变更日志。
§1.3工具调用闭环:模型出参数,运行时出执行
一句话类比:工具调用(Tool Call)是「模型填表、框架盖章」。模型负责决定「调用哪个工具、传什么 JSON 参数」,真正的执行永远发生在你的进程里。整个闭环分四步:
- 模型发出意图:assistant 消息的
content里出现toolCall块——工具名加一份 JSON 参数(参数由模型按你提供的 JSON Schema 生成)。 - 运行时校验并执行:框架按
name查找注册的工具(Pi 中是AgentTool),先用validateToolArguments做 Schema 校验,再调用工具的execute函数——这一步就是普通的后端代码:读文件、查库、发 HTTP 请求都行。 - 结果包成消息回填:执行结果(或错误)被包装成
toolResult消息,带上对应的toolCallId,追加到消息数组末尾。 - 再次请求模型:框架带着「加了 toolResult 的完整历史」发起下一次 LLM 请求。模型读到执行结果,决定继续调用别的工具,还是给出最终文本回答。不再产生新的
toolCall时,闭环结束。
注意第 3 步的措辞:由谁产生 toolResult?是运行时,不是模型,也不是工具自己「发给」模型——工具只是同步或异步地返回一个结果对象,包装成消息、维护配对关系的是 Agent 框架。这是面试和读源码时的高频混淆点。
Pi 中一个工具的完整定义如下(来自 pi-agent-core 的 README 示例,节选):
import { obj, str } from "@earendil-works/pi-schema";
const readFileParameters = obj({
path: str({ description: "File path" }),
});
const readFileTool: AgentTool<typeof readFileParameters> = {
name: "read_file",
label: "Read File", // UI 展示用
description: "Read a file's contents",
parameters: readFileParameters, // JSON Schema,模型按它生成参数,框架按它校验
execute: async (toolCallId, params, signal, onUpdate) => {
const content = await fs.readFile(params.path, "utf-8");
return {
content: [{ type: "text", text: content }], // 回填给模型的正文
details: { path: params.path, size: content.length }, // 给 UI / 审计的细节
};
},
};
agent.state.tools = [readFileTool]; // 注册进 agent 上下文
executionMode(sequential / parallel)与 onUpdate 进度流。示例保持官方 README 原样,仅加中文注释。关于错误处理,README 里有一句明确约定,值得抄进笔记:
「Throw an error when a tool fails. Do not return error messages as content.」(工具失败时抛出异常,不要把错误文本当正常 content 返回。)抛出的异常会被 Agent 捕获,并以
isError: true的toolResult报告给模型。
为什么约定「抛异常」而不是「返回错误字符串」?因为返回正常 content 意味着「执行成功」,模型会把它当作工具输出继续推理;而 isError: true 让模型明确知道调用失败,可以选择修正参数重试或换方案。这是把错误语义从数据里分离出来——和你写 RPC 接口时区分「业务返回值」与「异常」是同一个工程判断。
AgentTool 很像一段 SPI:接口契约由 JSON Schema 描述,运行时按 name 路由到实现,参数绑定相当于带校验的反射调用。而 Pi 的 beforeToolCall / afterToolCall 钩子就是 AOP 环绕通知——权限检查、结果改写、审计日志都挂在切面上,工具本体保持纯粹。第 7 讲会深入工具注册表与校验管线。
§1.4Agent Loop:所有 Agent 框架的心脏
把 §1.1 到 §1.3 连起来,就得到了 Agent 世界最重要的结构——Agent Loop(代理循环):请求模型 → 执行工具 → 结果回填 → 再请求模型,如此往复,直到模型不再需要工具。官方文档 how-pi-works.md 用一段话精确定义了 turn:
「Provider 流式返回一个 assistant 回复,其中可以包含文本和工具调用。Pi 记录该回复、执行每个工具调用、记录结果——这就完成了一个 turn。如果工具结果或排队中的消息需要再次请求模型,Pi 开启下一个 turn;否则本次 run 结束。」
于是有了本讲最需要背下来的两个定义:
- turn(轮)= 一次模型请求 + 它触发的全部工具执行 + 结果回填。一次用户提问触发的可能是一个 turn(纯聊天),也可能是 N 个 turn(边查边答)。
- run(一次运行)= 若干个 turn 的序列,从
agent_start事件到agent_end事件。
循环中还有两个外部干预点,都来自 how-pi-works.md 的原文:
- steering(中途插话):「Steering messages enter after the current assistant turn」——用户在模型忙碌时输入的消息,会在当前 assistant turn 结束后进入上下文,参与下一次请求,及时改变模型方向。
- abort(取消):「Aborting stops the current run and returns queued messages to the editor」——中断当前 run,排队消息退回输入框。源码层面就是一路传递
AbortSignal,流式读取与工具执行都会响应它。
packages/agent/README.md 的「With Tool Calls」事件序列。现在打开循环的真实源码。下面节选自 runLoop——agentLoop() 与 agentLoopContinue() 共享的主循环,保留双层 while 骨架:
// Outer loop: continues when queued follow-up messages arrive after agent would stop
while (true) {
let hasMoreToolCalls = true;
// Inner loop: process tool calls and steering messages
while (hasMoreToolCalls || pendingMessages.length > 0) {
// …prepareNextTurn 快照、steering 消息入列、prepareRequest 钩子…
// Stream assistant response(一次模型请求 = 一个 turn 的起点)
const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);
newMessages.push(message);
if (message.stopReason === "error" || message.stopReason === "aborted") {
// …记录 lastCompletedTurn、发 turn_end / agent_end 后 return…
}
// Check for tool calls:从 assistant 消息 content 中过滤 toolCall 块
const toolCalls = message.content.filter((c) => c.type === "toolCall");
const toolResults: ToolResultMessage[] = [];
hasMoreToolCalls = false;
if (toolCalls.length > 0) {
const executedToolBatch =
message.stopReason === "length"
? await failToolCallsFromTruncatedMessage(toolCalls, emit)
: await executeToolCalls(currentContext, message, config, signal, emit);
toolResults.push(...executedToolBatch.messages);
hasMoreToolCalls = !executedToolBatch.terminate; // 有工具结果 → 继续循环
for (const result of toolResults) {
currentContext.messages.push(result); // toolResult 回填进上下文
newMessages.push(result);
}
}
// …finishTurn 钩子、emit turn_end、拉取新的 steering 消息…
}
// Agent would stop here. Check for follow-up messages.
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) {
pendingMessages = followUpMessages; // 排队消息到达 → 外层循环再转一圈
continue;
}
break; // 没有新消息,run 结束
}
await emit({ type: "agent_end", messages: newMessages });
runLoop 的结构节选(省略号处有删节,部分行尾追加中文说明注释)。内层循环的持续条件是「还有工具结果要跟进,或收到 steering 消息」;外层循环负责 follow-up 消息触发的续跑。逐行精读留到第 6 讲。对照图 2 读这段代码,三件事一目了然:hasMoreToolCalls 由「本批工具是否全部 terminate」决定,回填发生在 currentContext.messages.push(result);stopReason 为 aborted 时立即收尾,对应 abort 语义;外层循环让「用户排队的新消息」可以把已经打算停下的 Agent 再拉起来。
Agent 对外是一个异步事件流(EventStream),语义上非常接近 Reactive Streams 的 Flow.Publisher:UI 订阅 message_update 做打字机渲染,订阅 tool_execution_start 显示工具进度——生产者(循环)与消费者(界面)解耦。而 agent-loop.ts 顶部的注释也点明了架构分层:循环内部始终用 AgentMessage,只在 LLM 调用边界才转换成 Message[]——像不像你的 Service 层不透出 DTO,只在 API 网关做一次转换?
§1.5Context Window 与 Token:一本必须精打细算的账
Token(词元)是模型处理文本的计量单位——不等于字符,也不等于单词,英文大约 4 个字符 1 个 token,中文常见 1~2 个字符 1 个 token(各 Provider 分词器不同)。Context Window(上下文窗口)是单次请求能容纳的 token 总预算,而且输入输出共享这本账:系统提示词、全部历史消息、工具定义(JSON Schema 也会被编码进上下文)、本轮要生成的回复,都从同一个额度里扣。
结合 §1.1 的无状态本质,账本问题立刻浮现:每轮都全量重发历史,历史只增不减。如果不加管理,会发生三件事:
- 超限报错:请求超过模型的 context window,Provider 直接拒绝。工具密集型任务(读几十个文件)撑爆窗口是常事。
- 成本线性上涨:输入 token 按量计费,第 N 轮要为前 N-1 轮的所有内容再付一次钱。
- 延迟与质量下降:超长上下文推理更慢,且模型对中段内容的注意力会衰减(lost in the middle 现象)。
所以 Agent 框架必须做上下文管理,Pi 的方案分两层:
- 裁剪与转换:发送前由
transformContext(修剪旧消息、注入外部上下文)和convertToLlm(过滤 UI 专用消息、转换自定义类型)处理消息数组——语义上就是一条 Filter 链。第 9 讲展开。 - 压缩(Compaction):官方文档写道「Compaction inserts a summary entry that replaces older messages in subsequent model requests. The original entries remain in the session tree.」——用一条摘要条目替换后续请求中的旧消息,但原始条目仍完整保留在会话树里。类比 Kafka 的日志压缩:消费视角被压缩,存储视角不可变。
最后是 Prompt Caching(提示词缓存):Provider 会缓存请求的前缀,命中缓存的部分按更低价格、更快速度处理。缓存按前缀匹配生效——这解释了为什么框架偏爱「系统提示词稳定 + 历史 append-only」的纪律:只要前缀一个字节都没变,缓存就能命中;一旦改动历史中段(比如裁掉早期消息、改写某条记录),从改动点起的整段缓存全部作废,费用与延迟双重受罚。上下文稳定性因此不只是整洁问题,而是直接的钱和延迟问题。
§1.6Agent 的构成公式与 Harness:Pi 到底是什么
现在可以给出本讲的总结性定义:
Agent = LLM + 工具 + 循环 + 状态管理
四个成分缺一不可:没有工具,模型只能说话不能做事;没有循环,一次工具调用后就停摆;没有状态管理(消息数组的维护、持久化、裁剪),无状态 API 就构不成会话。而把这四件事包装成可插拔运行时的框架,行业术语叫 Harness(挽具/马具)——套在模型这匹马身上、让它能拉车的一整套装备。Pi 的自我定位正是如此:
- Pi 不做什么:不训练模型、不微调模型、不提供模型权重。模型能力完全来自外部 Provider(Anthropic、OpenAI、Google 等),随时可换。
- Pi 做什么:围绕模型的一切工程——上下文组装(系统提示词 + 活动分支 + 工具定义)、工具注册与校验执行、事件流、会话持久化(JSONL 树)、扩展机制,以及在这一切之上的编码代理 CLI。
这个分工对 Java 工程师毫不陌生:Pi 之于模型,就像 Tomcat + Spring 之于你的业务代码。容器和框架不实现业务逻辑,但它们管理生命周期、路由请求、织入横切关注点,让业务代码只写核心逻辑。读 Pi 源码,读的正是「模型时代的应用服务器」怎么造。
再预览一眼 how-pi-works.md 里两个贯穿全课程的结构,本讲只需留下印象:
- 会话是一棵树:「Messages and events in a session form a tree. Each path through that tree is a branch. The branch ending at the current entry is the active branch and supplies the history for the next model request.」——从历史某个节点继续对话,就长出新分支;「当前分支」决定下一次请求读哪些历史。第 10 讲展开 JSONL 树与 Fork。
- 接口形态多样但内核同一:交互式 TUI、print 一次性输出、JSON 事件流、RPC 双向控制、进程内 SDK——「All interfaces use the same agent and session mechanisms」。学透 Agent Loop,就拿到了所有形态的钥匙。