Day 16 / 共 20 天 · 第 4 周 平台化与收官

Web 管理门户 portal

L2.5 研发体验层的门面:一个 Next.js 控制台,让工程师看清 agent 资产、成本、闸门命中、调用回放、评测。昨天(Day 15)我们把 20 多个业务 Agent 全家福点了一遍;今天进入第 4 周「平台化与收官」的第一站——给这一栋楼装上「物业监控大屏」,把 toolkit 一路采集的数据摊开给人看。看完门户,Day 17 就讲怎么把整栋楼部署上生产。

📍 你在 20 天里的位置(第 4 周:平台化与收官)
D15 Agent 全家福 D16 门户 portal D17 部署 D18 CI 闸门 D19 起新 Agent D20 收官
💡 用一个类比先兜住今天(延续「盖楼/物业」世界观) 如果整个平台是那栋精装公寓楼,portal 就是楼下物业管理处的那面「监控大屏 + 服务台」:大屏上滚动显示各户用电量(成本)、消防报警记录(闸门命中)、访客登记回放(调用回放)、验房打分(评测)——它自己不产生这些数据,只是把楼里各处的传感器(toolkit 埋的指标)汇总到一块屏上给物业师傅看。记住这个画面,下面 8 讲讲的就是「这块大屏长什么样、数据怎么从各户传到屏上」。
L01

portal 定位与一次重要转向

gov-agents-portal 是 Day 01 讲的 L2.5 DevX(研发体验)层。它经历过一次明确的定位转向,值得了解——因为这解释了为什么代码里有个 _legacy_v01/ 目录:

v0.1(已下沉)

  • 给"业务方"用的配置型自助 UI
  • 网页点几下起一个 agent
  • 代码移到 _legacy_v01/ 留痕

v0.2(当前主线)

  • 定位改为服务研发工程师
  • 脚手架 CLI + Web 文档站 + 开发工具
  • 价值:写新 agent 快 10×、样板代码 -80%
为什么转向? 因为实践发现"让业务方在网页上点点就造 agent"不现实——真正需要它的是研发工程师,而工程师更需要"脚手架 + 资产总览 + 调试工具"。这个转向本身就是产品迭代的真实案例。注意 portal 生成的 agent 不进 apps/,而是写成 configs/business-agents/<name>.yaml(配置即代码)。
💡 用物业的话讲这次转向 v0.1 就像物业想装一台「住户自助机」——让每户自己点几下就把家装好;结果发现住户根本不会用、也没这个需求。v0.2 把这台机器改成了「维修师傅工作站」——因为天天要造/调 agent 的是研发工程师(师傅),他们要的是趁手的工具台(脚手架 + 资产总览 + 调试台),不是给外行的自助按钮。_legacy_v01/ 就是那台被拆下来、留在仓库角落做纪念的旧自助机。
L02

技术栈 & 页面地图

选型
前端框架Next.js 14 App Routeroutput: "standalone"next.config.mjs:10,L04 会读)
UIReact + 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 走读)
SWR(stale-while-revalidate)是个 React 数据请求库:先显示缓存的旧数据、同时后台拉新数据、到了就刷新。配上轮询,控制台的数字就能"准实时"更新——很适合监控类面板。

侧边栏分组,每个页面(app/<route>/page.tsx)= 一个视角:

/

工作台 Dashboard

4 个 KPI 卡(生产 agent 数/本月调用/token/闸门拦截率)+ 24h 趋势 + 待办 + ADR。

/agents

Agent 资产

按类别过滤 + 按成本/调用/评测排序的列表。

/agents/[id]

Agent 详情

KPI + LangGraph 拓扑图 + eval 维度条 + 依赖 + 最近调用 + 编辑配置。

/gates

闸门监控

Critic 双层 + failsafe 闸 + sensitivity 红线,每闸命中 + 成本配额面板。(L06 读它源码)

/eval

评测台

在线跑 golden set,summary 卡 + 历史 + 失败用例表。

/replay

调用回放

invocation 列表 + timeline + state diff + 离线重放。debug 主入口。

/toolkit/[module]

工具箱文档

toolkit 模块文档(唯一 SSR 页)。

/skills

技能目录

skill 卡片(Day 19)。

/decisions

ADR 决策索引

