pi 的 Agent Loop:runLoop 双层控制流、退出路径与钩子时机

packages/agent/src/agent-loop.ts 里这条调用链分三层:runAgentLoop 建立一次 trace,runLoop 决定要不要继续下一轮,streamAssistantResponse 完成一次 LLM 请求

用户输入 开始探路。,system prompt 要求模型调用一次 trace_echo,参数 {value:"FLOW-42"},工具返回 ECHO:FLOW-42,模型再回复 DONE。

注释里用四个缩写代替 provider 生成的长 ID,它们的完整值不参与循环分支判断:

<call-id>       call_9MYXh52amb5eZEnamZ0m1IBy|fc_0e706ae8c8b7f4a6016aa22321c1cc87d08dd144835375c306
<response-id>   resp_0e706ae8c8b7f4a6016aa2231fb8b087d08f8d3c028c3dfdac   第一条 assistant
<response-id-2> resp_0e706ae8c8b7f4a6016aa223231b1887d0981dbb0b81998012   第二条 assistant
<message-id>    msg_0e706ae8c8b7f4a6016aa22324308c87d0b6595acbc0a11888    OpenAI 的 item id,写进 textSignature

这条消息最终产生四个AgentMessage。AgentMessage(packages/agent/src/types.ts:326)是 Message 加上应用自定义的消息;Message = UserMessage | AssistantMessage | ToolResultMessage(packages/ai/src/types.ts:470)。下面是一次完整运行后这四条对象的实际字段,后文注释里的对象值都取自这里,不再重复:

// [0] UserMessage,定义在 ai/src/types.ts:422-426
{
  role: "user",
  content: "开始探路。",        // 类型是 string | (TextContent | ImageContent)[],这里是纯字符串
  timestamp: 1789010718930,     // Unix 毫秒
}

// [1] AssistantMessage,定义在 ai/src/types.ts:428-450
{
  role: "assistant",
  content: [                    // (TextContent | ThinkingContent | ToolCall)[]
    { type: "toolCall", id: "<call-id>", name: "trace_echo", arguments: { value: "FLOW-42" } },
  ],
  api: "openai-codex-responses",
  provider: "openai-codex",
  model: "gpt-5.6-sol",
  usage: { input: 106, output: 20, cacheRead: 0, cacheWrite: 0, reasoning: 0,
           totalTokens: 126, cost: { /* 美元,input 0.00053 + output 0.0006 */ } },
  stopReason: "toolUse",        // StopReason = pending|stop|length|toolUse|error|aborted|deferred
  timestamp: 1789010718944,
  responseId: "<response-id>",
  rawStopReason: "completed",   // provider 原文,pi 自己的归一化结果在 stopReason
}

// [2] ToolResultMessage,定义在 ai/src/types.ts:452-468
{
  role: "toolResult",
  toolCallId: "<call-id>",      // 与 [1].content[0].id 逐字符相同,配对靠它,不靠 toolName
  toolName: "trace_echo",
  content: [{ type: "text", text: "ECHO:FLOW-42" }],
  details: { phase: "complete", toolCallId: "<call-id>", received: "FLOW-42" },
  usage: undefined,
  isError: false,
  timestamp: 1789010722350,
}

// [3] AssistantMessage,类型与 [1] 相同,不同的字段列出来,其余同 [1]
{
  role: "assistant",
  content: [ { type: "text", text: "DONE",
               textSignature: '{"v":1,"id":"<message-id>","phase":"final_answer"}' } ],
  usage: { input: 143, output: 5, cacheRead: 0, cacheWrite: 0, reasoning: 0, totalTokens: 148 },
  stopReason: "stop",
  timestamp: 1789010722357,
  responseId: "<response-id-2>",
}

content[0].textSignature 是 TextContent 上的可选字段(ai/src/types.ts:351-355),值就是 TextSignatureV1 序列化后的 JSON 字符串(ai/src/types.ts:345-350),由 openai-responses-shared.ts:700-701 在 text_end 时写入,下一轮回放给 provider 用。[1] 的 content[0] 是 ToolCall block(ai/src/types.ts:373-381),在 agent 包的类型里叫 AgentToolCall(agent/src/types.ts:53)。

注释里出现的类型名与定义位置:

注释里的名字 定义 这次的实际形状
AgentContext agent/src/types.ts:415-422 三个字段 systemPrompt: string、messages: AgentMessage[]、tools?: AgentTool[]
AgentTool agent/src/types.ts:387-412 Tool 的扩展,多 label、execute、executionMode、prepareArguments、replay
AgentLoopConfig agent/src/types.ts:149,继承 SimpleStreamOptions → StreamOptions model 必填;回调 convertToLlm、transformContext、getApiKey、shouldStopAfterTurn、prepareNextTurn、getSteeringMessages、getFollowUpMessages、beforeToolCall、afterToolCall;其余字段是 provider 请求选项
Context ai/src/types.ts:524-528 只有 systemPrompt?、messages: Message[]、tools?: Tool[] 三个字段
PrepareNextTurnContext agent/src/types.ts:147,内容同 ShouldStopAfterTurnContext(126-135) message、toolResults、context、newMessages 四个字段
AgentLoopTurnUpdate agent/src/types.ts:138-145 context?、model?、thinkingLevel?,全可选
ExecutedToolCallBatch agent/src/agent-loop.ts:426-429 messages: ToolResultMessage[]、terminate: boolean
AgentEvent agent/src/types.ts:431-446 10 个变体的判别联合,这次 10 种全部出现,共 29 个事件
AssistantMessageEvent ai/src/types.ts:546-562 流式事件,除 done/error 外每个都带 partial

runAgentLoop:建立一次 run

入口在 packages/agent/src/agent-loop.ts:96-119。它不负责判断工具是否还要继续,只做初始化、发出第一组事件,然后把控制权交给 runLoop。

签名里两个回调类型:AgentEventSink = (event: AgentEvent) => Promise<void> | void(agent-loop.ts:26),StreamFn 返一个 AssistantMessageEventStream(agent/src/types.ts:28)。AgentEvent 的 10 个变体在 agent/src/types.ts:431-446,后面注释里的事件名都是它的 type 字段。

skill 之类的东西是如何实现的呢?mcp又是如何实现的?这些都是tool吗?

