首页
/ 解码 exo 的 AGENTS.md:面向 AI 编码代理的构建、测试与分布式架构指南

解码 exo 的 AGENTS.md:面向 AI 编码代理的构建、测试与分布式架构指南

2026-09-05 14:23:37作者:吴年前Myrtle

AGENTS.md 是 exo 仓库为 AI 编码代理准备的“工作手册”:它定义了项目概览、构建与运行命令、提交前必过的四项检查、节点级架构与事件溯源模型,以及 Dashboard UI 的无头截图流程。读完本文,你将掌握 exo 的完整开发工作流——从 uv run exo 启动一个本地集群,到理解 Router/Worker/Master/Election/API 五大组件如何通过 zenoh 类型化主题协同,并能按仓库规范完成类型检查、Lint、格式化与测试。

项目概览:exo 是什么

AGENTS.md 开篇给出的一句话定义是整个文档的基石:

exo is a distributed AI inference system that connects multiple devices into a cluster. It enables running large language models across multiple machines using MLX as the inference backend and zenoh for peer-to-peer networking.

即 exo 是一个分布式 AI 推理系统,把多台设备连接成集群,使用 MLX 作为推理后端、zenoh 作为点对点网络层。仓库 pyproject.toml 可以印证这一技术栈:核心依赖包含 pydanticfastapiexo-rs(Rust 绑定)、huggingface-hub 等,而 [tool.uv.sources]exo-rs 声明为 workspace 成员;Rust 侧 Cargo.tomlzenoh 精确钉死在 =1.9.0,并通过 [patch.crates-io] 指向 exo 维护的 zenoh 分支,同时引入 pyo3/pyo3-async-runtimes 0.28 作为 Python 桥接层。项目固定运行于 Python ==3.13.*

构建与运行命令

AGENTS.md 给出的命令清单是代理动手前的第一张地图,完整保留如下:

# Build the dashboard (required before running exo)
cd dashboard && npm install && npm run build && cd ..

# Run exo (starts both master and worker with API at http://localhost:52415)
uv run exo

# Run with verbose logging
uv run exo -v   # or -vv for more verbose

# Run tests (excludes slow tests by default)
uv run pytest

# Run all tests including slow tests
uv run pytest -m ""

# Run a specific test file
uv run pytest src/exo/shared/tests/test_election.py

# Run a specific test function
uv run pytest src/exo/shared/tests/test_election.py::test_function_name

# Type checking (strict mode)
uv run basedpyright

# Linting
uv run ruff check

# Format code (using nix)
nix fmt