架构决策记录,状态机 SVG + 决策表 + approve/reject。

/cost

成本

一行 redirect 到 /gates(已合并)。

这些页面正好是前面 15 天知识的"可视化"——/gates 看 Day 07/08 的 Critic 和闸门命中,/eval 看 Day 10 的评测,/replay 看 Day 12 的 case envelope。门户就是把 toolkit 采集的数据摊开给人看。下面从 L03 起我们不再泛泛说,而是把 4 条真实数据通路的源码逐行读一遍。
L03

fetcher.ts — 一个 KPI 数字从哪来(真代码)

🤔 痛点页面上写个 useSWR("/portal/dashboard") 数字就出来了——可后端凭什么知道"这个请求是谁发的"?前端又是怎么把"当前登录用户"塞进每一个请求的?答案就一个 40 多行的小文件 lib/fetcher.ts

SWR 需要你给它一个 fetcher 函数——它负责"真正去发 HTTP 请求"。门户所有页面共用这一个(lib/fetcher.ts:18):

gov-agents-portal/frontend/web/lib/fetcher.ts:18-47
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 发出去。 失败时的处理藏着一个真实的坑(见下)。

⚠️ 易错点(源码里真的踩过):HTTP response 的 body 是一个只能读一次的流。如果先 await res.json() 失败了、再想 await res.text() 看看原文,就会抛 "body stream already read"所以 fetcher.ts:34 的写法是反过来的:先 text() 拿到字符串,再在字符串上 try JSON.parse——字符串可以反复解析,永远不会"读第二次"。这是处理 fetch 错误响应的标准姿势。
💡 设计取舍①:为什么把 token 逻辑抽成一个共享 fetcher,而不是每个页面自己 fetch?因为"怎么带鉴权"是横切关注点——20 多个页面都要带。抽成一个函数后,将来从 dev 的 Bearer dev- 切换到生产的真 OIDC token(文件注释里写着"prod 态待 H4.x 走真 OIDC"),只改这一个文件,所有页面自动跟着变。这跟后端"统一 envelope/鉴权"是同一个思路:把重复的护栏收拢到一处。
💡 设计取舍②:为什么自定义一个 FetcherError 类而不是直接 throw 字符串?fetcher.ts:7FetcherError 额外带了 status(HTTP 状态码)和 info(后端返回体)。这样页面的 error 卡就能显示"HTTP 404"这种具体信息(L06 会看到 error instanceof FetcherError ? HTTP ${error.status})——把"哪种失败"结构化地传给 UI,而不是给用户一句干巴巴的"出错了"。呼应"诚实、不误导"的主题。
L04

rewrites 反向代理 + SWR 轮询(真代码)

fetcher 里那个 fetch("/portal/dashboard") 打的是相对路径——前端跑在 3000 端口,后端在 8080,它怎么找到后端?靠 next.config.mjs 的 rewrites 反向代理(next.config.mjs:18):

gov-agents-portal/frontend/web/next.config.mjs:7-31
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.tsxpage.tsx:13/36):

