Skip to content

🐣 小a的核心任务

LLM 调用层通了,模型能说话了。但光会说话不算 Agent——装上循环,让它会调工具、会自主干活,它才是真正的 Agent。 这一章是 Part III 的核心:pi 的循环有 22KB,我们的只要 ~80 行,因为本质就那么简单,pi 的复杂都是工程细节。


26.1 回顾:循环是什么(第 7 章)

先把概念回顾一遍。Agent 循环的 5 步:

① 喂模型(system + history + 问题)
② 模型生成(可能含 toolCall)
③ 看 StopReason:toolUse → 继续;stop → 结束
④ 执行 toolCall,得 toolResult
⑤ toolResult 加进历史,回到 ①

这一章,我们用 TypeScript 实现这 5 步。


26.2 核心代码:runAgent 函数

直接上核心(这是本章的灵魂):

typescript
// agent.ts
import { streamText, type LLMEvent } from "./llm.ts";

// 消息类型(第 7 章的消息角色)
type Message =
  | { role: "user"; content: string }
  | { role: "assistant"; content: string; toolCalls?: ToolCall[] }
  | { role: "tool"; toolCallId: string; result: string };

interface ToolCall {
  id: string;
  name: string;
  args: any;
}

// 工具类型(第 6 章,第 27 章详讲)
interface Tool {
  name: string;
  description: string;
  schema: any;          // JSON schema 描述参数
  execute: (args: any) => Promise<string>;
}

// Agent 配置
interface AgentConfig {
  model: string;
  system: string;
  tools: Record<string, Tool>;
  maxTurns?: number;    // 防死循环(附录 E.5)
}

// ★ 核心循环 ★
export async function runAgent(
  config: AgentConfig,
  userMessage: string,
  history: Message[] = [],
) {
  history.push({ role: "user", content: userMessage });

  for (let turn = 0; turn < (config.maxTurns ?? 20); turn++) {
    // ① 喂模型
    let assistantText = "";
    const toolCalls: ToolCall[] = [];

    // ② 流式生成,收集文字和工具调用
    for await (const event of streamText({
      model: config.model,
      system: config.system,
      messages: history,
      tools: Object.values(config.tools).map(t => ({
        name: t.name, description: t.description, input_schema: t.schema,
      })),
    })) {
      if (event.type === "text") {
        assistantText += event.text;
        process.stdout.write(event.text);  // 实时显示
      } else if (event.type === "tool_use") {
        toolCalls.push({ id: event.id, name: event.name, args: event.input });
      }
    }

    // 把 assistant 回答回进历史
    history.push({ role: "assistant", content: assistantText, toolCalls });

    // ③ 没工具调用 → 模型说完了,结束循环
    if (toolCalls.length === 0) {
      return history;
    }

    // ④ 执行所有工具调用
    for (const call of toolCalls) {
      const tool = config.tools[call.name];
      if (!tool) {
        history.push({
          role: "tool", toolCallId: call.id,
          result: `错误:未知工具 ${call.name}`,
        });
        continue;
      }
      const result = await tool.execute(call.args);
      // ⑤ toolResult 加进历史
      history.push({ role: "tool", toolCallId: call.id, result });
    }
    // → 回到 ①,继续循环
  }

  console.log("\n(达到最大轮数,停止)");
  return history;
}

🧙 老z带小a逐段读:

消息类型(开头):三种角色——userassistanttool。第 7 章讲过,循环靠它们流转信息。这里简化了(没显式 system,system 单独传)。

Tool 接口:name + description + schema + execute。第 6 章讲过,工具是"包装好的能力"。第 27 章我们会实现具体工具。

核心循环(★ 标注):

  • ①②③④⑤ 完全对应第 7 章的 5 步
  • for (let turn...) 是外层轮次循环(防死循环)
  • 没工具调用(toolCalls.length === 0)就 break——这等价于 pi 看 stopReason === "stop"
  • 有工具调用 → 执行 → 结果进历史 → 循环

26.3 这段代码为什么这么短?

小a数了数:核心循环逻辑不到 80 行。但 pi 的 agent-loop.ts 有 22KB(~600 行)。差在哪?

🧙 我们 vs pi 的差距:

特性我们的pi 的
并发执行工具串行(for 循环)并发(Promise.all)
中断处理完整(abort signal)
错误恢复简单抛错重试、降级
上下文压缩compaction(第 18 章)
部分流式 JSON简化精细处理
事件订阅直接 consolesubscribe 观察者

