π Pi Agent 源码学习课程 阶段一 · 1/14

阶段一 · 准备与全景 | 第 1 讲(全 14 讲)

第 1 讲Agent 零基础:LLM、消息与工具调用

把大模型 API 当成一个无状态的 RPC 服务,用 Java 后端熟悉的词汇,重建 Agent 的核心心智模型。

预计 2~3 小时 前置要求:Java 后端经验,Agent 零基础 配套源码:pi-main 本地仓库
Agent Loop 一图:组装请求、模型回复、执行工具、回填结果、再次请求 ① 组装请求 messages + tools ② LLM 流式回复 assistant 消息 ③ 执行工具 toolCall → 你的代码 ④ 回填 toolResult 追加进 messages,带全量历史再次请求(下一个 turn) Agent Loop:一个 turn = 一次模型请求 + 它触发的工具执行 模型不再需要工具、给出最终文本时,循环结束

§0学习目标

说清 LLM API 的无状态本质:为什么「记忆」只是把历史消息数组重新发一遍。
掌握四种消息角色 system / user / assistant / toolResult 的分工与数据形态。
徒手画出工具调用闭环:toolCall 发出、运行时执行、toolResult 回填、再请求。
理解 Agent Loop 与 turn 的精确定义,以及 steering(中途插话)和 abort(取消)如何介入。
理解 Context Window、Token 预算、历史裁剪、压缩与 Prompt Caching 之间的关系。
给出 Agent 的构成公式,说清 Harness(挽具)一词:Pi 不做什么、专门做什么。

本讲配套阅读(本地仓库)

  • packages/coding-agent/docs/how-pi-works.md —— 官方架构总览,精读全文。
  • packages/agent/src/agent-loop.ts —— 对照循环源码,本讲只看 runLoop 的结构。
  • packages/agent/README.md —— 重点看「Event Flow」事件序列图部分。

§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 不保存任何会话 运行时(你的进程)· 请求 ① 前的消息数组 system 「你是运维助手,可用工具:read_file」 user 「帮我看看 config.json 里的端口」 请求 ① 发送全量数组:[system, user] —— 只有两条,也必须从头发 LLM API 无状态 RPC 不保存任何会话 assistant 回复:文本 + toolCall(read_file, {path:"config.json"}) 运行时(你的进程)· 请求 ② 前的消息数组(追加了两条) system 「你是运维助手…」(原样保留在最前) user 「帮我看看 config.json 里的端口」 assistant content 含 toolCall 块(id: call_1) toolResult toolCallId: call_1,content: 文件内容… 请求 ②:全量 4 条 服务端没有「上一次」 的记忆可依赖
图 1 · 消息流转:两次请求互不相识。所谓「记忆」,是运行时把不断增长的消息数组每次从头完整重发。绿框是工具结果回填(§1.3 展开),蓝框是含工具调用块的模型回复。
Java 视角 ①:无状态 HTTP RPC 与「会话状态上移」

把 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:

packages/ai/src/types.ts节选 · 结构有删节
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。此处为教学节选,建议打开原文件对照。
Java 视角 ②:System Prompt ≈ 全局 Filter 链注入的公共上下文

system 消息很像你在 Servlet Filter / Spring Interceptor 里往 RequestContext 塞的全局参数:每个「业务请求」(模型调用)开始前统一注入,业务代码(模型)只能读不能改。Pi 进一步把它用活了——系统消息可以出现在会话中段,携带「工具增减」的增量声明,像一条只追加的配置变更日志。

§1.3工具调用闭环:模型出参数,运行时出执行

一句话类比:工具调用(Tool Call)是「模型填表、框架盖章」。模型负责决定「调用哪个工具、传什么 JSON 参数」,真正的执行永远发生在你的进程里。整个闭环分四步:

  1. 模型发出意图:assistant 消息的 content 里出现 toolCall 块——工具名加一份 JSON 参数(参数由模型按你提供的 JSON Schema 生成)。
  2. 运行时校验并执行:框架按 name 查找注册的工具(Pi 中是 AgentTool),先用 validateToolArguments 做 Schema 校验,再调用工具的 execute 函数——这一步就是普通的后端代码:读文件、查库、发 HTTP 请求都行。
  3. 结果包成消息回填:执行结果(或错误)被包装成 toolResult 消息,带上对应的 toolCallId,追加到消息数组末尾。
  4. 再次请求模型:框架带着「加了 toolResult 的完整历史」发起下一次 LLM 请求。模型读到执行结果,决定继续调用别的工具,还是给出最终文本回答。不再产生新的 toolCall 时,闭环结束。

