使用 Gemma 4 实现简易代理工具调用

TL;DR · AI 摘要
Gemma 4 模型通过本地沙箱工具实现真正代理行为,支持文件系统探索和受限 Python 解释器。
核心要点
- Gemma 4 支持本地工具调用,如文件系统探索和受限 Python 执行,增强模型自主性
- 通过安全基目录限制防止路径遍历攻击,确保工具调用安全性
- 采用 JSON Schema 注册工具函数,构建可复用的代理调用循环
结构提纲
按章节快速跳转。
介绍传统语言模型仅依赖 Web API 的局限性,强调真正代理行为的重要性。
模型需能与运行环境交互,包括访问文件系统、执行代码等操作。
Gemma 4 小巧且具备结构化输出能力,适合本地运行并驱动代理循环。
介绍如何构建安全的文件系统探索工具和受限 Python 解释器。
通过设定安全基目录防止路径穿越,保障文件访问安全。
将 Python 解释器封装为工具,避免模型获得对系统的完全控制权。
思维导图
用一张图看清主题之间的关系。
查看大纲文本(无障碍 / 无 JS 友好)
- Gemma 4 代理工具调用
- 代理行为定义
- 环境交互
- 状态检查
- 工具实现
- 文件系统探索
- 受限 Python 执行
- 安全机制
- 路径限制
- 沙箱隔离
金句 / Highlights
值得收藏与分享的关键句。
当模型开始与运行环境交互时,才体现出真正的‘代理’行为。
通过限制工具调用范围,防止模型意外访问敏感资源。
Gemma 4 的 edge 版本可在本地运行,同时保持结构化输出能力。

