Agent Loop:一个循环,就是模型与真实世界之间的全部距离
🎯 本章导读
- 用一句话说清 Agent 和「调用一次模型 API」的区别。
- 看懂一轮 Agent 对话在网络上真正传了哪些 JSON 字段。
- 自己手写一个不到 60 行、能跑起来的 Agent Loop。
- 说出这个循环在什么条件下结束,以及为什么必须有轮次上限。
- 在本机跑通第 1 章配套代码,并让 Agent 帮你执行一条 PowerShell 命令。
你需要先具备什么 ▸
| 需要 | 程度 |
|---|---|
| TypeScript / JavaScript | 能看懂 async/await、数组、interface,不需要熟悉高级类型 |
| 命令行 | 能在 PowerShell 里进入目录、运行 npm 命令 |
| 大模型 API | 知道「把消息发给模型、模型返回文本」这件事就够了 |
| 上一章 | 无。这是第 1 章 |
不需要读过任何 Agent 框架的文档。本章出现的每个自造概念都会在下面的术语表里先解释一遍。
建议的阅读路线 ▸
本章导读(你在这)
↓
① 先说你现在是什么角色 ← 建立直觉:Agent 替代的是「你」
↓
② 两个信号,一个循环 ← 决策树只有两个分支
↓
③ 先看一眼真实的数据流 ← 两轮 JSON 实例,最关键的一节
↓
④ 五步拆解 + 完整函数 ← 代码怎么跑起来
↓
⑤ 边界与差距 ← 格式差异、shell 不是沙箱、CC 的四点差距
↓
⑥ 结论与源码地图 ← Agent = 模型 + Harness + 历史
↓
⑦ 运行第 1 章智能体 ← 动手,六步 + 排错表
↓
⑧ 验证、实验与小结 ← 三个动手实验 + 自测题
📇 术语速查
| 术语 | 一句话解释 |
|---|---|
| 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 | 出错时选择拒绝而不是放行。审批环节抛异常也当作「拒绝」处理 |
🧠 核心概念
1 · 你现在是什么角色:人工 API ▸
没有 Agent 时,你在对话框让模型「列出最大的 .ts 文件」,模型只输出一条 PowerShell 命令,但自己不跑。你切到终端、粘贴、回车、看输出、再切回来把输出粘进对话框。
每一次来回,你都在做同一件事:把工具结果手动传回给模型。这时你就是一个「人工 API」,是模型和真实世界之间的中间层。
把这个中间层自动化,就是 Agent Loop 要做的唯一一件事。
2 · 两个信号,一个循环 ▸
整个决策树只有两个分支:
模型返回 tool_calls 了?
├── 是 → 执行工具,把结果喂回去,再问一次
└── 否 → 模型觉得做完了,退出
| 信号 | 含义 | 动作 |
|---|---|---|
assistant.tool_calls 非空 | 模型举手「我要用工具」 | 执行 → 回填 → 继续循环 |
assistant.tool_calls 为空 | 模型没再请求工具 | 返回最终文本;无文本则明确失败 |
3 · 观察空间 vs 动作空间 ▸
从概念框架看,这个循环对应模型与真实世界之间的两个接口:
- 观察空间(模型能看到什么):由 system prompt、消息历史、工具 schema 共同组成的请求快照。
- 动作空间(模型能做什么):
ToolRegistry注册的全部工具。
工具执行完,结果必须重新放回观察空间,循环才能继续——这就是「回填」。
4 · 五步拆解:代码怎么跑起来 ▸
- 用户问题进消息列表:
history.push(userMessage(prompt))。消息用 system/user/assistant/tool 四种角色的 discriminated union,构造时冻结。 - 把消息+工具定义一起发给模型:
tools.snapshot()冻结本次可见工具集,保证模型看到的 schema 和执行时用的是同一份快照。 - 追加模型回答,检查是否调工具:
toolCalls.length === 0就退出。退出条件不是「说完了」,而是「没调工具」。 - 执行工具,收集结果:每个调用产生一个独立
role:"tool"消息,靠tool_call_id和前面的调用一一配对。 - 把结果追加,回到第二步:循环结构本身实现了「回去」,不需要额外代码。
5 · 退出条件不是「说完了」,而是「没调工具」 ▸
这两件事不等价,有四种组合:
| 说话 | 调工具 | 循环 |
|---|---|---|
| ✓ | ✓ | 继续(边说边调) |
| ✗ | ✓ | 继续(只调不说话) |
| ✓ | ✗ | 退出(说话且不调) |
| ✗ | ✗ | 明确失败(没文本也没工具) |
我们只关心它有没有举手要工具。
6 · shell 不是沙箱 ▸
工具名 shell,执行 PowerShell。没有字符串黑名单(换个写法就绕过,反而制造「已安全」的错觉)。它被标记为 execute 副作用,每次执行前显示工具名、原因、参数并询问 允许本次调用? [y/N]。
cwd 只是子进程起始目录,命令仍可切目录、访问绝对路径、启动进程、访问网络。在文件工具有路径边界之前,绝不能把 cwd 描述成 sandbox。真正的权限合并在第 3 章。7 · 几十行 vs 1729 行:差的全是保护机制 ▸
核心循环就几十行。参考仓库核查 Claude Code 旧版 query.ts 约 1729 行,剩下的全是保护机制:
| 差异点 | 最小循环 | 生产级(CC) |
|---|---|---|
| 继续判断 | 看 tool_calls 非空 | 流式中用 needsFollowUp 标志位,避免 stop_reason 滞后漏判 |
| State 字段 | history、轮次、上下文 | 10 个字段(含 token 恢复计数、响应式压缩、后台摘要…) |
| 退出路径 | 1 条正常 + 几个明确失败 | 8 条(最大轮次/超长/截断恢复/报错/中断/Hook/预算/压缩重试) |
| 工具执行 | 串行 | StreamingToolExecutor 按 concurrency-safe 标记决定并行/独占 |
📡 先看一眼真实的数据流
第 1 轮 · 我们发出的请求 ▸
只有两个我们关心的部分:messages(模型能看到什么)和 tools(模型能做什么)。
{
"model": "gpt-4.1",
"messages": [
{ "role": "system", "content": "You are a coding agent. Use tools when needed..." },
{ "role": "user", "content": "运行 Write-Output 42,并告诉我结果" }
],
"tools": [
{
"type": "function",
"function": {
"name": "shell",
"description": "Run a PowerShell command in the current workspace.",
"parameters": {
"type": "object",
"properties": { "command": { "type": "string", "minLength": 1 } },
"required": ["command"],
"additionalProperties": false
}
}
}
]
}
parameters 就是「工具说明书」,不用手写——它由 z.strictObject({ command: z.string().min(1) }) 这一行 Zod 定义自动生成。同一份定义既生成说明书,又用于校验模型传回的参数,不可能出现「说明书说 command,校验时却要 cmd」的错位。第 1 轮 · 模型返回的响应 ▸
{
"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 | 记住它,下一步回填结果时要用 |
中间步骤 · 我们在本机执行 ▸
① 按名字查工具 shell → 找到定义(找不到就生成 unknown_tool 错误结果)
② 解析 arguments JSON.parse('{"command":"Write-Output 42"}')
③ Zod 校验 必须有 command、必须非空字符串、不能有多余字段
④ 人工审批 终端打印命令,等你输入 y
↓ 你批准了
⑤ 真正执行 启动 PowerShell 子进程,拿到 stdout "42"
第 2 轮 · 把结果喂回去,模型给出答案 ▸
关键在于:上一轮的 assistant 消息要原样留在历史里,然后紧跟一条 role: "tool" 的新消息。
"messages": [
{ "role": "system", "content": "..." },
{ "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" }
]
tool_call_id 和上一轮的 id 对上了。这是模型唯一的线索——它靠这个编号知道「42 是我那次 Write-Output 42 调用的结果」。
模型返回最终答案:
{
"choices": [{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": "命令执行成功,输出是 42。",
"tool_calls": []
}
}]
}
tool_calls 空了。循环退出,把 content 作为最终答案返回给用户。
把两轮压缩成一张图 ▸
我们 模型
│ │
第1轮 │ messages(2条) + tools(1个) ───▶ │
│ │ 「我要 shell」
│ ◀──── tool_calls=[call_abc123] │
│ │
执行 │ 查工具→解析参数→Zod校验→审批→跑 │
│ 得到 "42" │
│ │
第2轮 │ messages(4条) + tools(1个) ───▶ │
│ ↑ 多了 assistant + tool 两条 │ 「够了,我能回答了」
│ ◀──── tool_calls=[] content="…" │
│ │
结束 │ 返回 content 给用户 │
messages 都在变长——这就是 Agent 的「记忆」。没有别的机制,就是一个数组不断 push。也正因为它只增不减,第 8 章才必须专门讲上下文压缩。▶️ 交互演示:Agent Loop 步进模拟器
tool_calls 了吗?toolMessage 回填 → 下一轮role:"tool" 消息靠 tool_call_id 和上面的 assistant 调用配对——OpenAI 格式的硬要求。📖 代码精读:最小 agentLoop
code/chapters/ch01/src/core/loop.ts。async function agentLoop(options): Promise<RunResult> {
const maxTurns = options.maxTurns ?? 20; // 带上限,防止永不退出
const history: ChatMessage[] = [userMessage(options.prompt)];
for (let turn = 1; turn <= maxTurns; turn += 1) { // ← 有界循环
const request: ModelRequest = {
messages: [systemMessage(options.systemPrompt), ...history],
tools: options.tools.openAITools(), // 工具 schema = 动作空间
};
const reply = await options.model.complete(request);
if (reply.finishReason === "length") throw new IncompleteModelReplyError(...);
if (reply.finishReason === "content_filter") throw new AgentRunError(...);
const assistant = reply.message;
history.push(assistant); // 第三步:追加回答
if (assistant.toolCalls.length === 0) { // ← 唯一的正常退出信号
if (assistant.content === null) throw new AgentRunError("stopped without text or tools");
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)); // 靠 id 配对回填
}
} // 第五步:回到 for 顶
throw new AgentLimitError(`exceeded maxTurns=${maxTurns}`);
}
shell 这个词。循环只认识「一次调用返回一个结果」这个抽象协议——这就是下一篇「加工具只加一行」的基础。🚀 运行第 1 章智能体
第 0–2 步 · 环境、依赖与离线测试 ▸
第 0 步 · 确认 Node 版本
node --version
预期输出类似 v20.12.0 或更高。低于 20.12 请先升级 Node.js。
第 1 步 · 进入 code 目录并安装依赖
Set-Location 'F:\笔记\Agent实操\code'
npm ci
用 npm ci 而不是 npm install:前者严格按 package-lock.json 安装,保证依赖版本和教程一致。
第 2 步 · 先跑离线测试(不需要任何密钥)
npm run test:ch01
Test Files 6 passed (6)
Tests 30 passed (30)
配置错误: Missing required settings: …——这是正常的,它是 config.test.ts 故意触发的用例输出。第 3 步 · 创建并填写 .env ▸
Copy-Item .env.example .env
notepad .env
OPENAI_BASE_URL=https://api.openai.com/v1
OPENAI_API_KEY=sk-...
OPENAI_MODEL=gpt-4.1
| 要求 | 正确 | 错误 |
|---|---|---|
不要带 /chat/completions | https://api.openai.com/v1 | …/v1/chat/completions |
通常要以 /v1 结尾 | https://your-provider.com/v1 | https://your-provider.com |
SDK 会自己在 base URL 后面拼 /chat/completions。config.ts 会在发请求前就检测出重复路径并直接报错。
第 4–5 步 · 跑真实 Agent 并看懂审批提示 ▸
# 入口一:固定章节脚本
npm run ch01 -- --prompt "运行 Write-Output 42,并告诉我结果"
# 入口二:统一 CLI,用 --chapter 选章
npm run agent-tutorial -- run --chapter 1 --prompt "运行 Write-Output 42,并告诉我结果"
注意 -- 那两个横杠:它告诉 npm「后面的参数是给脚本的」,漏了参数就传不进去。
工具调用需要批准: shell
原因: PowerShell command execution requires explicit approval.
参数: {"command":"Write-Output 42"}
允许本次调用? [y/N]
这三行对应审批的三个判断依据:哪个工具、为什么要批准、具体参数是什么。看清参数那一行再决定——这是你唯一能拦住危险命令的地方。
y 或 yes 才会执行。回车、其他输入、无交互 stdin 或审批异常都默认拒绝。拒绝仍会生成同一个 tool_call_id 的工具错误结果,所以你输 n 之后 Agent 不会崩。常见报错排查表 ▸
| 你看到的错误 | 原因 | 怎么修 |
|---|---|---|
配置错误: Missing required settings: … | .env 缺字段或字段为空 | 补齐报错里列出的那几项;等号后面有空格也算空 |
只报 OPENAI_BASE_URL | URL 不合法或结尾带了 /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: … | 供应商返回了非标准值 | 该接口未完全兼容 Chat Completions,换供应商或模型 |
无交互输入,默认拒绝。 | 没有交互终端(管道、CI、某些 IDE 终端) | 在真实 PowerShell 窗口里跑 |
| 401 / 403 | API Key 无效或无权限 | 确认该 key 能访问 OPENAI_MODEL |
| 404 | base URL 路径不对 | 多半是漏了 /v1 |
三个建议动手做的小实验 ▸
看懂不等于会写。下面三个改动都只要几分钟,但能把本章的核心约束从「知道」变成「验证过」。改完记得改回去。
| 实验 | 预期 | 学到什么 |
|---|---|---|
| 一·把轮次上限调到 1 让脚本化模型第一轮就请求工具 | 抛 AgentLimitError,消息是 Agent exceeded maxTurns=1 | 轮次上限拦的是「工具执行完了,循环却没有下一轮可用」 |
| 二·故意漏掉一个工具结果 两个 toolCalls 只跟一条 tool 消息 | 抛 MessageContractError: missing tool results for ids: ["call_2"] | 配对校验的价值在于提前报错,而不是等供应商返回含糊的 400 |
三·命令执行器返回非零退出码FakeCommandRunner 返回 exitCode: 1 | 循环不中断,历史里出现含 shell_failed 的 role:"tool" 消息 | 工具失败是数据,不是异常——也正是 Agent 能自己换方案重试的原因 |
🔑 一句话总结
Agent = 模型决策 + Harness 执行 + 消息历史传递即
Agent = LLM + 上下文 + 工具。Planner / Memory / Orchestrator 都是在这三件事之上叠加的能力。
- 模型决策:要不要用工具、用哪个、传什么参数——智能,循环控制不了。
- Harness 执行:工具调了就跑,结果喂回去——机械动作,就是这段短循环。
- 消息历史传递:每轮输入输出追加进列表——记忆,就是
history。
📝 本章小结
- Agent Loop 就是「问模型 → 看
tool_calls空不空 → 执行工具 → 把结果 push 回历史 → 再问模型」,代码不到 60 行。 - 循环只有一个分叉点(
tool_calls空不空)和三个出口(正常返回、轮次耗尽、不可恢复错误)。 - 生产级 Agent 的代码量差距不在循环,全在循环外围的保护:校验、审批、错误分类、恢复、上限。
一定要记住的五条 ▸
| # | 结论 | 出现在哪一节 |
|---|---|---|
| 1 | 退出条件是「模型没调工具」,不是「模型说完了」 | 第三步 |
| 2 | 每个 tool_call 必须有且只有一个同 ID 的结果,失败和拒绝也算 | 第四步 |
| 3 | 参数校验必须在副作用之前;prepare 不执行,invoke 才执行 | 第四步 |
| 4 | 工具失败返回 ToolResult,不 throw——否则模型没机会纠错 | shell 工具 |
| 5 | 安全边界出错时选择拒绝(fail-closed),不选择放行 | 第四步 |
本章代码边界(明确「还没做什么」) ▸
| 已经有 | 还没有 | 在哪一章补 |
|---|---|---|
一个 shell 工具 | 专用文件工具(读/写/改/找) | 第 2 章 |
| 逐次人工审批 | 规则化权限策略、审计、四态决定 | 第 3 章 |
cwd 起始目录 | 真正的路径边界(shell 目前没有) | 第 2、3 章 |
明确失败(length/content_filter) | 截断续写、限流重试、fallback 模型 | 第 11 章 |
只增不减的 history | 上下文压缩与产物落盘 | 第 8 章 |
| 单个 Agent | 子 Agent、多 Agent 协作 | 第 6、15–18 章 |
| 启动时固定的工具集 | MCP 动态工具池 | 第 19 章 |
cwd 当成沙箱。这是本章最需要强调的边界。