第 1 章 Agent Loop · Agent架构实操一 开始测验

Agent Loop:一个循环,就是模型与真实世界之间的全部距离

不画架构框图,只看代码。整章只讲一个核心:让模型和真实世界之间那个「人工搬运结果」的中间层自动化。
⏱ 约 12 分钟 📦 13 个源码文件 🔧 工具:shell 🧪 30 个离线测试

🎯 本章导读

读完这一章,你应该能用一句话回答下面每个问题。
读完能做到
  1. 用一句话说清 Agent 和「调用一次模型 API」的区别。
  2. 看懂一轮 Agent 对话在网络上真正传了哪些 JSON 字段。
  3. 自己手写一个不到 60 行、能跑起来的 Agent Loop。
  4. 说出这个循环在什么条件下结束,以及为什么必须有轮次上限。
  5. 在本机跑通第 1 章配套代码,并让 Agent 帮你执行一条 PowerShell 命令。
你需要先具备什么
需要程度
TypeScript / JavaScript能看懂 async/await、数组、interface,不需要熟悉高级类型
命令行能在 PowerShell 里进入目录、运行 npm 命令
大模型 API知道「把消息发给模型、模型返回文本」这件事就够了
上一章无。这是第 1 章

不需要读过任何 Agent 框架的文档。本章出现的每个自造概念都会在下面的术语表里先解释一遍。

建议的阅读路线
本章导读(你在这)
  ↓
① 先说你现在是什么角色      ← 建立直觉:Agent 替代的是「你」
  ↓
② 两个信号,一个循环        ← 决策树只有两个分支
  ↓
③ 先看一眼真实的数据流      ← 两轮 JSON 实例,最关键的一节
  ↓
④ 五步拆解 + 完整函数       ← 代码怎么跑起来
  ↓
⑤ 边界与差距               ← 格式差异、shell 不是沙箱、CC 的四点差距
  ↓
⑥ 结论与源码地图            ← Agent = 模型 + Harness + 历史
  ↓
⑦ 运行第 1 章智能体         ← 动手,六步 + 排错表
  ↓
⑧ 验证、实验与小结          ← 三个动手实验 + 自测题
提示 想先跑起来再读原理?直接跳到 运行第 1 章智能体,那一节是完全自包含的步骤清单,跑通后再回头读 ③ 真实数据流

📇 术语速查

本章第一次出现的词。先扫一眼,正文里遇到不认识的词回来查即可。不用背
术语一句话解释
Agent Loop「问模型 → 执行工具 → 把结果喂回去 → 再问模型」的循环。本章唯一主题
Harness(挽具)循环之外的所有代码:配置校验、工具注册、参数校验、审批、错误分类、轮次上限。模型负责决定,Harness 负责安全地落地
轮次(turn)一次「发请求给模型」算一轮。注意不是一次工具调用算一轮
tool_calls模型回复里的一个数组字段。非空表示「我要用工具」,空表示「我说完了」
tool_call_id每次工具调用的唯一编号。工具结果靠它对应回是哪次调用
ToolRegistry工具注册表。存放所有工具的名字、说明、参数格式和执行函数
snapshot(快照)把注册表在本轮「拍照定格」。保证模型看到的工具清单和随后真正执行的清单是同一份
ToolResult工具执行的统一结果类型。成功和失败都是 ToolResult,不抛异常打断循环
discriminated unionTypeScript 写法:用 role 字段区分四种消息,编译器据此知道每种消息各有哪些字段
观察空间 / 动作空间模型「能看到什么」(系统提示 + 消息历史 + 工具说明)和「能做什么」(注册表里的工具)
组合根唯一负责创建真实依赖并注入的文件,本章是 bootstrap.ts。核心循环自己不 new 任何 SDK 或子进程
profile章节能力白名单。第 1 章的 P01 只允许装 looppowershell 两项能力
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 · 五步拆解:代码怎么跑起来
  1. 用户问题进消息列表:history.push(userMessage(prompt))。消息用 system/user/assistant/tool 四种角色的 discriminated union,构造时冻结。
  2. 把消息+工具定义一起发给模型:tools.snapshot() 冻结本次可见工具集,保证模型看到的 schema 和执行时用的是同一份快照
  3. 追加模型回答,检查是否调工具:toolCalls.length === 0 就退出。退出条件不是「说完了」,而是「没调工具」。
  4. 执行工具,收集结果:每个调用产生一个独立 role:"tool" 消息,靠 tool_call_id 和前面的调用一一配对。
  5. 把结果追加,回到第二步:循环结构本身实现了「回去」,不需要额外代码。
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 标记决定并行/独占