这些命令与仓库中的配置一一对应,值得逐条核实:

  • Dashboard 必须先构建src/exo/main.py 的入口 main() 只负责组装 Node 并启动事件循环,Dashboard 静态产物由 API 服务端直接托管(AGENTS.md 注明输出位于 dashboard/build/)。仓库的 justfile 也提供了等价的 build-dashboard 目标(npm install && npm run build)。
  • uv run exo 的默认端口。从 src/exo/main.pyArgs 定义可见默认值:api_port: PositiveInt = 52415(即 AGENTS.md 所说的 API 地址 http://localhost:52415)、zenoh_port 默认 52414(zenoh 固定 TCP 监听口)、discovery_port 默认 52413(UDP 发现服务端口)。此外还支持 --no-worker--no-downloads--offline--no-batch--force-master--legacy-daemon--fast-synch/--no-fast-synch 等参数。
  • -v / -vv 的机制Args.parse()action="count" 解析 --verbose、用 store_const(-1) 解析 --quiet,最终把 verbosity 传入 logger_setup(EXO_LOG, args.verbosity),因此日志级别完全由这个计数驱动。
  • 测试默认排除 slow 用例pyproject.toml[tool.pytest.ini_options] 配置了 markers = ["slow: marks tests as slow (deselected by default)"]addopts = "-m 'not slow' --ignore=tests --ignore=tmp",这解释了为什么 uv run pytest 默认跳过慢测试,而 uv run pytest -m "" 会用空 marker 表达式覆盖 addopts 中的筛选、跑全量测试。

提交前必过的四项检查(Pre-Commit Checks)

AGENTS.md 用加粗标注了 REQUIRED:提交前必须跑通全部检查,否则 CI 会失败:

# 1. Type checking - MUST pass with 0 errors
uv run basedpyright

# 2. Linting - MUST pass
uv run ruff check

# 3. Formatting - MUST be applied
nix fmt

# 4. Tests - MUST pass
uv run pytest

也可以一条命令串联执行:

uv run basedpyright && uv run ruff check && nix fmt && uv run pytest

文档还强调两点细节:nix fmt 改动了文件,提交前必须把它们加入暂存区;CI 会执行 nix flake check,同时校验格式化、Lint 并运行 Rust 测试。

这四项检查的“严苛程度”在配置文件里可以找到依据:

  • basedpyright 是 strict 模式pyproject.tomltypeCheckingMode = "strict"failOnWarnings = true,并把 reportAnyreportUnknownVariableTypereportMissingParameterTypereportMissingTypeStubsreportInvalidCastreportUnnecessaryCast 等全部提升为 error——这正是 AGENTS.md 所说“MUST pass with 0 errors”的含义。
  • ruff 启用了大量规则族[tool.ruff.lint]extend-select = ["I", "N", "B", "A", "PIE", "SIM"],覆盖 import 排序、命名、Bugbear、aliased-import、可读性等类别。
  • Rust 侧同样有严格 lintCargo.toml[workspace.lints] 对 clippy correctness 设为 deny,并对 panicunwrap_usedindexing_slicingas_conversionsrestriction 类检查设为 warn

架构:单节点内的组件与集群协作

节点构成(Node Composition)

AGENTS.md 指出:一个 exo 的 Node(位于 src/exo/main.py)同时运行多个组件:

  • Router:基于 zenoh 的 pub/sub 消息层,经由 Rust 绑定(exo_rs)实现;
  • Worker:处理推理任务、下载模型、管理 runner 进程;
  • Master:协调集群状态,把模型实例放置到各个节点;
  • Election:用 Bully 算法选举 Master;
  • API:FastAPI 服务,提供 OpenAI 兼容的 chat completions。

从源码看,这一描述与 src/exo/main.pyNode 数据类字段完全吻合:router: Routerevent_router: EventRouterdownload_coordinatorworkerelectionmasterapi。其中有几处实现细节值得注意:

  • Node.create() 中每个节点都会创建 Master 实例(“We start every node with a master”),而 src/exo/main.py_elect_loop() 负责根据选举结果动态提升(promoting)或降级(demoting)本节点的 master 角色,并在“新 master 产生”时重建 EventRouter、DownloadCoordinator 与 Worker;
  • --force-master 参数的实现是把选举优先级(seniority)直接设为 1,000,000,从而让本节点必然赢得 Bully 选举——源码注释里还留了一句自嘲:“If someone manages to assemble 1 MILLION devices into an exo cluster then. well done. good job champ.”
  • 启动时 main() 会先做 PID 文件锁Pidfile(EXO_PID_FILE, 0o0600),来自 Rust 绑定 exo_rs),防止同一台机器重复拉起节点;--legacy-daemon 则走双 fork 的经典 SysV 守护进程化路径。

消息流:类型化主题(Typed Topics)

AGENTS.md 列出五个组件间通信的主题(定义于 src/exo/routing/topics.py):

主题 方向与用途
GLOBAL_EVENTS Master 向所有 Worker 广播带索引的事件
LOCAL_EVENTS Worker 向 Master 发送事件以供索引
COMMANDS Worker/API 向 Master 发送命令
ELECTION_MESSAGES 选举协议消息
CONNECTION_MESSAGES zenoh 连接状态更新

对照 src/exo/routing/topics.py 的实现,可以看到两个文档未明说但很关键的机制:

  1. TypedTopic 携带序列化契约。每个主题由 (topic 名, publish 策略, 绑定的 Pydantic 模型类型) 三元组构成,序列化统一走 model_dump_json() 编码为 UTF-8 字节,反序列化走 model_validate_json()。消息类型安全由主题定义本身保证。
  2. PublishPolicy 三档策略Never(本地消息,不上网络)、Minimal(仅当本地无接收者时才发布)、Always(始终发布)。实际配置中 CONNECTION_MESSAGES 使用 Never,其余均为 Always;此外源码中还有一个文档未列出的 DOWNLOAD_COMMANDS 主题(承载 ForwarderDownloadCommand),在 src/exo/main.py 中与其余主题一并注册。

