第 02 章:agent loop —— 让模型调用工具

这一章是整个教程的心脏。我们给模型装上第一个工具(bash),并写出驱动"LLM → 工具 → LLM"循环的 agent loop。写完你会发现,所谓 coding agent 的骨架不过百来行——pi 的核心 agent-loop.ts 也只有几百行,思想完全一致。

核心认知:tool calling 协议

工具调用不是模型"做"了什么,而是模型在回复里留下一张工单,由我们的代码执行后再把结果喂回去。一个完整回合在 wire 格式上长这样:

① 我们发送:messages + tools 定义
② 模型回复 assistant 消息,content 为空,但带着 tool_calls:
   { "role": "assistant", "content": null,
     "tool_calls": [{ "id": "call_1", "type": "function",
       "function": { "name": "bash", "arguments": "{\"command\":\"ls src\"}" } }] }
③ 我们执行 bash("ls src"),把结果作为 tool 消息回填:
   { "role": "tool", "tool_call_id": "call_1", "content": "a.ts\nb.ts\n" }
④ 模型看到结果,回复最终文本;或者再开一张工单 → 回到 ②

agent loop 就是把这个回合转起来:只要模型的回复里还有 tool_calls,就执行、回填、再调模型;直到某次回复不带任何 tool_calls,loop 结束,把控制权还给用户。

pi 把"一次 LLM 调用 + 由它触发的所有工具执行"称为一个 turn。pi 的 loop 没有任何 max steps 之类的旋钮——模型说停才停。作者的理由是:他从没遇到过需要强制截断的场景,多一个旋钮就多一份复杂性。我们的 mini-pi 也一样。

核心认知:工具是"描述 + JSON Schema"

模型怎么知道有哪些工具?我们把工具定义随请求一起发过去。描述的措辞直接决定模型用不用得好这个工具,所以 pi 的工具描述写得非常精炼且行为导向。mini-pi 的四个工具描述全部对照 pi:

src/tools.ts(本章先用 bash)
const TOOL_DEFS = [
  {
    type: "function",
    function: {
      name: "bash",
      description:
        "Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.",
      parameters: {
        type: "object",
        properties: {
          command: { type: "string", description: "Bash command to execute" },
          timeout: { type: "number", description: "Timeout in seconds (optional, no default timeout)" },
        },
        required: ["command"],
      },
    },
  },
];

有了 bash,模型就拥有了 lsgrepfindcat……的一切。这正是 pi "四个工具够用"论证的根基(详见设计思想:极简核心)。

核心认知:工具失败要"说给模型听"

不要在工具抛错时让 loop 崩溃。把错误变成文本,作为 tool 消息回给模型——模型很擅长读错误信息并自我修正(比如命令不存在就换一种写法)。pi 的做法一样:工具 execute() 抛出的异常会被 agent 捕获,以 isError: true 的 toolResult 回到上下文。

本章完整代码

在上一章基础上替换 src/main.ts(本章结束于非流式版本,第 04 章再换成流式):

src/main.ts(第 02 章完整版)
import { exec } from "node:child_process";
import * as readline from "node:readline";
import { promisify } from "node:util";

const CONFIG = {
  baseUrl: (process.env.MINI_PI_BASE_URL ?? process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1").replace(
    /\/$/,
    "",
  ),
  apiKey: process.env.MINI_PI_API_KEY ?? process.env.OPENAI_API_KEY ?? "",
  model: process.env.MINI_PI_MODEL ?? "gpt-4o-mini",
};

// ── 消息类型:OpenAI wire 格式 ──
interface ToolCall {
  id: string;
  type: "function";
  function: { name: string; arguments: string };
}

interface Message {
  role: "system" | "user" | "assistant" | "tool";
  content: string | null;
  tool_calls?: ToolCall[];
  tool_call_id?: string;
}

// ── LLM 调用(非流式,带工具) ──
interface AssistantResult {
  content: string;
  toolCalls: ToolCall[];
  finishReason: "stop" | "tool_calls" | "length";
}

