跳到主要内容

数据截至 (上游 commit fc74d079a18c)

平台架构:控制平面、CLI 与发布拓扑

30 秒导读: 前几章讲的是 Julep 的内核(编译、执行、工具、上下文)。这一章讲 工程装配:开发者在终端里怎么用 CLI 管一整个 agent 模块;生产上控制平面怎么接住 run 请求、把它交给 Temporal、把状态落 Postgres;发布怎么做到"内容寻址工件 + 摘要钉死的 Helm lane"。看完你能画出 v3 的运行时拓扑,并知道每个 HTTP 路由/CLI 子命令落在哪个文件。

本章不重复内核逻辑:执行见 02,数据模型见 01,工具面见 04

旧版对照(已移除): v1 的 Traefik gateway、docker-compose 多服务编排 (agents-api/memory-store/llm-proxy/integrations-service/blob-store/scheduler)、 @pg_query 装饰器查询层全部不存在。v3 是单包进程 + 外部基础设施 (Temporal、Postgres、S3 兼容存储、可选 K8s)。


1. 这是什么(零基础也能懂)

一句话定义: v3 的平台层 = CLI(开发者循环)+ 控制平面(运行入口)+ 发布流水线 (不可变工件),全部装在一个 pip install julep 的包里,按 extras 启用 ([server] 装控制平面,[store,temporal] 装发布与 worker,README.md:215)。

它要解决的问题。 agent 平台有两种截然不同的活儿,和 v1 一样:

  • 开发的活儿:改流程、本地试跑、lint、看 trace——要快、本地、无依赖
  • 生产的活儿:接 run 请求、管 release、管密钥、扩缩 worker——要可靠、可审计、不可变

v3 的切法和 v1 不同:v1 是"一套多服务 compose 走天下";v3 是CLI 与控制平面解耦—— 开发循环根本不需要起服务器(julep run 直接在子进程里解释,见 02 的本地执行一节),生产循环则是一个可以 julep serve api 拉起的 FastAPI 进程加外部基础设施。

一句话直觉: 开发循环像 dbt(声明式资产 + 选择语法 + 本地执行);生产循环像 "mini PaaS"(提交 run → 队列 → worker;发布 = 推工件 + 协调 lane)。


2. 顶层全景(它大概怎么转)

2.1 运行时拓扑

怎么读这张图: 上半是开发循环(CLI,本地);下半是生产循环(控制平面 + Temporal + worker + 存储)。左右两侧是共享的外部基础设施。

开发循环(本地,零基础设施)
────────────────────────────────
julep run/show/lint/test/trace ──▶ 子进程解释器(resolve child)
julep dev up ──▶ 本地 dev 栈
────────────────────────────────
生产循环
客户端 ──POST /runs──▶ 控制平面(FastAPI, julep/server/app.py:57)
│ KeyRing 鉴权 · ExecutionStore(Postgres)
│ TemporalGateway(julep/server/temporal.py:20)

Temporal ──派活──▶ julep worker(容器/Helm lane)
│ │ callTool/invokeReasoner
│ ▼
│ MCP 服务器 / native 工具 HTTP / LLM

投影事件/轨迹 ──▶ Postgres(projection/trajectory 表)
共享:S3 兼容 artifact store(发布工件+大值)· 密钥库(AES-GCM,库内)

2.2 部件一句话职责

部件干什么在哪
CLI发现/选择/本地跑/lint/deploy 整个 agent 模块julep/cli/main.py
控制平面 appFastAPI 工厂:装配 store/gateway/路由julep/server/app.py:57(create_app)
runs 路由提交/控制/查询 run,SSE 事件,人闸信号julep/server/routes/runs.py:173(start_run)
releases/deployments 路由发布/取回 release,激活 lanejulep/server/routes/releases.py:101julep/server/routes/deployments.py:62
secrets 路由运营者密钥库(加密落盘)julep/server/routes/secrets.py:45(put_secret)
authAPI Key 环 + 角色(client/worker/admin)julep/server/auth.py:122(KeyRing)
ExecutionStorePostgres:runs/投影/值/release/密钥julep/execution/projection_store.py:85
TemporalGatewaystart/cancel/查询工作流的窄接口julep/server/temporal.py:20
发布流水线plan/publish/reconcile 三段式julep/app_deploy.py:464(plan_application)
本地控制面免 Postgres/Temporal 的本地执行网关julep/server/local.py:974(create_local_app)

3. 核心机制一:runs 路由——一次 run 的完整生命线

