Skip to content

Latest commit

 

History

History
337 lines (253 loc) · 9.09 KB

File metadata and controls

337 lines (253 loc) · 9.09 KB

第三章 · Agent Loop

Agent Loop 是 Pi 的引擎——它决定了模型如何被循环调用、什么时候停下来。
全部逻辑在 agent-loop.ts 一个文件中,约 800 行代码。


3.1 为什么需要循环

LLM 本身是一问一答的:你给它一段输入,它返回一段输出。但 Agent 需要多轮自主执行

  1. 用户说"帮我改 bug"
  2. LLM 回复"我需要先读代码" → 调用 read 工具
  3. 读到代码后,LLM 说"找到问题了" → 调用 edit 工具
  4. 修改完后,LLM 说"运行测试看看" → 调用 bash 工具
  5. 测试通过,LLM 说"修好了"→ 停止

这个"调用 LLM → 执行工具 → 把结果喂回去 → 再调用 LLM"的过程,就是 Agent Loop

3.2 双循环架构

Pi 的 Agent Loop 由内循环外循环组成:

@startuml
skinparam backgroundColor transparent
skinparam ActivityBackgroundColor #f8f9fa
skinparam ActivityBorderColor #dee2e6

start

:检查 Steering 消息队列;
note right: 用户可能在等待时输入了新指令

partition "外循环 (Outer Loop)" {
  repeat
    partition "内循环 (Inner Loop)" {
      repeat
        :注入 Steering 消息(如有);
        :调用 LLM,获取 AssistantMessage;
        
        if (stopReason == error/aborted?) then (yes)
          :发射 agent_end;
          stop
        endif
        
        if (有 toolCall?) then (yes)
          :执行工具调用;
          :工具结果 → ToolResultMessage;
          :加入上下文;
        else (no)
          :hasMoreToolCalls = false;
        endif
        
        :检查 shouldStopAfterTurn;
        :检查 Steering 消息队列;
        
      repeat while (有工具调用 OR 有 Steering 消息?) is (yes)
    }
    
    :检查 Follow-up 消息队列;
    
  repeat while (有 Follow-up 消息?) is (yes)
}

:发射 agent_end;
stop

@enduml

内循环(工具执行循环)

只要 LLM 还在调用工具,或者有 Steering 消息等待处理,内循环就继续运转。

核心代码agent-loop.ts:155):

// 内循环条件:有工具调用 OR 有 Steering 消息
while (hasMoreToolCalls || pendingMessages.length > 0) {
  // 1. 注入 pending messages
  if (pendingMessages.length > 0) {
    for (const message of pendingMessages) {
      currentContext.messages.push(message);
    }
    pendingMessages = [];
  }

  // 2. 调用 LLM,获取 assistant 回复
  const message = await streamAssistantResponse(currentContext, config, signal, emit, streamFunction);

  // 3. 检查是否有工具调用
  const toolCalls = message.content.filter(c => c.type === "toolCall");
  hasMoreToolCalls = false;

  if (toolCalls.length > 0) {
    // 4. 执行工具
    const result = await executeToolCalls(currentContext, message, config, signal, emit);
    hasMoreToolCalls = !result.terminate;

    // 5. 工具结果加入上下文
    for (const r of result.messages) {
      currentContext.messages.push(r);
    }
  }

  // 6. 检查 Steering 消息
  pendingMessages = (await config.getSteeringMessages?.()) || [];
}

外循环(Follow-up 循环)

当内循环结束(LLM 不再调用工具)后,检查是否有 Follow-up 消息。如果有,重新进入内循环。

// 外循环:检查 Follow-up 消息
const followUpMessages = (await config.getFollowUpMessages?.()) || [];
if (followUpMessages.length > 0) {
  pendingMessages = followUpMessages;
  continue; // 重新进入内循环
}
break; // 没有更多消息,退出

3.3 Steering:运行时注入消息

Steering(转向)是 Pi 的独特设计——允许在 Agent 运行过程中注入消息改变 Agent 的行为。

@startuml
skinparam backgroundColor transparent

actor User
participant "Agent" as A
participant "Agent Loop" as Loop
participant "LLM" as LLM

User -> A: agent.prompt("分析代码")
A -> Loop: 启动循环
Loop -> LLM: 调用模型
LLM --> Loop: 调用 read 工具
Loop -> Loop: 执行 read 工具

User -> A: agent.steer("停下来,先看 tests/")
note right: 用户在 Agent 运行时\n注入 Steering 消息

Loop -> Loop: 检查 Steering 队列
Loop -> Loop: 注入 Steering 消息到上下文
Loop -> LLM: 再次调用模型(包含新消息)
LLM --> Loop: "好的,我来看 tests/"

@enduml

Steering 消息的投递模式:

模式 行为
one-at-a-time 每次循环只取一条(默认)
all 一次性取出所有待处理消息

