先看出事的那一刻
2026 年 7 月 19 日,一个知识库 Agent 项目要接入第三方的兼容模型。同一段代码,在自己的终端里运行正常;从 Claude Code 的会话里启动,就一直返回 401,重试十次都失败。用 curl 直接请求那个端点,却是好的。
在本机架了一个小服务,把请求截下来一看才明白:请求里带的根本不是第三方的密钥,而是 Claude Code 自己登录用的凭证。
第二天,同一个项目又遇到另一件事:演示时,Agent 试图去读知识库目录之外的一份个人笔记。
这节课要回答的是:Agent 和一问一答的 AI 有什么不同?给它工具的时候,边界画在哪里、靠什么守住?
装备几个词
为什么要懂
AI 替你做了
几十行代码就能搭起一个 Agent:接上模型、给几个工具、写一段提示词,它就会自己查资料、读文件、多轮行动。
留给你的
画清边界:它能用哪些工具、每个工具能碰到哪些数据、在什么环境里运行,以及这些限制靠什么守住。提示词里的一句“不要”,不算限制。
不懂的代价
Agent 以你的身份、在你的机器上行动。边界画错了,它能读到不该读的文件,拿到不该拿的凭证,花掉不该花的钱。
一个 Agent 由什么组成
以 E2E Review 的 AI 导师为例:
| 部分 | 是什么 | 在导师里的样子 |
|---|---|---|
| 模型 | 负责想下一步 | 第三方的兼容模型 |
| 循环 | 想一步、做一步,直到完成 | 每次最多 6 轮 |
| 工具 | 让它能动手 | 读取、搜索、调用 Skill,加上 5 个自定义工具 |
| MCP | 自定义工具的接法 | 内置在后端的一个服务:查词条、搜词条、查论文、了解学员背景、记下学员的好问题 |
| Skill | 按需读取的做法说明 | 讲解术语、带读论文、把理解转成会上能问出口的问题、只给线索的苏格拉底式辅导 |
| 边界 | 不许做什么 | 禁止执行命令、写文件、上网、派出子 Agent;每次调用工具前再核对一遍 |
一问一答的 AI 只能说话;Agent 能动手。能力的边界,就是它手里的工具,和你给这些工具划定的范围。
边界要写在代码里,而且排在最前面
限制 Agent 的办法有好几层。按 SDK 的文档,每一次工具调用会依次经过:调用前的钩子、拒绝规则、询问规则、权限模式、允许规则,最后才轮到权限回调。
这个顺序决定了一件事:在"跳过权限确认"的宽松模式下,请求在"权限模式"这一步就被放行了,排在最后的权限回调多半轮不到。想在任何模式下都拦得住,要把检查放在最前面的钩子里。
知识库项目当时得出这个结论,靠的是阅读 SDK 的源码和文档,并没有写测试验证;另一个项目的审计记录,甚至得出过相反的结论。两份说法相反的时候,唯一可靠的办法是写一个测试:修复时补上了 17 个单元测试,并实测拦下了读取系统文件的请求。
Agent 住在谁的环境里
SDK 启动的 Agent,是你机器上的一个子进程,默认继承运行环境里的所有变量。在 Claude Code 的会话里开发 Agent 时,环境里就有好几个宿主会话留下的变量:它们让子进程以为自己由宿主托管,于是去系统钥匙串里取出登录凭证,盖掉了你配置的密钥。
第一次修复用的是"黑名单":删掉看起来可疑的变量。规则漏了一个,还是 401。后来的 E2E 导师干脆反过来用"白名单":只交给 Agent 四个必需的变量,其余一概不给。冒烟测试记下来,被挡在门外的宿主变量有 19 个。
深潜一个“获取”工具,也可能在写数据+
E2E 导师的"查词条"工具,名字和说明都是"获取术语词条的完整内容"。可它每被调用一次,还会顺手把这个词条记进学员的"已读"清单;而导师的提示词又鼓励模型去查相关的词条。
结果是:学员问一个问题,模型为了讲清楚,去查了三四个相关词条,学员的阅读进度里就可能多出三四个他根本没看过的词。
给 Agent 的工具要分清只读和写入。会写数据的工具,名字和说明里要写明,调用的时机也要收紧。
找茬
下面是两个 Agent 项目里和边界有关的真实代码(节选)。第一个项目在 Claude Code 里开发,需要清掉宿主会话留下的环境变量;第二个项目用钩子在工具调用前做检查。找出边界画漏了、或者工具做了说明之外的事的地方。
这段 JS 里埋了 4 处问题。点击你觉得有问题的行,至少找出 3 处再揭晓。
·第 2 行高危
黑名单漏了一个,而且只锚住了第一项
这条规则漏掉了关键的
CLAUDE_CODE_CHILD_SESSION;开头的^也只作用于第一个选项。宿主变量没清干净,Agent 照样拿着宿主的凭证去请求,还是 401。清理环境,白名单比黑名单可靠。该问的话:Agent 进程最后拿到了哪些环境变量?能不能改成只给它必需的几个?
·第 8 行中危
只核对工具名,不看参数
钩子只检查工具的名字。读取、搜索这类工具被放行之后,要读哪个路径,没有任何检查。知识库项目的 Agent 伸手去读目录之外的文件,缺的正是这一步。
该问的话:允许的读取类工具,能读到哪些路径?越界的请求会被拦下吗?
·第 9 行低危
先记账,再判断
每次调用先被记进审计列表,然后才判断要不要拒绝。于是被拒绝的调用,也出现在最后汇报的"用过的工具"里,看记录分不清哪些真的执行了。
该问的话:审计记录里,被拒绝的调用和真正执行的调用分得清吗?
·第 15 行中危
名叫“获取”的工具,在写学员的进度
说明写的是"获取词条内容",这一行却把词条记进了学员的已读清单。模型为了回答一个问题去查几个相关词条,学员的进度里就可能多出几个没读过的词。
该问的话:这个工具除了返回数据,还改了什么?名字和说明里写清楚了吗?
查看代码与答案(4 处问题)
1 // 知识库 Agent:第一次清理宿主环境变量用的正则
2 /^CLAUDE_CODE_SDK_|OAUTH|ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN/
3
4 // E2E 导师:只交给 Agent 四个变量(节选)
5 export const cleanEnv = Object.freeze({ PATH: process.env.PATH, HOME: process.env.HOME, ANTHROPIC_BASE_URL: CONFIG.baseUrl, ANTHROPIC_AUTH_TOKEN: CONFIG.authToken });
6
7 // E2E 导师:工具调用前的检查(节选)
8 const name = input?.tool_name ?? "";
9 audit.push({ tool: name, at: Date.now() });
10 if (FORBIDDEN.includes(name)) { return { hookSpecificOutput: { … permissionDecision: "deny" … } }; }
11 if (!allowed.includes(name)) { return { hookSpecificOutput: { … permissionDecision: "deny" … } }; }
12
13 // E2E 导师:查词条工具(节选)
14 "按 slug 获取术语词条的完整内容(定义、白话解释、图解、易混点、关联词条)",
15 await markViewed(userId, slug);- 第 2 行 · 高危 · 黑名单漏了一个,而且只锚住了第一项:这条规则漏掉了关键的
CLAUDE_CODE_CHILD_SESSION;开头的^也只作用于第一个选项。宿主变量没清干净,Agent 照样拿着宿主的凭证去请求,还是 401。清理环境,白名单比黑名单可靠。 该问的话:Agent 进程最后拿到了哪些环境变量?能不能改成只给它必需的几个? - 第 8 行 · 中危 · 只核对工具名,不看参数:钩子只检查工具的名字。读取、搜索这类工具被放行之后,要读哪个路径,没有任何检查。知识库项目的 Agent 伸手去读目录之外的文件,缺的正是这一步。 该问的话:允许的读取类工具,能读到哪些路径?越界的请求会被拦下吗?
- 第 9 行 · 低危 · 先记账,再判断:每次调用先被记进审计列表,然后才判断要不要拒绝。于是被拒绝的调用,也出现在最后汇报的"用过的工具"里,看记录分不清哪些真的执行了。 该问的话:审计记录里,被拒绝的调用和真正执行的调用分得清吗?
- 第 15 行 · 中危 · 名叫“获取”的工具,在写学员的进度:说明写的是"获取词条内容",这一行却把词条记进了学员的已读清单。模型为了回答一个问题去查几个相关词条,学员的进度里就可能多出几个没读过的词。 该问的话:这个工具除了返回数据,还改了什么?名字和说明里写清楚了吗?
快测
1. Agent 和普通的 AI 对话,最大的区别是?
你可能是这么想的:背后可以是同一个模型。区别不在模型更强,而在它手里有工具、能循环行动。
对了。能力更大,需要的边界也更多:一问一答的 AI 只能说话,Agent 能动手。
你可能是这么想的:E2E 导师这样的 Agent,只用一个模型负责想下一步,再由循环和工具让它动手。
Agent 的能力和风险,都来自它手里的工具。
查看选项与答案
- A. 它背后是一个参数量更大、推理能力也更强的专用模型——背后可以是同一个模型。区别不在模型更强,而在它手里有工具、能循环行动。
- B. 它手里有工具,能多轮行动,自己决定下一步怎么走(正确)——能力更大,需要的边界也更多:一问一答的 AI 只能说话,Agent 能动手。
- C. 它由多个模型分工协作,每个模型各负责其中一个环节——E2E 导师这样的 Agent,只用一个模型负责想下一步,再由循环和工具让它动手。
Agent 的能力和风险,都来自它手里的工具。
2. 你想禁止 Agent 读取某个目录之外的文件。最可靠的做法是?
对了。钩子排在所有判定的最前面,任何模式下都会执行。再写测试证明它拦得住:知识库项目补了 17 个单元测试。
你可能是这么想的:只核对工具名,放行之后要读哪个路径没有任何检查。知识库项目的 Agent 伸手去读目录之外的文件,缺的正是这一步。
你可能是这么想的:提示词只是请求,不是限制:这次的 Agent,就是在只有这句提示词的情况下,去读了目录之外的笔记。
提示词是请求,钩子才是限制。
查看选项与答案
- A. 在工具调用前的钩子里检查要读取的路径,越界就拒绝(正确)——钩子排在所有判定的最前面,任何模式下都会执行。再写测试证明它拦得住:知识库项目补了 17 个单元测试。
- B. 在钩子里核对工具名,只放行读取、搜索这类工具——只核对工具名,放行之后要读哪个路径没有任何检查。知识库项目的 Agent 伸手去读目录之外的文件,缺的正是这一步。
- C. 在系统提示词里写明只许读知识库目录,不许读别处——提示词只是请求,不是限制:这次的 Agent,就是在只有这句提示词的情况下,去读了目录之外的笔记。
提示词是请求,钩子才是限制。
3. 在 Claude Code 里启动的 Agent,调用第三方模型一直 401,用 curl 直接请求却正常。最该先查什么?
你可能是这么想的:同一把密钥用 curl 请求是好的,密钥本身没有问题。
你可能是这么想的:同一段代码在自己的终端里运行正常,说明端点接受 SDK 的请求。变的是运行环境,不是请求格式。
对了。这一次,请求里带的根本不是第三方的密钥,而是宿主自己的登录凭证。
同样的密钥,换个环境就 401:先查环境,再查密钥。
查看选项与答案
- A. 密钥复制的时候有没有多带空格或换行,先重新复制一遍再试——同一把密钥用 curl 请求是好的,密钥本身没有问题。
- B. 第三方端点是不是拒绝了 Agent SDK 发出的这种请求格式——同一段代码在自己的终端里运行正常,说明端点接受 SDK 的请求。变的是运行环境,不是请求格式。
- C. Agent 进程继承了哪些环境变量,请求里实际带的是哪个凭证(正确)——这一次,请求里带的根本不是第三方的密钥,而是宿主自己的登录凭证。
同样的密钥,换个环境就 401:先查环境,再查密钥。
判断时刻
你要给一个 Agent 读取知识库文件的能力,知识库在一个固定的目录里。AI 给了三种限制方式。
你会选哪一个?
考察:请求还是限制
最省事,大多数时候模型也会照做。但它只是一个请求:这次的 Agent,就在只有这句话的情况下,去读了目录之外的个人笔记。
考察:看起来周全
按 SDK 文档的判定顺序,宽松模式在权限回调之前就把请求放行了,回调多半轮不到。闸门装上了,门却一直开着。
考察:代码里的边界
钩子排在所有判定的最前面,任何模式下都会执行。知识库项目最后就是这么做的:17 个单元测试,并实测拦下了读取系统文件的请求。
Agent 的边界要写在代码里、排在判定的最前面,并且用测试证明。提示词里的“不要”,只是一句请求。
三个选项各自的代价
- A. 在提示词里写清楚“只能读知识库目录”——考察请求还是限制:最省事,大多数时候模型也会照做。但它只是一个请求:这次的 Agent,就在只有这句话的情况下,去读了目录之外的个人笔记。
- B. 开“跳过权限确认”的模式,再写一个权限回调来检查路径——考察看起来周全:按 SDK 文档的判定顺序,宽松模式在权限回调之前就把请求放行了,回调多半轮不到。闸门装上了,门却一直开着。
- C. 用工具调用前的钩子检查路径,越界就拒绝,再写测试证明它拦得住——考察代码里的边界:钩子排在所有判定的最前面,任何模式下都会执行。知识库项目最后就是这么做的:17 个单元测试,并实测拦下了读取系统文件的请求。
Agent 的边界要写在代码里、排在判定的最前面,并且用测试证明。提示词里的“不要”,只是一句请求。
带走
下次让 AI 做这件事时,问它
- 这个 Agent 能用哪些工具?每个工具能碰到哪些数据、会不会写入?
- 哪些操作是硬性禁止的?靠什么机制禁止?有没有测试证明它拦得住?
- Agent 的进程继承了哪些环境变量?请改成只传入必需的那几个。
- 最多循环几轮、预算上限多少?超过时用户会看到什么?
自己验证
- 写一个测试:让 Agent 读取目录之外的文件、调用被禁止的工具,确认都被拒绝。
- 在 Agent 进程里打印它实际拿到的环境变量名(只打印名字,不打印值),看有没有宿主留下的。
- 把请求发到本地一个小服务上截下来,看请求里实际带的是哪个凭证(只看开头几位)。
给 Agent 的边界,要写在代码里、排在最前面,并且用测试证明。