julep/server/routes/runs.py 是生产流量主入口。start_run(julep/server/routes/runs.py:173) 的骨架:

  1. 解析目标:按 release(可选)找 pipeline,取该 pipeline 的活跃 release (_active_release_for_pipeline,julep/server/routes/runs.py:72)。
  2. 幂等:idempotency_key → 确定性 run id(_deterministic_run_id, julep/server/routes/runs.py:120,用 uuid5(NAMESPACE_URL, "julep:run:<key>"))。 重放同键必须连 pipeline/release 都一致,否则 409 (_assert_idempotency_retry_matches,julep/server/routes/runs.py:158)。
  3. 输入落账:输入先过"秘密形状脱敏地板"再做内容引用(_input_claim, julep/server/routes/runs.py:124——存储的 claim 与工作流收到的原文是两回事)。
  4. 起跑:经 TemporalGateway start workflow,runs 表插行;歧义启动 (TemporalStartAmbiguous,julep/server/routes/runs.py:28 引入,:328 处理)有专门处理。
  5. 状态推进:runs 表状态机 submitting → accepted → running → 终态 (约束在 julep/execution/projection_sql.py:22 的 CHECK;前置关系见 julep/execution/projection_store.py:44)。后台 _reconcile_loop (julep/server/app.py:31)周期性把 Temporal 侧终态回填 (reconcile_runs_once,julep/server/routes/runs.py:624)。

查询与交互面:

端点干什么位置
GET /runs / GET /runs/{id}列出/查单个 runjulep/server/routes/runs.py:359/:378
GET /runs/{id}/eventsSSE 事件流(游标分页)julep/server/routes/runs.py:459
GET /runs/{id}/result终值(引用解出)julep/server/routes/runs.py:491
GET /runs/{id}/values/{ref}按内容引用取大值julep/server/routes/runs.py:516
GET /runs/{id}/gates当前等人审批的激活 idjulep/server/routes/runs.py:533
POST /runs/{id}/signals/human投递人工决定(接第 02 章的 submitHuman 信号)julep/server/routes/runs.py:547
POST /runs/{id}/cancel / terminate取消/终止julep/server/routes/runs.py:410/:419

鉴权三角色:require_client/require_worker/require_admin (julep/server/auth.py:254/:245/:236),KeyRing(julep/server/auth.py:122)管理键; worker 键与 client 键的权限面不同,merge_principal(julep/server/auth.py:263)把请求方 principal 与键身份合并——这就是第 01 章说的 RunPrincipal 的出生地。


4. 核心机制二:ExecutionStore——控制平面的 Postgres 模型

ExecutionStore(julep/execution/projection_store.py:85)是协议,Postgres 实现的 schema 全在 julep/execution/projection_sql.py:

干什么定义
runsrun 状态机 + 输入/结果引用julep/execution/projection_sql.py:22
projection_eventspomset 事件(工作流内批量外送)julep/execution/projection_sql.py:51
projection_values大值内容寻址仓julep/execution/projection_sql.py:77
releases发布元数据(工件哈希)julep/execution/projection_sql.py:87
deploymentslane 部署状态julep/execution/projection_sql.py:96
secrets运营者密钥(密文)julep/execution/projection_sql.py:120

三条工程纪律值得点出:

  • 大值永不内联:内联上限 MAX_INLINE_VALUE_BYTES = 64 * 1024 (julep/execution/projection_store.py:39);超限走引用,表里只有 ref。
  • 脱敏在活动边界:所有 redaction/内容哈希/落库 IO 都发生在 activity 边界 (julep/execution/projection_store.py:1 docstring)——工作流本体零 IO,重放确定性。
  • 密钥是密文,不是明文:secrets 表存的是 AES-GCM 密文,密钥环与 Temporal 载荷加密 刻意分离(julep/secrets.py:1 docstring:密文认证"不可变的名字 + 世代",旧版本行 不能改名重放)。

5. 核心机制三:发布流水线——plan → publish → reconcile

生产部署的单位是 Application(julep/app.py,显式声明而非装饰器发现,同文件 docstring), 内含若干 PipelineSpec(julep/app.py:43,flow + reasoners + capabilities + lane + snapshot)。发布三段式(julep/app_deploy.py):

函数干什么位置
planplan_application只读漂移报告:工件/MCP schema/Helm/运行时julep/app_deploy.py:464
publishpublish_application冻结 → 推 S3 工件(Ed25519 签名)→ 记录 releasejulep/app_deploy.py:338
reconcilereconcile_application / HelmLaneReconciler按 lane 协调一个摘要钉死的 Helm release + 不可变 release 任务队列,不动流量julep/app_deploy.py:850/:570