3.4 LLM 调用:最晚转换

streamAssistantResponse() 是 Agent Loop 调用 LLM 的唯一入口。它做了一件关键的事:最晚转换(Late Conversion)。

async function streamAssistantResponse(context, config, signal, emit, streamFunction) {
  // 1. 可选:转换上下文(如压缩)
  let messages = context.messages;
  if (config.transformContext) {
    messages = await config.transformContext(messages, signal);
  }

  // 2. 关键:AgentMessage[] → Message[]
  //    只有在即将调 LLM 的这一刻才做转换
  const llmMessages = await config.convertToLlm(messages);

  // 3. 构建 LLM 上下文
  const llmContext = {
    systemPrompt: context.systemPrompt,
    messages: llmMessages,
    tools: context.tools,
  };

  // 4. 调用 LLM
  const response = await streamFunction(config.model, llmContext, { ...config, signal });

  // 5. 流式处理响应事件...
}

为什么要"最晚转换"?→ 详见第五章 · 消息系统

3.5 工具执行策略

当 LLM 在一次回复中返回多个工具调用时,Pi 支持两种执行策略:

并行执行(默认)

// 所有工具调用同时启动
const results = await Promise.all(
  toolCalls.map(tc => executePreparedToolCall(tc, signal, emit))
);

顺序执行

// 逐个执行,前一个完成后再执行下一个
for (const toolCall of toolCalls) {
  const result = await executePreparedToolCall(toolCall, signal, emit);
  results.push(result);
}

单个工具也可以声明自己必须顺序执行:

const bashTool: AgentTool = {
  name: "bash",
  executionMode: "sequential",  // 强制顺序
  // ...
};

3.6 工具调用的五步生命周期

每个工具调用都经历五个阶段:

@startuml
skinparam backgroundColor transparent

rectangle "1. prepareToolCall" as P1 #E8F5E9 {
}
rectangle "2. beforeToolCall\n(hook)" as P2 #FFF3E0 {
}
rectangle "3. executePreparedToolCall" as P3 #E3F2FD {
}
rectangle "4. afterToolCall\n(hook)" as P4 #FFF3E0 {
}
rectangle "5. createToolResultMessage" as P5 #F3E5F5 {
}

P1 --> P2 : 验证参数
P2 --> P3 : 未被拦截
P3 --> P4 : 获得执行结果
P4 --> P5 : 可能修改结果

note bottom of P1: 查找工具、校验参数
note bottom of P2: 扩展可在此拦截/修改
note bottom of P3: 实际执行工具逻辑
note bottom of P4: 扩展可修改结果
note bottom of P5: 封装为 ToolResultMessage

@enduml

代码体现(简化):

// Step 1: 准备
const preparation = await prepareToolCall(context, message, toolCall, config, signal);

// Step 2: beforeToolCall hook(可拦截)
if (config.beforeToolCall) {
  const result = await config.beforeToolCall({ toolCall, args }, signal);
  if (result?.block) return errorResult(result.reason);
}

// Step 3: 执行
const executed = await executePreparedToolCall(preparation, signal, emit);

// Step 4: afterToolCall hook(可修改结果)
if (config.afterToolCall) {
  const patch = await config.afterToolCall({ toolCall, result: executed.result }, signal);
  if (patch) { /* 合并修改 */ }
}

// Step 5: 封装为消息
const toolResultMessage = createToolResultMessage(finalized);

3.7 截断保护

如果 LLM 的输出被 token 限制截断(stopReason === "length"),工具调用的参数可能不完整。Pi 不会冒险执行:

if (message.stopReason === "length") {
  // 所有工具调用都标记为错误,要求 LLM 重新发送
  return failToolCallsFromTruncatedMessage(toolCalls, emit);
}

错误消息会提示 LLM:

Tool call "read" was not executed: the response hit the output token limit, so its arguments may be truncated. Re-issue the tool call with complete arguments.

3.8 事件发射时序

Agent Loop 在运行过程中发射一系列事件,外部(如 UI)通过订阅事件来响应:

agent_start
  turn_start
    message_start (user message)
    message_end
    message_start (assistant response - streaming)
    message_update (text_delta / toolcall_delta)
    message_end
    tool_execution_start
    tool_execution_update (partial results)
    tool_execution_end
    message_start (tool result)
    message_end
  turn_end
  turn_start
    ... (下一轮)
  turn_end
agent_end

3.9 本章检查清单

  • 能说出内循环和外循环各自的退出条件
  • 理解 Steering 消息的作用和投递时机
  • 知道工具调用的五步生命周期
  • 理解截断保护机制存在的原因
  • 能列出 Agent Loop 发射的主要事件类型

上一章第二章 · LLM 抽象层
下一章第四章 · 工具系统 — Agent 的"手"