export async function runAgentLoop(
  prompts: AgentMessage[],
  context: AgentContext,
  config: AgentLoopConfig,
  emit: AgentEventSink,
  signal: AbortSignal | undefined,
  streamFn: StreamFn,
): Promise<AgentMessage[]> {
  // 实测入参:
  //   prompts: AgentMessage[],length 1,prompts[0] 就是 [0] 那条 UserMessage
  //   context: AgentContext,三个字段全部存在
  //     context.systemPrompt: string,“You are a deterministic agent-loop probe. …”
  //     context.messages: [],length 0,新对话没有历史
  //     context.tools: AgentTool[],length 1,tools[0] =
  //       { name: "trace_echo",
  //         label: "Trace Echo",
  //         description: "Return the supplied value unchanged. Used only for the agent-loop trace experiment.",
  //         parameters: { type: "object", required: ["value"], properties: { value: { type: "string" } } },
  //         executionMode: "sequential",
  //         execute: [Function: execute] }
  //   config: AgentLoopConfig,这次实际传进来 14 个字段
  //     model: Model,{ id:"gpt-5.6-sol", provider:"openai-codex", api:"openai-codex-responses",
  //            baseUrl:"https://chatgpt.com/backend-api", reasoning:true, contextWindow:1050000,
  //            maxTokens:128000, thinkingLevelMap:{xhigh:"xhigh", max:"max", minimal:"low"}, cost, input, compat }
  //     reasoning: ThinkingLevel,"minimal"。model.reasoning 是布尔值(模型是否支持思考),
  //            两者同名不同物,ThinkingLevel 定义在 agent/src/types.ts:301
  //     sessionId:"agent-loop-cache-probe"、cacheRetention:"long"、maxTokens:512、
  //     transport:"sse"、toolExecution:"sequential"、onPayload、convertToLlm、transformContext、
  //     shouldStopAfterTurn、prepareNextTurn、getSteeringMessages、getFollowUpMessages
  //     没有 apiKey,也没有 getApiKey
  //   signal: AbortSignal.timeout(120_000),signal.aborted === false

  const newMessages: AgentMessage[] = [...prompts];
  // newMessages.length === 1,newMessages[0] === prompts[0]
  // 新数组、旧元素引用。它只收集这次 run 新增的消息,不包含进入函数前的历史。

  const currentContext: AgentContext = {
    ...context,
    messages: [...context.messages, ...prompts],
  };
  // currentContext 是新建的 AgentContext 对象:
  //   messages.length === 1,元素仍是那条 UserMessage
  //   systemPrompt 与 tools 是浅拷贝,tools 数组与 context.tools 同一引用
  // 传进来的 context.messages 仍是 [],这里没有改它。

  await emit({ type: "agent_start" });
  await emit({ type: "turn_start" });
  // 这两个事件只有 type 一个字段(agent/src/types.ts:433、436),是全局第 1、第 2 个事件。

  for (const prompt of prompts) {
    // prompts.length === 1,只转一次,prompt 就是那条 role:"user" 的对象。
    // message_start / message_end 都带一个 message: AgentMessage 字段,这里原样传入,不是拷贝。
    await emit({ type: "message_start", message: prompt });
    await emit({ type: "message_end", message: prompt });
  }

  await runLoop(
    currentContext,
    newMessages,
    config,
    signal,
    emit,
    streamFn ?? getDefaultStreamFn(),
  );

  // runLoop 返回时 newMessages.length === 4:
  //   [0] role:"user"        content 是字符串
  //   [1] role:"assistant"   content[0].type === "toolCall",stopReason === "toolUse"
  //   [2] role:"toolResult"  toolName === "trace_echo",isError === false
  //   [3] role:"assistant"   content[0].type === "text",text === "DONE",stopReason === "stop"
  return newMessages;
}

这里有两个消息数组。currentContext.messages 是发给后续 LLM 的完整上下文;newMessages 是本次调用的返回值。对新对话来说两者内容相同,但续跑时不能把它们当成一个概念。

runLoop:工具为什么会触发第二轮

入口在 agent-loop.ts:156-273。这次没有 steering 和 follow-up,所以外层循环只进入一次;工具调用让内层循环进入两次。

注意第二个参数 newMessages 是调用方 runAgentLoop 里那个数组的引用,本函数无返回值,它靠引用修改这个数组和 initialContext.messages 工作。

