演进侧车:架构、引擎与独立部署
本文是面向维护者的引擎内部与部署说明。想了解
/evolve-design是什么、什么时候用、怎么跑, 请看程序演进与使用PUCT优化一个文本压缩算法。
services/evolve 是一个 Python FastAPI 进程,跑演进搜索。它支持两种搜索引擎:
- PUCT(
puct_engine.py):flat-PUCT 树搜索,通过访问次数和排名引导探索。适合在一个起点上持续精炼。 - OpenEvolve(
openevolve_engine.py):MAP-Elites 多岛搜索 + 环形迁移,按代码复杂度和多样性分箱归档。适合解空间宽、容易早熟收敛的任务。
两个引擎共用 Domain 接缝(四种评分模式)、事件流、沙箱、模型代理和探针——只换算法核心(PuctTree ↔ OpenEvolveArchive)。algorithm 字段选择引擎,/evolve-design 命令让用户在发送前选择。
本文回答两个问题:引擎之间有何差异,以及把侧车从仓库里拿出去当独立后端运行要做什么。
结论先说:代码层面它已经是独立的,真正的工作在四个耦合点上,其中一个(共享文件系统)是硬的,其余三个是约定层面的。
0. 两个引擎的差异
PUCT 和 OpenEvolve 共用同一套骨架(Domain 接缝、事件流、沙箱、模型代理、探针),只换算法核心。差异在三个维度:
0.1 状态管理
PUCT 维护一棵树(PuctTree)。每个候选是树的一个节点,有 parent_index 指向父节点。树是追加式的——所有候选永久保留在树里,num_visits 决定下次选择时的探索权重。c_puct 和 prior_exponent 控制探索-利用平衡。
OpenEvolve 维护多岛 MAP-Elites 网格(OpenEvolveArchive)。num_islands 个岛各拥有一个 feature_bins × feature_bins 的网格,按(代码复杂度 × 代码多样性)分箱。一格一席——新候选如果分数高于当前占据者就替换,旧的被逐出。每 migration_interval 代,各岛最优候选通过环形迁移复制到下一个岛。
0.2 父选择
PUCT 用 flat-PUCT 公式:遍历所有叶子节点,计算 rank_score + c_puct · prior · √总访问数 / (1 + 访问数),选最高值。访问次数少的节点有更高的探索分。
OpenEvolve 用 ε-greedy + 轮转岛:按迭代号轮转选岛(iteration 1→岛0, 2→岛1, ...),从该岛的网格里 70% 选最优、30% 随机。额外选一个与父代码差异最大的 inspiration 程序放入变异 prompt(对应图谱的 inspires 边),鼓励跳变。
0.3 变异 prompt
PUCT:domain.prompt(parent.program)——只传父程序。
OpenEvolve:domain.prompt(parent) + archive 上下文(全局最优的指标 + 多样化 inspiration 的代码)。模型同时看到"当前最好的"和"和父完全不同的",从两个方向获得启发。
0.4 参数差异
| 参数 | PUCT | OpenEvolve | 依据 |
|---|---|---|---|
blast_radius |
1.0 | 0.6 | 上游 agentdescent OpenEvolve 用 0.6 |
solved_threshold |
2.0(永不跳过) | 1.0(找到最优跳过) | OpenEvolve 的 shard 是同一目标的多个种子 |
staleness |
full(从 options 读) | full(从 options 读) | 两者都 append-only |
c_puct / prior_exponent |
从 options 读 | 不用 | PUCT 特有 |
islands / archive_size / feature_bins / exploitation_ratio / migration_interval |
不用 | 从 options 读 | OpenEvolve 特有 |
model_retries / retry_backoff |
不用 | 默认 2 / 1.0 | OpenEvolve 有 model call retry |
0.5 事件流差异
| 事件 | PUCT | OpenEvolve |
|---|---|---|
expanded |
depth/parentIndex/score | + island + programId + inspirationIndexes |
inserted |
无 | complexityBin/diversityBin/island/via(insert/migration) |
migrated |
无 | fromIsland/toIsland |
selected |
ancestorVisits + puct | ancestorVisits=[] |
0.6 前端差异
| 视图 | PUCT | OpenEvolve |
|---|---|---|
| 图视图 | 树(x=depth) | 时间线森林(x=iteration,island 水平带 + inspires 虚线边) |
| 网格视图 | 无 | 岛列视图(每岛一列,best ★ 高亮,迁移 ↔ 徽章) |
| 表格列 | depth/rank/visits/outcome | island/cell/via/outcome + 失败原因 |
| 算法选择器 | 无 | popover(标签 + 特性 + 适用场景) |
1. 它现在有多独立
| 现状 | |
|---|---|
| 语言 / 进程 | Python 3.12,独立 venv,独立进程 |
| 第三方依赖 | fastapi、uvicorn、pydantic、agentdescent>=0.4.6(候选运行时的 numpy/pandas/scipy/sklearn 是可选组,属于候选而非侧车) |
| 对 Node 侧的 import | 零——它是另一门语言,编译期耦合不可能存在 |
| 对仓库其它目录的路径依赖 | pyproject.toml 里没有任何一条 |
| 对外接口 | 四个 HTTP 端点 |
| 业务状态 | 不持有。目标、评分卡、产物地址、run 记录全在控制面 |
| 模型密钥 | 不持有。只拿一个 run 级临时 token 调控制面的代理 |
它自己的模块文档明确:该进程不持有业务状态或模型密钥。这不是事后总结,是当初就按这个边界切的。
services/evolve/ 目录可以原样复制到一个新仓库,uv sync 之后 uvicorn 起来就能跑——只是没有调用方喂给它合法的请求。
2. 对外接口
四个端点,全部靠一个共享的内部 token 鉴权(SCIENCE_AGENT_EVOLVE_INTERNAL_TOKEN;未配置时视为本地开发,回环绑定是唯一边界)。
GET /health → {engine, running, sandbox_local, status}
POST /probe → 判别力探针,返回 {baseline, worsened, flat, label}
POST /runs → NDJSON 事件流(长连接)
POST /runs/{search_id}/stop → 停止
POST /runs 的请求体 RunRequest 有 24 个字段。按性质分四类:
(a) 纯数据,可以直接跨网
search_id、algorithm、expansions、workers、statement、scorecard(冻结的评分卡正文)、scorecard_hash、baseline_code、rubric、script(评测脚本原文)、source_material、candidate_timeout_seconds、max_tokens_per_call、thinking、packages、baseline_score、resume_from_sequence、engine、options。
评分卡正文和评测脚本是按值传的,不是引用。原因是侧车没有 CAS,而评测器只是一页 Python, 比一个候选更小。这个决定让侧车不需要访问内容库,是它能独立的重要一步。
(b) 文件系统路径
dataset_dir、workspace_dir。这是唯一的硬耦合,第 3 节详述。
(c) 回调地址 + 临时凭据
llm: {url, token}、judge: {url, token}。url 必须是绝对地址,否则侧车若要猜测 API
源地址,每次扩展都可能因与候选无关的原因失败。这一点本来就是为跨进程准备的,跨机也不用改。
(d) 上游探测结果
sandbox: {backend, ...}。控制面探好沙箱能力再告诉侧车,侧车不自己探。
3. 硬耦合:共享文件系统
三条路径,两个方向。
3.1 控制面写、侧车读:暂存的数据集
控制面把每个 run 的分片物化到一个目录,侧车按 manifest 读:
manifest.json
<criterionId>/train.csv 每个分片的候选拿来训练的行
<criterionId>/<shard>/test.csv 只有特征,不含目标列
<criterionId>/<shard>/truth.json 目标值,候选拿不到
切分策略(种子、每片行数、哪些索引是 gate 分片)由控制面决定,侧车只读结果。若两侧各自 决定切分,结果就会不一致。
test_gate 模式下还有 workspace_dir:项目的一份纯净副本,每次运行在一次性克隆里进行,冻结路径在执行前从这里还原。
3.2 侧车写、控制面读:候选源码
侧车把每个候选的源码按内容哈希写到 evolve-candidates/<runId>/<sha256>.py,控制面通过 CandidateSources.read() 直接读文件。candidates.ts 的注释说得很直白:
两个进程共用一个文件系统和数据目录,所以侧车写入、控制面读取。两者之间除该布局外没有协议; 布局按内容寻址,因此唯一需要一致的是哈希。
它不进 CAS,因为一个候选还不是产物——大多数会被拒绝,全部收进内容库只会塞满没人要的程序;用户保存某一个时才复制进去。
3.3 拆分后的三种做法
| 做法 | 代价 | 适用 |
|---|---|---|
| 保留共享卷(NFS / 同一台机 / 同一个 Pod 的 emptyDir) | 零代码改动 | 同机或同 Pod 部署,最省事 |
| 数据集改上传,候选改回传 | 数据集要走 multipart 或对象存储;候选源码改成随 expanded 事件带回,或加一个 GET /runs/{id}/candidates/{hash} |
真正跨机 |
| 两边都换成对象存储引用 | 控制面写 S3/OBS 并传 URL,侧车下载;候选反向同理 | 多副本、水平扩容 |
第二条是最小可行的跨机方案。候选源码走事件流会让事件变大(一个候选几 KB 到几十 KB),走新端点则要处理生命周期(run 结束后何时清理)。
4. 三个约定层面的耦合
4.1 事件契约靠文档对齐,不靠代码
events.py 的模块文档写着:
它镜像
packages/schema中的EvolveEvent/EvolveEventRecord,Node API 按这些形状解析。
这是一份手工维护的镜像。 侧车用普通 dict 加小函数构造事件,理由是"这个联合类型有十个分支,唯一的消费者是 json.dumps,在这边再写一份 schema 就是多一处要同步的东西"。在同一个仓库里这个取舍成立——两边一起改,CI 一起跑。
拆开之后它变成跨仓库的兼容性问题:侧车加一个字段,控制面的解析器不认识;控制面改一个字段名,侧车照旧发旧的。需要:
- 给事件流定版本号(
schemaVersion或Accept: application/vnd.evolve.v1+json) - 把
EvolveEvent的定义提成一个两边都消费的产物(JSON Schema / protobuf / 一个发布的 npm+PyPI 双包) - 或者接受"侧车只增不改"的约束,并在控制面对未知字段宽容
同样的问题也在 RunRequest 上:它现在是 pydantic 模型,而控制面用 TypeScript 手写请求体。
4.2 两个不变量分散在两侧
有两条规则,写在侧车的注释里,但执行者在控制面:
- 计数器是绝对值,不是增量。
visits和 cell 占用是系统里仅有的非幂等量;重放一个增量会重复计数,重放一个绝对值不会。这条让控制面可以丢弃水位线以下的一切,也让中途的 Neo4j 故障可以靠重放事件日志修复。 -inf不上线。json.dumps会把它写成裸 token-Infinity,不是合法 JSON,JSON.parse拒收。失败的候选带score: null且valid: false,节点照样进树——丢掉它会改变之后每一次迭代的排名分母。
拆开之后这两条要写进接口文档,否则第二个调用方会踩。
4.3 vendored 的上游代码要有人跟
vendor/puct/ 和 vendor/openevolve/ 都是从 agentdescent 的 examples/ 抄进来的——因为 wheel 不打包 examples/,只能抄不能依赖。主包(FlatPuct、Ledger、verifier、policies、async_evolve)是正常依赖。
这意味着独立出去的后端会带着一份上游代码的副本,需要有人负责同步。这不是理论风险:最近一次对齐发现我们落后了
sys.modules["candidate"]未注册(候选里写from __future__ import annotations+get_type_hints会以'NoneType' object has no attribute '__dict__'失败,被记成"模型写了个坏程序")- 先验机制(
Candidate.prior/prior_exponent) - 修复循环的位置(上游已从合并线程移到工作线程)
建议在独立仓库里把"上游 commit 指纹 + 差异清单"写进各 vendor/*/ 的 __init__.py(现在已经有了)并加一个 CI 任务定期比对。
5. 拆分后谁做什么
侧车不做的事,都留在控制面,这些是新调用方必须自己实现的:
| 职责 | 现在在哪 | 说明 |
|---|---|---|
| 提案校验与预检 | proposal.ts / preflight.ts |
起点、切分、模型 token 上限、沙箱后端 |
| 评分卡冻结 | scorecard.ts + CAS |
冻结后搜索无法改写判分标准 |
| 数据集切分与暂存 | dataset.ts |
种子、分片、rollout/gate/test 三分 |
| 沙箱能力探测 | sandbox.ts |
容器里的 --disable-userns、procfs 回退 |
| 模型代理 | llm-proxy.ts |
run 级临时 token,密钥不出控制面 |
| run 记录与事件持久化 | store.ts |
水位线、重放 |
| 图谱镜像 | graph-mirror.ts |
写 Neo4j |
| 产物发布 | platform.ts |
最优候选写进 CAS,成为产物版本 |
判别力探针(/probe)是唯一一件侧车替控制面做的"业务判断",但它只回数字,是否接受由控制面决定。
6. 一份最小的拆分清单
能立刻做的(零代码改动)
services/evolve/复制进新仓库,pyproject.toml原样可用- 起服务:
SCIENCE_AGENT_EVOLVE_INTERNAL_TOKEN=... uvicorn sciencediscovery_evolve.server:app - 部署成同机/同 Pod,共享
SCIENCE_AGENT_DATA_DIR(候选目录也可用SCIENCE_AGENT_EVOLVE_CANDIDATE_DIR单独指定——两侧都读这个变量)
跨机才需要做的
dataset_dir→ 上传接口或对象存储引用- 候选源码 → 新端点或随事件带回
workspace_dir→ 同 4(只影响test_gate模式)
独立仓库必须补的
- 事件流与
RunRequest定版本,并把契约提成两边共享的产物 - 把 §4.2 的两条不变量写进接口文档
- 上游同步的 CI 任务
可选
- 沙箱能力改成侧车自探(现在由调用方告知,是为了"不问两遍")
7. 已知的粗糙处
拆分前值得先处理,否则会变成新接口的一部分:
options是一个无类型的 dict。c_puct、prior_exponent、mode、async_ratio、staleness、completion_timeout、islands、archive_size、feature_bins、exploitation_ratio、migration_interval、model_retries、retry_backoff都塞在这里,未知的键被忽略。跨仓库之后"传了一个拼错的键,搜索照常跑完并报成功"会更难发现。algorithm保留了era别名。 更名为puct之后旧值仍被接受,因为控制面发的是存档目标里记着的那个名字。独立后端要决定这个别名保留多久。- 模式默认由
workers推断(workers > 1走async_evolve,否则serial)。这是个隐式规则,接口文档里要写明。 staleness_policy默认"full",注释说"没什么好过期的",但线上实测确实丢过提案(20 次选择只落地 18 个)。拆分前值得查清,否则新调用方会继承一个没人解释得清的行为。