差的都是"工程细节",不是"本质"。 我们这 80 行,体现的就是 Agent 循环的本质——pi 的 600 行,是把这个本质做"健壮"。

🐣 小a恍然:"所以理解本质用 80 行,做成产品用 600 行?那我先理解本质,够了再加健壮性?"

🧙 "对!这就是渐进式开发。 先让循环跑起来(能干活),再加并发(快)、加错误处理(稳)、加压缩(扛长对话)。别一上来就追求 pi 的完整度——那是多年迭代的结果。"


26.4 关键设计点详解

设计点一:history 是外部传入的

注意 runAgent 的签名:history: Message[] = [] —— 历史从外面传入,函数内部修改它

🧙 为什么这么设计?

因为记忆要持久化(第 28 章)。如果 history 是函数内部的状态,函数返回就没了。外部传入 → 调用方能存盘 → 下次能恢复。

这呼应第 18 章——pi 的 session 也是"外部存储",Agent 内核不持有状态。状态外置,内核纯逻辑。

设计点二:maxTurns 防死循环

for (let turn = 0; turn < maxTurns; turn++) —— 限制最大轮数。

🧙 为什么必须有?

模型可能陷入死循环——反复调同一个工具、永远不说"完成"。没有 maxTurns,你的 Agent 会无限烧 token(附录 E.5)。

这是安全网。 pi 也有类似机制(shouldStopAfterTurn hook,第 18 章)。

设计点三:实时显示 vs 累积

typescript
if (event.type === "text") {
  assistantText += event.text;      // 累积(进历史)
  process.stdout.write(event.text); // 实时显示
}

🧙 两件事同时做:

  • 累积进 assistantText(因为历史要存完整回答)
  • 实时 stdout.write(让用户看到流式输出)

这是"用户看到的"和"历史存的"分开——一个流式显示,一个完整保存。


26.5 跑起来看看(还没工具,先测纯对话)

虽然工具第 27 章才实现,但我们可以先测纯对话(不传工具):

typescript
// test.ts
import { runAgent } from "./agent.ts";

await runAgent(
  { model: "claude-sonnet-4-5", system: "你是个有用的助手。", tools: {} },
  "用一句话介绍 TypeScript",
);

跑:tsx test.ts。模型回答完就停(因为没工具,toolCalls.length === 0 直接结束)。这验证了循环的基础逻辑。


26.6 对照 pi:我们的循环少了什么重要的?

🧙 最大的差距:事件驱动(subscribe)

pi 的 Agent 是可订阅的——UI 通过 subscribe 实时刷新。我们的循环是"直接 console.write",没有事件机制

这意味着:我们的 Agent 没法接 TUI(TUI 需要 subscribe 来知道"该刷什么")。第 29 章我们会用最简的 print 模式,不碰 TUI。

如果将来想做 TUI,得加 subscribe——但那是"长大"之后的事(第 31 章)。


本章小结

🐣 小a的第二十六课(Part III 核心)

┌──────── Agent 循环(灵魂)────────┐
│                                 │
│  • runAgent() ~80 行核心:       │
│    ① 喂模型                     │
│    ② 流式生成(收集文字+工具)  │
│    ③ 无工具调用 → 结束          │
│    ④ 执行工具                   │
│    ⑤ 结果进历史 → 循环          │
│                                 │
│  • 消息角色:user/assistant/tool │
│  • history 外部传入(可持久化) │
│  • maxTurns 防死循环            │
│                                 │
│  • 我们 80 行 vs pi 600 行:     │
│    差在工程细节,非本质         │
│    (并发/中断/恢复/压缩)      │
│                                 │
│  • 暂无 subscribe → 不能接 TUI  │
│    (第 29 章用 print 模式)     │
└─────────────────────────────────┘

关键认知:Agent 循环的本质极其简单——80 行就讲清。pi 的复杂都是"把本质做健壮"。先让循环跑起来,再渐进加健壮性。这就是 YAGNI。


课后实验

  1. 写出 agent.ts:照着 19.2 的代码,实现 runAgent。
  2. 测纯对话:不传工具,跑"介绍 TypeScript",验证循环基础逻辑。
  3. 感受 maxTurns:故意把 maxTurns 设成 1,看看会发生什么(模型还没说完就被截断)。

下一章:循环有了,但还没工具可调。我们实现 read/write/bash 三个工具。 → 第 27 章 · 工具系统