async function runLoop(
  initialContext: AgentContext,
  newMessages: AgentMessage[],
  initialConfig: AgentLoopConfig,
  signal: AbortSignal | undefined,
  emit: AgentEventSink,
  streamFunction: StreamFn,
): Promise<void> {
  let currentContext = initialContext;
  // 与 initialContext 是同一个 AgentContext 对象,messages.length === 1

  let config = initialConfig;
  // 同一个 AgentLoopConfig 对象,config.model.id === "gpt-5.6-sol",config.reasoning === "minimal"

  let lastCompletedTurn: PrepareNextTurnContext | undefined;
  // 初值 undefined;第一轮结束后才会赋值。四个字段:message(AssistantMessage)、
  // toolResults(ToolResultMessage[])、context(AgentContext)、newMessages(AgentMessage[])

  let pendingMessages: AgentMessage[] =
    (await config.getSteeringMessages?.()) || [];
  // 回调存在,返回空数组,pendingMessages.length === 0

  while (true) {
    // 外层第 1 次。因为 followUpMessages 最后为 [],不会有第 2 次。

    let hasMoreToolCalls = true;
    // 先设 true,保证没有 pending message 时也会进入第一轮 LLM 请求。

    while (hasMoreToolCalls || pendingMessages.length > 0) {
      // 第 1 次:hasMoreToolCalls=true、pendingMessages.length=0 → 进入
      // 第 2 次:工具批次把 hasMoreToolCalls 改回 true → 进入
      // 第 2 次结束:false || false → 退出

      if (lastCompletedTurn) {
        // 第 1 次 undefined,不进入。
        // 第 2 次拿到的是 toolUse 那一轮的四字段快照(见下面的赋值处)

        const nextTurnSnapshot =
          await config.prepareNextTurn?.(lastCompletedTurn);
        // 返回类型 AgentLoopTurnUpdate,这次三个可选字段都给了值:
        //   context:       { systemPrompt, messages, tools },messages.length === 3,
        //                  role 依次 user、assistant、toolResult
        //   model:         与 config.model 同一个对象
        //   thinkingLevel: "minimal"

        if (nextTurnSnapshot) {
          currentContext = nextTurnSnapshot.context ?? currentContext;
          config = {
            ...config,
            model: nextTurnSnapshot.model ?? config.model,
            reasoning:
              nextTurnSnapshot.thinkingLevel === undefined
                ? config.reasoning
                : nextTurnSnapshot.thinkingLevel === "off"
                  ? undefined
                  : nextTurnSnapshot.thinkingLevel,
          };
          // thinkingLevel !== "off",所以 config.reasoning 仍是 "minimal";model 同一对象。
          // currentContext 换成快照里的新 AgentContext。探路脚本返回的是 {...turn.context,
          // messages:[...turn.context.messages]},对象和新数组都是新建的,所以第 1 轮快照里
          // 那个 context.messages 停在 3 条,后面只增长新对象。
        }

        if (pendingMessages.length === 0) {
          pendingMessages =
            (await config.getSteeringMessages?.()) || [];
        }
        // 重新轮询,仍返回空数组。

        await emit({ type: "turn_start" });
        // 全局第 22 个事件,也是第二个 turn_start。第一个由 runAgentLoop 发出。
      }

      if (pendingMessages.length > 0) {
        // 两次到这里 pendingMessages.length 都是 0,分支内一行都没执行。
        for (const message of pendingMessages) {
          await emit({ type: "message_start", message });
          await emit({ type: "message_end", message });
          currentContext.messages.push(message);
          newMessages.push(message);
        }
        pendingMessages = [];
      }

      const message = await streamAssistantResponse(
        currentContext,
        config,
        signal,
        emit,
        streamFunction,
      );
      // 返回值是 AssistantMessage,循环只看它的 stopReason 和 content:
      // 第 1 次:stopReason === "toolUse"
      //   content[0] = { type:"toolCall", id:"<call-id>", name:"trace_echo",
      //                  arguments:{ value:"FLOW-42" } }
      //   usage = { input:106, output:20, cacheRead:0, cacheWrite:0, totalTokens:126 }
      // 第 2 次:stopReason === "stop"
      //   content[0] = { type:"text", text:"DONE", textSignature:'{"v":1,…}' }
      //   usage = { input:143, output:5, cacheRead:0, cacheWrite:0, totalTokens:148 }

      newMessages.push(message);
      // 第 1 次后 newMessages.length === 2,role 依次 user、assistant
      // 第 2 次后 newMessages.length === 4,role 依次 user、assistant、toolResult、assistant

      if (
        message.stopReason === "error" ||
        message.stopReason === "aborted"
      ) {
        // 两次 stopReason 分别是 "toolUse" 和 "stop",判断结果都是 false。
        await emit({ type: "turn_end", message, toolResults: [] });
        await emit({ type: "agent_end", messages: newMessages });
        return;
      }

      const toolCalls = message.content.filter(
        (c) => c.type === "toolCall",
      );
      // 判据是 content block 的 type 字段,不是 stopReason,也不是 toolResults 的长度。
      // 第 1 次:length 1,元素是那个 ToolCall block 本身(不是消息)
      //   { type:"toolCall", id:"<call-id>", name:"trace_echo", arguments:{ value:"FLOW-42" } }
      // 第 2 次:length 0,content 里只有一个 type:"text" block

      const toolResults: ToolResultMessage[] = [];
      hasMoreToolCalls = false;
      // 两个变量都在内层循环体内声明,每轮重新来。toolResults 是新的空数组,
      // 后面写进 lastCompletedTurn 的就是它;hasMoreToolCalls 先复位,有没有下一轮要等工具批次结果决定。

      if (toolCalls.length > 0) {
        // 只有第 1 次进入。
        const executedToolBatch =
          message.stopReason === "length"
            ? await failToolCallsFromTruncatedMessage(toolCalls, emit)
            : await executeToolCalls(
                currentContext,
                message,
                config,
                signal,
                emit,
              );
        // stopReason 是 "toolUse",不等于 "length",走 executeToolCalls。
        // 返回类型 ExecutedToolCallBatch(agent-loop.ts:426-429),两个字段:
        //   messages: ToolResultMessage[],length 1,唯一元素就是前面的 [2]:
        //     { role:"toolResult", toolCallId:"<call-id>", toolName:"trace_echo",
        //       content:[{ type:"text", text:"ECHO:FLOW-42" }],
        //       details:{ phase:"complete", toolCallId:"<call-id>", received:"FLOW-42" },
        //       usage:undefined, isError:false, timestamp:1789010722350 }
        //   terminate: false
        // terminate 只在批次里每一个工具结果都带 terminate:true 时才为 true
        // (agent-loop.ts:589-591),这次工具返回里根本没设这个字段。

        toolResults.push(...executedToolBatch.messages);
        hasMoreToolCalls = !executedToolBatch.terminate;
        // toolResults.length === 1;hasMoreToolCalls = !false = true
        // 这就是内层循环会进入第 2 次的直接原因,跟工具结果内容无关。

        for (const result of toolResults) {
          currentContext.messages.push(result);
          newMessages.push(result);
        }
        // 两个数组都变到 3 个元素,role 依次 user、assistant、toolResult
        // push 的是引用,[1] 和 [2] 这两个对象在两个数组里是同一份
      }

      await emit({ type: "turn_end", message, toolResults });
      // turn_end 的两个额外字段:message: AgentMessage、toolResults: ToolResultMessage[]
      // 第 1 个 turn_end(全局第 21 个事件)toolResults.length === 1
      // 第 2 个 turn_end(第 28 个)toolResults.length === 0

      lastCompletedTurn = {
        message,
        toolResults,
        context: currentContext,
        newMessages,
      };
      // 四个字段全部是引用,不是拷贝。
      // 第 1 次赋值:message 是 [1],toolResults.length === 1,
      //   context.messages.length === 3,newMessages.length === 3
      // 第 2 次赋值:message 换成 [3],toolResults 是这一轮新建的空数组,
      //   newMessages 仍是同一个数组引用(长 4),context 已被 prepareNextTurn 换成新对象(长 4)

      if (await config.shouldStopAfterTurn?.(lastCompletedTurn)) {
        // 入参就是上面那个四字段快照;探路配置两次都返回 false。
        await emit({ type: "agent_end", messages: newMessages });
        return;
      }

      pendingMessages =
        (await config.getSteeringMessages?.()) || [];
      // 两次都拿到空数组。
      // 第 1 次:hasMoreToolCalls=true,内层条件成立,继续
      // 第 2 次:hasMoreToolCalls=false 且 length=0,内层退出
    }

    const followUpMessages =
      (await config.getFollowUpMessages?.()) || [];
    // followUpMessages: AgentMessage[],实测 length 0

    if (followUpMessages.length > 0) {
      pendingMessages = followUpMessages;
      continue;
    }

    break;
  }

  await emit({ type: "agent_end", messages: newMessages });
  // agent_end 只有一个额外字段 messages: AgentMessage[],就是 newMessages 同一引用,
  // length 4,role 依次 user、assistant、toolResult、assistant;它是全局第 29 个、也是最后一个事件
}

两轮内层循环的差异可以压成一张表:

变量 第 1 轮 第 2 轮
lastCompletedTurn undefined 第 1 轮快照
message.stopReason toolUse stop
toolCalls.length 1 0
toolResults.length 1 0
hasMoreToolCalls 轮末值 true false
newMessages.length 轮末值 3 4
currentContext.messages.length 轮末值 3 4
pendingMessages.length 0 0
是否继续内层循环 是 否

外层循环不是“工具循环”。它只负责 agent 本来要停时再查一次 follow-up。工具调用和 steering 都由内层循环消化。

streamAssistantResponse:完成一次 LLM 请求

入口在 agent-loop.ts:279-370。这个函数每调用一次,只对应一次 provider 请求。上面的内层循环执行两轮,所以它被调用两次。

