跟着OpenCode学习Pi Coding Agent-06-Agent循环

第 6 章:实习生的第一天

故事场景

你招了一个实习生。你给他一个任务:

"帮我把 src/ 下所有 .js 文件里的 var 改成 const。"

如果这个实习生是真人,他的工作流程大概是:

  1. :这个任务需要我做什么?"哦,先找文件,再改内容。"
  2. :打开终端,敲 ls src/*.js,看有哪些文件。
  3. :拿到文件列表了。下一步呢?"呃,find "var" a.js------不对,我应该用 sed。"
  4. 再做 :敲一串 sed 命令。
  5. 检查:改完了,看看有没有遗漏。
  6. 报告:"老板,改了 5 个文件,14 处。"

每一步他不是提前想好的------他是走一步看一步。每一步的输出决定了下一步做什么。

这就是 Agent 循环。LLM 本身是一个只会"回答问题"的机器------你问它答。要让它变成一个能自主完成多步任务的 Agent,你需要一个循环:

复制代码
while (任务没完成) {
  让 LLM 看一下当前情况 → LLM 说"我要调这个工具" → 执行工具 → 把结果告诉 LLM → 继续
}

这一章是整个教程的心脏。前面的四章都在铺路------接电话、定规矩、流式输出、统一遥控器------现在,你要把这些路接在一起,让实习生(Agent)跑起来。

这一章要做什么

  1. src/agent/types.ts 中定义 Agent 专属类型(AgentTool、AgentLoopConfig、AgentEvent)
  2. 实现 convertToLlm()------Agent 消息格式和 LLM 消息格式之间的翻译官
  3. 实现 Agent 循环 本身------agentLoop():while 循环 + 流式响应 + 工具检测 + 执行 + 回环
  4. 写一个验证脚本------给 Agent 一个任务,观察它的完整工作过程

做完后你会看到:

复制代码
🐣 Agent 启动
📝 用户: 用一句话解释什么是闭包
🤖 AI: 闭包是...(流式输出)
✅ Agent 完成

而当你给它一个需要调工具的任务时:

复制代码
🐣 Agent 启动
📝 用户: 读一下 package.json,告诉我项目名
🤖 AI: 我需要先读文件(toolcall: readFile)
🔧 执行工具: readFile → 读取 package.json
📊 工具结果: { "name": "my-pi", ... }
🤖 AI: 项目名是 my-pi
✅ Agent 完成

实现

准备工作:确认上一章的产物

在开始之前,你的 src/ 目录应该已经有这些文件(第 1-5 章的产出):

复制代码
src/
├── types.ts          # Message, Context, Tool, AssistantMessageEvent 类型定义
├── event-stream.ts   # EventStream 流式事件队列
├── models.ts         # Provider 接口 + Models 集合 + createModels()
├── providers/
│   └── deepseek.ts   # deepseekProvider()
├── agent/
│   └── types.ts      # 第 5 章产物:AgentMessage, AgentContext, AgentTool, AgentEvent 等
└── index.ts          # 入口文件

如果你还没有第 5 章的内容,下面是 src/agent/types.ts 的最小可用版本。把它复制进去:

typescript 复制代码
// src/agent/types.ts
// Agent 专属的类型定义------这些是在上一章 Message/Tool 的基础上做的扩展

import type { Message, ToolResultMessage, AssistantMessage, Model } from "../types.js";
import type { Models } from "../models.js";

// ─── AgentTool:比基础 Tool 多了一个 execute 方法 ───
// 基础 Tool 只是"说明书"(名字、描述、参数),AgentTool 多了"实际执行的代码"
// 就像说明书 vs. 真正会做饭的厨师

export interface AgentTool<TParams = any, TResult = any> {
  /** 工具名------必须唯一 */
  name: string;
  /** 工具描述------LLM 通过这个判断什么时候该用它 */
  description: string;
  /** 参数 schema------告诉 LLM 参数该长什么样 */
  parameters: Record<string, any>;
  /** 真正执行工具的代码 */
  execute: (
    toolCallId: string,
    params: TParams,
    signal?: AbortSignal,
  ) => Promise<AgentToolResult<TResult>>;
}

// ─── AgentToolResult:工具执行的结果 ───
// 工具可以返回文本、图片,以及结构化的额外信息(details)

export interface AgentToolResult<T = any> {
  /** 返回给 LLM 的内容------文字或图片 */
  content: Array<{ type: "text"; text: string } | { type: "image"; url: string }>;
  /** 结构化详情------给 UI 或者其他工具看的 */
  details?: T;
}

// ─── AgentMessage:Agent 内部使用的消息 ───
// 继承自基础的 Message 联合类型,为后续扩展预留空间
// 第 8 章会加入 steering、notification 等自定义消息

export type AgentMessage = Message;