注意第 3 步的措辞:由谁产生 toolResult?是运行时,不是模型,也不是工具自己「发给」模型——工具只是同步或异步地返回一个结果对象,包装成消息、维护配对关系的是 Agent 框架。这是面试和读源码时的高频混淆点。

Pi 中一个工具的完整定义如下(来自 pi-agent-core 的 README 示例,节选):

packages/agent/README.mdreadFileTool 示例 · 节选
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 接口时区分「业务返回值」与「异常」是同一个工程判断。

Java 视角 ③:SPI + 反射调用 + AOP 环绕

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,流式读取与工具执行都会响应它。
Turn 与 Agent Loop 时间线:一次 run 包含两个 turn,事件流对应 packages/agent/README.md 的事件序列 事件流(时间向下 ▾) 模型请求 工具执行 LLM 请求 ① 流式返回 assistant (文本 + toolCall) read_file 执行 运行时代码,非模型执行 LLM 请求 ② 读到 toolResult 后继续 agent_start turn_start message_start · user message_end · user message_start · assistant message_update · 流式增量(多次) message_end · assistant(含 toolCall) tool_execution_start { toolCallId, args } tool_execution_end { result } message_start/end · toolResult turn_end { message, toolResults } turn_start message_start · assistant message_update … message_end · 最终文本回答 turn_end { toolResults: [] } agent_end { messages } Turn 1 Turn 2 toolResult 需要 继续 → 下一个 turn 不再出现 toolCall → run 结束
图 2 · Turn / Loop 时间线:一次 run = Turn 1 + Turn 2。Turn 1 完整包含「模型请求 → 工具执行 → 结果回填」;Turn 2 是模型读到结果后的收尾请求。事件名与载荷字段严格对应 packages/agent/README.md 的「With Tool Calls」事件序列。

现在打开循环的真实源码。下面节选自 runLoop——agentLoop() 与 agentLoopContinue() 共享的主循环,保留双层 while 骨架:

packages/agent/src/agent-loop.tsrunLoop · 结构节选(省略号处有删节)
// 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 再拉起来。