async function streamAssistantResponse(
  context: AgentContext,
  config: AgentLoopConfig,
  signal: AbortSignal | undefined,
  emit: AgentEventSink,
  streamFunction: StreamFn,
): Promise<AssistantMessage> {
  let messages = context.messages;
  // messages: AgentMessage[],与 context.messages 是同一个数组引用(实测两次都判到相等)
  // 第 1 次 length 1,role 依次 user
  // 第 2 次 length 3,role 依次 user、assistant、toolResult

  if (config.transformContext) {
    messages = await config.transformContext(messages, signal);
  }
  // 探路脚本只做 [...messages]:新数组、同一批元素引用,length 与顺序不变。
  // 这一步在 AgentMessage[] 层面做,输入输出同类型。
  // 真正的 pi 会在这里运行 context 扩展;压缩也可能在下一轮前改历史。

  const llmMessages = await config.convertToLlm(messages);
  // 类型在这道边界从 AgentMessage[] 收到 Message[]。
  // 探路配置是 (messages) => messages,返回传入数组的同一引用,三个 role 原样保留。
  // 自定义 UI 消息会在这道边界被过滤或转换。

  const llmContext: Context = {
    systemPrompt: context.systemPrompt,
    messages: llmMessages,
    tools: context.tools,
  };
  // Context 只有三个字段(ai/src/types.ts:524-528):systemPrompt?: string、
  // messages: Message[]、tools?: Tool[]
  // 第 1 次:
  // {
  //   systemPrompt: "You are a deterministic agent-loop probe. On the first user request, …",
  //   messages: [ { role:"user", content:"开始探路。", timestamp:1789010718930 } ],
  //   tools: [ { name:"trace_echo", label:"Trace Echo", description:"…",
  //              parameters:{ type:"object", required:["value"], … },
  //              executionMode:"sequential", execute:[Function:execute] } ]
  // }
  //
  // 第 2 次:systemPrompt 与 tools 两个字段原样,messages 增长到 3 个元素,就是开头的 [0]、[1]、[2]
  // 注意 tools 元素仍是完整的 AgentTool,label、execute、executionMode 还在,
  // adapter 只取 name/description/parameters。协议层的 Tool(ai/src/types.ts:517-521)只有
  // name、description、parameters、constrainedSampling? 四个字段。
  // 这种“旧前缀不动,只在末尾追加”正是 prompt cache 能复用的结构。

  const resolvedApiKey =
    (config.getApiKey
      ? await config.getApiKey(config.model.provider)
      : undefined) || config.apiKey;
  // config.getApiKey 未定义,config.apiKey(StreamOptions 继自 ProviderRequestOptions,
  // ai/src/types.ts:128)也未设置,两边都是 undefined → resolvedApiKey === undefined
  // 外层 streamFunction 使用 ModelRuntime,OAuth 在它的 prepareRequest 中解析。

  const response = await streamFunction(
    config.model,
    llmContext,
    {
      ...config,
      apiKey: resolvedApiKey,
      signal,
    },
  );
  // response: AssistantMessageEventStream(ai/src/utils/event-stream.ts:91)。
  // 它同时是可异步迭代的事件流和最终结果的 promise,流里只给 partial 快照,
  // 完整消息要等 response.result()。

  let partialMessage: AssistantMessage | null = null;
  let addedPartial = false;
  // 每次请求开始时都是 null、false。addedPartial 记的就是“有没有往 context 里插过 partial”。

  for await (const event of response) {
    // event: AssistantMessageEvent(ai/src/types.ts:546-562)。这次两轮流的完整事件:
    // 第 1 次 11 个:
    //   1  { type:"start", partial:{ role:"assistant", content:[], stopReason:"pending",
    //        api, provider, model, usage 各字段全 0, timestamp:1789010718944 } }
    //   2  { type:"toolcall_start", contentIndex:0, partial }
    //   3-9 { type:"toolcall_delta", contentIndex:0, partial,
    //        delta 依次为 '{"'、'value'、'":"'、'FLOW'、'-'、'42'、'"}' }
    //   10 { type:"toolcall_end", contentIndex:0, partial,
    //        toolCall:{ type, id:"<call-id>", name, arguments:{value:"FLOW-42"} } }
    //   11 { type:"done", reason:"toolUse", message }
    // 第 2 次 5 个:
    //   1 start、2 text_start(contentIndex:0)、3 text_delta(delta:"DONE")、
    //   4 text_end(content:"DONE")、5 done(reason:"stop")
    // 除 done / error 外每个事件都带 partial,它是 provider 就地更新的可变快照:
    // toolcall_start 时 content[0] 已有 id、arguments 仍是空对象,另有一个临时字段 partialJson;
    // partialJson 是流式累积的 JSON 文本,在 toolcall_end 时被删掉(openai-responses-shared.ts:718),
    // 所以最终消息里只看到 arguments。

    switch (event.type) {
      case "start":
        partialMessage = event.partial;
        context.messages.push(partialMessage);
        addedPartial = true;
        // partialMessage 就是 event.partial 那个可变对象。
        // 先把它 push 进 context.messages 占住最后一个槽位,让 UI 能立刻拿到 assistant 壳子;
        // 注意 newMessages 在整个 for-await 期间不动,流式半成品不会进返回值。
        // emit 的 message 是 { ...partialMessage } 浅拷贝,事件消费方拿不到后续 mutation。

        await emit({
          type: "message_start",
          message: { ...partialMessage },
        });
        break;

      case "text_start":
      case "text_delta":
      case "text_end":
      case "thinking_start":
      case "thinking_delta":
      case "thinking_end":
      case "toolcall_start":
      case "toolcall_delta":
      case "toolcall_end":
        if (partialMessage) {
          partialMessage = event.partial;
          context.messages[context.messages.length - 1] = partialMessage;
          // 每个增量都覆写最后一个槽位,所以一次流式只有一条 assistant 在增长,
          // 不会追加一串半成品消息。emit 同样是浅拷贝。

          await emit({
            type: "message_update",
            assistantMessageEvent: event,
            message: { ...partialMessage },
          });
        }
        break;

      case "done":
      case "error": {
        const finalMessage = await response.result();
        // finalMessage: AssistantMessage,完整字段见开头的四条消息
        // 第 1 次 stopReason="toolUse"、content[0].type="toolCall"、usage.input=106
        // 第 2 次 stopReason="stop"、content[0].type="text"、usage.input=143

        if (addedPartial) {
          context.messages[context.messages.length - 1] = finalMessage;
          // 两次都走这里:把最后一个槽位从 partial 换成最终消息,length 不变。
        } else {
          context.messages.push(finalMessage);
          // 本次未走。它处理 provider 没发 start 的情况(addedPartial 仍为 false)。
        }

        if (!addedPartial) {
          await emit({
            type: "message_start",
            message: { ...finalMessage },
          });
        }

        await emit({ type: "message_end", message: finalMessage });
        return finalMessage;
      }
    }
  }

  // 本次没有走到这里。它是流结束但迭代器没显式交出 done/error 时的兜底。
  const finalMessage = await response.result();
  if (addedPartial) {
    context.messages[context.messages.length - 1] = finalMessage;
  } else {
    context.messages.push(finalMessage);
    await emit({ type: "message_start", message: { ...finalMessage } });
  }
  await emit({ type: "message_end", message: finalMessage });
  return finalMessage;
}

context.messages 在流开始时就出现 assistant partial,是为了让 UI 能渲染增量;这个 partial 也是一条 AssistantMessage,字段齐,只是 content 从 [] 开始增长、stopReason 是 "pending"、usage 全 0。message_end 前最后一个槽位会被 final message 替换。runLoop 接到返回值后只把 final message 加进 newMessages,所以返回数组里没有流式半成品。

llmContext 到协议请求体

adapter 之前,streamAssistantResponse 组装出的 llmContext(packages/ai/src/types.ts:524-528):

