hseshadr/edge-proc
GitHub: hseshadr/edge-proc
EdgeProc 是一个 Python 库,将签名验证、内容寻址增量分发和本地向量/关键词搜索整合在一起,让大型数据文件能够安全、高效地部署到边缘设备并在设备端完成检索。
Stars: 1 | Forks: 0
# EdgeProc
**将大文件分发到大量设备 —— 让每个设备在使用前都能证明这是未修改的真实文件。然后只发送发生更改的部分。**
[](https://github.com/hseshadr/edge-proc/actions/workflows/ci.yml)
[](LICENSE)
[](https://www.python.org/downloads/)
## 故事背景下的痛点
当电子游戏发布更新时,你的主机不会重新下载整个 80 GB 的游戏。它只会下载一个小补丁。在安装之前,它会检查签名以确认补丁确实来自工作室,而不是来自某个在镜像源上偷偷替换了修改版文件的人。
很多非游戏类软件恰恰需要这种机制,却很少能做到。比如搜索索引、机器学习模型、离线目录或价格表。这些文件体积庞大、频繁变动,而且必须部署到你无法拥有或控制的手机、浏览器和小型盒式设备上。
这就留下了两个棘手的问题:
1. **这真的是我的文件吗?** 它穿过了别人的网络,停留在别人的 CDN 上。如果它在传输过程中损坏,或者被悄悄掉包了,你能发现吗?还是说你的应用会继续运行,仅仅是开始给出错误的答案?
2. **我必须重新发送所有内容吗?** 如果一个 400 MB 索引中的一行发生了变化,让每个用户都重新下载 400 MB 会带来实实在在的带宽成本,且用户体验极差。
**EdgeProc 是一个 Python 库,它同时解决了这两个问题。** 你只需发布一次;任意数量的设备都可以拉取更新,验证它是否确实来自你且完好无损,并仅获取实际发生更改的字节。
## 它是如何解决的
- **每个文件都被分割成数据块,每个数据块都由其自身内容的指纹来命名。** 这个指纹(一个 SHA-256 哈希值)只要有一个字节发生改变就会完全不同 —— 因此,损坏或被篡改的数据块将不再匹配其被请求时的名称,从而被拒绝。这种机制的专业术语叫*内容寻址存储*(Content-Addressed Store,简称 CAS)。
- **只对唯一的一个小文件进行签名,由它来为其他所有内容担保。** 发布者对一个*版本指针*进行签名:短短几行字声明“版本 1.0.1 现已上线,其文件列表的哈希是 ``”。该文件列表(即*清单*)通过哈希值指定了每个数据块。因此,一次签名检查就能覆盖整个发布版本,而且只需要保护一个密钥。
- **数据块的边界跟随内容,而不是固定的偏移量。** 在一个大文件的中间编辑一行,只有包含该行的数据块会发生变化 —— 它后面的所有内容都会保留旧的指纹,而不是发生位移。这就是为什么下一次更新只会产生很小的增量。
- **如果验证失败,则不会安装任何内容。** 绝对不是“警告后安装”。同步进程会以非零状态退出,之前完好的旧版本会继续保持可用。
一旦数据部署完成,EdgeProc 还会**在设备上**运行搜索和排序 —— 请求路径中不需要 embedding API,不需要向量数据库,也不需要排序服务器。
## 查看运行效果
下面展示的所有操作均实际运行以生成所示输出。大约耗时五分钟,其中大部分是一次性的模型下载时间。
```
git clone https://github.com/hseshadr/edge-proc.git
cd edge-proc
uv sync --all-extras # first run also downloads a ~90 MB embedding model
```
### 1. 创建值得分发的文件
一个小型产品目录,被转化为磁盘上的可搜索索引。
```
cat > save_index.py <<'PY'
import asyncio
from pathlib import Path
from edgeproc_core.vector_mgmt.core.types import IndexConfig, VectorEmbedding
from edgeproc.localvec.encoder import TextEncoder
from edgeproc.localvec.faiss_index import FaissVectorIndex
CATALOG = {
"p1": "red running shoes",
"p2": "waterproof hiking boots",
"p3": "blue denim jacket",
"p4": "trail running sneakers",
}
async def main() -> None:
ids, texts = list(CATALOG), list(CATALOG.values())
encoder = TextEncoder()
index = FaissVectorIndex("catalog_idx", IndexConfig(dimension=encoder.dim))
await index.insert(
[
VectorEmbedding(entity_id=i, embedding=v.tolist())
for i, v in zip(ids, encoder.encode_texts(texts), strict=True)
]
)
index.save(Path("catalog_idx"))
asyncio.run(main())
PY
uv run python save_index.py
ls catalog_idx
```
```
index.faiss
state.json
```
### 2. 生成签名密钥
`private.key` 将永远留在你的构建机器上。`public.key` 是分发给设备的,也是设备唯一需要信任的东西。
```
uv run edgeproc keygen --out keys
```
```
wrote keys/private.key and keys/public.key
```
### 3. 发布签名版本
```
mkdir -p src && cp -r catalog_idx src/
uv run edgeproc publish \
--src src --origin-dir origin \
--key keys/private.key \
--bundle-id catalog --version 1.0.0 --pretty
```
```
published v1.0.0 manifest=c4c28ab05da5
```
`origin/` 现在是一个存放以哈希命名的文件的普通目录。直接将其放在任何静态 Web 服务器或 CDN 后面即可 —— 无需运行任何应用服务器。
### 4. 拉取到设备上
```
uv run edgeproc sync \
--base-url origin --cache-dir cache \
--key keys/public.key \
--materialize-to materialized --pretty
```
```
synced v1.0.0 manifest=c4c28ab05da5 chunks_fetched=2 chunks_reused=0 bytes_fetched=5903
```
### 5. 搜索刚刚到达的文件
```
cat > task.json <<'JSON'
{"kind": "search", "payload": {"query": "shoes for running", "k": 3}, "privacy_mode": "local_only"}
JSON
uv run edgeproc route --index-dir materialized/catalog_idx --task task.json --pretty
```
```
success=True runtime=localvec latency=112.7ms
p1 0.219
p4 0.246
p2 0.556
```
`p1` 是“红色跑鞋”,`p4` 是“越野跑运动鞋”—— 它们是根据语义匹配的,完全在本地机器上运行,请求路径中只有本地代码。上面的哈希值和距离完全可以复现;只有 `latency` 会随机器性能有所变化。
### 6. 现在看看它如何拒绝被欺骗
这部分值得你自己尝试,因为这才是核心所在。
**没有变化?那就什么都不下载。**
```
uv run edgeproc sync --base-url origin --cache-dir cache --key keys/public.key --pretty
```
```
synced v1.0.0 manifest=c4c28ab05da5 chunks_fetched=0 chunks_reused=2 bytes_fetched=0
```
**微小的编辑只需作为微小的增量分发。** 发布追加了行的 `1.0.1` 版本,然后重新同步:
```
echo "tiny edit" >> src/catalog_idx/state.json
uv run edgeproc publish --src src --origin-dir origin --key keys/private.key \
--bundle-id catalog --version 1.0.1 --pretty
uv run edgeproc sync --base-url origin --cache-dir cache --key keys/public.key \
--materialize-to materialized --pretty
```
```
published v1.0.1 manifest=312b66ae9d63
synced v1.0.1 manifest=312b66ae9d63 chunks_fetched=1 chunks_reused=1 bytes_fetched=157
```
只需 157 字节而不是 5,903 字节 —— 它重新获取了那一个发生改变的数据块,并复用了其余部分。
**没有密钥就意味着无法同步。** 这里不存在“仅此一次”的模式:
```
uv run edgeproc sync --base-url origin --cache-dir cache2 --pretty; echo "exit=$?"
```
```
[config.missing] no trust root: pass --key or set EDGEPROC_TRUST_ROOT_PUBKEY_PATH (refusing to sync)
exit=1
```
那个 `[config.missing]` 前缀是标准的错误代码,而不是装饰品 —— 每一次拒绝操作都包含这样一个代码,而 `EDGEPROC_ERROR_FORMAT=json` 会将相同的拒绝信息转换为机器可读的 [RFC 9457](https://www.rfc-editor.org/rfc/rfc9457) 对象,以便脚本根据它进行条件分支处理。
**篡改服务器上的数据块,设备将拒绝整个发布版本。** 覆盖 `origin/chunk/` 下的任何文件,并同步到一个全新的缓存中:
```
printf 'corrupted' > "origin/chunk/$(ls origin/chunk | head -1)"
uv run edgeproc sync --base-url origin --cache-dir cache3 --key keys/public.key --pretty
echo "exit=$?"
ls cache3
```
```
[bundle.integrity_failed] sync failed: stored chunk failed to decompress
exit=1
chunks
manifests
```
相比之下,正常的 `cache/` 目录下包含一个 `active` 目录。而 `cache3/` 从未获得过该目录:损坏的版本从未被提升为活动版本,处于这种状态的设备会继续提供上一个完好的版本数据,而不是默默提供损坏的数据。
### 更倾向于使用纯 Python?
使用进程内相同的搜索功能,无需 CLI:
```
import asyncio
from edgeproc import EdgeProc, PrivacyMode, RuntimeRegistry, Task, TaskKind
from edgeproc.localvec.encoder import TextEncoder
from edgeproc.localvec.runtime import LocalVecRuntime
CATALOG = {"p1": "red running shoes", "p2": "waterproof hiking boots", "p3": "trail sneakers"}
async def main() -> None:
runtime = await LocalVecRuntime.from_texts(CATALOG, encoder=TextEncoder())
registry = RuntimeRegistry(); registry.register(runtime)
result = await EdgeProc(registry=registry).run(
Task(kind=TaskKind.SEARCH, payload={"query": "shoes for running"}, privacy_mode=PrivacyMode.LOCAL_ONLY)
)
for entity_id, distance in result.payload["results"]:
print(f" {entity_id} {CATALOG[entity_id]:<24} distance={distance:.3f}")
asyncio.run(main())
```
```
p1 red running shoes distance=0.219
p3 trail sneakers distance=0.374
p2 waterproof hiking boots distance=0.556
```
将 `TaskKind.SEARCH` 替换为 `TaskKind.EMBED`(原始向量)或 `TaskKind.RANK`(结合关键词和基于语义的排序)。
## 为什么你会选择它
将数据和计算能力全部转移到设备端,可以一次性带来四大优势:
- **第 N 次查询的边际成本为零。** 过去按请求计算的 embedding、向量搜索和重排序的云端账单,现在全部塌缩为一次性的 bundle 构建过程。
- **流量高峰由客户端承担,而不是你的服务器。** 产品发布或主页被广泛引用带来的流量将由用户自己的设备吸收。无需自动扩容,没有服务崩溃的风险。
- **在弱网或无网络环境下依然坚挺。** 只要完成过一次同步,设备就不再需要任何网络连接即可持续响应查询。
- **坚决捕捉篡改,绝不妥协。** 验证机制是默认失败的(fail-closed),而且由于数据是在本地搜索的,它从根本上就不会离开设备。
典型的应用场景包括:一个无论用户量多大都不会产生按次搜索云费用的推荐应用;一个用户数据绝对不能离开设备的隐私敏感型工具;或者一个需要快速本地搜索但又不想搭建后端的边缘计算或 IoT 盒子。
## 底层原理(面向开发者)
上文介绍的都是对用户友好的表层功能。以下是实际发生的事情,使用了专业术语进行解析。
### 安装
edge-proc 尚未发布到 PyPI,因此目前可行的安装方式就是前面提到的克隆即用设置 —— 只需一条命令,无需额外检出其他代码库:
```
git clone https://github.com/hseshadr/edge-proc.git
cd edge-proc
uv sync --all-extras # core + extras + dev tooling
```
这种方式非常方便 —— `edgeproc-core` 会从
[PyPI](https://pypi.org/project/edgeproc-core/) 自动解析(要求 `edgeproc-core>=0.2.1`,这是第一个提供 `edgeproc_core` 导入包的发布版本),因此 `uv sync` 会自动拉取所有内容;无需克隆其他任何东西。想将 `edgeproc-core` 与 EdgeProc 结合进行联合开发?只需将其克隆到本仓库同级目录下,并在 `pyproject.toml` 中添加注释中提到的路径覆盖即可。
或者直接从 PyPI 安装 —— 扩展依赖可以直接安装:
```
pip install edge-proc # core + CLI (pure router, contracts)
pip install edge-proc[localvec] # + FAISS vector runtime (EMBED / SEARCH / RANK)
pip install edge-proc[bundles] # + manifest + checksum sync substrate
pip install edge-proc[localvec,bundles] # full local substrate
```
EdgeProc **纯粹是一个依赖项** —— 它是一个由应用程序嵌入的库,而不是你需要注册使用的服务。核心库极其轻量;繁重的底层机制(如 FAISS 和同步功能)都在可选的扩展依赖背后,由用户主动启用。它构建于 [`edgeproc-core`](https://github.com/hseshadr/edgeproc-core) 之上:这里的 FAISS 索引是该库 `VectorIndex` Protocol 的具体实现。
### 确定性路由器
你将一个 `Task` 交给 EdgeProc,路由器会选择由哪个引擎(即“运行时”)来处理它。**该路由器纯粹是一套规则集,绝不是 AI** —— 它会询问每个已注册的运行时“你接受此任务吗?”,并选择第一个回答“是”的运行时。因为它是纯函数,所以针对相同运行时的相同 `Task` 的路由方式永远相同,这意味着执行轨迹是可以重放的,你可以证明哪个运行时处理了某个请求。
### 类型化结果与 Task/预算模型
一个 `Task` 包含其 `kind`(`EMBED` / `SEARCH` / `RANK`)、`payload`、`privacy_mode`,以及一个延迟/内存**预算声明**。`EdgeProc` 通过线程安全的 `MemoryManager` 来准入任务:所有已声明且正在处理的预留内存总和不能超过 `max_in_flight_memory_mb`,并且每次预留都会在确保执行 `finally` 代码块的上下文中释放。这是确定性的准入控制,**而不是**原生 RSS(实际物理内存占用)限制:预算始终是一个声明值,对于 FAISS、NumPy 或其他原生运行时内部的内存分配,它并不是一个强制执行的硬性边界。宿主机或容器本身负责管理 RSS、CPU 和进程级别的终止。当多个外观接口共享同一个进程时,请让它们共享同一个 `MemoryManager`。
每次运行都会返回一个类型化的 `ResultEnvelope` —— 这是一个结构化对象,包含 `success` 状态、提供服务的 `runtime`、`latency` 以及 `payload` —— 而不是一个松散的字典。输入是类型化的,输出也是类型化的。
### 详尽的验证链
在信任任何内容**之前**,`sync` 会针对固定的受信任根公钥(trust-root pubkey)验证指针的签名,然后将清单与本地缓存进行对比,只抓取缺失的数据块,再次根据内容地址重新校验每个数据块,最后才会原子化地提升至新版本。被篡改的数据块将无法通过内容地址校验;伪造的指针则无法通过签名校验 —— 这两种情况都会直接以非零状态退出且不抛出 traceback,并且都不会将数据提升到缓存中。
数据分块采用了基于内容的算法,并且数据块经过了 zstd 压缩。在 `sync` 时添加 `--http` 参数,即可通过任何静态 HTTP 服务器或 CDN 来提供 `origin/` 目录的数据,从而实现基于网络传输而非文件系统读取;这种方式遵循的契约完全相同,仅仅是传输媒介发生了改变。
### 配置:`EdgeProcSettings` + 以 `EDGEPROC_` 为前缀的环境变量
部署时的配置会通过 `EdgeProcSettings`(`edgeproc/core/settings.py`)从环境变量或 `.env` 文件中延迟读取。它会验证已记录的配置项,但会忽略不相关的宿主机环境变量,从而确保作为嵌入式的库能够与应用程序自身的环境和谐共存。环境变量统一使用 `EDGEPROC_` 前缀(Hugging Face token 除外,它使用生态系统标准的 `HF_TOKEN`):
| 配置项 | 环境变量 | 默认值 | 用途 |
| --- | --- | --- | --- |
| `model_name` | `EDGEPROC_MODEL_NAME` | `sentence-transformers/all-MiniLM-L6-v2` | Embedding 模型。 |
| `hf_token` | `HF_TOKEN` | `None` | Hugging Face 认证 token。 |
| `default_k` | `EDGEPROC_DEFAULT_K` | `10` | 默认的 top-k 结果数量。 |
| `http_timeout` | `EDGEPROC_HTTP_TIMEOUT` | `30.0` | Bundle HTTP 抓取超时时间(秒)。 |
| `mutation_lock_timeout` | `EDGEPROC_MUTATION_LOCK_TIMEOUT` | `30.0` | 跨进程发布/同步/提升/GC操作的锁等待时间上限(秒)。 |
| `task_budget_ms` | `EDGEPROC_TASK_BUDGET_MS` | `5000` | 默认的每个任务延迟预算。 |
| `task_budget_memory_mb` | `EDGEPROC_TASK_BUDGET_MEMORY_MB` | `256` | 默认的每个任务内存预算。 |
| `max_in_flight_memory_mb` | `EDGEPROC_MAX_IN_FLIGHT_MEMORY_MB` | `512` | 单个 `EdgeProc` 实例同时准入的任务预留内存总和上限。 |
| `max_materialize_bytes` | `EDGEPROC_MAX_MATERIALIZE_BYTES` | `256 MiB` | 单个文件实例化为返回的 `bytes` 值时的最大字节数。 |
| `maxcompressed_bytes` | `EDGEPROC_MAX_DECOMPRESSED_BYTES` | `64 MiB` | 单个数据块解压后明文的大小上限 —— 防止 zstd 炸弹耗尽内存。合法的数据块通常 ≤256 KiB,因此这绝不会拒绝正常数据。 |
| `max_fetch_bytes` | `EDGEPROC_MAX_FETCH_BYTES` | `256 MiB` | 单次 HTTP 抓取主体(指针、清单或数据块)的字节上限 —— 限制恶意的源站。 |
| `max_sync_total_bytes` | `EDGEPROC_MAX_SYNC_TOTAL_BYTES` | `4 GiB` | 单次 `sync` 在拒绝执行前最多拉取的总字节数 —— 防范恶意清单导致的磁盘耗尽攻击。 |
| `max_sync_files` | `EDGEPROC_MAX_SYNC_FILES` | `100000` | 单次 `sync` 在拒绝执行前最多拉取的文件总数,原因同上。 |
| `rrf_k_window` | `EDGEPROC_RRF_K_WINDOW` | `60` | 用于混合融合的 RRF 排名窗口常数。 |
| `trust_root_pubkey_path` | `EDGEPROC_TRUST_ROOT_PUBKEY_PATH` | `None` | 固定的同步受信任根公钥(缺失此密钥 ⇒ 拒绝 `sync`)。 |
这就是完整的配置集合 —— `EdgeProcSettings` 的全部 15 个字段。我们通过一个测试用例确保此表与设置对象的字段一一对应,因此任何新增的配置项都不会在未记录的情况下发布。
还有一个环境变量,我们故意**没有**将其作为 `EdgeProcSettings` 的字段,因为它是用来控制 CLI 输出的,而不是控制库本身的行为:
| 环境变量 | 默认值 | 用途 |
| --- | --- | --- |
| `EDGEPROC_ERROR_FORMAT` | `text` | 设置为 `json` 后,每次失败的退出都会在标准错误输出中打印一个 [RFC 9457 Problem Details](https://www.rfc-editor.org/rfc/rfc9457) 对象,而不是一行文本 —— 这样 CI 步骤或监督进程就可以根据 `type` 进行逻辑分支处理,而无需通过正则匹配自然语言。 |
```
$ EDGEPROC_ERROR_FORMAT=json edgeproc sync --base-url ./origin --cache-dir ./cache --key absent.key
{"detail": "could not read trust-root key absent.key: [Errno 2] No such file or directory: 'absent.key'", "field": "--key", "title": "A required setting is missing: --key.", "type": "config.missing"}
```
故意将其直接从环境中读取而不通过 `EdgeProcSettings` 是有原因的:这运行在错误处理的失败路径上,如果在该处构建设置对象,可能会导致另一个不相关的格式错误变量在*报告当前错误时*抛出异常,从而掩盖了正在报告的拒绝原因。
欲了解全貌 —— 包括系统上下文、bundle 生命周期、验证链以及模块映射,请参阅 [**docs/ARCHITECTURE.md**](docs/ARCHITECTURE.md)(包含 d2 图表)。
### 实测数据
```
uv sync --all-extras
uv run poe gate
uv run python benchmarks/benchmark.py
```
这两条命令都会进行自我检查。基准测试会运行一个固定的、完全离线的测试用例,并输出包含测得的延迟、峰值内存、运行硬件环境以及各项预算是否达标的通过/失败状态的 JSON 数据 —— 因此你得到的是*属于你自己的*真实数据,而不必盲目相信别人的测试结果。
此处记录的测量数据以及相应的硬件环境都保存在 [**docs/OPERATIONS.md**](docs/OPERATIONS.md#measured-evidence) 中。那是这些数据唯一存在的地方:这篇 README 刻意不复述这些数据,因为同一个数字被复制到两份文档中,最终必然会产生不一致。
## 状态与路线图
**已交付(v0):** 确定性的非 AI 路由器、基于 FAISS 的本地向量运行时(`EMBED` / `SEARCH` / `RANK`),以及一个内容寻址、带签名的 bundle 同步底层支撑(固定使用 ed25519 密钥 + 基于内容的数据块切分),以上所有功能均通过可选的扩展依赖提供。
**路线图 —— 尚未构建**(保留为 Protocol 的扩展切点,在 v0 版本中不可用):
- **第一方 WASM 内核 v0** —— 一个确定性的热点路径(数据块哈希/验证,BM25 或重排数学计算),基于 Rust→wasm32 实现,通过 wasmtime 在浏览器和 Python 中以完全相同的方式运行,填补了 `CUSTOM_WASM` 扩展缝隙 —— *属于路线图规划,尚未构建;完整的完成定义请参阅 [ROADMAP.md](ROADMAP.md)。*
- Biscuit 能力令牌,用于实现细粒度、可衰减的授权 —— *属于路线图规划,尚未构建。*
- Sigstore 无密钥(Keyless)bundle 签名,作为固定 ed25519 密钥的替代方案 —— *属于路线图规划,尚未构建。*
## 文档
- [docs/QUICKSTART.md](docs/QUICKSTART.md) —— 一个独立的五分钟入门教程,展示 `keygen → publish → sync → route` 的完整流程。
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) —— 系统上下文、bundle 生命周期、CAS + 清单机制、模块边界以及扩展缝隙。
- [docs/OPERATIONS.md](docs/OPERATIONS.md) —— 威胁模型、隐私数据流向、故障恢复/SLA 责任归属、资源限制上限以及实测的性能门控指标。
- [docs/diagrams/](docs/diagrams/) —— d2 源码文件与渲染出的 SVG 图片。
## 开发
```
uv sync --all-extras # core + extras + dev tooling
uv run poe gate # lint + format-check + mypy strict + Radon Grade A + pytest (≥90% statement+branch cov)
```
`poe gate` 严格镜像了 CI 流程 —— 只要它在本地能通过,CI 就能通过。
## 关于
**EdgeProc** —— 也可写作 `edge-proc` 或 `edgeproc`;规范代码仓库为 [`hseshadr/edge-proc`](https://github.com/hseshadr/edge-proc) —— 就是上文描述的那个开源、本地优先的分发与搜索底层支撑框架。它构建于 [**edgeproc-core**](https://github.com/hseshadr/edgeproc-core) 之上,后者是一套向量分区协议,本项目的 FAISS 运行时正是实现了该协议。官方主页为:[edge-reco.com/edgeproc](https://edge-reco.com/edgeproc),部署在我们自己控制的域名下。它**与任何其他名为“EdgeProc”的产品或公司没有任何关联**。
## 许可证
MIT
标签:CVE, Python, 增量更新, 数字签名, 数据分发, 数据完整性校验, 无后门, 本地搜索, 边缘计算, 逆向工具