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

给 Agent 加一个工具,只需要加一行

💡 新增四个文件工具 read_file / write_file / edit_file / glob;Agent Loop 不需要改。
本章给 Agent 增加四个专用文件工具,P02 共五个工具。核心是『加一个工具只需注册一次 ToolDefinition』——循环只认识『一次调用返回一个结果』的抽象协议。文件路径统一受 safePath 工作区边界保护。
本章进度
0%
1 本章导读与学习目标
  • 独立给 Agent 加一个新工具,并说清为什么不用改 Agent Loop。
  • 用 Zod 写出一份「模型看到的说明」和「运行时校验规则」同源的输入 schema。
  • 说出为什么 edit_file 不能接受行号,以及模型正确的编辑工作流是什么。
  • 解释 safePath 的两道检查各拦住什么,并说明为什么第二道必须访问磁盘。
  • 跑通第 2 章,让 Agent 只用文件工具完成一次「写 → 改 → 读 → 找」的完整链路。
你需要先具备程度
第 1 章必须。要理解 ToolRegistryToolResulttool_call_id 配对和审批边界
TypeScript能看懂 interfaceasync/awaitinstanceof 判断
Zod不需要。本章用到的写法都会解释
文件系统概念知道「相对路径 / 绝对路径」「符号链接」大致是什么
本章的验收标准不是「写四个函数」就算完成。最终行为必须满足:P01 仍只暴露 shell;P02 按固定顺序暴露五个工具;参数必须先经过严格 schema 校验再产生副作用;路径只能落在当前 workspace 内;..、绝对路径、Windows 设备名和工作区外链接必须失败;写入使用 UTF-8 并返回真实字节数;编辑只替换第一处精确文本、找不到时不修改文件;glob 返回稳定排序的 POSIX 风格相对路径;一条 assistant 消息里的多个工具调用仍要逐个回填并保持 tool_call_id 配对。
本章导读(你在这) ↓ ① 先定义可观察结果 ← 本章的验收标准,9 条 ↓ ② 一个完整例子 ← 模型怎么用四个工具走完一次任务 ↓ ③ ToolRegistry ← 「加一行」到底加在哪 ↓ ④ 文件系统边界 + Zod ← 依赖方向、同源 schema、描述也是上下文 ↓ ⑤ safePath ← 本章安全性的核心,两道检查 ↓ ⑥ 四个工具逐个看 ← read / write / edit / glob 各自的取舍 ↓ ⑦ 错误映射 + profile ← 稳定错误码、P01 与 P02 的差别 ↓ ⑧ 配对与权限边界 ← 多工具单轮的硬规则、本章故意留的缺口 ↓ ⑨ 运行、验证与小结 ← 动手 + 排错表 + 自测题
想先跑起来?直接跳到第 6 节「运行第 2 章」,那一节是完全自包含的步骤清单,跑通后再回头读第 4 节的完整例子。
2 术语速查(本章第一次出现的词)
读法先扫一眼,正文里遇到不认识的词回来查即可。不用背,读完正文自然就懂了。
ToolDefinition一个工具的完整声明:名字、描述、输入 schema、副作用类别、handler。五件事写在一个对象里。
ZodTypeScript 的运行时校验库。一份 schema 既能校验数据,又能导出 JSON Schema。
z.strictObjectZod 的严格对象:schema 里没声明的字段一律拒绝,不是忽略。
.describe()给 Zod 字段加说明文字。它会进入导出的 JSON Schema,被模型读到。
静态前缀 / KV Cache请求里每轮不变的开头部分(system + tools)。字节稳定时服务端可复用计算结果,省钱省延迟。
safePath把「工作区相对路径」翻译成安全绝对路径的函数。翻译不了就拒绝,绝不猜测修复。
junction / 符号链接指向别处的目录「快捷方式」。文本路径看着在工作区内,真实位置可能在外面。
NTFS ADSWindows 的 Alternate Data Stream,靠路径里的冒号触发,例如 file:stream。
Windows 保留名NUL、CON、COM1、LPT9 等。它们指向设备而不是文件。
effect(副作用类别)工具自我声明的影响范围:read、write、execute。第 2 章只是元数据,第 3 章才参与决策。
WorkspaceFileSystemcore 层声明的文件能力接口。feature 只认这个接口,不认 Node。
profile(P01/P02)章节能力白名单。P02 = P01 + tool_registry + files。
z.toJSONSchema把 Zod schema 转成 OpenAI tool schema,契约同源,不用手写第二份。
3 核心知识点
为什么循环不该认识具体工具