{
 "systemPrompt": "Stable system instructions for the cache probe.",
 "messages": [
  {
   "role": "user",
   "content": "开始探路。",
   "timestamp": 1700000000000
  },
  {
   "role": "assistant",
   "content": [
    {
     "type": "toolCall",
     "id": "call_cache_probe",
     "name": "trace_echo",
     "arguments": {
      "value": "FLOW-42"
     }
    }
   ],
   "api": "openai-completions",
   "provider": "openai",
   "model": "gpt-cache-probe",
   "usage": {
    "input": 0,
    "output": 0,
    "cacheRead": 0,
    "cacheWrite": 0,
    "totalTokens": 0,
    "cost": {
     "input": 0,
     "output": 0,
     "cacheRead": 0,
     "cacheWrite": 0,
     "total": 0
    }
   },
   "stopReason": "toolUse",
   "timestamp": 1700000000000
  },
  {
   "role": "toolResult",
   "toolCallId": "call_cache_probe",
   "toolName": "trace_echo",
   "content": [
    {
     "type": "text",
     "text": "ECHO:FLOW-42"
    }
   ],
   "details": {
    "received": "FLOW-42"
   },
   "isError": false,
   "timestamp": 1700000000000
  }
 ],
 "tools": [
  {
   "name": "trace_echo",
   "label": "Trace Echo",
   "description": "Return the supplied value unchanged.",
   "parameters": {
    "type": "object",
    "required": [
     "value"
    ],
    "properties": {
     "value": {
      "type": "string"
     }
    }
   },
   "executionMode": "sequential",
   "prepareArguments": "[Function:prepareArguments]",
   "execute": "[Function:traceEchoExecute]"
  }
 ]
}

上面这份数据来自离线协议探路脚本,systemPrompt、model、toolCallId(call_cache_probe)、timestamp 都是脚本里写死的常量,不是实机值;实机那次的 system prompt 是文章开头那条 You are a deterministic agent-loop probe. …。真实 pi 的 system prompt 由 packages/coding-agent/src/core/system-prompt.ts:28 的 buildSystemPrompt() 生成,比这两个都长(含工具说明与上下文文件)。assistant 消息里的 api、provider、model 三个字段跟着目标协议变(anthropic 那份是 anthropic-messages、anthropic-probe、claude-cache-probe),其余逐字节相同。

同一份 llmContext 交给 openai-completions.ts 的 adapter(离线调用 stream(),不发网络):

{
 "model": "gpt-cache-probe",
 "messages": [
  {
   "role": "system",
   "content": "Stable system instructions for the cache probe."
  },
  {
   "role": "user",
   "content": "开始探路。"
  },
  {
   "role": "assistant",
   "content": null,
   "tool_calls": [
    {
     "id": "call_cache_probe",
     "type": "function",
     "function": {
      "name": "trace_echo",
      "arguments": "{\"value\":\"FLOW-42\"}"
     }
    }
   ]
  },
  {
   "role": "tool",
   "content": "ECHO:FLOW-42",
   "tool_call_id": "call_cache_probe"
  }
 ],
 "stream": true,
 "prompt_cache_key": "agent-loop-cache-probe",
 "prompt_cache_retention": "24h",
 "stream_options": {
  "include_usage": true
 },
 "tools": [
  {
   "type": "function",
   "function": {
    "name": "trace_echo",
    "description": "Return the supplied value unchanged.",
    "parameters": {
     "type": "object",
     "required": [
      "value"
     ],
     "properties": {
      "value": {
       "type": "string"
      }
     }
    },
    "strict": false
   }
  }
 ]
}

同一份 llmContext 交给 anthropic-messages.ts 的 adapter(离线调用 stream(),不发网络):

{
 "model": "claude-cache-probe",
 "messages": [
  {
   "role": "user",
   "content": "开始探路。"
  },
  {
   "role": "assistant",
   "content": [
    {
     "type": "tool_use",
     "id": "call_cache_probe",
     "name": "trace_echo",
     "input": {
      "value": "FLOW-42"
     }
    }
   ]
  },
  {
   "role": "user",
   "content": [
    {
     "type": "tool_result",
     "tool_use_id": "call_cache_probe",
     "content": "ECHO:FLOW-42",
     "is_error": false,
     "cache_control": {
      "type": "ephemeral",
      "ttl": "1h"
     }
    }
   ]
  }
 ],
 "max_tokens": 4096,
 "stream": true,
 "system": [
  {
   "type": "text",
   "text": "Stable system instructions for the cache probe.",
   "cache_control": {
    "type": "ephemeral",
    "ttl": "1h"
   }
  }
 ],
 "tools": [
  {
   "name": "trace_echo",
   "description": "Return the supplied value unchanged.",
   "eager_input_streaming": true,
   "input_schema": {
    "type": "object",
    "properties": {
     "value": {
      "type": "string"
     }
    },
    "required": [
     "value"
    ]
   },
   "cache_control": {
    "type": "ephemeral",
    "ttl": "1h"
   }
  }
 ]
}

anthropic-messages:三个缓存断点

请求构造在 packages/ai/src/api/anthropic-messages.ts:1020-1118。缓存配置先由 getCacheControl 统一成一个对象:

function getCacheControl(model, cacheRetention, env) {
  // 返回类型:{ retention: CacheRetention; cacheControl?: CacheControlEphemeral }
  const retention = resolveCacheRetention(cacheRetention, env);
  // 实测 cacheRetention = "long",retention = "long"。

  if (retention === "none") {
    return { retention };
  }

  const ttl =
    retention === "long" &&
    getAnthropicCompat(model).supportsLongCacheRetention
      ? "1h"
      : undefined;
  // 实测 supportsLongCacheRetention = true,所以 ttl = "1h"。

  return {
    retention,
    cacheControl: {
      type: "ephemeral",
      ...(ttl && { ttl }),
    },
  };
  // cacheControl = {type:"ephemeral", ttl:"1h"},下面三个位置用的是同一个对象引用
}

同一个 cacheControl 对象被放到三个位置:

// 1. system 数组的最后一个 block,anthropic-messages.ts:1064-1089
// 非 OAuth 路径只有一个 block;OAuth 路径会多一个写死的 Claude Code 身份 block,
// 两个 block 都带 cache_control
params.system = [
  { type: "text", text: sanitizeSurrogates(context.systemPrompt),
    ...(cacheControl ? { cache_control: cacheControl } : {}) },
];

// 2. 最后一个工具上,anthropic-messages.ts:1424-1459
// 实际输出字段名是下划线风格,input_schema 是 {type, properties, required}
// 同一个 map 还会条件输出 eager_input_streaming、strict、defer_loading
return tools.map((tool, index) => ({
  name: isOAuthToken ? toClaudeCodeName(tool.name) : tool.name,
  description: tool.description,
  ...(supportsEagerToolInputStreaming ? { eager_input_streaming: true } : {}),
  input_schema: inputSchema,
  ...(cacheControl && index === tools.length - 1 ? { cache_control: cacheControl } : {}),
}));

// 3. 最后一条 user 消息,anthropic-messages.ts:1373-1395
// 条件是 role === "user",且末尾 block 的 type 是 text / image / tool_result
const lastMessage = params[params.length - 1];
if (lastMessage.role === "user") {
  if (Array.isArray(lastMessage.content)) {
    const lastBlock = lastMessage.content[lastMessage.content.length - 1];
    if (lastBlock && (lastBlock.type === "text" || lastBlock.type === "image" || lastBlock.type === "tool_result")) {
      lastBlock.cache_control = cacheControl;
    }
  } else if (typeof lastMessage.content === "string") {
    // 字符串 content 在这里被包成一个 text block,然后才有地方放 cache_control
    lastMessage.content = [{ type: "text", text: lastMessage.content, cache_control: cacheControl }];
  }
}

两次请求的实测断点位置:

第一次  system[0]   tools[0]   messages[0].content[0]
第二次  system[0]   tools[0]   messages[2].content[0]