四个要点:

  • 内容寻址 + 签名:bundle 发布需要 64-hex Ed25519 种子(格式校验在 julep/bundle.py:109;签名生成 julep/bundle.py:115 起);生产 worker 侧设对应公钥 允许清单 JULEP_BUNDLE_ALLOWED_SIGNERS,apply 拒绝"允许清单里没有发布键"的配置 (README.md 的 Production secrets 节)。
  • 发布 ≠ 切流量:reconcile 只装"不活跃的 worker 容量"(julep/app_deploy.py:1 docstring:"Neither operation mutates an application's traffic route");流量切换是显式的 activate(julep/server/routes/deployments.py:62)。发布物、容量、流量三件事分离。
  • lane = 独立伸缩单元:每个 pipeline 声明 lane;lane 的任务队列名由 lane_task_queue(julep/app_deploy.py:909)从逻辑队列 + release 哈希派生—— 每次 release 一个不可变队列,新旧版本不会混跑。
  • 每 lane 的 worker 契约:pyproject [tool.julep.env.<name>] 声明 temporal 地址、 worker 镜像摘要、上下文工厂、K8s ServiceAccount/PriorityClass、payload 加密密钥引用、 普通/Secret 背景的 worker 环境变量(README.md 的 toml 示例)。julep worker 按 契约常驻;--smoke-test-seconds N 验连通后跑 N 秒即退。

6. 核心机制四:CLI——"dbt for agents"

julep 命令(julep/cli/main.py)面向一整个 agent 模块:发现目录里全部 @flow/Agent(...),当成一张跨 agent 图来操作(README.md:102:"dbt for agents, terminal-native")。主要子命令(挂载行号):

命令干什么位置
julep ls / show / graph列出/详情/跨 agent DOT 图julep/cli/main.py:634/:647/:662
julep run本地执行并流式渲染 trace 树julep/cli/main.py:696
julep lint / test校验选中 agent(+依赖)/ 跑 pytestjulep/cli/main.py:1230/:1264
julep eval.ctx 评测包julep/cli/main.py:1295
julep trace渲染缓存的 run trace + Langfuse 链接julep/cli/main.py:1375
julep doctor预检:发现/密钥引用/git/Langfuse/Temporaljulep/cli/main.py:1424
julep deploy冻结→发布→记入部署台账(legacy 单 agent 路径)julep/cli/main.py:847
julep plan / apply / status应用级三段式(§5)julep/cli/main.py:873/:926/:1087
julep activate切 lane 流量julep/cli/main.py:1049
julep worker常驻 worker(环境契约)julep/cli/main.py:1197
julep serve api拉起控制平面julep/cli/main.py:374(serve_api)
julep keygen / dev / db密钥生成/本地栈/迁移julep/cli/main.py:139/:193/:279

选择语法是 CLI 的灵魂:tag:supportstate:modified(Slim-CI 式)、 +agent/agent+/@agent(图遍历)、a,b 交集、--exclude(README.md:102 起)。 配置从 pyproject 的 [tool.julep] 读(load_config,julep/cli/config.py:638), 模块发现是 build_module(julep/cli/model.py:31)。

本地 run 的进程形态:julep runresolve 子进程里解释执行—— "pures live there"(julep/cli/runner.py:19 run_agent_local 的 docstring), 这样注册纯函数与用户代码同进程,而主 CLI 进程保持干净。


7. 服务拓扑怎么接起来(一次请求的全链)

客户端(带 API key)
│ POST /runs {release?, pipeline, input, idempotencyKey?}

控制平面 create_app(julep/server/app.py:57)
│ require_client 鉴权 → _input_claim(脱敏+内容引用)→ runs 表插行
│ TemporalGateway.start ──▶ Temporal ──▶ lane 任务队列(每 release 不可变)
│ ▼
│ julep worker(Helm lane,KEDA 扩缩)
│ build_worker 注册的全部 workflow+activity
│ │ callTool/invokeReasoner(第 02/04 章)
│ ▼
│ MCP / native HTTP / LLM
│ 投影批量回传(persist_projection_batch)→ Postgres

GET /runs/{id}/events(SSE)…… /result(终值)…… /gates + /signals/human(人闸)
└─ 后台 _reconcile_loop 周期回填 Temporal 终态

8. 巧妙之处(可借鉴的技术)

  • 确定性 run id。 uuid5(命名空间, "julep:run:"+幂等键) 让"重试同一逻辑请求" 天然收敛到同一 run,且冲突检查连 pipeline/release 都比对——幂等不是"键存在就 200" 的糊弄。见 julep/server/routes/runs.py:120

  • 存储 claim 与执行原文分离。 存进库的输入先过脱敏地板,工作流拿到的是原文—— 审计安全与执行保真两不误。见 julep/server/routes/runs.py:124

  • 发布三段式 + 发布不动流量。 plan(只读漂移)/publish(不可变工件)/ reconcile(装不活跃容量),流量切换是独立动词。回滚 = activate 旧 release,因为 每个 release 有自己的任务队列,新旧永不混跑。见 julep/app_deploy.py:338/:850/:909

  • 签名与允许清单的闭环。 工件签名(Ed25519 私钥)与 worker 侧公钥允许清单 (JULEP_BUNDLE_ALLOWED_SIGNERS)对账,apply 自己拒绝配错的允许清单——把供应链 校验做成发布流程的硬门。见 julep/bundle.py:109

  • 本地/生产同构。 CLI 的本地 run、create_local_app(julep/server/local.py:974) 的本地控制面、生产控制平面共用同一份配置/编译/解释器——差别只在效果层与存储, 不在流程语义。见 julep/local.py:1 docstring。