// ─── AgentContext:Agent 的"工作台" ───
// 一次 Agent 运行需要的全部上下文

export interface AgentContext {
  /** 系统提示词------Agent 的行为准则 */
  systemPrompt: string;
  /** 对话历史------之前所有轮的消息 */
  messages: AgentMessage[];
  /** Agent 可以使用的工具箱 */
  tools?: AgentTool[];
}

// ─── AgentLoopConfig:控制 Agent 循环行为的配置 ───

export interface AgentLoopConfig {
  /** 当前使用的模型 */
  model: Model;
  /** Models 集合------用来发 LLM 请求 */
  models: Models;
  /** 把 AgentMessage[] 转成 LLM 能理解的 Message[] ------消息格式翻译官 */
  convertToLlm?: (messages: AgentMessage[]) => Message[] | Promise<Message[]>;
  /** 工具执行模式------本章只支持 "sequential"(顺序执行) */
  toolExecution?: "sequential" | "parallel";
  /** 中断信号------用户想停下来时用 */
  signal?: AbortSignal;
}

// ─── AgentEvent:Agent 工作过程中的"直播事件" ───
// 每一个事件都对应 Agent 工作流程中的一个时间点
// UI 层通过监听这些事件来更新界面

export type AgentEvent =
  // Agent 生命周期
  | { type: "agent_start" }
  | { type: "agent_end"; messages: AgentMessage[] }
  // 一轮对话的开始和结束(一轮 = 一次 LLM 调用 + 可能的工具执行)
  | { type: "turn_start" }
  | { type: "turn_end"; message: AssistantMessage; toolResults: ToolResultMessage[] }
  // 消息生命周期
  | { type: "message_start"; message: AgentMessage }
  | { type: "message_update"; message: AgentMessage; assistantMessageEvent: AssistantMessageEvent }
  | { type: "message_end"; message: AgentMessage }
  // 工具执行生命周期
  | { type: "tool_execution_start"; toolCallId: string; toolName: string; args: any }
  | { type: "tool_execution_end"; toolCallId: string; toolName: string; result: ToolResultMessage; isError: boolean };

第一步:理解核心流程------Agent 是怎么"想→做→想→做"的

在写代码之前,用一张图理解整个循环:

复制代码
用户说: "读 package.json,告诉我项目名"
    │
    ▼
┌──────────────────────────────────────────┐
│  agentLoop()                             │
│                                          │
│  while (任务没完成) {                     │
│    ┌─────────────────────────────┐       │
│    │ 1. convertToLlm(messages)   │       │
│    │    把 Agent 的消息转成       │       │
│    │    LLM 能理解的格式          │       │
│    └──────────┬──────────────────┘       │
│               ▼                          │
│    ┌─────────────────────────────┐       │
│    │ 2. models.stream(model, ctx)│       │
│    │    流式调用 LLM             │       │
│    └──────────┬──────────────────┘       │
│               ▼                          │
│    ┌─────────────────────────────┐       │
│    │ 3. 处理流式事件             │       │
│    │    text_delta → 逐字打印    │       │
│    │    toolcall_end → 收集工具  │       │
│    │    done/error → 拿到完整回复│       │
│    └──────────┬──────────────────┘       │
│               ▼                          │
│    ┌─────────────────────────────┐       │
│    │ 4. 检查 stopReason          │       │
│    │    "stop" → 任务完成,退出   │       │
│    │    "toolUse" → 继续执行工具  │       │
│    └──────────┬──────────────────┘       │
│               ▼ (stopReason === "toolUse")│
│    ┌─────────────────────────────┐       │
│    │ 5. 执行工具调用             │       │
│    │    tool.execute(args)       │       │
│    └──────────┬──────────────────┘       │
│               ▼                          │
│    ┌─────────────────────────────┐       │
│    │ 6. 把工具结果追加到 messages │       │
│    │    messages.push(toolResult) │       │
│    └──────────┬──────────────────┘       │
│               │                          │
│               └──→ 回到循环开头          │
│          (LLM 看到工具结果后继续思考)      │
│  }                                       │
└──────────────────────────────────────────┘

第二步:实现 convertToLlm()------消息翻译官

这是 Agent 和 LLM 之间的桥。为什么需要它?

Agent 的消息系统比 LLM 的丰富。Agent 内部可能有各种自定义消息类型------通知、状态更新、UI 指令------但 LLM 只认三种角色:userassistanttoolResultconvertToLlm() 的工作就是过滤掉 LLM 不认识的,只留下它需要的

就像你跟外国客户开会,旁边站着一个翻译------你说了一堆中文(包括自言自语、跟同事商量),翻译只把"要告诉客户的部分"翻成英语。

创建 src/agent/agent-loop.ts,先写这个翻译函数:

typescript 复制代码
// src/agent/agent-loop.ts