Java 视角 ④:事件驱动与 Flow.Publisher

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 的无状态本质,账本问题立刻浮现:每轮都全量重发历史,历史只增不减。如果不加管理,会发生三件事:

  1. 超限报错:请求超过模型的 context window,Provider 直接拒绝。工具密集型任务(读几十个文件)撑爆窗口是常事。
  2. 成本线性上涨:输入 token 按量计费,第 N 轮要为前 N-1 轮的所有内容再付一次钱。
  3. 延迟与质量下降:超长上下文推理更慢,且模型对中段内容的注意力会衰减(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,就拿到了所有形态的钥匙。

§P动手实践

目标:用肌肉记忆固化「你扮演运行时」的视角。完成下面两个任务再进入第 2 讲。

任务一:在 LLM Playground 手工完成一次「伪工具调用」 打开任意支持多轮对话的 Playground(或直接用 Pi)。第一条消息里告诉模型:「假设你有一个工具 read_file(path: string),返回文件内容。当你需要读文件时,不要编造,请输出一段 JSON:{"tool":"read_file","args":{"path":"..."}},然后停下等我。」 提示词写清楚「停下等我」很关键,否则模型可能自己虚构结果继续。
提出一个必须读文件才能回答的问题,例如「config.json 里数据库端口是多少?」观察模型回复中出现工具调用 JSON。
复制这段 JSON。此刻你就是 Agent 框架:在本地真实读一下文件(哪怕手写一个假 config.json),把内容作为「工具结果」粘回对话,注明「这是 read_file 的执行结果」。
观察模型如何基于结果继续:它可能直接给答案,也可能再发一次工具调用(比如还要看 database.json)。每多一次调用,你就多扮演一轮运行时。
任务二:对照本地源码走一遍结构 打开 pi-main 仓库:① 精读 packages/coding-agent/docs/how-pi-works.md 全文(约 5 分钟);② 打开 packages/agent/src/agent-loop.ts,只找 runLoop 函数,对照本讲图 2 认出「内层 while、toolCalls 过滤、push 回填、turn_end」四个位置;③ 翻到 packages/agent/README.md 的 Event Flow 一节,核对图 2 的每个事件名。

§M常见误区

误区一:「模型有记忆,第二次请求只发新消息就行。」

错。LLM API 是无状态 RPC,会话状态的唯一载体是请求里的 messages 数组。只发增量,模型看到的就是一段没有前文的孤零零输入;你删掉的它就真的忘了。所有「记忆」都是客户端重发历史制造的。

误区二:「工具是模型在服务端执行的。」

错。模型只产出 toolCall——一份「想调用什么、参数是什么」的结构化意图。查找工具、校验参数、执行代码、包装 toolResult,全部发生在你的运行时进程里。这也意味着安全边界在你手上:模型永远「请求」,你的代码「批准并执行」。

误区三:「turn 就是一条消息 / 一次对话。」

都不准确。turn = 一次模型请求 + 它触发的全部工具执行与结果回填,以 turn_start / turn_end 事件为界。一次用户提问通常对应一个或多个 turn;把 turn 与 message、run 混为一谈,读事件流源码时必然晕头转向。

误区四:「上下文窗口很大,历史随便堆;反正有缓存就不贵。」

窗口再大也要付钱:输入按 token 计费且逐轮累加,工具调用密集时极易超限。Prompt Caching 按前缀命中,历史一旦被改写(裁剪、编辑),从改动点起的缓存即失效。上下文管理是 Agent 框架的核心职责,不是可选项。

§Q自测问答

先自己作答,再展开核对。答不上来的,回到对应小节重读。

① 为什么说 LLM API 是无状态的?点击展开答案

因为服务端不保存任何会话状态:每次请求都是独立计算,输出只取决于本次请求携带的 messages 数组、工具定义与模型参数。类比一个无返回状态的纯函数 reply(messages, tools)。所谓多轮「记忆」,是客户端每轮把完整历史重新发送制造的错觉——会话状态住在客户端(Pi 中是 JSONL 会话树),不在服务端。

② 一个 turn 由什么组成?点击展开答案

一个 turn = 一次模型请求 + 它触发的全部工具执行 + 结果回填。以 Pi 为例:Provider 流式返回一个 assistant 回复(可含文本与 toolCall),Pi 记录回复、执行每个工具调用、记录结果,turn_end 事件携带该轮的 message 与 toolResults。若工具结果或排队消息需要再次请求模型,则开启下一个 turn;若干 turn 从 agent_start 到 agent_end 构成一次 run。

③ toolResult 消息由谁产生、包含什么?点击展开答案

由运行时(Agent 框架)产生——既不是模型,也不是工具「直接发给」模型。工具的 execute 只返回结果对象,框架把它包装成 role: "toolResult" 的消息。核心字段:toolCallId(与 assistant 消息里 toolCall 的 id 一一配对)、toolName、content(文本或图片数组,会回填给模型)、details(给 UI/审计的结构化细节,不进模型上下文)、isError(失败标志,README 约定工具失败应抛异常,由框架转成 isError: true 的结果)。

④ 如果不裁剪历史会发生什么?点击展开答案

三重后果:一是超限——历史 + 系统提示词 + 工具定义 + 本轮输出共享 context window 预算,工具密集型任务很容易撑爆,Provider 直接拒绝请求;二是成本线性上涨——每轮全量重发,输入 token 逐轮累加计费;三是延迟与质量下降——超长上下文推理更慢,且存在中段注意力衰减。此外,粗暴改写历史还会打断 Prompt Caching 的前缀命中,缓存失效进一步推高费用与延迟。

⑤ 补充:Harness(挽具)指什么?Pi 不做什么、专门做什么?点击展开答案

Harness 指套在模型外面、让它能干活的整套工程框架。Pi 不训练、不微调模型,模型能力完全来自可替换的外部 Provider;它专门负责围绕模型的一切工程:上下文组装(系统提示词、活动分支、工具定义)、工具注册/校验/执行、Agent Loop 与事件流、会话持久化(JSONL 树)、扩展系统。类比:Pi 之于模型 ≈ Tomcat + Spring 之于业务代码。

⑥ 补充:steering 消息与 abort 分别如何介入循环?点击展开答案

steering(中途插话):用户在模型忙碌时输入的消息,在当前 assistant turn 结束后进入上下文、参与下一次模型请求,从而实时改变模型方向;follow-up 消息则在 Agent 完成待办后进入。abort(取消):中断当前 run,排队中的消息退回编辑器;源码层面通过 AbortSignal 贯穿流式读取与工具执行,循环收到 stopReason: "aborted" 后发出 turn_end 与 agent_end 收尾。