事件溯源(Event Sourcing)

AGENTS.md 将状态管理概括为三点,均可在源码中逐条验证:

  • State 是不可变状态对象src/exo/shared/types/state.py 中的 State(FrozenModel) 使用 strict=Trueextra="forbid"ConfigDict,字段包括 instancesrunnersdownloadstaskstopologylast_event_applied_idx,以及按不同频率更新的细粒度节点状态映射(内存、磁盘、网络、Thunderbolt、RDMA 状态等)。
  • apply() 是纯函数src/exo/shared/apply.pyevent_apply(event, state) -> State 用 Python matchEvent 联合类型做穷举匹配ChunkGeneratedTaskCreatedTopologyEdgeDeleted 等),逐分支调用 apply_* 纯函数派生新状态——这直接体现了仓库“用类型级纪律替代运行时分支”的编码信条。
  • Master 索引事件并广播,Worker 应用带索引的事件:事件从 Worker 出发经 LOCAL_EVENTS 到 Master 打索引,再经 GLOBAL_EVENTS 广播回各节点,EventRouter(见 src/exo/routing/event_router.py)负责这两条链路的收发。

关键类型层级与 Rust 组件

AGENTS.md 指出共享类型全部位于 src/exo/shared/types/ 下的 Pydantic 模型:events.py(事件的判别联合)、commands.py(命令类型)、tasks.py(Worker 执行的任务类型)、state.py(集群状态模型)。所有节点通过这些类型在“消息”与“状态”之间形成单一事实来源。

Rust 组件方面,AGENTS.md 列出三项:networking(zenoh 网络层,gossipsub、节点发现)、exo_rs(PyO3 绑定,向 Python 暴露 Rust 能力)、system_custodian(系统级操作)。从当前 Cargo.toml 的 workspace 成员看,实际纳入编译的是 rust/exo_rsrust/networking 两个 crate(rust/exo_rs 通过 PyO3 提供 Pidfile 等能力,rust/networking 包含 discovery.rsswarm.rs 等),system_custodian 未出现在当前 workspace 中——可以推断该条目描述的是文档写作时的组件全景,阅读时以 Cargo.toml 为准。

Dashboard

AGENTS.md 的最后一句:Dashboard 是 dashboard/ 下的 Svelte 5 + TypeScript 前端,构建产物输出到 dashboard/build/ 并由 API 服务托管。仓库中 dashboard/src/routes/ 包含主聊天页、advanced、downloads、integrations、traces 等路由,dashboard/src/lib/components/ 下有 TopologyGraph.svelteModelPickerModal.svelte 等 20 余个组件,前端通过 HTTP 与同一进程内的 API 协作。

exo 内置 Dashboard 的集群视图,展示多节点拓扑与模型卡片

代码风格与测试约定

AGENTS.md 引用的代码风格要求(源自 .cursorrules)可归纳为:

  • 严格且穷尽的类型标注,绝不绕过类型检查器;
  • 枚举式集合用 Literal[...],原始类型包装用 typing.NewType
  • Pydantic 模型统一 frozen=True + strict=True
  • 纯函数 + 可注入的 effect handler:副作用(如触发 saga)封装成函数传入纯函数,而不是让纯函数直接产生副作用;
  • 命名描述化——禁止缩写与三字母缩写;
  • 只在你能有意义地处理异常时才 catch;
  • 在适用处使用 @final 与不可变性。

这些要求与 RULES.md 的仓库规则互为表里(例如 UUID 需继承 Pydantic UUID4、幂等标签用加盐哈希生成以保证崩溃恢复后不重放、错误处理理由必须写进 docstring 等),也是 State 这类 FrozenModel 的由来。

测试方面,AGENTS.md 说明:测试使用 pytest-asyncio 的 asyncio_mode = "auto",测试代码放在被测代码同目录的 tests/ 子目录中,测试运行期间自动设置环境变量 EXO_TESTS=1。这三点在 pyproject.toml 中全部有对应配置:

[tool.pytest.ini_options]
asyncio_mode = "auto"
markers = ["slow: marks tests as slow (deselected by default)"]
env = ["EXO_TESTS=1"]
addopts = "-m 'not slow' --ignore=tests --ignore=tmp"

因此 uv run pytest src/exo/shared/tests/test_election.py 这类命令可以直接定位到 AGENTS.md 示例中提到的选举测试;仓库根目录下的 tests/ 则存放 1/2/4 节点集群级集成测试与 Dashboard 测试,被默认 --ignore 排除在单测之外。

Dashboard UI 测试与无头截图

AGENTS.md 的最后部分给出了完整的 Playwright 工作流,适合代理自动化完成 UI 验证:

构建并启动:

# Build the dashboard (must be done before running exo)
cd dashboard && npm install && npm run build && cd ..

# Start exo (serves the dashboard at http://localhost:52415)
uv run exo &
sleep 8  # Wait for server to start

一次性安装 Playwright:

npx --yes playwright install chromium
cd /tmp && npm init -y && npm install playwright

无头截图脚本(cd /tmp && node -e "..." 执行):

const { chromium } = require('playwright');
(async () => {
  const browser = await chromium.launch({ headless: true });
  const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
  await page.goto('http://localhost:52415', { waitUntil: 'networkidle' });
  await page.waitForTimeout(2000);

  // Inject test data into localStorage if needed (e.g., recent models)
  await page.evaluate(() => {
    localStorage.setItem('exo-recent-models', JSON.stringify([
      { modelId: 'mlx-community/Qwen3-30B-A3B-4bit', launchedAt: Date.now() },
    ]));
  });
  await page.reload({ waitUntil: 'networkidle' });
  await page.waitForTimeout(2000);

  // Interact with UI elements
  await page.locator('text=SELECT MODEL').click();
  await page.waitForTimeout(1000);

  // Take screenshot
  await page.screenshot({ path: '/tmp/screenshot.png', fullPage: false });
  await browser.close();
})();

两个实现细节值得留意:其一,exo-recent-models 直接注入 localStorage 后 reload,说明 Dashboard 的“最近使用模型”是纯前端持久化,这与 dashboard/src/lib/stores/recents.svelte.ts 的 store 实现路径一致;其二,AGENTS.md 随后给出向 PR 上传截图的变通方案(先把截图提交到分支、用永久 commit SHA 的 raw 地址发 PR 评论、再删除截图文件)——因为 GitHub API 不支持直接向 PR 评论上传图片,而引用已推送 commit 的原始 URL 可保证后续删除文件后评论中的图片仍可渲染。

关键路径速查

主题 路径
AI 代理工作手册(本文主体) AGENTS.md
仓库工程规则 RULES.md
节点入口与 Args 参数定义 src/exo/main.py
类型化主题与 PublishPolicy src/exo/routing/topics.py
事件溯源纯函数 src/exo/shared/apply.py
不可变全局状态 src/exo/shared/types/state.py
共享类型(events/commands/tasks) src/exo/shared/types/
Rust workspace(exo_rs + networking) Cargo.tomlrust/
工具链配置(pytest/basedpyright/ruff) pyproject.toml
常用任务(sync/check/test/build-dashboard) justfile
Dashboard 前端 dashboard/src/

小结

AGENTS.md 的价值在于把“如何正确地在这个仓库里干活”压缩成了五个可执行要点:先构建 Dashboard 再运行提交前跑通 basedpyright → ruff → nix fmt → pytest 四道闸门架构上以 Router/Worker/Master/Election/API 五组件 + 五个类型化主题为骨架状态管理走“不可变 State + 纯函数 apply”的事件溯源路线UI 验证用 Playwright 无头流程自动化。对照 src/exo/main.pysrc/exo/routing/topics.pypyproject.toml 的源码,文档中的每一条约定都能在仓库中找到落点,这正是它对人与 AI 代理都同样可靠的根本原因。

登录后查看全文
热门项目推荐
相关项目推荐