import type {
  Message, Context, Model,
  AssistantMessage, ToolCall, ToolResultMessage,
  AssistantMessageEvent,
} from "../types.js";
import type { Models } from "../models.js";
import type {
  AgentMessage, AgentContext, AgentLoopConfig,
  AgentEvent, AgentTool,
} from "./types.js";

// ─── 默认的消息转换器 ───
// AgentMessage[] → Message[]
// 为什么需要这个函数?
// Agent 内部有多种消息类型(以后还会有 steering、notification、artifact 等自定义消息)
// 但 LLM 的 API 只认三种角色:user、assistant、toolResult
// 这个函数就是"翻译官"------把 Agent 的丰富消息格式翻译成 LLM 的简单格式
// 不认识的类型直接过滤掉,不会报错
//
// 举例:
//   输入: [user消息, assistant消息, notification消息, toolResult消息]
//   输出: [user消息, assistant消息, toolResult消息]
//         ↑ notification 被过滤掉了,因为 LLM 不认识它

export function defaultConvertToLlm(messages: AgentMessage[]): Message[] {
  return messages.filter(
    (m) => m.role === "user" || m.role === "assistant" || m.role === "toolResult",
  );
}

第三步:实现 Agent 循环------心脏部分

继续在 src/agent/agent-loop.ts 里写。这是本章的核心,也是最长的函数。

我们把 Agent 循环实现为一个 AsyncGenerator(异步生成器函数)。为什么不用普通函数 + 回调?

  • AsyncGenerator 能让调用方用 for await (const event of agentLoop(...)) 逐个消费事件
  • 事件和 LLM 调用是同一条时间线上的------生成器天然匹配这个模式
  • 调用方可以随时 break 退出(支持中断),而不需要额外的取消机制

先放一个辅助函数------updatePartialContent。它负责把流式接收到的每个文本/工具碎片拼接到 partialMessage 里。agentLoop 每收到一个事件就调它一次。

typescript 复制代码
// ─── 辅助函数:把流式事件合并到 partialMessage ───
//
// LLM 的流式响应是这样过来的:
//   text_start  →  "AI 开始写第一段文字"
//   text_delta  →  "闭"
//   text_delta  →  "包"
//   text_end    →  "闭包是..."
//   toolcall_start → "AI 要调工具了"
//   toolcall_delta →  '{"name"'
//   toolcall_end   →  完整的 ToolCall 对象
//   done          →  整个回复结束了
//
// 这个函数把每个"小碎片"拼接到正确的位置(通过 contentIndex 索引)

function updatePartialContent(
  partial: AssistantMessage,
  event: AssistantMessageEvent,
): AssistantMessage {
  // start、done、error 三种事件不需要拼内容------直接处理
  if (event.type === "start") return partial;                  // 流开始,不管
  if (event.type === "done" || event.type === "error") {       // 流结束,拿完整消息
    return event.message;
  }

  // 确保 content 数组里对应位置有内容块
  while (partial.content.length <= event.contentIndex) {
    partial.content.push({ type: "text", text: "" } as any);
  }

  const block = partial.content[event.contentIndex];

  switch (event.type) {
    case "text_start":
      partial.content[event.contentIndex] = { type: "text", text: "" };
      break;

    case "text_delta":
      if (block.type === "text") {
        partial.content[event.contentIndex] = { ...block, text: block.text + event.delta };
      }
      break;

    case "text_end":
      if (block.type === "text") {
        partial.content[event.contentIndex] = { ...block, text: event.content };
      }
      break;

    case "toolcall_start":
      partial.content[event.contentIndex] = { type: "toolCall", id: "", name: "", arguments: {} };
      break;

    case "toolcall_end":
      partial.content[event.contentIndex] = event.toolCall;
      break;
  }

  return partial;
}

现在开始写主角------agentLoop 函数。这是整个教程的心脏。

typescript 复制代码
// ─── Agent 循环:让 LLM 变成一个能自主行动的 Agent ───
//
// 这就是整个教程的心脏。它的工作方式跟真人实习生一样:
// 1. 接到任务
// 2. 想一想(调 LLM)
// 3. 需要动手就动手(执行工具)
// 4. 把动手的结果拿回来继续想(把 tool result 喂回 LLM)
// 5. 重复直到"我做好了"或"我做不了"
//
// 参数说明:
//   prompts  - 用户本轮说的话(可以是多条消息)
//   context  - 当前的对话上下文(历史 + 系统提示词 + 工具箱)
//   config   - 循环配置(模型、Models集合、转换函数等)
//
// 函数返回一个 AsyncGenerator------调用方逐个消费 AgentEvent

