s01: Agent Loop — 一个循环就够了
一个工具 + 一个循环 = 一个 Agent。
s01 → s02
问题
你问模型:“帮我看看目录下有哪些文件,然后跑一下 xxx 脚本。”
模型能输出一条 bash 命令,但输出完就停了。它不会自己跑,也不会看到结果后继续推理。
你可以手动跑一遍,把输出贴回对话框,让它接着干。下一条命令出来,你再跑、再贴。每一个来回,你都在做中间层。
把这个中间层自动化,就是本章要做的事。
解决方案
一个 while 循环:content 里有 tool_use 块就继续,没有就停。退出判断看结构化的 content,不看 stop_reason——后者是 provider 元数据,跨 provider 命名不一(Anthropic 是 "tool_use",OpenAI 是 "tool_calls")。
| 信号 | 含义 | 循环动作 |
|---|---|---|
content 里有 tool_use 块 | 模型举手说”我要用工具” | 执行 → 结果喂回去 → 继续 |
content 里没有 tool_use 块 | 模型说”我做完了” | 正常退出循环 |
stop_reason 是 "error" 或 "aborted" | LLM 异常或被中断 | 抛错终止 |
stop_reason 只用于异常检测,不用于判断循环继续——这是与pi 一致的设计。
agent 的”记忆”就是一个消息数组:用户提问、模型回答、工具结果,全部按序追加,每轮原样发回给模型。没有隐藏状态。
工作原理
打开 code.ts,分四块看。
第 1 块:调用 LLM。 原生 fetch 发一次 POST,请求头三个、body 五个字段,没有 SDK 挡在中间。
const response = await fetch(`${CONFIG.baseUrl}/v1/messages`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-api-key": CONFIG.apiKey,
"anthropic-version": CONFIG.anthropicVersion,
},
body: JSON.stringify({ model: CONFIG.model, max_tokens: CONFIG.maxTokens, system: SYSTEM_PROMPT, messages, tools: TOOLS }),
});
第 2 块:执行工具。 spawnSync 同步跑 shell,黑名单、超时、输出截断三道护栏。
第 3 块:主循环。 全章核心,只有十几行。退出判断与pi 一致——看 content 里有没有 tool_use 块:
while (true) {
const response = await callLlm(messages);
messages.push({ role: "assistant", content: response.content });
if (response.stop_reason === "error" || response.stop_reason === "aborted") {
throw new Error(`LLM 异常终止: stop_reason=${response.stop_reason}`);
}
const toolCalls = response.content.filter((block) => block.type === "tool_use");
if (toolCalls.length === 0) {
return;
}
// 执行每个 tool_use 块,收集 tool_result
messages.push({ role: "user", content: results });
}
第 4 块:REPL 入口。 readline 读一行问一轮,对话历史跨轮保留,所以你可以追问”它刚才改了哪个文件”。
运行
export ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic
export ANTHROPIC_API_KEY=sk-deepseek-...
export MODEL_ID=deepseek-chat # 可选,有默认值
node code.ts
试这三个提示,观察循环转了几轮:
列出当前目录的文件(一轮工具调用就停)递归统计当前目录下每个子目录的文件数,把结果写进 report.txt(多轮)刚才那个 report.txt 的前三行写了什么(利用跨轮历史)
前置概念清单
本章只引入五个新概念,后续的章都建立在它们之上:
- tool_use:模型不只输出文本,还能输出结构化的”我要调用工具”块(带 id、name、input)。这是循环继续的唯一信号
- stop_reason:API 返回的元数据,告诉模型为什么停下。只用于异常检测(
"error"/"aborted"),不用于判断循环继续——跨 provider 命名不一 - 消息数组即状态:agent 的全部记忆是一个按序追加的数组,没有数据库、没有隐藏状态
- tool_result 回填:工具执行结果包装成
user角色的消息发回,id 与 tool_use 一一对应 - REPL:readline 读输入 → 跑循环 → 打印回答,交互外壳与 agent 内核分离
源码锚点
mini-pi 的十几行循环,在pi 里对应 runLoop:packages/agent/src/agent-loop.ts 第 155-269 行。先读一遍,再回答两个问题(答案就在代码里):
- 第 170 行和第 174 行各有一个
while,两层循环各自为什么存在?(提示:找followUpMessages和steeringMessages) - 第 289 行的
convertToLlm把内部消息模型转成 LLM 格式,为什么这个转换放在调 LLM 的边界上,而不是让内部直接用 LLM 格式?(s08 会展开)
动手任务(改 mini-pi):pi 有钩子和中断保护,mini-pi 没有。给 agentLoop 加一个最大轮数保护:循环超过 10 轮就停下来并提示。这是你的第一个 harness 策略——循环的形状不变,行为多了一条约束。
妥协清单
mini-pi 比pi 少做了什么,以及为什么省略是安全的:
| 省略项 | pi 的做法 | 为什么本章可以省 |
|---|---|---|
| 流式输出 | streamFn 逐 token 推送 | 非流式一次性返回,语义相同,只是少了打字机效果 |
| 事件流 | 每步 emit 事件供 TUI 渲染 | 用 console.log 直接打印,观察者只有终端 |
| 中断 AbortSignal | signal 贯穿全链路 | s04 专门讲,本章循环短,Ctrl+C 够用 |
| steering / follow-up | 双层循环注入运行中消息 | s04 讲;本章一问一答,不存在运行中插话 |
| 精细截断 | utils/truncate.ts 按行/字节策略截断 | 简单 slice 教学够用,s02 会碰到它的边界 |
| 多工具 dispatch | tools/index.ts 注册表 | s02 讲;一个工具不需要分发 |
| 钩子退出 | shouldStopAfterTurn 钩子返回 true 时强制退出 | s10 讲;本章没有扩展系统,钩子无处挂 |
关于退出判断:mini-pi 和pi 都用”content 里有没有 tool_use 块”判断循环继续,stop_reason 只用于异常检测("error"/"aborted")。不要用 stop_reason === "tool_use" 判断——那是 Anthropic 的命名,换到 OpenAI 就叫 "tool_calls",写死会绑死 provider。
这张表是本章的地图:后面七章,就是逐行划掉它。
默写验收
合上 code.ts 和本 README,打开 practice.ts,凭记忆补全四个函数体:callLlm、runBash、agentLoop、main。
通过标准:node practice.ts 跑起来,能完成”列出当前目录文件”这类需要一轮工具调用的任务。
写不出 agentLoop 说明主循环没进脑子,回到「工作原理」第 3 块重读,再默写一遍。其他三个函数写不出可以查,主循环不行。