第 一 章 Agent 架构实操 深入学习 · 交互式 含 QA 测试

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

💡 核心:模型提出工具调用 → 程序执行工具 → 结果放回消息历史 → 继续问模型,直到不再调用工具。
本章只讲 Agent 最核心的 Agent Loop。它是一个有界 for 循环:模型提出工具调用,程序执行,结果回填消息历史,再继续问模型,直到模型不再调工具。真正的智能全在模型里,循环只是模型的手脚。Agent = LLM + 上下文 + 工具。
本章进度
0%
1 本章导读与学习目标
  • 用一句话说清 Agent 和「调用一次模型 API」的区别。
  • 看懂一轮 Agent 对话在网络上真正传了哪些 JSON 字段。
  • 自己手写一个不到 60 行、能跑起来的 Agent Loop。
  • 说出这个循环在什么条件下结束,以及为什么必须有轮次上限。
  • 在本机跑通第 1 章配套代码,并让 Agent 帮你执行一条 PowerShell 命令。
你需要先具备程度
TypeScript / JavaScript能看懂 async/await、数组、interface,不需要熟悉高级类型
命令行能在 PowerShell 里进入目录、运行 npm 命令
大模型 API知道「把消息发给模型、模型返回文本」这件事就够了
上一章无。这是第 1 章
本章导读(你在这) ↓ ① 先说你现在是什么角色 ← 建立直觉:Agent 替代的是「你」 ↓ ② 两个信号,一个循环 ← 决策树只有两个分支 ↓ ③ 先看一眼真实的数据流 ← 两轮 JSON 实例,最关键的一节 ↓ ④ 五步拆解 + 完整函数 ← 代码怎么跑起来 ↓ ⑤ 边界与差距 ← 格式差异、shell 不是沙箱、CC 的四点差距 ↓ ⑥ 运行第 1 章智能体 ← 动手,六步 + 排错表 ↓ ⑦ 验证、实验与小结 ← 三个动手实验 + 自测题
想先跑起来?直接跳到第 6 节「运行第 1 章智能体」,那一节是完全自包含的步骤清单,跑通后再回头读第 4 节真实数据流。
2 术语速查(本章第一次出现的词)
读法先扫一眼,正文里遇到不认识的词回来查即可。不用背,读完正文自然就懂了。
Agent Loop「问模型 → 执行工具 → 把结果喂回去 → 再问模型」的循环。本章唯一主题。
Harness(挽具)循环之外的所有代码:配置校验、工具注册、参数校验、审批、错误分类、轮次上限。模型负责决定,Harness 负责安全地落地。
轮次(turn)一次「发请求给模型」算一轮。注意不是一次工具调用算一轮。
tool_calls模型回复里的一个数组字段。非空表示「我要用工具」,空表示「我说完了」。
tool_call_id每次工具调用的唯一编号。工具结果靠它对应回是哪次调用。
ToolRegistry工具注册表。存放所有工具的名字、说明、参数格式和执行函数。
snapshot(快照)把注册表在本轮「拍照定格」。保证模型看到的工具清单和随后真正执行的是同一份。
ToolResult工具执行的统一结果类型。成功和失败都是 ToolResult,不抛异常打断循环。
discriminated unionTypeScript 写法:用 role 字段区分四种消息,编译器据此知道每种消息各有哪些字段。
观察 / 动作空间模型「能看到什么」(系统提示 + 消息历史 + 工具说明)和「能做什么」(注册表里的工具)。
组合根唯一负责创建真实依赖并注入的文件,本章是 bootstrap.ts。核心循环自己不 new 任何 SDK 或子进程。
profile章节能力白名单。第 1 章的 P01 只允许装 loop 和 powershell 两项能力。
effect工具声明自己会不会改变世界。shell 标记为 execute,因此默认逐次要人工批准。
fail-closed出错时选择拒绝而不是放行。审批环节抛异常也当作「拒绝」处理。
maxTurns有界循环的最大轮次,避免无限调工具。
finish_reason模型停止的原因;length / content_filter 需区分,不能冒充最终答案。
3 核心知识点
两个信号,一个循环

整个决策树只有两个分支:模型返回 tool_calls 了吗?是——执行工具喂回去再问一次;否——退出。循环是机械的,没有智能。