export async function* agentLoop(
  prompts: AgentMessage[],
  context: AgentContext,
  config: AgentLoopConfig,
  models: Models,   // Models 集合(第 4 章)------agent loop 不持有它,由调用方传入
): AsyncGenerator<AgentEvent> {

  // ─── 阶段 0:初始化 ───

  // 告诉外界:Agent 开始干活了
  yield { type: "agent_start" };

  // 把用户本轮说的消息追加到对话历史上
  // 注意:我们复制了一个新数组,不修改原来的 context.messages
  const messages: AgentMessage[] = [...context.messages, ...prompts];

  // 开始第一轮------通知外界一轮对话开始了
  yield { type: "turn_start" };

  // 逐条发射用户消息事件------UI 可以据此渲染用户说的话
  for (const prompt of prompts) {
    yield { type: "message_start", message: prompt };
    yield { type: "message_end", message: prompt };
  }

  // 拿到消息转换函数------如果用户没提供,用默认的
  const convertToLlm = config.convertToLlm ?? defaultConvertToLlm;

  // ─── 主循环:Agent 会一直工作直到下面三种情况之一 ───
  // 情况 1: AI 正常结束了(stopReason === "stop")
  // 情况 2: AI 出错了或超长了(stopReason === "error" / "length")
  // 情况 3: 被外部中断了(signal.aborted)

  while (true) {
    // 检查是否被中断------比如用户按了 Ctrl+C
    if (config.signal?.aborted) {
      yield { type: "agent_end", messages };
      return;
    }

    // ─── 阶段 1:准备发请求 ───

    // 把 Agent 的消息格式转成 LLM 能理解的格式
    const llmMessages = await convertToLlm(messages);

    // 构建 LLM 的上下文包------系统提示词 + 转换后的消息 + 工具定义
    // 注意:传给 LLM 的工具定义是"说明书"版本(name + description + parameters)
    // 不包含 execute 方法------LLM 不需要知道工具怎么执行的
    const llmContext = {
      systemPrompt: context.systemPrompt,
      messages: llmMessages,
      tools: context.tools?.map((t) => ({
        name: t.name,
        description: t.description,
        parameters: t.parameters,
      })),
    };

    // ─── 阶段 2:呼叫 LLM(流式) ───

    const model = config.model;
    const stream = models.stream(model, llmContext);

    // 准备一个"半成品"助手消息------流式响应期间,内容会逐步填充
    // partialMessage 会随着每个 text_delta 事件不断更新
    let partialMessage: AssistantMessage = {
      role: "assistant",
      content: [],
      usage: { input: 0, output: 0, total: 0 },
      stopReason: "stop",
      timestamp: Date.now(),
    };

    // 告诉外界:AI 开始回复了(此时内容还是空的)
    yield { type: "message_start", message: partialMessage };

    // 读取流式事件的 reader
    const reader = stream.getReader();

    // ─── 阶段 3:消费 LLM 的流式事件 ───
    // 这里我们逐个处理 AI 的"打字过程"中的每个事件

    while (true) {
      const { done, value: event } = await reader.read();
      if (done) break;

      switch (event.type) {
        case "text_delta": {
          // AI 又吐出一个字(或一个词)------更新 partialMessage 的内容
          // 为什么做 contentIndex?AI 的回复是一个数组,可能有多个文本块
          // contentIndex=0 是第一个文本块,contentIndex=1 是第二个...
          partialMessage = updatePartialContent(partialMessage, event);
          // 通知外界内容更新了------UI 可以用这个事件刷新显示
          yield { type: "message_update", message: partialMessage, assistantMessageEvent: event };
          break;
        }

        case "toolcall_delta": {
          // AI 在构造工具调用的参数------同样是逐片传来的 JSON
          partialMessage = updatePartialContent(partialMessage, event);
          break;
        }

        case "done": {
          // LLM 正常完成了------拿到完整的 AssistantMessage
          // event.message 是从 event-stream 的 onMessage 回调构造的完整消息
          // 它包含了所有 content 片段、usage、stopReason
          partialMessage = event.message;
          break;
        }

        case "error": {
          // LLM 这边出错了------也包装成一个 AssistantMessage
          partialMessage = event.message;
          break;
        }

        // start、text_start、text_end、toolcall_start、toolcall_end 等事件
        // 它们的更新逻辑在 updatePartialContent 里统一处理
        default: {
          partialMessage = updatePartialContent(partialMessage, event);
          break;
        }
      }
    }

    // ─── 阶段 4:拿到了完整的 AI 回复 ───

    const assistantMessage = partialMessage;

    // 发送"消息结束"事件------UI 可以据此结束渲染
    yield { type: "message_end", message: assistantMessage };

    // 把 AI 的回复加入对话历史------下次循环 LLM 能看到它
    messages.push(assistantMessage);

    // ─── 阶段 5:判断 AI 是否完成了任务 ───

    // stopReason 是 LLM 的"停下来原因"
    // - "stop": AI 觉得说完了,任务完成
    // - "toolUse": AI 需要调工具------它的话还没说完
    // - "length": 输出达到长度上限------AI 还想说但被截断了
    // - "error": 出错了
    // - "aborted": 被中断了

    if (assistantMessage.stopReason !== "toolUse") {
      // AI 没有要调工具------任务结束(正常结束、出错、或超长)
      // 发送 turn_end → agent_end,然后退出循环
      yield { type: "turn_end", message: assistantMessage, toolResults: [] };
      yield { type: "agent_end", messages };
      return;
    }

    // ─── 阶段 6:AI 要调工具了------执行工具调用 ───

    // 从 assistantMessage.content 里提取所有 toolCall 类型的片段
    const toolCalls = assistantMessage.content.filter(
      (c): c is ToolCall => c.type === "toolCall",
    );

    // 收集本轮所有工具执行的结果
    const toolResults: ToolResultMessage[] = [];

    for (const toolCall of toolCalls) {
      // 找到对应的工具定义------在 context.tools 里按名字匹配
      const tool: AgentTool<any, any> | undefined = context.tools?.find(
        (t) => t.name === toolCall.name,
      );

      let result: ToolResultMessage;

      if (!tool) {
        // 工具不存在------AI 可能幻觉了,叫了一个没注册的工具名
        result = {
          role: "toolResult",
          toolCallId: toolCall.id,
          toolName: toolCall.name,
          content: [{ type: "text", text: `错误:找不到工具 "${toolCall.name}"。可用工具:${context.tools?.map(t => t.name).join(", ") || "无"}` }],
          isError: true,
          timestamp: Date.now(),
        };
      } else {
        // 通知外界:开始执行工具
        yield {
          type: "tool_execution_start",
          toolCallId: toolCall.id,
          toolName: toolCall.name,
          args: toolCall.arguments,
        };

        // 真正执行工具------调用 tool.execute()
        // 这是 Agent 真正"动手"的地方
        try {
          const executeResult = await tool.execute(
            toolCall.id,
            toolCall.arguments,
            config.signal,
          );

          result = {
            role: "toolResult",
            toolCallId: toolCall.id,
            toolName: toolCall.name,
            content: executeResult.content,
            isError: false,
            timestamp: Date.now(),
          };
        } catch (error) {
          // 工具执行出错了------把错误包装成 toolResult
          result = {
            role: "toolResult",
            toolCallId: toolCall.id,
            toolName: toolCall.name,
            content: [
              {
                type: "text",
                text: `工具执行出错:${error instanceof Error ? error.message : String(error)}`,
              },
            ],
            isError: true,
            timestamp: Date.now(),
          };
        }

        // 通知外界:工具执行完毕
        yield {
          type: "tool_execution_end",
          toolCallId: toolCall.id,
          toolName: toolCall.name,
          result,
          isError: result.isError,
        };
      }

      // 把工具结果加入本轮结果列表
      toolResults.push(result);
      // 把工具结果加入对话历史------下次循环时 LLM 会看到
      messages.push(result);
      // 通知外界有新消息
      yield { type: "message_start", message: result };
      yield { type: "message_end", message: result };
    }

    // 本轮结束------发送 turn_end 事件
    yield { type: "turn_end", message: assistantMessage, toolResults };

    // ─── 回到循环开头 ───
    // LLM 现在会看到对话历史里多了 toolResult 消息
    // 它会根据工具执行结果决定下一步:继续调工具、还是给出最终答案
  }
}