如果把工具写死在循环里,每加一个工具都要改循环。用 ToolRegistry 让循环只处理 ToolCall、PreparedToolCall、ToolResult 三种对象。

for (const call of assistant.toolCalls) {
  const prepared = tools.prepare(call);
  const result = prepared.error === undefined
    ? await tools.invoke(prepared, context)
    : prepared.error;
  this.#history.push(toolMessage(result.content, call.id));
}
文件系统做成边界

feature 不直接创建 Node 适配器,只依赖 WorkspaceFileSystem 接口;bootstrap 注入具体实现。测试注入替身、生产注入 Node 适配器。

export interface WorkspaceFileSystem {
  readFile(workspace, relativePath, limit?): Promise<string>;
  writeFile(workspace, relativePath, content): Promise<number>;
  editFile(...): Promise<void>;
  globFiles(...): Promise<readonly string[]>;
}
safePath:先守住 workspace

第一关词法检查(拒绝空值、NUL、绝对路径、盘符、..、Windows 保留组件);第二关解析磁盘上已存在的父路径,防止 junction / 符号链接越界。

拒绝示例:
  ../secret.txt
  C:\secret.txt
  /etc/passwd
  NUL.txt
  nested/CON.log
  file:stream
  trailing.
edit_file 只改第一处精确文本

不接受行号。indexOf 精确匹配 oldText,替换第一处后原样写回,未修改部分的 CRLF 不会被改成 LF。找不到旧文本时不写回文件。

const index = current.indexOf(oldText);
if (index === -1) throw new TextNotFoundError(...);
const updated = current.slice(0, index) + newText
  + current.slice(index + oldText.length);
await writeFileBytes(target, Buffer.from(updated, "utf8"));
schema 与 handler 同源

Zod schema 同时用于运行时校验和 OpenAI tool schema 生成:parameters: z.toJSONSchema(definition.inputSchema)。参数上的 .describe() 也会进入 JSON Schema。