system 与 tools 两段的值两次相同。第一次 messages[0].content 是被塞了 cache_control 的 block 数组,第二次退回字符串 "开始探路。",断点移到 messages[2].content[0],那条 block 的字段是 type:"tool_result"、tool_use_id:"call_cache_probe"、content:"ECHO:FLOW-42"、is_error:false、cache_control。断点标记本身是 {"type":"ephemeral","ttl":"1h"}。

两个请求走完一次命中

上面那两份请求体按 tools → system → messages 展开成 block 序列。一个 block 就是一个可缓存单元:一条工具定义、一个 system text block、消息里的一个 content block(含 tool_use、tool_result、image)。断点标在哪个 block 上,下面用它结尾的那段前缀就是一个候选条目。

请求1                                    请求2
block1  tools[0]                ◀ 断点 A  block1  tools[0]                  ◀ 断点 A
block2  system[0]               ◀ 断点 B  block2  system[0]                 ◀ 断点 B
block3  messages[0].content[0]  ◀ 断点 C  block3  messages[0].content(字符串,无标记)
                                            block4  messages[1].content[0]   tool_use
                                            block5  messages[2].content[0]   tool_result ◀ 断点 C

请求 1 什么都没有命中,在 A、B、C 三个位置各写一个条目:

   条目1 = tools                      到断点 A=block1
   条目2 = tools + system             到断点 B=block1..2
   条目3 = tools + system + messages[0]   到断点 C=block1..3

请求 2 带三个断点,做三次独立查表,每次查的键是「到该 block 为止的累积前缀哈希」:

  1. 断点 A 查 hash(block1),与请求 1 写的相同,命中。
  2. 断点 B 查 hash(block1..2),命中。
  3. 断点 C 查 hash(block1..5),没有这个条目,于是往回走一个 block 一个 block 地查,走到 block3 之后,命中条目3 。block4、block5 是新内容,正常算 token,同时在 block5 写一个新条目。block1..5

官方文档:

  • 写只发生在断点上,且哈希是累积的:Marking a block with cache_control writes exactly one cache entry: a hash of the prefix ending at that block ... changing any block at or before the breakpoint produces a different hash on the next request. 断点之前任何一个 block 变了,后面所有断点的哈希一起变。
  • 回溯窗口是 20 个位置:The system checks at most 20 positions per breakpoint, counting the breakpoint itself as the first ... The lookback does not find stable content behind your breakpoint; it finds entries that prior requests already wrote at their own breakpoints. 所以一轮新增超过 20 个 block(大量并行工具调用、塞长文档)就会把上一个条目推出窗口,命中断掉;对策是多设一个断点,多开一个窗口。pi 用三个断点,tools 与 system 这两个窗口基本不会被推走,会话那个窗口要求每轮新增 block 少于 20 个。

新条目是「新写」,不是「覆盖」。请求 1 留在 block3 的条目继续存在,到各自 TTL 自然过期;请求 3 的回溯会先撞上 block5 这个更新的条目。命中免费的读让条目续期,但续的是被读到的那一个。

openai-completions:稳定前缀和 cache key

OpenAI 缓存指南中 How prefix matching works 的图示配套代码用的是 Responses API。本节的离线脚本用的是 Chat Completions,两条路径在 pi 里对应不同文件:

请求来源 API 调用 pi 适配器 输入字段
官方图示的配套示例 client.responses.create(...) openai-responses.ts input
本节离线探路脚本 client.chat.completions.create(...) openai-completions.ts messages

pi 根据模型的 api 选择适配器,模型级设置覆盖 provider 级默认值。本机 ~/.pi/agent/models.json 里的 token-plan/qwen3.8-flash 和 agentrouter/gpt-5.6-sol 都走 openai-completions。这个设置只决定请求格式;缓存规则仍取决于 baseUrl 后面的网关和模型服务,不能因为接口兼容就套用 OpenAI 的全部规则。

文档里的 prompt_cache_options

官方 Chat Completions API 参考也列出了 prompt_cache_options,注明支持 GPT-5.6 及之后模型,并说明可通过 content block 上的 prompt_cache_breakpoint 设置显式断点。它们不是 Responses 独占的字段。

pi 0.85.1(源码 be26e32)的 openai-completions.ts 尚未在默认请求构造中接入这些新参数,所以本节的请求体里没有。函数入参 options 是 pi 的调用选项,里面的 sessionId、cacheRetention 会被转换成协议字段;它和请求体顶层的 prompt_cache_options 不是同一个对象。

同一版本的 openai-responses.ts:82-99,303-310 已有新字段映射,受 compat.supportsExplicitPromptCacheMode 控制。该标志为真时,cacheRetention:"none" 生成 {mode:"explicit"};cacheRetention:"long" 且支持长保留时生成 {ttl:"30m"},不再发送旧的 prompt_cache_retention;默认 short 仍省略新字段,由服务端使用默认模式。没有 prompt_cache_options 不等于没有缓存。

prompt_cache_key 与 prompt_cache_retention

openai-completions.ts 每轮仍发送完整的 messages 和 tools,不在本地查缓存。buildParams()(packages/ai/src/api/openai-completions.ts:792-853)按条件附带缓存参数:

const params = {
  model: model.id,
  messages,
  stream: true,

  prompt_cache_key:
    (model.baseUrl.includes("api.openai.com") &&
      cacheRetention !== "none") ||
    (cacheRetention === "long" &&
      compat.supportsLongCacheRetention)
      ? clampOpenAIPromptCacheKey(options?.sessionId)
      : undefined,
  // 实测 sessionId = "agent-loop-cache-probe"
  // prompt_cache_key = "agent-loop-cache-probe"

  prompt_cache_retention:
    cacheRetention === "long" &&
    compat.supportsLongCacheRetention
      ? "24h"
      : undefined,
  // 实测 = "24h"
};

prompt_cache_key 取自 options.sessionId,由 clampOpenAIPromptCacheKey 截到最多 64 个字符。同一会话通常保持同值。它是请求的分组标识,不是提示词内容的哈希,也不指向某条旧答案。

按 OpenAI 当前文档,GPT-5.6 之前的模型用稳定 key 辅助相关请求的缓存路由;GPT-5.6 及之后由 OpenAI 自动处理路由,key 用于按客户或用户分开缓存记账。相同 key 不保证命中,更不能让不同的提示词前缀变成相同前缀。

prompt_cache_retention 选择缓存保留策略,不选择缓存哪段内容。旧式策略有 in_memory 和 24h,可用值随模型变化:前者通常在不活跃 5 到 10 分钟后清除,最长 1 小时;后者通常可用约 30 分钟,最多保留 24 小时。24h 不保证保留满 24 小时。支持两种策略的模型,省略字段时的默认值还取决于组织是否启用 Zero Data Retention。

这份 adapter 只会显式发送 "24h",不会发送 "in_memory"。pi 的 short 表示不发保留字段,不等于指定短期缓存。GPT-5.6 起,指南用 prompt_cache_options.ttl 控制最短缓存寿命,当前只支持 "30m";这和旧字段的最长保留策略不是同一个量。

resolveCacheRetention()(:302-310)先取调用方的 cacheRetention,没给才检查 PI_CACHE_RETENTION=long,否则用 short。下面是 buildParams() 的默认输出;key 一栏假定传入了 sessionId:

cacheRetention endpoint 或能力条件 prompt_cache_key prompt_cache_retention
none 任意 不发 不发
short baseUrl 含 api.openai.com sessionId,最多 64 字符 不发
short 其他地址 不发 不发
long supportsLongCacheRetention:true,不限制地址 sessionId,最多 64 字符 "24h"
long supportsLongCacheRetention:false 仅 OpenAI 地址发 key 不发

所以第三方地址使用默认 short 时,两个字段都可能没有,后端仍可能按自己的规则自动缓存。此路径的 none 也只是停止发送这些参数,不能据此断定服务端缓存已关闭。

请求内容与命中统计

命中发生在服务端。OpenAI 保存的是输入前缀的 KV 状态,后续请求找到可复用条目后,继续处理未命中的输入并生成新回答。pi 从响应 usage 读取命中量,parseChunkUsage()(:1507-1547)兼容三种上报位置:

const cacheReadTokens =
  rawUsage.prompt_tokens_details?.cached_tokens ??
  rawUsage.prompt_cache_hit_tokens ??
  rawUsage.cached_tokens ??
  0;
const cacheWriteTokens =
  rawUsage.prompt_tokens_details?.cache_write_tokens || 0;

它们分别进入 usage.cacheRead 和 usage.cacheWrite。上游不报告写入量时,pi 记为 0,不能据此推断模型没有写缓存或不收写入费。

对照「llmContext 到协议请求体」那节里的 OpenAI 请求体,字段转换是:

llmContext 里的路径与值 请求体里的路径与值
messages[1].content[0].arguments = {value:"FLOW-42"} messages[2].tool_calls[0].function.arguments = "{\"value\":\"FLOW-42\"}"
messages[1].content 只有那一个 block messages[2].content = null
messages[2].toolName = "trace_echo"、toolCallId 只剩 messages[3].tool_call_id,toolName 不外发

两次请求的 messages[0]、messages[1] 与 tools 逐字段相同,key 和 retention 也没变。这保留了可复用的输入前缀,但还不能证明命中;system prompt 或工具定义发生变化,变化位置之后的前缀就不再匹配。

OpenAI-compatible endpoint 如果声明 compat.cacheControlFormat:"anthropic",pi 还会运行:

function applyAnthropicCacheControl(messages, tools, cacheControl) {
  addCacheControlToSystemPrompt(messages, cacheControl);
  addCacheControlToLastTool(tools, cacheControl);
  addCacheControlToLastConversationMessage(messages, cacheControl);
}

这条兼容路径的三个 marker 实测落在 messages[0].content[0](system)、tools[0]、messages[3].content[0](tool),字符串 content 在这一步被包成 block 数组;顶层同时带 prompt_cache_key 与 prompt_cache_retention:"24h"。endpoint 是否接受由配置负责,pi 不探测协议能力。

文章开头那次实机调用走的是 openai-codex-responses,模型为 gpt-5.6-sol,与本节的 Chat Completions 离线构造分开记录。

两个请求

离线构造的请求 1 如下。gpt-cache-probe 是脚本里的占位模型名;没有 prompt_cache_options 是这份 adapter 的输出,不是 Chat Completions 协议禁止该字段:

{
 "model": "gpt-cache-probe",
 "messages": [
  { "role": "system", "content": "Stable system instructions for the cache probe." },
  { "role": "user",   "content": "开始探路。" }
 ],
 "tools": [
  { "type": "function",
    "function": { "name": "trace_echo", "description": "Return the supplied value unchanged.",
                  "parameters": { "type": "object", "required": ["value"],
                                  "properties": { "value": { "type": "string" } } },
                  "strict": false } }
 ],
 "stream": true,
 "stream_options": { "include_usage": true },
 "prompt_cache_key": "agent-loop-cache-probe",
 "prompt_cache_retention": "24h"
}

请求体里的键顺序来自 buildParams() 的写入顺序::807-815 先写 model、messages、stream 和两个缓存字段,:819-856 再补 usage、token 上限、tools 等选项。JSON 对象的键排列本身没有缓存含义;模型渲染后的 token 前缀才是匹配对象。

请求 2 相对请求 1 的变化:

内容 请求 2
tools、messages[0]、messages[1] 保持原值
messages[2] 新增 assistant 工具调用,content:null,参数是 JSON 字符串
messages[3] 新增 tool 消息,正文为 ECHO:FLOW-42,带 tool_call_id
model、stream、usage 开关、key、retention 不变

官方图

缓存指南把渲染后的输入分成隐藏 system 内容、tools、developer message 和 context history。下面只对照内容位置,不把这份短请求当作命中记录:

图例分段 图例说明 请求 1 请求 2
Hidden system message OpenAI-provided instructions 不在请求体里,pi 看不到 同左
Tools Definitions and schemas tools[0] 那条函数定义 逐字段不变
Developer message Application instructions messages[0](pi 放的是 role:"system") 逐字段不变
Context history 会话消息、tool calls 与 results、文本与多模态 messages[1] 一条 user messages[1] 不变,尾部追加 messages[2](tool_calls)与 messages[3](role:"tool")

convertMessages() 在 openai-completions.ts:1215-1218 把 systemPrompt 放入第一条消息:model.reasoning && compat.supportsDeveloperRole 时用 developer,否则用 system。工具定义虽然在 JSON 里写得靠后,在官方图中却位于会话历史之前。role:"tool" 是工具结果消息,属于 context history,不是顶层 tools。

请求 2 新增的两条消息落在 context history 末尾。只有更早的相同前缀已被缓存、长度符合要求、条目仍可用且请求到达持有它的机器时,才可能复用那段前缀。

原文指引,以下落点和回溯数量按指南的 GPT-5.6 及之后模型说明:

  • 隐式模式的落点:When prompt_cache_options.mode is implicit, OpenAI places a breakpoint at the end of the latest eligible message. eligible 消息包括 user、连续工具响应里的最后一条,以及开头连续 developer 消息里的最后一条。
  • 回溯范围:Implicit mode: The first 2 and latest 50 explicit breakpoints, the implicit breakpoint, up to 20 earlier eligible message endings, and the endpoint of the initial consecutive block of developer messages. 服务端沿这些可查边界,从长前缀向短前缀寻找已有条目。

这些是服务端规则,在 openai-completions.ts 里找不到对应的查表循环。指南的模型对照表还区分了旧模型:GPT-5.5 与 5.5 Pro 的隐式断点间隔为 2048 token,更早的模型使用各自的固定间隔。不能从 api:"openai-completions" 或占位名 gpt-cache-probe 推出服务端采用哪套断点规则。

路由还看机器负载和初始 token 的哈希。官方说明,哈希取隐藏 OpenAI 内容之后的初始 token,存在工具定义时也包含在内,取多少 token 随模型变化。修改工具描述或顺序可能同时改变路由哈希和可匹配前缀;相同 key 不会把请求固定到某台机器。缓存保存在具体机器上,负载导致的分流也可能让请求到达没有该条目的机器。

GPT-5.6 起,指南给出的最短可缓存长度是 1024 个可见输入 token,隐藏内容不计入;写入按普通输入的 1.25 倍计费,读取按 0.1 倍。更早的模型没有额外写入费,可缓存长度随请求设置变化,报告的 cached tokens 会扣掉隐藏 token 并向下取整到 128 的倍数。这些长度和价格是 OpenAI 的模型规则,不是 pi 或所有兼容网关的统一规则。

