在最小 Agent 中,模型通常只有一个 Bash 工具。继续加入读文件、写文件、编辑文件和文件匹配能力时,一个很自然的问题是:每增加一个工具,是否都要重写 Agent Loop?
答案是否定的。更稳定的设计是把系统拆成两层:
- Agent Loop 负责模型调用、停止条件和消息回传;
- 工具层负责声明工具、查找实现并执行调用。
这样,扩展工具主要发生在工具注册表中,循环本身不必随着工具数量增长而不断分叉。
Tool Use 的完整链路
一次工具调用不是“模型直接执行代码”,而是一段受运行时控制的协议:
工具定义提供给模型
↓
模型生成 tool_use
↓
运行时校验名称与参数
↓
分发给对应 handler
↓
handler 执行并返回结果
↓
结果携带调用 ID 回传模型模型只负责提出调用意图。真正的权限判断、参数校验、执行和结果截断,都属于 Agent 运行时的职责。
工具定义和工具实现是两件事
一个工具通常先以结构化定义暴露给模型:
TOOLS = [
{
"name": "read_file",
"description": "Read a text file",
"input_schema": {
"type": "object",
"properties": {
"path": {"type": "string"},
"limit": {"type": "integer"},
},
"required": ["path"],
},
}
]这里的 name、description 和 input_schema 是面向模型的接口。它们帮助模型判断何时调用工具、应该生成哪些参数,但不会自动执行任何操作。
实际能力由运行时中的 handler 提供:
TOOL_HANDLERS = {
"bash": run_bash,
"read_file": run_read,
"write_file": run_write,
"edit_file": run_edit,
"glob": run_glob,
}
handler = TOOL_HANDLERS.get(call.name)工具定义可以理解为 API 契约,handler 则是契约背后的实现。把两者分开有几个直接收益:
- Agent Loop 不需要知道每个工具的业务细节;
- 未知工具可以在统一入口被拒绝;
- 每个 handler 可以独立测试;
- 权限、审计和输出限制可以在分发层集中处理。
**input 只是参数展开,不是校验
分发时常见这样的调用:
result = handler(**block.input)假设 block.input 是:
{"path": "README.md", "limit": 100}它等价于:
result = handler(path="README.md", limit=100)这只是把字典键值展开为关键字参数。即使模型看过 JSON Schema,运行时仍然要处理缺少参数、额外参数、错误类型、越界数值和非法组合。模型输出属于外部输入,不能把提示词或 schema 当作安全校验。
多个工具调用如何执行
一次模型响应可能包含文本,也可能包含一个或多个 tool_use 块。运行时应只执行工具块,并为每个结果保留对应的调用 ID:
for call in tool_calls:
output = dispatch(call)
results.append(
{
"type": "tool_result",
"tool_use_id": call.id,
"content": output,
}
)按响应顺序串行执行最容易理解,也能避免并发写入带来的部分冲突。但“顺序执行”不等于模型显式声明了依赖关系:如果第二个调用依赖第一个调用的结果,通常更稳妥的做法是先把第一个结果返回模型,再由模型发起下一轮调用。
只有在工具互相独立、权限允许、结果顺序可关联且运行时能处理并发失败时,才适合并行执行。
WORKDIR 是执行起点,不天然是沙箱
教学代码常用:
WORKDIR = Path.cwd()Path.cwd() 指的是进程启动时的当前工作目录,不是 Python 文件所在目录。相同代码从不同目录启动,工具能看到的文件范围也会不同。
更稳妥的运行时应当:
- 通过命令行参数或配置显式指定工作目录;
- 启动时将其解析为绝对路径并确认存在;
- 在日志中记录最终工作目录;
- 不依赖调用者“恰好在正确目录启动”。
即使所有文件工具都限制在 WORKDIR,它也只是一条应用层路径边界,并不等同于操作系统级隔离。
resolve 加包含关系检查能防什么
一个常见的路径检查如下:
def safe_path(path: str) -> Path:
candidate = (WORKDIR / path).resolve()
if not candidate.is_relative_to(WORKDIR):
raise ValueError("Path escapes the working directory")
return candidate它先规范化路径,再确认结果仍位于工作目录下,可以拦截常见的 ../ 越界,并在解析时识别许多通过已有符号链接逃逸的路径。
但这不是完整的文件系统沙箱,还要考虑:
- 检查完成后、真正打开文件前,路径或链接可能发生变化,即 TOCTOU 竞态;
- 目标可能是符号链接、设备文件、目录或其他非预期文件类型;
- 进程本身可能拥有工作目录之外的系统权限;
- Bash、解释器或其他工具可能完全绕过这套路径函数。
生产系统需要把路径校验与最小系统权限、容器或沙箱隔离、文件类型策略和审计结合起来。
为什么还需要专用文件工具
既然 Bash 能运行 cat、重定向和 sed,为什么还要单独提供文件工具?因为专用工具能表达更清晰的意图,也更容易控制副作用。
读取文件
读取工具可以统一字符编码、行数和输出大小限制,并把“文件不存在”“不是文本文件”“读取失败”转换为结构化错误。
教学实现可能直接读取整个文件后再截取行数。面对生产流量,还应在读取前检查文件大小,验证 limit 为非负且不超过上限,避免大文件先耗尽内存再被截断。
写入文件
写入工具通常会创建父目录,并覆盖目标文件。这两项都是副作用,接口应明确说明,还可以进一步加入:
- 默认拒绝覆盖,或要求显式传入
overwrite; - 内容大小限制;
- 临时文件加原子替换,避免写入一半留下损坏文件;
- 写入前再次确认链接和目标类型;
- 对敏感路径或文件类型单独授权。
编辑文件
最小编辑工具常做“将第一次出现的 old_text 替换为 new_text”。它很容易实现,但存在歧义:旧文本出现多次时,调用者可能以为会修改另一处;文件在读取后被其他进程修改时,也可能覆盖新内容。
更可靠的编辑协议可以要求旧文本唯一,返回匹配数量,并在写回前确认文件版本或内容摘要没有变化。
Bash 的边界尤其重要
若 handler 使用:
subprocess.run(command, shell=True, cwd=WORKDIR)那么输入会进入完整的 Shell 语言。管道、重定向、子命令、解释器、脚本和环境变量都会扩大可执行范围。
把 rm -rf 等字符串加入黑名单只能拦住少量直白写法,无法可靠覆盖别名、引号变化、间接调用、编码、脚本文件或其他等价命令。cwd=WORKDIR 也只是设置命令的启动目录,不会阻止它访问目录之外的资源。
因此,上游课程中的 Bash 防护应理解为教学提示,不应视为生产级安全机制。真实系统通常需要组合使用:
- 操作系统、容器或专用沙箱隔离;
- 最小用户权限和只读文件系统;
- 结构化命令或允许列表,尽量避免任意
shell=True; - 对写入、删除、联网等高风险动作增加人工批准;
- CPU、内存、执行时间、进程数和输出大小限制;
- 网络访问策略、凭据隔离和完整审计日志。
建议覆盖的测试
工具系统至少应验证这些场景:
- 正常工具和未知工具;
- 缺少参数、错误类型、额外参数和越界数值;
../路径穿越与符号链接逃逸;- 文件不存在、二进制文件和超大文件;
- 覆盖写入策略和编辑文本不唯一;
- Bash 超时、非零退出码和输出截断;
- 一次响应中的多个工具调用及调用 ID 关联;
- 权限拒绝、用户取消和 handler 异常;
- 结果过大时的截断标记与可观察性。
小结
从一个 Bash 扩展到多个工具,关键不在于继续给循环增加 if/elif,而在于建立清楚的工具协议:模型看到定义,运行时校验调用,分发表找到 handler,结果通过调用 ID 返回模型。
这套结构让 Agent Loop 保持稳定,也为权限、测试和审计留下了统一边界。同时要牢记:路径包含检查只能解决一部分文件访问问题,字符串黑名单也约束不了完整 Shell。工具越强,运行时越需要把安全控制放在模型之外。