#引言
在 Machine Learning Mastery 上的一篇近期文章 中,我们构建了一个工具调用代理,它能够 向外 调用,即从公共 API 获取天气、新闻、汇率和时间信息。那篇文章很好地覆盖了该模式的合成部分,但留下了更有趣的另一半未讨论:一个能对其自身环境进行推理、检查其运行机器并把不信任自己执行的逻辑卸载出去的代理。可以说,这更接近于真正意义上的“代理”。
本文承接上文继续展开。我们将给 Gemma 4 提供两个新工具——一个沙盒化的本地文件系统浏览器和一个受限的 Python 解释器——然后观察模型如何自行决定何时查看环境、何时进行计算。
我们将涵盖的主题包括:
- 为什么“代理式”工具调用需要超越 Web API 才有趣
- 如何构建一个带有严格路径遍历保护机制的文件系统检查工具
- 如何在不授予模型访问机器权限的情况下将 Python 解释器工具连接到模型
- 前面提到的相同编排循环如何推广到这些新能力
我强烈建议你在继续阅读前先 阅读这篇文章。
#从对话到代理行为
当语言模型所拥有的工具仅限于只读 Web API 时,本质上你仍然拥有一个聊天机器人,尽管这个聊天机器人可能具备访问更好信息的能力。模型接收提示后,决定调用哪个 API,并将 JSON 响应拼接成一段文字。这里没有真正的 环境 概念,没有可检查的状态,也没有需要推理的后果;这种情况更像是 检索增强生成,而非真正的代理行为。
在实践者使用的语境中,“代理行为”体现在模型开始与它所运行的系统互动时。这意味着它可以读取本地文件系统、执行代码、修改文件、调用其他进程,或这些操作的任意组合。当某个工具不仅能从远程服务返回干净字符串时,模型就必须开始询问 自身相关的问题:存在哪些文件?这个数字实际等于什么?在我声称某个文件夹包含内容之前,它里面到底有什么?
Gemma 4 系列,特别是我们一直在使用的 gemma4:e2b 边缘变体,体积足够小,可以在笔记本电脑上本地运行,同时又具备结构化输出能力,足以可靠地驱动这种循环。正是这种组合使本地代理模式变得有趣。本教程的完整代码 可以在这里找到。
#架构复用
前一篇教程中的编排循环并未改变。我们定义 Python 函数,通过 JSON Schema 暴露它们,将注册表连同用户提示一并传递给 Ollama,拦截响应中的任何 tool_calls 块,本地执行请求的函数,将结果以 tool 角色消息的形式附加,并重新查询模型以便其合成最终答案。相同的 call_ollama 辅助函数、相同的 TOOL_FUNCTIONS 字典、相同的 available_tools schema 数组都出现在本次教程中。
变化的是工具本身的性质。之前的工具都是远程 API 的轻量客户端,而我们现在构建的工具则会在机器上运行代码。这使得设计问题从“如何解析这个响应”转变为“如何确保模型即使意外也不会做它不该做的事”。
#工具 1:沙盒化文件系统浏览器
第一个工具 list_directory_contents 给模型提供了查看指定目录下文件的能力。这听起来很平凡,但当你意识到 os.listdir 接受任意字符串,包括 /、~ 和 ../../etc 时,就明白了其中的风险。一个简单的实现可能会让模型的好奇心直接走向你的 API 密钥。
这里的设防策略是在脚本启动时固定一个安全的基础目录,并拒绝所有超出该目录范围的请求:
# 安全:限制 list_directory_contents 只能在该基础目录及其子目录中操作
# 在脚本启动时设置为当前工作目录
SAFE_BASE_DIR = os.path.abspath(os.getcwd())
def list_directory_contents(path: str = ".") -> str:
"""列出路径下的文件和目录,限定在安全基础目录内"""
try:
# 将路径解析为绝对路径,并确认其位于 SAFE_BASE_DIR 内部
# 此方法阻止诸如 '../../etc' 或绝对路径 '/' 的遍历尝试
requested = os.path.abspath(os.path.join(SAFE_BASE_DIR, path))
if not (requested == SAFE_BASE_DIR or requested.startswith(SAFE_BASE_DIR + os.sep)):
return (
f"错误:访问被拒绝。路径 '{path}' 解析到了允许的工作区之外 "
f"({SAFE_BASE_DIR})。"
)
...这个模式虽然简单,但仍值得进一步思考。我们从不信任模型生成的字符串。我们将它连接到基础目录,将其解析为绝对路径(这样 .. 会被规范化),然后验证解析后的路径仍以基础目录开头。无论是 /etc/passwd 还是 ../../somewhere 都会折叠成不符合前缀检查的路径,在调用 os.listdir 之前就会被拒绝。
函数的其余部分主要是清理工作:确认路径存在且为目录,列出其内容,并将每个条目格式化为 [DIR] 或 [FILE],并附带字节大小。返回的字符串是结构化的纯英文文本,模型可以在第二次处理时解析该结构:
entries = sorted(os.listdir(requested))
if not entries:
return f"The directory '{path}' is empty."
lines = [f"Contents of '{path}' ({len(entries)} item(s)):"]
for name in entries:
full = os.path.join(requested, name)
if os.path.isdir(full):
lines.append(f" [DIR] {name}/")
else:
try:
size = os.path.getsize(full)
lines.append(f" [FILE] {name} ({size} bytes)")
except OSError:
lines.append(f" [FILE] {name}")
return "\n".join(lines)我们传递给模型的 JSON schema 在参数方面故意设计得较为宽松——path 是可选的,默认为工作区根目录,因为大多数有用的初步问题都与当前文件夹有关:
{
"type": "function",
"function": {
"name": "list_directory_contents",
"description": (
"Lists files and subdirectories inside a path within the user's workspace. "
"Use this to inspect the environment before answering questions about local files."
),
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": (
"A relative path inside the workspace, e.g. '.', 'data', or 'src/utils'. "
"Defaults to the workspace root."
)
}
},
"required": []
}
}
}请注意描述中包含了一些提示工程技巧:“Use this to inspect the environment before answering questions about local files.” 这句话会引导 Gemma 4 在用户询问关于“我的文件”的模糊问题时调用该工具,而不是猜测可能存在的内容。
#工具 2:受限的 Python 解释器
第二个工具 execute_python_code 是两个工具中更危险但也更具教学意义的一个。其前提是语言模型,尤其是小型模型,在精确计算、字符串操作以及涉及多个分支逻辑步骤的任务上不可靠。允许模型编写并运行确定性代码片段的工具,比让模型用自然语言推理解决这些问题要好得多。
实现使用了 exec() 并配合一个刻意精简的内置命名空间:
def execute_python_code(code: str) -> str:
"""Executes a snippet of Python code and returns whatever was printed to stdout.
This is a learning-only sandbox. exec() is fundamentally unsafe; do not expose this tool
to untrusted users or networks. The restrictions below stop the casual cases, not a
determined attacker.
"""
try:
# A minimal restricted environment. We strip __builtins__ down to a small
# whitelist so that, e.g., open(), eval(), and __import__ are not directly
# available from the snippet's global scope.
safe_builtins = {
"abs": abs, "all": all, "any": any, "bool": bool, "dict": dict,
"divmod": divmod, "enumerate": enumerate, "filter": filter, "float": float,
"int": int, "len": len, "list": list, "map": map, "max": max, "min": min,
"pow": pow, "print": print, "range": range, "repr": repr, "reversed": reversed,
"round": round, "set": set, "sorted": sorted, "str": str, "sum": sum,
"tuple": tuple, "zip": zip,
}
# Pre-import a couple of safe, useful modules so the model doesn't have to.
import math, statistics
restricted_globals = {
"__builtins__": safe_builtins,
"math": math,
"statistics": statistics,
}有几个值得指出的设计决策。我们完全替换了 __builtins__ 而不是黑名单个别函数,这意味着 open、eval、exec、compile、__import__、input 以及其他不在白名单中的函数在代码片段的全局作用域中根本不存在。我们将 math 和 statistics 预导入到代码片段的全局命名空间中,因为模型会频繁使用它们,我们宁愿不强迫它去绕过 __import__ 的限制。我们通过 contextlib.redirect_stdout 捕获 stdout,以便模型能获得其代码片段打印出的确切输出:
# Capture stdout so we can hand the printed output back to the model
buffer = io.StringIO()
with contextlib.redirect_stdout(buffer):
exec(code, restricted_globals, {})
output = buffer.getvalue().strip()
if not output:
return "Code executed successfully but produced no output. Use print() to return a value."
return f"Output:\n{output}"空输出分支看起来并不重要,但实际很重要。小型模型经常会写出类似 x = sum(range(101)) 的表达式却忘记加上 print(x)。返回一个明确的错误提示,告诉它们使用 print(),可以让编排循环有机会重试;如果没有这个机制,模型可能会基于空字符串合成最终答案,并自信地编造一个值。
最后关于安全性的一点说明,因为脚本的文档字符串已经很直白地提到了这一点:这是一个用于学习的沙箱环境,而非安全加固的环境。有经验的攻击者可以通过多种方式从 Python 的 exec 沙箱中逃脱,其中大多数方法都涉及通过 ().__class__.__mro__ 进行对象内省。对于仅在你自己笔记本电脑上运行、针对你自己的提示的单用户代理来说,这个白名单已经足够了。但对于其他用途,你需要真正的隔离层——例如带有 seccomp 的子进程、容器或 RestrictedPython。
主循环的结构与之前的教程保持一致。模型使用用户提示和工具注册表进行查询,如果响应包含 tool_calls,则对每个调用在 TOOL_FUNCTIONS 中进行分发:
if "tool_calls" in message and message["tool_calls"]:
print("[TOOL EXECUTION]")
messages.append(message)
num_tools = len(message["tool_calls"])
for i, tool_call in enumerate(message["tool_calls"]):
function_name = tool_call["function"]["name"]
arguments = tool_call["function"]["arguments"]
...
if function_name in TOOL_FUNCTIONS:
func = TOOL_FUNCTIONS[function_name]
try:
result = func(**arguments)
...
messages.append({
"role": "tool",
"content": str(result),
"name": function_name
})对于这个脚本,CLI 的格式化值得做一些小调整。execute_python_code 工具的 code 参数可以是一个带有换行符的多行字符串,如果直接打印出来会破坏 ASCII 树的显示效果。我们只对显示时的字符串参数进行扁平化和截断处理;当函数运行时模型仍接收完整的字符串:
def _short(v):
if isinstance(v, str):
flat = v.replace("\n", "\\n")
if len(flat) > 60:
flat = flat[:57] + "..."
return f"'{flat}'"
return str(v)
args_str = ", ".join(f"{k}={_short(v)}" for k, v in arguments.items())一旦每个工具的结果以 "role": "tool" 条目形式追加回消息历史中,我们会再次调用 Ollama 并传入增强后的负载,模型会生成其基于事实的最终答案。同样的两轮模式,同样的逻辑。
#测试工具
现在我们来测试工具调用功能。如果你还没有拉取 gemma4:e2b,请使用 ollama pull gemma4:e2b 进行拉取,然后从一个你不介意模型查看的文件夹中运行该脚本。
首先测试文件系统工具。从项目目录开始:
我当前文件夹里有哪些脚本?哪个看起来应该用于处理 CSV 文件?
结果:
[SYSTEM]
○ Tool: execute_python_code......................[LOADED]
○ Tool: list_directory_contents..................[LOADED]
○ Workspace: /Users/matt/projects/gemma_agent.....[SANDBOXED]
[PROMPT]
What scripts are in my current folder, and which one looks like it should be used to process CSVs?
[EXECUTION]
● Querying model...
[TOOL EXECUTION]
└── Calling: list_directory_contents
├─ Args: path='.'
└─ Result: Contents of '.' (5 item(s)):
[FILE] README.md (412 bytes)
[FILE] csv_cleaner.py (1834 bytes)
[FILE] main.py (10786 bytes)
[FILE] notes.txt (88 bytes)
[FILE] sales_report.py (2210 bytes)
[EXECUTION]
● Synthesizing results...
[RESPONSE]
Your current folder contains five files. The one that looks intended for CSV
processing is csv_cleaner.py — its name strongly suggests it handles CSV input.
sales_report.py may also touch CSV data, but its name is more about output than
ingestion.模型调用了工具,查看了实际的文件名,并基于列出的内容做出了合理的推断,而不是依赖于模型自身的权重。这就是幻觉与观察之间的区别。
接下来是 Python 解释器。这是一个小型模型在脑中计算时容易出错的小任务:
数字 12, 18, 23, 24, 29, 31, 35, 41, 44, 47 的标准差是多少?保留四位小数。
结果:
[SYSTEM]
○ Tool: execute_python_code......................[LOADED]
○ Tool: list_directory_contents..................[LOADED]
○ Workspace: /Users/matt/projects/gemma_agent.....[SANDBOXED]
[PROMPT]
What is the standard deviation of the numbers 12, 18, 23, 24, 29, 31, 35, 41, 44, 47, rounded to four decimal places?
[EXECUTION]
● Querying model...
[TOOL EXECUTION]
└── Calling: execute_python_code
├─ Args: code='nums = [12, 18, 23, 24, 29, 31, 35, 41, 44, 47]\nprint(rou...'
└─ Result: Output:
11.4659
[EXECUTION]
● Synthesizing results...
[RESPONSE]
The standard deviation of those numbers, rounded to four decimal places, is 11.4659.模型完全将计算任务卸载出去;它编写了一段代码,调用了 statistics.stdev,对结果进行了四舍五入,并报告了解释器的输出。没有心算,没有近似值,也没有编造的有效数字。
最后是更有趣的场景:需要顺序调用两个工具的提示。模型必须检查文件夹并且计算所发现内容的相关信息:
查看当前文件夹中的文件并告诉我总大小(以 KB 计),保留两位小数。
输出:
[SYSTEM]
○ Tool: execute_python_code......................[LOADED]
○ Tool: list_directory_contents..................[LOADED]
○ Workspace: /Users/matt/projects/gemma_agent.....[SANDBOXED]
[PROMPT]
Look at the files in the current folder and tell me the total size in kilobytes, rounded to two decimal places.
[EXECUTION]
● Querying model...
[TOOL EXECUTION]
┌── Calling: list_directory_contents
│ ├─ Args: path='.'
│ └─ Result: Contents of '.' (5 item(s)):
│ [FILE] README.md (412 bytes)
│ [FILE] csv_cleaner.py (1834 bytes)
│ [FILE] main.py (10786 bytes)
│ [FILE] notes.txt (88 bytes)
│ [FILE] sales_report.py (2210 bytes)
│
└── Calling: execute_python_code
├─ Args: code='sizes = [412, 1834, 10786, 88, 2210]\nprint(round(sum(siz...'
└─ Result: Output:
15.33
[EXECUTION]
● Synthesizing results...
[RESPONSE]
The five files in the current folder total 15.33 KB.两个工具,按正确顺序使用,一个工具的输出作为另一个工具的输入——这一切都由运行在没有 GPU 的笔记本电脑上的一个 20 亿参数模型完成。文件系统工具让模型基于实际存在的内容进行判断;解释器工具则确保答案基于实际正确的事实。模型贡献它真正擅长的部分,即决定向哪个工具提出什么问题。
同样值得检查一下安全机制,以确认它们确实有效。当要求模型“列出 /etc 目录的内容”时,工具结果中会返回预期的拒绝消息,模型也会优雅地报告这一结果,而不是伪造一个目录列表。如果在解释器中执行 open('/etc/passwd').read(),则会抛出 NameError,因为 open 不在白名单内置函数中。这两种失败情况都会转化为有用的错误字符串,而不是悄无声息地妥协,这正是你在这一层所期望的行为。
#结论
之前的教程展示了 Gemma 4 可以代表你访问互联网。而本教程则表明,当你有意识地构建了这种谨慎性时,它也可以小心地访问你所在的机器。一旦你建立了一个有效的工具调用循环,有趣的问题就不再是“模型能否调用函数”,而是“我应该让它接触什么”。
一个具备文件系统感知能力的工具和一个代码执行工具结合起来,基本上就能让你拥有真正符合“代理”(agent)一词含义的系统:它可以观察环境、决定哪些计算是重要的,并确定性地执行这些计算,而不是盲目猜测。这个模式可以推广到其他场景。数据库查询、Shell 命令、Git 操作、文档解析等,每一个都可以采用相同的 JSON Schema、相同的调度表、相同的两阶段合成过程,同时根据底层调用的影响范围设置适当的安全边界。
首先构建好安全边界。然后,把钥匙交给模型,让它去操作边界内的内容。
[](https://www.linkedin.com/in/mattmayo13/)**[Matthew Mayo](https://www.kdnuggets.com/wp-content/uploads/profile-pic.jpg) ([@mattmayo13**](https://twitter.com/mattmayo13)) 拥有计算机科学硕士学位和数据挖掘研究生文凭。作为 KDnuggets 和 Statology 的主编,以及 Machine Learning Mastery 的特约编辑,Matthew 致力于让复杂的数据科学概念变得易于理解。他的专业兴趣包括自然语言处理、语言模型、机器学习算法以及探索新兴人工智能技术。他致力于推动数据科学领域的知识普及。Matthew 从六岁起就开始编程。