Web 管理门户 portal
L2.5 研发体验层的门面:一个 Next.js 控制台,让工程师看清 agent 资产、成本、闸门命中、调用回放、评测。昨天(Day 15)我们把 20 多个业务 Agent 全家福点了一遍;今天进入第 4 周「平台化与收官」的第一站——给这一栋楼装上「物业监控大屏」,把 toolkit 一路采集的数据摊开给人看。看完门户,Day 17 就讲怎么把整栋楼部署上生产。
portal 定位与一次重要转向
gov-agents-portal 是 Day 01 讲的 L2.5 DevX(研发体验)层。它经历过一次明确的定位转向,值得了解——因为这解释了为什么代码里有个 _legacy_v01/ 目录:
v0.1(已下沉)
- 给"业务方"用的配置型自助 UI
- 网页点几下起一个 agent
- 代码移到
_legacy_v01/留痕
v0.2(当前主线)
- 定位改为服务研发工程师
- 脚手架 CLI + Web 文档站 + 开发工具
- 价值:写新 agent 快 10×、样板代码 -80%
apps/,而是写成 configs/business-agents/<name>.yaml(配置即代码)。_legacy_v01/ 就是那台被拆下来、留在仓库角落做纪念的旧自助机。技术栈 & 页面地图
| 层 | 选型 |
|---|---|
| 前端框架 | Next.js 14 App Router,output: "standalone"(next.config.mjs:10,L04 会读) |
| UI | React + TypeScript + Tailwind CSS |
| 数据获取 | SWR(带 60s 轮询,L04 会读真实 useSWR 调用) |
| 流式 | fetch + ReadableStream 手工解 SSE(lib/sse.ts,L05 走读) |
| 测试 | Storybook、Playwright(e2e + a11y)、Vitest |
| 后端 | FastAPI + LDAP SSO + JWT,MySQL 存调用历史(portal_backend/server.py,L07 走读) |
| 沙箱 | 独立包 ai-trust-toolkit-sandbox,子进程隔离跑生成的 agent(L08 走读) |
侧边栏分组,每个页面(app/<route>/page.tsx)= 一个视角:
工作台 Dashboard
4 个 KPI 卡(生产 agent 数/本月调用/token/闸门拦截率)+ 24h 趋势 + 待办 + ADR。
Agent 资产
按类别过滤 + 按成本/调用/评测排序的列表。
Agent 详情
KPI + LangGraph 拓扑图 + eval 维度条 + 依赖 + 最近调用 + 编辑配置。
闸门监控
Critic 双层 + failsafe 闸 + sensitivity 红线,每闸命中 + 成本配额面板。(L06 读它源码)
评测台
在线跑 golden set,summary 卡 + 历史 + 失败用例表。
调用回放
invocation 列表 + timeline + state diff + 离线重放。debug 主入口。
工具箱文档
toolkit 模块文档(唯一 SSR 页)。
技能目录
skill 卡片(Day 19)。
ADR 决策索引
架构决策记录,状态机 SVG + 决策表 + approve/reject。
成本
一行 redirect 到 /gates(已合并)。
/gates 看 Day 07/08 的 Critic 和闸门命中,/eval 看 Day 10 的评测,/replay 看 Day 12 的 case envelope。门户就是把 toolkit 采集的数据摊开给人看。下面从 L03 起我们不再泛泛说,而是把 4 条真实数据通路的源码逐行读一遍。fetcher.ts — 一个 KPI 数字从哪来(真代码)
useSWR("/portal/dashboard") 数字就出来了——可后端凭什么知道"这个请求是谁发的"?前端又是怎么把"当前登录用户"塞进每一个请求的?答案就一个 40 多行的小文件 lib/fetcher.ts。SWR 需要你给它一个 fetcher 函数——它负责"真正去发 HTTP 请求"。门户所有页面共用这一个(lib/fetcher.ts:18):
export async function fetcher<T>(url: string): Promise<T> {
// ① dev 态从 sessionStorage 读当前用户(AuthGuard 登录时写进去的)
const userId =
typeof window !== "undefined"
? sessionStorage.getItem("portal_user_id") || ""
: ""; // SSR 时没有 window → 空
const headers: HeadersInit = { "Content-Type": "application/json" };
// ② 有用户就拼一个 Bearer token 塞进 Authorization 头
if (userId) {
headers["Authorization"] = `Bearer dev-${encodeURIComponent(userId)}`; // fetcher.ts:28
}
const res = await fetch(url, { headers }); // ③ 真正发请求
if (!res.ok) {
// ④ 失败:先 text() 再试 JSON.parse —— 防 body 被读两次报 "already read"
const body = await res.text();
let info: unknown = body;
try { info = JSON.parse(body); } catch { /* 不是 JSON 就留字符串 */ }
throw new FetcherError(`Fetch ${url} failed: ${res.status}`, res.status, info);
}
return res.json() as Promise<T>;
}
逐行大白话:① 先判断 typeof window !== "undefined"——因为 Next.js 会在服务器端也执行这段代码(SSR),那时没有浏览器的 sessionStorage,硬读会崩,所以先兜一层。② 把用户 id 拼成 Bearer dev-alice 这种形状塞进 Authorization 头——这就是"每个请求都带上我是谁"的实现,就一行。③ 用浏览器原生 fetch 发出去。④ 失败时的处理藏着一个真实的坑(见下)。
await res.json() 失败了、再想 await res.text() 看看原文,就会抛 "body stream already read"。所以 fetcher.ts:34 的写法是反过来的:先 text() 拿到字符串,再在字符串上 try JSON.parse——字符串可以反复解析,永远不会"读第二次"。这是处理 fetch 错误响应的标准姿势。Bearer dev- 切换到生产的真 OIDC token(文件注释里写着"prod 态待 H4.x 走真 OIDC"),只改这一个文件,所有页面自动跟着变。这跟后端"统一 envelope/鉴权"是同一个思路:把重复的护栏收拢到一处。FetcherError 类而不是直接 throw 字符串?看 fetcher.ts:7,FetcherError 额外带了 status(HTTP 状态码)和 info(后端返回体)。这样页面的 error 卡就能显示"HTTP 404"这种具体信息(L06 会看到 error instanceof FetcherError ? HTTP ${error.status})——把"哪种失败"结构化地传给 UI,而不是给用户一句干巴巴的"出错了"。呼应"诚实、不误导"的主题。rewrites 反向代理 + SWR 轮询(真代码)
fetcher 里那个 fetch("/portal/dashboard") 打的是相对路径——前端跑在 3000 端口,后端在 8080,它怎么找到后端?靠 next.config.mjs 的 rewrites 反向代理(next.config.mjs:18):
const nextConfig = {
reactStrictMode: true,
output: "standalone", // next.config.mjs:10 · Docker 只带 server.js + 必要依赖
transpilePackages: ["@portal/design-tokens"], // workspace 内部包走 TS 源码
async rewrites() {
return [
{ // 开发态把 /portal/* 反代到 platform server(默认 :8080)
source: '/portal/:path*',
destination: `${process.env.PORTAL_BACKEND_URL || 'http://localhost:8080'}/portal/:path*`,
},
{ source: '/v1/:path*',
destination: `${process.env.PORTAL_BACKEND_URL || 'http://localhost:8080'}/v1/:path*`,
},
];
},
};
大白话:rewrites 让 Next.js 服务器自己把 /portal/* 的请求转发到 PORTAL_BACKEND_URL(默认 localhost:8080)。于是浏览器眼里所有请求都发给"自己这台 3000",是同源的——没有跨端口,也就没有 CORS。output: "standalone"(:10)则是给 Day 17 Docker 部署铺路:构建产物只带一个 server.js + 必要的 node_modules 子集,镜像小。
再看 fetcher 被谁调用——真实页面 app/gates/page.tsx(page.tsx:13/36):
const REFRESH_MS = 60_000; // page.tsx:13 · 60 秒轮询
const gates = useSWR<GatesMetricsResponse>(
"/portal/gates/metrics", // key = 后端路径
fetcher, // 用上面那个共享 fetcher
{
refreshInterval: REFRESH_MS, // 每 60s 自动重拉
revalidateOnFocus: true, // 切回窗口也重拉
keepPreviousData: true, // 拉新期间先展示旧数据(不闪白)
}
);
keepPreviousData: true 很关键?监控面板每 60 秒重拉一次。如果不留旧数据,每次重拉的一瞬间 data 会变 undefined,页面就闪一下骨架屏——非常晃眼。开了 keepPreviousData,SWR 在后台拉新数据时屏幕上仍是上一次的数字,新数据到了才无缝替换。这正是 SWR 名字里 "stale-while-revalidate"(先给旧的、同时校验)的字面意思。useSWR("/portal/gates/metrics", fetcher, {refreshInterval:60000})→ fetcher 从 sessionStorage 取到
alice,拼 Authorization: Bearer dev-alice(fetcher.ts:28)→ 请求
/portal/gates/metrics 被 next.config.mjs rewrites 反代到 localhost:8080(:23)→ 后端返回
{"metrics":{...},"data_source":"live"} → 图表渲染→ 60 秒后 SWR 后台自动再拉一次,期间屏幕仍显示旧数字(
keepPreviousData),新的到了才替换。lib/sse.ts — 为什么要手写 SSE 解析(真代码)
EventSource 专门干这个——可门户偏偏不用它,自己手写了一套。为什么?// EventSource 仅支持 GET + 无 custom header
// 故必走 fetch + ReadableStream reader 解 SSE。
原因很硬:原生 EventSource 只能发 GET、不能带自定义请求头。但起 agent 要 POST 一个 JSON body,还要带 L03 那个 Authorization: Bearer 头——EventSource 两条都做不到。所以只能用 fetch(能 POST、能带头)拿到响应流,自己手动切分 SSE 事件块(sse.ts:39):
const res = await fetch(url, {
method: "POST", // ← EventSource 做不到
headers, // 含 Accept: text/event-stream + Authorization
body: JSON.stringify(body), // ← EventSource 也做不到
});
// 非 SSE 响应(403 关闭 / 400 校验失败)→ 读 body 抛错
const ct = res.headers.get("content-type") || "";
if (!res.ok || !ct.includes("text/event-stream")) {
const body = await res.text(); // 又是"先 text 再 parse"(同 fetcher)
let info: unknown = body;
try { info = JSON.parse(body); } catch { /* 保留 string */ }
throw ...;
}
const reader = res.body?.getReader(); // sse.ts:74 · 拿到可读流
const decoder = new TextDecoder("utf-8");
let buf = "";
while (true) {
const { done, value } = await reader.read(); // 读一个 chunk
if (done) break;
buf += decoder.decode(value, { stream: true });
let idx: number;
while ((idx = buf.indexOf("\n\n")) !== -1) { // sse.ts:84 · 双换行 = 一个事件块结束
const block = buf.slice(0, idx);
buf = buf.slice(idx + 2); // 消费掉这一块,剩下的留在 buf
const event = parseSSEBlock(block); // 解析 event:/data: 两行
if (event) onEvent(event); // 回调给 UI(滚一行输出)
}
}
逐块讲:网络是一段一段(chunk)到的,一个 chunk 里可能有半个事件、也可能有两个半事件。所以要维护一个缓冲区 buf,不停往里拼;SSE 协议规定两个连续换行 \n\n 表示一个事件块结束,于是循环用 buf.indexOf("\n\n") 找边界、切出完整的块交给 parseSSEBlock,剩下不完整的半块继续留在 buf 里等下一个 chunk。这就是"流式解析"的经典写法。
text() 再 parse),全项目错误处理姿势统一。parseSSEBlock(sse.ts:101)只认 stdout/stderr/done/error 四种事件类型,别的一律返回 null 丢弃;data: 那行 JSON.parse 失败时不报错,而是退化成 { line: dataStr } 当纯文本行显示——宁可把原文滚出来,也不让一个畸形事件把整条流打断。while 循环结束后还会 flush 一次残余 buffer(sse.ts:92),防止最后一个没有 \n\n 结尾的块被吞掉。页面健壮性四件套 + mock 横幅(真代码)
门户每个页面都遵循同一套健壮性模式。直接读 app/gates/page.tsx 的真实结构(page.tsx:57-96):
const isLoading = (gates.isLoading && !gates.data) || (cost.isLoading && !cost.data);
return (
<div>
{/* ② mock 横幅:诚实告诉用户"这是假数据" */}
{(gates.data?.data_source === "mock" || cost.data?.data_source === "mock") && ( // page.tsx:67
<div className="text-amber-400 ..." data-testid="data-source-banner">
数据源 · mock(toolkit observability / cost / quota 未接通)
</div>
)}
{isLoading ? (
<PageSkeleton /> {/* ① 骨架屏,不白屏 */}
) : gates.error || cost.error ? (
<div className="..."> {/* ③ error 卡 + HTTP 码 + 重试 */}
Gates / Cost 数据加载失败
{gates.error instanceof FetcherError ? ` · gates HTTP ${gates.error.status}` : ""}
<button onClick={() => { void gates.mutate(); void cost.mutate(); }}>重试</button>
</div>
) : (
gates.data?.metrics && Array.isArray(gates.data.metrics.per_gate_hits)
? <ThreeCardRow .../> {/* ④ partial-data 防御:字段齐才渲染 */}
: <div>Gates metrics 不完整 · 缺 per_gate_hits</div>
)}
</div>
);
isLoading 为真时显示 <PageSkeleton/>(一堆 animate-pulse 灰块),数据没到也不白屏。注意条件是 isLoading && !data——已有旧数据就不显示骨架(配合 SWR 的 keepPreviousData)。page.tsx:67:后端返回 data_source === "mock" 时顶部弹琥珀色横幅。诚实标注"这块是假数据",不糊弄用户。FetcherError 显示 HTTP 404 这种具体码,"重试"按钮调 gates.mutate() 让 SWR 重拉。Array.isArray(metrics.per_gate_hits);缺字段就走"数据不完整"提示,绝不因一个字段缺失而整页崩。data_source: "live" | "mock" 这个字段为什么如此重要?看 lib/types.ts:8,几乎每个响应类型都带 data_source 字段。它是"可信工程化"从后端一路贯彻到前端像素的体现:toolkit 的可观测性还没接通时,后端返回 mock 数据但如实标记,前端就据实弹横幅。可信不只是"防幻觉",还包括"不误导使用者"——宁可承认"这是演示数据",也不假装它是真的。后端 build_router — POST /portal/agents 怎么拒绝 secret(真代码)
后端入口是 portal_backend/server.py::build_router(case_store, app)(server.py:132),它注册约 30 个 endpoint 子模块(xxx_endpoint.add_routes(router))。最值得读的是"工程师提交一份 YAML 造 agent"的 POST /portal/agents(server.py:221)——因为它演示了后端怎么做安全校验:
@router.post("/portal/agents")
async def create_agent(body: dict = Body(...), ctx: CallContext = Depends(get_call_context)):
# ... 先把 config_yaml 解析成 dict(yaml.safe_load,失败 → 422 INVALID_YAML)...
# ① validate + reject secret
try:
from portal_backend.config_parser.schema import scan_for_secrets
scan_for_secrets(raw) # server.py:252 · 扫明文密钥
cfg = AgentConfigSchema.model_validate(raw) # Pydantic 校验字段
except SecretInConfigError as e: # server.py:254 · 发现 secret
raise HTTPException(status_code=422,
detail={"code": "SecretInConfigError", "message": str(e)}) from e
except ValidationError as e: # 字段不合规
raise HTTPException(status_code=422,
detail={"code": "VALIDATION_ERROR", "errors": e.errors()}) from e
# ② 通过才写盘:configs/business-agents/-.yaml
out_path = _CONFIGS_DIR / f"{date_prefix}-{cfg.name}.yaml"
out_path.write_text(yaml.safe_dump(raw, allow_unicode=True), encoding="utf-8") # server.py:269
return {"agent_name": f"portal/{cfg.name}", "router_path": f"/v1/agent/portal/{cfg.name}", ...}
逐步讲:① 收到 YAML 先干两件校验——scan_for_secrets 扫有没有把明文密码/API key 写进配置(写了就抛 SecretInConfigError → 422 拒绝),再用 Pydantic 的 AgentConfigSchema.model_validate 校验字段合不合规。两关都过才 ② 把 YAML 写到 configs/business-agents/ 目录(配置即代码,进 git 留痕),返回这个新 agent 的路由路径。
note 字段原话:"YAML 已写盘 + git audit,但真注册到 AGENT_REGISTRY 需 server 重启 OR 走热加载"。把"造 agent"降维成"往 git 里写一个配置文件"——好处是每次创建都有 code review、有版本历史、可回滚,符合"配置即代码"。代价是不能即时生效(要重启)。这是"审计可追溯"压过"即时性"的取舍。scan_for_secrets 要在 Pydantic 校验之前跑?因为 secret 泄漏是安全红线,比"字段拼错"严重得多。先扫 secret,能保证"就算字段全对,只要夹带了明文密钥,一样拒绝入库"。呼应 Day 17 会讲的"secret 必须走 ${VAR} 环境变量、绝不写死在 yaml"——这个后端校验就是那条规范的强制执行点。build_router 里那一大段 invocation_store 初始化(server.py:150+)全用 try/except ... log.warning 包着,注释写"best-effort 不阻塞 mount"——数据库连不上也不让门户起不来,只打一条 warning。启动诊断这类"锦上添花"的事,绝不能拖垮主流程。沙箱预览 — 子进程隔离跑不可信代码(今日核心)
import 进门户进程跑?那等于让陌生装修队进主楼施工:他一个死循环吃光内存、或偷读环境变量里的密钥,整个门户跟着遭殃。怎么安全地跑不可信代码?答案在独立包 ai-trust-toolkit-sandbox 的 subprocess_pool.py。它的文件头注释把"为什么用子进程而非 in-process import"讲得很直白(subprocess_pool.py:5-8):
# 为什么 subprocess 而非 in-process import:
# - 崩溃 / OOM 隔离(跑挂了不拖垮 host)
# - RLIMIT_AS / RLIMIT_CPU 真生效(per-process)· pod cgroup 是全 pod 上限 · 互补
# - env 白名单 · 防 secret 漏到生成 agent 代码
三条隔离手段,全能在真代码里找到。第一:env 白名单(subprocess_pool.py:88)——不是把 host 的全部环境变量传给子进程,而是只显式列几个安全的:
def _build_subprocess_env(*, port, sandbox_id, anthropic_api_key):
"""显式白名单 · 防 secret 漏到子进程."""
env = {"SANDBOX_PORT": str(port), "SANDBOX_ID": sandbox_id, "PYTHONUNBUFFERED": "1"}
for key in ("PATH", "HOME", "LANG", "LC_ALL", "TZ"): # 只放行这 5 个无害变量
val = os.environ.get(key)
if val is not None:
env[key] = val
if anthropic_api_key: # LLM key 单独、显式传
env["ANTHROPIC_API_KEY"] = anthropic_api_key
return env
大白话:子进程拿到的环境变量是从零白名单拼出来的,host 上的数据库密码、其它服务 token 一个都不继承。唯一的 LLM key 也是调用方显式传参进来、单独塞的。第二:RLIMIT 资源限制(subprocess_pool.py:125),用 preexec_fn 在子进程启动前设上限:
def _make_preexec_fn():
"""Linux RLIMIT_AS + RLIMIT_CPU · macOS 仅 RLIMIT_CPU · Windows None."""
if sys.platform.startswith("win"):
return None # Windows 不支持 preexec_fn
mem_bytes = _default_mem_mb() * 1024 * 1024 # 默认 512MB(env 可调)
cpu_sec = _default_cpu_sec() # 默认 60s
is_linux = sys.platform.startswith("linux")
def _setlimits():
try:
resource.setrlimit(resource.RLIMIT_CPU, (cpu_sec, cpu_sec)) # 限 CPU 时间
except (ValueError, OSError) as e:
print(f"[sandbox preexec] RLIMIT_CPU failed: {e}", file=sys.stderr)
if is_linux: # RLIMIT_AS 只在 Linux 开
try:
resource.setrlimit(resource.RLIMIT_AS, (mem_bytes, mem_bytes)) # 限内存
except (ValueError, OSError) as e:
print(f"[sandbox preexec] RLIMIT_AS failed: {e}", file=sys.stderr)
with contextlib.suppress(OSError):
os.setpgrp() # 独立进程组,方便整组 kill
return _setlimits
第三:独立子进程 + 挑空闲端口 + 等就绪(spawn_sandbox,subprocess_pool.py:175):
port = _pick_free_port() # 让 kernel 分一个空闲端口(bind 0)
env = _build_subprocess_env(port=port, sandbox_id=sandbox_id, anthropic_api_key=...)
preexec = _make_preexec_fn()
popen_kwargs = {
"args": [sys.executable, "-m", agent_pkg], # python -m <生成的 agent 包>
"cwd": str(cwd), "env": env, # 白名单 env
"stdout": stdout_log, "stderr": stderr_log, # 输出写日志文件
}
if preexec is not None:
popen_kwargs["preexec_fn"] = preexec # 挂上 RLIMIT
proc = subprocess.Popen(**popen_kwargs) # subprocess_pool.py:216 · 真正起子进程
起完还会 _wait_for_ready(:152)每 100ms poll 子进程的 /healthz,200 才算就绪,超时就 _force_kill。之后门户通过 proxy.py 的反向代理中间件(server.py:191 挂的 /portal/v1/sandbox/)把请求转发到 127.0.0.1:<port>。
RLIMIT_AS/CPU 是per-process 的,正好补上这一层:"每个 sandbox 子进程各自最多 512MB / 60s CPU"。两层限额一粗一细,配合起来才严丝合缝。👶 小白:为什么 RLIMIT_AS(限内存)只在 Linux 开、macOS 关掉?
👨🏫 老师:看 subprocess_pool.py:138 的 if is_linux——因为 macOS 上 RLIMIT_AS 行为不可靠(会误伤正常进程),所以 macOS 只保留 RLIMIT_CPU。生产是 Linux pod,两条都生效;本地 mac 开发只兜 CPU。这是"按平台能力降级"的务实处理,不是偷懒。Windows 干脆连 preexec_fn 都不支持(:127 直接返回 None),因为项目本就不在 Windows 跑。
双仓镜像同步(一个真实工程约束)
一个容易困惑的点:前端真源码在一个独立 repo(gov-agents-ui),本 monorepo 里的 frontend/web/ 是它的只读镜像(mirror)。next.config.mjs:9 的注释里就留了痕迹:standalone output ... R-PORTAL-FRONTEND-DOCKER-1——前端团队用自己的 repo 迭代,但平台部署又需要前端代码在同一个镜像里构建,于是用"单向同步 + 只读镜像"折中。
Day 18 会看到有两道专门的 CI 闸守着它:ci-gate-frontend-readonly(禁止在 monorepo 里直接改镜像里的 .tsx)和 ci-gate-frontend-sync(检测两仓漂移超过 30 天就 fail)。例外白名单是 Dockerfile / next.config.mjs 这几个"平台侧持有的部署产物"。
backend/pyproject.toml 的 BOM 版本 pin 可能滞后于 packages 里的 starter(真实仓库里存在这种版本漂移)。读任何模块的依赖,都以它自己那个 pyproject.toml 的实际内容为准(Day 01 原则)。今日小结 + 动手
🧠 今天你应该能回答
- portal 的定位是什么?v0.1→v0.2 为什么转向?(业务方自助 → 研发工程师 DevX)
fetcher.ts怎么把"我是谁"塞进每个请求?错误处理为什么"先 text 再 parse"?(fetcher.ts:28/34)- 为什么用 rewrites 反代而不是直接跨端口打后端?(同源、免 CORS,
next.config.mjs:18) - 为什么手写 SSE 而不用原生 EventSource?(要 POST + 带鉴权头,
sse.ts:2) - 页面健壮性四件套 +
data_source: mock横幅体现了什么?(诚实、不误导) - 沙箱预览的三道隔离防线是什么?(env 白名单 + RLIMIT + 独立子进程,
subprocess_pool.py:88/125/216)
✋ 动手:读今天走读过的真代码
# 1. 前端如何带鉴权 + 反代
cat gov-agents-portal/frontend/web/lib/fetcher.ts
sed -n '7,31p' gov-agents-portal/frontend/web/next.config.mjs
# 2. 手写 SSE 流式解析
sed -n '39,95p' gov-agents-portal/frontend/web/lib/sse.ts
# 3. 一个页面的健壮性四件套 + mock 横幅
sed -n '36,96p' gov-agents-portal/frontend/web/app/gates/page.tsx
# 4. 后端 build_router + POST /portal/agents 拒绝 secret
sed -n '132,270p' gov-agents-portal/backend/portal_backend/server.py
# 5. 今日核心:沙箱子进程三道隔离
sed -n '88,146p' packages/ai-trust-toolkit-sandbox/src/ai_trust_toolkit_sandbox/subprocess_pool.py
sed -n '175,216p' packages/ai-trust-toolkit-sandbox/src/ai_trust_toolkit_sandbox/subprocess_pool.py
config.py 的 _deep_merge 真代码)、多阶段 Dockerfile、docker-compose、两种部署形态。