DOCS v0.1.0 BUILD: STABLE GitHub ↗
章节目录
S01

Agent Loop

while 循环、tool_use 往返、原生 fetch 直调 Messages API。

前置概念 tool_usestop_reason消息数组即状态tool_result 回填REPL

s01: Agent Loop — 一个循环就够了

一个工具 + 一个循环 = 一个 Agent。

s01s02


问题

你问模型:“帮我看看目录下有哪些文件,然后跑一下 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 LOOP
用户
提问
LLM
tool_usetool_result
工具
最终回答 · content 无 tool_use 块
用户
当前消息写个 hello.ts

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

试这三个提示,观察循环转了几轮:

  1. 列出当前目录的文件(一轮工具调用就停)
  2. 递归统计当前目录下每个子目录的文件数,把结果写进 report.txt(多轮)
  3. 刚才那个 report.txt 的前三行写了什么(利用跨轮历史)

前置概念清单

本章只引入五个新概念,后续的章都建立在它们之上:

  1. tool_use:模型不只输出文本,还能输出结构化的”我要调用工具”块(带 id、name、input)。这是循环继续的唯一信号
  2. stop_reason:API 返回的元数据,告诉模型为什么停下。只用于异常检测("error"/"aborted"),用于判断循环继续——跨 provider 命名不一
  3. 消息数组即状态:agent 的全部记忆是一个按序追加的数组,没有数据库、没有隐藏状态
  4. tool_result 回填:工具执行结果包装成 user 角色的消息发回,id 与 tool_use 一一对应
  5. REPL:readline 读输入 → 跑循环 → 打印回答,交互外壳与 agent 内核分离

源码锚点

mini-pi 的十几行循环,在pi 里对应 runLooppackages/agent/src/agent-loop.ts 第 155-269 行。先读一遍,再回答两个问题(答案就在代码里):

  1. 第 170 行和第 174 行各有一个 while两层循环各自为什么存在?(提示:找 followUpMessagessteeringMessages
  2. 第 289 行的 convertToLlm 把内部消息模型转成 LLM 格式,为什么这个转换放在调 LLM 的边界上,而不是让内部直接用 LLM 格式?(s08 会展开)

动手任务(改 mini-pi):pi 有钩子和中断保护,mini-pi 没有。给 agentLoop 加一个最大轮数保护:循环超过 10 轮就停下来并提示。这是你的第一个 harness 策略——循环的形状不变,行为多了一条约束。


妥协清单

mini-pi 比pi 少做了什么,以及为什么省略是安全的:

省略项pi 的做法为什么本章可以省
流式输出streamFn 逐 token 推送非流式一次性返回,语义相同,只是少了打字机效果
事件流每步 emit 事件供 TUI 渲染console.log 直接打印,观察者只有终端
中断 AbortSignalsignal 贯穿全链路s04 专门讲,本章循环短,Ctrl+C 够用
steering / follow-up双层循环注入运行中消息s04 讲;本章一问一答,不存在运行中插话
精细截断utils/truncate.ts 按行/字节策略截断简单 slice 教学够用,s02 会碰到它的边界
多工具 dispatchtools/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,凭记忆补全四个函数体:callLlmrunBashagentLoopmain

通过标准:node practice.ts 跑起来,能完成”列出当前目录文件”这类需要一轮工具调用的任务。

写不出 agentLoop 说明主循环没进脑子,回到「工作原理」第 3 块重读,再默写一遍。其他三个函数写不出可以查,主循环不行。


下一章:s02 bash/read/write — 三个”无聊”工具与 dispatch 表