模型返回 tool_calls?
├── 是 → 执行工具,把结果喂回去,再问一次
└── 否 → 模型觉得做完了,退出
Agent 公式

Agent = 模型决策 + Harness 执行 + 消息历史传递。换一种说法:Agent = LLM + 上下文 + 工具。系统提示、消息历史和工具 schema 组成了观察空间;ToolRegistry 里的工具组成了动作空间。

Agent = LLM + 上下文 + 工具
- LLM:决定要不要用工具、用哪个、传什么参数
- 上下文:模型每个决策点能看到的观察空间
- 工具:模型改变世界的动作空间
循环代码骨架

for 循环 + maxTurns 有界;每轮先请求模型,追加 assistant 消息;无工具调用且有文本就返回;否则逐个执行工具、回填 tool 消息,进入下一轮。

for (let turn = 1; turn <= maxTurns; turn += 1) {
  const reply = await model.complete(request);
  history.push(reply.message);
  if (reply.message.toolCalls.length === 0) return reply;
  for (const call of reply.message.toolCalls) {
    const result = await executeTool(call, context);
    history.push(toolMessage(result.content, call.id));
  }
}
Anthropic vs OpenAI 格式

两种格式形式不同,但本质信号一致:『模型有没有举手要工具』。OpenAI 用 message.tool_calls 数组与独立的 tool 角色;Anthropic 用 stop_reason==tool_use 与嵌在 user 消息里的 tool_result。

判断是否调工具:
  OpenAI:  response.choices[0].message.tool_calls 非空
  Anthropic: response.stop_reason == "tool_use"
结果消息 role:
  OpenAI:  tool(有 tool_call_id)
  Anthropic: user(内嵌 tool_result)
shell 不是沙箱

真实章节入口把 shell 标记为 execute,每次执行前询问『允许本次调用? [y/N]』,只有明确 y/yes 才执行。但 cwd 只是子进程起始目录,命令仍可访问绝对路径、切换目录或访问网络。

允许本次调用? [y/N]
→ 只有 y / yes 才执行
→ 回车、其他输入、无交互 stdin 都默认拒绝
→ 拒绝仍生成同一 tool_call_id 的工具错误结果
4 先看一眼真实的数据流
为什么先看数据很多人学 Agent 卡住,不是卡在代码,而是卡在「不知道网络上到底传了什么」。用户输入:「运行 Write-Output 42,并告诉我结果。」
第 1 轮 · 我们发出的请求

只有两个关心的部分:messages(模型能看到什么)和 tools(模型能做什么)。parameters 就是工具说明书,由 z.strictObject({ command: z.string().min(1) }) 自动生成——同一份定义既生成说明书又用于校验参数,不会错位。

{
  "model": "gpt-4.1",
  "messages": [
    { "role": "system", "content": "You are a coding agent..." },
    { "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
      }
    }
  }]
}
第 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" 的新消息。tool_call_id 和上一轮的 id 对上,这是模型唯一的线索。

"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" }
]

→ 模型回复:finish_reason="stop",content="命令执行成功,输出是 42。",tool_calls=[]
一句话总结每一轮 messages 都在变长——这就是 Agent 的「记忆」。没有别的机制,就是一个数组不断 push。也正因为只增不减,第 8 章才必须讲上下文压缩。
5 机制流程
1
用户问题进消息列表

this.#history.push(userMessage(prompt)),消息会被冻结。

2
快照工具 + 发给模型

ToolRegistry.snapshot() 冻结请求可见工具集,生成 JSON Schema。

3
追加回答 / 检查工具调用

tool_calls 为空且 content 非空 → 返回;为 null → 明确失败。

4
执行工具收集结果

prepare → 校验 → 审批边界 → invoke;每个调用回填同 ID tool 消息。

5
回到第二步

结果进入消息历史,进入下一次有界循环。

6 运行第 1 章智能体
自包含从零开始,六步跑通。每步都给出预期结果,对不上就看下面的排错表。
0
确认 Node 版本

node --version 预期 v20.12 或更高。

1
安装依赖

Set-Location codenpm ci,严格按 lock 文件安装。

2
先跑离线测试

npm run test:ch01 预期 6 个文件 30 个测试全过,不需要密钥。

3
创建并填写 .env