第四步:为什么这个设计是对的

设计决策 为什么这样做
while(true) 无限循环 Agent 不知道要调用多少次 LLM------可能 1 次就结束,可能 10 次。循环的退出条件由 LLM 的 stopReason 决定
用 AsyncGenerator 事件是逐步产生的(LLM 在打字、工具在执行),生成器天然匹配"逐步产出"的模式。调用方用 for await 消费,代码清晰
不修改输入的 context const messages = [...context.messages, ...prompts] 是浅拷贝------Agent 不污染传入的上下文,方便重试
LLM 的 messages 和 Agent 的 messages 分开管理 Agent 的 messages 包含更多类型(未来会有 steering 等),LLM 只看到过滤后的子集。转换在每次 LLM 调用前动态执行
工具执行在同一个 while 循环里 执行完工具后立即回到循环开头------LLM 马上就能看到工具结果并决定下一步。不需要额外的"等待 LLM 回来"机制
contentIndex 索引体系 LLM 的一次回复可能同时包含文本和工具调用------"我要读文件了(text),文件名是...(正在构造 toolCall),读它的原因是...(另一个 text)"。contentIndex 给每个片段标号,保证拼接不错位
工具错误不中断循环 工具执行出错时,我们把错误包装成 toolResult 的 error 消息传回 LLM。LLM 看到 "readFile 执行失败" 后会自己决定怎么办------重试、换个工具、还是告诉用户"我读不了"

