| 你需要先具备 | 程度 |
|---|---|
| TypeScript / JavaScript | 能看懂 async/await、数组、interface,不需要熟悉高级类型 |
| 命令行 | 能在 PowerShell 里进入目录、运行 npm 命令 |
| 大模型 API | 知道「把消息发给模型、模型返回文本」这件事就够了 |
| 上一章 | 无。这是第 1 章 |
本章导读(你在这)
↓
① 先说你现在是什么角色 ← 建立直觉:Agent 替代的是「你」
↓
② 两个信号,一个循环 ← 决策树只有两个分支
↓
③ 先看一眼真实的数据流 ← 两轮 JSON 实例,最关键的一节
↓
④ 五步拆解 + 完整函数 ← 代码怎么跑起来
↓
⑤ 边界与差距 ← 格式差异、shell 不是沙箱、CC 的四点差距
↓
⑥ 运行第 1 章智能体 ← 动手,六步 + 排错表
↓
⑦ 验证、实验与小结 ← 三个动手实验 + 自测题整个决策树只有两个分支:模型返回 tool_calls 了吗?是——执行工具喂回去再问一次;否——退出。循环是机械的,没有智能。
模型返回 tool_calls?
├── 是 → 执行工具,把结果喂回去,再问一次
└── 否 → 模型觉得做完了,退出
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));
}
}
两种格式形式不同,但本质信号一致:『模型有没有举手要工具』。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 标记为 execute,每次执行前询问『允许本次调用? [y/N]』,只有明确 y/yes 才执行。但 cwd 只是子进程起始目录,命令仍可访问绝对路径、切换目录或访问网络。
允许本次调用? [y/N]
→ 只有 y / yes 才执行
→ 回车、其他输入、无交互 stdin 都默认拒绝
→ 拒绝仍生成同一 tool_call_id 的工具错误结果
只有两个关心的部分: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
}
}
}]
}
{
"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"
上一轮的 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 章才必须讲上下文压缩。this.#history.push(userMessage(prompt)),消息会被冻结。
ToolRegistry.snapshot() 冻结请求可见工具集,生成 JSON Schema。
tool_calls 为空且 content 非空 → 返回;为 null → 明确失败。
prepare → 校验 → 审批边界 → invoke;每个调用回填同 ID tool 消息。
结果进入消息历史,进入下一次有界循环。
node --version 预期 v20.12 或更高。
Set-Location code 后 npm ci,严格按 lock 文件安装。
npm run test:ch01 预期 6 个文件 30 个测试全过,不需要密钥。
三项必填,任何一项为空都会在发请求前报错。
两个入口构建同一个 P01 profile,效果完全一样。
看清「参数」那一行再决定,这是唯一能拦住危险命令的地方。
它能验证代码本身没问题,把「环境问题」和「配置问题」分开。中间会看到一行 配置错误: Missing required settings: ...——这是正常的,它是 config.test.ts 故意触发的用例输出。
npm run test:ch01
Test Files 6 passed (6)
Tests 30 passed (30)
| 要求 | 正确 | 错误 |
|---|---|---|
不要带 /chat/completions | https://api.openai.com/v1 | …/v1/chat/completions |
通常要以 /v1 结尾 | https://your-provider.com/v1 | https://your-provider.com |
/chat/completions。config.ts 会在发请求前检测出重复路径并直接报错,不会让你对着 404 猜半天。# 入口一:固定章节脚本
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]
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 |
配套测试把本章最关键的边界变成了可重复的离线实验。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 是否仍能保持消息配对、错误回填和停止条件。| 实验 | 预期 | 学到什么 |
|---|---|---|
| 一 · 把轮次上限调到 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 能自己换方案重试的原因 |
| # | 结论 | 出现在哪一节 |
|---|---|---|
| 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 当成沙箱。审批不等于隔离。