📡 先看一眼真实的数据流

很多人学 Agent 卡住,不是卡在代码,而是卡在「不知道网络上到底传了什么」。下面不看代码,只看数据。用户输入:「运行 Write-Output 42,并告诉我结果。」
第 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\"}"
        }
      }]
    }
  }]
}
必须看清的细节为什么重要
contentnull模型这一轮一个字都没说,只举手要工具。所以「模型有没有说完」不能用有没有文本判断
arguments 是字符串里面是 JSON 文本,需要自己 JSON.parse。模型完全可能吐出语法错误的 JSON——必须处理的真实情况
idcall_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 步进模拟器

点「下一步」逐步推进循环,看消息历史如何增长、循环靠什么信号决定去留。左边是决策树,右边是消息日志。
📋 场景:找出当前目录最大的 .ts 文件并报告行数 轮次 0/3
模型返回 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)
建议 在配 API 之前先做这一步,把「环境问题」和「配置问题」分开。中间会看到一行 配置错误: 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/completionshttps://api.openai.com/v1…/v1/chat/completions
通常要以 /v1 结尾https://your-provider.com/v1https://your-provider.com

SDK 会自己在 base URL 后面拼 /chat/completionsconfig.ts 会在发请求前就检测出重复路径并直接报错。

没有隐式默认值 任何一项为空都会在网络请求前列出缺失字段。宁可启动失败,也不要让你以为在用 A 模型、实际在用 B 模型。
第 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]

这三行对应审批的三个判断依据:哪个工具、为什么要批准、具体参数是什么。看清参数那一行再决定——这是你唯一能拦住危险命令的地方。

fail-closed 只有明确输入 yyes 才会执行。回车、其他输入、无交互 stdin 或审批异常都默认拒绝。拒绝仍会生成同一个 tool_call_id 的工具错误结果,所以你输 n 之后 Agent 不会崩。
常见报错排查表
你看到的错误原因怎么修
配置错误: Missing required settings: ….env 缺字段或字段为空补齐报错里列出的那几项;等号后面有空格也算空
只报 OPENAI_BASE_URLURL 不合法或结尾带了 /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 / 403API Key 无效或无权限确认该 key 能访问 OPENAI_MODEL
404base URL 路径不对多半是漏了 /v1
三个建议动手做的小实验

看懂不等于会写。下面三个改动都只要几分钟,但能把本章的核心约束从「知道」变成「验证过」。改完记得改回去。

实验预期学到什么
一·把轮次上限调到 1
让脚本化模型第一轮就请求工具
AgentLimitError,消息是 Agent exceeded maxTurns=1轮次上限拦的是「工具执行完了,循环却没有下一轮可用」
二·故意漏掉一个工具结果
两个 toolCalls 只跟一条 tool 消息
MessageContractError: missing tool results for ids: ["call_2"]配对校验的价值在于提前报错,而不是等供应商返回含糊的 400
三·命令执行器返回非零退出码
FakeCommandRunner 返回 exitCode: 1
循环不中断,历史里出现含 shell_failedrole:"tool" 消息工具失败是数据,不是异常——也正是 Agent 能自己换方案重试的原因

🔑 一句话总结

Agent = 模型决策 + Harness 执行 + 消息历史传递
Agent = LLM + 上下文 + 工具。Planner / Memory / Orchestrator 都是在这三件事之上叠加的能力。

📝 本章小结

三句话版本、一定要记住的五条、以及本章还没做什么
三句话版本
  1. Agent Loop 就是「问模型 → 看 tool_calls 空不空 → 执行工具 → 把结果 push 回历史 → 再问模型」,代码不到 60 行。
  2. 循环只有一个分叉点(tool_calls 空不空)和三个出口(正常返回、轮次耗尽、不可恢复错误)。
  3. 生产级 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 章
重要 别把第 1 章的 cwd 当成沙箱。这是本章最需要强调的边界。
检查你是否真的读懂了(不看文章回答)
  1. 模型返回 content: "好的"tool_calls: [],循环该继续还是结束?
  2. 模型返回 content: nulltool_calls: [],代码该怎么做?为什么不能直接返回?
  3. 模型一轮请求了 3 个工具,其中 1 个执行失败。要往历史里 push 几条 tool 消息?
  4. snapshot() 到底防的是什么问题?
  5. 为什么 maxTurns=20 指的是 20 次模型请求,而不是 20 次工具调用?
提示 答不上来的,回 真实数据流核心概念 再看一遍,然后做下面的测验。

QA 测验

8 道题,选完即时看解析。全部答完显示得分。