gov-agents-portal/frontend/web/app/gates/page.tsx:13,36-52
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"(先给旧的、同时校验)的字面意思。
📝 举个例子:一次 /gates 数字刷新的完整链路(真实路径) 页面挂 useSWR("/portal/gates/metrics", fetcher, {refreshInterval:60000})
→ fetcher 从 sessionStorage 取到 alice,拼 Authorization: Bearer dev-alicefetcher.ts:28
→ 请求 /portal/gates/metricsnext.config.mjs rewrites 反代到 localhost:8080:23
→ 后端返回 {"metrics":{...},"data_source":"live"} → 图表渲染
→ 60 秒后 SWR 后台自动再拉一次,期间屏幕仍显示旧数字(keepPreviousData),新的到了才替换。
useSWR + fetcher 浏览器 :3000 rewrites 反代 next.config.mjs:18 FastAPI :8080 /portal/* Bearer dev-alice 同源转发 ↑ 数据回来 → refreshInterval 60s 定时重拉(keepPreviousData 不闪白) 浏览器眼里全是「发给自己 3000」的同源请求 → 无 CORS
图注:前端不跨端口打后端,而是让 Next.js 服务器把 /portal/* 反代到 8080;数据靠 SWR 60s 轮询刷新。
L05

lib/sse.ts — 为什么要手写 SSE 解析(真代码)

🤔 痛点"起一个新 agent"这种操作会在后台跑几十秒,还要把命令行输出实时滚在网页上。轮询太笨(要一直问"好了吗")。浏览器原生有个 EventSource 专门干这个——可门户偏偏不用它,自己手写了一套。为什么?
gov-agents-portal/frontend/web/lib/sse.ts:2-5(文件头注释,原话)
// EventSource 仅支持 GET + 无 custom header
// 故必走 fetch + ReadableStream reader 解 SSE。

原因很硬:原生 EventSource 只能发 GET、不能带自定义请求头。但起 agent 要 POST 一个 JSON body,还要带 L03 那个 Authorization: Bearer 头——EventSource 两条都做不到。所以只能用 fetch(能 POST、能带头)拿到响应流,自己手动切分 SSE 事件块(sse.ts:39):

gov-agents-portal/frontend/web/lib/sse.ts:51-95
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。这就是"流式解析"的经典写法。

💡 设计取舍:明明原生 EventSource 更省事,为什么宁可自己手写几十行?因为原生 API 的两条硬限制(只能 GET、不能带 header)跟本项目的两条硬需求(要 POST body、要带鉴权 token)直接冲突。这是一个典型的"标准 API 覆盖不了业务需求,只能降一层自己实现"的取舍——多写几十行换来"鉴权 + POST + 流式"三者兼得。注意它连错误处理都跟 fetcher 一致(先 text()parse),全项目错误处理姿势统一。
⚠️ 边界:parseSSEBlocksse.ts:101)只认 stdout/stderr/done/error 四种事件类型,别的一律返回 null 丢弃;data: 那行 JSON.parse 失败时不报错,而是退化成 { line: dataStr } 当纯文本行显示——宁可把原文滚出来,也不让一个畸形事件把整条流打断。while 循环结束后还会 flush 一次残余 buffer(sse.ts:92),防止最后一个没有 \n\n 结尾的块被吞掉。
L06

页面健壮性四件套 + mock 横幅(真代码)

门户每个页面都遵循同一套健壮性模式。直接读 app/gates/page.tsx 的真实结构(page.tsx:57-96):

gov-agents-portal/frontend/web/app/gates/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>
);
① Skeleton loading
isLoading 为真时显示 <PageSkeleton/>(一堆 animate-pulse 灰块),数据没到也不白屏。注意条件是 isLoading && !data——已有旧数据就不显示骨架(配合 SWR 的 keepPreviousData)。
② mock 横幅
page.tsx:67:后端返回 data_source === "mock" 时顶部弹琥珀色横幅。诚实标注"这块是假数据",不糊弄用户。
③ Error 卡 + 重试
失败时借 L03 的 FetcherError 显示 HTTP 404 这种具体码,"重试"按钮调 gates.mutate() 让 SWR 重拉。
④ partial-data 防御
即使请求成功,也要检查 Array.isArray(metrics.per_gate_hits);缺字段就走"数据不完整"提示,绝不因一个字段缺失而整页崩
💡 设计取舍:data_source: "live" | "mock" 这个字段为什么如此重要?lib/types.ts:8,几乎每个响应类型都带 data_source 字段。它是"可信工程化"从后端一路贯彻到前端像素的体现:toolkit 的可观测性还没接通时,后端返回 mock 数据但如实标记,前端就据实弹横幅。可信不只是"防幻觉",还包括"不误导使用者"——宁可承认"这是演示数据",也不假装它是真的。
⚠️ 小白常误以为「顶部弹琥珀色横幅 = 页面出 bug 了」。那是诚实提示不是报错:横幅只是说"这块数字现在是 mock(toolkit 可观测性没接通)",页面本身正常工作。真报错走的是 ③ 那套 error 卡 + 重试。就像样板间门口挂"此为样板、非交付实景"的牌子——不是房子坏了,是明明白白告诉你这是演示。
L07

后端 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/agentsserver.py:221)——因为它演示了后端怎么做安全校验:

gov-agents-portal/backend/portal_backend/server.py:245-270(裁剪)
@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 的路由路径。

💡 设计取舍①:为什么生成的 agent 是写成 YAML 文件而不是直接跑起来?看返回体的 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。启动诊断这类"锦上添花"的事,绝不能拖垮主流程。
L08

沙箱预览 — 子进程隔离跑不可信代码(今日核心)

🤔 痛点门户要"预览"AI 刚生成、还没人审过的 agent 代码——直接 import 进门户进程跑?那等于让陌生装修队进主楼施工:他一个死循环吃光内存、或偷读环境变量里的密钥,整个门户跟着遭殃。怎么安全地跑不可信代码?

答案在独立包 ai-trust-toolkit-sandboxsubprocess_pool.py。它的文件头注释把"为什么用子进程而非 in-process import"讲得很直白(subprocess_pool.py:5-8):

packages/ai-trust-toolkit-sandbox/src/ai_trust_toolkit_sandbox/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 的全部环境变量传给子进程,而是只显式列几个安全的

subprocess_pool.py:88-106
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 在子进程启动前设上限:

subprocess_pool.py:125-146
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_sandboxsubprocess_pool.py:175):

subprocess_pool.py:186-216(裁剪)
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>

sandbox 子进程隔离:三道防线 门户主进程 host spawn_sandbox() _pick_free_port 白名单 env preexec RLIMIT proxy /portal/v1/ sandbox/<id>/* Popen 独立子进程(跑 AI 生成的不可信代码) RLIMIT_AS 512MB RLIMIT_CPU 60s env 只有 5+1 个变量 uvicorn :随机端口 崩了 / OOM / 超时 → 只死这个子进程,门户毫发无伤 _wait_for_ready 每 100ms poll /healthz,超时 _force_kill env 白名单(secret 隔离) · RLIMIT(资源隔离) · 子进程(崩溃隔离) 三者互补
图注:门户主进程 fork 一个受限子进程跑生成代码,靠"白名单 env + RLIMIT + 独立进程"三道防线,坏了也只坏子进程。
💡 设计取舍:注释里说 "RLIMIT 与 pod cgroup 互补",为什么两个都要?K8s 的 cgroup 限的是整个 pod的资源上限——但门户 pod 里可能同时跑好几个 sandbox,cgroup 管不到"单个 sandbox 别吃太多"。RLIMIT_AS/CPUper-process 的,正好补上这一层:"每个 sandbox 子进程各自最多 512MB / 60s CPU"。两层限额一粗一细,配合起来才严丝合缝。

👶 小白:为什么 RLIMIT_AS(限内存)只在 Linux 开、macOS 关掉?

👨‍🏫 老师:看 subprocess_pool.py:138if is_linux——因为 macOS 上 RLIMIT_AS 行为不可靠(会误伤正常进程),所以 macOS 只保留 RLIMIT_CPU。生产是 Linux pod,两条都生效;本地 mac 开发只兜 CPU。这是"按平台能力降级"的务实处理,不是偷懒。Windows 干脆连 preexec_fn 都不支持(:127 直接返回 None),因为项目本就不在 Windows 跑。

L09

双仓镜像同步(一个真实工程约束)

一个容易困惑的点:前端真源码在一个独立 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 这几个"平台侧持有的部署产物"。

💡 设计取舍:为什么不干脆把前端也放进 monorepo,非要搞双仓?前端团队有自己的工具链、发布节奏、审查流程,硬塞进后端 monorepo 会互相掣肘。但部署时又必须"前后端一个镜像一起构建"。双仓 + 只读镜像 + CI 漂移检测,是"团队自治"和"部署一体"两个诉求的折中——代价是要维护同步机制。这类"组织约束驱动的技术方案"在真实工程里很常见。
看依赖以实际 pyproject 为准:门户后端 backend/pyproject.toml 的 BOM 版本 pin 可能滞后于 packages 里的 starter(真实仓库里存在这种版本漂移)。读任何模块的依赖,都以它自己那个 pyproject.toml 的实际内容为准(Day 01 原则)。
L10

今日小结 + 动手

🧠 今天你应该能回答

  • 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
明天预告 · Day 17:门户看完,Day 17 讲"怎么把整栋楼部署上生产"——配置 profile 深合并(读 config.py_deep_merge 真代码)、多阶段 Dockerfile、docker-compose、两种部署形态。
← Day 15 Agent 全家福 Day 17 · 配置 / Docker / 部署 →