Agent Loop
先把模型接到真实世界:理解“提出工具调用—执行—回填—继续”的最小闭环。
先看它怎样跑起来
这一章不是几个孤立知识点,而是一条会产生结果的因果链。
Agent Loop
亮起的是当前动作,留下的是已经满足的前置条件。点击任意一步,可以从那里继续。
- 循环不是魔法,而是受控的状态机。
- 每轮都要保存 tool_call_id 与结果的配对。
- 用最大轮次和错误分支保证最终可退出。
顺着原文把边界看清
01导读:问题背景与本章目标我见过很多人第一次接触 Agent 框架时的反应:打开架构图,看到 Orchestrator、Planner、Memory、Tool Registry,每个框之间都连着箭头。看的时候觉得懂了,但问一句“Agent 最核心的代码是什么”,往往答不上来。⌄
我见过很多人第一次接触 Agent 框架时的反应:打开架构图,看到 Orchestrator、Planner、Memory、Tool Registry,每个框之间都连着箭头。看的时候觉得懂了,但问一句“Agent 最核心的代码是什么”,往往答不上来。
这篇文章不画框,只看代码,而且只讲一个核心:Agent Loop。
它的概念是一个无限循环:模型提出工具调用,程序执行工具,再把结果放回消息历史,然后继续问模型,直到模型不再调用工具。为了让这个循环一定结束,配套实现把它写成一个带最大轮次的 for 循环,避免模型一直调工具导致程序永不退出。
02本章导读:读完这一章,你能做到什么五个可验收的能力,从「说清区别」到「在本机跑通并执行一条 PowerShell 命令」。⌄
- 用一句话说清 Agent 和「调用一次模型 API」的区别。
- 看懂一轮 Agent 对话在网络上真正传了哪些 JSON 字段。
- 自己手写一个不到 60 行、能跑起来的 Agent Loop。
- 说出这个循环在什么条件下结束,以及为什么必须有轮次上限。
- 在本机跑通第 1 章配套代码,并让 Agent 帮你执行一条 PowerShell 命令。
03你需要先具备什么不需要读过任何 Agent 框架的文档。本章出现的每个自造概念都会在术语表里先解释一遍。⌄
| 需要 | 程度 |
|---|---|
| TypeScript / JavaScript | 能看懂 async/await、数组、interface,不需要熟悉高级类型 |
| 命令行 | 能在 PowerShell 里进入目录、运行 npm 命令 |
| 大模型 API | 知道「把消息发给模型、模型返回文本」这件事就够了 |
| 上一章 | 无。这是第 1 章 |
04术语速查(本章第一次出现的词)先扫一眼,正文里遇到不认识的词回来查即可。不用背,读完正文自然就懂了。⌄
| 术语 | 一句话解释 |
|---|---|
| Agent Loop | 「问模型 → 执行工具 → 把结果喂回去 → 再问模型」的循环。本章唯一主题 |
| Harness(挽具) | 循环之外的所有代码:配置校验、工具注册、参数校验、审批、错误分类、轮次上限。模型负责决定,Harness 负责安全地落地 |
| 轮次(turn) | 一次「发请求给模型」算一轮。注意不是一次工具调用算一轮 |
tool_calls | 模型回复里的一个数组字段。非空表示「我要用工具」,空表示「我说完了」 |
tool_call_id | 每次工具调用的唯一编号。工具结果靠它对应回是哪次调用 |
ToolRegistry | 工具注册表。存放所有工具的名字、说明、参数格式和执行函数 |
| snapshot(快照) | 把注册表在本轮「拍照定格」。保证模型看到的工具清单和随后真正执行的是同一份 |
ToolResult | 工具执行的统一结果类型。成功和失败都是 ToolResult,不抛异常打断循环 |
| discriminated union | TypeScript 写法:用 role 字段区分四种消息,编译器据此知道每种消息各有哪些字段 |
| 观察空间 / 动作空间 | 模型「能看到什么」(系统提示 + 消息历史 + 工具说明)和「能做什么」(注册表里的工具) |
| 组合根 | 唯一负责创建真实依赖并注入的文件,本章是 bootstrap.ts。核心循环自己不 new 任何 SDK 或子进程 |
| profile | 章节能力白名单。第 1 章的 P01 只允许装 loop 和 powershell 两项能力 |
| effect(副作用类别) | 工具声明自己会不会改变世界。shell 标记为 execute,因此默认逐次要人工批准 |
| fail-closed | 出错时选择拒绝而不是放行。审批环节抛异常也当作「拒绝」处理 |
05建议的阅读路线想先跑起来再读原理?直接跳到「运行第 1 章智能体」,那一节是完全自包含的步骤清单。⌄
本章导读(你在这)
↓
① 先说你现在是什么角色 ← 建立直觉:Agent 替代的是「你」
↓
② 两个信号,一个循环 ← 决策树只有两个分支
↓
③ 先看一眼真实的数据流 ← 两轮 JSON 实例,最关键的一节
↓
④ 五步拆解 + 完整函数 ← 代码怎么跑起来
↓
⑤ 边界与差距 ← 格式差异、shell 不是沙箱、CC 的四点差距
↓
⑥ 结论与源码地图 ← Agent = 模型 + Harness + 历史;13 个文件各管什么
↓
⑦ 运行第 1 章智能体 ← 动手,六步 + 排错表
↓
⑧ 验证、实验与小结 ← 三个动手实验 + 自测题06先说你现在是什么角色在还没有 Agent 之前,你用大模型的方式大概是这样的:⌄
在还没有 Agent 之前,你用大模型的方式大概是这样的:
你在对话框输入「帮我看看当前目录有哪些 TypeScript 文件,然后分析一下哪个文件最大」。模型输出了一条命令:
Get-ChildItem -File -Filter '*.ts' |
Sort-Object Length -Descending |
Select-Object Name, Length但它自己不跑。你切到终端,粘贴进去,回车,看到输出,再切回对话框,把输出复制进去。模型接着说「好,最大的是 main.ts,我来分析一下……」,然后给出下一条命令:
(Get-Content .\main.ts).Count
Get-Content .\main.ts -TotalCount 50你再去终端跑,再把输出粘回来。
每一次来回,你都在做同一件事:把工具结果手动传回给模型。这个时候,你其实是一个“人工 API”,也是模型和真实世界之间的「中间层」。
把这个中间层自动化,就是 Agent Loop 要做的唯一一件事。
07两个信号,一个循环Agent Loop 的逻辑极其简单,整个决策树只有两个分支:⌄
Agent Loop 的逻辑极其简单,整个决策树只有两个分支:
模型返回 tool_calls 了?
├── 是 → 执行工具,把结果喂回去,再问一次
└── 否 → 模型觉得做完了,退出翻译成代码能识别的信号:
| 信号 | 含义 | 动作 |
|---|---|---|
| assistant.tool\_calls 非空 | 模型举手说「我要用工具」 | 为每个调用执行或生成错误结果 → 回填 → 继续循环 |
| assistant.tool\_calls 为空 | 模型没有再请求工具 | 返回最终文本;没有文本则明确失败 |
注意:这里的「循环」是机械的,没有任何智能。真正的智能全在模型里,循环只是模型的手脚。
从 ai-agent-book 的概念框架看,这个循环正好对应模型与真实世界之间的两个接口:
- 模型能看到什么,由上下文决定,也就是观察空间。
- 模型能做什么,由工具决定,也就是动作空间。
在这份代码里,观察空间是 system prompt、消息历史和工具 schema 共同组成的请求快照;动作空间是 ToolRegistry 注册的全部工具。工具执行完以后,结果必须重新放回观察空间,循环才能继续。
08先看一眼真实的数据流很多人学 Agent 卡住,不是卡在代码,而是卡在「不知道网络上到底传了什么」。所以先不看代码,只看数据。⌄
这一节是整章最关键的部分。假设用户输入:「运行 Write-Output 42,并告诉我结果。」下面是完整两轮请求响应的实际字段。
09第 1 轮 · 我们发出的请求只有两个我们关心的部分:messages(模型能看到什么)和 tools(模型能做什么)。⌄
{
"model": "gpt-4.1",
"messages": [
{ "role": "system", "content": "You are a coding agent. Use tools when needed, inspect their results, and answer accurately." },
{ "role": "user", "content": "运行 Write-Output 42,并告诉我结果" }
],
"tools": [
{
"type": "function",
"function": {
"name": "shell",
"description": "Run a PowerShell command in the current workspace.",
"parameters": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"properties": { "command": { "type": "string", "minLength": 1 } },
"required": ["command"],
"additionalProperties": false
}
}
}
]
}这段 system 内容就是 bootstrap.ts 里写死的那一行 SYSTEM_PROMPT,第 1 章不做任何动态拼装(第 10 章才引入按运行状态生成系统提示的 Provider)。
注意 tools 里那段 parameters:它就是「工具说明书」,由 ToolRegistry 从 z.strictObject({ command: z.string().min(1) }) 这一行 Zod 定义自动生成。同一份定义既生成给模型看的说明,又用于校验模型真的传回来的参数——这样不可能出现「说明书说 command,校验时却要 cmd」的错位。
10第 1 轮 · 模型返回的响应三个必须看清的细节:content 是 null、arguments 是字符串、id 要记住。⌄
{
"choices": [
{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "shell",
"arguments": "{\"command\":\"Write-Output 42\"}"
}
}
]
}
}
]
}| 细节 | 为什么重要 |
|---|---|
content 是 null | 模型这一轮一个字都没说,只举手要工具。所以「模型有没有说完」不能用有没有文本来判断 |
arguments 是字符串,不是对象 | 里面是 JSON 文本,需要我们自己 JSON.parse。而且模型完全可能吐出一段语法错误的 JSON——这是必须处理的真实情况,不是理论风险 |
id 是 "call_abc123" | 记住它,下一步回填结果时要用它配对 |
看到 tool_calls 非空,循环就知道:不能结束,得干活。
11中间步骤 · 我们在本机执行Harness 做四件事,顺序不能颠倒:查工具 → 解析参数 → Zod 校验 → 人工审批 → 真正执行。⌄
① 按名字查工具 shell → 找到定义(找不到就生成 unknown_tool 错误结果)
② 解析 arguments JSON.parse('{"command":"Write-Output 42"}')
③ Zod 校验 必须有 command、必须是非空字符串、不能有多余字段
④ 人工审批 终端打印命令,等你输入 y
↓ 你批准了
⑤ 真正执行 启动 PowerShell 子进程,拿到 stdout "42"第 ③ 步在第 ⑤ 步之前,这个顺序是安全性的核心。 参数校验必须发生在副作用之前——一旦命令已经跑了,再发现参数不合法就没有意义了。
12第 2 轮 · 我们把结果喂回去上一轮的 assistant 消息要原样留在历史里,然后紧跟一条 role: "tool" 的新消息。⌄
{
"model": "gpt-4.1",
"messages": [
{ "role": "system", "content": "You are a coding agent. Use tools when needed, inspect their results, and answer accurately." },
{ "role": "user", "content": "运行 Write-Output 42,并告诉我结果" },
{
"role": "assistant",
"content": null,
"tool_calls": [
{ "id": "call_abc123", "type": "function",
"function": { "name": "shell", "arguments": "{\"command\":\"Write-Output 42\"}" } }
]
},
{ "role": "tool", "tool_call_id": "call_abc123", "content": "42" }
],
"tools": [ "…和第 1 轮完全一样" ]
}tool_call_id: "call_abc123" 和上一轮的 id 对上了。这是模型唯一的线索——它靠这个编号知道「42 是我那次 Write-Output 42 调用的结果」。如果模型一轮请求了三个工具,就得有三条 role: "tool" 消息,ID 各自对应。
13第 2 轮 · 模型返回最终答案tool_calls 空了。循环退出,把 content 作为最终答案返回给用户。⌄
{
"choices": [
{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "命令执行成功,输出是 42。",
"tool_calls": []
}
}
]
}14把两轮压缩成一张图每一轮 messages 都在变长——这就是 Agent 的「记忆」。没有别的机制,就是一个数组不断 push。⌄
我们 模型
│ │
第1轮 │ messages(2条) + tools(1个) ───▶ │
│ │ 「我要 shell」
│ ◀──── tool_calls=[call_abc123] │
│ │
执行 │ 查工具→解析参数→zod校验→审批→跑 │
│ 得到 "42" │
│ │
第2轮 │ messages(4 条) + tools(1个) ───▶ │
│ ↑ 多了 assistant + tool 两条 │ 「够了,我能回答了」
│ ◀──── tool_calls=[] content="…" │
│ │
结束 │ 返回 content 给用户 │一句话总结这一节:Agent Loop 就是在「tool_calls 非空」和「tool_calls 为空」之间做选择,然后往一个数组里追加消息。剩下的所有代码,都是为了让这件事在出错时不崩、在失控时能停。
15五步拆解:代码怎么跑起来的下面把 Agent Loop 拆成五步。每一步都对应一段很小的代码。⌄
下面把 Agent Loop 拆成五步。每一步都对应一段很小的代码。
16第一步:用户问题进消息列表this.#history.push(userMessage(prompt));⌄
this.#history.push(userMessage(prompt));这没什么神秘的,就是把你说的话放进一个数组。为了让消息结构稳定,配套实现没有用 Record<string, unknown> 到处传,而是把 ChatMessage 定义成 system、user、assistant、tool 四种消息的 discriminated union:一条消息只能属于其中一种角色,读代码时也能清楚知道它携带哪些字段。
构造函数会冻结消息,配对验证器会拒绝重复调用 ID、孤立工具结果和缺失结果。后面每一轮的模型输出、工具结果,都会追加到这份会话历史里,成为模型下一次请求能看到的内容。
17第二步:把消息和工具定义一起发给模型const tools = this.#tools.snapshot();⌄
// AgentRunner.run() 内的片段
const tools = this.#tools.snapshot();
const request: ModelRequest = Object.freeze({
messages: Object.freeze([systemMessage(this.#systemPrompt), ...this.#history]),
tools: tools.openAITools(),
});
const reply = await this.#model.complete(request);ToolRegistry 会生成 JSON Schema,告诉模型「你现在有哪些手」:每个工具叫什么、作用是什么、接收什么参数。snapshot() 冻结本次请求可见的工具集合,保证模型看到的 schema 和随后执行调用时使用的 registry 是同一份快照,不会出现“模型看到工具 A,真正执行时却是工具 B”这种不一致。
第 1 章只有一个名为 shell 的 PowerShell 工具。OpenAI SDK 位于 code/chapters/ch01/src/adapters/openai-chat.ts:适配器使用 Chat Completions 发出请求,再立即把 SDK 响应转成 ModelReply 和内部消息。核心循环本身不保存 SDK 对象。
shell 的输入 schema、handler 和副作用类别来自同一个 ToolDefinition。下面是与配套实现对应的核心定义;实际源码位于 code/chapters/ch01/src/features/builtin-tools.ts:
import { z } from "zod";
import type { CommandResult, CommandRunner } from "../core/commands.js";
import type { ToolDefinition } from "../core/tools.js";
import { ToolRegistry, toolError, toolSuccess } from "../core/tools.js";
const shellInputSchema = z.strictObject({ command: z.string().min(1) });
function createShellTool(commandRunner: CommandRunner): ToolDefinition<{ command: string }> {
return {
name: "shell",
description: "Run a PowerShell command in the current workspace.",
inputSchema: shellInputSchema,
effect: "execute",
handler: async ({ command }, context) => {
let result: CommandResult;
try {
result = await commandRunner.run(command, context.workspace);
} catch {
return toolError("shell_start_failed", "PowerShell process could not be started");
}
let output = result.output.length === 0 ? "(no output)" : result.output;
if (result.truncated) output = `${output}\n[output truncated]`;
if (result.timedOut) return toolError("shell_timeout", output);
if (result.exitCode !== 0) {
return toolError(
"shell_failed",
`PowerShell exited with code ${result.exitCode}\n${output}`,
);
}
return toolSuccess(output);
},
};
}
export function createChapterOneTools(commandRunner: CommandRunner): ToolRegistry {
const tools = new ToolRegistry();
tools.register(createShellTool(commandRunner));
return tools;
}z.strictObject() 会在真正执行副作用之前拒绝多余参数。PowerShellRunner 合并 stdout 和 stderr,默认 120 秒超时,最多保留 50000 个字符;超时、非零退出码和截断都有明确的可观察结果。
PowerShellRunner 不在 feature 内实例化:组合根 bootstrap.ts 创建 adapter,再把它作为 CommandRunner 传给 createChapterOneTools。这样 feature 只依赖 core/commands.ts 的 contract,离线测试可以注入 fake 命令执行器,不必启动真实 PowerShell。
18第三步:追加模型回答,检查是否调了工具const assistant = reply.message;⌄
// AgentRunner.run() 内的片段
const assistant = reply.message;
this.#history.push(assistant);
if (assistant.toolCalls.length === 0) {
if (assistant.content === null) {
throw new AgentRunError("Model stopped without final text or tool calls");
}
return Object.freeze({
finalText: assistant.content,
history: Object.freeze([...this.#history]),
turns: turn,
});
}这里有个细节值得注意:SDK 回复对象不能直接混进长期历史。OpenAIChatModel 已经在适配器边界把它规范化成内部不可变消息;只有再次发请求时,toOpenAIMessage() 才重新生成 Chat Completions 所需的对象。这样业务代码看到的是稳定的内部类型,而不是某个 SDK 版本的响应形状。
另一个细节:循环退出的条件不是「模型说完了」,而是「模型没有调工具」。这两件事不等价——模型可以一边输出文本,一边调工具;也可以只调工具不说话;也可以只说话不调工具。我们只关心它有没有举手要工具。
finish_reason 仍然要校验:第 1 章遇到 length 或 content_filter 会给出明确的类型化失败,不能把不完整响应冒充最终答案。自动恢复会在后续章节加入。
19第四步:执行工具,收集结果for (const call of assistant.toolCalls) {⌄
// AgentRunner.run() 内的片段
for (const call of assistant.toolCalls) {
const prepared = tools.prepare(call);
const result = await tools.invoke(prepared, context);
this.#history.push(toolMessage(result.content, call.id));
}prepare() 先让 ToolRegistry 查找工具,再把参数解析成 JSON object、用 Zod 校验,然后经过运行时审批边界,最后才调用 handler。未知工具、非法 JSON、schema 错误、审批拒绝、超时和执行异常都会变成 ToolResult,而不是让消息链断掉。为了突出 Loop,上面的片段省略了审批分支;实际 CLI 中 shell 默认要求逐次批准。
这里每个工具调用结果都对应一个独立的 role: "tool" 消息,而且要通过 tool_call_id 和前面的调用对应上,这是 OpenAI 格式的要求,让模型知道哪个结果对应哪次调用。一个 assistant 消息中的每个调用都必须得到且只得到一个同 ID 结果,包括失败和拒绝。
20第五步:把结果追加,回到第二步循环结构本身就实现了「回到第二步」。这一轮模型看到的消息历史里,包含了它上一次的输出和工具执行结果,于是可以继续推理:「好,文件列表我拿到了,现在我需要……」⌄
// 上一步已经 push 了 tool 消息
// 直接进入下一次有界循环,再次调用模型循环结构本身就实现了「回到第二步」。这一轮模型看到的消息历史里,包含了它上一次的输出和工具执行结果,于是可以继续推理:「好,文件列表我拿到了,现在我需要……」
21完整的 agentloop 函数把上面五步组装起来,就是下面这个可运行的等价精简版。executeTool 是一个返回 ToolResult 的窄接口:循环只关心“这次调用得到什么结果”,不关心工具内部如何执行。真实 AgentRunner 使用 ToolRegistry.prepare()、审批边界和 ToolRegistry.invoke() 完成执行。⌄
把上面五步组装起来,就是下面这个可运行的等价精简版。executeTool 是一个返回 ToolResult 的窄接口:循环只关心“这次调用得到什么结果”,不关心工具内部如何执行。真实 AgentRunner 使用 ToolRegistry.prepare()、审批边界和 ToolRegistry.invoke() 完成执行。
import { resolve } from "node:path";
import {
AgentLimitError,
AgentRunError,
IncompleteModelReplyError,
} from "../core/loop.js";
import type { RunResult } from "../core/loop.js";
import { systemMessage, toolMessage, userMessage } from "../core/messages.js";
import type { ChatMessage, ToolCall } from "../core/messages.js";
import type { ModelClient, ModelRequest } from "../core/model.js";
import type { ToolContext, ToolRegistry, ToolResult } from "../core/tools.js";
type ExecuteTool = (call: ToolCall, context: ToolContext) => Promise<ToolResult>;
async function agentLoop(options: {
prompt: string;
model: ModelClient;
tools: ToolRegistry;
executeTool: ExecuteTool;
workspace: string;
systemPrompt: string;
maxTurns?: number;
}): Promise<RunResult> {
const maxTurns = options.maxTurns === undefined ? 20 : options.maxTurns;
if (!Number.isInteger(maxTurns) || maxTurns <= 0) {
throw new Error("maxTurns must be a positive integer");
}
const history: ChatMessage[] = [userMessage(options.prompt)];
const context = Object.freeze({ workspace: resolve(options.workspace), identity: "user" });
for (let turn = 1; turn <= maxTurns; turn += 1) {
const request: ModelRequest = {
messages: [systemMessage(options.systemPrompt), ...history],
tools: options.tools.openAITools(),
};
const reply = await options.model.complete(request);
if (reply.finishReason === "length") {
throw new IncompleteModelReplyError("Model output reached the token limit");
}
if (reply.finishReason === "content_filter") {
throw new AgentRunError("Model response was blocked by the content filter");
}
const assistant = reply.message;
history.push(assistant);
if (assistant.toolCalls.length === 0) {
if (assistant.content === null) {
throw new AgentRunError("Model stopped without final text or tool calls");
}
return { finalText: assistant.content, history, turns: turn };
}
for (const call of assistant.toolCalls) {
const result = await options.executeTool(call, context);
history.push(toolMessage(result.content, call.id));
}
}
throw new AgentLimitError(`Agent exceeded maxTurns=${maxTurns}`);
}这就是一个能跑起来的 Agent Harness 的最小内核。完整版本在 code/chapters/ch01/src/core/loop.ts,第 1 章固定入口在 code/chapters/ch01/src/chapters/ch01.ts,行为测试在 code/chapters/ch01/tests/ch01-loop.test.ts 和 code/chapters/ch01/tests/shell-tool.test.ts。
22一个容易被忽略的差异:Anthropic 格式 vs OpenAI 格式这份代码用的是 OpenAI 兼容格式。如果你去读 Anthropic 官方的 API 文档,会发现消息结构稍有不同。把两种格式放在一起看,能更清楚地理解「模型有没有请求工具」这个信号本身:⌄
这份代码用的是 OpenAI 兼容格式。如果你去读 Anthropic 官方的 API 文档,会发现消息结构稍有不同。把两种格式放在一起看,能更清楚地理解「模型有没有请求工具」这个信号本身:
| 概念 | Anthropic 格式 | OpenAI 格式 |
|---|---|---|
| 判断模型是否调工具 | response.stop\_reason == "tool\_use" | response.choices\[0\].message.tool\_calls 是否非空 |
| 工具调用 ID 字段 | tool\_use\_id | tool\_call\_id |
| 工具结果消息的 role | user(把 tool\_result 嵌在 user 消息里) | tool |
| 工具调用块的格式 | content 里的 type: "tool\_use" 块 | message.tool\_calls 数组 |
形式不同,但本质信号是一样的:「模型有没有举手要工具」。这是 Agent Loop 唯一需要感知的信号,和用哪家 SDK 无关。
23必须说清楚:shell 不是沙箱配套实现运行在 Windows 11,工具名是 shell,里面执行的是 PowerShell。它没有字符串黑名单,因为换一种写法就能绕过黑名单,反而容易制造「已经安全」的错觉。⌄
配套实现运行在 Windows 11,工具名是 shell,里面执行的是 PowerShell。它没有字符串黑名单,因为换一种写法就能绕过黑名单,反而容易制造「已经安全」的错觉。
真实章节入口把 shell 标记为 execute 副作用,每次执行前都会显示工具名、原因和参数,并询问:
允许本次调用? [y/N]只有明确输入 y 或 yes 才会执行;回车、其他输入、无交互 stdin 或审批异常都默认拒绝。拒绝仍会生成同一个 tool_call_id 的工具错误结果,让模型可以换方案。
但审批也不等于隔离。PowerShellRunner 的 cwd 只是子进程的起始工作目录,命令仍可能切换目录、访问绝对路径、启动其他进程或访问网络。后面的文件工具有单独的工作区路径边界,shell 没有。真正的规则合并、审计和拒绝优先级会在第 3 章展开;在那之前,绝不能把 cwd 描述成 sandbox。
24几十行 vs 1729 行:CC 源码的差距在哪参考仓库 shareAI-lab/learn-claude-code 曾对 Claude Code 旧版核心文件 query.ts 做过逐行核查,记录显示该文件约 1729 行。Claude Code 本身不开源,公开仓库也不包含这个文件,所以下面的行号只是参考快照,不代表当前接口。⌄
参考仓库 shareAI-lab/learn-claude-code 曾对 Claude Code 旧版核心文件 query.ts 做过逐行核查,记录显示该文件约 1729 行。Claude Code 本身不开源,公开仓库也不包含这个文件,所以下面的行号只是参考快照,不代表当前接口。
核心循环逻辑只是上面这几十行。那剩下的代码干什么的?
全是保护机制。
25差异一:循环继续的判断方式上面的代码靠 toolcalls 是否非空来判断是否继续。CC 的做法不同:⌄
上面的代码靠 tool_calls 是否非空来判断是否继续。CC 的做法不同:
// 参考核查快照(非当前源码摘录):
// stop_reason === 'tool_use' is unreliable.
// Set during streaming whenever a tool_use block arrives.
let needsFollowUp = falseCC 在参考快照中用一个 needsFollowUp 标志位,在流式接收响应时,只要检测到 tool_use 块就设为 true,而不是等整个响应接收完再看 stop_reason。
为什么?因为流式响应里 stop_reason 可能滞后:内容块已经到了,元数据字段还没更新。如果只看 stop_reason,在流式场景下可能漏判,导致工具没被执行,循环提前退出。这是生产级代码对流式传输的具体适配,不是理论层面的差异。
官方 Agent SDK 文档现在也把循环描述为“一直重复,直到模型产生没有工具调用的响应”;stop_reason 仍然用来判断最终轮为什么结束,例如 end_turn、max_tokens 或 refusal。
26差异二:State 对象扛了 10 个字段第 1 章的核心只需要 history、轮次和工具上下文。CC 的循环 State 对象有 10 个字段:⌄
第 1 章的核心只需要 history、轮次和工具上下文。CC 的循环 State 对象有 10 个字段:
| 字段 | 用途 |
|---|---|
| messages | 当前迭代的消息数组 |
| toolUseContext | 工具、信号、权限上下文 |
| autoCompactTracking | context 压缩状态追踪 |
| maxOutputTokensRecoveryCount | token 恢复尝试次数,上限 3 次 |
| hasAttemptedReactiveCompact | 本轮是否已尝试响应式压缩 |
| maxOutputTokensOverride | 输出 token 限制从 8K 升至 64K 的覆盖 |
| pendingToolUseSummary | 后台用 Haiku 生成的工具调用摘要 |
| stopHookActive | 停止钩子是否产生阻塞错误 |
| turnCount | 轮次计数,用于 maxTurns 检查 |
| transition | 上一次循环继续的原因 |
举两个有意思的:
maxOutputTokensRecoveryCount 最多重试 3 次。因为模型有时候会被 token 上限截断,输出还没说完就停了。生产系统必须检测这种情况并恢复,不能直接当成「模型做完了」退出。
pendingToolUseSummary 是在后台用更便宜的模型(Haiku)给工具调用历史生成摘要,压缩 context 长度省 token。这是 Agent 长时间跑任务时做 context 管理的手段之一。
另外注意:taskBudgetRemaining(任务预算剩余)不在 State 上。源码注释明确写了 Loop-local (not on State):它只是循环内部的局部变量,每次进入循环重置。
27差异三:退出路径最小概念代码只有 1 条正常退出路径:模型不调工具。配套实现从第 1 章起还增加了最大轮次,以及 length、contentfilter、无最终文本等明确失败;它们只负责阻止错误完成,不在本章自动恢复。⌄
最小概念代码只有 1 条正常退出路径:模型不调工具。配套实现从第 1 章起还增加了最大轮次,以及 length、content_filter、无最终文本等明确失败;它们只负责阻止错误完成,不在本章自动恢复。
CC 的退出路径覆盖了:
- 达到最大轮次(maxTurns)
- Context 超长(prompt too long),尝试压缩后继续
- 模型输出被 token 上限截断,恢复后继续
- 模型报错
- 用户主动中断(abort)
- Stop Hook 拦截并产生阻塞错误
- token budget 耗尽后的继续策略
- 响应式压缩重试
每种路径都有独立的处理逻辑和恢复策略,不是简单的 return 或 throw。第 1 章仍接近单行道;后续章节会逐步补齐这些恢复路径。
28差异四:流式并行工具执行CC 还有一个 StreamingToolExecutor,在模型还在生成响应内容的时候,对已经到达的工具调用就开始执行了,不等模型说完。⌄
CC 还有一个 StreamingToolExecutor,在模型还在生成响应内容的时候,对已经到达的工具调用就开始执行了,不等模型说完。
具体怎么决定并发还是串行?看工具的 concurrency-safe 标记。安全的工具可以并行跑,有状态冲突风险的工具独占执行。这是纯性能优化,和循环本身的正确性无关,但在有大量工具调用的 Agent 任务里能显著减少总延迟。
29把上面的东西放在一起Agent = 模型决策 + Harness 执行 + 消息历史传递⌄
现在可以给出一个清晰的结论:
Agent = 模型决策 + Harness 执行 + 消息历史传递这三件事加在一起就是 Agent。不需要 Planner、不需要 Memory、不需要 Orchestrator;这些都只是在这三件事的基础上叠加出来的能力。
- 模型决策:「要不要用工具、用哪个、传什么参数」。这是智能,由模型完成,循环代码控制不了。
- Harness 执行:「工具调了就跑,结果喂回去」。这是机械动作,就是一段很短的循环。
- 消息历史传递:「每一轮的输入输出都追加进列表」。这是记忆,也就是内部
history列表。
换一种更常见的概念公式,就是 Agent = LLM + 上下文 + 工具:
- LLM 是决策内核,决定「要不要用工具、用哪个、传什么参数」;
- 上下文是模型每个决策点能看到的观察空间,由工具定义、系统提示和会话历史共同构成;
- 工具是模型改变世界的动作空间,Harness 执行则把工具接入这个上下文。
围绕这三个部分搭起来的约束、验证、恢复和权限机制,就是 Harness。最小循环只保留这三个可运行的部分,Planner、Memory、Orchestrator 都是在这之上叠加的能力。
三件事里,Harness 执行就是我们今天写的这段最小循环。剩下的生产代码,是围绕「Harness 执行」加的保护、恢复和优化;核心循环本身始终没变。
在第 1 章代码里,观察空间由 system prompt、消息历史、工具 schema 组成;动作空间由 ToolRegistry 中的 shell 组成;而 config、人工审批、错误分类和 maxTurns,正是最早的 Harness。
30源码设计地图:13 个文件各管一件事把第 1 章的 13 个源码文件放到一起看,核心循环之外的大部分代码都在做「启动、边界和约束」:⌄
把第 1 章的 13 个源码文件放到一起看,核心循环之外的大部分代码都在做「启动、边界和约束」:
| 文件 | 职责 | 正文对应 |
|---|---|---|
src/core/loop.ts | AgentRunner:消息历史、有界循环、工具回填、停止条件 | 五步拆解 |
src/core/messages.ts | 四类消息的 discriminated union 与 tool 配对验证 | 第一步 |
src/core/tools.ts | ToolRegistry:schema、模型工具视图、prepare/invoke、错误结果 | 第二步、第四步 |
src/core/model.ts | ModelClient / ModelReply 模型边界 | 第二步 |
src/core/commands.ts | CommandRunner / CommandResult 进程执行契约 | shell 工具 |
src/core/profiles.ts | ChapterProfile / P01 章节能力白名单 | 启动链 |
src/features/builtin-tools.ts | 注册 shell 工具 | 第二步 |
src/adapters/openai-chat.ts | OpenAI SDK 适配器与响应收窄 | 第三步 |
src/adapters/powershell.ts | PowerShellRunner 超时、截断、UTF-8 | shell 工具 |
src/config.ts | 启动前校验 .env 与 URL | 运行 |
src/bootstrap.ts | 组合根:注入模型、命令、工具和 profile | 启动链 |
src/cli.ts | 固定章节 / 通用 CLI 两入口、TerminalAuthorizer | 审批 |
src/chapters/ch01.ts | 第 1 章固定入口 | 运行 |
profiles.ts 看起来很小,但它是章节能力边界:固定入口只允许 P01,组合根据此拒绝把后续章节能力装进第 1 章。commands.ts 则是 PowerShellRunner 后面的窄接口,让测试可以用 fake 替换真实子进程。启动顺序是:入口解析参数 → config.ts 校验配置 → bootstrap.ts 组装依赖 → AgentRunner 跑循环;核心循环完全不碰 SDK、子进程和 .env。
31手里只有 shell 的问题目前这个 Agent 手里只有一个工具:shell,也就是 Windows PowerShell 命令入口。⌄
目前这个 Agent 手里只有一个工具:shell,也就是 Windows PowerShell 命令入口。
这意味着:
- 读文件要 Get-Content .\path\to\file
- 写文件要 Set-Content .\path\to\file -Value "..."
- 找文件要 Get-ChildItem -Recurse -Filter '*.ts'
- 移动文件要 Move-Item
用起来又丑又脆:路径和转义容易写错;文件内容里有引号、换行或特殊字符时尤其明显;多步操作也没有原子性。
下一篇会给它四个专用工具:read_file、write_file、edit_file、glob。shell 仍然保留,后章在本章能力上继续累加。
到时候会出现新问题:模型会不会一次调用多个工具?两个工具同时写同一个文件会怎样?并行执行的边界在哪?
32运行第 1 章智能体配套代码要求 Node.js 20.12 或更高版本。先在 PowerShell 中进入 code/,按锁文件同步依赖并创建配置:⌄
配套代码要求 Node.js 20.12 或更高版本。先在 PowerShell 中进入 code/,按锁文件同步依赖并创建配置:
Set-Location 'F:\笔记\Agent实操\code'
npm ci
Copy-Item .env.example .env编辑 .env,显式填写下面三项。任何一项为空,程序都会在网络请求前列出缺失字段,不会用默认模型或默认地址掩盖配置错误:
OPENAI_BASE_URL=
OPENAI_API_KEY=
OPENAI_MODEL=OPENAI_BASE_URL 应是不含 /chat/completions 的 OpenAI 兼容地址,例如 https://api.openai.com/v1;多数第三方供应商地址也需要以 /v1 结尾,否则 SDK 会构造出错误路径。
第 1 章同时提供固定章节入口和统一 CLI 入口,两条命令构建同一个固定 P01 profile:
npm run ch01 -- --prompt "运行 Write-Output 42,并告诉我结果"
npm run agent-tutorial -- run --chapter 1 --prompt "运行 Write-Output 42,并告诉我结果"命令从当前目录读取 .env,也把当前目录作为 Agent workspace。模型请求 shell 时,终端会要求逐次审批;只批准你已经检查过的真实命令。
33第 0—2 步:确认 Node、安装依赖、先跑离线测试建议在配 API 之前先做第 2 步。它能把「环境问题」和「配置问题」分开。⌄
node --version # 预期 v20.12.0 或更高
Set-Location 'F:\笔记\Agent实操\code'
npm ci # 严格按 package-lock.json 安装
npm run test:ch01 # 不需要任何密钥第 2 步预期输出的最后几行:
Test Files 6 passed (6)
Tests 30 passed (30)中间会看到一行 配置错误: Missing required settings: OPENAI_BASE_URL, OPENAI_API_KEY, OPENAI_MODEL——这是正常的,它是 config.test.ts 故意触发的用例输出,不是你的环境出错。这 30 个测试全部离线:模型和 PowerShell 都被替身注入,不发一个网络请求。
34第 3 步:创建并填写 .envOPENAI_BASE_URL 有两条硬性要求,写错的人非常多。⌄
Copy-Item .env.example .env
notepad .envOPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4.1| 要求 | 正确 | 错误 |
|---|---|---|
不要带 /chat/completions | https://api.openai.com/v1 | https://api.openai.com/v1/chat/completions |
通常要以 /v1 结尾 | https://your-provider.com/v1 | https://your-provider.com |
SDK 会自己在 base URL 后面拼 /chat/completions。你要是已经写上了,最终请求路径就变成 /v1/chat/completions/chat/completions。config.ts 会在发请求前就检测出这种写法并直接报错。
任何一项为空,程序都会在网络请求前列出缺失字段。没有隐式默认值是有意的设计:宁可启动失败,也不要让你以为在用 A 模型、实际在用 B 模型。
35第 4—5 步:跑真实 Agent,看懂审批提示并批准三行审批信息对应三个判断依据:哪个工具、为什么要批准、具体参数是什么。⌄
# 入口一:固定章节脚本
npm run ch01 -- --prompt "运行 Write-Output 42,并告诉我结果"
# 入口二:统一 CLI,用 --chapter 选章
npm run agent-tutorial -- run --chapter 1 --prompt "运行 Write-Output 42,并告诉我结果"注意 -- 那两个横杠:它告诉 npm「后面的参数是给脚本的,不是给 npm 的」,漏了参数就传不进去。
工具调用需要批准: shell
原因: PowerShell command execution requires explicit approval.
参数: {"command":"Write-Output 42"}
允许本次调用? [y/N] 看清 参数 那一行再决定——这是你唯一能拦住危险命令的地方。输入 y 回车,预期最终输出类似:
42
命令执行成功,输出是 42。只有明确输入 y 或 yes 才会执行。回车、其他输入、无交互 stdin 或审批异常都默认拒绝。 拒绝仍会生成同一个 tool_call_id 的工具错误结果,让模型可以换方案——所以你输 n 之后 Agent 不会崩,它会告诉你「我没能执行命令」。
36常见报错排查按终端显示的错误文本查表。⌄
| 你看到的错误 | 原因 | 怎么修 |
|---|---|---|
配置错误: Missing required settings: ... | .env 缺字段或字段为空 | 补齐报错里列出的那几项。注意等号后面有空格也算空 |
只报 OPENAI_BASE_URL 这一项 | URL 不合法、协议不是 http(s)、或结尾带了 /chat/completions | 按第 3 步的表改 URL |
运行失败: Unknown argument: ... | 参数拼错,或漏了 -- | 检查是否写成 npm run ch01 --prompt "..."(少了一个 --) |
运行失败: Both --chapter and --prompt are required | 用统一入口但没给 --chapter | 加上 --chapter 1 |
运行失败: Expected command: run | 统一入口漏了 run 子命令 | 用 npm run agent-tutorial -- run --chapter 1 ... |
运行失败: Agent exceeded maxTurns=20 | 模型反复调工具但拿不到满意结果 | 换更明确的 prompt;或先用简单命令确认链路通 |
运行失败: Model output reached the token limit | 模型输出被截断(finish_reason=length) | 让任务输出更短。第 11 章才会自动恢复这种情况 |
运行失败: Unsupported finish_reason: ... | 供应商返回了非标准 finish_reason | 该第三方接口未完全兼容 Chat Completions,换供应商或换模型 |
打印 无交互输入,默认拒绝。 | 没有交互终端(管道、CI、某些 IDE 内置终端) | 在真实 PowerShell 窗口里跑 |
| 401 / 403 类网络错误 | API Key 无效或无权限 | 检查 OPENAI_API_KEY,确认该 key 能访问 OPENAI_MODEL |
| 404 类网络错误 | base URL 路径不对 | 多半是漏了 /v1,见第 3 步 |
37验证与实验:测试就是可重复的对照配套测试把本章最关键的边界变成了可重复的离线实验。运行 npm run test:ch01 会执行 6 个测试文件、30 个测试:⌄
配套测试把本章最关键的边界变成了可重复的离线实验。运行 npm run test:ch01 会执行 6 个测试文件、30 个测试:
| 测试文件 | 验证内容 |
|---|---|
ch01-loop.test.ts | 完整 Agent 循环、工具结果配对、未知工具、非法 JSON、最大轮次、截断回复 |
shell-tool.test.ts | 真实 PowerShell cwd / UTF-8、超时、截断、启动失败、handler 非法返回 |
messages.test.ts | tool 调用与结果的严格配对、重复 / 孤立 / 缺失结果 |
config.test.ts | 缺失配置、非 HTTP URL、重复 /chat/completions 路径 |
profiles.test.ts | P01 能力白名单与不可变集合 |
openai-chat.test.ts | SDK 响应规范化、拒绝、非法 finish_reason、legacy function_call、用量校验 |
这些测试是“消融实验”思维的工程化版本:通过脚本化 ModelClient 或 fake CommandRunner,一次只替换一个外部边界,观察 AgentRunner 是否仍能保持消息配对、错误回填和停止条件。真实模型实验需要密钥,离线验证由这些可重复测试覆盖。
38三个建议动手做的小实验看懂不等于会写。三个改动都只要几分钟,能把核心约束从「知道」变成「验证过」。改完记得改回去。⌄
| 实验 | 预期 | 学到什么 |
|---|---|---|
| 一 · 把轮次上限调到 1 在 ch01-loop.test.ts 里把 maxTurns 传成 1,让脚本化模型第一轮就请求工具 | 抛 AgentLimitError,消息是 Agent exceeded maxTurns=1 | 轮次上限拦的是「模型第一轮调了工具,但没机会给出最终文本」——工具执行完了,循环却没有下一轮可用 |
| 二 · 故意漏掉一个工具结果 一条 assistant 消息带两个 toolCalls,但只跟一条 tool 消息,再调 validateToolPairing(history) | 抛 MessageContractError,消息形如 missing tool results for ids: ["call_2"] | 配对校验的价值在于提前报错。少了这层校验,你会在供应商返回一个含糊的 400 时才发现问题,而且不知道是哪个 ID 漏了 |
三 · 让 fake 命令执行器返回非零退出码FakeCommandRunner 返回 exitCode: 1 | 循环不中断,历史里出现一条内容包含 shell_failed 的 role: "tool" 消息 | 工具失败是数据,不是异常。这也正是 Agent 能自己换方案重试的原因 |
39本章小结 · 三句话版本循环只有一个分叉点和三个出口;代码量的差距全在循环外围。⌄
- Agent Loop 就是「问模型 → 看
tool_calls空不空 → 执行工具 → 把结果 push 回历史 → 再问模型」,代码不到 60 行。 - 循环只有一个分叉点(
tool_calls空不空)和三个出口(正常返回、轮次耗尽、不可恢复错误)。 - 生产级 Agent 的代码量差距不在循环,全在循环外围的保护:校验、审批、错误分类、恢复、上限。
40一定要记住的五条五条结论都能追溯到正文里的具体一节。⌄
| # | 结论 | 出现在哪一节 |
|---|---|---|
| 1 | 退出条件是「模型没调工具」,不是「模型说完了」 | 第三步 |
| 2 | 每个 tool_call 必须有且只有一个同 ID 的结果,失败和拒绝也算 | 第四步 |
| 3 | 参数校验必须在副作用之前;prepare 不执行,invoke 才执行 | 第四步 |
| 4 | 工具失败返回 ToolResult,不 throw——否则模型没机会纠错 | shell 工具 |
| 5 | 安全边界出错时选择拒绝(fail-closed),不选择放行 | 第四步 |
41本章代码边界(明确「还没做什么」)别把第 1 章的 cwd 当成沙箱。这是本章最需要强调的边界。⌄
| 已经有 | 还没有 | 在哪一章补 |
|---|---|---|
一个 shell 工具 | 专用文件工具(读/写/改/找) | 第 2 章 |
| 逐次人工审批 | 规则化权限策略、审计、四态决定 | 第 3 章 |
cwd 起始目录 | 真正的路径边界(shell 目前没有) | 第 2、3 章 |
明确失败(length/content_filter) | 截断续写、限流重试、fallback 模型 | 第 11 章 |
只增不减的 history | 上下文压缩与产物落盘 | 第 8 章 |
| 单个 Agent | 子 Agent、多 Agent 协作 | 第 6、15–18 章 |
| 启动时固定的工具集 | MCP 动态工具池 | 第 19 章 |
42检查你是否真的读懂了试着不看文章回答这五个问题。答不上来的,回对应章节再看一遍。⌄
- 模型返回
content: "好的"且tool_calls: [],循环该继续还是结束?(第三步) - 模型返回
content: null且tool_calls: [],代码该怎么做?为什么不能直接返回?(第三步) - 模型一轮请求了 3 个工具,其中 1 个执行失败。要往历史里 push 几条
tool消息?(第四步) snapshot()到底防的是什么问题?(第二步)- 为什么
maxTurns=20指的是 20 次模型请求,而不是 20 次工具调用?(第五步)
下一章开始,我们给这个循环装上四个专用文件工具,并第一次遇到「工具需要边界」这个问题。
换个场景,你还会判断吗?
每题只测一个边界。先做决定,再看解释。
准备开始