三项必填,任何一项为空都会在发请求前报错。

4
跑真实 Agent

两个入口构建同一个 P01 profile,效果完全一样。

5
看懂审批并批准

看清「参数」那一行再决定,这是唯一能拦住危险命令的地方。

第 2 步 · 先跑离线测试(建议在配 API 之前做)

它能验证代码本身没问题,把「环境问题」和「配置问题」分开。中间会看到一行 配置错误: Missing required settings: ...——这是正常的,它是 config.test.ts 故意触发的用例输出。

npm run test:ch01

 Test Files  6 passed (6)
      Tests  30 passed (30)
第 3 步 · OPENAI_BASE_URL 的两条硬性要求
要求正确错误
不要带 /chat/completionshttps://api.openai.com/v1…/v1/chat/completions
通常要以 /v1 结尾https://your-provider.com/v1https://your-provider.com
没有隐式默认值SDK 会自己在 base URL 后面拼 /chat/completionsconfig.ts 会在发请求前检测出重复路径并直接报错,不会让你对着 404 猜半天。
第 4—5 步 · 两个入口与审批提示
# 入口一:固定章节脚本
npm run ch01 -- --prompt "运行 Write-Output 42,并告诉我结果"

# 入口二:统一 CLI
npm run agent-tutorial -- run --chapter 1 --prompt "运行 Write-Output 42,并告诉我结果"

工具调用需要批准: 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
7 验证与实验

配套测试把本章最关键的边界变成了可重复的离线实验。npm run test:ch01 会执行 6 个测试文件、30 个测试。

测试文件验证内容
ch01-loop.test.ts完整 Agent 循环、工具结果配对、未知工具、非法 JSON、最大轮次、截断回复
shell-tool.test.ts真实 PowerShell cwd / UTF-8、超时、截断、启动失败、handler 非法返回
messages.test.tstool 调用与结果的严格配对、重复 / 孤立 / 缺失结果
config.test.ts缺失配置、非 HTTP URL、重复 /chat/completions 路径
profiles.test.tsP01 能力白名单与不可变集合
openai-chat.test.tsSDK 响应规范化、拒绝、非法 finish_reason、legacy function_call、用量校验
消融实验思维通过脚本化 ModelClient 或 fake CommandRunner,一次只替换一个外部边界,观察 AgentRunner 是否仍能保持消息配对、错误回填和停止条件。
三个建议动手做的小实验
实验预期学到什么
一 · 把轮次上限调到 1
让脚本化模型第一轮就请求工具
AgentLimitError,消息是 Agent exceeded maxTurns=1轮次上限拦的是「工具执行完了,循环却没有下一轮可用」
二 · 故意漏掉一个工具结果
两个 toolCalls 只跟一条 tool 消息
MessageContractErrormissing tool results for ids: ["call_2"]配对校验的价值在于提前报错,而不是等供应商返回含糊的 400
三 · 命令执行器返回非零退出码
FakeCommandRunner 返回 exitCode: 1
循环不中断,历史里出现含 shell_failedrole:"tool" 消息工具失败是数据,不是异常——也正是 Agent 能自己换方案重试的原因
8 本章小结
三句话版本
  • 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 章
最需要强调的边界别把第 1 章的 cwd 当成沙箱。审批不等于隔离。
检查你是否真的读懂了(不看文章回答)
  • 模型返回 content: "好的" 且 tool_calls: [],循环该继续还是结束?
  • 模型返回 content: null 且 tool_calls: [],代码该怎么做?为什么不能直接返回?
  • 模型一轮请求了 3 个工具,其中 1 个执行失败。要往历史里 push 几条 tool 消息?
  • snapshot() 到底防的是什么问题?
  • 为什么 maxTurns=20 指的是 20 次模型请求,而不是 20 次工具调用?
9 QA 测试环节(自测题)
已完成 0 / 6 · 答对 0
Q1. Agent Loop 中,模型返回什么信号表示『要继续调用工具』?
Q2. 循环退出的条件是什么?
Q3. Agent = 什么?
Q4. 模型停止时 content 为 null 且无工具调用,正确处理是?
Q5. 关于真实 CLI 中 shell 工具,正确的是?
Q6. 下面哪一项不是第 1 章的终止状态?