Appearance
🐣 小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逐段读:
消息类型(开头):三种角色——
user、assistant、tool。第 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 简化 精细处理 事件订阅 直接 console subscribe 观察者 差的都是"工程细节",不是"本质"。 我们这 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 也有类似机制(
shouldStopAfterTurnhook,第 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。
课后实验
- 写出 agent.ts:照着 19.2 的代码,实现 runAgent。
- 测纯对话:不传工具,跑"介绍 TypeScript",验证循环基础逻辑。
- 感受 maxTurns:故意把 maxTurns 设成 1,看看会发生什么(模型还没说完就被截断)。
下一章:循环有了,但还没工具可调。我们实现 read/write/bash 三个工具。 → 第 27 章 · 工具系统