async function chatWithTools(messages: Message[]): Promise<AssistantResult> {
  const res = await fetch(`${CONFIG.baseUrl}/chat/completions`, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${CONFIG.apiKey}`,
    },
    body: JSON.stringify({ model: CONFIG.model, messages, tools: TOOL_DEFS }),
  });
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  const data = (await res.json()) as any;
  const msg = data.choices[0].message;
  return {
    content: msg.content ?? "",
    toolCalls: msg.tool_calls ?? [],
    finishReason: data.choices[0].finish_reason ?? "stop",
  };
}

// ── 工具定义与执行 ──
const TOOL_DEFS = [
  {
    type: "function",
    function: {
      name: "bash",
      description:
        "Execute a bash command in the current working directory. Returns stdout and stderr. Optionally provide a timeout in seconds.",
      parameters: {
        type: "object",
        properties: {
          command: { type: "string", description: "Bash command to execute" },
          timeout: { type: "number", description: "Timeout in seconds (optional, no default timeout)" },
        },
        required: ["command"],
      },
    },
  },
];

const execAsync = promisify(exec);

async function toolBash(args: { command: string; timeout?: number }): Promise<string> {
  try {
    const { stdout, stderr } = await execAsync(args.command, {
      cwd: process.cwd(),
      shell: "/bin/bash",
      timeout: args.timeout !== undefined ? args.timeout * 1000 : undefined,
      maxBuffer: 10 * 1024 * 1024,
    });
    return [stdout, stderr].filter(Boolean).join("\n") || "(no output)";
  } catch (err: any) {
    // 命令非零退出不算工具失败:把输出和退出码一起给模型,让它自己判断
    const out = [err.stdout, err.stderr].filter(Boolean).join("\n");
    if (out) return `${out}\n[command exited with code ${err.code}]`;
    throw err;
  }
}

/** 统一入口:任何失败都转成文本返回给模型,而不是让 loop 崩溃 */
async function executeTool(name: string, argsJson: string): Promise<{ content: string; isError: boolean }> {
  try {
    let args: any;
    try {
      args = JSON.parse(argsJson || "{}");
    } catch {
      throw new Error(`Invalid JSON arguments: ${argsJson.slice(0, 200)}`);
    }
    switch (name) {
      case "bash":
        return { content: await toolBash(args), isError: false };
      default:
        throw new Error(`Unknown tool: ${name}`);
    }
  } catch (err) {
    return { content: `Error: ${err instanceof Error ? err.message : String(err)}`, isError: true };
  }
}

// ── agent loop:一个 turn = 一次 LLM 调用 + 由它触发的所有工具执行 ──
async function runAgent(messages: Message[]): Promise<void> {
  while (true) {
    const result = await chatWithTools(messages);
    if (result.content) console.log(result.content);

    const assistantMsg: Message = { role: "assistant", content: result.content || null };
    if (result.toolCalls.length > 0) assistantMsg.tool_calls = result.toolCalls;
    messages.push(assistantMsg);

    if (result.toolCalls.length === 0) return; // 模型没有开新工单,loop 结束

    for (const call of result.toolCalls) {
      console.log(`\x1b[36m[${call.function.name}]\x1b[0m \x1b[2m${call.function.arguments}\x1b[0m`);
      const toolResult = await executeTool(call.function.name, call.function.arguments);
      console.log(`  \x1b[2m${toolResult.content.split("\n").slice(0, 5).join("\n  ")}\x1b[0m`);
      messages.push({ role: "tool", tool_call_id: call.id, content: toolResult.content });
    }
  }
}

// ── REPL ──
function main(): void {
  if (!CONFIG.apiKey) {
    console.error("Error: set MINI_PI_API_KEY (or OPENAI_API_KEY) first.");
    process.exit(1);
  }
  const messages: Message[] = [
    { role: "system", content: "You are a coding assistant with access to a bash tool. Be concise." },
  ];
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: "\n> " });

  let running = false;
  rl.on("line", (line) => {
    const text = line.trim();
    if (!text || running) return rl.prompt();
    running = true;
    messages.push({ role: "user", content: text });
    runAgent(messages)
      .catch((err) => console.error(`[error] ${err.message}`))
      .finally(() => {
        running = false;
        rl.prompt();
      });
  });
  rl.prompt();
}

main();

跑起来看看

找一个真实项目目录,问它:

> 这个目录下有哪些 TypeScript 文件?告诉我最大的那个有多少行
[bash] {"command":"find . -name '*.ts' -not -path './node_modules/*' | head -20"}
  ./src/main.ts
  ...
[bash] {"command":"wc -l ./src/main.ts"}
  152 ./src/main.ts
目录下有 N 个 TypeScript 文件,最大的是 src/main.ts,共 152 行。

注意发生了什么:模型自己决定先 findwc,两个 turn 串成了一条链。这就是 agent——不是一次调用,而是一个会自己动手达成目标的循环。

本章要点

  • agent loop = "回复里有 tool_calls 就执行并回填,否则结束"的 while 循环;pi 称之为 turn 的循环,且没有 max steps。
  • 工具 = 名称 + 行为导向的描述 + JSON Schema;bash 一个工具就覆盖了 shell 的一切。
  • 工具失败以文本回喂模型,而不是让程序崩溃——错误信息是模型自我修正的原料。
  • pi 对应实现:packages/agent/src/agent-loop.tsexecuteToolCalls()(它还做了参数校验、beforeToolCall 拦截、并行执行等,见设计思想:agent loop)。

下一章:第 03 章:补齐四大工具 read/write/edit