const readFileInputSchema = z.strictObject({
  path: z.string().min(1).describe("Workspace-relative path"),
  limit: z.number().int().positive().optional()
    .describe("Optional maximum lines"),
});
4 先看一眼真实的例子
用户输入只使用文件工具:写入 demo/note.txt,内容为 alpha;把第一处 alpha 改成 gamma;读取文件并用 glob 列出 demo/*.txt
四个工具,一步一轮

抽象契约不好记,先看一遍实际发生的事。模型会自己把任务拆成四步,一步一轮。下面只列每轮的关键字段,第 1 章那套 tool_call_id 配对规则完全不变。

模型请求的工具arguments回填给模型的结果
1write_file{"path":"demo/note.txt","content":"alpha"}Wrote 5 UTF-8 bytes to demo/note.txt
2edit_file{"path":"demo/note.txt","old_text":"alpha","new_text":"gamma"}Edited demo/note.txt
3read_file{"path":"demo/note.txt"}gamma
4glob{"pattern":"demo/*.txt"}demo/note.txt
5(无工具调用)模型给出最终文本,循环退出
注意那个 5第 1 轮返回 5alpha 五个 ASCII 字符正好 5 字节。换成 你好 就会返回 6——这正是「字节数以磁盘内容为准」要讲的事。
换成上一章的做法有多难

同样一件事,只有 shell 时模型得自己拼一串 PowerShell:

Set-Content -Path .\demo\note.txt -Value 'alpha' -Encoding utf8
(Get-Content .\demo\note.txt -Raw) -replace 'alpha','gamma' |
    Set-Content .\demo\note.txt -Encoding utf8 -NoNewline
Get-Content .\demo\note.txt
Get-ChildItem .\demo\*.txt | Select-Object -ExpandProperty Name

问题不在于「长」,而在于每一处都可能悄悄出错

隐患后果
demo 目录不存在Set-Content 直接失败,模型得先想到 New-Item
忘了 -Encoding utf8PowerShell 5 默认写 UTF-16,中文变乱码
-replace那是正则替换,alpha.beta 里的 . 会当成任意字符
-replace它替换所有匹配,不是第一处
忘了 -NoNewline文件末尾多出一个换行,下次精确匹配就失败
内容里有单引号引号转义写错,命令语法直接崩
加专用工具的真实理由不是为了少打字,是为了把这些隐患从「模型每次都要想起来」变成「工具默认就对」。write_file 自动建父目录、固定 UTF-8;edit_file 是精确文本匹配而非正则,且只改第一处。模型的推理预算可以省下来用在任务本身。
失败也是完整链路的一部分

模型可能试图访问工作区外的文件。这时不会抛异常打断循环,而是回填一条错误结果:

assistant  tool_calls=[ { id:"call_9", name:"read_file",
                          arguments:'{"path":"../secret.txt"}' } ]
tool       tool_call_id="call_9"
           content="Error [path_escape]: Path escapes workspace: ../secret.txt"
继承自第 1 章模型看到 path_escape 就知道换个路径,而不是卡死。工具失败是数据,不是异常
5 机制流程
1
定义 Zod schema

z.strictObject() 拒绝多余字段;path/old_text/pattern 不能为空;limit 必须为正整数。

2
定义 ToolDefinition

name + description + effect(read/write) + handler 绑定在同一个定义里。

3
注册一次

registry.register(createReadFileTool(fileSystem)),不用维护第二张 handler 表。

4
Agent Loop 不变

每轮仍从同一注册表快照生成 tools schema,调用、历史、配对都不改。

6 运行第 2 章
前置条件和第 1 章完全一样:code/ 里已经 npm ci.env 里三个变量都填好了。没做过的回第 1 章「运行第 1 章智能体」照做一遍。
1
先跑离线测试

npm run test:ch02 预期 8 个文件 47 个测试全过,不需要密钥。

2
跑完整四工具链路

写 → 改 → 读 → 找,会真实创建 code/demo/note.txt

3
换一个只读任务

只用 read_fileglob,不产生文件改动。

4
(可选)完整离线验证

typecheck / test / lint / format:check / build。

第 1 步 · 先跑离线测试
Set-Location 'F:\笔记\Agent实操\code'
npm run test:ch02

 Test Files  8 passed (8)
      Tests  47 passed (47)

比第 1 章多了 2 个文件、17 个测试——新增的正是文件工具和路径边界。同样不需要 API Key,不发网络请求。

第 2 步 · 跑一个完整的四工具链路
npm run ch02 -- --prompt '只使用文件工具:写入 demo/note.txt,内容为 alpha;把第一处 alpha 改成 gamma;读取文件并用 glob 列出 demo/*.txt'

Get-Content .\demo\note.txt
gamma

这条命令会真实创建 code/demo/note.txt。整个过程模型会调用四次工具,对应第 4 节那张表。

为什么写「只使用文件工具」不加这句,模型可能直接拼一条 PowerShell 命令,那就走回第 1 章了,也会触发 shell 的逐次审批。
第 3—4 步 · 只读任务与完整离线验证
npm run agent-tutorial -- run --chapter 2 --prompt '读取 package.json,并列出 chapters/ch02/src/**/*.ts'

npm run typecheck
npm test
npm run lint
npm run format:check
npm run build

第 3 步只用 read_fileglob,不产生任何文件改动,适合反复跑着观察。第 4 步这些测试不需要 API Key,也不会访问网络;真实 OpenAI 运行只是额外验证,不替代离线契约测试。

工作区边界在哪当前 PowerShell 目录既是 .env 查找位置,也是 Agent workspace。你在哪个目录敲命令,Agent 的工作区边界就在哪里。在 code/ 里跑,safePath 守的就是 code/
常见报错排查

第 1 章那张表仍然适用(配置、参数、审批相关的报错)。下面是本章新增的、和文件工具有关的情况:

现象原因怎么处理
Error [path_escape]: ...模型给的路径含 ..、绝对路径或 Windows 保留名正常行为。看 prompt 是否让模型误以为要访问工作区外的文件
Error [text_not_found]: ...模型凭猜测拼了 old_text正常行为。模型下一轮应该先 read_file;反复失败说明 prompt 里的文本描述不够精确
Error [invalid_utf8]: ...读到了二进制文件(图片、.exe、锁文件等)正常行为。用 glob 限定 **/*.ts 之类的文本后缀
Error [file_not_found]: ...路径写错,或文件确实不存在让模型先 glob 确认路径
模型仍然去调 shellprompt 没有明确限定在 prompt 里写「只使用文件工具」
文件写出来是乱码不是本章代码的问题write_file 固定 UTF-8。检查你用什么打开的——记事本旧版可能按 ANSI 解读
demo/note.txt 出现在意外的位置你不在 code/ 目录下运行Set-Locationcode/ 再跑
7 验证与实验

npm run test:ch02 会执行 8 个测试文件、47 个测试。它们分工如下:

测试文件验证内容
ch02-files.test.ts四个文件工具的成功路径、每个错误码、多工具单轮配对
filesystem.test.tssafePath 两道检查、Windows 保留名、junction 越界、glob 边界
ch01-loop.test.ts第 1 章循环行为在 P02 下仍然成立
shell-tool.test.tsshell 超时、截断、非零退出码保持不变
messages.test.tstool_call_id 严格配对
profiles.test.tsP01 只暴露 shell;P02 按固定顺序暴露五个工具
config.test.ts.env 校验与 baseUrl 归一化
openai-chat.test.tsSDK 响应收窄、refusal 处理
累加原则ch01-loop.test.tsshell-tool.test.ts 出现在这里不是复制粘贴凑数——第 2 章加了四个工具,第 1 章的行为必须一个字都没变。每一章都保留前章的测试,就是为了让「不小心改坏了旧行为」立刻暴露。
三个建议动手做的小实验
实验预期学到什么
一 · 给 read_file 传多余字段
脚本化模型返回 {"path":"note.txt","encoding":"gbk"}
prepare() 阶段就失败,返回 schema 错误结果,readFile 一次都没被调用strictObject + 校验前置的组合效果;可在替身文件系统里加计数器确认
二 · 连续两次 edit_file 用同一个 old_text第二次返回 text_not_found,且文件内容不变「只替换第一处 + 失败不写回」这两条行为的实际后果
三 · 手工造一个越界 junction
New-Item -ItemType Junction -Path .\escape -Target C:\Windows\Temp(需管理员权限,做完 Remove-Item .\escape
返回 path_escape,文件读不到safePath 第二关的实际效果——第一关看字符串完全放行,是磁盘解析拦住的
8 本章小结
三句话版本
  • 加工具的正确姿势是「定义 + 注册」,Agent Loop 一行都不用改。
  • 一份 Zod schema 同时供模型阅读和运行时校验,从根上消除「文档和实现不一致」。
  • 文件工具的安全性来自 safePath 的两道检查:词法拦明显越界,磁盘解析拦符号链接。
一定要记住的六条
#结论出现在哪一节
1校验全在 prepare(),副作用全在 invoke(),顺序不能反ToolRegistry
2契约外的输入明确失败,不做隐式转换、不忽略多余字段用 Zod 定义严格输入
3..、绝对路径、Windows 保留名一律拒绝,不尝试修复safePath
4只看字符串挡不住 junction,必须解析磁盘真实路径为什么必须有第二关
5edit_file 用精确文本而非行号,因为行号编辑一次就失效edit_file
6catch 只翻译能列举出名字的错误,其余继续抛handler 错误映射
本章代码边界(明确「还没做什么」)
已经有还没有在哪一章补
五个工具(含四个文件工具)结构化权限规则、审计日志第 3 章
shell 逐次人工审批写工具也进审批流程第 3 章
文件工具的 safePath 边界shell 的路径边界(仍然没有不在本教程范围;shell 始终依赖审批
effect 元数据effect 参与权限决策第 3 章
单轮多工具串行执行并发执行与并发安全契约第 13 章(后台任务)
完整读取 + limit 截断产物落盘、上下文压缩第 8 章
启动时固定的五个工具运行中动态增减工具第 19 章
最需要强调的边界文件工具靠代码边界,shell 靠人工审批,本章的安全模型是两套并存的。不要把前者的路径保证套到后者头上。
检查你是否真的读懂了(不看文章回答)
  • 模型传 {"path":"note.txt","limit":"10"},会发生什么?为什么不自动转成数字?
  • nested/../secret.txt 化简后在工作区内,为什么还是拒绝?
  • 工作区里有个指向外部的 junction,第一关和第二关分别是什么结论?
  • write_file 写入「你好」,返回的数字是 2 还是 6?为什么?
  • edit_file 的 old_text 在文件里出现 3 次,会改几处?
  • handler 的 catch 最后为什么要 throw error 而不是返回一个兜底错误码?
9 QA 测试环节(自测题)
已完成 0 / 6 · 答对 0
Q1. P02 新增的四个文件工具是?
Q2. 『加一个工具只需要加一行』的准确含义是?
Q3. edit_file 的行为是?
Q4. write_file 成功返回什么?
Q5. glob 的返回格式是?
Q6. 关于工具 schema 的说法,正确的是?