第五步:创建一个简单的测试工具------echo

为了能验证 Agent 循环,我们需要至少一个工具。创建一个极简的 echo 工具------它不做任何真实操作,只是把输入的参数原样返回。

创建 src/agent/test-tools.ts

typescript 复制代码
// src/agent/test-tools.ts
// 测试用的极简工具------用于验证 Agent 循环是否正常工作
// 正式的工具(读文件、写文件、搜索等)在第 9-11 章实现

import type { AgentTool } from "./types.js";

// ─── Echo 工具:把你说的话还给你 ───
// 这不是玩具------它的作用是验证 Agent 能否正确调用工具
// 当你看到 LLM 调了 echo 并且拿到了正确结果,说明整个循环链路是通的

export const echoTool: AgentTool<any, { receivedText: string }> = {
  name: "echo",
  label: "Echo",    // UI 显示用的人读标签
  description: "把输入的文本原样返回。用于测试工具调用是否正常工作。",

  // 参数定义------告诉 LLM "这个工具需要一个 text 参数"
  parameters: {
    type: "object" as const,
    properties: {
      text: {
        type: "string",
        description: "要回显的文本",
      },
    },
    required: ["text"],
  },

  // 执行逻辑------真的就是原样返回
  // 注意:onUpdate 是可选的(AgentTool 接口要求,简单工具不需要用它)
  async execute(toolCallId, params, signal, onUpdate?) {
    // 模拟一点延迟------让我们能在验证时看清工具执行的顺序
    await new Promise((resolve) => setTimeout(resolve, 100));

    return {
      content: [
        {
          type: "text" as const,
          text: `[echo 工具返回] ${params.text}`,
        },
      ],
      details: { receivedText: params.text },
    };
  },
};

第六步:写验证脚本

创建 src/test-agent.ts------这个脚本会启动 Agent,发一个需要调工具的请求,然后打印所有事件:

typescript 复制代码
// src/test-agent.ts
// 验证 Agent 循环是否正常工作

import { createModels } from "./models.js";
import { deepseekProvider } from "./providers/deepseek.js";
import { agentLoop, defaultConvertToLlm } from "./agent/agent-loop.js";
import { echoTool } from "./agent/test-tools.js";
import type { AgentMessage, AgentContext, AgentLoopConfig } from "./agent/types.js";

// ─── 1. 初始化 Models ───
const models = createModels();
models.setProvider(deepseekProvider());

const model = models.getModel("deepseek", "deepseek-v4-flash");
if (!model) {
  console.error("❌ 找不到模型 deepseek/deepseek-v4-flash");
  process.exit(1);
}

// ─── 2. 构建 Agent 上下文 ───
const context: AgentContext = {
  systemPrompt: `你是一个编程助手。回答要简洁。
你可以使用 echo 工具来回显文本。当用户要求你"复述"或"重复"什么内容时,使用 echo 工具。
其他情况下直接回答即可。`,
  messages: [],
  tools: [echoTool],
};

// ─── 3. 用户说的话 ───
const userMessage: AgentMessage = {
  role: "user",
  content: "请用 echo 工具复述这句话:'Agent 循环搭建成功!'",
  timestamp: Date.now(),
};

// ─── 4. Agent 循环配置 ───
const config: AgentLoopConfig = {
  model,
  convertToLlm: defaultConvertToLlm,  // 使用默认的翻译器------过滤掉 LLM 不认识的消息
};