本节两份 Chat Completions 请求只做了离线构造,没有发网络。开头那两条 Codex 实机请求的输入分别为 106、143 token,cacheRead 和 cacheWrite 都是 0。这里能观察到前缀如何保留,尚没有一次实际命中的记录。

哪些改动会打断前缀

  • transformContext 或 convertToLlm 重写旧消息
  • compaction 用摘要替换早期历史
  • before_agent_start 每轮生成不同的 system prompt
  • 工具名称、描述、schema 或顺序发生变化
  • model、thinking、tool choice 等影响 provider 渲染的参数变化
  • 时间戳、随机数、当前状态被放进 system prompt 或历史前部

pi 能做的是让正常工具循环保持“旧消息不动,新消息追加”。缓存是否命中还受最小 token 数、TTL、provider 路由和并发时序影响。Anthropic 应看 cache_creation_input_tokens 与 cache_read_input_tokens;OpenAI-compatible 路径应看 cached_tokens 与 cache_write_tokens。

为命中率做的处理

缓存本体在服务端。客户端能控制的只有请求内容、断点标记和路由参数。0.85.1(be26e32)里可数出的处理:

前缀只增长

位置 行为
agent/src/agent-loop.ts:238-239 工具结果 push 到 currentContext.messages 末尾,不重写旧消息
agent-loop.ts:335,348,363 流式 partial 与 final message 覆写同一个末位槽,messages.length 不变
coding-agent/src/core/agent-session.ts:1316 每轮把 _systemPromptOverride 清回 _baseSystemPrompt,扩展改写的 prompt 不留在前缀里
core/system-prompt.ts:34-147 拼装顺序固定:基础段、appendSystemPrompt、project_context、skills、cwd
agent-session.ts:983,2516 只在激活工具集变化时 rebuild system prompt

断点标记

Anthropic 路径三个位置共用一个 cacheControl 对象:

位置 标记点
anthropic-messages.ts:1065-1089 system;OAuth 身份段两个 block 都带
anthropic-messages.ts:1104-1118 最后一个 immediate 工具,受 supportsCacheControlOnTools(:194)控制
anthropic-messages.ts:1373-1394 最后一条 user 消息末尾 block,含 tool_result;字符串 content 先包成 block 数组才放得下标记

中途加载工具

utils/deferred-tools.ts:8-39 把「运行中途加入、历史里还没被调用」的工具分到 deferred 组,判据是 toolResult.addedToolNames(:25)。分出去之后:

  • Anthropic:排到 tools[] 末尾,第二组 convertTools 的 cacheControl 传 undefined(anthropic-messages.ts:1104-1118)
  • Responses:作为 additional_tools(openai-responses-shared.ts:321-327)或 tool_search_call 加 tool_search_output(:327-347)插进历史,defer_loading 只给 output 那组
  • test/deferred-tools.test.ts:420-446 断言这个 marker 的下标稳定落在该工具首次调用之前

顶层 tools 不随会话推进而变,前缀里的工具定义段因此保持原值。

key 与路由

prompt_cache_key 统一取 sessionId,openai-prompt-cache.ts:3-8 截到 64 字符:openai-completions.ts:810-815、openai-responses.ts:308、openai-codex-responses.ts:267,557。sessionId 来自 sessionManager.getSessionId()(sdk.ts:361),同会话内不变。

亲和性 header 按 compat.sessionAffinityFormat 选格式(openai-completions.ts:766-775、openai-responses.ts:250-257、anthropic-messages.ts:955-956)。Workers AI 靠 x-session-affinity 换 prefix caching 折扣(docs/providers.md)。

Codex 的增量请求

openai-codex-responses.ts:1404-1443:先比除 input 与 previous_response_id 以外的请求体(:1390-1402),相同再检查当前 input 是否以「上次 input 加上次输出」为前缀,两条都成立才只发新增部分。连接按 sessionId 分组,空闲 5 分钟关闭(:832,1032-1038)。这是 pi 唯一不整段重发的路径,条件不满足退回完整请求。

TTL 与主动关掉的场景

  • PI_CACHE_RETENTION=long 换算成各协议的长保留字段
  • compat.supportsExplicitPromptCacheMode 为真且 retention 是 none 时,Responses 发 {mode:"explicit"}(openai-responses.ts:77,91-99)
  • supportsMidConvoEffort 的模型用 insertThinkingLevelMessages(anthropic-messages.ts:1404-1416)重建历史 effort marker,并设 prefix_mismatch_behavior:"drop_block"(:1127),避免前缀不匹配持续返 400
  • 摘要与分支请求写定 cacheRetention:"none"(compaction.ts:586-593、harness/runtime/drive/structural.ts:787),注释写明理由是一次性摘要不写条目;没传 sessionId 时现造一个 uuidv7,不复用会话 key

没有的部分:不探测 endpoint 能力,全看 compat 声明;openai-completions.ts 不生成 prompt_cache_options 与 prompt_cache_breakpoint;Anthropic 的断点位置写死,没有按内容变化率调整的机制。parseChunkUsage(openai-completions.ts:1519-1521)兼容三种 cached tokens 上报位置,属统计口径。

参考

  • Anthropic Prompt Caching:缓存层级、20-block lookback、TTL 和最小 token 数
  • OpenAI Prompt Caching:前缀匹配、模型代际差异、cache key、retention 和 usage
  • OpenAI Chat Completions API 参考:prompt_cache_options、prompt_cache_retention 的字段定义
  • packages/agent/src/agent-loop.ts:96-119,156-370,426-429,589-591
  • packages/agent/src/types.ts:28,53,126-149,301,326,387-446(StreamFn、AgentToolCall、轮次快照与循环回调、ThinkingLevel、AgentMessage、AgentTool、AgentContext、AgentEvent)
  • packages/ai/src/types.ts:345-404,422-470,517-528,546-562(TextSignatureV1、TextContent、ToolCall、Usage、三种消息、Tool、Context、AssistantMessageEvent)
  • packages/ai/src/utils/event-stream.ts:91(AssistantMessageEventStream)
  • packages/ai/src/api/openai-responses-shared.ts:700-718(textSignature 写入与 partialJson 剪掉)
  • packages/ai/src/api/anthropic-messages.ts:51-77,194,1020-1130,1373-1459
  • packages/ai/src/api/openai-completions.ts:302-310,766-775,792-853,1062-1175,1507-1547
  • packages/ai/src/api/openai-prompt-cache.ts:1-8(sessionId 截断)
  • packages/ai/src/api/openai-responses.ts:77,91-99,250-257,308-310(新版缓存参数映射)
  • packages/ai/src/api/openai-responses-shared.ts:314-347(additional_tools 与 tool search 两条延迟工具路径)
  • packages/ai/src/api/openai-codex-responses.ts:267,557,832,1390-1443(WebSocket 增量续跑)
  • packages/ai/src/utils/deferred-tools.ts:8-39(splitDeferredTools)
  • packages/coding-agent/src/core/agent-session.ts:983,1316,2516、core/system-prompt.ts:34-147
  • packages/coding-agent/src/core/compaction/compaction.ts:586-593