9. 边界与局限(诚实)

  • 没有 v1 式的一键 compose。 自托管要自己准备 Temporal、Postgres、S3 兼容存储; K8s + Helm + KEDA 是应用级发布路径(julep plan/apply),不是小玩具路径。 本地开发用 julep dev up / serve api / 本地 run,别拿应用级流水线做开发。
  • 控制平面是单进程假设。 _reconcile_loop 是进程内后台协程 (julep/server/app.py:31),多副本部署时 reconcile 会重复跑(靠 run 状态机幂等兜底, 但这不是分布式锁)。
  • legacy 双轨。 CLI 同时保留"单 agent deploy 台账"(julep deploy, julep/cli/main.py:847)与"应用级 release"(plan/apply)两条路径;status 按是否 配置 [tool.julep].application 分流(julep/cli/main.py:1087)。读代码时先分清在哪条轨上。
  • worker 环境契约是强约束。 镜像必须摘要钉死、保留环境变量名被系统占用 (julep/app_deploy.py_RESERVED_WORKER_ENVIRONMENT)——想"随便挂个环境变量" 会撞墙,这是刻意的。
  • SSE 事件有页上限(EVENT_PAGE_LIMIT,在 julep/server/routes/runs.py:27 import 自 julep/server/sse.py)——长 run 的事件流要靠游标分页拉,不是无限缓冲。

10. 代码地图(导航索引)

主题文件路径符号名
控制平面 app 工厂julep/server/app.py:57create_app / lifespan(:97) / _reconcile_loop(:23)
提交 runjulep/server/routes/runs.py:173start_run / RunRequest(:36)
确定性 run id / 输入落账julep/server/routes/runs.py:120_deterministic_run_id / _input_claim(:124)
SSE 事件julep/server/routes/runs.py:459run_events(julep/server/sse.pyrun_event_response)
人闸端点julep/server/routes/runs.py:533get_open_gates / signal_human_gate(:547)
终态回填julep/server/routes/runs.py:624reconcile_runs_once
release 路由julep/server/routes/releases.py:101publish_release / load_release(:62)
lane 激活julep/server/routes/deployments.py:62activate_deployment / list_deployments(:118)
密钥路由julep/server/routes/secrets.py:45put_secret / get_secret_value(:76)
工件路由julep/server/routes/artifacts.py:28put_blob / head_blob(:54)
鉴权julep/server/auth.py:122KeyRing / require_client(:254) / merge_principal(:263)
Temporal 网关julep/server/temporal.py:20TemporalGateway / TemporalClientGateway(:46)
服务器设置julep/server/settings.py:258ServerSettings
本地控制面julep/server/local.py:974create_local_app / LocalExecutionGateway(:540)
ExecutionStore 协议julep/execution/projection_store.py:85ExecutionStore / TERMINAL_RUN_STATUSES(:39)
Postgres schemajulep/execution/projection_sql.py:22runs / projection_events(:51) / projection_values(:77) / releases(:87) / secrets(:120)
应用级发布julep/app_deploy.py:464plan_application / publish_application(:338) / reconcile_application(:850)
Helm lane 协调julep/app_deploy.py:570HelmLaneReconciler / lane_task_queue(:909)
bundle 签名julep/bundle.py:115_signature_blob(Ed25519)/ _signing_seed(:109 起)
工件存储julep/artifact_store.py:39ArtifactStore / artifact_store_from_url
密钥库julep/secrets.py:1VaultCipher / operator_secret_redactor(:400)
载荷加密密钥环julep/_payload_encryption.py:14parse_aes_gcm_keyring
CLI 入口julep/cli/main.py:696run(:696) / deploy(:847) / plan(:873) / apply(:926) / status(:1087) / worker(:1197) / trace(:1375) / doctor(:1424)
CLI 配置/发现julep/cli/config.py:638load_config / JulepConfig(:146) / build_module(julep/cli/model.py:31)
本地 run(子进程)julep/cli/runner.py:19run_agent_local
前台本地执行julep/local.py:168LocalPipeline / prepare_local_pipeline(:354) / run_local_pipeline(:441)