// ─── 5. 启动 Agent 循环并打印所有事件 ───
async function main() {
  console.log("╔══════════════════════════════════════╗");
  console.log("║   🐣 Agent 循环验证脚本              ║");
  console.log("╚══════════════════════════════════════╝\n");

  console.log("📝 用户说:", typeof userMessage.content === "string" ? userMessage.content : "(非文本内容)");
  console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");

  let turnCount = 0;
  let totalTokens = { input: 0, output: 0 };
  let printedLen = 0;  // 追踪已打印的字符数------用于增量输出,避免中文被切碎

  for await (const event of agentLoop([userMessage], context, config, models)) {
    switch (event.type) {
      case "agent_start":
        console.log("🐣 Agent 启动\n");
        break;

      case "turn_start":
        turnCount++;
        console.log(`🔄 [第 ${turnCount} 轮开始]`);
        break;

      case "message_start":
        if (event.message.role === "assistant") {
          process.stdout.write("🤖 AI: ");
        } else if (event.message.role === "toolResult") {
          console.log(`\n🔧 工具返回 [${event.message.toolName}]:`);
        }
        break;

      case "message_update":
        // 流式更新------提取新增的文本并增量打印(不会切碎中文)
        if (event.message.role === "assistant") {
          const textParts = event.message.content
            .filter((c: any) => c.type === "text")
            .map((c: any) => c.text)
            .join("");
          // textParts 是全量累积文本,printedLen 是上次打印到的位置
          // slice 按字符切分,不是按字节------中文不会被劈成两半
          const newPart = textParts.slice(printedLen);
          if (newPart.length > 0) {
            process.stdout.write(newPart);
            printedLen = textParts.length;
          }
        }
        break;

      case "message_end":
        if (event.message.role === "assistant") {
          const assistantMsg = event.message;
          console.log("");
          printedLen = 0;  // 重置------下一轮 AI 回复重新开始流式输出
          if (assistantMsg.usage) {
            totalTokens.input += assistantMsg.usage.input;
            totalTokens.output += assistantMsg.usage.output;
          }
          console.log(`   (stopReason: ${assistantMsg.stopReason})`);

          // 检查是否有工具调用
          const toolCalls = assistantMsg.content.filter(
            (c): c is ToolCall => c.type === "toolCall"
          );
          if (toolCalls.length > 0) {
            console.log("\n📞 AI 想要调用工具:");
            for (const tc of toolCalls) {
              console.log(`   - ${tc.name}(${JSON.stringify(tc.arguments)})`);
            }
            console.log("");
          }
        }
        break;

      case "tool_execution_start":
        console.log(`⏳ 执行工具: ${event.toolName}(${JSON.stringify(event.args)})`);
        break;

      case "tool_execution_end":
        console.log(`   ${event.isError ? "❌ 失败" : "✅ 完成"}`);
        if (event.result.isError) {
          const errorText = (event.result.content as any[])
            .filter((c: any) => c.type === "text")
            .map((c: any) => c.text)
            .join("");
          console.log(`   错误信息: ${errorText}`);
        }
        break;

      case "turn_end":
        console.log(`\n🔄 [第 ${turnCount} 轮结束]`);
        if (event.toolResults.length > 0) {
          console.log(`   执行了 ${event.toolResults.length} 个工具`);
        }
        console.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n");
        break;

      case "agent_end":
        console.log("🐣 Agent 结束");
        console.log(`📊 总共 ${turnCount} 轮对话`);
        console.log(`💰 Token 用量: ${totalTokens.input} + ${totalTokens.output} = ${totalTokens.input + totalTokens.output}`);
        console.log(`📝 产生了 ${event.messages.length} 条消息`);
        break;
    }
  }

  console.log("\n✅ 验证完成!");
}

main().catch(console.error);

第七步:更新 package.json 的启动脚本

修改 package.jsonscripts 部分------让 npm start 跑 Agent 验证而不是简单的对话:

json 复制代码
{
  "scripts": {
    "start": "npx tsx src/test-agent.ts"
  }
}

(如果你的 tsconfig.json 配置了 ESM,并且 package.json 里有 "type": "module"npx tsx 能直接跑 TypeScript。)

🔧 运行验证

bash 复制代码
npm start

你应该看到类似这样的输出:

复制代码
╔══════════════════════════════════════╗
║   🐣 Agent 循环验证脚本              ║
╚══════════════════════════════════════╝

📝 用户说: 请用 echo 工具复述这句话:'Agent 循环搭建成功!'
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

🐣 Agent 启动

🔄 [第 1 轮开始]
🤖 AI:
📞 AI 想要调用工具:
   - echo({"text":"Agent 循环搭建成功!"})

⏳ 执行工具: echo({"text":"Agent 循环搭建成功!"})
   ✅ 完成

🔧 工具返回 [echo]:

🔄 [第 2 轮开始]
   (stopReason: toolUse)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

🤖 AI: echo 工具返回了:'Agent 循环搭建成功!',循环已经正常工作了。
   (stopReason: stop)

🔄 [第 2 轮结束]
   执行了 0 个工具
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

🐣 Agent 结束
📊 总共 2 轮对话
💰 Token 用量: 245 + 89 = 334
📝 产生了 6 条消息

✅ 验证完成!

试试不带工具的纯对话

src/test-agent.ts 里的用户消息改成不需要工具的:

typescript 复制代码
const userMessage: AgentMessage = {
  role: "user",
  content: "用一句话解释什么是闭包",
  timestamp: Date.now(),
};

再次运行 npm start,你应该看到只有 1 轮对话,AI 直接回答,不调任何工具。

试试多工具场景

如果 LLM 足够聪明(或者你的系统提示词引导得好),你可以加两个工具,看 Agent 如何连续调用它们。

❓ 常见问题

Agent 循环跑了一次就停了,没有继续调工具

检查 LLM 的回复是否是"我不需要调工具"而不是"我要调工具"。关键看 stopReason

  • stopReason === "stop" → AI 觉得说完了,Agent 退出
  • stopReason === "toolUse" → AI 要调工具,Agent 继续循环

如果 AI 明明应该调工具但 stopReasonstop,可能是:

  1. 系统提示词没有告诉 AI 它有工具可用
  2. 工具描述不够清晰,AI 不知道什么时候该用它
  3. 某些模型对于"在什么情况下调工具"比较保守

流式输出正常,但工具调用的参数是空的

工具调用的参数是通过 toolcall_delta 事件逐片传来的(JSON 片段)。确保 updatePartialContent 正确处理了 toolcall_delta 事件。检查 event-stream.ts 是否在 flush() 里正确拼接了完整的 ToolCall。

partialMessage.content[event.contentIndex] 报 undefined

这个错误说明 event.contentIndex 指向了数组中不存在的位置。可能原因:

  • LLM 先发了 text_delta(contentIndex=1)然后才发 text_start(contentIndex=0)
  • updatePartialContent 里的 while 循环没有正确扩展数组

修复:确保 updatePartialContentwhile 循环在索引不存在时预先填充占位符。

循环跑了太多次------AI 一直在调工具不停

Agent 循环没有最大轮数限制。如果 LLM 陷入"调工具→不满意→再调→再不满意"的死循环,需要手动 Ctrl+C。第 7 章会加入最大轮数限制和重试控制。

📚 这一章你学到了

概念 一句话理解
Agent 循环 while(true) + 流式 LLM 调用 + 工具检测 + 执行 + 回环------让 AI 从"一问一答"变成"自主完成任务"
AsyncGenerator 在函数里用 yield 逐个产出异步结果------天然适合"事件流"这种逐步产生的数据
convertToLlm() Agent 消息格式和 LLM 消息格式之间的翻译官------过滤掉 LLM 不认识的,只留它需要的
消息分离 Agent 的 messages 比 LLM 的 messages 更丰富------转换在每次 LLM 调用前动态执行,互不污染
contentIndex LLM 的回复是数组,每个片段有自己的索引------text 和 toolCall 可以在同一条回复里共存
stopReason 驱动 Agent 循环的退出条件完全由 LLM 的 stopReason 决定------不硬编码轮数,让 AI 自己判断任务是否完成
工具错误转消息 工具执行失败不抛异常------把错误包装成 toolResult 喂回 LLM,让 AI 自己决定怎么处理

🔗 对应的 Pi 源码

  • packages/agent/src/agent-loop.ts:17-53 --- agentLoop() 入口函数
  • packages/agent/src/agent-loop.ts:95-143 --- runAgentLoop()runAgentLoopContinue()
  • packages/agent/src/agent-loop.ts:155-269 --- runLoop() 核心循环(内层 tool loop + 外层 follow-up loop)
  • packages/agent/src/agent-loop.ts:275-368 --- streamAssistantResponse()(LLM 调用 + 事件解析)
  • packages/agent/src/agent-loop.ts:373-516 --- executeToolCalls()(顺序 + 并行工具执行)
  • packages/agent/src/agent.ts:31-35 --- defaultConvertToLlm() 默认消息转换
  • packages/agent/src/types.ts:314 --- AgentMessage 类型定义
  • packages/agent/src/types.ts:397-404 --- AgentContext 接口

本章的教学版简化了很多 Pi 实际功能,留在后续章节:

  • 第 7 章:并行工具执行、beforeToolCall/afterToolCall 生命周期钩子
  • 第 8 章:steering(中途打断)、followUp(后续追问)、消息队列

下一步

现在 Agent 能跑起来了------但全是过程式代码,一堆变量到处飞。下一章我们把 Agent 循环包成一个 Agent 类------管理状态、事件订阅、消息队列、中断控制。就像给实习生配了一个项目经理,帮你盯着他的每一步。

相关推荐
小弥儿1 小时前
GitHub今日热榜 | 2026-07-09:OfficeCLI 以 92% 日增幅入围
学习·开源·github
糖果店的幽灵2 小时前
从 GPT Researcher 学习 LangChain
gpt·学习·langchain
取地址符2 小时前
Rust学习笔记(基于Rustlings)(2):数据结构与集合
笔记·学习·rust
心中有国也有家2 小时前
AtomGit Flutter 鸿蒙客户端:呼吸球动画
学习·flutter·华为·harmonyos
进阶的猪2 小时前
Linux 内核移植过程学习
linux·学习
SteveSenna3 小时前
aubo学习:设置TCP
学习
茯苓gao3 小时前
嵌入式开发笔记:工业机器人通信协议深度解析——从现场总线到工业以太网
笔记·嵌入式硬件·学习·机器人·信息与通信
取地址符3 小时前
Rust语法学习 (基于Rustlings)(1):基础语法
经验分享·笔记·学习·rust
绝世番茄3 小时前
VisualEffect 视觉特效鸿蒙HarmonyOS ArkTS原生学习
学习·华为·harmonyos·鸿蒙